Skip to content

Surface-layout engine and Surface Experiences

Status: accepted

Date: 2026-07-07

Relates to: docs/architecture/surface-layout-and-subject-packs.md (resolves its open decisions), ADR-0032 (token-scoped serve proxy for cmi5 launches), the secure-delivery lane (docs/design/secure-delivery-single-item-shape.md), CONTEXT.md → “Surface layout and subject packs”.

docs/architecture/surface-layout-and-subject-packs.md fixed the two-axis model (subject pack = what, surface layout = how) and level-B authoring (provider/admin-authored declarative presets; no builder), and left four decisions open: schema format, storage location, widget catalog v1, and i18n posture. This ADR records how those close, plus the structural decisions the build needs.

The driving product shape is the Surface Experience (“mini-app” informally): a bespoke interface over an offering, composed from the normal machinery. Canonical example: the Planet English offerings (one per year; 6 unit groupings × 4 sub-groupings × N activities as modified Adobe Captivate exports delivered as cmi5 external packages) need a bespoke dashboard plus a projectable activity screen — activity buttons along the left and top margins, the running activity filling ~85–90% of the viewport — usable by a teacher in front of a class and optionally the only interface a cohort sees. This was always the goal of the surface-layout lane; the engine below is sized for it.

  1. zod v4 is the single validation authority; JSON is the storage/wire format. The document schema lives in packages/domain (z.infer types feed the renderer); a JSON Schema artifact is derived via z.toJSONSchema() purely for editor autocomplete while hand-authoring. No second validator (no ajv), ever — two sources of truth drift.

  2. Layout documents are versioned, text-free, and structure-only. The JSON carries a self-describing envelope (schemaVersion) and named slots, each holding an ordered list of widget instances (placement + static presentation props only). All learner data arrives at render time via context (below); all display copy comes from widget code through @emergent/i18n (LL.surface.*, English entries now) — stored documents never need migration or re-authoring when locales land. Slot values are a discriminated union (kind: "widget-list" today) so a nested-container kind can be added at a future schemaVersion without breaking v1 documents. The renderer owns the grid, responsiveness, and reading order — that single tested implementation is what preserves the mandated a11y/i18n guarantees (the reason levels D/E stay deferred).

  3. Typed SurfaceContext facets; absent facet ⇒ widget omitted. The hosting route/player assembles optional facets — activity, session (capability functions submit(), next(), …), learner, structure (offering unit tree + selection + goToUnit()). Widgets declare required facets; a widget whose facet is absent is dropped through one logged “didn’t render, here’s why” rail. Control behavior binds to capability functions — never action strings in JSON.

  4. Per-delivery-mode widget policy, fail-closed at both ends. packages/domain declares which widget types each delivery mode permits (e.g. secure-exam denies hint, word-card, streak). The write rail rejects violating documents; the renderer drops denied widgets with a logged warning (defense in depth against policy tightening after rows exist). Authoring discipline is not a control — a withholding assumption nobody enforces is a no-op.

  5. Storage: synced tables, admin read-write / learner read-only.

    • surface_layout — the document rows, mirroring assessment_definition conventions (state draft|published|retired, in-place version_no bump, audit/LWW columns). Every row declares its layout_kind: screen (FK-referenced by an experience screen; matcher columns must be NULL; any number may be published) or preset (matcher-resolved). Matcher columns delivery_mode / activity_kind (nullable = wildcard) plus a NULL-folding (COALESCE-sentinel) partial unique index over published presets ship in the first migration so the generic preset layer never needs a live-table migration — but preset resolution is an explicit follow-up slice, not v1 (its only v1 consumer would be nested inside screens admins already control). Without the kind discriminator the preset-determinism index would collapse all matcher-less screen layouts into one ('*','*') slot — at most one published screen layout platform-wide — so the discriminator is load-bearing, not cosmetic. It also fixes the write-time policy rule: a preset is policy-validated against the delivery modes its matcher covers (NULL = all, fail-closed); a screen is shape-validated on write and mode-policed at render time (decision 4’s renderer end), since one screen legitimately hosts different content modes at different moments — validating screens against all modes at write time would wrongly ban hint/word-card/streak from every experience screen.
    • surface_experience (title, state, version) + surface_experience_screen (role-unique FK to shared surface_layout rows — layout reuse across year-offerings is a FK, not copied JSON) + surface_experience_attachment (offering FK + posture).
    • Learner row filters: published + membership reach (attached to an offering the learner belongs to). Ingestion rides the admin sync write rail (mutation expanders re-validate zod + policy server-side, fail-closed) with an admin-console paste-JSON + live-preview feature; dev/test seeding is drizzle-seed fixtures validated by the same schema. Raw SQL insertion is not an ingestion path — it bypasses the validator.
  6. Experiences are additive; the vanilla LMS stays static. Players and existing routes keep their hard-wired chrome. An experience enters via its own routes. Attachment posture: linked (menu entry), default (entering the offering lands in the experience), exclusive (offering reachable only through it; if every offering a learner has is exclusive, login lands in the experience). Many linked attachments per offering; at most one default/exclusive (partial unique index) — landing is structurally unambiguous, no priority ordering. Finer-than-offering targeting waits for group machinery; the offering is already the cohort boundary.

  7. The viewport widget hosts any assignment kind by dispatching to the existing per-kind route bodies, extracted into embeddable hosts (external-package/cmi5 iframe via the ADR-0032 serve proxy first-class, then assessment, web-content, media, documents). No parallel embedding machinery. Selection is URL state (deep-linkable, refresh-safe on a projector); switching activities mints launch sessions with normal semantics.

  8. Fail-closed for content, fail-open for access. Invalid documents never render and never ingest; but a screen whose stored layout fails validation (or carries an unknown schemaVersion) falls back to the vanilla offering view with a loud logged warning — even under exclusive posture. Malformed JSON must never lock a cohort out of their course.

  9. Widget catalog v1 (curated, 15 types). Chrome: instructions, audio-player, hint, progress, word-card, submit, next, streak, level-badge. Navigation (bound to the structure facet): unit-menu, activity-rail (orientation prop covers both margin rails), breadcrumb, offering-header, dashboard-tiles. Plus viewport. Unknown types fail validation and never render. Completion decoration on nav widgets is optional-data: it renders only from locally synced progress signals, degrading to plain buttons.

  • Widening authoring authority (B → C/D) remains ownership + permission on rows; the engine, schema, and renderer are unchanged. Level-E freeform composition stays rejected on a11y grounds.
  • The vanilla LMS and experiences co-evolve independently; no regression risk to existing players from this lane, at the cost of two coexisting interface sets to maintain.
  • One document schema serves both altitudes (activity chrome preset and experience screen), so widgets, policy, validation, preview, and tests are built once.
  • The deferred preset-resolution slice (F1) has its DDL already in place; wiring it later touches only domain + host code.
  • Host extraction (route bodies → embeddable components) is prerequisite work that also pays down duplication for any future embedding need (e.g. teacher preview panes).
  • Group machinery lands and a customer needs sub-offering cohort targeting of experiences.
  • A non-TS consumer must author/validate documents independently (would justify promoting the derived JSON Schema to a published contract).
  • A layout needs author-supplied display copy (would introduce the reserved LocalizableText { key, default } shape).
  • Experience screens need structure the named-slot grid cannot express (would introduce the nested-container slot kind under a new schemaVersion).
  • A customer needs to export/interchange an experience — that is a serialization format for the (already portable) JSON documents, not a repackaging behind a launch boundary. Modelling experiences as an internal “external package” was considered and rejected: the package machinery’s defining property is opacity (launch session, serve proxy, sandboxed iframe, xAPI-only signal), while experiences exist precisely to be deeply integrated — CAT cursors, behavioural sensors, the synced structure facet, and the widget policy all require in-process rendering. Experiences host external packages (via viewport), never the reverse; the only genuine commonality is the membership-reach filter grammar, shared as a packages/db helper (see build plan S2).