From 7b038bda5f4e4e63edbdcff71455bb2956604212 Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:50:23 -0400 Subject: [PATCH 01/91] Document Boxel execution runtime architecture --- docs/boxel-execution-runtime-architecture.md | 1783 +++++++++++++++++ ...cution-runtime-authorization-projection.md | 353 ++++ ...xel-execution-runtime-composition-suite.md | 1143 +++++++++++ .../boxel-execution-runtime-coverage-audit.md | 579 ++++++ ...xel-execution-runtime-mutation-protocol.md | 310 +++ ...el-execution-runtime-real-example-audit.md | 286 +++ 6 files changed, 4454 insertions(+) create mode 100644 docs/boxel-execution-runtime-architecture.md create mode 100644 docs/boxel-execution-runtime-authorization-projection.md create mode 100644 docs/boxel-execution-runtime-composition-suite.md create mode 100644 docs/boxel-execution-runtime-coverage-audit.md create mode 100644 docs/boxel-execution-runtime-mutation-protocol.md create mode 100644 docs/boxel-execution-runtime-real-example-audit.md diff --git a/docs/boxel-execution-runtime-architecture.md b/docs/boxel-execution-runtime-architecture.md new file mode 100644 index 00000000000..0451ca94721 --- /dev/null +++ b/docs/boxel-execution-runtime-architecture.md @@ -0,0 +1,1783 @@ +# Boxel execution runtime architecture + +## Status and purpose + +This document describes a target architecture for executing and rendering +Boxels through three execution tiers while preserving one authored API and one +user experience: + +- **Direct** — trusted modules execute in the Ember Host. +- **Capsule** — user-authored modules execute in an SES Compartment and render + through Host-owned Glimmer. +- **Sandbox** — user-authored modules execute and render in an origin-isolated + iframe, communicating with the Host through `MessageChannel`. + +It is a near-future implementation design, not a claim that the branch already +has every interface described below. It refines the boundary-record work in +[realm-sandbox-boundary-v2-plan.md](realm-sandbox-boundary-v2-plan.md) by +putting the fixed Boxel semantic API and the Glimmer rendering boundary into +one layered runtime model. + +This document is intentionally self-contained for architectural review. It +includes the authored and implicit API inventory, the complete `surface*` +capability plane, the validation conclusions drawn from real applications, and +the one twelve-case cumulative acceptance suite. The working coverage, +compatibility, and real-example ledgers remain useful implementation records, +but a reviewer does not need them to understand or evaluate this design. + +### Delivery approach: freeze the POC and rebuild from `main` + +The production implementation starts on a new branch from `origin/main`. It +does not continue restructuring `codex/code-preview-instant-reload` in place. +That branch is the **frozen reference implementation**: it proves that Capsule +and Sandbox execution, staging-backed cards, editing, HMR, iframe sizing, +media, prerender placeholders, and compatibility shims can produce a useful +product experience. + +At the decision point, the reference branch was 50 commits ahead of +`origin/main`, changed 242 files, and added approximately 42,500 lines. About +20,300 of those additions were Host production code, and the ten central +sandbox files alone contained approximately 13,350 lines. Its working behavior +is valuable; its aggregate ownership and review surface are not the target +architecture. + +Freeze means: + +- no new product features or architectural layers are added to the reference + branch; +- only critical fixes needed to keep its preview and comparison corpus usable + are accepted; +- its preview build, compatibility corpus, screenshots, import classifications, + protocol/security tests, and observation ledgers remain available as the + behavioral oracle; +- every delivery slice compares Direct and sandboxed output against both + `main` and the frozen preview; and +- implementation code is ported only when its ownership already matches this + architecture. Tests, fixtures, policies, and hard-won edge cases are ported + more aggressively than orchestration code. + +The new main-based branch is delivered as a sequence of independently +reviewable vertical slices. The smallest architectural slice—versioned Boxel +records, canonical projection, and a Direct adapter—should be roughly +2,000–3,000 production lines across 10–20 files, plus focused tests. It is +valuable before isolation ships because it removes implicit reflection and +makes Direct the conformance oracle. It is infrastructure, however, and is not +called a useful execution-runtime milestone by itself. + +The second phase must deliver both untrusted adapters: Capsule execution +through Host Glimmer and Sandbox execution through an origin-isolated iframe. +The same bounded, interactive fixture must run through Direct, Capsule, and +Sandbox before the new runtime is considered useful. Capsule-only or +Sandbox-only delivery would leave the classifier and authored API unproven. +The cumulative Phase 1–2 target is approximately 6,000–9,000 production lines +plus focused tests. + +The smallest honest implementation with the same important behavior as the +latest preview is expected to be roughly 9,000–14,000 production lines and +8,000–12,000 focused test lines across approximately 40–70 files. This is a +planning guardrail, not a line-count target: exceeding it requires explaining +which previously implicit Boxel semantic or lifecycle requirement was missing, +while beating it must not come from dropping compatibility, visual behavior, +editing, security, or cleanup. + +The sequencing principle is **semantic spine first, then complete vertical +capabilities**. Do not define every future API before exercising it, and do not +build sandboxes around ad hoc snapshots that must later be replaced. Establish +one canonical Boxel interface and Direct behavior, then add the minimum +capability transport plus Capsule and Sandbox adapters as one useful vertical +slice. Mutation, HMR, additional Surfaces, and BXL authorization then extend +that same spine. + +### Vocabulary: Boxel means Box Element + +In this architecture, **Boxel** is the technical noun **Box Element**. It does +not mean the Boxel product as a whole. A Boxel is any visually present, +interactive building block derived from `BaseDef`, including `CardDef`, +`FieldDef`, `FileDef`, and future compatible kinds. A Boxel may be a persisted +card, a field renderer, a file-backed visual element, or a nested part of +another Boxel. + +The narrower nouns retain narrower meanings: + +- **Card** means a `CardDef` or its persisted Store document/instance. +- **Realm** means the server-side location and authorization boundary for data + and modules. It is not the name of a Host rendering or execution service. +- **Module** means executable source and its dependency graph. +- **Surface** means a mounted presentation environment and its bounded visual + or interactive capabilities. +- **Runtime** names who executes a Boxel: Direct, Capsule, or Sandbox. + +Consequently, cross-kind runtime types use the `Boxel*` prefix. `Card*` remains +appropriate for Card JSON:API documents, Store operations, serialization, and +card-specific mutations. `Realm*` remains appropriate for Realm URLs, grants, +fetching, indexing, and cross-Realm authorization. + +The names and pseudo-code in this document follow the conventions already used +by Boxel `main` and Ember/Glimmer: + +- preserve the existing Card API verbs when the operation has the same + semantics: `loadCardDef`, `createFromSerialized`, `getFields`, `getField`, + `getComponent`, and `serializeCard`; +- use `Boxel*` for runtime contracts shared by CardDef, FieldDef, FileDef, and + future BaseDef-derived visual kinds; +- use `Card*` only where the contract truly requires a CardDef or persisted + card document; +- suffix opaque cross-boundary identities with `Handle` and cloneable + descriptions with `Description`; +- suffix Ember services with `Service`, transport endpoints with `Client` or + `Server`, and implementation adapters with their execution tier; +- reserve `ComponentManager` and its lifecycle hook names for their actual + Glimmer meanings; and +- document exported authority-bearing interfaces with TSDoc, while keeping + implementation commentary close to the mechanism it explains. + +The snippets are target TypeScript, not a new author-facing Card API. Where a +boundary operation mirrors an existing Card API operation, its return value is +a handle or cloneable description of that same result rather than a differently +named semantic. + +The naming and lifecycle choices were cross-checked against these existing +framework seams: + +- Boxel's Card API in `packages/base/card-api.gts` and its field support in + `packages/base/field-support.ts`; +- Boxel's current custom-manager pairs in + `packages/host/app/lib/html-component.ts` and + `packages/host/app/lib/hydratable-entry-component.ts`; +- Ember's public `setComponentManager` and `capabilities` exports in + `packages/@ember/component/index.ts`; and +- Glimmer's public `ComponentManager` contract in + `packages/@glimmer/interfaces/lib/managers/component.d.ts` and its adapter in + `packages/@glimmer/manager/lib/public/component.ts`, plus the corresponding + helper and modifier contracts in that same package family. + +Those sources are the compatibility boundary. Glimmer VM implementation types +may explain the machinery, but they are not protocol vocabulary and must not +appear in Boxel's execution-runtime interfaces. + +The same architecture is presented at four zoom levels. A reader can stop +after the level that answers their question: + +1. **System overview** — the thirty-second model and non-negotiable rules. +2. **Runtime topology** — ownership and the complete Direct/Capsule/Sandbox + flows. +3. **Protocols and Glimmer mechanics** — the interfaces, records, handles, + reactivity, blocks, effects, mutations, and failure behavior. +4. **Implementation plan** — current-code mapping, migration sequence, + testing, performance instrumentation, and deletion criteria. + +--- + +## Zoom level 1: system overview + +### The architecture in one paragraph + +The Store owns the canonical card document. Each execution tier owns exactly +one executable copy of a module in its permitted environment. Every tier +exposes the same fixed Boxel semantic interface for loading card definitions, +creating instances from serialized documents, resolving fields, and selecting +formats. Direct and +Capsule rendering use one Host-owned Glimmer runtime; Direct component logic +runs in the Host while Capsule component logic remains behind stable handles +in SES. Sandbox rendering uses a Glimmer runtime inside an isolated iframe and +speaks the same semantic protocol over `MessageChannel`. Trusted Base and +Catalog components remain ordinary Ember/Glimmer programs loaded once in the +Host. Only inert values and named capabilities cross a trust boundary. + +```mermaid +flowchart LR + Store["Canonical Store\ndocuments and relationships"] + Semantic["Fixed Boxel semantic API\ntypes, fields, instances, formats"] + HostGlimmer["Host Glimmer\nDOM and trusted components"] + Direct["Direct runtime\ntrusted Host module"] + Capsule["Capsule runtime\nmodule in SES"] + Sandbox["Sandbox runtime\nmodule + Glimmer in iframe"] + + Store <--> Semantic + Direct --> Semantic + Capsule --> Semantic + Sandbox <-->|"typed MessageChannel"| Semantic + Semantic --> HostGlimmer + Direct --> HostGlimmer + Capsule -->|"template and component handles"| HostGlimmer +``` + +### Runtime matrix + +| Tier | One true executable module | Semantic owner | Glimmer/DOM owner | Trusted Base presentation | +| ------- | -------------------------------- | -------------- | ----------------- | ---------------------------------------------------------- | +| Direct | Host Loader | Host module | Host | Shared Host module graph | +| Capsule | Per-principal Compartment Loader | SES module | Host | Shared Host module graph through trusted component portals | +| Sandbox | Iframe Loader | Iframe module | Iframe | Loaded in the isolated child as allowed by child policy | + +“One true module” does not mean every module executes in the Host. It means a +consumer never mixes multiple partially reconstructed executable classes for +the same module generation: + +- trusted source has one executable Host module; +- Capsule source has one executable Compartment module; +- Sandbox source has one executable child module; +- the Host sees records and handles for untrusted modules, never a second live + constructor. + +### Two layers, not one giant sandbox service + +```text +Layer 1 — Boxel semantics + CardDef, FieldDef, fields, documents, getters, computeVia, + configuration, relationships, format selection, mutations + +Layer 2 — rendering + captured Glimmer templates, component lifecycle, reactive cells, DOM operations, + trusted component portals, events, blocks, modifiers, styles +``` + +The first layer is intentionally bounded because the Boxel Card API is +bounded. The second layer does not attempt to serialize or emulate arbitrary +Ember components. It lets trusted Base components execute natively and gives +untrusted component logic a Capsule component manager implemented with Ember's +public custom component manager API. + +### The `surface*` capability plane + +`surface*` is an explicit coordination plane attached to rendering. It is not +part of card schema, the Store, or type introspection, and it is not ambient +browser authority. + +```text +Authored API + @cardstack/boxel-ui/surface + surfacePresentation, surfaceObserve, surfaceFocus, surfacePointer, ... + | + v +Render-tier adapter + Direct/Capsule: trusted Glimmer token or modifier manager + Sandbox: child SurfaceCapabilityClient over MessageChannel + | + v +Host SurfaceService + registration, grants, validation, coordination, lifetime, cleanup + | + v +Host DOM/browser operation or coordinated notification +``` + +The Host service is the semantic owner. A mounted render generation gets +one Host-only `SurfaceRegistration` containing its root element, execution +identity, card, format, principal, grants, and cleanup scope. The card receives +only an inert surface id and the operations granted to that surface. Unmounting +or replacing the generation revokes the registration and all observers, +listeners, timers, captures, media coordination, and pending requests. + +The public API belongs in Boxel UI because it is authored presentation code. +The transport-neutral request/response schema belongs in a small +runtime-common protocol module. The implementation and all browser authority +belong in a Host `SurfaceService`. Direct and Capsule calls dispatch to it +locally; Sandbox calls reach the same service through the iframe protocol. +Network, Store, Realm search, persistence, AI, and command authority remain +separate capability families because they are scoped to data principals rather +than a mounted surface. + +The portable authored Surface vocabulary is finite and capability-specific. +There is no generic DOM request escape hatch: + +| API | Portable semantic | Important boundary rule | +| --------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `surfaceRoot` | stable mounted Surface identity and nested path | Host registers the honest root element; authored code receives only an opaque id | +| `surfaceLifecycle` | mount, readiness, suspend, resume, teardown | callbacks are bounded component-handle messages, never Host functions | +| `surfaceObserve` | size, visibility, and intersection records | returns frozen numeric/boolean records and owns observer cleanup | +| `surfaceFocus` | focus, focus return, and bounded scroll intent | requires a registered descendant target and mode-appropriate authority | +| `surfacePointer` | pointer stream, capture, drag activation | returns reduced pointer records; the browser event and DOM nodes remain Host-owned | +| `surfaceStyle` | validated dynamic geometry/style values | allowlists properties and typed values; it cannot inject selectors or URLs | +| `surfacePresentation` | header and container/background presentation | publishes validated presentation tokens; it does not clone arbitrary iframe body CSS | +| `surfaceTransition` | scoped view-transition intent and lifecycle | transition names are namespaced by Surface/render-slot identity | +| `surfaceSchedule` | bounded timers, animation ticks, pause/resume | Host owns clocks, quotas, cancellation, and background throttling | +| `surfaceClipboard` | user-activated copy/paste of typed Boxel placement data | no unrestricted clipboard read; payload re-enters the placement validator | +| `surfaceHaptics` | bounded success/error feedback | optional result only; denial cannot change command semantics | +| `surfaceSlot` | placement into an approved Host presentation slot | named slots only; never arbitrary Host selectors or component access | +| `surfacePlayback` | media intent, leader/lease, position, rate, sequence | group membership uses Host-issued ids and monotonic state, not guessed Surface ids | +| `surfaceViewport` | pan/zoom intent and effective viewport state | coordinate conversions are typed and scoped to one mounted viewport | +| `surfaceLayout` | intrinsic measurement and allocated rectangles | parent allocation wins for fitted content; intrinsic formats report bounded size | +| `surfaceCanvas` | safe canvas/graph drawing and input coordination | Capsule receives a reviewed drawing/graph contract; arbitrary browser libraries remain Sandbox-only | + +Foundation components consume this capability plane, but the first runtime +gate does not require every foundation Surface to ship at once. The cumulative +suite uses representative composites: RichMarkdown proves `Layout` and `Run`, +a data table proves `Table` and `Cell`, and a campaign board proves +`PosterBoard` and `Frame`. Further foundation components use the same contract +and add focused conformance coverage when they ship. Product content that is +or may become data-bound remains a Surface, including direct product children +of composite Surfaces. Raw HTML is limited to native local structure, +decoration, accessory chrome, or text inside an existing leaf Surface. + +### Data, commands, Realm Script, and asynchronous AI + +Long-running work does not belong in `surface*` and does not receive ambient +Host authority. A mounted Capsule or Sandbox may invoke a separately granted, +typed command. The Host owns command authorization, provider proxying, +credentials, file persistence, and Store writes. + +Realm Script is another confined execution principal, not an extension of the +rendering Capsule. It receives bounded JSON input and named Realm operations, +produces schema-validated JSON, and is subject to input/result byte limits, +operation budgets, cancellation, and wall-clock timeout. Preview mode cannot +write. Commit mode still routes each mutation through an authorized Host +operation. The invoking harness selects the model; Realm Script cannot read or +override provider credentials. + +The canonical asynchronous image path is: + +```text +Authored action + -> granted typed command + -> one canonical Run/Job card with contained stages and logs + -> capability-scoped Realm Script produces a validated generation plan + -> Host provider command dispatches bounded requests + -> Host binary-file command persists each successful result + -> Store links durable ImageDefs and advances progress + -> SSE/index messages acknowledge, but never overwrite, the active generation +``` + +Partial success is first-class. Results may finish out of order while their +requested slots remain stable. Cancellation, retry, timeout, duplicate +acknowledgement, and stale-generation rejection cannot erase already durable +outputs. UI surfaces show stable placeholders and each image as soon as it is +persisted; linting, indexing, and acknowledgement are subsequent state, not a +reason to hide or remount the local result. + +### Non-negotiable rules + +1. The Store remains canonical for card data and relationship identity. +2. User-authored TypeScript never executes in the Host. +3. Trusted Base component internals are not mediated; the whole component + invocation is a trusted portal. +4. A live Store, Loader, service, constructor, component instance, callback, + DOM node, Glimmer tag, or browser event never crosses an untrusted boundary. +5. Data crossing a boundary is bounded, validated, cloneable, and versioned. +6. Authority crosses only as a named, revocable capability whose Host handler + rechecks authorization. +7. DOM-operation interception is necessary but not sufficient: component, + helper, modifier, block, and dynamic-resolution paths are controlled too. +8. Unsupported protocol semantics fail atomically and retain last-known-good + UI instead of partially rendering an unknown record. +9. Execution-tier selection is Host policy. A card may request stronger + isolation but cannot request Direct execution. +10. Receiving a document, Surface registration, or render handle never grants + Realm search, mutation, network, command, AI, or credential authority. +11. Long-running work is represented by canonical Run/Job state; progress, + partial success, retry, and acknowledgement do not depend on keeping the + initiating component mounted. +12. Client-side authorization may only reduce the server-authorized Boxel + graph. It never grants access, and every read, search, relationship + traversal, Command, and mutation is independently enforced by the server. + +--- + +## Zoom level 2: runtime topology and ownership + +### Canonical ownership + +| Concern | Owner | What other layers receive | +| -------------------------------------------- | ----------------------------- | --------------------------------------------------------------- | +| Card documents and relationship identifiers | Store | Versioned document projection | +| Trusted Base/Catalog executable code | Host trusted Loader | Trusted component/type identity | +| User code selected for Capsule | Per-principal Capsule runtime | Semantic records, captured template bundles, component handles | +| User code selected for Sandbox | Isolated child runtime | Typed protocol responses and effects | +| DOM for Direct and Capsule | Host Glimmer | Nothing; DOM remains Host-owned | +| DOM for Sandbox | Iframe Glimmer | Size, presentation, readiness, and named effects | +| `surface*` coordination | Host `SurfaceService` | Inert ids, bounded requests, responses, and notifications | +| Guide cards and cascade order | Store + trusted Guide runtime | Resolved labels, constraints, defaults, visibility, field order | +| Annotation, Actor, and Workflow documents | Store | Authorized target/anchor/body/state projections | +| Run/Job progress and durable outputs | Store | Versioned progress, logs, links, terminal state | +| Realm Script planning | Confined script runtime | Schema-validated JSON result and bounded activity records | +| Provider IO, binary writes, and credentials | Authorized Host commands | Durable FileDef/ImageDef identifiers, never credentials | +| Authentication and server authorization | Realm Server | Already-authorized resources and an upper-bound decision record | +| BXL UI authorization projection | Host authorization service | Frozen capability and redacted Boxel projection | +| Source generations and last-known-good state | Host orchestration | Monotonic revision and acknowledgements | + +### The common semantic contract + +Host orchestration depends on the `BoxelRuntime` interface defined +below. Rendering is deliberately tier-specific because only Capsule needs to +bridge authored component logic into a different Glimmer owner. + +The ownership distinction is deliberate: + +```text +DirectBoxelRuntime + Implements BoxelRuntime over the trusted Host module. Rendering uses + the existing Card API getComponent() result directly. + +CapsuleBoxelRuntime + Implements BoxelRuntime in the principal's Compartment and exposes a + CapsuleComponentRuntime adapter consumed by Host Glimmer. + +SandboxBoxelRuntimeClient + Implements BoxelRuntime and Sandbox surface lifecycle over the + private port. It does not expose child Glimmer component state to the Host. + +SandboxBoxelRuntimeServer + Implements BoxelRuntime beside child Glimmer. The child uses its + local Card API getComponent() result directly. +``` + +The Sandbox names must contain `Client` and `Server`. Using the same ambiguous +class name on both sides makes it too easy to invoke child authority in the +parent or treat a transport object as a local Glimmer runtime. In particular, +`getContext()` is never an iframe RPC: it is called synchronously by the +component manager in the same JavaScript realm as that Glimmer instance. + +### Direct flow + +```mermaid +sequenceDiagram + participant Store + participant Host as Direct runtime + participant Glimmer as Host Glimmer + + Host->>Store: read canonical document + Host->>Host: import trusted CardDef/FieldDef + Host->>Host: loadCardDef + createFromSerialized + Host->>Glimmer: real component + stable model projection + Glimmer->>Store: named write capability + Store-->>Glimmer: persisted revision +``` + +The adapter is still important in Direct mode. It prevents Host features from +depending on constructor reflection that Capsule and Sandbox cannot reproduce. +Direct is the reference implementation of the semantic contract, not a bypass +around it. + +### Capsule flow + +```mermaid +sequenceDiagram + participant Store + participant Host + participant Capsule as Capsule semantic runtime + participant Glimmer as Host Glimmer + + Host->>Store: read canonical document + Host->>Capsule: createFromSerialized(resource, document, relativeTo, purpose) + Capsule-->>Host: instance handle + tracked Host context + Host->>Capsule: getComponent(instance, format) + Capsule-->>Host: component definition + captured template bundle + Host->>Glimmer: mount through Capsule component manager + Glimmer->>Capsule: invokeAction(component, action, safeEvent) + Capsule-->>Glimmer: CapsuleComponentUpdate + named effects + Glimmer->>Host: apply effects + Host->>Store: authorized canonical mutation +``` + +The Capsule has no DOM. It owns authored classes, getters, actions, component +state, and pure computation. Host Glimmer owns rendering and creates only the +DOM permitted beneath the assigned render root. + +Capsule runtimes are shared per realm principal rather than per card. Module +evaluation and pure runtime support are amortized across the realm, while +module identity and invalidation remain isolated from Base and other realms. + +### Sandbox flow + +```mermaid +sequenceDiagram + participant Store + participant Parent as Host Sandbox client + participant Child as Sandbox server + participant Glimmer as Child Glimmer + + Parent->>Store: read bounded document projection + Parent->>Child: init(protocol, grant, source, document, format) + Child->>Child: loadCardDef + createFromSerialized + Child->>Glimmer: render inside isolated document + Child-->>Parent: ready(type presentation, height, revision) + Glimmer->>Child: local action + Child-->>Parent: named effect or mutation request + Parent->>Store: reauthorize and persist + Store-->>Parent: persisted revision + Parent-->>Child: acknowledgement or rejection +``` + +The child can use a real browser document and packages such as Three.js. It +does not inherit parent credentials, Store access, or parent-origin storage. +The Host client implements the common runtime contract as asynchronous +transport operations. + +### Trusted Base components are portals + +Base components are arbitrary, evolving Ember/Glimmer/TypeScript programs. +They cannot be reproduced by copying static properties or proxying every +method. The boundary therefore treats an approved Base component as an atomic +trusted portal: + +```ts +interface TrustedComponentReference { + kind: 'trusted-component'; + module: string; + export: string; +} +``` + +When a Capsule template invokes that reference, Host Glimmer resolves and +runs the real component from the shared trusted module graph. Only its inputs +and outputs are mediated: + +```ts +interface TrustedFieldInvocation { + fieldType: CodeRef; + fieldName: string; + value: JSONValue; + configuration: JSONValue; + writable: boolean; + setCapability?: CapabilityID; +} +``` + +The Base component may use any trusted Host service internally. It must not +receive an authored function, live card instance, Store, or DOM value from the +Capsule. + +### Server indexing and prerender + +Realm Server execution is a consumer of the same semantic contract, not a +fourth browser execution tier. It materializes authorized computed values and +produces versioned prerender output. Browser Host code may use that output as: + +- initial Host-mode HTML; +- inert last-known-good display; +- an immediate placeholder while a Sandbox becomes interactive; +- the materialized source for iframe-only computed values that must not open a + browser merely to compute an index field. + +### Guides and durable collaboration + +A Guide is a canonical data card, not an authored callback surface. The trusted +Guide runtime resolves base, domain, realm, and inline layers and evaluates +JQXL against the authorized card projection. Direct, Capsule, and Sandbox +renderers receive the same bounded result: labels, helper text, constraints, +defaults, visibility, field order, and named Command affordances. They do not +receive the Guide engine, Store, or executable functions. + +An Annotation is also canonical Store data. Its target and typed anchor +(field, text range, Cell, image region, timeline cue, or another supported +anchor kind) cross a boundary only after the target is authorized. Body, +author Actor, assignee, state, replies, and workflow step remain stable Boxel +identities. Reply, resolve, assign, and advance are typed Commands; mounting an +Annotation never grants mutation or neighboring-target access. This makes +human-to-agent review durable across Direct, Capsule, and Sandbox rather than +recreating it as component-local comment state. + +Coordination ownership is also a Boxel semantic, not a property of the mounted +renderer. A single card may have an ordinary revisioned title, a Yjs-concurrent +rich-text field, Command-owned approval fields, temporarily frozen release +terms, and computed readiness. The Store and server project one bounded, +field-scoped coordination description for every execution tier. They never +send a Policy engine, Yjs document, Command executor, or live collaboration +service across the boundary. + +The invariant is one write owner per field path at a time. Ordinary revision +writes, collaborative materialization, ordered Commands, and admitted atomic +snapshots all commit field-scoped patches through the same canonical document +revision. Policy custody is episodic and minimal: paths not named by the +active, versioned Policy retain their declared ordinary behavior, and custody +ends when the bounded term lapses. Cursor, selection, focus, and presence are +ephemeral awareness and never become Store data. An Annotation or message may +propose or explain a transition, but only a separately admitted typed Command +may enact consequential state. + +AI source/data rewrites use snapshot compare-and-swap rather than pretending +to be CRDT operations. A current snapshot briefly fences affected bindings, +settles accepted collaborative updates, closes their epochs, installs the +candidate atomically, and starts new epochs for remaining collaborators. A +stale snapshot is rejected without overwriting official state. Published views +similarly observe two clocks: compatible instance data may follow the source +change feed, while code, schema, templates, theme, and projection policy stay +pinned until republish. Incompatible changes retain last-known-good output and +surface `republish-required`. + +### BXL authorization projects the usable Boxel graph + +The detailed companion contract is +[boxel-execution-runtime-authorization-projection.md](boxel-execution-runtime-authorization-projection.md). + +BXL authorization is a Boxel semantic and data-capability concern, not a +`surface*` capability. `surface*` coordinates a mounted visual surface; BXL +decides which data and operations may enter that surface at all. + +The clinical-access example demonstrates the intended shape. A patient record +links a policy, facility, people, and recursively nested teams. The policy +evaluates capabilities such as `ViewClinicalSummary`, `ViewInternalNotes`, and +`EditCarePlan` from a finite snapshot. A positive nested-team seat can grant +eligibility, while a separate `Seat.Suspended` refusal wins. Access to one +patient resource does not imply access to another. The current demo prepares +BXL and constructs the dashboard projection inside the card; the runtime +architecture moves that repeated work into a Host-owned +`BoxelAuthorizationService`. + +The resource or owning application may link a BXL policy card, but that policy +reference is an input to the Host service, not authority handed to authored +code. The service resolves the authorized policy version, principal, resource, +bounded relationship graph, and request inputs; evaluates BXL synchronously +when a safe client snapshot is available; and returns a frozen projection. It +does not send the policy evaluator, live Store, hidden membership graph, or +unprojected resource values into a Capsule or Sandbox. + +```text +resource + linked policy + principal + request input + | + v + BoxelAuthorizationService + server upper bound ∩ client BXL decisions + | + v + authorization projection + capabilities + visible Boxel graph + | + v + buildBoxelRenderRecord() + | + Direct / Capsule / Sandbox +``` + +"Client-side filtering" is therefore implemented as **client-side +projection**. Filtering after materialization is too late: a denied field must +not briefly appear, become a loading placeholder, affect a count, leak a title +or URL, or cross an execution boundary. Projection can remove: + +- field values and FieldDef render slots; +- relationships, children, and query results; +- formats and presentation sections; +- Guide affordances and menu items; and +- Commands and mutation paths. + +The server remains the security boundary. It must not send secrets merely so +the client can hide them, and it rechecks every fetch, search, relationship +traversal, Command, and mutation. A locally evaluated BXL result may only +intersect with and reduce the server-authorized upper bound; it cannot widen +it. Missing, stale, incompatible, or failed authorization projection fails +closed while preserving a non-sensitive last-known-good shell. Explicit BXL +refusal wins over positive eligibility in both client projection and server +enforcement. + +Authorization changes are targeted semantic invalidations. Changing the +viewer, policy version, membership, resource state, or request input rebuilds +the affected projection and render slots without remounting unrelated Boxels. +The same projection record is consumed by Direct, Capsule, Sandbox, Code +preview, delegated rendering, Rich Markdown, fitted galleries, inspector +schema, and AI schema sharing, so a different execution tier cannot restore a +field or action that projection removed. + +--- + +## Zoom level 3: protocols and Glimmer mechanics + +### Semantic interface + +```ts +type RuntimeHandle = string & { readonly __runtimeHandle: unique symbol }; +type BoxelTypeHandle = RuntimeHandle & { readonly __boxelType: unique symbol }; +type CardInstanceHandle = RuntimeHandle & { + readonly __cardInstance: unique symbol; +}; + +/** + * The execution-tier-neutral subset of the existing Card API. + * + * Implementations execute these operations in Direct, Capsule, or Sandbox. + * Values returned to the caller are handles or cloneable records; live CardDef + * classes and instances never cross an untrusted boundary. + */ +export interface BoxelRuntime { + loadBoxel(ref: CodeRef): Promise; + + createFromSerialized( + resource: LooseCardResource, + document: LooseSingleCardDocument | CardDocument, + relativeTo: RealmResourceIdentifier | undefined, + purpose: MaterializationPurpose, + ): Promise; + + describeBoxel(boxel: BoxelTypeHandle): Promise; + + getFields( + boxel: BoxelTypeHandle | CardInstanceHandle, + ): Promise; + + getField( + boxel: BoxelTypeHandle | CardInstanceHandle, + fieldName: string, + ): Promise; + + serializeCard( + card: CardInstanceHandle, + options: SerializeOpts, + ): Promise; + + serializeCardPatch( + card: CardInstanceHandle, + changes: Record, + ): Promise; + + dispose(handle: RuntimeHandle): Promise; +} +``` + +`MaterializationPurpose` is explicit. Indexing, Host display, command +validation, Code preview, and interactive editing do not receive authority +merely because they all call `createFromSerialized()`. + +`serializeCardPatch()` is intentionally new rather than overloading +`serializeCard()`. The existing function serializes a complete card document; +the new operation accepts named edits and returns only canonical `PatchData`. +It never treats the richer render projection as a writable document. + +### Type and field descriptions + +The template inventory is open-ended. Formats shown here are examples, not an +exhaustive union embedded in the boundary record. + +```ts +export interface BoxelDescription { + protocolVersion: number; + requiredFeatures: string[]; + ref: CodeRef; + boxelKind: string; + ancestors: CodeRef[]; + fields: FieldDescription[]; + formats: FormatDescription[]; + presentation: TypePresentation; + executionHints: { + prefersFullSandbox: boolean; + }; +} + +export interface FormatDescription { + format: string; + provider: + | { kind: 'authored'; ref: CodeRef } + | { kind: 'trusted-base'; ref: CodeRef }; +} + +export interface ResolvedField { + fieldName: string; + fieldType: CodeRef; + kind: 'contains' | 'containsMany' | 'linksTo' | 'linksToMany'; + value: JSONValue; + resolvedConfiguration: JSONValue; + presentation: Record; + writable: boolean; +} +``` + +Executable configuration providers run with their semantic owner. A Capsule +configuration function runs in the Capsule and returns validated data. The +Host never attempts to copy the function or rediscover field-specific statics +such as currency symbols. Trusted Base presentation consumes the resolved +record. + +### Canonical Boxel render projection + +Authorization has two records. The first is Host-internal input to the policy +service; the second is the cloneable result that render consumers may receive. +Capability names remain domain policy vocabulary rather than a hard-coded +clinical or music union. + +```ts +export interface BoxelAuthorizationRequest { + policy: CardIdentifier; + principal: string; + resource: CardIdentifier; + resourceRevision: string; + requestedCapabilities: string[]; + input: Record; +} + +export interface BoxelAuthorizationProjection { + protocolVersion: number; + policy: CardIdentifier; + policyRevision: string; + principal: string; + resource: CardIdentifier; + resourceRevision: string; + inputHash: string; + capabilities: Record< + string, + { effect: 'allow' | 'refuse' | 'not-applicable'; reasonCode?: string } + >; + visibleFields: string[]; + visibleRelationships: string[]; + visibleFormats: string[]; + visibleSections: string[]; + availableCommands: string[]; +} +``` + +The Host may expose the frozen result through render context for custom +section composition, but this is descriptive state, not an authorization +closure. Generic Base templates consume the same field, relationship, format, +and Command lists without requiring card-specific conditionals. Explanations +use bounded reason codes; sensitive policy traces and membership paths remain +Host/server diagnostic data. + +All tiers consume one record assembled by a pure Host pipeline. Boxel normally +uses `build*` for a pure record assembler, so the target name is +`buildBoxelRenderRecord()`: + +```ts +buildBoxelRenderRecord({ + canonicalDocument, + boxelType, + projectedValue, + relationshipProjection, + authorizationProjection, + cardInfoProjection, + presentationProjection, + policy, +}): BoxelRenderRecord; +``` + +The record contains no capability closures. Host-only capabilities are stored +separately and keyed by execution identity, Boxel identity, format, grant, and +lifetime. `canonicalDocument` is present for persisted cards; FieldDef, +FileDef, and future Boxel kinds can project a value without pretending to be a +card document. + +### Mutation is a reverse projection + +The detailed companion contract is +[boxel-execution-runtime-mutation-protocol.md](boxel-execution-runtime-mutation-protocol.md). + +The rendered projection is deliberately richer than the writable document. +It may contain computed values, expanded relationships, presentation values, +and side-loaded data. It must never be sent back as a card PATCH. + +```text +rendered projection + | user edit + v +named field changes + | semantic runtime serializeCardPatch() + v +canonical attributes + relationship identifiers + | Host authorization and JSON-API validation + v +Store PATCH +``` + +`serializeCardPatch()` rejects computed fields, presentation-only values, +foreign side-loaded resources, unknown relationships, and fields outside the +current write grant. + +### Capsule component runtime + +```ts +type CapsuleComponentHandle = RuntimeHandle & { + readonly __capsuleComponent: unique symbol; +}; + +type CapsuleComponentInstanceHandle = RuntimeHandle & { + readonly __capsuleComponentInstance: unique symbol; +}; + +export interface CapsuleComponentDefinition { + component: CapsuleComponentHandle; + template: CapturedTemplateBundle; +} + +/** + * Resolves Capsule components and implements their authored lifecycle. + * + * The Host's Glimmer ComponentManager delegates to this interface. Direct and + * Sandbox rendering use their local Card API components and do not implement + * this cross-owner bridge. + */ +export interface CapsuleComponentRuntime { + getComponent( + card: CardInstanceHandle, + format: string, + ): Promise; + + createComponent( + definition: CapsuleComponentDefinition, + args: RenderArguments, + ): CapsuleComponentInstanceHandle; + + getContext(component: CapsuleComponentInstanceHandle): ComponentContext; + + updateComponent( + component: CapsuleComponentInstanceHandle, + args: RenderArguments, + ): CapsuleComponentUpdate; + + invokeAction( + component: CapsuleComponentInstanceHandle, + action: string, + event: SafeEvent, + ): Promise; + + destroyComponent(component: CapsuleComponentInstanceHandle): void; + dispose(handle: RuntimeHandle): Promise; +} +``` + +`createComponent`, `getContext`, `updateComponent`, and `destroyComponent` +deliberately match Ember's public custom component manager hooks. Boxel's +manager delegate has `capabilities('3.13', { updateHook: true, destructor: +true })`; it must not implement or expose Glimmer's internal `getSelf`, +`getDestroyable`, VM `Program`, or state-bucket APIs. + +The lifecycle hooks are synchronous because Ember's public manager contract is +synchronous. Any module loading, template capture, or policy negotiation must +finish in `getComponent()` before Glimmer receives the definition state. The +manager's `getContext()` returns a stable Host object backed by tracked cells; +it never waits on the Capsule or returns a Promise to the VM. + +Boxel supplies `_CapsuleComponent` as the definition object passed to +`createComponent()`. `CapsuleComponentState` is the manager's private state +bucket. Neither object is serialized, stored in the Card boundary record, or +exposed to card authors. + +### Captured template bundles + +A captured template bundle contains validated Glimmer wire data and explicit +references. It never contains an executable authored closure in Host memory. + +```ts +export interface CapturedTemplateBundle { + protocolVersion: number; + root: TemplateHandle; + templates: Record; + stylesheets: StylesheetReference[]; + dependencies: RenderDependency[]; +} + +type RenderDependency = + | TrustedComponentReference + | SandboxComponentReference + | TrustedHelperReference + | SafeModifierReference + | SandboxBlockReference; +``` + +The Host validates the bundle before Glimmer sees it. Unknown required +dependency kinds reject the complete generation. + +Glimmer needs both a component manager and a template factory. For every +validated entry in the bundle, the Host creates one private component +definition object, associates the common Capsule manager through its +prototype, and calls `setComponentTemplate()` exactly once on that definition +object: + +```ts +import { setComponentTemplate } from '@ember/component'; +import type { ComponentLike } from '@glint/template'; + +type TemplateFactory = Parameters[0]; + +type CapsuleComponent = ComponentLike<{ + Args: Record; + Element: Element; +}>; + +function createCapsuleComponentDefinition( + runtime: CapsuleComponentRuntime, + definition: CapsuleComponentDefinition, + template: TemplateFactory, +): CapsuleComponent { + let component = new _CapsuleComponent(runtime, definition); + setComponentTemplate(template, component); + return component as unknown as CapsuleComponent; +} +``` + +This is supported by Glimmer's public API: manager and template lookup both +walk the definition object's prototype chain, while a template may be +associated directly with an object. The Host caches the resulting definition +by runtime identity, component handle, source hash, and captured-template +signature. It never calls `setComponentTemplate()` twice on the same object, +which Glimmer explicitly rejects in debug builds. + +A template factory is immutable for the lifetime of that definition. An HMR +generation that changes only component state or arguments reuses the +definition. A generation that changes the captured template creates a new +definition; the stable outer template island then adopts compatible serialized +DOM or replaces only that island. The public custom component manager API does +not provide a mutable-layout hook, so the architecture must not claim that the +manager alone can replace a template while preserving DOM. + +### Host-owned Glimmer bridge + +Capsule component logic stays in SES while Host Glimmer owns DOM and +reactivity. A custom component manager performs the bridge: + +1. create a Capsule component instance and receive a stable handle; +2. create Host-owned cells for cloneable component state and getters; +3. render its validated captured template bundle; +4. dispatch actions to the Capsule with a reduced event record; +5. apply returned cell changes and dirty only the corresponding Glimmer tags; +6. execute named Host effects after policy checks; +7. destroy the component state on teardown. + +```ts +export interface CapsuleComponentUpdate { + generation: number; + componentRevision: number; + changed: Record; + effects: SurfaceEffect[]; +} +``` + +Host-owned cells—not SES objects or tags—connect the component to Glimmer. +Synchronous action updates return in `CapsuleComponentUpdate`. Asynchronous +authored changes use a bounded invalidation channel naming a component handle +and changed paths; they never receive a Host Glimmer tag. + +The manager itself should look ordinary to an Ember reviewer. The transport +and policy machinery stays behind `CapsuleComponentRuntime`: + +```ts +import { capabilities, setComponentManager } from '@ember/component'; + +type ComponentManager = ReturnType[0]>; +type ComponentArguments = Parameters[1]; + +class _CapsuleComponent { + constructor( + readonly runtime: CapsuleComponentRuntime, + readonly definition: CapsuleComponentDefinition, + ) {} +} + +class CapsuleComponentState { + constructor( + readonly runtime: CapsuleComponentRuntime, + readonly handle: CapsuleComponentInstanceHandle, + ) {} +} + +class CapsuleComponentManager implements ComponentManager { + capabilities = capabilities('3.13', { + destructor: true, + updateHook: true, + }); + + static create(_owner: unknown) { + return new CapsuleComponentManager(); + } + + createComponent(definition: _CapsuleComponent, args: ComponentArguments) { + let handle = definition.runtime.createComponent( + definition.definition, + renderArguments(args), + ); + return new CapsuleComponentState(definition.runtime, handle); + } + + getContext(component: CapsuleComponentState) { + return component.runtime.getContext(component.handle); + } + + updateComponent(component: CapsuleComponentState, args: ComponentArguments) { + applyComponentUpdate( + component.runtime.updateComponent( + component.handle, + renderArguments(args), + ), + ); + } + + destroyComponent(component: CapsuleComponentState) { + component.runtime.destroyComponent(component.handle); + } +} + +setComponentManager( + (owner) => CapsuleComponentManager.create(owner), + _CapsuleComponent.prototype, +); +``` + +`renderArguments()` and `applyComponentUpdate()` in this sketch are private +adapter helpers. They reduce Glimmer's stable argument proxy to the bounded +runtime argument shape and apply returned values to Host-owned tracked cells. +They are not Card API methods, Glimmer extensions, or author-facing +capabilities. + +This follows Boxel's existing `HTMLComponentManager` and +`HydratableEntryComponentManager` pattern: an internal component definition +value, a small manager delegate, `capabilities('3.13', ...)`, and +`setComponentManager()` on the definition prototype. The detailed runtime +does not pretend to be a Glimmer VM `InternalComponentManager`. + +### Components, helpers, and modifiers + +DOM interception alone is not a sufficient boundary. Resolution is also +explicit: + +- trusted component references resolve through the trusted Host Loader; +- authored component references resolve through the Capsule component manager; +- trusted helpers receive cloneable arguments and return cloneable values; +- authored helpers execute in the Capsule through handles; +- safe modifiers are named Host capabilities with reviewed argument/result + schemas and teardown; +- raw Ember modifiers from user code require Sandbox routing; +- dynamic component resolution accepts only a validated reference produced by + the semantic runtime. + +Boxel should use each public Glimmer manager for its actual purpose rather than +inventing one universal "sandbox manager": + +| Glimmer seam | Public hooks Boxel implements | Boundary rule | +| ----------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Component manager (`3.13`) | `createComponent`, `getContext`, optional `updateComponent`, `destroyComponent` | Host state bucket contains only runtime/handle/cell references; authored instance stays in Capsule | +| Helper manager (`3.23`, `hasValue`) | `createHelper`, `getValue`, optional destroyable | Capsule helper returns a cloneable value through a helper handle; Host-owned tracking invalidates it | +| Modifier manager (`3.22`) | `createModifier`, `installModifier`, `updateModifier`, `destroyModifier` | The trusted Host manager owns the `Element`; Capsule receives only validated arguments and named `surface*` operations | +| Component template | `setComponentTemplate` once per definition object | Host attaches a validated `TemplateFactory`; authored code never receives the factory or wire data back | + +All of these hooks are synchronous. Module loads, dependency resolution, and +policy checks happen before Glimmer resolves the definition. A helper or +modifier that cannot satisfy the bounded synchronous contract routes the +format to Sandbox rather than blocking the Host render transaction. The +manager state buckets are destroyable lifetime records, not serialized Boxel +state. + +### DOM and CSS boundary + +Host Glimmer uses a sandbox-aware owner/resolver and DOM policy beneath a +stable render root. The policy covers: + +- permitted elements and attributes; +- URL-bearing attributes and protocols; +- event listener ownership; +- literal and dynamic style values; +- stylesheet scoping, network-bearing declarations, global rules, and + keyframes; +- lifecycle cleanup and reference-counted stylesheet removal. + +The DOM policy complements source classification and captured-template +validation. No one layer is treated as the complete security boundary. + +### Blocks and yields + +No live block closure crosses between trusted and untrusted execution. + +```ts +interface SandboxBlockReference { + kind: 'sandbox-block'; + handle: TemplateHandle; + parameters: JSONValue[]; +} +``` + +When a trusted Base component yields, the Host projects and validates the +yielded parameters, then asks the Capsule component manager to render the +captured authored block. A block cannot recover the trusted component +instance, owner, Store, or original event through its parameters. + +### Events and effects + +Browser events are reduced to an allowlisted record: + +```ts +interface SafeEvent { + type: string; + key?: string; + code?: string; + value?: string; + checked?: boolean; + clientX?: number; + clientY?: number; + dataset?: Record; +} +``` + +Imperative behavior becomes a named effect rather than a method on a live +event or element. Examples include field mutation, navigation, focus, scroll, +selection, measurement, presentation, media, and playback. Each effect defines +its argument schema, result schema, lifetime, cleanup, and authorization rule. + +### `surface*` registration, dispatch, and coordination + +`surface*` capabilities are mounted-render capabilities. They do not belong on +`BoxelRuntime`, because loading a CardDef or creating a card instance +must not allocate DOM authority. `CapsuleComponentRuntime.createComponent()` +receives a Host-created surface context, and the Glimmer bridge makes its +granted author API available to the rendered component. + +The Host-only registration is approximately: + +```ts +interface SurfaceRegistration { + surfaceId: string; + executionId: string; + renderSlotId: string; + principal: string; + cardId: string; + format: string; + generation: number; + root: Element; + grants: ReadonlySet; + abort: AbortController; +} +``` + +`root`, `grants`, and `abort` never cross the boundary. The transport record is +smaller: + +```ts +interface SurfaceCapabilityRequest { + protocol: 'boxel-surface-capabilities/1'; + requestId: string; + surfaceId: string; + generation: number; + capability: string; + operation: string; + args: JSONValue; + activation?: string; +} +``` + +The injected Host service owns the operation boundary. Boxel services are +default-exported concrete classes, so its public declaration follows that +convention rather than introducing a parallel `SurfaceCoordinator`: + +```ts +/** Host authority for capabilities attached to a mounted card surface. */ +declare class SurfaceService extends Service { + registerSurface(registration: SurfaceRegistration): void; + unregisterSurface(surfaceId: string, generation: number): void; + + request( + request: SurfaceCapabilityRequest, + ): Promise; + + notify(notification: SurfaceCapabilityNotification): void; +} + +export default SurfaceService; +``` + +The implementation is an ordinary concrete Ember service in +`services/surface-service.ts`; the declaration shows only its relevant public +contract. + +`registerSurface()` and `unregisterSurface()` are Host-only. The author-facing +module exports capability-specific names such as `surfacePresentation`; it +does not expose this service or a generic `request()` escape hatch. The +Sandbox client serializes the same typed request that the Direct and Capsule +adapters pass locally. + +The proposed code ownership is: + +| Piece | Location and responsibility | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Author imports | `@cardstack/boxel-ui/surface`; typed modifiers/helpers/services with no Host objects | +| Protocol records | A runtime-common `surface-capability-protocol` module shared by Host and iframe child | +| Host semantic owner | `SurfaceService`; registrations, grants, validation, rate limits, coordination state, cleanup | +| Direct adapter | Trusted Boxel UI implementation dispatches to the service for consistent semantics | +| Capsule adapter | Trusted Glimmer token/modifier manager dispatches directly to the service; callbacks return through Capsule component handles | +| Sandbox adapter | Child `SurfaceCapabilityClient` sends requests over the private port; parent validates and dispatches to the service | + +Cross-card features such as synchronized playback are `SurfaceService` +concerns. They use a Host-issued coordination-group id and monotonic sequence/clock +state; cards cannot join a group by guessing another surface id. Pan/zoom, +selection, presentation, and playback can share this envelope while retaining +separate operation schemas and grants. A service notification contains only +bounded state such as `{ playing, position, rate, sequence }`, never a +media element or controller object. + +Direct rendering should use the same public `surface*` semantics even though +it could touch the DOM directly. This makes behavior portable across tiers and +keeps a later Capsule/Sandbox classification change from changing the authored +contract. Trusted Host-internal components remain free to use private Host APIs +when they are not presenting an author-facing portable surface contract. + +### Generation, HMR, and stable identity + +Source generation, semantic instance identity, component identity, and DOM +identity are separate: + +- a source hash identifies compilation and classification cache entries; +- a monotonic generation identifies a proposed module update; +- stable instance/component handles preserve state across compatible updates; +- stable render-slot keys preserve Glimmer and DOM identity; +- server/index echoes acknowledge an already-rendered generation; +- incompatible template bundles or explicit Reload Card create a new component + generation; +- failed generations keep the last-known-good template bundle mounted. + +Compatibility is decided by an explicit captured-template signature, not by +whether two generated JavaScript classes happen to be referentially equal. + +### Protocol negotiation and failure behavior + +Semantic records and iframe transport have independent protocol versions. +Every message or record includes: + +- protocol version; +- required features; +- execution identity; +- card/instance identity; +- source generation; +- request correlation id where applicable. + +An unsupported required feature rejects the complete operation. The Host keeps +last-known-good content visible, surfaces one actionable diagnostic, and does +not silently substitute `undefined` for a missing semantic. + +### Resource lifetime + +Every handle has an owner and release point. Runtimes track: + +- module generations; +- instances; +- component instances; +- captured templates; +- blocks; +- style references; +- effect subscriptions; +- pending requests. + +Per-principal Capsule runtimes are retained while they have active consumers +and evicted after an idle TTL. Settled loads and released handles are removed +immediately; runtime eviction is not the only cleanup mechanism. + +--- + +## Zoom level 4: implementation and migration plan + +### Frozen reference branch mapping + +The reference branch is evidence and a source of focused tests, policies, and +proven algorithms. It is not the base of the production implementation. The +table maps its working POC responsibilities so that each can be deliberately +covered, ported, or replaced on the new main-based branch. + +| Target responsibility | Current starting point | +| ---------------------------------------------- | --------------------------------------------------------------------------------- | +| Capsule module evaluation and Card API facade | `packages/host/app/lib/realm-compartment-module-runtime.ts` | +| Per-principal runtime lifetime | `packages/host/app/lib/realm-sandbox-runtime-registry.ts` | +| Opaque records and symbols | `packages/host/app/lib/realm-sandbox-boundary.ts` | +| Orchestration, classification, projection, HMR | `packages/host/app/services/realm-sandbox.ts` | +| Common render entry | `packages/host/app/components/card-renderer.gts` | +| Capsule rendering | `packages/host/app/components/realm-sandbox-render.gts` | +| Stable render island | `packages/host/app/components/realm-sandbox-template-island.gts` | +| Delegated trusted rendering | `packages/host/app/components/realm-sandbox-delegated-render.gts` | +| Contextual field rendering | `packages/host/app/lib/realm-sandbox-field-component.gts` | +| Sandbox parent component | `packages/host/app/components/realm-sandbox-iframe.gts` | +| Sandbox transport | `packages/host/app/lib/realm-iframe-sandbox-protocol.ts` | +| Sandbox child application | `packages/host/app/templates/realm-sandbox-frame.gts` | +| Height and media capabilities | `realm-iframe-height-service.ts`, `realm-iframe-media-bridge.ts` | +| First author-facing surface capability | `packages/boxel-ui/addon/src/surface.gts` and `modifiers/surface-presentation.ts` | +| Current Capsule/Sandbox presentation adapters | `realm-sandbox-render.gts` and `realm-sandbox-frame.gts` | +| Stylesheet policy/lifetime | `realm-sandbox-styles.ts` service and modifier | + +The legacy `RealmSandboxService` combines several of these concerns. Its name +describes the POC rather than the target architecture: Realm is a server-side +data/module location, not a Host execution service. Do not copy this service +and split it afterward. Port each proven behavior directly into its target +owner only when the corresponding vertical slice reaches it. + +### Target module and symbol names + +These are the intended names once behavior has moved behind the interfaces. +They follow Boxel's lower-kebab-case module names, named library exports, and +default-exported Ember service classes. + +| Module | Principal exports | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/runtime-common/boxel-execution-protocol.ts` | Cloneable `BoxelDescription`, `FieldDescription`, `ResolvedField`, handle ids, protocol requests, responses, and version checks; no Ember imports | +| `packages/host/app/lib/boxel-runtime.ts` | `BoxelRuntime` and shared semantic handles | +| `packages/host/app/lib/capsule-component-runtime.ts` | `CapsuleComponentRuntime`, `CapsuleComponentDefinition`, and Capsule component handles; no iframe transport | +| `packages/host/app/lib/direct-boxel-runtime.ts` | `DirectBoxelRuntime` | +| `packages/host/app/lib/capsule-boxel-runtime.ts` | `CapsuleBoxelRuntime`; selectively ports proven evaluator behavior without importing the legacy orchestration service | +| `packages/host/app/lib/sandbox-boxel-runtime-client.ts` | `SandboxBoxelRuntimeClient`; parent-side `BoxelRuntime`, surface lifecycle, correlation, and transport only—never Host Glimmer state | +| `packages/host/app/lib/capsule-component.ts` | `_CapsuleComponent`, `CapsuleComponentManager`, and its Glimmer definition/state wrappers | +| `packages/host/app/services/surface-service.ts` | default `SurfaceService extends Service`; mounted-surface registrations and browser capabilities | +| iframe child module beside `realm-sandbox-frame.gts` | `SandboxBoxelRuntimeServer`; child dispatch only | + +Do not introduce a `SandboxBoxelEnvironment`, `RemoteGlimmerProgram`, or other +parallel vocabulary. `Boxel*` identifies the cross-kind semantic boundary; +`Card*` is reserved for persisted Card documents and card-specific operations; +`Capsule*` and `Sandbox*` identify execution policy; and Glimmer lifecycle names +appear only on the component manager that actually implements them. + +### Phase 0: freeze the reference and establish evidence + +Create the delivery branch from `origin/main` and stop adding features to the +reference branch. Before implementation: + +1. preserve a reachable reference preview and record its commit; +2. port the deterministic compatibility fixtures and boundary assertions before + porting runtime code; +3. record Direct, Capsule, and Sandbox cold/steady render timings; +4. count module evaluations, cache hits, component remounts, DOM replacements, + stylesheet installations, and active handles; +5. capture current green/red corpus results and representative screenshots; +6. freeze `Direct`, `Capsule`, and `Sandbox` vocabulary in diagnostics and + tests; and +7. prohibit new constructor introspection in Host consumers. + +The reference branch is an oracle, not a dependency. A delivery test passes +only when it proves the intended semantic and visible behavior; matching an +accidental POC implementation detail is not a goal. + +### Phase 1: ship the semantic spine through Direct + +Add versioned `BoxelRuntime` records, `ResolvedField`, stable Boxel/render-slot +identity, and `buildBoxelRenderRecord()` without changing visible rendering. +Implement `DirectBoxelRuntime` over the existing trusted Loader and Card API. + +The canonical pipeline includes: + +- inherited field metadata and configuration; +- per-usage configuration functions; +- getters, `computeVia`, and their dependency/error state; +- relationship identity and bounded hydration; +- `cardInfo`, theme, title, description, thumbnail, and presentation; +- an open-ended authored format inventory plus trusted Base fallback slots; +- writability and mutation schema; and +- reserved authorization and capability-projection fields, even though their + services are implemented later. + +Add a development-only missing-path diagnostic proxy. Move one Host consumer +at a time from constructor reflection to the adapter. Direct is the executable +conformance oracle: if a semantic cannot be expressed here, the interface is +incomplete before Capsule or Sandbox work begins. + +This is the first independently mergeable slice. Its target is approximately +2,000–3,000 production lines across 10–20 files plus focused protocol, Direct, +and visual conformance tests. + +### Phase 2: deliver Capsule and Sandbox as one useful vertical slice + +Phase 2 is one milestone with three inseparable parts: the minimum Host +capability broker, the Capsule adapter, and the Sandbox adapter. Implementing +only the broker, only Capsule, or only Sandbox does not count as a useful +execution runtime. The same bounded authored fixture must execute through +Direct, Capsule, and Sandbox before this phase is complete. + +First create the Host `SurfaceService`, execution identity, registration +lifetime, and shared request/response/notification envelopes. Do not implement +the full `surface*` catalog. The initial capabilities are only those required +for honest ordinary rendering: + +- `surfacePresentation` for header/container intent; +- `surfaceLayout` for intrinsic and parent-allocated sizing; +- `surfaceObserve` for bounded size/visibility records; and +- the trusted modifier/manager path needed to bind those capabilities to the + real Host element. + +Direct uses a local dispatcher, Capsule uses trusted Glimmer managers, and +Sandbox uses a private `MessageChannel`; all three target the same Host-owned +service methods. Data access, authorization, and mutation remain outside +`SurfaceService`. + +Then implement both untrusted adapters: + +- `CapsuleBoxelRuntime` owns per-principal SES module lifetime, + source/import classification, stable component handles, deterministic + teardown, and the Host Glimmer component bridge; +- `SandboxBoxelRuntimeClient` owns parent-side correlation, capability + admission, lifecycle, and last-known-good placeholder state; and +- `SandboxBoxelRuntimeServer` owns the origin-isolated iframe's child Loader, + authored module, Glimmer runtime, and DOM. + +Selectively port proven evaluator, classifier, and transport behavior from the +reference branch; do not port `RealmSandboxService`. The vertical fixture must +prove, in all three tiers: + +1. text, tracked state, one action, scoped CSS, and asset loading; +2. a trusted Base field/component rendered as a native Host portal; +3. a yielded block, helper, and safe modifier through explicit managers; +4. FieldDef/CardDef/FileDef invocation records and field configuration; +5. contained and linked relationships plus delegated nested rendering; +6. `surfacePresentation`, intrinsic and allocated `surfaceLayout`, readiness, + and observation; and +7. isolated, embedded, fitted, atom, head, edit, and Markdown fallback behavior + sufficient to prove format routing and composition. + +The Sandbox path must additionally prove origin isolation, a transferred +private port, a persistent compatible child, an immediate non-interactive +prerender placeholder, and zero fallback to Direct after child failure. +Semantic and transport protocol versions remain separate. + +Support explicit `prefersFullSandbox` and classifier-required browser globals. +Classification remains module-based: if one executable module imports a +browser-global dependency, every format defined by that module is +Sandbox-classified. Authors split safe and browser-dependent formats into +separate modules to recover Capsule execution. Safe atom, head, fitted, or +Markdown formats must not pay for an iframe merely because another module in +the same card family requires one. + +Special-case field/component shims are accepted only as temporary compatibility +adapters around canonical records. Phase 2 exits only when tier diagnostics +prove the intended route, visible output and the authored action agree across +Direct/Capsule/Sandbox, nested delegated rendering crosses mixed boundaries, +and neither untrusted adapter receives a live Store, Loader, service, CardDef +instance, or ambient Host object. + +The cumulative Phase 1–2 target is approximately 6,000–9,000 production lines +plus focused protocol, security, visual, and interaction tests. This is the +first milestone called a useful new execution runtime; it is not yet full +editing or HMR parity. + +### Phase 3: make editing canonical across every adapter + +Implement the companion mutation protocol: write grants, +`serializeCardPatch()`, edit-session/generation identity, optimistic overlays, +structured rejection, and matching server/index acknowledgements. + +Test primitive, compound, contained, contained-many, linked, and linked-many +edits. Test read-only denial, side-loaded-data pruning, relationship +replacement, stale revision rejection, save/reload identity, and no transient +read-only flash. Direct, Capsule, and Sandbox use the same request/result +semantics; no tier receives a Store-write shortcut. + +### Phase 4: restore preview-speed behavior on stable identities + +Add module volatility, source generations, source-hash classification and +transpilation caches, last-known-good output, persistent render islands, and +server acknowledgement handling. + +Monaco, AI patches, Boxel CLI writes, and other out-of-band module updates enter +the same generation coordinator. Compatible CSS/template updates preserve the +render slot and authored DOM; an incompatible generation or explicit Reload +Card remounts deliberately. Matching SSE/index events acknowledge current state +and never restore an older generation. + +Cover rapid file navigation, format switching, new/broken GTS recovery, +prerender handoff, and Code/Interact parity without making file trees or Monaco +wait for preview execution. + +### Phase 5: add further `surface*` capabilities one at a time + +Only after Capsule and Sandbox both exercise the shared capability broker, add +viewport, playback, canvas, transitions, focus/pointer coordination, clipboard, +slots, scheduling, and other capabilities required by the cumulative suite. + +Each capability requires: + +1. public Boxel UI types and author semantics; +2. request, response, and notification schemas; +3. Direct, Capsule, and Sandbox adapters where meaningful; +4. grant, rate-limit, user-activation, lifetime, and cleanup rules; and +5. positive cross-tier behavior tests and negative authority tests. + +This avoids designing a large Surface API without real boundary pressure while +also avoiding one-off iframe bridges. + +### Phase 6: add Host-owned BXL authorization projection + +Implement `BoxelAuthorizationService` using the projection slot reserved in +Phase 1. Initially it may consume an explicitly linked policy card and bounded +finite snapshot matching the clinical-access example: + +1. the server supplies the authoritative upper bound and independently + enforces every operation; +2. the Host resolves the policy card and evaluates client BXL only over data + the principal is already allowed to receive; +3. local decisions can only reduce the server upper bound; +4. `buildBoxelRenderRecord()` omits denied values and slots before rendering; +5. Direct, Capsule, Sandbox, inspector, query, and AI consumers receive the + same frozen projection; and +6. policy/membership/input changes invalidate only affected projection keys. + +Test nested usersets, `via(...)`, capability composition, request inputs, +resource-scoped grants, explicit refusal, stale policy revisions, and failure. +Authorization is intentionally not a prerequisite for proving basic sandbox +parity, but sensitive BXL-governed applications cannot ship until this phase is +complete. + +### Phase 7: migrate all consumers and delete duplicates + +Migrate: + +- Interact card rendering; +- Code preview and Monaco HMR; +- inspector/schema/type presentation; +- trusted Base default templates; +- Rich Markdown and delegated rendering; +- format preview and fitted galleries; +- AI/tool schema sharing; +- indexing/prerender and Host-mode rehydration. + +Delete a legacy path only when all consumers use the canonical interface, the +conformance matrix is green, and the frozen preview comparison shows no +unexplained visual or interactive regression. Likely deletion targets include +duplicated snapshot builders, stored custom-format booleans, direct constructor +introspection, generated component-class identity as an invalidation signal, +and per-feature static-property tunnels superseded by resolved semantics. + +The full parity target is approximately 9,000–14,000 production lines and +8,000–12,000 focused test lines across 40–70 files. If the implementation grows +toward the POC's blast radius, stop and identify which owner has started +absorbing unrelated policy, rendering, mutation, HMR, or capability state. + +### Validation basis + +A separate working audit pressure-tested the architecture against real Boxel +Labs programs and realm applications. It initially found strong ownership for +eleven of twenty-three mechanism families, partial ownership for six, and no +deterministic owner for six. The gaps were concentrated at the execution +boundary: Surface modes/focus, inline-versus-lifted editing, typed placement, +structured Table/Cell mechanics, scheduling and collaboration, and +clipboard/haptics/view-transition behavior. Canvas/Scene, BXL mutation, +Host-tool authority, and asynchronous AI were only partial. + +The design below assigns every one of those mechanisms to the single +cumulative suite. That closes a planning gap, not an evidence gap: a mechanism +is implemented only when its deterministic assertions pass in every applicable +tier and in the final nested graph. + +### One cumulative twelve-case acceptance suite + +This is one suite, not a primary suite plus add-ons. It grows one independent +music-release graph. Each case reuses actual Boxels from earlier cases, and the +final multimedia timeline composes them across Direct, Capsule, Sandbox, Store, +Surface, query, mutation, media, and asynchronous command boundaries. + +| Case | Fixture growth | Mechanisms that must be proved | +| ---: | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Release identity | primitive/Base fields, dates, percent, getters/computeVia, cardInfo, title/header/wide hint, isolated/embedded/fitted/atom/head/markdown/edit | +| 2 | CatalogMetadata, Guide, Credit, Price, VenueAddress | FieldDef identity, contains/containsMany, nested paths, configuration, Guide/JQXL cascade, currency symbol, country options, writable compound values | +| 3 | DeluxeRelease, Theme, BrandGuide, cover assets | inheritance, enum variants, tags, theme/CSS variables/scoped CSS, brand tokens and examples, ImageDef/URL fallback, polymorphic images | +| 4 | MusicPlayer and Track | audio FileDef, actions/tracked state, `surfacePlayback`, loading/error/cleanup, actual reusable player identity | +| 5 | Playlist and linked/query tracks | linksTo/linksToMany, relationship states, search projection, explicit Partner grant, nested Direct↔Capsule routing | +| 6 | Rich LinerNotes | RichMarkdown, Mermaid, CodeMirror, recursive atom/embedded/fitted rendering, sanitization, Layout/Run composition, modes, keyboard/focus, accessories | +| 7 | ReleaseEditor | custom/default edit, Guide-driven form behavior, primitive/compound/contained/linked writes, validation, optimistic state, canonical PATCH, stale/rejected saves, no read-only flash | +| 8 | Campaign PosterBoard and Frames | PosterBoard/Frame identity, paths and context; modes/focus/selection; inline/lift; x/y/w/h; pointer/keyboard/paste placement; denial, ghost, FLIP, transitions | +| 9 | MerchArtifact, Canvas graph, Sandbox Scene | split safe/browser modules, Three.js/3MF, iframe origin/protocol/height/prerender, Canvas nodes/edges/reconnect/minimap, Scene camera/effects, teardown | +| 10 | ReleasePlanningSheet and BXL access policy | concrete music-release rows, complete query filters/sorts/pagination, nested membership and resource-scoped BXL projection, explicit refusal, narrow Store grants/revocation, Table/Cell identity, pinned/resized columns, typed cell editors, selection, lifted editor | +| 11 | Live production, approval Policy, async AI, volatile code | field-scoped CRUD/Yjs/Command/frozen ownership, Actor-attributed Annotation evidence, awareness, AI snapshot CAS, publication clocks, BXL paths, Host-tool grants, HMR/LKG, image generation, retry/timeout/acknowledgement | +| 12 | Multimedia production timeline | actual player, notes, playlist, PosterBoard, Table, governed Release Approval Room, Annotation, generated assets, Canvas and 3D Scene; nested coordination, playback/viewport, lifted edit, revocation, failure isolation, cleanup | + +Every case requires five kinds of proof: + +1. **Semantic** — correct values, identities, query membership, formats, and + mutation payloads. +2. **Visual** — visible text/images/icons, CSS variables, background handoff, + responsive geometry, loading/error/empty states, and no raw JSON or blank + placeholder accepted as success. +3. **Interactive** — focus, typing, selection, pointer/keyboard placement, + playback, pan/zoom, commands, progress, save, retry, and cancellation. +4. **Boundary** — expected Direct/Capsule/Sandbox routing, bounded grants, and + no leaked constructors, functions, services, Store, credentials, events, or + DOM nodes. +5. **Lifecycle** — stable render slots, generation acknowledgement, + last-known-good behavior, no unnecessary remount, and complete release of + styles, listeners, observers, ports, handles, media, timers, and WebGL. + +CI uses deterministic providers, clocks, completion ordering, ids, and test +realms. External network availability is not part of acceptance. The async AI +test still runs the real Realm Script schema/limits, command authorization, +binary persistence, ImageDef linking, Store updates, acknowledgement state +machine, and visual progressive-results path. + +### Acceptance-test matrix + +Tests should generate a cross-product rather than rely only on hand-picked +examples. + +| Axis | Required cases | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tier | Direct, Capsule, Sandbox where compatible | +| Definition | CardDef, FieldDef, FileDef | +| Format | isolated, embedded, fitted, edit, atom, head, markdown, unknown custom format | +| Fields | primitive, contains, containsMany, linksTo, linksToMany, polymorphic | +| Behavior | getter, computeVia, resolved configuration, action, async update | +| Presentation | title, icon, header color, wide hint, theme, thumbnail, scoped CSS | +| Composition | trusted component, authored component, yielded block, Rich Markdown embed | +| Mutation | writable, read-only, stale, side-loaded input, relationship update | +| Guide | base/domain/realm/inline cascade, JQXL, visibility, constraints, defaults, field order | +| Authorization | server upper bound, linked BXL policy, nested membership, `via(...)`, resource scope, request input, explicit refusal, redacted UI graph, operation reauthorization | +| Collaboration | Actor, Annotation target/anchor/body/state, reply/resolve, workflow step; field-scoped ordinary/Yjs/Command/frozen ownership, ephemeral awareness, Policy term and Command receipts | +| Surface | RichMarkdown Layout/Run, Table/Cell, PosterBoard/Frame, focus, lift, Canvas, Scene | +| Async work | BXL, Realm Script, command, progress, partial result, cancel, retry, acknowledgement | +| Lifecycle | cold load, format switch, HMR, server acknowledgement, failure, reload, teardown | + +Each semantic fixture declares: + +1. its semantic owner; +2. its boundary representation; +3. its Direct/Capsule/Sandbox consumers; +4. its expected DOM/state/effect result; +5. its security-negative assertions. + +The tests compare observable Boxel behavior, not private implementation +objects or byte-identical DOM where Glimmer legitimately differs. + +### Performance plan + +Measure before assigning absolute budgets. Required metrics include: + +- cold and warm module evaluation time by tier; +- time to first prerendered content and time to interactive content; +- semantic projection and field-configuration time; +- Glimmer initial render and update duration; +- action round-trip duration; +- component and DOM remount counts; +- format-switch latency; +- active runtime, instance, component, template bundle, style, and subscription + counts; +- memory after long cross-realm navigation and after idle eviction. + +Expected shape: + +- Direct remains the lowest-overhead baseline. +- Capsule pays one per-principal startup cost, then uses module and captured + template-bundle caches plus synchronous same-thread handle calls. +- Trusted Base remains one shared Host graph rather than one Base/Ember graph + per principal. +- State updates batch changed cells rather than invoking one boundary call per + template property. +- Sandbox pays child startup and transport costs but presents prerendered HTML + immediately and retains the child across compatible format/source updates. + +### Security review checklist + +- Does any authored constructor or function execute in the Host? +- Can any captured reference resolve outside the audited component/helper/ + modifier vocabulary? +- Can a trusted component receive an authored callback, owner, service, Store, + Loader, DOM node, event, or Glimmer tag? +- Can a record or guessed identifier expand Store authority? +- Can DOM/CSS values create network requests outside policy? +- Are yielded values, event records, action results, and configuration bounded + and cloned? +- Does every capability recheck execution identity, grant, card, format, + generation, and lifetime? +- Can Realm Script select a provider/model, read credentials, write during + preview, exceed its operation/byte/time budgets, or return executable data? +- Can cancellation, retry, duplicate completion, or a stale index + acknowledgement overwrite a newer or already durable async result? +- Does teardown release handles, observers, ports, object URLs, styles, and + timers? +- Does a protocol mismatch retain last-known-good output and fail closed? + +### Completion criteria + +The architecture is complete when: + +- all Host consumers use `BoxelRuntime` and the selected tier's render + adapter rather than authored constructor introspection; +- Direct and Capsule share Host Glimmer without sharing authored executable + objects; +- trusted Base components execute normally through generic portals; +- Sandbox uses the same semantic contract through an explicit client/server + transport; +- field configuration, computed values, relationships, cardInfo, media, and + mutations match Direct behavior in every compatible tier; +- compatible HMR updates preserve component and DOM identity; +- incompatible or failed updates preserve last-known-good UI; +- asynchronous commands preserve durable partial results and canonical Run/Job + state across cancellation, retry, unmount, and delayed acknowledgement; +- all twelve cumulative cases and the generated semantic cross-product are + green, with the fifty-example inventory mapped to passing owners or an + explicit unsupported/denied decision; +- performance and long-navigation memory results are recorded and acceptable; +- duplicate projection, introspection, and render-selection paths are removed. + +At that point, adding a Boxel semantic is mechanically incomplete until it +declares an owner, boundary representation, runtime consumers, Glimmer +behavior, and Direct/Capsule/Sandbox conformance proof. diff --git a/docs/boxel-execution-runtime-authorization-projection.md b/docs/boxel-execution-runtime-authorization-projection.md new file mode 100644 index 00000000000..d5e700008d3 --- /dev/null +++ b/docs/boxel-execution-runtime-authorization-projection.md @@ -0,0 +1,353 @@ +# Boxel execution runtime: BXL authorization projection + +## Status and scope + +This is a proposed companion contract for the Boxel execution runtime. Boxel +does not yet have this complete authorization layer. The purpose of the design +is to ensure that Direct, Capsule, and Sandbox rendering can consume the same +authorization result without confusing client-side UI reduction with the +server security boundary. + +In this document, **Boxel means Box Element**: a CardDef, FieldDef, FileDef, or +future compatible visual building block. Realm remains the server-side data +and module location. + +The proposal covers: + +- linking or otherwise selecting a BXL authorization policy for a resource; +- evaluating a finite, bounded authorization snapshot; +- projecting the Boxel graph to the fields, relationships, query results, + formats, sections, and Commands a principal may use; +- transporting the result across Direct, Capsule, and Sandbox boundaries; and +- preserving independent server enforcement for all reads and effects. + +It does not define authentication, credential storage, organization policy +administration, or the complete mutation protocol. + +## Evidence from BXL Clinical Access + +The local clinical example currently performs four operations inside +`patient-dashboard.gts`: + +1. resolve a linked `ClinicalAccessPolicy`, facility, patient resource, + principals, and nested team membership; +2. build a finite authorization snapshot; +3. run `prepareBxlAuthorizationSafe()` and `listCapabilities()` for the viewer, + resource, and request input; and +4. hand-build a dashboard projection whose denied values are `undefined` and + whose actions are capability booleans. + +The example demonstrates semantics the shared service must preserve: + +- nested usersets are relationship data and are expanded cycle-safely within + limits; +- authorization is resource-scoped: a care-team seat on one patient does not + grant another patient; +- `via(Resource.Facility; Capability.ViewOperationalContext)` delegates a + specific parent-resource capability rather than exposing the parent graph; +- capabilities can depend on other capabilities; +- request input can refine a result, such as break-glass plus incident ticket; + and +- explicit refusal is evaluated separately and wins after positive + eligibility, such as `Seat.Suspended` removing notes and mutations. + +Those are general BXL mechanics. The runtime must not hard-code clinical +capability names. + +## Security and UX invariants + +1. The Realm Server is the authoritative security boundary. +2. Client-side BXL evaluation can only reduce a server-authorized upper bound. +3. The server does not send secrets merely so the client can hide them. +4. Every fetch, search, relationship traversal, Command, and mutation is + independently authorized by the server. +5. Visibility is not mutation authority. An available Command is descriptive; + invocation still requires a Host capability and server authorization. +6. A policy card reference is configuration, not a grant. +7. No live Store, policy evaluator, membership service, component, callback, + credential, or unprojected resource crosses a Capsule or Sandbox boundary. +8. Explicit refusal wins over positive eligibility. +9. Missing, stale, failed, or protocol-incompatible authorization fails closed. +10. Denied data produces no value, child render slot, loading placeholder, + count, title, URL, menu item, diagnostic payload, or timing-distinct shell. +11. Direct, Capsule, Sandbox, inspector, query, delegated render, Rich + Markdown, fitted gallery, Code preview, and AI schema consumers use the + same projection. +12. A different execution tier cannot restore a semantic removed by + authorization projection. + +## Ownership + +| Concern | Owner | Boundary result | +| ------------------------------------- | ------------------------------------- | --------------------------------------------------------- | +| Authentication and principal identity | Host + Realm Server | stable principal id, never credentials | +| Authoritative data authorization | Realm Server | already-authorized resources and upper-bound decisions | +| BXL policy card and relationship data | Store + Realm Server | authorized policy reference/revision and bounded snapshot | +| Client BXL evaluation | Host `BoxelAuthorizationService` | frozen capability decisions | +| Boxel graph projection | Host render projection pipeline | redacted `BoxelRenderRecord` | +| Visual rendering | Direct/Capsule/Sandbox adapter | only projected data and descriptive decisions | +| Commands and mutation | Host capability broker + Realm Server | admitted result/receipt or bounded refusal | + +The service is deliberately not part of `SurfaceService`. Surface authority is +scoped to a mounted visual root. Authorization is scoped to principal, +resource, policy, data revision, and operation. + +## Policy binding + +A resource or owning application may link a policy card. The initial design +can support an explicit relationship such as `authorizationPolicy`; later it +may also support policy selection from workspace/application configuration or +server policy. The binding rules must be deterministic and inspectable. + +The rendered card does not choose a weaker policy. The Host resolves the +effective binding and the server-authorized policy revision. A card that is +itself an authorized policy editor may receive the policy card as ordinary +projected data, but merely rendering a governed resource does not expose the +policy document or membership graph. + +## Host service contract + +The Host service accepts an internal request and returns a cloneable +projection. Names are aligned with the execution runtime's `Boxel*` semantic +vocabulary. + +```ts +export interface BoxelAuthorizationRequest { + policy: CardIdentifier; + principal: string; + resource: CardIdentifier; + resourceRevision: string; + requestedCapabilities: string[]; + input: Record; +} + +export interface BoxelCapabilityDecision { + effect: 'allow' | 'refuse' | 'not-applicable'; + reasonCode?: string; +} + +export interface BoxelAuthorizationProjection { + protocolVersion: number; + policy: CardIdentifier; + policyRevision: string; + principal: string; + resource: CardIdentifier; + resourceRevision: string; + inputHash: string; + capabilities: Record; + visibleFields: string[]; + visibleRelationships: string[]; + visibleFormats: string[]; + visibleSections: string[]; + availableCommands: string[]; +} + +export interface BoxelAuthorizationService { + project( + request: BoxelAuthorizationRequest, + ): Promise; +} +``` + +`requestedCapabilities` keeps evaluation bounded and makes dependencies +observable. `inputHash` identifies the exact request-input state without +echoing potentially sensitive inputs across all consumers. Bounded reason +codes support safe UI explanations; full policy traces and membership paths +remain server/Host diagnostics unless separately authorized. + +The projection is descriptive. It contains no `can()` closure and no method +that can grant authority. Custom authored formats may read the frozen decision +record from render context, while generic Base templates consume the concrete +visible field, relationship, format, section, and Command lists. + +## Evaluation and intersection + +```text +Realm Server + authenticates principal + authorizes resource/policy/snapshot reads + returns an upper-bound authorization envelope + | + v +BoxelAuthorizationService + resolves effective policy revision + evaluates BXL over an already-authorized finite snapshot + applies nested membership, via, composition, input, refusal + intersects local decisions with the server upper bound + | + v +buildBoxelRenderRecord() + removes denied values and render slots + records descriptive capability decisions + | + v +Direct / Capsule / Sandbox / non-render consumers +``` + +Two production implementations are valid: + +- the server computes the complete projection and the client validates and + consumes it; or +- the server supplies an upper bound and an authorized finite snapshot, and + the Host evaluates BXL synchronously for responsive UI changes. + +In both cases the client result is an intersection. A forged or buggy local +`allow` cannot widen the server envelope. The second model enables interactions +like changing a viewer or local request input without a network round trip, +but is a UX optimization rather than enforcement. + +## Projecting the Boxel graph + +Authorization must run before the canonical render record is assembled: + +```ts +buildBoxelRenderRecord({ + canonicalDocument, + boxelType, + projectedValue, + relationshipProjection, + authorizationProjection, + cardInfoProjection, + presentationProjection, + policy, +}); +``` + +The projection controls these semantic domains: + +| Domain | Denied behavior | +| ------------ | ------------------------------------------------------------------- | +| field | value and FieldDef slot absent; no placeholder or validation detail | +| relationship | identifier, resolved child, and traversal affordance absent | +| query | unauthorized rows excluded before count/sort/page presentation | +| format | format unavailable; trusted fallback only if separately authorized | +| section | custom presentation section absent, not CSS-hidden | +| Guide | label/help/control/constraint visible only for remaining semantics | +| menu | item absent; no inert control that reveals a capability name | +| Command | affordance absent, while direct invocation remains server-denied | +| schema/AI | unauthorized field and relationship descriptions omitted | + +Projection needs a deterministic mapping from capabilities to these domains. +That mapping may be declared by trusted Base/schema metadata, the linked +policy/application contract, or a future Guide integration. It cannot be an +unvalidated callback copied from authored code into the Host. + +## Execution-boundary behavior + +### Direct + +Trusted code receives the same projected model and frozen authorization +record. Direct execution does not bypass the projection pipeline. + +### Capsule + +The Capsule receives the redacted `BoxelRenderRecord` and descriptive decision +record. It cannot load omitted data through a trusted Base portal: every nested +render and data request returns to the Host router with the same execution and +authorization identity. + +### Sandbox + +The iframe receives the same redacted records over the versioned protocol. +The policy evaluator, source snapshot, Store, credentials, and denied data +remain outside. A child request for a nested Boxel re-enters the Host router; +guessing an id or URL does not expand the projection. + +## Reactivity, caching, and last-known-good behavior + +Projection cache identity includes: + +- principal; +- resource id and revision; +- policy id and revision; +- server upper-bound revision; +- finite-snapshot revision; +- request-input hash; and +- requested capability set. + +Policy, membership, resource-state, principal, or request-input changes +invalidate only affected authorization keys. The Host diffs old and new +projections and preserves unrelated Table/Cell, field, relationship, and child +render-slot identity. + +An authorization failure must never fall back to an earlier broader +projection. The only reusable last-known-good UI is a non-sensitive shell whose +contents are independently safe under the new upper bound. When that cannot be +proved, the Host shows a generic denied/unavailable state. + +## Mutation interaction + +Authorization projection and mutation admission are related but distinct: + +```text +availableCommands / writable field paths + | + v + visible affordance + | + user requests operation + v +Host capability broker checks execution identity + current projection + | + v +Realm Server reauthorizes against current data/policy revisions + | + v +admitted PATCH/Command receipt or bounded refusal +``` + +A projected writable field does not make the rendered projection itself +writable. The mutation pipeline still serializes only named canonical changes, +rejects computed/presentation/side-loaded data, and detects stale revisions. +Revocation between display and invocation must be safe and ordinary. + +## Acceptance coverage + +The cumulative music-release suite applies the clinical mechanics to a +non-clinical domain through `ReleaseAccessPolicy` and +`ReleasePlanningSheet`. It must prove: + +- artist, nested release team, rights team, finance, guest, and suspended + collaborator views; +- one resource grant does not authorize a sibling release; +- nested membership and `via(Resource.Label; ...)` are bounded; +- explicit refusal removes internal notes and mutations after positive team + eligibility; +- request-input changes update only dependent semantics; +- denied rows do not influence counts, totals, sort, pagination, or timing; +- Direct, Capsule, Sandbox, inspector, query, and AI schema projections agree; +- forged allows and direct Command attempts fail; +- server revocation wins over cached client state; and +- unaffected DOM/render-slot identity survives a projection update. + +Unit tests cover record codecs, hashing, version rejection, intersection, +refusal precedence, and redaction. Adapter conformance tests run the same +projection through every execution tier. Browser tests assert visible output, +absence of leaks, interactions, targeted updates, and revocation behavior. + +## Implementation sequence + +1. Define the versioned request, upper-bound, decision, and projection records. +2. Add a test-only linked BXL policy binding to the music fixture graph. +3. Implement `BoxelAuthorizationService` over a bounded authorized snapshot. +4. Intersect local decisions with a server upper-bound fixture. +5. Insert authorization before `buildBoxelRenderRecord()` materialization. +6. Feed the same frozen result to Direct, Capsule, Sandbox, inspector, query, + Code preview, delegated rendering, and AI schema consumers. +7. Route every operation through the existing/future Host capability broker + and authoritative server check. +8. Add targeted invalidation and safe non-sensitive failure shells. +9. Add the cross-tier and browser assertions above. +10. Replace application-local authorization projection code only after parity + is proven. + +## Open design decisions + +- exact policy-binding precedence across resource relationship, application, + workspace, and server policy; +- whether the server returns a fully computed projection, an upper bound plus + finite snapshot, or both; +- the declarative capability-to-field/relationship/format/section mapping; +- safe, localized explanation records versus privileged audit traces; and +- whether authorization projection gets its own revision stream or is folded + into the canonical Store document/change acknowledgement protocol. diff --git a/docs/boxel-execution-runtime-composition-suite.md b/docs/boxel-execution-runtime-composition-suite.md new file mode 100644 index 00000000000..b380cafe5cc --- /dev/null +++ b/docs/boxel-execution-runtime-composition-suite.md @@ -0,0 +1,1143 @@ +# Boxel execution runtime composition suite + +## Purpose + +This is the deterministic acceptance suite for the Boxel execution runtime. +It replaces a collection of isolated sandbox demos with one deliberately +interlinked graph that grows through twelve use cases. Each use case reuses +Boxels introduced earlier, adds one new semantic pressure, and proves both the +visual result and the behavior across Direct, Capsule, and Sandbox execution. + +In this document, **Boxel means Box Element**: a visually present, +interactive `BaseDef`-derived building block. It does not mean the Boxel +product as a whole. CardDef, FieldDef, FileDef, and future compatible visual +kinds are Boxels. A persisted Card document is only one kind of Boxel state. + +This suite is intentionally tighter than the exploratory compatibility corpus: + +- the corpus remains useful for discovery, random sampling, and soak testing; +- this suite is small enough to run in CI on every change; +- every fixture is crafted locally and deterministic; +- every later use case composes actual earlier Boxels rather than copying + their markup or behavior; +- visual and interactive expectations are part of correctness; +- each boundary crossing has an expected runtime route and authority; +- a passing placeholder, raw JSON dump, blank panel, or inert control is a + failure even when no exception was thrown. + +The architecture that this suite verifies is described in +[boxel-execution-runtime-architecture.md](boxel-execution-runtime-architecture.md). +The BXL projection and server-enforcement contract is specified in +[boxel-execution-runtime-authorization-projection.md](boxel-execution-runtime-authorization-projection.md). +Canonical edits and Commands are specified in +[boxel-execution-runtime-mutation-protocol.md](boxel-execution-runtime-mutation-protocol.md). +The broader inventory of current POC behavior is in +[boxel-execution-runtime-coverage-audit.md](boxel-execution-runtime-coverage-audit.md). +The fifty-example pressure test that selected the mechanisms in these twelve +cases is in +[boxel-execution-runtime-real-example-audit.md](boxel-execution-runtime-real-example-audit.md). + +## What this suite must prove + +The suite has seven non-negotiable outcomes: + +1. **One authored API.** A Boxel author uses normal Boxel Card API, Field API, + Glimmer, and approved `surface*` capabilities. The author does not write + MessageChannel, SES, iframe, or boundary-record code. +2. **Composition survives a graph, not merely a pair.** A Capsule parent may + delegate to a trusted Base field, which may render a linked Capsule card, + whose isolated view may contain a Sandbox child. Each nested render + re-enters the Host router without flattening identity or trust. +3. **Provenance selects execution.** A parent cannot weaken a child's + isolation. Trusted Base can remain Direct; ordinary user source runs in a + Capsule; browser-dependent source runs in a Sandbox. +4. **The Store remains canonical.** Execution runtimes receive bounded + representations. Writes are explicit Host capabilities, re-authorized and + committed through the Store. +5. **Visual parity is semantic parity.** Themes, images, CSS variables, + layout, height, accessible roles, focus, loading states, and formatted + values must match the Direct reference within declared tolerances. +6. **No partial unknown records.** Missing protocol features retain + last-known-good output and show a diagnostic. They never degrade silently + to `undefined`, blank UI, raw JSON, or the wrong format. +7. **Authorization reduces the graph before rendering.** A linked BXL policy + projects only usable fields, relationships, query results, formats, + sections, and Commands. The client can never widen the server-authorized + graph, and denied values never cross Direct/Capsule/Sandbox boundaries. + +## Fixture topology and provenance + +The suite uses three deterministic test realms and trusted Base fixtures. The +word Realm is reserved for server-side data/module location and authorization; +it does not name a Host execution service. + +| Source lane | Example URL root | Default execution | Purpose | +| ----------- | ----------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ | +| Official | `https://cardstack.com/base/` and test-only trusted modules | Direct | Base fields, trusted components, Host commands, standard format fallback | +| Studio | `https://studio.test/album/` | Capsule | Primary user-authored release workspace and most composition | +| Partner | `https://partner.test/licensed/` | Capsule after an explicit grant | Cross-Realm authorization, inherited user code, linked media | +| Lab | `https://lab.test/artifacts/` | Sandbox when browser capability is required | Three.js/WebGL and other document/window-dependent programs | + +The test Store gives Studio no implicit access to Partner. A test chooser adds +one explicit Partner card link and a narrow read grant. A later test revokes +the grant. Lab data is similarly projected; loading a Sandbox does not grant +search access to the whole Store. + +```mermaid +flowchart LR + B["Official Base\nDirect"] + S["Studio modules\nCapsule"] + P["Partner modules\nCapsule after grant"] + L["Lab browser modules\nSandbox"] + H["Host router + Store"] + + S --> H + P --> H + L <-->|"typed protocol"| H + H --> B + H --> S + H --> P + H --> L +``` + +Every rendered node records a test-only execution trace containing: + +- Boxel URL and type reference; +- source module generation and source hash; +- selected format; +- execution tier (`direct`, `capsule`, or `sandbox`); +- parent render-slot id and child render-slot ids; +- Store document revision; +- granted capabilities; +- component mount/unmount counts; and +- stylesheet acquisition/release counts. + +The trace is diagnostic evidence, not an author-facing API and not permission +to introspect live constructors. + +## The cumulative graph + +The domain is an independent music release. It is visually rich enough to +exercise Boxel as an interactive system while remaining deterministic and +small. The final use case is a multimedia production timeline that reuses the +actual player, notes, playlist, poster, image, query, and 3D artifact Boxels +introduced earlier. + +```mermaid +flowchart TD + U1["1 Release identity"] + U2["2 Catalog metadata + Guide"] + U3["3 Theme + brand guide"] + U4["4 Music player"] + U5["5 Playlist and relationships"] + U6["6 Rich liner notes"] + U7["7 Release editor and writes"] + U8["8 Campaign poster board"] + U9["9 3D merch artifact"] + U10["10 Release-planning spreadsheet"] + U11["11 Live production + Annotation"] + U12["12 Multimedia timeline"] + + U1 --> U2 --> U3 + U1 --> U4 + U3 --> U4 + U4 --> U5 + U4 --> U6 + U3 --> U6 + U2 --> U7 + U3 --> U7 + U4 --> U8 + U6 --> U8 + U4 --> U9 + U5 --> U10 + U9 --> U10 + U7 --> U11 + U10 --> U11 + U4 --> U12 + U5 --> U12 + U6 --> U12 + U8 --> U12 + U9 --> U12 + U10 --> U12 + U11 --> U12 +``` + +## Shared assertion vocabulary + +Each case declares five forms of evidence. A test is incomplete if it checks +only the first. + +| Evidence | What it proves | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Semantic | Correct field values, computed values, relationship identity, query membership, format selection, and mutation payload | +| Visual | Required visible text, images, icons, formatted values, computed CSS variables, solid background handoff, and bounded geometry | +| Interactive | Focus, typing, selection, pointer input, playback, drag/resize, commands, or save behavior changes visible state | +| Boundary | Expected Direct/Capsule/Sandbox route, no leaked constructors/services/Store, and correct capability set | +| Lifecycle | Stable render-slot identity, loading/error/last-known-good behavior, HMR acknowledgement, cleanup, and no duplicate listeners/styles | + +High-value layouts use small screenshot regions in addition to DOM assertions. +Screenshot comparison is not used for rapidly changing browser chrome or font +antialiasing. The invariant manifest carries the durable visual contract: + +```ts +interface BoxelVisualExpectation { + format: string; + visibleText?: string[]; + roles?: Array<{ role: string; name?: string }>; + images?: Array<{ alt: string; loaded: boolean }>; + cssVariables?: Record; + geometry?: Array<{ + selector: string; + minWidth?: number; + minHeight?: number; + contains?: string; + }>; + interactive?: Array<'focusable' | 'editable' | 'draggable' | 'playable'>; +} +``` + +## Use case 1 — Release identity and primitive fields + +### Boxels introduced + +- `Release` CardDef in Studio. +- Official Base fields rendered through trusted Direct component portals. +- One `Release/opening-night.json` instance. + +### Semantics exercised + +- `@field` with `contains` for `StringField`, `NumberField`, `BooleanField`, + `BigIntegerField`, `TextAreaField`, `EmailField`, `UrlField`, + `PhoneNumberField`, `EthereumAddressField`, and `ColorField`. +- `DateField`, `DateTimeField`, `TimeField`, `DateRangeField`, and + `DateTimeStampField`, with exact date-versus-datetime JSON shapes. +- `PercentageField` formatting. +- `cardTitle`, `cardDescription`, `cardInfo`, `headerColor`, and + `prefersWideFormat` projection. +- `computeVia` for a short release status string and a chained ordinary getter. +- `isolated`, `embedded`, `fitted`, `atom`, `head`, `markdown`, and default + `edit` format selection. The format set is data, not a closed enum. + +### Visual and interactive contract + +The isolated card shows coverless release identity, release date, availability +status, completion percentage, contact link, and an explicit color swatch. +Embedded is a compact two-row identity block. Fitted responds at badge, strip, +tile, and card container sizes without overflow. Atom is one readable pill. +Head supplies only head metadata. Markdown is stable copy, not `[object Object]`. +Default edit renders appropriate trusted Base controls and remains writable. + +### Required assertions + +- Capsule author code never runs in Host. +- Every Base field component is Direct and sees the same field value/config as + the authored template would see without a boundary. +- `0`, `false`, empty string, null optional fields, and the BigInt transport + codec do not collapse into one another. +- Date formatting does not throw and a wrong Date/DateTime fixture is rejected + as a fixture error rather than becoming a blank preview. +- Header title is not `Untitled`; header color and wide-format preference match + the Direct reference. + +## Use case 2 — Catalog metadata FieldDef, configuration, and Guide + +### Boxels introduced + +- `CatalogMetadata` FieldDef contained by `Release`. +- `Credit` FieldDef used by `containsMany` with exactly two small entries. +- `Price` using `AmountWithCurrency` and linked `CurrencyField`. +- `VenueAddress` using `AddressField`, `CountryField`, and `CoordinateField`. +- `ReleaseGuide`, a data-authored Guide linked from `cardInfo.guide`. + +### Semantics exercised + +- FieldDef versus CardDef identity: contained metadata has no independent card + URL and does not cross the Store as a fake card document. +- `contains(FieldDef)`, `containsMany(FieldDef)`, nested field paths, and + delegated `<@fields.* />` rendering. +- Static field configuration, configuration callback using owner context, and + per-use configuration override. +- `ConfigurationInput`, resolved field-configuration merging, invalidation of + the per-instance field-configuration cache, and `enumConfig` callbacks. +- `CurrencyField.symbol` and nested getters across trusted Base FieldDefs. +- `@field`, `@model`, `@format`, `@context`, `@configuration`, and writable + `@set` component arguments. +- Compound validation and fallback presentation. +- Guide cascade across base, domain, realm, and inline layers; JQXL-backed + visibility, constraints, computed defaults, helper text, and field order. + +### Visual and interactive contract + +The metadata section is a two-column grid with producer, region, origin +country, farm/venue coordinate, process, price with currency symbol, and two +credits. Edit mode exposes a country selector, numeric price control, currency +selector, coordinate inputs, and configured labels/placeholders. It must never +remain on `Loading countries...` after the Base options resolve. +Changing the Guide changes help text, field visibility, ordering, constraints, +and suggested defaults without loading new authored TypeScript. + +### Required assertions + +- A nested edit updates the canonical Release document through one authorized + mutation and retains unrelated fields. +- No side-loaded contained FieldDef is serialized as a card resource. +- Configuration callbacks run in the owning runtime and cross the rendering + boundary as resolved configuration, not executable functions. +- Guide data and evaluated JQXL results cross the boundary as bounded records; + no Guide introduces a callback, Store handle, or ambient authority. +- Scalar/list/field-order cascade rules produce the same form in Direct, + Capsule, and Sandbox. +- `CurrencyField.symbol`, nested `computeVia`, and an undefined optional nested + path produce their declared fallback, not `undefined 24`. + +## Use case 3 — Enumerations, inheritance, theme, brand guide, and images + +### Boxels introduced + +- `DeluxeRelease extends Release` in Studio. +- `ReleaseTheme` CardDef using `CssValueField` and `TypographyField`. +- `ReleaseBrandGuide`, a local instance adopting the trusted `BrandGuide` + definition and linked through `cardInfo.theme`; it is not a subclass. +- Cover image pair: `linksTo(ImageDef)` plus `contains(UrlField)` fallback. +- One linked `PngDef` and one polymorphic `WebpDef` fixture. + +### Semantics exercised + +- inherited fields, inherited formats, an overridden format, and a new field; +- `enumField` static scalar options; +- rich enum labels/icons; +- dynamic enum options resolved from owner state; +- per-use enum configuration override; +- null enum value and invalid-value validation; +- `Tag` cards through `linksToMany`; +- `cardInfo.theme`, theme inheritance, CSS-variable injection, `headerColor`, + and authored scoped styles; +- the trusted Theme → StructuredTheme → StyleReference → BrandGuide behavior, + with local values on one linked BrandGuide instance rather than copied token + fields or a new subclass; +- BrandGuide palette-to-functional CSS-variable computation and `cssImports` + font loading; +- the URL/ImageDef pair, polymorphic image links, and broken-image fallback. + +### Visual and interactive contract + +The deluxe release has a deliberate poster-like theme, cover art with alt +text, typography tokens, a stage pill from a rich enum, and visible tags. The +Brand Guide visibly demonstrates the same palette, type scale, spacing, +imagery treatment, and representative controls. Direct reference and Capsule +must have matching background, foreground, typeface family category, spacing +tokens, and loaded image. Styles may not leak into Host chrome or sibling +cards. + +### Required assertions + +- Inherited type metadata preserves the child's source provenance and does not + promote it to Direct because its parent is trusted. +- Every declared/overridden format appears in `BoxelDescription.formats`. +- Theme and Brand Guide retain one canonical token source and render the same + tokens in Direct and Capsule. +- Relinking `cardInfo.theme` to a rotated-palette BrandGuide changes every + visible themed element without remounting the release or retaining a + hard-coded color/font leak. +- Image identity, content URL, alt text, intrinsic dimensions, and load/error + state survive the boundary. +- Enum option labels/icons and dynamic configuration are identical in Direct + reference and Capsule rendering. + +## Use case 4 — Music player + +This Boxel is deliberately introduced early because use case 12 must reuse the +actual player, not a visually similar copy. + +### Boxels introduced + +- `Track` CardDef in Studio. +- linked audio `FileDef` fixture and linked cover `ImageDef` from use case 3; +- `MusicPlayer` component used by Track's isolated and embedded formats. + +### Semantics exercised + +- FileDef link identity, MIME type, filename, content URL, and metadata; +- `@tracked` play state, current time, duration, volume, and scrub position; +- Glimmer actions, modifiers, event cleanup, and derived display getters; +- `surfacePlayback` registration and commands: play, pause, seek, rate, + current-time reporting, ended, and error; +- safe media element access through the Surface capability plane rather than + ambient navigator/window authority; +- isolated, embedded, fitted, atom, and edit presentation. + +### Visual and interactive contract + +The player shows cover, track title, artist, play/pause control, elapsed and +duration text, seek bar, and volume control. Atom is a non-playing identity +pill; fitted is a bounded mini-player only when interaction is allowed by the +container. Edit changes Track metadata but does not duplicate audio bytes into +card JSON. + +### Required assertions + +- Clicking play advances time and changes accessible control name to Pause. +- Seek and volume changes round-trip once; no duplicate event listeners appear + after format switches or HMR. +- Two player surfaces can be assigned to the same playback group and converge + on play state/time within tolerance without recursively echoing commands. +- The player is Capsule-capable because it uses approved capabilities and no + browser-dependent external package. + +## Use case 5 — Playlist, relationships, and query fields + +### Boxels introduced + +- `Playlist` CardDef in Studio. +- Three Track instances, including one explicitly granted Partner Track. +- One deliberately broken relationship slot. +- A query-backed `recentTracks` field. + +### Semantics exercised + +- `linksTo`, `linksToMany`, indexed relationship JSON keys, null, loading, + present, and broken states; +- `getRelationshipMembershipState(...).isLoading`, including the requirement + that the template also reads the field; +- `undefined` holes without changing relationship-array length; +- defensive link traversal and `.filter(Boolean)` only where intended; +- query-backed `linksTo` and `linksToMany`, `$this`, `$REALM`, sort, and page; +- aggregate `computeVia` across loaded related values; +- delegated Track rendering in atom, fitted, and embedded formats. + +### Visual and interactive contract + +The playlist has ordered rows, cover thumbnails, duration, and play controls. +While loading, it shows the existing Boxel spinner and stable row geometry. +A broken slot shows a bounded unavailable-row state rather than disappearing, +throwing, or shifting later row identity. Query refresh visibly returns to a +loading state and then preserves sort order. + +### Required assertions + +- The Partner Track is unreadable before the explicit grant and readable after + it without granting search over the Partner realm. +- Query-backed fields are materialized for rendering but omitted from PATCH. +- The child Track runtime is selected from the Track module's provenance, not + inherited from Playlist. +- Nested path is at least Capsule Playlist → Host router → Direct Base field → + Host router → Capsule Track. + +## Use case 6 — Rich liner notes and recursive delegated rendering + +### Boxels introduced + +- `LinerNotes` CardDef in Studio using `RichMarkdownField`. +- Markdown fixture containing headings, lists, a table, a Mermaid diagram, + cover image, Track atom, Track embedded player, Playlist fitted card, and a + link to the Release. + +### Semantics exercised + +- MarkdownField and RichMarkdownField rendering and editing; +- trusted CodeMirror and Mermaid shims distributed by Boxel; +- Boxel-flavored Markdown embeds through `viewCard`/delegated rendering; +- nested format propagation and stable child identities; +- image/file embeds and sanitization; +- editable body mutation through the trusted Base RichMarkdown editor; +- a RichMarkdown-owned Layout → Run projection of the same canonical document; +- Surface use/change/inspect mode propagation, keyboard traversal, and focus + return through the outline and editor; and +- cue label, description, status, and accessory semantics as non-product + chrome. + +### Visual and interactive contract + +Read mode shows formatted prose, a rendered Mermaid SVG, a loaded cover image, +and visibly distinct atom/embedded/fitted child formats. Edit mode shows a +working rich editor with the full body loaded, toolbar, slash menu, and live +preview behavior. Raw Markdown, raw Mermaid source, and raw JSON are failures. + +### Required assertions + +- CodeMirror and Mermaid execute as explicitly trusted shim modules, not as + ambient Host imports granted to all authored code. +- Markdown → Base RichMarkdown Direct portal → Host router → Capsule Track → + Base media component proves the recursive graph. +- Focus, selection, and edits remain stable when a nested child finishes + loading. +- RichMarkdown Layout and Run nodes project into stable Surface paths without + copying their content or creating a second document state. +- A nested iframe is never created for atom/head/markdown merely because the + same card type has a Sandbox-only isolated renderer in another module. + +## Use case 7 — Release editor, writable fields, and canonical PATCH + +### Boxels introduced + +- `ReleaseEditor` custom edit component in Studio. +- `BoxelSelect` for rich enum selection. +- Save and validation status components. + +### Semantics exercised + +- custom versus default edit templates; +- writable `@set` for primitive, compound, contained, and linked fields; +- BoxelSelect delegated rendering; +- validation errors, dirty state, optimistic local state, server + acknowledgement, and last-known-good rollback; +- cardInfo aliases without duplicate synthetic fields; +- canonical Boxel JSON:API PATCH shape and removal of query-backed fields; +- read/write capability independent of execution tier; and +- `ReleaseGuide` remains the source of labels, constraints, ordering, and + conditional visibility in both default and custom edit formats. + +### Visual and interactive contract + +Edit is visibly an editor: labels, controls, validation, save status, and no +read-only flash while authority settles. A successful local edit appears +immediately. Server acknowledgement does not remount or revert it. A rejected +save keeps the draft, restores the last valid preview, and shows an actionable +error overlay in the standard bottom position. + +### Required assertions + +- A field writable Direct remains writable in Capsule or Sandbox when the + capability is granted; sandboxing alone cannot turn it read-only. +- The Host rejects side-loaded non-card resources and unauthorized links. +- Mutation intent contains changed field path/value and expected document + revision, not a live Store object or authored instance. +- Reauthorization happens on every mutation, including after a grant is + revoked. + +## Use case 8 — Release campaign PosterBoard and Frame coordination + +### Boxels introduced + +- `CampaignBoard` CardDef in Studio. +- `PositionedCardField` entries referencing Release, LinerNotes, Track, and + ImageDef, each with x/y/width/height. +- a trusted `PosterBoard` Surface containing one `Frame` per campaign asset; +- two ordered placement lanes and one positioned target. + +### Semantics exercised + +- `PositionedCardField` identity and geometry; +- `surfacePresentation` background/header presentation; +- `surfaceViewport` pan, zoom, focus, and coordinate conversion; +- `surfaceLayout` allocated rectangles and intrinsic-size reporting; +- `surfaceObserve`, `surfaceStyle`, `surfaceFocus`, `surfacePointer`, and + `surfaceSlot` where already shipped or explicitly stubbed by the fixture; +- drag/resize mutation through a Host-authorized capability; +- theme variables and scoped authored CSS in nested render slots; +- stable PosterBoard/Frame identity/path, parent context, coordinate source, and + use/change/inspect posture propagation; +- keyboard focus ladder, one selected Frame, and inspect hover; +- inline-versus-lifted editing with anchor geometry, commit/cancel, and focus + return; +- CSS-like Surface rule matching by specificity/order and component choice; +- self/children/descendants/subtree directive scope and posture inheritance; +- typed cross-container placement shared by pointer, keyboard, and paste; and +- placement ghost, insertion wedge, structured denial, autoscroll, and scoped + FLIP/view-transition lifecycle. + +### Visual and interactive contract + +The board is a designed poster canvas, not a list. Cards have deterministic +positions and sizes, cover images load, pan/zoom retains crisp geometry, and +selection/focus is visible. Embedded children fit their allocated rectangles. +Background presentation avoids the double-frame effect without copying +untrusted arbitrary CSS into Host chrome. + +### Required assertions + +- x/y/width/height cross the boundary as typed values and persist after drag. +- Surface capabilities are issued per mounted render slot and revoked at + teardown; they do not live on the Boxel semantic runtime. +- Nested styles cannot select Host chrome or sibling slots. +- A child asking for intrinsic height cannot override a parent-allocated + fitted rectangle. +- Move and reference placement preserve stable Card identity and commit the + exact ordered index requested by the target. +- Pointer capture, drag observers, lifted planes, transition names, and + temporary ghosts are released after commit, cancel, error, and teardown. + +## Use case 9 — Split-module 3D merchandise artifact + +### Boxels introduced + +- `MerchArtifact` CardDef in Studio with safe metadata/formats. +- `merch-artifact-canvas.gts` in Lab importing Three.js and a 3MF loader. +- linked 3MF FileDef, poster ImageDef, and thumbnail formats. +- a safe editable Canvas graph with two nodes and one edge whose canonical + node state is also consumed by the Sandbox Scene. + +### Semantics exercised + +- format implementation split across modules; +- natural classifier behavior: the safe GTS is Capsule and the Three.js GTS is + Sandbox because of its browser-dependent import; +- literal `static prefersFullSandbox = true` as an explicit minimum-isolation + request where present; +- format-aware selection without pretending module-level imports are + format-local; +- `surfaceCanvas`, pointer input, allocated/intrinsic sizing, body/container + presentation, prerender placeholder, readiness spinner, and teardown; +- safe thumbnail/atom/head/fitted rendering without inline iframes; +- Canvas node drag/resize, handles, connection/reconnection, edge label, + minimap, and viewport portal; and +- Scene camera drag, wheel momentum, node motion, and one deterministic visual + effect without granting ambient DOM authority to the Capsule. + +### Visual and interactive contract + +Isolated and embedded show the interactive rotating artifact in an +origin-isolated Sandbox. Edit exposes metadata controls and a bounded preview. +Fitted, atom, head, and markdown use the safe poster/thumbnail module in a +Capsule. Prerendered format-correct HTML appears immediately while the iframe +loads; the standard spinner sits beside the realm icon until interaction is +ready. Replacement causes no content jump beyond a declared tolerance. + +### Required assertions + +- The same source module is never classified differently merely because a + different format was requested. Separate modules are the optimization seam. +- If the author imports Three.js into the main module, all executable formats + from that module use Sandbox; non-executable prerendered thumbnails remain + possible but are not hydrated as Capsule code. +- Sandbox origin, CSP/fetch policy, MessageChannel version, and height mode are + verified. +- Canvas and Scene agree on coordinate conversion while retaining independent + mounted presentation state. +- WebGL contexts, observers, animation frames, listeners, and object URLs are + released on teardown. + +## Use case 10 — Release-planning spreadsheet and explicit grants + +### Boxels introduced + +- `ReleaseCollection` in Studio. +- `ReleasePlanningSheet` in Studio, backed by the collection's canonical query + and rendered with trusted `Table` and `Cell` components. +- `LicensedTrack extends Track` in Partner. +- `ReleaseAccessPolicy`, a linked BXL policy for resource-scoped release + visibility and Commands. +- label, release-team, rights-team, finance-team, suspended-collaborator, and + guest principals, including one recursively nested release team. +- query result sections rendered through `@context.searchResultsComponent`. +- one granted Partner image and one ungranted sibling. +- rows for each Track with title, artist, ISRC, rights status, launch date, + territory, price, rating, and campaign-ready state. + +### Semantics exercised + +- `RealmField`, `CodeRefField`, and `AbsoluteCodeRefField`; +- `codeRef(here, path, name)` and injected `realmURL` identity; +- type, eq, in, contains, range, matches, any, every, and not filters; +- custom-field sort with `on`, general sort without `on`, and pagination; +- `@context.searchResultsComponent`, `getCards`, and query-backed fields as + three distinct query consumers; +- `searchable` relationship projection; +- user-selected cross-Realm links and narrow Store grants; +- Host-owned BXL projection from a finite, already-authorized relationship + snapshot; +- nested team membership, `via(Resource.Label; ...)`, capability composition, + request inputs, and explicit refusal that wins after positive eligibility; +- resource-scoped field, relationship, query, section, and Command visibility; +- inheritance across provenance boundaries; +- stable Table/Cell identities under virtualization, pinned/resized columns, + keyboard cell traversal, selection, and deterministic bulk status changes; +- trusted number, date, currency, enum/status, checkbox, and rating cell + widgets; and +- lifted cell editor and context menu focus/commit/cancel behavior. + +### Visual and interactive contract + +The release-planning spreadsheet has stable sections for new releases, +high-rated tracks, launch windows, text matches, and explicitly licensed +Partner content. Its visible columns and editors are meaningful to music +release operations rather than a generic Grid demonstration. Each result uses +its declared child format and shows title, image, and release status. Empty +authorized results show an empty state; unauthorized results show no leaked +title, count, URL, or timing-dependent placeholder. + +The same sheet is projected for several viewers. The artist sees identity, +catalog, and submission controls. A rights-team member sees territories, +licenses, and rights approval. Finance sees pricing and revenue fields without +unreleased media or internal notes. A guest sees a release locator and +request-access control only. A suspended collaborator remains a member of a +nested release team, but an explicit refusal removes internal notes and every +mutation. Switching viewers updates only authorization-dependent columns, +sections, rows, and Commands; unaffected Table/Cell and child render-slot +identity remains stable. + +### Required assertions + +- A bare `{ on: ref }` fixture is rejected by fixture validation rather than + silently passing with zero rows. +- Custom sort without `on` is rejected; `lastModified`, `createdAt`, and + `cardURL` remain valid general sorts. +- Granting one linked Partner card does not grant arbitrary search, neighboring + card access, module source, or Realm enumeration. +- A grant or nested membership on one Release resource does not grant another + Release, and `via(...)` exposes only the declared label capability. +- Denied fields, relationships, rows, totals, titles, URLs, menu items, and + Commands are absent from the render record and DOM rather than hidden after + materialization. +- Client BXL decisions can only reduce the server upper bound; forged `allow`, + stale policy/input revisions, and direct Command attempts are refused by the + Host/server. +- Explicit refusal wins over nested-team eligibility in Direct, Capsule, and + Sandbox consumers. +- Revocation invalidates only affected render/query consumers and retains + unaffected Direct/Capsule slots. +- Query refresh preserves Table selection, focus, geometry, and unrelated Cell + mount identity. + +## Use case 11 — Live production, Realm Script, async AI, and volatile modules + +### Boxels introduced + +- `ProductionConsole` in Studio. +- a typed `PublishReleaseCommand` with a run card and progress. +- a volatile Track format module edited by Monaco and by an out-of-band write. +- `CampaignImageRun` with contained stages/logs and four generated + `CampaignAsset` image links. +- a capability-scoped Realm Script that resolves release data into a validated + image-generation plan; provider IO and binary persistence remain Host + commands. +- `ReleaseReviewAnnotation` with target, typed Field/TextRange/Cell anchor, + body, author Actor, assignee, state, and a linked approval workflow. +- `ReleaseApprovalRoom`, a concrete mixed-ownership card governed for a bounded + review window by a versioned `ReleaseApprovalPolicy`: + - liner notes are a Yjs-concurrent rich-text field; + - publication state, rights approval, and approver are Command-owned; + - release date and territories are frozen after the review window opens; + - cover artwork remains an ordinary revisioned ImageDef link; + - readiness is computed; and + - discussion and `ReleaseReviewAnnotation` records are evidence, not + authority. + +### Semantics exercised + +- Command input/output/progress and Host command capabilities; +- source classification and transpilation cache by source hash; +- volatile module generation, local draft, server acknowledgement, and + last-known-good state machine; +- HMR for Capsule formats and persistent Sandbox protocol for Sandbox formats; +- error overlay, reload action, and explicit execution signage; +- source navigation independent from preview readiness; +- BXL patch result paths, deterministic scheduling, and two-client replay + convergence; +- Realm Script preview-versus-commit, JSON-schema output validation, input and + result byte limits, cancellation, and wall-clock timeout; +- an optimistic asynchronous image pipeline: resolve voice/prompt, dispatch + four provider jobs, persist each binary as it completes, link ImageDefs, + then settle indexing acknowledgements; +- partial success, out-of-order completion, cancellation, retry, idempotency, + and stale-generation rejection; and +- exact Host-tool grants and import denials rather than ambient AI, Store, + network, filesystem, or credential access; +- durable Annotation creation, assignment, reply, resolution, and query-backed + review state through typed Commands with Actor attribution; and +- ordered workflow advancement using canonical Annotation and Command state, + never UI-local comment state; +- episodic, field-scoped coordination: one current write owner per path, + minimal Policy custody, and automatic return to ordinary revisioned writes + when the approval term lapses; +- lazy Yjs epochs for the declared rich-text field, with cursor, selection, + focus, and presence carried as ephemeral awareness rather than card state; +- typed Command admission, sequencing, idempotency, and receipts for + consequential approval fields, with every accepted field patch joining the + same canonical card-revision boundary as ordinary and collaborative writes; +- atomic AI snapshot compare-and-swap while collaboration is active: settle + accepted Yjs updates, fence and close the affected epoch, install the + candidate only if its base revision is current, and start a new epoch for + connected collaborators; and +- publication's two clocks: compatible instance-data revisions follow the + source change feed while code, schema, templates, theme, and projection + policy stay pinned until republish. + +### Visual and interactive contract + +Monaco/file navigation displays source as soon as fetched and never waits for +preview classification/rendering. Valid text or CSS edits update the preview +without destroying the render slot or unrelated Store state. The first +canonical-to-volatile transition may show one loading flash; subsequent +compatible generations do not. A syntax error keeps last-known-good output and +floats the standard error panel over the bottom of the card. + +The image run shows four stable aspect-ratio placeholders, per-stage progress, +and each successful image as soon as its binary is durable. A failed variant +remains an actionable retry tile; it does not hide the three successful +results or block Monaco, playback, or navigation. + +The review Annotation is visibly attached to the release field, +release-planning Cell, or liner-notes text range it addresses. Its body, +author, assignee, status, replies, and workflow step survive navigation and +render in Capsule and Sandbox without exposing the target's ungranted +neighboring data. + +The Release Approval Room shows the ownership model rather than hiding it in a +protocol test. Two collaborators can edit liner notes and see each other's +presence. Approval controls show admitted, refused, duplicate, and completed +Command receipts. Frozen release terms remain legible but unavailable, while +an ungoverned title correction and artwork replacement remain writable. A +review Annotation can recommend approval but cannot change publication state. +When the bounded Policy term closes, its custody indicator disappears and the +formerly governed paths return to their declared ordinary behavior. + +### Required assertions + +- Matching SSE/index echoes acknowledge the active generation; they do not + reload the preview or revert to older source. +- An out-of-band Boxel CLI write to the displayed module joins the same + volatile pipeline until the card unloads. +- A manual Reload Card action deliberately remounts only the selected render + slot and resets its volatile runtime generation. +- Command authority is explicit and cannot be acquired merely by importing a + Host tool module from authored code. +- Annotation targets and anchors retain stable Boxel identities, Actor + attribution, and authorization across Direct, Capsule, and Sandbox; resolve + and reply are mutations, not local component state. +- At most one of ordinary revision writes, Yjs, Command authority, frozen + custody, or atomic snapshot installation owns a field path at a time; the + Host never infers ownership from which component happens to be mounted. +- A direct write to a Command-owned or frozen path is refused, while an + unlisted path on the same card remains writable. Duplicate or out-of-order + approval Commands produce exactly one accepted transition and stable + receipts. +- Two Yjs clients converge on the same liner-notes content; awareness never + appears in serialized card JSON, indexing, search, prerender, or PATCH. +- A stale AI snapshot cannot silently replace collaborative work. A current + snapshot changes the epoch without replacing Command-owned fields that were + outside its admitted scope. +- Annotation bodies, local messages, and federated evidence cannot mutate the + approval projection without an independently authorized local Command. +- A compatible data change reaches a published view without republishing its + executable definition. An incompatible schema change retains last-known-good + output and reports `republish-required` instead of partially rendering. +- CI uses a deterministic fake image provider with controlled completion + order; the contract test still exercises the real Realm Script, command, + binary-file, ImageDef, Store, indexing-acknowledgement, and rendering path. +- Realm Script cannot select its model, read provider credentials, issue an + ungranted request, write in preview mode, or smuggle an executable function + through its schema-validated result. +- A BXL mutation and two-client scheduled event log converge exactly once + after duplicate, delayed, and out-of-order acknowledgements. + +## Use case 12 — Multimedia production timeline + +This is the graph acceptance test. It reuses the exact Boxels from earlier +cases: + +- MusicPlayer and Track from use case 4; +- Playlist from use case 5; +- LinerNotes from use case 6; +- ReleaseEditor state from use case 7; +- Campaign PosterBoard and Frames from use case 8; +- MerchArtifact from use case 9; +- federated Partner content from use case 10; +- command/volatile state, generated CampaignAssets, and the still-queryable + CampaignImageRun from use case 11; and +- the unresolved release-review Annotation and approval workflow from use case 11. +- the active Release Approval Room, including its collaborative liner notes, + field-custody projection, Command receipts, and bounded Policy term from use + case 11. + +### Boxels introduced + +- `TimelineEntry` FieldDef with time, duration, lane, format, and linked Boxel; +- `MultimediaTimeline` CardDef in Studio; +- one video FileDef and synchronized audio/video surfaces. + +### Semantics exercised + +- heterogeneous CardDef/FieldDef/FileDef visual graph; +- repeated rendering of the same Card instance in different formats; +- nested Capsule → Host → Direct → Host → Capsule and Capsule → Host → + Sandbox paths; +- playback group synchronization, viewport synchronization, focus, pointer, + layout, presentation, intrinsic/allocated height, and slot coordination; +- simultaneous relationship/query loading and a later grant revocation; +- cycle detection, render budgets, stable identities, and teardown; +- edit and command mutations while media remains mounted; +- pointer/keyboard/paste placement of earlier Boxels, lifted metadata editing, + use/change/inspect mode switching, and one Surface-scoped view transition; + and +- generated image arrival and failed-variant retry while the timeline remains + mounted; and +- a TimelineAnchor Annotation on a cue, resolved without replacing the + Annotation card or interrupting playback; and +- concurrent liner-notes edits and an approval Command crossing distinct + nested render paths without either writer replacing the other's field-scoped + canonical patch. + +### Visual and interactive contract + +The timeline has visible time rulers and lanes for audio, video, notes, poster +composition, and 3D artifact. The use-case-4 MusicPlayer is mounted in the +audio lane and remains the controller of its media state. Seeking the timeline +updates player and video; playing either approved leader synchronizes the +group. Notes and board elements retain their authored theme. The Sandbox 3D +entry receives an allocated viewport while atom/fitted references use safe +thumbnails. Editing release metadata updates all relevant labels without +resetting playback, pan/zoom, focus, or the iframe. +The campaign lane reuses the case-11 image assets: successful variants appear +in completion order without changing their requested slot order, and retrying +the failed variant does not remount the player or the board. + +### Required assertions + +- The expected execution graph includes at least one route with five boundary + transitions and preserves the original Card/Field/File identities. +- Duplicate views share canonical Store data but have independent mounted + presentation state unless explicitly joined by a `surface*` group. +- No child can search, mutate, navigate, or read media beyond its capabilities + and explicit Store grants. +- One child error or revoked grant does not blank the timeline. +- Dragging the case-4 player from the release-planning Table into a timeline lane uses the + same typed placement command for pointer, keyboard, and paste. A lifted + editor can modify it without interrupting playback. +- Grant revocation and generated-image acknowledgements preserve unrelated + focus, playback, Table selection, Canvas viewport, and Sandbox state. +- Policy expiry, Yjs epoch transition, and approval receipts update every view + of the Release Approval Room without remounting the player, Sandbox Scene, + RichMarkdown editor, or unrelated Direct Base fields. +- After teardown there are zero active timeline surface registrations, media + subscriptions, iframe ports, Capsule component handles, styles, observers, + or animation loops. + +## Boxel semantic cross-product + +The twelve cases are the narrative fixtures. The following table is the +boring-but-load-bearing checklist generated from the Boxel skills glossary. +Every row must map to at least one fixture assertion and one boundary codec or +explicit statement that the value never crosses a boundary. + +### Base field catalog + +| Family | Required coverage | Primary use case | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| Primitive/string | String, Number, Boolean, BigInteger, TextArea, Email, URL, PhoneNumber, EthereumAddress, Color; null/empty/zero/false distinctions | 1 | +| Time | Date, DateTime, Time, DateRange, DateTimeStamp; exact JSON formats and formatter output | 1 | +| Money/quantity | Percentage, AmountWithCurrency, Currency and nested `symbol` getter | 1, 2 | +| Geographic | Address, Country option loading, Coordinate round-trip | 2, 8 | +| Markdown | Markdown read/edit and RichMarkdown read/edit/BFM/Mermaid | 6 | +| File-backed | generic FileDef, ImageDef polymorphism, PNG, JPG, WebP, GIF, AVIF, SVG, Markdown, CSV, JSON, GTS, TS, Text | 3, 4, 6, 9 plus codec matrix | +| Metadata/schema | CodeRef, AbsoluteCodeRef, Realm, CssValue, Typography | 3, 10 | +| Enum | static, rich, dynamic, per-use override, null, invalid | 3, 7 | +| Special | Tag, PositionedCardField; Base64Image explicitly rejected for new fixtures | 3, 8 | + +The narrative cases need not render a separate large card for every file +subtype. A parameterized file-codec contract test covers all listed subtypes; +the visual graph uses representative image, audio, video, 3MF, Markdown, and +source files. + +### Field and relationship semantics + +| Semantic | Required variants | Primary use case | +| ------------------- | ------------------------------------------------------------------------------------------------------ | ---------------- | +| Field declaration | contains, containsMany, linksTo, linksToMany | 1, 2, 5 | +| Field kind | CardDef, FieldDef, FileDef, polymorphic target | 2, 3, 4 | +| Configuration | `ConfigurationInput`, static/callback, cache invalidation, inherited/per-use merge, `enumConfig`, null | 2, 3 | +| Computation | ordinary getter, computeVia, chained dependency, nested Base getter, null/error/cycle | 1, 2, 5 | +| Relationship state | unloaded, loading, present, null, broken, undefined hole, live-query reload | 5 | +| Search projection | contained always, linked opt-in with searchable path(s), non-searchable query rejection | 5, 10 | +| Inheritance | inherited fields/formats, override, child provenance, polymorphism | 3, 10 | +| Delegated rendering | `@fields`, `viewCard`, searchResultsComponent, atom/embedded/fitted recursion | 2, 5, 6, 10 | +| Component context | model, field, format, context, configuration, set | 2, 7 | + +### Query semantics + +| Query feature | Required assertion | Use case 10 section | +| ---------------------------- | --------------------------------------------------------------------- | ------------------- | +| type | adopts-from match without `on` | +| eq/in/contains/range/matches | each has a valid `on` scope and returns known rows | +| any/every/not | OR, AND, and negation preserve result identity/order | +| sort | custom field includes `on`; general metadata sort may omit it | +| page | stable page size and cursor/offset behavior used by implementation | +| refs | `codeRef(here, ...)`, `.gts` module path, injected `realmURL` | +| substitutions | `$this` and `$REALM` resolve in the owning runtime | +| consumers | searchResultsComponent, getCards, query-backed linksTo/linksToMany | +| persistence | query fields never enter card PATCH payload | +| refresh | loading state re-enters and matching acknowledgement does not remount | + +### Authorization semantics + +| Authorization feature | Required assertion | +| --------------------- | ---------------------------------------------------------------------------------------------------------- | +| server upper bound | client projection can remove an allowed capability but cannot add a denied one | +| linked BXL policy | Host resolves the authorized policy revision; authored code receives no policy evaluator | +| nested userset | recursive team membership is cycle-safe, bounded, and grants only the named resource seat | +| `via(...)` | a declared parent-resource capability projects without exposing unrelated parent data | +| composition | one capability may depend on another without leaking the intermediate policy graph | +| request input | break-glass-like input updates the projection only when all policy predicates hold | +| explicit refusal | refusal wins after positive eligibility and removes both data and Commands | +| resource scope | membership/grant on one Release does not authorize a sibling Release | +| UI projection | denied values, relationships, query rows, totals, formats, sections, menus, and Commands never materialize | +| reauthorization | every fetch, search, traversal, Command, and mutation is independently checked by the Host/server | +| lifecycle | policy, membership, principal, resource, or input changes preserve unrelated render-slot identity | + +### Presentation, Surface, and lifecycle semantics + +The suite must enumerate every shipped or planned `surface*` contract in the +architecture capability ledger. At minimum it assigns fixtures for: + +- `surfacePresentation` — header/background intent and iframe container match; +- `surfaceLayout` — intrinsic size and parent-allocated rectangles; +- `surfaceViewport` — pan, zoom, coordinate conversion, viewport observation; +- `surfacePlayback` — play/pause/seek/rate/time/error and group leadership; +- `surfaceCanvas` — 2D/WebGL target and lifecycle; +- `surfaceFocus`, `surfacePointer`, `surfaceObserve`, `surfaceStyle`, and + `surfaceSlot` — only when their contract is declared in the runtime plan. + +Capabilities belong to a mounted Surface registration, not `BoxelRuntime`. +They are scoped to a render-slot id, revocable, and reauthorized at every +mutating operation. A capability named in this document but not yet shipped is +marked `design` in the implementation ledger and cannot be counted as a pass. + +## Boundary graph rules + +These rules make the suite about composability rather than one-hop transport: + +1. Every nested render request returns to the Host router with Boxel identity, + format, parent slot, and provenance. +2. The router independently selects Direct, Capsule, or Sandbox for the child. +3. Trusted Base components execute Direct even inside a Capsule presentation; + the Capsule receives no live component constructor or Ember service. +4. A Sandbox owns its document and Glimmer runtime. It receives bounded Store + projections and capabilities over the protocol. +5. A CardDef, FieldDef, or FileDef may appear at any depth. Boundary records + distinguish kind without forcing non-card Boxels into Card JSON:API. +6. Repeated Card documents share canonical Store state, not component state. +7. Format is passed on every delegated render; it is never inferred from an + ancestor after the first selection. +8. The graph has cycle and depth budgets with a visible bounded diagnostic. +9. Runtime/cache invalidation is keyed by affected module generation, not one + global Realm revision. +10. Teardown walks the graph and releases children even after partial failure. + +## CI suite layout + +The intended deterministic fixture location is a test-only realm under the +Host test fixtures, with source organized by the twelve use cases rather than +copied from staging. The exact path should follow the existing Host fixture +convention at implementation time. + +### Layer A — protocol and codec tests + +Fast unit tests, no browser: + +- clone/reject each primitive and compound value; +- BoxelDescription formats/kind/cardInfo/theme metadata; +- BoxelRenderRecord projection and mutation sanitation; +- all Base file subtype metadata codecs; +- missing-path diagnostics; +- protocol feature/version negotiation; +- capability ids are opaque and unforgeable; +- no live constructor, Store, service, loader, DOM node, function, or Proxy is + cloneable across the boundary. + +### Layer B — semantic conformance adapter tests + +Run the same semantic contract against Direct, Capsule, and Sandbox adapters +where the fixture is eligible: + +- describe Boxel; +- instantiate Card from canonical document; +- resolve fields/configuration/getters/computeVia; +- resolve relationships and query fields; +- select and render formats; +- authorize and commit mutations; +- reload/acknowledge generations; +- release all handles. + +Sandbox-only browser dependencies are not forced through Capsule merely to +fill a matrix cell. Instead, use case 9 proves that safe and browser-dependent +modules compose at the format boundary. + +### Layer C — browser composition acceptance tests + +Run the twelve cases in order in one browser suite. Each case may assume the +fixture definitions from earlier cases but resets Store documents and runtime +registries. Browser tests assert semantics, accessible DOM, computed styles, +geometry, interactions, trace route, and lifecycle counts. + +High-value screenshot regions: + +- use case 3 themed isolated and fitted views; +- use case 6 RichMarkdown read and edit views; +- use case 8 poster board before/after pan; +- use case 9 prerender-to-Sandbox transition; +- use case 12 complete timeline. + +### Layer D — navigation/HMR acceptance tests + +Use case 11 and 12 add focused tests for: + +- immediate file-tree and recent-file navigation; +- Monaco source display independent from preview readiness; +- valid local edit, invalid edit, recovery, SSE acknowledgement, out-of-band + write, format switch, manual reload, and unload; +- one flash when entering volatile mode, no compatible-generation remounts; +- no stale generation replacing a newer local draft. + +### Layer E — soak and compatibility sampling + +This is not a per-PR blocker initially: + +- navigate all twelve cases across all meaningful formats for 30 minutes; +- open/close repeated Sandbox instances; +- change grants and themes; +- perform 100 compatible HMR generations; +- assert bounded module/runtime/template/style/handle/media growth; +- then sample the larger compatibility corpus and ten recent staging cards. + +## Pass criteria + +The suite is green only when: + +- all twelve cases pass their semantic, visual, interactive, boundary, and + lifecycle assertions; +- the expected execution graph matches exactly; +- there are no console exceptions, blank panels, raw JSON fallbacks, missing + required images, perpetual loading labels, or `Untitled` headers; +- Direct reference and Capsule/Sandbox output meet each case's declared visual + parity tolerances; +- writable controls remain writable with authority and fail closed without it; +- one case's module update does not remount unrelated Boxels; +- all handles, ports, Surface registrations, styles, and media resources are + released after teardown; and +- no existing regression test was weakened merely to accommodate the runtime. + +## Implementation order + +1. Build use cases 1–3 and the protocol/semantic adapter harness. This is the + ordinary Boxel API foundation. +2. Add use case 4 once and expose it as a reusable fixture dependency. +3. Add use cases 5–7 to prove relationships, RichMarkdown, and writes. +4. Add use cases 8–9 for Surface coordination and true browser isolation. +5. Add use cases 10–11 for grants, queries, commands, and HMR. +6. Assemble use case 12 exclusively from the earlier reusable Boxels. +7. Turn the cross-product tables into machine-readable coverage metadata and + fail CI when an API row has no test owner. +8. Keep the larger compatibility corpus as an independent discovery/soak gate; + do not substitute it for this deterministic graph suite. + +The final design test is simple: if use case 12 requires a copied player, +copied card markup, a special one-off iframe API, or a trusted Host import in +user code, the architecture has failed the composition goal even if the page +looks correct. + +## Glossary sources and maintenance rule + +The ordinary semantic checklist is derived from the author guidance shipped +with Boxel CLI, especially: + +- [the Base field catalog](../packages/boxel-cli/plugin/skills/boxel/references/base-field-catalog.md); +- [enumerations](../packages/boxel-cli/plugin/skills/boxel/references/enumerations.md); +- [query systems](../packages/boxel-cli/plugin/skills/boxel/references/query-systems.md); +- [relationship loading state](../packages/boxel-cli/plugin/skills/boxel/references/relationship-loading-state.md); +- [delegated rendering](../packages/boxel-cli/plugin/skills/boxel/references/delegated-rendering.md); and +- the main [Boxel skill glossary](../packages/boxel-cli/plugin/skills/boxel/SKILL.md). + +When that glossary adds a field family, relationship/query semantic, format, +component argument, or author-visible capability, the same change must name a +row and owning use case here. Conversely, a POC-only transport mechanism does +not enter the author glossary merely because the runtime needs it internally. diff --git a/docs/boxel-execution-runtime-coverage-audit.md b/docs/boxel-execution-runtime-coverage-audit.md new file mode 100644 index 00000000000..282fdefa7e1 --- /dev/null +++ b/docs/boxel-execution-runtime-coverage-audit.md @@ -0,0 +1,579 @@ +# Boxel execution runtime coverage audit + +This audit is the broad inventory and migration ledger. The companion +[execution runtime composition suite](boxel-execution-runtime-composition-suite.md) +is the smaller deterministic CI graph: twelve cumulative use cases covering +ordinary fields and queries through rich composition, media, Surface +capabilities, and mixed Direct/Capsule/Sandbox execution. +The +[real-example audit](boxel-execution-runtime-real-example-audit.md) checks that +single suite against fifty Boxel Labs and realm examples, including the full +foundation Surface vocabulary and asynchronous AI production. + +## Purpose + +This is the migration checklist for replacing the current realm-sandbox POC +with the execution runtime described in +[Boxel execution runtime architecture](boxel-execution-runtime-architecture.md). +The architecture may replace the internals, but it may not silently reduce the +set of cards, fields, formats, interactions, or UI behavior that the POC has +already exercised. + +This document answers four different questions that must not be collapsed: + +1. Which authored and implicit Boxel APIs do current cards use? +2. Which of those APIs have a boundary representation in the POC? +3. Which behaviors have deterministic automated proof, rather than only a + successful manual comparison? +4. Which multi-boundary execution graphs must the new architecture preserve? + +The detailed author-facing API semantics remain canonical in the +[card API compatibility ledger](realm-sandbox-card-api-compatibility.md). The +[surface capability design](realm-sandbox-surface-capabilities.md) remains the +canonical proposal for the `surface*` family. This audit is the cross-product: +it maps those APIs to actual cards, tests, execution owners, graph paths, and +completion gates. + +## Evidence vocabulary + +Every row uses one of these evidence levels: + +| Level | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Exact automated** | A deterministic test constructs the same API shape and asserts the relevant render, effect, or protocol result. | +| **Contract automated** | A lower-level or smaller fixture proves the individual contract, but not the full corpus card or nested graph. | +| **Manual parity** | The same Realm document was compared in staging and the sandbox Host and recorded in `COMPARISON-OBSERVATIONS.md`. | +| **Fixture only** | A representative card exists, but no current automated or recorded manual result proves the claimed behavior. | +| **Design only** | The API is proposed and has no implementation proof. | + +A capability is **not migration-complete** until it has exact automated proof +in Direct and every applicable confined runtime. Pairwise proof is also not +enough for a compositional API: it needs at least one graph test in which it is +nested behind another card or field boundary. + +## Audited sources + +This audit is based on the current `codex/code-preview-instant-reload` branch +and these concrete sources: + +- the 40 synthetic cards and five real-card probes in + `/Users/chris/boxel-workspaces/sandbox-compatibility-corpus-20260803`; +- `Acceptance | code submode | sandbox live reload`; +- `Integration | preview` and `Integration | realm sandbox iframe`; +- the realm-compartment, boundary, source-policy, import-policy, iframe + protocol, media, Store-boundary, style, HMR, and lifecycle unit suites; +- Boxel Base's `card-api.gts`, `field-support.ts`, default templates, Rich + Markdown implementation, and `CardContext`; +- the existing skill and real-workspace import audit in + [realm-sandbox-skill-import-audit.md](realm-sandbox-skill-import-audit.md). + +The synthetic corpus currently imports these major runtime families: + +| Import family | Current corpus use | +| ---------------------------------------------- | -----------------: | +| `@cardstack/base/card-api` | 43 files | +| `@cardstack/base/string` | 39 files | +| `@cardstack/base/number` | 19 files | +| `@ember/modifier` | 14 files | +| `@glimmer/tracking` | 11 files | +| `@ember/helper` | 8 files | +| `@cardstack/boxel-ui/helpers` | 5 files | +| `@cardstack/base/boolean` | 5 files | +| `ember-modifier` | 3 files | +| `@cardstack/boxel-ui/components` | 3 files | +| current Surfaces modules | 3 files | +| browser libraries such as Three.js and Leaflet | 3 files | + +The larger staging-workspace scan is more important for compatibility breadth: +3,072 source files, 1,415 distinct runtime module specifiers, and concentrated +use of Base, Boxel UI, Ember/Glimmer, commands, runtime-common, local modules, +and a smaller external-package tail. The new runtime must be driven by that +inventory, not by whichever imports happen to be convenient in a small test +Realm. + +### Automated suite ledger + +This table is the index from existing proof to the contract it protects. It is +not permission to delete narrower historical tests: the new architecture must +pass these tests or replace them with stronger assertions that retain their +original regression intent. + +| Existing suite | Contracts currently exercised | Refactor use | +| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| [`sandbox-live-reload-test.gts`](../packages/host/tests/acceptance/code-submode/sandbox-live-reload-test.gts) | cold Capsule render, authored FieldDef delegation, chained compute, relationships, all formats, Rich Markdown, recursion, HMR, warm format islands, persistent iframe, failure recovery, explicit reload | Primary end-to-end compatibility suite; extend it with the mixed-runtime graph gauntlet rather than weakening its assertions. | +| [`preview-test.gts`](../packages/host/tests/integration/components/preview-test.gts) | trust routing, opaque `getComponent()` compatibility, themes, `surfacePresentation`, nested/primitive FieldDefs, `viewCard`, head format, stable contained-field identity | Direct-versus-Capsule semantic and Host portal conformance. | +| [`realm-sandbox-iframe-test.gts`](../packages/host/tests/integration/components/realm-sandbox-iframe-test.gts) | readiness, inert type presentation, intrinsic/allocated sizing, permission updates | Parent-side Sandbox component contract; add a real child runtime test separately. | +| [`realm-sandbox-boundary-test.ts`](../packages/host/tests/unit/lib/realm-sandbox-boundary-test.ts) | cloneable records, projection, mutation sanitation, rejection of unsafe values | Seed for the canonical `BoxelRenderRecord` codec. | +| [`realm-compartment-module-runtime-test.ts`](../packages/host/tests/unit/realm-compartment-module-runtime-test.ts) | Capsule imports, evaluation, trusted identities, helpers/components, generation behavior | Seed for `BoxelRuntime` and `CapsuleComponentRuntime`. | +| source/import/URL policy unit suites | runtime classification, safe/unsafe module graph decisions, URL normalization | Convert to policy tests over `ModuleGraphDescription`; never let a query parameter choose execution. | +| iframe protocol, origin, draft, and media unit suites | exact origin, one-use channel/bootstrap, persistent draft updates, bounded image transport | Seed for the versioned Sandbox protocol and grants. | +| styles, acknowledgement, lifecycle, contextual-field unit suites | scoped CSS, server-echo acknowledgement, runtime cleanup, nested field state | Preserve as subsystem tests and add graph-level assertions for their interactions. | +| [`hydratable-card-test.gts`](../packages/host/tests/integration/components/hydratable-card-test.gts) | prerender identity, placeholder adoption, render-slot stability | Seed for `I -> H -> C/S` handoff tests. | +| Boxel UI `safe-modifier` and `surface-presentation` integration suites | current authored effect/presentation APIs and teardown | Seed for shared Direct/Capsule/Sandbox `SurfaceService` behavior specifications. | + +### Manual and corpus artifacts + +The Realm corpus adds evidence that deterministic tests cannot yet express: + +- `COMPARISON-OBSERVATIONS.md` is the append-only staging-versus-branch visual + and interaction log. Its results are **manual parity**, not CI proof. +- `CompatibilityMatrix/matrix` is a card-rendered summary grouped by syntax, + API, and boundary layer. The matrix itself exercises nested + `containsMany(FieldDef)` rendering. +- `FormatPreviewBatchOne/sample` mounts Primitive Profile, Activity Timeline, + Rich Markdown Article, Multi-format Signal, and Image Story simultaneously + in `isolated`, `embedded`, `fitted`, `atom`, `edit`, `head`, and `markdown`. + That is 35 visible delegated boundaries and must become a deterministic + graph acceptance test before the POC paths are removed. + +## Completion rule for every semantic + +No new or migrated Boxel semantic is complete until its implementation record +contains all five items: + +1. **Owner** — Store, trusted Base, Host, authored Capsule, or authored + Sandbox. +2. **Boundary representation** — immutable value, trusted identity, component + handle, effect handle, or capability request. +3. **Runtime consumers** — Direct, Capsule, Sandbox, indexing/prerender, and + code preview as applicable. +4. **Pairwise conformance** — the same authored behavior in Direct and each + applicable confined runtime. +5. **Graph conformance** — the semantic still works when its renderer is + reached through at least one additional card, field, trusted portal, or + Sandbox boundary. + +If an item is intentionally unsupported, the record must name the replacement +behavior and the diagnostic. Partial rendering of an unknown record is not a +valid fallback; retain last-known-good output or fail with a named contract +error. + +## The execution graph, not a stack of pairs + +### Nodes + +The compositional model is a graph whose nodes are render or semantic owners: + +| Node | Owns | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `H` — Host | canonical Store identity, trusted Ember owner, Host chrome, policy, capabilities, mutation validation, render-slot identity | +| `D` — Direct | trusted Base/official module execution using native Boxel and Glimmer APIs | +| `C(principal, generation)` — Capsule | authored module instances, authored getters/computeds, tracked state, captured template programs | +| `S(origin, instance, generation)` — Sandbox | a separate document, local Glimmer runtime, browser-dependent authored modules, local DOM and library state | +| `I` — Index/prerender | server materialization, indexed values, inert HTML/markdown, and last-known-good presentation | + +### Typed edges + +Every crossing must be a typed edge. “It is already in memory” is not a type. + +| Edge | Required representation | +| ----------------------- | -------------------------------------------------------------------------------------------------------- | +| `H -> C` | canonical card projection, type/field descriptions, trusted import identities, scoped capability handles | +| `C -> H` | captured component program, bounded effect request, mutation proposal, presentation update, diagnostic | +| `H -> S` | versioned bootstrap, card document/grant, format presentation, permission state, private `MessagePort` | +| `S -> H` | readiness, size/presentation, bounded fetch/effect request, mutation proposal, diagnostic | +| `H -> D` | native card instance and `CardContext`; no boundary projection is necessary | +| `H <-> I` | indexed card resource, prerendered format HTML/markdown, source/index acknowledgement | +| `H -> H` trusted portal | trusted Base component identity plus projected model/configuration and bounded authored callbacks | + +An invocation path must carry a stable trace tuple: + +```ts +interface RenderGraphTrace { + renderSlotId: string; + parentRenderSlotId?: string; + principal: string; + runtime: 'direct' | 'capsule' | 'sandbox' | 'prerender'; + cardId?: string; + typeRef: { module: string; name: string }; + format: string; + generation: number; + sourceHash?: string; +} +``` + +This is diagnostic identity, not authority. Authority remains in unforgeable +Host-owned grants associated with the render slot. + +### Graph invariants + +- A nested render always re-enters the Host policy router; a parent runtime + cannot decide that a child may run with less isolation. +- A trusted Base portal may render Host DOM, but it receives only the projected + data and callbacks declared by its contract. It does not hand its Ember owner + back to authored code. +- An iframe Sandbox uses its own local trusted Base/Glimmer runtime. Host DOM + is never transplanted into the child document and child DOM never crosses to + the Host. +- A Capsule may call a trusted helper/component/modifier identity only through + the Host component runtime. It never receives a live Host element. +- Relationships create graph edges, not embedded authority. Loading a linked + card does not widen the caller's Store grant. +- Cycles are detected by logical card/type identity, not just object identity. +- Each render request has depth, node-count, and in-flight-load budgets. A + recursive `containsMany(FieldDef)` may be deep; an accidental cycle may not + allocate unbounded components, ports, or iframes. +- Teardown is graph-aware: releasing a parent releases only descendants for + which it is the last active consumer. +- Format selection is per render node. One CardDef may use a Capsule renderer + for `atom` and a Sandbox renderer for `isolated` without changing canonical + card identity. + +## Required graph gauntlet + +These paths are the minimum compositional suite for the new architecture. +They deliberately include alternating owners rather than only one boundary. + +| ID | Path | Representative behavior | Current proof | Migration gate | +| ---- | ------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| G-01 | `H -> C` | ordinary authored isolated card | Exact automated: `COLD-INTERACT-01` | Preserve authored DOM, styles, metadata, and stable slot. | +| G-02 | `H -> C -> H -> C` | CardDef invokes trusted Base field portal which invokes an authored FieldDef template | Exact automated: `COLD-INTERACT-02` | Assert projected value, configuration, scoped CSS inheritance, and `@set`. | +| G-03 | `H -> C -> H -> C -> H -> C` | recursive `containsMany(FieldDef)` with indexed components | Exact automated: `CORPUS-03` | Assert three depths, no JSON fallback, bounded recursion, and teardown. | +| G-04 | `H -> C -> H -> C2` | `linksTo`/`linksToMany` delegates another CardDef and format | Exact automated: `COLD-INTERACT-03` | Include same-realm and separately granted cross-realm child principals. | +| G-05 | `H -> C -> H -> D -> H -> C` | authored card uses trusted Rich Markdown, which embeds an authored card | Partial: `CORPUS-02` proves Rich Markdown; embed tests prove formats separately | Add one exact nested Rich Markdown card-embed graph test. | +| G-06 | `H -> C -> H -> S` | Capsule parent delegates a browser-dependent linked card | Contract automated only | Add an exact parent/child test with readiness, intrinsic height, and parent stability. | +| G-07 | `H -> S(local Base -> authored child)` | iframe card renders Base fields and authored local components in its own document | Protocol automated; full nested child is manual-only | Add a real child-document integration test, not only parent messages. | +| G-08 | `H -> C -> H -> S -> H(write) -> Store -> C/S` | nested Sandbox edit proposes a write and all visible consumers reconcile | Pairwise write protocol exists | Add exact multi-consumer mutation and permission-revocation test. | +| G-09 | `I -> H placeholder -> S interactive` | prerendered HTML is immediate while an iframe becomes interactive | Hydratable Host card is automated; iframe handoff is incomplete | Assert format-correct placeholder, header spinner, no layout jump, then readiness. | +| G-10 | `H -> C(format A) / C(format B)` | same module and card switch between warm formats | Exact automated: `[NAV-07]` for two SES islands | Preserve card identity and bounded two-format LRU. | +| G-11 | `H -> C(atom) / S(isolated)` | compact renderer stays Capsule-safe while browser-heavy renderer uses iframe | Source-policy unit proof | Add one real split-module CardDef across both formats. | +| G-12 | `H -> C -> surface* -> H` and `H -> S -> surface* -> H` | same named capability through direct dispatcher and MessageChannel | Only `surfacePresentation` has both paths | Every shipped `surface*` capability needs this transport-equivalence test. | + +## Authored Boxel semantic checklist + +### Definitions, fields, and projections + +| Semantic used by current cards | POC boundary behavior | Evidence | Required new-architecture contract | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `CardDef`, `FieldDef`, `FileDef`, `Component` | Capsule-owned classes plus Host type descriptions; native classes in Direct/Sandbox-local runtime | Contract automated | One `BoxelRuntime` description/projection contract; never pass a live authored constructor to Host. | +| `@field` | field metadata captured during Capsule evaluation | Contract automated | Preserve declaration order, inheritance, override identity, definition kind, and configuration. | +| `contains` / `containsMany` | materialized snapshot plus stable singular/indexed component capabilities | Exact automated: COLD-02, CORPUS-03 | Project values recursively; preserve array length/index/iteration and authored FieldDef renderer identity. | +| `linksTo` / `linksToMany` | relationship resource plus asynchronously prepared delegated renderer | Exact automated: COLD-03 | Relationship stays an id/grant edge; Host loads and routes each target without widening the parent grant. | +| query-backed relationships | projected results use the relationship component contract | Manual parity; existing query suites | Add exact sandbox test for loading, empty, error, membership change, and teardown. | +| `computeVia` | executes in the owning Capsule; iframe leaves use child/indexed value rather than opening an iframe for projection | Exact automated: COLD-02B and CORPUS-03 | Include dependency tracking, chained/nested FieldDefs, errors, cycles, and indexing parity. | +| authored getters | evaluated in authored runtime and exposed as projected values | Contract automated | Same dependency/error/cycle rules as `computeVia`; never evaluate authored getter in Host. | +| field `configuration` object/function | resolved against the parent instance and projected to the renderer | Contract coverage is incomplete | Add inherited, per-use merge, function, and dynamic update tests across a trusted Base field portal. | +| `searchable`, query formatting, serializers | Store/index semantics remain trusted | Existing Base/realm tests; sandbox graph not exact | New runtime consumes canonical Store/index output; it must not reimplement serializer/index rules. | +| recursive/lazy field type functions | Capsule resolves type identity without eager infinite recursion | Exact automated: CORPUS-03 | Preserve lazy resolution and logical cycle guard across runtime generations. | +| inherited fields and templates | type description includes ancestors and inherited slots | CORPUS-03 plus manual corpus | Add direct/Capsule/Sandbox conformance for inherited metadata and renderer selection. | +| polymorphic or overridden field types | opaque type state supports per-instance field overrides | Contract automated in Base; sandbox proof incomplete | Include override type refs in the boundary record and test nested render/write. | +| primitive serialization/deserialization | primitive value crosses as data; trusted field handles edit semantics | Exact primitive FieldDef integration test | Preserve `null`, empty, number/string/boolean, date, URL, currency, and file values without JSON coercion. | +| JSON:API persistence | Host serializes canonical document and strips non-card side-loaded projections | Exact boundary unit tests | All writes use one `projectCardMutation()` path; reject non-card included resources and non-cloneable values. | +| `cardInfo` | nested field with `name`, `summary`, `cardThumbnail`, `cardThumbnailURL`, `theme`, and `notes` | Contract automated; corpus has CardInfo Recipe and Theme | Treat as one nested field, not six synthetic top-level fields. Test title, description, image, theme relationship, and notes together. | +| `cardTitle`, `cardDescription`, `cardTheme`, `cardThumbnailURL` | trusted Base computed aliases over `cardInfo` | Manual parity and metadata tests | Project canonical computed presentation before Host header render; eliminate `Untitled` races. | + +### Formats and type presentation + +The set of formats must be open-ended in the new architecture. These are the +current examples, not a closed protocol enum. + +| Semantic | Current evidence | Migration checklist | +| ------------------------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `isolated` | Exact automated top-level render | ☐ Direct/Capsule/Sandbox; ☐ default Base fallback; ☐ prerender placeholder; ☐ error/LKG. | +| `embedded` | Exact delegated-format test | ☐ nested graph; ☐ intrinsic iframe sizing; ☐ Rich Markdown embedding policy. | +| `fitted` | Exact delegated-format test | ☐ allocated sizing; ☐ warm gallery performance; ☐ no iframe pill/farm regression. | +| `atom` | Exact delegated-format test | ☐ compact Capsule/default renderer; ☐ inert fallback for iframe-only modules. | +| `edit` | Exact delegated-format and primitive `@set` tests | ☐ writable/read-only parity; ☐ Base fallback; ☐ mutation rejection; ☐ intrinsic iframe size. | +| `head` | Exact delegated-format test | ☐ compact safe renderer or inert fallback; ☐ no iframe farm. | +| `markdown` | Exact delegated-format and fallback tests | ☐ trusted conversion fallback; ☐ authored renderer; ☐ nested directives. | +| named fitted/custom dimensions | Markdown embed integration tests | ☐ carry variant id and allocated dimensions through all runtime adapters. | +| future/custom format | Not proven | ☐ `FormatDescription` is keyed by string and unknown formats fail explicitly rather than disappearing. | +| `displayName` | Boundary/type-presentation tests | ☐ no `Untitled` settlement race; ☐ update on valid HMR generation. | +| `icon` | Trusted identity fallback exists | ☐ approved identity mapping; ☐ authored unsupported icon fallback; ☐ no component crosses iframe protocol. | +| `headerColor` | Metadata and iframe presentation path | ☐ validation parity; ☐ title-bar-only semantics; ☐ HMR update. | +| `prefersWideFormat` | Metadata and iframe presentation path | ☐ strict boolean; ☐ Host remains layout owner; ☐ HMR update. | +| `prefersFullSandbox` | Unit source/routing tests | ☐ only strengthens `isolated`/`embedded`/`edit`; ☐ cannot be set by URL; ☐ compact formats remain composable. | +| authored format in a separate module | Source-policy unit tests | ☐ defer unsafe graph only for that slot; ☐ shared module-scope use makes dependency eager; ☐ real two-file card test. | + +### Component arguments and implicit template API + +These are implicit APIs because existing cards receive them through Base's +component signature rather than importing a capability module. + +| Implicit input | Owner and boundary rule | Evidence | Migration gate | +| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `@model` | projected canonical value; authored runtime owns executable behavior | Exact across card and primitive FieldDef tests | Stable identity where possible; nested object writes cannot bypass mutation validation. | +| `@cardOrField` | inert type description/approved identity, never authored constructor in Host | Contract automated | Replace constructor introspection with `BoxelDescription`. | +| `@fields` | stable named and indexed renderer handles | Exact COLD/CORPUS tests | Singular, many, inherited, recursive, relationship, loading, and error states. | +| `@format` | string selected by Host policy | Exact format gauntlet | Open-ended, per-node, and updateable without replacing unrelated runtime state. | +| `@set` | bounded mutation callback | Exact FieldDef test | Permission, path, type, generation, and card identity revalidated by Host. | +| `@fieldName` | inert field path/name | Contract automated | Preserve nested path semantics without exposing unrelated schema. | +| `@configuration` | resolved cloneable field configuration | Incomplete | Add exact trusted Base field portal coverage. | +| `@canEdit` / `canWrite` | Host permission-derived boolean | Iframe protocol test | No writable/read-only flash; Host rechecks every write. | +| `@typeConstraint` | resolved inert CodeRef | Contract partial | Validate through owning realm without Host-importing authored code. | +| CRUD functions | Host capabilities, not function-valued card data | Direct exists; sandbox coverage incomplete | Separate named create/view/edit/save/delete operations with principal and activation checks. | +| `@context.mode` / `submode` | inert presentation hint | Contract partial | Same values across Direct/Capsule/Sandbox; never an authority decision. | +| `@context.requestRender` | trusted Base portal asks its owning render slot to update | Rich Markdown proof | Private Host/Base capability; do not project into arbitrary authored context. | +| `@context.trustedUI` | trusted loaders for CodeMirror, KaTeX, Mermaid | Exact Rich Markdown acceptance test | Private to trusted Base/catalog portals; authored code sees rendered result, not loader/service. | +| `@context.validateCodeRef` | realm-scoped validation through owning runtime | Unit/integration partial | Private trusted editor capability with bounded CodeRef result. | +| `searchResultsComponent` | trusted Host rendering portal | Existing component integration tests | Add nested authored-card use through Capsule; do not pass component class through JSON. | +| `cardComponentModifier` | trusted element tracking hook | Hydratable-card tests | Host-only; never present in authored Capsule or iframe code. | +| `markdownEmbedChooser` | trusted operator-mode UI capability | Existing editor tests | Host/Base only and absent in prerender/unsupported contexts. | +| `toolContext` / legacy `commandContext` | privileged Host tool context | Current Capsule receives no real context | Replace each needed operation with a named command capability; never proxy the context bag. | + +### Glimmer, Ember, and authored-program semantics + +| Semantic in current cards | Current handling | Evidence | Required contract | +| -------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `@tracked` / `@cached` | Capsule-local reactive state with render notifications | HMR and interaction contract tests | Persistent component instance; same update semantics; teardown stops notifications. | +| `@action` | Capsule-local method binding | Contract automated | Event handle invokes only the owning instance/generation. | +| `on` modifier | trusted modifier identity plus authored callback | Interaction tests | Host owns element/listener; callback receives projected event data or approved event contract. | +| `fn`, `get`, `concat`, `hash`, `array` | trusted helper identities | Module-runtime tests | Preserve helper semantics and nested component handles without general helper authority. | +| template-only and ordinary Glimmer components | captured program/Host component manager in Capsule; native locally in Sandbox | Manual corpus and component-runtime tests | Blocks, args, splattributes, dynamic element, and local state conformance. | +| blocks and `yield` | implicit Glimmer composition | Coverage incomplete | Add Capsule -> trusted Base portal -> authored yielded-block test, including updates and teardown. | +| dynamic component/helper/modifier lookup | allowlisted/trusted identities only | Source/module policy partial | Unknown dynamic identity fails with a named diagnostic; no ambient owner lookup. | +| `ember-modifier` custom modifiers | iframe classification unless represented by a reviewed adapter | Source-policy exact | Existing cards run unchanged in Sandbox; new `surface*` alternatives may remain Capsule. | +| `ember-concurrency` | used by real cards; facade incomplete | Import audit only | Decide Capsule-local task runtime versus Sandbox; test cancellation, restart, error, and teardown. | +| `ember-resources`, destroyables, provide/consume context | real-workspace use; incomplete | Import audit only | Specify runtime-local lifetime/context semantics and graph test before migration. | +| locale/string/number/date behavior | selected safe intrinsics preserved | Computed Flight Plan manual parity | Direct/Capsule/Sandbox conformance for locale, timezone, formatting, and deterministic indexing. | + +## `surface*` API inventory + +### What is actually shipped in the POC + +The distinction in this table is critical. The POC does **not** currently ship +the whole proposed family merely because the design document names it. + +| API | Current status | Semantics and evidence | New-runtime gate | +| ---------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| `safeModifier('focus')` | Shipped opt-in | trusted focus operation; Boxel UI integration test | Decide whether retained as compatibility alias for `surfaceFocus`. | +| `safeModifier('scroll-into-view')` | Shipped opt-in | trusted bounded scroll operation; implementation exists, focused test should be added | Same as above. | +| `safeModifier('observe-size', callback)` | Shipped opt-in | frozen finite `{ width, height }`, trusted `ResizeObserver`, teardown; exact Boxel UI test | Map to `surfaceObserve('size')`; test Capsule and Sandbox transport equivalence. | +| `surfacePresentation` | Shipped opt-in | publishes validated `containerBackground`; SES integration and iframe protocol tests | Move dispatch/lifetime into `SurfaceService`; preserve the authored import. | +| `static headerColor` | Existing CardDef API | trusted card-title background metadata | Keep separate from body/container presentation. | + +### Proposed `surface*` family + +Each proposed item remains unchecked until it has an authored API, Host +dispatcher, Capsule adapter, iframe protocol adapter where applicable, +revocation, bounds, and Direct/Capsule/Sandbox conformance tests. + +| Capability | Replaces | Current corpus demand | Status | +| --------------------- | ---------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------- | +| `surfaceRoot` | ad hoc root discovery/identity | all root-confined effects and nested surfaces | ☐ Design only | +| `surfaceLifecycle` | lifecycle-only custom modifiers | Scrabble, Tier Maker, Assistant Run, Signet | ☐ Design only | +| `surfaceObserve` | `ResizeObserver`, `IntersectionObserver` | iframe height, responsive panels, 3D/canvas sizing | ◐ `safeModifier observe-size` only | +| `surfaceFocus` | `focus()`, `scrollIntoView()` | Tier Maker, forms, keyboard workflows | ◐ `safeModifier` operations only | +| `surfacePointer` | pointer listeners/capture/drag | Tier Maker, Poster Board, maps, signature/canvas | ☐ Design only | +| `surfaceStyle` | validated dynamic style mutation | Invoice form, rating, geometry, pan/zoom | ☐ Design only | +| `surfacePresentation` | Host container/backdrop presentation | iframe double-frame/background parity | ☑ Shipped for solid/matched color | +| `surfaceTransition` | document view transitions and global names | View Transition Gallery, Tier Maker | ☐ Design only | +| `surfaceSchedule` | timers/animation scheduling | Scrabble, playback, workflows | ☐ Design only | +| `surfaceClipboard` | `navigator.clipboard` | Tier Maker/share flows | ☐ Design only | +| `surfaceHaptics` | `navigator.vibrate` | Tier Maker | ☐ Design only | +| `surfaceSlot` | portals into Host chrome | Assistant Run toolbar | ☐ Design only | +| `surfacePlayback` | cross-surface media intent/state/leader coordination | Video, Audio, 3D, playback lab | ☐ Separate design in `surface-playback-synchronization.md` | +| `surfaceViewport` | pan/zoom intent and effective viewport state | Poster Board, map, viewport relay | ☐ Design only | + +Network, Store access, Realm search, commands, secrets, and AI proxy requests +must not be hidden inside `surface*`. Their authority is a principal/data +grant, not a mounted DOM surface. + +## Browser, DOM, CSS, and media checklist + +| Behavior | Runtime decision | Current evidence | Migration gate | +| -------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------- | +| scoped styles | Capsule shared document with compiler scope; native in Sandbox document | style unit tests and visual canaries | Reject selectors that escape scope; ref-count styles; stable identity across HMR. | +| unscoped/global CSS | Sandbox or fail closed | source-policy unit tests | No network-bearing CSS bypass; preserve existing card in iframe. | +| dynamic inline styles | currently conservative Sandbox selection | source-policy tests | Keep Sandbox until `surfaceStyle` covers the exact property/value contract. | +| theme CSS variables | Host resolves `cardInfo.theme` and attaches bounded variables | preview integration; Themed Dashboard manual | Direct/Capsule/Sandbox visual token parity and update test. | +| inherited parent CSS/custom properties | delegated FieldDef must retain Base wrapper and inheritance | Computed Flight Plan manual parity | Exact nested FieldDef CSS conformance test. | +| native image | SES-safe markup; iframe media bridge for child-private fetch | media bridge unit tests | Relative URL resolution, cross-realm grant, content type, loading/error, edit/format switch. | +| native audio/video | SES-safe unless authored code requests browser authority | manual corpus | Add play/control/source/poster tests without iframe escalation. | +| canvas/WebGL/Three.js/3MF | Sandbox document | source policy + manual corpus | Hosted origin, asset/module policy, resize, cleanup, context loss, prerender placeholder. | +| Leaflet/maps | Sandbox document unless replaced by a narrow surface | manual corpus | Tiles/style/module policy, pointer, marker state, resize, teardown. | +| Mermaid/KaTeX/CodeMirror | trusted Base portal loads Host-vetted packages | exact Rich Markdown test | Nested Capsule -> Base portal -> authored embed graph, edit write parity. | +| top layer/popover | Sandbox while it requires document-global top-layer behavior | source-policy test + manual corpus | Correct iframe containment, focus, size, close, and teardown. | +| view transitions | Sandbox until `surfaceTransition` exists | source-policy + manual corpus | Namespaced identity and lifecycle if brought into Capsule. | + +## Iframe Sandbox protocol inventory + +| Message/semantic | Current POC | Required new-runtime treatment | +| --------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| exact-origin `listening` + bootstrap id | Implemented and tested | Version negotiation, nonce origin, one-use port, sibling-frame rejection. | +| `connect` | document/draft, root module, presentation, `canWrite` | Replace ad hoc payload with versioned Sandbox session/grant record. | +| `ready` | card id, revision, error, type presentation | Separate loaded, interactive, and failed states; preserve last-known-good placeholder. | +| `render` | format, height mode, field/code ref, container flag | Persistent child session; open-ended format; stable child document where compatible. | +| `permissions` | `canWrite` boolean | Update without frame remount; Host still rechecks every mutation. | +| `resize` | finite width/height | Intrinsic for isolated/embedded/edit/atom; allocated for fitted; clamp/rate-limit. | +| `surface-presentation` | validated solid container background | Generalize through `SurfaceService` without arbitrary CSS/DOM. | +| `fetch-request/response` | bounded module/media broker | Bind to declared graph and grant; no ambient credentials or arbitrary authenticated fetch. | +| `card-update/result` | full data-only card document and revision | Move to canonical mutation protocol with field/card identity and conflict semantics. | +| draft/HMR | source, URL, revision | Source-hash generation, child acknowledgement, LKG, no server-echo remount. | +| media hydration | private image transport | Extend only by content-specific reviewed capability; do not make it generic credentialed network. | +| height readiness and header spinner | implemented UI pieces | Spinner right of Realm icon; prerender remains visible until interactive; no layout jump. | + +## Corpus coverage ledger + +The corpus README remains the detailed visible-proof specification. This table +records what each card contributes to the migration, and whether the current +automated suite proves that exact card shape. + +| # | Card | Primary contract tags | Best current evidence | Missing exact proof | +| --: | ----------------------- | --------------------------------------------- | ----------------------------------------- | -------------------------------------- | +| 1 | Primitive Profile | primitives, compute, three formats, wide | Contract automated + manual parity | Exact three-format fixture | +| 2 | Nested Field Host | `contains(FieldDef)`, delegated CSS/write | Exact automated COLD-02 | Direct/Sandbox conformance | +| 3 | Activity Timeline | indexed `containsMany`, sort | Manual parity; indexed contract automated | Exact ordering/update test | +| 4 | Rich Markdown Article | trusted portal, Mermaid, editor, embeds | Exact Rich Markdown core CORPUS-02 | Full nested embed graph | +| 5 | Linked Project | links, delegated cards/formats | Exact automated COLD-03 | Cross-principal grant | +| 6 | Query Board | query-backed links | Manual parity | Exact loading/update/error test | +| 7 | Safe Interaction | tracked/action/`on` | Contract automated | Exact corpus fixture | +| 8 | Themed Dashboard | `cardInfo.theme`, CSS variables | Preview contract + manual parity | Exact theme update test | +| 9 | Browser Canvas | iframe, canvas, intrinsic height | Protocol automated + manual parity | Real child-document CI test | +| 10 | Default Template | trusted Base fallback | Contract automated + manual parity | Explicit no-sandbox timing/parity test | +| 11 | Computed Flight Plan | nested/chained compute, CSS inheritance | Exact compute contract + manual parity | Exact CSS assertions in CI | +| 12 | Recursive Discussion | recursive `containsMany(FieldDef)` | Exact automated CORPUS-03 | Cycle/budget failure case | +| 13 | Inherited Experience | inherited fields/computeds | CORPUS-03 analogous + manual | Exact inheritance fixture | +| 14 | Variant Scorecard | nested config/dynamic presentation | Manual parity | Configuration conformance | +| 15 | Sectioned Profile | DateField, DOM, anchor, iframe height | Protocol contract + manual | Exact navigation/height test | +| 16 | CardInfo Recipe | name/summary override | Metadata contract + manual | Full `cardInfo` aliases test | +| 17 | Editable Rating | icons/helpers/action/`@set`/atom | `@set` contract + manual | Exact edit/read-write test | +| 18 | Atomic Work Item | generated FieldDefs/edit/select/radio | Field relationship tests + manual | Generated type/edit fixture | +| 19 | Multi-format Signal | trusted FittedCard/icons/formats | Exact format contract + manual | Trusted component portal nesting | +| 20 | Dynamic Title Group | template-only, dynamic element, splattributes | Manual parity | Glimmer bridge conformance | +| 21 | Typed Command Lab | `Command`, typed context/progress | Command unit contract + manual | Named capability end-to-end | +| 22 | Surface Data Table | Surfaces Environment/Layout/Grid | Manual parity | New runtime portal/API decision | +| 23 | Poster Board | Surfaces, geometry, images, nested fields | Manual parity | Graph + image + mutation test | +| 24 | Workflow Studio | tracked state/actions/workflow | Contract automated + manual | Exact no-remount test | +| 25 | Image Story | realm image/CSS | Media contract + manual | Visual/layout and error test | +| 26 | Surface Command Center | Surfaces, form, metrics | Manual parity | New runtime portal/API decision | +| 27 | View Transition Gallery | document transition/CSS pseudo-elements | Source-policy + manual | Sandbox child integration | +| 28 | Fabrication Viewer | Three.js/3MF/WebGL/cleanup | Source-policy + manual | Asset/cleanup/prerender CI | +| 29 | Video Dispatch | native video/poster/sources | Manual parity | Exact SES no-escalation test | +| 30 | Audio Program | native audio/realm media | Manual parity | Exact SES no-escalation test | +| 31 | Media Cue Workflow | nested fields/tracked selection | Contract automated + manual | Playback graph integration | +| 32 | Geo Dispatch Map | Leaflet/CSS/tiles/pointer/cleanup | Manual parity | Hosted Sandbox/network test | +| 33 | Chord Conductor | UMD/Tone/WebAudio/gesture/cleanup | Manual parity | Activation/audio/teardown test | +| 34 | Flip Memory | keyboard/click/3D CSS/tracked | Contract automated + manual | Exact accessibility/state test | +| 35 | Sorted Queue | multi-key derived sort | Manual parity | Exact deterministic ordering test | +| 36 | Playback Capability Lab | leader/epoch/sequence/lease | Fixture/manual semantic proof | Real `surfacePlayback` implementation | +| 37 | Viewport Relay | pan/zoom intent/effective state | Fixture/manual semantic proof | Real `surfaceViewport` implementation | +| 38 | CSS Carousel Deck | nested fields/3D CSS/keyframes | CSS contracts + manual | Exact scoped keyframe/state test | +| 39 | Protocol Event Log | ordering/discriminated events/ack | Manual parity | Exact sequence/ack test | +| 40 | Top Layer Studio | popover/backdrop/nested control | Source-policy + manual | Sandbox focus/size integration | + +### Real-card probes + +| Card | Implicit APIs it revealed | Migration requirement | +| -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Scrabble Stream | lifecycle, scheduling, authenticated AI proxy, collaborative state | Keep ordinary UI in Capsule once named lifecycle/schedule/data capabilities exist; do not generalize to ambient Host access. | +| Tier Maker | modifiers, pointer/drag, focus, clipboard, haptics, transitions, dynamic styles, image media | Preserve current iframe fallback first; then migrate only covered operations to `surface*`; exact image and edit/return tests. | +| Assistant Run | Host tools, Realm runner operations, toolbar placement | `surfaceSlot` for presentation and separately reviewed command capabilities for data/effects. | +| Signet Proposal | enum factory, Markdown, canvas/signature, commands | Trusted Base enum/Markdown portals; isolate only the canvas package if it truly requires browser DOM. | +| Invoice Billing Form | nested Base FieldDefs, configuration, dynamic color style, writes | `surfaceStyle` or trusted Base portal; writable parity is mandatory. | + +## UX parity checklist + +The architecture is not complete when the card eventually renders. It must +retain the behavior users experienced on `main` and the successful parts of +the POC. + +### Interact mode + +- [ ] Trusted Base/default templates appear without starting a Capsule or + Sandbox. +- [ ] Card header title, icon, `headerColor`, theme, and wide-format state are + correct on first stable paint; no `Untitled` settlement. +- [ ] Prerendered HTML is format-correct and remains visible while a live + Capsule/Sandbox becomes interactive. +- [ ] The loading spinner appears beside the Realm icon only while the live + renderer is not interactive. +- [ ] Intrinsic and allocated sizing obey format semantics without double + frames or layout jumps. +- [ ] Edit controls are writable exactly when the Host permission says they + are; no read-only/writable flashing. +- [ ] A failed generation keeps last-known-good output and shows the standard + bottom error overlay. +- [ ] Reload Card deliberately replaces only the selected render generation. +- [ ] Execution signage reports `Direct`, `Capsule`, or `Sandbox` for the + selected format and does not imply a trust label for the whole card. + +### Code mode and HMR + +- [ ] File tree and recent files do not wait for card analysis/rendering. +- [ ] Monaco mounts when source arrives and does not wait for preview startup. +- [ ] A valid local generation updates the existing render island. +- [ ] Matching save/index/SSE echoes acknowledge that generation rather than + restoring old code or remounting the card. +- [ ] Syntax/runtime failures retain last-known-good output and standard error + UI. +- [ ] Source classification and transpilation are cached by source hash. +- [ ] Volatile state lasts at least the agreed quiet period and is shared by + Monaco, AI patches, and out-of-band Realm file writes. +- [ ] Format switching reuses the same module graph and a bounded warm-island + cache. + +### Performance and lifetime + +- [ ] Direct/Base loaders are immune to user-module churn. +- [ ] Capsule runtimes are keyed by explicit principal/generation policy and + never per card by accident. +- [ ] Sandbox documents persist across compatible format/source updates. +- [ ] Nested graph nodes share completed loads and styles by identity. +- [ ] Cross-realm navigation releases inactive runtimes, ports, observers, + timers, styles, media resources, and Store residency. +- [ ] A fitted gallery does not perform global invalidation or create an + iframe per compact child when a safe/inert renderer exists. + +## Missing coverage to add before the refactor deletes POC paths + +The following gaps are higher priority than adding more pairwise unit tests: + +1. Exact Rich Markdown graph: Capsule article -> trusted Rich Markdown portal + -> embedded Capsule card -> authored FieldDef, with Mermaid and edit. +2. Exact mixed-runtime graph: Capsule parent -> linked Sandbox child -> Host + mutation -> parent and child reconciliation. +3. Sandbox child integration: execute a real nested Base field and authored + child inside the child document in CI, not only protocol message mocks. +4. Field configuration cross-product: static, functional, inherited, + per-usage, dynamic update, and custom edit renderer. +5. Query relationship lifecycle: loading, success, empty, error, membership + update, navigation, and teardown. +6. Blocks/yields through a trusted portal, including authored callback and + contextual component identity. +7. One split-module card whose compact formats are Capsule and whose isolated + format is Sandbox. +8. Format-correct prerender placeholder -> Capsule and placeholder -> Sandbox + handoffs with stable dimensions. +9. Permission revocation during a nested edit, with a forged stale write + rejected by generation and principal. +10. Transport-equivalence harness that runs every shipped `surface*` + capability through Direct, Capsule, and Sandbox adapters from one behavior + specification. + +## New-architecture migration ledger + +This ledger should be updated in the new branch. A row may be checked only +when the old path has been replaced and its exact proof passes. + +- [ ] `BoxelRuntime` owns type description, projection, compute/getter + execution, and mutation proposals for Direct, Capsule, and Sandbox adapters. +- [ ] `BoxelRenderRecord` is the only card/field data representation consumed + by confined renderers. +- [ ] `FormatDescription` is open-ended and contains renderer, sizing, + presentation, and prerender policy. +- [ ] `CapsuleComponentRuntime` is the only Capsule-to-Glimmer bridge. +- [ ] Trusted Base components/helpers/modifiers are explicit portals with + enumerated args, blocks, callbacks, and capabilities. +- [ ] `SurfaceService` owns surface registration, generation, dispatch, + revocation, coordination, and Direct/Capsule/Sandbox adapters. +- [ ] Sandbox uses one versioned session protocol and a child-local + Boxel/Glimmer runtime; no Host object or credential crosses. +- [ ] Store grants bind principal, allowed roots/cards/operations, expiry, and + revocation to every sandbox-originated read, search, hydrate, or write. +- [ ] Render graph traces, depth/node budgets, cycle detection, and + graph-aware teardown are implemented. +- [ ] Direct, Capsule, Sandbox, prerender/index, Interact, and Code preview all + consume the same semantic records instead of rebuilding snapshots. +- [ ] The graph gauntlet and missing-coverage list above are green. +- [ ] The 40-card corpus has no red compatibility cells for three consecutive + representative expansion rounds, with every newly discovered contract + minimized into CI before the POC implementation is removed. + +## Review rule + +When a refactor changes one of these contracts, reviewers should be able to +follow a single row from authored syntax, to semantic owner, to boundary +record, to runtime adapter, to pairwise test, to nested graph test, to visible +UX expectation. If that chain cannot be followed, the semantic has not yet +been made explicit enough to safely replace the POC. diff --git a/docs/boxel-execution-runtime-mutation-protocol.md b/docs/boxel-execution-runtime-mutation-protocol.md new file mode 100644 index 00000000000..06a2830f65b --- /dev/null +++ b/docs/boxel-execution-runtime-mutation-protocol.md @@ -0,0 +1,310 @@ +# Boxel execution runtime: mutation protocol + +## Status and scope + +This is a proposed companion contract for the Boxel execution runtime. It +describes how edits originating in Direct, Capsule, or Sandbox rendering become +canonical Store mutations without sending a rendered projection back as card +JSON:API. + +In this document, **Boxel means Box Element**: a CardDef, FieldDef, FileDef, or +future compatible visual building block. Realm remains the server-side data and +module location. + +This proposal covers: + +- editable projections and bounded write grants; +- serialization of primitive, contained, and linked changes; +- optimistic state, validation, acknowledgement, and rollback; +- typed Commands and long-running effects; and +- identical mutation behavior across Direct, Capsule, and Sandbox execution. + +It does not define BXL policy evaluation, provider credentials, collaborative +text internals, or the complete Realm Server authorization model. BXL +projection is specified separately in +[boxel-execution-runtime-authorization-projection.md](boxel-execution-runtime-authorization-projection.md). + +## Why mutation is not render transport in reverse + +A render record may contain computed values, expanded linked resources, +side-loaded data, presentation metadata, authorization decisions, and resolved +FieldDef state. None of those facts makes them writable card JSON:API. + +The failure mode already has a concrete signature: a UI edits a projected +record and submits the entire object, then the server rejects it because +side-loaded data is not a valid card resource. A more dangerous implementation +could silently persist presentation or foreign relationship data. + +Mutation therefore starts with a named semantic change, not a modified render +snapshot: + +```text +rendered projection + -> named field/relationship/Command intent + -> current write grant + -> serializeCardPatch() + -> canonical JSON:API attributes + relationship identifiers + -> Host admission + -> Realm Server authorization and validation + -> Store acknowledgement or bounded refusal +``` + +## Invariants + +1. The Store owns canonical card documents and relationship identity. +2. Receiving a document, render record, component handle, or Surface capability + grants no mutation authority. +3. A rendered projection is never submitted as a PATCH body. +4. Every change names its target Boxel, path, expected revision, and write + grant. +5. Computed, presentation-only, authorization, side-loaded, unknown, and + foreign values are rejected before network IO. +6. Linked relationships serialize identifiers, never expanded child records. +7. Contained values serialize only through their declared field schema. +8. Writability is stable while the same edit session and authorization + generation remain valid; the UI does not flash read-only while metadata + settles. +9. Optimistic state never becomes authority. The server independently + authorizes and validates every operation. +10. A matching SSE/index event acknowledges the active mutation; it does not + reload an older snapshot or remount the renderer. +11. A stale, denied, invalid, or conflicting mutation preserves the last known + good canonical document and returns a structured result. +12. Direct, Capsule, and Sandbox use the same mutation request and result + semantics. + +## Ownership + +| Concern | Owner | Boundary representation | +| ----------------------------------- | ---------------------------------- | ------------------------------------------------ | +| canonical document and revision | Store | stable Boxel id and document revision | +| server authorization and validation | Realm Server | admitted receipt or structured refusal | +| client authorization projection | `BoxelAuthorizationService` | available Commands and writable paths | +| edit session and optimistic overlay | Host mutation coordinator | edit-session id and generation | +| semantic PATCH serialization | trusted Boxel runtime/Base schema | canonical JSON:API patch | +| authored edit UI | Direct/Capsule/Sandbox renderer | named mutation intent | +| persistence and acknowledgement | Store + Host mutation coordinator | mutation id, canonical revision, changed paths | +| long-running effects | Host capability broker/Run records | typed Command request and durable Run/Job result | + +Mutation is not a `surface*` capability. Surface capabilities coordinate a +mounted visual root. Mutation authority is scoped to principal, target Boxel, +document revision, semantic path, operation, and authorization generation. + +## Protocol records + +The transport is cloneable and descriptive. It contains no Store method, +component callback, or ambient service. + +```ts +export interface BoxelWriteGrant { + id: string; + principal: string; + target: CardIdentifier; + documentRevision: string; + authorizationRevision: string; + writablePaths: string[]; + availableCommands: string[]; + expiresAt?: string; +} + +export type BoxelMutationIntent = + | { + kind: 'set-field'; + path: string; + value: JSONValue; + } + | { + kind: 'set-relationship'; + path: string; + value: CardIdentifier | null; + } + | { + kind: 'set-relationships'; + path: string; + value: CardIdentifier[]; + } + | { + kind: 'invoke-command'; + command: string; + input: Record; + }; + +export interface BoxelMutationRequest { + protocolVersion: number; + mutationId: string; + editSessionId: string; + generation: number; + target: CardIdentifier; + expectedRevision: string; + writeGrantId: string; + intent: BoxelMutationIntent; +} + +export type BoxelMutationResult = + | { + status: 'accepted'; + mutationId: string; + canonicalRevision: string; + changedPaths: string[]; + } + | { + status: 'rejected'; + mutationId: string; + reason: + | 'unauthorized' + | 'read-only' + | 'stale-revision' + | 'invalid-value' + | 'invalid-relationship' + | 'unknown-path' + | 'conflict'; + safeMessage?: string; + currentRevision?: string; + }; +``` + +The exact public names can be revised during implementation, but these facts +must remain explicit. An opaque `set(model)` callback does not carry enough +identity, revision, or authority to be a safe cross-boundary mutation API. + +## Canonical patch serialization + +`serializeCardPatch()` is a trusted semantic operation. It resolves the target +field against the same Boxel description used to build the render record, then +emits the smallest canonical patch. + +It must distinguish: + +| Field shape | Canonical write | +| ------------------- | ------------------------------------------------------------------ | +| primitive | declared JSON value under the canonical attribute path | +| compound/FieldDef | schema-validated contained attribute object | +| contains | contained data owned by the parent field | +| containsMany | ordered contained values with stable identity where needed | +| linksTo | one relationship identifier or null | +| linksToMany | ordered relationship identifiers | +| computed/computeVia | rejected; no canonical write path | +| query field | rejected; query definitions/results are not persisted as card data | +| presentation | rejected unless separately declared canonical card data | + +Field configuration can affect parsing, validation, labels, and controls, but +does not create a write path that the underlying schema lacks. + +## Edit-session behavior + +An edit session binds: + +- target Boxel and canonical revision; +- authorization/write-grant generation; +- projected editable fields and relationships; +- optimistic overlay generation; and +- pending mutation ids. + +The Host can show the editor as soon as source/data and the last valid grant are +available. It should not toggle writable/read-only state because a redundant +card load, template load, or sandbox classification completes. Writability +changes only when the authoritative permission, target, revision/conflict +state, or explicit mode changes. + +Local edits update an optimistic overlay immediately. Lint, server save, +indexing, Matrix acknowledgement, and SSE are separate phases. A matching +server echo settles the pending mutation and advances the canonical revision +without replacing the optimistic generation. An older or non-matching echo +cannot overwrite it. + +## Execution-tier behavior + +### Direct + +Trusted components emit the same named intent through the Host mutation +coordinator. Direct execution does not get a privileged Store-write shortcut. + +### Capsule + +Authored code sends a cloneable mutation request keyed to its execution +identity and write grant. The Host validates identity, target, path, revision, +and grant before calling trusted serialization or persistence. + +### Sandbox + +The iframe sends the same request over the versioned protocol. It receives only +the structured result and any subsequently projected canonical state. Guessing +a field path, relationship id, command name, or mutation id grants nothing. + +Nested delegated rendering does not inherit ambient write authority. Each child +slot receives only the grant appropriate to its target and projected semantic +path. A writable parent cannot mutate a linked child unless a distinct grant +permits it. + +## Commands and asynchronous work + +A Command is used when the operation is more than a canonical field or +relationship PATCH. Examples include approval transitions, image generation, +provider calls, file writes, and workflow advancement. + +The render projection exposes a descriptive Command name and input schema. On +invocation, the Host capability broker checks execution identity and the +current authorization projection; the Realm Server checks again. Long-running +work creates or updates canonical Run/Job cards. Component lifetime is not the +job lifetime. + +Partial success, progress, retry, cancellation, timeout, and duplicate +acknowledgement are durable state transitions. Remounting a card or destroying +an iframe cannot cancel or erase already admitted work unless an explicit +authorized cancellation Command does so. + +## Authorization interaction + +Authorization projection controls whether an edit affordance, writable path, +or Command is presented. Mutation admission controls whether an attempted +operation may execute now. + +Revocation between display and invocation is expected. The Host rejects the +stale write grant and the server rejects the operation. The UI refreshes the +authorization projection and retains user input as a non-authoritative draft +when it is safe to do so; it never retries under broader authority. + +## Acceptance coverage + +The cumulative composition suite must prove: + +- primitive, compound, contained, contained-many, linked, and linked-many + edits in Direct, Capsule, and Sandbox; +- null, empty, zero, false, ordering, deletion, and replacement semantics; +- read/write parity with Direct rendering and no transient read-only flash; +- computed, query, presentation, unknown, and side-loaded values are excluded; +- the exact JSON:API body contains only the intended canonical change; +- nested delegated children cannot borrow a parent or sibling grant; +- stale revision, conflict, invalid value, invalid relationship, and + authorization revocation have structured outcomes; +- optimistic UI is immediate and matching acknowledgement does not remount; +- an older SSE/index echo cannot restore old data or code; +- Command invocation is independently authorized and returns durable state; + and +- teardown releases edit sessions and protocol handles without discarding + admitted server work. + +## Implementation sequence + +1. Define mutation intent, write-grant, request, and result records. +2. Implement `serializeCardPatch()` against the canonical Boxel description. +3. Add a Host mutation coordinator with edit-session and generation identity. +4. Route existing Direct edits through it without changing visible behavior. +5. Add Capsule and Sandbox protocol adapters for the same requests. +6. Separate optimistic application, validation, persistence, indexing, and + acknowledgement state. +7. Treat matching SSE/index events as acknowledgements rather than reloads. +8. Integrate BXL authorization projection and reauthorization. +9. Add the cross-tier mutation matrix and browser interaction tests. +10. Remove whole-snapshot PATCH paths only after unchanged-card parity passes. + +## Open design decisions + +- the stable identity model for members of `containsMany` fields; +- whether simple field intents are batched per animation frame or edit + transaction; +- conflict UX for independent fields versus compound/ordered values; +- how collaborative fields publish materialized revisions into the ordinary + Store acknowledgement stream; and +- how long a safe local draft may survive authorization revocation or target + navigation. diff --git a/docs/boxel-execution-runtime-real-example-audit.md b/docs/boxel-execution-runtime-real-example-audit.md new file mode 100644 index 00000000000..88404a8f0f0 --- /dev/null +++ b/docs/boxel-execution-runtime-real-example-audit.md @@ -0,0 +1,286 @@ +# Boxel execution runtime real-example audit + +## Purpose and scope + +This audit checks the crafted +[twelve-use-case composition suite](boxel-execution-runtime-composition-suite.md) +against fifty examples we developed, debugged, or used as architecture probes +together outside the sandbox compatibility corpus. It includes the Boxel Labs +Surface workbench and component demos as well as realm cards that exposed +boundary regressions. + +In this document, **Boxel means Box Element**, not the product as a whole. +Some Boxel Labs entries are reference programs rather than persisted CardDef +instances. They still belong in this audit because they exercise the visual, +interactive, and composition mechanisms that a CardDef/FieldDef/FileDef must +be able to use through the execution runtime. + +Coverage labels describe the **planned twelve-case suite**, not tests that +already pass: + +- **Strong** — the suite names the mechanism, assigns it to a concrete fixture, + and specifies semantic, visual, interaction, boundary, and lifecycle checks. +- **Partial** — the suite has the underlying capability but not the full + behavior demonstrated by the example. +- **Gap** — the suite does not yet have a deterministic owner for the + mechanism. + +## Boxel Labs foundation Surface vocabulary + +The Boxel Labs foundation exports all of these Surface kinds, and the suite +must mount every one at least once in a real nested graph: + +`Environment`, `Layout`, `Canvas`, `Scene`, `Grid`, `Row`, `Scroll`, `Flow`, +`Frame`, `Pane`, `Plane`, `Outline`, `Cell`, `Run`, and `Unit`. + +Merely importing them is insufficient. The conformance fixture must verify +identity/path propagation, parent context, coordinate space, mode, focus, +selection, inspection, edit routing, accessibility semantics, and teardown. + +## Fifty real examples + +### Boxel Labs and Surface reference programs + +| # | Example | Mechanisms it contributes | Suite coverage before this audit | +| --: | ------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| 1 | Basic Layout | all fifteen foundation Surface kinds, nesting, ambient parent context, representative fixtures | Partial — case 8 had a board but not an all-foundation conformance tree | +| 2 | Surface Accessories | cue label/description/status, accessory aliases, ARIA description wiring, non-product chrome | Partial — case 8 named presentation but not accessory semantics | +| 3 | Airline Dashboard | deep mixed Layout/Grid/Flow/Scene/Outline composition, dashboard geometry, dense domain presentation | Strong — cases 8 and 12 cover the graph and visual geometry | +| 4 | Airline Surface Language | semantic Surface vocabulary used as ordinary product markup, dynamic context, compact/full presentations | Partial — composition is covered; vocabulary/default selection was implicit | +| 5 | Keyboard Surface Navigation | focus ladder, arrow traversal, one selected leaf, ancestor context, keyboard mode | Gap — focus existed, but the complete navigation state machine did not | +| 6 | Surface Modes | use/change/inspect, hover-versus-focus separation, subtree mode propagation | Gap — execution formats were covered, Surface modes were not | +| 7 | Combinatorial Workspace | many Surface types in one workspace, stable paths, nested combinations, teardown | Strong — case 12 is the graph analogue, once all foundation types are added | +| 8 | In-place versus Lift Editing | inline edit, lifted edit, edit-route choice, commit/cancel/focus return, portal/layer | Gap — case 7 covered edit but not inline-versus-lift routing | +| 9 | Notion Document | outline/row/run/unit hierarchy, rich document editing, coordinate debug, lifted controls | Partial — case 6 covers rich content; Surface outline/edit routing was absent | +| 10 | V2 Notion | runtime policy, target resolution, node snapshots, edit-route policy | Partial — runtime selection is covered, generic Surface policy conformance was not | +| 11 | V2 Spreadsheet Structured Value | grid coordinates, structured values, directional movement, policy-selected target | Partial — ordinary fields covered; spreadsheet navigation and structured-cell moves did not | +| 12 | V3 Drag Network | cross-container drag, surface posture, typed target network, move semantics | Gap — case 8 had drag/resize but not a typed placement network | +| 13 | V3 Rule Templates | CSS-like Surface rule matching, specificity, component selection, contextual templates | Gap — no deterministic rule-resolution fixture existed | +| 14 | V3 Outline/Scroll Directives | descendant/subtree directives, outline plus scroll, posture propagation | Gap — no directive scope/inheritance assertions existed | +| 15 | Grid Spreadsheet Example | virtual rows, cell focus, range selection, keyboard movement, resize/pin behavior | Gap — no high-density Grid conformance fixture existed | +| 16 | Grid Widgets Showcase | checkbox, date, currency, rating, status, priority, text and widget editing in cells | Partial — field controls are covered individually, not inside a virtual Grid | +| 17 | Grid Cell Popover | top-layer/lifted cell editor, focus containment, commit/cancel, anchor geometry | Gap — same missing Lift/Plane contract as example 8 | +| 18 | Grid Fill Handle | drag selection, range overlay, fill operation, drop indicator, autoscroll edge cases | Gap — poster dragging does not prove spreadsheet fill semantics | +| 19 | Canvas Demo | nodes, edges, handles, pan/zoom, reconnect, resize, minimap, viewport portal | Partial — cases 8/12 cover pan/zoom and positioned cards, not graph editing | +| 20 | Scene Demo | camera drag, wheel momentum, node motion, halo/FX, WebGL effects, scene coordinates | Partial — case 9 covers WebGL/3D lifecycle, not Scene camera semantics | +| 21 | Surface Matrix | side-by-side Surface comparison, selected detail, lift host, semantic matrix | Partial — visual matrix exists conceptually, but no suite case checks Surface parity rows | +| 22 | Surface Primer | foundation explanations rendered as accessible working examples | Partial — semantics are distributed across cases rather than one primer fixture | +| 23 | Surface Mini Demo | compact composition, constrained geometry, minimal nested state | Strong — fitted/embedded and nested geometry are explicit | +| 24 | Widget Lab Table Pane | table/container rendering, row/cell selection, pane sizing, data-density behavior | Partial — case 10 gains a Grid fixture from this audit | +| 25 | Canvas Frame Mockup | canvas inside frame, allocated coordinate region, clipping and presentation shell | Strong — cases 8/9 cover allocated layout, viewport, and presentation | +| 26 | Cell Multileaf Mockup | one cell with multiple leaves/slots, focus target choice, compact composition | Partial — nested slots are covered, multi-leaf focus arbitration was not | +| 27 | Empty/Loading/Error Mockup | stable geometry and explicit empty/loading/error states in the same Surface | Strong — cases 5, 7, 9, and 11 cover all three plus last-known-good | +| 28 | Lift Panel Mockup | lifted panel, anchor/portal geometry, modal/popover plane, dismiss and focus return | Gap — top-layer/lift lifecycle lacked a test owner | + +### Realm cards and application probes + +| # | Example | Mechanisms it contributes | Suite coverage before this audit | +| --: | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| 29 | Tier Maker / TierList | cross-list drag/drop, reorder, images, dynamic styles, focus, clipboard, haptics, view transitions, edit/return state | Partial — images/edit/styles covered; full placement, clipboard, haptics, and transitions were not | +| 30 | Scrabble Stream / Replay | BXL mutation, streamed/collaborative state, scheduling, replay ordering, authenticated AI operation | Partial — command/HMR covered; BXL and deterministic multi-client scheduling were not | +| 31 | DMN Workflow Catalog | RichMarkdown, complex tables, Mermaid, nested business-decision data, large content | Strong — case 6 expressly covers RichMarkdown/Mermaid/table/nested embeds | +| 32 | Software Periodic Table | large fitted gallery, `prefersWideFormat`, many child cards, format-specific CSS, selection | Strong — cases 1, 5, and 12 cover wide format, fitted children, styles, and identity | +| 33 | Integrated Software Layer Matrix | wide matrix, nested fields, computed/grouped rows, themes, fitted-versus-isolated parity | Strong — cases 2, 3, 8, and 10 cover these mechanisms | +| 34 | Invoice Billing Form | nested Base FieldDefs, field configuration, currency, dynamic color, writable edit, validation | Strong — cases 2 and 7 were designed from these failures | +| 35 | Coffee Shop Menu | query/relationship composition, images, commands/BXL update, nested product cards, themes | Strong — cases 3, 5, 7, 10, and 11 cover it | +| 36 | Coffee Bean Product | image URL/ImageDef pair, currency/number formatting, computed inventory, country options, nested writes | Strong — cases 2, 3, and 7 explicitly guard the observed regressions | +| 37 | Gym Shift Board | repeated cards, scheduling/workflow state, themes, actions, compact rows | Partial — list/actions/theme covered; schedule semantics needed a stronger owner | +| 38 | Signet Proposal | enum factory, Markdown, canvas/signature input, commands, theme | Strong — cases 3, 6, 7, 8, and 11 cover it | +| 39 | Assistant Realm Runner / AssistantRun | Host tools, typed commands, Realm operations, toolbar slot, progress, restricted authority | Partial — command/slot covered conceptually; Host-tool and data grants need exact negative tests | +| 40 | Tribeca Sign Maker | Three.js/3MF, browser/document dependency, iframe height, poster fallback, editable source | Strong — case 9 directly models this split-module path | +| 41 | NYC Fire Guard Practice Exam | randomized questions, forms, derived score, progress, repeated interaction | Partial — fields/actions/compute covered; deterministic seeded workflow progression was not explicit | +| 42 | Cardstack AI-Native Landing Page | very large scoped CSS, theme variables, responsive layout, navigation, Monaco HMR | Strong — cases 3 and 11 cover style confinement and volatile source updates | +| 43 | Attendance Staff Member | nested linked profiles/assignments, computed labels, actions, image/avatar, live data | Strong — cases 3, 5, and 7 cover it | +| 44 | Color Tree Playground | custom components, `prefersWideFormat`, color fields, safe modifier behavior, several formats | Strong — cases 1, 2, 3, and 8 cover it | +| 45 | Airline Flight / AA4500 | deep linked BXL/computeVia graph, pre-indexed computed values, currency/percent, themes | Strong — cases 2, 5, and 10 cover deep compute/relationship projection | +| 46 | Recipe Card / Fire-Roasted Beans | ordinary Card API, nested ingredients/content, image, default and authored formats | Strong — cases 1–3 provide the ordinary-card baseline | +| 47 | Commonplace Proposal | RichMarkdown/editor, publication navigation, theme, responsive long-form layout | Strong — cases 3 and 6 cover it | +| 48 | Realm Collaboration / Collab Scene | cross-Realm links, scene state, user authorization, collaborative updates | Partial — grants/Scene exist separately; multi-client collaboration was not combined | +| 49 | Iterative Image Generation Pipeline | Realm Script planning, voice→prompt→image→persist stages, 25+ Job runs, optimistic progress, binary files, ImageDef links, partial failure and retry | Partial — command/progress existed, but case 11 did not yet own the full asynchronous pipeline | +| 50 | Greasy Gecko Boxel AI Website | large themed site layout, animation, custom components, inherited content and responsive CSS | Partial — theme/CSS/HMR covered; animation scheduling and view-transition lifecycle were weak | + +## Coverage verdict + +The original twelve-case suite covered most **Boxel semantic data paths** but +not most **advanced Surface interaction paths**. + +My assessment before applying the improvements below: + +| Mechanism family | Assessment | Why | +| ---------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Primitive/compound fields, enum, field configuration | Strong | Cases 1–3 enumerate the Base catalog, configuration callbacks, cache invalidation, and nested getters. | +| contains/containsMany/linksTo/linksToMany | Strong | Cases 2, 5, 7, and 10 cover identity, loading, broken slots, mutation, and grants. | +| computeVia/getters/BXL-derived values | Partial | Getter/compute graphs are strong; BXL mutation/evaluation/acknowledgement needs an exact fixture. | +| Queries and query fields | Strong | Case 10 covers filters, sorts, refs, consumers, refresh, and PATCH omission. | +| Images/files/media | Strong | Image pairs, polymorphism, files, audio/video, 3MF, failures, and cleanup are assigned. | +| Formats/delegated rendering | Strong | Open-ended formats, atom/embedded/fitted/isolated/edit/head/markdown, nested dispatch, and split modules are assigned. | +| RichMarkdown/Mermaid/CodeMirror | Strong | Case 6 is an exact recursive portal/editor fixture. | +| Themes/scoped CSS/presentation | Strong | Cases 3, 8, 9, and 12 assert computed variables, confinement, header/background, and parity. | +| Writes/read-write parity | Strong | Case 7 covers canonical Store mutation, reauthorization, optimistic state, errors, and acknowledgements. | +| HMR/source navigation | Strong | Case 11 covers local and out-of-band changes, generations, last-known-good, and no-remount behavior. | +| 3D/iframe/height/prerender | Strong | Case 9 directly owns this path. | +| Cross-Realm authorization | Strong | Cases 5 and 10 cover explicit selection, narrow grants, revocation, and negative access. | +| Surface identity/path/context | Partial | The suite used surfaces but did not mount every foundation kind or assert context/path rules. | +| Surface modes/focus/accessories | Gap | use/change/inspect, focus ladder, selected leaf, hover, cue/accessory semantics lacked an owner. | +| Inline/lifted editing and portals | Gap | Ordinary edit was covered; lift/plane/top-layer/focus-return was not. | +| Drag/drop/placement | Gap | Poster dragging was much narrower than typed cross-container placement, keyboard/paste parity, ghost, denial, and FLIP. | +| Grid/spreadsheet mechanics | Gap | No virtualization, pinning, range/fill, cell popover, or directional navigation fixture. | +| Canvas graph mechanics | Partial | Pan/zoom and positioned cards existed; edges, handles, reconnect, minimap, and graph editing did not. | +| Scene/camera/effects | Partial | WebGL lifecycle existed; camera momentum, node motion, and scene-coordinate semantics did not. | +| Scheduling/collaboration | Gap | Playback coordination existed; seeded timers, ordered replay, two-client convergence, and reconnect did not. | +| Clipboard/haptics/view transitions | Gap | Listed as proposed `surface*` APIs but not exercised end-to-end. | +| Host tools/AI/command authority | Partial | Positive command flow existed; exact import denial and narrowly granted Host-tool tests were missing. | +| Realm Script and asynchronous AI production | Partial | Realm Script limits, optimistic progress, provider IO, binary persistence, partial success, cancellation, retry, and out-of-order acknowledgement lacked one cumulative fixture. | + +So the honest answer is: **yes for most Card/Field/File and rendering +mechanisms; no for enough of the Surface interaction system that the suite was +not yet a sufficient replacement for the examples.** Counting by the +twenty-three families above, 11 were strong, 6 partial, and 6 gaps. More +importantly, the gaps cluster in the mechanisms most likely to fail at a +sandbox boundary. + +## Changes integrated into the twelve-case suite + +There is only one acceptance suite. The following mechanisms are integrated +directly into their owning cumulative cases rather than maintained as a second +suite or a layer of add-on tests. + +### Case 6: document projection + +Add an Outline projection of the same LinerNotes document: + +- Outline → Row → Run → Unit nested Surface path; +- RichMarkdown headings map to outline rows without copying content; +- use/change/inspect affect the outline and editor consistently; +- keyboard traversal and focus return survive an embedded Track finishing load; +- cue label/description/status accessories remain non-product chrome. + +This absorbs the Notion Document, Markdown Projection, Outline, and Surface +Accessories mechanisms. + +### Case 8: Surface and placement conformance + +CampaignBoard gains two deterministic subfixtures. + +**Foundation tree:** mount all fifteen foundation Surface kinds in one honest +product graph and assert: + +- stable identity and inherited Surface path; +- parent/dynamic context; +- coordinate schema/source; +- use/change/inspect mode propagation; +- one focus leaf, one selected leaf, ancestor context, separate inspect hover; +- inline versus lifted edit route, commit/cancel, anchor geometry, focus return; +- cue/accessory semantics and teardown. + +**Placement lane:** move a Track, Release, and image between two ordered +containers and one PositionedCard target using the same typed acceptance and +commit path. Assert: + +- pointer activation threshold; +- compatible target highlighting and structured denial; +- live ghost at the target-requested format; +- insertion wedge and exact ordered index; +- cross-container move versus reference semantics; +- FLIP/settle animation and stable Card identity; +- keyboard pickup/navigation/commit/cancel; +- clipboard paste through the same placement command; +- pointer capture/autoscroll cleanup; +- transition names scoped by Surface/render-slot identity. + +This absorbs Tier Maker and the Boxel Labs drag/lift/rule examples. Haptics are +an optional result of successful placement; absence or denial never changes +the command semantics. + +### Case 9: Canvas and Scene + +Before mounting the 3D artifact, the safe module renders a small editable +Canvas graph with two nodes and one edge. The Sandbox Scene consumes the same +canonical node data. Assert: + +- node drag/resize, handle connection/reconnection, edge label, minimap, and + viewport portal; +- pan/zoom conversion and allocated geometry; +- camera drag/wheel momentum and one deterministic node transition; +- trusted/shared Canvas primitives do not grant ambient DOM authority to the + authored Capsule; +- WebGL/Scene effects remain Sandbox-local and release every resource. + +This covers the Canvas and Scene reference programs while retaining the +split-module rule. + +### Case 10: query Grid + +Render one ReleaseCollection query through a virtualized Grid in addition to +the existing search-results and card formats. Assert: + +- row/cell identity under virtualization; +- pinned column and resized column geometry; +- keyboard cell navigation and focus preservation; +- range selection and deterministic fill over an editable rating field; +- trusted cell widgets for number, date, currency, enum/status, checkbox, and + rating; +- lifted cell editor and context menu focus/commit/cancel; +- query refresh does not discard selection or remount unrelated cells. + +This gives Grid/spreadsheet behavior a real data/query owner. + +### Case 11: BXL, Realm Script, asynchronous AI, authority, and collaboration + +ProductionConsole gains a deterministic two-client replay harness: + +- a BXL patch changes a Track field and returns the canonical affected paths; +- local generation renders immediately; +- server/index acknowledgement cannot restore the old value; +- seeded `surfaceSchedule` emits ordered ticks and supports pause/resume; +- two clients converge on one ordered Scrabble-like event log after reconnect; +- duplicate/out-of-order acknowledgements are idempotent; +- unauthorized imports of Host tools fail during classification/evaluation; +- a separately granted command handle performs one narrowly scoped Host + operation, with progress and revocation. +- a capability-scoped Realm Script produces a schema-validated plan for four + campaign image variants but never receives provider credentials; +- Host commands execute a deterministic four-job provider harness in CI, + persist successful binaries, and link ImageDefs as results arrive; +- controlled out-of-order completion, one partial failure, cancellation, + retry, timeout, and stale-generation acknowledgement cannot reorder or erase + already durable outputs. + +This absorbs Scrabble Stream, Gym Shift Board, the Iterative Image Generation +Pipeline, collaboration, and Assistant Runner mechanisms without granting +general Host services. + +### Case 12: final graph + +The timeline must combine the new mechanisms rather than merely render them: + +- drag the case-4 MusicPlayer from the query Grid into a timeline lane; +- use the same typed placement path for pointer, keyboard, and paste; +- open its metadata in a lifted editor while playback remains mounted; +- synchronize the audio player, video, Canvas viewport, and Sandbox Scene; +- switch use/change/inspect modes across the composed subtree; +- run one scoped view transition without leaking a global transition name; +- revoke a Partner grant and prove unrelated focus, playback, Grid selection, + and iframe state survive. +- show completed CampaignAssets in the poster/timeline as each result becomes + durable and retry a failed image while playback and editing continue. + +Only this final combination proves that the added tests compose instead of +passing as isolated mechanisms. + +## Remaining deliberate exclusions + +These are not silently ignored. They need separate product/security decisions +before becoming required runtime capabilities: + +- unrestricted clipboard read; +- arbitrary navigator APIs; +- generic vibration/haptics beyond a bounded success signal; +- arbitrary DOM/CSS mutation from Capsule code; +- unbounded timers/background work; +- unrestricted AI/Host tool import; +- cross-Realm search without an explicit Store grant; and +- arbitrary third-party browser packages outside an origin Sandbox. + +The suite should test their denial. It should not invent a broad capability +merely to make one historical example remain Capsule-eligible. From 093538b0a44744a60c3cea440c0f0cefa7e09730 Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:56:29 -0400 Subject: [PATCH 02/91] WIP: build Boxel execution runtime foundation --- docs/boxel-execution-runtime-architecture.md | 4 + docs/boxel-execution-runtime-suite-harness.md | 342 +++ packages/base/card-api.gts | 8 + .../addon/src/modifiers/surface-layout.ts | 40 + .../addon/src/modifiers/surface-observe.ts | 51 + .../src/modifiers/surface-presentation.ts | 55 + packages/boxel-ui/addon/src/surface.gts | 29 + .../app/components/boxel-sandbox-runtime.gts | 125 + .../host/app/components/card-renderer.gts | 8 +- packages/host/app/deprecation-workflow.js | 7 +- .../host/app/lib/boxel-execution-engine.ts | 297 +++ .../host/app/lib/boxel-execution-policy.ts | 38 + packages/host/app/lib/boxel-render-record.ts | 35 + packages/host/app/lib/boxel-runtime-router.ts | 80 + packages/host/app/lib/boxel-runtime.ts | 119 + .../host/app/lib/boxel-source-classifier.ts | 760 ++++++ .../host/app/lib/capsule-boxel-runtime.ts | 431 ++++ .../host/app/lib/capsule-component-runtime.ts | 360 +++ packages/host/app/lib/capsule-component.ts | 269 ++ .../host/app/lib/capsule-module-evaluator.ts | 2210 +++++++++++++++++ .../host/app/lib/capsule-runtime-helpers.ts | 73 + .../host/app/lib/capsule-runtime-registry.ts | 5 + packages/host/app/lib/direct-boxel-runtime.ts | 520 ++++ .../host/app/lib/retained-runtime-registry.ts | 106 + .../app/lib/sandbox-boxel-runtime-client.ts | 205 ++ .../app/lib/sandbox-boxel-runtime-server.ts | 160 ++ .../host/app/lib/sandbox-render-transport.ts | 246 ++ packages/host/app/lib/sandbox-runtime-host.ts | 171 ++ .../host/app/lib/sandbox-runtime-process.ts | 381 +++ .../host/app/lib/sandbox-surface-transport.ts | 261 ++ packages/host/app/lib/surface-client.ts | 34 + .../host/app/modifiers/surface-element.ts | 66 + packages/host/app/router.ts | 3 + .../host/app/routes/boxel-sandbox-runtime.ts | 29 + .../host/app/services/direct-boxel-runtime.ts | 22 + packages/host/app/services/surface-service.ts | 251 ++ .../app/templates/boxel-sandbox-runtime.gts | 13 + packages/host/package.json | 3 + .../integration/components/preview-test.gts | 208 ++ .../modifiers/surface-element-test.gts | 79 + .../unit/lib/boxel-execution-engine-test.ts | 489 ++++ .../unit/lib/boxel-runtime-transport-test.ts | 264 ++ .../lib/capsule-module-registration-test.ts | 122 + .../unit/services/surface-service-test.ts | 81 + .../capsule-module-registration-evaluator.ts | 81 + .../boxel-execution-protocol.ts | 264 ++ packages/runtime-common/index.ts | 9 +- packages/runtime-common/loader.ts | 224 +- pnpm-lock.yaml | 33 + 49 files changed, 9619 insertions(+), 52 deletions(-) create mode 100644 docs/boxel-execution-runtime-suite-harness.md create mode 100644 packages/boxel-ui/addon/src/modifiers/surface-layout.ts create mode 100644 packages/boxel-ui/addon/src/modifiers/surface-observe.ts create mode 100644 packages/boxel-ui/addon/src/modifiers/surface-presentation.ts create mode 100644 packages/boxel-ui/addon/src/surface.gts create mode 100644 packages/host/app/components/boxel-sandbox-runtime.gts create mode 100644 packages/host/app/lib/boxel-execution-engine.ts create mode 100644 packages/host/app/lib/boxel-execution-policy.ts create mode 100644 packages/host/app/lib/boxel-render-record.ts create mode 100644 packages/host/app/lib/boxel-runtime-router.ts create mode 100644 packages/host/app/lib/boxel-runtime.ts create mode 100644 packages/host/app/lib/boxel-source-classifier.ts create mode 100644 packages/host/app/lib/capsule-boxel-runtime.ts create mode 100644 packages/host/app/lib/capsule-component-runtime.ts create mode 100644 packages/host/app/lib/capsule-component.ts create mode 100644 packages/host/app/lib/capsule-module-evaluator.ts create mode 100644 packages/host/app/lib/capsule-runtime-helpers.ts create mode 100644 packages/host/app/lib/capsule-runtime-registry.ts create mode 100644 packages/host/app/lib/direct-boxel-runtime.ts create mode 100644 packages/host/app/lib/retained-runtime-registry.ts create mode 100644 packages/host/app/lib/sandbox-boxel-runtime-client.ts create mode 100644 packages/host/app/lib/sandbox-boxel-runtime-server.ts create mode 100644 packages/host/app/lib/sandbox-render-transport.ts create mode 100644 packages/host/app/lib/sandbox-runtime-host.ts create mode 100644 packages/host/app/lib/sandbox-runtime-process.ts create mode 100644 packages/host/app/lib/sandbox-surface-transport.ts create mode 100644 packages/host/app/lib/surface-client.ts create mode 100644 packages/host/app/modifiers/surface-element.ts create mode 100644 packages/host/app/routes/boxel-sandbox-runtime.ts create mode 100644 packages/host/app/services/direct-boxel-runtime.ts create mode 100644 packages/host/app/services/surface-service.ts create mode 100644 packages/host/app/templates/boxel-sandbox-runtime.gts create mode 100644 packages/host/tests/integration/modifiers/surface-element-test.gts create mode 100644 packages/host/tests/unit/lib/boxel-execution-engine-test.ts create mode 100644 packages/host/tests/unit/lib/boxel-runtime-transport-test.ts create mode 100644 packages/host/tests/unit/lib/capsule-module-registration-test.ts create mode 100644 packages/host/tests/unit/services/surface-service-test.ts create mode 100644 packages/host/workers/capsule-module-registration-evaluator.ts create mode 100644 packages/runtime-common/boxel-execution-protocol.ts diff --git a/docs/boxel-execution-runtime-architecture.md b/docs/boxel-execution-runtime-architecture.md index 0451ca94721..e129b6cc40c 100644 --- a/docs/boxel-execution-runtime-architecture.md +++ b/docs/boxel-execution-runtime-architecture.md @@ -1427,6 +1427,10 @@ accidental POC implementation detail is not a goal. Add versioned `BoxelRuntime` records, `ResolvedField`, stable Boxel/render-slot identity, and `buildBoxelRenderRecord()` without changing visible rendering. Implement `DirectBoxelRuntime` over the existing trusted Loader and Card API. +Direct rendering must continue through main's canonical `transpileJS()` and +Loader pipeline, including the existing `glimmer-scoped-css` transform. The +runtime adapter owns execution and semantic projection; it does not introduce +another stylesheet compiler, registry, or selector-rewriting path. The canonical pipeline includes: diff --git a/docs/boxel-execution-runtime-suite-harness.md b/docs/boxel-execution-runtime-suite-harness.md new file mode 100644 index 00000000000..bf0975b6b78 --- /dev/null +++ b/docs/boxel-execution-runtime-suite-harness.md @@ -0,0 +1,342 @@ +# Boxel execution runtime suite — fixture harness + +The acceptance suite specified in +[boxel-execution-runtime-composition-suite.md](boxel-execution-runtime-composition-suite.md) +needs a place to live while the execution runtime is being built. This document +describes the harness that hosts it: what it is, what it can already answer, +and the single seam the runtime must fill. + +The harness is a Boxel realm, not a test file. That is deliberate. The suite's +own pass criteria include "a passing placeholder, raw JSON dump, blank panel, +or inert control is a failure even when no exception was thrown" — a claim that +can only be settled by mounting the real Boxel and looking at what it produced. + +**Location.** `https://realms-staging.stack.cards/ctse/execution-runtime-suite/` +(single realm for now; the Studio / Partner / Lab realm split arrives with use +case 5). + +## Built on the current API + +Every module in the suite uses only the API documented in the shipped Boxel +author guidance: `CardDef`, `FieldDef`, `contains` / `containsMany` / +`linksTo`, `Component` formats, base fields, `enumField`, `Command`, and +ordinary Glimmer. Nothing imports the execution runtime, because it does not +exist yet. Consequences worth stating plainly: + +- the suite runs today, so a red row is a real defect, not scaffolding; +- the boundary lane is **declared, not observed** — see the seam below; +- when the new API lands, a couple of cases get refactored onto it and their + boundary rows go green. The remaining cases keep working unchanged, which is + itself the regression signal: adopting the runtime must not require rewriting + ordinary authored cards. + +## Two deliverables per case + +Each use case pins **two** cards, and they are different products: + +| | What it is | +| ------------------- | -------------------------------------------------------------------------------------- | +| **a. Diagnostic** | The `SuiteCase` instrument that measures the runtime. | +| **b. Product card** | What an end user is actually handed — a believable, finished card, not a fixture stub. | + +For use case 1 that is `SuiteCase/uc-01-release-identity` (diagnostic) and +`Release/opening-night` + `Release/second-pressing` (product). The diagnostic +links the product card through `subject` and **mounts the real instance**, +never a copy of its markup. That constraint is what makes use case 12's +composition claim testable at all, so it holds from case 1 onward. + +## Layers + +``` +suite/vocabulary.gts evidence kinds, execution tiers, source lanes, probe rules +suite/probe.gts FieldProbe FieldDef + runFieldProbe evaluator +suite/visual-expectation.gts VisualExpectation FieldDef + evaluateVisual + visualProbe modifier +suite/expected-route.gts ExpectedRoute FieldDef + runRouteCheck +suite/suite-case.gts SuiteCase CardDef — the instrument +suite/suite-home.gts SuiteHome CardDef — the index +suite/run-case-command.gts RunCaseCommand — durable verdicts +lib/bxl.ts, lib/bxl/ vendored bxl 0.5.1 realm bundle (pinned) +use-case-1/release.gts the first subject Boxel +use-case-1/release-schema.ts the readable schema Release publishes to a Guide +use-case-2/catalog-metadata.gts contained metadata + field configuration +use-case-2/guided-card-info.gts CardInfoField subclass — the guide attachment +use-case-2/release-guide.gts Guide card, cascade, bxl evaluation, GuidePanel +``` + +Nothing above `use-case-1/` knows anything about music releases. Cases 2–12 add +subject modules and fixture JSON; they do not add harness code. + +## Assertions are data, not code + +A `SuiteCase` carries three `containsMany` fixtures, all authored in the case's +JSON instance: + +| Fixture | Field | Answers | +| ------------------- | -------------- | -------------------------------------------------------- | +| `FieldProbe` | `probes` | Semantic evidence, evaluated now | +| `VisualExpectation` | `expectations` | Visual evidence, measured against the live DOM | +| `ExpectedRoute` | `routes` | Boundary evidence, **pending** until the runtime reports | + +### FieldProbe + +`{ path, rule, expected, claim, evidence }`. `path` is a dot path read through +the ordinary Card API — no runtime internals, no live constructors. Rules: + +| Rule | Meaning | +| --------------- | -------------------------------------------------------------------------------------------- | +| `defined` | value is neither null nor undefined | +| `unset` | value is null or undefined | +| `equals` | scalar compare form equals `expected` (`''`, `'0'`, `'false'` are distinct) | +| `type` | `string` \| `number` \| `boolean` \| `bigint` \| `date` \| `object` \| `null` \| `undefined` | +| `matches` | `new RegExp(expected)` matches the compare form | +| `date` | value is a valid Date whose **local** `YYYY-MM-DD` equals `expected` | +| `datetime` | value is a valid Date whose `toISOString()` equals `expected` | +| `not-untitled` | value is a string that does not begin `Untitled` | +| `has-format` | `subject.constructor[path]` is a component class | +| `static-equals` | `subject.constructor[path]` compare form equals `expected` | + +`date` uses local components on purpose: `DateField` deserializes through +date-fns `parse`, which produces a local Date. + +Every branch is total. A probe against a missing field reports a failure with +the observed value; it never throws and never renders a blank row. + +### VisualExpectation + +`{ format, visibleText[], roles[], images[], cssVariables[], geometry[], note }`. +The case mounts the subject once per expectation at that format, then measures +the produced DOM after paint and again after a settle delay. + +- `roles` accept implicit roles (`link`, `heading`, `textbox`, …), not just + `[role=…]`. +- `images` are alt text; an `` that is present but not decoded fails. +- `cssVariables` are `--token: value`, optionally scoped: + `.release --release-accent: #f2c14e`. +- `geometry` is `selector` or `selector >= WIDTHxHEIGHT` in CSS pixels. +- A `fitted` expectation mounts the subject into all four container classes + (badge 150×40, strip 250×65, tile 170×250, card 400×275) in one pane. + +`roles`, `geometry` and `cssVariables` name **real selectors in the product +card's markup**, so redesigning a subject is a two-file change: the template +and the case fixture. That coupling is deliberate — it is what stops a +redesign from silently dropping a required element — but it means a visual +refactor that forgets the fixture shows up as red geometry rows, not as a +passing suite. + +The measuring modifier takes **only a pane index and a stable component +arrow** — never the expectation object, and never a `(fn …)` closure. This is +load-bearing, not stylistic. The getter that builds the panes reads the +recorded results, so it recomputes whenever a measurement lands; any +object-valued argument would be a fresh reference on each recompute, Glimmer +would see changed args, re-run the modifier, measure again, and record again. +That loop does not settle — it overflows the stack. The component resolves the +expectation by index instead, and an equality guard makes a repeat measurement +a genuine no-op as a second line of defence. + +Measurement is scheduled with `scheduleOnce('afterRender', job, 'measure')` on +a per-install target, not a timer: the prerenderer blocks `setTimeout` +outright, and `scheduleOnce` cannot dedupe an inline closure (realm lint +enforces this — `ember/no-incorrect-calls-with-inline-anonymous-functions`). + +### ExpectedRoute — **the seam** + +`{ format, lane, expectedTier, capabilities[], observedTier, note }`. + +`lane` ∈ `official | studio | partner | lab`; `expectedTier` ∈ +`direct | capsule | sandbox`. `observedTier` is **unset today**, and every +route therefore reports `pending`. An unrouted boundary is never a pass — the +case's overall verdict is `pending` while any route lacks a trace. + +**What the runtime must supply.** For each mounted render slot, write the +selected tier back to the matching route's `observedTier`. The suite compares +and reports `pass` / `fail` with the mismatch spelled out. That is the entire +contract on the harness side; the runtime does not need to know about probes, +expectations, cases, or the command. + +The composition-suite document lists a wider trace record (source generation +and hash, parent/child slot ids, Store revision, granted capabilities, mount +and stylesheet counts). Those become additional `ExpectedRoute` fields — or a +sibling `ObservedTrace` FieldDef — as the runtime starts producing them. The +tier is the minimum that makes the boundary lane meaningful. + +## The Guide layer (use case 2) — applied + +Use case 2 calls for `ReleaseGuide`: a data-authored Guide linked from +`cardInfo.guide`, with expression-backed visibility, constraints, computed +defaults, helper text and field order cascading base → domain → realm → +inline. + +An earlier revision of this document called that unbuildable, on the grounds +that `CardInfoField` carries only `name`, `summary`, `cardThumbnail`, +`cardThumbnailURL`, `theme` and `notes`. That was right about the platform and +wrong about the conclusion. `CardInfoField` is an ordinary FieldDef, so a realm +can **subclass** it: + +```ts +export class GuidedCardInfo extends CardInfoField { + @field guide = linksTo(() => ReleaseGuide); +} +``` + +`Release` declares `@field cardInfo = contains(GuidedCardInfo)`, keeps every +inherited CardInfo field including the theme link, and `cardInfo.guide` +resolves today. The Guide layer is therefore **applied, not declared**. + +### What runs it + +The rule vocabulary is ported from the jqxl Guides spec (§38's annotation +types, §43.2's `Guide` CardDef shape) onto **bxl 0.5.1**, which ships a +first-class Boxel Guide runtime — `prepareBoxelGuide`, `BoxelGuideSpec`, +`BoxelFieldState`. jqxl is the predecessor language; bxl is what exists. The +bundle is vendored at `lib/bxl/index.ts` and pinned: an acceptance suite cannot +have its evaluator drift underneath its fixtures. + +Nothing in the suite evaluates an expression by hand. Constraints, visibility +tests, suggestions and computed defaults are compiled once and evaluated +against a plain snapshot of the release. + +Rules are authored in **readable BXL** — the Excel-like surface, not jq: + +``` +LEN(Catalog.Producer) > 0 +Catalog.Process <> "lathe" OR Catalog.PressingRun <= 500 +LEN(Catalog.Region) = 0 OR Catalog.Region = Catalog.Venue.Address.State +Completion < 100 OR IsAvailable = TRUE +ROUND(Catalog.Price.Amount * 1.1, 0) +``` + +Two conventions hold across every guide: + +- **PascalCase paths, not quoted labels.** Both resolve — `Catalog.PressingRun` + by key, `Catalog."Pressing Run"` by label — but the PascalCase form reads as + a path rather than as prose, which matters when the expression is the thing + under review. +- **No `REGEXMATCH`.** It is not a bxl builtin, and bxl compiles an unknown + function to `null` instead of raising. As a constraint that fails closed, but + for the wrong reason, and as a `computedVia` it would silently write null. + Spell pattern checks out of real builtins: + `LEFT(CatalogNumber, 4) = "STU-" AND ISNUMBER(VALUE(RIGHT(CatalogNumber, 4)))`. + +Two responsibilities stay in the realm rather than in bxl: + +1. **The cascade.** §43.2.1 says guides compose in order and later layers win. + bxl takes one flat `BoxelGuideSpec`, so `composeFieldGuides` folds base → + domain → realm → inline first: set scalars overwrite, blanks do not erase an + earlier layer's value, and constraints concatenate. The fixture authors + `.catalog.sleeveNote` twice, at `base` and at `domain`, precisely so the + merge is observable — eleven authored rules compose to ten. +2. **Fail-closed reporting.** A compile error, a runtime error, or a + non-boolean constraint result is a violation. `evaluateGuide` returns + `applied: false` with the compiler's own message; it never returns an empty + green result. + +### The schema is load-bearing + +`evaluateGuide` requires the governed card's `GuideSchema`, published next to +its snapshot (`RELEASE_GUIDE_SCHEMA` and `releaseGuideInput` on `Release`). +bxl compiles in schema-aware mode, so a rule naming a path the card does not +publish fails at **compile** time — `Unknown field 'venue' in schema-aware +path` — rather than evaluating to null and quietly passing. A typo'd guide +turns the panel red, which is the only useful behaviour for a suite. + +`release-schema.ts` is its own module so both sides can import it — the +release to evaluate the guide, the Guide card to compile its own rules against +the real target via a small `target` → schema registry. The Guide card +therefore makes the same claim the release does, on the guide itself, where an +authoring mistake belongs. + +A schema-free compile is **not** a usable fallback, which is worth recording +because it looks like one. bxl synthesizes a partial schema from the guide's +own `fieldPath`s; that puts a scope in play, which disables the PascalCase +fallback. `Catalog.Venue.Address.State` then fails with `Unknown field 'State'` +unless some fieldGuide happens to target that exact path. Compile against the +real schema or report that you could not — there is no honest middle. + +### What case 2 now asserts + +Twelve semantic probes cover the attachment (`cardInfo` is the subclass, the +inherited name and theme survive, `cardInfo.guide` resolves to a live +`ReleaseGuide`) and the rule data (target, counts, dot paths, layers, the +bxl source text of a constraint). One visual expectation covers the applied +panel: the single real violation, the cross-field suggestion drawn from +`.catalog.venue.address.state`, the `2 hidden by a visibility rule` footer, and +the absence of the fields those rules hide. + +The guide route still reports `pending` — but for the ordinary reason every +route does, `observedTier` being unwritten, not because the feature is missing. + +### The remaining seam + +When the platform ships `guide` on `CardInfoField` itself, the refactor is +deleting `guided-card-info.gts` and changing one line on `Release`. The guide +instances, the rules, the cascade and the panel are unaffected. That is the +seam, and it is now much smaller than a missing feature. + +## Verdicts + +A case's isolated view rolls up three lanes: semantic, visual, boundary. The +overall verdict is `fail` if any lane has a failure, `pending` if any lane has +a pending check, and `pass` only when every declared check is answered and +green. `RunCaseCommand` evaluates the semantic probes headlessly and persists +`lastRunAt` / `lastRunSummary` so the suite home can show verdicts without +mounting every case at once. + +Note: existing-card `SaveCardCommand` is optimistic in the current host, so a +recorded run is durable only once the realm resource reflects it. The live +instrument, not the recorded summary, is the authority. + +## Use case 1 — what is currently covered + +`Release` (Studio lane) exercises the primitive, string-variant, time and +quantity field families through trusted Base field components in seven +declared formats (`isolated`, `embedded`, `fitted`, `atom`, `head`, `markdown`, +and the un-overridden default `edit`). + +The fixture `Release/opening-night.json` is chosen so the distinctions that +matter cannot be faked: `unitsSold` is `0`, `isExplicit` is `false`, `edition` +is `''`, `pressNotes` is unset, and `preOrders` is `18446744073709551617` — +past `Number.MAX_SAFE_INTEGER`. All five survive a realm round trip. + +43 semantic probes, 7 visual expectations and 5 expected routes are authored. +The routes are all `pending` by design. + +`availabilityStatus` is a `computeVia` that reads only stored fields — a +computed that read the clock would render differently in the indexer than in +the browser and the suite would stop being deterministic. `catalogStamp` is an +ordinary getter chained onto it. + +## Adding a case + +1. Add `use-case-N/.gts` and its **product card** instances — built on + the current documented API, finished enough that an end user would accept + them. +2. Add the **diagnostic** `SuiteCase/uc-NN-.json`, linking `subject` to + the real instance — never a copy of it. +3. Author probes, expectations and routes as data. +4. Pin **both** in `index.json` `entryPoints`. + +No harness code changes. If a case cannot be expressed in the rule set, that is +a signal to add one rule to `vocabulary.gts` and `probe.gts` — not to write a +bespoke case component. + +## Known toolchain baselines + +`npx boxel parse` reports three errors on this realm that are pre-existing +toolchain gaps, reproducible in unrelated working realm code: + +- `Cannot find module '@cardstack/boxel-host/tools/save-card'` — the bundled + wildcard declaration does not resolve for local-workspace parse programs. +- `No overload matches this call` on any functional-modifier invocation in a + template (reproduced with a two-line scratch modifier, and on + `@context.cardComponentModifier` in shipped realm code). + +`lib/bxl/index.ts` adds a fourth: the vendored minified bundle is untyped dist +code and reports thousands of implicit-`any` and structural errors. Authored +modules report none. + +Realm lint is clean on every authored module. It is **not** clean on +`lib/bxl/index.ts`: the vendored minified bundle trips 364 style rules +(`no-var`, `require-yield`, unused minifier temporaries). This is the standing +baseline for a vendored dist bundle in a realm — the `ctse/common-libs` realm +reports 456 of the same errors for its own copy. Filter lint output by filename +before reading it. diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index b635f8f04d7..74ac962ead3 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -194,6 +194,7 @@ import { peekAtField, propagateRealmContext, realmContext, + resolveFieldConfiguration, setFieldDescription, setRealmContextOnField, type BrokenLinkFinding, @@ -242,6 +243,7 @@ export { primitive, realmURL, relativeTo, + resolveFieldConfiguration, serialize, serializeCard, serializeFileDef, @@ -2486,6 +2488,12 @@ export class BaseDef { static data?: Record; // TODO probably refactor this away all together static displayName = 'Base'; static icon: CardOrFieldTypeIcon; + /** + * Requests the stronger origin-isolated Sandbox boundary for authored + * rendering. Host policy may always choose a stronger boundary, while this + * hint can never request trusted Direct execution. + */ + static prefersFullSandbox = false; static getDisplayName(instance: BaseDef) { return instance.constructor.displayName; diff --git a/packages/boxel-ui/addon/src/modifiers/surface-layout.ts b/packages/boxel-ui/addon/src/modifiers/surface-layout.ts new file mode 100644 index 00000000000..3fdbc795eac --- /dev/null +++ b/packages/boxel-ui/addon/src/modifiers/surface-layout.ts @@ -0,0 +1,40 @@ +import { modifier } from 'ember-modifier'; + +export const surfaceLayoutEvent = 'boxel-surface-layout'; + +export interface SurfaceLayoutIntent { + heightMode: 'intrinsic' | 'allocated'; + minimumHeight?: number; +} + +interface Signature { + Args: { + Named: SurfaceLayoutIntent; + }; + Element: HTMLElement; +} + +/** Publish bounded layout intent without receiving the owning DOM element. */ +const surfaceLayout = modifier((element, _positional, named) => { + let active = true; + queueMicrotask(() => { + if (!active) { + return; + } + element.dispatchEvent( + new CustomEvent(surfaceLayoutEvent, { + bubbles: true, + composed: true, + detail: { + heightMode: named.heightMode, + minimumHeight: named.minimumHeight, + } satisfies SurfaceLayoutIntent, + }), + ); + }); + return () => { + active = false; + }; +}); + +export default surfaceLayout; diff --git a/packages/boxel-ui/addon/src/modifiers/surface-observe.ts b/packages/boxel-ui/addon/src/modifiers/surface-observe.ts new file mode 100644 index 00000000000..df46ce2dd19 --- /dev/null +++ b/packages/boxel-ui/addon/src/modifiers/surface-observe.ts @@ -0,0 +1,51 @@ +import { modifier } from 'ember-modifier'; + +export const surfaceObserveEvent = 'boxel-surface-observe'; + +export interface SurfaceObservationValue { + height: number; + visible: boolean; + width: number; +} + +export interface SurfaceObserveIntent { + callback(observation: SurfaceObservationValue): void; + connected(release: () => void): void; +} + +interface Signature { + Args: { + Positional: [(observation: SurfaceObservationValue) => void]; + }; + Element: HTMLElement; +} + +/** Subscribe through the nearest execution adapter, never through ambient DOM. */ +const surfaceObserve = modifier((element, [callback]) => { + let active = true; + let release: () => void = () => undefined; + queueMicrotask(() => { + if (!active) { + return; + } + element.dispatchEvent( + new CustomEvent(surfaceObserveEvent, { + bubbles: true, + composed: true, + detail: { + callback, + connected(value) { + release(); + release = value; + }, + }, + }), + ); + }); + return () => { + active = false; + release(); + }; +}); + +export default surfaceObserve; diff --git a/packages/boxel-ui/addon/src/modifiers/surface-presentation.ts b/packages/boxel-ui/addon/src/modifiers/surface-presentation.ts new file mode 100644 index 00000000000..8f660f734dc --- /dev/null +++ b/packages/boxel-ui/addon/src/modifiers/surface-presentation.ts @@ -0,0 +1,55 @@ +import { modifier } from 'ember-modifier'; + +export const surfacePresentationEvent = 'boxel-surface-presentation'; + +export interface SurfacePresentationIntent { + containerBackground?: string | null; + headerColor?: string | null; +} + +interface Signature { + Args: { + Named: { + containerBackground?: string | null; + headerColor?: string | null; + }; + }; + Element: HTMLElement; +} + +/** Publish inert presentation intent to the nearest Host-owned Surface. */ +const surfacePresentation = modifier( + (element, _positional, named) => { + let active = true; + let presentation: SurfacePresentationIntent = { + headerColor: named.headerColor, + containerBackground: named.containerBackground, + }; + queueMicrotask(() => { + if (active) { + publishPresentation(element, presentation); + } + }); + return () => { + active = false; + if (element.isConnected) { + publishPresentation(element, {}); + } + }; + }, +); + +function publishPresentation( + element: HTMLElement, + presentation: SurfacePresentationIntent, +): void { + element.dispatchEvent( + new CustomEvent(surfacePresentationEvent, { + bubbles: true, + composed: true, + detail: presentation, + }), + ); +} + +export default surfacePresentation; diff --git a/packages/boxel-ui/addon/src/surface.gts b/packages/boxel-ui/addon/src/surface.gts new file mode 100644 index 00000000000..f282ac92653 --- /dev/null +++ b/packages/boxel-ui/addon/src/surface.gts @@ -0,0 +1,29 @@ +import surfaceLayout, { + type SurfaceLayoutIntent, + surfaceLayoutEvent, +} from './modifiers/surface-layout.ts'; +import surfaceObserve, { + type SurfaceObservationValue, + type SurfaceObserveIntent, + surfaceObserveEvent, +} from './modifiers/surface-observe.ts'; +import surfacePresentation, { + type SurfacePresentationIntent, + surfacePresentationEvent, +} from './modifiers/surface-presentation.ts'; + +export { + surfaceLayout, + surfaceLayoutEvent, + surfaceObserve, + surfaceObserveEvent, + surfacePresentation, + surfacePresentationEvent, +}; + +export type { + SurfaceLayoutIntent, + SurfaceObservationValue, + SurfaceObserveIntent, + SurfacePresentationIntent, +}; diff --git a/packages/host/app/components/boxel-sandbox-runtime.gts b/packages/host/app/components/boxel-sandbox-runtime.gts new file mode 100644 index 00000000000..5602e4ab014 --- /dev/null +++ b/packages/host/app/components/boxel-sandbox-runtime.gts @@ -0,0 +1,125 @@ +import { scheduleOnce } from '@ember/runloop'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { modifier } from 'ember-modifier'; + +import type { BoxelInstanceHandle } from '@cardstack/runtime-common'; + +import type { SandboxRenderTarget } from '@cardstack/host/lib/sandbox-render-transport'; +import { installSandboxRuntimeHost } from '@cardstack/host/lib/sandbox-runtime-host'; +import { connectSandboxSurface } from '@cardstack/host/lib/sandbox-surface-transport'; +import type { SandboxSurfaceClient } from '@cardstack/host/lib/sandbox-surface-transport'; +import type { BoxelSandboxRuntimeModel } from '@cardstack/host/routes/boxel-sandbox-runtime'; +import type DirectBoxelRuntimeService from '@cardstack/host/services/direct-boxel-runtime'; + +import type { ComponentLike } from '@glint/template'; + +interface Signature { + Args: { model: BoxelSandboxRuntimeModel }; +} + +interface SandboxComponentSignature { + Args: { format: string }; + Element: Element; +} + +const attachSurface = modifier<{ + Args: { Positional: [SandboxSurfaceClient] }; + Element: HTMLElement; +}>((element, [surface]) => + connectSandboxSurface(element, surface, (error) => { + console.error('Sandbox Surface capability failed', error); + }), +); + +/** + * Trusted shell inside the isolated origin. Authored Glimmer and its DOM stay + * below this component; only opaque handles and capability messages cross to + * the parent Host. + */ +export default class BoxelSandboxRuntime extends Component { + @service declare private directBoxelRuntime: DirectBoxelRuntimeService; + + @tracked private renderedComponent?: ComponentLike; + @tracked private format = 'isolated'; + @tracked private surface?: SandboxSurfaceClient; + @tracked private error?: Error; + + private abortBootstrap = new AbortController(); + + private runtimeHost = installSandboxRuntimeHost({ + parentOrigin: this.args.model.parentOrigin, + bootstrapId: this.args.model.bootstrapId, + createRuntime: () => this.directBoxelRuntime.runtime, + createRenderTarget: (_runtime, surface) => { + this.surface = surface; + return this.renderTarget; + }, + signal: this.abortBootstrap.signal, + }).catch((error) => { + if (!this.abortBootstrap.signal.aborted) { + this.error = asError(error); + } + return undefined; + }); + + private renderTarget: SandboxRenderTarget = { + render: async (card: BoxelInstanceHandle, format: string) => { + let slot = this.directBoxelRuntime.runtime.getRenderSlotForHandle(card); + this.format = format; + this.renderedComponent = + slot.component as ComponentLike; + this.error = undefined; + await afterRender(); + }, + clear: async () => { + this.renderedComponent = undefined; + this.error = undefined; + await afterRender(); + }, + }; + + willDestroy(): void { + super.willDestroy(); + this.abortBootstrap.abort(); + void this.runtimeHost.then((host) => host?.destroy()); + } + + +} + +function afterRender(): Promise { + return new Promise((resolve) => scheduleOnce('afterRender', null, resolve)); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/host/app/components/card-renderer.gts b/packages/host/app/components/card-renderer.gts index 8f409741791..f3e308b1f9a 100644 --- a/packages/host/app/components/card-renderer.gts +++ b/packages/host/app/components/card-renderer.gts @@ -1,3 +1,4 @@ +import { service } from '@ember/service'; import Component from '@glimmer/component'; import { provide, consume } from 'ember-provide-consume-context'; @@ -18,6 +19,7 @@ import { } from '@cardstack/runtime-common'; import HeadFormatPreview from '@cardstack/host/components/head-format-preview'; +import type DirectBoxelRuntimeService from '@cardstack/host/services/direct-boxel-runtime'; import type { BaseDef, @@ -38,6 +40,8 @@ interface Signature { } export default class CardRenderer extends Component { + @service declare private directBoxelRuntime: DirectBoxelRuntimeService; + @consume(GetCardContextName) declare private getCard: getCard; @consume(GetCardsContextName) declare private getCards: getCards; @consume(GetCardCollectionContextName) @@ -75,10 +79,10 @@ export default class CardRenderer extends Component { get renderedCard() { - return this.args.card.constructor.getComponent( + return this.directBoxelRuntime.runtime.getRenderSlot( this.args.card, this.args.field, this.args.codeRef ? { componentCodeRef: this.args.codeRef } : undefined, - ); + ).component; } } diff --git a/packages/host/app/deprecation-workflow.js b/packages/host/app/deprecation-workflow.js index f68f3887d4f..6672bf4b6a0 100644 --- a/packages/host/app/deprecation-workflow.js +++ b/packages/host/app/deprecation-workflow.js @@ -1,4 +1,9 @@ -import setupDeprecationWorkflow from 'ember-cli-deprecation-workflow'; +// The classic addon package root is its Node-only Ember CLI build hook. Vite's +// production resolver does not apply the classic addon's runtime remapping, so +// importing the root bundles `index.js` and crashes in the browser while +// evaluating `window.require('./package')`. Import the addon's browser module +// explicitly; development and production then execute the same code. +import setupDeprecationWorkflow from 'ember-cli-deprecation-workflow/addon/index.js'; setupDeprecationWorkflow({ workflow: [ diff --git a/packages/host/app/lib/boxel-execution-engine.ts b/packages/host/app/lib/boxel-execution-engine.ts new file mode 100644 index 00000000000..bc007f25bc5 --- /dev/null +++ b/packages/host/app/lib/boxel-execution-engine.ts @@ -0,0 +1,297 @@ +import type { + BoxelInstanceHandle, + BoxelRenderRecord, + LooseCardResource, + LooseSingleCardDocument, + RealmResourceIdentifier, +} from '@cardstack/runtime-common'; + +import { + classifyBoxelSource, + type BoxelSourceClassification, +} from './boxel-source-classifier'; + +import type { MaterializationPurpose } from './boxel-runtime'; +import type BoxelRuntimeRouter from './boxel-runtime-router'; +import type { + BoxelRuntimeLease, + BoxelRuntimeRouteInput, +} from './boxel-runtime-router'; +import type CapsuleBoxelRuntime from './capsule-boxel-runtime'; +import type { CapsuleRenderSlot } from './capsule-component'; +import type { DirectRenderSlot } from './direct-boxel-runtime'; +import type DirectBoxelRuntime from './direct-boxel-runtime'; +import type { SandboxRenderSlot } from './sandbox-runtime-process'; +import type SandboxRuntimeProcess from './sandbox-runtime-process'; + +export interface BoxelExecutionRequest { + /** Viewer/app execution principal, never inferred from the Realm URL. */ + principal: string; + /** Stable identity of the mounted visual surface. */ + surfaceId: string; + trusted: boolean; + format: string; + moduleIdentifier: string; + source: string; + resource: LooseCardResource; + document: LooseSingleCardDocument; + relativeTo?: RealmResourceIdentifier; + purpose: MaterializationPurpose; + /** A Host-known stronger-boundary request, if already available. */ + prefersFullSandbox?: boolean; +} + +export interface BoxelExecutionGeneration { + readonly generation: number; + readonly lease: BoxelRuntimeLease; + readonly card: BoxelInstanceHandle; + readonly renderRecord: BoxelRenderRecord; +} + +export type BoxelExecutionStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export type BoxelExecutionRenderSlot = + | DirectRenderSlot + | CapsuleRenderSlot + | SandboxRenderSlot; + +export interface BoxelExecutionSessionSnapshot { + status: BoxelExecutionStatus; + requestedGeneration: number; + current?: BoxelExecutionGeneration; + error?: Error; +} + +export type BoxelExecutionSessionListener = ( + snapshot: BoxelExecutionSessionSnapshot, +) => void; + +export type BoxelSourceClassifier = ( + moduleIdentifier: string, + source: string, +) => Promise; + +/** + * Host owner for one mounted Boxel execution surface. + * + * A session changes runtime generations atomically: an incomplete or failed + * candidate cannot replace the last-known-good generation, and an obsolete + * asynchronous result is disposed before it can become visible. + */ +export class BoxelExecutionSession { + private requestedGeneration = 0; + private currentGeneration?: BoxelExecutionGeneration; + private status: BoxelExecutionStatus = 'idle'; + private error?: Error; + private closed = false; + private listeners = new Set(); + + constructor( + private readonly router: BoxelRuntimeRouter, + private readonly classifySource: BoxelSourceClassifier, + ) {} + + get snapshot(): BoxelExecutionSessionSnapshot { + return { + status: this.status, + requestedGeneration: this.requestedGeneration, + ...(this.currentGeneration ? { current: this.currentGeneration } : {}), + ...(this.error ? { error: this.error } : {}), + }; + } + + subscribe(listener: BoxelExecutionSessionListener): () => void { + this.assertOpen(); + this.listeners.add(listener); + listener(this.snapshot); + return () => this.listeners.delete(listener); + } + + async getRenderSlot(format: string): Promise { + let current = this.currentGeneration; + if (!current) { + throw new Error('Boxel execution session has no ready generation'); + } + switch (current.lease.runtime.mode) { + case 'direct': + return ( + current.lease.runtime as DirectBoxelRuntime + ).getRenderSlotForHandle(current.card); + case 'capsule': + return (current.lease.runtime as CapsuleBoxelRuntime).getRenderSlot( + current.card, + format, + ); + case 'sandbox': + return (current.lease.runtime as SandboxRuntimeProcess).getRenderSlot( + current.card, + format, + ); + } + } + + async update( + request: BoxelExecutionRequest, + ): Promise { + this.assertOpen(); + let generation = ++this.requestedGeneration; + this.status = 'loading'; + this.error = undefined; + this.notify(); + + let candidate: BoxelExecutionGeneration | undefined; + try { + let source = await this.classifySource( + request.moduleIdentifier, + request.source, + ); + this.assertCurrent(generation); + candidate = await this.materialize(generation, request, source); + + // The type itself may request the stronger process boundary. This hint + // is authoritative only in the upward direction: it can never select a + // weaker runtime than source analysis or Host trust policy selected. + if ( + candidate.lease.decision.mode !== 'sandbox' && + candidate.renderRecord.boxel.executionHints.prefersFullSandbox + ) { + await disposeGeneration(candidate); + candidate = await this.materialize(generation, request, source, true); + } + + this.assertCurrent(generation); + let previous = this.currentGeneration; + this.currentGeneration = candidate; + candidate = undefined; + this.status = 'ready'; + this.error = undefined; + this.notify(); + if (previous) { + await disposeGeneration(previous); + } + return this.currentGeneration; + } catch (error) { + if (candidate) { + await disposeGeneration(candidate); + } + if (this.closed || generation !== this.requestedGeneration) { + return undefined; + } + this.status = 'error'; + this.error = asError(error); + this.notify(); + return undefined; + } + } + + async destroy(): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.requestedGeneration++; + let current = this.currentGeneration; + this.currentGeneration = undefined; + this.listeners.clear(); + this.status = 'idle'; + this.error = undefined; + if (current) { + await disposeGeneration(current); + } + } + + private async materialize( + generation: number, + request: BoxelExecutionRequest, + source: BoxelSourceClassification, + prefersFullSandbox = request.prefersFullSandbox ?? false, + ): Promise { + let route: BoxelRuntimeRouteInput = { + principal: request.principal, + surfaceId: request.surfaceId, + trusted: request.trusted, + format: request.format, + source, + prefersFullSandbox, + }; + let lease = this.router.route(route); + let card: BoxelInstanceHandle | undefined; + try { + card = await lease.runtime.createFromSerialized( + request.resource, + request.document, + request.relativeTo, + request.purpose, + ); + this.assertCurrent(generation); + let renderRecord = await lease.runtime.buildRenderRecord(card); + this.assertCurrent(generation); + return { generation, lease, card, renderRecord }; + } catch (error) { + if (card) { + await lease.runtime.dispose(card).catch(() => undefined); + } + lease.release(); + throw error; + } + } + + private assertCurrent(generation: number): void { + this.assertOpen(); + if (generation !== this.requestedGeneration) { + throw new ObsoleteBoxelExecutionGeneration(); + } + } + + private assertOpen(): void { + if (this.closed) { + throw new Error('Boxel execution session is closed'); + } + } + + private notify(): void { + let snapshot = this.snapshot; + for (let listener of this.listeners) { + listener(snapshot); + } + } +} + +/** Creates stable per-surface sessions over the shared runtime router. */ +export default class BoxelExecutionEngine { + constructor( + private readonly router: BoxelRuntimeRouter, + private readonly classifySource: BoxelSourceClassifier = ( + _module, + source, + ) => classifyBoxelSource(source), + ) {} + + createSession(): BoxelExecutionSession { + return new BoxelExecutionSession(this.router, this.classifySource); + } + + destroy(): void { + this.router.destroy(); + } +} + +class ObsoleteBoxelExecutionGeneration extends Error { + constructor() { + super('Boxel execution generation was superseded'); + this.name = 'ObsoleteBoxelExecutionGeneration'; + } +} + +async function disposeGeneration( + generation: BoxelExecutionGeneration, +): Promise { + await generation.lease.runtime + .dispose(generation.card) + .catch(() => undefined); + generation.lease.release(); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/host/app/lib/boxel-execution-policy.ts b/packages/host/app/lib/boxel-execution-policy.ts new file mode 100644 index 00000000000..48daf91452b --- /dev/null +++ b/packages/host/app/lib/boxel-execution-policy.ts @@ -0,0 +1,38 @@ +import { + executionDecisionForFormat, + type BoxelSourceClassification, +} from './boxel-source-classifier'; + +import type { BoxelExecutionMode } from './boxel-runtime'; + +export interface BoxelExecutionPolicyInput { + trusted: boolean; + format?: string; + source: BoxelSourceClassification; + prefersFullSandbox: boolean; +} + +export interface BoxelExecutionDecision { + mode: BoxelExecutionMode; + reason: string; +} + +/** + * Select execution from trust and analyzed source. URL state is deliberately + * absent: authored code cannot weaken its boundary by changing navigation. + */ +export function decideBoxelExecution( + input: BoxelExecutionPolicyInput, +): BoxelExecutionDecision { + if (input.prefersFullSandbox) { + return { mode: 'sandbox', reason: 'prefers-full-sandbox' }; + } + if (input.trusted) { + return { mode: 'direct', reason: 'trusted-boxel-module' }; + } + let decision = executionDecisionForFormat(input.source, input.format); + return { + mode: decision.tier, + reason: decision.reason, + }; +} diff --git a/packages/host/app/lib/boxel-render-record.ts b/packages/host/app/lib/boxel-render-record.ts new file mode 100644 index 00000000000..1f317718304 --- /dev/null +++ b/packages/host/app/lib/boxel-render-record.ts @@ -0,0 +1,35 @@ +import { + BOXEL_EXECUTION_PROTOCOL_VERSION, + type BoxelDescription, + type BoxelRenderRecord, + type InstancePresentation, + type ResolvedField, +} from '@cardstack/runtime-common'; + +export interface BuildBoxelRenderRecordInput { + boxel: BoxelDescription; + instanceId: string | null; + fields: ResolvedField[]; + presentation: InstancePresentation; +} + +/** + * Assemble the execution-tier-neutral rendering input. + * + * All executable inspection happens before this function. Keeping the final + * assembler pure makes the exact record consumed by Direct, Capsule, and + * Sandbox straightforward to validate and version. + */ +export function buildBoxelRenderRecord( + input: BuildBoxelRenderRecordInput, +): BoxelRenderRecord { + return { + protocolVersion: BOXEL_EXECUTION_PROTOCOL_VERSION, + boxel: input.boxel, + instance: { + id: input.instanceId, + fields: input.fields, + }, + presentation: input.presentation, + }; +} diff --git a/packages/host/app/lib/boxel-runtime-router.ts b/packages/host/app/lib/boxel-runtime-router.ts new file mode 100644 index 00000000000..d253fd8c618 --- /dev/null +++ b/packages/host/app/lib/boxel-runtime-router.ts @@ -0,0 +1,80 @@ +import { + decideBoxelExecution, + type BoxelExecutionDecision, + type BoxelExecutionPolicyInput, +} from './boxel-execution-policy'; +import CapsuleRuntimeRegistry from './capsule-runtime-registry'; + +import RetainedRuntimeRegistry from './retained-runtime-registry'; + +import type { BoxelRuntime } from './boxel-runtime'; +import type CapsuleBoxelRuntime from './capsule-boxel-runtime'; + +import type DirectBoxelRuntime from './direct-boxel-runtime'; + +import type SandboxRuntimeProcess from './sandbox-runtime-process'; + +export interface BoxelRuntimeRouteInput extends BoxelExecutionPolicyInput { + /** Viewer/app execution principal, not a data Realm URL. */ + principal: string; + /** Stable mounted surface identity for a persistent Sandbox child. */ + surfaceId: string; +} + +export interface BoxelRuntimeLease { + runtime: BoxelRuntime; + decision: BoxelExecutionDecision; + release(): void; +} + +/** + * Central execution owner. Direct is shared across the Host, Capsule is + * retained per principal, and Sandbox is retained per mounted surface. + */ +export default class BoxelRuntimeRouter { + private capsuleRuntimes: CapsuleRuntimeRegistry; + private sandboxRuntimes: RetainedRuntimeRegistry; + + constructor( + private directRuntime: DirectBoxelRuntime, + createCapsule: (principal: string) => CapsuleBoxelRuntime, + createSandbox: (surfaceIdentity: string) => SandboxRuntimeProcess, + idleTTL = 90_000, + ) { + this.capsuleRuntimes = new CapsuleRuntimeRegistry( + createCapsule, + () => undefined, + idleTTL, + ); + this.sandboxRuntimes = new RetainedRuntimeRegistry( + createSandbox, + () => undefined, + idleTTL, + ); + } + + route(input: BoxelRuntimeRouteInput): BoxelRuntimeLease { + let decision = decideBoxelExecution(input); + if (decision.mode === 'direct') { + return { + runtime: this.directRuntime, + decision, + release: () => undefined, + }; + } + if (decision.mode === 'capsule') { + let runtime = this.capsuleRuntimes.runtimeFor(input.principal); + let release = this.capsuleRuntimes.retain(input.principal); + return { runtime, decision, release }; + } + let identity = `${input.principal}:${input.surfaceId}`; + let runtime = this.sandboxRuntimes.runtimeFor(identity); + let release = this.sandboxRuntimes.retain(identity); + return { runtime, decision, release }; + } + + destroy(): void { + this.capsuleRuntimes.destroy(); + this.sandboxRuntimes.destroy(); + } +} diff --git a/packages/host/app/lib/boxel-runtime.ts b/packages/host/app/lib/boxel-runtime.ts new file mode 100644 index 00000000000..eb28d3c2de7 --- /dev/null +++ b/packages/host/app/lib/boxel-runtime.ts @@ -0,0 +1,119 @@ +import type { + BoxelDescription, + BoxelRenderRecord, + CodeRef, + LooseCardResource, + LooseSingleCardDocument, + PatchData, + RealmResourceIdentifier, + ResolvedField, + BoxelInstanceHandle, + BoxelTypeHandle, + JSONValue, + RuntimeHandle, +} from '@cardstack/runtime-common'; + +export type BoxelExecutionMode = 'direct' | 'capsule' | 'sandbox'; + +export type { BoxelInstanceHandle, BoxelTypeHandle, RuntimeHandle }; + +export type MaterializationPurpose = + | 'host-display' + | 'code-preview' + | 'interactive-edit' + | 'command-validation' + | 'indexing'; + +export interface BoxelRuntime { + readonly mode: BoxelExecutionMode; + + loadBoxel(ref: CodeRef): Promise; + + createFromSerialized( + resource: LooseCardResource, + document: LooseSingleCardDocument, + relativeTo: RealmResourceIdentifier | undefined, + purpose: MaterializationPurpose, + ): Promise; + + describeBoxel(boxel: BoxelTypeHandle): Promise; + + getFields( + boxel: BoxelTypeHandle | BoxelInstanceHandle, + ): Promise; + + getField( + boxel: BoxelTypeHandle | BoxelInstanceHandle, + fieldName: string, + ): Promise; + + buildRenderRecord(card: BoxelInstanceHandle): Promise; + + serializeCard(card: BoxelInstanceHandle): Promise; + + serializeCardPatch( + card: BoxelInstanceHandle, + changes: Record, + ): Promise; + + dispose(handle: RuntimeHandle): Promise; +} + +/** + * Runtime-local object identities. Handles are unguessable within a runtime, + * have deterministic ownership, and are removed as soon as their consumer is + * released. Live classes and instances never leave the runtime through them. + */ +export class RuntimeHandleRegistry { + private nextHandle = 0; + private values = new Map(); + private handles = new WeakMap(); + + constructor(private readonly prefix: string) {} + + add(value: T): RuntimeHandle { + let existing = this.handles.get(value); + if (existing) { + return existing; + } + let handle = `${this.prefix}:${++this.nextHandle}` as RuntimeHandle; + this.values.set(handle, value); + this.handles.set(value, handle); + return handle; + } + + get(handle: RuntimeHandle): T { + let value = this.values.get(handle); + if (!value) { + throw new Error(`Unknown or released ${this.prefix} handle '${handle}'`); + } + return value; + } + + release(handle: RuntimeHandle): void { + let value = this.values.get(handle); + if (value) { + this.handles.delete(value); + this.values.delete(handle); + } + } + + clear(): void { + this.values.clear(); + this.handles = new WeakMap(); + } + + get size(): number { + return this.values.size; + } +} + +export function asBoxelTypeHandle(handle: RuntimeHandle): BoxelTypeHandle { + return handle as BoxelTypeHandle; +} + +export function asBoxelInstanceHandle( + handle: RuntimeHandle, +): BoxelInstanceHandle { + return handle as BoxelInstanceHandle; +} diff --git a/packages/host/app/lib/boxel-source-classifier.ts b/packages/host/app/lib/boxel-source-classifier.ts new file mode 100644 index 00000000000..3dfb2056fe3 --- /dev/null +++ b/packages/host/app/lib/boxel-source-classifier.ts @@ -0,0 +1,760 @@ +import * as babel from '@babel/core'; +// @ts-ignore no upstream types are available +import typescriptPlugin from '@babel/plugin-transform-typescript'; +import * as ContentTag from 'content-tag'; +import { init, parse } from 'es-module-lexer'; + +export type AuthoredExecutionMode = 'capsule' | 'sandbox'; + +export type BoxelRenderFormat = + | 'isolated' + | 'embedded' + | 'fitted' + | 'edit' + | 'atom' + | 'head' + | 'markdown'; + +export interface BoxelSourceClassification { + tier: AuthoredExecutionMode; + reason: string; + imports: string[]; + signals: string[]; + // Some iframe requirements are part of an exported render surface and must + // follow a static import edge (for example Three.js or an unscoped template + // style). Ambient browser globals are different: a library may contain a + // dormant browser adapter that SES can safely leave unavailable. Promoting + // every importer for a mere `document` token makes otherwise Capsule-compatible + // cards depend on the hosted iframe service. + propagatesToImporters: boolean; + // An ordinary ESM import whose runtime bindings are used exclusively as + // direct values of iframe-capable static format slots. The SES evaluator + // may replace this one eager edge with inert component references while the + // iframe/native loader retains ordinary ESM semantics. Absence means the + // source contains no provably liftable edge. + formatOnlyImports?: BoxelFormatOnlyImport[]; +} + +export interface BoxelFormatOnlyImportBinding { + exportName: string; + formats: BoxelRenderFormat[]; +} + +export interface BoxelFormatOnlyImport { + specifier: string; + bindings: BoxelFormatOnlyImportBinding[]; +} + +const iframeRenderFormats = new Set(['isolated', 'embedded', 'edit']); +const liftableFormatNames = new Set([ + 'isolated', + 'embedded', + 'edit', +]); +const compiledLiteralStyleElement = + /\[\s*10\s*,\s*(?:["']style["']|\\["']style\\["'])\s*\]/i; +const compiledDynamicInlineStyleAttribute = + /\[\s*(?:15|16|22|23)\s*,\s*(?:5|\\*["']style\\*["'])\s*,/i; +const topLayerAttributeName = + '(?:command|commandfor|popover|popovertarget|popovertargetaction)'; +const authoredTopLayerAttribute = new RegExp( + `\\s${topLayerAttributeName}(?=\\s|=|/?>)`, + 'i', +); +const compiledTopLayerAttribute = new RegExp( + `\\[\\s*(?:14|15|16|22|23|24)\\s*,\\s*\\\\?["']${topLayerAttributeName}\\\\?["']\\s*,`, + 'i', +); + +// Source classification describes what the module needs. The requested card +// format separately limits where it may run. Compact and non-DOM formats must +// remain composable in the host document (especially the fitted gallery), so +// a browser-dependent definition receives an iframe only for its full/edit +// surfaces. Its fitted, atom, head, and markdown surfaces stay in a Capsule and fail +// closed there if they depend on ambient DOM authority. +export function executionDecisionForFormat( + decision: Pick, + format: string | undefined, +): Pick { + let effectiveFormat = format ?? 'isolated'; + if (decision.tier !== 'sandbox' || iframeRenderFormats.has(effectiveFormat)) { + return decision; + } + return { + tier: 'capsule', + reason: `ses-only-format:${effectiveFormat}`, + }; +} + +// These packages require a real browser document/canvas or are commonly +// loaded as browser-global renderers. They belong in the isolated iframe +// renderer, where authored CardDefs remain unaware of the transport. +const iframeImportSignals = [ + '@babylonjs', + '@google/model-viewer', + '@react-three', + '@tweenjs/tween.js', + 'aframe', + 'babylonjs', + 'cesium', + 'deck.gl', + 'ember-modifier', + 'konva', + 'leaflet', + 'mapbox-gl', + 'maplibre-gl', + 'p5', + 'paper', + 'pixi.js', + 'potree', + 'three', + 'three-bvh-csg', + 'vtk.js', +] as const; + +const iframeGlobalSignals = [ + 'CanvasRenderingContext2D', + 'HTMLCanvasElement', + 'HTMLElement', + 'MutationObserver', + 'ResizeObserver', + 'WebGL2RenderingContext', + 'WebGLRenderingContext', + 'customElements', + 'document', + 'localStorage', + 'navigator', + 'sessionStorage', + 'window', +] as const; + +// Browser authority is often hidden behind a value whose DOM type appears +// only in TypeScript syntax. `canvas.getContext()` is the canonical example: +// stripping the `HTMLCanvasElement` annotation must not make the executable +// member call look Capsule-compatible. Keep this list deliberately narrow. Each +// method below either acquires a browser rendering capability or depends on a +// live document-owned element in a way that our data-only SES shims cannot +// reproduce. +const iframeDOMMethodSignals = [ + 'getContext', + 'requestPointerLock', + 'setPointerCapture', + 'showModal', + 'toBlob', + 'toDataURL', +] as const; + +let lexerReady = Promise.resolve(init); + +function analyzeEmbeddedTemplates(source: string): { + javascript: string; + hasDynamicInlineStyle: boolean; + hasTopLayerAttribute: boolean; + hasUnscopedStyle: boolean; +} { + let characters = Array.from(source); + let hasDynamicInlineStyle = false; + let hasTopLayerAttribute = false; + let hasUnscopedStyle = false; + for (let match of new ContentTag.Preprocessor().parse(source)) { + hasDynamicInlineStyle ||= /\sstyle\s*=\s*{{/i.test(match.contents); + for (let tag of match.contents.matchAll(/<[^>]+>/g)) { + hasTopLayerAttribute ||= authoredTopLayerAttribute.test(tag[0]); + } + let styleTags = match.contents.matchAll(/])([^>]*)>/gi); + for (let styleTag of styleTags) { + let attributes = styleTag[1] ?? ''; + if (!/(?:^|\s)scoped(?=\s|=|$)/i.test(attributes)) { + hasUnscopedStyle = true; + } + } + for ( + let index = match.range.startChar; + index < match.range.endChar; + index++ + ) { + // Preserve newlines so parse errors and diagnostics retain source lines. + if (characters[index] !== '\n' && characters[index] !== '\r') { + characters[index] = ' '; + } + } + } + return { + javascript: characters.join(''), + hasDynamicInlineStyle, + hasTopLayerAttribute, + hasUnscopedStyle, + }; +} + +function hasCompiledUnscopedStyle(source: string): boolean { + // The realm server normally sends already-compiled card JavaScript to + // interact mode. Ember's wire format represents a literal element as + // [OpenElement, tagName], where OpenElement is opcode 10. A scoped style is + // extracted by glimmer-scoped-css and never produces this tuple. This signal + // is only a compatibility router; template capture independently rejects the + // literal style and remains the fail-closed security boundary. + return compiledLiteralStyleElement.test(source); +} + +function maskStringsAndComments(source: string): string { + let output = Array.from(source); + let quote: "'" | '"' | '`' | undefined; + let lineComment = false; + let blockComment = false; + let escaped = false; + + for (let index = 0; index < output.length; index++) { + let current = output[index]!; + let next = output[index + 1]; + if (lineComment) { + if (current === '\n') { + lineComment = false; + } else { + output[index] = ' '; + } + continue; + } + if (blockComment) { + if (current === '*' && next === '/') { + output[index] = output[index + 1] = ' '; + index++; + blockComment = false; + } else if (current !== '\n' && current !== '\r') { + output[index] = ' '; + } + continue; + } + if (quote) { + if (escaped) { + escaped = false; + } else if (current === '\\') { + escaped = true; + } else if (current === quote) { + quote = undefined; + } + if (current !== '\n' && current !== '\r') { + output[index] = ' '; + } + continue; + } + if (current === '/' && next === '/') { + output[index] = output[index + 1] = ' '; + index++; + lineComment = true; + } else if (current === '/' && next === '*') { + output[index] = output[index + 1] = ' '; + index++; + blockComment = true; + } else if (current === "'" || current === '"' || current === '`') { + quote = current; + output[index] = ' '; + } + } + return output.join(''); +} + +function packageName(moduleIdentifier: string): string { + try { + let url = new URL(moduleIdentifier); + if (url.hostname === 'esm.sh') { + let pathname = url.pathname.replace(/^\//, '').toLowerCase(); + let versionMarker = pathname.indexOf( + '@', + pathname.startsWith('@') ? 1 : 0, + ); + return versionMarker === -1 ? pathname : pathname.slice(0, versionMarker); + } + return `${url.hostname}${url.pathname}`.toLowerCase(); + } catch { + return moduleIdentifier.toLowerCase(); + } +} + +function iframeImportSignal(moduleIdentifier: string): string | undefined { + let candidate = packageName(moduleIdentifier); + return iframeImportSignals.find( + (signal) => + candidate === signal || + candidate.startsWith(`${signal}/`) || + candidate.includes(`/${signal}/`) || + candidate.includes(`/${signal}@`), + ); +} + +function usedBrowserGlobals(source: string): string[] { + let code = maskStringsAndComments(source); + return iframeGlobalSignals.filter((signal) => + new RegExp(`\\b${signal}\\b`).test(code), + ); +} + +function executableBrowserGlobals(source: string): string[] { + let possibleSignals = usedBrowserGlobals(source); + if (possibleSignals.length === 0) { + return []; + } + + try { + // Card source is TypeScript. A DOM name in an interface, type annotation, + // or `as HTMLElement` assertion does not request browser authority. Strip + // type-only syntax before deciding whether the module needs an iframe. + // We only pay for this parse when a possible browser global was found. + let unboundBrowserGlobals = new Set(); + let collectUnboundBrowserGlobals: babel.PluginObj = { + visitor: { + ReferencedIdentifier(path) { + let name = path.node.name; + if ( + iframeGlobalSignals.includes( + name as (typeof iframeGlobalSignals)[number], + ) && + !path.scope.hasBinding(name) + ) { + unboundBrowserGlobals.add(name); + } + }, + }, + }; + babel.transformSync(source, { + filename: 'boxel-source.ts', + babelrc: false, + configFile: false, + compact: true, + plugins: [ + [typescriptPlugin, { allowDeclareFields: true }], + collectUnboundBrowserGlobals, + ], + parserOpts: { plugins: ['decorators-legacy'] }, + }); + return iframeGlobalSignals.filter((signal) => + unboundBrowserGlobals.has(signal), + ); + } catch { + // Classification is a security boundary. Unknown or incomplete syntax + // keeps the conservative result instead of silently gaining SES access. + return possibleSignals; + } +} + +function executableDOMMethodCalls(source: string): string[] { + let possibleSignals = iframeDOMMethodSignals.filter((method) => + new RegExp(`\\.${method}\\s*\\(`).test(source), + ); + if (possibleSignals.length === 0) { + return []; + } + + try { + let calls = new Set(); + let collectCalls: babel.PluginObj = { + visitor: { + CallExpression(path) { + let callee = path.node.callee; + if ( + babel.types.isMemberExpression(callee) && + !callee.computed && + babel.types.isIdentifier(callee.property) && + iframeDOMMethodSignals.includes( + callee.property.name as (typeof iframeDOMMethodSignals)[number], + ) + ) { + calls.add(callee.property.name); + } + }, + }, + }; + babel.transformSync(source, { + filename: 'boxel-source.ts', + babelrc: false, + configFile: false, + compact: true, + plugins: [[typescriptPlugin, { allowDeclareFields: true }], collectCalls], + parserOpts: { plugins: ['decorators-legacy'] }, + }); + return iframeDOMMethodSignals + .filter((method) => calls.has(method)) + .map((method) => `dom-method:${method}`); + } catch { + // As with unbound globals, ambiguous executable syntax fails toward the + // stronger process boundary instead of silently receiving SES access. + return possibleSignals.map((method) => `dom-method:${method}`); + } +} + +// This is deliberately a structural convention, not a filename/package +// allowlist. A dependency is liftable only when all of its imported runtime +// bindings are used solely as the complete value of an iframe-capable static +// format slot. Any other reference preserves normal eager ESM behavior. +function formatOnlyImports(source: string): BoxelFormatOnlyImport[] { + let result: BoxelFormatOnlyImport[] = []; + let collectFormatImports: babel.PluginObj = { + visitor: { + Program: { + exit(programPath) { + for (let statementPath of programPath.get('body')) { + if (!statementPath.isImportDeclaration()) { + continue; + } + if (statementPath.node.importKind === 'type') { + continue; + } + let runtimeSpecifiers = statementPath + .get('specifiers') + .filter( + (specifierPath) => + !specifierPath.isImportSpecifier() || + specifierPath.node.importKind !== 'type', + ); + if (runtimeSpecifiers.length === 0) { + // A side-effect-only import can never be lifted. + continue; + } + let bindings: BoxelFormatOnlyImportBinding[] = []; + let liftable = true; + for (let specifierPath of runtimeSpecifiers) { + let local = specifierPath.node.local.name; + let binding = statementPath.scope.getBinding(local); + if (!binding || binding.referencePaths.length === 0) { + liftable = false; + break; + } + let importedName = 'default'; + if (specifierPath.isImportSpecifier()) { + importedName = babel.types.isIdentifier( + specifierPath.node.imported, + ) + ? specifierPath.node.imported.name + : specifierPath.node.imported.value; + } else if (specifierPath.isImportNamespaceSpecifier()) { + importedName = '*'; + } + let formats = new Set(); + let exportNames = new Set(); + for (let referencePath of binding.referencePaths) { + let valuePath = referencePath; + let exportName = importedName; + if (specifierPath.isImportNamespaceSpecifier()) { + let memberPath = referencePath.parentPath; + if ( + !memberPath?.isMemberExpression() || + memberPath.node.object !== referencePath.node + ) { + liftable = false; + break; + } + let property = memberPath.node.property; + if (memberPath.node.computed) { + if (!babel.types.isStringLiteral(property)) { + liftable = false; + break; + } + exportName = property.value; + } else { + if (!babel.types.isIdentifier(property)) { + liftable = false; + break; + } + exportName = property.name; + } + valuePath = memberPath; + } + let propertyPath = valuePath.parentPath; + if ( + !propertyPath?.isClassProperty() || + !propertyPath.node.static || + propertyPath.node.value !== valuePath.node + ) { + liftable = false; + break; + } + let key = propertyPath.node.key; + let format = babel.types.isIdentifier(key) + ? key.name + : babel.types.isStringLiteral(key) + ? key.value + : undefined; + if ( + !format || + !liftableFormatNames.has(format as BoxelRenderFormat) + ) { + liftable = false; + break; + } + formats.add(format as BoxelRenderFormat); + exportNames.add(exportName); + } + if (!liftable || exportNames.size !== 1) { + liftable = false; + break; + } + bindings.push({ + exportName: [...exportNames][0]!, + formats: [...formats], + }); + } + if (liftable) { + result.push({ + specifier: statementPath.node.source.value, + bindings, + }); + } + } + }, + }, + }, + }; + try { + babel.transformSync(source, { + filename: 'boxel-source.ts', + babelrc: false, + configFile: false, + compact: true, + plugins: [ + [typescriptPlugin, { allowDeclareFields: true }], + collectFormatImports, + ], + parserOpts: { plugins: ['decorators-legacy'] }, + }); + } catch { + // Ambiguous or incomplete source keeps ordinary eager import semantics. + return []; + } + return result; +} + +export async function classifyBoxelSource( + source: string, +): Promise { + let javascript: string; + let dynamicInlineStyle = compiledDynamicInlineStyleAttribute.test(source); + let topLayerAttribute = compiledTopLayerAttribute.test(source); + let unscopedStyle = hasCompiledUnscopedStyle(source); + try { + let templateAnalysis = analyzeEmbeddedTemplates(source); + dynamicInlineStyle ||= templateAnalysis.hasDynamicInlineStyle; + topLayerAttribute ||= templateAnalysis.hasTopLayerAttribute; + unscopedStyle ||= templateAnalysis.hasUnscopedStyle; + javascript = templateAnalysis.javascript; + } catch { + // A malformed in-progress GTS draft remains in the more restrictive SES + // renderer. The last-good-render path keeps the prior preview visible. + return { + tier: 'capsule', + reason: 'source-parse-pending', + imports: [], + signals: [], + propagatesToImporters: false, + }; + } + + await lexerReady; + let imports: string[]; + try { + imports = parse(javascript)[0] + .map((entry) => entry.n) + .filter( + (specifier): specifier is string => typeof specifier === 'string', + ); + } catch { + return { + tier: 'capsule', + reason: 'source-parse-pending', + imports: [], + signals: [], + propagatesToImporters: false, + }; + } + + let importSignals = imports + .map(iframeImportSignal) + .filter((signal): signal is string => Boolean(signal)); + let globalSignals = executableBrowserGlobals(javascript); + let domMethodSignals = executableDOMMethodCalls(javascript); + let signals = [ + ...new Set([ + ...importSignals, + ...globalSignals, + ...domMethodSignals, + ...(dynamicInlineStyle ? ['dynamic-inline-style'] : []), + ...(topLayerAttribute ? ['top-layer-markup'] : []), + ...(unscopedStyle ? ['unscoped-style'] : []), + ]), + ]; + let liftedImports = formatOnlyImports(javascript); + let propagatesToImporters = + importSignals.length > 0 || + domMethodSignals.length > 0 || + dynamicInlineStyle || + topLayerAttribute || + unscopedStyle; + if (signals.length > 0) { + return { + tier: 'sandbox', + reason: `browser-runtime:${signals.join(',')}`, + imports, + signals, + propagatesToImporters, + ...(liftedImports.length > 0 ? { formatOnlyImports: liftedImports } : {}), + }; + } + return { + tier: 'capsule', + reason: 'default-user-card', + imports, + signals: [], + propagatesToImporters: false, + ...(liftedImports.length > 0 ? { formatOnlyImports: liftedImports } : {}), + }; +} + +export interface BoxelModuleGraphClassifierOptions { + loadSource(moduleIdentifier: string): Promise; + resolveImport(specifier: string, relativeTo: string): string; + isTrustedModule(moduleIdentifier: string): boolean; + maxModules?: number; +} + +/** + * Classifies one executable authored module graph, not merely its entry file. + * + * Trusted modules are explicit leaves. An authored dependency whose browser + * requirement propagates to importers strengthens the entry module to the + * Sandbox tier. The walk is bounded and fails closed when a dependency cannot + * be resolved, loaded, or when the graph exceeds its configured size. + */ +export class BoxelModuleGraphClassifier { + private cache = new Map>(); + private dependencies = new Map>(); + + constructor(private readonly options: BoxelModuleGraphClassifierOptions) {} + + classify( + moduleIdentifier: string, + source?: string, + ): Promise { + let cacheKey = source === undefined ? moduleIdentifier : undefined; + if (cacheKey) { + let existing = this.cache.get(cacheKey); + if (existing) { + return existing; + } + } + let observedDependencies = new Set(); + let classification = this.classifyGraph( + moduleIdentifier, + source, + observedDependencies, + ); + if (cacheKey) { + this.cache.set(cacheKey, classification); + this.dependencies.set(cacheKey, observedDependencies); + void classification.catch(() => { + if (this.cache.get(cacheKey) === classification) { + this.cache.delete(cacheKey); + this.dependencies.delete(cacheKey); + } + }); + } + return classification; + } + + invalidate(moduleIdentifier?: string): void { + if (moduleIdentifier) { + for (let [entry, dependencies] of this.dependencies) { + if (entry === moduleIdentifier || dependencies.has(moduleIdentifier)) { + this.cache.delete(entry); + this.dependencies.delete(entry); + } + } + } else { + this.cache.clear(); + this.dependencies.clear(); + } + } + + private async classifyGraph( + moduleIdentifier: string, + entrySource?: string, + observedDependencies = new Set(), + ): Promise { + let visited = new Set(); + let maxModules = this.options.maxModules ?? 256; + + let visit = async ( + identifier: string, + suppliedSource?: string, + ): Promise => { + if (this.options.isTrustedModule(identifier) || visited.has(identifier)) { + return capsuleClassification(); + } + visited.add(identifier); + if (visited.size > maxModules) { + return unavailableClassification('module-graph-limit'); + } + + let source: string; + try { + source = suppliedSource ?? (await this.options.loadSource(identifier)); + } catch { + return unavailableClassification(`module-load:${identifier}`); + } + let own = await classifyBoxelSource(source); + if (own.tier === 'sandbox') { + return own; + } + + for (let specifier of own.imports) { + let dependency: string; + try { + dependency = this.options.resolveImport(specifier, identifier); + } catch { + return unavailableClassification(`module-resolve:${specifier}`); + } + if (this.options.isTrustedModule(dependency)) { + continue; + } + observedDependencies.add(dependency); + let dependencyClassification = await visit(dependency); + if ( + dependencyClassification.tier === 'sandbox' && + dependencyClassification.propagatesToImporters + ) { + return { + tier: 'sandbox', + reason: `dependency-runtime:${dependency}`, + imports: own.imports, + signals: dependencyClassification.signals, + propagatesToImporters: true, + ...(own.formatOnlyImports + ? { formatOnlyImports: own.formatOnlyImports } + : {}), + }; + } + } + return own; + }; + + return visit(moduleIdentifier, entrySource); + } +} + +function capsuleClassification(): BoxelSourceClassification { + return { + tier: 'capsule', + reason: 'trusted-or-visited-module', + imports: [], + signals: [], + propagatesToImporters: false, + }; +} + +function unavailableClassification(reason: string): BoxelSourceClassification { + return { + tier: 'sandbox', + reason, + imports: [], + signals: [reason], + propagatesToImporters: true, + }; +} diff --git a/packages/host/app/lib/capsule-boxel-runtime.ts b/packages/host/app/lib/capsule-boxel-runtime.ts new file mode 100644 index 00000000000..d942cfeccc0 --- /dev/null +++ b/packages/host/app/lib/capsule-boxel-runtime.ts @@ -0,0 +1,431 @@ +import { + BOXEL_EXECUTION_PROTOCOL_VERSION, + normalizeCodeRef, + type BoxelDescription, + type BoxelInstanceHandle, + type BoxelRenderRecord, + type BoxelTypeHandle, + type BoxelValueReference, + type CodeRef, + type FieldDescription, + type FormatDescription, + type InstancePresentation, + type JSONValue, + type LooseCardResource, + type LooseSingleCardDocument, + type PatchData, + type RealmResourceIdentifier, + type ResolvedField, + type RuntimeHandle, +} from '@cardstack/runtime-common'; + +import { buildBoxelRenderRecord } from './boxel-render-record'; +import { + RuntimeHandleRegistry, + asBoxelInstanceHandle, + asBoxelTypeHandle, + type BoxelRuntime, + type MaterializationPurpose, +} from './boxel-runtime'; +import { + createCapsuleRenderSlot, + type CapsuleRenderSlot, +} from './capsule-component'; +import { DefaultCapsuleComponentRuntime } from './capsule-component-runtime'; + +import type CapsuleModuleEvaluator from './capsule-module-evaluator'; +import type { + CapsuleCardFieldMetadata, + CapsuleCardTypeMetadata, + CapsuleTemplateBundle, +} from './capsule-module-evaluator'; + +interface CapsuleTypeState { + ref: CodeRef; + module: string; + name: string; + metadata?: CapsuleCardTypeMetadata; +} + +interface CapsuleInstanceState { + type: CapsuleTypeState; + resource: LooseCardResource; + document: LooseSingleCardDocument; + relativeTo: RealmResourceIdentifier | undefined; + purpose: MaterializationPurpose; + projection?: Record; +} + +const trustedBaseFallbackRef: CodeRef = { + module: 'https://cardstack.com/base/card-api' as RealmResourceIdentifier, + name: 'CardDef', +}; + +/** + * Boxel's semantic adapter over one principal-owned SES Capsule. + * + * The evaluator owns executable classes, getters, computeVia functions, and + * templates. This adapter owns only opaque handles and cloneable records. + */ +export default class CapsuleBoxelRuntime implements BoxelRuntime { + readonly mode = 'capsule' as const; + + private types = new RuntimeHandleRegistry('capsule-type'); + private instances = new RuntimeHandleRegistry( + 'capsule-instance', + ); + private componentRuntime: DefaultCapsuleComponentRuntime; + private renderSlots = new Map< + BoxelInstanceHandle, + Map> + >(); + + constructor( + readonly evaluator: CapsuleModuleEvaluator, + private readonly loadTrustedModule: ( + moduleIdentifier: string, + ) => Promise> = () => + Promise.reject( + new Error('Capsule trusted module loader is not configured'), + ), + ) { + this.componentRuntime = new DefaultCapsuleComponentRuntime(evaluator); + } + + async loadBoxel(ref: CodeRef): Promise { + let { module, name } = normalizeCodeRef(ref); + return asBoxelTypeHandle(this.types.add({ ref, module, name })); + } + + async createFromSerialized( + resource: LooseCardResource, + document: LooseSingleCardDocument, + relativeTo: RealmResourceIdentifier | undefined, + purpose: MaterializationPurpose, + ): Promise { + let ref = resource.meta?.adoptsFrom; + if (!ref) { + throw new Error('Cannot create a Capsule Boxel without adoptsFrom'); + } + let { module, name } = normalizeCodeRef(ref); + let type: CapsuleTypeState = { ref, module, name }; + let instance: CapsuleInstanceState = { + type, + resource: structuredClone(resource), + document: structuredClone(document), + relativeTo, + purpose, + }; + return asBoxelInstanceHandle(this.instances.add(instance)); + } + + async describeBoxel(boxel: BoxelTypeHandle): Promise { + let type = this.types.get(boxel); + return this.descriptionFor(type); + } + + async getFields( + boxel: BoxelTypeHandle | BoxelInstanceHandle, + ): Promise { + if (boxel.startsWith('capsule-type:')) { + let type = this.types.get(boxel); + let metadata = await this.metadataFor(type); + return Object.entries(metadata.fields).map(([fieldName, field]) => + resolvedField(fieldName, field, null, false), + ); + } + let instance = this.instances.get(boxel); + return this.fieldsFor(instance); + } + + async getField( + boxel: BoxelTypeHandle | BoxelInstanceHandle, + fieldName: string, + ): Promise { + return (await this.getFields(boxel)).find( + (field) => field.fieldName === fieldName, + ); + } + + async buildRenderRecord( + card: BoxelInstanceHandle, + ): Promise { + let instance = this.instances.get(card); + return buildBoxelRenderRecord({ + boxel: await this.descriptionFor(instance.type), + instanceId: instance.resource.id ?? null, + fields: await this.fieldsFor(instance), + presentation: await this.presentationFor(instance), + }); + } + + async templateFor( + card: BoxelInstanceHandle, + format: string, + ): Promise { + let instance = this.instances.get(card); + return this.evaluator.evaluateTemplate( + instance.type.module, + instance.type.name, + format, + ); + } + + getRenderSlot( + card: BoxelInstanceHandle, + format: string, + ): Promise { + let byFormat = this.renderSlots.get(card); + if (!byFormat) { + byFormat = new Map(); + this.renderSlots.set(card, byFormat); + } + let existing = byFormat.get(format); + if (existing) { + return existing; + } + let slot = this.templateFor(card, format).then((bundle) => + createCapsuleRenderSlot( + this.componentRuntime, + bundle, + this.loadTrustedModule, + ), + ); + byFormat.set(format, slot); + void slot.catch(() => { + if (byFormat?.get(format) === slot) { + byFormat.delete(format); + } + }); + return slot; + } + + async serializeCard( + card: BoxelInstanceHandle, + ): Promise { + let instance = this.instances.get(card); + let projection = await this.projectionFor(instance); + let document = structuredClone(instance.document); + document.data.attributes = { + ...(document.data.attributes ?? {}), + ...projection, + }; + return document; + } + + async serializeCardPatch( + card: BoxelInstanceHandle, + changes: Record, + ): Promise { + let instance = this.instances.get(card); + let metadata = await this.metadataFor(instance.type); + let patch: PatchData = {}; + for (let [fieldName, value] of Object.entries(changes)) { + let field = metadata.fields[fieldName]; + if (!field) { + throw new Error(`Unknown field '${fieldName}'`); + } + if (field.kind === 'linksTo' || field.kind === 'linksToMany') { + patch.relationships ??= {}; + patch.relationships[fieldName] = value as never; + } else { + patch.attributes ??= {}; + patch.attributes[fieldName] = value; + } + } + return patch; + } + + async dispose(handle: RuntimeHandle): Promise { + if (handle.startsWith('capsule-type:')) { + this.types.release(handle); + } else if (handle.startsWith('capsule-instance:')) { + this.renderSlots.delete(handle as BoxelInstanceHandle); + this.instances.release(handle); + } + } + + destroy(): void { + this.types.clear(); + this.instances.clear(); + this.renderSlots.clear(); + this.componentRuntime.destroy(); + this.evaluator.destroy(); + } + + private async metadataFor( + type: CapsuleTypeState, + ): Promise { + return (type.metadata ??= await this.evaluator.evaluateCardTypeMetadata( + type.module, + type.name, + )); + } + + private async descriptionFor( + type: CapsuleTypeState, + ): Promise { + let metadata = await this.metadataFor(type); + let fields = Object.entries(metadata.fields).map( + ([fieldName, field]): FieldDescription => ({ + fieldName, + fieldType: trustedIdentityRef(field.type), + kind: field.kind, + isComputed: field.isComputed, + }), + ); + let formats = metadata.authoredTemplateFormats.map( + (format): FormatDescription => ({ + format, + provider: { kind: 'authored', ref: type.ref }, + }), + ); + for (let format of [ + 'isolated', + 'embedded', + 'fitted', + 'atom', + 'edit', + 'head', + 'markdown', + ]) { + if (!formats.some((item) => item.format === format)) { + formats.push({ + format, + provider: { kind: 'trusted-base', ref: trustedBaseFallbackRef }, + }); + } + } + return { + protocolVersion: BOXEL_EXECUTION_PROTOCOL_VERSION, + requiredFeatures: [], + ref: type.ref, + boxelKind: metadata.definitionKind, + ancestors: metadata.ancestorTypes.map(trustedIdentityRef), + fields, + formats, + presentation: { + displayName: metadata.displayName ?? type.name, + headerColor: metadata.headerColor, + prefersWideFormat: metadata.prefersWideFormat, + }, + executionHints: { + prefersFullSandbox: metadata.prefersFullSandbox, + }, + }; + } + + private async projectionFor( + instance: CapsuleInstanceState, + ): Promise> { + return (instance.projection ??= await this.evaluator.evaluateCardProjection( + instance.type.module, + instance.type.name, + snapshotFromResource(instance.resource), + )); + } + + private async fieldsFor( + instance: CapsuleInstanceState, + ): Promise { + let metadata = await this.metadataFor(instance.type); + let projection = await this.projectionFor(instance); + return Object.entries(metadata.fields).map(([fieldName, field]) => + resolvedField( + fieldName, + field, + cloneJSONValue(projection[fieldName]), + !field.isComputed && instance.purpose === 'interactive-edit', + ), + ); + } + + private async presentationFor( + instance: CapsuleInstanceState, + ): Promise { + let projection = await this.projectionFor(instance); + return { + title: stringOrNull(projection.cardTitle), + summary: stringOrNull(projection.cardDescription), + thumbnailURL: stringOrNull(projection.cardThumbnailURL), + theme: boxelReferenceOrNull(projection.cardTheme), + }; + } +} + +function trustedIdentityRef(identity: { + module: string; + name: string; +}): CodeRef { + return { + module: identity.module as RealmResourceIdentifier, + name: identity.name, + }; +} + +function snapshotFromResource( + resource: LooseCardResource, +): Record { + let snapshot: Record = { + ...(resource.attributes ?? {}), + }; + for (let [fieldName, relationship] of Object.entries( + resource.relationships ?? {}, + )) { + snapshot[fieldName] = relationship; + } + return snapshot; +} + +function resolvedField( + fieldName: string, + metadata: CapsuleCardFieldMetadata, + value: JSONValue | BoxelValueReference | BoxelValueReference[], + writable: boolean, +): ResolvedField { + return { + fieldName, + fieldType: trustedIdentityRef(metadata.type), + kind: metadata.kind, + value, + resolvedConfiguration: null, + presentation: metadata.displayName + ? { displayName: metadata.displayName } + : {}, + writable, + }; +} + +function cloneJSONValue(value: unknown): JSONValue { + if (value === undefined) { + return null; + } + return structuredClone(value) as JSONValue; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function boxelReferenceOrNull(value: unknown): BoxelValueReference | null { + if (typeof value !== 'object' || value === null) { + return null; + } + let candidate = value as { + id?: unknown; + type?: unknown; + $boxel?: unknown; + }; + if (candidate.$boxel) { + return structuredClone(value) as BoxelValueReference; + } + if (typeof candidate.type !== 'object' || candidate.type === null) { + return null; + } + return { + $boxel: { + id: typeof candidate.id === 'string' ? candidate.id : null, + type: candidate.type as CodeRef, + }, + }; +} diff --git a/packages/host/app/lib/capsule-component-runtime.ts b/packages/host/app/lib/capsule-component-runtime.ts new file mode 100644 index 00000000000..05cde9f03b9 --- /dev/null +++ b/packages/host/app/lib/capsule-component-runtime.ts @@ -0,0 +1,360 @@ +import { tracked } from '@glimmer/tracking'; + +import type { JSONValue } from '@cardstack/runtime-common'; +import { realmURL } from '@cardstack/runtime-common/constants'; + +import { + capsuleRealmURLArgument, + capsuleSetCapabilityArgument, + capsuleViewCardCapabilityArgument, + type CapsuleComponentActionResult, + type CapsuleComponentEffect, + type CapsuleComponentInstanceDescriptor, + type CapsuleTemplateDescriptor, +} from './capsule-module-evaluator'; + +import type CapsuleModuleEvaluator from './capsule-module-evaluator'; + +declare const capsuleComponentHandleBrand: unique symbol; +declare const capsuleComponentInstanceHandleBrand: unique symbol; + +export type CapsuleComponentHandle = string & { + readonly [capsuleComponentHandleBrand]: true; +}; + +export type CapsuleComponentInstanceHandle = string & { + readonly [capsuleComponentInstanceHandleBrand]: true; +}; + +export interface CapsuleComponentDefinition { + component: CapsuleComponentHandle; + descriptor: CapsuleTemplateDescriptor; + stylesheets: string[]; +} + +export interface CapsuleComponentUpdate { + generation: number; + componentRevision: number; + changed: Record; + effects: CapsuleComponentEffect[]; + returnValue?: unknown; +} + +export interface CapsuleComponentRuntime { + createComponent( + definition: CapsuleComponentDefinition, + args: Record, + ): CapsuleComponentInstanceHandle; + getContext(component: CapsuleComponentInstanceHandle): object; + updateComponent( + component: CapsuleComponentInstanceHandle, + args: Record, + ): CapsuleComponentUpdate; + invokeAction( + component: CapsuleComponentInstanceHandle, + action: string, + args: unknown[], + ): CapsuleComponentUpdate | Promise; + destroyComponent(component: CapsuleComponentInstanceHandle): void; + destroy(): void; +} + +interface LiveCapsuleComponent { + definition: CapsuleComponentDefinition; + context: CapsuleComponentContext; + evaluatorHandle: string; + argumentSignature: string; + hostArgs: Record; + generation: number; + revision: number; +} + +/** + * Stable Host-owned context read by Glimmer templates captured from a Capsule. + * Executable authored state remains in SES. + */ +class CapsuleComponentContext { + @tracked private revision = 0; + private state: Record = {}; + + constructor( + private readonly runtime: DefaultCapsuleComponentRuntime, + private readonly handle: CapsuleComponentInstanceHandle, + descriptor: CapsuleComponentInstanceDescriptor, + ) { + this.installShape(descriptor); + this.state = descriptor.state; + } + + update(descriptor: CapsuleComponentInstanceDescriptor): void { + this.installShape(descriptor); + this.state = descriptor.state; + this.revision++; + } + + readState(name: string, fallback?: unknown): unknown { + this.revision; + return this.state[name] ?? fallback; + } + + readGetter(name: string): unknown { + this.revision; + return this.runtime.readProperty(this.handle, name); + } + + private installShape(descriptor: CapsuleComponentInstanceDescriptor): void { + for (let [name, fallback] of Object.entries(descriptor.state)) { + if (Object.prototype.hasOwnProperty.call(this, name)) { + continue; + } + Object.defineProperty(this, name, { + configurable: false, + enumerable: true, + get: () => this.readState(name, fallback), + }); + } + for (let name of descriptor.getters) { + if (Object.prototype.hasOwnProperty.call(this, name)) { + continue; + } + Object.defineProperty(this, name, { + configurable: false, + enumerable: true, + get: () => this.readGetter(name), + }); + } + for (let name of descriptor.actions) { + if (Object.prototype.hasOwnProperty.call(this, name)) { + continue; + } + Object.defineProperty(this, name, { + configurable: false, + enumerable: true, + value: (...args: unknown[]) => + this.runtime.invokeAction(this.handle, name, args), + }); + } + } +} + +/** Public-manager-facing adapter over a single principal's SES evaluator. */ +export class DefaultCapsuleComponentRuntime implements CapsuleComponentRuntime { + private nextInstance = 0; + private instances = new Map< + CapsuleComponentInstanceHandle, + LiveCapsuleComponent + >(); + + constructor(private readonly evaluator: CapsuleModuleEvaluator) {} + + createComponent( + definition: CapsuleComponentDefinition, + args: Record, + ): CapsuleComponentInstanceHandle { + let projectedArgs = projectComponentArguments(args); + let descriptor = this.evaluator.instantiateComponent( + definition.descriptor.instance.handle, + projectedArgs, + ); + let handle = + `capsule-component-instance:${++this.nextInstance}` as CapsuleComponentInstanceHandle; + let context = new CapsuleComponentContext(this, handle, descriptor); + this.instances.set(handle, { + definition, + context, + evaluatorHandle: descriptor.handle, + argumentSignature: stableJSONString(projectedArgs), + hostArgs: args, + generation: 1, + revision: 0, + }); + return handle; + } + + getContext(component: CapsuleComponentInstanceHandle): object { + return this.get(component).context; + } + + updateComponent( + component: CapsuleComponentInstanceHandle, + args: Record, + ): CapsuleComponentUpdate { + let live = this.get(component); + live.hostArgs = args; + let projectedArgs = projectComponentArguments(args); + let signature = stableJSONString(projectedArgs); + if (signature === live.argumentSignature) { + return unchangedUpdate(live); + } + + this.evaluator.releaseComponentInstance(live.evaluatorHandle); + let descriptor = this.evaluator.instantiateComponent( + live.definition.descriptor.instance.handle, + projectedArgs, + ); + live.evaluatorHandle = descriptor.handle; + live.argumentSignature = signature; + live.generation++; + live.revision++; + live.context.update(descriptor); + return { + generation: live.generation, + componentRevision: live.revision, + changed: jsonState(descriptor.state), + effects: [], + }; + } + + invokeAction( + component: CapsuleComponentInstanceHandle, + action: string, + args: unknown[], + ): CapsuleComponentUpdate | Promise { + let live = this.get(component); + let result = this.evaluator.invokeComponentAction( + live.evaluatorHandle, + action, + args, + ); + let apply = (value: CapsuleComponentActionResult) => + this.applyActionResult(live, value); + return result instanceof Promise ? result.then(apply) : apply(result); + } + + destroyComponent(component: CapsuleComponentInstanceHandle): void { + let live = this.instances.get(component); + if (!live) { + return; + } + this.evaluator.releaseComponentInstance(live.evaluatorHandle); + this.instances.delete(component); + } + + readProperty( + component: CapsuleComponentInstanceHandle, + property: string, + ): unknown { + let live = this.get(component); + return this.evaluator.readComponentProperty(live.evaluatorHandle, property); + } + + get activeComponentCount(): number { + return this.instances.size; + } + + destroy(): void { + for (let handle of [...this.instances.keys()]) { + this.destroyComponent(handle); + } + } + + private applyActionResult( + live: LiveCapsuleComponent, + result: CapsuleComponentActionResult, + ): CapsuleComponentUpdate | Promise { + live.revision++; + live.context.update(result); + for (let effect of result.effects) { + dispatchEffect(live.hostArgs, effect); + } + return { + generation: live.generation, + componentRevision: live.revision, + changed: jsonState(result.state), + effects: result.effects, + ...(result.returnValue !== undefined + ? { returnValue: result.returnValue } + : {}), + }; + } + + private get(handle: CapsuleComponentInstanceHandle): LiveCapsuleComponent { + let live = this.instances.get(handle); + if (!live) { + throw new Error(`Unknown or released Capsule component '${handle}'`); + } + return live; + } +} + +function unchangedUpdate(live: LiveCapsuleComponent): CapsuleComponentUpdate { + return { + generation: live.generation, + componentRevision: live.revision, + changed: {}, + effects: [], + }; +} + +function dispatchEffect( + args: Record, + effect: CapsuleComponentEffect, +): void { + if (effect.type === 'view-card') { + let viewCard = args.viewCard; + if (typeof viewCard === 'function') { + viewCard(effect.target, effect.format, effect.options); + } + } else if (effect.type === 'set') { + let set = args.set; + if (typeof set === 'function') { + set(effect.value); + } + } +} + +/** Reduce Glimmer's stable named-argument proxy to a cloneable Capsule record. */ +export function projectComponentArguments( + value: unknown, +): Record { + if (typeof value !== 'object' || value === null) { + return {}; + } + let source = value as Record; + let result: Record = {}; + for (let [name, item] of Object.entries(source)) { + if (name === 'viewCard' && typeof item === 'function') { + result[capsuleViewCardCapabilityArgument] = true; + continue; + } + if (name === 'set' && typeof item === 'function') { + result[capsuleSetCapabilityArgument] = true; + continue; + } + let cloned = cloneJSON(item); + if (cloned !== undefined) { + result[name] = cloned; + } + } + + let model = source.model; + if (typeof model === 'object' && model !== null) { + let href = (model as { [realmURL]?: { href?: unknown } })[realmURL]?.href; + let plainModel = result.model; + if ( + typeof href === 'string' && + typeof plainModel === 'object' && + plainModel !== null + ) { + (plainModel as Record)[capsuleRealmURLArgument] = href; + } + } + return result; +} + +function cloneJSON(value: unknown): unknown { + try { + let json = JSON.stringify(value); + return json === undefined ? undefined : JSON.parse(json); + } catch { + return undefined; + } +} + +function stableJSONString(value: Record): string { + return JSON.stringify(value); +} + +function jsonState(value: Record): Record { + return JSON.parse(JSON.stringify(value)) as Record; +} diff --git a/packages/host/app/lib/capsule-component.ts b/packages/host/app/lib/capsule-component.ts new file mode 100644 index 00000000000..c6cf16c4b23 --- /dev/null +++ b/packages/host/app/lib/capsule-component.ts @@ -0,0 +1,269 @@ +import { + capabilities, + setComponentManager, + setComponentTemplate, +} from '@ember/component'; +import { createTemplateFactory } from '@ember/template-factory'; + +import { decodeScopedCSSRequest } from '@cardstack/runtime-common'; + +import type { + CapsuleComponentDefinition, + CapsuleComponentInstanceHandle, + CapsuleComponentRuntime, + CapsuleComponentHandle, +} from './capsule-component-runtime'; + +import type { + CapsuleScopeReference, + CapsuleTemplateBundle, + CapsuleTemplateDescriptor, +} from './capsule-module-evaluator'; +import type { ComponentLike } from '@glint/template'; + +type ComponentManager = ReturnType[0]>; + +export type CapsuleComponent = ComponentLike<{ + Args: Record; + Element: Element; +}>; + +export interface CapsuleRenderSlot { + readonly owner: 'capsule'; + readonly component: CapsuleComponent; + readonly stylesheets: string[]; +} + +class _CapsuleComponent { + constructor( + readonly runtime: CapsuleComponentRuntime, + readonly definition: CapsuleComponentDefinition, + ) {} +} + +class CapsuleComponentState { + constructor( + readonly runtime: CapsuleComponentRuntime, + readonly handle: CapsuleComponentInstanceHandle, + readonly releaseStyles: () => void, + ) {} +} + +class CapsuleComponentManager implements ComponentManager { + capabilities = capabilities('3.13', { + destructor: true, + updateHook: true, + }); + + static create(_owner: unknown) { + return new CapsuleComponentManager(); + } + + createComponent( + definition: _CapsuleComponent, + args: unknown, + ): CapsuleComponentState { + let releaseStyles = capsuleStylesheets.retain( + definition.definition.stylesheets, + ); + let handle = definition.runtime.createComponent( + definition.definition, + namedArguments(args), + ); + return new CapsuleComponentState(definition.runtime, handle, releaseStyles); + } + + getContext(component: CapsuleComponentState): object { + return component.runtime.getContext(component.handle); + } + + updateComponent(component: CapsuleComponentState, args: unknown): void { + component.runtime.updateComponent(component.handle, namedArguments(args)); + } + + destroyComponent(component: CapsuleComponentState): void { + component.runtime.destroyComponent(component.handle); + component.releaseStyles(); + } +} + +function namedArguments(args: unknown): Record { + if ( + typeof args !== 'object' || + args === null || + !('named' in args) || + typeof args.named !== 'object' || + args.named === null + ) { + return {}; + } + return args.named as Record; +} + +setComponentManager( + (owner) => CapsuleComponentManager.create(owner), + _CapsuleComponent.prototype, +); + +/** Reify one validated Capsule template graph into private Host definitions. */ +export async function createCapsuleRenderSlot( + runtime: CapsuleComponentRuntime, + bundle: CapsuleTemplateBundle, + loadTrustedModule: ( + moduleIdentifier: string, + ) => Promise>, +): Promise { + let definitions = new Map(); + let stylesheets = decodedStylesheets(bundle); + + for (let [id, descriptor] of Object.entries(bundle.templates)) { + validateTemplateDescriptor(descriptor); + definitions.set( + id, + new _CapsuleComponent(runtime, { + component: id as CapsuleComponentHandle, + descriptor, + stylesheets: id === bundle.root ? stylesheets : [], + }), + ); + } + + let moduleCache = new Map>(); + let resolveScope = async (reference: CapsuleScopeReference) => { + switch (reference.kind) { + case 'component': { + let definition = definitions.get(reference.component); + if (!definition) { + throw new Error( + `Capsule template references unknown component '${reference.component}'`, + ); + } + return definition; + } + case 'trusted-export': { + let module = moduleCache.get(reference.module); + if (!module) { + module = await loadTrustedModule(reference.module); + moduleCache.set(reference.module, module); + } + if (!(reference.name in module)) { + throw new Error( + `Trusted module '${reference.module}' has no '${reference.name}' export`, + ); + } + return module[reference.name]; + } + case 'value': + return structuredClone(reference.value); + } + }; + + for (let [id, descriptor] of Object.entries(bundle.templates)) { + let definition = definitions.get(id)!; + let scope = await Promise.all(descriptor.scope.map(resolveScope)); + let template = createTemplateFactory({ + id: `${descriptor.id}-capsule`, + block: descriptor.block, + moduleName: descriptor.moduleName, + scope: () => scope, + isStrictMode: descriptor.isStrictMode, + }); + setComponentTemplate(template, definition); + } + + let root = definitions.get(bundle.root); + if (!root) { + throw new Error(`Capsule template bundle has no root '${bundle.root}'`); + } + return { + owner: 'capsule', + component: root as unknown as CapsuleComponent, + stylesheets, + }; +} + +function validateTemplateDescriptor( + descriptor: CapsuleTemplateDescriptor, +): void { + let block: unknown; + try { + block = JSON.parse(descriptor.block); + } catch { + throw new Error( + `Capsule template '${descriptor.id}' has invalid wire data`, + ); + } + if (!Array.isArray(block)) { + throw new Error( + `Capsule template '${descriptor.id}' has invalid wire data`, + ); + } +} + +function decodedStylesheets(bundle: CapsuleTemplateBundle): string[] { + let result = new Set(); + for (let descriptor of Object.values(bundle.templates)) { + for (let request of descriptor.stylesheets) { + let css = decodeScopedCSSRequest(request).css; + validateCapsuleStylesheet(css); + result.add(css); + } + } + return [...result]; +} + +function validateCapsuleStylesheet(css: string): void { + if (!/\[data-scopedcss-[a-z0-9-]+/i.test(css)) { + throw new Error('Capsule stylesheet is missing its compiled scope'); + } + if (/@(?:import|namespace|charset)\b/i.test(css)) { + throw new Error('Capsule stylesheet contains a global at-rule'); + } +} + +interface RetainedStyle { + count: number; + element: HTMLStyleElement; +} + +class CapsuleStylesheetRegistry { + private styles = new Map(); + + retain(stylesheets: string[]): () => void { + if (typeof document === 'undefined') { + return () => undefined; + } + for (let css of stylesheets) { + let retained = this.styles.get(css); + if (retained) { + retained.count++; + } else { + let element = document.createElement('style'); + element.dataset.boxelCapsuleStyle = ''; + element.textContent = css; + document.head.appendChild(element); + this.styles.set(css, { count: 1, element }); + } + } + let released = false; + return () => { + if (released) { + return; + } + released = true; + for (let css of stylesheets) { + let retained = this.styles.get(css); + if (!retained) { + continue; + } + retained.count--; + if (retained.count === 0) { + retained.element.remove(); + this.styles.delete(css); + } + } + }; + } +} + +const capsuleStylesheets = new CapsuleStylesheetRegistry(); diff --git a/packages/host/app/lib/capsule-module-evaluator.ts b/packages/host/app/lib/capsule-module-evaluator.ts new file mode 100644 index 00000000000..95d0d1e2822 --- /dev/null +++ b/packages/host/app/lib/capsule-module-evaluator.ts @@ -0,0 +1,2210 @@ +import 'ses'; + +import { isTesting } from '@embroider/macros'; + +import { + baseRRI, + getMenuItems, + realmURL, +} from '@cardstack/runtime-common/constants'; +import { Loader } from '@cardstack/runtime-common/loader'; +import { codeRef } from '@cardstack/runtime-common/realm-identifiers'; +import type { VirtualNetwork } from '@cardstack/runtime-common/virtual-network'; + +import { capsuleSearchEntryWireQueryFromQuery } from '@cardstack/host/lib/capsule-runtime-helpers'; + +import { createCapsuleCompartment } from '../../workers/capsule-module-registration-evaluator'; + +export type CapsuleScopeReference = + | { kind: 'component'; component: string } + | { kind: 'trusted-export'; module: string; name: string } + | { kind: 'value'; value: unknown }; + +export interface CapsuleTemplateDescriptor { + id: string; + block: string; + moduleName: string; + isStrictMode: boolean; + stylesheets: string[]; + scope: CapsuleScopeReference[]; + instance: CapsuleComponentInstanceDescriptor; +} + +export interface CapsuleComponentInstanceDescriptor { + handle: string; + state: Record; + getters: string[]; + actions: string[]; +} + +export type CapsuleComponentEffect = + | { + type: 'view-card'; + target: string; + format?: string; + options?: Record; + } + | { + type: 'set'; + value: unknown; + }; + +export interface CapsuleComponentActionResult extends CapsuleComponentInstanceDescriptor { + effects: CapsuleComponentEffect[]; + returnValue?: unknown; +} + +export interface CapsuleTemplateBundle { + root: string; + templates: Record; +} + +export interface CapsuleTrustedExportIdentity { + module: string; + name: string; +} + +interface CapsuleFormatReference { + kind: 'capsule-format-reference'; + module: string; + name: string; +} + +export interface CapsuleFormatOnlyImportDescriptor { + module: string; + exports: string[]; +} + +export interface CapsuleCardFieldMetadata { + kind: 'contains' | 'containsMany' | 'linksTo' | 'linksToMany'; + type: CapsuleTrustedExportIdentity; + isComputed: boolean; + displayName?: string; +} + +export interface CapsuleCardTypeMetadata { + definitionKind: 'card' | 'field' | 'file'; + ancestorTypes: CapsuleTrustedExportIdentity[]; + displayName?: string; + fields: Record; + headerColor: string | null; + hasCustomEditTemplate: boolean; + hasCustomIsolatedTemplate: boolean; + authoredTemplateFormats: string[]; + icon?: CapsuleTrustedExportIdentity; + prefersFullSandbox: boolean; + prefersWideFormat: boolean; +} + +export interface CapsuleCardMethodResult { + returnValue?: unknown; + card?: { + type: CapsuleTrustedExportIdentity; + attributes: Record; + }; +} + +interface CapturedCardFieldMetadata { + kind: CapsuleCardFieldMetadata['kind']; + card: object; + computeVia?: (this: Record) => unknown; +} + +interface CapsuleCardFieldDefinition { + type: CapsuleCardFieldMetadata['kind']; + card: object; + computeVia?: (this: Record) => unknown; +} + +function safeEventTarget( + target: EventTarget | null, +): Record | null { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return null; + } + let source = target as Element & { + checked?: unknown; + dataset?: DOMStringMap; + name?: unknown; + selectedIndex?: unknown; + type?: unknown; + value?: unknown; + }; + let result: Record = { + tagName: source.tagName, + }; + for (let property of [ + 'checked', + 'id', + 'name', + 'selectedIndex', + 'type', + 'value', + ] as const) { + let value = source[property]; + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + result[property] = value; + } + } + if (source.dataset) { + result.dataset = Object.fromEntries(Object.entries(source.dataset)); + } + return result; +} + +function safeEvent(event: Event): Record { + let result: Record = { + type: event.type, + bubbles: event.bubbles, + cancelable: event.cancelable, + composed: event.composed, + defaultPrevented: event.defaultPrevented, + target: safeEventTarget(event.target), + currentTarget: safeEventTarget(event.currentTarget), + }; + for (let property of [ + 'altKey', + 'button', + 'buttons', + 'clientX', + 'clientY', + 'code', + 'ctrlKey', + 'data', + 'deltaMode', + 'deltaX', + 'deltaY', + 'inputType', + 'isPrimary', + 'key', + 'metaKey', + 'pageX', + 'pageY', + 'pointerId', + 'pointerType', + 'repeat', + 'screenX', + 'screenY', + 'shiftKey', + ] as const) { + let value = (event as unknown as Record)[property]; + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) { + result[property] = value; + } + } + return result; +} + +export function projectCapsuleActionArguments(args: unknown[]): unknown[] { + return args.map((value) => + typeof Event !== 'undefined' && value instanceof Event + ? safeEvent(value) + : value, + ); +} + +export interface CompartmentAmbientReport { + window: string; + document: string; + localStorage: string; + fetch: string; + XMLHttpRequest: string; + URL: string; + URLSearchParams: string; +} + +interface TemplateFactoryDescriptor { + id: string; + block: string; + moduleName: string; + isStrictMode?: boolean; + scope?: () => unknown[]; +} + +interface TemplateFactoryResult { + parsedLayout?: TemplateFactoryDescriptor; +} + +interface CapturedTemplate { + descriptor: Omit; + scope: unknown[]; +} + +function templateContainsLiteralElement( + value: unknown, + tagName: string, +): boolean { + if (!Array.isArray(value)) { + return false; + } + // Ember's serialized wire format represents a literal element as + // [OpenElement, tagName]. Scoped style elements never reach this block: + // glimmer-scoped-css removes them and emits a hashed stylesheet dependency. + // Therefore a literal style element here is necessarily an unscoped style + // that the browser would apply to the shared host document. + if (value[0] === 10 && value[1] === tagName) { + return true; + } + return value.some((entry) => templateContainsLiteralElement(entry, tagName)); +} + +const inertHeadElementPrefix = 'boxel-head-tag-'; + +function inertHeadTemplateElements(value: unknown): unknown { + if (!Array.isArray(value)) { + return value; + } + let result = value.map(inertHeadTemplateElements); + // Head previews must observe authored markup without ever installing live + // style, script, link, image, or other browser-active elements into the + // shared Host document. Preserve the wire structure and attributes, but + // replace every literal tag with an inert custom element. The trusted head + // preview restores these names only inside a detached parser. + if (result[0] === 10 && typeof result[1] === 'string') { + let tagName = result[1].toLowerCase().replace(/[^a-z0-9-]/g, '-'); + result[1] = `${inertHeadElementPrefix}${tagName}`; + } + return result; +} + +export interface CapsuleModuleEvaluatorOptions { + fetch: typeof fetch; + resolveImport: (moduleIdentifier: string) => string; + virtualNetwork?: VirtualNetwork; + decoratorRuntime?: unknown; + documentFacade?: object; + mathFacade?: object; + isTrustedImport?: (moduleIdentifier: string) => boolean | string; + validateInlineStyle?: (style: string) => void; +} + +const lockdownMarker = Symbol.for('boxel.realm-compartment.lockdown'); +export const capsuleRealmURLArgument = '__boxelCapsuleRealmURL'; +export const capsuleViewCardCapabilityArgument = + '__boxelCapsuleHasViewCardCapability'; +export const capsuleSetCapabilityArgument = '__boxelCapsuleHasSetCapability'; + +const staticAttributeOpcodes = new Set([14, 24]); +const dynamicAttributeOpcodes = new Set([15, 16, 22, 23]); +const topLayerAttributeNames = new Set([ + 'command', + 'commandfor', + 'popover', + 'popovertarget', + 'popovertargetaction', +]); + +function validateTemplateDOMPolicy( + value: unknown, + validateInlineStyle: ((style: string) => void) | undefined, +): void { + if (!Array.isArray(value)) { + return; + } + let [opcode, name, attributeValue] = value; + if ( + typeof name === 'string' && + topLayerAttributeNames.has(name.toLowerCase()) && + (staticAttributeOpcodes.has(Number(opcode)) || + dynamicAttributeOpcodes.has(Number(opcode))) + ) { + // Popovers and command-invoked dialogs enter the browser top layer. That + // layer is intentionally outside ancestor paint/layout containment, so + // allowing these declarative attributes would let Capsule content cover Host + // chrome without ever receiving document or element capabilities. + throw new Error( + `Capsule templates cannot use the ${name} attribute because it can escape the card paint boundary`, + ); + } + let isStyleAttribute = name === 'style' || name === 5; + if (isStyleAttribute && staticAttributeOpcodes.has(Number(opcode))) { + if (typeof attributeValue !== 'string' || !validateInlineStyle) { + throw new Error( + 'Capsule template inline style validation is unavailable', + ); + } + validateInlineStyle(attributeValue); + } else if (isStyleAttribute && dynamicAttributeOpcodes.has(Number(opcode))) { + throw new Error( + 'Capsule templates cannot use dynamic inline styles; use + + }; + } + loader.shimModule(`${testRealmURL}direct-runtime-scoped-card`, { + ScopedRuntimeCard, + }); + + let card = new ScopedRuntimeCard({}); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + await waitFor('[data-test-direct-runtime-css-canary]'); + + let cardCanary = document.querySelector( + '[data-test-direct-runtime-css-canary]', + ); + let hostCanary = document.querySelector( + '[data-test-host-css-canary]', + ); + assert.ok(cardCanary, 'the Direct-owned card component rendered'); + assert.ok(hostCanary, 'the Host canary rendered outside the card'); + + let scopeAttribute = Array.from(cardCanary?.attributes ?? []) + .map((attribute) => attribute.localName) + .find((attributeName) => attributeName.startsWith('data-scopedcss-')); + assert.ok( + scopeAttribute, + 'main glimmer-scoped-css annotated the Direct-owned card element', + ); + assert.false( + hostCanary?.hasAttribute(scopeAttribute ?? ''), + 'the Host element does not receive the authored scope attribute', + ); + assert.strictEqual( + getComputedStyle(cardCanary!).outlineWidth, + '7px', + 'the authored rule applies inside the Direct render slot', + ); + assert.strictEqual( + getComputedStyle(hostCanary!).outlineWidth, + '0px', + 'the same class name cannot leak the authored rule into Host chrome', + ); + + let scopedStyle = Array.from( + document.querySelectorAll( + 'style[data-boxel-scoped-css]', + ), + ).find((style) => style.textContent?.includes(scopeAttribute ?? '')); + assert.ok(scopedStyle, 'the canonical scoped stylesheet was installed'); + assert.true( + scopedStyle?.textContent?.includes( + `.direct-runtime-css-canary[${scopeAttribute}]`, + ), + 'the installed selector is attribute-scoped rather than global', + ); + }); + test('renders head meta tags preview for a card head format', async function (assert) { let { field, contains, CardDef, Component } = cardApi; let { default: StringField } = string; diff --git a/packages/host/tests/integration/modifiers/surface-element-test.gts b/packages/host/tests/integration/modifiers/surface-element-test.gts new file mode 100644 index 00000000000..5acb0e8ca06 --- /dev/null +++ b/packages/host/tests/integration/modifiers/surface-element-test.gts @@ -0,0 +1,79 @@ +import { render } from '@ember/test-helpers'; + +import Component from '@glimmer/component'; + +import { module, test } from 'qunit'; + +import { + surfaceLayout, + surfaceObserve, + surfacePresentation, + type SurfaceObservationValue, +} from '@cardstack/boxel-ui/surface'; + +import surfaceElement from '@cardstack/host/modifiers/surface-element'; + +import type SurfaceService from '@cardstack/host/services/surface-service'; + +import { setupRenderingTest } from '../../helpers/setup'; + +module('Integration | Modifier | surface-element', function (hooks) { + setupRenderingTest(hooks); + + test('portable surface modifiers target the nearest Host registration', async function (assert) { + let service = this.owner.lookup( + 'service:surface-service', + ) as SurfaceService; + let handle = service.register({ + mode: 'capsule', + principal: 'test-user', + surfaceId: 'preview', + }); + let observation: SurfaceObservationValue | undefined; + + class TestDriver extends Component { + handle = handle; + capture = (value: SurfaceObservationValue) => { + observation = value; + }; + + + } + + try { + await render(); + let root = document.querySelector( + '[data-test-surface-root]', + )!; + assert.strictEqual( + root.style.getPropertyValue('--boxel-surface-header-color'), + '#102030', + ); + assert.strictEqual( + root.style.getPropertyValue('--boxel-surface-container-background'), + '#f8f4ee', + ); + assert.strictEqual(root.dataset.boxelSurfaceHeightMode, 'allocated'); + assert.strictEqual(root.style.minHeight, '360px'); + assert.true(Boolean(observation), 'surfaceObserve received a projection'); + assert.strictEqual(typeof observation?.width, 'number'); + assert.strictEqual(typeof observation?.height, 'number'); + assert.strictEqual(typeof observation?.visible, 'boolean'); + } finally { + service.release(handle); + } + }); +}); diff --git a/packages/host/tests/unit/lib/boxel-execution-engine-test.ts b/packages/host/tests/unit/lib/boxel-execution-engine-test.ts new file mode 100644 index 00000000000..d69bbefd174 --- /dev/null +++ b/packages/host/tests/unit/lib/boxel-execution-engine-test.ts @@ -0,0 +1,489 @@ +import { module, test } from 'qunit'; + +import type { + BoxelInstanceHandle, + BoxelRenderRecord, + LooseCardResource, + LooseSingleCardDocument, +} from '@cardstack/runtime-common'; + +import BoxelExecutionEngine from '@cardstack/host/lib/boxel-execution-engine'; +import { + decideBoxelExecution, + type BoxelExecutionPolicyInput, +} from '@cardstack/host/lib/boxel-execution-policy'; +import type { BoxelRuntime } from '@cardstack/host/lib/boxel-runtime'; +import BoxelRuntimeRouter from '@cardstack/host/lib/boxel-runtime-router'; +import { + BoxelModuleGraphClassifier, + classifyBoxelSource, + type BoxelSourceClassification, +} from '@cardstack/host/lib/boxel-source-classifier'; + +import type CapsuleBoxelRuntime from '@cardstack/host/lib/capsule-boxel-runtime'; +import type DirectBoxelRuntime from '@cardstack/host/lib/direct-boxel-runtime'; +import type SandboxRuntimeProcess from '@cardstack/host/lib/sandbox-runtime-process'; + +const capsuleSource: BoxelSourceClassification = { + tier: 'capsule', + reason: 'default-user-card', + imports: [], + signals: [], + propagatesToImporters: false, +}; + +const sandboxSource: BoxelSourceClassification = { + tier: 'sandbox', + reason: 'browser-runtime:document', + imports: [], + signals: ['document'], + propagatesToImporters: false, +}; + +function policy( + overrides: Partial = {}, +): BoxelExecutionPolicyInput { + return { + trusted: false, + format: 'isolated', + source: capsuleSource, + prefersFullSandbox: false, + ...overrides, + }; +} + +class TestRuntime { + destroyed = false; + disposed: string[] = []; + failBuild = false; + prefersFullSandbox = false; + private nextInstance = 0; + + constructor(readonly mode: BoxelRuntime['mode']) {} + + destroy(): void { + this.destroyed = true; + } + + async loadBoxel(): Promise { + throw new Error('not used'); + } + async createFromSerialized(): Promise { + return `${this.mode}-instance:${++this.nextInstance}` as BoxelInstanceHandle; + } + async describeBoxel(): Promise { + throw new Error('not used'); + } + async getFields(): Promise { + throw new Error('not used'); + } + async getField(): Promise { + throw new Error('not used'); + } + async buildRenderRecord(): Promise { + if (this.failBuild) { + throw new Error(`${this.mode} render failed`); + } + return renderRecord(this.mode, this.prefersFullSandbox); + } + async serializeCard(): Promise { + throw new Error('not used'); + } + async serializeCardPatch(): Promise { + throw new Error('not used'); + } + async dispose(handle: string): Promise { + this.disposed.push(handle); + } + + getRenderSlotForHandle() { + return { owner: 'direct' as const, component: {} as never }; + } + + async getRenderSlot() { + return { + owner: 'capsule' as const, + component: {} as never, + stylesheets: [], + }; + } +} + +const resource = { + id: 'https://example.test/Card/one', + type: 'card', + attributes: {}, + relationships: {}, + meta: { + adoptsFrom: { + module: 'https://example.test/card', + name: 'Example', + }, + }, +} as unknown as LooseCardResource; + +const cardDocument = { + data: resource, +} as unknown as LooseSingleCardDocument; + +function renderRecord( + mode: BoxelRuntime['mode'], + prefersFullSandbox = false, +): BoxelRenderRecord { + return { + protocolVersion: 1, + boxel: { + protocolVersion: 1, + requiredFeatures: [], + ref: resource.meta!.adoptsFrom!, + boxelKind: 'card', + ancestors: [], + fields: [], + formats: [], + presentation: { + displayName: `${mode} card`, + headerColor: null, + prefersWideFormat: false, + }, + executionHints: { prefersFullSandbox }, + }, + instance: { id: resource.id ?? null, fields: [] }, + presentation: { + title: `${mode} title`, + summary: null, + thumbnailURL: null, + theme: null, + }, + }; +} + +function executionRequest(source = 'capsule') { + return { + principal: 'user:one', + surfaceId: 'surface:one', + trusted: false, + format: 'isolated', + moduleIdentifier: 'https://example.test/card', + source, + resource, + document: cardDocument, + purpose: 'host-display' as const, + }; +} + +module('Unit | Boxel execution engine', function () { + test('Host policy chooses execution and authored input can only strengthen it', function (assert) { + assert.deepEqual(decideBoxelExecution(policy({ trusted: true })), { + mode: 'direct', + reason: 'trusted-boxel-module', + }); + assert.deepEqual(decideBoxelExecution(policy()), { + mode: 'capsule', + reason: 'default-user-card', + }); + assert.deepEqual(decideBoxelExecution(policy({ source: sandboxSource })), { + mode: 'sandbox', + reason: 'browser-runtime:document', + }); + assert.deepEqual( + decideBoxelExecution( + policy({ + trusted: true, + prefersFullSandbox: true, + }), + ), + { mode: 'sandbox', reason: 'prefers-full-sandbox' }, + 'even trusted code can explicitly request the stronger process boundary', + ); + assert.deepEqual( + decideBoxelExecution(policy({ source: sandboxSource, format: 'fitted' })), + { mode: 'capsule', reason: 'ses-only-format:fitted' }, + 'compact composition formats never create inline iframes', + ); + }); + + test('source classification distinguishes type references from browser authority', async function (assert) { + let typeOnly = await classifyBoxelSource(` + import { CardDef } from '@cardstack/base/card-api'; + export class Example extends CardDef { + element?: HTMLElement; + } + `); + assert.strictEqual(typeOnly.tier, 'capsule'); + + let browser = await classifyBoxelSource(` + import { CardDef } from '@cardstack/base/card-api'; + export class Example extends CardDef { + static isolated = document.createElement('canvas'); + } + `); + assert.strictEqual(browser.tier, 'sandbox'); + assert.true(browser.signals.includes('document')); + + let externalRenderer = await classifyBoxelSource(` + import { CardDef } from '@cardstack/base/card-api'; + import * as THREE from 'three'; + export class Example extends CardDef { + static isolated = THREE.Scene; + } + `); + assert.strictEqual(externalRenderer.tier, 'sandbox'); + assert.true(externalRenderer.signals.includes('three')); + assert.deepEqual(externalRenderer.formatOnlyImports, [ + { + specifier: 'three', + bindings: [{ exportName: 'Scene', formats: ['isolated'] }], + }, + ]); + }); + + test('module graph classification propagates authored browser dependencies and stops at trusted modules', async function (assert) { + let sources: Record = { + 'https://example.test/entry.gts': ` + import Renderer from './renderer.gts'; + import { CardDef } from 'https://cardstack.com/base/card-api'; + export class Example extends CardDef { static isolated = Renderer; } + `, + 'https://example.test/renderer.gts': ` + import * as THREE from 'three'; + export default THREE.Scene; + `, + }; + let loads: string[] = []; + let classifier = new BoxelModuleGraphClassifier({ + loadSource: async (identifier) => { + loads.push(identifier); + let source = sources[identifier]; + if (source === undefined) { + throw new Error('not found'); + } + return source; + }, + resolveImport: (specifier, relativeTo) => + specifier.startsWith('.') + ? new URL(specifier, relativeTo).href + : specifier, + isTrustedModule: (identifier) => + identifier.startsWith('https://cardstack.com/base/'), + }); + + // eslint-disable-next-line ember/no-string-prototype-extensions -- this is the graph classifier API, not Ember.String.classify + let result = await classifier.classify('https://example.test/entry.gts'); + assert.strictEqual(result.tier, 'sandbox'); + assert.strictEqual( + result.reason, + 'dependency-runtime:https://example.test/renderer.gts', + ); + assert.true(result.signals.includes('three')); + assert.false( + loads.includes('https://cardstack.com/base/card-api'), + 'trusted imports are semantic leaves and are never fetched as authored source', + ); + }); + + test('the router retains Capsule by principal and Sandbox by mounted surface', async function (assert) { + let direct = new TestRuntime('direct'); + let capsules: TestRuntime[] = []; + let sandboxes: TestRuntime[] = []; + let router = new BoxelRuntimeRouter( + direct as unknown as DirectBoxelRuntime, + () => { + let runtime = new TestRuntime('capsule'); + capsules.push(runtime); + return runtime as unknown as CapsuleBoxelRuntime; + }, + () => { + let runtime = new TestRuntime('sandbox'); + sandboxes.push(runtime); + return runtime as unknown as SandboxRuntimeProcess; + }, + 0, + ); + + let sharedInput = { + ...policy(), + principal: 'user:one', + surfaceId: 'surface:one', + }; + let capsuleOne = router.route(sharedInput); + let capsuleTwo = router.route({ ...sharedInput, surfaceId: 'surface:two' }); + assert.strictEqual( + capsuleOne.runtime, + capsuleTwo.runtime, + 'one principal shares one warm Capsule across surfaces', + ); + + let sandboxOne = router.route({ + ...sharedInput, + source: sandboxSource, + }); + let sandboxTwo = router.route({ + ...sharedInput, + source: sandboxSource, + surfaceId: 'surface:two', + }); + assert.notStrictEqual( + sandboxOne.runtime, + sandboxTwo.runtime, + 'each mounted Sandbox surface owns a distinct child process', + ); + assert.strictEqual(capsules.length, 1); + assert.strictEqual(sandboxes.length, 2); + + capsuleOne.release(); + assert.false(capsules[0]!.destroyed, 'one retained consumer remains'); + capsuleTwo.release(); + sandboxOne.release(); + sandboxTwo.release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.true(capsules[0]!.destroyed, 'idle Capsule was evicted'); + assert.true(sandboxes[0]!.destroyed, 'idle Sandbox was evicted'); + assert.true(sandboxes[1]!.destroyed, 'other idle Sandbox was evicted'); + assert.false(direct.destroyed, 'the trusted Host runtime is not evicted'); + + router.destroy(); + }); + + test('an execution session swaps complete generations and retains last-known-good on failure', async function (assert) { + let direct = new TestRuntime('direct'); + let capsule = new TestRuntime('capsule'); + let sandbox = new TestRuntime('sandbox'); + let router = new BoxelRuntimeRouter( + direct as unknown as DirectBoxelRuntime, + () => capsule as unknown as CapsuleBoxelRuntime, + () => sandbox as unknown as SandboxRuntimeProcess, + ); + let engine = new BoxelExecutionEngine(router, async (_module, source) => + source === 'sandbox' ? sandboxSource : capsuleSource, + ); + let session = engine.createSession(); + + let first = await session.update(executionRequest()); + assert.strictEqual(first?.lease.runtime, capsule); + assert.strictEqual(session.snapshot.status, 'ready'); + + capsule.failBuild = true; + let failed = await session.update(executionRequest()); + assert.strictEqual(failed, undefined); + assert.strictEqual(session.snapshot.status, 'error'); + assert.strictEqual( + session.snapshot.current, + first, + 'a failed candidate cannot replace the last-known-good generation', + ); + assert.strictEqual( + session.snapshot.error?.message, + 'capsule render failed', + ); + + await session.destroy(); + engine.destroy(); + }); + + test('obsolete async classification cannot replace a newer generation', async function (assert) { + let direct = new TestRuntime('direct'); + let capsule = new TestRuntime('capsule'); + let sandbox = new TestRuntime('sandbox'); + let releaseSlow!: (classification: BoxelSourceClassification) => void; + let slow = new Promise((resolve) => { + releaseSlow = resolve; + }); + let router = new BoxelRuntimeRouter( + direct as unknown as DirectBoxelRuntime, + () => capsule as unknown as CapsuleBoxelRuntime, + () => sandbox as unknown as SandboxRuntimeProcess, + ); + let engine = new BoxelExecutionEngine(router, (_module, source) => + source === 'slow' ? slow : Promise.resolve(sandboxSource), + ); + let session = engine.createSession(); + + let obsolete = session.update(executionRequest('slow')); + let current = await session.update(executionRequest('fast')); + releaseSlow(capsuleSource); + + assert.strictEqual(await obsolete, undefined); + assert.strictEqual(session.snapshot.current, current); + assert.strictEqual(current?.lease.runtime, sandbox); + assert.strictEqual(session.snapshot.requestedGeneration, 2); + + await session.destroy(); + engine.destroy(); + }); + + test('a type-discovered full Sandbox preference strengthens the initial decision', async function (assert) { + let direct = new TestRuntime('direct'); + let capsule = new TestRuntime('capsule'); + capsule.prefersFullSandbox = true; + let sandbox = new TestRuntime('sandbox'); + let router = new BoxelRuntimeRouter( + direct as unknown as DirectBoxelRuntime, + () => capsule as unknown as CapsuleBoxelRuntime, + () => sandbox as unknown as SandboxRuntimeProcess, + ); + let engine = new BoxelExecutionEngine(router, async () => capsuleSource); + let session = engine.createSession(); + + let generation = await session.update(executionRequest()); + assert.strictEqual(generation?.lease.decision.mode, 'sandbox'); + assert.strictEqual(generation?.lease.runtime, sandbox); + assert.strictEqual( + capsule.disposed.length, + 1, + 'the weaker candidate was disposed before the stronger one became current', + ); + + await session.destroy(); + engine.destroy(); + }); + + test('the execution session exposes the render slot owned by the selected tier', async function (assert) { + let direct = new TestRuntime('direct'); + let capsule = new TestRuntime('capsule'); + let sandbox = new TestRuntime('sandbox'); + Object.assign(sandbox, { + getRenderSlot: () => ({ + owner: 'sandbox' as const, + iframe: document.createElement('iframe'), + surface: 'surface:test', + }), + }); + let router = new BoxelRuntimeRouter( + direct as unknown as DirectBoxelRuntime, + () => capsule as unknown as CapsuleBoxelRuntime, + () => sandbox as unknown as SandboxRuntimeProcess, + ); + let engine = new BoxelExecutionEngine(router, async (_module, source) => + source === 'sandbox' ? sandboxSource : capsuleSource, + ); + + let directSession = engine.createSession(); + await directSession.update({ + ...executionRequest(), + trusted: true, + }); + assert.strictEqual( + (await directSession.getRenderSlot('isolated')).owner, + 'direct', + ); + + let capsuleSession = engine.createSession(); + await capsuleSession.update(executionRequest()); + assert.strictEqual( + (await capsuleSession.getRenderSlot('fitted')).owner, + 'capsule', + ); + + let sandboxSession = engine.createSession(); + await sandboxSession.update(executionRequest('sandbox')); + assert.strictEqual( + (await sandboxSession.getRenderSlot('isolated')).owner, + 'sandbox', + ); + + await directSession.destroy(); + await capsuleSession.destroy(); + await sandboxSession.destroy(); + engine.destroy(); + }); +}); diff --git a/packages/host/tests/unit/lib/boxel-runtime-transport-test.ts b/packages/host/tests/unit/lib/boxel-runtime-transport-test.ts new file mode 100644 index 00000000000..632f61b725e --- /dev/null +++ b/packages/host/tests/unit/lib/boxel-runtime-transport-test.ts @@ -0,0 +1,264 @@ +import { module, test } from 'qunit'; + +import { + BOXEL_EXECUTION_PROTOCOL_VERSION, + BOXEL_EXECUTION_TRANSPORT_VERSION, + type BoxelDescription, + type BoxelInstanceHandle, + type BoxelRenderRecord, + type BoxelTypeHandle, + type CodeRef, + type LooseCardResource, + type LooseSingleCardDocument, + type RealmResourceIdentifier, + type RuntimeHandle, +} from '@cardstack/runtime-common'; + +import type { BoxelRuntime } from '@cardstack/host/lib/boxel-runtime'; +import SandboxBoxelRuntimeClient from '@cardstack/host/lib/sandbox-boxel-runtime-client'; +import SandboxBoxelRuntimeServer from '@cardstack/host/lib/sandbox-boxel-runtime-server'; +import { + SandboxRenderClient, + SandboxRenderServer, +} from '@cardstack/host/lib/sandbox-render-transport'; + +const typeHandle = 'test-type:1' as BoxelTypeHandle; +const instanceHandle = 'test-instance:1' as BoxelInstanceHandle; +const ref: CodeRef = { + module: 'https://example.test/person' as RealmResourceIdentifier, + name: 'Person', +}; + +class TestRuntime implements BoxelRuntime { + readonly mode = 'sandbox' as const; + disposed: RuntimeHandle[] = []; + + async loadBoxel() { + return typeHandle; + } + async createFromSerialized() { + return instanceHandle; + } + async describeBoxel(): Promise { + return { + protocolVersion: BOXEL_EXECUTION_PROTOCOL_VERSION, + requiredFeatures: [], + ref, + boxelKind: 'card', + ancestors: [], + fields: [], + formats: [], + presentation: { + displayName: 'Person', + headerColor: null, + prefersWideFormat: false, + }, + executionHints: { prefersFullSandbox: false }, + }; + } + async getFields() { + return []; + } + async getField() { + return undefined; + } + async buildRenderRecord(): Promise { + return { + protocolVersion: BOXEL_EXECUTION_PROTOCOL_VERSION, + boxel: await this.describeBoxel(), + instance: { id: 'https://example.test/Person/1', fields: [] }, + presentation: { + title: 'Ada', + summary: null, + thumbnailURL: null, + theme: null, + }, + }; + } + async serializeCard(): Promise { + return { + data: { + type: 'card', + id: 'https://example.test/Person/1', + attributes: {}, + meta: { adoptsFrom: ref }, + }, + }; + } + async serializeCardPatch() { + return { attributes: { name: 'Ada' } }; + } + async dispose(handle: RuntimeHandle) { + this.disposed.push(handle); + } +} + +module('Unit | Boxel runtime transport', function () { + test('a private MessageChannel carries only cloneable Boxel semantics', async function (assert) { + let channel = new MessageChannel(); + let runtime = new TestRuntime(); + let server = new SandboxBoxelRuntimeServer(channel.port2, runtime); + let client = new SandboxBoxelRuntimeClient(channel.port1); + + try { + assert.strictEqual(await client.loadBoxel(ref), typeHandle); + assert.strictEqual( + (await client.describeBoxel(typeHandle)).presentation.displayName, + 'Person', + ); + assert.strictEqual( + (await client.buildRenderRecord(instanceHandle)).presentation.title, + 'Ada', + ); + assert.deepEqual(await client.serializeCardPatch(instanceHandle, {}), { + attributes: { name: 'Ada' }, + }); + await client.dispose(instanceHandle); + assert.deepEqual(runtime.disposed, [instanceHandle]); + } finally { + client.destroy(); + server.destroy(); + } + }); + + test('serialized creation does not transfer a live instance', async function (assert) { + let channel = new MessageChannel(); + let runtime = new TestRuntime(); + let server = new SandboxBoxelRuntimeServer(channel.port2, runtime); + let client = new SandboxBoxelRuntimeClient(channel.port1); + let resource = { + type: 'card', + attributes: {}, + meta: { adoptsFrom: ref }, + } as LooseCardResource; + let document = { data: resource } as LooseSingleCardDocument; + try { + assert.strictEqual( + await client.createFromSerialized( + resource, + document, + undefined, + 'host-display', + ), + instanceHandle, + ); + } finally { + client.destroy(); + server.destroy(); + } + }); + + test('the client fails closed when its private peer speaks an incompatible protocol', async function (assert) { + let channel = new MessageChannel(); + let client = new SandboxBoxelRuntimeClient(channel.port1); + channel.port2.addEventListener('message', (event) => { + let request = event.data as { requestId?: unknown }; + channel.port2.postMessage({ + kind: 'boxel-runtime-response', + transportVersion: BOXEL_EXECUTION_TRANSPORT_VERSION + 1, + requestId: request.requestId, + ok: true, + value: typeHandle, + }); + }); + channel.port2.start(); + + try { + await assert.rejects( + client.loadBoxel(ref), + /Unsupported Boxel execution transport version/, + ); + await assert.rejects( + client.loadBoxel(ref), + /Sandbox runtime client is closed/, + ); + } finally { + client.destroy(); + channel.port2.close(); + } + }); + + test('the server ignores malformed envelopes without invoking runtime authority', async function (assert) { + let channel = new MessageChannel(); + let runtime = new TestRuntime(); + let server = new SandboxBoxelRuntimeServer(channel.port2, runtime); + let received = false; + channel.port1.addEventListener('message', () => (received = true)); + channel.port1.start(); + + try { + channel.port1.postMessage({ + kind: 'boxel-runtime-request', + transportVersion: BOXEL_EXECUTION_TRANSPORT_VERSION, + requestId: '', + operation: 'hostEscape', + args: [], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.false(received, 'no response or authority is produced'); + assert.deepEqual(runtime.disposed, [], 'the runtime was not invoked'); + } finally { + server.destroy(); + channel.port1.close(); + } + }); + + test('Sandbox render effects stay child-owned and preserve message order', async function (assert) { + let channel = new MessageChannel(); + let rendered: string[] = []; + let server = new SandboxRenderServer(channel.port2, { + async render(card, format) { + if (format === 'isolated') { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + rendered.push(`${card}:${format}`); + }, + clear() { + rendered.push('clear'); + }, + }); + let client = new SandboxRenderClient(channel.port1); + + try { + let first = client.render(instanceHandle, 'isolated'); + let second = client.render(instanceHandle, 'embedded'); + await Promise.all([first, second]); + await client.clear(); + assert.deepEqual(rendered, [ + `${instanceHandle}:isolated`, + `${instanceHandle}:embedded`, + 'clear', + ]); + } finally { + client.destroy(); + server.destroy(); + channel.port1.close(); + channel.port2.close(); + } + }); + + test('Sandbox render errors are projected without exposing child objects', async function (assert) { + let channel = new MessageChannel(); + let server = new SandboxRenderServer(channel.port2, { + render() { + let error = new Error('renderer rejected the format'); + Object.assign(error, { secret: globalThis }); + throw error; + }, + clear() {}, + }); + let client = new SandboxRenderClient(channel.port1); + + try { + await assert.rejects( + client.render(instanceHandle, 'isolated'), + /renderer rejected the format/, + ); + } finally { + client.destroy(); + server.destroy(); + channel.port1.close(); + channel.port2.close(); + } + }); +}); diff --git a/packages/host/tests/unit/lib/capsule-module-registration-test.ts b/packages/host/tests/unit/lib/capsule-module-registration-test.ts new file mode 100644 index 00000000000..b66d31afceb --- /dev/null +++ b/packages/host/tests/unit/lib/capsule-module-registration-test.ts @@ -0,0 +1,122 @@ +import 'ses'; + +import { module, test } from 'qunit'; + +import CapsuleModuleEvaluator, { + ensureCapsuleLockdown, +} from '@cardstack/host/lib/capsule-module-evaluator'; + +import { createCapsuleCompartment } from '../../../workers/capsule-module-registration-evaluator'; + +const moduleId = 'https://example.test/cards/article.js'; +const isolatedBlock = JSON.stringify([[['Append', 'Capsule isolated']], []]); +const fittedBlock = JSON.stringify([[['Append', 'Capsule fitted']], []]); + +function evaluatorFor(sources: Record) { + return new CapsuleModuleEvaluator('https://example.test/cards/', { + fetch: async (input) => { + let url = input instanceof Request ? input.url : String(input); + let source = sources[url]; + return source === undefined + ? new Response('not granted', { status: 403 }) + : new Response(source, { status: 200 }); + }, + resolveImport: (moduleIdentifier) => + moduleIdentifier.startsWith('@') + ? `https://packages.example/${moduleIdentifier}` + : moduleIdentifier, + }); +} + +module('Unit | Capsule module registration', function () { + test('AMD registration executes inside a compartment without browser authority', function (assert) { + ensureCapsuleLockdown(); + let capsule = createCapsuleCompartment('test-capsule', {}); + let registration = capsule.moduleEvaluator( + `define('fixture', ['exports'], function (exports) { + exports.answer = 42; + exports.ambient = { + window: typeof window, + document: typeof document, + fetch: typeof fetch + }; + });`, + 'https://example.test/fixture.js', + ); + let exports: Record = {}; + registration.implementation(exports); + assert.strictEqual(exports.answer, 42); + assert.deepEqual(exports.ambient, { + window: 'undefined', + document: 'undefined', + fetch: 'undefined', + }); + }); + + test('one retained Capsule evaluates an authored module once across formats', async function (assert) { + let source = ` + import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + import { setComponentTemplate } from '@ember/component'; + import { createTemplateFactory } from '@ember/template-factory'; + + if (import.meta.loader !== undefined) { + throw new Error('host Loader leaked through import.meta'); + } + export class ArticleCard extends CardDef {} + ArticleCard.isolated = class Isolated extends Component {}; + setComponentTemplate(createTemplateFactory({ + id: 'article-isolated', + block: ${JSON.stringify(isolatedBlock)}, + moduleName: ${JSON.stringify(moduleId)}, + isStrictMode: true, + }), ArticleCard.isolated); + ArticleCard.fitted = class Fitted extends Component {}; + setComponentTemplate(createTemplateFactory({ + id: 'article-fitted', + block: ${JSON.stringify(fittedBlock)}, + moduleName: ${JSON.stringify(moduleId)}, + isStrictMode: true, + }), ArticleCard.fitted); + `; + let evaluator = evaluatorFor({ [moduleId]: source }); + try { + let isolated = await evaluator.evaluateTemplate( + moduleId, + 'ArticleCard', + 'isolated', + ); + let fitted = await evaluator.evaluateTemplate( + moduleId, + 'ArticleCard', + 'fitted', + ); + let isolatedAgain = await evaluator.evaluateTemplate( + moduleId, + 'ArticleCard', + 'isolated', + ); + + assert.strictEqual( + isolated.templates[isolated.root]?.id, + 'article-isolated', + ); + assert.strictEqual(fitted.templates[fitted.root]?.id, 'article-fitted'); + assert.deepEqual(isolatedAgain, isolated); + assert.deepEqual(evaluator.stats, { + moduleEvaluations: 1, + moduleCacheHits: 2, + }); + assert.deepEqual(evaluator.ambientReport, { + window: 'undefined', + document: 'undefined', + localStorage: 'undefined', + fetch: 'undefined', + XMLHttpRequest: 'undefined', + URL: 'function', + URLSearchParams: 'function', + }); + } finally { + evaluator.destroy(); + } + }); +}); diff --git a/packages/host/tests/unit/services/surface-service-test.ts b/packages/host/tests/unit/services/surface-service-test.ts new file mode 100644 index 00000000000..89352ae222f --- /dev/null +++ b/packages/host/tests/unit/services/surface-service-test.ts @@ -0,0 +1,81 @@ +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +import { + SandboxSurfaceClient, + SandboxSurfaceServer, +} from '@cardstack/host/lib/sandbox-surface-transport'; +import { LocalSurfaceClient } from '@cardstack/host/lib/surface-client'; + +import type SurfaceService from '@cardstack/host/services/surface-service'; + +module('Unit | Service | surface-service', function (hooks) { + setupTest(hooks); + + test('local and sandbox clients target the same Host-owned surface', async function (assert) { + let service = this.owner.lookup( + 'service:surface-service', + ) as SurfaceService; + let handle = service.register({ + mode: 'capsule', + principal: 'test-user', + surfaceId: 'preview', + }); + let element = document.createElement('div'); + document.body.appendChild(element); + let detach = service.attach(handle, element); + let local = new LocalSurfaceClient(service, handle); + let channel = new MessageChannel(); + let sandbox = new SandboxSurfaceClient(channel.port1, handle); + let server = new SandboxSurfaceServer(channel.port2, service, handle); + channel.port1.start(); + channel.port2.start(); + + try { + await local.present({ headerColor: '#112233' }); + assert.strictEqual( + element.style.getPropertyValue('--boxel-surface-header-color'), + '#112233', + ); + + await sandbox.present({ containerBackground: '#f4f0e8' }); + assert.strictEqual( + element.style.getPropertyValue('--boxel-surface-container-background'), + '#f4f0e8', + ); + + await sandbox.layout({ heightMode: 'allocated', minimumHeight: 320 }); + assert.strictEqual(element.dataset.boxelSurfaceHeightMode, 'allocated'); + assert.strictEqual(element.style.minHeight, '320px'); + assert.deepEqual(service.identityFor(handle), { + mode: 'capsule', + principal: 'test-user', + surfaceId: 'preview', + }); + } finally { + sandbox.destroy(); + server.destroy(); + channel.port1.close(); + channel.port2.close(); + detach(); + service.release(handle); + element.remove(); + } + }); + + test('released handles fail closed', function (assert) { + let service = this.owner.lookup( + 'service:surface-service', + ) as SurfaceService; + let handle = service.register({ + mode: 'direct', + principal: 'host', + surfaceId: 'card', + }); + service.release(handle); + assert.throws( + () => service.present(handle, { headerColor: 'red' }), + /Unknown or released Surface handle/, + ); + }); +}); diff --git a/packages/host/workers/capsule-module-registration-evaluator.ts b/packages/host/workers/capsule-module-registration-evaluator.ts new file mode 100644 index 00000000000..26d7cd3fc35 --- /dev/null +++ b/packages/host/workers/capsule-module-registration-evaluator.ts @@ -0,0 +1,81 @@ +import type { + ModuleEvaluator, + ModuleRegistration, +} from '@cardstack/runtime-common'; + +export interface CapsuleCompartment { + compartment: Compartment; + moduleEvaluator: ModuleEvaluator; +} + +function escapeHtmlCommentTokensForSES(source: string): string { + // SES conservatively rejects the raw HTML-comment tokens anywhere in a + // script, including inside the serialized Glimmer template block emitted by + // Boxel's trusted transpiler. Escape one character in each token so normal + // JS strings, template strings, and regular expressions evaluate to the + // original value while SES never sees the ambiguous Annex B spelling. If a + // token somehow occurs as executable JS rather than generated literal data + // or a comment, the inserted hex escape is invalid in that position and the + // compartment still fails closed with a syntax error. + return source.split('').join('--\\x3e'); +} + +export function createCapsuleCompartment( + name: string, + globals: Record, +): CapsuleCompartment { + let activeModule: string | undefined; + let activeRegistration: ModuleRegistration | undefined; + + let define = harden( + (_moduleId: string, dependencyList: string[], implementation: Function) => { + if (!activeModule) { + throw new Error('Module registration attempted outside evaluation'); + } + if (activeRegistration) { + throw new Error(`Module ${activeModule} registered more than once`); + } + if ( + !Array.isArray(dependencyList) || + dependencyList.some((dependency) => typeof dependency !== 'string') || + typeof implementation !== 'function' + ) { + throw new Error(`Module ${activeModule} registered an invalid shape`); + } + activeRegistration = harden({ + dependencyList: [...dependencyList], + implementation, + }); + }, + ); + let compartment = new Compartment({ + name, + globals: { + ...globals, + define, + } as unknown as Map, + __options__: true, + }); + + let moduleEvaluator: ModuleEvaluator = (source, moduleIdentifier) => { + if (activeModule) { + throw new Error( + `Cannot evaluate ${moduleIdentifier} while ${activeModule} is registering`, + ); + } + activeModule = moduleIdentifier; + activeRegistration = undefined; + try { + compartment.evaluate(escapeHtmlCommentTokensForSES(source)); + if (!activeRegistration) { + throw new Error(`Module ${moduleIdentifier} did not register itself`); + } + return activeRegistration; + } finally { + activeModule = undefined; + activeRegistration = undefined; + } + }; + + return harden({ compartment, moduleEvaluator }); +} diff --git a/packages/runtime-common/boxel-execution-protocol.ts b/packages/runtime-common/boxel-execution-protocol.ts new file mode 100644 index 00000000000..fe07ae89092 --- /dev/null +++ b/packages/runtime-common/boxel-execution-protocol.ts @@ -0,0 +1,264 @@ +import type { CodeRef } from './code-ref.ts'; + +export const BOXEL_EXECUTION_PROTOCOL_VERSION = 1; +export const BOXEL_EXECUTION_TRANSPORT_VERSION = 1; +export const BOXEL_SURFACE_PROTOCOL_VERSION = 1; + +declare const runtimeHandleBrand: unique symbol; +declare const boxelTypeHandleBrand: unique symbol; +declare const boxelInstanceHandleBrand: unique symbol; + +export type RuntimeHandle = string & { + readonly [runtimeHandleBrand]: true; +}; + +declare const surfaceHandleBrand: unique symbol; +export type SurfaceHandle = string & { + readonly [surfaceHandleBrand]: true; +}; + +export type SurfaceHeightMode = 'intrinsic' | 'allocated'; + +export interface SurfacePresentation { + headerColor?: string | null; + containerBackground?: string | null; +} + +export interface SurfaceLayout { + heightMode: SurfaceHeightMode; + minimumHeight?: number; +} + +export interface SurfaceObservation { + width: number; + height: number; + visible: boolean; +} + +export type SurfaceCapabilityRequest = + | { + kind: 'boxel-surface-request'; + protocolVersion: number; + requestId: string; + operation: 'present'; + surface: SurfaceHandle; + presentation: SurfacePresentation; + } + | { + kind: 'boxel-surface-request'; + protocolVersion: number; + requestId: string; + operation: 'layout'; + surface: SurfaceHandle; + layout: SurfaceLayout; + }; + +export interface SurfaceCapabilityResponse { + kind: 'boxel-surface-response'; + protocolVersion: number; + requestId: string; + ok: boolean; + error?: string; +} + +export interface SurfaceObservationNotification { + kind: 'boxel-surface-observation'; + protocolVersion: number; + surface: SurfaceHandle; + observation: SurfaceObservation; +} +export type BoxelTypeHandle = RuntimeHandle & { + readonly [boxelTypeHandleBrand]: true; +}; +export type BoxelInstanceHandle = RuntimeHandle & { + readonly [boxelInstanceHandleBrand]: true; +}; + +export type BoxelRuntimeOperation = + | 'loadBoxel' + | 'createFromSerialized' + | 'describeBoxel' + | 'getFields' + | 'getField' + | 'buildRenderRecord' + | 'serializeCard' + | 'serializeCardPatch' + | 'dispose'; + +export interface BoxelRuntimeRequest { + kind: 'boxel-runtime-request'; + transportVersion: number; + requestId: string; + operation: BoxelRuntimeOperation; + args: JSONValue[]; +} + +export interface BoxelRuntimeSuccess { + kind: 'boxel-runtime-response'; + transportVersion: number; + requestId: string; + ok: true; + value: JSONValue; +} + +export interface BoxelRuntimeFailure { + kind: 'boxel-runtime-response'; + transportVersion: number; + requestId: string; + ok: false; + error: { + name: string; + message: string; + code?: string; + }; +} + +export type BoxelRuntimeResponse = BoxelRuntimeSuccess | BoxelRuntimeFailure; + +/** + * Rendering is a process-local effect, not part of the cloneable semantic + * BoxelRuntime API. The Host may select an opaque child-owned instance and a + * format, but the component definition and DOM remain in the Sandbox. + */ +export type SandboxRenderRequest = + | { + kind: 'boxel-sandbox-render-request'; + transportVersion: number; + requestId: string; + operation: 'render'; + card: BoxelInstanceHandle; + format: string; + } + | { + kind: 'boxel-sandbox-render-request'; + transportVersion: number; + requestId: string; + operation: 'clear'; + }; + +export type SandboxRenderResponse = + | { + kind: 'boxel-sandbox-render-response'; + transportVersion: number; + requestId: string; + ok: true; + } + | { + kind: 'boxel-sandbox-render-response'; + transportVersion: number; + requestId: string; + ok: false; + error: { + name: string; + message: string; + }; + }; + +export function assertBoxelExecutionTransportVersion(version: number): void { + if (version !== BOXEL_EXECUTION_TRANSPORT_VERSION) { + throw new Error( + `Unsupported Boxel execution transport version ${version}; expected ${BOXEL_EXECUTION_TRANSPORT_VERSION}`, + ); + } +} + +export type JSONPrimitive = string | number | boolean | null; +export type JSONValue = + | JSONPrimitive + | JSONValue[] + | { [key: string]: JSONValue }; + +export type BoxelKind = 'card' | 'field' | 'file'; + +/** + * Cloneable metadata for one field declared by a Boxel type. + * + * This deliberately contains no Field object, CardDef class, serializer, + * getter, or component definition. Those remain owned by the runtime that + * loaded the type. + */ +export interface FieldDescription { + fieldName: string; + fieldType: CodeRef; + kind: 'contains' | 'containsMany' | 'linksTo' | 'linksToMany'; + isComputed: boolean; +} + +/** + * A format is an open string so new authored formats do not require a + * protocol release. The provider identifies which executable owner supplies + * the format without transferring its component definition. + */ +export interface FormatDescription { + format: string; + provider: { + kind: 'authored' | 'trusted-base'; + ref: CodeRef; + }; +} + +export interface TypePresentation { + displayName: string; + headerColor: string | null; + prefersWideFormat: boolean; +} + +export interface BoxelDescription { + protocolVersion: number; + requiredFeatures: string[]; + ref: CodeRef; + boxelKind: BoxelKind; + ancestors: CodeRef[]; + fields: FieldDescription[]; + formats: FormatDescription[]; + presentation: TypePresentation; + executionHints: { + prefersFullSandbox: boolean; + }; +} + +/** + * A reference-shaped projection of a nested Boxel value. The referenced + * value is resolved through the canonical Store; no live instance crosses + * the runtime boundary. + */ +export interface BoxelValueReference { + $boxel: { + id: string | null; + type: CodeRef; + }; +} + +export interface ResolvedField { + fieldName: string; + fieldType: CodeRef; + kind: FieldDescription['kind']; + value: JSONValue | BoxelValueReference | BoxelValueReference[]; + resolvedConfiguration: JSONValue | null; + presentation: Record; + writable: boolean; +} + +export interface InstancePresentation { + title: string | null; + summary: string | null; + thumbnailURL: string | null; + theme: BoxelValueReference | null; +} + +/** + * The cloneable semantic input shared by rendering tiers. + * + * Direct rendering has an additional Host-local render slot. That slot is + * intentionally not represented here because Glimmer component definitions + * are executable objects and must remain with their execution owner. + */ +export interface BoxelRenderRecord { + protocolVersion: number; + boxel: BoxelDescription; + instance: { + id: string | null; + fields: ResolvedField[]; + }; + presentation: InstancePresentation; +} diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 232d3986cbf..b53a0189c33 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -1021,7 +1021,11 @@ export interface RealmCards { export { v4 as uuidv4 } from '@lukeed/uuid'; // isomorphic UUID's using Math.random import type { LocalPath } from './paths.ts'; import type { CardTypeFilter, Query, EveryFilter } from './query.ts'; -import { Loader } from './loader.ts'; +import { + Loader, + type ModuleEvaluator, + type ModuleRegistration, +} from './loader.ts'; export * from './frontmatter-parse.ts'; export * from './paths.ts'; export * from './realm-client.ts'; @@ -1031,6 +1035,7 @@ export * from './realm-index-card.ts'; export * from './cached-fetch.ts'; export * from './definition-lookup.ts'; export * from './definitions.ts'; +export * from './boxel-execution-protocol.ts'; export * from './searchable-routes.ts'; export * from './catalog.ts'; export * from './commands.ts'; @@ -1084,7 +1089,7 @@ export * from './github-submissions.ts'; export { getCreatedTime } from './file-meta.ts'; export { mergeRelationships } from './merge-relationships.ts'; export { makeLogDefinitions, logger, reapplyLogLevels } from './log.ts'; -export { Loader }; +export { Loader, type ModuleEvaluator, type ModuleRegistration }; export { fetchWithTransientRetry, isRetryableStatus, diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 98885716a76..84cd4081b35 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -106,6 +106,37 @@ export type RequestHandler = (req: Request) => Promise; type Fetch = typeof fetch; +export interface ModuleRegistration { + dependencyList: string[]; + implementation: Function; +} + +/** + * Evaluates only the AMD registration wrapper produced by transpileAmd. + * Trusted loaders use the current realm; Capsule loaders inject an evaluator + * whose `define` binding lives inside an SES Compartment. + */ +export type ModuleEvaluator = ( + source: string, + moduleIdentifier: string, +) => ModuleRegistration; + +function evaluateModuleInCurrentRealm( + source: string, + moduleIdentifier: string, +): ModuleRegistration { + let registration: ModuleRegistration | undefined; + let define = (_mid: string, dependencyList: string[], impl: Function) => { + registration = { dependencyList, implementation: impl }; + }; + void define; + eval(source); + if (!registration) { + throw new Error(`Module ${moduleIdentifier} did not register itself`); + } + return registration; +} + // Transient upstream statuses that we briefly retry on module-source fetches // (e.g. nginx returning 502/503/504 while the single-writer realm server is // momentarily stalled under reindex load — see CS-10820). Kept private so @@ -195,6 +226,7 @@ export class Loader { private modules = new Map(); private moduleShims = new Map>(); + private fetchedModuleShims = new Set(); private moduleCanonicalURLs = new Map(); // Cache the flattened dependency sets for evaluated modules. Once a module is // evaluated its consumedModules never change, so the result of @@ -224,6 +256,8 @@ export class Loader { // host injects a sleep that goes through the native (unblocked) // setTimeout so the retry actually fires. private retrySleep: ((ms: number) => Promise) | undefined; + private moduleEvaluator: ModuleEvaluator; + private moduleMeta: ((moduleIdentifier: string) => object) | undefined; constructor( fetch: Fetch, @@ -231,6 +265,8 @@ export class Loader { options?: { retrySleep?: (ms: number) => Promise; virtualNetwork?: VirtualNetwork; + moduleEvaluator?: ModuleEvaluator; + moduleMeta?: (moduleIdentifier: string) => object; }, ) { this.fetchImplementation = fetch; @@ -238,12 +274,16 @@ export class Loader { resolveImport ?? ((moduleIdentifier) => moduleIdentifier); this.retrySleep = options?.retrySleep; this.virtualNetwork = options?.virtualNetwork; + this.moduleEvaluator = + options?.moduleEvaluator ?? evaluateModuleInCurrentRealm; + this.moduleMeta = options?.moduleMeta; // Module caches are keyed by canonical RRI form (see moduleCacheKey), whose // relationship to a real URL is only stable between realm-mapping changes. // Discard the RRI-keyed caches whenever a mapping is added or removed so an // entry can't outlive the spelling it was keyed under. this.unsubscribeMappingChange = this.virtualNetwork?.onMappingChange(() => { this.modules.clear(); + this.fetchedModuleShims.clear(); this.moduleCanonicalURLs.clear(); this.knownDepsCache.clear(); }); @@ -266,6 +306,8 @@ export class Loader { let clone = new Loader(loader.fetchImplementation, loader.resolveImport, { retrySleep: loader.retrySleep, virtualNetwork: loader.virtualNetwork, + moduleEvaluator: loader.moduleEvaluator, + moduleMeta: loader.moduleMeta, }); for (let [moduleIdentifier, module] of loader.moduleShims) { clone.shimModule(moduleIdentifier, module); @@ -339,6 +381,95 @@ export class Loader { }); } + isModuleShimmed(moduleIdentifier: string): boolean { + try { + moduleIdentifier = this.resolveImport(moduleIdentifier); + return ( + this.moduleShims.has(moduleIdentifier) || + this.fetchedModuleShims.has(this.moduleCacheKey(moduleIdentifier)) + ); + } catch (error) { + if (error instanceof TypeError) { + return false; + } + throw error; + } + } + + /** + * Invalidates one module and only already-known reverse dependants. This is + * the primitive that lets a retained Capsule update authored code without + * discarding trusted modules or unrelated render islands. + */ + invalidateModule(moduleIdentifier: string): number { + let target: string; + try { + target = this.moduleCacheKey( + new URL(this.resolveImport(moduleIdentifier)).href, + ); + } catch (error) { + if (error instanceof TypeError) { + return 0; + } + throw error; + } + + let invalidated = new Set([target]); + let changed = true; + while (changed) { + changed = false; + for (let [key, module] of this.modules) { + if (invalidated.has(key)) { + continue; + } + for (let dependency of this.directModuleDependencies(module)) { + let dependencyKey = this.moduleCacheKey(dependency); + if (invalidated.has(dependencyKey)) { + invalidated.add(key); + changed = true; + break; + } + } + } + } + + let removed = 0; + for (let key of invalidated) { + if (this.modules.delete(key)) { + removed++; + } + this.moduleCanonicalURLs.delete(key); + this.fetchedModuleShims.delete(key); + } + this.knownDepsCache.clear(); + return removed; + } + + private directModuleDependencies(module: Module): string[] { + switch (module.state) { + case 'evaluated': + case 'preparing': + case 'broken': + return [...module.consumedModules]; + case 'registered': + return module.dependencyList.flatMap((entry) => + entry.type === 'dep' ? [entry.moduleURL.href] : [], + ); + case 'registered-completing-deps': + case 'registered-with-deps': + return module.dependencies.flatMap((entry) => + entry.type === 'dep' || entry.type === 'completing-dep' + ? [entry.moduleURL.href] + : [], + ); + case 'fetching': + return []; + default: + assertNever(module); + return []; + } + } + // Returns the transitive consumed modules of `moduleIdentifier` in // canonical identifier form: the registered realm-prefix (RRI) spelling // (e.g. `@cardstack/base/card-api`) when the virtual network has a matching @@ -1027,6 +1158,7 @@ export class Loader { this.setCanonicalModuleURL(moduleIdentifier, canonicalURL); if (loaded.type === 'shimmed') { + this.fetchedModuleShims.add(this.moduleCacheKey(moduleIdentifier)); this.captureIdentitiesOfModuleExports(loaded.module, moduleIdentifier); this.setModule(moduleIdentifier, { @@ -1052,26 +1184,30 @@ export class Loader { throw exception; } - type DefineFunc = (( - mid: string, - depList: string[], - impl: Function, - ) => void) & { - dependencyList: UnregisteredDep[]; - implementation: Function; - }; - - // this local is here for the evals to see. We're sticking the - // dependencyList and implementation onto the function itself because that's - // a convenient way to ensure that build tools like Rollup don't optimize it - // away. Rollup violates the JS spec by removing a local that's visible to `eval`. - let define = ((_mid: string, depList: string[], impl: Function) => { - define.dependencyList = depList.map((depId) => { - if (depId === 'exports') { - return { type: 'exports' }; - } else if (depId === '__import_meta__') { - return { type: '__import_meta__' }; - } else { + try { + // Append `sourceURL` so stack traces from inside the eval-ed AMD + // module name the original module URL instead of ``. + // Strip any CR/LF from the identifier so a maliciously-crafted + // module URL can't terminate the comment and inject extra source + // text into the eval-ed program. + let source = + src + '\n//# sourceURL=' + moduleIdentifier.replace(/[\r\n]/g, ''); + let registration = this.moduleEvaluator(source, moduleIdentifier); + if ( + !Array.isArray(registration.dependencyList) || + typeof registration.implementation !== 'function' + ) { + throw new Error( + `Module evaluator returned an invalid registration for ${moduleIdentifier}`, + ); + } + let dependencyList: UnregisteredDep[] = registration.dependencyList.map( + (depId): UnregisteredDep => { + if (depId === 'exports') { + return { type: 'exports' }; + } else if (depId === '__import_meta__') { + return { type: '__import_meta__' }; + } return { type: 'dep', moduleURL: new URL( @@ -1079,18 +1215,17 @@ export class Loader { new URL(moduleIdentifier), ), }; - } - }); - define.implementation = impl; - }) as DefineFunc; + }, + ); - try { - // Append `sourceURL` so stack traces from inside the eval-ed AMD - // module name the original module URL instead of ``. - // Strip any CR/LF from the identifier so a maliciously-crafted - // module URL can't terminate the comment and inject extra source - // text into the eval-ed program. - eval(src + '\n//# sourceURL=' + moduleIdentifier.replace(/[\r\n]/g, '')); + let registeredModule: RegisteredModule = { + state: 'registered', + dependencyList, + implementation: registration.implementation, + }; + this.setModule(moduleIdentifier, registeredModule); + module.deferred.fulfill(); + this.prefetchDependencies(registeredModule.dependencyList); } catch (exception) { this.setModule(moduleIdentifier, { state: 'broken', @@ -1100,16 +1235,6 @@ export class Loader { module.deferred.fulfill(); throw exception; } - - let registeredModule: RegisteredModule = { - state: 'registered', - dependencyList: define.dependencyList, - implementation: define.implementation, - }; - - this.setModule(moduleIdentifier, registeredModule); - module.deferred.fulfill(); - this.prefetchDependencies(registeredModule.dependencyList); } private evaluate(moduleIdentifier: string, module: EvaluatableModule): T { @@ -1141,12 +1266,17 @@ export class Loader { case 'exports': return privateModuleInstance; case '__import_meta__': - return { - url: - this.getCanonicalModuleURL(moduleIdentifier) ?? - moduleIdentifier, - loader: this, - }; + return this.moduleMeta + ? this.moduleMeta( + this.getCanonicalModuleURL(moduleIdentifier) ?? + moduleIdentifier, + ) + : { + url: + this.getCanonicalModuleURL(moduleIdentifier) ?? + moduleIdentifier, + loader: this, + }; case 'completing-dep': case 'dep': { let depModule = this.getModule(entry.moduleURL.href); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c238ef6ca67..d49f777a53c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1951,6 +1951,9 @@ importers: reactiveweb: specifier: 'catalog:' version: 1.9.3(@babel/core@7.29.7)(@ember/test-waiters@4.1.2)(@glimmer/component@2.1.1)(@glint/template@1.7.7) + ses: + specifier: 2.2.0 + version: 2.2.0 unique-names-generator: specifier: ^4.7.1 version: 4.7.1 @@ -2093,6 +2096,9 @@ importers: '@sqlite.org/sqlite-wasm': specifier: 'catalog:' version: 3.45.1-build1 + '@types/babel__core': + specifier: 'catalog:' + version: 7.20.5 '@types/flat': specifier: 'catalog:' version: 5.0.5 @@ -2282,6 +2288,9 @@ importers: ember-window-mock: specifier: ^1.0.1 version: 1.0.2(@glint/template@1.7.7) + es-module-lexer: + specifier: ^1.7.0 + version: 1.7.0 eslint: specifier: 'catalog:' version: 8.57.1 @@ -4476,6 +4485,15 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@endo/cache-map@1.1.0': + resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==} + + '@endo/env-options@1.1.11': + resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==} + + '@endo/immutable-arraybuffer@1.1.2': + resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==} + '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} engines: {node: '>=12'} @@ -13943,6 +13961,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + ses@2.2.0: + resolution: {integrity: sha512-mXZfO9O2bhE9E3INX5Dbqq+eo1Dj6Yeo+r7jas7GYTXRS6fzcZFYf0UbSHcXO7xJuPbhztBskSuSqJLMMoYMVQ==} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -17442,6 +17463,12 @@ snapshots: tslib: 2.8.1 optional: true + '@endo/cache-map@1.1.0': {} + + '@endo/env-options@1.1.11': {} + + '@endo/immutable-arraybuffer@1.1.2': {} + '@esbuild/aix-ppc64@0.19.12': optional: true @@ -29041,6 +29068,12 @@ snapshots: transitivePeerDependencies: - supports-color + ses@2.2.0: + dependencies: + '@endo/cache-map': 1.1.0 + '@endo/env-options': 1.1.11 + '@endo/immutable-arraybuffer': 1.1.2 + set-blocking@2.0.0: {} set-function-length@1.2.2: From 3d566253fc81b6a4558826b32747da46b4592703 Mon Sep 17 00:00:00 2001 From: Chris Tse <2302191+christse@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:04:56 -0400 Subject: [PATCH 03/91] Implement Phase 2 Boxel execution runtimes --- docs/boxel-execution-runtime-architecture.md | 50 ++ docs/boxel-execution-runtime-suite-harness.md | 294 +++++++++- .../components/boxel-execution-renderer.gts | 362 ++++++++++++ .../app/components/boxel-field-portal.gts | 125 +++++ .../app/components/boxel-sandbox-runtime.gts | 99 +++- .../host/app/components/card-renderer.gts | 28 +- .../app/components/head-format-preview.gts | 17 +- .../host/app/components/host-mode/card.gts | 1 + .../operator-mode/preview-panel/index.gts | 1 + .../components/operator-mode/stack-item.gts | 1 + .../app/components/trusted-base-format.gts | 156 ++++++ packages/host/app/config/environment.ts | 1 + .../host/app/lib/boxel-execution-engine.ts | 3 + .../host/app/lib/boxel-source-classifier.ts | 38 +- .../host/app/lib/capsule-boxel-runtime.ts | 126 ++++- packages/host/app/lib/capsule-component.ts | 30 +- packages/host/app/lib/capsule-css-policy.ts | 68 +++ .../host/app/lib/capsule-module-evaluator.ts | 13 +- packages/host/app/lib/direct-boxel-runtime.ts | 69 ++- .../app/lib/sandbox-boxel-runtime-client.ts | 6 +- .../app/lib/sandbox-boxel-runtime-server.ts | 2 + .../host/app/lib/sandbox-fetch-transport.ts | 242 ++++++++ .../host/app/lib/sandbox-module-authority.ts | 90 +++ packages/host/app/lib/sandbox-runtime-host.ts | 11 +- .../host/app/lib/sandbox-runtime-process.ts | 39 +- .../host/app/modifiers/boxel-sandbox-slot.ts | 29 + packages/host/app/services/boxel-execution.ts | 516 ++++++++++++++++++ packages/host/config/environment.js | 4 + .../integration/components/preview-test.gts | 42 ++ .../unit/lib/boxel-execution-engine-test.ts | 137 +++++ .../tests/unit/lib/capsule-css-policy-test.ts | 53 ++ .../lib/capsule-module-registration-test.ts | 167 ++++++ .../unit/lib/sandbox-fetch-transport-test.ts | 144 +++++ .../unit/lib/sandbox-runtime-process-test.ts | 58 ++ .../unit/services/boxel-execution-test.ts | 90 +++ .../boxel-execution-protocol.ts | 1 + 36 files changed, 3029 insertions(+), 84 deletions(-) create mode 100644 packages/host/app/components/boxel-execution-renderer.gts create mode 100644 packages/host/app/components/boxel-field-portal.gts create mode 100644 packages/host/app/components/trusted-base-format.gts create mode 100644 packages/host/app/lib/capsule-css-policy.ts create mode 100644 packages/host/app/lib/sandbox-fetch-transport.ts create mode 100644 packages/host/app/lib/sandbox-module-authority.ts create mode 100644 packages/host/app/modifiers/boxel-sandbox-slot.ts create mode 100644 packages/host/app/services/boxel-execution.ts create mode 100644 packages/host/tests/unit/lib/capsule-css-policy-test.ts create mode 100644 packages/host/tests/unit/lib/sandbox-fetch-transport-test.ts create mode 100644 packages/host/tests/unit/lib/sandbox-runtime-process-test.ts create mode 100644 packages/host/tests/unit/services/boxel-execution-test.ts diff --git a/docs/boxel-execution-runtime-architecture.md b/docs/boxel-execution-runtime-architecture.md index e129b6cc40c..a00e3b825f8 100644 --- a/docs/boxel-execution-runtime-architecture.md +++ b/docs/boxel-execution-runtime-architecture.md @@ -1526,6 +1526,56 @@ plus focused protocol, security, visual, and interaction tests. This is the first milestone called a useful new execution runtime; it is not yet full editing or HMR parity. +#### Phase 2 implementation ledger + +Phase 2 is implemented on `codex/boxel-execution-runtime-architecture` as one +vertical system, not as three independent prototypes: + +1. **Rendering is wired through the Host boundary.** Ordinary card render + entry points use `BoxelExecutionRenderer`, which asks `BoxelExecutionService` + for one execution session and mounts the selected Direct, Capsule, or + Sandbox-owned render slot. Indexed HTML is an inert, non-interactive loading + placeholder; it is never treated as the live renderer. +2. **Capsule is end to end.** Authored modules evaluate in retained SES + compartments without browser globals, produce cloneable type and instance + records, retain authored component state, and emit only explicitly granted + effects. Trusted Glimmer managers render the resulting component and scoped + styles in the Host document. +3. **Sandbox is end to end.** Browser-dependent formats run in a credentialless + iframe on the configured Sandbox origin. A transferred private + `MessageChannel` carries versioned render and Surface messages. Authored + module fetches are Host-brokered, GET-only, recursively admitted from literal + imports, bounded in size, and cancelled when the process is destroyed. +4. **Composition crosses mixed tiers.** `BoxelFieldPortal` is the Host-owned + invocation capability for nested authored FieldDefs, CardDefs, and FileDefs. + It recursively routes contained and linked Boxels through the same engine; + trusted Base fields stay native. Child process teardown does not invalidate + its Capsule parent or the Direct Base runtime. +5. **Format routing is explicit.** Isolated, embedded, and edit may use the + iframe Sandbox when the module requires browser authority. Fitted, atom, + head, and Markdown stay Capsule-rendered so compact composition never + creates inline iframes. Missing authored formats use a Host-owned Base + fallback over the same record instead of executing authored code Direct. +6. **One contract is exercised in all three tiers.** Acceptance coverage sends + the same resource and render request through Direct, Capsule, and Sandbox and + verifies the selected owner and cloneable render-record shape. Layered + integration coverage adds getters and `computeVia`, field configuration, + linked snapshots, scoped CSS, retained actions, nested mixed boundaries, + Surface presentation/layout/observation, and the seven format decisions. +7. **Security and lifecycle are evidence, not assumptions.** Tests cover ambient + browser denial in Capsule, stylesheet confinement, exact recursive Sandbox + module authority, header stripping, origin and protocol validation, + credentialless iframe construction, last-known-good retention, deterministic + Surface release, iframe removal, and runtime eviction. No runtime request or + record contains a live Store, Loader, service, CardDef instance, or Host DOM + object. + +`data-boxel-execution` on the mounted slot is a temporary development and test +diagnostic (`direct`, `capsule`, `sandbox`, or `prerender`), not a card-author +API. The runtime, not URL state or authored input, remains authoritative. Full +mutation parity, source volatility, and HMR intentionally begin in Phase 3 and +Phase 4. + ### Phase 3: make editing canonical across every adapter Implement the companion mutation protocol: write grants, diff --git a/docs/boxel-execution-runtime-suite-harness.md b/docs/boxel-execution-runtime-suite-harness.md index bf0975b6b78..8f4cd5dce7d 100644 --- a/docs/boxel-execution-runtime-suite-harness.md +++ b/docs/boxel-execution-runtime-suite-harness.md @@ -11,9 +11,10 @@ own pass criteria include "a passing placeholder, raw JSON dump, blank panel, or inert control is a failure even when no exception was thrown" — a claim that can only be settled by mounting the real Boxel and looking at what it produced. -**Location.** `https://realms-staging.stack.cards/ctse/execution-runtime-suite/` -(single realm for now; the Studio / Partner / Lab realm split arrives with use -case 5). +**Location.** Studio lane: +`https://realms-staging.stack.cards/ctse/execution-runtime-suite/`. +Partner lane: `https://realms-staging.stack.cards/ctse/execution-runtime-partner/` +(created for use case 5). The Lab lane arrives with use case 9. ## Built on the current API @@ -61,10 +62,24 @@ use-case-1/release-schema.ts the readable schema Release publishes to a Guide use-case-2/catalog-metadata.gts contained metadata + field configuration use-case-2/guided-card-info.gts CardInfoField subclass — the guide attachment use-case-2/release-guide.gts Guide card, cascade, bxl evaluation, GuidePanel +use-case-3/release-theme.gts poster tokens — CSSValueField + TypographyField +use-case-3/deluxe-release.gts DeluxeRelease extends Release; enums, images, tags +suite/interaction-script.gts InteractionStep fixture + step runner (added by case 4) +use-case-4/playback-group.ts playback-group registry — the surfacePlayback seam +use-case-4/track.gts Track + MusicPlayer +use-case-5/playlist.gts Playlist — relationship states, cross-realm, query fields +assets/ real image and audio files, typed by the realm from their bytes ``` -Nothing above `use-case-1/` knows anything about music releases. Cases 2–12 add -subject modules and fixture JSON; they do not add harness code. +Nothing above `use-case-1/` knows anything about music releases. + +Cases 2 and 3 added subject modules and fixture JSON only. **Case 4 broke +that**, and the earlier claim that it would hold through case 12 was wrong: +interactive evidence is a claim about what happens _after_ a user acts, and +nothing in the harness acted on anything. `suite/interaction-script.gts` and a +fifth lane on `SuiteCase` are the addition. Expect the same for a genuinely new +kind of evidence — the rule is that a case may not need a _bespoke case +component_, not that the vocabulary is frozen. ## Assertions are data, not code @@ -138,20 +153,48 @@ a per-install target, not a timer: the prerenderer blocks `setTimeout` outright, and `scheduleOnce` cannot dedupe an inline closure (realm lint enforces this — `ember/no-incorrect-calls-with-inline-anonymous-functions`). +### InteractionStep + +`{ action, target, expected, attribute, settleMs, claim }`. Actions: `click`, +`assert-text`, `assert-absent`, `assert-name`, `assert-attr`, `assert-count`, +`assert-advanced`, `set-range`. `target` is a CSS selector resolved **inside +the mounted subject**, never globally, and `assert-name` computes the +accessible name the way a control is actually announced (`aria-label`, then +`aria-labelledby`, then text). + +Two constraints, both load-bearing: + +- **Operator-triggered, never automatic.** A script runs when someone presses + Run. Auto-running would put a click — and the state change it causes — + inside the render that produced the element being clicked, which is the same + re-entrancy that overflowed the stack when the visual modifier took an object + argument. It would also be meaningless in the prerenderer, which never clicks + and blocks the timers a media element needs. +- **Pending until run.** A green row for a click nobody made would be worse + than no row. + +Steps run in order against one captured pane element, with `assert-advanced` +carrying a value forward between two readings. Known limit: the runner captures +**pane 0**, so a script drives the first declared expectation's mount. Asserting +against the embedded mount needs the runner to take a pane index. + ### ExpectedRoute — **the seam** `{ format, lane, expectedTier, capabilities[], observedTier, note }`. `lane` ∈ `official | studio | partner | lab`; `expectedTier` ∈ -`direct | capsule | sandbox`. `observedTier` is **unset today**, and every -route therefore reports `pending`. An unrouted boundary is never a pass — the -case's overall verdict is `pending` while any route lacks a trace. - -**What the runtime must supply.** For each mounted render slot, write the -selected tier back to the matching route's `observedTier`. The suite compares -and reports `pass` / `fail` with the mismatch spelled out. That is the entire -contract on the harness side; the runtime does not need to know about probes, -expectations, cases, or the command. +`direct | capsule | sandbox`. The Phase 2 Host now marks every live mount with +`data-boxel-execution='direct|capsule|sandbox'` (and marks an inert indexed-HTML +placeholder as `prerender`). The realm fixture still needs a small adapter that +copies the live value into `observedTier`; until that adapter is installed, its +persisted route rows correctly remain `pending`. An unrouted boundary is never +a pass — the case's overall verdict is `pending` while any route lacks a trace. + +**What the harness adapter must supply.** For each mounted render slot, observe +the Host diagnostic and write the selected tier back to the matching route's +`observedTier`. The suite compares and reports `pass` / `fail` with the mismatch +spelled out. That is the entire contract on the harness side; the execution +runtime does not know about probes, expectations, cases, or the command. The composition-suite document lists a wider trace record (source generation and hash, parent/child slot ids, Store revision, granted capabilities, mount @@ -274,7 +317,8 @@ seam, and it is now much smaller than a missing feature. ## Verdicts -A case's isolated view rolls up three lanes: semantic, visual, boundary. The +A case's isolated view rolls up four lanes: semantic, visual, interactive and +boundary. The overall verdict is `fail` if any lane has a failure, `pending` if any lane has a pending check, and `pass` only when every declared check is answered and green. `RunCaseCommand` evaluates the semantic probes headlessly and persists @@ -305,6 +349,226 @@ computed that read the clock would render differently in the indexer than in the browser and the suite would stop being deterministic. `catalogStamp` is an ordinary getter chained onto it. +## Use case 3 — what is currently covered + +`DeluxeRelease extends Release` and redeclares **one** format. `embedded`, +`fitted`, `atom`, `head` and `markdown` render through the parent's templates +against the child's data; `isolated` is the poster. Inherited computeds +recompute (`availabilityStatus`), and case 2's guide attachment survives the +subclass because `cardInfo` is inherited whole. + +Overriding a format requires a loose annotation **on the parent**: +`static isolated: BaseDefComponent = ReleaseIsolated`. Without it the parent's +static is inferred as the concrete `typeof ReleaseIsolated` and no subclass can +override it — annotating the child does not help. This is how Base's own cards +declare formats, so it is the idiom rather than a workaround. + +Three deliberately different enum shapes: + +| Field | Shape | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `stage` | static rich options with labels **and** icons | +| `pressingTier` | options resolved from owner state — `lathe` is offered only when `catalog.pressingRun` ≤ 500, and the label interpolates the run | +| `sleeveFinish` | per-use `enumConfig` narrowing the FieldDef's five finishes to three | + +The enum edge cases are real data rather than staged: `DeluxeRelease/corridor-tapes` +has `stage` unset, and `pressingTier` holding `lathe` — a value **outside its +own option list**, because the run grew past 500 after the value was entered. + +**Images are real files.** `assets/*.png` and `assets/*.webp` are pushed like +any other realm file; the realm types them as `PngDef` / `WebpDef` from the +bytes and extracts intrinsic dimensions. `linksTo(ImageDef)` therefore links to +a **file path**, not to an authored instance, and the subtype resolves +polymorphically — the field never names it. `coverImageKind` / +`sleeveImageKind` store what resolved so a probe can assert it without +mounting anything. + +The broken half of the URL/ImageDef pair is genuinely broken: +`insertImageURL` on `night-sessions` points at a file that is not there, and +`CoverArt` sets its `broken` flag from the `error` event rather than guessing +from the URL. A fallback that has never failed proves nothing. The fallback is +mounted only when a source was **declared** — an empty placeholder for artwork +that was never meant to exist reads as a defect, not as a fallback. + +Note that realm asset URLs are authenticated: an unauthenticated `curl` returns +401 while the host and prerenderer load the bytes. Verify artwork through the +indexed `url` / `width` / `contentType`, not through a raw fetch. + +**Theme and brand guide keep one canonical token source.** +`BrandGuide/night-sessions` is a local **instance** adopting the trusted +`BrandGuide` definition — not a subclass — linked through `cardInfo.theme`. It +computes functional CSS variables from its own palette: + +``` +--background: var(--sleeve-paper); --moon-gold: #F2C14E; +--foreground: var(--corridor-ink); --theme-heading-font-family: Playfair Display; +--primary: var(--moon-gold); +``` + +`ReleaseTheme` maps poster roles onto those tokens and copies none of them. +Its `allTokensDerived` computed reports `derived` / `has-literals`, so a +hard-coded colour — the thing that would survive a theme relink — is data a +probe can catch rather than something review has to notice. +`BrandGuide/night-sessions-rotated` is the relink target, linked live from +`corridor-tapes`. + +39 semantic probes, 4 visual expectations and 5 expected routes are authored. +The routes are all `pending` by design. + +## Use case 4 — what is currently covered + +`Track` links a real MP3 — pushed like any other file, typed `Mp3Def` from its +bytes, with `duration` extracted (18.207s) — plus the case-3 cover art **by +link**, not by copy. `MusicPlayer` holds `@tracked` play state, current time, +duration and volume; `Track` declares isolated, embedded, fitted (a bounded +mini-player that drops the transport entirely below the strip quantum, where no +control could be hit) and atom (a non-playing identity pill). + +`edit` is deliberately **not** overridden. The spec requires that editing Track +metadata does not duplicate audio bytes into card JSON, and the default editor +— which edits the _link_ — is the evidence. + +### Three silent failures a naive player has + +Corrected against two existing workspace realms rather than discovered by +testing, which is the cheaper order: + +1. **A native media element cannot authenticate.** `