How to bake an identity
A daemon that gets recycled needs to say which build it is running, so a supervisor can tell “the daemon I shipped” from “an older one still holding the gate.” That identity is baked in at build time by Nix and read back at runtime by one shared recipe.
1. Bake the pair with Nix
Use mkDaemonIdentity (in @kolu/surface-daemon’s nix/daemon-identity.nix)
to --set two environment variables on the daemon binary, under a prefix of
your choosing:
<PREFIX>_BUILD_ID— a hash over the daemon’s behavioural source closure. It flips exactly when a restart would load different wire or behaviour code.<PREFIX>_COMMIT_HASH— the git ref the daemon was built from.
Select the behavioural fileset deliberately — it is the slice whose change means “a restart behaves differently.” Everything outside it (docs, unrelated code) can change without flipping the build id.
2. Derive the fileset — don’t list it
Prefer deriving the fileset over listing files by hand. A hand-kept list
drifts silently from what the process actually loads: a new dependency lands,
nobody edits the list, and a rebuilt daemon ships an unchanged identity. In a
pnpm workspace the loadable code is already declared — the transitive
dependencies closure of the daemon’s package, the same edges pnpm’s isolated
node_modules makes the only resolvable ones at runtime.
The recipe that walks it ships in the same package:
nix/workspace-closure.nix. Apply mkWorkspaceClosure to a name → directory
members map and it hands back two derivations of that graph:
depClosure { entries, stop? }— the member names reachable fromentries, minus eachstopentry and everything reachable only through it.identityInputs { entries, stableLeaves?, isHashedSource? }— the same closure rendered as the two argumentsmkDaemonIdentitytakes:behavioralFileset(each local member’s runtimesrcplus itspackage.json) andpinnedSources(the members that are pins).isHashedSourcedefaults to real.ts/.tsxsource, dropping.test.ts/.test-d.ts/.testlib.ts.
A member is either local — a path in your own repo, whose files go into the
hashed fileset — or pinned: a package reached through a content-addressed
pin, which contributes its whole store path instead. Which is which is
declared, in pinned, as a list of member names; it is never guessed from
how the value is written. A local member’s value must be a path literal and a
pinned one a string already inside the store, and mkWorkspaceClosure asserts
exactly that, so neither kind can be silently mistaken for the other.
Set mustCover to the name prefixes that must never resolve outside members:
outside a pnpm workspace there is no workspace: protocol to give a stale map
away, and a framework package that quietly resolves from the registry would be
invisible to the id.
let
closure = (import "${kolu}/packages/surface-daemon/nix/workspace-closure.nix" {
inherit lib;
}).mkWorkspaceClosure {
members = {
"my-daemon" = ./packages/my-daemon;
"my-wire" = ./packages/my-wire;
# Reached through the kolu pin, not this repo. Each contributes its whole
# store path to the id, so a pin bump moves the identity by construction.
# Strings, not paths — the pin is already in the store, and a path value
# would be copied in again under a fresh hash.
"@kolu/surface" = "${kolu}/packages/surface";
"@kolu/surface-daemon" = "${kolu}/packages/surface-daemon";
};
# DECLARE the pins by name. A member listed here whose value is a path, or a
# member NOT listed here whose value is a string, fails eval by name.
pinned = [ "@kolu/surface" "@kolu/surface-daemon" ];
# A @kolu/* package resolving from the registry instead of the pin would
# leave the behavioural closure unnoticed — fail eval instead.
mustCover = [ "@kolu/" ];
};
in
mkDaemonIdentity {
name = "my-daemon";
prefix = "MY_DAEMON";
root = ./.;
inherit commitHash;
inherit (closure.identityInputs {
entries = [ "my-daemon" ];
# Pure policy — the closure packages this daemon keys no currency on.
stableLeaves = [ "@kolu/surface-daemon" ];
}) behavioralFileset pinnedSources;
}
Here behavioralFileset carries the two local members’ sources, and
pinnedSources carries @kolu/surface — my-daemon declares it directly (it
is what defineSurface comes from), so making the spine a stable leaf does not
take it out of the closure with it.
pinnedSources folds each pinned member in as <name>=<store path>, so a pin
bump lands in the build id exactly like a source edit. The empty case hashes
byte-identically to the fileset alone, so a workspace-only consumer’s live ids
do not move just because the pinned arm exists.
Every list here is loud at eval time. A workspace: edge (or a
mustCover-matching edge) whose target is missing from members throws; a
stableLeaves entry that has left the closure throws; a members key that
disagrees with the package’s own name throws; a pinned name that is not a
members key throws, as does a member whose value shape disagrees with its
declared kind. stableLeaves is then the only hand-kept list left, and it is
pure policy rather than bookkeeping — step 4 is how you choose what goes in it.
The alternative: hash a narrowed derivation. A small daemon that already
builds from a narrowed derivation of its own can skip the walk entirely and hash
that derivation’s store path — buildId = builtins.hashString "sha256" (toString inner), where inner is the unwrapped daemon. This is
drishti’s shipped pattern, and it is equally
sanctioned. A store path is platform-dependent, so bake a per-system id map
({ x86_64-linux = …; aarch64-darwin = …; }) rather than a single id: a client
comparing against a daemon provisioned on another platform looks its peer’s
system up instead of comparing two hashes that could never match. The trade is
that the id now moves with everything inside the derivation, the runtime engine
included — a nixpkgs bump rotates it. That is fine when the daemon’s staleness
response is a cheap restart; it is not when staleness costs a human nudge or
live state (step 4).
3. Read it back at runtime
readBakedIdentity(prefix) (from @kolu/surface-daemon) is the one recipe both
the reads share. It returns { staleKey, navigableCommit }:
export function currentIdentity() {
return readBakedIdentity("KAVAL"); // reads KAVAL_BUILD_ID / KAVAL_COMMIT_HASH
}
export const currentBuildId = () => currentIdentity().staleKey;
export const currentCommit = () => currentIdentity().navigableCommit;
staleKey is the build hash a supervisor compares; navigableCommit is the git
ref you surface as a clickable identity in the UI.
4. Decide what the build id hashes
One policy choice is left, and stableLeaves is where you spell it: whether the
daemon-spine code is inside the hashed closure.
- If a contract-compatible spine change is behaviourally interchangeable — because the wire contract version is itself hashed — you can exclude the spine from the build id (kaval does this; the contract version carries the compatibility signal).
- If you prefer a cheap auto-drain where over-firing is harmless, include the whole closure (padi does this).
Pick by what staleness costs. Kaval’s staleKey drives a human “update available” nudge whose only remedy recycles the daemon and kills live PTYs, so over-firing is expensive and its slice is narrow. Padi’s is a cheap auto-drain, so it keys on nearly its whole closure.
Either way, the identity a client reads is now stable and meaningful across restarts, which is what recycling and upgrading depends on.