Conversation
….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.
EvidenceThe built binary ( Fleet view — the host map's Host view — every primitive class on the surface at once: 447 processes through the collection Console: clean (one pre-existing SolidJS "computations created outside a 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: Spike A —
|
| Leg | Result |
|---|---|
serveOverUnixSocket ↔ unixSocketLink, in-process |
pass |
a real bun child serving serveOverStdio ↔ stdioLink |
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.
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.
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.
…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).


Pairs with juspay/kolu#2101.
kolu's
effectbranch 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.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
DaemonSpec.faultSignal/armRuntimeFaultExit— observingruntime.donewith log-and-continue is now contractually wrongprocess.exit(1)inside the runtime builderarmRuntimeFaultExit→faultSignalimplementSurface(...).donewas never read at allMirrorRemoteSurfaceOptions.onFault(typedMirrorFault)pumpRemoteSurface, passinglogonlymirror.donerejects on member fault / settles promptlyvoidedwebsocketLinkthrowingurl()/connect()thunkwebsocketLinkcallsframeLimit/rpcSerializationLayerThe agent daemon exited on a fault — around the shutdown spine, not through it
buildAgentRuntimecarriedvoid 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 skipsdaemonMain'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
armRuntimeFaultExit→DaemonSpec.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 thatdaemonExitCodescoresruntime-fault, which is the supervisor's only channel for that was a crash, not a stop.The observation moved out of
runtime.tsentirely. 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.doneis handed out; the daemon binary decides.The parent dropped its bridge's fault on the floor
router.ts's per-hostimplementSurface(mirroredAgentSurface, …)runtime had itsdonenever 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
pumpRemoteSurfaceresidual 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
logand 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'sLoggeris a bare(line: string) => voidwith no levels and no filtering — every line reaches stderr — so the disappearing-log half could not happen here. What drishti lacked was the distinction.onFaultnow carries aFAULT-prefixed line with the error's own stack, and reportsscopeverbatim:member(the mirror can never re-subscribe; the pump's promise rejects) andkey(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 raiseOsfactsSourceErrorwhen a facet is missing. Nothing in drishti relied on a T+0 seed failure being fatal: the recurringtickalready swallows read errors and keeps serving, and the one seed-is-fatal path (readSystem()at construction) is a plainawait, 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 intoprocess.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 theurl()/connect()thunks and the re-dial classification lands entirely inside kolu. drishti's one URL thunk (wsBase()) is passed toconnectSurfacesand 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 —
daemonMain→serveOverUnixSocket,surfaceAppLayer,serveSurfaceSocket,serveHostMap— and each appliesrpcSerializationLayerinternally 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 inruntimeFaultWiring.test.tsso a future retention or cadence change that erodes the headroom fails there instead of on a user's socket.The residual is the
processescollection. Nothing caps the process count, and each row carries an unboundedlistenersarray; only the per-rowcommandandcwdstrings 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.tspins both halves of the fault wiring (the exit is gone from the builder,doneis still handed out;main.tsarms offruntime.done, passesfaultSignal, and usesflushRingas last rites) and the frame-limit headroom. Reverted to the bareprocess.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 totscand visible only in the shape of the call. One wrinkle worth recording: the pins strip comments before matching, because the rationale comments quote the veryprocess.exitthey 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.
stdioLinknow REQUIRES an un-forgeableStdioReadinessProof, minted only byawaitStdioReadinessoff a peer's ownreadybanner;duplexWireLinkis no longer exported;serveOverStdiogreets at boot;sshConnectorreads 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 --stdiohandedfrontDaemonOverStdioa 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'sconvergeAdmit, 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,sshConnectorand any hand-rolled splice across all ofpackages/.agent/src/main.ts—drishti-agent --stdioagent/src/fixtures/{high,low}ContractMain.ts(viafixtures/skewFront.ts)agent/src/dialDaemon.testlib.ts—dialOverStdiostdioLinkconstructionawaitStdioReadinesson the front's own streamagent/src/convergeFront.ts—socketDuplexLink(new)agent/src/dialDaemon.testlib.ts—unixSocketLinkapp/src/server/hostRegistry.ts—sshConnector×3serveOverStdioduplexWireLinkTwo
.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
probeDaemonIdentityfactory (the raw dial with the byte tap and the silence deadline — the only thing that can see a previous-epoch peer, unlike the assembly-onlyprobeDaemonIdentityFromthe parent's admit uses), gate corroboration against the pid table, takeover viareapHolder, andreExecAsDetachedDaemonas the driver so a replacement is an agent of this closure. Converged, it greetsreadyand splices. Unconverged, it writes arefusedbanner carrying the typed anomaly and exits non-zero without relaying.A side effect worth naming: drishti's
unspeakable-protocoldaemon-status arm has been dead-but-typed since it was written. Its only producer is the framework's endpoint-armconverge, which drishti never called — every path went throughconvergeAdmit, 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, withrefuseon contract skew andnudge-humanon build mismatch — both meaning leave the survivor standing and report it.not-drainableis 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-refusedoutcome, which means the resident answeredhello— and answeringhellois 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
withAgentBootBarrierturns "the agent died with a fatal stderr line" into a terminalConnectErrorso 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
sshConnectorreturned aConnectionunconditionally — it spliced the child's stdio into an RPC client without asking the peer anything — so a boot refusal could only ever surface downstream asconn.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.1regressed 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
drishtiAgentConvergencePolicylived inapp/src/server/hostRegistry.tsfor 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 ofpackages/agent+packages/common, withpackages/appdeliberately not an input. A policy left app-side is therefore not merely awkward for the front, it is structurally invisible to it. It moves todrishti-common/convergence-policy(its own export subpath, so the browser bundle never pulls daemon-supervision code), withhostRegistryre-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, whichcreateConnectorDrainBudgetdemands, is narrower ononContractSkew; annotating the return widens the literal and the connector arm stops compiling.as const satisfiesproves conformance without erasing which arm was chosen.Closure and lockfiles
@kolu/surface-daemon-supervisorjoins the agent's hydrated set, andts-patternjoinsagent.package.json— a real runtime import of that kit (probeDaemonIdentity,index), unlike@kolu/log, whichsurface-daemontakes asimport typeonly and therefore needs no entry. The regeneration is exactly one tarball inagent.lock+agent.bun.nix, no transitive movement; the rootbun.nixdoes not move at all because the app already resolvedts-patternat 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'ssurface/package.jsongained only the./links/readinessexport entry), sobun installreports 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 recordedsha256-L8uY…exactly, which is what makes the newsha256-SJ/r…trustworthy.The falsifier
agent/src/epochGate.e2e.test.tsreproduces the incident's cause rather than mocking its symptom.fixtures/muteEpochDaemon.tsis 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.103kolu 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 ofeffect— two module instances mean_tagnarrowing 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 projectedagent.package.json, and the heredoc insidescripts/regenerate-agent-deps.shthat 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 newsha256-L8uY…trustworthy.branchalso goes back toeffect—osfacts-effectwas a short-lived kolu branch that has since merged and been deleted upstream, so the field named a refnpins updatecould 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/.tsxinpackages/: noSchemaIssue/getActual/Schema.redact, noSchema.UnknownFromJsonString, noSchemaMultiDocument, noContext.mutateorContext.getReferenceUnsafe, and the singleSchema.Recordcall site (main.ts's agent-drv map) is the plain two-argument form thekeyValueCombinerremoval leaves alone. Nothing here touches Effect'sClock, so the monotonic/wall-clock split lands on nobody. The microtask-dispatch andAtomfixes 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.
pumpRemoteSurfacegained an exit case. A mirror that ended while its link was still alive — every subscription settled — used to parkcursor.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 asystem.liveround-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 plainreturnfrom aPromise<void>that drishti already discards, no test asserts the pump parks, and the exit line does reach the parent's log because drishti passeslog.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 readingconnected— the pump is deliberately not the authority for link health (serveHostMap+session.onStateis). 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,
41d517754— the restore seed's wait for the answered tile is bounded — ispackages/clientonly, and drishti vendors none ofclient: the hydrated set issurface,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 — onlypackages/servergained a dependency and drishti vendors none ofservereither — sobun installreports no changes, andbun.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):
awaiton 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'srunCall,dialDaemon.testlib.ts'sunary(),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 functions —hostRpc(host),adminRpc(),hostStreams(host),hostCollections(host)— soawait 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 aliasconst verb = …; await verb(x), the stored descriptionconst p = …(x); await p) plus the outright ban on anEffect.run*that is named but never called.The sweep it pins is clean — all 121
.ts/.tsxfiles underpackages/, 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.runCallis 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/isDefinedErrordeleted, the.effectnesting folded onto.surface), flipped the whole supervisor face to Effect, and tookhonoout of@kolu/surface-app. Most of that the compiler catches.One class of it the compiler cannot. An
Effectis 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 anas unknown ascast. Both were true at the old pin and became lies at this one, with nothing to catch them:hostRegistry.ts'sControlProbeClientis now an alias of the framework's ownControlCoreProbeClient, not a hand-written twin. A twin restates a shape that moves whenever the frozen fragment does — and it just did.dialDaemon.testlib.tscast three faces to Promise signatures. It runs the Effects now, behind one namedunary()helper, soawait 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
AbortSignalawaitExitis anEffect<void>now, andawaitExitViaProcessOraclewith it. The signal had exactly one job — tell a poll-shaped wait to stop once the drain ceiling won — andconvergeAdmitforks the oracle into a scope it closes on every path, so the wait is interrupted rather than notified. Interruption is not refusable; an abandonedAbortControllerwas. drishti's own drain is an Effect value, like the framework'sfireDrain, so it cannot beawaited into a no-op either.hono is gone
installSurfaceApp(app, opts)becamesurfaceAppLayer(opts)— anHttpRouterlayer, not an installer — so the parent's whole HTTP app moves ontoeffect/unstable/http. It owns itshttp.Serverand hands therequestevent an Effect handler, which is precisely what leaves theupgradeevent 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-serverand — the reactor runs on Effect'sAtomnow —@preact/signals-coreleave 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 rejectingonDrainis 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 latcheddrainNow(), 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.tshand-built a server-only widened oRPC contract and re-adapted two finalized routers through it with anas anysplice — 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;adminContractdisappears 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 livemetricHistorysubscriptions, and used to decrement in the generator'sfinally. UnderStreamthat leaks: the framework's AsyncIterable bridge deliberately never calls.return()on a producer (awaiting it deadlocks a generator parked at anawait), so thefinallydoes not run when a consumer walks away. It is anEffect.acquireReleaseon the stream's own scope now. A leaked lease would have kept a forgotten daemon alive forever.Spikes, run before committing to the plan
@effect/platform-nodetransports under bunwithDecodingDefaultKeyabsent key), a stream, and a cell:serveOverUnixSocket↔unixSocketLinkin-process; a realbunchild serving overserveOverStdio↔stdioLink; and thefrontDaemonOverStdiobyte splice (stdio client → unix server). Re-running it at3c631446dneeded exactly one change — the unary leg had to run its Effect rather thanawaitit, which is the hazard above showing up in the spike itself.typescript@^5.8(5.9.3) with zero errors and no TS2590. No bump totypescript@7needed.One pre-existing bun quirk found and worth recording, unrelated to Effect: under
bun test(1.3.10) a handledconnect ENOENTon a unix path escapes as an uncaught error and fails the test — unless anet.Serverhas already been created in that process. It reproduces identically onmasterwith the old oRPC pin, so it is bun'snode:netclient 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.enum→Schema.Literals,z.tuple→Schema.Tuple,z.discriminatedUnion("kind", …)→Schema.Union(neverTaggedUnion— that renames the discriminant to_tagand changes the bytes),.optional()→Schema.optionalKey,.default("TERM")→Schema.withDecodingDefaultKey.The two landmines this repo has sit in the same procedure (
process.kill's defaultedsignaland optionalerror), so they are pinned on the encoded JSON string — a decode-equality test would pass happily while"error":nullstarted appearing on every successful kill. Same for all fourMetricHistoryMessagearms, the alerts cell, andhistory.ring.json, where the fixtures also pin that an absentalertskey stays absent and that an explicitnullis corrupt — precisely the divergenceSchema.optionalwould have introduced.Also in here
sshConnectortakes 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 sameConnection.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 valueadmitreceives.connectSurfacesreturns a promise, sowire.tsawaits 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 rideextraGroups— 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.AbortControllers inApp.tsxare gone.unenrolledStreamCallreturns aStreamandcreateSubscriptionowns teardown as a fiber interrupt — the controllers were that interrupt, spelled by hand. partysocket's 60s cold-startconnectionTimeoutgoes too, and its absence is the point: it existed to stop a 4s default from abandoning a dial whilenix copyran, and the Effect link never times an attempt out at all.daemonStatus.tsgains the sixthunspeakable-protocolarm — 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.wire.ts'srunCall. 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 lettingEffect.runPromisespread across seven call sites.@orpc/*,zod,partysocket,hono,@hono/node-serverand@preact/signals-coreare gone;effect+@effect/platform-nodeland at the literal4.0.0-beta.103the hydrated sources declare, in all three override sites in lockstep (root manifest, agent projection manifest, and the heredoc insideregenerate-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
deltasverb, 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. Theidentity.infoprobe resolving on both theSRVandCLIENTfooters 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/abcand a missing/foo.png→ theno-storeshell;/assets/<hashed>.js→public, max-age=31536000, immutable;/assets/gone-999.js→ 404 +no-store, never the shell;/sw.js→no-cache, must-revalidate+text/javascript;/manifest.webmanifest→application/manifest+json; and a request carrying the response's ownETagback 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-hostsystemcell, andmetricHistory) — the exact tag class the deleted widening seam used to 404 on.🤖 Generated with Claude Code