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”.
Context
Section titled “Context”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.
Decisions
Section titled “Decisions”-
zod v4 is the single validation authority; JSON is the storage/wire format. The document schema lives in
packages/domain(z.infertypes feed the renderer); a JSON Schema artifact is derived viaz.toJSONSchema()purely for editor autocomplete while hand-authoring. No second validator (no ajv), ever — two sources of truth drift. -
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 futureschemaVersionwithout 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). -
Typed
SurfaceContextfacets; absent facet ⇒ widget omitted. The hosting route/player assembles optional facets —activity,session(capability functionssubmit(),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. -
Per-delivery-mode widget policy, fail-closed at both ends.
packages/domaindeclares which widget types each delivery mode permits (e.g.secure-examdenieshint,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. -
Storage: synced tables, admin read-write / learner read-only.
surface_layout— the document rows, mirroringassessment_definitionconventions (statedraft|published|retired, in-placeversion_nobump, audit/LWW columns). Every row declares itslayout_kind:screen(FK-referenced by an experience screen; matcher columns must be NULL; any number may be published) orpreset(matcher-resolved). Matcher columnsdelivery_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 banhint/word-card/streakfrom every experience screen.surface_experience(title, state, version) +surface_experience_screen(role-unique FK to sharedsurface_layoutrows — 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.
-
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). Manylinkedattachments per offering; at most onedefault/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. -
The
viewportwidget 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. -
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 underexclusiveposture. Malformed JSON must never lock a cohort out of their course. -
Widget catalog v1 (curated, 15 types). Chrome:
instructions,audio-player,hint,progress,word-card,submit,next,streak,level-badge. Navigation (bound to thestructurefacet):unit-menu,activity-rail(orientation prop covers both margin rails),breadcrumb,offering-header,dashboard-tiles. Plusviewport. 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.
Consequences
Section titled “Consequences”- 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).
Revisit triggers
Section titled “Revisit triggers”- 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 apackages/dbhelper (see build plan S2).