kolu
Docs

Daemon invariants

The rules the daemon packages hold. They are facts about the machinery, not advice — each is enforced in code or by the type system. The reasoning behind them is in The daemon spine.

Boundary

RuleStatement
Binary-onlyOnly daemon binary code — serve and front — lives in @kolu/surface-daemon.
Client-onlyOnly supervising code lives in @kolu/surface-daemon-supervisor; it runs in the client, never the daemon, so it is never a staleKey root.
The package boundary is the hash boundaryA change to the supervisor cannot change what a daemon restart would load; that is the daemon half’s job alone.

Lifecycle

RuleStatement
Single-instanceacquirePidGate is an atomic link(2) claim. If the gate is held, the process exits 0 — another live daemon already serves this scope.
Idempotent adoptThe adopt-or-spawn poll is idempotent under the daemon’s own pid-gate, so a racing second claim is a clean no-op.
Teardown orderingShutdown is always stop accepting + disconnect established peers + remove the socket file (the listener close()) → release gate → return DaemonExit → release the wrapper’s resources → exitdaemonProcessMain runs the exit strictly after everything inside its run() promise, so the exit cannot preempt a release stage, and a shutting-down daemon no longer leaves still-connected clients being served by a “closed” socket.
No process.exit in the skeletonThe rationale is testability, and the partition preserves it completely: daemonMain (the value core) still returns a DaemonExit and never exits, so the whole lifecycle stays drivable in-process from a test. Process ownership is a different thing and now says so — it lives in daemonProcessMain, the bin half, and nowhere else. The socket close() is idempotent and safe to call from a process.on("exit") handler.
The lingering-daemon state is unspellable in a daemonProcessMain binA bin that adopts daemonProcessMain cannot forget the exit or the crash arm: once its run() settles — the tenure ended, resources released — the process ends. A live resource or timer cannot keep a finished daemon alive (a run that never settles is a different state: the daemon isn’t finished).
A wedged bound-to-pid daemon still diesdaemonProcessMain arms a sibling that does not share the daemon’s event loop. When KOLU_DAEMON_BIND_PID is set, that sibling polls the bind pid, waits a grace for the clean pid-gone path, then SIGKILLs if the daemon is still up. It does not SIGTERM — that handler is already gone on the clean path. In-process daemonMain tests never arm it — they share the runner’s pid.
Every daemon declares its anchorDaemonSpec.anchor is required — a daemon author must answer “which directory’s deletion makes me garbage?” to compile. The spine polls it under every lifetime and reaps the daemon (anchor-gone, exit 0) once the anchor is proven gone, so a git worktree remove can no longer strand a daemon forever. A daemon with genuinely no on-disk identity says so visibly (() => undefined); there is no opt-out knob.
Reaping requires proofOnly ENOENT counts as “the anchor is gone” (anchorGone), and only after two consecutive polls. An unreadable path (EACCES, EIO, ENOTDIR) is “I could not read whether it exists” — the opposite of proof — and a single miss could be a transient unmount; neither ever reaps. The supervisor side (kolu-server’s padi binder) shares the same predicate, so the two ends cannot drift on what “gone” means.
Serves without askingThe daemon serves the { group, handlers } it is handed and asks no questions — no env or spawn policy lives in it.
A route set is one setThe group a daemon advertises and the handlers it binds are asserted equal at boot. An advertised-but-unanswered tag and an answered-but-unadvertised one are both crashes, never a runtime 404.
Frozen within an epochThe control-core fragment never versions inside a protocol epoch. The Effect-4 framing change (oRPC base64+newline → Effect RPC ndjson) was a declared flag day, not a negotiation — negotiation happens inside the protocol being replaced, so a previous-epoch peer cannot be asked anything at all. It is observed as unspeakable-protocol, never as an incompatible version. CONTROL_CORE_VERSION versions the payload, and the payload’s six fields did not move (byte-pinned); a value carried inside the frame cannot describe a break in the frame itself. From this epoch forward the frozen contract holds again, unchanged. controlCore.ts’s header is the source of truth for this doctrine.
A refusal the wire did not declare is a defectcore.drain declares no error schema, so a not-drainable daemon’s refusal is an Effect.die, not a member failure. A supervisor cannot narrow on it and “handle” it — which is the point: the daemon is not busy, it is not drainable.

Identity and versioning

RuleStatement
Version-agnostic control coreIdentity is read over a version-agnostic channel, before the versioned handshake, so it stays reachable at a skew.
One probe assembly authorityprobeDaemonIdentityFrom turns an already-dialed control-core client plus its exit oracle into the complete convergence probe. The socket-path probeDaemonIdentity delegates to it; transports do not reconstruct identity or drain fields themselves.
Link loss is not exitA connector’s exit oracle must prove the daemon process exited. A closed transport alone never becomes the stronger ClosedInfo.kind === "exit" fact.
Contracts are orderedContract versions are ordered — contractIsCompatible / contractIsNewer reuse @kolu/surface’s predicate.
Builds are match-onlyBuild ids are match-only, with no ordering exported — store hashes do not order.
Honest absencereadBakedIdentity returns empty strings only when both identity variables are absent off-nix rather than inventing an identity. The build id and commit are one fact: both must be non-empty or both variables absent; a half-pair or explicitly empty baked value crashes at daemon boot. readControlCoreHello enforces the same joint fact on observations: both absent, both empty, or both non-empty; a partial frozen identity is rejected at the shared reader before convergence can make a decision.
A missing frozen route is not a missing daemonnull means only an honest absent listener. During a named upgrade window, a live pre-fragment daemon’s structured NOT_FOUND is an older-build observation; every other hello failure throws.
Not-drainable stays aliveThe frozen wire retains drain(): void, but a not-drainable daemon refuses a direct call with a typed application error. Its policy cannot invoke drain, and the refusal cannot exit the daemon or touch its owned resources.
A proven skew is named, never collapsedThe endpoint reports “failures flip to dead before they throw” with ONE carve-out: a connect rejection that is a DaemonContractSkewError reports incompatible — carrying daemonVersion/requiredVersion off the error’s own fields — never dead (a fresh spawn that skews) and never degraded (a refuse-policy survivor that skews). A restart hold does not coerce it: the skew is the restart’s terminal verdict. Consumers derive affordances from the state sum, so no restart verb can be offered against a daemon a restart provably cannot fix.
An unspeakable peer is a third observation, not a skewA daemon from a previous protocol epoch cannot report a version at all, so convergence carries a third narrowly-typed observation beside “identity” and “absent”. It is raised at exactly two triggers — the peer speaks first in a framing we cannot parse (undecodable-frame), or it accepts the connection and says nothing for UNSPEAKABLE_SILENCE_MS (silence, which is what the previous epoch’s server, waiting for its own hello, actually looks like). Every other dial failure stays an ordinary probe-failed; probe-failed is never widened to absorb one.
An observation may only act on a daemon proven oursThe unspeakable fact becomes an unspeakable verdict only under two attestations: the gate file at this rendezvous is ours, and the pid it names passed the holder identity law. Uncorroborated it is probe-failed, and a foreign socket-squatter keeps the untouched SocketSquatterForeignError path — so no disposition can put a SIGTERM near a process we have not proven is our own daemon. That split is what lets the silence trigger exist at all: a merely slow peer stops at the fact.
The disposition is takeover, for every policyA corroborated unspeakable peer is taken over, not negotiated with: re-attest that our gate still names the classified pid, SIGTERM it (its own in-process shutdown runs — the very thing a drain verb would have requested), wait, escalate to SIGKILL only past a stated deadline, then spawn a daemon of this epoch that seeds from disk. The contract-skew policy is not consulted, because an undecodable wire is not a skew and the drain verb does not exist on it. The only refusal left is the residual where the gate stopped naming the classified pid before the kill — then nothing is signalled, and the unconverged anomaly carries the unspeakable-protocol cause with socketPath, gatePath and that pid as data.

The three make-illegal pins

The supervisor’s convergence kit makes three classes of mistake unrepresentable rather than merely discouraged:

  1. A drainless daemon cannot declare a drain policy. The drain-and-replace policy arms exist only for a drain-capable handshake; a drainless daemon (kaval) declaring one is a compile error.
  2. Contracts order, builds do not. The ordering predicate exists for contract versions and is simply absent for build ids — you cannot ask whether one build “is newer than” another.
  3. Identity precedes the handshake. The control-core identity is probed pre-connect, so a version skew can never hide it.