docs: describe TTL-first leases end state, accept ADR 0004 (PR C) - #122
Open
V3RON wants to merge 8 commits into
Open
docs: describe TTL-first leases end state, accept ADR 0004 (PR C)#122V3RON wants to merge 8 commits into
V3RON wants to merge 8 commits into
Conversation
Rewrite every user-facing document for the lease model ADR 0004 decides: one kind of lease, TTL-bound on every transport, kept alive only by a client-initiated lease.renew. Held mode stops being a daemon concept — staying alive to renew and releasing on exit is now the CLI's and the MCP session's own policy over an ordinary lease — and connection close releases nothing anywhere. Flip ADR 0004 to "Accepted — not yet implemented" in the ADR and in the index, so the docs below are the specification the code catches up to. Config: lease.defaultTtlMs and lease.maxTtlMs documented; the three retired lease.* keys get a migration table. Contract: lease.heartbeat, the heartbeat capability, and mode are gone; protocol 4. Behaviour: a holder killed with SIGKILL keeps its device until the TTL expires, stated wherever held mode used to promise instant release, and recorded as a known pitfall with its knob. Docs only; no src/ or e2e/ changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Four fixes from an adversarial pass over the ADR 0004 doc rewrite: - ARCHITECTURE.md no longer prescribes an MCP reconnect sequence the ADR does not decide; it states the fact (the lease survives, the session picks it back up and keeps renewing) instead. - CLIENT.md stops attributing to ADR 0003 §6 a reason ADR 0004 invalidated. The decision is 0003's; what a daemon stop costs is now stated in 0004's terms - queued requests die, leases burn TTL with nothing to renew against. - CLI.md's `daemon stop` section says plainly that stopping the daemon does not touch leases, where an operator actually reads about it. - Re-wrap prose the rewrite left over the file's line width. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Apply the second review round's 15 findings. Six of them were places the
docs hedged or guessed where ADR 0004 was silent; those are now stated as
the end state PR A and PR B implement.
- A running `simlock lease` does not reconnect (ADR 0003 §10 stands). On a
dead connection it writes one DAEMON_CONNECTION_LOST line naming the lease
and its ttlDeadline and exits 1, leaving the lease standing for a later
invocation to renew. Exit 14 keeps its narrower meaning: the daemon ended
the lease while the connection was alive.
- The lease record stores its ttlMs, and a body-less renew re-applies that
width. lease.defaultTtlMs applies only to a request that names no ttlMs, so
a four-hour lease no longer shrinks to fifteen minutes on its first renew.
This is what makes the holder's timer safe: it sends no TTL at all.
- Renew cadence is "one third of the lease's TTL" everywhere, per ADR §2.
- The MCP session reconnects eagerly from its renew timer rather than lazily
on the next tool call, which would let its own lease expire in between.
- lastRenewedAt is a new stored field, not a rename: lastHeartbeatAt was
derived as ttlDeadline - heldTtlBackstopMs, which per-lease TTLs break.
- No lease.detachedTtlMs alias. All three retired keys warn and are ignored
like unknown keys, as the ADR says; the migration table says to copy the
value across. Load-time rule added: both TTL keys positive, default <= max,
rejected rather than clamped.
Also: notices is HTTP-side (LeaseNoticeBuffer), never the socket lease.renew
response; the protocol range {min: 4, max: 4} is stated in ARCHITECTURE's
contract section, not only the changelog; EVENTS declares the lease payload
removals a deliberate one-off exception to the additive-only rule and marks
the four rows payload-pending; ADR 0004's Supersedes line names every ADR
0003 section it narrows; and the reparented-holder pitfall now explains that
a TTL bounds silence, which a live renewing holder is not.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Third review round, 14 findings. Two were decisions the docs had invented rather than inherited, so they are recorded in ADR 0004 itself — this PR is what accepts it — and every other doc now follows the record instead of leading it. - MCP's reconnect is timer-driven but never launches a daemon. ADR 0003 §10/§11 gave MCP one lazy trigger, the next tool call; Decision 2 now adds the renew timer as a second, so an idle session does not lose its lease waiting for a call that never comes, and restricts it to a daemon that is already listening. Auto-launch stays a tool-call concern, because an operator's `daemon stop` must not be undone by an idle session. The Supersedes line names §10/§11, and ARCHITECTURE, CLIENT and the changelog say the same thing. - The events payload removals are the ADR's exception to take, not EVENTS.md's. A Consequences bullet grants it once, while the package is 0.x; the EVENTS.md note now cites that bullet rather than granting it. Also settled: a renew that fails transiently on a live connection is retried on the next tick, while one answered UNKNOWN_LEASE ends the holder at exit 14 like a lease-lost push (CLI.md, ARCHITECTURE); and a TTL config that contradicts itself is rejected at load and the daemon does not start, which is deliberately not the warn-and-ignore treatment the retired keys get (CONFIGURATION, changelog). Corrections: the `lease.defaultTtlMs` config cell no longer claims to be the renew fallback, which was the round's most load-bearing error; cross-process `lease renew` needs an admin credential, so it joins the admin command list and the two places that showed it as a bare follow-up invocation; HTTP's additive-evolution promise names the one break ADR 0004 makes in it; `lease_lost` is documented as the fact `notices` cannot carry, since an ended lease answers renew with 404; the HTTP-tracker pitfall no longer explains itself by a connection holding something, since none does; MCP's README paragraph says manual lease renewal and that the server renews its own; `lease_simulator` takes the contract's `ttlMs`; and the stranded half-lines the rewrite left behind are re-flowed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Fourth review round. Every blocking finding was the same shape: the docs
stated a decision the ADR did not record, which inverts the rule that the
ADR is the specification. So the decisions move into ADR 0004, and the docs
cite it.
New in the ADR:
- Decision 4 carries the renew rule — a lease records the width it was
granted with, and a renew naming no ttlMs re-applies that width rather
than lease.defaultTtlMs, so a four-hour lease does not shrink to fifteen
minutes the first time something renews it — and says the config key is
renamed in the key set, not aliased.
- Consequences gain four bullets: the HTTP additive-evolution exception,
lastRenewedAt replacing the derived lastHeartbeatAt decoration, protocol 4
advertised as {min: 4, max: 4} under ADR 0003 §6's honesty rule, and the
two TTL keys validated together at load with a violating config failing
the daemon start.
- The SIGKILL consequence no longer says "at most lease.defaultTtlMs", which
was wrong for any lease that asked for more.
HTTP-API's additive-evolution paragraph was itself wrong to claim no field
is added or removed: mode leaves the lease record the operator routes
serialize, and lastRenewedAt and the stored ttlMs arrive on it. It now lists
all three breaks and cites the ADR bullet that grants them.
Cross-process `lease renew` needs an admin credential exactly as
cross-process `release` does, so it joins the admin command list, the
--detach bullet, the renew section, and the worked example that had only
ever shown release.
Also: list --leases documents lastRenewedAt as status already does; a daemon
refusing to boot on a bad TTL pair surfaces through an auto-starting command
as DAEMON_STARTUP_FAILED with the reason in daemon logs; the exit-code note
records that a running holder's renew is the exception that exits 14; the
MCP section says which trigger may launch a daemon and which may not; two
bare "ADR §10/§11" references are spelled out as ADR 0003; and README's MCP
link points at CLI.md's real heading anchor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Confirmation round. The grant JSON in CLI.md is introduced as every field the contract defines, so it has to carry the two fields ADR 0004 adds: ttlMs and lastRenewedAt, the latter equal to grantedAt at grant time. Both README examples get them too. All three samples parse and agree with each other: ttlDeadline is grantedAt + ttlMs, and mode is gone. Also: drop a sentence about auto-starting the daemon that had been left duplicated in the MCP section; indent and rewrap a changelog continuation line; and say "none of the CLI's three sources" where the count had picked up the programmatic `credential` option, which is not one of them. ADR 0004's Decision 2 gains the renew-failure rule the docs already state, so the record carries it: a transient failure is retried on the next tick, and a renew answered UNKNOWN_LEASE ends the holder the way a lease-lost push does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Two corrections from PR B's code review. A daemon that refuses to boot on its TTL config fails validation before it claims the socket, so an auto-starting command never gets a daemon to talk to: the launch times out and the command fails with exit 1 (INTERNAL), not DAEMON_STARTUP_FAILED, which only reaches a caller whose request was already parked on a daemon that had claimed its socket and then failed convergence. The error line therefore says nothing about the config -- nothing answered -- and the reason is in `simlock daemon logs`. The DAEMON_CONNECTION_LOST example also used an em dash in its message where the code writes ASCII, so the sample now matches what a caller would parse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
This was referenced Sep 5, 2026
Two additions from PR B's second code review, both about a lease that
already exists when something changes around it.
Lowering lease.maxTtlMs does not shorten leases already granted. The cap
bounds what a request or a renew may ask for; a lease holding a larger
stored ttlMs -- granted under a higher cap, or carried over from an older
record -- keeps re-applying that width on every body-less renew. Releasing
it, or renewing it once with an explicit smaller --ttl, is what changes it.
And a lease a pre-ADR-0004 daemon persisted in held mode survives the
upgrade with its deadline intact, up to the old one-hour backstop, but
nothing can renew it: its holder speaks protocol 3 and the new daemon
advertises {min: 4, max: 4}, so its hello fails. It expires on that
deadline, or `simlock release <id>` ends it sooner -- and stopping the old
daemon while idle, which the protocol-mismatch error already advises,
avoids the situation altogether.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
V3RON
pushed a commit
that referenced
this pull request
Sep 6, 2026
The third file this PR's review found unsynced. `device.exec` adds no event of its own -- it is a command, not a fact about a device -- so this commit is purely the ADR 0004 text, byte for byte from origin/claude/adr-0004-c-docs, with nothing of mine on top. Committed with --no-verify: the pre-commit formatter has `docs/**` in its ignore list and errors out when a commit stages nothing else. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #114. PR C of three: the documentation, changelog, and ADR status change for ADR 0004. No code — PR A (client renew timers) and PR B (daemon, config, protocol 4) land the implementation separately. Meant to be re-based onto PR B's branch before merge, which is why this touches nothing but
.mdfiles.The ADR is the specification, and this PR is what accepts it
docs/adr/0004-ttl-first-leases-on-every-transport.mdmoves from Proposed to Accepted — not yet implemented, and its row indocs/adr/README.mdmatches. Per that file's status vocabulary, the docs describe the decided end state and the code catches up in PRs A and B.Successive review rounds kept finding the same shape of problem: a doc settling something the ADR had not recorded, which inverts the rule that the ADR is the specification. So the decisions live in the record, and the docs cite it.
Decision 2 carries MCP's reconnect rule — its renew timer drives the reconnect, but only ever to a daemon that is already listening, because auto-launch must stay a tool-call concern so an operator's
daemon stopis not undone by an idle session — and the renew-failure rule: a transient failure is retried on the next tick, while a renew answeredUNKNOWN_LEASEends the holder the way alease-lostpush does. Decision 4 carries the renew-width rule (a lease records the width it was granted with; a renew naming nottlMsre-applies that width, so a four-hour lease does not shrink to fifteen minutes) and states thatlease.detachedTtlMsis renamed in the key set, not aliased. Consequences grant, one bullet each: the events-payload exception todocs/agent-rules/events.mdrule 6; the HTTP additive-evolution exception;lastRenewedAtreplacing the derivedlastHeartbeatAtdecoration; protocol 4 advertised{min: 4, max: 4}under ADR 0003 §6's honesty rule; and the two TTL keys validated together at load, with a violating config failing the daemon start. The SIGKILL consequence no longer says "at mostlease.defaultTtlMs", which was wrong for any lease that asked for more.The
Supersedesline names every ADR 0003 section 0004 narrows: §3'slease.heartbeatrow, §8's push and held set, §9's held-ttlMsrule, §10'sonLeaseLoston connection loss, and §10/§11's lazy-only MCP reconnect. ADR 0003 and 0005 are untouched, as isdocs/agent-rules/events.md(the rule-6 wording is a follow-up).The decisions, in one place
lease.renewbefore the deadline is the only thing that keeps it alive.ttlMs. A body-less renew re-applies that width;lease.defaultTtlMsapplies to a request that names nottlMsand nowhere else — which is what makes the holder's timer safe, since it sends no TTL at all. Cadence is one third of the lease's TTL.lease.maxTtlMsbounds what is asked for, not what already exists. Lowering it does not shorten a lease already holding a larger stored width; that lease keeps re-applying it until released or renewed with an explicit smaller--ttl.DAEMON_CONNECTION_LOSTline naming the lease id andttlDeadlineand exits 1, leaving the lease standing. Exit 14 stays narrower: the daemon ended the lease while the connection was alive.SIGKILLed holder keeps its device untilexpiresAt— at most the lease's own TTL after its last renew.detachedTtlMsalias; retired keys warn and are ignored, while a self-contradicting TTL pair is rejected at load and the daemon does not start.lease renewneeds an admin credential, exactly as cross-processreleasedoes — a lease belongs to the session it was granted to.What each doc now says
docs/CLI.md—simlock leaseprints the grant and stays alive, renewing at one third of the lease's TTL and releasing on exit, parent death, orSIGINT/SIGTERM; transient-renew andUNKNOWN_LEASEoutcomes stated. New--ttl; new connection-loss block;lease renewkeeps the lease's own width and carries the admin-credential clause, as does the--detachbullet.lease renewjoins the admin command list and the worked cross-invocation example. The grant example carries the full contract shape —ttlMsandlastRenewedAtincluded,modegone.list --leasesdocumentslastRenewedAt;statusrenders "last renewed"; the exit-code note records the exit-14 exception; thedaemonsection covers a stop (ends connections, not leases) and a config-refused start;simlock mcpdocumentslease_simulator'sttlMsand which reconnect trigger may launch a daemon. A pre-existing broken code fence around the grant example is closed.docs/CLIENT.md— renewing is the frontend's job, with the stored-ttlMsrule. "One connection, no reconnect" gains "A dead connection is not a dead lease";onLeaseLostno longer fires withdaemon-connection-lost; both MCP reconnect triggers and their different powers; the CLI bullet states the holder exits 1 while its lease stands.docs/HTTP-API.md— "detached-only over HTTP" becomes "TTL-bound, the same as everywhere else". The additive-evolution paragraph lists all three breaks —modegone from the record the operator routes serialize,lastRenewedAtand the storedttlMsnew on it,ttlMsabove the cap now400, and the reportedttlMsbeing the lease's own width — instead of wrongly claiming no field is added or removed.noticesis HTTP-sideLeaseNoticeBufferstate, andlease_lostthe one fact it cannot carry (an ended lease answers renew404 UNKNOWN_LEASE).docs/CONFIGURATION.md—lease.defaultTtlMsis request-only and explicitly not the renew fallback;lease.maxTtlMsadded, with a note that it bounds what is asked for and is not re-applied to leases that already exist; retired keys are a migration table with no aliasing; validation distinguishes reject-at-load from warn-and-ignore.docs/ARCHITECTURE.md— "Leases" rewritten around one kind of lease (storedttlMs,lastRenewedAt, renew-failure semantics, the SIGKILL cost). Topology bullets lose "connection close = release" and "the connection is the lease heartbeat"; MCP's two reconnect triggers spelled out; the contract section states the{min: 4, max: 4}range; startup restores every lease's timer with no orphan sweep; the admin example includeslease renew;noticescorrected to HTTP-side; bare "ADR §10/§11" references spelled out as ADR 0003.docs/EVENTS.md— the note cites the ADR's Consequences bullet; the four lease rows are markedimplemented (payload per ADR 0004 pending).docs/known-pitfalls.md— new "A SIGKILLed lease holder keeps its device until the TTL expires". The reparented-holder pitfall reframed (a TTL bounds silence, and a live renewing holder is not silent). The HTTP-tracker pitfall explains itself by a polling client being absent between calls, not by a connection holding something.docs/ABOUT.md,README.md— one TTL lease kind everywhere; both grant examples carryttlMs/lastRenewedAtand dropmode; MCP's paragraph says manual lease renewal and that the server renews its own session's lease; the MCP cross-link points at CLI.md's real heading anchor.Breaking changes the changelog lists
## Unreleased, in 0.3.0's style, opening with a note that ADR 0004 is accepted-but-unimplemented and these entries land with PRs A and B. Breaking:lease.heartbeatand theheartbeatcapability removed;modeout oflease.requestand the lease record; a body-less renew re-applying the lease's own TTL;lastRenewedAtas a new stored field; protocol 4; three config keys retired with no alias plus the fail-closed validation rule; the upgrade case for a lease a pre-ADR-0004 daemon persisted in held mode (deadline survives, nothing can renew it on protocol 4, it expires or is released); theSIGKILLbound;daemon stopnot releasing and no startup sweep; the two droppedlease.releasedreasons;onLeaseLostnot firing on connection loss;simlock leaseexiting 1 on a dead connection; and the MCP renew timer's reconnect that never launches a daemon.Review history
Round 1 (not isolated — this session has no
Agent/Tasktool, so I ran the checklist myself): 4 findings →250e479.Round 2 (isolated): MERGE AFTER FIXES, 15 findings →
146c910.Round 3 (isolated): MERGE AFTER FIXES, 14 findings →
0ce1dba.Round 4 (isolated): MERGE AFTER FIXES, 4 blocking + 3 should-fix + nits →
dba8292. Every blocking finding was "the ADR does not record what the docs settle", fixed by moving the decision into the ADR.Round 5 (confirmation): all twelve settled points CONSISTENT across ADR and docs; one gap and three nits →
7b3d244.From PR B's code review →
679ebcc: a config-rejected daemon boot does not surface asDAEMON_STARTUP_FAILED— that validation runs before the socket is claimed, so an auto-starting command's launch times out and it fails with exit 1 (INTERNAL), the reason being insimlock daemon logs. (DAEMON_STARTUP_FAILEDin ARCHITECTURE.md is the other case — convergence throwing after the socket claim — and is correct as-is.) TheDAEMON_CONNECTION_LOSTsample also used an em dash where the code writes ASCII--.From PR B's second code review →
c8ff5b4, two cases about a lease that already exists when something changes around it:lease.maxTtlMsis not retroactive (docs/CONFIGURATION.md)ttlMs— granted under a higher cap, or carried over from an older record — keeps re-applying that width on every body-less renew, so lowering the cap does not shorten it; release it, or renew once with an explicit smaller--ttl.CHANGELOG.md)lease.heldTtlBackstopMs. Nothing can renew it — its holder speaks protocol 3, the new daemon advertises{min: 4, max: 4}, so itshellofails. It expires on that deadline, orsimlock release <lease-id>ends it sooner; stopping the old daemon while idle, as the protocol-mismatch error already advises, avoids it entirely.Validation (re-run after each round)
pnpm run format:checkandpnpm run lint— clean.ttlDeadline == grantedAt + ttlMs,lastRenewedAt == grantedAt, nomodekey. TheDAEMON_CONNECTION_LOSTsample is parsed too, and asserted free of non-ASCII dashes.0ce1dbawere bounded..mdfiles resolves.lastRenewedAtis not a rename.remaining TTLphrasing; no doc callslease.defaultTtlMsthe renew fallback; no added prose line exceeds the wrap width.git diff --name-onlyagainst the base touches nothing undersrc/,e2e/, ordocs/agent-rules/— 12 files, all.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz