--- name: surface description: >- How a downstream app consumes the shared @kolu/surface stack (@kolu/surface · surface-app · surface-remote · surface-mcp) — declaring a typed reactive surface, serving it, consuming it (SolidJS hooks or a CLI), and mirroring a remote surface over ssh. Grounded in the real consumers: kolu, drishti, odu, and the TUIs. Load when wiring a surface server/client/mirror, or reaching for makeSession / a link / the `.use()` hooks. CHANGING the framework is gated separately — see "Changing the framework" below (a paired, CI-green drishti PR pinned to final kolu HEAD). --- # Using @kolu/surface (downstream consumer guide) Declare a typed reactive surface once; the framework derives the Effect RPC group, wires the server, and binds the Solid client hooks. **This is the consumer guide** — *changing* the framework needs a paired drishti PR (see "Changing the framework" below). ## Who uses it — match the closest consumer, don't hand-roll | Consumer | Shape | Notable | | --- | --- | --- | | **kolu** (`client`+`server`) | one browser ⇄ one Node server, ONE ws | single-tier; two sibling surfaces; uses `surface`+`surface-app` only (**not** `-remote`) | | **drishti** (`srid/drishti`) | browser ⇄ Node ⇄ ssh agent mirror | the canonical twin; 3 workspaces (common/agent/app) | | **odu** (`juspay/odu`) | CI runner: stdio lanes → unix-socket fan-in → CLI/MCP | serve+consume+mirror over every transport at once; `surface-mcp` projection | | **kolu-cli** (`kolu ls/create/send/wait/…`) | one-shot CLI verbs, no browser | the primary CLI consumer: a PURE padi client — transport-blind `{client,dispose}`, unix-socket local, ssh remote (`--host`); **no `.use()` hooks**. `padi-tui`/`kaval-tui` are the same shape, retiring | ## The spine (real import paths) - **Define** — `defineSurface({cells,collections,streams,events,procedures})` (`@kolu/surface/define`). Many surfaces over one transport: `composeSurfaceContracts(map)` + sibling clients, never merged. Several flat GROUPS onto one wire (a sibling bundle beside a keyed map, hand-written root procedures, an unprefixed root surface): `mergeDisjointGroups({label: group})` — the one counted merge, which names the tag and both halves on a collision. Never a bare `RpcGroup.merge` (last-writer-wins, silent). - **Serve** — `implementSurface(surface, deps)` / `implementSurfaces(map, base, perKeyDeps)` (`@kolu/surface/server`) returns a supervised runtime `{ router, ctx, done, close }`. `router` is the FINAL top-level router — **serve it directly** (no `implement(surface.contract).router(...)` re-wrap; a second finalize double-prefixes `/surface/surface/…` and 404s). Observe `done` (owned-fault channel) and own `close` at teardown. Serve on a SHARED caller-owned publisher via `implementSurfaceOnPublisher` / `implementSurfacesOnPublisher`. A ROOTED bundle whose sibling set MOVES while it is served is `implementRootedSurfaces(core, base, coreDeps)` — `mount(key, surface, deps)` returns a `MountedSurface {key, surface, ctx, drop()}` (the registration carries its own undo), `group`/`handlers` are LIVE reads (read them as a pair in one turn), composition is INCREMENTAL (survivors keep handler identity, stores and sources), and a drop refuses at a record an open connection already captured — in-flight subscriptions included (`SurfaceSiblingDropped`). Live-ness is a property of the ROOTED door because a rootless wire's reserved probes target the FIRST sibling, which a moving roster can drop. - **Consume (SolidJS)** — `surfaceClient(surface, link)` / `surfaceClients(link, map)` (`@kolu/surface/solid`) → `client.cells.X.use({authority,initial,onError})`, `.collections.X.use({keys,onError})` then `.byKey(id)?.()` / `.keys()`, `.streams.X.use(inputFn,{onError})` with `.pending()`/`.error()`, `.events.X.use(inputFn,handler)`. - **Consume (CLI/TUI)** — no reactive hooks; raw awaited `conn.client.surface.(…)` + async-iterator iteration; a live board uses `mirrorRemoteSurface(spec, client, {collections,streams}, {log})` (`@kolu/surface/mirror`) into plain callbacks. ## Links (transport, swappable) `websocketLink(ws)` (`/links/websocket`) · `stdioLink` (`/links/stdio`) · `unixSocketLink({socketPath})` (`/links/unix-socket`) · `directLink(router)` (`/links/direct`, in-process identity, for tests). Serve side: `serveOverStdio` (`/peer-server`), `serveOverUnixSocket` (`/unix-socket`), and for browsers **`serveSurfaceApp`** (`@kolu/surface-app/serve`) — **don't hand-roll a listener**: it owns the whole order (origin gate → upgrade → stale-tab check → heartbeat enrolment → serve) plus the shell layers, the bind and the scope-registered teardown, and it reads the inbound frame cap from `RPC_MAX_FRAME_BYTES` so a consumer cannot undercut the wire. TLS (`tls`), an HTTP `middleware`, an app's own `routes` and a dist-less dev shell (`clientDist` is optional) are all options — kolu's own listener rides it. Reach past it to the granular `acceptSurfaceSocket` + `serveSurfaceSocket` only when the app picks WHICH runtime serves each upgrade — `?host=` per-host dispatch (drishti), the one decision the accept seam leaves at the call site. CLIs keep ONE transport-blind `Connection = {client, dispose}` so every command is written once across local vs ssh. `serveSurfaceApp` and `serveOverUnixSocket` take an optional per-face `expose` (`@kolu/surface/expose`: `exposeFace(surface, map)` / `exposeFaces` for a bundle / `exposeRootedFaces` for a root beside a bundle, the same `ExposeMap` `serveSurfaceAsMcp` takes and the same `classifyExpose` grammar), so a verb can be reachable over the unix socket and not to browsers. WHICH faces take a map, which gate themselves, and what a key grants are stated once — in that module's header. Read it there rather than from memory. ## Mirror a remote surface (drishti / pulam-web / odu) 1. **Dial the host** — `makeSession({connectOnce: sshConnector({host, binary, resolveDrvPath})})` (`@kolu/surface-remote`): long-lived, `nix copy`s the agent closure, runs ` --stdio` over ssh, reconnects. `buildRemotePool` fans out N hosts; one-shot CLIs use `dialAgentOnce` instead. 2. **Mirror inward** — `pumpRemoteSurface({source, session, makeSink, …})` (`-remote`) folds the remote agent's frames into a local `implementSurface` re-serve via a `SurfaceSink` (`makeSink`, `@kolu/surface/mirror`). The parent implements the *same* surface; a remotely-unobservable cell (e.g. connection state) is parent-authoritative. 3. **Re-serve** — the local re-served surface (a FINAL `implementSurface` router) served on `/rpc/ws`, accepted via `acceptSurfaceSocket` (`@kolu/surface-app/server`). Browsers connect with `connectSurface` (`@kolu/surface-app/solid`), which bundles socket + `websocketLink` + `surfaceClient` + a default-on liveness heartbeat. `connectSurfaces` is the multi-surface twin, and its `core: {surface, name}` slot carries an UNPREFIXED root beside the siblings — typed client, health enrolment under `name`, and the reserved probes addressed to the root's bare `surface/system/*`. A rooted app has no reason to hand-assemble the seam. A SIBLING ROSTER CHANGE IS A REDIAL, not an update — Effect RPC bakes the `RpcGroup` into the client at `openWireLink`, so new tags need a new wire at both ends; `conn.redial(surfaces)` owns it, re-using this connection's own options, keeping the root, and dialling the replacement BEFORE releasing the old wire. ## Gotchas (hard-won, all real) - **Procedures call off the FULL link, not the scoped client** — `surfaceClients` per-key `.rpc` is typed `unknown`; reach the root link for raw procedures. - **Raw streaming** — `unenrolledStreamCall(client.X, input, {signal, onRetry})` (`@kolu/surface/client`) carries the reconnect (`STREAM_RETRY`) context; a bare `client.X(…)` silently loses it. There is **no `stream` namespace** (the `streaming` skill owns the full rule). - **Consume streams fine-grained** — value-bearing → `.streams.use()` (replace-each-frame); delta-accumulate → `mirrorRemoteSurface` / `createSubscription`+`reduce`. Never coarse-read-and-copy: same-shape frames coalesce and the view freezes. - **Snapshot-then-deltas + fail-fast** — a cell always opens with a snapshot; `firstFrameOrThrow` (`@kolu/surface/first-frame`) treats an empty stream as a link failure, never a silent empty. - **Liveness is on by construction** — framework-reserved `surface.system.live`; `connectSurface` / `HostSession` / `createServerLifecycle` default their watchdog to it (`probeSurfaceLive`). Don't nominate your own probe unless you mean to. - **Version skew** — gate on `isContractVersionCompatible` (major.minor), never a string `==`. - **Nix-baked deps** — odu declares no `@kolu/*` in `package.json`; they're symlinked at Nix build (the bake-in-via-Nix convention). A bare `pnpm install` won't resolve them. ## Reference Runnable end-to-end: `packages/surface/example/` (its `mini-ci` stdio example is odu's seed). Full API + rationale: each package's `README.md`. Match the closest consumer above; don't reinvent a primitive the table already shows how to use. ## Changing the framework The consumer guide above is for using the stack. Changing it has two gates and one docs requirement: the drishti pair, the ODU-IMPACT verdict, and the Reference-page update below. ### Surface libraries are shared — mirror API changes to drishti `@kolu/surface` and its sibling libraries (`@kolu/surface-app`, `@kolu/surface-map`, `@kolu/surface-remote`, and — since drishti's durable agent landed (UW3) — `@kolu/surface-daemon` and `@kolu/surface-daemon-supervisor`) are not kolu-internal: they are consumed by [`drishti`](https://github.com/srid/drishti) as well as kolu. Drishti's daemon serves the frozen control-core fragment, rides `daemonHome`/`daemonMain`/`frontDaemonOverStdio`, converges via `convergeAdmit`/`probeDaemonIdentityFrom`, and imports `./upgrade-window.testlib` — the daemon packages are production surface for it, gate file included. Drishti's *client* reads the host map through `@kolu/surface-map`: `EntryState` and the `entries` collection back its host dot, its fleet card, its tab chip and its readiness gate across a dozen files. **How this list is kept honest:** it is the set of `@kolu/*` packages drishti IMPORTS FROM SOURCE, which is not the set it hydrates into `node_modules`. Re-derive it, never recall it — `grep -rhoE '"@kolu/[a-z-]+' packages/*/src --include=*.ts --include=*.tsx | sort -u` in a drishti checkout. Today that yields exactly the five above plus `@kolu/solid-pwa-install` (one import, `TabStrip.tsx`), while `@kolu/log` and `@kolu/shell-quote` are hydrated by Nix and imported by nothing — so they need no gate. `solid-pwa-install` is deliberately **outside** this rule (it is not part of the surface stack and its one consumer is a leaf hook); if it ever grows a second consumer or a contract-shaped API, it wants a gate of its own rather than a quiet addition to this one. **Any API-facing change** to `packages/surface/`, `packages/surface-app/`, `packages/surface-map/`, `packages/surface-remote/`, `packages/surface-daemon/`, or `packages/surface-daemon-supervisor/` — exported types, function signatures, the Effect RPC contract (the `defineSurface` spec, the wire tags an `RpcGroup` mints from it, a member's payload/success/error `Schema`), package `exports`, runtime behaviour a consumer depends on, or anything else that changes how a downstream consumer wires these packages — **requires a corresponding PR to [`github.com/srid/drishti`](https://github.com/srid/drishti) that updates drishti for the change and passes full CI.** - Open the drishti PR before (or alongside) merging the kolu change, and link the two PRs to each other. **Cut and keep the pair branch on CURRENT drishti master**: `git fetch origin` and branch off `origin/master` at open — never a stale local `master` — and merge `origin/master` into the pair branch again at **every** repin, confirming GitHub reports it `MERGEABLE` before calling it ready. The recorded failure: a pair PR (drishti#107) cut off a stale base reached the human CONFLICTING — unmergeable at the exact moment it was needed. - The drishti PR is not optional and "done" is not "done" until drishti's CI is green against the new surface API. - **The gate is only satisfied against the *final* kolu HEAD — re-validate it after any post-gate edit that touches the surface API.** A drishti CI green pinned to a pre-gauntlet SHA is **stale**: the review gauntlet (lens/debate/police) and CI fix-commits routinely reshape the shared API *after* the gate first passed — e.g. a gauntlet fix dropping a parameter (`pumpRemoteSurface`'s `makeSink` client arg) silently breaks drishti's consumer (`(_client, {seq})` → `({seq})`), which a gate validated at the earlier SHA never caught. Whenever the surface API moves after the gate was last green, bump the drishti PR's pin to the final kolu HEAD and re-confirm its CI is green there before calling it done. - Purely internal changes (private helpers, tests, comments, refactors with no observable API delta) do not need a drishti PR. - **The recorded near-miss this list's completeness exists to prevent:** [juspay/kolu#2129](https://github.com/juspay/kolu/pull/2129) added an arm to `@kolu/surface-map`'s client-facing `EntryState` — a change that fails drishti's build outright — while `surface-map` was absent from the package list above. The gate did not fire, and the pair PR ([srid/drishti#134](https://github.com/srid/drishti/pull/134)) was opened only because the author grepped drishti's tree by hand. A glob that is *nearly* right is worse than an obviously partial one: it reads as authoritative, so nobody re-derives it. Hence the grep above, and hence this note. ### The third consumer — odu rides a loose pin, and pays with a verdict [`odu`](https://github.com/juspay/odu) also consumes `@kolu/surface*`, but **deliberately loosely**: it pins kolu by npins and absorbs changes at its own pin bumps — there is **no per-PR odu pair** (three-repo lockstep was considered and rejected; drishti is the tight twin because it tracks kolu master continuously). The recorded failure this section exists to prevent: a whole campaign of surface changes accumulated with **zero odu-impact accounting**, so nobody knew what the eventual bump would break until it was scheduled. - **Every PR the drishti gate applies to also posts an ODU-IMPACT VERDICT in its body** — one of `breaks-at-bump` / `adoption-opportunity` / `none`, each grounded by an actual grep of odu's tree at its **pinned** kolu SHA for consumption of the changed exports (a verdict without the grep is hollow). - **Any non-`none` verdict is appended as a line item to the standing ledger — [odu#43](https://github.com/juspay/odu/issues/43)** — naming the kolu PR, the affected odu site, and the verdict. The ledger, not memory, is what the next pin bump drains. - **A `breaks-at-bump` item makes the next bump schedulable work, not someday-work** — the coordinator surfaces it; the bump PR's body checks off every drained line item. ### Surface public-API changes must update the Reference docs in the same PR The `@kolu/surface*` packages each have a **Reference page** in the four-quadrant Surface docs at `website/src/content/surface/`. Those pages are the austere, authoritative record of the public API — they are only trustworthy if they move with the code. **Any public-API change** to one of these packages — an exported type or function signature, the Effect RPC contract (the `defineSurface` spec, the wire tags an `RpcGroup` mints from it, a member's payload/success/error `Schema`), package `exports`, a member added to or removed from `defineSurface`, or any runtime behaviour a documented consumer wires against — **must update the matching Reference page in the same PR.** | Package | Reference page | | --- | --- | | `@kolu/surface` | `website/src/content/surface/ref-surface.mdx` | | `@kolu/surface-app` | `website/src/content/surface/ref-surface-app.mdx` | | `@kolu/surface-mcp` | `website/src/content/surface/ref-surface-mcp.mdx` | | `@kolu/surface-cli` | `website/src/content/surface/ref-surface-cli.mdx` | | `@kolu/surface-daemon` | `website/src/content/surface/ref-surface-daemon.mdx` | | `@kolu/surface-daemon-supervisor` | `website/src/content/surface/ref-surface-supervisor.mdx` | | `@kolu/surface-remote` | `website/src/content/surface/ref-surface-remote.mdx` | | `@kolu/surface-map` | `website/src/content/surface/ref-surface-map.mdx` | - Update the affected entries (names, signatures, tables, snippets) so the page describes the new surface, not the old one. If the change touches an invariant, update `surface-daemon-invariants.mdx` or the relevant Explanation page too. - "Done" is not done until the page matches the shipped API. This is the mirror, on the docs side, of the drishti-consumer gate above — the same "the record must not lie" discipline that the docs themselves are built on. - Purely internal changes (private helpers, tests, comments, refactors with no observable API delta) do not need a docs update. - A new export that a consumer is meant to use is an API change: add it to the Reference page. If the package's README changes shape, keep the ~15-line README (identity + install + one snippet + links into the quadrants) pointing at the Reference page rather than duplicating it.