@ssheleg/agent-sync 1.19.3 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## v1.20.0 — the two-sources duty stops carrying its own elaboration
2
+
3
+ Sherlock external-v3 (14 findings) plus the House audit gap they introduced.
4
+
5
+ - The audit's doctrine grew SKILL.md past the house working limit, and the rule
6
+ for that is a SPLIT, not a trim. The `Two documentation sources` section keeps
7
+ what a reader needs before opening anything — the two questions, the gap IS the
8
+ finding, the duty at both ends, and that `reconcile` is mechanical so its green
9
+ is not agreement — and hands the rest to `references/two-sources.md`, which
10
+ already held every one of those points in full.
11
+ - `references/adapter-contract.md` passed 100 lines and gained a `## Contents`.
12
+ - CI now MEASURES the token budget with a real tokenizer instead of estimating it.
13
+
1
14
  ## v1.19.3 — the boundary ships with the skill, and two descriptions stop looking like drift
2
15
 
3
16
  Close-out of the 2026-09-06 family audit (wave AUDIT-WAVE-0906). One bullet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.19.3",
3
+ "version": "1.20.0",
4
4
  "description": "Let concurrent coding agents share one project without colliding — leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "agent-sync",
4
4
  "displayName": "Agent Sync",
5
- "version": "1.19.3",
5
+ "version": "1.20.0",
6
6
  "description": "Coordination layer for multi-agent repositories — leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
