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
| Rule | Statement |
|---|---|
| Binary-only | Only daemon binary code — serve and front — lives in @kolu/surface-daemon. |
| Client-only | Only 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 boundary | A change to the supervisor cannot change what a daemon restart would load; that is the daemon half’s job alone. |
Lifecycle
| Rule | Statement |
|---|---|
| Single-instance | acquirePidGate is an atomic link(2) claim. If the gate is held, the process exits 0 — another live daemon already serves this scope. |
| Idempotent adopt | The adopt-or-spawn poll is idempotent under the daemon’s own pid-gate, so a racing second claim is a clean no-op. |
| Teardown ordering | Shutdown is always stop accepting + disconnect established peers + remove the socket file (the listener close()) → release gate → return DaemonExit → release the wrapper’s resources → exit — daemonProcessMain 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 skeleton | The 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 bin | A 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 dies | daemonProcessMain 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 anchor | DaemonSpec.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 proof | Only 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 asking | The 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 set | The 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 epoch | The 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 defect | core.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
| Rule | Statement |
|---|---|
| Version-agnostic control core | Identity is read over a version-agnostic channel, before the versioned handshake, so it stays reachable at a skew. |
| One probe assembly authority | probeDaemonIdentityFrom 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 exit | A connector’s exit oracle must prove the daemon process exited. A closed transport alone never becomes the stronger ClosedInfo.kind === "exit" fact. |
| Contracts are ordered | Contract versions are ordered — contractIsCompatible / contractIsNewer reuse @kolu/surface’s predicate. |
| Builds are match-only | Build ids are match-only, with no ordering exported — store hashes do not order. |
| Honest absence | readBakedIdentity 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 daemon | null 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 alive | The 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 collapsed | The 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 skew | A 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 ours | The 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 policy | A 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:
- A drainless daemon cannot declare a drain policy. The
drain-and-replacepolicy arms exist only for a drain-capable handshake; a drainless daemon (kaval) declaring one is a compile error. - 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.
- Identity precedes the handshake. The control-core identity is probed pre-connect, so a version skew can never hide it.