Skip to content

feat(padi): W4 — the switch (warm host pool + live per-tab host switching) - #1708

Closed
srid wants to merge 38 commits into
masterfrom
w4-the-switch
Closed

feat(padi): W4 — the switch (warm host pool + live per-tab host switching)#1708
srid wants to merge 38 commits into
masterfrom
w4-the-switch

Conversation

@srid

@srid srid commented Jul 6, 2026

Copy link
Copy Markdown
Member

kolu keeps warm connections to several machines and each browser tab picks — and live-switches, no reload — which machine it's looking at. Your laptop tab can sit on zest while your phone sits on sincereintent, against the same kolu, neither disturbing the other. This is W4 "the switch" (padi plan of record) — ONE kolu PR + a paired drishti PR (the framework half).

Implements the plan of record's three layers, top to bottom.

Framework — @kolu/surface-app (drishti-paired)

SurfaceAppProvider's controlPlane becomes Accessor<ControlPlane<T>> (the ratified S5 shape). The build-identity subscription is re-derived per control-plane identity (a createMemo + createRoot-per-identity), so a live host swap tears down the old host's stream and re-subscribes — a framework guarantee, pinned by a new provider test ("swap tears down + re-subscribes", where the gray-chip #1687 class goes to die). Static callers pass () => client. drishti is the proving second consumer (.claude/rules/surface.md gate); its migration is a one-character edit (surfaceAppClient()surfaceAppClient, already accessor-shaped).

Server — kolu (additive; the layer L6+L23+#1705 cleaned)

The warm pool: hostPool.ts holds a buildHostRegistry with one PadiSession (DaemonSession<PadiSurfaceClient>) per machine — the local endpoint arm for the local host, the ssh arm otherwise — each re-served into a per-host router (the shared kolu+surfaceApp fragment spliced with THIS host's padi) and a per-host WsRPCHandler. No controls (kolu needs no fleet verbs). ws-upgrade dispatches by the connection's declared ?host; an unknown host is rejected loud (close 1008). New root RPCs hosts.add/hosts.remove — the picker adds before it connects (deliberate intent, never a side-effectful GET that provisions ssh from a stray query param). preferences.recentHosts (schema + 1.32.0 migration) is the pool's persisted host set, shared across a user's devices. The preview route reads through the per-request host's session — killing the remoteHost fork (closing the #1685 deferral). server.info.defaultHost lets a tab pick its initial binding (KOLU_PADI_HOST boots CI through it).

Client — kolu (the de-entangling)

The wire stops being one page-lifetime module-global bundle. binding/bindings.ts holds one connectSurfaces bundle per host; an activeHost signal picks the one the tab views; switchHost swaps it in place — no reload. wire.ts re-exports the sibling clients as proxies over the active binding (so ~35 consumers don't rename), and the app-lifetime singleton subs re-key via bindingScoped (tear down + re-subscribe on a switch — the client twin of the framework guarantee).

  • The misroute guard (in this PR, not L11): each binding has a retired flag; switching retires + disposes the old one, so a stale in-flight call rejects loudly rather than landing on the wrong or dead host — and separate sockets make a cross-host route impossible by construction. Pinned by bindings.test.ts.
  • Per-host readiness rides the padi connection cell (mirroredSurface, liveWhen), folding into padi.health().live per binding. useDaemonStatus derives readiness / the create-refused-while-warming gate / the loud degraded states off the active host. The single-host kolu.padiLink cell retired — it could never carry N bound hosts (the mirrored connection cell is its per-binding replacement).
  • The picker, hidden: a nested group under the command palette's Debug section only — no ChromeBar, no keybind, no tip. Local + the default host + server-persisted recents + a free-typed ssh host. The palette item and KOLU_PADI_HOST are both undocumented — together the only way to reach remote terminals until the feature stabilizes.

Done (the W4 list)

  • ✅ two tabs on two hosts stay independent — separate page loads, per-tab active host in localStorage
  • ✅ recents persist across devices — preferences.recentHosts in config.json
  • ✅ create refused while warming — refuseIfWarming() reads the active host's readiness
  • ✅ local single-host mode byte-identical — the default path is behaviorally unchanged (evidence + the full e2e suite)
  • ▶ the live-switch e2e (local → remote → local; a stale in-flight call rejected; the switch never disturbing another device) — driven against a real pu second host (see Evidence)
  • ▶ paired drishti PR CI-green at the final kolu HEAD

Design-philosophy honored

  • Fail-fast, no fallbacks: an unknown ?host is rejected loud; a stale binding throws (no silent misroute); the re-serve pump failing is process.exit(1); illegal states unrepresentable via the typed pool slot (DestroyableSession) + the misroute guard.
  • Electricity boundaries: the swap-safe wire lifetime lands in its true receptacle — @kolu/surface-app's accessor — with drishti as the proving second consumer, not a new app-in-a-package.
  • Reuse the source of truth: the mirrored connection cell (not a hand-rolled per-host padiLink); buildHostRegistry (not a bespoke Map); preferences (not a parallel store).

Notes

  • L11 ledger: the client's module-global accessor shape is L11's declared starting point (the full scope-through-context sweep). The L11 ledger entry belongs in padi-cleanup.mdx (coordinator-owned, RT-fable).
  • Per-host diagnostics (A1, resolved): padi uptime / build / convergence / RSS are now sourced per host from each pool entry's cached session.identity() / convergence() (perHostKoluCells.ts), not the boot default — an earlier note here called them an "acceptable-for-scope default-host diagnostic"; the adversarial review killed that. serverRss / server uptime stay kolu-server's own (host-independent).
  • No changelog entry: the feature is deliberately hidden/undocumented until it stabilizes.

Known gaps (ratified deferrals — for morning ratify/overrule)

  • Honest clocks (plan piece 5, E1) — deferred to a named fast-follow. padi has served control.core.clockNow since W1, but NO binder consumes it: host-stamped timestamps (lastActivityAt, sleptAt, parkedAt) are formatted with the viewer's clock, so a cross-host "3m ago" is wrong under clock skew. Pre-existing (W3.1-era), not a W4 regression. The served half exists; the consumer half (wiring the RTT-halved offset through to the client) is the fast-follow.
  • Cross-host live-switch (F-d) — PROVEN (no longer deferred). Run for real against a leased kolu-ci-5 pu box: palette switch local → kolu-ci-5 → local with no reload, a live terminal on the remote (toor@kolu-ci-5), and two tabs holding different hosts at once — see the ## Evidence comment. The drive also caught two real bugs now fixed on this branch (the picker's free-typed connect couldn't submit; a first-boot D1 schema-validation false error). A repeatable e2e scenario still wants pu headroom to run every PR ([Log] pu issues #1204), but the behaviour itself is demonstrated.

Post-review live testing (on real hardware) — 3 bugs + a beta relabel

Running the feature on a Mac (zest) binding another Mac (sincereintent) surfaced what the unit suite couldn't:

  • Subscription leak — the switch toasts. Every host switch fired three "server restarted — reload required (stale tab)" toasts (Memory / Padi memory / Host inventory), both directions — yet zest's server had 9h54m uptime and never restarted. The toast was the visible edge of a real bug: every switch leaked all three live subscriptions. useCell wrapped its createSubscription in a detached createRoot(() => …) whose dispose was discarded, so a bindingScoped switch disposed the per-key owner but never the sub → controller.abort() never fired → the leaked sub errored on the retired socket and the (correctly guarded) catch saw aborted === false → toast. Fixed by tying the useCell root's dispose to the caller's owner + threading the abort signal into source() — the invariant "a disposed subscription cannot report anything" — and retireSocket now throws an honest "socket retired by this client" fact, not a "server restarted" claim (the genuine-restart affordance stays the lifecycle-status reload overlay). Pinned by useCellDispose.test (red before the fix).
  • Picker couldn't submit a new host — palette value-mode Enter had no target for a hint-only value input, so "Connect to a host…" did nothing; since recents start empty, that free-typed connect is the only first path to a remote (unreachable from a clean install). Fixed at altitude (a value input may offer no options; Enter submits selected: undefined).
  • First-boot D1 schema false-error — the startup validation hand-built its parse object and omitted the new top-level recentHosts key, logging a scary "Delete your state file" for a store that was fine. Fixed by reading every schema key.

Labs relabel. The picker moved from the "Debug" palette group to a new "Labs" group (flask icon) — the truthful home for a works-but-unstable beta feature — with the entry carrying a known-issues link (kolu.dev/atlas/remote-bind-parity.html). Same hidden posture (palette-only, undocumented); just an honest "beta, unsupported" signal Debug never conveyed.

Adversarial review (coordinator's 4-lens batch) — all 13 findings actioned

B0 (compile-error backstop for subs opened outside bindingScoped) · B1/B2/B3 (stale-binding pins: the Code-tab pulse + preview + terminalExit re-key per host) · A1 (per-host padi diagnostics) · A2 (server.info labels per-host) · A3 (default host un-forgettable — un-brick) · C1 (switchHost epoch: last-pick-wins + cancel-via-re-pick) · C2 (a guest host's pump fault is contained, not a global exit) · D1 (recents live cross-device via their own server-authority cell) · H1–H4 (createKeyedRoot extraction, per-host names, installUnknownHostFallback, loud preview fallback) · F-a/F-b/F-c (the done-list test-honesty debt pinned). Each fix keeps the suite green; see the fix(review) commits.

🤖 Generated with Claude Code

srid added 3 commits July 5, 2026 23:44
Layer 1 (framework, @kolu/surface-app): SurfaceAppProvider.controlPlane becomes
Accessor<ControlPlane<T>>; the buildInfo subscription is re-derived per
control-plane identity (createMemo + createRoot-per-identity), so a live host
swap tears down the old host's stream and re-subscribes — the framework
guarantee, pinned by a new provider test. Callers pass () => client.

Layer 2 (server, kolu — additive): the warm pool. hostPool.ts holds a
buildHostRegistry with one PadiSession per machine (local endpoint arm for the
local host, ssh arm otherwise); each entry re-serves padi into a per-host router
(shared kolu+surfaceApp fragment + this host's padi) + a per-host WsRPCHandler.
ws-upgrade dispatches by ?host (unknown host rejected loud). New hosts.add/
hosts.remove root RPCs (deliberate add-then-connect, never a side-effectful GET).
preferences.recentHosts (schema + 1.32.0 migration) is the pool's persisted host
set. The preview route reads through the per-request host's session, killing the
remoteHost fork (closes the #1685 deferral). server.info.defaultHost lets a tab
pick its initial binding (KOLU_PADI_HOST boots CI through it). Samplers bound to
the default host's binding for now; padiLink retirement rides the client layer.

Layer 3 (client) is next: the binding/ module, the Debug picker, the misroute
guard, per-host connection-cell readiness, and the padiLink retirement.
…guard

Layer 3 (client — the de-entangling). The wire stops being one page-lifetime
module-global bundle: binding/bindings.ts holds one connectSurfaces bundle per
host (a ?host=<host> socket + its own lifecycle), an activeHost signal picks the
one the tab views, and switchHost swaps it IN PLACE — no page reload. wire.ts
re-exports the sibling clients as PROXIES over the active binding, so ~35 consumers
don't rename; the app-lifetime singleton subs (preferences, recents, saved-session,
terminal list) re-key via bindingScoped (tear down + re-subscribe on a switch — the
client twin of the surface-app framework guarantee, no leak across the swap).

- MISROUTE GUARD (in this PR, not L11): each binding has a retired flag; switching
  retires + disposes the old one, so an in-flight/stale call rejects loudly
  (assertLive) rather than landing on the wrong or dead host — and separate sockets
  make a cross-host route impossible by construction. Pinned by bindings.test.ts.
- Per-host readiness rides the padi `connection` cell (mirroredSurface, liveWhen),
  folding into padi.health().live per binding; useDaemonStatus derives readiness /
  the create-refused-while-warming gate / the loud degraded states off the ACTIVE
  host. The single-host `kolu.padiLink` cell RETIRED (it can't carry N hosts).
- The HIDDEN picker: a nested group under the command palette's Debug section only
  (no ChromeBar, no keybind, no tip) — local + the default host + server-persisted
  recents + a free-typed ssh host; picking calls switchHost (add-then-connect).
- Boot: restore the per-tab host, and fall to the server default (KOLU_PADI_HOST)
  on a fresh tab. IdentityRail names the active host.

Tests: surface-app swap guarantee, hostPool ?host dispatch + recentHosts persist +
hosts.add/remove, the misroute guard, connectionToPadiLink. Full client (555) +
server (162) suites green; typecheck + biome clean.

Clock-offset consumption (piece 5, "consumed at last") is deferred as
acceptable-for-scope: the server plumbing (control.core.clockNow) exists; wiring the
RTT-halved offset through to the client is a refinement not gating the DONE list.
The per-scenario Before hook test__set's a FULL Preferences object; the new
required recentHosts field made every reset 400 (all 494 scenarios failed at the
hook). Reset it to [] so each scenario starts with no remembered hosts.
@srid srid mentioned this pull request Jul 6, 2026
srid added 9 commits July 6, 2026 01:14
…values

Drop the pool's parallel `mirrors`/`routers` Maps (which `registry.remove`
could never reach, leaking a removed host's MirrorClient + router) and capture
single `defaultMirror`/`defaultRouter` inside buildEntry when host===defaultHost;
narrow getMirror()/getRouter() to no-arg and update the two index.ts callers.

Agreed by the lowy ⇄ hickey lens debate (finding lowy-1, raised by lowy). Not pushed or merged.
`Binding.id`/`nextBindingId` were minted and documented as the misroute guard's
identity but never read (assertLive checks only `retired`). Delete the field, its
counter, and the object-literal entry; correct the module header and drop the
'unique id' clause so the guard is described by its load-bearing teeth — the
`retired` flag plus separate per-host sockets.

Agreed by the lowy ⇄ hickey lens debate (finding lowy-2, raised by lowy). Not pushed or merged.
…op the re-patch

createServerLifecycle needs only `conn` (never the Binding), so derive it before
constructing the object and build the Binding once with the real status/lifecycle/
serverProcessId accessors. Removes the three placeholder accessors, the three
`as`-cast re-patch assignments, and the inaccurate 'the lifecycle needs binding'
comment.

Agreed by the lowy ⇄ hickey lens debate (finding lowy-5, raised by lowy). Not pushed or merged.
…ntract

The `"local"` sentinel was duplicated as three independent module-level consts
that had to stay byte-identical across the client/server wire boundary. Define it
once in kolu-common/contract.ts (beside HostInputSchema), import it in hostPool.ts,
bindings.ts, and useDaemonStatus.ts (re-exporting from the first two for their
callers), and drop the three local definitions plus the reconciliation comment.

Agreed by the lowy ⇄ hickey lens debate (finding hickey-2, raised by hickey). Not pushed or merged.
The `surfaceApp` proxy had no importers — its one intended consumer, the
SurfaceAppProvider, deliberately bypasses it (`() => activeBinding().clients
.surfaceApp`), and rpc.ts no longer uses it. Delete the export and its doc comment;
the `Clients` type alias stays (app/padi/the singleton types use it).

Agreed by the lowy ⇄ hickey lens debate (finding hickey-3, raised by hickey). Not pushed or merged.
W4 retired the server-side `padiLink` cell; padi's link state now comes from the
padi `connection` cell via `padiLinkState()`/`connectionToPadiLink`. Fix the three
comments this PR left describing the dead mechanism: padiPresentation.ts's padiDot
doc, IdentityRail.tsx's padiStateText note, and the orphaned "Drive koluSurface's
padiLink cell" block in server/index.ts (deleted — its surviving uptime publish is
already documented at the onState call below).

Agreed by the lowy ⇄ hickey lens debate (finding hickey-8, raised by hickey). Not pushed or merged.
Address codex's 7 findings on the W4 "the switch" branch.

F1 (blocking) — host-scoped subscriptions that pinned the boot binding now
re-key through `bindingScoped`: the terminal-metadata collection
(useTerminalMetadata) and KavalUpdateBadge's padi `status` cell that codex
named, PLUS four more of the same class found while auditing —
useMemoryUsage / useProcessUptime / useDaemonInventory (kolu-surface, but the
socket is per-binding) and useHostInventory (per-host padi cell). These became
mandatory once F4 closes the retired socket.

F2 (major) — thread the active host into preview URLs. `buildTerminalFileUrl`
takes a required `host` and appends `?host=`; the binary/img/video/iframe path
and markdown-image resolution both pass `activeHost()`, so a tab on a
non-default host reads files off the right host instead of the default.

F3 (major) — each binding owns its lifecycle under its own `createRoot`, torn
down by `binding.dispose()`, so the lifecycle's lifetime is the binding's — not
whatever incidental owner first read `activeBinding()` (fixes the leak AND the
premature-free case). Dispose is idempotent.

F4 (major) — remove the dead `activePadiRpc` and make the misroute guard's
teeth real: `binding.dispose()` never actually closed the socket (only detached
listeners), so a stale-captured proc could re-dial the still-open old host.
Added `retireSocket(conn.ws)` on dispose (close + poison send). Kept the
ratified `assertLive` and corrected the guard comment.

F5 (minor) — server.info() carries the functional default-host seed; its
failure no longer collapses silently (console.error naming the consequence).

F6 (minor) — active-host persistence moved from localStorage to sessionStorage
so it is genuinely per-tab (survives reload, scoped to the tab).

F7 (nit) — corrected the koluSurface comments that still listed the retired
`padiLink` cell.

Client 555 + server 162 unit suites green; typecheck + biome clean.
Simplify pass over the W4 change (reuse/simplification/efficiency agents):
- wire.ts: collapse the three near-identical Subscription-shaped forwarders
  (preferencesSub/savedSessionSub/terminalListSub) into one `reSub` helper.
- wire.ts: `window.__koluWs` re-pointer becomes a plain re-pointing effect — it
  opens no disposable sub, so it needs none of bindingScoped's per-switch
  root/dispose machinery (efficiency + simplification both flagged it).
- bindings.ts: drop `hasStoredHost`; `readStoredHost() === undefined` is the
  presence check the one caller needs.
- hostPickerCommand.ts: unify the dedupe idiom to `[...new Set(...)]` (matches
  hostPool.ts; the ...Set preserves first-seen order).

Skipped (noted): sessionStorage helpers → makePersisted (the presence check is
load-bearing and makePersisted's fallback would mask it); LOCAL_HOST vs padi's
package-private LOCAL_HOST_ID (not cleanly single-sourceable).
Pass 2 (fact-check) — correctness bugs, each now pinned by a test:
- hostPool.ts: `hosts.add` had a TOCTOU race — two devices adding the same new
  host both passed the `has()` check and each dialed a live session; the loser's
  session/pump was orphaned (never destroyed) and its fail-loud pump could crash
  the whole server. Serialize per-host via an in-flight `Map` so the 2nd add joins
  the 1st. (hostPool.test: "serializes CONCURRENT adds of the same new host".)
- bindings.ts: removing a host another tab was viewing left that tab re-dialing
  `?host=<gone>` forever (server rejects 1008) behind a misleading "Reconnecting…".
  On a 1008 close the tab now falls back to local. (bindings.test: 2 cases —
  1008 → fallback; 1006 → no fallback.)

Pass 1 (rule checklist):
- no-dead-code: delete the never-called `assertLive`; the misroute guard's runtime
  teeth are the retired socket (dispose closes it + stubs `send`). Doc + test updated.
- no-unbounded-growth: cap `recentHosts` at MAX_RECENT_HOSTS=20 (MRU) — every recent
  host is re-warmed into a live session at boot, so the list can't grow without bound.
- dry-rule-of-three: extract `useBindingScopedSub` for the
  `createSharedRoot(() => bindingScoped(...))` pattern repeated across 6 hooks.
- silent-swallow / collapse-to-empty: comment `readStoredHost`'s catch (a broken
  sessionStorage genuinely has no stored host → local is the correct answer).
- errors-must-log-at-error: reconcile the unknown-host ws comment — it's a
  RECOVERABLE stale-tab case (the client now falls to local), so `warn` is right.
- watcher-lifecycle-logs: log the per-host binding install/retire in a greppable form.
@srid

srid commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

⚖️ Lowy ⇄ Hickey lens debate — ✅ consensus (1 round)

Base: 59ab99b (merge-base). Both structural lenses reviewed independently, then debated every finding to consensus. 6 fix findings, all applied (each its own commit).

# Origin Finding Commit
lowy-1 lowy+hickey Per-host mirrors/routers side-maps lived outside the registry's host lifecycle (leaked on remove) and only the default host was ever read → collapsed to single default-host getMirror()/getRouter(). dbf31ecf9
lowy-2 lowy+hickey Binding.id was write-only dead state; the guard's real teeth are the retired flag + separate sockets → deleted the field, corrected the doc. 8928118fd
lens Assemble the Binding once — derive its lifecycle first, drop the placeholder-then-repatch dance. 5eeb782c9
lens Define the LOCAL_HOST wire sentinel once in kolu-common/contract (was redefined per module). e6063b771
lens Drop the dead surfaceApp proxy export in wire.ts. 2944cf15e
lens Correct the stale "off koluSurface's padiLink cell" comments (the cell was retired). e4fd86a97

Deliberate, coordinator-ratified decisions (wire-clients-as-proxies = interim/L11, the retired padiLink cell, the intentional hostPool no-controls asymmetry, the hidden picker, samplers bound to the default host) were threaded as rationale and correctly not flagged.

@srid

srid commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Codex ⇄ Claude debate — ✅ consensus (2 rounds, reasoning effort: xhigh)

Base: 59ab99b (merge-base). Reviewer: codex; author: claude. All 7 findings resolved.

Round 1

codex — approved: false

The branch has the right broad shape for per-host bindings, but several host-scoped paths still pin the first binding or omit the active host. That means the main switch can show a new host while parts of the app still read or call the old/default host. I'm not approving until those cross-host correctness issues are fixed.

Findings:

  • F1 · blocking · open — The terminal metadata collection is still created once from padi.collections.terminals.use(...). The terminal key list was moved to bindingScoped, but this metadata subscription captures the initial binding's padi client and only changes its keys. After a host switch, the app can receive terminal IDs from the new host while asking the previous host for their records, so the canvas/right panel can render missing or wrong terminal metadata. The same pattern also exists in host-scoped module-level subscriptions like KavalUpdateBadge's padi.cells.status.use(...). (packages/client/src/terminal/useTerminalMetadata.ts:88)
  • F2 · major · open — The server preview route now dispatches by ?host and defaults to pool.defaultHost when it is absent, but the client still builds binary preview URLs without the active host. A tab switched to a non-default host will fetch /api/terminals/.../file/... from the default host, producing 404s for that terminal or, worse, serving a same-ID file from the wrong host. Markdown image URLs have the same problem at packages/client/src/right-panel/markdownImageSrc.ts:26. (packages/client/src/right-panel/BrowseFileDispatcher.tsx:153)
  • F3 · major · open — makeBinding calls createServerLifecycle under whatever Solid owner happens to create the binding, then Binding.dispose() only calls conn.dispose(). If the first caller is an app-lifetime owner, old binding lifecycle listeners and cleanup closures stay retained until page teardown after every host switch. The lifetime of a per-binding resource should not depend on the incidental caller that first read activeBinding(). (packages/client/src/binding/bindings.ts:91)
  • F4 · major · open — The explicit assertLive guard is not on any real app call path. activePadiRpc() is unused, while existing code still calls padiRpc(padi) directly and can capture concrete nested procedures such as padiRpc(padi).surface.subscribeRepoChange.get in createRepoPolledQuery. That means the retired flag is tested as a helper, but stale RPC references do not actually check it at invocation time. (packages/client/src/binding/bindings.ts:285)
  • F5 · minor · open — server.info() is still treated as cosmetic on failure, but it now also seeds defaultHost. If this one-shot request fails during startup, a fresh tab never falls through to KOLU_PADI_HOST and silently remains on local until reload or manual picker use. (packages/client/src/useServerIdentity.ts:27)
  • F6 · minor · open — The comments call active-host persistence "per-tab", but it uses localStorage, which is shared across tabs for the origin. Switching host in one tab changes what another tab will restore on reload, so the persisted behavior is not actually tab-scoped. (packages/client/src/binding/bindings.ts:171)
  • F7 · nit · open — The surface comments still list padiLink as part of koluSurface even though the cell was retired in this change. That makes the architecture prose contradict the actual surface shape a few lines later. (packages/common/src/surface.ts:443)

claude — Fixed all seven findings: re-keyed the pinned host-scoped subscriptions behind bindingScoped, threaded the active host into preview URLs, gave each binding's lifecycle its own root, made the misroute guard's socket-teardown real (and dropped the dead activePadiRpc), surfaced the default-host seed failure, moved active-host persistence to sessionStorage, and corrected the retired-padiLink comments.

  • F1 fixed — The terminal-metadata collection pinned the boot host's padi client. Moved it behind bindingScoped (terminal/useTerminalMetadata.ts:88bindingScoped((b) => b.clients.padi.collections.terminals.use({ keys, onError })); getMetadata now reads terminals().byKey(id)). Same fix for KavalUpdateBadge's padi.cells.status.use(...) (kaval/KavalUpdateBadge.tsx:28createSharedRoot(() => bindingScoped((b) => b.clients.padi.cells.status.use(...))); expectedKaval reads kavalStatus()().value()). While auditing the class codex's "like…" flagged, I found four MORE pinned module-level subs and fixed them too: ui/useMemoryUsage.ts, ui/useProcessUptime.ts, ui/useDaemonInventory.ts (app.cells.* — kolu's host-independent surface, but the SOCKET is per-binding) and ui/useHostInventory.ts (padi.cells.hostInventory — genuinely per-host). These became load-bearing to fix once F4 retires the old socket (a pinned sub on the boot binding would read a now-closed socket after switching away). subscribeExit (terminal/useTerminals.ts:62) needs no change — it's list-driven via mapArray, so it re-mounts per host already. Updated useTerminalMetadata.test.ts to mock ../binding/bindings (the collection now flows through the binding, not the ../wire proxy). All 555 client tests green.

  • F2 fixed — The server preview route dispatches by ?host (defaulting to pool.defaultHost) but the client built the URL without it, so a tab on a non-default host fetched files off the default host. buildTerminalFileUrl (common/src/preview.ts:160) now takes a REQUIRED host and appends ?host=<enc>; the binary/img/video/iframe path passes activeHost() (right-panel/BrowseFileDispatcher.tsx:153, with &v=<mtime>), and markdown images thread it through resolveMarkdownImageSrc(...ic, activeHost()) (right-panel/markdownImageSrc.ts:26). pathFromPreviewPathname strips the query (split("?")), so in-iframe navigation inversion is unaffected. Updated markdownImageSrc.test.ts + server/src/iframePreviewRoute.test.ts. One narrower hop is a documented follow-up, NOT this finding's scope: an .html preview's RELATIVE sub-resource (<img src="logo.png">) resolves against the iframe document URL and drops the ?host query — fixing that needs a server-injected <base> or a host-in-path route, and it only bites remote-host HTML previews with relative assets (an experimental/hidden surface). The top-level img/video/iframe/markdown URLs codex named are all fixed.

  • F3 fixedmakeBinding (binding/bindings.ts:91) now creates the lifecycle inside its OWN createRoot, captures the root's disposer as disposeLifecycle, and binding.dispose() calls it. The lifecycle's lifetime is now exactly the binding's, decoupled from whatever incidental Solid owner first read activeBinding() — fixing BOTH the app-lifetime-owner leak codex named AND the symmetric premature-free case (a short-lived component owner would otherwise onCleanup-dispose a still-active binding's status). dispose() is idempotent via an early if (binding.retired) return.

  • F4 fixed — took codex's own offered resolution ("remove the unused helper and make the intended socket-disposal behavior explicit"). Removed the dead activePadiRpc (+ its now-unused padiRpc import). While verifying the guard I found the deeper defect: binding.dispose()conn.dispose()transport.dispose() only DETACHES listeners — it never closed the socket, so the module's "closing its socket" claim was false and a stale-captured deep proc could re-dial the still-open (partysocket-reconnecting) old host — a real misroute, and a socket leak. Added retireSocket(conn.ws) to binding.dispose() (binding/bindings.ts:139): it closes the socket and poisons send to throw, so a late call on a retired binding throws at the transport by construction — the real teeth, stronger than an invoke-time assertLive nobody called. Kept assertLive (ratified guard vocabulary, pinned by the misroute-guard unit test) and corrected the module comment to state the true teeth. Added a test assertion that a switch retires the old socket (retireSocketMock called with local.ws). NB: this is what made the F1 sweep of the other pinned subs mandatory.

  • F5 fixeduseServerIdentity.ts:27: the functional default-host (KOLU_PADI_HOST) seed rides the same one-shot server.info() as the cosmetic identity, and a failure silently left a remote-default tab on local. The catch now escalates from a cosmetic console.warn to a console.error naming the consequence (default host not applied; tab stays on local until reload/picker), rather than collapsing the functional decision silently. There is no independent channel for defaultHost, and the fetch is over the buffered live socket (a reject is a genuine server failure, not a not-yet-connected blip), so surfacing loudly — not crashing the boot on a cosmetic fetch — is the proportionate level for a minor.

  • F6 fixedbinding/bindings.ts:171: the active host is genuinely per-tab, so it now persists in sessionStorage, not localStorage. sessionStorage survives THIS tab's reload (the "reload lands back on the host it was viewing" intent) while staying scoped to the browsing context — switching host in one tab no longer changes what another tab restores, and a fresh tab starts clean (no stored host → seedDefaultHost falls to the server default, so KOLU_PADI_HOST/CI still works). Updated the comments (now accurate) and the unit-test stub (bindings.test.ts stubs sessionStorage).

  • F7 fixedcommon/src/surface.ts:443 + :469: both comment blocks that still listed padiLink as a live koluSurface cell now list preferences / processMemory / processStartedAt / daemonInventory and call out padiLink as RETIRED at W4 (replaced by the padi connection cell), matching the actual cell set and the retirement note a few lines down.

commit: ef8eda6fad602968c5e613a3c3ff2e61dca5f785

Round 2

codex — approved: true

All seven prior findings are resolved in the current tree. I re-checked the changed areas tied to those findings and did not find a new regression introduced by Claude's fixes.

Findings:

  • F1 · blocking · resolved — Host-scoped subscriptions previously pinned the initial binding, so switching hosts could keep reading terminal metadata/status from the wrong socket. (packages/client/src/terminal/useTerminalMetadata.ts:98)
  • F2 · major · resolved — Preview URLs previously omitted the active host, allowing the server route to serve files from the default host while the UI viewed another host. (packages/common/src/preview.ts:165)
  • F3 · major · resolved — Binding lifecycle cleanup was previously tied to an incidental Solid owner instead of the binding lifetime. (packages/client/src/binding/bindings.ts:103)
  • F4 · major · resolved — The retired-binding guard previously had no real enforcement on stale captured clients because the old socket was not actually retired. (packages/client/src/binding/bindings.ts:139)
  • F5 · minor · resolved — server.info() failure previously treated the default-host seed failure as merely cosmetic, leaving a remote-default tab on local without a loud diagnostic. (packages/client/src/useServerIdentity.ts:27)
  • F6 · minor · resolved — Active host persistence was described as per-tab but used localStorage, which is shared across tabs. (packages/client/src/binding/bindings.ts:205)
  • F7 · nit · resolved — Comments still described retired padiLink surface cells as live. (packages/common/src/surface.ts:442)

codex on the rebuttal: F1 accepted: the subscriptions I flagged now go through bindingScoped, and the extra sweep of same-class standing subscriptions is appropriate once old sockets are really retired. F2 accepted: the reported top-level preview URLs now carry host; the remaining relative-subresource behavior inside remote HTML previews is narrower than my finding. F3 accepted: the binding-owned lifecycle root fixes the owner leak and premature-cleanup cases. F4 accepted: closing/poisoning the retired socket gives stale captured clients real enforcement, and removing activePadiRpc removes the dead path. F5 accepted: a user-visible surface would be stronger, but the functional failure no longer collapses silently and the error names the consequence. F6 accepted: sessionStorage matches the stated per-tab semantics. F7 accepted: the stale padiLink comments covered by the finding are corrected.

@srid

srid commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

👮 Code-police — 2 correctness bugs + 6 rule findings, all actioned

Two passes (rule-checklist + fact-check) over the change vs 59ab99b. Applied in 5f253e520.

Pass 2 (fact-check) — real bugs, each now pinned by a test:

  • TOCTOU in hosts.add — two devices adding the same new host both passed the has() check and each dialed a live ssh session; the loser's session/pump was orphaned (never destroyed) and its fail-loud pump (process.exit(1)) could crash the whole server. Fixed: serialize per-host via an in-flight Map (2nd add joins the 1st). Pinned: hostPool.test "serializes CONCURRENT adds".
  • Removing a host another tab is viewing → that tab re-dialed ?host=<gone> forever (server rejects 1008) behind a misleading "Reconnecting…". Fixed: on a 1008 close the tab falls back to local. Pinned: bindings.test (1008 → fallback; 1006 → no fallback).

Pass 1 (rule checklist):

  • no-dead-code — deleted the never-called assertLive (the guard's runtime teeth are the retired socket); doc + test updated.
  • no-unbounded-growth — capped recentHosts at 20 (MRU): every recent host is re-warmed into a live session at boot, so it couldn't grow unbounded.
  • dry-rule-of-three — extracted useBindingScopedSub for the createSharedRoot(() => bindingScoped(...)) pattern repeated across 6 hooks.
  • no-silent-error-swallowing / caught-error-must-not-collapse-to-empty — documented readStoredHost's catch (a broken sessionStorage genuinely has no stored host → local is correct).
  • errors-must-log-at-error — reconciled the unknown-host ws comment: it's a RECOVERABLE stale-tab case (client now falls to local), so warn is the right level.
  • watcher-lifecycle-logs — log the per-host binding install/retire in a greppable form.

Found by driving the built client live (chrome-devtools): the dev render crashed
with `Cannot read properties of undefined (reading 'byKey'/'value')`. Root cause:
`bindingScoped` set its value from a deferred `createEffect`, so the accessor
returned `undefined` until the effect phase — and a consumer reading
`daemonStatusSub()().byKey(...)` / `memory()().value()` on the first synchronous
render hit `undefined.<member>`. The pre-W4 direct subs (`app.collections.X.use`)
were never undefined; a render effect restores that invariant by running
synchronously on creation, so the factory result is in `value` before any read.

The production build's timing happened to avoid the window (CI's ci::smoke passed),
but relying on render-order luck is fragile — this makes it correct by construction
for every bindingScoped consumer (daemon status, memory, uptime, inventory, kaval
status, and wire.ts's preferences/terminal-list singletons) at once. Pinned by a
new test asserting the value is present on the first synchronous read.
@srid

srid commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Evidence

W4 "the switch" is proven leg-by-leg. Stated honestly — the parts were proven different ways, and one leg is deferred with the reason named.

1. CI — green on both platforms

ci::smoke, ci::unit, ci::nix, ci::biome, ci::fmt, ci::flake-check, ci::install, ci::home-manager, ci::pnpm-hash-fresh, ci::atlas-sync, ci::surface-app-example-build, ci::surface-example-buildall pass on both x86_64-linux AND aarch64-darwin, plus CodeQL / Analyze / SentinelOne. On the final HEAD e889b8764. (ci::smoke is the production-build app render + e2e; a green smoke on both platforms is the authoritative "the built client renders + works" gate.)

2. The live switch UI, driven with chrome-devtools (a dev kolu on random ports — never production)

I drove the built client live (chrome-devtools against a dev server on random ports; production kolu.service was provably untouched — same PID + uptime before/after). What this confirmed on the real running app:

  • The client renders with the W4 IdentityRail (Kolu · Padi · Kaval health chips; the padi chip's host segment is the W4 add — null on local, the host name on remote).
  • File-preview URLs carry the active host — the running app served …/file/…?host=local&v=… (the codex F2 host-threading fix, visibly working).
  • The warm pool boots: the server logged hostPool: local binding installed { host: "local" } (the police watcher-lifecycle log) and padi-local … connection: connecting → connected.

This drive also caught a real robustness bug: the first render crashed on undefined.byKey/undefined.value because bindingScoped set its value from a deferred effect (undefined until the effect phase). The production build's render order happened to dodge the window (CI smoke passed), but that's luck, not correctness — fixed by making bindingScoped populate synchronously (createRenderEffect), restoring the never-undefined invariant the pre-W4 direct subs had (commit e889b8764, pinned by a new test).

Honestly not claimed: the interactive picker→switch→back drive and full-canvas screenshots are withheld, and here's exactly why — the padi daemon socket namespace is machine-global, so a dev kolu on this box necessarily adopts the live session's own padi daemon rather than an isolated one. The canvas therefore shows real, in-flight internal agent terminals (not postable to a public PR), and keying into it drives live sessions. So the interactive switch drive against a clean isolated host is deferred with the cross-machine leg below; the switch mechanics are pinned by the tests in §4.

3. The remote arm (the ssh hop) — proven, and exactly how

W4 does not touch the remote-bind machinery: remotePadiBinding.ts, padiBinding.ts, padiConvergence.ts, padiSession.ts are byte-identical to master (git diff empty) — the warm pool just invokes them per host. So the shipped W3.1 just e2e-ssh proof (a real ssh hop through that machinery) still holds for the remote arm unchanged. What W4 adds around it — ?host dispatch, hosts.add/remove, recentHosts — is pinned by hostPool.test.

The full cross-machine chrome-devtools drive (local → a real 2nd machine → local, creating a terminal on the remote) is deferred pending pu capacity: the Incus cluster was saturated all night ("No cluster member has capacity — limit 4 per member"; logged on #1204), and self-ssh was ruled out (it fakes the second machine — same clock, same fs). The remote leg is proven by the unchanged W3.1 e2e-ssh arm + the pool unit tests; only the visual cross-machine UI drive is deferred, and it is not claimed to have happened.

4. The mechanism, pinned by tests (run in normal CI, both platforms)

  • framework swapsurface-app/provider.test: swapping the control plane tears down the old host's build-identity stream and re-subscribes (no leak).
  • the poolhostPool.test: ?host dispatch (distinct handler per host, unknown→undefined), recentHosts persist, hosts.add/remove idempotent, concurrent-add serialization (the TOCTOU fix), local never added/removed.
  • the misroute guard + switchbindings.test: switching retires the old binding and closes its socket (a stale ref fails at the dead transport); a distinct live binding is built for the new host; a removed host (close 1008) falls the tab back to local (not a forever reconnect loop); bindingScoped populates synchronously.
  • per-host readinessconnectionToPadiLink collapses the padi connection cell onto the display state.
  • local single-host mode byte-identical — the full e2e suite passes unchanged.

5. Paired drishti PR

The framework half (@kolu/surface-app controlPlane accessor) is exercised by the paired drishti PR #89, re-pinned to this final kolu HEAD (e889b8764) and CI-green — the second real consumer proving the accessor shape.

@srid
srid marked this pull request as ready for review July 6, 2026 07:48
srid added 9 commits July 6, 2026 05:36
B0 (structural backstop): strip `.use()` off the wire.ts app/padi PROXY exports at
the type level, so a persistent subscription opened on the module-global active-
binding proxy is a COMPILE ERROR — the compiler enumerates the violating sites
instead of a reviewer. It caught all three:

- B1: createRepoPolledQuery's `pulseProc`/`client` pinned the mount-time binding, and
  createPolledQuery's pulse effect keyed only on `input` — so after a host switch all
  five Code-tab queries pulsed the RETIRED binding forever (Code tab dead post-switch).
  Fix: re-key the pulse on `activeHost`, resolve client+proc from the active binding.
- B2: BrowseFileDispatcher's file-content pulse — same class; preview stopped
  live-updating after a switch. Same fix via the shared createPolledQuery.
- B3: useTerminals' terminalExit `.use()` went through the proxy — thread the ACTIVE
  binding (its per-terminal list-keyed owner re-keys on switch).

Pinned: createPolledQuery.test "re-keys the pulse onto the NEW host's client after a
switch" (asserts the pulse re-opens against the switched-to client).
The default host (KOLU_PADI_HOST) may be REMOTE, and its session/mirror/router are
boot-captured by the HTTP /rpc handler + the memory/identity/inventory samplers.
`hosts.remove` guarded only local, and every persist writes the pool set (minus local)
into recentHosts — so a remote default lands in recents and the picker's forget list
offered it (the "default is never forgettable" comment was FALSE). Forgetting it
destroyed the session under the boot captures → permanent brick (no re-add heals a
captured ref).

- hostPool.ts: guard `host === defaultHost` in hosts.remove (mirrors the local guard) —
  closes both the picker path and the raw hosts.remove RPC.
- hostPickerCommand.ts: filter the default out of the forget list; correct the comment.
- Pinned: hostPool.test "never removes the DEFAULT host — even a REMOTE one".

(The stronger "stop boot-capturing" is deferred: the guard makes the captured default
un-removable for the process lifetime, so the stale-capture path is now unreachable;
un-capturing would ripple through the onState wiring + the RPCHandler construction.)
…re-pick)

`hosts.add` takes seconds over ssh, so picks overlap. Two verified holes:
- Hole 1: re-picking the CURRENT host as a cancel was a pure no-op (early return),
  so a pending add for a different host still resolved and YANKED the tab.
- Hole 2: two pending adds were first-RESOLVE-wins, not last-pick-wins, and the loser
  toasted a misleading "couldn't reach" error.

Fix: a module `pickEpoch`; `const myPick = ++pickEpoch` as switchHost's FIRST statement
(before the same-host early return — that placement is what makes re-picking the current
host cancel an in-flight add); after the await AND in the catch, `myPick !== pickEpoch`
bows out. Pinned: bindings.test "re-picking the CURRENT host cancels an in-flight add"
and "two overlapping picks: the LAST pick wins".
Every per-host router's `server.info` folded the boot `remotePadiHost()` into the PWA
identity, so a tab on host A titled itself with the DEFAULT host's name. `buildAppRouter`
didn't even receive its host. Thread `host` into BuildAppRouterDeps; label the identity
from it (local → `Kolu [srv]` byte-identical, remote → `Kolu [srv → host]`). Pinned:
pwaIdentity.test "server.info labels the identity from THIS router's host".
RULING: the re-serve pump's `process.exit(1)` per host means any added host's fault
kills the whole server for every device — and W4 lets users add arbitrary hosts, so a
divergent-build adopt-loudly fault is a plausible per-guest event.

Scope containment to the binding: the DEFAULT/local binding stays fail-fast (the server
is useless without it; the supervisor restarts clean); a GUEST host's pump fault logs at
error and RETIRES the binding via hosts.remove (its ?host sockets close → the picker drops
it → each viewing tab's reconnect is rejected 1008 → the client's fall-to-local guard).
Pinned: hostPool.test — guest fault retires + no exit; default fault exits(1).
…fallback

- H3: lift the 1008 unknown-host → fall-to-local handler out of makeBinding into a
  named `installUnknownHostFallback(binding, ws)` (reads binding.host).
- H4: the preview route's `?host ?? defaultHost` silently served the DEFAULT host's fs
  for RELATIVE SUBRESOURCES inside a preview iframe (the client always appends ?host via
  buildTerminalFileUrl, so a request without it is a subresource that dropped the parent's
  query). Log it loudly (warn) rather than fail silent; the full per-subresource host
  routing is noted as an A1-shaped follow-up.
- F-a: refuseIfWarming BLOCKS + toasts the terminal create while the active binding
  is (re)connecting, and lets it through once connected (useDaemonStatus.test — the
  warming DECISION itself is already pinned in daemonPresentation.test).
- F-b: the stale-call rejection was pinned only by asserting a MOCKED retireSocket was
  called (≠ a call rejecting). Add a call-level test driving the REAL retireSocket:
  after retirement the socket's send THROWS (the misroute guard's real teeth).
- F-c: two-tabs independence — pin storeHost → sessionStorage (per-tab, not localStorage)
  and restoreStoredHost re-reading it on reload (a regression to localStorage now fails).

(F-d — a live-switch "tiles live" e2e — needs a real 2nd host; pu is saturated (#1204),
so it's recorded as a named gap in the PR alongside E1, not run tonight.)
…e) + H2

THE HEADLINE. kolu-server sampled padi uptime / build / convergence / RSS off the BOOT
DEFAULT host and served ONE value on every host's socket, while the client dressed them
per-host — so IdentityRail rendered "ssh · zest" beside the DEFAULT host's version + RSS.
Ruling (B)-PLUS: source them per-host from each pool entry's cached, ssh-FREE
`session.identity()` / `session.convergence()` (the #1705 identity work) — no @kolu/padi
surface change.

Server (per-host cells):
- NEW perHostKoluCells.ts: a minimal per-entry `implementSurfaces` for just
  `processStartedAt` + `daemonInventory` (cell specs derived from koluSurface), over its
  OWN in-memory channel (the reServeSurface isolation pattern), wired to THAT entry's
  session. Byte-identical wire paths → spliced over `kolu` with no contract change.
- hostPool: injects `buildHostKoluCells(host, session)`, splices the two cells over the
  shared `kolu` namespace per entry, and disposes them on host removal.
- index.ts: deletes the `defaultSession`-pinned processStartedAt driver +
  startDaemonInventorySampler block (moved per-host).
- daemonInventory.ts: the sampler returns a disposer (leak-free per-host teardown) —
  pinned by a test; the named corner from the design.

Client (the per-host reads, killing the three "acceptable-for-scope" comments):
- useMemoryUsage: padi+kaval RSS now ride the ACTIVE host's `padi.cells.processMemory`;
  serverRss stays kolu-server's own (host-independent) cell.
- useDaemonInventory: surfaceVersion reads padi's own per-host `padi.cells.version`; the
  daemonInventory cell is now per-host, so its comment says so.
- useProcessUptime: `processStartedAt` is per-host now — comment corrected.

H2: the "active"/"bound" reader names (activePadiSurfaceVersion, boundPadiConvergence,
padiMemoryDisplay, …) are now ACCURATE per-host names — A1 made them true (server* stays
host-independent), so no rename is needed.
"recents persist across devices" was half-broken: recentHosts was a PreferencesSchema
field, and the client reads preferences authority:"local" — seeds from the first yield,
ignores every later server push. So a hosts.add on device A published via
preferences.patch but never reached device B's OPEN picker until reload.

Fix (ruling A, cheap): recents are SERVER-owned pool state, not a user preference — give
them their OWN home. recentHosts was new in THIS PR (never shipped in preferences), so
this places it right at first release, no version bump:
- common/surface.ts: remove recentHosts from PreferencesSchema; add a read-only,
  SERVER-authority `recentHosts` koluSurface cell.
- state.ts: recentHosts is its OWN top-level Conf key; the 1.32.0 migration (never
  shipped) is reworked to move any branch residue out of preferences + strip it
  (pinned by stateMigration.test).
- surface.ts: the cell is Conf-backed — one `.set` both persists AND publishes.
- index.ts: persistRecentHosts writes the cell; boot warms from the new key.
- client: a server-authority `recentHosts()` accessor (wire.ts, no authority:"local");
  the picker reads it, so another device's add lands live.
- hooks.ts: reset the recentHosts cell (its own test__set) not the preferences field.

surface.test updated for the new cell.
…e value-mode Enter)

Cross-machine functional verification (the deferred F-d drive, now run on a leased
kolu-ci box) caught a feature-breaking bug the unit suite couldn't: typing a new ssh
host into the picker's "Connect to a host…" and pressing Enter did NOTHING.

Root cause in CommandPalette: value-mode Enter runs `execute(items[selectedIndex()])`,
but `items` is `partitioned().interactive`, which EXCLUDES hints. The host picker's
value input has only a hint child (no label), so `items` is empty, `selected` is
`undefined`, and `if (selected) execute(selected)` skips submit entirely — `onSubmit`
never fires. Every other value input (worktree create) happens to ship a default label
("Plain shell") as the Enter target, so the gap only bit the picker. And since recents
start empty, the free-typed connect is the ONLY first path to a remote host — so the
whole remote feature was unreachable from a clean install.

Fix at altitude (generalize, not a dummy label): a value input may legitimately offer NO
options — Enter submits the typed value with `selected: undefined`. `onSubmit`'s type now
admits `undefined`; value-mode Enter routes through a `submitValue(selected?)` helper
regardless of whether a label is highlighted; the worktree onSubmit reads `selected?.data`
defensively (it always has a label, but the type no longer promises one).

Verified LIVE end-to-end on kolu-ci-5 over ssh: palette switch local → kolu-ci-5 → local
with no reload, a real terminal spawned on the remote (toor@kolu-ci-5), and two browser
tabs holding different hosts at once. See ## Evidence. (The palette has no component-render
test harness — adding @kolu/solid-testing is a noted follow-up, not in this fix's scope.)
@srid

srid commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Evidence — cross-machine live host-switch (proven end-to-end over ssh)

The plan's deferred F-d ("tiles live across a local→remote→local switch") is no longer a gap — it ran for real against a leased kolu-ci-5 pu box. A local just dev-auto (random ports, isolated state dir; production kolu.service left untouched) bound the remote padi over plain ssh kolu-ci-5 (the pu proxy already rides ~/.ssh/config's Include ~/.pu-state/*/ssh_config), and I drove it with chrome-devtools.

The switch is a real ssh hop to a real remote padi — server log:

pool: add host {host: kolu-ci-5}
binding a REMOTE padi over ssh (KOLU_PADI_HOST set) — the whole canvas is this host
connected {ws:3, host:kolu-ci-5}
[host:kolu-ci-5] kolu-ci-5: already provisioned … skipped copy   ← padi warm in CI's nix store
[host:kolu-ci-5 remote] padi --stdio: no daemon … starting one
[host:kolu-ci-5] connection: copying → connecting → connected     ← the loud warming sequence

1 · Local (baseline) — the tab views this machine

Padi contract v1.2 (no ssh); the local workspace renders its terminals.
local baseline

2 · Switch → kolu-ci-5 (no reload) — fresh remote canvas

Banner flips to Padi ssh · kolu-ci-5; the canvas is empty — a brand-new padi daemon on the remote box, fully isolated from local's terminals.
remote empty

3 · A real terminal, running on the remote machine

Prompt reads toor in ⬢ kolu-ci-5 in ~ ● [Incus] — a live shell as the ssh user on the remote Incus container, not local.
remote terminal

4 · Switch back → local (round-trip) — local terminals restored

Padi contract v1.2 again; the full local canvas re-renders via the re-key (createKeyedRoot), no reload. (The foreground tile is this very agent narrating the drive.)
back to local

5 · Two-context independence — two tabs, two hosts, at once

Same origin: tab 1 = local (Padi contract v1.2), tab 2 = Padi ssh · kolu-ci-5 — per-tab host (sessionStorage-scoped), switched independently. kolu-ci-5 also appears in the picker's recents on tab 2 — the D1 server-authority recentHosts cell shared it across contexts.
two context


The drive caught TWO real bugs the unit suite couldn't — both fixed on this branch

  • fix(review): host picker's free-typed connect couldn't submit (9b6d28943) — typing a new ssh host into "Connect to a host…" + Enter did nothing: in value mode the palette's Enter runs execute(items[selectedIndex()]), and items excludes hints, so a value input with only a hint child (the picker) had no Enter target and onSubmit never fired. Since recents start empty, the free-typed connect is the only first path to a remote — so the feature was unreachable from a clean install. Fixed at altitude: a value input may offer no options; Enter submits with selected: undefined.
  • fix(review): D1 boot validation — read every schema key (2c7d586b3) — first boot after upgrade logged a scary Persisted state does not match schema (recentHosts …) — Delete <path> to reset, because the startup check hand-built its parse object and omitted the new top-level recentHosts key. Fixed by reading every key the schema declares; verified against a pre-D1 state file.

Honest scope notes

  • The remote-padi provision was a cache hit (skipped copy) — kolu-ci-5 already had the padi closure from CI, so the visual warming was brief; the copying → connecting → connected sequence is in the log above. A cold box would show a longer warm.
  • No component-render harness exists for CommandPalette (client tests are logic/mock-based), so the palette fix is verified by this live drive rather than a unit test — adding @solidjs/testing-library is a noted follow-up.

srid added 3 commits July 6, 2026 10:55
…Cell subs on owner teardown

srid hit "server restarted — reload required (stale tab)" toasts (Memory / Padi memory /
Host inventory) on EVERY host switch, both directions. zest's kolu-server had 9h54m uptime
— it never restarted. The toast was the visible edge of a real bug: every switch LEAKED
all three live subscriptions.

Root cause (coordinator ruling via architecture-first-principles + perfection): NOT an
unguarded error sink. `useCellServer`/`useCellLocal` wrapped `createSubscription` in a
DETACHED `createRoot(() => …)` whose dispose was DISCARDED. So a bindingScoped switch
disposed the per-key owner but never the inner root → `createSubscription`'s
`onCleanup(() => controller.abort())` never fired → the sub leaked on live
(`aborted === false`). When the outgoing socket retired, the leaked sub's for-await
errored and the (correctly guarded) catch saw `aborted === false` → `setError` → toast.
"Why only these 3": only re-keyed (bindingScoped) cells have an owner that tears down on
switch to expose the leak.

The fix realizes the invariant "a disposed subscription cannot report anything":
- (1) tie the useCell detached-root dispose to the caller's reactive owner (`getOwner()`),
  so a switch disposes → aborts the sub; ownerless callers keep app-lifetime behaviour.
- (2) thread the abort signal INTO `createSubscription`'s `source()` (via `streamingThunk`
  / useCollection), so a disposed sub's pending `next()` cancels at once instead of
  lingering until the transport errors — removing an asymmetry, since the sibling
  `createReactiveSubscription` already passed its signal.
- retireSocket now throws the honest, distinctly-typed fact ("socket retired by this
  client", `SURFACE_TRANSPORT_RETIRED`) — used for BOTH a host switch and a stale-restart
  teardown — not a "server restarted" claim. The genuine-restart affordance stays the
  lifecycle-status reload overlay (unchanged).

Pins: `useCellDispose.test` — disposing the owner ABORTS the sub's stream signal and
reports nothing (server + local authority; RED before the fix). Ordering in
setActiveHostInternal (flip activeHost → dispose subs, before retire) stays as hygiene.

Framework touch (@kolu/surface, @kolu/surface-app) — drishti re-pinned + re-greened.
…a known-issues link

Per srid's ruling: the host picker (W4 "the switch") is a works-but-unstable BETA feature,
so its truthful home is a new "Labs" palette group — not "Debug" (diagnostics only, which
never conveyed "beta, unsupported"). Same hidden posture (palette-only, undocumented; no
ChromeBar, no keybind, no tip), but a finder now gets the signal:
- new "Labs" group under Help, with a conical-flask icon (lucide flask-conical, the
  industry idiom for experiments), matching the palette's inline-SVG icon set;
- the picker entry's description carries the beta known-issues link
  (kolu.dev/atlas/remote-bind-parity.html — goes live when the plan PR #1649 merges),
  as plain URL text (the palette description is secondary text, not a live link).

Debug keeps its diagnostics. No e2e/unit selector targeted the picker's Debug grouping
(the picker is deliberately hidden, no e2e), so none needed updating. Client-only.
srid added 4 commits July 6, 2026 13:34
…ndpoint) [electricity 2/3]

Graduate kolu's `bindingScoped` re-key into a generic framework primitive:
`connectionScoped(connectionKey, connection, factory)` — a subscription factory keyed to
a SWAPPABLE connection accessor, re-run under a fresh root when the active connection
changes (prior root disposed first — no leak, #1687), value populated synchronously on
first read. It rides `createKeyedRoot` (already graduated), inheriting every guarantee.

Two accessors deliberately: `connectionKey` is the STABLE identity of the active
connection, `connection` the current VALUE (often a fresh object each swap — a retired
binding is rebuilt — so keying on the value would re-run on every incidental rebuild).

This is ledger L11's declared endpoint: kolu's app-lifetime singleton subscriptions
re-key onto whichever host a tab views WITHOUT a per-consumer scope-through-context port.
kolu's `bindingScoped` is now a one-line policy alias:
`connectionScoped(activeHost, activeBinding, factory)`.

Additive to the @kolu/surface-app/solid barrel (no signature drishti touches changes).
Pinned: createKeyedRoot.test's new connectionScoped case (re-runs on KEY change, NOT on a
same-key VALUE rebuild); bindings.test's sync-populate still green.
…ce-app [electricity 1/3]

Graduate kolu's binding machinery — the keyed cache of live client connections with ONE
active — into a generic framework receptacle: the CLIENT-side twin of buildHostRegistry.
createActiveConnectionManager<K,C> owns the volatility (retire-on-switch with the typed
retired-transport stub, pick-epoch last-intent-wins over slow async warms, the
server-rejected-key/1008 fallback predicate, per-tab persistence); kolu plugs in padi
POLICY — makeConnection (connectSurfaces + lifecycle), warm (hosts.add via the current
link), the toasts, socketOf, LOCAL as the fallback.

Per the coordinator's steer, the 1008 reaction is a POLICY CALLBACK (onServerRejected),
not a config field: a string can't express which key to land on or gate wording on
context. onWarmError carries `superseded` so policy stays silent on a superseded pick.

kolu's binding/ module is now pure policy: activeHost/activeBinding/switchHost/
bindingScoped/restoreStoredHost are the manager's surface; only makeBinding, the padi
policy object, seedDefaultHost, forgetHost, and recents remain.

createKeyedRoot + connectionScoped move to their own keyedRoot.ts so the manager composes
connectionScoped without an index-barrel import cycle (both still re-exported from
/solid). Additive to the barrel — no signature drishti consumes changes.

Pinned: activeConnectionManager.test (cache + rebuild-if-retired, flip-then-retire,
epoch last-intent-wins incl. re-pick-as-cancel, the 1008 predicate, restore); bindings.test
(11, the padi-policy wiring) still green through the real manager.
…[electricity 3/3]

Generalize kolu's per-host padi cells into a registry capability: HostEntry gains an
optional `cells?: { dispose() }` — per-entry SERVED cells derived from the entry's
session — and buildHostRegistry disposes them beside `session.destroy()` at EVERY
teardown site (remove, add-rollback, destroyAll). The registry stays ignorant of which
cells or what they carry, same discipline it applies to `session` (it knows only
`.destroy()`).

kolu's hostPool is the first consumer: buildEntry returns
`cells: { dispose: perHostKolu.dispose }`; the hand-rolled `koluCellDisposers` side-map +
its manual dispose in `hosts.remove` are gone. This CLOSES two latent leaks the side-map
had — it only disposed on `hosts.remove`, never on add-rollback (a persist-reject that
already destroyed the never-admitted session) or destroyAll (server shutdown) — now the
per-host sampler timers + onState closures ride the entry's exact lifetime.

perHostKoluCells.ts + the buildHostKoluCells adapter are unchanged. Additive optional
member on HostEntry — drishti's registry usage compiles unchanged.
Pinned: hostFanout.test's new "disposes each entry's cells at EVERY teardown site".

With this, kolu's binding/ + hostPool shrink to pure policy — the L11 end-state across
all three electricity extractions.
… surface

The three framework packages changed src but their READMEs didn't — closing that gap
(do.md "update every part that no longer matches"). Doc-only; no code change.

- surface-app/README: document the three multi-connection primitives added to /solid —
  createActiveConnectionManager (the client twin of buildHostRegistry, with its policy
  shape + the 1008 policy-callback rationale), connectionScoped (+ the bindingScoped/
  useBindingScopedSub alias note), createKeyedRoot — in a new "Multiple connections, one
  active" section mirroring how surface-nix-host documents buildHostRegistry. Also fix the
  stale controlPlane usage: it is now an Accessor<ControlPlane<T>> (S5), re-deriving
  buildInfo per control-plane identity on a host swap (the JSX example + the prose both
  showed a bare client).
- surface-nix-host/README: HostEntry now carries an optional cells?:{dispose()} — the
  buildHostRegistry table row + the fan-out invariant now note the registry disposes each
  entry's cells at EVERY teardown site.
- surface/README: document surface.system.identity beside system.live — the reserved
  identity twin (SurfaceIdentity sum, no nulls). This was #1705's to document and was
  absent; completed here while syncing the reserved-member story.
- root README: the Architecture section said the provider is "fed the surfaceApp sibling
  client" — corrected to the S5 accessor of the active host's client.

drishti unaffected (READMEs don't cross the surface.md gate).
srid added a commit that referenced this pull request Jul 6, 2026
… a REAL kolu usage snippet (from the #1708 head: the manager construction with its policy args, bindingScoped=manager.connectionScoped + the KavalUpdateBadge consumer, the provider prop, socketOf-inherited retirement, buildEntry cells)
srid added 5 commits July 6, 2026 16:01
… loud unremovable-host [batch group 1]

1a — a remembered guest host no longer bricks a from-source boot. parsePadiDrvMap was
fail-fast at BIND time (written for the KOLU_PADI_HOST singleton era); the warm pool now
dials it synchronously at boot for every persisted recentHosts entry, so a from-source run
(no drv map baked) with any remembered remote host crashed at startup. Parse LAZILY inside
makeResolvePadiDrv's dial thunk, throwing a per-entry ResolveDrvError("remote") — that
host's own connection failure (loud in the picker / connection cell) while the server boots
and serves local. Reworded the single-host-era log + drv-map error (folds in 5e's
remotePadiBinding part). Pin: remotePadiDrvMap.test — construction survives (boot doesn't
throw), the deferred thunk carries the fault.

1b(i) — hosts.remove(local | default) now throws a typed UnremovableHostError instead of the
silent-success no-op that resolved as if the removal succeeded; the client toasts it
honestly. hostPickerCommand comment reconciled (the forget-list filter is the UX layer over
the real server guard). The re-serve pump's structural-host handling is made explicit: the
default is fatal, the non-default LOCAL binding is logged + LEFT (not retired — remove(local)
would reject — and not fatal, since a remote default still serves), a guest is retired.
Pins: hostPool.test remove-rejects + the local-non-default log-and-leave path.

1b(ii) — getMirror/getRouter read THROUGH a call-time host-keyed map instead of two
boot-captured `let` consts (the registry stays the single source of truth); the sampler's
defaultSession stays a stable subscription ref, sound ONLY because the default is
UNREMOVABLE — pinned by a comment naming UnremovableHostError as the guard + a
reference-identity test (getSession(defaultHost) is identical across a renew()). If anyone
makes the default removable, that pin fails loudly rather than the sampler going stale.
… [batch group 2]

2a — no `X()()` anywhere. useBindingScopedSub now DEREFS the shared re-keying accessor
internally, returning the current host's sub handle in ONE call — consumers write
`useX().value()` / `useX().byKey()`, never `useX()()`. wire.ts's four hand-spelled
`Accessor<ReturnType<…>>` chains collapse to the helper's inferred types (the `let _X` +
module-load createRoot block become five `useBindingScopedSub` calls); `reSub` stays (the
Subscription-shaped exports `terminalListSub`/`savedSessionSub` are consumed as stable
objects) but no longer double-calls. Every `()()` consumer site (daemon status, inventory,
memory, uptime, kaval, host inventory) collapses. Grep-proof: zero `()()` code sites.

2b — adopt @solid-primitives/rootless (already transitive; promoted to a direct dep of
@kolu/surface). `createSubRoot` replaces BOTH hand-rolled owner-tied-dispose dances in
useCell.ts (the `let disposeSub!` + detached createRoot + `if (getOwner()) onCleanup`
pattern) — byte-for-byte its contract, incl. ownerless-stays-app-lifetime. Pure
deduplication, behavior-neutral (the leak pin `useCellDispose.test` stays green); no
context-propagation gain claimed.

createSingletonRoot was NOT adopted for createSharedRoot: an 18/18 audit falsified the
"duplicate" premise — createSingletonRoot ref-counts (dispose-when-idle) where every
createSharedRoot consumer needs app-lifetime never-teardown. A header note records the
semantic distinction so it isn't re-flagged. (Disposition: finding falsified by audit —
semantic mismatch, keep.)
…ager [batch group 3]

Four verified findings, one coherent reshape of the active-connection manager:

3a — `fallbackKey` was a dead required option (never read), and `onServerRejected` couldn't
reach the manager, which forced kolu's `let manager`-then-assign forward-reference dance.
FUSED: the manager ENACTS the fallback itself — on the server-rejected close predicate it
calls its own `switchTo(fallbackKey)`; `onServerRejected` demotes to a notification-only
hook (kolu's toast). `fallbackKey` is now genuinely read; kolu's forward-ref dies (`const
manager`).

3b — deleted `manager.restore()` / kolu's `restoreStoredHost`: dead exported API whose
semantics silently SKIPPED the misroute guard (a raw signal flip). The construction-time
`persistence.read()` already covers boot; a late-restore consumer, if one appears, goes
through `setActive` and inherits retirement.

3c — retirement bookkeeping goes INTERNAL: `ManagedConnection.retired` (a two-part consumer
obligation) becomes a manager-owned `WeakSet`. The consumer's connection type shrinks to
`{ dispose() }` + whatever it needs; the manager marks + fences retirement itself.

3d — the manager owns a reactive root PER cache entry: it runs `makeConnection(k)` under a
`createRoot`, disposed on retire/rebuild. kolu's `makeBinding` sheds its 17-line dedicated
lifecycle-root dance + the `retired` flag — `createServerLifecycle`'s onCleanup now ties to
the manager's per-entry root — and is pure construction returning a plain `dispose()`.

Pins updated: activeConnectionManager.test (rebuild-after-retire via setActive, the
fallback-enaction on 1008, construction-time restore); bindings.test's misroute guard now
asserts the socket-retire teeth (retirement is internal). Leak pin `useCellDispose` green.
Additive-safe for drishti (it never used ManagedConnection.retired).
…ce/solid [batch group 4]

4a — createKeyedRoot becomes a thin alias over solid-js's own `mapArray`:
`createMemo(mapArray(() => [key()], factory))` + an eager `createRenderEffect`. Deletes the
hand-rolled `disposePrev` bookkeeping, the manual `untrack` fence, and the hand-written
dispose ordering (~20 lines) — mapArray keys by value-identity (the same fence) and
disposes each key's per-item owner on a change or on the caller's teardown. The eager
render effect is KEPT + newly pinned: the swap disposes the old root SYNCHRONOUSLY on a key
flip (before any read), which is load-bearing for the switch-toast abort ordering.

4b — moved createKeyedRoot + connectionScoped down to the base `@kolu/surface/solid` layer
(pure solid-generic). Dissolves the import-cycle contortion that kept them in a standalone
surface-app file; `@kolu/surface-app/solid` re-exports both for compat (drishti + kolu
imports unchanged). Test co-located in @kolu/surface.

4c — ownerless misuse now THROWS the documented contract instead of silently degrading
(lazy dispose + a leaked final root): one up-front `getOwner()` throw replaces the two
`if (getOwner())` guards. Pinned.

4d — observableHolder is backed by a private `EventEmitter` + `node:events` `once(...,
{ signal })` (max-listeners lifted, since waiters are legitimately unbounded), deleting the
hand-rolled waiter Set + abort plumbing. relayStream.test green.
… toast helper, dialog clarity [batch group 5]

5a — ONE shared local-machine daemon-inventory scan. kolu-server's own box is
host-independent, but each remote pool entry ran its OWN 10s sampler re-scanning it — N
writers of one fact (a P3 single-writer violation, not just waste). `startSharedLocalDaemonScan`
owns the read-only scan; the per-host `daemonInventory` cells become EVENT-DRIVEN readers
(re-publish on session.onState + the shared-scan refresh), reading its cached result. The
cell's SERVED shape is unchanged (behavioral test assertions intact); only the driver moved.

5b — `evictArch(host)` drops the per-process nix-system cache when a host leaves the pool
(hostFanout's remove), so a forgotten-then-re-added host (reimaged / alias reused) re-probes
instead of realising the wrong .drv off a stale arch.

5c — the picker's free-type input rejects the literal `local` sentinel (an in-band value in
the ssh keyspace, now user-typable) with a helpful message; local is already the first action.

5d — the repeated `(err) => toast.error(\`label: ${err.message}\`)` lambda at every
useBindingScopedSub / cell `.use()` site collapses to a `subErrorToast("label")` helper
owned beside useBindingScopedSub; call sites shrink to their varying label.

5e — folded into group 1 (the single-host-era remotePadiBinding log + drv-map error reword).

5f — padi/kaval info-dialog daemon-list clarity (srid, live screenshot): the BOUND daemon
list gets the accent treatment (border + `● bound` marker + a "serving your terminals"
badge) tying together the socket / bound-host / in-use signals; the local-machine
diagnostic list is DEMOTED (dimmed, dashed, "Diagnostic only — not your terminals"), so
"which daemon serves my terminals" is answerable in one glance. Client-only, no data change.
@srid

srid commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Closing in favor of a redesign: the client-side switch machinery here (activeConnectionManager + per-host sockets + wrapper layer) is superseded by a first-class surface notion — a dynamic keyed family of remote surfaces (@kolu/surface-family). Design + debate record: docs/atlas/src/content/atlas/remote-surfaces.mdx (kolu.dev/atlas/remote-surfaces.html once merged). The server-half lessons (lazy drv resolution, UnremovableHostError, entry-scoped cells, shared local scan, dialog clarity) carry forward as pins/checklists for the successor PR — clean-room re-derivation, no cherry-pick. The switch feature itself ships with the successor.

@srid srid closed this Jul 6, 2026
srid added a commit that referenced this pull request Jul 6, 2026
…ection as the membership authority (absent variant dead, P3), branded family key + misroute claim stated at its true strength (P4 at the typed API, P5 on the wire), pure entry()/Solid useEntry() split, dedup rebuilt ON createSingletonRoot with the membership-tied lifetime contract (C1+P1 answered), the one-socket ops gate as a blocking acceptance item, ONE kolu PR + substantive drishti adoption PR (the two hand-rollings unified; #1708 + drishti#89 closed with successor pointers), padi-dashboard demoted to future work
srid added a commit that referenced this pull request Jul 7, 2026
Per the coordinator's Phase-2-go rulings, pre-consumer:

(A) Uniform fold envelope { mapKey, input } for EVERY proc (was a spread merge).
    One wire shape for any input — object, primitive (padi's terminalAttach), or
    none — and an entry input carrying its own `mapKey` field cannot collide with
    the folded key (it rides `input`, nested): misroute-by-collision is now
    unconstructible (P4), not merely unlikely. Reworks foldInput (define) +
    foldMapKey (client) + unwrapInput (server).

(B) Transport-liveness seam. @kolu/surface/solid now exposes buildSurfaceClient +
    resolveTransport for framework composition. connectSurfaceMap resolves the app
    transport ONCE (half-open guard applied) and threads the resolved `live` into
    the entries client AND every per-key client, and exposes client.live — so a
    per-key chip floors its status on real transport liveness (#1568), not a green
    dot over a dead link. Constant-true only for an in-process directLink.

(C) createKeyedRoot moved to @kolu/surface/solid (pure solid-generic, the spec's
    layering) with the eager synchronous-dispose pin restored (the #1708
    switch-abort ordering the local copy had dropped) + a test pinning it;
    surface-map imports it, no package-local copy.

Base-package API additions (buildSurfaceClient, resolveTransport, createKeyedRoot)
— drishti gate at final HEAD. All green: surface 267, surface-map 5, biome + tsc.
srid added a commit that referenced this pull request Jul 7, 2026
The wire pivot: the `padi` sibling is no longer a single bound surface but the
`padiHostMap` — one entry surface (padiSurface) served N times, keyed by host. env
KOLU_PADI_HOST unset ⇒ a 1-member map (local) ⇒ pixel-identical to before. Server +
client + tests move together (the wire shape changes; there is no green intermediate).

SERVER (index.ts / surface.ts / common surface.ts):
- Boot: a warm `buildRemotePool` over the parsed seed (LOCAL_HOST the unremovable
  default); `reServeFor(host, session)` caches the per-host re-serve with the #1708
  pump-fault pins (default death ⇒ fatal, guest death ⇒ pool.remove); `serveHostMap`
  splices the map's folded members + `entries` under `padi`. Default (local) session
  boot-awaited; the samplers/onState/preview bind to it (host-independent facts).
- `servedContract`: the `padi` sibling is `padiHostMap.contract.surface`.
- New `hostMapGate` cell on koluSurface — set once from `isMultiHost()`; the client
  reads it to render the multi-host UI (it never reads env). `boundHost` ⇒ null.

CLIENT (wire.ts + ~28 consumers):
- `connectSurfaces` drops to the padi-less siblings; `padiMap = connectSurfaceMap(
  padiHostMap, scopeSibling(conn.link, "padi"), { live })` threads conn's watchdog
  liveness through the composition seam. Per-tab `activeHost` (sessionStorage,
  parseKey-guarded).
- The 4 import-time module-const subs DIE. `preferences` (host-independent) stays a
  plain kolu sub; activityFeed/session/terminal-keys (host-scoped) ride
  `padiMap.useEntry(activeHost)` under ONE app-scope `createRoot` (inline in wire.ts
  to avoid an import cycle — commented so it isn't "tidied" apart). ~30 procedure
  point-calls go `padiRpcOf(activeHost()).surface.<ns>.<verb>` (the concrete-cast rpc);
  the terminal attach stays `unenrolledStreamCall` via the map entry.

TESTS: surface.test (hostMapGate in the cell set), seal.test (measureClockOffset
allowlisted), client mocks re-shaped to padiMap/padiRpcOf/activeHost.

Green: whole-workspace tsc; server 166, client 559, surface-app 142, surface-remote
123, common 22, surface-map 6; biome error-on-warnings.
srid added a commit that referenced this pull request Jul 7, 2026
…ship

The selector strip's add/remove actions, as root RPCs (they mutate the POOL, not one
host's surface):
- `HostKeySchema`/`HostKey`/`LOCAL_HOST` move to a padi-LESS `common/hostKey.ts` so the
  padi-less `contract.ts` can type the `hosts.*` RPCs without pulling `@kolu/padi` into
  the client contract (the seal). `surfacesWithPadi` re-exports them + builds the map.
- `contract.ts`: a `hosts` root namespace (`add`/`remove`, each `{ host: HostKey }`) on
  the SHARED contract — the client strip calls it, so not a kolu-server-local splice.
- `router.ts` + `buildAppRouter`: `addHost`/`removeHost` deps; `index.ts` forwards to
  `pool.add`/`pool.remove`. `pool.add` uses the pool's stored `buildEntry` (a guest host
  → the remote ssh arm).
- `assertRemovableHost(host, defaultHost)` (extracted, testable): a `remove` of
  LOCAL_HOST or the boot default throws `UnremovableHostError` — fails LOUD, never a
  silent no-op (the #1708 pin; "being able to override" is never a feature).

Pins: 3 assertRemovableHost tests (LOCAL_HOST throws, default-parameterized throws, guest
removable); seal root-namespace assertion → [daemon, hosts, server]; stub deps threaded
through the two buildAppRouter test call sites. tsc clean (common/server/client), server
169 tests, biome.
srid added a commit that referenced this pull request Jul 8, 2026
…1714)

`@kolu/surface-map` is a **keyed map of remote surfaces** — one entry
surface, typed once, served N times, keyed at runtime — and this PR is
**two honest halves at once**: it builds that framework *and* ships the
product surface it unlocks. A browser tab now **live-switches which
machine's terminals it views** — no reload — over a warm pool of
per-host padi bindings, reaching a host's cells through
`padiMap.entry("zest").cells.daemonStatus.use(…)`. Supersedes the closed
#1708 (clean-room redesign; the ratified Atlas note
`remote-surfaces.mdx` is the sole spec). First of three serial PRs —
this one, then a drishti adoption (#90), then a mechanical odu rename
(#40); both companions' pins are already bumped to this HEAD and green.

## The shape

`KOLU_PADI_HOST` stops being an all-or-nothing "the whole canvas becomes
that box" knob and becomes a **comma-seed list** for a warm pool.
`local` is always the implicit, **unremovable** default, so **env-unset
is a 1-member map — pixel-identical to today's single-host canvas** (the
multi-host UI is gated off and renders *nothing*). No dual code path:
the map is *always* the wire shape; N=1 is the degenerate case.

## Half 1 — the framework (`@kolu/surface-map` +
`@kolu/surface{,-remote}`)

- `defineSurfaceMap(keySchema, entry)` + the uniform fold envelope `{
mapKey, input }` on every proc; `entries` is the one membership
authority (a `Collection<Key, EntryStatus>`), status a **projection**,
never a second writer. A void-input member folds to **no input key**
(schema declares none), so the fold is zod-version-independent — closing
the drishti-fleet incident where zod ≥4.3.7's stricter missing-key check
silently broke every void fold.
- Client `connectSurfaceMap`: `entry(key)` (pure lens, total, cached) +
`useEntry(acc)` (Solid, owns swap disposal, re-keys on switch);
existence is always a **value** (`state()`), never a nullable `entry()`.
`Entry.rpc` is the per-key procedure client (folds `{mapKey}` into every
call; absent-key → typed `MAP_KEY_UNKNOWN`).
- `@kolu/surface`: ref-counted base subscription **dedup** (N views of
one `(proc, input)` → one upstream sub).
- `@kolu/surface-remote` (**renamed** from `surface-nix-host`):
`serveHostMap` bridges the warm ssh pool → the map, fusing pool
membership with each session's `onState`, and folds a **clock offset**
(RTT-halved at admit) into `connected` so two hosts render on two clocks
without comparing them.

### Typed status vocabulary (D1/D2)

- **`EntryStatus<Cause>`** — the `failed` arm carries a *typed*
domain-cause discriminant, generic so `@kolu/surface-map` stays
volatility-neutral (`= string` default keeps every consumer compiling);
padi instantiates `EntryStatus<PadiEntryFailedCause>`.
- **Typed contract-skew pair (D2)** — a `contract-skew-refused` cause
carries a real `{ running, expected }` version pair on the wire, never
re-scanned from a human string.
- The **projection contract**: `warming` = IN MOTION (coming up, or
coming back on its own); `failed` = NOT PROCEEDING WITHOUT INTERVENTION
(a standing refuse *or* a terminal give-up). A retriable
reconnect-backoff reads `warming`; a standing refuse reads `failed` +
its cause.

## Half 2 — the gated product surface (kolu app)

The **always-map flip**: the server serves + the client dials the map in
one atomic change (the 4 import-time module-const subs die; host-scoped
readouts ride `useEntry(activeHost)` under one app-scope owner; ~30
procedure sites go `padiRpcOf(activeHost())`). Gated entirely on
`hostMapGate` (from `isMultiHost()`) — the client never reads env.

- **Host selector strip** — one chip per pool host: a fact-only-green
connection dot, a live awaiting-you badge read off that machine, active
ring, guest remove-✕ (visible-dimmed at rest), a `+ add` input, and a
real DOM-measured overflow row (`⋯ +N` menu) + a `sm` dropdown switcher.
`hosts.add`/`hosts.remove` root RPCs; removing the unremovable default
fails **loud**.
- **Per-host canvas (shape B)** — each host keeps its own `HostView`
(active tile, MRU, per-tile attention, restore latches) keyed by the
canonical host string; a switch **swaps** the record, so host A's tiles
never bleed into B, and a returning host re-hydrates its splits + focus.
A pure switch fires **no wrong-host RPC**. A tile's split survives an
A→B→A round trip (a transient Corvu collapse is no longer persisted).
- **Stationary daemon slot** (header) — the active host's padi + kaval
identity live in ONE fixed segment right after the Kolu chip; content
re-keys on `activeHost`, position/size never change, so a host-switch
never reflows the strip.
- **Host-down cards (Skew-UX)** — when a host's binding is down, a
cause-typed card (each `EntryFailedCause` first-class, incl.
**`cross-supervisor` → "Another kolu owns this host"**) with a single
**`[Switch to local]`** action and **no Retry**; a
`contract-skew-refused` host shows a running→expected skew badge in the
stationary slot. `CanvasFacts` is a discriminated union so kaval-derived
facts are reachable ONLY on the `connected` arm (`.test-d.ts`-pinned).

## Ops — never two supervisors on one padi (P0 ownership-gated drain)

Only **one** kolu-server may supervise a given padi state root — two
would drain-and-respawn the same daemon in a livelock.

- **Local pid-gate** — a sibling `supervisor.pid` (reusing the daemon
pid-gate's atomic `link(2)` claim); a second kolu on the same root
**fails fast at boot** with the `KOLU_STATE_DIR`/`KOLU_PADI_STATE_DIR`
remedy. A same-lineage restart (dead predecessor) reaps the stale gate
and still drains.
- **Remote (D3)** — the existing anti-livelock fight-detection surfaces
the typed **`cross-supervisor`** cause and fail-honest-refuses on both
axes (never rides a contested build). **`KOLU_REMOTE_PADI_STATE_DIR`**
forwards `--state-root` to the remote padi as the primary isolation
lever (two kolus reach distinct remote padis).
- dev / e2e paths isolate their state so the gate never false-positives
beside production.

## Review + validation trail

- **Serial gauntlet** (lens-debate → codex-debate → simplify →
code-police) on top of a perfection-review + six adversarial re-sweeps.
Defense-in-depth earned its keep — a `process.exit(1)`-defeating
unguarded guest `session.destroy()` throw was caught by code-police
alone (guarded at the source: a throwing teardown leaves the server
alive + other entries streaming).
- **Architecture review** (23 agents / 6 lenses / 16 refuted) → one
confirmed framework defect + 2 minors, all fixed + pinned: the **P4**
`projectStatus` collapse of retriable `disconnected` onto terminal
`failed` (now → `warming`); a bare-field-presence
`EntrySession|EntryFault` (now `kind`-tagged); an adopt path that could
silently no-op `markConnected` (now enters `connected` directly + loud
on a connector-contract breach).
- **Live user-flow retest** (isolated `nix run` kolu ⇄ a real remote box
via chrome-devtools) caught **two card-masking bugs no unit test could
see**: `projectStatus` masking a standing refuse as `warming` (the
chip), and `resolveCanvasMode`'s loading gate stranding a `failed` host
so the host-down card never rendered (the canvas). Both fixed + pinned;
each reproduced against a live host first.
- **D3 validated for real** — driving a genuinely contested
`sincereintent` (another kolu supervising its padi on a different
build), the cross-supervisor path fired, the card rendered verbatim,
`[Switch to local]` worked, and setting `KOLU_REMOTE_PADI_STATE_DIR`
brought the remote up `connected` with no fight.
- **Known tail**: an intermittent stdio-teardown `AbortError` flake (an
orphaned orpc pull rejection on transport close) is root-caused with a
deterministic mechanism repro + fix hypotheses in #1719 —
deferred (a real fix would be an unvalidated shared-teardown change)
rather than guessed; the wrong-premise in-PR comment is corrected so no
false-premise code remains.

## Green

Two-platform CI green (x86_64-linux + aarch64-darwin). Whole-workspace
tsc; biome `--error-on-warnings`; nix build (client + server) + the full
production `kolu.drv`.

## Try it locally

```sh
KOLU_PADI_HOST="<ssh-host>" nix run github:juspay/kolu/surface-map
```

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-opus-4-8`)._
srid added a commit that referenced this pull request Jul 8, 2026
…1710)

The switch (W4, #1708) makes kolu multi-host; this note is the
researched answer to "what next?" — the menu for the next planning pass,
not a plan of record (`status: proposed`).

Built from two ground-up research passes, not memory: an inventory of
every shipped primitive and already-named future item in this repo, and
a source-grounded survey of 17 capabilities across tmux/mosh/Eternal
Terminal/Zellij/VS Code
Remote/Coder/Gitpod/Tailscale/Warp/tmate/Blink/Wave — each cited, each
assessed for whether kolu's architecture makes it easier or uniquely
better.

The organizing insight the survey produced: every existing product owns
one slice (persistence, roaming, sharing, fleet cost, agent
notification) and **none can know what an agent is waiting for** — so
the note tiers all candidates by how directly they exploit kolu's typed
agent-urgency advantage. Tier 1: the attention products (W5 validated,
the dock, agent-aware idle suspension — the gap Coder's own users file).
Tier 2: daemon-ownership dividends (agent-aware resurrection via
`--resume` after a fleet reboot; auto port-forward with preview URLs;
agent audit trails). Tier 3: already-named mechanics (honest clocks,
`padi-tui --host` with the S10 correction — L24's cited `getHostSession`
was deleted, kolu-tui). Four items parked with reasons; a constraint
ledger (parity tail, urgency-count-sized wires, ssh-user trust model,
drishti gate, clock gap) closes it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
srid added a commit that referenced this pull request Jul 12, 2026
**`buildRemotePool`'s `remove` carried two different facts under one
verb** — a *user* removing a host (which should persist the departure)
and the pool shedding a dead session on its *own* initiative (#1708's
guest re-serve pump-death). Once `remove` is wired to a `persist` hook,
that conflation means an internal shed **permanently un-remembers a host
the user never removed** — the defect the [W10 host-persistence
PR](#1772) surfaced and is now
blocked on. This splits the verb so persistence follows *intent*.

### Two verbs, not a `persist` flag

| verb | teardown | persists? | for |
| --- | --- | --- | --- |
| `remove(host)` | evict sockets → drop membership → destroy session |
**yes** (the `persist` hook fires first) | the **user's** explicit
removal |
| `retire(host)` | *identical* | **no** | an **internal** shed — a dead
session the pool sheds itself; the host stays in the persisted set and a
membership store re-seeds it next boot |

The destructive teardown is factored into one shared `tearDownEntry`;
the two verbs differ *only* in whether they `persist` before it. Because
`retire` has no persist step and swallows its own teardown fault, it
**can't reject** — so a fire-and-forget `void pool.retire(h)` needs no
`.catch` (unlike `remove`, whose persist step can). kolu's #1708 guest
pump-death path now calls `pool.retire`.

> **On master this is behaviour-neutral** — kolu passes no `persist`
hook, so `retire` and `remove` are identical today. It lands **first**
so W10 can rebase onto a correct contract, per srid's **W11** ruling
(the padi note, #1770). This is the sanctioned "framework change whose
consumer lands right after it" shape.

### Drishti gate

Additive API change — a new `retire` method on `RemotePool`. Drishti
consumes `buildRemotePool`/`RemotePool` (`hostRegistry.ts`,
`admin-router.ts`) but calls only existing verbs
(`add`/`remove`/`reconnect`/`recheckAll`/`has`/`subscribe`/`destroyAll`)
— never `retire`. Drishti's app **typechecks green** against this new
surface-remote (verified locally by hydrating the change into drishti's
tree). **No paired drishti PR is forced** — no drishti code changes are
needed.

### Carried finding (from W10's review)

**F3:** if `retire`'s teardown fails, a dead re-serve mirror could be
left in kolu's `reServes` cache *and* the pool's `entries`. The teardown
ordering here (drop membership + notify before destroy, destroy
swallowed) is the pool half; kolu's per-host `reServeSurface` cache
eviction is keyed to `pool.subscribe` membership, so a retired host's
mirror is pruned by `pruneToMembers` on the membership drop. Tracked so
it isn't lost.

Tests: `retire` runs the same teardown as `remove` but doesn't persist;
a no-op on an unknown host; swallows a destroy fault and resolves
(proving the fire-and-forget is safe).

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-opus-4-8`)._
srid added a commit that referenced this pull request Jul 12, 2026
> ## 📚 Stacked on #1775 (the retire-verb split)
>
> This now sits on **#1775**, which delivers the framework fix W10
needed. The root cause — one verb (`pool.remove`) carrying both
*user-remove* (persist) and *system-retire* (#1708 pump-death, must not
persist) — is fixed there: kolu's guest pump-death path now calls
**`pool.retire`**, which tears the host out of the live pool **without**
persisting, so a transient guest fault no longer un-remembers a host.
W10's earlier `.catch` guard on `pool.remove` was dropped in the rebase
(retire provably can't reject).
>
> **Carried findings, resolved:** F2 (a guest pump-death permanently
forgot a host) — fixed by the split's `retire`. F3 (a dead re-serve
mirror stranded on retire) — kolu's per-host `reServeSurface` cache is
evicted by `pruneToMembers` wired to `pool.subscribe`, which fires on
`retire`'s membership drop, so the mirror is pruned.
>
> **Merge order:** split **#1775** first, then this. Base is
`w11-retire-split`; it retargets to `master` automatically once #1775
merges. Tracked as **W11** in the padi note (#1770).
>
> _CI + evidence run on the final (rebased) tree — pending a free pu box
(shared-pool saturation)._

---

**The remote hosts you add from the selector strip now survive a kolu
restart.** Today a restart forgets everything but the local default and
you re-add your fleet by hand; after this, every remembered host
reappears in the strip and reconnects through the normal connect
pipeline — a host that has since gone away shows its honest **failed**
chip with the cause rather than silently vanishing. The only way a host
leaves the strip is your explicit remove.

Membership is a **server** fact — kolu-server's pool (`buildRemotePool`)
is its one writer — so its memory lives beside that authority, never in
the browser, where localStorage would fork the one list into per-device
copies. The persisted artifact is a value replaced whole: a zod-schemed
`{ version: 1, hosts: string[] }` JSON of **encoded host keys only**,
beside `conf`'s `config.json` under `KOLU_STATE_DIR`, written atomically
(tmp + rename, async so it never blocks the serving loop).

### The seam this wires (srid-approved over the brief's sketch)

The brief sketched a hand-rolled write *inside the add/removeHost
handlers*. But the pool already ships a purpose-built, transactional
`persist` hook (from #1714) that kolu's own `buildRemotePool` call never
wired. Hand-rolling would duplicate it — the "parallel hand-rolled
mechanism" the *reuse the existing source of truth* rule names as a
defect — so we **wired the existing seam** instead:

| | Handler-side write (brief's sketch) | Pool `persist` hook (this PR)
|
| --- | --- | --- |
| Ordering | writes *after* `pool.add` commits → disk/memory can diverge
on failure | write ordered **before** the in-memory commit |
| Concurrency | two rapid strip-adds race the file | serialized through
the pool's one mutation queue |
| Failure | host live but unpersisted | just-built session **rolled
back** |

### Boot: seed, don't replay

```
parseKoluPadiHostSeed()  ─┐
                          ├─▶ dedup ─▶ initialHosts ─▶ buildEntry ─▶ W6 connect pipeline
loadPersistedHosts(file) ─┘
```

Persisted hosts are merged into `initialHosts` (deduped against the env
seed via order-preserving `new Set`) rather than re-added via a
post-build `pool.add` loop. Both flow the identical connect pipeline,
but seeding at construction *doesn't re-fire `persist`* — so the file is
only ever rewritten by a genuine runtime add/remove, and **an
interrupted boot can't truncate it**.

### Design philosophy

- **Fail fast** — a file that exists but fails the schema *crashes the
boot* naming the path (delete it to recover). Never
start-with-empty-fleet, which would silently eat the user's hosts
(`caught-error-must-not-collapse-to-empty`). This is why it's a
**separate** file, not the `conf` store: `conf` data is reconstructible
so a corrupt file resets to defaults; a fleet is not. Only `ENOENT`
reads as "fresh install"; a permission error surfaces.
- **Electricity** — persistence policy is app-level (`packages/server`
only); the durability *mechanism* is the framework's `persist` contract.
No framework change in *this* PR — that's exactly the split PR's job.
- **Reuse the source of truth** — the pool's `persist` seam,
`KOLU_STATE_DIR` (via a narrowed export from `state.ts`), the pool's own
`encodeHostKey` vocabulary, and a shared `isEncodedHostKey` promoted
into `kolu-common` (de-duping a copy the client held).

### Review gauntlet outcome

- **architecture-first-principles** — 1 confirmed defect **fixed** (W10
made `pool.remove` rejectable; guarded the fire-and-forget guest-retire
so a disk-write failure can't crash the whole server) + 3 minors folded
in. It also surfaced the deeper verb-conflation that became the block
above.
- **lens-debate (lowy ⇄ hickey)** — consensus, 1 round, 1 fix (key the
persist-exclusion off `LOCAL_HOST`, not the boot-default).
- **codex-debate** — *stopped as non-converging* after 4 rounds
(findings grew 13→16, never approved) when its author spiralled into an
out-of-scope retry/overlay mechanism that generated its own blocking
bugs. Reset to the lens-approved tree and triaged codex's **round-1**
review (of the clean diff) by hand — the legit in-scope findings are
applied (fail-fast read vs `existsSync` masking `EACCES`, `0600` mode on
the ssh-target file, APM-source sync, atlas doc-sync). The full
round-1–4 codex trail is posted as a comment for audit.
- **simplify** — efficiency + altitude clean; applied a reuse de-dup and
dropped dead test state.
- **code-police** — 1 fix (async fs on the persist path). Two findings
**recorded, not actioned** because they contradict the ratified plan:
*prefer-focused-library* (use `conf`) — the plan mandates a zod JSON
tmp+rename kept outside conf's ladder; *persisted-schema-stays-tolerant*
(filter, don't crash) — the plan mandates a fail-fast crash-with-path,
with a dedicated test. Surfaced for srid.

Plan of record: the W10 section of
`docs/atlas/src/content/atlas/padi.mdx` (#1770); the block resolution is
minted there as **W11**.

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-opus-4-8`)._


---

### Carried finding → resolved in the W11 split

**F3 (codex round-1, residual):** if the internal guest-retire's
teardown fails, a **dead re-serve mirror can be left stranded in both
cache layers** (`reServes` in `index.ts` and the pool's `entries`). This
is legitimately the **W11 split's** world — the retire path is being
rebuilt there (the non-persisting internal retire), so the
mirror-eviction ordering is fixed as part of that rebuild, not patched
here. Named here so it isn't dropped; tracked on the W11 PR.

_Disposition table for the full codex trail is in the coordinator
report; the round-1 in-scope fixes are applied on this branch (fail-fast
read, 0600 + fsync durability, env-seed provenance, strict schema,
doc-sync)._
srid added a commit that referenced this pull request Aug 8, 2026
…2126)

Kolu has been on `effect@4.0.0-beta.103` since it was current. Three
betas later,
[beta.106](https://github.com/Effect-TS/effect/releases/tag/effect%404.0.0-beta.106)
is out, and the interesting thing about the gap is how little of it Kolu
had to react to — and which one thing it *couldn't* avoid.

## The forcing change


[beta.104](https://github.com/Effect-TS/effect/releases/tag/effect%404.0.0-beta.104)
renamed the Schema error constructors to line up with their `Data`
counterparts:

| before | after |
| --- | --- |
| `Schema.ErrorClass` | `Schema.Error` |
| `Schema.TaggedErrorClass` | `Schema.TaggedError` |
| `Schema.ErrorReviver` | `Schema.ErrorInstanceReviver` |

The old names are **gone**, not deprecated. Kolu spells
`Schema.TaggedErrorClass` 37 times across 15 files — every wire-visible
error in `@kolu/surface`, `@kolu/padi` and `kaval` is one — so the bump
and the rename are one commit. None of the other renames in these betas
(`Schedule.andThen`, `RateLimiter.makeSleep`, CLI `withHidden`) appear
anywhere in the tree.

**The rename also lands in a repo Kolu doesn't own.** `osfacts-client`
is grafted into the workspace from the `osfacts` npins pin, it is
`.gitignore`d, and it declares three `Schema.TaggedErrorClass` errors of
its own — so it broke the typecheck from a file no grep of this repo
would ever surface. That fix is **juspay/osfacts#6** (green on both
platforms), and the pin here advances to that commit so the graft and
the workspace agree on one Effect version.

## What the new betas gave us, and what we took

Most of the new API has no home here — verified by sweep, not
assumption: no `HttpClient` anywhere (so `withRateLimiter`'s new bounded
429 retries have nothing to attach to), no `Cron`, no `ExecutionPlan`,
no Effect `Config`/`ConfigProvider`, no `HttpApi` (only `HttpRouter`),
no multipart, no fast-check, no `File.seek`, and no Effect CLI.

**YAML parsing moves into Effect — but only where it belongs.** beta.104
added lightweight INI/YAML/TOML parsers under
`effect/unstable/encoding`. The two governance checks now use
`Yaml.parse`, which **drops the `yaml` dependency from `packages/tests`
outright** (Effect was already a dependency there). Both parsers were
run over the real inputs first — `pnpm-workspace.yaml` and the 87 KB
`coverage-ledger.yaml` — and agree deep-equal, 4ms→2ms and 34ms→5ms.

`@kolu/solid-markdown` deliberately **keeps** the `yaml` package,
because the same differential says the new parser is the wrong tool for
*that* input. Front matter is user-authored, and against a corpus of all
312 front-matter blocks in this repo plus the cases `render.test.ts`
pins, the new parser:

- throws on a multi-line plain scalar (`desc: one⏎ two`), which `yaml`
folds — a front-matter block that renders as a table today would fall
back to a raw code block;
- throws on the cyclic anchor `a: &a [*a]` that `render.test.ts`
explicitly pins as "must still render a table";
- stops recognising a following ` #` comment once a plain scalar
contains an apostrophe — `kolu's closed #1708 manager` keeps the comment
as content where `yaml` truncates. Two real Atlas notes parse
differently because of it.

(It is *better* on one axis — it parses the compact nested mappings in
six `SKILL.md` files that `yaml` rejects — but "differently wrong on
user content" is not a trade worth taking for a preview surface.)

**A `Stdio` adoption was tried and removed.** `kaval-tui attach` briefly
asked Effect's new `Stdio` service whether stdin/stdout were terminals.
The review gauntlet killed it and it is **not** in this diff — see the
lens comment below. Short version: `NodeStdio.layer` is `Effect.sync(()
=> process.stdin.isTTY === true)`, a pass-through of the global it
replaced; `main.ts` runs `cli()` at module load so no test can ever
import it to substitute the service; and `attach.ts` already has
`AttachTty`, a *tested* seam carrying the raw-mode and resize operations
`Stdio` structurally cannot express. `packages/kaval-tui/src/main.ts` is
byte-identical to master.

## The beta-assumption gate, honoured rather than satisfied

`packages/tests/governance/betaAssumptions.ts` exists so a version bump
can't land quietly: every `BETA-ASSUMPTION(<tag>)` marker must carry the
current pin's tag, so a bump turns all of them red until someone
**re-measures the behaviour**. Re-stamping them is the easy way to
defeat the mechanism, so each was actually re-measured against beta.106:

- the reactor, socket-redial and frame-limit assumptions by the named
tests they cite — `@kolu/surface` is green at **61 files / 628 tests**;
- a failed `Effect.forkChild` child still does **not** fail its parent
fiber;
- `Schema.optionalKey(Schema.Never)` still accepts a missing key and
rejects any present value, explicit `undefined` included;
- the RPC pinger still `Effect.delay("5 seconds")` and opens its kill
latch on the next unanswered tick — so `probeDaemonIdentity`'s derived 8
s still sits inside the band it was derived from.

**The gate had a blind spot, and this PR closes it.** Its regex
validates the marker *line* only — so three markers here were re-stamped
to beta.106 while the prose recording *what was measured* still said
beta.103, and that shipped green. Code-police caught it. The prose is
now re-verified (all of `frameLimit`'s quoted dist facts still hold
verbatim in beta.106), and a second scan holds every `effect@<version>`
written in prose under `packages/` to the pinned version. Proven in both
directions: green as-is, and failing loudly when the exact original
defect is re-introduced.

## One claim worth retracting

An earlier reading of beta.105's release note suggested Kolu's ~8
schema-decode sites would lose their human-readable errors, since
"schema issues no longer format implicitly." Measured across both
versions, that is **not** what happens: the change moved the
**constructor** adapters to a generic `"Schema validation failed"` with
the issue in `cause`, while `decodeUnknownSync` — which is what every
one of those sites uses — still throws a `SchemaError` whose message is
the path-annotated tree, byte-identical on beta.103 and beta.106. No
migration was needed and none was done.

## Verification

- **CI green on both platforms** — `6a6c4c2#1`, 24 recipes per lane,
14m26s. All 48 required checks green.
- `@kolu/surface` unit lane — 61 files, 628 passed
- governance gate — `effect@4.0.0-beta.106 agreed across 68 pin sites,
11 beta-behavior assumptions stamped (3 evidence citations agreed)`
- no open code-scanning alerts on this PR

**Review gauntlet**: lens (lowy ∥ hickey) applied, simplify clean + 1
altitude fix, code-police 4 findings all actioned. The **agent-debate
track has not run** — the `grok` CLI is unauthenticated on this machine
(`grok login`), and the gauntlet forbids substituting another peer.

**Merge order**: juspay/osfacts#6 first, then re-point this pin at the
merged commit (its `branch` field currently names the feature branch
deliberately, so `npins update` can't silently walk it back to the
pre-rename client).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant