Skip to content

Common Cartridge 1.4 import — implementation plan

Companion to ADR-0022. Executable, sliced build plan. Two repos move: spec-shaped decompose + QTI 1.2→3 bridge are born upstream in conform-ed ([[feedback_internal_lib_release_all_packages]]); the domain mapping, schema, endpoint, and UI are emergent. Each slice is independently shippable + validated; feature branch per slice, rebased to land ([[feedback_rebase_never_merge]]).

  • conform-ed ships the full CC 1.4 contracts (CommonCartridgeV1_4): manifest (imscp raw/profile + thin/k12 variants), LOM metadata, discussion topic, web link, LTI (imslticc/basiclti), authorizations, curriculum-standards metadata, a11y metadata, assignment/variant/openvideo extensions, VDEX — and the CC-profiled QTI as ccv1p4_qtiasiv1p2p1_v1p0.ts (QtiQuestestinteropRaw/ProfileDocumentSchema).
  • Engine gap: @conform-ed/qti-xml (QtiVersion = "2.2" | "3.0.1") and @conform-ed/qti-react have no QTI 1.2 normalizer / 1.2→3 conversion / headless projection. There is no CC decompose runtime anywhere (only schemas; no CLI command).
  • emergent: ingest is item-only + offering-scoped (ingest-service.ts writes assessment_item + assessment_item_version, never a test). External Package kinds are h5p|cmi5|scorm-1.2|scorm-2004; runtime providers lumi|static-package|scorm-again (a static-package provider exists, no matching kind). LTI schema is tool-side only (lti-tool.ts) — no consumer/launch-external path.
  • No CC fixtures exist (conform-ed has only the QTI-3 corpus).

Slice 1 — QTI 1.2→3 bridge (conform-ed/qti-xml) ← LEADS

Section titled “Slice 1 — QTI 1.2→3 bridge (conform-ed/qti-xml) ← LEADS”
  • convertCcQtiV1ToV3(questestinteropXml) → QTI 3.0.1 ASI XML, covering the CC 1.2.1 subset: response_lid Single/Multiple/Ordered → choiceInteraction; response_str + render_fibtextEntryInteraction / extendedTextInteraction; material/mattext → item body; resprocessing (respcondition/setvar/displayfeedback) → response processing + feedback.
  • Bridge round-trip test: 1.2.1 fixture → convert → validateQtiXmlContent passes → headless projection delivers + scores. Release single-version; pin in emergent.
  • Outcome: CC’s QTI becomes ingestable by emergent’s existing pipeline, unchanged.

Slice 2 — CC decompose (@conform-ed/common-cartridge, NEW)

Section titled “Slice 2 — CC decompose (@conform-ed/common-cartridge, NEW)”
  • decomposeCommonCartridge(bytes) → DecomposedCartridge: unzip (fflate), validate imsmanifest.xml against CC 1.4 contracts, return the <organizations> tree + resources[] typed by CC resource-type with hrefs + file contents; resolve resource dependencies (which webcontent files are assets of which resource).
  • Unit tests over vendored real cartridges asserting tree shape + resource classification.

Slice 3 — emergent schema + test-level QTI ingest

Section titled “Slice 3 — emergent schema + test-level QTI ingest”
  • New web-content External Package kind/launchKind; cartridge_import record (status pending→ready→failed, report jsonb, provenance links). Migration via the regenerate-migrations runbook ([[feedback_never_edit_generated_migrations]]; DB reset/apply are Anton’s steps).
  • Test-level ingest: convert a CC assessment → QTI 3 assessmentTest XML → validate/normalize → create assessment_definition (Assessment Test) + referenced Items. Reuses the ADR-0020 test model. (Item-only ingest is already done.)

Slice 4 — CC mapping service + async import endpoint (apps/api)

Section titled “Slice 4 — CC mapping service + async import endpoint (apps/api)”
  • Map DecomposedCartridge → new Offering + Learning Unit tree + routed content per the ADR-0022 table; QTI via the bridge + ingest, webcontentweb-content External Packages (deps bundled), long-tail → quarantined report entries with preserved payloads.
  • POST /offerings/import-cartridge (teacher-manage / org-admin authz), async, keyed on cartridge_import; integration test over a real cartridge asserting Offering / Unit tree / Item Bank / Assessment Test / report (mapped + quarantined).

Slice 5 — teacher-portal upload + report surface

Section titled “Slice 5 — teacher-portal upload + report surface”
  • Upload .imscc, poll cartridge_import.status, render the import report (what mapped, what was quarantined and why). Thin UI over the Slice-4 pipeline. (Mantine MCP first.)
  • BUILT 2026-06-17 (commit 345d627; documents section added with the EPUB lane), synchronous v1. CartridgeImportCard on the offerings index (sibling to “create offering”, same org-admin gate): FileInput for the .imscc, org select, optional title override, and a report panel listing imported assessments + question banks + readings (epub/pdf documents) + web content plus a quarantine table. On success it invalidates the offerings loader and links to the new Offering. Consumes the synchronous endpoint directly — no polling, since cartridge_import (the async status record) was deferred with the schema. Swap to poll-on-status when Slice 3’s cartridge_import migration lands. (The earlier “web content lands server-side but isn’t listed in the report” gap was closed 2026-06-17 — the teacher result type + report now carry webContents.)

Build note (Slices 3–5). Slices 3+4 shipped (commits 0d71ef8, a0e5535, 3a7ab29) without any new schema — the importer reuses existing offering / learning_unit / assessment_definition / assessment_item tables and returned the report synchronously. Slice 5 (teacher-portal upload + report surface) shipped in commit 345d627.

Build note (web-content + documents + cartridge_import, 2026-06-17). The remaining schema-backed pieces are now BUILT (one migration regenerated via the runbook; DB reset/apply remain Anton’s steps):

  • web-content External Package kind + serving runtime. webcontent resources now land as web-content External Packages on the static-package runtime (bundle stored unzipped, verbatim paths) instead of being quarantined, and are served to entitled learners through a token-authed proxy into a sandboxed iframe (apps/api/.../web-content-delivery, learner-web web-content.$assignmentId route). Needs WEB_CONTENT_DELIVERY_TOKEN_SECRET. See ADR-0022 §“Implementation status”.
  • EPUB/PDF documents → in-app reader (commits 871912b, 79c2083, de950d0). The mapping service content-sniffs each resource’s entry file (detectDocumentKind) ahead of the kind routing, so a real .epub/.pdf reaches the reader whether declared under the EPUB3 type or shipped as plain webcontent; unzipped-OCF EPUBs are detected (META-INF/container.xml) and repackaged into one .epub. Lands through the same uploadDocument seam as ADR-0026; the report gained an imported.documents[] array. See ADR-0022 §“Implementation note — EPUB3 routing” and the EPUB reading plan Slice 6.
  • cartridge_import record. Every import persists provenance + report + status (pending → ready → failed); the import runs synchronously (no worker yet — async/polling is the documented future flip the record already supports). Count columns: imported_assessment_count / _question_bank_count / _web_content_count / _document_count / quarantined_count (imported_document_count added 2026-06-17 — the whole migration set was regenerated via the runbook; DB reset/apply remain Anton’s steps).

Slice 6 — conformance lane + fixtures — BUILT 2026-06-17

