Skip to content

Common Cartridge long-tail landing — analysis & plan

Companion to ADR-0022 (CC import) and its plan common-cartridge-import-plan.md. Scope: the CC resource kinds the importer currently quarantines-with-reasonlti-link, discussion-topic, assignment, and web-link — and what it would take to give each a native home instead.

Status: analysis only (2026-06-17). Nothing here is built or decided. ADR-0022 deliberately deferred this long tail (“map only QTI + webcontent + documents in v1”); this document sizes the work so the go/no-go and sequencing can be decided deliberately. Each landing is a net-new product surface, not import plumbing — that is the central finding.

The import side is the cheap part. The importer already routes by classified kind in one switch (cartridge-import-service.ts), and each existing landing (landDocument, landWebContent, QTI ingest) is a handful of lines that delegate to a domain capability that already exists (the reader, the External Package runtime, QTI ingest). For the long tail, that domain capability does not exist:

CC kind Native target needed Does it exist today? Import effort once target exists Build effort of the target
web-link an external-link content type on a Unit No (closest: External Package, but that’s bundled files, not a URL) XS S
assignment a human-scored Assessment over essay/upload (not a new domain — see ADR-0027) Yes — BUILT (Increment 1 shipped the chain + skins + grading; Increment 2 lands the CC mapping) S M
discussion-topic a Discussion (topic + threaded posts) domain No S L
lti-link emergent as an LTI platform/consumer No (emergent is an LTI tool) M XL

So “un-quarantining” any of these means building the feature firstexcept assignment, whose domain capability turns out to already exist (ADR-0027 supersedes the §2 framing below). The CC mapping that follows is then a one-line addition to the routing switch — exactly like landDocument was once the reader existed (ADR-0026 → ADR-0022 §EPUB).

Status (shipped). assignment now lands natively: landAssignment (cartridge-import) parses the CC assignment_xmlv1p0 (title, instructions, points_possible, submission format) and calls ingestNativeAssignment, which synthesizes a single open-response item (essay for text/HTML, upload for file) and writes a server-anchored, human-scored Assessment carrying the points. It is no longer quarantined; the conformance lane proves it (assignment.imscc).

Grounded in @conform-ed/common-cartridge (classifyCcResourceType) + the CC 1.4 contracts:

  • web-link (imswl_xmlv1p*): an XML file with <webLink><title>…</title><url href="…"/></webLink>. Just a titled outbound URL. The smallest of the four.
  • discussion-topic (imsdt_xmlv1p*): <topic><title>…</title><text texttype="text/html">…</text></topic> — a prompt only. The cartridge carries no posts/replies (those are runtime state, never in a static package).
  • assignment (Canvas assignment_xmlv1p0 extension): title, instructions (HTML), submission types (online text / file upload / URL), points possible, optional due date. Again definition only — no learner submissions.
  • lti-link (imsbasiclti_xmlv1p* / imslticc_*): a launch URL, consumer key/secret (LTI 1.1) or a 1.3 client/deployment config, custom parameters, title, and (sometimes) a Deep-Linking hint. Frequently LTI 1.1 in real cartridges.
Section titled “1. web-link — smallest, highest ROI — IMPORT LANDING BUILT 2026-06-17”

Target. A lightweight “external link” that hangs on an activity Learning Unit: { title, url }, rendered to the learner as an outbound link card (open in a new tab, rel="noopener"), no embedding.

Built (commit on qti-authoring-breadth). Chosen option (a): a learning_unit_external_link table (offering + unit + title + url + sequence + source_system; no person data, so no erasure entry). The importer’s landWebLink extracts the <url href> (single/double-quoted), inserts the row on the resource’s activity Unit, and reports imported.webLinks[] — web-link no longer quarantines (a link with no URL, or one not placed in the course structure, still quarantines-with-reason). cartridge_import gained imported_web_link_count; the teacher report shows a “Links imported” section.

Learner rendering — BUILT 2026-06-17. The shared learner discovery surface now exists: a GET /workflow/offerings/:offeringId/contents endpoint (the offering-contents feature) returns the offering’s published web-content, documents, and external links to an entitled member, and the learner offering page renders them in a “Course materials” section (web-content → viewer, document → reader, link → new tab). Crucially this is online discovery, not the sync raillearning_unit_external_link and external_package_assignment remain unsynced (still deliberately, like unit_release_rule), because the delivery they point at is online anyway. So all three imported content kinds are now learner-reachable.

Options.

  • (a) A tiny new learning_unit_external_link table (or a web-link row on existing structure). Clean, honest, ~one table + one learner card + one teacher edit affordance.
  • (b) Reuse the External Package model with a web-link kind whose “asset” is a URL, not a bundle. Tempting (mirrors web-content) but wrong: the External Package runtime is about delivering stored bytes; a bare URL has no bytes, no version snapshot, no signed-URL serving. Forcing it in would distort that model. Rejected.
  • (c) Generate a one-line HTML redirect page and land it as web-content. A hack that buries the URL inside a bundle and loses “this is a link.” Rejected.

Recommendation: option (a). Effort S (1 table, 1 learner render, 1 teacher CRUD, erasure-trivial — no person data). This is the cheapest real win and the natural first long-tail landing. Done end to end (see the BUILT notes above): import + teacher report, and the shared learner discovery surface.

2. assignment — a human-scored Assessment, NOT a new domain (M) — see ADR-0027

Section titled “2. assignment — a human-scored Assessment, NOT a new domain (M) — see ADR-0027”

This section’s original framing (“L — needs a new Assignment + Submission domain”) was overturned by the /grill-with-docs session of 2026-06-17 and is superseded by ADR-0027. The grounding below records why; treat ADR-0027 as authoritative.

The reframe. An “assignment” is not a separate concept — it is an Assessment (ADR-0001), server-anchored and human-scored, over an extendedText (essay) and/or uploadInteraction (file) Item. The whole chain already exists and was built for exactly this shape:

  • Definition: assessment_definition already has dueAt, assignAt, maxScore, gradeCategoryId, scoreScaleId, rubricSchema, deliveryProfile: server-anchored, scoringPolicy: server.
  • The hand-in is an Attempt (assessment_attempt, states started → submitted → evaluated → finalized), never a “Submission” (a banned term in CONTEXT.md). Uploaded files are submission_artifact rows.
  • Grading: assessment_score (evaluationSource: auto | human | hybrid, evaluatedByPersonId, isFinal); feedback_entry (instructor comment); gradebook_entry status enum is literally not_submitted | submitted | partially_graded | graded | exempt | retracted.
  • Routing already half-works: finalize-service routes a server-anchored attempt with un-auto-scorable items to a non-final human/hybrid score + gradebook partially_graded (ADR-0019 O1).
  • Erasure is already wired: submission_artifact is cascade and the executor returns the artifact URIs so the uploaded files are deleted from Storage; attempt/gradebook/item_session are sever; comments/feedback scrub. (My earlier “erasure + consent gating is mandatory, not optional” was right about mandatory and wrong about not done — and grading needs no research Consent at all; it is operational use under the org’s delegated authority, ADR-0008.)

What’s genuinely missing (the real, CC-independent work).

  • A human-grading completion surface. finalize flags pendingHumanEvaluation but nothing completes it; teacher-portal has no grading UI. Teacher scores each pending item_session server-side → re-run aggregateItemSessions → final assessment_score → gradebook graded. Benefits every human/hybrid assessment, not just assignments.
  • Two deliverable interactions. extendedTextInteraction and uploadInteraction are authorable but un-skinned (the Mantine skin wires only choice/text-entry/inline-choice/PCI). Upload uses TUS direct-to-Storage + finalize (as qti-item-asset) into a new dedicated learner-submission-artifacts bucket (owner-scoped RLS; a DB-owner migration).
  • A content-aware erasure scrub of free-text/extendedText responses in item_session.response_payload (assignments make essays the primary content; the retained-for-stats accepted risk no longer holds) + a guard test.

