Skip to content

feat: enforce one root criteria group per competency in the database - #900

Merged
ormsbee merged 1 commit into
openedx:mainfrom
alezconsultant:alezconsultant/872-criteria-group-root-scope-code
Oct 30, 2026
Merged

ormsbee merged 1 commit into
openedx:mainfrom
alezconsultant:alezconsultant/872-criteria-group-root-scope-code

Conversation

@alezconsultant

@alezconsultant alezconsultant commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

Makes "one root CompetencyCriteriaGroup per competency" a database rule. Until now it relied
on the select_for_update() lock that associate_competency_criterion() takes. MySQL has no
partial unique indexes, and the (tag, course, parent) constraint from #847 treats a root's null
course and parent as distinct, so two roots for one competency would both save. This is the
follow-up for the #847 review
(#847 (comment)).

CompetencyCriteriaGroup gets a derived scope_code column, following the pattern
CompetencyRuleProfile uses (ADR-0002 Decision 3). It is set on root groups only, and an
unconditional unique constraint on it rejects a second root. A group's tag, parent and course
are immutable after creation, so the code is fixed when the row is created. After this PR, one root
per competency and one course-level group per competency and course are both database rules;
immutability stays an application check in save().

Changes

  • src/openedx_learning/applets/cbe/models/criteria.py: adds scope_code
    (CharField(max_length=255, null=True, editable=False)), _compute_scope_code(),
    _check_tree_position_immutable() and save(). save() rejects a changed tag, parent or
    course on an existing row with a ValidationError, then sets the code to "tag:<tag_id>"
    for a root and null for every other group. Adds a unique constraint on scope_code, and a
    check constraint that only a root may have a code. history excludes the column. The class
    docstring names the writes that bypass save() and so bypass the rule.
  • src/openedx_learning/migrations/0017_criteria_group_scope_code.py (new): checks for
    duplicate roots, adds the column, backfills existing roots, and adds both constraints (see
    Migration).
  • src/openedx_learning/applets/cbe/api.py: comments only. The Tag row lock comment says what
    the lock still protects (see Behavior), and _create_leaf_group()'s docstring says its two
    get_or_create() lookups need no lock of their own: the unique constraints hold one root per
    tag and one course-level group per tag and course, and get_or_create() recovers from a
    concurrent insert.
  • docs/openedx_learning/decisions/0002-competency-criteria-model.rst: ADR-0002 amendment (see
    ADR).
  • tests/openedx_learning/applets/cbe/test_criteria_group.py,
    test_criteria_group_deletion.py and tests/openedx_learning/test_migrations.py: see Tests.

Behavior

  • Archived roots keep their code. scope_code does not depend on archived, so an
    archived root keeps blocking a second root, and [BE] Enforce archived in Competency Criteria creation #717 reactivates it in place instead of
    creating a new one. This differs from CompetencyRuleProfile.scope_code, which is nulled
    while archived so that a replacement profile can be created. The reason is how the two tables
    are read. A criterion points straight at its profile, so an archived profile beside a live
    one costs nothing. A learner has exactly one StudentCompetencyStatus row per competency,
    derived from the competency's root, so a live root beside archived ones would force every
    rollup and every read to decide which root counts first. Keeping exactly one root row per
    competency, archived or not, keeps that question from existing.
  • Tag, parent and course are immutable. ADR-0002 already made course_id immutable after
    creation; this PR adds oel_tagging_tag_id and parent_id and enforces all three in save(),
    the way CompetencyRuleProfile enforces its scope columns. A group is created in its place in
    its competency's tree and stays there, so a root's code never has to change after creation.
    Writes that skip save() are not checked, the same as for the profile.
  • Course-level groups get no code. The existing (tag, course, parent) constraint already
    allows only one per competency and course, live or archived, which matches [BE] Enforce archived in Competency Criteria creation #717 rejecting an
    archived course-level group instead of replacing it.
  • The check constraint runs one way. It says "a group with a code is a root", not "every
    root has a code". On MySQL, which cannot defer constraint checks, Django's delete collector
    sets the nullable parent and course of rows it is about to cascade-delete to null first,
    so the stricter rule would reject deleting a root or a course run. I confirmed this by
    temporarily applying the stricter rule: both deletion tests failed on MySQL with error 3819.
  • The Tag lock stays. It now only serializes _reject_duplicate_criterion(), which is a
    check-then-act on both paths: a request without group_id always creates a fresh leaf, and
    with group_id the CompetencyCriterion table has no unique constraint on
    (object_tag, group). The same content may legitimately sit in two explicitly named groups,
    so no unique index can express "this content is already associated with this competency".
    Removing the lock would need either a product change (one criterion per content and
    competency) or extra constraints that still leave a rare race, so it is out of scope here.
  • Archiving needs no extra step. Archiving through save() or a bulk
    .update(archived=True) leaves the code in place, so [BE] Delete Competency Criterion #674, [BE] Build endpoint for removing a Competency Criteria Group #675 and [BE] Enforce archived in Competency Criteria creation #717 work as written.

Migration

0017 runs a read-only check first. If any competency has more than one root, live or
archived, it raises DuplicateRootCriteriaGroupsError, naming each tag and its root group ids,
before changing the schema. The check comes first because MySQL commits each schema change
immediately: a failure after the column was added would leave it behind with the migration
unrecorded, and a plain rerun would then fail. The migration then adds the column, backfills
it for existing roots with one UPDATE, and adds both constraints. It reverses cleanly.

CompetencyCriteriaGroup has shipped since v1.5.0, and openedx-platform master pins v1.15.1
with openedx_learning installed, so deployments tracking master may already have rows. No
named release (Ulmo, Verawood) installs the app yet.

ADR

ADR-0002 is amended, a sentence or two per point and with no implementation detail:

  • Decision 2 gains scope_code, marks oel_tagging_tag_id and parent_id immutable after
    creation alongside course_id, and states that for MVP archiving a group or criterion is permanent, with the
    archived root as the one reversible exception, because archiving it for good would remove
    the ability to ever associate criteria with that competency again. Archiving a course-level
    group blocks that course for that competency, and archiving a leaf closes only that leaf.
  • Decision 2's course_id and retrieval-scope descriptions are corrected to match the code:
    only the course-level group carries course_id, and a read always includes the root group
    plus the selected course-scoped subtrees.
  • Decision 3 contrasts the two scope_code columns, Decision 7 states the MVP archive rule for
    groups and criteria and points oel_tagging_objecttag at ADR-0003, and Rejected Alternative
    7 records the replace-the-root design. Decision 5's index list gains the scope_code unique
    constraint and the (tag, course, parent) one from Alezconsultant/665 criterion endpoint on 846 #847. The size estimate now bounds each
    course-scoped subtree, and the changelog entry lists the immutability and MVP archiving changes.

Tests

There is one test per scenario in #872's acceptance criteria. Also, the tests cover:

  • reactivating an archived root: it keeps its code and keeps blocking a second root
  • archiving a root through a bulk .update()
  • changing a saved group's tag, parent or course through save() being rejected, and the
    row staying where it was
  • .update(parent=...) on a root being rejected by the check constraint
  • deleting a root with descendants, and a course run whose course-level group and leaves sit
    under a root that survives the deletion (these exercise MySQL's delete collector)
  • the migration's duplicate check (it leaves no column behind, and the test cleans up even
    when an assertion fails), its backfill over live and archived roots, course-level groups and
    leaves, that the live model's save() produces the same code as the backfill (a second root
    created through the live model after migrating is rejected; a comment notes this INSERT will
    need replacing once a later migration adds a column to the table), and its reverse (the
    column is gone)

test_group_logic_operator_accepts_and_or_and_null_regardless_of_child_count used to create two
roots for one tag; its second tree now uses a second tag, and its assertions are unchanged.

Manual Testing

Manual testing via the Django shell in the devstack, starting with migration 0017 unapplied
(./manage.py cms migrate openedx_learning 0016_add_competency_course_run), then
docker exec -it -e PYTHONPATH=/mnt/openedx-core/src tutor_dev-cms-1 ./manage.py cms shell:

import uuid
from django.core.management import call_command
from django.db import connection
from openedx_catalog.models import CatalogCourse, CourseRun
from openedx_learning.models import CompetencyCriteriaGroup, CompetencyTaxonomy
from openedx_tagging.models import Tag
from organizations.models import Organization

table = CompetencyCriteriaGroup._meta.db_table
cursor = connection.cursor()
taxonomy = CompetencyTaxonomy.objects.create(name="Test872", export_id="test872")
tag = Tag.objects.create(taxonomy=taxonomy, value="Writing")

# At 0016, plant two roots for one competency, one live and one archived. Raw SQL, because the model already expects scope_code.
cursor.execute(f"INSERT INTO {table} (uuid, oel_tagging_tag_id, name, ordering, archived) VALUES (%s, %s, '', 0, 0), (%s, %s, '', 0, 1)", [uuid.uuid4().hex, tag.pk, uuid.uuid4().hex, tag.pk])
call_command("migrate", "openedx_learning")  # DuplicateRootCriteriaGroupsError: ... tag <id> (root group ids: <a>, <b>).
"scope_code" in [c.name for c in connection.introspection.get_table_description(cursor, table)]  # False: the check runs before any schema change

# Remove the archived duplicate and migrate again.
cursor.execute(f"DELETE FROM {table} WHERE oel_tagging_tag_id = %s AND archived = 1", [tag.pk])
call_command("migrate", "openedx_learning")  # Applying openedx_learning.0017_criteria_group_scope_code... OK
root = CompetencyCriteriaGroup.objects.get(tag=tag, parent=None)
root.scope_code  # 'tag:<id>', backfilled

