Skip to content

docs: add ADR for non-nullable tag external_id#658

Merged
ormsbee merged 3 commits into
openedx:mainfrom
mgwozdz-unicon:cbe-non-nullable-external-id-adr
Jul 16, 2026
Merged

docs: add ADR for non-nullable tag external_id#658
ormsbee merged 3 commits into
openedx:mainfrom
mgwozdz-unicon:cbe-non-nullable-external-id-adr

Conversation

@mgwozdz-unicon

Copy link
Copy Markdown
Contributor

Why

CBE needs every tag to have an external_id so it can be associated with objects as part of competency criteria. Non-CBE taxonomies are expected to want a mandatory, institution-assigned identifier too, particularly once there's a need to convert an existing regular taxonomy into a Competency Taxonomy.

Summary

  • Proposes making Tag.external_id NOT NULL at the database level, for every taxonomy.
  • Builds on openedx/openedx-core's ADR 0010 (mutable external_id): backfills every existing NULL row with a value derived from the tag's value (first ~7 characters, with a deterministic numeric-counter fallback on collision), then adds the constraint in the same migration.
  • New tags created without an external_id (REST API, import, UI) get one auto-generated the same way if not provided, rather than the request being rejected; existing integrations that omit external_id today don't need to change.
  • Institutions can replace an auto-generated value with their own through ADR 0010's rename pathway.
  • Not treated as a breaking API change or routed through the DEPR process: the request side stays optional, and the response side only narrows from nullable to always-present.

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

Copy link
Copy Markdown

Thanks for the pull request, @mgwozdz-unicon!

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.

@bradenmacdonald bradenmacdonald 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.

Generally this makes sense to me.

I'm just wondering about the algorithm for deriving the default external_id. I think I would prefer just using value directly or using the uppercase version of value (perhaps with spaces replaced with underscores) as the default for backfilling (with a number added if needed, but it almost never will be since value is also unique per taxonomy and most taxonomies either have external IDs for all values or none).

The reason is that I expect we'll be displaying these values in the UI, and in the taxonomies I've looked at there are often a lot of tags that start with similar prefixes. So I think things like ADMINISTRATIVE_FUNCTIONS, ADMINISTRATION, ADMINISTRATIVE_SOFTWARE are better external_ids than Adminis, Adminis2, Adminis3, and will be less confusing to users when they're seen.

@jesperhodge jesperhodge 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.

Looks great to me, just have a look at my comment about the 7-character cutoff.

Today, ``external_id`` is nullable specifically to work around its own
``unique_together(taxonomy, external_id)`` constraint: NULL is the only value Postgres,
MySQL, and SQLite all treat as exempt from a uniqueness constraint, so it's the
mechanism that lets multiple tags in the same taxonomy have "no external_id" at once.

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. For clarification: this just means that if we don't have nullable external_id, then every external_id in the same taxonomy must be unique and we can't use a workaround like a string to avoid external_id if it's not available, correct?
And this is does not pose a problem for us because the "value" field already is unique per taxonomy, so just backfilling to external_id will have external_id be unique.

Make ``Tag.external_id`` ``NOT NULL`` at the database level, for every taxonomy, in one
migration.

- **Backfill for existing rows.** A ``RunPython`` step generates a value for every tag

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. Sounds fine, but curious why not two migrations - a data migration for backfill followed by a migration with this constraint?

backfill-then-constrain shape as the ``depth``/``lineage`` migration
(``0020_tag_depth_and_lineage.py``): populate real values first, then add the
constraint in the same migration once every row satisfies it.
- **Backfill algorithm.** Take the first ~7 characters of the tag's ``value`` (matching

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.

Why the first 7 characters, not all of the value?
Taking the first 7 characters seems to create unnecessary collisions because we have skills like
"data" and "data science" and such that share the same initial characters. And having external ids like "data1" and "data2" for "data" and "data science" could be misleading.
Values are already used as unique labels / identifiers. Wouldn't copying the whole value string (if necessary adding some affixes) be cleaner? Is there any drawback to it?

Set external_id to the tag's existing value instead of a fixed-length
prefix, per review feedback. Since (taxonomy, value) already carries
the same unique constraint being added to external_id, this is
guaranteed collision-free with no fallback logic needed.
@mgwozdz-unicon

Copy link
Copy Markdown
Contributor Author

@bradenmacdonald Agreed, and it turns out to simplify things really nicely. Because value already has its own unique_together(taxonomy, value) constraint (case-insensitive, matching external_id's), backfilling external_id directly from value is guaranteed collision-free, so no counter or fallback logic is needed at all. I kept your uppercase/underscore suggestion in as an example of a UI-legibility transform an implementation could apply, but didn't mandate a specific format in the ADR itself.

@jesperhodge, addressed your three comments:

  1. Confirmed, and added a sentence to the Context section: value already carries the same per-taxonomy, case-insensitive uniqueness constraint as the one being added to external_id, so backfilling from it can't introduce a new collision. No workaround needed.
  2. Going to leave this as one migration rather than splitting it. The existing 0020_tag_depth_and_lineage.py migration already established backfill-then-constrain-in-one-migration as the pattern here, so this follows precedent. Splitting it would also leave a backfilled-but-not-yet-constrained state that a downstream instance could get stuck in if its migration run is interrupted or the two land out of order across a release, one migration avoids that.
  3. Resolved by the same change as Braden's comment: backfill now uses the full value rather than a fixed-length prefix, so there's no truncation to create the collisions you flagged (e.g. "data" vs. "data science").

tag count.
- **Backfill algorithm.** Set ``external_id`` to the tag's existing ``value``. Since
``(taxonomy, value)`` is already a unique, case-insensitive constraint matching
``external_id``'s own, this is guaranteed unique with no collision handling needed.

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.

It is possible I think for some weird taxonomy to exist where only some tags have external IDs specified and one of those existing external IDs is the same as the value of a different tag that doesn't yet have an external ID. This should be an extremely rare case, maybe never encountered at all, but it is possible, and if it happened, then some sort of conflict handling (append a number etc.) would be required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I committed an update to address this.

@mphilbrick211 mphilbrick211 added the FC Relates to an Axim Funded Contribution project label Jul 13, 2026
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Ready for Review in Contributions Jul 13, 2026
@ormsbee
ormsbee merged commit 002187c into openedx:main Jul 16, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from Ready for Review to Done in Contributions Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

FC Relates to an Axim Funded Contribution project open-source-contribution PR author is not from Axim or 2U

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants