Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Competency data model (updated)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless others disagree, I think I'd recommend updating the existing PNG or trading it out for the mermaid diagram in the existing ADR 0002, as well as making it clearer that we already supported the active vs history dynamic and how the mastery levels fit in and anything else needed, rather than adding this extra file. We want to try to keep the ADRs light and concise and only change what really needs to be changed. We've also been asked to try to keep the implementation detail in ADRs to a minimum, which may also be relevant here.


The authoritative data model for CBE competency criteria and learner mastery. It builds on the
original `images/CompetencyCriteriaModel.png` overview, corrected against ADRs 0001-0003 (which are
the source of truth where the PNG diverges) and updated for the storage decisions in
`0005-competency-mastery-storage.rst`.

Notable differences from the PNG:

- Each learner-status level is now two tables: an **ACTIVE** table (one current row per learner and
node, updated in place) and an append-only **HISTORY** table (one row per genuine status advance,
bounded by monotonicity). The PNG showed a single table per level.
- The `mastery_level_id` column the PNG drew on `StudentCompetencyCriteriaStatus` is **not** part of
the model (mastery level is a future rule type; see ADR 0002).
- Learner-status tables use 64-bit primary keys and are reachable through a dedicated database alias
(defaulting to the main database). Their references to the definition tables and to the user are
**logical foreign keys without database-level constraints**, so the tables can live in a separate
database (see ADR 0005).

Existing tagging tables (`oel_tagging_*`) are shown minimally, only enough to anchor the
relationships they participate in.

```mermaid
erDiagram
oel_tagging_taxonomy ||--|| CompetencyTaxonomy : "MTI subtype"
oel_tagging_taxonomy ||--o{ oel_tagging_tag : "taxonomy_id"
oel_tagging_tag ||--o{ oel_tagging_objecttag : "tag_id"
CompetencyTaxonomy ||--o{ CompetencyRuleProfile : "scopes"
oel_tagging_tag ||--o{ CompetencyCriteriaGroup : "competency"
CompetencyCriteriaGroup ||--o{ CompetencyCriteriaGroup : "parent_id (nesting)"
CompetencyCriteriaGroup ||--o{ CompetencyCriteria : "contains"
oel_tagging_objecttag ||--o{ CompetencyCriteria : "association"
CompetencyRuleProfile ||--o{ CompetencyCriteria : "default rule"
CompetencyCriteria ||--o{ StudentCompetencyCriteriaStatus : "leaf ACTIVE (no DB FK)"
StudentCompetencyCriteriaStatus ||--o{ StudentCompetencyCriteriaStatusHistory : "advances (same key)"
CompetencyCriteriaGroup ||--o{ StudentCompetencyCriteriaGroupStatus : "group ACTIVE (no DB FK)"
StudentCompetencyCriteriaGroupStatus ||--o{ StudentCompetencyCriteriaGroupStatusHistory : "advances (same key)"
oel_tagging_tag ||--o{ StudentCompetencyStatus : "competency ACTIVE (no DB FK)"
StudentCompetencyStatus ||--o{ StudentCompetencyStatusHistory : "advances (same key)"
CompetencyMasteryStatuses ||--o{ StudentCompetencyCriteriaStatus : "status_id"
CompetencyMasteryStatuses ||--o{ StudentCompetencyCriteriaGroupStatus : "status_id"
CompetencyMasteryStatuses ||--o{ StudentCompetencyStatus : "status_id"

oel_tagging_taxonomy {
int id PK
}
oel_tagging_tag {
int id PK
int taxonomy_id FK
}
oel_tagging_objecttag {
int id PK
int tag_id FK
}
CompetencyTaxonomy {
int taxonomy_ptr_id PK "1:1 to oel_tagging_taxonomy"
}
CompetencyRuleProfile {
int id PK
int organization_id "nullable scope"
varchar course_id "nullable scope"
int competency_taxonomy_id FK "nullable scope"
varchar rule_type "Grade (View, MasteryLevel future)"
json rule_payload
}
CompetencyCriteriaGroup {
int id PK
int parent_id FK "self; null = root"
int oel_tagging_tag_id FK "competency"
varchar course_id "nullable course scope"
varchar name
int ordering
varchar logic_operator "AND / OR / null"
}
CompetencyCriteria {
int id PK
int competency_criteria_group_id FK
int oel_tagging_objecttag_id FK
int competency_rule_profile_id FK "nullable"
varchar rule_type_override "nullable"
json rule_payload_override "nullable"
}
CompetencyMasteryStatuses {
int id PK
varchar status UK "Demonstrated / AttemptedNotDemonstrated / PartiallyAttempted"
}
StudentCompetencyCriteriaStatus {
bigint id PK "64-bit"
int user_id "logical FK, no DB constraint"
int competency_criteria_id "logical FK, no DB constraint"
int status_id FK "leaf: Demonstrated / AttemptedNotDemonstrated"
datetime effective_source_timestamp
datetime created
datetime modified
}
StudentCompetencyCriteriaStatusHistory {
bigint id PK "64-bit"
int user_id
int competency_criteria_id
int status_id FK
datetime effective_source_timestamp
datetime created "one row per advance"
}
StudentCompetencyCriteriaGroupStatus {
bigint id PK "64-bit"
int user_id "logical FK, no DB constraint"
int competency_criteria_group_id "logical FK, no DB constraint"
int status_id FK
datetime effective_source_timestamp
datetime created
datetime modified
}
StudentCompetencyCriteriaGroupStatusHistory {
bigint id PK "64-bit"
int user_id
int competency_criteria_group_id
int status_id FK
datetime effective_source_timestamp
datetime created "one row per advance"
}
StudentCompetencyStatus {
bigint id PK "64-bit"
int user_id "logical FK, no DB constraint"
int oel_tagging_tag_id "logical FK, no DB constraint"
int status_id FK "Demonstrated / PartiallyAttempted only"
datetime effective_source_timestamp
datetime created
datetime modified
}
StudentCompetencyStatusHistory {
bigint id PK "64-bit"
int user_id
int oel_tagging_tag_id
int status_id FK
datetime effective_source_timestamp
datetime created "one row per advance"
}
```

