Skip to content

docs: describe TTL-first leases end state, accept ADR 0004 (PR C) - #122

Open
V3RON wants to merge 8 commits into
claude/simlock-host-worker-prd-ohc30efrom
claude/adr-0004-c-docs
Open

docs: describe TTL-first leases end state, accept ADR 0004 (PR C)#122
V3RON wants to merge 8 commits into
claude/simlock-host-worker-prd-ohc30efrom
claude/adr-0004-c-docs

Conversation

@V3RON

@V3RON V3RON commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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 .md files.

Release note: @release-it/conventional-changelog prepends a generated section at release time, so the hand-written ## Unreleased block has to be folded into the next generated entry rather than left beside it.

The ADR is the specification, and this PR is what accepts it

docs/adr/0004-ttl-first-leases-on-every-transport.md moves from Proposed to Accepted — not yet implemented, and its row in docs/adr/README.md matches. 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 stop is not undone by an idle session — and the renew-failure rule: a transient failure is retried on the next tick, while a renew answered UNKNOWN_LEASE ends the holder the way a lease-lost push does. Decision 4 carries the renew-width rule (a lease records the width it was granted with; a renew naming no ttlMs re-applies that width, so a four-hour lease does not shrink to fifteen minutes) and states that lease.detachedTtlMs is renamed in the key set, not aliased. Consequences grant, one bullet each: the events-payload exception to docs/agent-rules/events.md rule 6; the HTTP additive-evolution exception; lastRenewedAt replacing the derived lastHeartbeatAt decoration; 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 most lease.defaultTtlMs", which was wrong for any lease that asked for more.

The Supersedes line names every ADR 0003 section 0004 narrows: §3's lease.heartbeat row, §8's push and held set, §9's held-ttlMs rule, §10's onLeaseLost on connection loss, and §10/§11's lazy-only MCP reconnect. ADR 0003 and 0005 are untouched, as is docs/agent-rules/events.md (the rule-6 wording is a follow-up).

The decisions, in one place

  • One kind of lease. TTL-bound on every transport; lease.renew before the deadline is the only thing that keeps it alive.
  • The lease stores its ttlMs. A body-less renew re-applies that width; lease.defaultTtlMs applies to a request that names no ttlMs and 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.maxTtlMs bounds 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.
  • Connection close means nothing to a lease — but the CLI holder does not reconnect (ADR 0003 §10): it writes one DAEMON_CONNECTION_LOST line naming the lease id and ttlDeadline and exits 1, leaving the lease standing. Exit 14 stays narrower: the daemon ended the lease while the connection was alive.
  • A SIGKILLed holder keeps its device until expiresAt — at most the lease's own TTL after its last renew.
  • Config: no detachedTtlMs alias; retired keys warn and are ignored, while a self-contradicting TTL pair is rejected at load and the daemon does not start.
  • Cross-process lease renew needs an admin credential, exactly as cross-process release does — a lease belongs to the session it was granted to.

What each doc now says

  • docs/CLI.mdsimlock lease prints the grant and stays alive, renewing at one third of the lease's TTL and releasing on exit, parent death, or SIGINT/SIGTERM; transient-renew and UNKNOWN_LEASE outcomes stated. New --ttl; new connection-loss block; lease renew keeps the lease's own width and carries the admin-credential clause, as does the --detach bullet. lease renew joins the admin command list and the worked cross-invocation example. The grant example carries the full contract shape — ttlMs and lastRenewedAt included, mode gone. list --leases documents lastRenewedAt; status renders "last renewed"; the exit-code note records the exit-14 exception; the daemon section covers a stop (ends connections, not leases) and a config-refused start; simlock mcp documents lease_simulator's ttlMs and 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-ttlMs rule. "One connection, no reconnect" gains "A dead connection is not a dead lease"; onLeaseLost no longer fires with daemon-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 — mode gone from the record the operator routes serialize, lastRenewedAt and the stored ttlMs new on it, ttlMs above the cap now 400, and the reported ttlMs being the lease's own width — instead of wrongly claiming no field is added or removed. notices is HTTP-side LeaseNoticeBuffer state, and lease_lost the one fact it cannot carry (an ended lease answers renew 404 UNKNOWN_LEASE).
  • docs/CONFIGURATION.mdlease.defaultTtlMs is request-only and explicitly not the renew fallback; lease.maxTtlMs added, 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 (stored ttlMs, 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 includes lease renew; notices corrected 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 marked implemented (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 carry ttlMs/lastRenewedAt and drop mode; 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.heartbeat and the heartbeat capability removed; mode out of lease.request and the lease record; a body-less renew re-applying the lease's own TTL; lastRenewedAt as 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); the SIGKILL bound; daemon stop not releasing and no startup sweep; the two dropped lease.released reasons; onLeaseLost not firing on connection loss; simlock lease exiting 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/Task tool, 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 review679ebcc: a config-rejected daemon boot does not surface as DAEMON_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 in simlock daemon logs. (DAEMON_STARTUP_FAILED in ARCHITECTURE.md is the other case — convergence throwing after the socket claim — and is correct as-is.) The DAEMON_CONNECTION_LOST sample also used an em dash where the code writes ASCII --.
From PR B's second code reviewc8ff5b4, two cases about a lease that already exists when something changes around it:

Addition What it says
lease.maxTtlMs is not retroactive (docs/CONFIGURATION.md) The cap bounds what a request or renew may ask for, and is not re-applied to existing leases. 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, so lowering the cap does not shorten it; release it, or renew once with an explicit smaller --ttl.
Upgrading over a live held lease (CHANGELOG.md) A lease a pre-ADR-0004 daemon persisted in held mode survives the upgrade with its deadline intact, up to the old one-hour lease.heldTtlBackstopMs. Nothing can renew it — its holder speaks protocol 3, the new daemon advertises {min: 4, max: 4}, so its hello fails. It expires on that deadline, or simlock 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:check and pnpm run lint — clean.
  • All three grant samples parsed as JSON and asserted consistent: ttlDeadline == grantedAt + ttlMs, lastRenewedAt == grantedAt, no mode key. The DAEMON_CONNECTION_LOST sample is parsed too, and asserted free of non-ASCII dashes.
  • Word-level diff of every changed file against the previous commit on each round: each change region is exactly one intended edit, which is how the whole-list re-flows in 0ce1dba were bounded.
  • Every relative markdown link and heading anchor across all 25 tracked .md files resolves.
  • Retired-identifier grep — every hit deliberate: ADR 0003/0004 quoting the history they narrow, CONFIGURATION's migration table, the changelog naming removed keys and the old backstop in the upgrade note, and the places explaining why lastRenewedAt is not a rename.
  • No remaining TTL phrasing; no doc calls lease.defaultTtlMs the renew fallback; no added prose line exceeds the wrap width.
  • git diff --name-only against the base touches nothing under src/, e2e/, or docs/agent-rules/ — 12 files, all .md.

Pre-existing hook bug, tracked as #126: .oxfmtrc.json sets ignorePatterns: ["docs/**"], but lefthook's pre-commit format job runs oxfmt --check {staged_files}. A commit staging only files under docs/ hands oxfmt zero targets and it exits 2 ("Expected at least one target file"), failing the hook on a perfectly-formatted change. 679ebcc is docs-only and hit this; it was committed with --no-verify after confirming repo-wide format:check and lint both pass. skip_empty: true on that job, or narrowing its glob to exclude docs/, would fix it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz

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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants