← the Atlas

Design — kaval contract skew: two commits to kill the class

seedling·implemented·

The build-ready design for the remote-kaval contract-skew fix: commit 1 repairs the three defects at their seams (log bridge, version-bearing error, typed rethrow); commit 2 fortifies the surface framework (an incompatible daemon state, one kaval-attention surface with per-axis recovery, a declared error channel on defineSurface).

#1876

This note is the build plan for the two-tier fix ruled in the bug note: one PR, two commits — commit 1 repairs the instances (SK1–SK3), commit 2 makes the class inexpressible (SK4–SK6). Work items carry the SK track prefix (new; no collisions in the Atlas). Every file:line below was re-grounded at kaval-skew HEAD (318f17437).

The skew fact — typed at the mint, typed at every hop MINT · connect.ts:97 DaemonContractSkewError + daemonVersion + requiredVersion SK2 — message derived from fields STATE · supervisor endpoint … | degraded | dead | incompatible{dv, rv} today: catch→dead (endpoint.ts:431), refuse→degraded (:741) — the collapse WIRE · daemonStatus DaemonStatusSchema — a third object arm (vocab.ts:577) {state:"incompatible", daemonVersion, requiredVersion} DERIVE · kavalAttention() kavalCurrency.ts — the ONE version-comparison site (SK5) none | stale{run,exp} | incompatible{dv,rv} canvas skew card DegradedCanvas gains the incompatible arm — no Restart KavalInfoDialog banner same slot as "newer build available" — two axes, one chrome host-chip pip + tooltip, same derivation RECOVERY · a total function of the axis — SK5 stale → lifecycle.recycleKaval (RestartKavalButton — converge the build from the padi's own closure) incompatible · any host → confirm → hosts.renewDaemon → binder renew(): drain → re-realise closure → re-exec → converge recycles kaval. Respawn already failed; change the closure. The RPC leg — declared, not flattened HANDLER · recycleKaval servePadi.ts:443 — catch skew, throw errors.KAVAL_CONTRACT_SKEW SK3 (ad-hoc) → SK6 (declared) CONTRACT · defineSurface ProcedureSpec.errors → oc.errors() declared union on the wire (SK6) oRPC 1.13.13 — API exists at the pin CLIENT · discriminated catch isDefinedError(e) → e.data: {daemonVersion, requiredVersion} feeds the same kavalAttention chrome KILLED · the flatten plain Error → toORPCError → "INTERNAL_SERVER_ERROR" today's toast — the P5 defect SK1 · the log bridge — the crash that hid everything makeSession's onLog?: (line, severity) => void becomes log?: Logger (the @kolu/log shape). emit calls opts.log[severity]({ line }, label) a receiver-bound call, so the consumer-side unbound-pino ternary (remotePadiBinding.ts:697, padiBinding.ts:605) has no spellable form left. The compensating per-line try/catch (session.ts:567) is DELETED — its reason to exist is gone; keeping it would hide the next regression.
The skew fact stays typed at every hop: minted with version fields (SK2), carried as a first-class state (SK4), derived once (SK5), recovered per axis. The RPC leg is declared, not flattened (SK3→SK6). SK1 unblocks the diagnostics that hid all of it.

How this honors the design philosophy

Gate criteria (binding)

  1. The skew card — the user sees both versions (“this host’s kaval speaks 5.0, your kolu needs 5.2”) carried as typed fields end-to-end (padi throw → status arm → client); no consumer ever re-parses message prose.
  2. The card offers the working recovery — one action that stops the old kaval and starts a correct-version one, via the binder’s reprovision path (re-realise the host closure → fresh kaval), never the recycle path (by incompatible’s construction a recycle from the current closure has already been tried and skewed — see SK5). To the user: “Update & restart kaval.” The “working” claim is proven on the incident topology in the evidence, not inferred (see Gates).
  3. Faithful propagation — every hop from surface to UI preserves the typed skew; no INTERNAL_SERVER_ERROR flattening anywhere on the path.

Commit 1 — the repairs

SK1 — the log bridge: a receiver-bound Logger seam

The crash is an unbound pino method: (severity === "error" ? log.error : … : log.info)({…}, "…") extracts a bare function reference, so pino runs with this === undefined and throws on every line — in both consumers (packages/server/src/padi/remotePadiBinding.ts:697, packages/server/src/padi/padiBinding.ts:605). The structural fix moves severity dispatch inside the session so the hazard has no spellable form:

SK2 — the error carries its versions

DaemonContractSkewError (packages/surface-daemon-supervisor/src/endpoint.ts:82) today takes a prose message; both mint sites already hold the two versions as values and bake them into the string. The class gains the facts:

new DaemonContractSkewError({ subject: "pty-host", daemonVersion, requiredVersion })
// .message derived: `${subject} contract skew: daemon speaks ${daemonVersion}, needs ${requiredVersion}`

SK3 — typed rethrow at the knowing endpoint

recycleKaval’s catch (packages/padi/src/servePadi.ts:443–458) rethrows a plain error, which oRPC’s toORPCError collapses to INTERNAL_SERVER_ERROR — the toast the user saw. Mirroring the existing fileGoneAsNotFound pattern (servePadi.ts:124):

catch (err) {
  if (isContractSkewError(err)) {
    throw new ORPCError("KAVAL_CONTRACT_SKEW", {
      message: err.message,
      data: { daemonVersion: err.daemonVersion, requiredVersion: err.requiredVersion },
    });
  }
  …existing journal line + rethrow…
}

Commit 2 — the skew becomes a state with one honest surface

SK4 — incompatible: a first-class daemon state

The discrimination already exists inside the endpoint (SurvivorConnect = adopted | skew | unreachable, endpoint.ts:476–479) — it just dies before the status surface: a recycle whose fresh spawn still skews is caught by a skew-blind catch and emitted dead (endpoint.ts:427–433), and the padi binder’s refuse arm emits degraded (endpoint.ts:741). Extend, don’t invent:

Seam Change
endpointStates.ts ENDPOINT_STATES gains "incompatible"
EndpointStatus (endpoint.ts:57) third arm: { state: "incompatible"; daemonVersion; requiredVersion } (versions from SK2’s fields)
spawnConnectHold catch (endpoint.ts:427) isContractSkewError(err) → emit incompatible with versions; else dead as today
refuse arm (endpoint.ts:734–742) emits incompatible (was degraded) — same verdict, now named
restart-hold coercion (endpoint.ts:307) incompatible passes through un-coerced, like dead — it is a terminal verdict, not a transition
daemonStatus.ts:105 fold threads the versions onto the wire object
DaemonStatusSchema (vocab.ts:577) a third object arm carrying both versions; NON_CONNECTED_ENDPOINT_STATES (vocab.ts:563) excludes "incompatible" so the payload is spellable
padiBinding.ts:479 the session-loop condition gains the arm (degraded || dead || incompatible)

Client-side totality — every site the state-flow survey found, made exhaustive rather than fallthrough:

Red-first pins: supervisor unit — a recycle whose fresh spawn throws skew emits {state: "incompatible", daemonVersion, requiredVersion}, never dead (red today at endpoint.ts:431); client unit — incompatible presents down with no restart verb (red today: warming fallthrough + enabled button).

The honest version bump — PADI_SURFACE_VERSION 4.0 → 4.1. The third DaemonStatusSchema arm is a new emitted wire variant, and the repo’s own versioning doctrine (surface.ts:180–208: “the version is an honest statement of the wire SHAPE”; 3.1 stayed minor only because it had “no reshape, no required field, no emitted variant”) requires the minor bump. The two skew directions both converge: a new binder against an old padi (4.0 < 4.1) fails isContractVersionCompatible’s minor rule (define.ts:1090 — reported minor must be ≥ expected), so the padi convergence policy drains-and-replaces the old padi before any status is consumed; an old binder against a new padi is version-compatible (4.1 ≥ 4.0) but build-mismatched, so the build axis drains-and-replaces padi first — the old client schema never sits long enough against a padi that could emit the arm it can’t parse. That paragraph is the answer to “who reports the skew of the skew-reporter”. The symmetry teaches the rule: #1865 folded an orphan 4.1 bump back because there was no emitted delta; here the bump is required because there is one.Doctrine precedents in surface.ts’s version log: 3.0/4.0 majors for reshapes and removals (“only a major flips isContractVersionCompatible to refuse the skew”); 3.1 minor for purely-optional adds. A new emitted variant sits exactly at the minor line: additive for a newer client, but a shape an older client must never be left facing — which the build-axis convergence guarantees.

Red-first pins (SK4, completing the set):

ENDPOINT_STATES/EndpointStatus are exported API → ref-surface-supervisor.mdx + surface-daemon-invariants.mdx (the “failures report dead before they throw” invariant gains the skew carve-out) same commit. Neither drishti nor odu imports the supervisor package (grounded grep, zero hits).

SK5 — one attention surface, per-axis recovery

The problem is already half-solved in the UI — kolu has a kaval-attention surface: kavalStale (kavalCurrency.ts:31, the pure currency derivation), the KavalInfoDialog banner (“Newer Kaval build available”, KavalInfoDialog.tsx:256–277, hosting the session-preserving RestartKavalButton), and the host-chip pip + tooltip (HostDaemonChips.tsx:353–361). The skew presentation is this same surface extended with the contract axis — not a parallel card. Two grounded gaps bridge it:

(1) Predicate. kavalStale requires state === "connected" (a build-behind daemon is honestly connected) — a contract-skewed kaval never connects, so the skew reaches the surface via SK4’s incompatible status arm, not via kavalStale. The join is a new single derivation in kavalCurrency.ts, superseding bare kavalStale at every read site:

type KavalAttention =
  | { kind: "none" }
  | { kind: "stale"; running: string; expected: string }          // currency axis — "newer build available"
  | { kind: "incompatible"; daemonVersion: string; requiredVersion: string }; // contract axis — "incompatible, needs update"

kavalAttention(expected, status, live): KavalAttention
// live-floored like kavalStale; incompatible read from the TYPED status arm — never message prose

Both axes light the one badge/dialog/pip, distinctly labeled, one chrome: amber for stale, the down tone for incompatible. The two version pairs render from typed fields (staleKey/navigableCommit for currency; daemonVersion/requiredVersion for contract). All three readouts — dialog banner, chip pip/tooltip, and the canvas card below — call this one function; no second version-comparison site exists anywhere in the client.

(2) Action per axis — two rows, no host split. One dialog slot, wired per axis; incompatible gets one action on both hosts:

Axis Action Why it works
stale (any host) RestartKavalButtonlifecycle.recycleKaval (unchanged) respawn from the padi’s own closure is exactly “converge the build”
incompatible (any host) “Update & restart kaval” (confirm) → hosts.renewDaemon(host) by the time incompatible renders, a respawn from the host’s current closure has already been tried and skewed — the arm’s only producer is the fresh-spawn-still-skews catch (endpoint.ts:427–433; a skewed adopted survivor is recycled silently by adoptOrEnsure + onContractSkew: recycle and never reaches the UI). The only action left that changes anything is the one that changes the closure: renew.

A local plain-restart row would offer a recovery the server has already proven failed — defect B relocated to the local axis — so it does not exist. Local renew is the same verb through the same seam: drain the local padi → the binder respawns it from the server’s own build → its converge policy recycles kaval from the new KOLU_KAVAL_BIN. It strictly dominates a plain restart and also heals the stale-bound-padi edge cases. The action is destructive to the host’s terminals (padi drains, kaval recycles), so the card’s button carries a confirm affordance — the same pattern as the existing Restart confirm — whose copy states that this host’s terminals restart.

hosts.renewDaemon is the one new RPC: a host-keyed sibling of hosts.reconnect (router.ts:105–108) that forwards to that host’s padiSession.renew() (padiSession.ts:138 — the binder-owned drain: padi persists + exits → the reconnect loop re-dials → provisionAgent re-realises the current drv (nixCopy.ts:138, resolved fresh at the top of every dial, sshConnector.ts:71–74) → the --stdio front re-execs padi from the new closure (stdioBridge.ts:13–18) → the new padi’s converge policy auto-recycles the old kaval from its new KOLU_KAVAL_BIN (KAVAL_CONVERGENCE_POLICY.onContractSkew: recycle, ptyHost/index.ts:71–75); on the local host the same drain respawns padi from the server’s own build constant, same converge, same fresh kaval). Every step of that pipeline already exists and is the only path that changes the closure; the RPC merely exposes it host-keyed.

Scope line: the padi-axis contract skew (dial.ts:152, subject “padiSurface”) is deliberately out of scope for this card — it already has its own verdict channel (skew-refusedcontract-skew-refused host-down card), and the two flavors cannot collide on the kaval card today (a skew-refused padi never serves the daemonStatus collection this card reads).Why not a new PadiConvergence arm (“kaval-skew”) instead? Because the fact already has a per-host wire home — the re-served padiSurface daemonStatus collection (state incompatible, SK4) — and the binder’s convergence() fact reaches the browser only via the legacy local-hardcoded daemonInventory cell (useDaemonInventory.ts:41–50 flags the gap). Mirroring the same fact onto a second channel would create two authorities for one truth — the P3 defect this PR exists to kill. One authority per fact: padi reports the skew (the status arm), only the binder can fix it (the renew verb). The convergence vocabulary is reused where it lives: renew() is the binder’s existing convergence enactment, and the card’s copy speaks the same “converge” language as hostDownCopy.ts.

The UX flow (canvas card = the widened DegradedCanvas arm; dialog = the extended banner):

kaval is incompatible with this kolu

this host's kaval speaks 5.0 · your kolu needs 5.2

Restarting can't fix this — the host's kaval binary is from an older kolu install. Updating re-realises the host's closure and starts a correct-version kaval.

opens a confirm (the existing Restart-confirm pattern): drains padi, re-provisions the closure, starts a correct-version kaval — terminals on this host restart

Incompatible — needs update

kaval speaks 5.0 · kolu needs 5.2

Newer Kaval build available

running a1b2c3d · expected e4f5a6b

the KavalInfoDialog banner slot — the two axes, distinctly labeled, one chrome (only one shows at a time: a skewed kaval is never connected, so the axes are mutually exclusive by construction)

Red-first pin: client unit/e2e — on incompatible (any host) the canvas card shows both versions and offers Update & restart kaval behind a confirm — never the plain Restart kaval. Red today: the canvas shows “kaval didn’t start” + a Restart that loops forever.

SK6 — a declared error channel on defineSurface

The framework move that kills “a typed daemon error arrives as an opaque INTERNAL_SERVER_ERROR” as a class. The pinned oRPC (1.13.13, every @orpc/* in pnpm-lock.yaml) carries the full contract-level typed-error API — .errors() on every builder stage, handler-side opts.errors constructor maps, client-side isDefinedError/safe — and kolu uses none of it: ProcedureSpec (define.ts:227) has no error slot, so a procedure cannot declare its failure modes.

Red-first pins: type-level (.test-d.ts) — a declaring procedure’s client rejection narrows via isDefinedError to the declared {daemonVersion, requiredVersion} data, and errors? is threaded through all four buildProcedure* oracles (define.ts:845–859, the define.ts:779 drift-watch) — not just the shape recycleKaval happens to use; runtime — the declared error crosses the incident hop: minted by a handler behind surface-map’s keyed proxy + mirrorRemoteSurface (the exact path the field failure flattened at, server.ts:534) and arrives at the client defined: true with data intact, while an undeclared plain throw still arrives as INTERNAL_SERVER_ERROR. That pin is the incident as a permanent regression test.

Gates — consumers, docs, evidence

Consumer gate (.claude/rules/surface.md), grounded greps at both pins:

Consumer Exposure Verdict
drishti (hostRegistry.ts:92) makeSession — passes no onLog; specs don’t spell errors; no supervisor import pair PR = pin bump to final kolu HEAD, no code change expected; CI green required
odu (lane.ts:69) makeSession — no onLog; depends on the default stderr sink (display.ts:26), which SK1 preserves; no supervisor import SK1/SK2/SK4: none · SK6: adoption-opportunity (declared errors for node.rerun/run.cancel) → ledger odu#43

Docs, same PR: ref-surface-remote.mdx (SK1 — also fixes the stale “info for the expected-absent case” line at :154–157; the code emits debug), ref-surface-supervisor.mdx (SK2, SK4), surface-daemon-invariants.mdx (SK4), ref-surface.mdx (SK6), packages/padi/README.md + packages/kaval/README.md if the daemon-topology prose mentions restart semantics (grep at build time), changelog: one fixed entry (the user-facing skew UX) + one changed entry (the surface framework’s declared error channel).

Evidence — prove the recovery, not just the card. The remote-host skew is exactly what the remote-host-testing harness exists for, and gate criterion 2’s “the working recovery” must be shown working on the incident topology: a current-contract padi + an ancient adoptable kaval survivor on the box (the field trace’s shape — the “needs 5.2” constant was compiled into the remote padi that threw, so the padi was current and the kaval was the graveyard survivor). Capture: (before) the dead-end Restart loop / opaque toast; (after) the incompatible card with both versions, then hosts.renewDaemon actually converging the host — a fresh correct-version kaval connects and the canvas comes back. CI: linux pu lease