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-reason — lti-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 central finding
Section titled “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 first — except 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).
assignmentnow lands natively:landAssignment(cartridge-import) parses the CCassignment_xmlv1p0(title, instructions,points_possible, submission format) and callsingestNativeAssignment, 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).
What each resource actually carries
Section titled “What each resource actually carries”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(Canvasassignment_xmlv1p0extension): 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.
Per-kind analysis
Section titled “Per-kind analysis”1. web-link — smallest, highest ROI — IMPORT LANDING BUILT 2026-06-17
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 rail — learning_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_linktable (or aweb-linkrow on existing structure). Clean, honest, ~one table + one learner card + one teacher edit affordance. - (b) Reuse the External Package model with a
web-linkkind whose “asset” is a URL, not a bundle. Tempting (mirrorsweb-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-docssession 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_definitionalready hasdueAt,assignAt,maxScore,gradeCategoryId,scoreScaleId,rubricSchema,deliveryProfile: server-anchored,scoringPolicy: server. - The hand-in is an Attempt (
assessment_attempt, statesstarted → submitted → evaluated → finalized), never a “Submission” (a banned term in CONTEXT.md). Uploaded files aresubmission_artifactrows. - Grading:
assessment_score(evaluationSource: auto | human | hybrid,evaluatedByPersonId,isFinal);feedback_entry(instructor comment);gradebook_entrystatus enum is literallynot_submitted | submitted | partially_graded | graded | exempt | retracted. - Routing already half-works:
finalize-serviceroutes a server-anchored attempt with un-auto-scorable items to a non-finalhuman/hybridscore + gradebookpartially_graded(ADR-0019 O1). - Erasure is already wired:
submission_artifactiscascadeand the executor returns the artifact URIs so the uploaded files are deleted from Storage; attempt/gradebook/item_session aresever; 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
pendingHumanEvaluationbut nothing completes it; teacher-portal has no grading UI. Teacher scores each pendingitem_sessionserver-side → re-runaggregateItemSessions→ finalassessment_score→ gradebookgraded. Benefits every human/hybrid assessment, not just assignments. - Two deliverable interactions.
extendedTextInteractionanduploadInteractionare 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 dedicatedlearner-submission-artifactsbucket (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_definition →
gradebook_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_possible
→ maxScore, due_at → dueAt, allowed_attempts → maxAttempts, 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
publicmust 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).
4. lti-link — needs emergent to become an LTI platform/consumer (XL)
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.
Cross-cutting recommendation
Section titled “Cross-cutting recommendation”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 (
describeQuarantinedResourcein 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>) anddetail?(a best-effort extracted URL — weblink<url href>/ LTI<launch_url>). Rides the existingreport_payloadjsonb — 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.
Sequencing (by ROI)
Section titled “Sequencing (by ROI)”- Slice 0 payload preservation (S) — ✅ done.
- web-link native landing (S feature) — ✅ done — cheapest real un-quarantine.
- 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
landAssignmentlayer. - learner discovery surface (M) — ✅ done — the shared
offering-contentsendpoint + “Course materials” section that makes the landed web-content/documents/web-links learner-reachable online. - 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-docssession (see §3). - 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.
How this interacts with conformance
Section titled “How this interacts with conformance”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.