Skip to content

feat(client-ts): port the client to Effect - #5

Open
srid wants to merge 5 commits into
mainfrom
effect-client
Open

feat(client-ts): port the client to Effect#5
srid wants to merge 5 commits into
mainfrom
effect-client

Conversation

@srid

@srid srid commented Aug 4, 2026

Copy link
Copy Markdown
Member

The TypeScript client now speaks Effect. Its spawning verbs hand back
Effect.Effect<Reading, OsfactsClientError> instead of a Promise, its kind
string union becomes three Schema.TaggedErrorClasses, and the two places where
the obvious Effect-shaped move would have been wrong keep their old shape — with
the reason written into the source rather than left to be rediscovered.

Part of the follow-up campaign from juspay/kolu#2101 (comment 5182758828). This
is the upstream half; the downstream adaptations were folded into the main PRs and are green there: juspay/kolu#2101 (adopts the client, flips the supervisor seams; pins this branch) and srid/drishti#132 (adapts proc.ts). Both now ride effect 4.0.0-beta.103, matching this PR. Merge order: this PR merges first, kolu#2101 then re-pins its npins osfacts entry to the merge commit before merging, and the consumer PRs follow.

What changed

The spawning verbs return Effects. snapshotSubtree, snapshotHost,
snapshotPids, processIdentityAsync, socketHolders, host, and the function
osfactsSocketHolders returns each hand back
Effect.Effect<Success, OsfactsClientError>. Nothing spawns until the caller
runs it, every declared failure is in the type, and an interrupted fiber kills
the in-flight child instead of leaving it to run out its five-second deadline
against a host nobody is reading. socketHolders' empty-path guard is now a
typed FAILURE rather than a synchronous throw, which dissolves the old
"async, so this one guard rejects like the others" note — the return type says
it now.

The empty-pid-list short-circuits are preserved and pinned: snapshotSubtree
and snapshotPids over an empty array answer emptySnapshotReading() without
spawning anything.

Three tagged error classes. OsfactsSpawnError, OsfactsVersionError, and
OsfactsParseError replace OsfactsClientError's kind field, one-to-one at
every raise site. OsfactsClientError survives as the union TYPE, with
isOsfactsClientError as its guard. They are still Errors, which is what lets
the sync island go on throwing them.

exactOptionalPropertyTypes is on. It makes HostReading's four
genuinely-absent fields machine-checked, and forces the two deliberately
present-and-undefined shapes — ListenerRow.uid and SocketHolder.command
to say so in their types (?: T | undefined, one comment each). No output shape
moved: the four optional HostReading fields, the present-undefined uid, the
threads / frequencyMhz null forms, and SocketOccupancy's non-empty-tuple
held arm with its two literal detail strings are byte-identical.

One runtime dependency. effect at 4.0.0-beta.102 — the exact version both
consumers already pin. The two-line pnpm-workspace.yaml beside it declines
msgpackr's optional native accelerator, which arrives through effect's own
graph and which pnpm 11 otherwise refuses to guess about — it fails an install
that has no recorded decision for a build script. (The nested file is inert in a
consumer that grafts this directory as a workspace member; verified.)

Two decisions, written down rather than assumed

The sync island stays sync. snapshotPidsSync, processIdentity, and
processIdentityFromEnv remain synchronous over execFileSync and keep
throwing. Two structural reasons, both now in client.ts's module header: the
consumers' single-instance gate (acquirePidGate in @kolu/surface-daemon,
shared by kolu's three daemons and drishti) is a deliberately synchronous claim
path, and async there reorders the gate against the boot side effects it guards;
and execFileSync cannot be interrupted, so an Effect wrapper would add the
ceremony of a fiber while advertising a capability the call does not have.

