The "English Planet" Captivate/OLX corpus lands as cmi5 AUs on the External Package runtime — not repackaged, not fanned-out
Status: accepted (2026-06-20) — design-only this session; build not yet started.
Builds on ADR-0006 (delivery-profile / offline regimes), ADR-0007 (Session is runtime
telemetry), ADR-0009 (imported-standard boundary), ADR-0014 (erasure is anonymization),
ADR-0019 (server-anchored delivery), ADR-0022 (Common Cartridge decompose-and-land — the
long tail and the static-package/web-content External Package runtime), and ADR-0026
(opaque sandboxed-iframe External-Package delivery). Pre-launch, no legacy
([[feedback_no_legacy_pre_launch]]); upstream bugs are fixed upstream, never worked around
in-app. The External Package model (packages/db/src/schema/external-package.ts), the
self-hosted LRS (lrs Postgres schema), and the cmi5 launch flow
(apps/api/src/features/launch-session/) already exist — this ADR is about which of
them this corpus uses, not new machinery for them.
The trigger
Section titled “The trigger”One of emergent’s first real uses is a corpus of “Frankenstein” Adobe Captivate exports, hacked to be served as openedx OLX courses (“English Planet”): three Levels (Level 1: 6 lessons × 4 units; Level 2: 6 × 4; Level 3: 1 × 2 — partial), ~649 activity leaves total. The question posed three options: repackage into a black-box format (SCORM / H5P / CC 1.4), fan a Common Cartridge out into the native model, or integrate bespoke. A secondary question: since the first wave of customers all get the same read-only offerings, can a cheap import stand in for a not-yet-built “duplicate offering” feature?
What the artifacts actually are (and the correction that reframed everything)
Section titled “What the artifacts actually are (and the correction that reframed everything)”- The OLX is a pure shell. Every
htmlleaf is a single<iframe>into one shared Captivate player, parameterised by?mediaPath=…&dataPath=…. The OLX carries the only human structure: Level → Lesson → Unit → Activity, with display names and explicit ordering, plus thedataPaththat pairs each leaf to its payload. - The converted tree is one shared player (
CPLibrary.js, ~1.4 MB), 349 MB of shared content-hashed media (media/), and a per-leafproject-data.json. - The player’s reporting stack is present but dormant.
CPLibrary.jsshipscp.LMSTypes.TinCan, acp.LMSDriverHolderwith the full interaction-recorder family, CMI interaction/score state, and a publicwindow.cpAPIInterface/cpAPIEventEmittersurface exposing slide/movie events and thecpQuizInfo*variable family. Nothing reports today only because the bootstrap configures paths but no LMS. So basic cmi5/xAPI is reachable with a small adapter, not a rebuild. This correction is load-bearing: it moves the corpus from “inert web-content” to “latent cmi5 AUs.”
The decision
Section titled “The decision”1. Delivery: cmi5 External Packages on the static-package runtime
Section titled “1. Delivery: cmi5 External Packages on the static-package runtime”Each activity lands as a cmi5 External Package launch, served as-is through the existing
static-package runtime in a sandboxed iframe. Not SCORM/H5P repackaging (wraps an
already-opaque widget in a second opaque wrapper, breaks shared-media dedup, and the widget
emits no SCORM anyway). Not CC 1.4 fan-out (fan-out decomposes into the native model; these
leaves are opaque runtimes with nothing to decompose — they would land as web-content
regardless, so the cartridge step buys nothing).
2. Adapter: an observer we own, in-iframe, emitting our own cmi5 statements
Section titled “2. Adapter: an observer we own, in-iframe, emitting our own cmi5 statements”A small adapter, baked into the deployed index.html at import time, subscribes to the
documented cpAPIInterface events + cpQuizInfo* variables and emits cmi5-conformant
statements to emergent’s own LRS as a standard cmi5 AU (fetch handshake → initialized /
passed / failed / completed / terminated). We own the verbs, activity IRIs, and
conformance. Rejected: driving Captivate’s built-in TinCan driver — it couples us to Adobe’s
(old, possibly non-conformant) xAPI profile and to reverse-engineering how the stripped build
wants its LMS config, for less control.
The adapter↔player coupling carries no version risk: we freeze the exact bytes we import, so the player never changes underneath the adapter.
3. Grain: one AU per activity leaf; the heavy bytes stored once
Section titled “3. Grain: one AU per activity leaf; the heavy bytes stored once”One cmi5 AU per activity leaf (matches the source openedx, which tracked a done completion
xblock per vertical). The schema already fits the dedup: one external_package + version
package_assetper org points at one shared object-storage deployment; N assignments each carry their ownlearning_unit_id,xapi_activity_root_iri, andcmi5_au_metadata_payload.launchParameters(thedataPath), with their ownpackage_progress. Media and player are stored once; only thin per-AU rows multiply.
4. Structure: three Offerings, one per Level
Section titled “4. Structure: three Offerings, one per Level”The Level is the enrollment/cohort unit (a learner is “in Level 1”). Lesson → Unit → Activity
become the Learning Unit tree (grouping for the branches, activity for the AU-bearing
leaf). The provider’s labels (Level/Lesson/Unit/Activity) are carried as titles only — no
new structural kinds, consistent with CONTEXT.md’s one-vocabulary rule.
5. Tenancy: per-org copy via re-running the importer — so no duplicate-offering feature yet
Section titled “5. Tenancy: per-org copy via re-running the importer — so no duplicate-offering feature yet”The import-service enforces that an External Package and the Offering it is assigned to are the
same org (offeringOrganizationMismatch), so a single platform-owned package shared
by reference across customer orgs is not supported today. Instead a platform_admin
re-runs the idempotent importer per org: a new package + 3 Offerings + ~649 assignments,
all pointing at the same shared object-storage deployment. Because this content is
platform-authored and read-only, the per-org copies never diverge — so re-import is the
copy mechanism, and emergent does not need a generic “duplicate offering” feature for it.
That feature remains genuinely needed, but for a different case: teacher-owned courses, whose
duplicate-and-draft copies must diverge and become the teacher’s. See Provisioned
Offering in CONTEXT.md.
6. Serving: a launch-token serve-proxy with HTTP Range
Section titled “6. Serving: a launch-token serve-proxy with HTTP Range”The player concatenates relative media URLs at runtime (mediaPath + <hash>.webm), which rules
out per-object signed URLs. The bytes are paid IP, so a public bucket is out. An emergent
route/edge serves /<deployment>/* by validating the cmi5 launch session and streaming from
object storage with Range support (for .webm seeking); mediaPath/dataPath point at
this gated base. This is the static-package serving side, which does not exist yet
(PackageStorageAdapter has only put/delete) and is net-new work.
7. Completion/scoring: completion-gated, scores captured but ungraded (v1)
Section titled “7. Completion/scoring: completion-gated, scores captured but ungraded (v1)”Uniform adapter: emit completed on movie-end for every AU; additionally emit passed/failed
- scaled score when the player reports quiz results.
moveOn = CompletedandassignmentKind = requiredeverywhere — advancing needs completion, not passing. Quiz scores land inpackage_progressand are visible, but are not weighted into a formal grade in v1 (promotable later). The adapter therefore never has to classify which of the 649 JSONs are quizzes — it discovers it at runtime.
8. Importer: a bespoke one-shot, OLX-driven CLI
Section titled “8. Importer: a bespoke one-shot, OLX-driven CLI”A throwaway tools/scripts/*.ts run by a platform_admin, not a generic OLX importer.
Phase 1 uploads converted/ to object storage once (content-addressed, idempotent). Phase 2
(per org, idempotent on stable slugs derived from OLX url_names) parses the OLX for
structure/titles/ordering, pairs each leaf’s dataPath to its project-data.json (strip the
/public/ca/ prefix → storage root), and writes the package + 3 Offerings + Learning Unit tree
- AU assignments. The converted tree alone cannot drive this: filesystem order is unreliable
(
10_…before2_…) and dir slugs are poor titles — the OLX states both cleanly.
Consequences
Section titled “Consequences”- Delivered online-only. cmi5 needs connectivity and External Packages are not on the offline sync rail (ADR-0006/0026); acceptable for a 665 MB media corpus.
- Erasure rides existing machinery:
package_progress.person_idissever-class (aggregates survive unlinked, ADR-0014) and LRS actors are account-based. - New build surface: the observer adapter, the Range-capable launch-token serve-proxy
(the
static-packageserving side), and the one-shot importer. Everything else (cmi5 launch, LRS,package_progressprojection, External Package model) is reused as-is. - openedx artifacts not imported: the OLX
discussions_settingsanddonexblocks are dropped — emergent has native discussion (ADR-0029) and derives completion from cmi5. - Reversibility: the per-AU
assignmentKind/moveOncan be promoted to graded later; the adapter and serve-proxy are the only hard-to-reverse commitments, which is why they are recorded here.