kolu
Docs

@kolu/surface reference

The core package. You declare a surface with defineSurface, serve it with implementSurface, and consume it with surfaceClient. Everything below is imported from a @kolu/surface/* subpath.

Defining a surface

defineSurface(spec) declares the whole reactive surface of an app at one site and returns a Surface<S>. Every schema on the spec is an Effect Schema — specifically a WireSchema<T> (Schema.Codec<T, unknown, never, never>): decode and encode must require no Effect services, because the wire has no environment to provide them. That is a type bound, not a convention.

Two spellings are laws for a wire field. An optional key is Schema.optionalKey — never Schema.optional, which round-trips an explicit undefined through null. A defaulted key is Schema.withDecodingDefaultKey — the encoded side stays T and the key stays omittable. The other Effect variants change the encoded bytes.

export const surface = defineSurface({
  cells: { load: { schema: Load, default: ZERO } },
  collections: { processes: { keySchema: Pid, schema: Proc } },
  streams: { nodeLog: { inputSchema: NodeId, outputSchema: LogFrame } },
  events: { autosave: { inputSchema: DocId, outputSchema: SavedAt } },
  procedures: { proc: { kill: { input: KillArgs, output: Killed } } },
});

A surface has five member kinds. The first four are the reactive primitives; procedures is the imperative escape hatch.

MemberQuestion it answersCardinalityServer sendsClient-mutableCurrent value
cells”What’s the current X?“one singletonsnapshot, then deltasyesyes
collections”current X for each key?“many, keyedper-key snapshot, then deltasyesyes (per key)
streams”live output for input I?“one per inputsnapshot, then deltasnoyes
events”has X happened?“occurrencesoccurrences, no snapshotnono (handler-based)
procedures”do this now”per calla return value

Cells, collections, and streams are state — a current value to render. An event is an occurrence — a handler fires per yield and there is no current value. Anything genuinely outside these shapes (bidirectional binary streams, custom retry plumbing) stays outside the surface as a raw Rpc in the host’s own group. For why these four and not one, see Why surfaces.

Declaring array identity — arrayKey

A CellSpec, a StreamSpec or a CollectionSpec may declare arrayKey?: string — the field that identifies an element of an array inside that member’s value (for a collection, inside one entry’s value).

It exists because solid-js/store’s reconcile cannot be told this anywhere else, and a framework merging arbitrary app payloads must not guess it (see how a push reconciles). Undeclared, a frame replaces every array element it merges, so a frame that repeats itself still notifies every reader of every array. Declared, an array whose elements carry the field is diffed by it: a repeated frame notifies nothing, and a reorder moves the objects rather than rewriting them.

streams: {
  page: { inputSchema: PageRequest, outputSchema: PageReading, arrayKey: "key" },
}

Name a field the schema types required and non-nullable. The merge decides keyed-versus-positional for a whole array from its first element’s value, so an optional field lets whichever row happens to be first decide for every other row in that frame. It cannot corrupt anything — an object survives a position only when its key matches the one already standing there, so a mismatch replaces rather than recycles — but it silently drops that frame back to the undeclared behaviour. The schema is where that is made unrepresentable; the merge sees values, not schemas.

Three things it is not:

  • Not a CollectionSpec.keySchema. That is the dictionary key a collection’s entries are filed under, on the wire and in the store; arrayKey names a field inside one value. A collection declares both, and which of its two deliveries applies the arrayKey is decided per call site: the per-key path (.use({ keys }), and any collection whose verbs omit deltas) merges an entry’s value through the same seam a cell’s goes through, so it honours the declaration; the batched deltas path replaces each named leaf whole rather than merging — a fold consumer may be holding that very object — so there is no merge there for a key to govern.
  • Not per-array. One field per member, because reconcile takes one, and it reaches every array in the value at every depth. Arrays whose elements do not carry the field are merged by POSITION — the declared reach of the key, not a fallback around it; that too is silent on a repeated frame. Name the field that identifies the arrays whose identity a consumer actually follows, and read the rest by value. It is also identity wherever it appears, not only inside arrays: a nested object that happens to carry the field is merged in place while the field reads the same, and replaced whole the moment it reads different.
  • Not a use-site option. It is declared on the spec and travels on the member’s descriptor, so no .use() call site can spell it, override it, or disagree with another call site about it.

The wire shape — one flat tag namespace

The Surface<S> value carries four fields:

FieldMeaning
groupthe flat Effect RpcGroup — one Rpc per member verb, tagged <tagPrefix><member>/<verb>
tagPrefix"surface/" standalone, "surface/<key>/" for a composed sibling — carried on the value, never assumed by a caller
specthe declaration verbatim; read member types back with SurfaceTypes<typeof surface.spec>
descriptorsthe Cell/Collection/Stream/Event handles, for the manual escape hatch

There is no router and no nesting on the wire — only string tags. A cell load mints surface/load/get; a procedure proc.kill mints surface/proc/kill. The surface/ root is what keeps a surface composable with hand-written raw RPC: a host merges surface.group with its own group and no host tag (terminal/create, git/status) can collide with a surface member.

The flatness is enforced, not assumed. RpcGroup.make is a plain Map.set — a colliding tag is silently overwritten — so defineSurface claims every tag as it walks, throws on a duplicate, and then asserts the assembled group’s size against the claim count. A name that is empty or contains / is refused outright, because stream "conn/get" and procedure conn.get would otherwise spell the same tag with different (member, verb) pairs.

group is deliberately typed with the erased Rpc.Any element: it is assembled from a runtime spec walk, so its type parameter carries nothing a caller could trust. Per-member precision lives in the spec-derived SurfaceRpcsFor<S> and its _tag projection SurfaceTags<S>, and in the bound client faces.

Declared errors — tagged classes, narrowed by _tag

A procedure may declare an error channelProcedureSpec.error, one Effect Schema, normally a Schema.Union of Schema.TaggedErrors. A declaring handler fails with an instance of a declared class; the caller receives it decoded, _tag and data intact, and narrows on the _tag. A typed domain error can no longer flatten to an opaque transport failure at a generic hop — through a SurfaceMap’s keyed proxy and mirrorRemoteSurface’s forwarders included.

This replaces the oRPC-era errors: ErrorMap keyed by magic code string. A procedure call is an Effect, so the declared union rides its error channel and you narrow it there:

client.procedures.lifecycle.recycleKaval().pipe(
  Effect.catchTag("KavalBusy", (e) => /* typed: e is the declared class */),
);

The channel is the declared union plus SurfaceCallFailure — Effect RPC’s RpcClientError and the framework’s own tagged vocabulary. That union is what makes the narrowing honest in both directions: a transport death is never mistaken for an application rejection, and once you have caught every declared tag the compiler still tells you the transport failure is unhandled. Write Effect.orDie if you mean “any failure here is fatal”, and say so on purpose.

An undeclared throw is a defect (Effect.die), not a failure: it crosses as an opaque defect, which remains the fail-fast crash-loudly channel. Declare exactly the failures a caller can act on; an undeclared spec resolves its error channel to Schema.Never (“declares no failures”).

The three reserved members

Every surface also carries three framework-reserved members at surface/system/{live,identity,clockNow} — injected by defineSurface, answered by the server, never declared or implemented by an app (see Liveness). They share one system namespace with any app-owned system procedures; only a duplicate tag is a boot-time collision.

system/clockNow (@kolu/surface/clock-now) returns the server’s own wall clock ({ epochMs }), so a consumer that binds a remote surface can measure the clock offset between the two hosts once at admit. probeSurfaceClockNow(client) performs the raw round-trip and fails with a typed ClockNowUnavailableError when the member is structurally absent — the check is inside the effect, so an absent member arrives on the channel the caller is already handling rather than throwing at the moment the probe was merely built. measureSurfaceClockOffset(client) returns the RTT-compensated offset (round(remoteEpochMs − (sentMs + rtt/2)), sampling local time at the round-trip midpoint so the one-way latency does not bias the result). Both take the value carrying .surface (a SurfaceFace or a bound client) and both are Effects: a caller that gives up cancels the in-flight probe by interrupting, so there is no signal to pass. probeSurfaceLive(client) and probeSurfaceIdentity(client) are the same shape.

Composing siblings