The spawn is execFile under Effect.callback, not a platform command
layer.
The reason is written at the spawn site:

  1. childFailure.ts classifies a child's fate by reading NODE's error shapes —
    .code / .status / .stderr / .signal. A command layer reshapes a
    failure into its own vocabulary, and the exit-2 refusal and the
    signal-truncation rule are one-line rules whose failure mode is a silently
    discarded (or silently accepted) document. They would go inert without a
    single test turning red.
  2. The deadline that matters is the CHILD's. CHILD_OPTIONS.timeout with
    killSignal: "SIGKILL" is kernel-enforced against a wedged binary; an Effect
    timeout interrupts the FIBER, which is a fact about the caller.
    OSFACTS_COMMAND_TIMEOUT_MS therefore means exactly what it meant.
  3. Zero new platform deps keeps drishti — which runs this client under Bun —
    safe.

The callback reassembles node's error into exactly the shape
promisify(execFile) used to hand over, so the classifier still sees ONE shape
from both twins. That invariant ("the only thing that may differ is HOW the
child is invoked") is restated in childFailure.ts's header.

Tests

39 → 41, all green on the CI line (nix develop .#client-ts -c …,
pnpm install --frozen-lockfile && pnpm run typecheck && pnpm run test:unit).
Every existing assertion that pins bytes, shapes, or messages survives verbatim;
only the call shapes moved (Effect verbs run through Effect.runPromise).

Two new pins, each falsifiable:

  • Interruption kills the child. A stub that exec sleep 30s after reporting
    its pid; the fiber is interrupted and the pid is watched until the kernel says
    it is gone. Reverting the finalizer turns it red (verified).
  • An empty pid scope spawns nothing. Asked with "" as the binary path —
    which assertBinPath refuses — so a reading coming back at all is the proof.

The bad-row corpora now assert the tagged class rather than the old base class,
and the version test asserts OsfactsVersionError plus the guard. The
childFailure pins on exit-1-document / exit-2-refusal / signal-refusal are
untouched and still green; the async path's exit-1 and exit-2 pins are what
prove the reassembled stdio reaches the classifier.

Consumer impact

Both consumers adapt at their pin bumps; neither is broken today, because both
reach this client through a pinned graft.

  • kolu grafts client-ts as the workspace member osfacts-client from its
    npins osfacts pin. A downstream kolu PR adapting the call sites (and bumping
    the pin) is next in the campaign.
  • drishti reaches the same bytes through kolu's overlay and runs under Bun —
    which is one of the three reasons the spawn stays on node's execFile.

The delta a consumer has to absorb: the seven spawning verbs return Effects;
OsfactsClientError is a union type rather than a class, so new OsfactsClientError(...) and err.kind become the three classes and
isOsfactsClientError; everything else — the sync island, the parsers, the
folds, bakedOsFactsBin, every row and reading type, and every constant — is
unchanged.

🤖 Generated with Claude Code

srid added 4 commits August 4, 2026 14:24
The client was zero-dependency; it now pins `effect` at the exact version both
consumers already pin (4.0.0-beta.102), which is what lets its verbs hand back
Effects that compose with the callers' own.

The `.npmrc` line is the price of the dep's own graph: `effect` depends on
`msgpackr`, whose OPTIONAL native accelerator carries an install script, and
pnpm refuses to guess — an undecided install script fails the install outright.
This client never encodes msgpack, so the answer is no, recorded once. A NEW
script-bearing dependency still fails loudly rather than inheriting it.
…agged errors

Three changes that are one change, because they are one API.

The SPAWNING verbs — snapshotSubtree, snapshotHost, snapshotPids,
processIdentityAsync, socketHolders, host, and the function osfactsSocketHolders
returns — now hand back `Effect.Effect<Reading, OsfactsClientError>` instead of a
Promise. Nothing spawns until the caller runs it, every declared failure is in
the type, and an interrupted fiber KILLS the in-flight child rather than leaving
it to run out its five-second deadline against a host nobody is reading. That
last part is the capability a Promise could not express, and it is pinned by a
test that interrupts a sleeping stub and watches its pid go.

The spawn stays node's own `execFile` under `Effect.callback`, NOT a platform
command layer, and the reason is written at the call site: childFailure.ts
classifies a child's fate by reading node's error shapes (.code / .status /
.stderr / .signal), the child-level timeout with killSignal SIGKILL is the
kernel-enforced bound a fiber interrupt cannot replace, and drishti runs this
client under Bun. The callback reassembles node's error into exactly the shape
`promisify(execFile)` used to hand over, so the classifier still sees ONE shape
from both twins.

The `kind: "spawn" | "version" | "parse"` union becomes three
`Schema.TaggedErrorClass`es — OsfactsSpawnError, OsfactsVersionError,
OsfactsParseError — with `OsfactsClientError` as their union type and
`isOsfactsClientError` as the guard. The discriminant was always the kind; as
classes it narrows, and a verb can declare what it fails with. They are still
Errors, which is what lets the sync island go on throwing them.

The SYNC ISLAND is decided and documented in the module header:
snapshotPidsSync, processIdentity, and processIdentityFromEnv stay synchronous
over execFileSync. Their consumers' single-instance gate is a deliberately
synchronous claim path — async there reorders the gate against the boot side
effects it guards — and execFileSync cannot be interrupted, so an Effect wrapper
would advertise a capability the call does not have. The parsers and folds stay
pure sync throwing functions for the same reason: both consumers import them to
read a document they already hold.

Also turns on exactOptionalPropertyTypes, which makes HostReading's four
genuinely-absent fields machine-checked and forces the two deliberately
present-and-undefined shapes (ListenerRow.uid, SocketHolder.command) to say so.

BREAKING CHANGE: the spawning verbs return Effects, `OsfactsClientError` is now
a union TYPE rather than a class (construct/branch on the three classes, or use
`isOsfactsClientError`), and `.kind` is gone.
…ability list

"What it does not" claimed the client does not spawn synchronously and does not
answer identity questions. It has done both since the sync gate path landed —
snapshotPidsSync spawns synchronously on purpose, and processIdentity* reports a
pid's start-qualified identity. What it does not do is decide what that fact
MEANS, which is the honest line and now the one the list draws.

Adds the Effect surface: which verbs return Effects, why three functions are a
deliberate sync island, and the three-class error vocabulary.
… reads it

The `.npmrc` form was accepted and then ignored — the install passed locally
only because an earlier run had already written the decision into
`node_modules`, and a clean checkout (CI, both platforms) failed exactly as it
did before the line existed. pnpm 11 reads `allowBuilds` from
`pnpm-workspace.yaml` and from nowhere else; `package.json` and `.npmrc` are
silently inert.

Verified against a simulated consumer graft: a parent pnpm workspace that
contains this directory as a member ignores the nested file entirely and links
the member as usual, so the setting has no reach beyond an install run from
here.
@srid

srid commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Campaign disposition — upstream half (re-walked at final HEAD 86091cf6)

Per the definition of done in juspay/kolu#2101 (comment):

  • The ~9 Promise verbs → Effect: all six async verbs plus osfactsSocketHolders' inner function now return Effect.Effect<_, OsfactsClientError>. Empty-scope short-circuits still never spawn (pinned); socketHolders' empty-path guard became a typed failure (the async-so-it-rejects design note dissolved, and says so).
  • Error vocabulary: OsfactsSpawnError / OsfactsVersionError / OsfactsParseError (Schema.TaggedErrorClass), union alias OsfactsClientError, runtime guard isOsfactsClientError. Every old kind raise site maps 1:1.
  • The sync twins — decided, not drifted: snapshotPidsSync / processIdentity / processIdentityFromEnv stay raw-sync, reason written in the module header — the consumers' single-instance gate claim (acquirePidGate, shared by kolu's three daemons and drishti) is a deliberately synchronous claim path, and execFileSync cannot be interrupted, so an Effect wrapper adds ceremony without capability.
  • Spawn mechanism — decided deviation from the Command-layer suggestion, reason written at the site: the spawns stay on execFile under an Effect wrapper because (a) childFailure's twin-symmetry invariant and its exit-2-refusal / signal-truncation discrimination read Node execFile error shapes, which a Command layer would reshape; (b) the child-level timeout + SIGKILL must stay kernel-enforced — an Effect timeout interrupts the fiber, not a wedged child; (c) zero platform deps keeps the Bun consumer (drishti) safe. Effect interruption does kill the in-flight child, and that is falsified (reverting the finalizer turns the test red).
  • No new unbounded wait/retry/fallback: none added; OSFACTS_COMMAND_TIMEOUT_MS semantics unchanged.
  • The pin spelling: the package declares the literal "effect": "4.0.0-beta.102" (catalog spellings don't resolve outside kolu's workspace); kolu's effectPin gate learned the site in the downstream PR.
  • Bonus: exactOptionalPropertyTypes: true is ON in client-ts's own tsconfig, making the exact-optional HostReading spelling machine-checked; the two deliberate present-undefined shapes (ListenerRow.uid, SocketHolder.command) are spelled ?: T | undefined with their one-line reasons.
  • Receipts: 39 → 41 tests, all byte/shape/message assertions surviving verbatim; CI 12/12 green both OSes (client-ts, hermetic, live-oracle, CodeQL).

