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
As a course author, I want a competency's completion requirements stored as an ordered
AND/OR tree of criteria, in order to express a rule like "pass the final and either lab"
rather than a single flat threshold.
CompetencyCriteriaGroup has all required columns: id, parent_id (nullable self-FK), oel_tagging_tag_id, course_id (nullable ForeignKey to openedx_catalog.CourseRun), name, ordering, logic_operator (AND/OR/null).
openedx_catalog is added to .importlinter's root_packages and placed in the src_layering contract below openedx_learning. Today it appears in neither, so the first openedx_learning to openedx_catalog import would pass unexamined. lint-imports passes with no rule loosened. It is placed as an independent sibling of openedx_content, written openedx_content | openedx_catalog, rather than as a layer of its own above or below it: layers is a strict total order, so a layer of its own would also decide the catalog-to-content direction that src/openedx_catalog/ARCHITECTURE.md records as undecided. [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613's wording says only "below openedx_learning" and needs the same clarification.
logic_operator accepts 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 own clean() always sees zero children, and adding a child later calls the child's clean(), never the parent's. Whatever rule governs null is enforced in the authoring API, when a tree is saved as a unit.
No UniqueConstraint on (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 leaf CompetencyCriteria rows, and the leaf model has no ordering column at all, so sibling order among leaves would stay undefined while looking solved.
CompetencyRuleProfile has every column from ADR-0002 Decision 3: id, organization_id, course_id, competency_taxonomy_id, scope_code, rule_type, rule_payload, archived. rule_payload is a validated JSON field with shape enforced per rule_type.
scope_code is 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 on scope_code alone.
The scope_code migration is applied against MySQL, not only the SQLite used for local runs. ADR-0002 Rejected Alternative 6 records that the obvious substitute, a conditional UniqueConstraint over the three nullable columns, compiles to a partial index that MySQL does not support: Django emits a models.W036 warning, 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_id and competency_taxonomy_id is 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 with archived false, rule_type"Grade", and rule_payload{"op": "gte", "value": 0.8, "scale": "percent"}, which means "a grade of 80% or higher". Note that value is a fraction between 0.0 and 1.0, not a number out of 100 (ADR-0002 Decision 3), so 80% is written 0.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.
CompetencyRuleProfile scope fields are immutable after creation; editing a profile may change rule_type and rule_payload only, 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_override and rule_payload_override, with the same validation contract. Its class name is CompetencyCriterion, singular, matching ADR-0002 Decision 4, which calls one leaf a criterion. No Meta.db_table override is added, so the table is openedx_learning_competencycriteria; see "The three criteria models" above for why. [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 still asks for Meta.db_table = "CompetencyCriteria" and needs the same correction.
A check constraint enforces ADR-0002 Decision 4's invariant: either competency_rule_profile_id is set and both override fields are null, or competency_rule_profile_id is 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_id at 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_payload and rule_payload_override shape validation is enforced in clean(), reached via full_clean(); a test must call full_clean() with an invalid payload and assert ValidationError is raised. clean() is a convenience for the admin and for tests, not an enforcement layer: Django's ModelForm calls full_clean(), but DRF's ModelSerializer never does, and neither does QuerySet.update() or bulk_create().
The indexes from ADR-0002 Decision 5 that belong to these models are present: 1 (CompetencyCriteriaGroup(oel_tagging_tag_id, course_id)), 2 (CompetencyCriteriaGroup(parent_id)), 4 (CompetencyCriteria(oel_tagging_objecttag_id)), 5 (CompetencyCriteria(competency_criteria_group_id)) and 9 (CompetencyRuleProfile(scope_code)). Index 9 must be unique; a plain index there fails this criterion. Index 3, oel_tagging_objecttag(object_id), is already satisfied by the existing db_index=True on ObjectTag.object_id (src/openedx_tagging/models/base.py), so no work is required for it here. Indexes 6, 7, 8 and 10 come from Mastery status lookup + learner progress models #642, which verifies all ten end to end.
CompetencyCriteriaGroup, CompetencyCriterion and CompetencyRuleProfile each carry a uuid external identifier alongside the internal id, 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.
The models added here are registered in .annotation_safe_list.yml (or annotated inline) as .. no_pii:, including the three models django-simple-history generates: HistoricalCompetencyCriteriaGroup, HistoricalCompetencyCriterion and HistoricalCompetencyRuleProfile. Those are real Django models and the annotation scan counts them; this is the first use of django-simple-history anywhere in src/, so nothing in this repo has hit that before. Mastery status lookup + learner progress models #642, as the last ticket to merge, carries the make pii_check 100% coverage gate for the feature as a whole.
django-simple-history (HistoricalRecords()) is applied to CompetencyCriteriaGroup, CompetencyCriterion and CompetencyRuleProfile.
django-simple-history is not applied to oel_tagging_tag, oel_tagging_taxonomy, or CompetencyTaxonomy.
No column exists on the models added here beyond those in ADR-0002 Decisions 1 through 4, the constraints, identifiers and timestamps this ticket lists, and the columns django-simple-history generates. One exception, listed again under Deletions below: the archived column that the 2026-09-01 amendment added to Decisions 2 and 4 is not added here, because [BE] Add archived field to CompetencyCriteriaGroup and CompetencyCriterion #716 owns it.
All FK relationships match the ADR definitions exactly, targets included: course_id points at openedx_catalog.CourseRun.
Deletions
The four foreign keys that carry Django's collector down the criteria tree are CASCADE: CompetencyCriteriaGroup.tag, CompetencyCriteriaGroup.parent, CompetencyCriterion.group and CompetencyCriterion.object_tag. These are what make ADR-0002 Decision 7's delete protection work, because the PROTECT that blocks a delete lives on Mastery status lookup + learner progress models #642's Student*Status foreign keys, two of which point at rows one and two levels below the tag, and Django reaches them only by walking down foreign keys marked CASCADE.
CompetencyRuleProfile.competency_taxonomy is CASCADE, not PROTECT, so a rule profile is never the reason a taxonomy delete fails. Blocking a taxonomy delete when learner data is connected to it is an application-layer requirement, not a database constraint; see the requirement above and [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613.
The other four are PROTECT: CompetencyCriterion.rule_profile, CompetencyCriteriaGroup.course, CompetencyRuleProfile.course and CompetencyRuleProfile.organization. No TODO comment is attached to any of the nine; all nine values are final.
Deleting an oel_tagging_tag succeeds and removes the whole criteria tree beneath it: every CompetencyCriteriaGroup for that tag, every descendant group, and every CompetencyCriterion under any of them.
Deleting a CompetencyCriteriaGroup at depth succeeds and removes the target, every descendant group, and every criterion under any of them.
Deleting an oel_tagging_objecttag succeeds and cascades its criteria away, leaving the parent group behind.
Deleting an oel_tagging_taxonomy succeeds and removes the criteria trees of every tag beneath it. Tag.taxonomy is already CASCADE in openedx_tagging, which is what makes the tag case above hold transitively from the taxonomy.
No delete() override, no archive-versus-delete branch, and no deletion-lock field lands in this ticket. The cascade is declared on the foreign keys and nothing here implements deletion behavior in code. [Arch] Implementation approach for competency data delete/edit guardrails #655's approved design enforces archive-versus-delete entirely at the application layer, driven by a persisted lock flag on oel_tagging_objecttag, which changes openedx_tagging as well as CBE.
CompetencyRuleProfile.archived exists as a column defaulting to false, per the ADR-0002 Decision 3 criterion above. Nothing in this ticket enforces that a profile is only archived and never deleted. [Arch] Implementation approach for competency data delete/edit guardrails #655 resolves that differently rather than deferring it: the model gets no DELETE endpoint at all, and only the single system-default row exists in MVP.
This is one of three tickets implementing #613's model layer, and the first of the three
to merge. Every criterion above is one of #613's, or one of #613's narrowed to this
ticket's models. Nothing here is additional to the parent.
Technical Details
Background and a suggested approach, not the source of truth. The Acceptance Criteria
above define what must be true when the work is done; this section exists so an
implementer does not have to rediscover the surrounding context first.
In short
The three criteria models. A competency is a Tag in a competency-enabled taxonomy.
Hanging off that tag is a tree: CompetencyCriteriaGroup rows are the internal AND/OR
nodes, and CompetencyCriterion rows are the leaves. Each leaf points at an ObjectTag,
meaning one specific piece of tagged content, and takes its pass rule either from a shared CompetencyRuleProfile or from its own inline override pair. A CompetencyRuleProfile is
a reusable set of evaluation settings scoped to exactly one of an organization, a course,
or a taxonomy, plus one system-default row scoped to none of them. The leaf keeps the
default table name because ADR-0002's heading, "CompetencyCriterion concept
(CompetencyCriteria database table)", names the domain concept the way every other
heading in that ADR does rather than instructing a rename; no model in src/ overrides db_table today, and an unprefixed CamelCase table name would risk a collision, because
openedx-core's tables share a MySQL schema with openedx-platform's. Today src/openedx_learning/applets/cbe/models.py is a single module holding only CompetencyTaxonomy; this ticket turns it into a models/ package, since the three new
models form one connected structure and want a module of their own.
Deletion, and the two facts most likely to be misread.#655 fixed all nine on_delete
values on 2026-09-02, they are final, and the criteria above say why the four CASCADE
links are load-bearing rather than a relaxation. Two consequences are easy to get wrong.
First, on_delete governs deletion of the row a foreign key points at, never the row
holding the foreign key, so CompetencyCriterion.rule_profile being PROTECT does not
block a tag delete that cascades criteria away; it only stops a rule profile from being
deleted while a criterion still references it. Second, PROTECT is evaluated on every row
Django's collector reaches, not only on the row passed to delete(), which is what makes
the transitive cases work at all. One consequence of splitting this work from #642: until #642 merges, main carries a CASCADE chain with no PROTECT at the bottom, so deleting
a tag removes the whole authored tree and nothing objects. That window is expected and
harmless, because the learner status tables do not exist yet.
Several plausible additions are excluded on purpose, worth knowing up front so they are
not added and then removed in review: no constraint tying a group's logic_operator to its
child count and no rejection of empty groups, because a group has no children at the moment
it is validated and saving a child later runs the child's clean() rather than the
parent's; no UniqueConstraint on (parent, ordering), because leaves carry no ordering
column and sibling order among them would stay undefined while looking solved; and nothing
that resolves a criterion's rule profile at read time, because ADR-0002 Decision 4 assigns
that foreign key at four named write events and stores the result. All three belong to the
authoring API, which no ticket owns yet. Relatedly, clean() is a convenience for the admin
and for tests rather than an enforcement layer, which is why this ticket's two invariants
are database check constraints instead.
django-simple-history is not a declared dependency yet. It is pinned in the
compiled requirements only as a transitive dependency of edx-organizations, and simple_history is absent from INSTALLED_APPS. Add it to requirements/base.in and
register the app in test_settings.py and projects/dev.py before HistoricalRecords()
will work.
The Grade rule payload shape is {"op": "gte", "value": 0.8, "scale": "percent"},
where op is one of gte, lte, eq and value is a fraction from 0.0 to 1.0 rather
than a number out of 100. Grade is the only rule_type supported now.
Why taxonomy_overrides_org ships although nothing reads it. It settles a tiebreak
that cannot arise yet: when a criterion could inherit its rule from an organization-scoped
profile or from a taxonomy-scoped one, this flag decides which wins. Organization-scoped
profiles do not exist. Adding the column now avoids a later migration against a table that
by then has learner data hanging off it.
The requirement referenced by the competency_taxonomy criterion above. Once a rule
profile can be scoped to a taxonomy, deleting that taxonomy must be blocked when learner
data is connected to it and must otherwise succeed, matching [Arch] Implementation approach for competency data delete/edit guardrails #655's approved behavior for
every other record. That guardrail is application-layer Python, is not designed yet, and is
not this ticket's work. This ticket only keeps the database from pre-empting the decision,
by leaving PROTECT off CompetencyRuleProfile.competency_taxonomy.
Two accepted cascade consequences, so neither reads as a defect in review. Deleting an ObjectTag outside [BE] Build endpoint for removing a Competency Criterion #674 and [BE] Build endpoint for removing a Competency Criteria Group #675 cascades its criteria away and can leave an empty parent
group behind, which is reachable only for an ungraded criterion. Deleting a Tag leaves
its ObjectTag rows with a null tag, since ObjectTag.tag is SET_NULL, which dangles
nothing because every criterion pointing at those rows is cascaded away in the same
operation. Neither cascade is silent: django-simple-history connects post_delete, so a history_type='-' row is written for every group and criterion removed.
CompetencyRuleProfile.organization needs no requirements change.edx-organizations
is already in requirements/base.in, and src/openedx_catalog/models/catalog_course.py
already imports Organization for CatalogCourse.org.
src/openedx_catalog/ARCHITECTURE.md records the catalog-to-content import direction as
undecided. .importlinter already uses the sibling form for openedx_content.applets.components | openedx_content.applets.containers.
Competency criteria models (authoring/definition layer)
User Story
As a course author, I want a competency's completion requirements stored as an ordered
AND/OR tree of criteria, in order to express a rule like "pass the final and either lab"
rather than a single flat threshold.
Acceptance Criteria
CompetencyTaxonomyhas thetaxonomy_overrides_orgboolean, defaultfalse. The model itself shipped in PR feat: provide openedx_learning djangoapp and CompetencyTaxonomy model #712 without this column.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. It is placed as an independent sibling ofopenedx_content, writtenopenedx_content | openedx_catalog, rather than as a layer of its own above or below it:layersis a strict total order, so a layer of its own would also decide the catalog-to-content direction thatsrc/openedx_catalog/ARCHITECTURE.mdrecords as undecided. [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613's wording says only "belowopenedx_learning" and needs the same clarification.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.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.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.organization_id,course_idandcompetency_taxonomy_idis non-null, with a test for each rejected two-column combination.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.competency_rule_profile_id,rule_type_overrideandrule_payload_override, with the same validation contract. Its class name isCompetencyCriterion, singular, matching ADR-0002 Decision 4, which calls one leaf a criterion. NoMeta.db_tableoverride is added, so the table isopenedx_learning_competencycriteria; see "The three criteria models" above for why. [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 still asks forMeta.db_table = "CompetencyCriteria"and needs the same correction.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.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().CompetencyCriteriaGroup(oel_tagging_tag_id, course_id)), 2 (CompetencyCriteriaGroup(parent_id)), 4 (CompetencyCriteria(oel_tagging_objecttag_id)), 5 (CompetencyCriteria(competency_criteria_group_id)) and 9 (CompetencyRuleProfile(scope_code)). Index 9 must be unique; a plain index there fails this criterion. Index 3,oel_tagging_objecttag(object_id), is already satisfied by the existingdb_index=TrueonObjectTag.object_id(src/openedx_tagging/models/base.py), so no work is required for it here. Indexes 6, 7, 8 and 10 come from Mastery status lookup + learner progress models #642, which verifies all ten end to end.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..annotation_safe_list.yml(or annotated inline) as.. no_pii:, including the three modelsdjango-simple-historygenerates:HistoricalCompetencyCriteriaGroup,HistoricalCompetencyCriterionandHistoricalCompetencyRuleProfile. Those are real Django models and the annotation scan counts them; this is the first use ofdjango-simple-historyanywhere insrc/, so nothing in this repo has hit that before. Mastery status lookup + learner progress models #642, as the last ticket to merge, carries themake pii_check100% coverage gate for the feature as a whole.django-simple-history(HistoricalRecords()) is applied toCompetencyCriteriaGroup,CompetencyCriterionandCompetencyRuleProfile.django-simple-historyis not applied tooel_tagging_tag,oel_tagging_taxonomy, orCompetencyTaxonomy.django-simple-historygenerates. One exception, listed again under Deletions below: thearchivedcolumn that the 2026-09-01 amendment added to Decisions 2 and 4 is not added here, because [BE] Add archived field to CompetencyCriteriaGroup and CompetencyCriterion #716 owns it.course_idpoints atopenedx_catalog.CourseRun.Deletions
CASCADE:CompetencyCriteriaGroup.tag,CompetencyCriteriaGroup.parent,CompetencyCriterion.groupandCompetencyCriterion.object_tag. These are what make ADR-0002 Decision 7's delete protection work, because thePROTECTthat blocks a delete lives on Mastery status lookup + learner progress models #642'sStudent*Statusforeign keys, two of which point at rows one and two levels below the tag, and Django reaches them only by walking down foreign keys markedCASCADE.CompetencyRuleProfile.competency_taxonomyisCASCADE, notPROTECT, so a rule profile is never the reason a taxonomy delete fails. Blocking a taxonomy delete when learner data is connected to it is an application-layer requirement, not a database constraint; see the requirement above and [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613.PROTECT:CompetencyCriterion.rule_profile,CompetencyCriteriaGroup.course,CompetencyRuleProfile.courseandCompetencyRuleProfile.organization. NoTODOcomment is attached to any of the nine; all nine values are final.ProtectedErrorcase belongs to Mastery status lookup + learner progress models #642, because asserting one requires aStudent*Statusrow and Mastery status lookup + learner progress models #642 is the ticket that creates those three tables. This ticket merges first, so the rows those assertions need do not exist yet.oel_tagging_tagsucceeds and removes the whole criteria tree beneath it: everyCompetencyCriteriaGroupfor that tag, every descendant group, and everyCompetencyCriterionunder any of them.CompetencyCriteriaGroupat depth succeeds and removes the target, every descendant group, and every criterion under any of them.oel_tagging_objecttagsucceeds and cascades its criteria away, leaving the parent group behind.oel_tagging_taxonomysucceeds and removes the criteria trees of every tag beneath it.Tag.taxonomyis alreadyCASCADEinopenedx_tagging, which is what makes the tag case above hold transitively from the taxonomy.delete()override, no archive-versus-delete branch, and no deletion-lock field lands in this ticket. The cascade is declared on the foreign keys and nothing here implements deletion behavior in code. [Arch] Implementation approach for competency data delete/edit guardrails #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.CompetencyRuleProfile.archivedexists as a column defaulting to false, per the ADR-0002 Decision 3 criterion above. Nothing in this ticket enforces that a profile is only archived and never deleted. [Arch] Implementation approach for competency data delete/edit guardrails #655 resolves that differently rather than deferring it: the model gets no DELETE endpoint at all, and only the single system-default row exists in MVP.archivedcolumn is added toCompetencyCriteriaGrouporCompetencyCriterion. ADR-0002 Decisions 2 and 4 now list one on each, added by the 2026-09-01 amendment for [Arch] Implementation approach for competency data delete/edit guardrails #655, but [BE] Add archived field to CompetencyCriteriaGroup and CompetencyCriterion #716 owns landing it. Read the "no column beyond ADR-0002 Decisions 1 through 4" criterion above with that carve-out.Description
This is one of three tickets implementing #613's model layer, and the first of the three
to merge. Every criterion above is one of #613's, or one of #613's narrowed to this
ticket's models. Nothing here is additional to the parent.
Technical Details
Background and a suggested approach, not the source of truth. The Acceptance Criteria
above define what must be true when the work is done; this section exists so an
implementer does not have to rediscover the surrounding context first.
In short
The three criteria models. A competency is a
Tagin a competency-enabled taxonomy.Hanging off that tag is a tree:
CompetencyCriteriaGrouprows are the internal AND/ORnodes, and
CompetencyCriterionrows are the leaves. Each leaf points at anObjectTag,meaning one specific piece of tagged content, and takes its pass rule either from a shared
CompetencyRuleProfileor from its own inline override pair. ACompetencyRuleProfileisa reusable set of evaluation settings scoped to exactly one of an organization, a course,
or a taxonomy, plus one system-default row scoped to none of them. The leaf keeps the
default table name because ADR-0002's heading, "
CompetencyCriterionconcept(
CompetencyCriteriadatabase table)", names the domain concept the way every otherheading in that ADR does rather than instructing a rename; no model in
src/overridesdb_tabletoday, and an unprefixed CamelCase table name would risk a collision, becauseopenedx-core's tables share a MySQL schema with openedx-platform's. Today
src/openedx_learning/applets/cbe/models.pyis a single module holding onlyCompetencyTaxonomy; this ticket turns it into amodels/package, since the three newmodels form one connected structure and want a module of their own.
Deletion, and the two facts most likely to be misread. #655 fixed all nine
on_deletevalues on 2026-09-02, they are final, and the criteria above say why the four
CASCADElinks are load-bearing rather than a relaxation. Two consequences are easy to get wrong.
First,
on_deletegoverns deletion of the row a foreign key points at, never the rowholding the foreign key, so
CompetencyCriterion.rule_profilebeingPROTECTdoes notblock a tag delete that cascades criteria away; it only stops a rule profile from being
deleted while a criterion still references it. Second,
PROTECTis evaluated on every rowDjango's collector reaches, not only on the row passed to
delete(), which is what makesthe transitive cases work at all. One consequence of splitting this work from #642: until
#642 merges,
maincarries aCASCADEchain with noPROTECTat the bottom, so deletinga tag removes the whole authored tree and nothing objects. That window is expected and
harmless, because the learner status tables do not exist yet.
Several plausible additions are excluded on purpose, worth knowing up front so they are
not added and then removed in review: no constraint tying a group's
logic_operatorto itschild count and no rejection of empty groups, because a group has no children at the moment
it is validated and saving a child later runs the child's
clean()rather than theparent's; no
UniqueConstrainton(parent, ordering), because leaves carry noorderingcolumn and sibling order among them would stay undefined while looking solved; and nothing
that resolves a criterion's rule profile at read time, because ADR-0002 Decision 4 assigns
that foreign key at four named write events and stores the result. All three belong to the
authoring API, which no ticket owns yet. Relatedly,
clean()is a convenience for the adminand for tests rather than an enforcement layer, which is why this ticket's two invariants
are database check constraints instead.
Implementation specifics
src/openedx_learning/migrations/. Thecbeapplet has nomigrations package of its own. CBE app foundation + CompetencyTaxonomy model #640's
0001_initialis there, so this ticket's twomigrations are
0002and0003, and Mastery status lookup + learner progress models #642 renumbers onto them.django-simple-historyis not a declared dependency yet. It is pinned in thecompiled requirements only as a transitive dependency of
edx-organizations, andsimple_historyis absent fromINSTALLED_APPS. Add it torequirements/base.inandregister the app in
test_settings.pyandprojects/dev.pybeforeHistoricalRecords()will work.
Graderule payload shape is{"op": "gte", "value": 0.8, "scale": "percent"},where
opis one ofgte,lte,eqandvalueis a fraction from 0.0 to 1.0 ratherthan a number out of 100.
Gradeis the onlyrule_typesupported now.taxonomy_overrides_orgships although nothing reads it. It settles a tiebreakthat cannot arise yet: when a criterion could inherit its rule from an organization-scoped
profile or from a taxonomy-scoped one, this flag decides which wins. Organization-scoped
profiles do not exist. Adding the column now avoids a later migration against a table that
by then has learner data hanging off it.
competency_taxonomycriterion above. Once a ruleprofile can be scoped to a taxonomy, deleting that taxonomy must be blocked when learner
data is connected to it and must otherwise succeed, matching [Arch] Implementation approach for competency data delete/edit guardrails #655's approved behavior for
every other record. That guardrail is application-layer Python, is not designed yet, and is
not this ticket's work. This ticket only keeps the database from pre-empting the decision,
by leaving
PROTECToffCompetencyRuleProfile.competency_taxonomy.ObjectTagoutside [BE] Build endpoint for removing a Competency Criterion #674 and [BE] Build endpoint for removing a Competency Criteria Group #675 cascades its criteria away and can leave an empty parentgroup behind, which is reachable only for an ungraded criterion. Deleting a
Tagleavesits
ObjectTagrows with a nulltag, sinceObjectTag.tagisSET_NULL, which danglesnothing because every criterion pointing at those rows is cascaded away in the same
operation. Neither cascade is silent:
django-simple-historyconnectspost_delete, so ahistory_type='-'row is written for every group and criterion removed.CompetencyRuleProfile.organizationneeds no requirements change.edx-organizationsis already in
requirements/base.in, andsrc/openedx_catalog/models/catalog_course.pyalready imports
OrganizationforCatalogCourse.org.which [Arch] Implementation approach for competency data delete/edit guardrails #655 governs and which lands in [BE] Build endpoint for removing a Competency Criterion #674, [BE] Build endpoint for removing a Competency Criteria Group #675, [BE] Add archived field to CompetencyCriteriaGroup and CompetencyCriterion #716, [BE] Add archived and deletion_locked fields to Tag, Taxonomy, and ObjectTag #776 and [BE] Exclude archived tagging records from read paths and block edits to them #778, with [Placeholder] Competency Criteria Deletions #799 closed
as superseded; the app skeleton, delivered by PR feat: provide openedx_learning djangoapp and CompetencyTaxonomy model #712 for CBE app foundation + CompetencyTaxonomy model #640; and any REST API or UI
work.
Files to create and modify
New files
Modified files
Context
on_deletevalues.can be developed in parallel with it but must merge first.
CompetencyTaxonomy.openedx_catalog.CourseRunalready exists in this repo.
src/openedx_catalog/ARCHITECTURE.mdrecords the catalog-to-content import direction asundecided.
.importlinteralready uses the sibling form foropenedx_content.applets.components | openedx_content.applets.containers.Open Questions
CompetencyRuleProfilerows exist, doesCompetencyCriterion.rule_profilestill want
PROTECT? Owner: whoever designs the application-layer guardrail described inTechnical Details. Not settled here; see [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613.