Skip to content

feat: add competency criteria models for CBE authoring layer - #800

Draft
jesperhodge wants to merge 4 commits into
openedx:mainfrom
jesperhodge:jesperhodge/feat--641-competency-criteria-models
Draft

feat: add competency criteria models for CBE authoring layer#800
jesperhodge wants to merge 4 commits into
openedx:mainfrom
jesperhodge:jesperhodge/feat--641-competency-criteria-models

Conversation

@jesperhodge

@jesperhodge jesperhodge commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Note

This PR was implemented by an AI agent (Claude Code), with a human directing the
work and reviewing the decisions.
Every acceptance criterion, deviation and known gap
is spelled out below. Please review it as you would any other PR, and treat the
"Deletion behavior", "Two consequences worth knowing" and "What is not verified here"
sections as the places to look hardest.

Closes #641.

What this adds

The authoring and definition half of the CBE data model, per
ADR-0002
and ADR-0003.
Three models, one column on an existing model, two migrations, and four configuration edits.
No REST endpoints, no UI, no evaluation logic.

  • CompetencyCriteriaGroup: internal AND/OR nodes of the criteria tree. Nullable self-FK
    for the parent, a tag FK for the competency, and a nullable CourseRun FK that scopes
    evaluation windowing.
  • CompetencyRuleProfile: reusable evaluation defaults, scoped to at most one of an
    organization, a course, or a taxonomy.
  • CompetencyCriterion: leaf nodes, pointing either at a rule profile or at a pair of
    per-criterion overrides.
  • CompetencyTaxonomy.taxonomy_overrides_org: the boolean ADR-0002 Decision 1 calls for,
    which PR feat: provide openedx_learning djangoapp and CompetencyTaxonomy model #712 left off. Nothing reads it yet; it settles a tiebreak that cannot arise until
    organization-scoped profiles exist. Shipping it now means adding those later is not a
    migration against a table with learner data hanging off it.

Why scope_code is a generated column

At most one profile may exist per distinct scope. A unique constraint over the three nullable
scope columns would not enforce that, because SQL never treats two NULLs as equal: two rows
both with organization_id=5 and the other two columns null would both be accepted. The
Django-idiomatic fix, UniqueConstraint(condition=Q(...)), is worse than useless here.
Django compiles it to a partial index, MySQL does not support partial indexes, so Django
emits a models.W036 warning and silently skips the constraint, while SQLite does support
them and would keep local tests green with nothing enforced. ADR-0002 Rejected Alternative 6
records this. scope_code collapses the scope into one always-non-null string so a plain
unique constraint works identically on every backend, and a test asserts no UniqueConstraint
on the model carries a condition, so the rejected alternative cannot creep back in.

Where each rule is enforced, and why

Both structural invariants are database check constraints, not clean() checks: at most one
scope column non-null, and a criterion having either a profile or both overrides, never both
and never neither. clean() is reached only through full_clean(), which ModelForm and the
admin call but which DRF's ModelSerializer, QuerySet.update() and bulk_create() do not.
Payload shape validation does live in clean(), as the issue specifies.

Scope fields are immutable after creation, because criteria store the profile id they were
assigned and never re-resolve it, so a scope change would silently re-govern every criterion
pointing at the profile. This is enforced at the instance level and covers deferred loads;
QuerySet.update() remains outside its reach, and the model docstring says so.

Deletion behavior

#655 decided this on 2026-09-02. All nine values are final and none carries a TODO.

Foreign key Target Value
CompetencyCriteriaGroup.parent itself CASCADE
CompetencyCriteriaGroup.tag oel_tagging.Tag CASCADE
CompetencyCriterion.group CompetencyCriteriaGroup CASCADE
CompetencyCriterion.object_tag oel_tagging.ObjectTag CASCADE
CompetencyCriterion.rule_profile CompetencyRuleProfile PROTECT
CompetencyCriteriaGroup.course openedx_catalog.CourseRun PROTECT
CompetencyRuleProfile.course openedx_catalog.CourseRun PROTECT
CompetencyRuleProfile.organization organizations.Organization PROTECT
CompetencyRuleProfile.competency_taxonomy CompetencyTaxonomy PROTECT

