Skip to content

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.

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 html leaf 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 the dataPath that 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-leaf project-data.json.
  • The player’s reporting stack is present but dormant. CPLibrary.js ships cp.LMSTypes.TinCan, a cp.LMSDriverHolder with the full interaction-recorder family, CMI interaction/score state, and a public window.cpAPIInterface / cpAPIEventEmitter surface exposing slide/movie events and the cpQuizInfo* 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.”

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_asset per org points at one shared object-storage deployment; N assignments each carry their own learning_unit_id, xapi_activity_root_iri, and cmi5_au_metadata_payload.launchParameters (the dataPath), with their own package_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 = Completed and assignmentKind = required everywhere — advancing needs completion, not passing. Quiz scores land in package_progress and 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_… before 2_…) and dir slugs are poor titles — the OLX states both cleanly.
  • 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_id is sever-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-package serving side), and the one-shot importer. Everything else (cmi5 launch, LRS, package_progress projection, External Package model) is reused as-is.
  • openedx artifacts not imported: the OLX discussions_settings and done xblocks are dropped — emergent has native discussion (ADR-0029) and derives completion from cmi5.
  • Reversibility: the per-AU assignmentKind/moveOn can be promoted to graded later; the adapter and serve-proxy are the only hard-to-reverse commitments, which is why they are recorded here.