Skip to content

Take kolu's Effect 4 migration: Effect RPC end to end, and a drain that can say what happened - #132

Merged
srid merged 23 commits into
masterfrom
effect
Aug 7, 2026
Merged

Take kolu's Effect 4 migration: Effect RPC end to end, and a drain that can say what happened#132
srid merged 23 commits into
masterfrom
effect

Conversation

@srid

@srid srid commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Pairs with juspay/kolu#2101.

kolu's effect branch replaces oRPC + zod with Effect RPC and Effect Schema across the whole @kolu/surface* stack. drishti vendors those sources straight out of the Nix store, so it does not get to opt in later — it takes the same migration or it stops compiling. This is that migration: the pin, the framework re-wiring, and drishti's own zod → Effect Schema conversion, across a stack of staged commits.

juspay/kolu#2101 has LANDED — squash commit 05484f0fb — so the pin stops chasing a branch. It now tracks kolu master at 2104ddea9, which carries the merge plus the master-side work that never existed on effect (release 2.2.0, the changelog rules, the /release CI-gate delegation, the operations page). The osfacts-client adoption (opened as #133) is folded into this branch — this is the one PR for this repo.

CI at the master pin: two-platform FULL GREEN24 ok · 0 failed · 0 errored · 0 skipped · 0 cancelled across x86_64-linux and aarch64-darwin, all 24 GitHub statuses success at 05e3482, no rerun and no flake. The heavy daemon-gated lane (ci::test-daemon@x86_64-linux, KOLU_DAEMON_TESTS=1) ran 389 pass · 0 fail across 56 files in 78s — the epoch-gate e2e and the G-round fault-wiring pins included. Receipts: PR checks · commit statuses at 05e3482.

Round: the mute death (kolu#2101 G-round, deploy #2)

The second deploy incident had a different signature from the first. The epoch gate stopped a client attaching to a peer that could not speak; this one is about a process that keeps answering after its insides have died — alive, gate held, socket accepting, runtime dead — and about every channel that was supposed to report it being either absent, silent, or filtered away.

kolu's fix is framework-wide (callback containment, cell-local poll seeds, a typed onFault, a daemon fault-exit arm, an explicit frame cap). drishti had two instances of the class and one near-miss. This section is the audit, with the disposition for each.

The G-round audit

Delta drishti's exposure Disposition
DaemonSpec.faultSignal / armRuntimeFaultExit — observing runtime.done with log-and-continue is now contractually wrong Exposed, differently than expected. drishti already treated a fault as fatal — but via a bare process.exit(1) inside the runtime builder Fixed. Rides armRuntimeFaultExitfaultSignal
Same, parent side Exposed. The per-host bridge's implementSurface(...).done was never read at all Fixed, loud but deliberately non-fatal
MirrorRemoteSurfaceOptions.onFault (typed MirrorFault) One call site, pumpRemoteSurface, passing log only Adopted. Faults now on their own channel
mirror.done rejects on member fault / settles promptly Same call site; the promise is voided No change — residual restated below
Reactor poll cells: T+0 seed failure is now cell-local Five poll sources, all with throwing reads No change needed — and a latent bug disappears
websocketLink throwing url()/connect() thunk None — zero websocketLink calls N/A, argued below
frameLimit / rpcSerializationLayer Every serve site is a kolu primitive Inherited, no drishti edit
Framework callback containment Consumer callbacks throughout Free — strictly a safety gain

The agent daemon exited on a fault — around the shutdown spine, not through it

buildAgentRuntime carried void built.done.catch(… process.exit(1)). The verdict was already right, which is exactly why this was easy to miss: a runtime fault is structural death, and drishti said so. The mechanism was wrong. A bare exit from inside the runtime builder skips daemonMain's teardown, so the unix socket and the pid gate were never released and the history ring was never flushed. The successor then met a gate naming a dead pid, and the ring had lost every sample since the last 30 s persist.

It now rides armRuntimeFaultExitDaemonSpec.faultSignal: ordered teardown, the ring flushed as last rites (drishti's analogue of padi's final-session capture — the ring is the one piece of agent state a respawn cannot reconstruct), and an exit that daemonExitCode scores runtime-fault, which is the supervisor's only channel for that was a crash, not a stop.

The observation moved out of runtime.ts entirely. A library that builds a runtime has no business killing a process it does not own — and the test path (serveAgent) has no daemon to exit at all. done is handed out; the daemon binary decides.

The parent dropped its bridge's fault on the floor

router.ts's per-host implementSurface(mirroredAgentSurface, …) runtime had its done never read. That is the silent half of the same class, parent-side: the bridge's reactive wiring dies, the host's projection stops updating, and nothing anywhere says so.

It is observed now. Deliberately not fatal, and the asymmetry is the point: the parent serves every host, and one bridge dying is not grounds to take the other hosts' canvases down with it. So the disposition is a loud, attributable line, and the residual is named in the code rather than implied — a host whose data is frozen behind a chip the session still calls connected. What the UI should say there is a product call, not something to smuggle into a pin bump. It joins the pumpRemoteSurface residual already flagged in this PR; they are the same open question seen from two angles.

Faults stopped sharing a channel with chatter

The pump passed log and nothing else, so a member's stream dying read as narration.

Worth being precise about what drishti was and was not exposed to. kolu's incident was mute: its fault line went to log.debug, which production filters out, so a dead mirror produced not one line. drishti's Logger is a bare (line: string) => void with no levels and no filtering — every line reaches stderr — so the disappearing-log half could not happen here. What drishti lacked was the distinction.

onFault now carries a FAULT-prefixed line with the error's own stack, and reports scope verbatim: member (the mirror can never re-subscribe; the pump's promise rejects) and key (one key died, the host's mirror survives by design) must not look alike, or a survivable fault reads like a dead host.

Two audits that needed no code, and are not "no-ops"

Poll cells — and a latent boot-death that disappears. drishti has five reactor poll sources (processes, unclaimedListeners, sourceErrors, cpuCores, networkInterfaces), every one with a read that genuinely throws — readProcesses() and friends raise OsfactsSourceError when a facet is missing. Nothing in drishti relied on a T+0 seed failure being fatal: the recurring tick already swallows read errors and keeps serving, and the one seed-is-fatal path (readSystem() at construction) is a plain await, not a reactor cell.

The interesting part is the direction of the change. Before this release a T+0 seed throw was fatal to runtime.done — which drishti's old code turned into process.exit(1). So a single transient osfacts hiccup at daemon boot would have killed the daemon, and with the fault-exit wiring above it would now do so properly, which is worse, not better. The G-round makes that seed failure cell-local (hold the default, retry next tick), so the combination is correct: transients no longer reach the fault channel, and only structural death does. drishti gets a latent boot-fragility removed for free, and the fault-exit stays meaningful because it can no longer fire on a hiccup.

websocketLink — genuinely not applicable. Zero calls. drishti's browser client dials through connectSurfaces (@kolu/surface-app/solid), so the framework owns the url()/connect() thunks and the re-dial classification lands entirely inside kolu. drishti's one URL thunk (wsBase()) is passed to connectSurfaces and is now covered by the framework's own containment.

The frame cap: inherited, with one residual measured rather than asserted

Every drishti serve site is a kolu primitive — daemonMainserveOverUnixSocket, surfaceAppLayer, serveSurfaceSocket, serveHostMap — and each applies rpcSerializationLayer internally now. So the 16 MiB cap is explicit on drishti's wire without a drishti edit, and there is no silent-Effect-default left anywhere in the path.

The payload audit found one bounded and one unbounded shape.

metricHistory's snapshot frame is the largest bounded one, and it is pinned as a number rather than a claim: 30 min retention ÷ 2 s poll = 900 samples, generously padded to 160 B each ≈ 144 KB — over 100× under the cap, asserted in runtimeFaultWiring.test.ts so a future retention or cadence change that erodes the headroom fails there instead of on a user's socket.

The residual is the processes collection. Nothing caps the process count, and each row carries an unbounded listeners array; only the per-row command and cwd strings are truncated (200 chars). At ~400–600 B/row that is roughly 30k processes to reach the cap — implausible on an ordinary host, less so on a container-dense box, and the listeners array is a multiplier that lowers the threshold on a socket-heavy one. It is flagged rather than fixed: capping it means silently dropping processes from a process monitor, which is a product decision about what the user is owed, and inventing that in a pin bump is how a monitoring tool quietly starts lying. The cap's consequence is worth stating plainly — an oversized frame closes the whole socket (1009), so it would take every unrelated subscription on that tab with it, not just the process list.

Falsified

packages/agent/src/runtimeFaultWiring.test.ts pins both halves of the fault wiring (the exit is gone from the builder, done is still handed out; main.ts arms off runtime.done, passes faultSignal, and uses flushRing as last rites) and the frame-limit headroom. Reverted to the bare process.exit, the two wiring pins fail and the headroom pin correctly does not, being independent of it.

These are source pins, and deliberately so: the wrong version of this code also compiles and also handles the fault — the old process.exit(1) did both. The defect was never a missing handler, it was a handler that skipped teardown, which is invisible to tsc and visible only in the shape of the call. One wrinkle worth recording: the pins strip comments before matching, because the rationale comments quote the very process.exit they exist to explain — a test that punishes documenting the fix is worse than no test.

Round: the stdio epoch gate, and drishti's fleet arm (kolu#2101 F5b)

kolu's production incident — every remote host wedged in a permanent connect loop — turned out to be a framework-level hole, so the fix is framework-level and drishti takes it the same way it takes everything else in this PR: by re-pinning. stdioLink now REQUIRES an un-forgeable StdioReadinessProof, minted only by awaitStdioReadiness off a peer's own ready banner; duplexWireLink is no longer exported; serveOverStdio greets at boot; sshConnector reads the banner before it will build a client. The invariant is no pinger before a proven epoch.

kolu's review comment asks this PR to answer one question directly (F5b): does drishti's fleet arm have the same class?

It does, and it had more of it than kolu's remote arm did.

drishti-agent --stdio handed frontDaemonOverStdio a socket path and spliced into whatever accepted — no gate read, no identity probe, no epoch check. kolu at least ran the full convergence kit on its local arm; drishti's only supervisor of an agent daemon was the parent's convergeAdmit, an ssh hop away from the gate file, the pid table and the signals it would need to act on. And drishti's agent daemon is resident: it idle-exits after 60 minutes, so it routinely outlives a deploy. A previous-epoch drishti agent is not a rare race — it is the ordinary state of every host between a deploy and the next idle timeout, which is exactly the population the incident wedged.

The F5b audit — every stdio site in the repo

Swept for stdioLink, duplexWireLink, socketDuplexLink, serveOverStdio, frontDaemonOverStdio, unixSocketLink, sshConnector and any hand-rolled splice across all of packages/.

Site Class Action
agent/src/main.tsdrishti-agent --stdio Daemon front over a RESIDENT daemon — the incident class Converge-before-relay pre-step + readiness banner
agent/src/fixtures/{high,low}ContractMain.ts (via fixtures/skewFront.ts) Daemon front (contract-skew fixtures, same shape) Same pre-step, shared verbatim — see below
agent/src/dialDaemon.testlib.tsdialOverStdio Direct stdioLink construction Mints a real proof via awaitStdioReadiness on the front's own stream
agent/src/convergeFront.tssocketDuplexLink (new) Local unix rendezvous Documented residual; its epoch safety is the very thing the converge establishes
agent/src/dialDaemon.testlib.tsunixSocketLink Local unix rendezvous Unchanged; no proof owed
app/src/server/hostRegistry.tssshConnector ×3 Dialer No source change; it now awaits the banner. The boot barrier around it needed one (see below)
serveOverStdio Not used. drishti's daemon serves a unix socket; nothing here is an ephemeral stdio agent
duplexWireLink Not used. The removed export costs this repo nothing

Two .pipe( hits in the sweep are Effect pipes, not stream splices.

The front converges, then greets, then relays

The pre-step runs the real kit on the box where the facts live: the probeDaemonIdentity factory (the raw dial with the byte tap and the silence deadline — the only thing that can see a previous-epoch peer, unlike the assembly-only probeDaemonIdentityFrom the parent's admit uses), gate corroboration against the pid table, takeover via reapHolder, and reExecAsDetachedDaemon as the driver so a replacement is an agent of this closure. Converged, it greets ready and splices. Unconverged, it writes a refused banner carrying the typed anomaly and exits non-zero without relaying.

A side effect worth naming: drishti's unspeakable-protocol daemon-status arm has been dead-but-typed since it was written. Its only producer is the framework's endpoint-arm converge, which drishti never called — every path went through convergeAdmit, which structurally cannot emit it. This pre-step is its first producer, so the arm now means something.

The front's policy is deliberately NOT the parent's

This is the one real design decision in the round, and it is worth stating plainly because the obvious move is wrong.

The front's question is the epoch question, and only that: does a daemon that speaks this wire at all hold this rendezvous? That is what the banner attests, and it is what the incident turned on — a previous-epoch peer accepts the splice and then says nothing, so no amount of in-epoch reasoning can even begin.

Every in-epoch verdict — contract skew, build mismatch, the drain budget, the standing anomaly, the degraded chip, the renew affordance — stays with the parent's convergeAdmit. Those already work end to end and have been tested since W6, and the parent is the only one of the two supervisors with a UI.

So the front's policy is not-drainable, with refuse on contract skew and nudge-human on build mismatch — both meaning leave the survivor standing and report it. not-drainable is the structural statement: with no drain arms and no budget spellable, a front cannot quietly grow into a second adjudicator of skew.

The discriminator is a skew-refused outcome, which means the resident answered hello — and answering hello is what being in-epoch means. So the banner may honestly certify it, and the front relays so the parent can do its job. Handing the front the parent's policy instead looked right for about an hour and was measurably wrong: it went terminal on daemons the parent knows exactly how to drain and replace, and it took four of drishti's own e2e lanes down with it, because the front refused before the parent ever saw the resident. In production it would have made the whole skew → degraded → renew path unreachable.

That the two contract-skew fixtures now run the production front verbatim — no override, no fixture-specific arm — is the small proof that the layering is right.

The boot barrier grew a second observation point

withAgentBootBarrier turns "the agent died with a fatal stderr line" into a terminal ConnectError so a planted 0755 state dir gives up immediately with the agent's own sentence, instead of retrying forever as "host unreachable".

It used to have exactly one place to observe that, because sshConnector returned a Connection unconditionally — it spliced the child's stdio into an RPC client without asking the peer anything — so a boot refusal could only ever surface downstream as conn.closed. The readiness gate moves that observation earlier: the connector now waits for the banner, races it against the child's own exit, and a child that dies without greeting makes the connector throw rather than return.

So the barrier catches the throw and applies the same classification. This is not a fallback — it is the same verdict at the same instant, reached by the new control path. Without it, U3.1 regressed from "terminal, with the agent's own sentence" to "host unreachable", which is precisely the retry-forever class the gate exists to abolish. A throw with no fatal line is rethrown untouched: that is the connector's own classified verdict, and relabelling it here would take the gate's terminal budget away from the one authority that owns it.

The policy graduated out of the app

drishtiAgentConvergencePolicy lived in app/src/server/hostRegistry.ts for the only reason that mattered at the time — the parent was the only supervisor. There are two now, and the agent's Nix closure is a positive projection of packages/agent + packages/common, with packages/app deliberately not an input. A policy left app-side is therefore not merely awkward for the front, it is structurally invisible to it. It moves to drishti-common/convergence-policy (its own export subpath, so the browser bundle never pulls daemon-supervision code), with hostRegistry re-exporting so no import path moved — the source of truth moved, not the API.

One typing trap is pinned in a comment for the next editor: neither policy factory may grow an explicit : ConvergencePolicy<…> return type. ConnectorPolicy, which createConnectorDrainBudget demands, is narrower on onContractSkew; annotating the return widens the literal and the connector arm stops compiling. as const satisfies proves conformance without erasing which arm was chosen.

Closure and lockfiles

@kolu/surface-daemon-supervisor joins the agent's hydrated set, and ts-pattern joins agent.package.json — a real runtime import of that kit (probeDaemonIdentity, index), unlike @kolu/log, which surface-daemon takes as import type only and therefore needs no entry. The regeneration is exactly one tarball in agent.lock + agent.bun.nix, no transitive movement; the root bun.nix does not move at all because the app already resolved ts-pattern at the same version. The kit runs in the fronting process and never in the daemon, so it cannot change what a daemon restart loads.

Effect stays at 4.0.0-beta.103 — no vendored manifest gained a dependency this round (kolu's surface/package.json gained only the ./links/readiness export entry), so bun install reports no changes and the pin really is just the pin. The npins hash was verified rather than assumed: the outgoing revision was re-prefetched first and reproduced its recorded sha256-L8uY… exactly, which is what makes the new sha256-SJ/r… trustworthy.

The falsifier

agent/src/epochGate.e2e.test.ts reproduces the incident's cause rather than mocking its symptom. fixtures/muteEpochDaemon.ts is a previous-epoch daemon written as its observable behaviour — it holds the rendezvous with a real pid gate, and when something connects it accepts and says nothing, which is what a daemon waiting for a greeting nobody speaks any more actually does. No old release is vendored; the silence is the presentation.

Two e2es assert the inverse of the incident: one takeover and one clean converge, and then a second front that adopts in place — same pid, no drain, no respawn — because a re-takeover on every dial is the livelock wearing different clothes. Three pure tests pin the epoch-only policy, which is the layering decision no typechecker can see and the one most likely to be "fixed" back into a bug.

Falsified against the fix reverted: both e2es fail with the incident's own signature — the peer accepted the pipe and sent no readiness banner within 30000ms — the blind splice caught at the gate instead of ten seconds later as a nondescript transport death. The three policy tests correctly keep passing, since they do not depend on the front's wiring.

Effect 4.0.0-beta.103

kolu moved the whole stack from beta.102 to beta.103, including the seven vendored @kolu/surface* literals this repo hydrates out of the Nix store. A hydrated source and its consumer must agree on ONE copy of effect — two module instances mean _tag narrowing silently stops recognising anything — so drishti's own pins move in the same commit as the pin that brings the new sources in.

Six authored sites carry the literal here: the root overrides, the three package manifests, the agent's projected agent.package.json, and the heredoc inside scripts/regenerate-agent-deps.sh that reconstructs the agent-only workspace. The last one is the one that hides: leave it and the next regeneration silently re-pins the projection back to beta.102. Both lock/nix pairs regenerate, and the delta is exactly the three effect tarballs (effect, @effect/platform-node, @effect/platform-node-shared) — a url + integrity line each, no transitive dependency moved.

The npins hash was verified rather than assumed: the outgoing revision was re-prefetched first and reproduced its recorded sha256-3Gpv… exactly, which is what makes the new sha256-L8uY… trustworthy. branch also goes back to effectosfacts-effect was a short-lived kolu branch that has since merged and been deleted upstream, so the field named a ref npins update could no longer follow.

No source adaptation was needed, and that is a swept result, not a hope. beta.103's removals were grepped for across every .ts/.tsx in packages/: no SchemaIssue / getActual / Schema.redact, no Schema.UnknownFromJsonString, no SchemaMultiDocument, no Context.mutate or Context.getReferenceUnsafe, and the single Schema.Record call site (main.ts's agent-drv map) is the plain two-argument form the keyValueCombiner removal leaves alone. Nothing here touches Effect's Clock, so the monotonic/wall-clock split lands on nobody. The microtask-dispatch and Atom fixes are the ones that could have bitten — this repo runs Effect under bun — and the async/reactive suites are green: 375 pass · 5 skip · 0 fail across 54 files locally, and the full daemon-gated e2e lane clean on both platforms in CI.

What the review round changed here

Eight kolu commits, and only one of them is visible from this repo.

pumpRemoteSurface gained an exit case. A mirror that ended while its link was still alive — every subscription settled — used to park cursor.next() forever: a pump silently serving nothing behind cleared holders, waiting for a spawn that was never coming. Each mirror end now races that wait against a system.live round-trip on the client that just ended; a far end that still answers ends the pump instead. drishti rides this pump at exactly one site — router.ts's agent→parent bridge — and needs no change: the exit is a plain return from a Promise<void> that drishti already discards, no test asserts the pump parks, and the exit line does reach the parent's log because drishti passes log.

It is worth naming the residual rather than pretending the absorption is total. drishti voids that promise, and this is the first way the pump can resolve with the session still alive and still reading connected — the pump is deliberately not the authority for link health (serveHostMap + session.onState is). So a bridge that ended this way would leave a green host chip over frozen data. No test reaches it, the drain race that could reach it is unproven in either direction, and what the UI should say in that state is a product call rather than something to smuggle into a pin bump — so it is flagged here, not fixed here.

The other seven are kolu-internal (a preference-ladder fix, a typing-echo bench port, a client session-restore race and the follow-up that bounds its wait, comment-only docblock edits). The newest of them, 41d517754the restore seed's wait for the answered tile is bounded — is packages/client only, and drishti vendors none of client: the hydrated set is surface, surface-remote, surface-map, shell-quote, log, surface-app, solid-pwa-install, surface-daemon, surface-daemon-supervisor. No vendored manifest moved across the whole round — only packages/server gained a dependency and drishti vendors none of server either — so bun install reports no changes, and bun.lock, bun.nix, the agent projection (agent.lock / agent.bun.nix) and the three effect-override sites are all untouched. The re-pin is the pin.

The scanner this repo never had

kolu's round also grew governance for the exact silent-failure class this whole campaign is about (#2101 B1): await on a member face compiles, resolves with the Effect object, and never touches the wire. drishti had adopted that discipline during campaign 2 but never made it red — the rule is written in prose three times (wire.ts's runCall, dialDaemon.testlib.ts's unary(), hostRegistry.ts's note that the drain is an Effect value) and enforced by nothing. It held only because two well-written confinement helpers existed and everybody used them.

A verbatim copy of kolu's scanner would have been worse than no scanner, because it would have looked guarded while passing vacuously over the entire client tier. kolu's pattern requires a literal .surface. / .procedures. path segment; drishti spells its faces as functionshostRpc(host), adminRpc(), hostStreams(host), hostCollections(host) — so await adminRpc().hosts.add(x), the likeliest dodge in this repo, scores clean under it. The twin teaches the accessor call to be a face, and takes the round's two new dodges with it (the alias const verb = …; await verb(x), the stored description const p = …(x); await p) plus the outright ban on an Effect.run* that is named but never called.

The sweep it pins is clean — all 121 .ts/.tsx files under packages/, zero hits in every category, so this fixes nothing and prevents something. Its own falsifiability is asserted in-file: each clause catches its dodge, each legitimate spelling (runCall(...), Effect.runPromise(...), yield*) survives on the nesting parens that make the scan possible at all, and a file-count floor separates a clean tree from a walk that found nothing. runCall is deliberately not banned as an uncalled reference — a bare identifier would flag its own declaration and every import of it.

What campaign 2 added, and the one hazard it left behind

The second campaign flipped the member face to Effect-only (UnaryProcedure / BoundProcedure / safe / isDefinedError deleted, the .effect nesting folded onto .surface), flipped the whole supervisor face to Effect, and took hono out of @kolu/surface-app. Most of that the compiler catches.

One class of it the compiler cannot. An Effect is a description: awaiting one compiles, resolves with the Effect object, and never touches the wire. kolu bit on this nine times, including a test that had quietly disabled the drain it existed to prove. In this repo the exposure was not the call sites — it was the two places that typed a face as a Promise shape through an as unknown as cast. Both were true at the old pin and became lies at this one, with nothing to catch them:

  • hostRegistry.ts's ControlProbeClient is now an alias of the framework's own ControlCoreProbeClient, not a hand-written twin. A twin restates a shape that moves whenever the frozen fragment does — and it just did.
  • dialDaemon.testlib.ts cast three faces to Promise signatures. It runs the Effects now, behind one named unary() helper, so await client.control.hello() at every call site is honest again. Without this the agent's drain/adopt e2e would have passed while dispatching nothing.

The two source-grep tests that pinned drainPersistFailureOf(await active.drain()) now pin the composed spelling and negatively pin the awaited one.

The exit oracle lost its AbortSignal

awaitExit is an Effect<void> now, and awaitExitViaProcessOracle with it. The signal had exactly one job — tell a poll-shaped wait to stop once the drain ceiling won — and convergeAdmit forks the oracle into a scope it closes on every path, so the wait is interrupted rather than notified. Interruption is not refusable; an abandoned AbortController was. drishti's own drain is an Effect value, like the framework's fireDrain, so it cannot be awaited into a no-op either.

hono is gone

installSurfaceApp(app, opts) became surfaceAppLayer(opts) — an HttpRouter layer, not an installer — so the parent's whole HTTP app moves onto effect/unstable/http. It owns its http.Server and hands the request event an Effect handler, which is precisely what leaves the upgrade event ours: the stale-tab gate and the ws reaper have to run in front of dispatch. Registration order stops meaning anything (find-my-way ranks by specificity), so the two ordering comments go with the framework instance. hono, @hono/node-server and — the reactor runs on Effect's Atom now — @preact/signals-core leave all three manifests.

Three findings that shaped the work

The one honest redesign is the drain. drishti used to smuggle a failed final ring-write out of the frozen control-core drain as ORPCError("DRISHTI_PERSIST_FAILED"). That channel declares no error and no output, and the new epoch is explicit that a rejecting onDrain is a defect — "a daemon whose drain hook throws is broken, not busy". But a full disk is not a broken daemon: the drain worked, and its final write did not land. That is a verdict about a successful call, so it needs a value channel, not an error one.

So drishti declares its own: daemon.ring.drain, on a new sibling beside the frozen fragment, answering { persisted, error? }. A sibling and not an app member, because the app surface is re-served verbatim to the browser — a drain verb there would hand every tab the authority to stop any host's daemon. Both verbs drive one latched drainNow(), so a generic supervisor speaking only the frozen fragment still gets a correct final write (it just does not learn the verdict), and calling both flushes exactly once. captureDrainPersistFailure — an error-narrowing function with a cross-realm plain-object arm — is deleted outright; there is no error to narrow any more.

The best win is a deletion. admin-router.ts hand-built a server-only widened oRPC contract and re-adapted two finalized routers through it with an as any splice — the most delicate ~40 lines in the repo, and the reason a host-map subscription could 404 while everything else stayed green. It existed because an oRPC contract was a nested matcher tree. On a flat tag namespace a tag carries its own route, so the splice is a record merge; adminContract disappears as a name and as a concept. What replaces the deleted matcher-tree reasoning is a route-set assertion in both directions, because on this wire the tag set is the contract.

A lease stopped being a finally, and that mattered. isIdle — the oracle a 60-minute daemon idle-exit hangs off — counts live metricHistory subscriptions, and used to decrement in the generator's finally. Under Stream that leaks: the framework's AsyncIterable bridge deliberately never calls .return() on a producer (awaiting it deadlocks a generator parked at an await), so the finally does not run when a consumer walks away. It is an Effect.acquireRelease on the stream's own scope now. A leaked lease would have kept a forgotten daemon alive forever.

Spikes, run before committing to the plan

Spike Verdict
@effect/platform-node transports under bun PASS, and re-run at the final pin. All four legs round-trip a unary call (including a withDecodingDefaultKey absent key), a stream, and a cell: serveOverUnixSocketunixSocketLink in-process; a real bun child serving over serveOverStdiostdioLink; and the frontDaemonOverStdio byte splice (stdio client → unix server). Re-running it at 3c631446d needed exactly one change — the unary leg had to run its Effect rather than await it, which is the hazard above showing up in the spike itself.
TS 5.8 vs the ~40-tag spec-derived face PASS. The whole workspace typechecks under the repo's own typescript@^5.8 (5.9.3) with zero errors and no TS2590. No bump to typescript@7 needed.

One pre-existing bun quirk found and worth recording, unrelated to Effect: under bun test (1.3.10) a handled connect ENOENT on a unix path escapes as an uncaught error and fails the test — unless a net.Server has already been created in that process. It reproduces identically on master with the old oRPC pin, so it is bun's node:net client path, not ours. It never bites drishti in practice (the daemon serves in forked children, and dials target live sockets).

The #17 mapping table as law

Every wire and disk schema moved under it: z.enumSchema.Literals, z.tupleSchema.Tuple, z.discriminatedUnion("kind", …)Schema.Union (never TaggedUnion — that renames the discriminant to _tag and changes the bytes), .optional()Schema.optionalKey, .default("TERM")Schema.withDecodingDefaultKey.

The two landmines this repo has sit in the same procedure (process.kill's defaulted signal and optional error), so they are pinned on the encoded JSON string — a decode-equality test would pass happily while "error":null started appearing on every successful kill. Same for all four MetricHistoryMessage arms, the alerts cell, and history.ring.json, where the fixtures also pin that an absent alerts key stays absent and that an explicit null is corrupt — precisely the divergence Schema.optional would have introduced.

Also in here

  • The dial is one wire with three faces. sshConnector takes the surface as a value now (Effect RPC cannot mint a client from a type). drishti dials with the app sibling's spec/prefix carrying the combined group, and builds the control-core and daemon faces over the same Connection.dispatch. scopeSibling(client, "app") is deleted — the connector's own client already is the app-scoped one, so the WeakMap is keyed by the very value admit receives.
  • The client bootstrap is async. connectSurfaces returns a promise, so wire.ts awaits it at module scope: every consumer's import stays synchronous-looking instead of ~40 call sites learning the wire might not exist yet. The host map's tags ride extraGroups — the client twin of the server's merge, because Effect RPC resolves a call's schemas by tag lookup and a tag outside the group cannot be dispatched at all.
  • Both AbortControllers in App.tsx are gone. unenrolledStreamCall returns a Stream and createSubscription owns teardown as a fiber interrupt — the controllers were that interrupt, spelled by hand. partysocket's 60s cold-start connectionTimeout goes too, and its absence is the point: it existed to stop a 4s default from abandoning a dial while nix copy ran, and the Effect link never times an attempt out at all.
  • daemonStatus.ts gains the sixth unspeakable-protocol arm — a daemon from the previous protocol epoch whose first frame this supervisor cannot decode. Both exhaustive switches grew a case, which is exactly what the closed union is for.
  • The client has ONE run edge, wire.ts's runCall. Solid's event handlers and store writers are synchronous callbacks with no Effect slot to compose into, so the seam is real; naming it once keeps it countable instead of letting Effect.runPromise spread across seven call sites.
  • Dependencies: @orpc/*, zod, partysocket, hono, @hono/node-server and @preact/signals-core are gone; effect + @effect/platform-node land at the literal 4.0.0-beta.103 the hydrated sources declare, in all three override sites in lockstep (root manifest, agent projection manifest, and the heredoc inside regenerate-agent-deps.sh) — a fourth copy that drifts is how the agent projection silently diverges.

Evidence

Beyond CI, the built binary was driven end to end in a real browser — and re-driven at the final pin, after the HTTP stack moved off hono: 519 processes streaming through the collection deltas verb, 16 CPU cores, 3 NICs, 8 unclaimed listeners, live load/mem/swap/disk, and the metric-history chart — every primitive class on the surface, over the new wire, with a clean console. The identity.info probe resolving on both the SRV and CLIENT footers is the surface-app sibling answering over the same socket.

The freshness contract was curled straight off the new Effect HTTP layer, under bun: / and /t/abc and a missing /foo.png → the no-store shell; /assets/<hashed>.jspublic, max-age=31536000, immutable; /assets/gone-999.js404 + no-store, never the shell; /sw.jsno-cache, must-revalidate + text/javascript; /manifest.webmanifestapplication/manifest+json; and a request carrying the response's own ETag back still gets 200, never a 304 — the suppression that keeps a Nix-store epoch mtime from replaying a stale shell (kolu#1319).

The host-map fold was also exercised directly over a real WebSocket against the built server (surface/hosts/entries/keys, a folded per-host system cell, and metricHistory) — the exact tag class the deleted widening seam used to 404 on.

🤖 Generated with Claude Code

srid added 4 commits August 3, 2026 09:25
….0.0-beta.102

kolu's `effect` branch (juspay/kolu#2101) replaces oRPC + zod with Effect RPC
and Effect Schema across the whole @kolu/surface* stack. drishti vendors those
sources from the Nix store, so it takes the same migration or it does not
compile.

This first wave moves the ground the rest stands on:

- npins: kolu → branch `effect`, rev b0db04a4c (the unmerged PR head; it is
  re-pinned to the merge commit when kolu#2101 lands).
- the three manifests drop `@orpc/*`, `zod` and `partysocket` and declare
  `effect` + `@effect/platform-node` at the LITERAL `4.0.0-beta.102` the
  hydrated sources declare — kolu PLAN #22 exists because of this vendoring,
  and `_tag` narrowing across two `effect` copies silently stops recognising
  anything.
- the same pin lands in all THREE override sites in lockstep (root
  package.json, packages/agent/agent.package.json, and the heredoc inside
  scripts/regenerate-agent-deps.sh) — a fourth copy that drifts is how the
  agent projection silently diverges from the root lock.
- both lock/nix pairs regenerated (`bun.nix`, `agent.bun.nix`).

And it converts the agent surface itself, schema for schema, with the #17
mapping table as law: `z.enum` → `Schema.Literals`, `z.tuple` →
`Schema.Tuple`, `z.discriminatedUnion("kind", …)` → `Schema.Union` (never
`TaggedUnion` — that renames the discriminant to `_tag` and changes the
bytes), `.optional()` → `Schema.optionalKey`, and `.default("TERM")` →
`Schema.withDecodingDefaultKey`. The last two are this repo's only two #17
landmines and they sit in the same procedure, so `surfaceWire.test.ts` pins
them on the ENCODED JSON STRING — a decode-equality test would pass happily
while `"error":null` started appearing on every successful kill.

The same file also pins the surface's exact wire tag set. On a flat tag
namespace the tag set IS the contract, so that assertion is the successor to
the oRPC-era matcher-tree reasoning.
…a verdict it can say

The agent's serving path loses its last `any`. `SurfaceRuntime.router` is gone;
what a daemon serves now is a flat `RpcGroup` plus a tag-keyed handler record,
so `daemonMain({ group, handlers })` replaces `daemonMain({ router })` and both
`biome-ignore noExplicitAny` comments that existed only to carry
`Router<any, any>` go with it. Procedure impls return `Effect`; the
`metricHistory` source returns a `Stream`.

## The lease had to stop being a `finally`

`isIdle` — the oracle a 60-minute idle exit hangs off — counts live
`metricHistory` subscriptions, and it used to decrement in the generator's
`finally`. Under `Stream` that would leak: the framework's AsyncIterable bridge
deliberately never calls `.return()` on a producer (awaiting it deadlocks a
generator parked at an `await`), so the `finally` does NOT run when a consumer
walks away. The lease is now an `Effect.acquireRelease` on the stream's own
scope, which fiber interruption always closes. A leaked lease would have kept a
forgotten daemon alive forever.

## DRISHTI_PERSIST_FAILED, redesigned rather than translated

`ORPCError("DRISHTI_PERSIST_FAILED")` thrown from the frozen control-core drain
has no honest Effect spelling. That channel declares no error, and the new epoch
is explicit that a rejecting `onDrain` is a DEFECT — "a daemon whose drain hook
throws is broken, not busy". A full disk is not a broken daemon: the drain
WORKED, and its final write did not land. That is a verdict about a successful
call, so it needs a value channel, not an error one.

So drishti declares its own: `daemon.ring.drain`, on a new
`daemonControlSurface` SIBLING beside the frozen fragment, answering
`{ persisted, error? }`. A sibling and not an app member, because the app
surface is re-served verbatim to the browser — a drain verb there would hand
every tab the authority to stop any host's daemon.

Both verbs drive ONE latched `drainNow()`, so the frozen `control.core.drain`
still gives a generic supervisor a correct final write (it just does not learn
the verdict), and calling both flushes exactly once. `onDrain` no longer throws
at all.

Disk schemas (`history.ring.json`, the rate baselines nested in it) move to
Effect Schema under the same #17 law, with byte fixtures asserting the exact
JSON string — including that an absent `alerts` key stays absent and that an
explicit `null` is corrupt, which is precisely the divergence
`Schema.optional` would have introduced.
… browser on Effect RPC

## The best win in the campaign is a deletion

`admin-router.ts` used to hand-build a SERVER-ONLY widened oRPC contract
(`oc.router({...adminContract, surface: {…, hosts: map.surfaceContract}})`) and
re-adapt two finalized routers through it with an `as any` splice — the most
delicate ~40 lines in the repo, and the reason a host-map subscription could
404 while everything else stayed green. It existed because an oRPC contract was
a nested MATCHER TREE: the client-shared 2-sibling contract had no route for
`hosts.*` no matter what extra keys the handlers object carried.

On a flat tag namespace a tag carries its own route. `serveSurfaceMap` already
binds at full wire tags with the `hosts/` prefix baked in, and
`implementSurfaces` does the same for the two siblings — so the host merges two
`{group, handlers}` pairs. `adminContract` is gone as a name and as a concept;
what replaces the deleted matcher-tree reasoning is a route-set assertion in
both directions, because on this wire the tag set IS the contract.

Everything else follows the framework's own breaks: `directLink` →
`directDispatch`, `linkFor` → `dispatchFor`, `AgentClient<C>` → the non-generic
face, `RPCHandler.upgrade` → `serveSurfaceSocket({group, handlers, socket})`
behind the UNCHANGED gate → enrol → dispatch order (the upgrade stays ours
precisely because owning it is what lets the stale-tab gate and the ws reaper
run in front of dispatch).

## The dial is one wire with three faces

`sshConnector` takes the SURFACE as a value now — Effect RPC cannot mint a
client from a type. drishti dials with the app sibling's spec/prefix carrying
the COMBINED group, and builds the control-core and daemon faces over the same
`Connection.dispatch`. `scopeSibling(client, "app")` is deleted: the
connector's own client already IS the app-scoped one, so the WeakMap is keyed
by the very value `admit` receives rather than by a re-wrap.

## Client

`connectSurfaces` is async, so `wire.ts` awaits it at module scope — every
consumer's import stays synchronous-looking instead of ~40 call sites learning
the wire might not exist yet. The host map's tags are declared as
`extraGroups`, the client twin of the server's merge: Effect RPC resolves a
call's schemas by tag lookup, so a tag absent from the group cannot be
dispatched at all.

Both `AbortController`s in `App.tsx` are gone. `unenrolledStreamCall` returns a
`Stream` and `createSubscription` owns teardown as a fiber interrupt — the
controllers were that interrupt, spelled by hand. `partysocket`'s 60s
cold-start `connectionTimeout` is gone too, and its absence is the point: it
existed to stop a 4s default from abandoning a dial while `nix copy` ran, and
the Effect link never times an attempt out at all.

`daemonStatus.ts` gains the sixth `unspeakable-protocol` arm — a daemon from
the PREVIOUS protocol epoch whose first frame this supervisor cannot decode.
Both exhaustive switches (server projection, client presentation) grew a case,
which is exactly what the closed union is for.
`surface.test.ts` already pins the mutation blast radius of the surface the
browser sees: exactly one procedure namespace, exactly one verb. Adding
`daemon.ring.drain` did not trip it — because the drain deliberately lives on
a SEPARATE sibling that the parent never re-serves.

That separation is load-bearing and currently only a comment, so it gets a
test: the drain tag is absent from the mirrored surface and from
`browserSurface`, present on the composed daemon wire at
`surface/daemon/ring/drain`, and the three-sibling merge drops nothing.
@srid

srid commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Evidence

The built binary (nix build .#default), driven in a real browser against a live localhost host — the whole chain on the new wire: browser → websocketLink → the parent's serveSurfaceSocket → the host-map fold → pumpRemoteSurfacesshConnector → the agent daemon over stdio + unix socket → osfacts.

Fleet view — the host map's entries membership and the folded per-host system cell:

fleet

Host view — every primitive class on the surface at once: 447 processes through the collection deltas verb, 16 CPU cores, 3 NICs, 8 unclaimed listeners, the alerts cell, and the metricHistory stream:

host

Console: clean (one pre-existing SolidJS "computations created outside a createRoot" warning from wire.ts's module-scope subscriptions — unchanged by this PR).

The host-map fold was also exercised directly over a real WebSocket against the same built server, which is the tag class the deleted widening seam used to 404 on:

[smoke] hosts/entries/keys        -> Some(["localhost"])
[smoke] hosts/localhost system    -> Some({"loadAvg":[0,0,0],"cpuPct":0,…})
[smoke] hosts/localhost metricHistory -> Some({"kind":"snapshot","samples":[]})

Spike A — @effect/platform-node transports under bun

Run against the pinned kolu sources before any wave was committed to. All four legs round-trip a unary call (including a withDecodingDefaultKey absent key and an optionalKey absent field), a stream, and a cell:

Leg Result
serveOverUnixSocketunixSocketLink, in-process pass
a real bun child serving serveOverStdiostdioLink pass
frontDaemonOverStdio's byte splice — stdio client → unix server pass
a dead path rejects the dial (ENOENT), preserving the "nothing is serving here" verdict pass

The three headers that described machinery this migration removed —
hostRegistry's 'combined app+control contract' + combined-client stash,
admin-router's 'spliced in afterward' widening story, and the `linkFor` /
`directLink` names in the host-map comment — now describe what is actually
there: one link with three faces, and a merge that needs no widening because a
tag carries its own route.
srid and others added 6 commits August 3, 2026 20:07
The pair rule wants this PR green against the thing that actually merges,
and kolu's `effect` branch ran a SECOND campaign after `b0db04a4c`. This
re-pins to `3c631446d` (branch `effect`, real tarball hash) and takes
everything that landed in between.

## The member face is Effect-only, and `await` on it is silent

`UnaryProcedure` / `BoundProcedure` / `safe` / `isDefinedError` are gone,
the `.effect` nesting folded onto `.surface`, and a unary verb returns an
`Effect`. An `Effect` is a DESCRIPTION: `await`ing one compiles, resolves
WITH the Effect object, and never touches the wire — the shape that bit
kolu nine times, including a test that had quietly disabled the drain it
existed to prove.

So this repo's two Promise-typed face MIRRORS were the real hazard, not
the call sites the compiler caught. `hostRegistry.ts`'s `ControlProbeClient`
is now an ALIAS of the framework's own `ControlCoreProbeClient` rather than
a hand-written twin, and `dialDaemon.testlib.ts` — which cast three faces
to a Promise shape — RUNS the Effects behind one named `unary()` helper
instead. Both casts were true at the old pin and became lies at this one,
with nothing to catch them: the tests would have passed while dispatching
nothing. The two source-grep tests that pinned `await active.drain()` now
pin the composed spelling and NEGATIVELY pin the awaited one.

The client gains its ONE run edge, `wire.ts`'s `runCall`. Solid's event
handlers and store writers are synchronous callbacks with no Effect slot,
so the seam is real; naming it once keeps it countable instead of letting
`Effect.runPromise` spread across seven call sites.

## The exit oracle lost its AbortSignal

`awaitExitViaProcessOracle(processExit, signal)` is `Effect<void>` now.
The signal had exactly one job — tell a poll-shaped wait to stop once the
ceiling won — and `convergeAdmit` forks the oracle into a scope it closes
on every path, so the wait is INTERRUPTED rather than notified.
Interruption is not refusable; an abandoned AbortController was.
`probeDaemonIdentityFrom`, `convergeAdmit` and `drainAndAwaitExit` are
Effects; drishti's own drain is an Effect VALUE, like the framework's
`fireDrain`, so it cannot be `await`ed into a no-op.

## hono is gone

`installSurfaceApp(app, opts)` became `surfaceAppLayer(opts)` — an
`HttpRouter` layer, not an installer — so the parent's whole HTTP app
moves onto `effect/unstable/http`. It owns its `http.Server` and hands the
`request` event an Effect handler, which is precisely what leaves the
`upgrade` event ours: the stale-tab gate and the ws reaper must run in
front of dispatch. Registration order stops meaning anything (find-my-way
ranks by specificity), so the two ordering comments go with the framework
instance. `hono`, `@hono/node-server` and — the reactor runs on Effect's
Atom now — `@preact/signals-core` leave all three manifests; both
lock/nix pairs and the agent projection are regenerated, with the three
override sites unchanged and still in lockstep.

Verified beyond CI: the bun-transport spike re-run at this pin (all four
legs — unix in-process, a real bun child over stdio, the
`frontDaemonOverStdio` byte splice, and a streaming member — green), and
the built parent driven end to end in a real browser: 519 processes over
the collection `deltas` verb, 16 cores, 3 NICs, 8 unclaimed listeners,
live load/mem/swap/disk and the metric-history chart, with the freshness
contract curled off the new HTTP stack (`no-store` shell, immutable
hashed assets, 404-not-shell on an asset miss, no 304 on a matching etag).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
`httpServer.address()` inside the listen callback is always an `AddressInfo`
for a host+port bind, so the two ternaries that fell back to the REQUESTED
`bindHost`/`port` were a silent-degradation path for a case that cannot
happen — and if it ever did, the log line's one job (say where we actually
bound) would quietly become a lie. Crash instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
kolu's `effect` branch took a review round after `3c631446d` — juspay/kolu#2101's
checklist — and the pair rule wants this PR green against the thing that merges.

Nothing in the seven commits moves a vendored manifest: only `packages/server`
gained a dep (the typing-echo bench), and drishti vendors none of `server`. So
`bun install` reports no changes, `bun.lock` / `bun.nix` / the agent projection
are untouched, and the three effect-override sites stay at the literal
`4.0.0-beta.102` the hydrated sources still declare. The re-pin is the pin.

The one consumer-visible change is `@kolu/surface-remote`: `pumpRemoteSurface`
now EXITS when a mirror ends while its link still answers `system.live`, instead
of parking on `cursor.next()` forever. Verified against drishti's use sites.
…ccessors

kolu's review round grew governance for the silent-failure class this whole
campaign is about (juspay/kolu#2101 B1): `await` on a member face compiles,
resolves WITH the Effect object, and never touches the wire. drishti had adopted
the discipline but never made it RED — the rule is stated in prose three times
(`wire.ts`'s `runCall`, `dialDaemon.testlib.ts`'s `unary()`, `hostRegistry.ts`'s
note that the drain is an Effect VALUE) and enforced by nothing. It held only
because two well-written confinement helpers existed and everyone used them.

A VERBATIM copy of kolu's scanner would have been worse than none, because it
would have looked guarded while passing vacuously over the entire client tier:
kolu's pattern requires a literal `.surface.`/`.procedures.` path segment, and
drishti spells its faces as FUNCTIONS — `hostRpc(host)`, `adminRpc()`,
`hostStreams(host)`, `hostCollections(host)`. `await adminRpc().hosts.add(x)`,
the likeliest dodge in this repo, scores clean under kolu's regex. So the
accessor call is taught to BE a face, alongside the round's two new dodges (the
alias `const verb = …; await verb(x)` and the stored description `const p =
…(x); await p`) and the outright ban on an `Effect.run*` named but not called.

The scan over all 121 files under `packages/` is green — this pins that, rather
than changing it. Falsifiability is asserted in-file: each clause catches its
own dodge, each legitimate spelling (`runCall(...)`, `Effect.runPromise(...)`,
`yield*`) survives on its nesting parens, and a file-count floor separates a
clean tree from a walk that found nothing. `runCall` itself is deliberately NOT
banned as an uncalled reference — a bare identifier would flag its own
declaration and every import.

Lives under `app` and not `common`: `common` is copied whole into the agent's
Nix fileset, and a governance test parked there would rotate the agent BUILD_ID
on every edit.
kolu's `effect` branch took one more commit past `8f0ce9780` — the review
round's F2 follow-up, `fix(client): the restore seed's wait for the answered
tile is bounded` — and the pair rule wants this PR green against the thing
that merges.

The commit is `packages/client` only (createSessionRestore.ts,
useSessionRestore.ts + its seam test). drishti vendors none of `client`: the
hydrated set is surface, surface-remote, surface-map, shell-quote, log,
surface-app, solid-pwa-install, surface-daemon, surface-daemon-supervisor. No
vendored manifest moved, so `bun install` reports no changes — `bun.lock`,
`bun.nix`, `agent.lock` and `agent.bun.nix` are untouched and the hydrate
script needs no edit. The re-pin is the pin.

Hash method validated by re-prefetching the OLD revision first: it reproduces
the recorded `sha256-K4XJ3d…` exactly, so the new
`sha256-nZPp6CF7ZHR9Ev+Fjy2tRi6kwhae+jHJUqJVo0NLvQE=` is computed the same way
(npins sources.json stays at its v7 schema; npins 0.5.0 refuses to read it).
osfacts-client's spawning verbs return `Effect.Effect<_, OsfactsClientError>`
now (juspay/osfacts#5). drishti consumes exactly two of them — `snapshotHost`
and `host`, both in `proc.ts` — so the adoption is two run edges and the pin
that brings the new client in.

The edges are IN `proc.ts`, not above it. `ProcReader` feeds the reactor's
`source({ read })`, whose `read` is `() => Promise<T>` by design; pushing the
Effects up would only relocate the run, and would put two Promise faces on the
same reader. This is the same call kolu made for padi's port sampler in the
paired adoption, and the same one `hostRegistry.ts` already documents at the
`Admit` seam it does not own.

`hostFromOsfacts` stays OUTSIDE the host Effect deliberately. It throws
`OsfactsSourceError`, and `readSourceErrors` recovers that by reading a marker
out of the error's own message — folded in as an `Effect.map`, the throw would
become a DEFECT, reach the caller wrapped, and the marker read would go blind.

`SnapshotHost` / `ReadHost` are `typeof` the verbs they double, so the API
change landed as a type error rather than as doubles that had quietly drifted.
The doubles are now `Effect.sync`, which is what makes the two counting tests
count SPAWNS rather than descriptions built.

The new pin is a falsified one: `hands a failed osfacts read through the run
edge as the client's own tagged error` asserts identity (`toBe`), because a
runner that wrapped the failure would silently break `readSourceErrors`. Made
red by wrapping the rejection, then green again.

Untouched, and verified so: `processIdentity.ts` (the client's sync island,
which the port kept synchronous), the parsers in `proc.test.ts`, and the
supervisor seam — drishti uses `convergeAdmit` / `probeDaemonIdentityFrom`,
neither of which takes an osfacts reader, and never `createEndpoint`'s
`readSocketHolders` / `readProcessIdentityAsync`.

Pins kolu at c72da36cd (branch `osfacts-effect`), which pins osfacts at
86091cf6. The `branch` field follows the revision: left at `effect` it would
be a lie a future `npins update` would "fix" by yanking the pin off the
adoption.

Co-Authored-By: Claude <noreply@anthropic.com>
The paired branch moved while this was being built. The NAR hash does not
change, and that is not a mistake: the new commit only rebuilds
`docs/atlas/dist/electricity.html`, and kolu's `.gitattributes` marks `/docs`
`export-ignore` — so the path is absent from the GitHub archive npins fetches
and the unpacked tree is byte-identical to c72da36cd's. Verified by prefetching
both revisions rather than assumed.

The revision moves anyway, so the pin names the commit it actually tracks.
srid added 10 commits August 4, 2026 17:41
kolu's `effect` branch took Effect from beta.102 to beta.103 across all 68 of
its pin sites — including the seven vendored `@kolu/surface*` literals this
repo hydrates out of the Nix store. A hydrated source and its consumer must
agree on ONE copy of `effect`, or `_tag` narrowing across two module instances
quietly stops recognising anything, so drishti's own pins move in the same
commit as the pin that brings the new sources in.

Six authored sites carry the literal: the root `overrides`, the three package
manifests, the agent's projected `agent.package.json`, and the heredoc inside
`scripts/regenerate-agent-deps.sh` that reconstructs the agent-only workspace.
The last one is easy to miss and would have silently re-pinned the projection
back to beta.102 on the next regeneration.

The npins revision moves to `beb2f74184e0eed9b9f2546f51b2b1a6343b039f` with a
real tarball hash (`sha256-L8uY…`); the method was validated by re-prefetching
the OUTGOING revision first and confirming it reproduces the recorded
`sha256-3Gpv…` exactly. `branch` goes back to `effect`: `osfacts-effect` was a
short-lived kolu branch that has since merged and been deleted upstream, so the
field named a ref that no longer exists and `npins update` could not follow it.

Both lock/nix pairs regenerate, and the delta is exactly the three effect
tarballs — `effect`, `@effect/platform-node`, `@effect/platform-node-shared`,
each a url + integrity line. No transitive dependency moved.

No source adaptation was needed. beta.103's removals were swept for and none is
reachable here: no `SchemaIssue` / `getActual` / `Schema.redact`, no
`Schema.UnknownFromJsonString`, no `SchemaMultiDocument`, no `Context.mutate` or
`Context.getReferenceUnsafe`, and the one `Schema.Record` call site
(`main.ts`'s agent-drv map) is the plain two-argument form the
`keyValueCombiner` removal leaves alone. Nothing in this repo touches Effect's
`Clock`, so the monotonic/wall-clock split lands on nobody.

Local battery green at the new pin: typecheck across all three workspace
members, 375 pass / 5 skip / 0 fail across 54 files (the async and reactive
suites included — beta.103 changes microtask dispatch and Atom, both of which
run under bun here), and nixpkgs-fmt clean.
kolu's `effect` branch gained the juspay/kolu#2101 fix: `stdioLink` now
REQUIRES a `StdioReadinessProof`, `duplexWireLink` is no longer exported, and
`serveOverStdio` / `sshConnector` speak a readiness banner.

Pin only, so the re-pin to kolu's merge commit stays a one-file change. The
adaptation rides the next commit. Outgoing hash was re-prefetched and
reproduced sha256-L8uY… exactly before the update, which is what makes the
incoming sha256-SJ/r… trustworthy. Effect stays at 4.0.0-beta.103 — no
vendored manifest gained a runtime dep, so bun.lock/bun.nix do not move.
drishti's fleet arm had the whole juspay/kolu#2101 class, and rather more of
it than kolu's did. `drishti-agent --stdio` handed `frontDaemonOverStdio` a
socket path and spliced into whatever accepted: no gate read, no identity
probe, no epoch check. kolu at least ran the full kit on its local arm;
drishti's ONLY supervisor was the parent's `convergeAdmit`, an ssh hop away
from the gate file, the pid table and the signals it would need to act. And
drishti's daemon is RESIDENT — 60-minute idle exit — so a previous-epoch agent
is not a rare race, it is the ordinary state of every host between a deploy
and the next idle timeout.

The front now converges on the box where those facts live, then greets, then
relays. Unconverged, it writes a `refused` banner carrying the typed anomaly
and exits non-zero without relaying.

The front's policy is deliberately NOT the parent's. It answers the EPOCH
question — does a daemon that speaks this wire at all hold this rendezvous —
and defers every in-epoch verdict to `convergeAdmit`, which has the drain
budget, the standing anomaly, the degraded chip and the renew affordance.
`not-drainable` is the structural statement of that: with no drain arms and no
budget spellable, the front cannot quietly grow into a second adjudicator of
skew. A `skew-refused` outcome means the resident ANSWERED `hello`, which is
what being in-epoch means, so the banner may honestly certify it. Refusing
there instead would go terminal on a daemon the parent knows how to drain, and
would make drishti's skew → degraded → renew path unreachable in production.

What the front does still enact is the one thing only it can: taking over a
peer the probe classifies unspeakable, corroborated against the gate file and
pid table that exist only on that box. That also makes drishti's
`unspeakable-protocol` status arm reachable for the first time — until now its
only producer was the endpoint-arm `converge`, which drishti never called, so
the arm was dead-but-typed.

The boot barrier grew a second observation point. An early agent exit used to
be visible only as `conn.closed`, because the connector returned a Connection
unconditionally. The readiness gate moved that observation earlier — the
connector now throws when a child dies before greeting — so the barrier catches
the throw and applies the same fatal-line classification. Without it a planted
0755 state dir regressed from "terminal, with the agent's own sentence" to
"host unreachable", which is the retry-forever class the gate exists to
abolish.

The convergence policy graduates to `drishti-common/convergence-policy`: two
supervisors now read it, and the agent's Nix closure is a positive projection
of packages/agent + packages/common, so a policy left app-side is
structurally invisible to the front that needs it. `hostRegistry` re-exports,
so no import path moved. `@kolu/surface-daemon-supervisor` joins the agent
closure and `ts-pattern` joins its manifest — a real runtime import of that
kit, unlike `@kolu/log`, which surface-daemon takes as `import type` only.

Falsified: `epochGate.e2e.test.ts` spawns a mute previous-epoch daemon holding
a real gate and asserts one takeover, one clean converge, and a second front
that adopts in place without re-taking-over. Reverted against the pre-fix
front both e2es fail with the incident's own signature — "the peer accepted the
pipe and sent no readiness banner", the blind splice caught at the gate instead
of ten seconds later as a nondescript transport death. Three pure tests pin the
epoch-only policy, the layering decision no typechecker can see.
The two commits above the b1bb1ef51 pin are kolu-internal: a real-ssh remote
upgrade-window e2e in `packages/server` (the #2101 F4 falsifier) and a fix to
kolu's own `ci/agent-substitutable` probe, plus its justfile and nix-cache
workflow. drishti vendors none of them — the hydrated set is surface,
surface-remote, surface-map, shell-quote, log, surface-app, solid-pwa-install,
surface-daemon, surface-daemon-supervisor — so nothing regenerates: `bun
install` reports no changes and no lock, projection or manifest moves.

Outgoing revision was re-prefetched first and reproduced its recorded
sha256-SJ/r… exactly, which is what makes the new sha256-D91F… trustworthy.
One kolu-internal commit above 994fd1ff9: a kaval `--stdio` test seam pinning
that front's converge → greet → relay order (juspay/kolu#2101 review residual
R1), touching `packages/kaval` and kolu's own governance runEdges. drishti's
hydrated set is surface, surface-remote, surface-map, shell-quote, log,
surface-app, solid-pwa-install, surface-daemon, surface-daemon-supervisor — no
kaval — so nothing regenerates: `bun install` reports no changes and no lock,
projection or manifest moves.

Outgoing revision was re-prefetched first and reproduced its recorded
sha256-D91F… exactly, which is what makes the new sha256-3QGx… trustworthy.
…ad mirror says so

Takes kolu's G-round (juspay/kolu#2101 deploy #2), whose theme is the mute
death: a process that keeps ANSWERING after its insides have died. drishti had
two instances of it and one near-miss.

**The agent daemon exited on a runtime fault, but around the shutdown spine.**
`buildAgentRuntime` carried a `void built.done.catch(… process.exit(1))`. The
verdict was right; the mechanism was not. A bare exit from inside the runtime
builder skips `daemonMain`'s teardown, so the unix socket and the pid gate were
never released and the history ring was never flushed — a successor met a gate
naming a dead pid, and the ring lost everything since the last periodic
persist. It now rides `armRuntimeFaultExit` → `DaemonSpec.faultSignal`: ordered
teardown, ring flushed as last rites, and an exit `daemonExitCode` scores
`runtime-fault` so the supervisor can tell a crash from a stop. A library that
builds a runtime has no business killing a process it does not own, so
`runtime.ts` hands `done` out and the daemon binary decides.

**The parent's per-host bridge dropped its runtime `done` on the floor.** Never
read at all — the silent half of the same class, parent-side. It is observed
now and reported loudly. Deliberately NOT fatal: the parent serves every host,
and one bridge dying is not grounds to take the other hosts' canvases down. The
residual — a host whose data is frozen behind a chip that still says connected
— is named in the code rather than implied, because what the UI should say
there is a product call.

**The pump's faults shared a channel with its chatter.** `onFault` is wired
distinctly from `log`. drishti was never exposed to the MUTE half of kolu's
incident (its `Logger` has no levels and no filtering, so kolu's `log.debug`
disappearance cannot happen here) — what it lacked was the distinction, so a
member's stream dying read as narration. Faults are now `FAULT`-prefixed,
carry the error's own stack, and report scope verbatim, because `member`
(mirror is dead) and `key` (one key, host survives) must not look alike.

Audits with no change required, recorded in the PR body: no framework poll cell
relied on T+0-seed-fatal semantics (drishti's five poll sources tolerate a
throwing read at every tick, and the G-round's cell-local change strictly
removes a latent boot-death); no `websocketLink` call exists (the client dials
through `connectSurfaces`, so the thunk change lands inside the framework); and
every drishti serve site is a kolu primitive that now applies
`rpcSerializationLayer` internally, so the frame cap is explicit without a
drishti edit.

Falsified: `runtimeFaultWiring.test.ts` pins both halves of the fault wiring
and the metricHistory frame's headroom against the 16 MiB cap. Reverted to the
bare `process.exit`, the two wiring pins fail; the headroom pin correctly does
not, being independent. The pins strip comments before matching — the rationale
comments quote the very `process.exit` they explain, and a test that punishes
documenting a fix is worse than no test.
One padi-internal commit above 8403697: scratch paths get a single canonical
realpath-resolved spelling for create and append (juspay/kolu#2101 G9a, a
darwin symlink fix). It touches `packages/padi` only, and drishti vendors no
padi — the hydrated set is surface, surface-remote, surface-map, shell-quote,
log, surface-app, solid-pwa-install, surface-daemon, surface-daemon-supervisor.
Nothing regenerates: `bun install` reports no changes and no lock, projection
or manifest moves.

Outgoing revision was re-prefetched first and reproduced its recorded
sha256-5QKJ… exactly, which is what makes the new sha256-E3qE… trustworthy.
Three kolu-internal commits above 85136453a: an e2e step-match fix for the
N-MiB drop scenario, convergence-not-stopwatch waits in surface-remote's own
tests, and kolu-server's routeErrorLogging fix (a Respondable delivered through
the failure channel is not a 500), with its VM-test scaffold.

drishti vendors `@kolu/surface-remote`, so that one was checked rather than
assumed: the diff there is `.test.ts` only — `reServeSurface.test.ts` and
`relayStream.test.ts` — with no change to any source module drishti imports.
`packages/server` is not vendored at all. Nothing regenerates: `bun install`
reports no changes and no lock, projection or manifest moves.

Outgoing revision was re-prefetched first and reproduced its recorded
sha256-E3qE… exactly, which is what makes the new sha256-HZ+O… trustworthy.
Eight kolu commits above 72da67ec6 — the H-round: `nudge()` on surface-remote's
Session (a waking client fires the already-scheduled retry now, H2), a deadline
on the client's first attach frame (H3), no toast for a grid publish to a host
known down (H1), connectPublishEffect's publish containment routed through the
shared `containThrow` helper (N1), a `ci::protect` enrolment verb, and a
repo-wide biome pass.

drishti vendors `@kolu/surface-remote` and `@kolu/surface`, so both were read
rather than assumed. The only surface API delta is the ADDED required member
`nudge(): void` on the exported `Session` interface, between `recheck()` and
`identity()`. An added member on an interface is invisible to a consumer that
only calls it: `makeSession` implements it, and drishti has no hand-built object
typed as `Session` — every site holds a real `makeSession` result, a `Pick<>` of
one, or an `as` cast (`router.ts:259`). Typecheck across all three workspace
members is clean with no source edit, so none was made. `reactor.ts` moved only
a comment and a `try`/`catch` into the shared containment call — same
disposition, no signature. `packages/server` and `packages/client` are not
vendored at all. Nothing regenerates: `bun install` reports no changes, and
neither the lock, the projection, nor any manifest moves.

Outgoing revision was re-prefetched first and reproduced its recorded
sha256-HZ+O… exactly, which is what makes the new sha256-GllR… trustworthy.
srid added a commit to juspay/kolu that referenced this pull request Aug 7, 2026
…aemons on Layers (#2101)

Kolu now runs end-to-end on **Effect 4.0 (beta.103)** — the wire, the
schemas, the daemons, the HTTP app, the client's business logic, the
reactive engine, and the CLIs. The oRPC wire is replaced by
`effect/unstable/rpc` (flat, slash-tagged `RpcGroup`s over ndjson),
every zod schema is Effect Schema, the surface framework's transports
ride Effect sockets, the daemons boot through Layer graphs, and hono is
gone — the entire HTTP app (static, PWA, artifact-sdk, health, preview,
`serve()` itself, and the example servers) rides `effect/unstable/http`.
Zero `zod`, `@orpc/*`, `partysocket`, `hono`, or `@preact/signals-core`
dependencies remain. vazhi is the one deliberate non-Effect island (Ink
rendering internals; nothing orchestral).

The work ran as two planned campaigns plus a review round, all on this
branch. **The campaign notes — plan of record, recon dossiers, the 26
adversarial review findings that reshaped the plan, and every per-stage
report — are attached as a gist:
https://gist.github.com/srid/76c5cfa5e52a21fc77196aa6237e09f4** (the
`PLAN D<n>` / `review #<n>` / wave-label citations in source comments
resolve there; the gist's index maps each citation form to its file).

## The wire epoch, and the hands-off upgrade

The migration is a **wire epoch break**, handled as a declared flag day:
`PTY_HOST_CONTRACT_VERSION` 6.0 → 7.0, `PADI_SURFACE_VERSION` 4.7 → 5.1.
The daemon supervisor gained a third convergence observation —
`unspeakable-protocol` — raised only for a **corroborated** peer (owned
gate file, verified pid, re-attested immediately before any signal) that
either sends undecodable frames or accepts and stays silent past 8s (the
measured behavior of a previous-epoch daemon, pinned between Effect
RPC's 5s ping and its ~10s connection kill).

Disposition is each daemon's declared policy: **kaval recycles**
(terminals don't survive a broken wire), **padi is taken over** —
SIGTERM so its own shutdown runs (padi also gained the SIGTERM
final-capture it never had), a 120s exit deadline, a 5s SIGKILL
backstop. Deploying this build over a running production kolu is a plain
service restart. Foreign socket-squatters keep the refuse protection
untouched. Proven against a **real previous-release binary** in the
upgrade-window e2e and in the six NixOS adoption VM tests
(`nix/home/example/adoption/`: `adoption-adopt`, `-skew`, `-currency`,
`-padi-upgrade`, `-upgrade`, `-upgrade-reboot`, all riding
`ci::home-manager`).

## What the migration itself caught

The typed edges and the harnesses found real bugs, several live in
production paths:

- Four client call sites crashed at the input-encode edge (`Cmd+T` with
no cwd, every split, first scrollback backfill, session restore) — zod
tolerated a present-but-`undefined` key; Effect Schema doesn't. A
systematic audit of all 87 `optionalKey` conversion sites found 8
producer/schema mismatches (2 already live), each fixed at the right
layer with byte fixtures.
- One dead terminal tap's defect killed the **whole** padi connection
(Effect RPC's `disableFatalDefects` default) — fixed centrally in
`surfaceRpcServerLayer`, seam test falsified both ways.
- A cell `get` served `Stream.concat(snapshot, bus)` — a zero-subscriber
window that dropped writes landing on a reconnect edge. Fixed with the
`subscribeBeforeSnapshot` discipline.
- A session-restore lost-write race (terminals push vs. session cell
echo under IO load) — the restore RPC now answers with the settled
`activeTerminalId` and hydration waits on the answer; deterministic
repro pinned it.
- **Two silent-failure classes, structurally closed**: `await` on a
now-Effect-returning call compiles and never dispatches (bit 11+ times
across kolu and its consumers, including a test that had disabled the
drain it exists to prove); and a `() => void` callback swallowing a
built-but-never-run Effect (four tile buttons). Both are
governance-banned with falsified scanners — including the
alias/stored-promise dodges.

## Governance (machine-checked invariants this PR adds)

- **Run edges**: every `Effect.run*` under `packages/` — production,
examples, testlibs, the harness — is a named, justified allowlist row
(109 sites in 54 files; test files are the awaited-face scanner's
jurisdiction, stated in the header). Uncalled `Effect.run*` references
(aliasing) are banned outright.
- **Effect pin agreement**: all 68 effect-family pin spellings are
discovered mechanically and must agree with the catalog — the 7 vendored
`@kolu/surface*` manifests owe the literal (`catalog:` doesn't resolve
for drishti/odu), everyone else owes `catalog:`.
- **Beta-behavior assumptions**: the sites that depend on beta.103
*behavior* (not API) carry grep-able `BETA-ASSUMPTION(beta.103)` markers
(eleven at HEAD, spanning the ping band, forkChild, optionalKey(Never),
the three Atom batch-semantics laws, the RPC frame-cap close, and the
socket-redial swallow and never-re-send pair behind the wire re-drive
epoch and the attach first-frame deadline); a pin bump fails the gate
until each is re-measured against its named measuring-law test.
- **`Schema.optional` tolerance shims**: the four deliberate
present-`undefined` tolerances are an allowlisted enumeration; a fifth
can't appear silently. (`exactOptionalPropertyTypes` was probed: 940
errors across 35 packages, including npins-grafted code this repo can't
edit — recorded in the gate's header as why the flag isn't the remedy.)
- **`kolu-rpc` quarantine**: the harness-only wire CLI (the VM tests'
probe after the HTTP `/rpc/*` arm was deleted) is scanner-pinned out of
`agentToolPackages`, the home-manager module, and the app closures (its
drv closure carries 0 references from anything a user installs).

The widened scans immediately paid for themselves: they found a docs
snippet that awaited a `kill()` Effect that never dispatched, and the
typing-echo latency bench dead in three independent ways — now ported to
the canonical client path and verified against a nix-built server with
real PTY echoes.

## Evidence

- Full local e2e: **509/509 scenarios, 4672/4672 steps, zero retries**.
- Repo-wide typecheck (TS 7.0.2/tsgo), unit lanes, daemon lane (incl.
the real previous-release upgrade-window run — now against **v2.2.0**,
the last stable release before this switch), e2e-governance (summary
line: `109 allowlisted Effect.run* edges in 54 files, 4 allowlisted
Schema.optional shims in 3 files, effect@4.0.0-beta.103 agreed across 68
pin sites, 11 beta-behavior assumptions stamped`), biome
`--error-on-warnings`, dev-smoke, website build: green.
- The remote upgrade window is proven over **real ssh**: `just
e2e-ssh-upgrade` plants a real previous-release padi daemon (v2.0.0 at
the time of the run; the harness tracks the latest release tag) on a
disposable box and asserts the current build produces **one takeover and
one clean converge in a single campaign** (20.3s wall, vs. the
incident's forever-loop). CI has no ssh lane, so this recipe is the
enforced run — stated, not silent.
- Two-platform CI on every push; final settle **`09700fa#1` green on
both platforms, first attempt** — the e2e metrics comment tracks it.

## Consumer repos (merge order)

- **drishti pair PR: srid/drishti#132** — full Effect adoption, CI green
at each kolu pin. Break classes: `Surface.contract` → `{group,
tagPrefix}`; async wire links `{dispatch, dispose[, wire]}`;
Encoded-side client face types; `ORPCError` → `Schema.TaggedErrorClass`
vocabulary; `ProcedureSpec.errors` → singular `error`; AbortSignal →
`Stream`/fiber interruption; `serveOver*` take `{group, handlers}`;
schemas Effect Schema throughout.
- **ODU-IMPACT VERDICT: `breaks-at-bump` — adoption PR juspay/odu#74**
(ledger odu#43 drained — plus one N3 line item: `serveOverUnixSocket`
now takes a required `log`, at the same call site the adoption already
rewrites; byte-compat proven against production data; odu's moved master
merged back in at `74f922e` — its new `odu wait`/`rerun` CLI re-ported
onto the Effect surface and #78's fresh zod import converted, **16/16
required contexts green** by odu's own runner, `MERGEABLE`).
- **Order**: **juspay/osfacts#5 merges first** (this PR pins its branch
head; re-pin the npins `osfacts` entry to the merge commit before
merging here), **then this PR** — **odu#74 must not merge before it** —
**then the consumer PRs** (drishti#132, odu#74), each re-pinning to the
kolu merge commit and re-confirming green.

## Review rounds

- **[Round 1
checklist](#2101 (comment)
— all 13 items addressed; the comment carries the per-item disposition.
Highlights: the campaign notes are attached (A1, the gist above); the
pre-1.30 `activityAlerts` ladder bug is **fixed** with a chained-ladder
test (A3); the six adoption VM tests are named above (A4, they were
in-tree all along); `kolu-rpc` is ratified *and* scanner-quarantined
(A5); the disclosed `sessionRestore` and `pumpRemoteSurface` holes are
fixed with falsified tests (C1, C2).
- **[Round 2
follow-up](#2101 (comment)
— F1: both pair PRs pinned to this PR's final HEAD with receipts; F2:
the restore seed's wait is **bounded** (`41d517754`) — a 10s deadline
that provably cannot fire on a healthy link (the written argument sits
at the site), converting the one reachable non-arrival (a stale answer
after a daemon recycle) into a conservative seed plus a loud report,
with the non-arrival case driven in the seam test.
- **Round 4, effect 4.0.0-beta.103** — the first pin bump, taken on this
branch by request: all 68 pin sites moved together (osfacts upstream
first), the three `BETA-ASSUMPTION` markers re-measured (ping band
5014ms/10019ms — unchanged; `forkChild` and `optionalKey(Never)`
unchanged), the [reviewer's six-surface
map](#2101 (comment))
walked explicitly (all clean; RPC frame cap 16MiB vs 0.40MiB measured
worst-case real frame; the five JSON-schema shim patches re-derived
individually), zero removed-API hits, CI green first attempt both
platforms.
- **[Round 5, the production
incident](#2101 (comment)
— deploying an earlier head broke every remote host in a permanent
connect loop: the wire-epoch machinery existed only on the local arm.
Fixed at the framework level so the class is unrepresentable: a stdio
wire link now **requires an un-forgeable readiness proof**
(WeakSet-branded, minted only by consuming the peer's pre-splice
readiness banner — no pinger before proven epoch), the `padi --stdio` /
`kaval --stdio` fronts run the **full convergence kit on the remote box
before relaying a byte** (unspeakable classification, gate
corroboration, takeover via SIGTERM — the same kit the local arm runs),
and a gate refusal is a typed `"remote"` verdict that reaches a terminal
`failed` within the existing 5-attempt budget and renders as "previous
protocol epoch" on the host map instead of an eternal spinner. Proven by
a real-ssh upgrade-window e2e (previous release resident → one takeover,
one converge — log attached to the gist) and a permanent inverted
falsifier reproducing the incident's infinite loop. Secondary finding
fixed along the way: the nix-cache workflow was warming the PR **merge
ref** — a tree nobody deploys — so deployed closures were never on the
cache; it now checks out the PR head and re-queries the cache (over
plain HTTP) for every exposed agent attr after the push.
- **[Round 6, deploy #2's four
incidents](#2101 (comment)
([G5–G7](#2101 (comment)),
[G8](#2101 (comment)),
[G9](#2101 (comment)))
— the second deploy surfaced four incident classes, all closed with
falsified tests: **(1) the half-dead daemon** — a transient kaval-probe
timeout at boot faulted padi's whole surface runtime into a zombie; poll
reads are now cell-local at every tick (the same failure was fatal at
T+0 and benign a second later — that timing dependence was the defect),
and a genuine runtime fault now **exits the daemon** through the
shutdown machinery (capture, gate release, supervisor respawn) instead
of the #1792 log-and-continue; **(2) the mute server freeze** —
projection-layer faults resolved *clean* at DEBUG; they are now typed,
loud, and prompt, every kolu-supplied callback on the engine's writer
stack is containment-bracketed, and Atom's beta.103 batch semantics
carry three measured `BETA-ASSUMPTION` markers (the severed-edge
hypothesis was **refuted by measurement** — pinned as a law); **(3)
frozen panes** — attach streams manufactured clean ends under load (now
re-opened server-side for a live PTY, re-attached client-side), and the
port had flipped a documented-recoverable resize race into the defect
channel (now a typed failure the loop retries; the channel taxonomy is
written law, and the same sweep fixed the framework twins on
`fenceStream`/`websocketLink`); **(4) the wire-killing upload** — a >16
MiB frame closed the whole multiplexed socket; uploads ride 3 MiB
chunks, the cap is an owned, marked constant at every serialization
site, and an oversized frame is refused client-side before send. Plus
the deploy-artifact cache gap root-caused structurally
(`ci/agent-substitutable <flake-ref>` + `ci/agent-preflight` on the
deploy host) and a latent HTTP 500 (a `Respondable` response answered as
an unhandled fault) found by the VM lane under saturation.
**[Verification closed the
docket](#2101 (comment)
with one non-blocking residual, since landed: N1 —
`connectPublishEffect`'s publish half now routes through the one shared
containment implementation (`containThrow`) instead of hand-rolling it.
Alongside it, `just ci::protect` now **enrols the nix-cache workflow's
two `build-and-push` contexts in branch protection** (derived from the
workflow file with a loud drift guard, re-appended after every `odu
protect` since odu replaces the list) — the cache-warm guard that G7
showed is deploy-critical is now a required check, applied live (48
required contexts on `master`).
- **[Round 7, the lid-close field
test](#2101 (comment)
— the wake machinery held (typed mirror death within ~15s, honest
bounded probing, hands-off reconvergence adopting the surviving daemon),
and the three residual defects from the wake window are closed: **(1)**
a grid publish to a host the client *knows* is down no longer toasts
"failed unexpectedly" — the publish gates on the client's own host-map
state, and the same refusal from a host believed up stays loud
(falsified both ways); **(2)** a waking client now fast-forwards every
down host's scheduled retry via a new narrow `Session.nudge()` fired on
websocket accept — the same attempt the backoff had scheduled, budget
and terminal-verdict semantics provably unchanged (`recheck()` was
measured to be the wrong verb: it refills the give-up budget); **(3)**
an attach stream that opens and never delivers its first frame now hits
a 10s first-frame deadline into the existing retry channel (budget 1,
loud verdict on repeat) — closing the one channel the loop's taxonomy
didn't cover (transport failures, clean ends, stale grids, and now *no
end at all*). The mandated socket-cycle hypothesis was tested and did
**not** confirm (a force-cycled socket already fails registered entries
— pinned as law); the genuine hole is Effect RPC's
`retryTransientErrors` swallowing `SocketOpenError` re-dials under
parked subscriptions, now the ninth measured `BETA-ASSUMPTION`.
- **[Round 8, I1 — the artifact carries its
agents](#2101 (comment)
— maintainer's call executed: a deployed kolu now guarantees every agent
closure it can provision is present in the deploying host's store, by
construction. The `padi-agent ⊃ default` circularity dissolves at the
deployment layer — the home-manager **generation** references both
without a cycle, via a new *required* `services.kolu.agentPackages`
option (`nonEmptyListOf` — an omitting or empty-list consumer fails at
eval, no degrade knob) defaulted by the flake wrapper from
`nix/agent-packages.json`'s `expose` list and anchored on both
supervisors with zero PATH pollution. Measured cost: **+1,760 bytes**
(one store path) over `default`'s 740.84 MiB closure; +0.0001% on the
end-to-end generation. Proven two ways in the `ci::home-manager` lane: a
closure-containment check (red pre-fix at `MISSING padi-agent`), and a
two-node **offline-provision VM test** — substituters forced empty and
asserted, a real ssh connect ships the closure and creates a terminal
with zero cache/source-realise narration, where the pre-fix run
reproduces deploy #2's exact `no local copy of the agent to ship …
realising from source` path down to the target compiling
`padi-agent.drv`. The G7 scripts are re-scoped to one narrative (the
module guarantees by construction;
`agent-preflight`/`agent-substitutable` cover the raw-install and
cache-warm flows outside it) and the user docs' cache-conditional
provisioning language is corrected.
- **[Round 9, J+K — the law-2 park and the attach-path
audit](#2101 (comment)
([K
audit](#2101 (comment)))
— a second field incident (wake at 13:11: healthy wire, watchdog
satisfied, every subscription in the tab parked on pre-sleep state) plus
a three-audit deep review of the attach path, closed as one round:
**(J1)** `websocketLink` now counts open edges and fails, itself, every
stream *and* in-flight unary a re-dial cycle orphaned — including cycles
whose intermediate failure Effect RPC swallows (`retryTransientErrors` +
never-re-send, now two cross-referenced `BETA-ASSUMPTION` rows, census 9
→ 11) — so the fence re-drives the whole tab with no clock and no
per-subscription machinery; **(K1)** the first-frame deadline was
measured to be a hard ceiling (a re-attach cancels the in-flight
snapshot), re-derived to 45s above every structural repair, budgets
refill per-episode, and no verdict executes a pane — exhaustion is one
loud toast then a 30s cadence; **(K2)** the abort-during-reopen leak
triad closed against kaval's real fan-out (including the WHATWG
already-aborted-signal hole, found twice); **(K3)** the two silent
unbounded lanes keep their unbounded-by-design arguments and lose their
silence (derived-N structured warn server-side, derived-N verdict
client-side); **(K4)** a grid suppressed while the host was down is
restated once on the connected flip; **(K5–K7)** the fourth reopen lane
joins the taxonomy, the inter-check PTY exit throws tagged, and the
tombstone-evicted exit fails loud instead of fabricating `0` (with one
genuinely-reachable caller identified and handled). **(J2)** Diagnostic
Info now carries a copy-pasteable plain-text snapshot that proves a wire
incident from the browser alone: dial history including the
previously-invisible swallowed dials, a per-subscription liveness table
with a parked verdict, host entries with client-stamped freshness,
heartbeat verdicts — built lazily from client-held state only, so it
works exactly when the wire is lying.
- **[Round 10, M — the give-up budget counted the failures it promised
to
ignore](#2101 (comment)
([disposition](#2101 (comment)))
— a first-overnight-sleep field incident: ~18 "host unreachable"
attempts plus ONE dark-wake remote failure went instantly terminal,
because the day-one shared `consecutiveFailures` counter incremented for
both causes while the ceiling gate read only `remote` — a master-born
latent defect this PR's own (correct) banner gate made reachable for the
first time. Killed as a class: `@kolu/surface/failure-ledger` is a new
leaf primitive where cross-class counting is **unrepresentable**
(per-class runs, verdicts computed only from a class's own run, the
interleaving rule declared as data, `attempts()` exposed strictly as
display/pacing tier), with the anti-conflation law — the one no test had
ever pinned — proven three ways in the framework. The session migrates
to the spec `network: unbounded, resets remote` (an unreachable gap
means the host *went away*; the next remote blip is fresh evidence, not
accumulation), the give-up message derives from the verdict so it can
only ever name the true remote run, three lying doc sites are corrected,
`makeStepBudget` is ratified hand-rolled (single-class — the disease
can't exist there), and the field shape is pinned by four seam
falsifiers whose pre-fix red reproduced the incident's exact lying
give-up line byte-for-byte.
- **[Round 11, N — a comatose kaval becomes survivable by
design](#2101 (comment)
— a field incident (kaval alive but comatose after macOS sleep: accepts
connections, answers nothing, zero error lines; padi diagnosed it in 10s
and was architecturally forbidden from treating; the host dot stayed
green) closed as four items: **(N1)** padi's probe verdicts feed an M1
failure ledger (`wedged`/`unreachable` at ceiling 3 ≈ 30s of coma, the
auto-repair itself ledger-bounded at 3 before the card returns) and
exhaustion runs *exactly what the "Restart kaval" button runs* — one
shared routine, two triggers — proven by a SIGSTOP-coma falsifier
against a real kaval (pre-fix red: "padi never repaired the comatose
kaval", post-fix hands-off repair in 168.8s); two *pre-existing*
unbounded waits reachable from the button were found and closed on the
way, including a drain that parked the recycle forever on the very fault
it was repairing (and the first fix attempt via fiber timeout provably
couldn't work — the effect was uninterruptible; the reasoning is written
at the site); **(N2)** kaval self-dials its own socket every 10s and
three consecutive failures exit through the existing G2 fault arm —
comatose-forever becomes dead-loudly, which everything already handles;
suspension is detected (a tick firing two cadences late resets the
budget), and the raw-timer-vs-Effect-Clock ruling is written at the site
citing heap-diag's precedent; **(N3)** the listener telemetry the Effect
port silenced is restored as a confirmed regression fix (pre-fix red:
the logger was never called at all), with the `log` parameter
deliberately *required*; **(N4)** the host dot composes the daemon chain
— reachable-host-dead-kaval renders amber "kaval down" with the daemon's
verdict, reusing the exact cell the kaval-down card already reads (no
new wire field). The kernel-level double-bind itself stays out of scope
as mandated: unprovable from this repo, and survivable-by-design once
N1/N2 hold.
- **[Round 3, the osfacts follow-up
campaign](#2101 (comment)
— delivered as separate PRs, per its own instruction not to reopen this
one: juspay/osfacts#5 (the client ported to Effect), the kolu adoption
(opened as #2103, then **folded into this branch** by maintainer
decision — flips the supervisor's last two Promise seams to Effect,
retires this PR's "osfacts is uneditable" premise from the governance
gates), and the drishti adaptation (opened as srid/drishti#133, likewise
folded into drishti#132). Dispositions with receipts on each.

## Docs & changelog, against the v2.2.0 baseline

v2.2.0 was released mid-flight as the deliberate **last stable release
before this switch**, and every user-facing surface was re-baselined
against it:

- **Changelog** (`unreleased.mdx`) rewritten **release-relative** — what
a user upgrading *from 2.2.0* experiences, as one squashed release, not
a diary of this branch's iterations. Every entry was then adversarially
fact-checked claim-by-claim against `origin/master` (nine claims
corrected, one wrongly-deleted entry restored) and audited against
`.claude/rules/changelog.md` (kinds are release-relative; branch-only
fixes don't narrate as user-facing leaks). A final entry-by-entry trial
against the v2.2.0 tree itself asked, for each `fixed`, *can a 2.2.0
user actually hit this?* — it re-kinded one entry, deleted one whose
failure mode was measured to be this branch's own engine (folded into
the Surface entry as a property of the new engine), and corrected four
more claims down to what the tag's code supports.
- **Docs pages** synced where the changelog exposed staleness: the
host-down card table's new "previous protocol epoch" row, the flag-day
exception on the sessions and troubleshooting update paths, the
blank-pane verdict on the tiles page, the Attention-alerts master toggle
on notifications.
- **Reference pages** carry the campaign's new public API
(`Session.nudge()`, `@kolu/surface/subscriptions`,
`StreamFenceOptions.label`, `WebsocketLink.diagnostics`, …) and all six
touched package READMEs were brought to the Effect-era surface (kaval's
`PtyHost` face was still fully pre-Effect prose).
- The release also moved the previous-release harness's target, catching
two **test-only** gaps: the gate assertion now asserts the #2011
pid-first law instead of one release's exact bytes, and the takeover arm
now composes the production identity reads instead of a unit-test fake —
so the e2e proves the exact 2.2.0 → this-release crossing every user
will make (kaval recycled, padi taken over, against the real previous
binary). The daemon machinery itself needed no change.

## Known remainders

- `.claude/skills/nix-typescript` (vendored from juspay/skills) names a
nonexistent `nix/modules/typescript.nix` — upstream doc fix, tracked
outside this PR.
- A pre-existing medium CodeQL alert on
`packages/padi/src/ports/scan.live.test.ts` (named exception; untouched
code).

### Try it locally

```sh
nix run github:juspay/kolu/effect
```

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-fable-5`)._
juspay/kolu#2101 landed as squash commit 05484f0fb, so the pin no longer
needs to chase the unmerged `effect` branch. Track `master` instead, at
2104ddea9, which also picks up the master-side work that never existed on
`effect` (release 2.2.0, the changelog rules, the operations docs).
@srid
srid merged commit faca237 into master Aug 7, 2026
28 checks passed
@srid
srid deleted the effect branch August 7, 2026 15:55
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