# A second root is rejected, next to a live root and next to an archived one.
CompetencyCriteriaGroup.objects.create(tag=tag)  # IntegrityError: (1062, "Duplicate entry 'tag:<id>' ...")
CompetencyCriteriaGroup.objects.filter(pk=root.pk).update(archived=True)  # 1
CompetencyCriteriaGroup.objects.create(tag=tag)  # IntegrityError: (1062, "Duplicate entry 'tag:<id>' ...")

# Reactivating the archived root keeps it the only root.
root.refresh_from_db(); root.archived = False; root.save()
CompetencyCriteriaGroup.objects.filter(tag=tag, parent=None).count()  # 1

# One course-level group per course, live or archived. Several courses and leaves are fine, and none of them gets a code.
org, _ = Organization.objects.get_or_create(short_name="Test872", defaults={"name": "Test872"})
catalog_course = CatalogCourse.objects.create(org=org, course_code="C872")
run_x = CourseRun.objects.create(catalog_course=catalog_course, run_code="X")
run_y = CourseRun.objects.create(catalog_course=catalog_course, run_code="Y")
course_x = CompetencyCriteriaGroup.objects.create(tag=tag, course=run_x, parent=root, archived=True)
course_y = CompetencyCriteriaGroup.objects.create(tag=tag, course=run_y, parent=root)
leaves = [CompetencyCriteriaGroup.objects.create(tag=tag, parent=course_y, archived=i == 2) for i in range(3)]
CompetencyCriteriaGroup.objects.create(tag=tag, course=run_x, parent=root)  # IntegrityError: (1062, "Duplicate entry ..."), the archived course-level group still blocks
course_y.scope_code, leaves[0].scope_code  # (None, None)

# A saved group cannot move. Through save() it is a ValidationError; behind save() the check constraint catches a root given a parent.
course_y.parent = None; course_y.save()  # ValidationError: A CompetencyCriteriaGroup's tag, parent and course cannot be changed after creation.
course_y.refresh_from_db()
other_root = CompetencyCriteriaGroup.objects.create(tag=Tag.objects.create(taxonomy=taxonomy, value="Reading"))
CompetencyCriteriaGroup.objects.filter(pk=other_root.pk).update(parent=root)  # IntegrityError: (3819, "Check constraint 'oel_cbe_criteria_group_scope_code_root_only_check' is violated.")

# Deleting a course run removes its course-level group and leaves. Deleting the root removes the rest and frees its place.
run_y.delete()
CompetencyCriteriaGroup.objects.filter(pk__in=[course_y.pk] + [leaf.pk for leaf in leaves]).exists()  # False
root.delete()
CompetencyCriteriaGroup.objects.filter(tag=tag).exists()  # False
CompetencyCriteriaGroup.objects.create(tag=tag).scope_code  # 'tag:<id>'

# Clean up.
taxonomy.delete(); run_x.delete(); catalog_course.delete()

Verification

  • pytest tests/openedx_learning --no-cov: 403 passed on SQLite. The three changed test
    modules (30 tests) also pass on MySQL 8.
  • pylint, pycodestyle, isort --check-only, mypy: clean on every changed file, apart
    from the unused-argument and class-docstring warnings every data migration here carries.
  • lint-imports: all contracts kept.
  • makemigrations --check: no changes.
  • doc8 and the Sphinx HTML build (-W): no errors or warnings.

Related to #872

🤖 Generated with Claude Code

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 23, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @alezconsultant!

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.

Details
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.

@mgwozdz-unicon mgwozdz-unicon left a comment

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.

Claude Fable and I reviewed this together. The sections below say which of us found what.

Found by both of us

1. The reactivation test cannot fail. test_criteria_group.py:221 creates the root already archived, so nothing ever blocks a second root and count() == 1 holds with or without scope_code. Claude ran it with _compute_scope_code() returning None always and it still passed. Please create the root live, archive it with save(update_fields=["archived"]), reactivate it with save(), then assert the code is still set and that create(tag=tag) raises IntegrityError.

2. "Archiving is permanent" says more than was decided. The new Decision 7 bullet (0002-competency-criteria-model.rst:313) sits in a list whose archive rule also covers oel_tagging_tag, oel_tagging_taxonomy, CompetencyTaxonomy and oel_tagging_objecttag, so as written it declares ObjectTag archiving permanent too, and #779 un-archives an ObjectTag in place when its tag is re-applied. #717 only decided that archiving below the root is permanent for MVP. Please scope this bullet and the Decision 2 item 8 sentences (line 90) to groups and criteria and to the MVP. On line 90, also say why the root is the exception, something like "because archiving it for good would remove the ability to ever associate criteria with that competency again", and end with "More unarchival capabilities may be added after MVP."

3. Drop the ,course: segment. The course segment of "tag:X,course:" is always empty. The ticket suggested it to mirror the profile's format and leave room for a course-level code if archived course groups ever become replaceable, but nothing uses that room and the ADR now calls archiving below the root permanent for MVP. "tag:X" says what the column means, and now is the time: once a deployment tracking master has run 0016, changing the format is a data migration. (Why not a unique on tag_id directly: every group in a tree carries the same tag, so that would allow one group per competency in total, and "unique where parent is null" needs a partial index MySQL lacks.)

