ADR-003: Daemonless Ledger and Lock-Free Claims
Status: Accepted 2026-09-13 Date: 2026-09-13 Decision Makers: Chris (@amiable-dev), LLM Council Council Review: 2026-09-13 — round one endorsed the daemonless ledger; round two specified the claim files, lock metadata, retry policy and hook deadline (docs/DESIGN.md §5.3, §6) Related: ADR-001 (hooks are read-only), ADR-004 (the reconciler consumes the ledger), ADR-005 (hook budget); docs/DESIGN.md §5.1, §5.3, §5.11
Context
The ledger is written by many short-lived processes (thirty SessionStart hooks can fire within the same second when a laptop wakes) and read by more (every berth ls, every dashboard poll). The registries surveyed in docs/RESEARCH.md take one of three shapes, each with a cost:
- A daemon behind an HTTP or MCP API (port-daddy, hotel): an always-on process, its own port to coordinate, one more thing to be down when a hook runs.
- One JSON file behind one lock (portmarshal, porta): simple, but thirty hooks contend on the lock, and a hook that waits past its budget delays the session start it is meant to inform.
- SQLite: correct concurrency, but
node:sqliteis absent on the Node 20.20 installed here, and a native module contradicts the zero-dependency bundle (ADR-005).
Two further facts shape the design: a hook must finish in about 200 ms to be invisible to the user, and pid reuse makes "is the lock owner alive?" a question a bare pid cannot answer.
Decision
There is no daemon. State lives in ~/.local/state/berth/: leases.json, claims/, tombstones.json, the lock file and a .bak. Policy lives in ~/.config/berth/policy.toml, versioned with dotfiles.
- Reads never lock.
leases.jsonis written by temp-file plusrenamewith the directory fsynced, so a reader always sees a complete document. A.bakcopy survives a torn write. - Claims are lock-free. A session writes its own
claims/<session_id>.jsonwithO_EXCL. Thirty sessions starting at once contend on nothing. The reconciler, or an explicitberth compact, folds claim files intoleases.jsonunder the single lock. - One lock, on compaction only. The lock file records
{pid, pid_start_time, host, cmd, ts}. A lock is broken only when its owner's pid and start time are dead (start time defeats pid reuse). If that cannot be verified, the lock is stale after 30 s. If the owner is alive, the command warns and never breaks it. - Jittered retry with a hard hook deadline. Retry is jittered exponential from 10 ms to 250 ms. A hook gives up after 200 ms and exits 0 with an advisory warning; an interactive command waits 2–5 s.
- Lease shape.
{port, project, worktree, role, kind: block|dynamic|declared|shared, owner: {session_id, tool, pid, pid_start}, cwd, created, expires, note}. - Liveness beats wall-clock. A lease whose pid is alive and whose port is bound is
okregardless of TTL, so a laptop asleep for a weekend does not wake to mass expiry. After a reconcile gap longer than the TTL, every lease gets one TTL of grace. - Per user by construction. The ledger lives in the user's home; two humans on one machine are out of scope (§5.11).
Consequences
Positive. Nothing to start, stop or keep alive; a hook that finds the ledger locked still delivers context. Claim files make session attribution explicit before compaction ever runs. Atomic rename plus .bak means a crash mid-write loses at most the claim being folded, never the ledger.
Negative. Until compaction, leases.json may lag the claim files, so a reader that wants the freshest view merges both (the CLI and dashboard do). The pid-start-time check is platform-specific (ps -o lstart on macOS, /proc/<pid>/stat on Linux). Claim files from sessions that never ran SessionEnd accumulate until compaction marks them stale.
Neutral. Moving to SQLite later would be contained behind the --json boundary (ADR-005) and would not change any command's output.
Compliance / Validation
- Concurrency test: N parallel claim writers produce N claim files and zero errors; one compaction then produces exactly N leases.
- A lock owned by a dead pid with a mismatched start time is broken; a lock owned by a live pid is never broken; an unverifiable lock is broken only after 30 s.
- Hook path: with the lock held by another process,
berth contextreturns within 200 ms and exits 0. - Torn-write test: killing the writer between temp file and rename leaves a parseable
leases.json.