Skip to content

Offering duplication is a *forking copy* of the authored aggregate; provisioning is a *tracking copy* — one deep-copy engine, two semantics

Status: accepted (2026-06-21) — increment 1 (same-org duplicate-and-draft) AND increment 2 (tracking-copy / read-only provisioning) both built this session; promote and cross-org forking remain specified design-only. See “Increment 2” below for the refinements the tracking copy made to this ADR.

Builds on ADR-0022 (Common Cartridge decompose-and-land — the import service whose unitByResourceRef remap is the seed of this ADR’s id-map), ADR-0032 §5 (which flagged the duplicate-and-draft need but built nothing — “re-import is the copy mechanism” for the platform-authored English-Planet corpus, contrasted with teacher-owned offerings that “still need” a duplicate feature), ADR-0033 (the Common Cartridge producer — the first concrete consumer of this feature, but not the reason for it), ADR-0014 (erasure is anonymization — defines the person-linked tables a copy must never carry), ADR-0018 (CASE durable competency nodes — the one reference a copy preserves rather than remaps), ADR-0023/0024 (the QTI item-asset pipeline — the offering-scoped bytes a copy must clone), ADR-0012 (the PCI catalog + OneRoster catalog resources), and ADR-0006 (the delivery profile carried on assessments). Pre-launch, no legacy ([[feedback_no_legacy_pre_launch]]); migrations are regenerated per the runbook, never hand-patched, and the DB apply is Anton’s ([[feedback_db_migrations_anton_domain]]). Work lands on develop ([[feedback_develop_branch_no_new_branches]]).

A teacher (or author) must be able to take an existing Offering and produce an independent, editable copy — for a new term, a variant section, a sandbox to experiment in, or the immediate case: a curated source to export as a Common Cartridge. This is an obligatory pre-launch capability and it is not CC-specific; the CC producer (ADR-0033) just happens to be its first consumer. ADR-0032 §5 already conceded the feature is “genuinely needed … for teacher-owned courses, whose duplicate-and-draft copies must diverge and become the teacher’s.” This ADR designs it.

The core distinction: forking copy vs tracking copy

Section titled “The core distinction: forking copy vs tracking copy”

Every copy of an Offering’s authored content is one of two kinds. Collapsing them is the mistake this ADR exists to prevent.

  • A forking copy yields an independent, divergent, editable Offering owned by the target Authority Level. It never shares mutable state with its source. This is the one engine behind duplicate-and-draft (a teacher/admin copies within a scope), promote (an admin copies up an Authority Level), and editable provisioning (a higher scope hands a lower one an editable starting point). These differ only in (who triggers, whose ownership the copy lands under, same- or cross-org) — so editable provisioning is not a separate mechanism, it is a forking copy with an admin trigger and a target owner.
  • A tracking copy yields a read-only, lockstep Offering that is replaced wholesale when its source changes and may never be duplicated downstream — i.e. a Provisioned Offering (CONTEXT.md). It carries a durable link to its source so re-provision can find-and-replace. This supersedes ADR-0032 §5’s CLI re-import when built.

Both run on the same deep-copy + id-remap engine; only the result semantics (divergent-and-owned vs read-only-and-linked) and the guardrails differ.

Authority Level and the sentinel platform org

Section titled “Authority Level and the sentinel platform org”

Offerings carry an Authority Levelplatform | organization | teacher — naming the scope that owns the Offering and its resources. Higher scopes provision content down; an admin may promote content up. A platform Offering lives in a sentinel “customer-0” Organization rather than via a nullable offering.organization_id — this avoids a NOT NULL migration on a heavily-referenced column and, more importantly, makes platform→org provisioning reuse the exact cross-org copy path (rebind-to-target) instead of a bespoke platform-scoped one.

A copy’s Authority Level is inherited from its source and raised only by promote. The column defaults to organization (the conservative shared-content level); precise per-creator assignment (teacher-created → teacher, platform-admin-in-sentinel → platform) is wired together with its consumers — promote, provision, and level-aware visibility — which are deferred. Until then nothing distinguishes the levels behaviourally, so a duplicate simply inherits the source’s stored level.

1. The copy-set is the full offering-scoped aggregate, not the exporter’s read-set

Section titled “1. The copy-set is the full offering-scoped aggregate, not the exporter’s read-set”

A duplicate deep-copies every row keyed by offering_id plus its offering-scoped children — not the CC exporter’s enumeration, which is a lossy content inventory (it skips drafts, item assets, PCI modules, release rules, and all non-CC-representable fields). Copied: offering, learning_unit (+ unit_release_rule, learning_unit_external_link), assessment_definition (incl. draft), assessment_item + all assessment_item_version (payload + qti_source_xml), assessment_item_asset, assessment_pci_module, the QTI authoring sub-entities (stimulus/pnp/feedback), discussion (prompt only), competency_alignment, the external-package assignments, and the offering’s vocabulary/period/catalog/container junctions.

