Seam: the observation vocabulary
The contract between what the runtime emits and what a surface renders: one semantic fact about a
thread, in our words, and the frame that carries it on the follow. Glossary domain: Observation —
three words for three hops, the runtime emits events, the host’s mapper turns them into
observations, the follow carries frames; and one rule for every surface that looks: observation
reads, writes nothing, records nothing, controls nothing (#144). This is the wire between server and
browser (#112) and the thing a log record at debug describes.
The kinds are not restated here: ../reference/observations.md is
generated from the union, mapper, prompt route, browser adapter, every chunk name and its
browser consumer — one row per kind — and the docs gate diffs it against its generator.
Every fence on this page is the source, checked by test/docs-gate.test.ts. Refresh with
bun scripts/docs/refresh-fences.ts docs/seams/observation.md.
The contract
Section titled “The contract”The union. Closed, and with no “skip” member by design (module header, observation.ts lines 9–13):
an event the mapper has no word for becomes unmapped, so dropping is inexpressible.
/** */export const EnsoObservation = Type.Union([ EnsoTextDelta, EnsoThinkingDelta, EnsoToolCall, EnsoToolResultObservation, EnsoCustomEntry, EnsoPermissionMode, EnsoPermissionModeRejected, EnsoModelObservation, EnsoMessageStart, EnsoMessageEnd, EnsoSessionUsageObservation, EnsoTurnStart, EnsoTurnEnd, EnsoProviderRefused, EnsoProviderRetrying, EnsoProviderRetryEnded, EnsoQueue, EnsoPromptRejected, EnsoExtensionError, EnsoExtensionUiRequest, EnsoDialogSettled, EnsoRunEnded, EnsoSettled, EnsoUnmapped,])Each member is one Type.Object declared beside its kind literal in observation.ts — the schema
column of ../reference/observations.md names every one, so they are
not repeated here. The one shape shared across kinds is the accounting that rides on a text delta and a
message end:
/** * Token and cost accounting, present on `message_update`, assistant and tool messages. */export const EnsoUsage = Type.Object({ input: Type.Integer({ minimum: 0 }), output: Type.Integer({ minimum: 0 }), cacheRead: Type.Integer({ minimum: 0 }), cacheWrite: Type.Integer({ minimum: 0 }), totalCost: Type.Optional(Type.Number({ minimum: 0 })),})The frames. Every follow opens with exactly one snapshot, then observations each with a seq greater
than the last, and status frames (follow.ts header, lines 11–15):
export const EnsoFollowFrame = Type.Union([EnsoFollowSnapshot, EnsoFollowObservation, EnsoFollowStatus])/** * The server↔browser wire (#112): a thread's feed, as the follow route serves it. * * Every follow opens with exactly one `snapshot` — the thread as of `asOfSeq` — and then * carries only `observation` frames (each `seq` greater than the last) and `status` * frames (the thread's lifecycle as the server sees it). A reconnect is a new follow and * a new snapshot; there is no cursor to resume from, because the snapshot IS the resync * (deepseek-harness: one complete snapshot per generation, then only deltas). * * The payload is `EnsoObservation` — the same layer the event tails and the debug pane * reason in. AG-UI is not on this wire; a browser adapter derives it if a renderer wants it * (`docs/architecture.md`, decided constraints). */export const EnsoFollowSnapshot = Type.Object( { type: Type.Literal('snapshot'), threadId: Type.String(), /** The feed's position this snapshot is exact as of; every later observation's `seq` is greater. */ asOfSeq: Type.Integer({ minimum: 0 }), /** A pi session exists for the thread on this server. False: served cold from the file, or empty. */ live: Type.Boolean(), busy: Type.Boolean(), /** See `EnsoThreadSummary.generation`. */ generation: Type.Integer({ minimum: 0 }), permissionMode: EnsoPermissionModeState, /** The session's model and thinking level (#338 slice 2); `null` when the thread is not live — a stored thread does not say. */ model: Type.Union([EnsoModelState, Type.Null()]), /** * The tree pi reads and writes for this thread (#44), AS OF THIS SNAPSHOT — the live session's * own cwd; when the thread is not live, the stored session's (pi fixed it at creation); and * for a thread with neither, its project's (#395), where it will be built — or `''` when that * project has been removed, so it will be built nowhere (its prompt is refused). Not re-sent when a * session is built mid-follow: it cannot differ, since a new thread is built in exactly that * directory. What makes a wrong-project session visible. * * ⚠ Adding a field here is wire-breaking for an OPEN TAB: the schema is closed, so a page and * server of different versions reject each other's snapshots. The page says so and asks for a * reload (`follow-connection.ts`) rather than going quietly stale. */ cwd: Type.String(), /** * What the session has spent and how full its context is (#45), as of this snapshot — pi's * totals from the live session, or from the file when the thread is not live (then with no * `context`). Later changes arrive as `session-usage` observations, each the whole state. */ usage: EnsoSessionUsage, /** The branch as TanStack `UIMessage`s — `Unknown` because the shape is the renderer's, not enso's. */ messages: Type.Array(Type.Unknown()), /** Every question pi is blocked on right now, oldest first (#114). Empty when not live. */ openDialogs: Type.Array(EnsoOpenDialog), interrupted: Type.Optional(EnsoInterruptedTail), }, { additionalProperties: false },)export const EnsoFollowObservation = Type.Object( { type: Type.Literal('observation'), seq: Type.Integer({ minimum: 1 }), observation: EnsoObservation, }, { additionalProperties: false },)export const EnsoFollowStatus = Type.Object( { type: Type.Literal('status'), live: Type.Boolean(), busy: Type.Boolean(), generation: Type.Integer({ minimum: 0 }), }, { additionalProperties: false },)Who implements it, who consumes it
Section titled “Who implements it, who consumes it”Produced in two places. The mapper, mapPiEvent in packages/web/src/host/map-events.ts (below
the thread-runtime seam since L2, #162 workstream 4)
(line 229, top-level; not fenced here because the declaration is the whole 120-line switch): one
runtime event in, one observation out, never undefined (header lines 25–28). Its second parameter is
the delivery the host tagged (#96): replayed becomes origin: "session-start" on a ui request, live
becomes "run" (lines 225–227). The one kind the server synthesises itself is prompt-rejected: the
prompt route in packages/web/src/server/follow.ts announces it (line 256) when the runtime refuses
a prompt after its receipt went out. Both are grounded in the kinds table’s “Produced by” column.
Minted once. The registry, packages/web/src/server/thread-registry.ts, is the only place a seq
is assigned: publishObservation (line 354) increments the feed’s counter, records the observation
(observation-log.ts — info for the spine kinds, debug for every observation, trace with the
payload), then publishes. The feed subscribes the runtime once per live thread and routes every event
through the mapper (lines 332–334); the prompt route’s announce (line 516) enters the same counter.
Consumed by the follow route and the browser. followThread (server/follow.ts line 153) writes
one frame per feed frame — observation with its seq, status with live/busy/generation
(lines 160–170) — after a snapshot taken in the same tick as the subscription, exact as of asOfSeq
(lines 146–150, 178–189). In the browser packages/web/src/follow-connection.ts checks every frame
against EnsoFollowFrame with a validator compiled at module load (line 497, compiled at 71), pushes
the snapshot and status frames whole as chunks named by their type (lines 383–389), closes runs on
settled (line 396) and matches prompt-rejected to the prompt it
queued (line 400). packages/web/src/to-agui.ts is the adapter proper: observation → state-layer
chunk, run in the page, never serialised or stored (header lines 5–34).
The resync rule. Nothing is replayed by cursor. A reconnect opens a new follow and gets a new
snapshot (follow.ts header lines 13–15; server/follow.ts lines 175–186); when the last follower
leaves, the registry forgets the feed and seq restarts with the next follower, snapshot-first
(thread-registry.ts lines 563–571). asOfSeq is the watermark the snapshot is complete through.
What fences it
Section titled “What fences it”- The kinds oracle:
packages/core/src/__tests__/observation-kinds.ts(@enso/core/observation-kinds) derives everykindliteral from the union itself — never a hard-coded count, which drifted once (566e8b1). Two sweeps compare against it:host/__tests__/observation-sweep.test.tsproves every event the mapper handles, plus the server-synthesised kinds, validates againstEnsoObservationand covers every member;__tests__/to-agui.test.tsproves the adapter handles every member. The oracle lives in@enso/corebecause both sweeps sit in zones that may import onlycore(#173). - The generated inventory:
docs/reference/observations.mdmust equal its generator’s stdout (test/docs-gate.test.ts); a kind with no producer or consumer is printed as a finding, not omitted. - Closed frames: the three frame schemas are
additionalProperties: false(follow.tslines 40, 50, 61); the browser refuses a frame that failsEnsoFollowFramewith a problem record, not a crash (follow-connection.tslines 497–500). - The vendor gate (
test/vendor-gate.test.ts, #145): the mapper and the adapter import@enso/core, not the runtime’s package.
What crosses it that should not
Section titled “What crosses it that should not”EnsoQueue keeps the runtime’s SHAPE, under our words (#216). pi’s queue_update holds two
lists — steering messages, which enter at the next step boundary, and follow-ups, which enter after
the run settles — and observation.ts#EnsoQueue has the same two as interrupting / enqueued,
mapped in host/map-events.ts where pi’s field names are read and nowhere else. The inbound half is
EnsoPromptAdmission on the prompt body (thread-runtime.md → What crosses it that should not).
What still crosses is the SPLIT, and no consumer uses it: the router concatenates the two lists into
one ThreadStores.queue, and the abort receipt concatenates them too. It survives because a follower
renders what is waiting and “this one cuts in” is a different sentence from “this one waits its
turn”; a runtime with one queue class, or three, changes this shape and nothing above it.
messages on the snapshot is the state layer’s shape. EnsoFollowSnapshot.messages
(follow.ts line 56), carried as Type.Array(Type.Unknown()): not an observation but the storage
seam’s rebuild, built by server/transcript-to-messages.ts and put on the opening snapshot by the
follow route — see storage.md, and presentation.md → What crosses it that should not, which
prices it. It is on THIS wire, with Unknown in place of a shape, and no client reads it off the
snapshot today.
EnsoUnmapped.event is Type.Unknown() by decision, and is the one place a raw event crosses to
the browser whole.
The mapper reads the runtime’s field names — below the seam. map-events.ts declares
PiContentPart and PiToolDetails and reads event.assistantMessageEvent; since L2 (#162 workstream
4) it lives under host/, where the vendor’s names are allowed, and the host calls it at delivery so
ThreadRuntime.subscribe hands out observations. Nothing above the host reads an event.
Nothing of the browser’s old parallel vocabulary, since #162 workstream 5. The browser used to
route observations to its stores by a second set of names — eleven Enso.* custom events, enso.mode
for a permission-mode, enso.notify for an extension-ui-request, a forged notify for a
permission-mode-rejected — and a rename had two sides to land on. A CUSTOM chunk is now named by
EnsoObservation.kind and carries the observation whole (to-agui.ts#custom); the snapshot and status
frames cross whole, named by EnsoFollowFrame.type; the one chunk that is neither — the follow ending
under the page — is FOLLOW_ENDED_CUSTOM_EVENT, declared by its only reader, the router.
custom-event-names.ts is gone.
Ordered live crossings, then the deliberate holes, then the closed receipts: the bold lead of this
section is the architecture table’s leak column (scripts/docs/seams-table.ts), so a paragraph
saying “nothing” must not stand in front of one saying “something” (#216, #219).
Pivot cost
Section titled “Pivot cost”Replace the runtime and the vocabulary does not move: the union, the frames, the follow route, the
registry’s feed, the browser adapter, the kinds table and both sweeps stay. What changes is the
producer — the host’s map-events.ts learns the new loop’s event names, or maps its typed events
directly. The fake that drives the server (follow.test.ts in particular, which asserts frames one
by one: snapshot, seq, status, dialog outcomes) emits observations and proves the consumer side
today without a runtime at all.
Replace the state layer instead and the adapter goes: to-agui.ts and the
router — “a renderer that stops speaking AG-UI deletes it and the adapter” (to-agui.ts line 28).
The observation vocabulary survives whole, and so does every frame that carries one. The wire does
NOT: EnsoFollowSnapshot.messages is the state layer’s shape on this wire and
EnsoThreadHistory.messages is the same shape on the history route, both as Unknown — so both
change format with no schema diff to show it, which is why presentation.md → Pivot cost names
seven sites and not three.