The four CASCADE values are not a relaxation of ADR-0002 Decision 7, and that is the part
most worth checking.
Deleting a tag nobody holds mastery against has to succeed, and #655's
design forbids openedx_tagging from knowing CBE exists, so the tagging-side path cannot clear
the criteria tree first. CASCADE lets the delete take the tree with it. The protection that
Decision 7 promises lives on #642's three Student*Status foreign keys, two of which sit one and
two levels below the tag, and Django reaches them only by walking down CASCADE edges. So these
four links are what carries the collector to the PROTECT that enforces the guarantee. Turn any
link in that chain to SET_NULL and a tag delete succeeds while learner statuses still exist.

parent and group are CASCADE for a separate reason: Django's collector looks up referencing
rows in the database via related_objects() rather than in the set it has already decided to
delete, so a parent and child reached in the same batch would still trip PROTECT and abort the
walk partway down.

The direction rule, since it is the likeliest objection to the table above. on_delete
governs what happens when the row a foreign key points at is deleted, and is never consulted
when the row holding it is deleted. So rule_profile staying PROTECT does not block a tag
delete that cascades criteria away; it only stops a CompetencyRuleProfile from being deleted
while a criterion references it, which is Decision 7's archive-only rule at the ORM layer.
PROTECT is evaluated on every row the collector reaches, not only the row passed to delete(),
which is what makes the transitive cases work at all.

The five PROTECT values: rule_profile because a profile is archive-only; both course fields
because PROTECT matches openedx_catalog's own convention (CourseRun.catalog_course and
CatalogCourse.org are both PROTECT) and because SET_NULL on CompetencyCriteriaGroup.course
would make a course-level group read as a root group and break #675's root-group rejection;
organization because edx-organizations exposes remove_organization(), which deactivates
rather than deletes; competency_taxonomy because a profile's scope is immutable so SET_NULL is
forbidden, and CASCADE would only move the failure one hop.

Nothing here implements deletion behavior in code: no delete() override, no archive-versus-delete
branch, no deletion-lock field. That enforcement is an openedx_tagging change under #655's design.

Two consequences worth knowing, named rather than fixed

  1. An ObjectTag deleted outside [BE] Build endpoint for removing a Competency Criterion #674/[BE] Build endpoint for removing a Competency Criteria Group #675 can leave a persisted empty group. Its criteria
    cascade away, but nothing removes the now-childless CompetencyCriteriaGroup. This is reachable
    only for ungraded criteria, since once a learner holds status the PROTECT chain blocks the
    delete outright, and the endpoints in [BE] Build endpoint for removing a Competency Criterion #674/[BE] Build endpoint for removing a Competency Criteria Group #675 archive instead of deleting. Flagging it rather
    than adding cleanup logic, which Competency criteria models (authoring/definition layer) #641 puts out of scope.
  2. Deleting a Tag leaves ObjectTag rows with a null tag. ObjectTag.tag is SET_NULL
    upstream, by ADR-0006's design so cached tag text keeps rendering. This dangles nothing here,
    because the criteria pointing at those ObjectTag rows are removed in the same operation, via
    the tag to group to criterion cascade.

The cascade is not silent for audit. django-simple-history writes a history_type='-' row
for every group and criterion a cascade removes. Verified rather than assumed: post_delete is
connected unconditionally, and registering that receiver also stops Django's collector from
fast-deleting these models, so the signal fires for cascaded rows too. test_group_tag_cascade
asserts it.

Deviations from the acceptance criteria

  1. Meta.db_table is openedx_learning_competencycriteria, not the literal
    "CompetencyCriteria". The table stays plural against a singular class name, which is what
    ADR-0002 Decision 4 asks for, but app-prefixed and lowercase like every other table in this
    repo. An unprefixed mixed-case table would be the only one of its kind here, and mixed-case
    table names behave differently between macOS and Linux MySQL. Happy to change it back if
    the reviewer would rather have the literal name.
  2. ADR indexes 2, 4 and 5 have no explicit models.Index. All three are plain foreign-key
    columns, which Django already indexes, so writing them out would create a second index on
    each and cost write throughput for nothing. test_adr_indexes_present introspects the real
    tables and asserts each ADR position is covered, so this can be checked rather than taken on
    trust. Index 1 is explicit and index 9 is the unique constraint.
  3. django-simple-history is now declared in requirements/base.in. CBE app foundation + CompetencyTaxonomy model #640 said no
    requirements work was needed because it appears in base.txt, but that appearance is
    transitive, via edx-organizations. setup.py builds install_requires from base.in, so
    importing it directly without declaring it breaks the day edx-organizations drops it. The
    pin is unchanged at 3.13.0.