Notes on the diagram:

- Each ACTIVE table is **unique on `(user_id, node_id)`** (one current row per learner and node):
`(user_id, competency_criteria_id)`, `(user_id, competency_criteria_group_id)`, and
`(user_id, oel_tagging_tag_id)` respectively. HISTORY tables have no such uniqueness; they carry
one row per advance.
- The ACTIVE-to-HISTORY relationship is drawn as one-to-many but is not a literal foreign key: a
HISTORY row shares the ACTIVE row's `(user_id, node_id)` identity rather than pointing at its
primary key.
- `status_id` references the shared `CompetencyMasteryStatuses` lookup so status semantics live in
one place.
129 changes: 92 additions & 37 deletions docs/openedx_learning/decisions/0002-competency-criteria-model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Decision
4. ``course_id``: Nullable foreign key to ``openedx_catalog_courserun.id`` for the course that scopes this criteria tree.
5. ``name``: string
6. ``ordering``: Indicates evaluation sequence number for this criteria group. This defines deterministic evaluation order for siblings during read-time evaluation and event-driven recomputation, and enables short-circuit evaluation.
7. ``logic_operator``: Either “AND” or “OR” or null. This determines how children are combined at a group node ("AND" or "OR").
7. ``logic_operator``: Either “AND” or “OR” or null. This determines how children are combined at a group node ("AND" or "OR"). Deliberately no negation operator: keeping every combinator monotone (advancing a child status can only advance or leave unchanged its parent, never regress it) is what lets :ref:`openedx-learning-adr-0004` record concurrently without coordination.

Example: A root group uses "OR" with two child groups.

Expand Down Expand Up @@ -155,29 +155,58 @@ Decision
3. ``oel_tagging_objecttag(object_id)``
4. ``CompetencyCriteria(oel_tagging_objecttag_id)``
5. ``CompetencyCriteria(competency_criteria_group_id)``
6. ``StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)``
7. ``StudentCompetencyCriteriaGroupStatus(user_id, competency_criteria_group_id)``
8. ``StudentCompetencyStatus(user_id, oel_tagging_tag_id)``
9. ``CompetencyRuleProfile(competency_taxonomy_id, course_id, organization_id)``
10. ``CompetencyMasteryStatuses(status)`` (unique)
6. ``StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)`` (unique; ACTIVE current row)
7. ``StudentCompetencyCriteriaGroupStatus(user_id, competency_criteria_group_id)`` (unique; ACTIVE current row)
8. ``StudentCompetencyStatus(user_id, oel_tagging_tag_id)`` (unique; ACTIVE current row)
9. ``StudentCompetencyCriteriaStatusHistory(user_id, competency_criteria_id, created)`` (point-in-time reconstruction)
10. ``StudentCompetencyCriteriaGroupStatusHistory(user_id, competency_criteria_group_id, created)``
11. ``StudentCompetencyStatusHistory(user_id, oel_tagging_tag_id, created)``
12. ``CompetencyRuleProfile(competency_taxonomy_id, course_id, organization_id)``
13. ``CompetencyMasteryStatuses(status)`` (unique)

6. Learner progress status concepts (``StudentCompetency*Status`` database tables)

When a completion event (graded, completed, mastered, etc.) occurs for an object, determine and track the learner's status in earning the competency. To reduce recalculation frequency, store results at each level.
When a completion event (a subsection grade change, delivered as described in
:ref:`openedx-learning-adr-0004`) is evaluated for an object, determine and track the learner's
status in earning the competency. Results are stored at each level (leaf, group, competency) so
reads do not recompute. Each level is stored as two tables: an ACTIVE table holding one current
row per learner and node, updated in place, and an append-only HISTORY table that records one row
per genuine status advance. Current status is a direct lookup on the ACTIVE row; the HISTORY table
is the audit and point-in-time record. Because mastery is monotonic
(:ref:`openedx-learning-adr-0005`), the number of advances per node is bounded by the status
lattice, so the HISTORY tables grow with learners and nodes, not with time.

These are the large per-learner tables in this model, so they follow the scale posture of
:ref:`openedx-learning-adr-0005` (mirroring edx-platform's persistent grades): a 64-bit
``BigAutoField`` primary key, narrow rows, per-read-path composite indexes (Decision 5), and a
dedicated database alias reached through a router that defaults to the main database. Their foreign
keys to the criteria-definition tables and to the user are logical references declared without
database-level constraints, so the tables can live in a different database from the definitions;
referential integrity and the delete protection of Decision 7 are enforced in application code.

Relationship to other concepts:

- ``StudentCompetencyCriteriaStatus`` tracks status at ``CompetencyCriterion`` leaf level.
- ``StudentCompetencyCriteriaGroupStatus`` tracks status at ``CompetencyCriteriaGroup`` node level.
- ``StudentCompetencyStatus`` tracks top-level competency demonstration state.
- All learner status rows use a shared lookup table (``CompetencyMasteryStatuses``) so status semantics live in one place and student status tables stay structurally consistent.

Intended update flow (bottom-up materialization):

- A learner event updates one ``StudentCompetencyCriteriaStatus`` row.
- Recompute ancestor ``CompetencyCriteriaGroup`` statuses upward to the root.
- At each group, evaluate children in ``ordering`` sequence and short-circuit when the group's result is already determined by its ``logic_operator``.
- Persist only rows whose status changed.
- ``StudentCompetencyCriteriaStatus`` / ``StudentCompetencyCriteriaStatusHistory`` track status at
the ``CompetencyCriterion`` leaf level. A leaf is atomic, so its status is only ``Demonstrated``
or ``AttemptedNotDemonstrated``.
- ``StudentCompetencyCriteriaGroupStatus`` / ``StudentCompetencyCriteriaGroupStatusHistory`` track
status at the ``CompetencyCriteriaGroup`` node level.
- ``StudentCompetencyStatus`` / ``StudentCompetencyStatusHistory`` track top-level competency
demonstration state.
- All learner status rows use a shared lookup table (``CompetencyMasteryStatuses``) so status
semantics live in one place and the status tables stay structurally consistent.

Intended update flow (bottom-up roll-up):

- A learner event evaluates one leaf; if its status advances, its ACTIVE row is updated in place
and a leaf HISTORY row is appended.
- Recompute ancestor ``CompetencyCriteriaGroup`` statuses upward to the root, reading the stored
child rows directly.
- At each group, evaluate children in ``ordering`` sequence and short-circuit when the group's
result is already determined by its ``logic_operator``.
- Persist only nodes whose status changed: update the ACTIVE row in place and append a HISTORY row.
A banked (``Demonstrated``) status is never regressed, so a downward grade correction advances
nothing and writes no HISTORY row.

1. Add new database table for ``CompetencyMasteryStatuses`` with these columns:

Expand All @@ -188,34 +217,51 @@ Decision

- This table is system-owned lookup data and should be treated as immutable configuration, not user-authored rows.

2. Add new database table for ``StudentCompetencyCriteriaStatus`` with these columns:
2. Add the leaf-level tables ``StudentCompetencyCriteriaStatus`` (ACTIVE) and
``StudentCompetencyCriteriaStatusHistory`` (HISTORY). Both tables have these columns:

1. ``id``: unique primary key
2. ``competency_criteria_id``: Foreign key to ``CompetencyCriterion.id``
3. ``user_id``: Foreign key pointing to user_id (presumably the learner's id, although it appears that it is possible for staff to get grades as well) in ``auth_user`` table
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``
5. ``created``: The timestamp at which the student's criterion status was set.
1. ``id``: 64-bit ``BigAutoField`` primary key
2. ``competency_criteria_id``: logical foreign key to ``CompetencyCriterion.id`` (no database-level constraint)
3. ``user_id``: logical foreign key to the learner's user id (no database-level constraint)
4. ``status_id``: foreign key to ``CompetencyMasteryStatuses.id``
5. ``effective_source_timestamp``: the timestamp of the grade change this status was computed from, used for the out-of-order defense in :ref:`openedx-learning-adr-0004`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is effective_source_timestamp different from created?

6. ``created``: on ACTIVE, when the row was first written; on HISTORY, when this advance was appended

3. Add a new database table for ``StudentCompetencyCriteriaGroupStatus`` with these columns:
The ACTIVE table additionally has a ``modified`` timestamp (last in-place update) and is unique

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like these modified fields should get enumerated like the rest of the fields.

on ``(user_id, competency_criteria_id)`` (one current row per learner and leaf). The HISTORY
table has no such uniqueness constraint: it holds one row per advance and is not written for a
suppressed downward correction.

1. ``id``: unique primary key
2. ``competency_criteria_group_id``: Foreign key to ``CompetencyCriteriaGroup.id``
3. ``user_id``: Foreign key pointing to user_id (presumably the learner's id, although it appears that it is possible for staff to get grades as well) in ``auth_user`` table
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``
5. ``created``: The timestamp at which the student's criteria-group status was set.
3. Add the group-level tables ``StudentCompetencyCriteriaGroupStatus`` (ACTIVE) and
``StudentCompetencyCriteriaGroupStatusHistory`` (HISTORY). Both tables have these columns:

4. Add a new database table for ``StudentCompetencyStatus`` with these columns:
1. ``id``: 64-bit ``BigAutoField`` primary key
2. ``competency_criteria_group_id``: logical foreign key to ``CompetencyCriteriaGroup.id`` (no database-level constraint)
3. ``user_id``: logical foreign key to the learner's user id (no database-level constraint)
4. ``status_id``: foreign key to ``CompetencyMasteryStatuses.id``
5. ``effective_source_timestamp``: as in the leaf tables
6. ``created``: as in the leaf tables

1. ``id``: unique primary key
2. ``oel_tagging_tag_id``: Foreign key pointing to Tag id
3. ``user_id``: Foreign key pointing to user_id (presumably the learner's id, although it appears that it is possible for staff to get grades as well) in ``auth_user`` table
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``. This table should have a constraint to only allow status values of “Demonstrated” and “PartiallyAttempted” since it represents overall competency demonstration state, not in-progress states.
5. ``created``: The timestamp at which the student's competency status was set.
The ACTIVE table additionally has a ``modified`` timestamp and is unique on
``(user_id, competency_criteria_group_id)``.

4. Add the competency-level tables ``StudentCompetencyStatus`` (ACTIVE) and
``StudentCompetencyStatusHistory`` (HISTORY). Both tables have these columns:

1. ``id``: 64-bit ``BigAutoField`` primary key
2. ``oel_tagging_tag_id``: logical foreign key to the competency ``Tag`` id (no database-level constraint)
3. ``user_id``: logical foreign key to the learner's user id (no database-level constraint)
4. ``status_id``: foreign key to ``CompetencyMasteryStatuses.id``. Constrained to “Demonstrated” and “PartiallyAttempted” only, since this represents overall competency demonstration state, not in-progress states.
5. ``effective_source_timestamp``: as in the leaf tables
6. ``created``: as in the leaf tables

The ACTIVE table additionally has a ``modified`` timestamp and is unique on
``(user_id, oel_tagging_tag_id)``.

7. Delete protection boundaries

- If no learner status rows exist for a competency definition, hard delete is allowed and cascades through competency metadata tables.
- Once any related learner status exists in ``StudentCompetencyStatus``, ``StudentCompetencyCriteriaGroupStatus``, or ``StudentCompetencyCriteriaStatus``, deletion of associated competency definition rows is blocked.
- Once any related learner status exists (an ACTIVE or HISTORY row at any level), deletion of associated competency definition rows is blocked. Because the learner-status foreign keys carry no database-level constraint (Decision 6), this protection is enforced in application code, not by a database cascade or constraint.
- This delete protection applies to ``oel_tagging_taxonomy``, ``CompetencyTaxonomy``, ``oel_tagging_tag``, ``oel_tagging_objecttag``, ``CompetencyCriteriaGroup``, ``CompetencyCriteria``, and ``CompetencyRuleProfile``.
- Once any related learner status exists, retiring definitions may be archive-only (hidden from authoring and new associations), not hard delete.

Expand All @@ -224,6 +270,15 @@ Decision
:width: 80%
:align: center

.. note::

The PNG above is the original overview. It predates the ACTIVE/HISTORY split introduced here and
in :ref:`openedx-learning-adr-0005`, and it shows a ``mastery_level_id`` column on
``StudentCompetencyCriteriaStatus`` that is not part of this decision (mastery level is a future
rule type). The current data model, including the ACTIVE and HISTORY tables, is diagrammed in
``0002-competency-criteria-model-diagram.md``, which is authoritative where it differs from the
PNG.


Example
-------
Expand Down
Loading