The gradebook question, resolved. An assignment grade is already an assessment_definitiongradebook_entry, and OneRoster’s line-item projection (oneroster_line_item_mapping) already maps from assessment_definition. One gradebook, no new wiring.

CC mapping (best-effort, never quarantines the definition): instructions → item prompt, points_possiblemaxScore, due_atdueAt, allowed_attemptsmaxAttempts, summative; online_text → extendedText, online_upload → uploadInteraction, online_url → text, on_paper/none → instructions-only (direct teacher grading), external_tool → instructions-only + note (LTI consumer deferred). No submissions to import (static package). Import effort S; feature effort M (delivery + grading-completion + erasure scrub), sequenced as two increments (native feature first, thin CC layer second).

3. discussion-topic — needs a Discussion capability (L) — VENDOR-vs-BUILD, own session

Section titled “3. discussion-topic — needs a Discussion capability (L) — VENDOR-vs-BUILD, own session”

Target. A per-offering discussion: a topic (title, prompt) + threaded posts (author, body, parent, timestamps) + read/participation surfaces.

What emergent has and lacks. Nothing collaborative exists — no topics, threads, posts, or notifications. This is a whole interaction capability, not a content type.

Open question (do NOT prejudge — its own /grill-with-docs session, 2026-06-17). This was sized as “build a Discussion domain (L)”, but discussion/forum systems are a mature, crowded category, so the first question is vendor vs build, and “vendor” is itself several different things:

  • Full platform (Discourse / NodeBB / Flarum / Misago …) — a separate service with its own auth, DB, and deploy. Integrating means SSO + an embed/launch (closer to the LTI-consumer shape than to a native feature) and gives up emergent’s offline-sync rail, RLS row-filtering, erasure/consent governance, and roster/role model at the seam. Heavy impedance mismatch with an offline-first LMS.
  • Embeddable/headless comments (SaaS like Commento/Cusdis, or a headless backend) — lighter, but third-party data custody collides with the erasure/consent obligations below, and still no offline.
  • Library, not platform — a threaded-comment UI/data library we host on emergent’s own stack (Bun/Drizzle/Supabase/Electric/pgxsinkit + Mantine), keeping sync, RLS, and erasure native. This is “build” with less from-scratch UI.
  • Build native — full control, full integration, highest from-scratch cost.

The ADR-0027 precedent matters: assignment looked like a new domain but was really an existing capability reframed. The session should test whether discussion has a similar reframe, or is genuinely net-new — and weigh that against what any vendored option forfeits.

Cost drivers / non-negotiables any option must satisfy.

  • Threaded model + posting/reply/moderation; teacher moderation + (likely) realtime or polling.
  • Erasure: posts are person-authored PII → ADR-0014 registry + consent, same as submissions. A vendored store that holds posts outside public must still honour right-to-erasure.
  • Offline-first: emergent’s learner surfaces are sync-rail-first (pgxsinkit/PGlite); a discussion that only works online is a real divergence to decide deliberately.
  • Notifications/unread state (optional v1, but expected of a discussion feature).
  • Abuse/safety surface (reporting, hiding) — a real obligation for learner-authored content.
  • Neutral domain vocabulary + per-role layouts (CONTEXT.md / lms-domain rule).

CC mapping (once the capability exists): the cartridge seeds one topic with its prompt; all threading is the feature. Import effort S; capability effort L (or less, if vendored well).

Section titled “4. lti-link — needs emergent to become an LTI platform/consumer (XL)”

The reversal. Today emergent is an LTI tool: lti_tool_platform stores platforms that launch it; AGS publishes grades back; NRPS reads membership in (lti-tool.ts, apps/api/src/features/lti-tool/). A CC imsbasiclti link needs the opposite — emergent launching an external tool — which is an entirely separate role.

What a consumer/platform requires.

  • External-tool registration & custody (1.3 client/deployment config, or 1.1 consumer key/secret), with secret storage (Vault, as the credential lane does).
  • OIDC login initiation + resource-link launch: mint a signed launch JWT, host a JWKS, run the login→launch handshake, render the tool in a frame/new tab.
  • Optional Deep Linking (let a teacher pick a resource from the tool at import/author time).
  • AGS as a platform (receive grades from the tool into emergent’s gradebook) and NRPS as a platform (expose emergent’s roster to the tool) — the mirror of today’s tool-side AGS/NRPS.
  • LTI 1.1 support (OAuth1 HMAC signing), which emergent does not have at all and which many real cartridge links still use.

Cost drivers. Security-critical (key custody, JWT/JWKS, OIDC, replay protection), a large standalone program, and partly a product question (do we want to be a platform that launches third-party tools?) rather than an import gap. Effort XL. Import mapping is M only after the platform exists (register the tool from the CC config, create a resource link on the Unit).

Recommendation: keep quarantined until there is a real product need for tool-launching; it is not justified by CC import alone.

Slice 0 — quarantine payload preservation (do this first, regardless) — S — BUILT 2026-06-17

Section titled “Slice 0 — quarantine payload preservation (do this first, regardless) — S — BUILT 2026-06-17”

ADR-0022 §5 promises the report carries “its preserved payload and a reason,” and open question O-4 asks how much to retain. Previously the entry was { resourceIdentifier, type, reason } with a generic resource kind "x" is not yet imported — the salient payload (the weblink URL, the LTI launch URL, the title) was not surfaced. Now closed:

  • Each long-tail kind gets a friendly, specific reason (describeQuarantinedResource in cartridge-import-service.ts): “Web link — no native landing yet”, “External tool (LTI) link …”, “Discussion topic …”, “Assignment …”.
  • The entry gained title? (from the manifest org tree, falling back to the resource XML <title>) and detail? (a best-effort extracted URL — weblink <url href> / LTI <launch_url>). Rides the existing report_payload jsonb — no schema change.
  • The teacher-portal quarantine table now shows the friendly title (resource column) and the preserved URL (under the reason).

The cartridge itself remains the full archive (a future native landing re-imports it); this makes the report actionable and fully honors “nothing silently dropped”. Discussion prompts / assignment instructions are intentionally not copied into the report (large HTML bodies; the title is the actionable datum, the body lives in the cartridge) — revisit if a use case needs them.

  1. Slice 0 payload preservation (S) — ✅ done.
  2. web-link native landing (S feature) — ✅ done — cheapest real un-quarantine.
  3. assignment (M, not L — ADR-0027) — ✅ done — the domain already existed; the work was a human-grading completion surface + essay/upload delivery skins + a free-text erasure scrub (a CC-independent product feature), then a thin CC landAssignment layer.
  4. learner discovery surface (M) — ✅ done — the shared offering-contents endpoint + “Course materials” section that makes the landed web-content/documents/web-links learner-reachable online.
  5. discussion (L) — high value but the heaviest non-LTI (moderation/safety/realtime); not built — vendor-vs-build is open and gets its own /grill-with-docs session (see §3).
  6. LTI consumer (XL) — only on a real product need; a program of its own, security-critical; not built.

Each, when built, is a one-line addition to the importer’s routing switch + a land<Kind> helper that delegates to the new domain capability — the established ADR-0022 pattern. The order is deliberate: spend nothing on import plumbing until the destination feature exists.

The CC conformance lane (test:cc:conformance, ADR-0022 §7) asserts totality — every resource mapped or quarantined-with-reason. As each landing ships, the corresponding fixtures move from the “quarantined” column to “imported”; the totality invariant holds throughout and is the regression net.