composeSurfaceContracts({ <key>: surface }) keys several standalone surfaces into one flat group and returns { group, siblings }. Composition is per-sibling tag prefixing (surface/<key>/<member>/<verb>), never a bare RpcGroup.merge: every surface carries the same three reserved system/* members, so merging two bare groups would collide them — silently, merge being last-writer-wins. Each siblings[key] is the same Surface shape as a standalone surface, carrying only its own members at its own tagPrefix, which is what lets handler binding and client dispatch be keyed by sibling without re-deriving the tag rule.

Declaring a client error policy — defineSurfaceWithPolicy

A member can carry an opaque, app-typed client error policy on its spec — a value the framework never interprets, only threads to the app’s registered interpreter when that member’s client subscription fails. The app owns both the policy union and the interpreter; @kolu/surface names neither.

defineSurfaceWithPolicy<TPolicy>()(spec) is the curried form of defineSurface that threads the app’s policy union TPolicy through the spec (the two-step call pins TPolicy explicitly while the spec keeps its precise const S inference). Plain defineSurface is the TPolicy = never case — its client slot is unfillable, so existing callers pay nothing.

  • ClientCellPolicy<TPolicy> — a cell’s client slot. The onError policy sits flat, intersected with an authority discriminant: { onError?: TPolicy } & ({ authority?: "server" } | { authority: "local"; coalesceMs?: number }). There is no initial — a local-authority cell seeds its store from the mandatory CellSpec.default, so “local-authority without a seed” is a type error, not a runtime surprise. The client runtime sources authority and coalesceMs from this declaration, so a bare .cells.X.use() inherits them (a use-site value still overrides); declaring authority: "local" on the spec is what makes a use-site bag empty.
  • ClientCollectionPolicy<TPolicy> — a collection’s client slot, onError only (a collection’s keys filter is per-site use-site wiring, not policy).
type Toast = { kind: "toast"; label: string };
const surface = defineSurfaceWithPolicy<Toast>()({
  cells: {
    preferences: {
      schema: PreferencesSchema,
      default: DEFAULT_PREFERENCES,
      client: { authority: "local", coalesceMs: 150,
                onError: { kind: "toast", label: "Preferences" } },
    },
  },
});

The interpreter is registered at the connection seamonClientError on connectSurfaces (@kolu/surface-app) or connectSurfaceMap (@kolu/surface-map), threaded inward to every buildSurfaceClient. The base interpreter is origin-free ((policy, err) => void); the { key } origin a keyed map needs is injected in @kolu/surface-map (see its reference). A member that declares client.onError but whose client was built with no interpreter makes buildSurfaceClient throw at construction — a declared policy can never route nowhere. The policy is interpreted per subscription: a deduped cell fires once, a keyed collection’s per-key subs each fire.

Serving a surface

implementSurface(surface, deps, opts?) wires every handler and returns a supervised SurfaceRuntime{ group, handlers, ctx, done, close }. Persistence and pub/sub are supplied as dependencies.

export const deps: ImplementSurfaceDeps<typeof surface.spec> = {
  cells: { load: { store: inMemoryStore(ZERO) } },
  collections: { processes: { readAll, upsert, remove } },
  streams: { nodeLog: { source } },
  procedures: {
    proc: {
      // A procedure returns an `Effect`. Its DECLARED failures are the spec's
      // `error` schema (none here); an undeclared throw stays a DEFECT.
      kill: ({ input, ctx }) =>
        Effect.sync(() => {
          ctx.collections.processes.remove(input.pid);
          return { ok: true };
        }),
    },
  },
};
const runtime = implementSurface(surface, deps);
// `runtime.group` + `runtime.handlers` go straight to a transport.
  • group — the surface’s own flat RpcGroup, by identity.

  • handlers — every bound member handler, keyed by full wire tag. A unary handler returns an Effect, a streaming one a Stream; both take the member’s decoded payload.

  • ctx — the typed cells/collections/events mutation map.

  • done — a Promise<void> that rejects on an owned runtime fault (a cell connector’s WIRING failing, or one of its finalizers) and resolves after a clean close(). A serving site MUST observe it and treat a rejection as fatal — log it whole, then exit through its shutdown path so a supervisor respawns. That is safe because every fallible periodic thing is deliberately off this channel:

    PathSettles done?Class
    Builder wiring throws (one-shot guard, builder body)rejectsruntime-fatal
    A push source’s install throwsrejectsruntime-fatal
    A poll source’s cadence install(tick) throwsrejectsruntime-fatal
    A scope finalizer throws during close()rejects (aggregated)runtime-fatal
    Poll T+0 seed read/publish failsnocell-local — logged, cadence held, retried
    Poll later tick read/publish failsnocell-local — log-skip-continue
    scan step throwsnocell-local — stop-hold
    computed recompute throws (later read)nocell-local — holds last, heals
    Interruption (close()), clean closeresolvesend-of-life

    An eager-seed throw (a non-poll derived cell’s pull, a compute cell’s bind) is not on this channel at all: it throws out of implementSurface as a boot crash.

    The CONSUME-side twin of this audit — every fiber mirrorRemoteSurface (@kolu/surface/mirror) starts, how it dies, and who is told — lives in that file’s header docblock. Neither audit may grow a MUTE row. mirrorRemoteSurface takes { signal?, onFault? }: onFault is a structured MirrorFault ({ label, err, scope: "member" | "key" }) that a serving consumer wires at ERROR level. A member fault also REJECTS the mirror’s done (that member is never re-subscribed, so the mirror is dead); a key fault is key-local and never reaches done, making onFault its only notice.

  • Nothing you hand the engine may throw. Every callback the reactive engine runs — an effect body, a source listener, a cell’s onWrite, a channel subscriber — executes inside the batch drain on the writer’s stack. The framework brackets each one: a throw is logged loudly and CONTAINED, siblings still run, and the write completes. This is deliberate (containThrow): the alternative is not a crash but a silent global freeze, since an exception mid-drain costs every remaining subscriber that frame.

  • close() — releases every owned source. Each connector runs as a FIBER in its own scope, so close() interrupts each one and awaits its exit — and a fiber’s exit already includes its scope’s finalizers, which is why “abort, then observe the settle, then dispose” is one await rather than three steps. Idempotent, and always resolves: a teardown fault is routed to done.

group + handlers is the pair every transport takesserveOverStdio, serveOverUnixSocket, serveSurfaceSocket, or directDispatch in-process. There is nothing to finalize and nothing to re-prefix at a mount site: a tag carries its own route. implementSurface asserts at boot that the handler key set equals the group’s tag set, so a route nobody answers (a 404 at the far end) and a handler nobody advertises are both boot crashes.

opts.identity is the server’s declared build triple — what the reserved system/identity member serves as its identified arm (the framework stamps startedAt and processId). Omit it and the surface is served anonymous, stamped with the same two.

Dependency shapes, per member:

  • cells: { <k>: { store, patch?, connect? } }store is a CellStore<T>. connect is a CellConnector<T>: (cell) => Effect<void, unknown, Scope>. It is an owned source of the runtime — a failure reaches done, and close() interrupts it, which releases whatever it acquired (Effect.acquireRelease / addFinalizer / forkScoped). There is no abort signal to thread and no disposer to return: cancellation is interruption, so an interrupted connector is end-of-life by construction and can never masquerade as a fault.

    Two rules for writing one. Install synchronously — the runtime forks the connector and Effect runs a sync step on the forking stack, so an acquireRelease whose acquire subscribes is live the instant implementSurface returns. And do not fork the publish: routing a frame through a fiber or a queue moves it behind an event published in the same tick, which is the delivery order kill.feature pins. Fork what must be concurrent, never the publish.

  • collections: { <k>: { readAll, upsert, remove, readOne?, holders? } } — persistence only; the surface wraps publish.

    holders?: CollectionHolders<K> — that is (key: K) => Effect<unknown, never, Scope> — is the last-reader seam. The wire already says when a reader OPENS a key — a per-key get IS a subscription, and readOne is where the server hears one arrive — but nothing said when the last reader LET GO, so a server that had to know inferred it from opens and aged the answer out. This publishes the fact it was inferring: a reader HOLDS key for the lifetime of the scope holders runs in, which is the get stream’s own scope. Fiber interruption is the release — the scope closes when the tab navigates, the socket drops, the runtime tears down, or a one-shot reader takes its frame and leaves.

    FactBehaviour
    Pull orderhold → channel subscribe → readOne snapshot, and that is contract, not accident: a readOne that ACTS on the hold (reading a body only a held path is read for) must find the hold already in place. Pinned by test.
    ReleaseAcquired in the returned stream’s scope — the same scope the channel subscription rides — so an interruption ANYWHERE, including between the hold and the subscribe or mid-snapshot, releases exactly once. A subscription nobody runs holds nothing: the stream is lazy.
    MultiplicityEvery get is its own stream, own scope, own hold. Two readers, two holds; the first to leave takes only its own. The framework REPORTS lifetimes and does not count — the refcount is the consumer’s.
    FailureThe error channel is never, so failure is unspellable. A defect propagates as a defect and kills that ONE subscription loudly — fail-fast, no degrade-to-unheld path.
    Scopeget only. keys and deltas are collection-wide streams; “who holds this key” has no meaning there, and folding a collection-wide hold in would be a second axis on the same knob.
    AbsentThe exact expression served today — not a wrapped equivalent. Zero overhead, zero behaviour delta, for every collection that never asked.

    Nothing crosses the wire: there is no verb, no member, no schema. A release verb a reader had to CALL would be a promise a closed tab cannot keep — the readers this is about are exactly the ones that vanish — so the transport is what is asked, because the transport is what notices.

  • streams: { <k>: { source } }source(input) returns an Effect Stream; cancellation is fiber interruption, so there is no AbortSignal to thread and none to forget. Or a declarative poll form { read, install, isEqual }. Supplying both is a type error.

  • procedures: { <ns>: { <verb>: ({ input, ctx }) => Effect } } — mutate through ctx.cells.X.set(…), ctx.collections.X.upsert(k, v), ctx.events.X.publish(input, payload) so the apply-and-publish chain stays single. A procedure that declares an error schema fails the Effect with an instance of a declared tagged class (Effect.fail(new KavalBusy({ … }))); an undeclared throw stays a defect, so reach for Effect.promise over Effect.tryPromise where no error is declared.

Stores and channels:

AdapterImportPurpose
inMemoryStore<T>(initial)@kolu/surface/serverin-memory cell store
inMemoryCollection<K, V>()@kolu/surface/serverin-memory additive collection deps ({ readAll, upsert, remove })
confStore<T>(conf, key)@kolu/surface/serverconf-backed persistent cell
inMemoryChannel<T>()@kolu/surface/serversingle-process broadcast
inMemoryChannelByName()@kolu/surface/serverthe channel factory form
inMemoryPublisher()@kolu/surface/servera name-keyed { publish, subscribe } publisher
publisherChannel<T>(publisher, name)@kolu/surface/serveradapt any name-keyed publisher to a typed Channel<T>

On a shared publisher — a distinct constructor, not a flag

The ordinary implementSurface(surface, deps) owns an internal inMemoryChannelByName() — self-contained in-process channels. A consumer that must serve on a shared, caller-owned publisher (whose cross-channel microtask order is load-bearing, and whose teardown the caller owns) reaches for implementSurfaceOnPublisher(surface, deps, channel) instead — a distinct ownership promise, never a mode flag. The runtime’s close() releases only what it minted, never the shared publisher.

implementSurfaces(surfaces, base, deps) serves N surfaces over one transport, keyed the same as the map, and likewise returns a supervised SurfacesRuntime. Its shared-publisher twin is implementSurfacesOnPublisher(surfaces, base, deps), where base carries the channel factory.

Composing a local runtime onto a re-served one

extendSurface(base, ext) composes a parent-local runtime (ext) onto a re-served base (a reServeSurface mirror of a remote agent) into one served surface — parent-owned additions stay causally separate from mirroring (post-commit observation, no second mirror). Both arguments are the structural ServedSurface<S>{ surface, handlers, done, close } (a reServeSurface result satisfies it directly; a local implementSurface runtime does once its surface descriptor is carried alongside). It returns ExtendedSurface<Base, Ext> — the same supervision contract over the merged surface Surface<ComposedSurfaceSpec<Base, Ext>> (a flat per-kind merge of the two specs). Because both sides’ handlers are already keyed by full wire tag, the merge is a handler-record union under one combined group — the tags are byte-identical to what each half served alone, on every transport rather than only in-process. A member-name collision between base and extension fails loud — the check is on the flat, per-name wire namespace across all kinds, so a base cell and an ext procedure of the same name collide too, and a duplicate bound tag is a second, independent crash.

Supervision routes through superviseTerminalSource: the base is the terminal driver (its mirror pump ends when the remote session is destroyed, the composite’s resolving edge), the local ext is passive (only its fault settles done before close). close tears the base down fully firstbase.close() awaited to completion (it aborts the base’s pump and releases the base runtime’s own sources) — then releases the local runtime. The combinator superviseTerminalSource(runtime, terminal) pairs a passive owned runtime with a terminal driver whose close: () => Promise<void> is the atomic teardown verb (tear down and settle); it is the framework home for the terminal-source teardown reServeSurface used to hand-roll, and the seam both reServeSurface (a sync-abort pump wrapped as one close) and extendSurface (a full re-served base runtime) route through. Both closes are always attempted in that order. One close failure is rethrown unchanged; several are surfaced together as an AggregateError.

The reactor (reactive bridge)

@kolu/surface/reactor is the one exit from a backend signal graph into the cell machinery — state is a signal, derived state a computed, and the wire is the signal boundary that snapshots and replays. The signals engine is Effect’s own Atom/AtomRegistry (effect/unstable/reactivity), wrapped here and lint-banned everywhere else, so this wrapper is the graph’s only exit by construction. No engine type reaches the public surface: a node’s level is a ReadonlyLevel<T>.value (the TRACKED read: reading it inside a derivation is depending on it) and .peek() (untracked).

ExportSignatureRole
source(install, initial?)(SourceInstall<T>, T?) → Source<T>an external PUSH input; install(emit) returns an uninstall fn, installed lazily on the first subscriber and torn down when the last leaves. initial seeds the level (else undefined until the first frame)
source({read, install})({read, install}) → PollSource<T>an external POLL input read on a caller-owned cadence (install(tick) schedules re-reads). Published via derived.cell / derived.collection, the bridge owns the policy: a read failure is cell-local at every tick, T+0 included — logged loudly, nothing published (no fabricated default), the cadence HELD and retried on the next tick or edge — plus a non-overlap (inFlight) guard. It never faults runtime.done: connectors and builders do that, periodic reads do not, so a poll read is never the right home for a deterministic boot defect. A consumer that must STOP on a permanent verdict (padi’s port sampler on an unreadable platform) owns that decision itself, at whichever tick produces it
everyMs(ms)(number) → (tick: () => void) => SourceCleanupthe cadence half of a poll source — the fixed-interval install closure, source({ read, install: everyMs(5_000) }). The interval is unref’d (a live sampler never keeps the event loop alive) and the returned cleanup clears it — the one home for the unref’d-interval hygiene every interval-driven poll source would otherwise re-spell
everyMsOr(ms, subscribe)(number, (tick) => () => void) → (tick: () => void) => SourceCleanupthe interval+edge cadence fuse: everyMs’s interval PLUS an edge subscribe(tick) (any change source — a reconnect/state feed, a config-changed hook), for a poll whose value can move faster than its coarse interval. Re-reads on the tick AND the instant subscribe fires; both the interval and the subscription tear down on cleanup — the one home for the fuse, previously re-spelled as app-local twins
source({ read, install, label })PollSourceOptions<T> → PollSource<T>the poll source. label names it in a loop-guard error — diagnostics only (there is no field that can silence the guard), and REQUIRED, because “name your poll sources” as a convention is a rule held by memory: the next fused cell is added by someone who has not read it, and the guard then fires anonymously on exactly the source nobody expected to loop. The guard: each read runs inside an AsyncLocalStorage context keyed to its source, so a change edge firing UNDER that context was caused by the read it is about to re-trigger; three consecutive self-caused ticks crash naming the source. A read that ANNOUNCES on the edge that triggers it is an unbounded cycle the reactor executes forever — it froze a production kolu (HTTP dead, SIGTERM ignored). Timer, I/O and other cells microtask bursts carry a different context and can never count, however fast or value-equal
assertCellConverges({ build, kick?, settleMs? })→ Promise<{reads, loop}>the authoring-time twin, from @kolu/surface/assert-cell-converges: drive a poll source, kick the act that would start a cycle, and assert it settles. Generalized from the incident regression, because the defect lived in the JOIN between a read and the edge it was fused with — a place neither module own unit tests can see
viewerAddressOf({ peerAddress, forwardedFor, hostAddresses })@kolu/surface/viewer-identitywhich machine a viewer is actually at, for an app behind a reverse proxy — the trust gate WHOLE, so the order it rests on is enforced here rather than by each consumer re-reading prose: a header on an untrusted connection is ignored entirely, and on a trusted one the LAST forwarded entry wins. Takes the header as a string or as a list a caller already split — node itself folds a repeated header into one string. Beside it: viewerIsOnHost, normalizeAddress, isTrustedLocalPeer. Pure and total — DNS and interface enumeration stay with the consumer
scan(src, initial, step)(Source<F>, S, (state, frame) => S) → ScanNode<S>fold a source’s occurrences into a level; each emission steps once. A step returning the prev reference (===) means “no change” — the level holds and nothing publishes
computed(fn)(() => T) → GraphNode<T>a derived value reading OTHER graph nodes — a private intermediate several wire members can share without any becoming a wire member. Glitch-free by the engine’s version-checked lazy pull; nothing to dispose
batch(fn)(() => T) → Tgroup several graph writes into ONE frame so derivations recompute once — the one knob an app reaches for to coalesce a multi-member burst of ctx writes. Every write the bridge itself makes already opens one (the engine has no implicit batch), so this only ever nests
derived.cell(node)(GraphNode<T>) → DerivedCell<T>publish a pre-built graph node (a scan, a computed) as a cell — the deps for an implementSurface cells.<key> slot
derived.cell(($) => …)(($: SiblingRead<S>) => T) → DerivedComputeCell<S, T>publish a SIBLING derivation as a cell; $ is the typed sibling-read face ($.someCell(): T, $.someCollection(): ReadonlyMap<K, T>). Reading a sibling is depending on it — the cell recomputes when a sibling it read changed
derived.collection(node)(GraphNode<ReadonlyMap<K,V>>) → DerivedCollectionpublish a keyed node (a poll source reading a whole Map, or a computed producing one) as a COLLECTION — the keyed-reconciler wire adapter: it diffs each frame against the last by the collection’s equals and drives the surface’s per-key publishers for exactly the changed + removed keys. Graph-owned (ctx upsert/remove throw)
reactiveFamily(opts)({members, attach, onEvict?}) → ReactiveFamily<K,S>a keyed family of member states as a graph SOURCE (SR9). Owns, once for every consumer: membership diff (attach entrants, detach + evict leavers), last-frame hold (a version-signal change edge, O(1) per frame), per-key disposal, and per-member error isolation. members is a Source<readonly K[]>; attach(key, set) subscribes one member’s state
derived.registry(family, resolve)(ReactiveFamily<K,S>, (key, state?) => Entry) → DerivedRegistry<K,Entry>the pull-face MapRegistry exit over a reactiveFamily (SR9) — resolves each member’s entry on demand from its cached state and fires subscribe on every family change. The split of the old registryFromFamily along the source/exit axis

The $ face is a plain mapped type over the surface spec (SiblingRead<S> in @kolu/surface/define), typed at the declaration site by the deps slot — no keyof union explosion. An authored sibling’s graph face is a mirror the bridge pokes post-spec-equals, riding a bridge-owned store wrapper both cell write paths pass through (so a missed poke is unwritable, not a rider held by tests); a derived sibling reads as its own computed, so every chain is a pure computed graph — glitch-free by the engine’s lazy pull even across a diamond, and a suppressed write never pokes. Declaration order is irrelevant: the boot walk builds every derived node before it seeds any, so a derived.cell may read a sibling derived.cell via $ whether declared before or after it — only a genuine cycle fails.

Guarantees a derived.cell rides:

  • Wire-read-only by construction. A derived.cell dep is branded; the boot walk crashes if that cell declares any write verb (set / patch / test__set). The graph is the member’s only writer.
  • Dedup at the member’s equals, once. A derived value flows through the member’s own equals → onWrite → store.set → bus.publish gate (the connect seam), so the wire dedup point is unchanged and an equals-equal recompute never crosses the wire. equals lives on the member’s spec.
  • Never fabricates. The cell seeds from its node’s current level by an eager pull at wiring (a throw at seed time is a boot crash) — truth, never a fabricated default served before the value exists. A compute cell’s node is built after every sibling mirror exists, then eager-pulled.
  • Compute error policy: log-skip-continue. A throw in a derived.cell(($) => …) recompute is logged and the last published value HELD; the next successful recompute heals it (contrast scan’s stop-hold, which carries state).
  • The publish is synchronous with the writer. A derived cell’s connect is a CellConnector whose acquire subscribes the node on the runtime’s own stack, and the graph notifies synchronously — so a graph write and an event published in the same tick reach a consumer in publish order. Teardown is the scope’s: close() interrupts the connector, which disposes the subscription and the backing node. A POLL-source cell suspends inside the connector for its T+0 seed (a FAILED seed is cell-local — the cell keeps serving its spec default and the cadence retries), and interruption becomes the AbortSignal its Promise-shaped read sees.

scan carries the stop-hold error law: a step that throws stops the derivation (its source subscription is disposed), holds the last value (never a fabricated reset), logs loudly, and latches stopped — a ReadonlyLevel<boolean> that is server-observable in phase 0: it gates the scan from stepping again and distinguishes a frozen derivation from a legitimately quiet one. Bridging stopped into the surface’s client-side liveness (folding it into a member’s liveWhen, which in phase 0 receives the serialized cell VALUE, not the signal) is a deliberate later phase — the “unhealthy-after-N-failures” question; phase 0 stops loudly and latches but does not yet flip client health. It never heals; recovery is a restart. Streams and events deliberately do not ride the graph — a signal conflates same-batch frames, and a stream must see every one.

derived.collection(node) publishes a keyed node as a collection through a keyed reconciler: it holds the current map, diffs each new frame against it by the collection’s value equals, and drives the surface’s own per-key upsert/remove publishers for exactly the changed and removed keys — so a poll-backed collection republishes only what moved. The graph is its one writer (the ctx upsert/remove throw). Two collection opt-ins ride with it:

  • CollectionSpec.equals?: (a, b) => boolean — per-key value equality, the collection sibling of CellSpec.equals. The reconciler’s diff predicate, declared once on the spec instead of hand-held at each write site. Omitted ⇒ every present key republishes each frame (an always-moving per-tick rate).
  • CollectionImplDeps.materializeSiblingView?: boolean — a PURE optimization behind readAll() for an authored collection whose readAll is an EXPENSIVE per-key fold. Set it and the $-sibling read returns a per-key cache maintained by the collection’s own upsert/remove writes, instead of re-folding the whole collection on every $-read — the fix for a derived member folding $.<coll>() on a firehose (O(M²)→O(M) recomputes). SAFE ONLY when every mutation flows through the ctx upsert/remove seam (the view’s single write path).

Consuming in SolidJS

surfaceClient(surface, transport) returns a client with a bound accessor per member. It replaces the source / mutate / keyToInput refs you would otherwise thread at every hook. transport is either a watchdog-backed LiveSignalHandle or a bare in-process SurfaceDispatch (see the caution below).

const app = surfaceClient(surface, dispatch);

const load = app.cells.load.use({ authority: "server" }); // Accessor<Load>
const procs = app.collections.processes.use(); // .byKey(id) / .keys()
const log = app.streams.nodeLog.use(() => nodeId, { onError }); // .pending() / .error()
app.events.autosave.use(() => docId, handler, { onError }); // returns nothing
Effect.runFork(app.procedures.proc.kill({ pid })); // an Effect, run at the UI edge
MemberHookReturns
cell (mutable).cells.X.use({ authority, initial, applyPatch, onError, coalesceMs })a UseCellResult { value, pending, error, set, patch, sub }value is the accessor, sub is the Subscription (its .updated(...) / .changed(...) is the change channel); mutate with .set(v) / .patch(p)
cell (read-only, verbs: ["get"]).cells.X.use({ onError? })a ReadOnlyUseCellResult { value, pending, error, sub } — NO set / patch, and the options accept ONLY onError (no authority: "local" branch — a get-only cell carries no wire mutation verb)
collection.collections.X.use({ keys?, onError? }).byKey(id)?.(), .keys(); mutate with .upsert(k,v) / .delete(k). A deltas-declaring collection’s WHOLE-collection .use() also carries .fold({ init, step }) — see the frame socket
stream.streams.X.use(inputFn, { onError? })a Subscription DIRECTLY — an accessor plus .pending(), .error(), .complete(), .updated(...) and .changed(...)
event.events.X.use(inputFn, handler, { onError?, signal? })nothing (no current value)
procedure.procedures.<ns>.<verb>(input)a ProcedureEffect<O, E>Effect<O, E | SurfaceCallFailure>, the declared union in a channel the compiler tracks

Every declared procedure is bound at .procedures.<ns>.<verb> and typed from its declaration — the typed dual of the reactive .use() primitives. The input is the Encoded side of the member’s schema (the face decodes at the edge, exactly where zod’s .parse used to run), while the result is the decoded side; a decoding default therefore stays omittable at the call site. Positions the client also holds — a cell’s set/patch payload, a collection key — stay decoded, because the client feeds them to the spec’s own patch and keys its own maps with them.

A call is a description, not a request already in flight: nothing is dispatched until the effect runs. That is what lets the call be part of a program rather than the whole of one — a command folding several members concurrently, a read that needs a deadline, a request superseded by the next one, anything that must die on Ctrl-C. Each of those is a combinator here. There is no AbortSignal to thread: cancellation is fiber interruption, and interrupting the caller tears the call’s own work down with it.

.rpc remains for the framework-reserved members (system.live / system.identity / system.clockNow, never in spec.procedures) and as the escape hatch for a member the bound shapes can’t model. It is the structural SurfaceFacerpc.surface.<member>.<verb>, typed unknown per leaf by design: per-member precision lives in the bound faces, and a second precise mapped type over the same spec is the union-budget blowup that split is there to avoid. It covers every member (cells’ set/patch, collections’ upsert/delete, the reserved system.*), not just the declared procedures — and like the bound face, a unary leaf there is an Effect. A declared procedure never needs it.

A cell’s authority is "server" (default — every push reconciles) or "local" (the local store wins after the first server yield). An inputFn that returns null pauses a stream or event.

How a push reconciles: by the member’s DECLARED key, else positionally — never by an inferred one. An undeclared merge into a bound store passes Solid’s reconcile(..., { key: null }). Solid’s own default is key: "id", which reads a field called id as an element’s IDENTITY and RECYCLES the previous row objects for the records it thinks matched — so on a payload whose elements have no top-level id (every element’s key reads undefined, and they all “match”) the object that held one record comes to hold another. A reader that goes by position never notices; a <For> keyed by reference, a per-row memo, or a component holding a row across frames sees a row whose identity and whose fields disagree. A framework merging arbitrary app payloads has no basis for inferring identity from a field name, so it does not: undeclared, an element is replaced rather than recycled.

That safety has a price, and the member is what pays it or opts out of it: replacement is total, so a frame that merely REPEATS what the store holds still replaces every element and notifies every reader of every array under it — a keyed <For> tears its DOM down on every frame, and every per-row binding re-runs for every row for a one-character change in one row. A cell or stream that declares arrayKey is merged by that field instead: a repeated frame notifies nothing, and a reorder MOVES the objects a keyed view is following. A deltas collection’s keyed dictionary is not merged here at all — its hook owns a store and writes the keys a frame NAMES, replacing each leaf whole so an entry a fold consumer is holding never mutates under it; a collection’s PER-KEY delivery does come through this merge, and honours the same declaration. surfaceClients(transport, map) splits one combined transport into a sibling client per surface — each built over a tag-scoped dispatch, so the face mints standalone tags and the wrapper splices the sibling key in, landing on exactly the tags implementSurfaces bound. surfaceClientsHealth(clients) AND-reduces the bundle into one fact.

A Subscription<T> (@kolu/surface/solid) is a SolidJS Accessor<T | undefined> with .pending(), .error(), .complete() (latches once the stream ends normally; the value is then frozen), .updated(handler) and .changed(handler). Where it sits differs by member: streams.X.use(...) returns the Subscription DIRECTLY (call it for the value). A mutable cells.X.use(...) returns a UseCellResult{ value, pending, error, set, patch, sub } — whose .value is the accessor and whose .sub is the Subscription, so the change channel is cells.X.use(...).sub.updated(...). A read-only cell (declared verbs: ["get"], e.g. a derived.cell) instead returns a ReadOnlyUseCellResult{ value, pending, error, sub } with NO set / patch — and its .use() accepts only onError (no authority: "local" option, since a get-only cell has no wire mutation verb to reconcile against). Collection subscriptions depend on the selected delivery path — and one thing differs beyond error()/pending()/keys(): under BATCHED delivery the per-key handle byKey(id) returns is assembled from the one stream’s signals, so it carries no .updated(). A consumer that wants “what changed” there asks fold instead, which hands over the wire’s whole frame rather than one key’s before/after. .updated() is the change half of an FRP Dynamic: subscribe to { prev, next } change pairs (CellChange<T>) under the change-iff-fired law — a first frame is a value, not a change and never fires; a reconnect snapshot equal (by value) to the last-seen frame never fires; a frame that differs fires exactly once, with prev the last-seen value. It returns a Dispose. Optional for the same reason as complete — a hand-assembled Subscription-shaped value may omit it; every subscription this package’s factories mint provides it.

.changed(handler) is the same law without the payload — same moments, same Dispose, no { prev, next }. The difference is not stylistic: .updated() must hand its handlers a SNAPSHOT, because the store adopts a frame and mutates it on the next write, so a retained pair would change out from under the consumer. That snapshot is two structuredClones of a whole frame, and it is the price of the payload. A consumer that only wants to know THAT something arrived — a frame counter, a re-ask trigger, an invalidation — was paying two deep clones of a page per keystroke for an integer; subscribing here, the clones do not happen at all. Reach for .updated() when you read prev/next, .changed() when you don’t. Neither is a raw arrival count: an equal reconnect snapshot is silent on both.

The frame socket — fold on a deltas collection

A collection that declares the deltas verb is served by ONE coalesced snapshot-then-delta stream, and the client applies each frame to a keyed store by writing exactly the keys the frame names. That store is the right answer for a consumer whose accumulator IS a keyed map. For a consumer whose accumulator is something else — an index, a patched document set, a running total — the frame itself is the answer, so the whole-collection .use() of a deltas collection carries one more member:

const view = client.collections.entries.use();

const index = view.fold({
  // A full-set frame: the wire's first frame, every reconnect snapshot, and the
  // seeding a fold registered mid-stream is given. Entries are the client store's
  // own values, in its arrival order — the same objects `byKey` reads.
  init: (entries) => buildIndex(entries),
  // One coalesced delta frame — the wire's own `{ upserts, removes }`, unchanged.
  step: (held, { upserts, removes }) => patch(held, { upserts, removes }),
});

index(); // Accessor<A | undefined>

init and step are the consumer’s, and A is inferred from them — the framework never sees inside the accumulator.

FactBehaviour
SnapshotRE-INITIALIZES every registered fold (acc = init(entries)), after the store applies it. First-connect and reconnect are the same event to a fold; it never classifies a frame.
Registering mid-streamSeeded SYNCHRONOUSLY from the held store, so arriving late is indistinguishable from a reconnect. Registered while pending(), the accessor reads undefined until the real snapshot lands.
What init is handedThe CLIENT STORE’s own values, in its arrival order — the same objects byKey reads — for every full-set frame, wire snapshot included. Never the wire’s entries directly: the store’s snapshot write is VALUE-diffed, so an entry a reconnect re-serialized unchanged keeps the object already held and the fresh copy is dropped. Seeding from the wire would hand a fold objects the store refused, and make what init receives depend on WHEN the fold registered.
undefinedMeans exactly one thing: there is no valid accumulator. That is the state before the first snapshot, and the state a throwing init / step returns the fold to until the next snapshot re-seeds it.
Removes of unknown keysDelivered VERBATIM. The server’s tick coalescer resolves an upsert-then-remove within one producer tick to a bare remove, so step MUST be total over a remove of a key it never saw. The framework does not filter it — that would be swallowing part of the frame.
Orderingassert keys → apply to the store → notify the folds → clear pending, all in one tick. A step that reads byKey sees state consistent with the frame it holds.
Aliasingstep receives the wire’s decoded frame objects and init receives the store’s held ones; the store adopts a value by REPLACEMENT and neither side ever mutates one, so a fold may retain either without cloning. A fold that keeps what init gave it holds the very objects byKey serves, not a second copy of them.
ErrorsThe batched stream’s error is collection-wide and terminal. Fold accessors keep their last value, frozen — exactly byKey’s behaviour — and the failure surfaces through the shared error() / onError / health enrolment. A throwing init / step is a different matter: it is contained to its own fold and reported loudly (never killing the stream, the store, or another fold), and it INVALIDATES that fold’s accumulator — later deltas have no valid base to land on, so the accessor returns to undefined rather than reading live while frozen forever.
Teardownfold() must be called under a reactive owner; the registration is dropped by that owner’s onCleanup. Called ownerless it THROWS, rather than minting an accumulator nothing can drop.

Where fold is spellable is the gate. It is typed only on the whole-collection .use() of a collection whose verbs declare deltas — the same verb gate unenrolledDeltas rides. A collection without the verb has no frames; so does a NARROWED .use({ keys }), which is served by the per-key keys+get path even on a deltas collection. In both, fold is a type error rather than an undefined at runtime.

useCollectionDeltas — the hook .use() drives for a deltas collection — is exported from @kolu/surface/solid for the deliberately un-enrolled reach: a consumer that takes .unenrolledDeltas (the #1591 health carve-out) hands the raw stream to it as source and gets the same store and the same fold, instead of hand-rolling a second one. Its result carries one member the bound .use() does not: .stream, the single batched stream’s own error() / pending() / complete(). That reach has no client.health() fact to join its feed to, so a dead feed surfaces there or nowhere; under the enrolled .use() the health fact owns that, and a parallel accessor would be one more thing to remember to read.

The face and the links meet at one erased, transport-neutral value. A link factory produces a SurfaceDispatch; the face consumes one, and nothing else crosses.

interface SurfaceDispatch {
  unary(tag: string, payload: unknown): Effect.Effect<unknown, unknown>;
  stream(tag: string, payload: unknown): Stream.Stream<unknown, unknown>;
}

Two rules ride on it. Payloads are the decoded side — the face decodes at its edge, so a wire dispatcher hands the value to Effect RPC (which encodes it) and directDispatch hands it straight to a handler, with no encode/decode pair to skip. And stream must not emit or end synchronously with the subscribe: a stream that reaches its typed end inside the subscribe evicts the keyed subscription cache’s slot while the slot is still being built, so N consumers of one member each open their own upstream subscription. Every wire dispatch satisfies this for free; directDispatch satisfies it deliberately, with a yield before it touches a handler.

ExportRole
SurfaceDispatchthe erased { unary, stream } seam both stages compile against
WireStatusconnecting · open · closed · retired — published from the PROTOCOL’s connect/disconnect, so open means “the protocol can send” (not merely “the socket fired open”); retired is terminal (the server closed a stale tab’s socket with 4001)
WatchableWire{ status, onStatus, forceReconnect } — what the half-open watchdog needs from a transport
WireTransport{ dispatch, wire }, minted together, so a watchdog provably probes the transport it reconnects
brandHalfOpenDispatch · isHalfOpenDispatchthe wire brand and its guard
brandDirectDispatch · isDirectDispatchthe in-process brand (its constant-true liveness is honest by construction)

A link maps a way of reaching the served group to a dispatch. Pick one per transport; see How to choose a link.

LinkImportReaches
websocketLink({ group, url, … })@kolu/surface/links/websocketa server over a WebSocket (the browser path)
stdioLink({ group, read, write, readiness })@kolu/surface/links/stdioa subprocess or ssh stdio pair — requires a readiness proof
socketDuplexLink({ group, socket, describe })@kolu/surface/links/stdioan already-connected LOCAL unix socket (you own the dial, and its close)
unixSocketLink({ group, socketPath })@kolu/surface/links/unix-socketa daemon on the same machine (it dials)
directDispatch(served)@kolu/surface/links/directthe in-process handlers, no wire (the identity element)

Every wire link takes the served surface’s group and is async — building a protocol and its fibers is an effect — returning a WireLink: { dispatch, dispose }, where dispose() releases the link’s scope (dial, ping and response fibers) and is idempotent. websocketLink additionally returns the wire half, so it satisfies WireTransport and can be handed whole to createLiveSignal.

websocketLink’s url thunk is re-evaluated on every (re)dial, and it is allowed to throw: a thunk that cannot answer yet (kolu’s reads the server’s process id out of live state) fails that dial as an ordinary SocketOpenError, so the reconnect schedule backs off and asks again. It does not kill the link — a link that stopped dialling would sit connecting forever with nothing logged.

A re-dial cycle fails what it orphaned

websocketLink counts its own open edges — the wire epoch — and every call it dispatches records the epoch it binds to: the current one if the wire is open (the request goes out on this socket), otherwise the next one (the write parks in the socket’s latch and flushes when that socket opens). When the wire reaches an epoch past a call’s binding epoch, the link fails that call with an RpcClientError naming the cycle — streams and unaries alike.

This is not belt-and-braces; it is the only cover for a whole class. Effect RPC registers a call’s entry once and never re-sends it across a re-dial, and an answer can only travel the socket its request went out on. When a run ends with a SocketOpenError — a pre-open dial failure, or the ping timeout on a socket that died silently — retryTransientErrors: true swallows it without broadcasting, so nothing fails: the protocol re-dials underneath and every call that socket carried parks forever over a wire that reports open. That is a laptop waking to a tab full of stale state with a green dot. Failing those calls turns the class back into the ordinary transport failure the retry fence already re-subscribes on; an unfenced caller gets a rejected promise instead of a dead one.

A call that has already failed (a live socket closing does broadcast) is unaffected: its guard goes with the attempt, and the fence’s re-subscribe binds to the new epoch — one re-drive, never two. A stream begun while the wire is down is likewise not failed by the open it was waiting for.

websocketLink().diagnostics

The link also hands back what a client-side diagnostic needs to prove a wire incident without the server’s log:

MemberAnswers
dialHistory()the last 20 dial attempts, oldest first — startedAt, openedAt?, endedAt?, closeCode? and a classification
epoch()how many times this wire has reached open

classification is "in-flight" (dialing or connected), "opened-then-closed", "terminal" (the close classifier retired the wire), or "ended-without-open" — a dial that failed before its socket opened. That last row is the reason the field exists: such an attempt is swallowed, publishes no status of its own, and never reaches the server at all, so before this nothing anywhere recorded that it happened. Timestamps are Date.now(), so they sit beside a server log.

It rides on the returned link object rather than on WatchableWire deliberately: WatchableWire is implemented by hand in tests and consumers, and widening it would break every such implementation for a fact only a real link can produce.

The shapes are exported from @kolu/surface/links/websocket: WebsocketLink (the returned link — a WireLink and a WireTransport, plus diagnostics), WireDiagnostics ({ dialHistory, epoch }), DialAttempt (one record) and DialClassification (the four verdicts above).

directDispatch(served) is shaped differently on purpose: it takes the served surface itself (anything carrying handlers — a SurfaceRuntime, a ServedSurface, a serveSurfaceMap result) and calls its handlers in-process. A tag it cannot resolve throws loudly rather than resolving undefined: an in-process dispatch can only 404 if the face and the runtime were built from different surfaces.

createLoopbackPair() (@kolu/surface/loopback) is not a link — it produces no dispatch. It is two cross-piped PassThrough ends you feed into one: pair.client to stdioLink, pair.server to serveOverStdio. The framing is the same ndjson the real stdio and socket legs carry, so a loopback round-trip is genuine end-to-end evidence without a subprocess. greetLoopback(pair, describe?) (same module) performs the readiness handshake below over that pair — the server half writes the banner, the client half reads it — and hands back the proof stdioLink needs; describe defaults to "loopback" and is the name the link puts in its transport errors. stallLoopback(pair) returns { stall, resume }, which cork and uncork BOTH directions: nothing is destroyed and no FIN is sent, so it injects only “the peer said nothing for a while” — the shape a starved but living peer has from the far end.

The stdio readiness gate — @kolu/surface/links/readiness

stdioLink takes a required readiness: StdioReadinessProof, and the only way to obtain one is to read a peer’s ready banner off the very stream you are about to attach to. Building an RPC protocol starts Effect RPC’s pinger; a peer from a previous protocol epoch accepts the pipe and then says nothing, so the pinger kills the link ~10s later — and a consumer that reconnects on that retries a peer that will never speak, forever. The gate makes that blind attach unrepresentable rather than merely discouraged.

The death itself now says which death it was, as data. Both tags isDeadTransportError unions carry a death field, under the same name so the union is uniformly branchable: SurfaceStdioTransportClosed takes a StdioTransportDeath"keepAliveUnanswered", "streamEnded" or "disposed" — and SurfaceTransportRetired a RetiredTransportDeath"retiredByServer" or "disposed". A consumer that puts words on a screen branches on the field rather than reading reason; a duplex leg reports an unanswered keep-alive as itself and explicitly declines to claim the peer exited. The distinction is diagnostic, not behavioural: the link is dead either way, and neverReconnect still halts on the first failure. Each field is an optional key, so a payload from a peer built before it existed still decodes — absent means “the producer did not classify”, never a defaulted guess.

ExportWhat it does
writeStdioReadiness(write, verdict)write the one banner line; the FIRST thing a stdio server puts on the wire
awaitStdioReadiness({ read, deadlineMs, describe })read it back and mint the proof; rejects StdioReadinessError otherwise
isStdioReadinessProof(v) / isStdioReadinessError(v)narrow a proof (un-forgeable WeakSet brand) or a failure
StdioReadinessVerdict{ verdict: "ready" } or { verdict: "refused", detail, anomaly }
STDIO_READINESS_KEY / STDIO_READINESS_VERSIONthe reserved banner key and its version

The wire form is exactly one newline-terminated JSON line, before any RPC frame: {"surfaceStdioGate":{"v":1,"verdict":"ready"}}. anomaly on a refusal is opaque to the framework — the app that writes it owns its shape, and the app that reads it decodes it with its own schema.

awaitStdioReadiness consumes exactly up to and including the first \n and unshifts every remaining byte back onto the stream, in paused mode, so a banner and a first frame arriving in one chunk cannot lose the frame. Its failures are classified — refused (the peer said no, and why), undecodable (the first line was not a banner, with a bounded excerpt), silent (the deadline passed), closed — never swallowed.

serveOverStdio writes the ready banner itself when the process is the agent (no transport override). Over an explicit transport the caller plays the server and greets with writeStdioReadiness — which is also what a daemon front does, after it has converged the daemon it is about to relay to.

socketDuplexLink deliberately takes no proof. It is a documented residual for a LOCAL unix rendezvous, where epoch safety is owed by the supervisor’s converge-before-dial discipline rather than by a banner over a pipe that never leaves the box; its Socket parameter is what keeps the ssh/subprocess leg from being spelled through it.

Matching serve-side entry points, all taking the same { group, handlers } pair: serveOverStdio (@kolu/surface/peer-server), serveOverUnixSocket (@kolu/surface/unix-socket), and serveSurfaceSocket (@kolu/surface-app/server) for browsers.

serveOverUnixSocket takes two arguments the others do not — log, and the optional per-face expose:

serveOverUnixSocket({ socketPath, group, handlers, log, expose? })
  : Promise<UnixSocketListener>

log is a @kolu/log Logger and is required — it is where the listener’s own lifetime is narrated, one structured line each with the socketPath in the context object:

EventLevelMessage
the path is bound and acceptinginfounix-socket listener bound
a post-listen fault on the server (nobody dialing, no handler in flight)errorunix-socket listener error (post-listen) — carries the whole err
an established peer’s socket errorsdebugunix-socket peer error
close() runs (first call only)infounix-socket listener closed

Required rather than optional because the alternative default is silence, and silence is what the incident behind this seam was made of: a daemon whose listening socket went comatose after a suspend/resume wrote not one error line for the rest of its life. BIND-TIME verdicts are not logged here — they are UnixSocketListener.outcome values, and the app-flavored advice for each (“pass --pty-host-socket”) belongs to the caller.

The same module exports isPrivateOwnedDir(dir): boolean — the three-check privacy predicate serveOverUnixSocket uses on the socket’s parent (lstat, never stat; a directory; current-uid owned with no group/other bits). It is public so a consumer that did not create the directory can make the same judgement; olai’s vault lock is the first. A failed lstat throws (ENOENT, EACCES) rather than collapsing to “not private”. Returns true on platforms without uid semantics.

Per-face expose (@kolu/surface/expose)

One surface is usually served by several faces at once, and they do not carry the same trust — a local CLI on a 0700 socket is not an anonymous tab someone left open. A face therefore takes its own default-deny allowlist, in the same ExposeMap shape @kolu/surface-mcp takes.

Three steps, and the middle one is the point:

import { exposeFace } from "@kolu/surface/expose";

// Bind the map to the surface it describes — where the surface is in scope, so
// `S` is inferred and a typo'd key is a TYPE error, not a boot crash.
// `notes` is a collection, `admin` a procedure NAMESPACE — a dotted key always
// names a procedure, never a primitive's write verb (which has no spelling).
const browser = exposeFace(surface, { notes: "resource" });
const cli = exposeFace(surface, { notes: "resource", "admin.wipe": "tool" });

// Hand each face its own.
serveSurfaceApp({ group, handlers, host, port, allowedOrigins, clientDist, expose: browser });
serveOverUnixSocket({ socketPath, group, handlers, log, expose: cli });

Membership is the allowlist. Omit expose and the face serves the whole surface, which is what every face did before this existed; pass one and every member it does not name is refused on that face while another face may still serve it.

ExportRole
ExposeMap<S> · ToolExposurethe map shape — the ONE home for it. @kolu/surface-mcp imports it from here rather than re-exporting or declaring its own
exposureMutates(exposure)does this exposure declare a MUTATING procedure? Conservative: anything that does not explicitly say mutates: false is mutating. One derivation rather than one per face, because it is a safety default — a read-only hint can let a host auto-execute a call unconfirmed, and a rule spelled twice can be relaxed in one place and noticed in neither
classifyExpose(spec, map, face?)the key grammar — what each key names, with the member spec it resolved carried on the entry. Every face reads a map through this one function, serveSurfaceAsMcp included, which is what makes “the same key means the same thing on every face” true rather than aspirational. face is the optional brand a non-framework face stamps on the refusal, at the throw — @kolu/surface-mcp passes its adapter name
exposeFace(surface, map)map + standalone surface → FaceExposure: the tag set one wire face serves, plus the universe of tags the surface it was built from advertises
exposeFaces(surfaces, maps)the sibling-bundle twin: one map per sibling, keyed the way the bundle is, over the standalone sibling surfaces. A fold of exposeFace’s step over composeSurfaceContracts — the same composition implementSurfaces binds against, so the scoped tags the gate names and the ones the runtime serves come from one walk. A sibling with no map is fully denied; a map for a sibling that does not EXIST is an ExposeMapError
restrictHandlers(group, handlers, exposure?)apply it — the filter the two faces above call for you. Total over an absent exposure: no policy returns the handlers unchanged (the same record, not a copy), so the “omit expose = ungated” rule has one implementation. Also where the reserved members are granted, and where the handler record is proved to be exactly the group’s
SurfaceMemberNotExposed · ExposeMapErrorthe per-request refusal, and every boot-time refusal this module raises — a map that names nothing on its surface, and an exposure that does not describe the group being served. face carries which door the consumer came through. SurfaceMemberNotExposed is matchable server-side; over the wire it arrives as an unstructured defect, so a client reads its message text
KeyGrants
"<ns>.<verb>" → a ToolExposure ("tool" / { tool: { mutates } })that one procedure — split at whichever dot the spec’s own procedures resolve at, uniquely: a member name may itself contain a . (only / is refused), so a key two procedures both answer to is refused as ambiguous rather than granted to the first. A malformed ToolExposure is refused here too, so the erased call path gets the same ExposeMapError the typed one gets at compile time. A wire face reads only the membership — mutates describes how an MCP host should present a call, not whether this face may make it. “A dot” means the ASCII full stop: a lookalike (a.b, a․b) reads as a primitive key and refuses as one
"<member>""resource"the read verbs that primitive declares (get / keys / deltas@kolu/surface/define’s READ_VERBS, which lives beside the verb vocabulary it partitions), never its writes (set / patch / upsert / delete / test__set) — the tab reads the cell, the socket writes it. A primitive that must be writable on a gated face therefore has no spelling today: "resource" is the read word and there is no second one yet (see the note at the end of this section)

The three reserved system/* members are always reachable on a gated face (per sibling, in a bundle) and are not spellable in a map: a client’s heartbeat rides system/live, its stale-tab handshake system/identity, and its clock offset system/clockNow. Gating those off would not restrict the face, it would break the link. That carve-out belongs to restrictHandlers, which asks isReservedSurfaceTag (@kolu/surface/define) of each tag as it applies the policy — so it holds for any FaceExposure, including one a consumer assembled itself, rather than only the ones the two constructors built.

A denied member answers — it does not vanish. Every tag stays bound, and the denied ones are replaced with a handler that raises SurfaceMemberNotExposed (surface: "<tag>" is not exposed on this face) as a per-request defect, in the shape its Rpc promised (a dying Stream for a streaming member). The class is matchable in-process; a client sees only the message text, because a defect crosses the wire unstructured. It is a defect and not a typed failure because there is no declared arm to fail into — a primitive’s Rpc has no error channel and a procedure’s defaults to Schema.Never — and because surfaceRpcServerLayer runs with disableFatalDefects, one member’s refusal never touches a sibling subscription on the same connection.

Every key resolves against the surface’s OWN members. A spec table is a plain object literal, so it inherits Object.prototype: toString, constructor, valueOf and their siblings name nothing and refuse like any other absent key, while a member a surface really names toString stays exposable on every face.

Everything is checked before anything is served, and nothing a map can get wrong is a no-op. exposeFace throws an ExposeMapError on a key that names nothing, a key two of the spec’s procedures answer to, a procedure exposed as "resource", a primitive exposed as a tool, a malformed ToolExposure (the { tool: … } wrapper forgotten), and a key that resolves but grants nothing — a write-only primitive, whose "resource" offers only read verbs the member does not declare. exposeFaces adds one: a map keyed by a sibling the bundle does not have (an omitted sibling is a deliberate full denial; a misspelled one is a policy nobody reads). restrictHandlers throws the same class if the exposure’s universe — the tags the surface it was built from advertises — is not exactly the set the group being served carries: a different surface and a merely partial one (a bundle exposed with a sibling left out, which would silently deny that sibling’s system/live heartbeat) are both caught, in both directions. It also throws if the exposure grants a tag the surface does not serve (reachable only on a hand-assembled FaceExposure, and otherwise ignored in silence), and if the handler record is not exactly the group’s, the same proof implementSurface owes when it builds one. None of them degrade to a quieter face, because a gate that matches nothing denies everything and still binds — the one failure mode that looks like success from outside. On serveOverUnixSocket this is the one thing that rejects: every transport verdict comes back as an outcome a host can survive, but there is no listener worth having on the far side of a gate that never took effect.

A member that must be writable on a gated face has no spelling today — a known gap, tracked with the feature itself (#2169).

surfaceRpcServerLayer — a member’s defect is not the wire’s

surfaceRpcServerLayer(group, handlers)
  : Layer<never, never, RpcServer.Protocol>

The RpcServer half every one of those serve sites is built from (@kolu/surface/server): the group’s bound handlers plus the one defect policy a multiplexed surface requires. A caller supplies only the protocol, serialization and transport layers below it, so the policy cannot differ between the unix-socket, websocket and stdio legs.

It serves with disableFatalDefects: true. Effect RPC’s default answers an unhandled handler defect with a connection-level Defect message, which fails every other in-flight request on that connection and closes the transport — so on a surface that multiplexes a dozen members over one socket, one member’s bad minute is a total blackout. With this on, the defect is delivered as that request’s own exit: the subscriber that asked sees it, loudly, and every sibling subscription keeps flowing. Nothing is swallowed — the server still reports the cause through Effect’s logger, and the failing member still fails.

Reach for it only when hand-building a serve path; the three entry points above already go through it.

serveOverUnixSocket’s listener close() runs the whole ordered teardown: it stops accepting, disconnects every established peer, and removes the socket file. Each severed connection then settles its own serve in the event-loop turns behind the call — in-flight subscriptions finalize and their timers clear — without close() waiting on it. A closed host cannot go on serving live peers; a wedged half-open peer is destroyed without drain negotiation (its unflushed frames are dropped — a host that closed is closed). Closing one listener never touches another listener’s connections. close() stays synchronous, idempotent, and safe from a process.on("exit") handler.

serveOverStdio lifetime — selected by construction

serveOverStdio({ group, handlers }) never rejects: it resolves a discriminated { reason: "end" } | { reason: "error", error } when the read stream ends. The reason classification is arm-independent: clean teardown reads as "end" from both directions — a clean EOF (the peer closed its end) and a benign write death (EPIPE/ERR_STREAM_DESTROYED, the peer’s read side vanishing mid-push) — while "error" is reserved for a genuinely abnormal death (a read error, a decode failure, a non-benign write failure). What happens after the resolve is selected by the same construction that picks the transport:

  • No transport override (the default process.stdin/stdout — the subprocess-agent case): the process IS the agent, and the framework owns its lifetime. After the returned promise settles — every synchronous post-settle continuation (an await-site’s dispose() + log) still runs — the framework exits the process: 0 for "end", 1 for "error". There is no opt-out: a live handle (a poll interval, a watcher) can no longer keep an agent alive after its link died.
  • Explicit transport (loopback pairs in tests, a socket, any embedded peer): the promise resolves the value and the caller owns the process lifetime, as before. (The resolved value itself did change: a benign write death used to read "error" here and now reads "end".)

The face and its retry fence (@kolu/surface/client)

buildSurfaceFace(surface, dispatch) is the addressing layer every higher client stands on: it re-nests the flat tags once into face.surface[member][verb], reading surface.tagPrefix off the value so it never learns whether it faces a standalone surface or a composed sibling.

Both leaf shapes are lazy, and neither takes an AbortSignal. A unary leaf is a UnaryEffect<I, O, E>: a lazy Effect that dispatches nothing until it runs, so a caller composes it — catches a declared _tag, races it, bounds it, lets interruption tear it down. A streaming leaf is a StreamingProcedure<I, O> returning a lazy Stream — running it is the subscription, and interrupting the fiber tears the wire subscription down. In both cases the argument is decoded eagerly, at the call site, exactly where zod’s .parse-at-input used to throw; only the dispatch is deferred.

Two shapes a projecting face holds live here, beside the builder that mints them. SurfaceClientCallable is the callable-leaved loosening of SurfaceFace a face holds opaquely — permissive enough that a concrete SurfaceClientOf<S> assigns without a cast, yet callable at the leaf, which SurfaceFace’s unknown leaves forbid. OwnedSurfaceConnection<Client> is that client plus the release the face is responsible for (dispose, and the transport’s optional onClose) — one shape whether the span is one CLI command or an MCP subscription’s lifetime, so a host can write one connection factory that feeds both faces. @kolu/surface-mcp’s PusherConnection / OwnedSurfaceConnection and @kolu/surface-cli’s SurfaceCliConnection are aliases of it.

The per-subscription retry fence lives here too, not in a link: a link that retried inside itself could not tell one subscriber’s supersession from another’s. STREAM_RETRY retries forever on a failure the fence accepts, spaced STREAM_RETRY_DELAY_MS, re-running the whole stream — so the next frame a consumer sees is the member’s fresh snapshot.

The fence retries failures, so it only covers what fails. A wire link is responsible for making a re-dial produce one: websocketLink fails every call an open edge superseded (above), which is what turns a swallowed re-dial — a subscription that would otherwise park forever with no failure anywhere — into an ordinary retry through this schedule.

ExportPurpose
buildSurfaceFace(surface, dispatch)the nested member face over a dispatch — face.surface[member][verb]
SurfaceFace · StreamingProcedure<I,O>the face, and its streaming leaf shape — (encoded input) => Stream<O>
UnaryEffect<I,O,E>the unary leaf — (encoded input) => Effect<O, E | SurfaceCallFailure>, lazy, interruptible, composable
SurfaceCallFailurewhat a call can fail with that no spec declared: Effect RPC’s RpcClientError plus the framework’s own SurfaceError vocabulary. Defects are deliberately NOT in it — an undeclared throw stays an Effect.die
isTransportError(err)positive test for Effect RPC’s RpcClientError — matched structurally on _tag, so it survives a second effect copy or a relay hop
shouldRetryStreamError(err)the shared fence: retry a transport error, plus the one retryable relay end; never a declared error, never a dead transport
STREAM_RETRY · STREAM_RETRY_DELAY_MSthe schedule (spaced 1s, forever, gated on the fence)
fenceStream(stream, { label?, onRetry? })apply the fence to a raw stream
StreamFenceOptionsthat options bag — label (the name the liveness registry records) and onRetry
unenrolledStreamCall(procedure, input, { label?, onRetry? })call a streaming member with the fence applied and no health() enrolment

onRetry fires once per retryable failure, between the failed attempt’s last frame and the next attempt’s first — the place to clear a view the fresh snapshot will replace.

label names the subscription in the liveness registry below. It is optional and additive; an unlabelled subscription still registers, as "(unlabeled)", so a gap in coverage shows up in a diagnostic rather than hiding a stream from it. Spell it in the client.health() vocabulary — a cell or stream is its bare key, a collection’s keys-stream is "<key>.keys", a per-key value sub is "<key>[<id>]" — which is what the framework’s own enrolment sites pass, so the two records name one subscription one way. A raw call site adds whatever scope makes it identifiable (kolu appends the host key or the terminal id: the same member is legitimately opened once per host and once per pane).

A throw from onRetry is contained and logged, never propagated: the hook runs on the fence’s own stack, where unwinding would end the stream immediately after telling you to clear your view — leaving a cleared view with no stream left to refill it (and, through rawStream, a health() entry stuck pending forever). Containment is what makes “fired ⇒ a re-subscribe follows” true even when the hook is broken. Clean up defensively anyway: the log names your hook, not framework code.

Per-subscription liveness (@kolu/surface/subscriptions)

Every fenced subscription funnels through fenceStream, so the fence is also where the framework records what each one has actually received. That record is the only thing that can tell a parked subscription from a healthy one: a parked stream is not pending (its first frame landed, long ago) and not erroring (nothing failed — that is the disease), so client.health() reads it as perfectly fine. Only a frame timestamp says otherwise.

ExportPurpose
subscriptionLiveness()every subscription this runtime holds, plus the last 20 it finished with, in subscribe order
resetSubscriptionLiveness()drop every record — tests only
SubscriptionLivenessone record’s shape — what a diagnostic reads
registerSubscription(label?)mint a record and its writer. Framework-internal: fenceStream is the only caller, which is what makes registration total — a consumer labels its subscription and never calls this
SubscriptionProbethat writer — frame() · retry(error) · finish(exit)
UNLABELED_SUBSCRIPTION · ENDED_RETENTIONthe "(unlabeled)" fallback label, and how many ended records are kept

Each record carries label, subscribedAt, lastFrameAt?, framesReceived, retries, state (live / ended / failed), endedAt? and lastError?. Timestamps are Date.now(), like the dial history’s, so the two read side by side and beside a server log. The registry is module-scoped: the question it answers spans every client in a runtime (a root surface, a control plane, one keyed-map client per host, plus the deliberately un-enrolled raw streams that belong to no client), which is a different scope from the per-client health fact. It is bounded — live records are bounded by the subscriptions you hold, ended ones by ENDED_RETENTION.

A re-subscribe updates the SAME record (retries climbs) rather than minting a second, and a record is minted when a fenced stream runs, never when its lazy value is built.

The verdict itself — is this subscription parked? — is not stored: it is a comparison between a record’s lastFrameAt and the wire’s current open-since (diagnostics.dialHistory()), both of which move on their own, so a consumer computes it at the moment it reports. kolu’s Diagnostic Info block is the worked example.

The tagged-error vocabulary (@kolu/surface/errors)

Every error that has to be recognised on the far side of a wire hop lives in one module all three tiers import, as a Schema.TaggedError. That single home is the requirement, not tidiness: a re-serving parent decodes an error from upstream and re-encodes it downstream, and only a schema both ends were built from survives serialize → deserialize → re-serialize with its _tag and data intact.

ClassMeaningRetryable
SurfaceTransportRetiredthe server retired this browser socket (a stale tab, close 4001). Carries death: RetiredTransportDeath — see the death fieldnever — terminal by construction
SurfaceStdioTransportCloseda stdio / unix-socket leg closed; the owner re-dials for a new link. Carries death: StdioTransportDeath — see the death fieldnever
SurfaceRelayTransportLosta re-serve relay’s middle-hop upstream drop the parent will healyes — the one retryable framework error
MapKeyNonCanonical · MapKeyUnknown · MapEntryFailedthe keyed-map rejections (see @kolu/surface-map)never

SurfaceErrorSchema is the closed union of all six — what a relay decodes against, rather than a per-call guess — and SurfaceError its decoded type. The predicates beside them are type guards, so a call site narrows instead of re-checking _tag by hand: isSurfaceError, isDeadTransportError (the two permanently-dead tags), isSurfaceTransportRetired, isSurfaceStdioTransportClosed, isSurfaceRelayTransportLost.

App-declared procedure errors are not in this union — they are declared per procedure on ProcedureSpec.error and travel on that procedure’s own error channel. And a defect is still a defect: an undeclared throw stays an Effect.die and is not modelled here.

messageOf(error) sits at the foot of the same module for that reason: it is what a face falls back to when the value it caught is not one of the declared errors — a defect, a scaffold’s throw, a plain object someone failed with. One derivation, so every projecting face words one failure the same way, and it handles the two shapes e instanceof Error ? e.message : String(e) gets wrong: a tagged error whose message is empty (its identity is in _tag), and a failure declared as a plain object ([object Object]). A value JSON cannot render is named by its constructor and its keys, carrying the reason it refused.

One-shot snapshot reads (@kolu/surface/first-frame)

Every member verb hands back a lazy Stream, and a one-shot read is “take the opening snapshot frame, then stop”. These readers take that Stream DIRECTLY and consume it with Stream.runHead, whose interruption of the rest is the unsubscribe — so no consumer writes a toAsyncIterable bridge, and no consumer re-derives the held-open-get hazard below.

ExportSignaturePurpose
firstFrameOrUndefined(stream)(Stream<T>) → Effect<T | undefined>the snapshot frame, or undefined when the stream ends empty (a benign “no value yet”)
firstFrameOrThrow(stream, onEmptyMessage)(Stream<T>, string) → Effect<T>the snapshot frame; FAILS on an empty stream — a missing snapshot is a dropped link, never a silent undefined. A recoverable failure, not a thrown defect, so a caller may Effect.catch it without swallowing bugs. The empty open fails as a tagged NoSnapshotFrame, never a bare Error — see below
firstFrameOfCollectionItem(item, keys, key, onEmptyItem, deadlineMs)(Stream<T>, Stream<unknown> | null, unknown, string, number) → Effect<CollectionItemFrame<T>>the BOUNDED one-shot read of a collection ITEM. A get for a not-yet-member key is held open and yields nothing forever (#1681), so this races the item’s first frame against BOTH a live keys-absence watch and a hard deadline — always both, never one
CollectionItemFrame<T>{present:true, value:T} | {present:false, reason:"absent"|"deadline"}a typed sum, so “present with an undefined value” and “absent” can never collapse into one nullable hole — and so a caller can log the UNCERTAIN deadline case distinctly from a known absence
NoSnapshotFrameData.TaggedError("NoSnapshotFrame")<{message}>the empty-open failure BOTH readers raise — tagged rather than a bare Error, so it can be told from the stream’s own error channel
isNoSnapshotFrame(error)(unknown) → error is NoSnapshotFramethe ONE predicate to discriminate on, so no consumer re-derives it from a message or an instanceof across a package boundary
ITEM_READ_DEADLINE_MS5_000the deadline a bounded item read uses when its caller has no reason of its own to pick one. Both projecting faces read it rather than spelling the number each

The empty open is TAGGED, and that is the whole point. Both readers can fail two ways — the stream opened and closed without its snapshot, or the stream’s own error channel failed — and those mean opposite things: the first says the link went away and nothing answered, the second says the far side answered and the answer was no. A reader that catches the whole failure channel and re-words it collapses them. @kolu/surface-cli did exactly that, and a member’s declared refusal came back on the exit code that means “nothing is serving here” — while the same refusal on the streaming path reported correctly, so one member gave two answers depending on a flag. So discriminate with isNoSnapshotFrame and let everything else through:

Effect.catch(firstFrameOrThrow(stream, "…opened and closed with no snapshot"), (error) =>
  isNoSnapshotFrame(error) ? Effect.fail(myLinkDropped(error.message)) : Effect.fail(error),
)

All three are Effects, and there is no signal to pass, because cancellation already is interruption. That matters because a one-shot read is almost always part of something larger — a concurrent fold over a host’s terminals, an MCP resources/read under the request’s own cancellation, a CLI command that must die on Ctrl-C. Staying inside the fiber tree that bounds it is what makes “the caller gave up” reach the read, so it never waits out a wedged-but-retrying link.

The Effect→callback edge (@kolu/surface/run-stream)

A member verb hands back a lazy Stream, and somewhere a non-Effect consumer has to RUN one so its frames become signal writes, terminal output, or a redraw. runStreamScoped is that place — one function, not one Effect.runFork per subscription primitive — and it owns three rules so nobody re-derives them:

  • teardown is a fiber interrupt, and the returned stopper latches before interrupting, so after it runs nothing reports: not a late frame, not the interruption exit, not a failure racing the stop;
  • an interruption is never a failure (an unmount must not register as a subscription error in health());
  • a failure is normalised once to an Error, so a tagged surface error still arrives as itself and a caller can narrow on it.
ExportSignaturePurpose
runStreamScoped(stream, handlers)(Stream<T>, { onFrame, onEnd, onFailure }) → () => voidrun the stream on its own fiber; returns the idempotent, synchronous stopper
StreamRunHandlers<T>{ onFrame, onEnd, onFailure }the three mutually-exclusive outcomes — frames, then EITHER a typed end OR a failure, or silence if stopped first
toError(value)(unknown) → Errorthe one failure normalisation (a tagged surface error passes through with its _tag intact)

It lives at the package root, on its own subpath, because it has nothing to do with Solid: pollOnChange is deliberately Solid-free and runs its pulse through this same edge, and so do consumers outside this repo. It is also re-exported from @kolu/surface/solid, so the Solid consumers that already import it there are unchanged.

The bounded-wait scaffold (@kolu/surface/wait)

The client-side “block until a condition lands on a stream” discipline, shared by every consumer that hand-rolled it before (kaval-tui’s output-idle wait, padi-tui’s agent-state wait, the kolu MCP face’s composite wait_* tools). Zero imports — a stream-consumption leaf beside first-frame.

ExportPurpose
WaitOutcome<Met>the one outcome union — met (payload spread flat; Met must not carry its own kind) · gone · timeout · interrupted · closed
runWait(opts, watchers)own the race: chain the caller’s signal, arm the timeout, first-writer-wins settle, resolve the interrupted/closed fallback
WaitCtx<Met>what watchers receive — settle · signal (chain every subscription) · elapsedMs() · recordUpstreamError
waitOutcomeJson(id, outcome, metJson)serialize an outcome to the driver-facing JSON frame — owns the four terminal arms (timeout/gone/interrupted/closed) once; the per-face met shape is the injected metJson callback
MAX_TIMER_MS / isValidTimerMsthe setTimeout 32-bit-ceiling range rule every timeout/idle window flows through

A watcher that rejects is a bug, not an outcome: the rejection propagates out of runWait verbatim — closed is reserved for the link settling without an outcome. The condition watchers stay in each consumer (they bind that consumer’s surface contract); only the outcome union and the race lifecycle live here.

pollOnChange (@kolu/surface/poll-on-change) — the client pulse-then-requery core

The framework-free CLIENT dual of the server’s pollOnEvent: subscribe a value-bearing {seq} PULSE stream (over unenrolledStreamCall, so it carries STREAM_RETRY), re-run a request/response procedure on every frame (initial snapshot + each change + each post-reconnect snapshot), abort-supersede a slow read, and emit through onResult / onError / onComplete. Zero Solid/kolu imports — the loop only. The SolidJS ergonomics (a reconciled store, .pending(), the #818 selection-stability guard) wrap it in packages/client’s createPolledQuery; a consumer needing the same pattern reaches for the wrapper, not this engine directly.

The classified failure ledger (@kolu/surface/failure-ledger)

A bounded retry budget whose classes cannot count each other’s failures. Zero imports — a counting leaf beside wait. The disease it makes unrepresentable: a bounded budget whose increment predicate differs from its ceiling predicate (consecutiveFailures += 1 for every cause, cause === "remote" && count >= 5 at the gate — so a night of unreachable attempts spends the rejection budget, and the give-up line names a count that never happened; juspay/kolu#2101).

ExportPurpose
makeFailureLedger(spec)build the ledger from a Record<K, FailureClassSpec>. Throws at construction on a resets entry naming a class the spec does not declare
FailureClassSpecone class’s budget — ceiling (number, or null for UNBOUNDED: that class can never produce an exhausted verdict) and resets? (the interleaving rule as DATA: classes whose run a recording of this class restarts)
FailureVerdictwhat a recording says about the class recorded, and only it — exhausted · run (this class’s run AFTER the recording, safe to print) · ceiling
FailureLedger<K>record(cause) → FailureVerdict · success() (clears every run and the attempt count) · attempts()

exhausted is computed from that class’s own run and that class’s own ceiling — no cross-class read exists in the verdict path, so the two predicates are the same predicate by construction and a message built from verdict.run can only ever name the run that tripped. attempts() is the display/pacing tier: the total across all classes since the last success(), exposed for backoff exponents and “attempt N” log lines, and read by no ceiling. A single-class budget does not need this (its one increment and one ceiling already share the one predicate); reach for it when a budget has more than one failure class. @kolu/surface-remote’s session is the consumer: { network: { ceiling: null, resets: ["remote"] }, remote: { ceiling: 5 } }.

The projection vocabulary (@kolu/surface/verbs)

A surface is served to more than one kind of caller, and a projecting face@kolu/surface-mcp as MCP, @kolu/surface-cli as argv — needs a handful of things that belong to neither transport. They live here so two faces read one contract by one grammar, exactly as expose does for the allowlist.

ExportPurpose
SurfaceVerb<I, O>a hand-authored, call-shaped verb — input? (an Effect Schema), handler(args, client, signal) returning an Effect, plus description? / title? / mutates?. The record an app hands to EVERY face verbatim
SurfaceVerbInputSchema<I>the bound on a verb’s input: Schema.Codec<I, unknown, never, never> — context-free, because an argument that arrived as argv or as JSON has no environment to decode in
toolName(ns, verb)the flat name a procedure answers to on a face with no dots to spend — <ns>_<verb>. Only the SEPARATOR is rewritten, so a.b·c mints a.b_c and stays reversible to one pair
toInputSchema(schema?)the Effect Schema → JSON-Schema bridge (draft 2020-12), dereferenced, opened, numeric-normalised, top-level object enforced
inputSchema(schema?)AdvertisedInput: the document plus the wrapped bit (did a non-object input have to travel under one property?). A union on that bit — the wrapped arm carries that value’s own inner node, handed over so a face never NAMES the wrapper property, and the unwrapped arm has no such field to test for
unwrapArgs · wrapValuethe two halves of the wrapping rule a face performs; the key a scalar travels under stays private to the module
decodeTextValue(schema, text)land a text token in a declared type — verbatim first, then its JSON reading — answering with BOTH: decoded for a face that addresses by value, encoded for one that hands a member’s own ref something to decode
admitsNoArgument(schema)does this member’s declared input admit no argument at all? The question every face asks before offering a member that carries none — MCP before publishing a static surface:// resource, the CLI before letting get <member> stand with no [arg]. One predicate, because each face turns a false into its own refusal and two spellings is how they come to disagree about which members are addressable

mutates defaults conservatively: absent means mutating, because a read-only hint can let an MCP host auto-run a verb unconfirmed. Mark a genuinely read-only verb mutates: false. For a procedure, that default is exposureMutates(exposure) — one derivation both faces read, because a safety default spelled once per face is one that can be relaxed on one face.

The Schema → JSON-Schema bridge (toInputSchema · inputSchema · unwrapArgs · wrapValue) has a module of its own behind this subpath, which re-exports it: it is the one piece of this vocabulary with a version seam (the converter’s option defaults shift between effect betas, with a byte fixture standing over them), and a file that holds the volatility says which half moves. Nothing about how you import it changes — one concept, one import path.

Two things that a face reads are deliberately not here, so nothing has to import the projection vocabulary to reach them: SurfaceClientCallable — the callable-leaved client shape a face holds opaquely, and the connection it owns — is @kolu/surface/client’s, beside the buildSurfaceFace that mints one; and messageOf is @kolu/surface/errors’s, because “what did this failure say” is failure vocabulary and words a stdin read and a run-edge defect that have nothing to do with verbs. There is no re-export of either: one concept, one import path.

Liveness and health

Liveness is on by construction. The reserved system/live member (@kolu/surface/liveness) is a surface-agnostic round-trip a client watchdog calls to tell a live link from a silently half-open one — no app nominates its own probe. probeSurfaceLive(client) is the one-liner that calls it (it walks .surface.system.live structurally); the socket and ssh seams default their watchdog to it. probeSurfaceIdentity(client) is its system/identity twin, returning the identified | anonymous union implementSurface’s identity option declares — both arms carrying startedAt (the UPTIME axis) and processId (the RESTART axis).

surfaceProcessId() (@kolu/surface/identity) is that processId: a nonce minted once per process, on first read. It is what the reserved member answers with, what a surface app’s client echoes back as ?pid= on every reconnect, and what gateStaleSocket compares a reconnecting claim against — one value, read from one place, so a stale-tab handshake cannot come to compare two unrelated strings. There is deliberately no way to inject one: a process has one identity, and anything that wants to stamp a log line with it reads it from here. It is DISTINCT from startedAt on purpose — two processes can start in the same millisecond, and a timestamp read as an identity is a near-miss waiting to happen.

monotonicNow() (@kolu/surface/time) is the shared monotonic-clock primitive both the heartbeat’s suspension check and a consumer’s own dead-man ceiling clock read — performance.now() where present, else Date.now(). It lives in its own neutral module (not @kolu/surface/heartbeat) so neither the heartbeat nor a downstream watchdog reaches into the other for the clock; both import it from here.

client.health() returns a fact, not a verdict: { live: boolean, subs: [{ name, pending, error }] }. live is the full conjunction of transport-live and every readiness predicate; a subscription error stays in subs and is never folded into live. This is what lets the UI stay honest — see Reactive honesty.

The readout

The fact answers what is true; the readout (@kolu/surface/solid) is the one verdict the framework does own, because it is the one an app kept getting wrong: what a connection indicator may say. It folds the transport’s four states together with the fact into five, and connectSurface / connectSurfaces hand it back in place of a transport-only status.

ExportTypeMeaning
SurfaceReadoutStatus"connecting" | "live" | "degraded" | "reconnecting" | "retired"the five states. Type your wording table Record<SurfaceReadoutStatus, …> and a new state is a type error in your file, not a silent default
SurfaceReadoutTransportReadout | DegradedReadout{ status, needsReload }, plus stopped on the degraded arm
surfaceReadout(status, health)→ SurfaceReadoutthe pure fold — unit-test your look table against every state without a wire
createSurfaceReadout(status, health)→ { readout, dispose }the memoized accessor over the two reactive facts, in a root of its own (the connect seams call this and fold dispose into theirs)
  • live is the conjunction. The socket is live, the fact agrees, and nothing enrolled is erroring. A socket that is open and answering under a dead subscription reads degraded, not live — that gap is where the green-light-over-an-empty-collection bug lives.
  • degraded names what stopped: stopped carries the erroring subscriptions’ own names (documents.keys, terminals[3], and across siblings surfaceApp/buildInfo), non-empty by type, so an indicator can always say what is missing rather than that “something” is.
  • Pending does not degrade. A subscription waiting for its first frame is what every page load looks like. This is a universal default and not app policy — unlike gateStatus, the gate’s verdict, where a pending first frame legitimately blocks the body.
  • needsReload is true for retired and nothing else: the one state a reload, and only a reload, recovers. Read it rather than keeping a list of which states are terminal — a list read down as transient once, and drew “reconnecting…” over a page that never would.
  • The app owns the look. Which words, which colours, whether the names go in a tooltip or a strip. The framework owns which state is true.

Recognizing a transport-loss end (for reconnect retry)

A re-serve relay ends a live downstream with one of two tagged errors on a transport loss: SurfaceStdioTransportClosed (the raw stdio close) or, when the relay catches an upstream death mid-stream and re-raises it wrapped, the retryable SurfaceRelayTransportLost. A client whose stream rides the fence re-subscribes on the latter automatically via STREAM_RETRY; a consumer that dials the re-served surface with a raw dispatch (no fence) recognizes them with isSurfaceStdioTransportClosed(reason) / isSurfaceRelayTransportLost(reason) (@kolu/surface/errors) to retry the subscribe across a reconnect window. Both are deliberately tight; the relay re-raises genuine application errors UNCHANGED, so neither tag is ever an app error.

The wire’s frame cap (@kolu/surface/frame-limit)

Every link’s ndjson serialization is built here, from one owned constant, rather than from Effect’s default. The default and this value are the same 16 MiB — the point of passing it explicitly is that a future Effect bump cannot move kolu’s wire silently.

ExportTypeMeaning
RPC_MAX_FRAME_BYTESnumber16 MiB. The largest single ndjson frame the decoder will buffer.
rpcSerializationLayerLayer<RpcSerialization>The ndjson layer carrying that cap. Use instead of RpcSerialization.layerNdjson at every client and server leg.
exceedsFrameLimit(bytes)(number) → booleanMirrors Effect’s own predicate, boundary included: a frame of exactly RPC_MAX_FRAME_BYTES passes.
FRAME_TOO_LARGE_CLOSE_CODEnumber1009.
isFrameTooLargeClose(code)(number) → booleanClassifies the frame-cap close distinctly.

An oversized frame is not a failed call — it is a closed socket. Effect’s server-side decode answers a frame over the cap with CloseEvent(1009), which takes every subscription on that multiplexed connection with it. (The client leg is asymmetric: an oversized frame arriving server→client surfaces as a broadcast ClientProtocolError, so the socket survives and the retry fence recovers.)

Two consequences a member author owns:

  • No member’s payload may scale with user data. Chunk it into bounded calls (scratch.write’s appendTo continuation is the worked example) or cap it with a typed refusal. The cap is a backstop, not a budget.
  • Refuse client-side, before send. A frame that would bust the limit must be rejected with an honest message rather than handed to the transport to die on.
  • 1009 must stay OUT of any isTerminalClose set — it is recoverable, and halting the retry schedule on it would strand the tab with no subscriptions.

The close-on-exceed semantics carry a BETA-ASSUMPTION(rc.110) marker (governed by packages/tests/governance/betaAssumptions.ts), so an Effect bump must re-measure them before it can land.

Fitting under it (@kolu/surface/frame-chunking)

The sender’s half of the cap. An app that ever puts bytes on the wire — a drop, a paste, an attachment — has to cut them so no single frame scales with the file, and that arithmetic has more terms than it looks like it has. It lives here, once, so nobody derives it a third time: give it an encoded payload and the budget derived from the cap, get legal frames. (Encoding is the caller’s — chunkBase64 splits a base64 string, and base64CharsFor sizes one before you build it.)

ExportTypeMeaning
FRAME_CHUNK_BYTESnumber3 MiB. Raw bytes one frame carries. A multiple of 3, so the base64 division is exact.
FRAME_CHUNK_BASE64_CHARSnumber4 MiB. FRAME_CHUNK_BYTES after the 4/3 expansion — a multiple of 4 by construction.
FRAME_ENVELOPE_BYTESnumber64 KiB. What everything on the frame other than the payload is budgeted at.
FRAME_PAYLOAD_BUDGETnumberRPC_MAX_FRAME_BYTES − FRAME_ENVELOPE_BYTES.
chunkBase64(data, chunkChars?)(string, number?) → readonly [string, ...string[]]Splits a base64 string on 4-character boundaries. Always at least one piece, so an empty input still performs exactly one write. Throws if chunkChars is not a multiple of 4.
base64CharsFor(rawBytes)(number) → numberceil(R / 3) * 4.
base64DecodedLength(data)(string) → numberDecoded size without decoding — for a size gate that must not materialize a buffer.
frameBytesFor(base64Chars)(number) → numberWhat a frame carrying that payload costs, envelope included. Hand it to exceedsFrameLimit for a pre-send refusal.

Three things the derivation turns on, each of which a re-derivation gets wrong:

  • Two expansions, and only two. base64 is 4/3; the JSON envelope is a bounded constant. There is no third — base64’s alphabet contains no character JSON escapes, so the payload does not grow again inside the JSON string.
  • The chunk is a multiple of 3. That is what puts every boundary on a 4-character group, and a group is the unit base64 decodes independently. Split off it and the pieces reassemble as a string while decoding to garbage. (A round 4 MiB is not divisible by 3; 3 MiB is, and 3 MiB of bytes is exactly 4 MiB of base64.)
  • The chunk does not fill the budget. ~3.9x headroom, so the number survives a bump that adds envelope fields — while a 50 MB file still costs 17 round trips.

The margin is a test (frameChunking.test.ts), not a paragraph: it measures the chunk against the imported cap, so a bump that moved the cap fails there rather than rotting a comment.

What stays with the app. This module pays the arithmetic, not the gate. A size policy cap, an extension allowlist, the wording of a refusal, where the bytes land on disk — those are the consuming app’s (kolu’s own live in @kolu/padi/upload). Chunking is wire physics; what you’ll accept is not.

Cross-cutting invariants

  • Snapshot-then-deltas. The first frame of every stream is a fresh full snapshot; every server helper enforces it, because the retry fence re-runs the source on each reconnect and a delta-first frame would silently lose state.
  • patch is a cross-runtime contract. A cell’s patch(current, p) runs on both the server and the client. Treat any change to it as a wire-format change and redeploy both sides together.
  • The group and the handlers are one route set. implementSurface (and implementSurfaces, and extendSurface) asserts the two key sets are equal at boot, so an advertised-but-unanswered tag and an answered-but-unadvertised one are both crashes rather than a runtime 404.
  • A raw stream joins health through client.rawStream / client.enroll so it can’t escape health(). unenrolledStreamCall (@kolu/surface/client) applies the retry fence without enrolling, for non-descriptor shapes. Its captured input is replayed verbatim on every re-subscribe, so keep it a stable key — a live fact (a viewport, a cursor) would be re-sent stale after a drop.
  • .use() is the default; the un-enrolled ref is a deliberate carve-out. A stream’s .use() and a collection’s .use() drive the subscription AND enrol its pending/error into health() — the transport gate sees them. For the narrow class whose transient re-subscribe is normal and self-healing and so must NOT flicker that gate — a terminal re-attach (#1591), a change-pulse that drives a requery — reach the raw ref instead: .streams.X.unenrolled (a StreamingProcedure), .collections.X.unenrolledKeys (the raw keys StreamingProcedure, present iff the collection declares the keys verb), or .collections.X.unenrolledDeltas (the raw batched CollectionDeltasMsg StreamingProcedure, present iff the collection declares the deltas verb — the deltas twin of unenrolledKeys), passed to unenrolledStreamCall. Each is typed from the declaration (no cast) and named so the health carve-out is legible at the call site — not a casual bypass of .use().
  • The 2-file invariant. Adding a member is one spec entry plus one wiring entry. If the count creeps up, something is wrong.