Section titled “Slice 6 — conformance lane + fixtures — BUILT 2026-06-17”
  • Vendored two real, MIT-licensed cartridge exports (instructure/common-cartridge-viewer) into tests/conformance/fixtures/common-cartridge/ with a provenance README.md (source commit + SHA-256 pins; the same corpus conform-ed’s own CC lane uses, kept byte-identical).
  • test:cc:conformance (tests/conformance/common-cartridge.conformance.test.ts): runs the real import pipeline over PGlite (real schema via pushSchema, real SQL, real QTI ingest — deterministic + offline, like the QTI lane) over each fixture and asserts the persisted Offering + Learning Unit tree, that the CC QTI ingests as real QTI 3 items (≥4: mc/tf/mr/essay), that web content lands, and the cartridge_import record settles ready. The decompose + QTI 1.2.1→3 bridge round-trip are gated upstream over the same fixtures by conform-ed’s cc-conformance.test.ts, so this lane proves the emergent half rather than duplicating it.
  • Core gate — “nothing silently dropped.” expectNothingSilentlyDropped asserts every declared <resource> is accounted for in the import report (mapped to a native structure or quarantined-with-reason) — ADR-0022 §5 made executable.
  • test:cc:conformance:local (cc-corpus.local.test.ts): opt-in lane over a larger directory of .imscc files (EMERGENT_CC_CORPUS_DIR, else the conform-ed viewer corpus), non-fatal on backlog but enforcing the totality invariant on every successful import. Locally it imports 5 real cartridges (incl. a 202-resource Canvas ushistory course and allyworkshop, whose webcontent yields 3 content-sniffed EPUB/PDF documents) end to end with nothing dropped.
  • Note: the conformance lane is the real-execution integration assertion Slice 4 named — it drives the actual import service against real cartridges. A separate container-backed (real Postgres, via run-integration-suite) lane was judged unnecessary on top of it; revisit only if PGlite/Postgres behavioural drift ever matters for this path.

Any CC-profiled 1.2 case the bridge mishandles, or CC structure the decompose mishandles, is fixed in conform-ed and released ([[feedback_internal_lib_release_all_packages]]); emergent bumps the pin. emergent never patches CC/QTI semantics locally.

  • Per slice: bun test (touched pkg) + bun run typecheck; UI/DB slices add bun run validate:full and (Slice 3+/4) bun run test:integration.
  • Conformance bar: bun run test:cc:conformance green on the vendored set; :local is the stretch.
  • O-1. Vendored CC fixture sourcing — 1EdTech CC examples vs real Canvas/Moodle/Blackboard exports (prefer ≥1 of each export tool, since their CC dialects differ).
  • O-2. webcontent link rewriting — relative hrefs between bundled files must resolve inside the static-package runtime; decide the base-path/serving convention.
  • O-3. Offering metadata seed — CC manifest LOM (title, subject, education level) should reconcile onto the Subject / Education Level registries (as OneRoster/CASE do), not free strings.
  • O-4. RESOLVED 2026-06-17 (Slice 0 BUILT). Quarantine payload retention — each long-tail entry now carries a friendly reason + a preserved title and detail (weblink / LTI launch URL), surfaced in the teacher report; rides report_payload (no schema change). Discussion prompts / assignment instructions are intentionally left in the cartridge (not copied into the report). Analysed (with the native-landing options for the whole long tail) in cc-long-tail-landing-analysis.md.
Section titled “Long tail (LTI / discussion / assignment / web-link)”

The quarantined long tail and what it would take to give each a native home is analysed separately in cc-long-tail-landing-analysis.md — each is a net-new product surface (no LTI consumer / discussion / assignment / external-link model exists today), so the import mapping is cheap but the destination feature is the cost. Sequenced by ROI there. Progress:

  • Slice 0 — quarantine payload preservation: BUILT 2026-06-17 (O-4 above).
  • web-link → native learning_unit_external_link: import landing + teacher report BUILT 2026-06-17 (landWebLink; imported_web_link_count).
  • Learner discovery surface: BUILT 2026-06-17 — GET /workflow/offerings/:offeringId/contents (offering-contents feature) + a “Course materials” section on the learner offering page surface the landed web-content, documents, and web-links to entitled members. Online discovery (not the sync rail — the delivery is online anyway), so no schema/sync change. All three content kinds are now learner-reachable.
  • assignment: BUILT 2026-06-17 (ADR-0027 — lands as a native human-scored Assessment).
  • discussion: analysed (a threaded-discussion capability); not built — vendor-vs-build is open (the forum/discussion category is mature) and gets its own dedicated /grill-with-docs session; see cc-long-tail-landing-analysis.md §3.
  • LTI consumer: deferred by design (a standalone platform program).