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]]).
The trigger
Section titled “The trigger”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 Level — platform | 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.
The decision
Section titled “The decision”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_assetare 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 newexternal_package_assignmentrows only. Crossing the org boundary forces a byte copy (the import service already enforcesofferingOrganizationMismatch). - 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 test —
no 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.
8. Schema additions
Section titled “8. Schema additions”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.
Build scope
Section titled “Build scope”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:
- “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). - 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/assetrows into the target org (org-scoping holds) but keeps the samestorageBucket/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. - Result is read-only:
provisioned_from_offering_id = source(which activates thecannotDuplicateReadOnlygate).authority_levelinherited.
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.
Consequences
Section titled “Consequences”- A new
offeringspine (authority_level+ lineage links).authority_leveldefaults toorganization; 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.