7
7
  "author": {
8
8
  "name": "ssheleg",
@@ -3,6 +3,30 @@
3
3
  # Any other non-zero code is NON-blocking in Claude Code, so every internal
4
4
  # failure must also exit 2 — a crashing guard that fails open guards nothing.
5
5
  set -uo pipefail
6
+ # --------------------------------------------------------------------------
7
+ # THE PROTECTION BOUNDARY IS ADVISORY, AND STATED SO (FIX-SY-07.01).
8
+ #
9
+ # This guard is a best-effort PreToolUse check, not a sandbox. It covers the
10
+ # mutation surfaces a Claude Code matcher can name; everything else is a write
11
+ # vector it cannot see. The capability matrix, per write vector:
12
+ #
13
+ # Write / Edit / MultiEdit / NotebookEdit COVERED (file_path matcher)
14
+ # Bash: git commit (incl. -C, env, compound) COVERED (parsed below)
15
+ # Bash: apply_patch UNSUPPORTED (no file_path parse)
16
+ # Bash: python -c "...write_text(...)" UNSUPPORTED (a runtime, not a path)
17
+ # Bash: sed -i / perl -i UNSUPPORTED (in-place edit, no arg parse)
18
+ # Bash: > redirect / tee UNSUPPORTED (shell effect, not a tool arg)
19
+ # Bash: git -C <dir> commit COVERED (repo resolved from -C)
20
+ # Bash: env VAR=x git commit COVERED (env prefix skipped)
21
+ # Bash: compound (a && git commit) COVERED (each segment parsed)
22
+ #
23
+ # An UNSUPPORTED vector is not silently trusted — it is DECLARED unenforced.
24
+ # A caller that needs a hard guarantee routes file writes through a trusted
25
+ # mutation API, an isolated worktree, or OS controls with resource locks; a
26
+ # regex shell parser is not a universal sandbox and this guard never claims to
27
+ # be one. The late staged-path check on a commit cannot recover a working tree
28
+ # already overwritten by an unsupported vector before staging.
29
+ # --------------------------------------------------------------------------
6
30
  . "${CLAUDE_PLUGIN_ROOT}/hooks/_lib.sh"
7
31
  S="$AGENT_SYNC_PY"
8
32
  agent_sync_configured || exit 0
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "agent-sync — lease enforcement and run lifecycle. Every hook exits 0 immediately when the project has no .claude/agent-sync.json, so installing the plugin globally changes nothing in projects that do not use it.",
2
+ "description": "agent-sync — ADVISORY lease guard and run lifecycle. The PreToolUse guard covers exactly the mutation surfaces a Claude Code matcher can name: Edit, Write, MultiEdit, NotebookEdit, and a Bash `git commit` (parsed for compound/`-C`/env forms). It is NOT a sandbox: a language-runtime write (Python write_text), a `sed -i`, or a shell redirect is OUTSIDE the boundary and is not enforced — see the capability matrix in guard.sh. Real enforcement needs a trusted mutation API, an isolated worktree, or OS controls with resource locks, not a regex shell parser. Every hook exits 0 immediately when the project has no .claude/agent-sync.json, so installing the plugin globally changes nothing in projects that do not use it.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
4
4
  compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
5
5
  license: MIT
6
6
  metadata:
7
- version: "1.19.3"
7
+ version: "1.20.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -45,8 +45,9 @@ recorded in `docs/MERGES.md`, the `--key` lease released; `merges` says what lan
45
45
  you were away. **Read `references/branching.md`** before merging.
46
46
 
47
47
  **3. Hooks exist only in Claude Code.** Elsewhere nothing blocks a guarded edit: run
48
- `guard` yourself and record the run as `ungated`. Do not describe a project as protected
49
- when it is not.
48
+ `guard` yourself and record the run as `ungated`. A mode's five capability fields
49
+ live in ONE place — `references/adapter-contract.md` → *The status capability
50
+ contract*, never restated here. Do not describe a project as protected when it is not.
50
51
 
51
52
  **4. Parse liberally, and never call an unreadable log a lost race.** The store rewrites
52
53
  what you wrote — Outline turns a `- ` bullet into `* `. Emit `- `, accept `-`/`*`/`+`,
@@ -93,21 +94,19 @@ registers — decisions belong to the parent. Then take the chain above from `in
93
94
 
94
95
  ## First command in a project: `init`
95
96
 
96
- **Never run anything else against an uninitialised project.** `init` is where the storage
97
- question gets asked and answered, once, and written down.
97
+ **Never run anything else against an uninitialised project.** `init` asks and answers
98
+ the storage question once, and writes it down.
98
99
 
99
100
  **Ask the operator these two things in chat — do not guess, do not pick a default:**
100
101
 
101
102
  1. **Where should coordination state live?**
102
103
  - a knowledge cloud — `outline`, hosted or self-hosted, or `notion` — the shared
103
- record, awareness and board across machines. **Neither decides leases**; nothing
104
- in either can (trap 1);
104
+ record, awareness and board across machines — **neither decides leases** (trap 1);
105
105
  - or local files (`fs`) — no credentials, and no visibility to an agent on another
106
- machine: no shared awareness, no cross-repo signals, no shared board.
106
+ machine — no shared awareness, cross-repo signal or board.
107
107
 
108
108
  The lease is decided separately by `leaseBackend` (trap 2), and **`gated` follows that
109
- choice, never the record plane**. Report the guarantee you actually have, not the
110
- stronger one the record plane suggests.
109
+ choice, never the record plane** — report the guarantee you actually have.
111
110
  2. **If cloud: where.** Outline needs its instance URL; Notion needs the id of the page
112
111
  the container goes under. Both are configuration, not secrets, so you may write them.
113
112
  The **token is not** — you never ask for it in chat, never read it back, and never
@@ -145,9 +144,10 @@ cannot give two answers about one project.
145
144
  away — watermarked per run, so it stays quiet until something changes. A dependency that
146
145
  moved may unblock what you planned, or invalidate it.
147
146
 
148
- What else `status` decides: no credentials → degraded mode, reported, and it continues;
149
- `task-pipeline` absent → it prints the install line and stops. Do not improvise a substitute
150
- flow — without those stages there is nothing to bind to.
147
+ `status` also decides: no credentials → degraded mode; `task-pipeline` absent → it
148
+ prints the install line and stops. Absence is checked across every host layout or an
149
+ explicit `pipelinePath` — no host's copy masks or is missed. The lease core
150
+ (`acquire`/`renew`/`release`) needs a backend + lease, not the binding.
151
151
 
152
152
  ```bash
153
153
  npx sshlg-skills install
@@ -163,7 +163,8 @@ npx sshlg-skills install
163
163
  | `acquire <KEY>` | Take the lease on a task id. Prints `won` or `lost <holder>` |
164
164
  | `renew <KEY>` | Extend the lease. The `PostToolUse` hook does this for you |
165
165
  | `release <KEY>` | Give the lease back. Always do this, including on failure |
166
- | `reserve <REG>` | Reserve the next id in a register (`DEC`, `OQ`, `DEP`, …). Prints the id |
166
+ | `reserve <REG> [--key K] [--offline]` | Reserve the next id in a register (`DEC`, `OQ`, `DEP`, …); prints it. `--key` makes a retry idempotent (one key, one number); `--offline` issues a namespaced `REG-o-…` id with no global authority |
167
+ | `map-offline <REG> <ID> <N>` | Bind an offline id to a properly reserved number — append-only, never rebound |
167
168
  | `release-id <REG> <ID>` | Return an id you did not end up writing to git |
168
169
  | `journal <text>` | Append one line to this run's journal |
169
170
  | `record <text>` | Append what you **actually built** — `--decision DEC-…`, `--files a,b` |
@@ -232,10 +233,19 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" guard docs/DECISIONS.md
232
233
  ```
233
234
 
234
235
  **Exit 2 is about *this run*: it holds no lease** — not that somebody else holds that file.
235
- One lease covers every guarded file; hold one or write none. A denial names the other run
236
- **and its key**, because "r-x holds a lease" beside a path gets repeated as "r-x holds this
237
- file". Do not edit anyway, and do not "just fix one line" — a clobbered decision looks exactly
238
- like a decision.
236
+ A denial names the other run **and its key**, because "r-x holds a lease" beside a path gets
237
+ repeated as "r-x holds this file". Do not edit anyway, and do not "just fix one line" — a
238
+ clobbered decision looks exactly like a decision.
239
+
240
+ **Two write modes, and the tool does not promise the wrong one.** A task lease authorizes
241
+ the TASK, never the file — so a guarded write also takes the FILE's own **resource claim**
242
+ (`res--<repo>--<canonical path>`, SY-04). That is the **short transaction lock**: two agents
243
+ on one shared registry serialize on it, independent files never serialize, honest
244
+ cross-machine only under `leaseBackend: "git"` (advisory otherwise). The other mode is
245
+ **isolated worktree + merge** — each agent a private checkout, a merge policy reconciling
246
+ them — for when writes overlap so heavily a lock would just queue everyone. What the guard
247
+ does NOT promise is enforcement from a single task owner: holding *a* lease was never
248
+ holding *this* file.
239
249
 
240
250
  Claude Code's `PreToolUse` hook runs this for you. Elsewhere nothing does.
241
251
 
@@ -262,32 +272,24 @@ the generated-object contract: `references/two-sources.md`.
262
272
  ## Two documentation sources, and the duty to reconcile them
263
273
 
264
274
  Git docs answer **how it should be**; the as-built record answers **how it actually is**.
265
- Neither outranks the other, and **the gap between them is the finding**, not a defect.
275
+ Neither outranks the other, and **the gap between them is the finding**, not a defect. The
276
+ duty runs at both ends of a task: `reconcile` before starting, `record` and `reconcile`
277
+ after finishing — and `reconcile` is mechanical, so treating its green as agreement is how
278
+ a divergence survives both ends.
266
279
 
267
- The duty runs at both ends of a task: `reconcile` and resolve every divergence before
268
- starting, then `record` and `reconcile` again after finishing. Building on an unresolved
269
- divergence is writing code against a system that does not exist.
270
-
271
- **The trap: `reconcile` is mechanical and refuses to judge** whether the built thing matches
272
- the document — it compares ids, commits, presence and claim tags. That reading is yours, and
273
- treating its green as agreement is how a divergence survives both ends.
274
-
275
- Every project also carries a **generated snapshot** of its own wiring (`setup`) — commit it
276
- and link it from the agent instructions, so agents read the pipeline instead of inferring it.
277
-
278
- **Read `references/two-sources.md`** before the first reconcile, and whenever deciding which
279
- side a document belongs on.
280
+ **Read `references/two-sources.md`** before the first reconcile: it holds the full duty,
281
+ what `reconcile` refuses to judge, the generated `setup` snapshot, where a document
282
+ belongs, and why nothing is deleted.
280
283
 
281
284
  ## Binding to task-pipeline
282
285
 
283
- This skill supplies stages; the names are `task-pipeline`'s own. Five of the eleven stages
284
- carry an ordering rule: **0** `acquire` before the brief is committed; **1** `reconcile` before
285
- writing code; **3** `reserve` every id before it reaches git; **9** the main write point;
286
- **10** `merge` or `release` every lease.
286
+ This skill supplies stages; the names are `task-pipeline`'s own.
287
+ Five of the eleven stages carry an ordering rule: **0** `acquire` before the brief
288
+ commits; **1** `reconcile` before code; **3** `reserve` ids before git; **9** the main
289
+ write; **10** `merge`/`release` every lease.
287
290
 
288
- **Read `references/pipeline-binding.md`** when wiring `pipeline.json` — it holds the
289
- per-stage reasoning, the `skills[]` entries, what must be guarded, and the gate
290
- expressions.
291
+ **Read `references/pipeline-binding.md`** when wiring `pipeline.json` — per-stage
292
+ reasoning, `skills[]` entries, what to guard, and the gate expressions.
291
293
 
292
294
  ## Configuration
293
295
 
@@ -14,6 +14,16 @@ candidate backend can be trusted with leases.
14
14
  A backend is an adapter that implements six primitives and declares three
15
15
  capabilities. Nothing else about it is the coordinator's business.
16
16
 
17
+ ## Contents
18
+
19
+ - [Primitives](#primitives)
20
+ - [Capabilities](#capabilities)
21
+ - [The status capability contract — one source, five fields](#the-status-capability-contract--one-source-five-fields)
22
+ - [Degradation — non-negotiable](#degradation--non-negotiable)
23
+ - [Errors and retries](#errors-and-retries)
24
+ - [Credentials](#credentials)
25
+ - [Adding a backend — checklist](#adding-a-backend--checklist)
26
+
17
27
  ## Primitives
18
28
 
19
29
  | Primitive | Signature | Semantics |
@@ -44,6 +54,28 @@ capabilities. Nothing else about it is the coordinator's business.
44
54
  it. Declaring it true without one is the most damaging lie an adapter can tell, so
45
55
  the default is false and the burden of proof is on the adapter.
46
56
 
57
+ ## The status capability contract — one source, five fields
58
+
59
+ The RECORD-plane capabilities above (`atomicAppend`/`totalOrderRead`/…) are not
60
+ the same axis as the STATUS the coordinator reports about a run. The status is
61
+ `Sync.capabilities()` in `scripts/agent_sync.py` (FIX-SY-06.01), and it is the
62
+ ONE source every doc describes a mode from — SKILL.md and `backend-fs.md` point
63
+ here rather than restating it, so a single mode can never be described three
64
+ different ways. The five fields:
65
+
66
+ | field | values | meaning |
67
+ |---|---|---|
68
+ | `lease_scope` | cross-machine \| machine-local | where exclusion holds |
69
+ | `enforcement_mode` | enforced \| advisory | enforced only for a cross-machine CAS the operator asked for on a reachable backend |
70
+ | `awareness_scope` | shared \| isolated | whether other agents can see this project's state |
71
+ | `identity_strength` | strong \| weak | how strongly a run identity is bound |
72
+ | `backend_health` | up \| failed | an unreachable backend is `failed`, never reported active |
73
+
74
+ **Legacy `gated` is a DERIVED summary** of `enforcement_mode`, kept for old
75
+ callers. **An unavailable observation is `unknown`/`failed`, never asserted**:
76
+ a backend the coordinator cannot reach collapses `enforcement_mode` to advisory
77
+ and `backend_health` to failed rather than showing green.
78
+
47
79
  ## Degradation — non-negotiable
48
80
 
49
81
  **No adapter is the lease authority.** Exclusion is an atomic local lock; the adapter
@@ -34,6 +34,11 @@ from `leaseBackend` (`local` = `O_EXCL`, `git` = a pushed ref). What this backen
34
34
  1. says so at session start, in one plain sentence;
35
35
  2. keeps the lease exactly as configured — `leaseBackend` is independent of this choice;
36
36
  3. marks every run `ungated` on the board, because nobody else can read the state.
37
+ In the five-field contract (`adapter-contract.md` → *The status capability
38
+ contract*, the one source this file describes the mode from) that is
39
+ `awareness_scope: isolated`, `enforcement_mode: advisory` — and if the local
40
+ state cannot even be read, `backend_health: failed`, reported as `unknown`,
41
+ never as active green.
37
42
 
38
43
  ## The lease is not this backend's job
39
44
 
@@ -42,7 +42,8 @@ so a parser anchored to the character you wrote rejects every line the server ha
42
42
  back. Observed live, and it presented as a lost race rather than a parse failure.
43
43
 
44
44
  Required on every line: `op`, `key`, `run`. `op` is one of
45
- `acquire` · `release` · `renew` · `base` · `reserve` · `release_id` · `signal` · `journal`.
45
+ `acquire` · `release` · `renew` · `base` · `reserve` · `release_id` ·
46
+ `reserve_offline` · `map_offline` · `signal` · `journal`.
46
47
 
47
48
  Unparseable lines are **counted and reported**, never guessed at. Anything
48
49
  entry-shaped (`^[-*+] \``) that fails the full pattern counts as unparseable; blank
