Files
mvschwarz 15563c8909 Degraded reads, falsy defers, files/raw errors, and four stale doc lines
review.r2 contract audit — the Important and Minor findings that need no design
ruling (I4 excluded, PM's lane; drive C2 shipped separately).

I1 — a conforming degraded read looked healthy to watchSignal. The contract's
honest-degradation shape is HTTP 200 with { ok:false, degraded }; watchSignal
checked only HTTP status, so a degraded envelope did not throw and was delivered
as fresh data with degraded never reported. It now inspects ok:false at 200,
reports degraded, and does not deliver or advance the cursor on it. Control: a
200 ok:false with a moved marker reports degraded (not healthy) and its body is
not delivered.

M2 — deferWhileDirty dropped falsy deferred changes. null was both the sentinel
and a legal value, so deferring null/false/0/"" reported "deferred" and then the
!pending flush guard read the falsy value as nothing to do. A distinct hasPending
sentinel tracks whether a change is held separately from what it is. Control: all
four falsy values survive a defer-then-flush.

I6 — /api/files/raw violated the stable error contract. A bare empty 404 is not
4xx JSON { ok:false, error }, and a directory passed existsSync, got 200 headers,
then failed mid-stream — success committed before the read could fail. It now
statSync-and-isFile before writing any status, and errors are 4xx JSON. Control
boots the runtime, asserts JSON 404 for a missing path and refuses a directory
(discovered via files/tree) before any 200. The per-route discriminators for
read/raw are updated to stay mutually exclusive on raw's error naming "raw".

I5 — capture-target was live behaviour outside the closed shell protocol. The
shell accepts { t:"capture-target" } and replies capture-target-result, but the
protocol table (declared closed) had no row. Documented into the table per the
same-commit rule — my own recent work brought into the contract it lived outside.

M1 — two conformance rows contradicted their own normative sections and the
build: focus.md said `by` cannot be overridden while --identity-header ships
verified attribution; change-signal.md said no observe verb mints a marker while
focus and drive both honour ?since=. Both rows corrected to what ships.

362 -> 365 tests, 365/365 green.
2026-08-08 11:11:05 -07:00

12 KiB
Raw Permalink Blame History

The shell protocol — lifecycle, URL rules, and the postMessage API

contractVersion: 0.1

The shell (/, served from app/shell.html) is the hallway: a rail of surfaces on the left, a stage that hosts one surface at a time in an iframe, and an agent sidebar. This document is what a surface may rely on.

URL resolution (stable)

  • A row with path is loaded same-origin: {origin}{path}.
  • A row with url is loaded as given (external surfaces).
  • Deep link: ?s=<id> opens that surface directly and survives reload; the shell keeps the address bar in sync as the user switches surfaces.

Surface lifecycle (stable)

  • The stage iframe for a surface is created on first visit and kept alive when the user switches away — your surface keeps its state and its open connections while backgrounded.
  • Exception: rows with unmountOnBlur: true have their iframe removed on switch-away and recreated on return. Declare this if your surface holds an attachment that must not persist invisibly (terminal viewers are the canonical case: a hidden kept-alive client stays attached and fights the visible ones). Cost: your surface cold-starts on each return.
  • popout: true rows open in a new browser tab instead of the stage.
  • hideAgents: true suppresses the agent-sidebar toggle while your surface is active.

Liveness (stable behavior, cosmetic result)

The shell polls every surface URL with an opaque no-cors fetch on an interval and renders a rail dot: answered = live, failed = down. Your surface needs no code for this — just answer HTTP.

The postMessage API — the ONLY surface→shell channel (stable)

Send from inside your iframe with parent.postMessage(msg, "*"):

Message Effect
{ "t": "open-surface", "id": "<surface-id>" } The shell activates that surface (same as a rail click). Unknown ids are ignored.
{ "t": "open-agent" } The shell opens the agent sidebar.
{ "t": "open-agent", "seat": "<seat-name>" } Opens the sidebar with that roster seat highlighted. Unknown seats open the sidebar unhighlighted.
{ "t": "header", … } Declares the surface's half of the header. See The header splits in half below.
{ "t": "annotation-context", "id": "<opaque>" } Refines which board of annotations this surface is showing. Only for a surface holding several documents behind one id — see annotations.md. null or omitted returns to the default.
{ "t": "annotation-target", "frame": "<iframe element id>" } Names a same-origin nested iframe whose document annotations should anchor inside. For a surface that hosts its real content in a frame. null or omitted returns to the surface's own document.
{ "t": "open-markup" } Asks the shell to turn markup on ({"on": false} turns it off). A REQUEST, not a seizure: the shell owns the state and the header button stays the visible truth. For a surface that opens an artifact and knows the user is looking at something markable.
{ "t": "annotation-refresh" } "I moved something — re-measure." Repaints anchored marks without reloading the board or disturbing undo. Needed because an element that moves changes no size and no scroll, so nothing else fires.
{ "t": "capture-target", "id": "<opaque>", "root": "<kind>", "path": "<relative>" } Declares where captures from this surface should be written, as a bound root KIND plus a path relative to it (never an absolute directory — roots are kinds, per app-manifest.md). The shell forwards it to POST /api/capture-target, which resolves it against this box's bound roots and refuses anything that escapes, then replies to the sender with { "t": "capture-target-result", "id": "<echoed>", "ok": <bool>, "target": <resolved-or-null>, "error": <string-or-null> }. The id correlates concurrent declarations so a reply cannot be matched to the wrong request. See signal.js declareCaptureTarget.

This message set is closed: the shell ignores any other t value. New message types arrive as contract additions, never by surfaces inventing them — which is why each addition since v0.1 shipped was documented in this table in the same commit that implemented it, rather than being discovered later by reading the shell.

Every one of these is additive and optional. A surface that sends nothing at all is a correct surface: it gets a header from its manifest row, one board of annotations, and no loss of function.

The agent sidebar (what "seat" means in v0.1)

The contracted seat surface is deliberately small:

  • Seats are declared as chatSeats[] roster rows in the manifest.
  • A surface may summon the sidebar (open-agent above).

That is the whole contracted seat surface in v0.1. Typing into a seat and richer seat→surface channels exist in some runtimes as runtime-internal capability (see runtime-api.md) and are not contract. The reference runtime renders the roster and says honestly that no live seat backing is present.

The change-signal loop (how a surface stays fresh)

The canonical read loop for a live surface:

  1. Fetch what you need from the observe/files verbs (runtime-api.md).
  2. Open GET /api/events (SSE). On any message: re-fetch what you care about. Do not parse event payloads — the stream is a signal, not a data channel.
  3. If the stream errors or emits a degraded event: close it and fall back to polling, and keep retrying the stream when convenient.

Freshness bound (fallback polling): poll every 10–30 seconds while the stream is down; 15 seconds is the recommended default. Faster than 10s wastes work against a signal-driven runtime; slower than 30s reads as stale to a human watching the surface. When the stream reconnects, stop polling and re-fetch once.

This loop is the intended pattern; the fixture runtime fires the signal when fixture or manifest files change, so you can watch it work by editing a fixture file.

The shell chrome — what the shell draws and a surface does not (stable)

The shell provides two persistent frames and a surface renders inside them:

frame what it is who owns it
the launcher rail, left which surfaces exist, which is open the shell
the header, top what you are looking at, and studio-level controls the shell

A SURFACE DOES NOT DRAW ITS OWN TITLE BAR. The header already names the open surface and carries its hint. A surface that draws one too produces two headers stacked on one screen, and — worse than the wasted room — switching surfaces then flips between one header and two, so the studio reads as a set of pages rather than one application. That was measured across five apps before this rule was written: three drew their own, two did not.

The header splits in half (stable)

half belongs to holds
left the app its title, its crumb, its own actions
right the shell the agent sidebar opener, markup

A surface DECLARES what goes in its half; it never draws it. That distinction is the whole rule. Declaring means the shell renders it, so there is exactly one header on screen and the shell is never surprised by markup it did not write — which is what closes the two-headers problem in the contract rather than by asking every app to delete something.

Send over the existing surface→shell channel:

{ "t": "header",
  "title": "CUTDOWN",
  "crumb": "take-04.mov · 3 cuts",
  "actions": [ { "id": "export", "label": "EXPORT" },
               { "id": "grid", "label": "GRID", "on": true } ] }

Every field is optional. A surface that sends nothing keeps the shell's defaults — the surface's name and hint from its manifest row — so an existing surface needs no change and still gets a correct header.

Clicking a declared action posts { "t": "header-action", "id": "<id>" } back to the surface. The shell does not know what any action means and does not try: it renders a label and returns the click.

The header shows at most SIX actions, and says when it drops some (stable)

The left half shares one fixed bar with the title, the crumb and the shell's own half, so the list is bounded rather than unbounded — an unlimited one pushes into controls the surface does not own.

The bound is not the interesting part; being told about it is. This used to truncate in silence, so a surface declaring seven actions rendered six and had no way to discover it — not in a DOM it cannot see, not in any response. The seventh button simply never worked, which reads as a bug in the app.

Declaring more than six now produces a console warning naming the dropped ids and a message back to the surface:

{ "t": "header-overflow", "limit": 6, "declared": 8, "dropped": ["export", "share"] }

Two channels because they reach different people: the console reaches whoever is building the app, and the message reaches the app, which is the only party that can actually adapt — collapse the extras into a menu, or move them into the surface body.

A surface that ignores the message is unaffected beyond the drop itself.

Only the ACTIVE surface may write the header. A background surface is still running and still holds a live channel, so without that rule the header shows whichever surface spoke last rather than the one you are looking at. Actions are cleared when the shown surface changes — a stale button that still looks live is worse than an empty space.

This replaces the earlier rule that per-app actions stay in the surface body. The slot exists now, and it is the left half.

The rail collapses to a toolstrip and a surface must not assume its width. Anything that measures the viewport should measure it, not compute it from a constant — the surface's own width changes when the launcher collapses and when the agent sidebar opens.

Studio-level controls live in the header (stable)

The header is where controls that mean the same thing on every surface live — the agent sidebar opener, and markup. A surface does not reimplement them; the point of a studio-level control is that it is the same one everywhere. Two controls for one mode are two things that can disagree, and the one a surface did not write is the one that will be wrong.

{ "t": "markup", "on": <boolean> } is sent from the shell to the active surface when markup mode changes. It is advisory: a surface that ignores it is unaffected and still annotatable, because annotation is drawn by the shell over the stage rather than by the surface. A surface handles it only when it wants to participate — to render its own richer annotations, or to get its own controls out of the way.

Do not build a feature that requires this message. A shell feature that only works on surfaces which cooperate is a feature that does not work on the surfaces that already exist.

Annotation is the worked example of that rule. The shell draws marks on an overlay above the stage, so every surface is annotatable including ones written before the feature existed. A surface may refine which board it is showing with annotation-context when it holds several documents behind one id, but it is never asked to draw, store, or understand a mark. See annotations.md.

Surface expectations (visual / accessibility baseline)

v0.1 deliberately declares no required visual system and no enforced accessibility baseline — surfaces are sovereign inside their iframe. Two conventions are stated so surfaces cohere with the shell without a rulebook:

  • Declare color-scheme: dark (the shell is dark; a light-flashing iframe reads as broken).
  • Degraded and error states must be visible in the UI, not only in the console — when something is wrong it must look wrong.

A formal baseline (contrast, keyboard reachability, test convention) is a candidate for a later contract version, not an unstated v0.1 rule.