wowbagger 0.1.0-alpha.12 → 0.1.0-alpha.13

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
@@ -7,6 +7,59 @@ consolidation. The first tagged release inherits this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.1.0-alpha.13 - 2026-08-28
11
+
12
+ ### Fixed
13
+
14
+ - **Every reconciling command reads the same writer evidence.** `claim-verify`
15
+ and ordinary mutations named the worktree they spoke for when they
16
+ classified reconciliation; `publish-claimed`, `claim-adopt`, and the
17
+ `claim acquire`, `claim renew`, and `claim release` lifecycle commands did
18
+ not. An unreachable successor written by the current worktree therefore read
19
+ as advisory sibling synchronization on those surfaces, whose target scoping
20
+ let `publish-claimed` commit a publication and `claim acquire` grant a claim
21
+ in exactly the state a `patch` refused. `publish-claimed`, `claim acquire`,
22
+ and `claim renew` now report `unauthorized-revision` and refuse, matching
23
+ `claim-verify`. `claim release` stays available: it relinquishes authority
24
+ rather than extending it, and refusing it would strand the lease in the
25
+ worktree least able to clear the barrier, because no other worktree can take
26
+ the item over while the claim is held. The release compare-and-swap is
27
+ unchanged, so a wrong owner, epoch, or expiry still refuses with
28
+ `claim-conflict`. A genuine sibling successor and an authorization written
29
+ before writer identity existed remain advisory synchronization on every
30
+ surface. `claim-adopt` semantics are unchanged: it reports no reconciliation
31
+ diagnosis and stays available as the remedy. It now judges its own identity
32
+ bytes before the worktree roster, so its own malformed identity reports as
33
+ such instead of as a failed sibling enumeration.
34
+
35
+ ### Known limitations
36
+
37
+ - **Creates in more than one worktree can commit duplicate item numbers, even
38
+ when the creates are sequential, and no sanctioned repair exists** (items
39
+ #181 and #182). Create is journal-silent, so nothing coordinates the number
40
+ it derives. Any two worktrees whose checkouts have not been integrated derive
41
+ the next schema-v2 `number` from the base each can see, and both derive the
42
+ same one. The two creates need not overlap in time: a create in one worktree
43
+ today and a create in another worktree tomorrow collide just as surely, so
44
+ long as neither worktree has seen the other's commit. Each create succeeds,
45
+ each refuses nothing, and reconciliation reports nothing. The collision only
46
+ exists once the branches are integrated. `validate` then fails globally with
47
+ `duplicate-number` on every colliding item, and because an invalid ledger
48
+ blocks every mutation, the whole ledger stops accepting work. `number` is
49
+ immutable and `patch` correctly rejects it, so Wowbagger currently offers no
50
+ operation that repairs the collision.
51
+
52
+ Until both items ship, serialize `create` through a single worktree, and run
53
+ `validate` immediately after you integrate branches so a collision surfaces
54
+ at the merge rather than at the next mutation.
55
+
56
+ The PropertyCompass repair — editing `number` in the item source by hand,
57
+ committing it, then running `claim-adopt` — was an emergency intervention
58
+ taken under an outage. It is **not a supported workaround**. It bypasses the
59
+ number-collision and reference checks every mutation performs, and it can
60
+ leave dangling `depends_on`, `related`, and parent references that nothing
61
+ reports. Do not adopt it as routine practice.
62
+
10
63
  ## 0.1.0-alpha.12 - 2026-08-28
11
64
 
12
65
  ### Fixed
package/README.md CHANGED
@@ -30,8 +30,8 @@ agent to use those guarantees instead of hand-editing your Markdown.
30
30
 
31
31
  **Start here:** [install the core and set up a ledger](#start-here).
32
32
 
33
- > **Status: alpha, published, and self-hosted.** `0.1.0-alpha.12` is on npm under
34
- > the `next` tag and on this repository's `v0.1.0-alpha.12` tag. It is the
33
+ > **Status: alpha, published, and self-hosted.** `0.1.0-alpha.13` is on npm under
34
+ > the `next` tag and on this repository's `v0.1.0-alpha.13` tag. It is the
35
35
  > version this repository runs its own backlog on. The API is not frozen and the
36
36
  > version will move before a stable release.
37
37
  >
@@ -77,7 +77,7 @@ Wowbagger is the core authority for a Git-native work ledger. Use it instead
77
77
  of editing ledger Markdown by hand.
78
78
 
79
79
  ```sh
80
- wowbagger --version # require 0.1.0-alpha.12
80
+ wowbagger --version # require 0.1.0-alpha.13
81
81
  wowbagger capabilities --json # require contract_version: 5
82
82
  wowbagger validate --ledger ledger --json
83
83
  wowbagger ready --ledger ledger --as-of YYYY-MM-DD --json
@@ -103,10 +103,10 @@ cooperating writers; they are not exclusive locks.
103
103
  Install the core CLI, then verify it. The core requires Node.js 20 or later:
104
104
 
105
105
  ```sh
106
- npm install -g wowbagger@0.1.0-alpha.12 # exact plugin-matched release
106
+ npm install -g wowbagger@0.1.0-alpha.13 # exact plugin-matched release
107
107
  # or, from this release's Git tag:
108
- # npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.12
109
- wowbagger --version # 0.1.0-alpha.12
108
+ # npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.13
109
+ wowbagger --version # 0.1.0-alpha.13
110
110
  wowbagger capabilities --json # must report contract_version: 5
111
111
  ```
112
112
 
@@ -325,7 +325,7 @@ two supported install routes:
325
325
  registry requires a `latest` tag), so a bare install resolves to the same
326
326
  bytes.
327
327
  - **git tag** —
328
- `npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.12` installs this
328
+ `npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.13` installs this
329
329
  release. Installing at a ref installs the core and every adapter that ref
330
330
  carries.
331
331
 
@@ -397,7 +397,7 @@ Upgrade the pieces you installed:
397
397
 
398
398
  ```sh
399
399
  npm install -g wowbagger@next # public npm registry
400
- npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.12 # immutable Git release
400
+ npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.13 # immutable Git release
401
401
  git pull && npm ci # or: a direct checkout
402
402
  ```
403
403
 
@@ -360,6 +360,31 @@ next mutation refuses. Inside the window, only an actor that bypasses this tool
360
360
  can overwrite the item, and this protocol does not defend against that actor.
361
361
  It is merge-coordinated, not exclusive.
362
362
 
363
+ **Warning: the same window lets more than one worktree commit one item number,
364
+ and nothing repairs it** (items #181 and #182). Because create is
365
+ journal-silent, nothing coordinates the number a create derives. Any two
366
+ worktrees whose checkouts have not been integrated derive the next schema-v2
367
+ `number` from the base each can see, and both derive the same one. The two
368
+ creates need not overlap in time: a create in one worktree today and a create
369
+ in another worktree tomorrow collide just as surely, so long as neither
370
+ worktree has seen the other's commit. Both creates succeed, both refuse
371
+ nothing, and reconciliation reports nothing, because there is no recorded
372
+ revision to compare. The collision becomes visible only when the branches are
373
+ integrated: `validate` then fails globally with `duplicate-number` on every
374
+ colliding item, and an invalid ledger blocks every mutation, so the whole
375
+ ledger stops accepting work. `number` is immutable and `patch` correctly
376
+ rejects it, so this protocol currently offers no operation that repairs the
377
+ collision.
378
+
379
+ Until both items ship, serialize `create` through a single worktree, and run
380
+ `validate` immediately after you integrate branches so a collision surfaces at
381
+ the merge rather than at the next mutation. Editing `number` in the item
382
+ source by hand, committing it, and then running `claim-adopt` is **not a
383
+ supported workaround**: one field deployment did exactly that as an emergency
384
+ intervention during an outage, and it bypasses the number-collision and
385
+ reference checks every mutation performs, so it can leave dangling
386
+ `depends_on`, `related`, and parent references that nothing reports.
387
+
363
388
  **`unauthorized-revision` has two remedies, and only one of them is
364
389
  destructive.** Restoring the authorized revision discards the out-of-protocol
365
390
  edit. Adopting the committed revision keeps it and moves the coordinator's
@@ -384,6 +409,57 @@ worktree; it remains `unauthorized-revision` and blocks unrelated mutations.
384
409
  This distinguishes a real sibling's uncommitted successor from history the
385
410
  current checkout already owns.
386
411
 
412
+ **Writer identity.** A legacy mutation and a claimed publication record the
413
+ worktree that authorized them in their journal entries as
414
+ `writer_worktree_id`: `legacy-mutation-intent`, `legacy-mutation`,
415
+ `publish-intent`, and `publish-final`. The field is optional on all of them: an
416
+ entry written before the field existed stays valid and attributes nothing. A
417
+ publication resolves its identity once under the claim lock, so its intent and
418
+ its terminal name one writer, and a terminal that recovery reconstructs after a
419
+ lost response carries the identity its intent recorded. When the journal names
420
+ the current worktree as the writer of the expected revision and no reachable
421
+ ref carries that revision, no sibling can ever produce it, so the finding is
422
+ `unauthorized-revision` and blocks every mutation instead of
423
+ `worktree-synchronization-required`, which blocks only its own item.
424
+
425
+ Recording the field on publication entries is an additive optional journal
426
+ change, so core `contract_version` stays `5` and every public request, success
427
+ envelope, and refusal envelope is unchanged. The item #122 lock-coarsening
428
+ parity golden, `test/publication-parity-baseline.json`, was regenerated to
429
+ record it: that recording is a work-claim contract change, never routine
430
+ fixture maintenance.
431
+
432
+ **What that identity discloses.** The identity is an opaque random UUID,
433
+ created once per worktree in that worktree's private Git directory. It contains
434
+ no path, branch, ref, hostname, user, or machine data. Journal entries are
435
+ projected into the tracked reconciliation log, so the identity persists in
436
+ committed Git history: any reader of that history can correlate every entry
437
+ written from one worktree, and an identity stays in that history indefinitely
438
+ after the worktree it named is removed. It never becomes a claim owner, and no
439
+ protocol decision reads it other than the writer comparison above.
440
+
441
+ **Ambiguous identity.** A UUID names one worktree. Before it reasons from a
442
+ recorded writer, the coordinator enumerates the worktrees Git currently reports
443
+ live and reads the identity each one already holds; it creates none. Two live
444
+ worktrees answering to one UUID, or a roster the coordinator could not finish
445
+ reading, refuses `claim-verify`, every claim-protected mutation,
446
+ `publish-claimed`, and `claim-adopt` before anything is classified or written.
447
+ The refusal keeps the existing exit `6`, `claim-store-unavailable`,
448
+ `claim-store-unreadable`, `state: "unchanged"` form and adds one
449
+ `error.details.identity_diagnostic`: `code: "duplicate-worktree-identity"` with
450
+ `worktree_id` and `live_worktree_count`, or `code:
451
+ "worktree-enumeration-failed"` with no further member. Auto-commit surfaces the
452
+ same diagnostic inside its `auto-commit-preflight-failed` details with
453
+ `retryable: false`. Nothing else about the envelope changes, and core
454
+ `contract_version` stays `5`.
455
+
456
+ Detection covers only what Git reports live. A worktree Git marks prunable —
457
+ including one whose path is temporarily unavailable or unmounted — is excluded,
458
+ so a duplicate identity held there is not detected until Git sees that worktree
459
+ live again. Removing a worktree removes the private Git directory that held its
460
+ identity, and nothing restores it: a worktree recreated at the same path earns
461
+ a new UUID, and the removed UUID stays in journal history attributing nothing.
462
+
387
463
  ### 3.2 Recovering from a foreign-writer block
388
464
 
389
465
  1. Stop writing the affected item in the blocked worktree. Unrelated item
@@ -452,10 +528,12 @@ answering with `namespace: "work-claim"`, `command: "claim-adopt"`, and
452
528
  mutation runs and no item file changes. It is not a `claim-verify` flag, because
453
529
  `claim-verify` is the read-mostly reconciliation report every remediation string
454
530
  names, and one command name must not mean both "tell me the state" and "change
455
- the authorization baseline". It is not a `claim` subcommand, because every claim
456
- lifecycle subcommand refuses with `publication-reconciliation-required` while
457
- reconciliation reports blocking findings, and adoption exists to clear exactly
458
- that state, so it runs while those findings stand.
531
+ the authorization baseline". It is not a `claim` subcommand, because
532
+ `work-claim.acquire` and `work-claim.renew` refuse with
533
+ `publication-reconciliation-required` while reconciliation reports blocking
534
+ findings, and adoption exists to clear exactly that state, so it runs while
535
+ those findings stand. `work-claim.release` also runs while they stand, for the
536
+ narrower reason that surrendering a lease takes no authority (section 5).
459
537
 
460
538
  The request is UTF-8 JSON with exactly these members:
461
539
 
@@ -602,6 +680,22 @@ member at any depth, and exactly the listed members. Unknown members, wrong
602
680
  types, noncanonical values, and unprovisioned namespaces are exit 2
603
681
  `invalid-request`; no authoritative lease decision has then occurred.
604
682
 
683
+ **A reconciliation barrier stops a caller from taking or extending authority,
684
+ never from surrendering it.** `work-claim.acquire` and `work-claim.renew` MUST
685
+ refuse with exit 6 `claim-store-unavailable`, reason
686
+ `publication-reconciliation-required`, and the reconciliation `findings`,
687
+ whenever reconciliation reports a finding that blocks the request's item.
688
+ `work-claim.release` MUST NOT refuse for that reason. The holder has to be able
689
+ to hand the lease back: no other worktree can take the item over while the
690
+ claim is held, and the worktree holding it is often the one least able to clear
691
+ the barrier. Refusing the surrender strands the lease and the item with it.
692
+
693
+ Release keeps every other refusal. An identity the domain cannot resolve, a
694
+ journal it cannot read, and a clock floor it cannot persist all refuse before
695
+ any lease decision, and the owner, epoch, and expected-expiry CAS tuple below
696
+ still rules on the request: a mismatch is exit 4 `claim-conflict` and the claim
697
+ stays held. A barrier never turns a release into an unconditional discard.
698
+
605
699
  ### Read
606
700
 
607
701
  `work-claim.read` accepts exactly:
@@ -673,6 +767,10 @@ It uses the same precedence. Success sets `active` to `null`, retains
673
767
  `last_epoch`, and returns `released_claim` plus `read_back`. A later acquire
674
768
  must allocate a greater epoch, preventing ABA even across restart.
675
769
 
770
+ A reconciliation barrier does not refuse a release; it refuses only acquire and
771
+ renew (section 5 preamble). The CAS tuple still applies, so a release under a
772
+ barrier is exactly as conditional as a release without one.
773
+
676
774
  Success envelopes for these three commands have exactly `ok`, `namespace`,
677
775
  `command`, `contract_version`, `state: "committed"`, and `result`. Semantic
678
776
  failures replace `result` with `error`, use `state: "unchanged"`, and include
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wowbagger",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.13",
4
4
  "description": "Git-native work ledger for coding agents: deterministic ready queues, guarded CAS mutations, claims and fencing, and self-contained HTML reports.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,7 +14,7 @@ publish something.
14
14
  Install the core separately before using this skill:
15
15
 
16
16
  ```sh
17
- npm install -g wowbagger@0.1.0-alpha.12
17
+ npm install -g wowbagger@0.1.0-alpha.13
18
18
  ```
19
19
 
20
20
  The core requires Node.js 20 or later. This plugin ships only agent
@@ -34,9 +34,9 @@ wowbagger capabilities --json
34
34
 
35
35
  Read the plain distribution version from the first command and the top-level
36
36
  `contract_version` from the second. **This skill requires distribution version
37
- `0.1.0-alpha.12` and core `contract_version: 5`.**
37
+ `0.1.0-alpha.13` and core `contract_version: 5`.**
38
38
 
39
- The distribution pin names the published `0.1.0-alpha.12` release; the cut that
39
+ The distribution pin names the published `0.1.0-alpha.13` release; the cut that
40
40
  publishes core `contract_version: 5` moves it. Earlier cores report
41
41
  `contract_version: 3` or lower and lack behavior this skill requires, including
42
42
  the bounded item source, so the version check refuses them. Do not soften
@@ -14,6 +14,11 @@ import { readBack } from './claim-operations.js';
14
14
  import { reconcileClaimJournal } from './claim-publication.js';
15
15
  import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './claim-store.js';
16
16
  import { readNamespace } from './namespace.js';
17
+ import {
18
+ assertUniqueWorktreeIdentity,
19
+ ensureWorktreeIdentity,
20
+ identityDiagnosticDetails,
21
+ } from './worktree-identity.js';
17
22
 
18
23
  export async function withLegacyMutationFence(
19
24
  ledgerDirectory,
@@ -37,12 +42,22 @@ export async function withLegacyMutationFence(
37
42
  let intent = null;
38
43
  try {
39
44
  return await withClaimLock(storePath, async () => {
45
+ // The writer's own identity must exist before anything it does can be
46
+ // attributed, so it is established under the same lock that serializes
47
+ // the journal, ahead of reconciliation and of any intent append. Its own
48
+ // file is judged first, so bytes this worktree owns keep reporting as its
49
+ // own invalid identity rather than as an unreadable roster.
50
+ const currentWorktreeId = await ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir });
51
+ // Then the domain: a UUID two live worktrees answer to attributes
52
+ // nothing, so nothing may be written from it.
53
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
40
54
  const replayed = await replayClaimJournal(journalPath, namespace);
41
55
  const reconciled = await reconcileClaimJournal({
42
56
  ledgerDirectory,
43
57
  gitCommonDir,
44
58
  namespace,
45
59
  replayed,
60
+ currentWorktreeId,
46
61
  physicalNow: new Date().toISOString(),
47
62
  targetItemId: itemId,
48
63
  writeLogOnUnsafe: false,
@@ -76,6 +91,7 @@ export async function withLegacyMutationFence(
76
91
  expected_revision: expectedRevision,
77
92
  candidate_revision: candidateRevision,
78
93
  item_path: itemPath,
94
+ writer_worktree_id: currentWorktreeId,
79
95
  observed_at: observedAt,
80
96
  };
81
97
  const terminalEntry = {
@@ -86,6 +102,7 @@ export async function withLegacyMutationFence(
86
102
  command,
87
103
  committed_revision: candidateRevision,
88
104
  item_path: itemPath,
105
+ writer_worktree_id: currentWorktreeId,
89
106
  observed_at: observedAt,
90
107
  };
91
108
  const abortEntry = {
@@ -148,6 +165,7 @@ export async function withLegacyMutationFence(
148
165
  committed_revision: committedRevision,
149
166
  observed_at: observedAt,
150
167
  item_path: intent.item_path,
168
+ writer_worktree_id: currentWorktreeId,
151
169
  }));
152
170
  } catch {
153
171
  return claimStoreUnavailable(responseCommand, 'legacy-mutation-record-failed', {
@@ -192,7 +210,7 @@ export async function withLegacyMutationFence(
192
210
  } catch (error) {
193
211
  return claimStoreUnavailable(responseCommand, error?.code === 'CLAIM_LOCK_HELD'
194
212
  ? 'claim-store-locked'
195
- : 'claim-store-unreadable', {}, intent ? 'unknown' : 'unchanged');
213
+ : 'claim-store-unreadable', identityDiagnosticDetails(error), intent ? 'unknown' : 'unchanged');
196
214
  }
197
215
  }
198
216
 
@@ -286,6 +286,8 @@ function validJournalEntry(entry, namespace) {
286
286
  && typeof entry.expected_revision === 'string'
287
287
  && typeof entry.candidate_revision === 'string'
288
288
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
289
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
290
+ || typeof entry.writer_worktree_id === 'string')
289
291
  && typeof entry.observed_at === 'string';
290
292
  }
291
293
  if (entry.type === 'legacy-mutation') {
@@ -296,6 +298,8 @@ function validJournalEntry(entry, namespace) {
296
298
  && ['patch-v1', 'transition-v1'].includes(entry.command)
297
299
  && typeof entry.committed_revision === 'string'
298
300
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
301
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
302
+ || typeof entry.writer_worktree_id === 'string')
299
303
  && typeof entry.observed_at === 'string';
300
304
  }
301
305
  if (entry.type === 'legacy-mutation-abort') {
@@ -325,6 +329,8 @@ function validJournalEntry(entry, namespace) {
325
329
  && typeof entry.expected_revision === 'string'
326
330
  && typeof entry.candidate_sha256 === 'string'
327
331
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
332
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
333
+ || typeof entry.writer_worktree_id === 'string')
328
334
  && isRecord(entry.fence)
329
335
  && (namespace === null || entry.fence.ledger_namespace === namespace)
330
336
  && entry.fence.item_id === entry.item_id;
@@ -335,6 +341,8 @@ function validJournalEntry(entry, namespace) {
335
341
  && typeof entry.ledger_namespace === 'string'
336
342
  && (namespace === null || entry.ledger_namespace === namespace)
337
343
  && typeof entry.item_id === 'string'
344
+ && (!Object.hasOwn(entry, 'writer_worktree_id')
345
+ || typeof entry.writer_worktree_id === 'string')
338
346
  && validPublicationOutcome(entry);
339
347
  }
340
348
  return typeof entry.operation_id === 'string'
@@ -19,6 +19,12 @@ import { MAX_ITEM_SOURCE_BYTES } from './limits.js';
19
19
  import { findRevisionOwner, readGitHeadLedger, readGitTreeFile } from './git-reconciliation.js';
20
20
  import { publishClaimedCandidate, revisionFor } from './mutation.js';
21
21
  import { validateLedger } from './validate.js';
22
+ import {
23
+ assertUniqueWorktreeIdentity,
24
+ ensureWorktreeIdentity,
25
+ identityDiagnosticDetails,
26
+ readWorktreeIdentity,
27
+ } from './worktree-identity.js';
22
28
 
23
29
  const ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
24
30
  const NAMESPACE_ID = /^wbns_[a-f0-9]{32}$/;
@@ -179,6 +185,15 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
179
185
  // file, so the first of them is the snapshot the rest share. The read
180
186
  // under lock, which the mutation engine still performs on its own, is
181
187
  // what makes the revision compare-and-swap meaningful.
188
+ //
189
+ // A claimed publication authorizes an item revision, so an ambiguous
190
+ // domain is refused before reconciliation classifies one. The writer
191
+ // names itself first, under this same hold, so a domain that cannot
192
+ // resolve its own identity refuses as its own invalid identity rather
193
+ // than as an unreadable roster — and so every entry this hold appends
194
+ // carries one ID resolved exactly once.
195
+ const currentWorktreeId = await ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir });
196
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
182
197
  let reconciled;
183
198
  try {
184
199
  reconciled = await reconcileClaimJournal({
@@ -188,6 +203,7 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
188
203
  replayed,
189
204
  physicalNow: new Date().toISOString(),
190
205
  targetItemId: request.item_id,
206
+ currentWorktreeId,
191
207
  writeLogOnUnsafe: false,
192
208
  });
193
209
  } catch (error) {
@@ -237,7 +253,7 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
237
253
  active_owner_id: record.active?.owner_id ?? null,
238
254
  active_epoch: record.active?.epoch ?? null,
239
255
  }, 4);
240
- return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
256
+ return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
241
257
  }
242
258
  await publicationTestCheckpoint(scenario, 'before-publish-intent', ledgerDirectory);
243
259
  const intent = await appendClaimEntry(journalPath, {
@@ -249,6 +265,7 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
249
265
  candidate_sha256: request.candidate_sha256,
250
266
  fence: request.claim_fence,
251
267
  floor: observedAt,
268
+ writer_worktree_id: currentWorktreeId,
252
269
  state: 'pending',
253
270
  });
254
271
  entries.push(intent);
@@ -258,11 +275,11 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
258
275
  });
259
276
  if (!mutation.ok) {
260
277
  const outcome = mutationFailure(request, mutation);
261
- return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
278
+ return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
262
279
  }
263
280
  await publicationTestCheckpoint(scenario, 'after-ledger-commit', ledgerDirectory);
264
281
  const outcome = publicationSuccess(request, record, observedAt, mutation.item?.path, namespace);
265
- const terminal = await persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
282
+ const terminal = await persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
266
283
  await publicationTestCheckpoint(scenario, 'after-terminal-record', ledgerDirectory);
267
284
  return terminal;
268
285
  });
@@ -272,6 +289,15 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
272
289
  reason: 'claim-store-locked',
273
290
  }, 6);
274
291
  }
292
+ // An identity the coordination domain cannot resolve is a store this
293
+ // command could not read, not a mutation whose outcome is unknown: it
294
+ // refused before it appended an intent or touched an item byte.
295
+ if (error?.code === 'CLAIM_WORKTREE_IDENTITY_INVALID') {
296
+ return publicationError(request, 'claim-store-unavailable', 'The durable claim store is unavailable.', {
297
+ reason: 'claim-store-unreadable',
298
+ ...identityDiagnosticDetails(error),
299
+ }, 6);
300
+ }
275
301
  return publicationUnknown(request);
276
302
  }
277
303
  }
@@ -350,6 +376,10 @@ export async function reconcileClaimJournal({
350
376
  namespace,
351
377
  replayed,
352
378
  physicalNow,
379
+ // The worktree this invocation speaks for, when it has established an
380
+ // identity. A caller that cannot name itself passes null, and every
381
+ // recorded writer then reads as unknown.
382
+ currentWorktreeId = null,
353
383
  targetItemId = null,
354
384
  writeLogOnUnsafe = true,
355
385
  writeLogWhenEmpty = true,
@@ -407,6 +437,12 @@ export async function reconcileClaimJournal({
407
437
  command: intent.command,
408
438
  committed_revision: actualRevision,
409
439
  ...(intent.item_path ? { item_path: intent.item_path } : {}),
440
+ // The writer that authorized the attempt owns the revision this
441
+ // resolution ratifies, so its identity travels with the terminal
442
+ // entry. Absent on alpha.12 intents, which stay valid without it.
443
+ ...(intent.writer_worktree_id
444
+ ? { writer_worktree_id: intent.writer_worktree_id }
445
+ : {}),
410
446
  observed_at: observedAt,
411
447
  }));
412
448
  } else if (actualRevision === intent.expected_revision) {
@@ -496,6 +532,12 @@ export async function reconcileClaimJournal({
496
532
  operation_digest: intent.operation_digest,
497
533
  ledger_namespace: namespace,
498
534
  item_id: intent.item_id,
535
+ // The writer that authorized the publication owns the revision this
536
+ // resolution ratifies, so its identity travels with the terminal entry.
537
+ // Absent on alpha.12 intents, which stay valid without it.
538
+ ...(intent.writer_worktree_id
539
+ ? { writer_worktree_id: intent.writer_worktree_id }
540
+ : {}),
499
541
  outcome,
500
542
  }));
501
543
  const unknownPath = itemPathRelativeToLedger(ledgerDirectory, item?.file);
@@ -608,6 +650,13 @@ export async function reconcileClaimJournal({
608
650
  });
609
651
  continue;
610
652
  }
653
+ // Who the journal says wrote the authorized revision, judged against who
654
+ // is asking. An entry from before writer identity existed, or a caller
655
+ // that cannot name itself, leaves the writer unknown.
656
+ const recordedWriter = latestAuthorized.writer_worktree_id ?? null;
657
+ const expectedWriter = recordedWriter === null || currentWorktreeId === null
658
+ ? 'unknown'
659
+ : recordedWriter === currentWorktreeId ? 'current' : 'other';
611
660
  const diagnosis = await reconciliationDiagnosis({
612
661
  ledgerDirectory,
613
662
  actualRevision,
@@ -615,6 +664,7 @@ export async function reconcileClaimJournal({
615
664
  expectedPath,
616
665
  expectedRevision,
617
666
  headRevision,
667
+ expectedWriter,
618
668
  workingTreeChanged,
619
669
  });
620
670
  findings.push({
@@ -755,14 +805,26 @@ function blocksTarget(finding, targetItemId) {
755
805
  // Ownership is evidence, never a guess: an unreadable history or a missing
756
806
  // expected path leaves the revision unattributed instead of naming a ref.
757
807
  async function revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision) {
758
- if (!expectedPath) return { owner_unavailable: true };
808
+ if (!expectedPath) return { kind: 'unreachable' };
759
809
  try {
760
810
  return await findRevisionOwner(ledgerDirectory, expectedPath, expectedRevision);
761
811
  } catch {
762
- return { owner_unavailable: true };
812
+ return { kind: 'unreachable' };
763
813
  }
764
814
  }
765
815
 
816
+ // One owner, one shape: only an active named worktree reaches this finding, and
817
+ // the remediation names the same ref and commit the finding does.
818
+ function namedOwnerFinding(owner, expectedPath) {
819
+ return {
820
+ reason: 'worktree-synchronization-required',
821
+ ...(expectedPath ? { expected_path: expectedPath } : {}),
822
+ owner_ref: owner.ref,
823
+ owner_commit: owner.commit,
824
+ remediation: `WAIT for owner ${owner.ref} to publish ${owner.commit}, then synchronize this worktree and run claim-verify.`,
825
+ };
826
+ }
827
+
766
828
  async function reconciliationDiagnosis({
767
829
  ledgerDirectory,
768
830
  actualRevision,
@@ -770,6 +832,7 @@ async function reconciliationDiagnosis({
770
832
  expectedPath,
771
833
  expectedRevision,
772
834
  headRevision,
835
+ expectedWriter,
773
836
  workingTreeChanged,
774
837
  }) {
775
838
  const pathLabel = expectedPath ?? 'the item path';
@@ -791,15 +854,7 @@ async function reconciliationDiagnosis({
791
854
  if (headRevision !== null && actualRevision === headRevision
792
855
  && headRevision !== expectedRevision) {
793
856
  expectedOwner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
794
- if (expectedOwner.owner_ref) {
795
- return {
796
- reason: 'worktree-synchronization-required',
797
- ...(expectedPath ? { expected_path: expectedPath } : {}),
798
- owner_ref: expectedOwner.owner_ref,
799
- owner_commit: expectedOwner.owner_commit,
800
- remediation: `WAIT for owner ${expectedOwner.owner_ref} to publish ${expectedOwner.owner_commit}, then synchronize this worktree and run claim-verify.`,
801
- };
802
- }
857
+ if (expectedOwner.kind === 'named-sibling') return namedOwnerFinding(expectedOwner, expectedPath);
803
858
  }
804
859
  if (!workingTreeChanged && headRevision !== expectedRevision) {
805
860
  return {
@@ -810,30 +865,23 @@ async function reconciliationDiagnosis({
810
865
  }
811
866
  if (actualRevision === null && headRevision !== expectedRevision) {
812
867
  const owner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
813
- const publicOwner = owner.owner_ref
814
- ? { owner_ref: owner.owner_ref, owner_commit: owner.owner_commit }
815
- : { owner_unavailable: true };
868
+ if (owner.kind === 'named-sibling') return namedOwnerFinding(owner, expectedPath);
816
869
  return {
817
870
  reason: 'worktree-synchronization-required',
818
871
  ...(expectedPath ? { expected_path: expectedPath } : {}),
819
- ...publicOwner,
820
- remediation: owner.owner_ref
821
- ? `WAIT for owner ${owner.owner_ref} to publish ${owner.owner_commit}, then synchronize this worktree and run claim-verify.`
822
- : `Ownership of ${pathLabel} revision ${expectedRevision} cannot be established from reachable refs; inspect reachable or dangling commits, restore or explicitly adopt reviewed bytes, then run claim-verify.`,
872
+ owner_unavailable: true,
873
+ remediation: `Ownership of ${pathLabel} revision ${expectedRevision} cannot be established from reachable refs; inspect reachable or dangling commits, restore or explicitly adopt reviewed bytes, then run claim-verify.`,
823
874
  };
824
875
  }
825
876
  if (authorizedRevisions.has(actualRevision)) {
826
877
  expectedOwner ??= await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
827
- if (expectedOwner.owner_ref) {
828
- return {
829
- reason: 'worktree-synchronization-required',
830
- ...(expectedPath ? { expected_path: expectedPath } : {}),
831
- owner_ref: expectedOwner.owner_ref,
832
- owner_commit: expectedOwner.owner_commit,
833
- remediation: `WAIT for owner ${expectedOwner.owner_ref} to publish ${expectedOwner.owner_commit}, then synchronize this worktree and run claim-verify.`,
834
- };
835
- }
836
- if (expectedOwner.owner_current_ref !== true) {
878
+ if (expectedOwner.kind === 'named-sibling') return namedOwnerFinding(expectedOwner, expectedPath);
879
+ // Advice to wait for an owning worktree needs an owner that could still
880
+ // appear. When the journal names this worktree as the writer of the
881
+ // expected revision and no active worktree carries it, the successor exists
882
+ // nowhere but in the journal: there is nothing to synchronize from, and the
883
+ // local bytes are simply unauthorized.
884
+ if (expectedWriter !== 'current' && expectedOwner.kind !== 'current') {
837
885
  return {
838
886
  reason: 'worktree-synchronization-required',
839
887
  ...(expectedPath ? { expected_path: expectedPath } : {}),
@@ -913,12 +961,22 @@ export async function verifyClaimJournal({
913
961
  const journalPath = claimJournalPath(gitCommonDir, namespace);
914
962
  try {
915
963
  return await withClaimLock(storePath, async () => {
964
+ // Verification writes no item byte and creates no identity: it reports on
965
+ // the worktree it runs in, and an unidentified worktree simply cannot
966
+ // recognize itself in a recorded writer. Its own file is judged first, so
967
+ // bytes this worktree owns keep reporting as its own invalid identity
968
+ // rather than as an unreadable roster.
969
+ const currentWorktreeId = await readWorktreeIdentity({ ledgerDirectory, gitCommonDir });
970
+ // Verification reasons from recorded writers, so it refuses an ambiguous
971
+ // domain before it classifies anything, exactly as a mutation does.
972
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
916
973
  const reconciled = await reconcileClaimJournal({
917
974
  ledgerDirectory,
918
975
  gitCommonDir,
919
976
  namespace,
920
977
  replayed: await replayClaimJournal(journalPath, namespace),
921
978
  physicalNow: new Date().toISOString(),
979
+ currentWorktreeId,
922
980
  targetItemId,
923
981
  writeLogWhenEmpty,
924
982
  writeLogOnUnsafe,
@@ -957,6 +1015,7 @@ export async function verifyClaimJournal({
957
1015
  reason: error?.code === 'CLAIM_LOCK_HELD'
958
1016
  ? 'claim-store-locked'
959
1017
  : 'claim-store-unreadable',
1018
+ ...identityDiagnosticDetails(error),
960
1019
  },
961
1020
  },
962
1021
  },
@@ -968,20 +1027,30 @@ export async function verifyClaimJournal({
968
1027
  // re-baselines the coordinator's authorized revision onto bytes that are
969
1028
  // already committed, and writes no item byte. It runs while reconciliation is
970
1029
  // unsafe on purpose — clearing that state is the point — so it never refuses on
971
- // `publication-reconciliation-required` the way a claim lifecycle operation
972
- // does. Every precondition is checked under the claim lock against the state
973
- // reconciliation just observed.
1030
+ // `publication-reconciliation-required` the way `claim acquire` or
1031
+ // `claim renew` do. Every precondition is checked under the claim lock against
1032
+ // the state reconciliation just observed.
974
1033
  export async function adoptItemRevision({ ledgerDirectory, gitCommonDir, namespace, request }) {
975
1034
  const storePath = claimStorePath(gitCommonDir, namespace);
976
1035
  const journalPath = claimJournalPath(gitCommonDir, namespace);
977
1036
  try {
978
1037
  return await withClaimLock(storePath, async () => {
1038
+ // Adoption writes no item byte and creates no identity, so it names
1039
+ // itself by reading the ID it already answers to. Its own file is judged
1040
+ // first, so bytes this worktree owns keep reporting as its own invalid
1041
+ // identity rather than as an unreadable roster, exactly as claim-verify
1042
+ // reports them.
1043
+ const currentWorktreeId = await readWorktreeIdentity({ ledgerDirectory, gitCommonDir });
1044
+ // Adoption re-baselines an authorized revision, so it too refuses an
1045
+ // ambiguous domain before reconciliation classifies one.
1046
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
979
1047
  const reconciled = await reconcileClaimJournal({
980
1048
  ledgerDirectory,
981
1049
  gitCommonDir,
982
1050
  namespace,
983
1051
  replayed: await replayClaimJournal(journalPath, namespace),
984
1052
  physicalNow: new Date().toISOString(),
1053
+ currentWorktreeId,
985
1054
  });
986
1055
  const refusal = adoptionRefusal(reconciled, ledgerDirectory, request);
987
1056
  if (refusal) return refusal;
@@ -1050,6 +1119,7 @@ export async function adoptItemRevision({ ledgerDirectory, gitCommonDir, namespa
1050
1119
  reason: error?.code === 'CLAIM_LOCK_HELD'
1051
1120
  ? 'claim-store-locked'
1052
1121
  : 'claim-store-unreadable',
1122
+ ...identityDiagnosticDetails(error),
1053
1123
  },
1054
1124
  },
1055
1125
  },
@@ -1156,13 +1226,14 @@ export function operationDigest(request) {
1156
1226
  return `sha256:${createHash('sha256').update(canonicalJson(request)).digest('hex')}`;
1157
1227
  }
1158
1228
 
1159
- async function persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, state, storePath) {
1229
+ async function persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, state, storePath, writerWorktreeId) {
1160
1230
  const terminal = await appendClaimEntry(journalPath, {
1161
1231
  type: 'publish-final',
1162
1232
  operation_id: request.operation_id,
1163
1233
  operation_digest: operationDigest(request),
1164
1234
  ledger_namespace: request.ledger_namespace,
1165
1235
  item_id: request.item_id,
1236
+ writer_worktree_id: writerWorktreeId,
1166
1237
  outcome,
1167
1238
  });
1168
1239
  entries.push(terminal);
@@ -127,7 +127,12 @@ export function namespaceLockHeld(hold, storePath) {
127
127
  && hold.storePath === storePath;
128
128
  }
129
129
 
130
- export async function withClaimLock(storePath, fn) {
130
+ // `withClaimLock` is the one file-lock primitive; the counter name says which
131
+ // lock a caller is taking, so a profile can hold each one to its own number
132
+ // instead of watching a single total drift.
133
+ export async function withClaimLock(storePath, fn, {
134
+ counter = 'namespace_lock_acquisitions',
135
+ } = {}) {
131
136
  const directory = path.dirname(storePath);
132
137
  await mkdir(directory, { recursive: true });
133
138
  const lockPath = `${storePath}.lock`;
@@ -143,7 +148,7 @@ export async function withClaimLock(storePath, fn) {
143
148
  }
144
149
 
145
150
  try {
146
- await acquireClaimLock(candidatePath, lockPath, recoveryPath);
151
+ await acquireClaimLock(candidatePath, lockPath, recoveryPath, counter);
147
152
  const hold = { [NAMESPACE_LOCK_HOLD]: true, storePath, released: false };
148
153
  try {
149
154
  return await fn(hold);
@@ -158,7 +163,7 @@ export async function withClaimLock(storePath, fn) {
158
163
  }
159
164
  }
160
165
 
161
- async function acquireClaimLock(candidatePath, lockPath, recoveryPath) {
166
+ async function acquireClaimLock(candidatePath, lockPath, recoveryPath, counter) {
162
167
  for (;;) {
163
168
  const recoveryOwner = await readLockOwner(recoveryPath);
164
169
  if (recoveryOwner) {
@@ -168,7 +173,7 @@ async function acquireClaimLock(candidatePath, lockPath, recoveryPath) {
168
173
  }
169
174
  try {
170
175
  await link(candidatePath, lockPath);
171
- recordCount('namespace_lock_acquisitions');
176
+ recordCount(counter);
172
177
  return;
173
178
  } catch (error) {
174
179
  if (error?.code !== 'EEXIST') throw error;
package/src/cli.js CHANGED
@@ -82,6 +82,7 @@ import { normalizeJsonValue, parseJsonRequest, sortIssues } from './request.js';
82
82
  import { selectReady } from './ready.js';
83
83
  import { isCalendarDate, validateLedger } from './validate.js';
84
84
  import { inspectWorkbench } from './workbench.js';
85
+ import { readWorktreeIdentity } from './worktree-identity.js';
85
86
 
86
87
  const CLAIM_OPERATIONS = { read: claimRead, acquire: claimAcquire, renew: claimRenew, release: claimRelease };
87
88
  const MUTATION_CONTRACT_VERSION = 5;
@@ -1659,6 +1660,21 @@ async function runClaimCommand(claimCommand, argumentsList) {
1659
1660
  } catch (error) {
1660
1661
  throw taggedFailure('CLAIM_STORE_UNREADABLE', error);
1661
1662
  }
1663
+ // A claim lifecycle command classifies item reconciliation and refuses
1664
+ // on it, so it must reason from the same writer evidence every other
1665
+ // classifying surface uses; otherwise one command grants a claim on
1666
+ // exactly the state another command refuses to write. It writes no item
1667
+ // byte and no writer-attributed entry, so it reads the identity it
1668
+ // already answers to rather than creating one.
1669
+ let currentWorktreeId;
1670
+ try {
1671
+ currentWorktreeId = await readWorktreeIdentity({
1672
+ ledgerDirectory: parsedOptions.options.ledger,
1673
+ gitCommonDir,
1674
+ });
1675
+ } catch (error) {
1676
+ throw taggedFailure('CLAIM_STORE_UNREADABLE', error);
1677
+ }
1662
1678
  const physicalNow = new Date().toISOString();
1663
1679
  let reconciled;
1664
1680
  try {
@@ -1669,6 +1685,7 @@ async function runClaimCommand(claimCommand, argumentsList) {
1669
1685
  replayed,
1670
1686
  physicalNow,
1671
1687
  targetItemId: request.item_id,
1688
+ currentWorktreeId,
1672
1689
  writeLogOnUnsafe: false,
1673
1690
  });
1674
1691
  } catch (error) {
@@ -1679,7 +1696,15 @@ async function runClaimCommand(claimCommand, argumentsList) {
1679
1696
  error,
1680
1697
  );
1681
1698
  }
1682
- if (reconciled.unsafe) {
1699
+ // Release relinquishes authority; it never extends or grants any. A
1700
+ // barrier that refuses it strands the lease in the worktree least able
1701
+ // to clear the barrier, because no other worktree can take the item over
1702
+ // while the claim is held. Acquire and renew do extend authority against
1703
+ // bytes nobody has ruled legitimate, so they keep refusing. Only this
1704
+ // classification is bypassed: an unresolvable identity, an unreadable
1705
+ // journal, and a clock floor that will not persist all throw before this
1706
+ // point, and the tuple compare-and-swap below still rules on the request.
1707
+ if (reconciled.unsafe && claimCommand !== 'release') {
1683
1708
  return claimStoreUnavailable(claimCommand, 'publication-reconciliation-required', {
1684
1709
  findings: reconciled.findings,
1685
1710
  });
@@ -410,6 +410,12 @@ function claimVerificationFailureDetails(verified) {
410
410
  return {
411
411
  ...(error?.code ? { claim_verify_code: error.code } : {}),
412
412
  ...(error?.details?.reason ? { claim_verify_reason: error.details.reason } : {}),
413
+ // Why the claim store could not be read travels with the refusal: an
414
+ // operator told only that a store is unreadable cannot find the two
415
+ // worktrees answering to one identity.
416
+ ...(error?.details?.identity_diagnostic
417
+ ? { identity_diagnostic: error.details.identity_diagnostic }
418
+ : {}),
413
419
  ...(findings ? { findings } : {}),
414
420
  };
415
421
  }
@@ -2,6 +2,7 @@ import { execFile, spawn } from 'node:child_process';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
+ import { listWorktrees } from './git-worktrees.js';
5
6
  import { recordCount, timePhase } from './instrumentation.js';
6
7
  import { revisionFor } from './mutation.js';
7
8
 
@@ -63,48 +64,84 @@ export async function readGitTreeFile(ledgerDirectory, treeish, relativePath) {
63
64
  return gitBuffer(root, ['show', `${treeish}:${gitPath}`]);
64
65
  }
65
66
 
66
- // Which local ref carries the revision the journal expects. A finding may name
67
- // only ownership it can prove from reachable history, so a revision no ref
68
- // contains stays unattributed rather than guessed from the item path.
67
+ // Which active worktree carries the revision the journal expects. Reachability
68
+ // alone proves nothing about ownership: a tag, a remote-tracking ref, or a
69
+ // branch nobody has checked out names no worktree that could publish anything.
70
+ // So the evidence is the worktree roster, this worktree first, and a revision
71
+ // reachable only outside it stays unowned rather than attributed to a ref.
72
+ //
73
+ // Returns `{ kind, ref?, commit? }`, where `kind` is one of:
74
+ // `current` this worktree's own history reaches the revision;
75
+ // `named-sibling` a live sibling worktree on a branch reaches it;
76
+ // `reachable-unowned` Git reaches it, but no live named worktree does;
77
+ // `unreachable` no reachable commit carries the expected bytes.
69
78
  export async function findRevisionOwner(ledgerDirectory, itemPath, expectedRevision) {
70
79
  const { root, relativeLedger } = await resolveWorktreeLedger(ledgerDirectory);
71
80
  const gitPath = toGitPath(path.join(relativeLedger, itemPath));
72
- let currentRef = null;
81
+ const carrierIn = revisionCarrier(root, gitPath, expectedRevision);
82
+ const current = await carrierIn(await pathHistory(root, 'HEAD', gitPath));
83
+ if (current) return { kind: 'current', commit: current };
84
+ const live = (await listWorktrees(root)).filter((record) => (
85
+ !record.bare && !record.prunable && record.head !== null
86
+ ));
87
+ // One expected revision can sit in several live worktrees at once. Branch ref
88
+ // then path is the only ordering both stable across runs and independent of
89
+ // the order Git happened to register the worktrees in.
90
+ for (const record of live.filter((entry) => entry.branch !== null).sort(byBranchThenPath)) {
91
+ const commit = await carrierIn(await pathHistory(root, record.head, gitPath));
92
+ if (commit) return { kind: 'named-sibling', ref: record.branch, commit };
93
+ }
94
+ // A detached worktree is a real writer with no ref to wait on, so it proves
95
+ // reachability and nothing else. So does any remaining ref: a tag, a
96
+ // remote-tracking ref, or a branch no worktree has checked out.
97
+ for (const record of live.filter((entry) => entry.branch === null)) {
98
+ const commit = await carrierIn(await pathHistory(root, record.head, gitPath));
99
+ if (commit) return { kind: 'reachable-unowned', commit };
100
+ }
101
+ const reachable = await carrierIn(await pathHistory(root, '--all', gitPath));
102
+ return reachable ? { kind: 'reachable-unowned', commit: reachable } : { kind: 'unreachable' };
103
+ }
104
+
105
+ // The first commit of a history whose item bytes are the expected revision, or
106
+ // null. Sibling histories overlap almost entirely and every answer costs a
107
+ // `git show`, so one commit is read at most once per lookup.
108
+ function revisionCarrier(root, gitPath, expectedRevision) {
109
+ const answered = new Map();
110
+ return async (commits) => {
111
+ for (const commit of commits) {
112
+ if (!answered.has(commit)) {
113
+ answered.set(commit, await carriesRevision(root, commit, gitPath, expectedRevision));
114
+ }
115
+ if (answered.get(commit)) return commit;
116
+ }
117
+ return null;
118
+ };
119
+ }
120
+
121
+ async function carriesRevision(root, commit, gitPath, expectedRevision) {
73
122
  try {
74
- currentRef = (await gitText(root, ['symbolic-ref', '-q', 'HEAD'])).trim() || null;
123
+ const bytes = await gitBuffer(root, ['show', `${commit}:${gitPath}`]);
124
+ return revisionFor(bytes) === expectedRevision;
75
125
  } catch {
76
- // A detached HEAD has no current branch; its commit history is checked below.
126
+ // The path does not exist in this commit, so it cannot carry the revision.
127
+ return false;
77
128
  }
78
- const detachedHeadCommits = new Set(currentRef === null
79
- ? gitLines(await gitText(root, ['rev-list', 'HEAD', '--', gitPath]))
80
- : []);
81
- const allRefCommits = gitLines(await gitText(root, ['rev-list', '--all', '--', gitPath]));
82
- const commits = [...new Set([...detachedHeadCommits, ...allRefCommits])];
83
- for (const commit of commits) {
84
- let bytes;
85
- try {
86
- bytes = await gitBuffer(root, ['show', `${commit}:${gitPath}`]);
87
- } catch {
88
- // The path does not exist in this commit, so it cannot carry the revision.
89
- continue;
90
- }
91
- if (revisionFor(bytes) !== expectedRevision) continue;
92
- if (detachedHeadCommits.has(commit)) {
93
- return { owner_unavailable: true, owner_current_ref: true, owner_commit: commit };
94
- }
95
- const refs = gitLines(await gitText(root, [
96
- 'for-each-ref',
97
- '--contains',
98
- commit,
99
- '--format=%(refname)',
100
- ])).sort();
101
- if (currentRef !== null && refs.includes(currentRef)) {
102
- return { owner_unavailable: true, owner_current_ref: true, owner_commit: commit };
103
- }
104
- const [ownerRef] = refs;
105
- return ownerRef ? { owner_ref: ownerRef, owner_commit: commit } : { owner_unavailable: true };
129
+ }
130
+
131
+ // The commits one revision range reaches that touch the item path, newest
132
+ // first. An unborn `HEAD` names no commit, so a worktree Git cannot resolve
133
+ // reaches nothing.
134
+ async function pathHistory(root, revisionRange, gitPath) {
135
+ try {
136
+ return gitLines(await gitText(root, ['rev-list', revisionRange, '--', gitPath]));
137
+ } catch {
138
+ return [];
106
139
  }
107
- return { owner_unavailable: true };
140
+ }
141
+
142
+ function byBranchThenPath(a, b) {
143
+ if (a.branch !== b.branch) return a.branch < b.branch ? -1 : 1;
144
+ return a.path < b.path ? -1 : 1;
108
145
  }
109
146
 
110
147
  async function gitText(cwd, argumentsList) {
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ // Git answers `GIT_DIR` and its siblings before it answers `cwd`, so an
7
+ // inherited value would report a repository the caller never named.
8
+ const GIT_ENVIRONMENT = Object.fromEntries(
9
+ Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
10
+ );
11
+
12
+ // Every worktree of one repository owns a private Git directory: the common
13
+ // directory itself for the main worktree, `<common>/worktrees/<name>` for a
14
+ // linked one. Anything a single worktree must not share with its siblings
15
+ // belongs there. Git resolves it; nothing here parses a `.git` file.
16
+ export async function resolvePrivateGitDir(directory) {
17
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--absolute-git-dir'], {
18
+ cwd: path.resolve(directory),
19
+ encoding: 'utf8',
20
+ env: GIT_ENVIRONMENT,
21
+ maxBuffer: 1024 * 1024,
22
+ });
23
+ return stdout.trim();
24
+ }
25
+
26
+ // The registered worktrees of one repository, as Git itself reports them.
27
+ // `--porcelain -z` terminates every attribute with NUL and separates records
28
+ // with one extra NUL, so a worktree path holding a newline still parses; the
29
+ // newline-delimited spelling cannot promise that. Git emits `worktree <path>`
30
+ // first, then `HEAD <oid>` and either `branch <ref>` or `detached`, plus the
31
+ // bare, locked, and prunable markers where they apply. An attribute before the
32
+ // first `worktree` line cannot exist, and one that does belongs to no record.
33
+ export async function listWorktrees(directory) {
34
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain', '-z'], {
35
+ cwd: path.resolve(directory),
36
+ encoding: 'utf8',
37
+ env: GIT_ENVIRONMENT,
38
+ maxBuffer: 16 * 1024 * 1024,
39
+ });
40
+ const worktrees = [];
41
+ let record = null;
42
+ for (const field of stdout.split('\0')) {
43
+ if (field === '') {
44
+ if (record) worktrees.push(record);
45
+ record = null;
46
+ continue;
47
+ }
48
+ const boundary = field.indexOf(' ');
49
+ const name = boundary === -1 ? field : field.slice(0, boundary);
50
+ const value = boundary === -1 ? '' : field.slice(boundary + 1);
51
+ if (name === 'worktree') {
52
+ record = {
53
+ path: value,
54
+ head: null,
55
+ branch: null,
56
+ detached: false,
57
+ bare: false,
58
+ locked: false,
59
+ prunable: false,
60
+ };
61
+ continue;
62
+ }
63
+ if (!record) continue;
64
+ if (name === 'HEAD') record.head = value;
65
+ else if (name === 'branch') record.branch = value;
66
+ else if (name === 'detached') record.detached = true;
67
+ else if (name === 'bare') record.bare = true;
68
+ else if (name === 'locked') record.locked = true;
69
+ else if (name === 'prunable') record.prunable = true;
70
+ }
71
+ if (record) worktrees.push(record);
72
+ return worktrees;
73
+ }
@@ -16,6 +16,7 @@ const COUNTER_NAMES = [
16
16
  'item_lock_fsyncs',
17
17
  'item_lock_releases',
18
18
  'namespace_lock_acquisitions',
19
+ 'worktree_identity_lock_acquisitions',
19
20
  'head_tree_entries',
20
21
  'head_blobs_read',
21
22
  'head_bytes_read',
@@ -0,0 +1,165 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { open, readFile, realpath, rename, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { withClaimLock } from './claim-store.js';
6
+ import { listWorktrees, resolvePrivateGitDir } from './git-worktrees.js';
7
+
8
+ const IDENTITY_FILE = 'wowbagger-worktree-id';
9
+ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
10
+
11
+ function invalidIdentity(reason) {
12
+ const error = new Error('worktree identity is invalid');
13
+ error.code = 'CLAIM_WORKTREE_IDENTITY_INVALID';
14
+ error.reason = reason;
15
+ return error;
16
+ }
17
+
18
+ export async function readWorktreeIdentity({ ledgerDirectory, gitCommonDir }) {
19
+ return readIdentityFile(await resolveIdentityPath(ledgerDirectory, gitCommonDir));
20
+ }
21
+
22
+ // Two live worktrees answering to one UUID make every writer attribution in
23
+ // the shared journal ambiguous, and an ambiguous writer is exactly what
24
+ // reconciliation reasons from. The caller runs this before it classifies or
25
+ // publishes anything, so an ambiguous domain refuses rather than guesses.
26
+ //
27
+ // Nothing here creates an identity file: a worktree that has never written one
28
+ // stays anonymous and holds no UUID to collide with.
29
+ export async function assertUniqueWorktreeIdentity({ ledgerDirectory }) {
30
+ const identities = await enumerateWorktreeIdentities(ledgerDirectory);
31
+ const holders = new Map();
32
+ for (const identity of identities) {
33
+ if (identity !== null) holders.set(identity, (holders.get(identity) ?? 0) + 1);
34
+ }
35
+ // Sorted so a domain holding more than one collision always names the same
36
+ // one, and the diagnostic an operator reads twice does not move.
37
+ const duplicate = [...holders]
38
+ .filter(([, count]) => count > 1)
39
+ .sort(([left], [right]) => (left < right ? -1 : 1))
40
+ .at(0);
41
+ if (!duplicate) return;
42
+ const error = invalidIdentity('duplicate-worktree-identity');
43
+ error.identityDiagnostic = {
44
+ code: 'duplicate-worktree-identity',
45
+ worktree_id: duplicate[0],
46
+ live_worktree_count: duplicate[1],
47
+ };
48
+ throw error;
49
+ }
50
+
51
+ // One entry per live registered worktree: its recorded UUID, or null when it
52
+ // has never written one.
53
+ //
54
+ // Evidence gathering fails closed, because a roster this function could not
55
+ // finish reading is not a roster without duplicates. An enumeration that never
56
+ // ran, a private Git directory that will not resolve, a path that vanished
57
+ // between the listing and the read, and identity bytes that are present but
58
+ // malformed all leave the roster incomplete, so all of them refuse the caller
59
+ // rather than shrink the evidence. Silently dropping any of them could hide
60
+ // the very duplicate this check exists to find.
61
+ //
62
+ // Only what Git itself has already disowned is excluded: a record Git marks
63
+ // prunable, and a bare repository, which has no checkout to hold an identity.
64
+ async function enumerateWorktreeIdentities(ledgerDirectory) {
65
+ try {
66
+ const roster = await listWorktrees(ledgerDirectory);
67
+ return await Promise.all(roster
68
+ .filter((worktree) => !(worktree.bare || worktree.prunable))
69
+ .map(async (worktree) => readIdentityFile(path.join(
70
+ await resolvePrivateGitDir(worktree.path), IDENTITY_FILE,
71
+ ))));
72
+ } catch {
73
+ const error = invalidIdentity('worktree-enumeration-failed');
74
+ // The roster is unknown, so the diagnostic names no UUID and no count.
75
+ error.identityDiagnostic = { code: 'worktree-enumeration-failed' };
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ // A duplicate refusal keeps its caller's existing outer code and reason, and
81
+ // adds only this diagnostic to the details a client already tolerates extra
82
+ // members in.
83
+ export function identityDiagnosticDetails(error) {
84
+ return error?.identityDiagnostic ? { identity_diagnostic: error.identityDiagnostic } : {};
85
+ }
86
+
87
+ // The identity file is created once and never rewritten: a worktree that
88
+ // already answers to an ID keeps it, because journal entries elsewhere already
89
+ // name it.
90
+ //
91
+ // Read and create run under a lock keyed on the identity path, which is the
92
+ // worktree itself. The namespace write lock cannot stand in for it: two ledger
93
+ // namespaces can share one worktree, so they hold different namespace locks
94
+ // while contending for one identity file. Without this lock both would observe
95
+ // no identity, both would create one, and the later rename would leave the
96
+ // earlier writer holding an ID the file no longer contains. The lock is a
97
+ // try-lock, so a losing writer is refused with `CLAIM_LOCK_HELD` and retries
98
+ // rather than publishing a second identity.
99
+ //
100
+ // Creation still writes a fresh file in the same directory, fsyncs it, and
101
+ // renames it over the final path, so a reader taking no lock at all sees
102
+ // either no file or one whole ID. The final path is never opened for writing,
103
+ // so a crash can never leave a truncated identity behind.
104
+ export async function ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir }) {
105
+ const identityPath = await resolveIdentityPath(ledgerDirectory, gitCommonDir);
106
+ return withClaimLock(identityPath, async () => {
107
+ const existing = await readIdentityFile(identityPath);
108
+ if (existing !== null) return existing;
109
+
110
+ const temporaryPath = path.join(
111
+ path.dirname(identityPath),
112
+ `.${IDENTITY_FILE}.${randomUUID()}`,
113
+ );
114
+ try {
115
+ const handle = await open(temporaryPath, 'wx', 0o600);
116
+ try {
117
+ await handle.writeFile(`${randomUUID()}\n`, 'utf8');
118
+ await handle.sync();
119
+ } finally {
120
+ await handle.close();
121
+ }
122
+ await rename(temporaryPath, identityPath);
123
+ } finally {
124
+ await rm(temporaryPath, { force: true });
125
+ }
126
+
127
+ // Read back rather than trust the value just written: the caller must act
128
+ // on the ID the next reader will see, not the one this process intended.
129
+ const written = await readIdentityFile(identityPath);
130
+ if (written === null) throw invalidIdentity('missing-after-write');
131
+ return written;
132
+ }, { counter: 'worktree_identity_lock_acquisitions' });
133
+ }
134
+
135
+ // The verified common directory is the caller's proof of which repository it
136
+ // is fencing. A private directory that is neither that directory nor one of
137
+ // its linked worktrees belongs to some other repository, and an identity
138
+ // written there would name the wrong writer.
139
+ async function resolveIdentityPath(ledgerDirectory, gitCommonDir) {
140
+ const privateGitDir = await resolvePrivateGitDir(ledgerDirectory);
141
+ const [privateReal, commonReal] = await Promise.all([
142
+ realpath(privateGitDir),
143
+ realpath(gitCommonDir),
144
+ ]);
145
+ const linked = path.join(commonReal, 'worktrees');
146
+ if (privateReal !== commonReal && path.dirname(privateReal) !== linked) {
147
+ throw invalidIdentity('private-git-dir-outside-common-dir');
148
+ }
149
+ return path.join(privateGitDir, IDENTITY_FILE);
150
+ }
151
+
152
+ async function readIdentityFile(identityPath) {
153
+ let text;
154
+ try {
155
+ text = await readFile(identityPath, 'utf8');
156
+ } catch (error) {
157
+ if (error?.code === 'ENOENT') return null;
158
+ throw error;
159
+ }
160
+ if (!text.endsWith('\n')) throw invalidIdentity('missing-terminator');
161
+ const identity = text.slice(0, -1);
162
+ // The anchored pattern rejects a second line as well as a malformed one.
163
+ if (!UUID_V4.test(identity)) throw invalidIdentity('malformed-uuid');
164
+ return identity;
165
+ }