4. Overlapping tests. Several new tests assert the same behavior through slightly different setups:

  1. test_criteria_group.py:168 (test_archiving_a_course_level_group_keeps_it_blocking_a_second_one): the "archived course-level group still blocks" assertion is already the archived case of the parametrized test at :141, and "save() leaves a course-level group's code null" is already asserted at :109. I would delete it.
  2. test_criteria_group.py:206 (test_bulk_archiving_a_root_keeps_it_blocking_a_second_root): the course-level half (bulk-archive the course-level group, then expect a second one to be rejected) is the same :141 case again. Keep the root half, drop the course-level half.
  3. test_criteria_group_deletion.py:126 (test_deleting_a_course_run_deletes_its_course_level_groups_and_their_leaves): the existing test at :53 already covers a course run cascading to its group, and :20 already covers a child whose parent is nulled on MySQL before the delete. The only new assertion is that the root keeps its code, which is trivial. Please add two leaves under the group in the test at :53 instead, which is what the ticket's "with leaf groups under them" scenario asks for, and drop :126. The test at :107 is different: "a new root can be created afterwards" is a real new assertion, so keep that one, but its docstring can drop the MySQL paragraph, which the docstring at :60-63 already has.
  4. test_criteria_group.py:239 (test_course_level_groups_and_leaves_are_not_limited): "not limited" is not something a count of six can prove, and the name says nothing about what is asserted. What the test actually guards is that the new constraints do not touch non-root groups, and :109 already builds a root, a course-level group and a leaf for the same purpose. Please fold the second course and the extra leaves into :109 and delete :239.

Found by Mary

5. ADR line 86. "Only the subtree's topmost group, the course-level group, carries it" is correct but takes a moment to parse, because the topmost group of the subtree is the second level of the tree. "Only the course-level group carries it" is enough.

6. ADR lines 113 and 114. "The root group and any groups under it outside a course-scoped subtree" describes a shape nothing creates: every group under the root is a course-level group or sits under one. "A read always includes the root group, plus any selected subset of course-scoped groups" says it without the edge case, and the size bullet on line 114 can say "For the root group" rather than "For the root scope".