@@ -74,6 +75,15 @@ documents, and implemented nowhere except a warning line on the board that retur
74
75
  5. won write {run, ts, ttl, repo}; publish op=acquire to the plane for visibility
75
76
  ```
76
77
 
78
+ **Every local writer shares ONE critical section — and step 3 is it.** The
79
+ `.steal` guard is not the stealer's private door any more: `renew` and the
80
+ local release enter the same section, so a renewal arriving mid-steal DEFERS
81
+ to the next heartbeat instead of rewriting a timestamp the stealer's expiry
82
+ re-read already consumed. Ownership changes bump a `gen` counter a renewal
83
+ preserves — a reader holding a stale generation is holding a stale ownership.
84
+ And a host where the O_EXCL primitive itself fails gets an explicit
85
+ `unsupported` refusal naming the remedy, never an unlocked fallback (SY-03).
86
+
77
87
  **Step 3 is one critical section, not two calls.** `unlink` followed by `O_EXCL create`
78
88
  leaves a gap, and a second stealer that has already read the lock as expired removes the
79
89
  lock the first one just created — both then hold what each believes is exclusive. Twelve
@@ -127,6 +137,32 @@ the second read is the state.
127
137
  identically to an operator and mean opposite things, so they are printed differently and
128
138
  `reap` exits non-zero on the first.
129
139
 
140
+ ## The lock is published FULL, never created empty (SY-05)
141
+
142
+ A lock created empty by `O_EXCL` and filled by a later write has a window a
143
+ competitor reads as `{}` — not live, therefore stealable — and steals while
144
+ the creator writes on into a now-unlinked inode: two winners. So a lock is
145
+ PUBLISHED already carrying its body — written to a temp inode, fsync'd, then
146
+ `os.link`ed onto the final name (an atomic no-replace create of a FULL inode)
147
+ — and no reader ever observes an empty lock. Any empty or partial lock that
148
+ does appear is a creation IN FLIGHT, not an expired lease: it is left alone
149
+ within a short creation grace and reclaimed only once its own file age proves
150
+ it abandoned — arbitrated by age, never by the heuristic that empty JSON means
151
+ free.
152
+
153
+ ## Resource identity — the file's own claim (SY-04)
154
+
155
+ A task lease is ownership of the TASK, never of a file: two runs holding two
156
+ different task ids used to both pass the guard and interleave writes to one
157
+ shared registry. A guarded write now also takes the file's own claim — key
158
+ `res--<repo>--<canonical path>` (realpath on both sides: a `/var` vs
159
+ `/private/var` symlink split makes one file two names, and a guard that sees
160
+ two names guards neither). The claim is auto-taken under the task lease, so a
161
+ single agent feels nothing; two agents on one file serialize on the FILE;
162
+ independent files carry independent keys and never serialize without cause.
163
+ Releasing the run's last task key releases its resource claims with it — a
164
+ file claim only ever rides under a task lease.
165
+
130
166
  ## Expiry and stealing
131
167
 
132
168
  A lock is expired when `now > ts + ttl` for the timestamp inside it.
@@ -136,6 +172,25 @@ lock file in `local` mode, and re-pushes the ref with `--force-with-lease` again
136
172
  exact object it read in `git` mode. The `op=renew` line it also appends to the record plane
137
173
  is visibility, not renewal.
138
174
 
175
+ **And a renewal is FENCED, not unconditional (SY-03.02).** Three refusals stand
176
+ between a heartbeat and the lock: the run id must match; the lease must still be
177
+ LIVE — an expired lease is not renewed, it is acquired again, because a stealer
178
+ may already have read it as up for grabs; and the lock's `gen` must equal the
179
+ generation this session acquired under. The third is what the run id cannot do:
180
+ a replacement session shares the run id and the checkout, so after its steal
181
+ (gen bump) the old session's heartbeat matches on run and would resurrect the
182
+ lease forever — only the in-memory generation, the one thing a zombie does not
183
+ share with its replacement, tells the two apart. Re-taking one's own expired
184
+ lease is therefore a steal with a generation bump, never a refresh.
185
+
186
+ **The heartbeat's throttle is per (run, key), never shared.** It was one file per
187
+ checkout, and one agent touching it every hundred seconds meant every OTHER run's
188
+ heartbeat read "renewed recently" and refreshed nothing — a 45-minute lease expiring
189
+ under live work because a neighbour was busy (SY-02). Each key of each run now ages
190
+ against its own marker; an `acquire` stamps only the key it just took; and an
191
+ **explicit `renew <key>` never hides behind the throttle** — it refreshes for real or
192
+ answers with the precise per-key reason it could not.
193
+
139
194
  That distinction is the whole of the bug fixed in 1.5.3: `renew` wrote *only* the record
140
195
  line. The lock's `ts` was written once, by `acquire`, so a run holding a lease lost it at
141
196
  TTL while still working — its own guard began denying it, and another run acquired the task
@@ -248,29 +303,64 @@ contracts on purpose. Do not "align" them by widening `reap`.
248
303
 
249
304
  ## Id reservation
250
305
 
251
- Reading a "next free id" line from a file is not reserving it. Allocation is
252
- **positional over the log**, so no agent has to trust another's arithmetic.
253
-
254
- A register is opened once:
306
+ Reading a "next free id" line from a file is not reserving it. An issued id is
307
+ **immutable**: once `reserve` has printed a number, no replay, late-arriving shard or
308
+ appended base may move it. The line that records it carries the value —
255
309
 
256
310
  ```