2. Never copy person-linked or delivery state — permanently, not “later”

Section titled “2. Never copy person-linked or delivery state — permanently, not “later””

Excluded for all time (the ADR-0014 sever/person tables): memberships, attempts, scores, gradebook entries, item/practice sessions, interaction events, discussion posts, group sets/groups/ memberships, launch sessions, submission artifacts, package progress, credential awards, feedback entries. A duplicate is content with nobody in it and no record of anyone having done anything. The one person field that travels — created_by_person_id — is stamped to the triggering principal, not carried from the source author.

3. Storage bytes split by scoping — there is no single rule

Section titled “3. Storage bytes split by scoping — there is no single rule”
  • assessment_item_asset: always copy the bytes. The key is {offeringId}/{uuid} and serving is RLS-gated by offering (ADR-0023/0024) — the key is the authz boundary, so referencing the source would serve another offering’s prefix. Copy to {newOfferingId}/…; content-addressing ((offering, sha256)) dedups within the copy.
  • assessment_pci_module: copy the row. Its source is a Postgres column, so it falls out free; item references are symbolic (module="<moduleId>", resolved per-offering) so they need no rewrite.
  • External packages: reference same-org, copy cross-org. external_package/package_asset are org-scoped and immutable, and share-by-reference is already first-class (ADR-0032 §3: N assignments → 1 package). The package’s lifecycle is the org’s, not the offering’s, so referencing is safe against source-offering deletion. A same-org duplicate mints new external_package_assignment rows only. Crossing the org boundary forces a byte copy (the import service already enforces offeringOrganizationMismatch).
  • The full sever-from-the-org artifact is export → import, which already copies every byte. Duplication deliberately does not re-implement it.

4. One id-remap engine: global id-map, two passes, three reference classes, leak-guard

Section titled “4. One id-remap engine: global id-map, two passes, three reference classes, leak-guard”

Generalise import’s unitByResourceRef (keyed by cartridge identifier) into a single global Map<oldUuid, newUuid> (UUIDs are globally unique). Insert copied rows in dependency order minting new ids, then a second pass rewrites every reference. References fall into three classes:

Class Examples Rule
Internal aggregate parent_unit_id, child learning_unit_id, assessment→unit, item→version, test_structure.itemVersionId (jsonb), asset URLs …/qti-item-assets/{id} (in qti_source_xml/payload), competency_alignment.target_id, construct_id Remap
Durable / platform-scoped competency_alignment.competency_node_id (CASE durable node, ADR-0018) Preserve
Org-scoped reference data grade_category_id, score_scale_id, learning-period / education-level / subject junctions, learning_container_id Preserve same-org; rebind-or-drop cross-org

The two blob landmines are test_structure’s embedded itemVersionId and the asset-serving URLs baked into qti_source_xml/payload (PCI refs are symbolic and exempt). A leak-guard testno source-aggregate UUID may appear in any new row or blob, except the deliberate duplicated_from_offering_id lineage pointer — is the executable form of the “without leaking an old id” requirement and the regression that catches a forgotten embedded ref.

5. Authz: mutable source; teacher or admin; cross-org is platform-admin only

Section titled “5. Authz: mutable source; teacher or admin; cross-org is platform-admin only”

The source must be mutable — a read-only (tracking-copy / Provisioned) Offering cannot be duplicate-and-drafted; it propagates only by re-provision. (Read-only is derived from provisioned_from_offering_id IS NOT NULL, not a separate flag.) The trigger is the source’s teacher (offering membership role teacher) or an org/platform admin — broader than the export’s admin-only gate, because duplication stays inside the org and CONTEXT.md defines duplicate-and-draft as a teacher capability. This adds the one app-layer “managing member of offering” check (mirroring the existing RLS teacher_manage_or_admin). Cross-org duplication is platform-admin only.

6. Narrowing is out of scope — duplication stays format-agnostic

Section titled “6. Narrowing is out of scope — duplication stays format-agnostic”

“Narrow to CC 1.3” is a lossy transformation defined by a target export format’s fidelity ceiling, the opposite of a faithful copy, and there is no CC 1.3 emitter today anyway. It belongs to the CC producer (ADR-0033, a later session) as a degrade-to-webcontent + degradation-report export profile. The duplicate is merely the recommended workspace for human curation (duplicate → curate → export). A “duplicate + auto-degrade + export” macro is rejected: it throws away the human control that is the entire point of the editable-curate path.

7. Surface & execution: teacher-portal, synchronous, single transaction

Section titled “7. Surface & execution: teacher-portal, synchronous, single transaction”

