You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/openedx_learning/decisions/0002-competency-criteria-model.rst
+18-7Lines changed: 18 additions & 7 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -135,7 +135,7 @@ Decision
135
135
7. ``rule_payload``: JSON payload keyed by ``rule_type`` to avoid freeform strings. It is structured JSON (not arbitrary freeform data): each ``rule_type`` defines the allowed payload shape and required keys, and validation enforces this contract. JSON is used instead of fixed columns like ``op``, ``value``, and ``scale`` so that future rule types (for example, ``MasteryLevel`` thresholds or plugin-defined evaluators such as CEL-based rules) can add their own fields without repeated schema migrations or many nullable columns. Examples:
136
136
137
137
1. ``Grade``: ``{"op": "gte", "value": 0.75, "scale": "percent"}``. Allowed ``op`` values: ``gte``, ``lte``, ``eq``. ``value`` must be a fraction between 0.0 and 1.0 inclusive, matching the platform's existing fractional grade representation, not a 0-100 scale.
138
-
8. ``archived``: Boolean, defaults to false. Set instead of deleting a profile that is no longer wanted. Archived profiles are hidden from authoring and new associations but remain queryable, so existing ``CompetencyCriterion`` rows and learner status history stay resolvable.
138
+
8. ``archived``: Boolean, defaults to false. Set instead of deleting a profile that is no longer wanted. Archived profiles are hidden from authoring and new associations but remain queryable, so existing ``CompetencyCriterion`` rows and learner status rows stay resolvable.
139
139
140
140
A check constraint requires that at most one of ``organization_id``, ``course_id``, and ``competency_taxonomy_id`` is non-null on any row, matching the scoping rule above.
6. ``StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)`` (unique -- each learner status table holds exactly one row per learner and target entity, updated in place; see ADR 0003 Decision 5)
9. ``CompetencyRuleProfile(scope_code)`` (unique -- at most one profile per distinct scope value; a plain unique constraint on the three raw nullable scope columns would not enforce this, since SQL never treats two ``NULL`` values as equal and this project's MySQL backend does not support the conditional/partial unique indexes that would otherwise route around that; see the ``scope_code`` column in Decision 3)
2. ``competency_criteria_id``: Foreign key to ``CompetencyCriterion.id``
280
280
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
281
281
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``
282
-
5. ``created``: The timestamp at which the student's criterion status was set.
282
+
5. ``created``: The timestamp at which the student's criterion status row was first written.
283
+
6. ``modified``: The timestamp at which the student's criterion status was last updated.
283
284
284
285
3. Add a new database table for ``StudentCompetencyCriteriaGroupStatus`` with these columns:
285
286
286
287
1. ``id``: unique primary key
287
288
2. ``competency_criteria_group_id``: Foreign key to ``CompetencyCriteriaGroup.id``
288
289
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
289
290
4. ``status_id``: Foreign key to ``CompetencyMasteryStatuses.id``
290
-
5. ``created``: The timestamp at which the student's criteria-group status was set.
291
+
5. ``created``: The timestamp at which the student's criteria-group status row was first written.
292
+
6. ``modified``: The timestamp at which the student's criteria-group status was last updated.
291
293
292
294
4. Add a new database table for ``StudentCompetencyStatus`` with these columns:
293
295
294
296
1. ``id``: unique primary key
295
297
2. ``oel_tagging_tag_id``: Foreign key pointing to Tag id
296
298
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
297
299
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.
298
-
5. ``created``: The timestamp at which the student's competency status was set.
300
+
5. ``created``: The timestamp at which the student's competency status row was first written.
301
+
6. ``modified``: The timestamp at which the student's competency status was last updated.
299
302
300
303
7. Delete protection boundaries
301
304
@@ -422,3 +425,11 @@ Rejected Alternatives
422
425
423
426
1. Silently does not work on this project's tested and production database backend. Django compiles a conditional ``UniqueConstraint`` to a partial index, which MySQL does not support; Django raises only a non-fatal system-check warning (``models.W036``) and skips creating the constraint, leaving the uniqueness rule completely unenforced at the database level.
424
427
2. The gap would surface only as a data-integrity incident under concurrent writes, not as a test or migration failure, since SQLite (used for quick local test runs) does support partial indexes and would mask the problem in that environment.
428
+
429
+
Changelog
430
+
---------
431
+
432
+
2026-07-27:
433
+
434
+
* Made the learner status indexes unique, so there is one row per learner and node. This is what
435
+
the in-place, monotone status updates in :ref:`openedx-learning-adr-0004` read, lock, and update.
Copy file name to clipboardExpand all lines: docs/openedx_learning/decisions/0003-competency-criteria-versioning.rst
+47-6Lines changed: 47 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -13,7 +13,7 @@ Typically, institutions and instructional designers do not change the mastery re
13
13
14
14
Currently, Open edX always displays the latest edited version of content in the Studio UI and always shows the latest published version of content in the LMS UI, despite having more robust version tracking on the backend (Publishable Entities).
15
15
16
-
Authoring data (criteria definitions) and runtime learner data (status) have different governance needs. The former is long-lived and typically non-PII, while the latter is user-specific, can be large (learners x criteria/competencies x time), and may require stricter retention and access controls. These differing lifecycles can make deep coupling of authoring and runtime data harder to manage at scale. Performance is also a consideration as computing or resolving versioned criteria for large courses could add overhead in Studio authoring screens or LMS views.
16
+
Authoring data (criteria definitions) and runtime learner data (status) have different governance needs. The former is long-lived and typically non-PII, while the latter is user-specific, can be large (learners x criteria/competencies), and may require stricter retention and access controls. These differing lifecycles can make deep coupling of authoring and runtime data harder to manage at scale. Performance is also a consideration as computing or resolving versioned criteria for large courses could add overhead in Studio authoring screens or LMS views.
17
17
18
18
Decision
19
19
--------
@@ -44,12 +44,13 @@ For the initial implementation, versioning and traceability of competency achiev
44
44
- A ``CompetencyRuleProfile`` is "in use" if any ``CompetencyCriterion`` assigned to it (``competency_rule_profile_id``) has an associated ``StudentCompetencyCriteriaStatus`` row. Editing an in-use profile's ``rule_type``/``rule_payload`` requires the same warning and confirmation.
45
45
- The same warning applies when creating a more specific profile causes existing criteria to be reassigned to it, and when an authoring action switches a criterion between a profile assignment and per-criterion overrides (ADR 0002 Decision 4).
46
46
47
-
5. Learner status models/tables are append-only history and do not use ``django-simple-history``:
47
+
5. Do not store history for learner competency status tables, and update rows in place. These tables do not use ``django-simple-history``:
48
48
49
-
- For ``StudentCompetencyCriteriaStatus``, ``StudentCompetencyCriteriaGroupStatus``, and ``StudentCompetencyStatus``, each status change is stored as a new row with ``created`` as the write timestamp.
50
-
- Existing learner status rows are not updated in place.
51
-
- Current status is determined by the most recent row for a given learner + target entity (ordered by ``created``, with ``id`` as a tie-breaker).
52
-
- Older rows represent the learner status history and remain available for audit/tracing.
49
+
- For ``StudentCompetencyCriteriaStatus``, ``StudentCompetencyCriteriaGroupStatus``, and ``StudentCompetencyStatus``, each row is updated in place when a learner's status changes.
50
+
- There is no history of prior status values beyond the ``modified`` timestamp, and no separate history table.
51
+
- Current status is the single row for a given learner + target entity.
52
+
53
+
Open edX has no concept of gradeable subsection attempts. This means that an attempt is actually defined at the level of an individual problem, so storing one row per attempt can result in tens of billions of rows for a large Open edX instance. That scale creates real operational burden: schema migrations, backups, and truncation and retention policy. Therefore, we did additional market research and found that storing history for learner competency status will not be required by the initial pilot partners for the MVP of the CBE implementation, and it would be safe to add later if needed.
53
54
54
55
55
56
Rejected Alternatives
@@ -85,3 +86,43 @@ Rejected Alternatives
85
86
- Cons:
86
87
- Requires custom tooling to reconstruct past versions
87
88
- Does not align with existing publishable versioning patterns
89
+
5. Keep the learner status tables append-only, storing every status change as a new row.
90
+
- Pros:
91
+
- Every write is an insert rather than a read-modify-write, so there is no current row to keep consistent.
92
+
- Preserves a full audit trail of every status change.
93
+
- Cons:
94
+
- No MVP requirement calls for this history.
95
+
- Grows the leaf table by a further multiplier of problem attempts per learner per leaf, reaching tens of billions of rows for a large instance.
96
+
- A read must resolve the latest row for a learner and node rather than reading one in-place row, which is more expensive and more complex.
97
+
6. Compute leaves transiently, never store them.
98
+
- Pros:
99
+
- Eliminates the largest table, since leaf demonstration would be computed on demand from the leaf's rule and the learner's grade.
100
+
- Cons:
101
+
- A recomputed leaf reflects the rule as it stands now, not the rule in force when the learner was graded, which contradicts Decision 4 above and can silently lower a status.
102
+
7. Store child evaluations on the parent group row instead of a leaf table.
103
+
- Pros:
104
+
- Avoids the largest table entirely.
105
+
- Cons:
106
+
- A leaf write becomes a read-modify-write of a column shared with every sibling.
107
+
- No unique index or foreign key stands behind a status packed into a per-group array, so nothing but application code keeps it consistent with the criteria it describes.
108
+
- Couples a leaf's frozen mastery to the current shape of the criteria tree, so restructuring the tree can corrupt already-recorded mastery.
109
+
- Removes the ability to individually track competency status progress by learning object, for example by subsection.
110
+
8. Put the leaf table behind its own database alias and router, a separate physical database, or native partitioning or sharding, from the start.
111
+
- Pros:
112
+
- Physically isolates or splits the largest table from the start.
113
+
- Cons:
114
+
- A second database alias runs on its own connection, so the leaf write and the ancestor writes could no longer share a single transaction with the grade write, which gives up the atomicity these writes need.
115
+
- Imposes real operational cost on every deployment with nothing measured to justify it, and remains available later if a specific need is proven.
116
+
9. Serve heavy leaf-table reads from a read replica.
117
+
- Pros:
118
+
- Keeps dashboard and reporting reads off the primary.
119
+
- Cons:
120
+
- Premature: no measurement shows the primary struggling, and these are point lookups on a composite index, not the wide, expensive reads that drive ``StudentModule`` load in edx-platform.
121
+
- Adding it later is a per-query choice, not a schema decision.
122
+
10. Give the leaf table a custom unsigned 64-bit primary key.
123
+
- Cons:
124
+
- ``BigAutoField``'s range is already far out of reach for this table.
125
+
- Unsigned integers do not exist in PostgreSQL, and the custom field type carries ongoing maintenance cost for no real benefit at this scale.
126
+
11. Drop the database-level foreign key constraint on the learner column.
127
+
- Cons:
128
+
- This repo's convention is a real foreign key to ``settings.AUTH_USER_MODEL``. Reports of contention on the user row exist elsewhere in the ecosystem, but are not understood well enough here to design around.
0 commit comments