What is not verified here

The MySQL criterion. #641 requires the scope_code migration to be applied against MySQL,
because a green SQLite run is not evidence for it. That has not been done locally; it rides on
this repo's CI, whose django52 tox environment runs the suite against mysql:8 via
mysql_test_settings.py. The two backends genuinely diverge: Django compiles Concat to
CONCAT_WS('', ...) on MySQL and to || on SQLite. Please confirm CI is green before
merging rather than treating the local results below as sufficient.

The import-linter gate covers only half of what these models import. Registering
openedx_catalog closes the openedx_learning to openedx_catalog dependency, which is what
#641's criterion asks for. But CompetencyRuleProfile.organization adds a second new dependency,
on organizations.Organization from edx-organizations, which becomes a new runtime requirement
of this library. That package is third party, so it is not in root_packages and the
src_layering contract does not reach it: nothing examines that dependency at all. Saying so
explicitly rather than letting lint-imports: 2 contracts kept read as complete coverage.

Half of the transitive delete criteria belong to #642. #641 pairs each cascade case with a
"raises ProtectedError when a learner status row exists beneath it" case. Those need #642's
Student*Status tables, which do not exist on this branch, and #642's own criterion says they
"can only run once #641's tables exist, so they belong in the slice that follows #641." The
cascade halves are all tested here; the protective halves are not stubbed or faked, and a comment
in the test file records why they are absent.

Out of scope

Testing

25 new test functions across test_criteria_models.py and test_criteria_deletion.py, expanding
to 41 cases through parametrization, plus one added to test_models.py. They cover the tree
shape, every rejected scope combination, the exact scope_code string for all four scope shapes,
uniqueness including the two-rows-same-org case a naive constraint would miss, both invalid
criterion states, nine invalid payload shapes against both models, history rows for these three
models and not for CompetencyTaxonomy, scope immutability including the deferred-load path, the
ADR indexes as they exist in the database, the seeded system-default row, the on_delete behavior
of all nine foreign keys, and the transitive cascade cases.

The PROTECT tests assert on ProtectedError.protected_objects, not just the exception type: a
single delete can trip several protected relationships, so a bare pytest.raises would not prove
which foreign key did the protecting. The cascade tests assert the referencing rows are actually
gone from the database, and that they existed beforehand.

Each of the four CASCADE foreign keys was mutation-tested: flipping it back to PROTECT fails
exactly the tests that depend on it and no others, so none of these tests is vacuous.

Run locally against SQLite:

581 passed, 1 skipped
mypy: Success: no issues found in 24 source files
pylint / pycodestyle / pydocstyle / isort: clean
lint-imports: 2 contracts kept, 0 broken
makemigrations --check: No changes detected

No # noqa, # pylint: disable, # type: ignore or TODO was added anywhere in this diff.

Note for anyone running make pii_check on this branch: it fails, at 70.1% coverage with two
lint conflicts. All 20 uncovered models and both conflicts are pre-existing on main and in
code this PR does not touch. The models added here are annotated and covered.

🤖 Generated with Claude Code

Implements the authoring and definition half of the CBE data model from
ADR-0002: CompetencyCriteriaGroup (internal AND/OR nodes),
CompetencyRuleProfile (reusable scoped evaluation defaults) and
CompetencyCriterion (leaf nodes). Also adds the taxonomy_overrides_org
column that PR openedx#712 left off CompetencyTaxonomy.

CompetencyRuleProfile.scope_code is a generated, never-null column with a
plain unique constraint. SQL never treats two NULLs as equal, so a unique
constraint over the three nullable scope columns would accept two rows
with the same scope, and the conditional UniqueConstraint that would
normally fix that compiles to a partial index MySQL does not support.

Both structural invariants are database check constraints rather than
clean() checks, since DRF serializers, QuerySet.update() and
bulk_create() never call full_clean(). Payload shape validation stays in
clean(), per the issue.

Every new foreign key is on_delete=PROTECT with a TODO(openedx#799) comment.
That is a fail-closed placeholder, not a per-key decision; openedx#799 sets the
real values once openedx#655 lands.

openedx_catalog joins .importlinter's root_packages and the src_layering
contract, since CompetencyCriteriaGroup.course is the first foreign key
from openedx_learning into that app. django-simple-history moves into
base.in: it was only ever a transitive dependency of edx-organizations,
and setup.py builds install_requires from base.in.

Refs openedx#641

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Sep 1, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @jesperhodge!

This repository is currently maintained by @axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

🔘 Update the status of your PR

Your PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate.


Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

jesperhodge and others added 2 commits September 2, 2026 10:45
openedx#655 closed with an approved design and openedx#799 is now closed as superseded,
so both halves of the nine repeated TODO comments were false: openedx#799 does
not own the on_delete question, and no follow-up ticket will set "the
real" per-foreign-key values.

Replaces those nine identical comments with one explanation in the module
docstring, which also records the open question openedx#655's design creates for
CompetencyCriteriaGroup.tag and CompetencyCriterion.object_tag: that
design keeps openedx_tagging ignorant of CBE and promises a plain hard
delete for a tag no learner holds mastery against, which PROTECT turns
into a ProtectedError whenever an author's criteria tree references the
tag and nobody has been graded yet.

The PROTECT values themselves are unchanged. They remain the fail-closed
default until openedx#655's reviewers settle the question.

Refs openedx#641, openedx#655

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openedx#641 requires at least one test per foreign key asserting that deleting
the referenced row matches what the field declares. All nine are PROTECT,
so all nine assert ProtectedError, and each inspects
ProtectedError.protected_objects rather than only the exception type: a
single delete can trip several protected relationships, so a bare
pytest.raises would not prove which foreign key did the protecting.

Two cases needed isolating to avoid passing for the wrong reason.
CatalogCourse.org is itself PROTECT, so the organization test uses an
organization with no catalog course attached. Tag.taxonomy is CASCADE, so
the competency_taxonomy test omits the tag and group fixtures.

A tenth test pins the open openedx#655 question in executable form: deleting a
CompetencyTaxonomy whose tag carries a criteria tree raises
ProtectedError today, though that design promises the delete succeeds
when no learner status exists. It is the test that has to change if the
reviewers move CompetencyCriteriaGroup.tag to CASCADE, and says so.

Refs openedx#641

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openedx#655 decided the on_delete question on 2026-09-02, so the four foreign
keys between definition tables become CASCADE:
CompetencyCriteriaGroup.parent, CompetencyCriteriaGroup.tag,
CompetencyCriterion.group and CompetencyCriterion.object_tag. The other
five stay PROTECT and are now final.

Deleting a Tag nobody holds mastery against has to succeed, and openedx#655's
design forbids openedx_tagging from knowing CBE exists, so the
tagging-side path cannot clear the criteria tree first. CASCADE lets the
delete take the tree with it. parent and group need it too, because
Django's collector looks up referencing rows in the database rather than
in the set it has already collected, so a parent and child reached in one
batch would trip PROTECT and abort the walk partway down.

This does not weaken ADR-0002 Decision 7. The four CASCADE links are what
carries the collector down to the PROTECT that enforces it, on openedx#642's
Student*Status foreign keys one and two levels below the tag, which Django
reaches only by walking CASCADE edges.

Migration 0002 is edited in place rather than gaining an AlterField, since
it is unmerged.

The delete tests are reworked accordingly and extended with the transitive
cases: a tag delete cascading a whole tree, a group delete at depth taking
its descendants and their criteria, and a taxonomy delete reaching through
tag to group to criterion. The matching "raises ProtectedError when a
learner status exists" halves need openedx#642's tables and belong to that slice,
which a comment in the test file records. One cascade test also asserts
django-simple-history writes a history_type='-' row per removed row, so
the cascade is not silent for audit.

Refs openedx#641, openedx#655

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

Competency criteria models (authoring/definition layer)

3 participants