Duplicate lives next to the Export button on the teacher-portal offering surface. v1 runs synchronously (no background-job runner exists; the cartridge import sets the precedent for a whole-offering synchronous operation) inside a single transaction so a mid-copy failure rolls back to nothing — never a half-built offering. Item-asset byte copies happen first; being content-addressed and idempotent, any orphan from a rolled-back txn is harmless and GC-able. A light, durable offering_duplication record (pending → ready/failed + a copy summary) gives audit, failure diagnosis, and the ready-made seam for future background execution. The default title copies the source verbatim with the smallest free integer suffix (Algebra I (2)) — language-neutral, deferring the offering human-language question entirely. The result is always a draft.

offering.authority_level (platform | organization | teacher), offering.duplicated_from_offering_id (nullable provenance uuid, indexed for “all duplicates of X”), reserved offering.provisioned_from_offering_id (nullable provenance uuid — tracking lineage + read-only derivation), and an offering_duplication operation-record table. Lineage is modelled as a provenance uuid rather than a self-FK — it is provenance, never a key (as the cartridge import keeps CC ids), so it survives the source’s deletion with no cascade and avoids self-reference type-circularity on the heavily-referenced offering sync table.

Built this increment: same-org duplicate-and-draft of a mutable Offering — the deep-copy engine, the three-class remap + leak-guard, item-asset/PCI copy + external-package reference, teacher/admin authz, the schema additions, the synchronous single-transaction service + record, and the teacher-portal entry point.

Specified, deferred: promote (forking copy up an Authority Level) and cross-org forking duplication (platform-admin; byte-copy external packages; rebind-or-drop org-scoped references).

Increment 2 — tracking copy / read-only provisioning (built)

Section titled “Increment 2 — tracking copy / read-only provisioning (built)”

The tracking copy is now built (apps/api/src/features/offering-provisioning/, provisionOffering(sourceOfferingId, targetOrganizationId), POST /workflow/offerings/:offeringId/provision, platform-admin only). It is a separate engine from forking — it shares the UUID-substitution rewrite but differs in three load-bearing ways, which refine this ADR:

  1. “Replace wholesale” → upsert-in-place with deterministic ids. A Provisioned Offering has members generating delivery state (memberships, attempts, sessions, package_progress, all keyed by offering/unit/assignment id). A literal delete-and-recreate would destroy that on every content update. So target ids are deterministic (sha256(sourceRowId : targetOrg)), making re-provision an idempotent upsert that keeps stable ids — learner state is never disturbed. Only content is upserted; person/delivery tables are never touched. v1 caveat: source-side deletions are not yet pruned (add/update only); a future archive-diff pass handles removals without destroying learner history (CONTEXT.md no-destruction).
  2. External-package bytes: copy the rows, share the bytes (overrides §3). §3’s “cross-org = copy bytes” holds for a teacher’s org-owned content needing independence. Platform-provisioned read-only content is the opposite — deliberately shared (English Planet’s “media stored once”, 665 MB). So the tracking copy copies the external_package/version/asset rows into the target org (org-scoping holds) but keeps the same storageBucket/storageKey (bytes shared). Item assets still copy their bytes (the offering-scoped key is the serving authz boundary). Cross-org org-scoped refs (grade category, score scale, periods, container, level/subject junctions) are dropped.
  3. Result is read-only: provisioned_from_offering_id = source (which activates the cannotDuplicateReadOnly gate). authority_level inherited.

English Planet rewired (ADR-0032 §5 superseded): the one-shot importer now imports once into the sentinel “customer-0” org as the editable master (provision-master), and each customer org gets a read-only tracking copy linked to that master (provision --org <id>, deterministic + idempotent, shared media). The importer stays the bespoke one-shot ADR-0032 §8 prescribes; the generic provisionOffering engine + API route serve all other platform content.

  • A new offering spine (authority_level + lineage links). authority_level defaults to organization; precise per-creator assignment lands with its consumers (promote/provision). Existing offerings need no backfill (there are none, pre-launch).
  • The sentinel platform org becomes a real Organization the platform authors into; platform→org provisioning is then “just” a cross-org copy, no platform-scoped code path.
  • The id-map + leak-guard become reusable across all three forking operations and the tracking copy, so promote/cross-org/provision inherit the correctness machinery rather than re-deriving it.
  • Reference, not copy, for same-org external packages couples a duplicate to an org-admin hard-deleting the package itself (not the offering) — accepted, as the retire-don’t-delete version model is the intended guard, and within an org admins already outrank teachers.
  • Item-asset byte copy is the only new object-storage write path v1 introduces; the large, immutable external packages are never cloned within an org.
  • A very large offering could approach the gateway timeout on a synchronous copy — the same risk the cartridge import already lives with; true-background execution is the named upgrade path, which cross-org byte copies will force regardless.