Merge — parked, with the ordering: juspay/kolu#2101 merges first, then this PR, then kolu's adoption PR (juspay/kolu#2103) re-pins osfacts to this PR's merge commit. Merging is the maintainer's act; everything on this side is complete and green.

Downstream: juspay/kolu#2103 (adoption, green) · srid/drishti#133 (pair, green).

🤖 Generated with Claude Code

srid added a commit to juspay/kolu that referenced this pull request Aug 7, 2026
…aemons on Layers (#2101)

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

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

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

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

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

## What the migration itself caught

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

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

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

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

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

## Evidence

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

## Consumer repos (merge order)

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

## Review rounds

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

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

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

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

## Known remainders

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

### Try it locally

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

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-fable-5`)._
srid added a commit to srid/drishti that referenced this pull request Aug 7, 2026
…at can say what happened (#132)

* feat(deps): pin kolu at the Effect branch and put drishti on effect@4.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.

* feat(agent): serve the daemon on Effect handlers, and give the drain 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.

* feat(app): collapse the widened-contract seam, and put the parent and 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.

* test(common): pin the daemon control sibling OFF the mirrored surface

`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.

* docs: retire the docstrings the deleted oRPC shapes left behind

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.

* chore(deps): re-pin kolu at final effect HEAD, and adopt campaign 2

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>

* fix(server): the listen log reports where we LANDED, not where we asked

`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>

* chore(deps): re-pin kolu at 8f0ce9780 (review round 2101)

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.

* test(app): twin kolu's awaited-face scanner, extended for drishti's accessors

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.

* chore(deps): re-pin kolu at 41d517754

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).

* feat(agent): adopt the Effect-native osfacts client

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>

* chore(deps): re-pin kolu at 7d58e57f0

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.

* chore(deps): effect 4.0.0-beta.103, re-pinned to kolu beb2f7418

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.

* chore(deps): re-pin kolu at b1bb1ef51 (stdio epoch gate)

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.

* feat(agent): converge before the stdio front relays a byte

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.

* chore(deps): re-pin kolu at final effect HEAD 994fd1ff9

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.

* chore(deps): re-pin kolu at final effect HEAD f010fcdaf

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.

* feat(agent,server): an owned runtime fault exits the daemon, and a dead 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.

* chore(deps): re-pin kolu at final effect HEAD 85136453a

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.

* chore(deps): re-pin kolu at final effect HEAD 72da67ec6

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.

* chore(deps): re-pin kolu at final effect HEAD d6788f1c1

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.

* chore(deps): re-pin kolu at final effect HEAD e9b9a4c54

* chore(deps): advance kolu pin to master (2104ddea9)

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).

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant