openedx / openedx/openedx-core
[BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status)
@jesperhodge is already working on this.
Since Jul 7, 2026.
- Dominant language
- Python
- Stars
- 10
- Forks
- 32
- Avg merge
- 2d 17h
- Merged PRs (30d)
- 12
Description
Use Case
Competency-Based Education (CBE) programs require learners to demonstrate mastery of specific
competencies, not just complete coursework. Open edX currently has no way to define or evaluate the
rules that determine whether a learner has demonstrated a competency, for example "a learner must
score 80% or higher on Assignment 1 OR Assignment 2 to demonstrate the Writing Poetry competency."
This issue implements the foundational database layer that makes the following possible:
- Course Authors and Platform Administrators can define competency achievement criteria in Studio,
specifying which course content (assignments, exams, etc.) a learner must complete, at what
threshold, and with what AND/OR logic. - The platform can track each learner's progress toward demonstrating each competency as they
receive grades and completions. - A history of criteria changes is preserved for audit and traceability.
Without this data model, no competency progress dashboards, automated competency evaluations, or
CBE-specific authoring tools can be built.
Description
Implement the Django database models defined in
ADR-0002 (Competency Criteria Model),
ADR-0003 (Competency Criteria Versioning)
and
ADR-0004 (Competency Mastery Concurrency).
This is a data-layer issue: no REST endpoints, no UI, and no business logic beyond schema
constraints and two seed migrations. Delete protection was originally moved out to #799 pending #655.
#655 closed on 2026-09-02 with an approved design, and #799 is now closed as superseded. The
on_delete values that design implies for this issue's foreign keys are set out in the Deletions
section below, since revised by an amendment to ADR-0002 Decision 7.
The code goes in src/openedx_learning/applets/cbe/, the app created by PR #712 per
ADR-0001.
Already delivered. PR #712 added the openedx_learning app and the CompetencyTaxonomy model.
This issue adds the one column that PR left out, taxonomy_overrides_org.
Authoring and definition models
CompetencyTaxonomy: extendsTaxonomyvia Django multi-table inheritance to mark a taxonomy as
CBE-enabled. Already exists; needstaxonomy_overrides_orgadded.CompetencyCriteriaGroup: internal nodes of the AND/OR expression tree.CompetencyRuleProfile: reusable evaluation rule defaults, scoped by taxonomy, course, or
organization.CompetencyCriterion: leaf nodes of the tree, linking a tag/object association either to a rule
profile or to per-criterion overrides. The Python class isCompetencyCriterion, singular,
because one leaf is one criterion; the database table staysCompetencyCriteriavia
Meta.db_table.
Lookup data
CompetencyMasteryStatuses: the shared status valuesAttemptedNotDemonstrated,
PartiallyAttemptedandDemonstrated, in that order from lowest to highest. The order has to be
readable by the database, not only by Python, for the reason given in the acceptance criteria.
Learner progress models
StudentCompetencyCriteriaStatusStudentCompetencyCriteriaGroupStatusStudentCompetencyStatus
These hold one row per learner and node, updated in place under a unique constraint, each carrying
both created and modified. ADR-0003 Decision 5 originally specified append-only rows; it was
amended on 2026-07-27 for ADR-0004, which needs a single row per learner and node that a writer can
raise with one conditional UPDATE.
Beyond the models
- Two data migrations: one seeding the three
CompetencyMasteryStatusesvalues, one seeding the
single system-defaultCompetencyRuleProfileat a grade threshold of 80%. - One
.importlinterchange:openedx_catalogadded toroot_packagesand to thesrc_layering
contract, sinceCompetencyCriteriaGroup.course_idis the first foreign key from
openedx_learninginto that app.
History tracking
Per ADR-0003, django-simple-history is applied to CompetencyCriteriaGroup,
CompetencyCriterion and CompetencyRuleProfile only. It is not applied to the tagging models, to
CompetencyTaxonomy, or to the learner status tables.
Explicitly out of scope. These rules from the ADRs are not implemented here. The first four belong
to the API layer, where each would either not work at the model layer or would be enforced at the wrong
moment. The fifth and sixth are deferred:
- Computing which
CompetencyRuleProfilea criterion resolves to (ADR-0002 Decision 4). The models
store the foreign key and nothing recomputes it at read time. - The rule that an automatic status write may raise a status but never lower it, and that a staff
correction may lower one (ADR-0003 Decision 5, ADR-0004 Decisions 4 and 6). The models accept any
status value; this issue only has to make the comparison expressible in a single SQL statement. - The rule that a group with more than one child needs a non-null
logic_operator(ADR-0002
Decision 2). A group has no children at the moment it is saved, because a child row needs its
parent's primary key first, and saving a child later validates the child, never the parent. This
can only be enforced where a whole tree is saved as a unit. - Rejecting empty groups (ADR-0002 Decision 2), for the same reason.
- All archive-versus-delete enforcement logic (ADR-0002 Decision 7). #655 closed on 2026-09-02 with
an approved design. The work it implies is split across #674 and #675 (the criterion and group
removal endpoints), #716 (thearchivedcolumn onCompetencyCriteriaGroupand
CompetencyCriterion), and anopenedx_taggingticket not yet filed (thearchivedand
deletion_lockedcolumns on the tagging models, plus the lock functions CBE calls). See the
Deletions section below for the nineon_deletevalues this issue sets, which are final rather than
a placeholder for that future work to replace. - Deciding what happens to a course that still relies on an organization-scoped
CompetencyRuleProfile
after that organization is deleted, since deleting an organization does not delete its courses along
with it. The two options under consideration are falling that course back to the system-default
profile, or cloning the organization's profile into a new course-scoped profile for that course, with
the current lean toward the latter. Organization-scoped profiles don't exist yet in this MVP
(Decision 3), soCompetencyRuleProfile.organizationstaysPROTECT; that decision, and the
on_deletevalue it implies, is made together when organization-scoped profiles are built, not here.
Verification note. The scope_code unique constraint has to be verified against MySQL, not only
the SQLite used for quick local test runs. The acceptance criteria below explain why a green local
suite is not evidence for that one.
Acceptance Criteria
Note: This issue delivers a data model, not a user-facing feature. There is no manual QA path.
Acceptance is determined by a PR reviewer verifying the following against ADR-0002, ADR-0003 and
ADR-0004, with the one exception of the MySQL check, which needs a real MySQL database.
Schema Correctness (ADR-0002)
-
CompetencyTaxonomyuses Django MTI (not ataxonomy_typecolumn);taxonomy_ptr_idis both the PK and FK tooel_tagging_taxonomy.id. Delivered by PR #712, so this ships already satisfied. -
CompetencyTaxonomyhas thetaxonomy_overrides_orgboolean, defaultfalse. -
CompetencyCriteriaGrouphas all required columns:id,parent_id(nullable self-FK),oel_tagging_tag_id,course_id(nullableForeignKeytoopenedx_catalog.CourseRun),name,ordering,logic_operator(AND/OR/null). -
openedx_catalogis added to.importlinter'sroot_packagesand placed in thesrc_layeringcontract belowopenedx_learning. Today it appears in neither, so the firstopenedx_learningtoopenedx_catalogimport would pass unexamined.lint-importspasses with no rule loosened. -
logic_operatoraccepts AND, OR or null, per ADR-0002 Decision 2, and nothing at the data layer constrains it by child count. That rule cannot hold here: a group's children need its primary key, so the group's ownclean()always sees zero children, and adding a child later calls the child'sclean(), never the parent's. Whatever rule governs null is enforced in the authoring API, when a tree is saved as a unit. - No
UniqueConstrainton(parent_id, ordering)is added. ADR-0002 requires none, and it would settle only half the ordering question: a group's children are both child groups and leafCompetencyCriteriarows, and the leaf model has noorderingcolumn at all, so sibling order among leaves would stay undefined while looking solved. -
CompetencyRuleProfilehas every column from ADR-0002 Decision 3:id,organization_id,course_id,competency_taxonomy_id,scope_code,rule_type,rule_payload,archived.rule_payloadis a validated JSON field with shape enforced perrule_type. -
scope_codeis a generated, never-null column in the format"org:X,course:Y,taxonomy:Z", non-null for the system-default row where all three scope columns are null, with a unique constraint onscope_codealone. - The
scope_codemigration is applied against MySQL, not only the SQLite used for local runs. ADR-0002 Rejected Alternative 6 records that the obvious substitute, a conditionalUniqueConstraintover the three nullable columns, compiles to a partial index that MySQL does not support: Django emits amodels.W036warning, skips the constraint, and SQLite supports partial indexes so local tests stay green. A passing local suite is not evidence here. - A check constraint enforces that at most one of
organization_id,course_idandcompetency_taxonomy_idis non-null, with a test for each rejected two-column combination. - A data migration seeds the single system-default
CompetencyRuleProfile, the row where all three scope fields are null. It is seeded witharchivedfalse,rule_type"Grade", andrule_payload{"op": "gte", "value": 0.8, "scale": "percent"}, which means "a grade of 80% or higher". Note thatvalueis a fraction between 0.0 and 1.0, not a number out of 100 (ADR-0002 Decision 3), so 80% is written0.8. This is the rule every competency criterion falls back to when nothing more specific applies, so a deployment that installs this app and adds no profiles of its own gets an 80% threshold. -
CompetencyRuleProfilescope fields are immutable after creation; editing a profile may changerule_typeandrule_payloadonly, so that criteria already resolved to a profile are never silently re-scoped. - The leaf model has all required columns, including nullable
competency_rule_profile_id,rule_type_overrideandrule_payload_override, with the same validation contract. Its class name isCompetencyCriterionwithMeta.db_table = "CompetencyCriteria", matching ADR-0002 Decision 4, which names the conceptCompetencyCriterionand the tableCompetencyCriteria. - A check constraint enforces ADR-0002 Decision 4's invariant: either
competency_rule_profile_idis set and both override fields are null, orcompetency_rule_profile_idis null and both override fields are set. Never both, never neither. A test covers each of the two invalid states. - Nothing resolves
competency_rule_profile_idat read time. ADR-0002 Decision 4 assigns it at four named write events and stores the result, and says the FK is never re-resolved dynamically at evaluation time. The assignment computation itself is API-layer work and is out of scope here. -
rule_payloadandrule_payload_overrideshape validation is enforced inclean(), reached viafull_clean(); a test must callfull_clean()with an invalid payload and assertValidationErroris raised.clean()is a convenience for the admin and for tests, not an enforcement layer: Django'sModelFormcallsfull_clean(), but DRF'sModelSerializernever does, and neither doesQuerySet.update()orbulk_create(). - The three mastery status values,
AttemptedNotDemonstrated,PartiallyAttemptedandDemonstrated, exist and their order is available to the database, so that raising a status can be written as one conditionalUPDATErather than a read followed by a write. A test asserts that a write of a lower value against a higher stored value changes no row, using a single statement. -
StudentCompetencyStatusrejectsAttemptedNotDemonstratedand accepts onlyDemonstratedandPartiallyAttempted. The rejection holds on every write path, includingQuerySet.update()andbulk_create(), which never callclean(). Tests cover a direct save and a bulk write. - Every index from ADR-0002 Decision 5 is present, and the unique ones are unique: indexes 6, 7 and 8 are unique on
(user_id, node_id), index 9 is unique onscope_code, and index 10 is unique on the status value. A plain index in any of those four positions fails this criterion. - All new models are registered in
.annotation_safe_list.yml(or inline docstrings) andmake pii_checkpasses with 100% coverage. Every one of them, the threeStudentCompetency*Statusmodels included, is annotated.. no_pii:: each stores a user foreign key and a status value and no personal data of its own, which is how every existing openedx-core model with a user foreign key is annotated,openedx_content.PublishableEntityandCollectionamong them.pii_retirement: consumer_apiis not used, because it asserts a consumer-facing retirement API that openedx-core does not have. -
CompetencyCriteriaGroup,CompetencyCriterionandCompetencyRuleProfileeach carry auuidexternal identifier alongside the internalid, following this repo's identifier convention, so that the REST APIs and events built on them are never forced to expose an integer primary key.
Versioning (ADR-0003)
-
django-simple-history(HistoricalRecords()) is applied toCompetencyCriteriaGroup,CompetencyCriterion, andCompetencyRuleProfile. -
django-simple-historyis not applied tooel_tagging_tag,oel_tagging_taxonomy, orCompetencyTaxonomy. - Learner status rows are updated in place, one row per learner and node under a unique constraint, per ADR-0003 Decision 5 as amended on 2026-07-27. Each table carries both
created(auto_now_add=True) andmodified(auto_now=True). No history package is applied. - No monotone-write logic and no staff-edit path land here. The models accept any status value the caller writes; the rules that decide which writes are allowed, that an automatic write may raise a status but never lower it and that a staff correction may lower one, are enforced in the API layer.
Deletions
All archive-versus-delete enforcement logic from ADR-0002 Decision 7 is out of scope here. #655 closed
on 2026-09-02 with an approved design for it, and the work that design implies is split across separate
issues as described above.
What this issue does own is the on_delete value on every new foreign key #641 and #642 add, decided
on #655 on 2026-09-02 and since revised by an amendment to ADR-0002 Decision 7, listed in the checkboxes
below. Three things make ADR-0002 Decision 7's guarantee, that a learner's demonstrated mastery can
never be silently invalidated, actually hold rather than merely state: #655's approved design keeps
openedx_tagging from ever calling into CBE to ask whether a tag is "in use," so the only way a
deletion can be stopped while a learner has mastery under it is for Django's own delete collector to
walk down into the criteria tree on its own, and Django only walks a foreign key marked CASCADE.
Seven foreign keys are CASCADE for that reason: four carry the collector down from the tag through
the tree (CompetencyCriteriaGroup.tag, CompetencyCriteriaGroup.parent, CompetencyCriterion.group,
CompetencyCriterion.object_tag), and three carry it down from a CompetencyTaxonomy or CourseRun
into anything scoped to it (CompetencyCriteriaGroup.course, CompetencyRuleProfile.course,
CompetencyRuleProfile.competency_taxonomy), safe because a taxonomy or course only ever hard-deletes
once nothing beneath it needs protecting. CompetencyCriterion.rule_profile is RESTRICT instead, so a
profile and the criteria using it can be removed together in the same delete, while a criterion outside
that delete still relying on the profile still stops it, a distinction flat PROTECT can't make.
CompetencyRuleProfile.organization stays PROTECT, deliberately: organization-scoped profiles don't
exist yet (Decision 3), and removing a shared one before deciding what happens to the courses still
using it would silently discard rule configuration they depend on, a decision made only once
organization-scoped profiles are built.
The three foreign keys from the Student*Status tables back up to their definition row are PROTECT,
and that is the actual stop, the only thing that turns the walk into a real block: a delete with no
learner status beneath it cascades away cleanly, and one with a status row anywhere beneath it raises
ProtectedError instead.
The user_id foreign key on all three Student*Status tables is CASCADE. A learner status
row is a derived fact about that user, not something a user's own account deletion should be blocked by.
-
CompetencyCriteriaGroup.tag,CompetencyCriteriaGroup.parent,CompetencyCriterion.group,
CompetencyCriterion.object_tag,CompetencyCriteriaGroup.course,CompetencyRuleProfile.course,
andCompetencyRuleProfile.competency_taxonomyareCASCADE. -
CompetencyCriterion.rule_profileisRESTRICT. -
CompetencyRuleProfile.organizationisPROTECT. - The foreign key from each of the three
Student*Statustables to its definition row
(CompetencyCriterion,CompetencyCriteriaGroup, oroel_tagging_tag) isPROTECT. NoTODO
comment is attached to any of these nineon_deletevalues; all nine are final. - The
user_idforeign key on all threeStudent*Statustables isCASCADE. Thestatus_id
foreign key toCompetencyMasteryStatusesisPROTECT. - The transitive cases are tested, not only the direct ones. Deleting an
oel_tagging_tagwith a
learner status row anywhere beneath it raisesProtectedError, and deleting one with no status rows
beneath it succeeds and cascades the whole criteria tree away. The same holds for deleting a
CompetencyCriteriaGroupat depth and for deleting anoel_tagging_objecttag. - Deleting a
CompetencyTaxonomyorCourseRunwith no learner status anywhere beneath it
succeeds and removes its scopedCompetencyRuleProfilealong with it. The same delete, when a
CompetencyCriterionoutside the tree being deleted still resolves to that profile, fails instead of
silently deleting the profile out from under that criterion, sinceCompetencyCriterion.rule_profile
isRESTRICT, notCASCADE. Tests cover both outcomes. - Deleting a user row removes that user's status rows across all three
Student*Statusmodels,
and a test covers it. - No
delete()override, no archive-versus-delete branch, and no deletion-lock field lands in this
issue. TheCASCADE,RESTRICT, andPROTECTvalues above are declared on the foreign keys;
nothing here implements deletion behavior in code. #655's approved design enforces archive-versus-delete
entirely at the application layer, driven by a persisted lock flag onoel_tagging_objecttag, which
changesopenedx_taggingas well as CBE and is not yet owned by a filed ticket. -
CompetencyRuleProfile.archivedexists as a column defaulting to false, per the Decision 3
criteria above. Nothing in this issue enforces that a profile is only archived and never deleted;
#655 resolves this differently: the model gets no DELETE endpoint at all, and only the single
system-default row exists in MVP.
General
- Migrations are present and apply cleanly from scratch.
- No column exists on any of these models beyond those in ADR-0002 Decisions 1 through 4 and 6, the constraints, identifiers and timestamps this issue lists, and the columns
django-simple-historygenerates. - All FK relationships match the ADR definitions exactly, targets included:
course_idpoints atopenedx_catalog.CourseRun, and the learneruser_idpoints atsettings.AUTH_USER_MODELrather thanauth.User, withmigrations.swappable_dependencydeclared in the migration, so that deployments with a swapped user model still work.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.