remoteSurfaces — a keyed map of remote surfaces (W4, redesigned)
The switch rebuilt on a first-class surface notion — a dynamic keyed map of re-served remote surfaces, N-active by construction — unifying the two hand-rolled implementations of the pattern (kolu's closed
What this note is. The redesign of W4 under srid’s constraints: no facade over the swap — the framework gains a notion; consumers reach a host’s cells through the client object itself (app.remoteSurfaces.get("zest").padi.cells.daemonStatus.use(…)); multi-host multiplexing is in scope now, not a future relaxation; nothing deferred; every invariant held to /architecture-first-principles and the perfection bar (the defect class becomes inexpressible, not guarded). Grounded in the code, not memory: a surface is a typed oRPC router already stream-multiplexed over one socket; the client is type-generated from the contract; buildHostRegistry + reServeSurface already hold and re-serve one warm session per host. Those three facts are what make this design small. This revision folds in a 3-agent adversarial debate (simplifier / purist / pragmatist lenses; record in debates/remote-surfaces-family/, uncommitted) and the grounded discovery that drishti already hand-rolled this entire pattern (hostRegistry.ts + admin-surface.ts: keyed host collection, per-host connection cell, add/remove/reconnect procedures, membership-subscribed client) — which, with kolu’s #1708 manager (closed, superseded by this design), makes two independent implementations: the prove-then-extract bar for the map, met before a line is written.
User-facing description
One kolu PR — the switch, multi-active chips, and the map that powers them; the whole multi-host UI gated on KOLU_PADI_HOST. The env var (srid-ruled) grows to a comma-separated seed list — KOLU_PADI_HOST="localhost,srid@zest" — and is the feature gate: set, kolu’s chrome shows a host selector strip just like drishti’s (one chip per pool host: live status + agent-urgency count — N hosts consumed concurrently; padi already serves the urgency cell {awaiting, awaitingIds}) plus add/remove host buttons; unset, kolu renders exactly as today — zero UI change, single local host. Switching is instant: picking a chip repaints the canvas into that host’s warming/connected state at once (selection is a signal write, not an awaited connect). Add a host → its chip appears immediately warming → flips connected; a bogus host shows failed(reason) as a value while siblings keep streaming; removal ends its subscriptions with a typed reason. Membership semantics are drishti’s, adopted wholesale: the env seeds the pool at boot, runtime add/remove are ephemeral (gone on server restart), local is always the unremovable default member. No Labs entry, no persisted recentHosts cell — the env is the explicit seed, which also kills the boot-brick class at its root (nothing persisted to parse at boot; a seeded remote with no drv map is that entry’s failed(reason), never a crash).
The paired drishti PR is the unification’s other half — adoption as deletion. drishti already hand-rolled the map pattern (hostRegistry.ts, admin-surface.ts: keyed host collection + per-host connection cell + add/remove/reconnect + membership-subscribed tab strip). Its PR replaces that machinery with @kolu/surface-map consumption, behavior-parity, acceptance measured in deleted code — the framework proving itself by making an existing product smaller, not by a demo app. (padi-dashboard — the attention product — remains a natural future PR on the proven map; the demo burden no longer rides on it. packages/surface-map ships only the README hello-world + a mock-entry e2e harness through the same resolver seam.)
Architecture-level changes
The new notion — SurfaceMap: a dynamic keyed set of surfaces served as one. Today a surface’s shape is static in the contract. The map adds the one honest dynamic axis: entry spec typed once, entries keyed at runtime. Server-side, serveSurfaceMap(entrySpec, registry) is a router transform, not a transport change: every entry proc gets the key folded into its input, the handler resolves registry.getSession(key) at call time, and membership gates every call — unknown key is a typed rejection. Alongside the entry procs, one authoritative collection carries the membership fact (debate-unanimous refinement — the earlier keys-collection-plus-total-status-cell shape gave one fact two homes, a P3 violation): entries: Collection<Key, EntryStatus> with EntryStatus = warming | connected | failed(reason) (+ the entry’s clockOffset, derived by the serving process with its own clock at hello — the writer named, per P3). Absence = the key is not in the collection; there is no absent status variant. One writer (the membership registry) publishes membership and status together, so cross-channel contradictions (status says connected, keys says gone) are unconstructible at the source. Removing an entry ends that key’s live subscriptions with a typed reason — the mirrored session’s teardown already ends its streams; the map maps that into a typed completion instead of a socket error.Why key-as-input and not per-key sockets or new frame tags: the oRPC peer already multiplexes many concurrent streams over one connection — that is how N cells coexist today. Key-as-input reuses the existing dispatch and typing wholesale; the misroute guard stops being retire-stubbing machinery and becomes a property of the data — a subscription carries its key in every frame by construction, so a call can’t cross hosts any more than it can cross procs. W4’s per-host sockets made misroute structurally impossible (a frame on zest’s socket physically cannot reach local’s handler); key-in-input is honestly weaker — a runtime membership gate (P5), not unrepresentability (P4). The design buys the P4 half back at the public API with a branded key type: defineSurfaceMap(keySchema, entrySpec) brands Key, and the map lens is the only public producer of branded keys, so a forged or unchecked key is a type error for every consumer of the typed API (raw wire access can still spell a bad key and gets the typed rejection — claim stated at its true strength, per the debate’s purist).
The client half — app.remoteSurfaces, a typed map client. .get(key) partial-applies the key and returns the entry-typed client subtree — so remoteSurfaces.get("zest").padi.cells.daemonStatus.use(…) is the API, exactly the spelling asked for. Two properties do all the work the old wrappers did:
- Pure and reactive access are two named APIs (debate refinement — the owner boundary gets a name instead of a runtime surprise):
entry(key)is the pure lens (no owner needed, no I/O — safe anywhere), anduseEntry(keyAccessor)is the Solid-only form that owns swap disposal — key change disposes the old entry scope and rebuilds synchronously (thecreateKeyedRoot/mapArrayatom survives as the internal mechanism; it was always right — exposing wrappers around it was the mistake).useEntryoutside a reactive owner throws the documented contract. - Subscription dedup by construction — built ON the ecosystem primitive, in the BASE client. The sharing-by-convention hack (module-const singletons) predates multi-host, so ref-counted dedup per
(proc, stable-input)lands in@kolu/surface’s client where every consumer inherits it; the map gets keyed dedup free (its branded key is part of the input). The implementation is not hand-rolled ref-counting (the debate’s C1 objection, answered): each cache slot is@solid-primitives/rootless’screateSingletonRoot— the audited ecosystem primitive whose exact semantics are “created once, ref-counted per reactive listener, disposed when the last leaves” — held in a map keyed by(proc, stable-input-hash). (The same audit that rejected this primitive forcreateSharedRoot’s never-teardown consumers endorses it here: a feed nobody watches should close. TanStack Solid Query was weighed and declined: request/response-shaped with staleness semantics — the wrong shape for server-push cells.) The lifetime contract closes the debate’s P1 hole: on removal the map completes matching iterators with the typed end before the registry destroys the session (test pin: no socket-error frame after a typed end); a typed completion evicts the cache slot; a key absent fromentriesat subscribe time is never cached. A live cached sub for an absent member is thereby unrepresentable. Dedup applies to static-input subs (every real kolu case today — 15/15); accessor-input subs stay per-consumer (two input accessors are honestly two subscriptions). Warmth is policy: one deliberate app-scope reader in kolu’swire.ts, not a framework knob. This deletes the module-const convention,createSharedRoot-as-sharing,useBindingScopedSub,bindingScoped,reSub, and wire.ts’s adapter layer.
entry(key) is total — existence is a value, never a nullable. The lens is partial application of the key: pure, synchronous, no I/O, no existence claim — because membership is a time-varying fact, and an entry(): Entry | undefined would be a TOCTOU lie reintroducing the overloaded-undefined this cycle eradicated elsewhere. Existence lives in exactly two honest places, both reading the ONE authority: (1) a client-side total fold over entries — entry(k).state() yields the collection’s EntryStatus when the key is a member and an explicit not-a-member value when it isn’t, so a persisted active host removed from another device boots into a value the fallback policy reads (the totality the consumer needs, WITHOUT a second server-side authority — the debate’s fix); “inactive” is not a framework concept (activeness is the app’s signal). (2) Data subs follow one rule: a subscription ends, typed, when-and-because its key is not a member — removed mid-stream → {reason: "removed"}; never a member → immediate {reason: "absent"} on the same channel (one semantics, both timings). One-shot calls on an absent key typed-reject, since a call cannot end gracefully.
Activeness is app policy — a value, and that kills two machines. The framework holds the set; “which host(s) this tab looks at” is a plain signal in kolu. Switching = writing the signal — synchronous, so there is nothing to race: the pick-epoch machinery (built because selection awaited a warm) has no reason to exist. Warming stays real but becomes a server-side observable (status cell) the UI renders honestly — the W6 “honest connect” note lands here for free. Retirement stubbing (send-throws-typed-error) likewise dies: a switched-away key’s subs just keep running if something still reads them (multi-active is allowed), and end typed when the entry is actually removed. One-active is a policy the app enforces by construction of its own signal — precisely the “multiplicity as policy, not physics” line the old plan’s footnote promised, now made literal.
Packaging: @kolu/surface-map, its own package
The map spans contract, server, and client — inside @kolu/surface it would smear across define.ts/server.ts/client.ts as edits to each. It is also its own volatility axis (lowy): membership dynamics (“the set of surfaces changes at runtime”) can change independently of stream mechanics — you could swap how membership works (config, discovery) without touching how a cell streams, and vice versa. So it gets its own receptacle, packages/surface-map (-map over -family/-multi, srid-ruled: the members are homogeneous — one entry spec, N keys — so the thing IS a keyed map; “family” colloquially suggests kin-but-different members, and “multi” names nothing), depending only on @kolu/surface, with its own define/serve/client/solid entrypoints. Layering: @kolu/surface gains only the generic sub-dedup (universal, see above) · @kolu/surface-map owns the keyed axis · @kolu/surface-remote (srid-ruled rename of surface-nix-host, done IN this PR: with the map extracted, the package’s identity is ‘hosting surface daemons on remote machines’ — ssh is the transport, nix the provisioning strategy, remoteness the name; it survives a future non-nix provisioner) composes serveHostMap = map over buildRemotePool · kolu, drishti, and odu consume. The rename carries a naming sweep: exported API drops legacy vocabulary — anchor: buildHostRegistry → buildRemotePool (‘registry’ retires; a pool holds warm sessions, the map’s entries publishes membership — one word per concept). The sweep judges each export in the PR; names that are already honest (makeSession, sshConnector, isLocalHost, resolveSystem — odu’s entire import surface) stay, keeping odu’s change mechanical. Location is structure: the notion is first-class in the workspace, with its own README and tests. And the extraction bar is not speculative — the two consumers already exist as hand-rollings: kolu’s activeConnectionManager + hostPool admin plumbing (#1708, closed) and drishti’s hostRegistry + admin-surface. The package’s job is their unification; prove-then-extract is satisfied by archaeology, not promises (the debate’s evidence-gate demand, answered).
The notion in ~25 lines — kolu’s ACTUAL usage, not a toy (map API indicative; the padi cells are real code)
// 0 · THE PACKAGE UNDER PROPOSAL — every map API below comes from it
import { defineSurfaceMap } from "@kolu/surface-map"; // contract half
import { serveSurfaceMap } from "@kolu/surface-map/server"; // server half
import { connectSurfaceMap } from "@kolu/surface-map/client"; // client half (+ /solid: useEntry)
// 1 · CONTRACT — no new spec invented: the map is built over kolu's REAL padi surface
// (packages/padi/src/surface.ts — its `urgency` cell {awaiting, awaitingIds} and
// `terminals` collection already exist and already stream today)
import { padiSurface } from "@kolu/padi/surface";
export const padiHosts = defineSurfaceMap(HostKeySchema, padiSurface);
// ↑ HostKeySchema BRANDS the key; brings `entries: Collection<HostKey, EntryStatus>` free —
// the ONE membership authority (warming | connected | failed(reason), + clockOffset;
// absence = not in the collection)
// 2 · SERVER (kolu-server) — the warm ssh pool IS the resolver; env seeds membership.
// buildRemotePool is NOT map API — it's the EXISTING pool from @kolu/surface-remote
// (né surface-nix-host's buildHostRegistry, renamed with the package: one warm
// DaemonSession per host — ssh dial, nix provisioning, reconnect). Pool ≠ map: the
// pool owns the UPSTREAM connections (its volatility: ssh/nix/daemon lifetime); the
// map owns the DOWNSTREAM serving shape (membership on the wire).
// The pool is the single WRITER of membership; the map's `entries` collection is
// that truth PUBLISHED — one writer, one projection (P3). The resolver seam is the
// boundary: any session source can back a map (this pool, drishti's agent shipper,
// the e2e harness's mock subprocesses).
import { buildRemotePool } from "@kolu/surface-remote"; // né buildHostRegistry / surface-nix-host
const pool = buildRemotePool({ seed: parseKoluPadiHost() /* "localhost,srid@zest" */ });
serveSurfaceMap(padiHosts, poolAsMapRegistry(pool));
// MapRegistry = { members(), subscribe(onChange), has(key), resolve(key) => EntrySession | EntryFault }
// — membership as a first-class SUBSCRIBABLE fact (it must DRIVE the entries collection; a fused
// resolve-only seam can't observe changes). onChange fires only AFTER members()/has() reflect the
// change; status is DERIVED from the resolved session's state (projection, not a second writer).
// hosts.add/remove stay root RPCs writing the pool — one writer, runtime-ephemeral
// 3 · CLIENT — construct the typed map client ONCE from the contract + the app's link
// (this is where `app.padiHosts` comes from — nothing ambient)
const app = { padiHosts: connectSurfaceMap(padiHosts, link) };
// the switcher chips: every host's agent urgency, LIVE, one loop
<For each={app.padiHosts.entries.use().keys()}>{(host) => (
<HostChip
status={app.padiHosts.entry(host).state()} // EntryStatus | not-a-member — a VALUE
awaiting={app.padiHosts.entry(host).cells.urgency.use().value()?.awaiting}
/>
)}</For>
// 4 · CLIENT — the canvas follows the picked host; switching = writing a signal
const [activeHost, setActiveHost] = createSignal(LOCAL_HOST); // per-tab, sessionStorage-backed
const active = app.padiHosts.useEntry(activeHost); // Solid-only: owns swap disposal
active.cells.terminals.use(); // re-keys on switch, synchronous first value; a removed host's
// subs end typed {reason:"removed"} — the fallback policy reads
// entry(host).state() and falls back to local: a value, no callback
What survives from #1708, honestly. The server half survives nearly whole: buildHostRegistry + sessions, ssh warm, lazy drv resolution + typed per-host faults (Group 1), UnremovableHostError, entry cells (they become the map’s status/identity cells), the shared local scan, the dialog work, Labs UI (repointed). The batch’s framework atoms survive (createKeyedRoot-over-mapArray in @kolu/surface/solid, createSubRoot in useCell, the manager-honesty lessons as test cases). What is discarded is the client-side composite: createActiveConnectionManager, connectionScoped as consumer API, per-host browser sockets, retirement stubbing, pick-epoch, and kolu’s wrapper tower. Adoption path — clean-room, no cherry-pick (anti-regurgitation rule): PR1 starts from a fresh branch off master with this note as the only spec and the old client design explicitly out of bounds; in PR2 the #1708 branch serves as a lessons reference — its pins and test cases become checklists the new code must also satisfy (the toast ordering, the drv-map boot, the remove-default rejection) — but code is re-derived against the map shape, and anything copied verbatim must individually justify itself against the new design. Rejecting the PR discards shape, not knowledge.
The invariant table (perfection bar: each defect class inexpressible)
| defect class (all real, all hit this cycle) | killed by |
|---|---|
| misroute — a call resolves against the wrong host | branded key type — only the map lens constructs one (P4 at the typed API); runtime membership gate on the wire (P5, stated at its true strength) |
| yank — a slow warm commits a stale pick | selection is a synchronous value; nothing awaits (P1/P2) |
| leak-on-swap — old sub outlives the switch | entry scope owns subs; disposal is mapArray’s, not convention (P1) |
| sharing-by-convention — module-const singletons | base-client dedup: a keyed map of createSingletonRoot slots (ecosystem primitive, C1) with the membership-tied lifetime contract (P1) |
| signal-of-signals at call sites | .get(key) returns the entry client; nesting internal to the atom (C2) |
| boot-brick — a persisted host crashes startup | per-entry failed(reason) status value; boot never parses per-host config eagerly (P4, kept from Group 1) |
| dishonest teardown — “server restarted” lie | removal ends subs with a typed reason; no stub errors (P4/P5) |
silent no-op — remove(default) “succeeds” |
typed UnremovableHostError (kept) |
| foreign clock — cross-host timestamps | per-entry clockOffset on entries, derived by the serving process with its own clock at hello — one named writer (P3; no deferred honest-clocks item) |
| live cached sub for an absent member | typed end evicts the dedup slot; absent-at-subscribe is never cached; map completes streams before session destroy (P1/P5, pinned) |
| dual authority for membership | ONE entries collection; absence = not in it; status and membership published by one writer (P3) |
Implementation details
One kolu PR + the paired drishti adoption PR (srid-ruled; #1708 and drishti#89 are closed with successor pointers). With the old PR closed there is no separate “framework proof” PR: the demand proof is the two hand-rollings that already exist, and the kolu PR ships the product (switch + chips) on the notion (the map) in one reviewable unit — the atlas one-PR default, honored. The drishti PR is the unification’s other half and satisfies the surface gate substantively.
The kolu PR — @kolu/surface-map + base dedup + the switch + chips.
@kolu/surface(base): sub-dedup as a keyed map ofcreateSingletonRootslots per(proc, stable-input-hash), with the membership-tied lifetime contract (typed end evicts; absent-at-subscribe never cached; pinned).packages/surface-map(new):defineSurfaceMap(keySchema, entrySpec)— branded key, entry-router transform (key into input, zod-validated), theentriescollection contract (status + clockOffset, one writer);serveSurfaceMap(map, mapRegistry)—MapRegistry={ members, subscribe, has, resolve => EntrySession | EntryFault }: membership as a first-class subscribable fact (it drivesentries; ruled at the Phase-1 consult — a fused resolve-only seam cannot observe membership changes), source-agnostic (pool adapter, mock harness);SurfaceMapClientwith pureentry(key)+ SoliduseEntry(accessor); typed end on membership loss. README hello-world + a mock-entry e2e harness (subprocess entries through the same resolver seam) are the package’s example — no demo app.@kolu/surface-remote— the rename ofsurface-nix-host+ the naming sweep land here, same PR:serveHostMap(buildRemotePool(...)); entry faults from the lazy drv/ssh path land asfailed(reason)values.- kolu: server parses
KOLU_PADI_HOSTas a comma-separated seed list (validated per entry; local always the implicit unremovable default, deduped if listed); serves the padi map;hosts.add/removestay root RPCs writing the pool (P3), runtime-ephemeral like drishti’s. Client:wire.tsconsumesuseEntry(activeHost)(per-tab signal +sessionStorage); the selector strip + urgency chips + add/remove buttons render only when the gate cell says the env is set (server publishes the gate as a cell — the client never reads env); rejection/removal fallback is app policy readingentries. No Labs entry; no persistedrecentHosts. - The #1708 lessons re-land clean-room (no cherry-pick — the anti-regurgitation rule): its pins are the checklist (lazy drv boot,
UnremovableHostError, shared local scan, dialog clarity, toast ordering), the code re-derived against the map shape; anything copied verbatim must individually justify itself. - Done-criteria naming the paths: switch mid-stream (old key’s subs release, new key’s populate synchronously); boot with a dead host in the env seed (chip
failed, canvas fine, fallback policy reads a value); remove-active-host from another tab (typed end → fallback); gate off (env unset) → zero multi-host UI rendered; two hosts’ chips ticking on two hosts’ clocks with per-entry offsets; dedup observable (two views of one cell = one upstream sub in server count); no socket-error frame after a typed end. - The one-socket ops question — RESOLVED by product ruling (srid, 2026-07-08), not by measurement: the debate’s storm/HOL concerns were fleet-scale concerns misapplied to a few-hosts product — padi/kaval persist all state, so a socket blip is views re-hydrating through honest pending states (nothing lost), and head-of-line is noise at status-cell/urgency-count frame sizes. The measurement campaign is cancelled; one simple recovery check rides the user pass (kill the ws mid-session → honest degrade → live recovery, no refresh). Per-host sockets remain re-introducible under the same map API if scale ever demands — the notion is transport-agnostic, which is the point of it.
The drishti PR — adoption as deletion. hostRegistry.ts + admin-surface.ts replaced by map consumption; behavior parity (tab strip, fleet cards, add/remove/reconnect, ?host= deep links); acceptance = the deletion diff + parity screenshots. This is the pairing PR the surface gate requires, made substantive.
Risks, named with mitigations: dedup input-hashing — stable-stringify only zod-validated static inputs, throw on functions; end-vs-error ordering on removal — the map completes streams before the registry destroys the session (pinned above); chip fan-out cost — bounded by pool size and the urgency-count wire rule (W5), measured in the PR.
Both PRs run the full gauntlet — the C1–C7 checks first (C2 reads every new export from the consumer’s chair; this design exists because that chair was empty last time), then lens/codex/simplify/police, then /perfection-review against the invariant table above: for each row, either cite the structural mechanism or construct the defect — no “acceptable for scope”.