257
- - `…` `op=base` `key=DEC` `value=0216` `run=r-bootstrap`
311
+ - `…` `op=reserve` `key=DEC` `value=0042` `run=r-7f3a91`
258
312
  ```
259
313
 
260
- Then, replaying in order and maintaining a free list:
261
-
314
+ — a **receipt** of an allocation that already happened, never a claim to be computed
315
+ later. A receipt whose value is already live lost its race and gets no assignment; the
316
+ run that wrote it saw the loss on read-back and appended another line. Where the value
317
+ comes from depends on the mode:
318
+
319
+ - **`leaseBackend: "git"`** — the allocator is a compare-and-swap on a remote ref,
320
+ `refs/agent-sync/ids/<REG>`, whose tip commit records the next free number. Winning
321
+ the push IS the allocation: the remote accepts exactly one successor per tip, so two
322
+ concurrent reserves cannot take one number — the loser re-reads the moved tip and
323
+ takes the next, bounded at `RESERVE_RETRIES` attempts before reporting contention.
324
+ This is the same push semantics the lease itself rides on, and it is why positional
325
+ replay could never be safe across machines: a shard another machine has not pushed
326
+ yet is invisible, and two machines replaying different logs were both "correct" about
327
+ histories nobody shared. A released id is recorded for the leak report but never
328
+ reissued automatically here — the counter only moves forward.
329
+ - **Total-order backends** (Outline, Notion) — the value is probed positionally over
330
+ the merged log, then claimed by appending the receipt and confirmed on read-back;
331
+ a lost race retries with the next number, bounded the same way.
332
+
333
+ A receipt also **names its authority**: `backend=` (git or log), `rev=` (the
334
+ counter commit that served it, in git mode) and `rkey=` (the reservation key).
335
+ A retry with the same `--key` is the SAME reservation — answered from the
336
+ merged log, or, when the run died between winning the compare-and-swap and
337
+ writing its receipt, from the counter ref's own chain, which remembers which
338
+ key each number was served to. One key, one number, however many retries.
339
+
340
+ **Offline, there is no global sequence to pretend at.** `reserve --offline`
341
+ issues a namespaced composite — `REG-o-<run>-<seq>` — that cannot collide with
342
+ the numeric sequence, and `map-offline` later binds it to a properly reserved
343
+ number, append-only: the same fact twice is one fact, a different number is
344
+ refused, and an id never issued cannot be mapped at all.
345
+
346
+ Legacy bare `op=reserve` lines (no `value=`) still resolve positionally, replaying in
347
+ order and maintaining a free list:
348
+
349
+ - `op=base key=DEC value=0216` opens a register; a `base` only ever moves allocation
350
+ **forward** — two runs opening a register in the same minute cannot restart each
351
+ other's count.
262
352
  - `op=release_id key=DEC value=NNNN` pushes `NNNN` onto the free list.
263
- - `op=reserve key=DEC` takes the free-list head if it is non-empty; otherwise it
264
- takes `base + (count of prior reserves not served from the free list)`.
353
+ - bare `op=reserve key=DEC` takes the free-list head if it is non-empty; otherwise
354
+ `base + (count of prior reserves not served from the free list)`.
265
355
 
266
356
  Every reader computes the same assignment for every reserve line, including its own —
267
357
  **and "the log" means every shard merged, never the one this run writes.** Reading only
268
358
  its own document is how `reserve` handed three runs `DEC-0007` three times (fixed in
269
359
  1.5.3): each replayed a log containing only its own lines, each seeded its own `base`
270
- from the register, and each was correct about a history nobody else shared. The failure
271
- is the same one that disqualified per-writer documents as a *lease* store, arriving in
272
- the allocator — so a `base` now only ever moves allocation **forward**, and two runs
273
- opening a register in the same minute cannot restart each other's count.
360
+ from the register, and each was correct about a history nobody else shared. The
361
+ value-carrying receipt closes the remaining half of that defect: the merged order
362
+ itself could still renumber an already-issued id when a shard arrived late, and now it
363
+ cannot (`test/audit_regressions/fix-sy-01.01.py`, finding SY-01).
274
364
 
275
365
  **An id you reserved and did not write to git must be released** with
276
366
  `release_id`. An id that is reserved, unreleased and absent from git after its run