7. A group's parent should be immutable, and three tests move it. test_criteria_group.py:182, :194 and :230 all reparent a group (two move a root under another tree's root, one promotes a child to root). That should not be possible at all, but nothing says so: the ADR marks only course_id as immutable after creation, and neither the model nor the API blocks a parent change (nothing enforces the course_id rule either). I had not written this down before. Please add "Immutable after creation" to parent_id in Decision 2 the way course_id has it. For enforcement, either add a save() check here that rejects a changed parent_id or course_id on an existing row, with one test, or file a follow-up ticket for it and link it from the PR description, whichever you prefer. For the tests: :230 should stay, because it is the only test that fails when the check constraint is removed, but please rename it to say what it proves (a root cannot be given a parent behind save()'s back). :182 and :194 test the update_fields branch of save() through a reparent that the design forbids. If you keep that branch as a safety net, keep one of the two for it and have :194 assert the code was null before the promotion. Otherwise drop both.

Found by Claude

8. Nothing ties the migration's backfill format to the model's. test_migrations.py:36 compares the backfilled value to a hand-written literal, while the migration builds the code with Concat/Cast and the model with an f-string. When Claude changed the model's format, seven model tests failed and no migration test did. After apply_tested_migration(MIGRATE_TO), asserting with the real CompetencyCriteriaGroup that create(tag_id=tag_a.pk) raises IntegrityError would catch drift in either direction. This matters more with the format change in item 3.

9. The duplicate-roots migration test's cleanup runs only on success. test_migrations.py:113, archived_root.delete(), is skipped if any assertion before it fails. The migrator teardown then migrates to the latest state and raises DuplicateRootCriteriaGroupsError a second time as an ERROR (Claude inverted the column assertion to check). A try/finally around the assertions fixes it.

10. The reverse-migration test asserts less than its docstring says. test_migrations.py:74-85 says the reverse "drops scope_code and keeps existing groups", but the only assertion is that the row still exists. The column-introspection block from the test below can be reused to assert the column is gone.

11. Suggestions.

  1. criteria.py:41-53: the class docstring says nothing about scope_code. Writes that skip save() leave a root with a null code (bulk_create(), bulk_create_with_history(), which #694 plans to use, QuerySet.update(parent=None), loaddata, raw SQL), and QuerySet.update(tag=...) leaves a stale one. Claude confirmed both: two bulk_create roots for one tag both insert, and after update(tag=other) the old tag is blocked while the new tag can get a second root. Once two roots exist, get_or_create(tag=tag, parent=None) at api.py:150 raises MultipleObjectsReturned for every request on that competency. No supported path does any of this today and #872 accepted the bulk_create gap, but one docstring sentence naming the writes that bypass the rule would put the contract where the #694 implementer will see it. I would not add a bulk_create override.
  2. criteria.py:148-149: "MySQL's delete collector nulls a root's cascading parent keys" names the wrong component and the wrong rows. It is Django's collector, and it nulls each descendant's parent (and a deleted course run's groups' course), not anything on the root. The docstring at test_criteria_group_deletion.py:60-63 has it right. Something like: "On MySQL, which cannot defer constraint checks, Django's delete collector sets each descendant's nullable parent to NULL before deleting it, so 'every root has a scope_code' would reject those rows mid-delete."
  3. api.py:145: _create_leaf_group()'s docstring still says "Must run inside a transaction holding the tag row lock." With both uniqueness rules in the database, get_or_create() recovers from a concurrent insert on its own, so only _reject_duplicate_criterion() still needs the lock, which is what your new comment at 348-349 says. #872 said to leave the function alone, so your call, but the sentence reads as stale now.
  4. api.py:348-349: the "since a request without group_id always creates a fresh leaf" reason covers only one path. With group_id supplied, _reject_duplicate_criterion() is also a check-then-act that only the lock serializes, because CompetencyCriterion has no unique constraint on (object_tag, group). Saying the lock serializes the duplicate check on both paths would stop someone removing it for the explicit-group case.

12. Nit. PR description: the first Description sentence is missing its verb ("the select_for_update() lock that associate_competency_criterion() takes"), and openedx-platform master pins openedx-core 1.15.1 now, not 1.12.0.

@mgwozdz-unicon mgwozdz-unicon left a comment

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.

Claude Fable and I went through 8ab8e59 against the first round. Every item is addressed as asked, and we re-verified each one against the code. Thanks for the thorough pass.

One thing to fix before I approve, then a few small things.

1. The immutability check should cover tag too. _check_tree_position_immutable() compares parent_id and course_id, but scope_code is built from tag_id. Claude confirmed by probe that root.tag = other_tag; root.save() succeeds and rewrites the code to the new tag, after which a second root for the old tag is created without error while the old root's descendants still carry the old tag. With save(update_fields=["tag"]) the new tag is stored and the old code stays, since the branch that forced scope_code into update_fields is gone. So the save() docstring's "parent never changes, so the code is fixed at creation" is not true as written. #760 already says a changed oel_tagging_tag_id is rejected, so the intent is settled. Please add tag_id to the same values_list and comparison (no extra query), a "tag" case to test_a_saved_group_cannot_change_its_parent_or_course, widen the message, and add "Immutable after creation." to item 3 of ADR-0002 Decision 2.

2. The course-run deletion test builds the shape the ADR forbids. test_deleting_a_course_run_also_deletes_its_course_scoped_criteria_groups creates CompetencyCriteriaGroup(tag=tag, course=course_run) with no parent, so its "course-level group" is a root carrying a course, and the fold I asked for hung two leaves under it. That line predates this PR, so this is partly on my earlier request. Could you create the root first, put the course-level group and the two leaves under it, and keep one assertion that the root survives the course run's deletion? That restores the one assertion the deleted test had that nothing else covers.

3. _create_leaf_group()'s "needs no lock" sentence. get_or_create() does retry the get after an IntegrityError, but on MySQL under REPEATABLE READ a transaction that already took a consistent snapshot may not see the other transaction's row, so the retry misses and the IntegrityError re-raises. Today the Tag row lock in associate_competency_criterion() serializes callers so this cannot happen. Consider "the caller's tag lock serializes these lookups and the unique constraints are the backstop", or keep it and accept the edge case for a future lock-free caller. We did not run this on MySQL.

4. ADR small things. The 2026-10-22 changelog entry could mention parent immutability and the MVP scoping of permanent archiving. Line 114 now reads "For the root group, expected size is small (realistically <=500 nodes)", and after the rewording a root group is one row, so the estimate no longer bounds anything; drop it or say what it bounds.

5. Migration test, your call. The backfill test now inserts through the live model at migration state 0016, which is what I asked for. Claude points out that any later migration adding a column to this table would break that INSERT for an unrelated reason. A no-write alternative keeps the tie: compare the backfilled value with CompetencyCriteriaGroup(tag_id=tag_a.pk)._compute_scope_code(). A comment on the current version is also fine.

6. PR description. "After this PR, every rule about the stored criteria tree is enforced by the database" overstates it: parent and course immutability is an application check in save(), and nothing enforces "a root has course_id null". I am not asking for enforcement of the latter. A check constraint would fail root deletion on MySQL for the same reason your scope_code check is one-way (the collector nulls parent on the course-level groups while their course is still set), and no API path creates that shape. Please just soften the sentence. Also: the first sentence is still missing its verb, openedx-platform master pins 1.15.1 (not 1.12.0), Changes still describes the removed update_fields branch and the "tag:<tag_id>,course:" format, Tests still lists the two removed update_fields tests, the count is 278 now, and _check_tree_position_immutable() is not mentioned.

@alezconsultant

Copy link
Copy Markdown
Contributor Author

@mgwozdz-unicon

  1. The test creates the root live, archives it with save(update_fields=["archived"]), reactivates it with save(), then asserts the code is still set, create(tag=tag) raises IntegrityError, and the root count is 1.

  2. The Decision 7 bullet now reads "For MVP, archiving a CompetencyCriteriaGroup or a CompetencyCriteria row is permanent, with one exception", and a second sentence points oel_tagging_objecttag at ADR-0003 Decision 3, so [BE] Branch tag association deletes between archiving and hard delete #779 is not contradicted. The Decision 2 item 8 sentences are scoped the same way, say the root is the exception because archiving it for good would remove the ability to ever associate criteria with that competency again, and end with "More unarchival capabilities may be added after MVP."

  3. Fixed. The code is tag:<tag_id> in _compute_scope_code(), the migration's Concat, both help texts, ten test literals and the PR description.

  4. test_archiving_a_course_level_group_keeps_it_blocking_a_second_one is deleted.

  5. test_bulk_archiving_a_root_keeps_it_blocking_a_second_root keeps only the root half.

  6. The course-run deletion test now creates two leaves under its group and asserts all three rows are gone. test_deleting_a_course_run_deletes_its_course_level_groups_and_their_leaves is deleted, and the docstring of test_deleting_a_root_with_descendants_frees_its_place cut to one sentence.

  7. The second course run and the three leaves, one archived, moved into test_a_root_group_gets_a_scope_code_and_other_groups_do_not.
    test_course_level_groups_and_leaves_are_not_limited is deleted.

  8. ADR line 86 now says: "Only the course-level group carries it".

  9. ADR lines 113 and 114 now are: "A read always includes the root group, plus any selected subset of course root group" in the size bullet.

  10. A changed parent_id or course_id on an existing row raises ValidationError, with one parametrized test, and ADR Decision 2 marks parent_id immutable. The update_fields branch and its two tests are gone, since parent can no longer change; test is renamed test_a_root_cannot_be_given_a_parent_behind_save.

  11. After migrating, the backfill test creates a root for tag_a through the real model and expects IntegrityError.

  12. Add try/finally to test.

  13. It asserts scope_code is not among the table's columns after reversing. The introspection is a small _column_names() helper shared with the test in item 9.

  14. The class docstring has one paragraph naming bulk_create(), QuerySet.update(), loaddata and raw SQL as the writes that leave a root without a code or with a stale one. No
    bulk_create override.

  15. Comment says that on MySQL, which cannot defer constraint checks, Django's delete collector sets each descendant's nullable parent to NULL before deleting it.

  16. Comment says the root and course-level lookups need no lock bh uniqueness rules and get_or_create() recovers from a concurrent insert.

  17. Comment says the lock only serializes _reject_duplicate_criterion(), which is check-then-act on both paths: a fresh leaf without group_id, and no unique constraint on
    (object_tag, group) with group_id.

  18. PR description fixed

@mgwozdz-unicon mgwozdz-unicon left a comment

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.

Thank you!

@mphilbrick211 mphilbrick211 moved this from Needs Triage to In Eng Review in Contributions Oct 27, 2026

@ormsbee ormsbee left a comment

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.

@mgwozdz-unicon asked on #872 whether I had concerns about keeping scope_code on an archived root. I don't. Each learner has one StudentCompetencyStatus row per competency, derived from one root, and I'd rather the database hold exactly one root row than have every rollup and read filter for the live one. Thank you for writing that down in the ADR.

I have an open question on always creating a scope_code. What's left is the rebase, which isn't quite mechanical: #875 merged this morning and took migration number 0016 and rewrote the lock comment this PR also rewrites, and two of #681's tests reparent a group through save(), which this PR now rejects. Details inline.

Thank you.


Claude drafted the first pass of this review and did a trial merge of this branch onto main (9247ee4) in a scratch worktree: the conflicts were the migration number and the api.py comment, and after renumbering the migration and reworking the two #681 tests as described inline, pytest tests/openedx_learning passed 403 tests on SQLite and makemigrations --check reported no changes.

dependencies = [
('oel_tagging', '0022_tag_external_id_not_null'),
('openedx_catalog', '0002_pathways'),
('openedx_learning', '0015_criteria_group_unique_constraints'),

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.

This needs to become 0017_criteria_group_scope_code, with this dependency pointed at 0016_add_competency_course_run, since #875 merged this morning and took 0016. Please renumber rather than makemigrations --merge: the two migrations don't touch the same table, so a merge migration would only add noise. MIGRATE_FROM/MIGRATE_TO, the import_module() path, and the "at state 0016" comment in tests/openedx_learning/test_migrations.py need the same change.

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Comment on lines +349 to +351
# The database enforces one root per tag; this lock only serializes _reject_duplicate_criterion(). That check
# is check-then-act on both paths: without group_id a request always creates a fresh leaf, and with group_id
# CompetencyCriterion has no unique constraint on (object_tag, group), so no index catches a concurrent copy.

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.

This conflicts with the comment #875 put here. When you resolve it, please keep that PR's first sentence ("Lock order is always the tag, then the course run, so two creates can't deadlock.") above yours, since it's the rule that keeps the CompetencyCourseRun lock on the next lines from deadlocking against a concurrent create, and your rewrite still holds underneath it. Something like:

        # Lock order is always the tag, then the course run, so two creates can't deadlock.
        # The database enforces one root per tag; the tag lock only serializes _reject_duplicate_criterion(). That
        # check is check-then-act on both paths: without group_id a request always creates a fresh leaf, and with
        # group_id CompetencyCriterion has no unique constraint on (object_tag, group), so no index catches a
        # concurrent copy.

Comment on lines +162 to +174
def _check_tree_position_immutable(self) -> None:
"""Raise ValidationError if tag, parent or course no longer matches what is persisted for this row."""
if self.pk is None:
return
# Queried rather than compared against a value cached at load time, so a deferred load or
# a refresh_from_db() cannot bypass the check.
persisted = (
CompetencyCriteriaGroup.objects.filter(pk=self.pk).values_list("tag_id", "parent_id", "course_id").first()
)
if persisted is not None and (self.tag_id, self.parent_id, self.course_id) != persisted:
raise ValidationError(
_("A CompetencyCriteriaGroup's tag, parent and course cannot be changed after creation.")
)

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.

Two of #681's tests reparent a group through save() and fail against this check once the branch is on main: test_get_competency_criteria_tree_keeps_a_group_moved_under_a_later_created_parent in test_api.py and test_tree_response_never_references_a_group_it_left_out in test_views.py. Please rework them as part of the rebase. The first one guards the tree walk against a parent_id that points at a larger id, which is now only reachable behind save(), so building that shape with .filter(pk=group_b.pk).update(parent=group_c) keeps the test honest about what it defends against (the check constraint only forbids giving a root a parent). The second one just needs its kept leaf under a second course run's course-level group instead of being moved under the root. Claude made both changes on a trial merge and the suite passed, so neither needs more than that.

Likewise, the comment above the walk in get_competency_criteria_tree() ("ordering by parent_id does not guarantee a parent comes before its child once groups can be moved") now contradicts ADR-0002 and this method. The walk is still worth keeping, since nothing stops a QuerySet.update() from producing that shape, but please reword it so the next reader doesn't take it as a plan to make groups movable.

FWIW, I'm fine with parent being immutable: #760 already rejects a parent change at the API, relaxing this later is a one-line change, and tightening it after rows have moved around would not be.

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Comment on lines +145 to +146
The caller's tag lock serializes the root and course-level lookups; the unique constraints on
one root per tag and one course-level group per tag and course are the backstop.

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.

The version of this sentence from 8ab8e59 was the right one, and I'd like it back: the root and course-level lookups don't need the lock. We run MySQL under READ COMMITTED (Django's default for it), so the get() that get_or_create() retries after an IntegrityError sees the other transaction's committed row; the miss described in the last round only happens under REPEATABLE READ, which we don't use. As written, this reads as a dependency on the tag lock, and the point of this PR is that group integrity no longer has one.

Suggested change
The caller's tag lock serializes the root and course-level lookups; the unique constraints on
one root per tag and one course-level group per tag and course are the backstop.
The root and course-level lookups need no lock of their own: the unique constraints hold one
root per tag and one course-level group per tag and course, and ``get_or_create()`` recovers
from a concurrent insert. The caller's tag lock is for ``_reject_duplicate_criterion()``.

condition=Q(scope_code__isnull=True) | Q(parent__isnull=True),
name="oel_cbe_criteria_group_scope_code_root_only_check",
violation_error_message=_("Only a root CompetencyCriteriaGroup may have a scope_code."),
),

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.

Question: Is this more trouble than it's worth? I think I get the reasoning for this, in that the scope_code is only strictly necessary when one of these values is null, and that we're essentially making a sort of composite constraint where the UniqueConstraint on scope_code is only there to cover what the other UniqueConstraint on (tag, course, parent) can't. Still, it feels fragile, and I'd feel more comfortable if scope_code always existed (i.e. was not nullable).

I realize this uses more space, and that it may complicate the migration a bit (create nullable field, backfill, make field no longer nullable). But it would reduce the code complexity and guard against things like bulk insertion later bypassing our constraints and causing data corruption.

I can be argued out of this one. Please let me know if there's something I'm not seeing here.

@alezconsultant
alezconsultant force-pushed the alezconsultant/872-criteria-group-root-scope-code branch from 80c7956 to b4eb963 Compare October 29, 2026 16:49
@alezconsultant

Copy link
Copy Markdown
Contributor Author

@ormsbee
I fixed and resolved the conflicts you pointed out in your comments.
As for question which you raise.
I'd like to keep it nullable, mostly because non-null forces a value onto every non-root row and the unique constraint still covers them. Leaves under one course-level group share tag, parent, and a null course, so the only distinct input is the record's uuid. At that point the column is a second row key: the constraint enforces nothing for non-roots, and we add 255-char value to every leaf, one per criterion.

In my opinion it also doesn't simplify much. The check constraint can no longer be a null test and would have to become a prefix test on the string, and the migration gains a full backfill plus an AlterField.

On bypasses: non-null does make a bulk_create() that omits the field fail. But it's the only case it closes. A QuerySet.update(parent=None) can still turn a group into a second root under either design, and the current check constraint already catches the reverse move. The check that would close both, "a root must have a code", doesn't work here. When a root is deleted, Django first sets every child's parent to NULL and only then deletes them, so during that step the children look like roots without a code and the check would fail. A generated column breaks on the same step, because those children would compute the root's code and collide with it.

It also keeps the same reading as CompetencyRuleProfile.scope_code. If a bulk-write path for groups ever appears, tightening then is the same backfill-and-alter it would be now.

@ormsbee ormsbee left a comment

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.

All four of yesterday's items are fixed, and this now merges cleanly onto main with #760 in it.

On making scope_code non-nullable:

The check that would close both, "a root must have a code", doesn't work here. When a root is deleted, Django first sets every child's parent to NULL and only then deletes them, so during that step the children look like roots without a code and the check would fail.

Okay, that's the piece I was missing. The point of always having a code was that a root could never be without one, and the cascade step rules that check out on MySQL under either design. What's left is the bulk_create() case, and paying for that with a second row key on every leaf isn't worth it. Keep it nullable. (FWIW, Claude also pointed out that non-null wouldn't even make that case fail cleanly: a CharField with no default gets '' when null=False, so the first omitted code would insert and only the second would trip the unique constraint.)

One more thing before squashing: the PR description still calls the migration 0016, and its Changes section says the _create_leaf_group() docstring claims the tag lock serializes its lookups, which is now the opposite of what it says. Please update it, since that's what the squash commit will carry. Then please squash and I'll merge. Thank you.


Claude drafted the first pass of this review and did a trial merge of this branch onto main (d201c97) in a scratch worktree: it merged cleanly, pytest tests/openedx_learning passed 472 tests on SQLite, and makemigrations --check reported no changes.

Comment on lines +111 to +114
# Recomputed in save(); set for roots only, archived or not, so an archived root keeps blocking a second one.
scope_code = models.CharField(
max_length=255,
null=True,

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.

Nit (optional): Please consider adding line here on why this stays null below the root (a leaf has no natural key to put here, and the one-way check below means non-null wouldn't buy a stronger constraint anyway) would save the next person from asking my question again.

Add CompetencyCriteriaGroup.scope_code, set in save() to "tag:<tag_id>" for a root and
null for every other group, with a unique constraint on it. Until now one root per
competency relied on the Tag row lock that associate_competency_criterion() takes: MySQL
has no partial unique indexes, and the (tag, course, parent) constraint treats a root's
null course and parent as distinct, so two roots for one competency would both save. The
lock stays, but it now only serializes the duplicate-criterion check.

A group's tag, parent and course are immutable after creation; save() rejects a change to
any of them with a ValidationError, so a root's code never has to change. An archived root
keeps its code and is reactivated rather than replaced: each learner has one
StudentCompetencyStatus row per competency, derived from one root, so a live root beside
archived ones would force every rollup and read to pick one. A check constraint allows a
code only on a root. It runs one way, "a coded row is a root", because MySQL cannot defer
constraint checks and Django's delete collector nulls parent and course before cascading.
Below the root the column stays null: a leaf has no natural key to put there, and the
one-way check means non-null would not make the rule stronger.

Migration 0017 raises DuplicateRootCriteriaGroupsError on existing duplicate roots before
any schema change, since MySQL commits each schema change immediately, then adds the
column, backfills roots, and adds both constraints. ADR-0002 is amended: Decision 2 gains
scope_code and the immutable columns, Decision 3 contrasts the two scope_code columns,
Decision 7 states the MVP archive rule, and Rejected Alternative 7 records the
replace-the-root design.

Related to openedx#872

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@alezconsultant
alezconsultant force-pushed the alezconsultant/872-criteria-group-root-scope-code branch from b4eb963 to ec32430 Compare October 29, 2026 21:34
@alezconsultant

Copy link
Copy Markdown
Contributor Author

@ormsbee All done.

@ormsbee
ormsbee merged commit d6e6810 into openedx:main Oct 30, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In Eng Review to Done in Contributions Oct 30, 2026
ormsbee pushed a commit to openedx/openedx-platform that referenced this pull request Oct 30, 2026
Regenerated via `make upgrade-package package=openedx-core`. Picks up
openedx/openedx-core#900 (one root criteria group per competency,
enforced in the database by migration openedx_learning 0017,
openedx/openedx-core#872), first released in 1.19.0, and
openedx/openedx-core#911 (`course_keys` scoping on the criteria-groups
read endpoint, openedx/openedx-core#682), released in 1.20.0, so the
Competency-Based Education (CBE) work can go to QA.

Related to openedx/openedx-core#872 and openedx/openedx-core#682

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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: Done

Development

Successfully merging this pull request may close these issues.

4 participants