wowbagger 0.1.0-alpha.9 → 0.5.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +509 -0
  2. package/README.md +272 -136
  3. package/docs/adapter-contract.md +1 -1
  4. package/docs/host-contract.md +7 -1
  5. package/docs/mutation-contract.md +354 -82
  6. package/docs/work-claim-contract.md +466 -88
  7. package/package.json +2 -2
  8. package/schemas/core-capabilities-response.json +1 -1
  9. package/schemas/core-envelope.json +4 -3
  10. package/schemas/index.json +18 -0
  11. package/schemas/ledger-repair-proposal.json +170 -0
  12. package/schemas/ledger-repair-request.json +61 -0
  13. package/schemas/ledger-repair-response.json +90 -0
  14. package/schemas/report-config-v1.json +4 -0
  15. package/schemas/report-config-v2.json +5 -0
  16. package/skills/wowbagger/SKILL.md +242 -59
  17. package/src/adapter/core-probe.js +3 -4
  18. package/src/adapter/process-outcome.js +8 -1
  19. package/src/claim-capabilities.js +3 -3
  20. package/src/claim-coordinator.js +61 -12
  21. package/src/claim-journal.js +212 -7
  22. package/src/claim-prospective.js +1 -28
  23. package/src/claim-publication.js +412 -78
  24. package/src/claim-request.js +9 -0
  25. package/src/claim-store.js +9 -4
  26. package/src/cli.js +302 -56
  27. package/src/extensions.js +1 -0
  28. package/src/git-autocommit.js +106 -43
  29. package/src/git-reconciliation.js +74 -19
  30. package/src/git-worktrees.js +73 -0
  31. package/src/instrumentation.js +1 -0
  32. package/src/launch.js +2 -2
  33. package/src/ledger-repair.js +1170 -0
  34. package/src/mutation.js +73 -15
  35. package/src/reconciliation-classifier.js +117 -0
  36. package/src/report-evidence.js +158 -41
  37. package/src/report-graph.js +201 -73
  38. package/src/report-html.js +358 -155
  39. package/src/report-impact.js +106 -0
  40. package/src/report-selection.js +97 -0
  41. package/src/report-sequencing.js +4 -4
  42. package/src/report-svg.js +74 -20
  43. package/src/report-view.js +14 -1
  44. package/src/report.js +109 -23
  45. package/src/version-drift.js +98 -0
  46. package/src/worktree-identity.js +165 -0
@@ -1,11 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { access, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
 
5
6
  import {
6
7
  appendClaimEntry,
8
+ assertClaimJournalCapacity,
7
9
  claimJournalPath,
8
10
  claimReconcileLogPath,
11
+ isClaimJournalCapacityError,
12
+ isProjectedJournalEntry,
13
+ parseReconcileLog,
14
+ replayClaimEntries,
9
15
  replayClaimJournal,
10
16
  writeReconcileLog,
11
17
  } from './claim-journal.js';
@@ -13,9 +19,21 @@ import { advanceClockFloor, readBack } from './claim-operations.js';
13
19
  import { claimStorePath, withClaimLock, writeClaimState } from './claim-store.js';
14
20
  import { loadLedger, parseLedgerItemSource } from './ledger.js';
15
21
  import { MAX_ITEM_SOURCE_BYTES } from './limits.js';
16
- import { findRevisionOwner, readGitHeadLedger } from './git-reconciliation.js';
22
+ import { findRevisionOwner, readGitHeadLedger, readGitTreeFile } from './git-reconciliation.js';
17
23
  import { publishClaimedCandidate, revisionFor } from './mutation.js';
24
+ import {
25
+ blocksTarget,
26
+ classifyReconciliation,
27
+ normalizeRevision,
28
+ requiresOwnerEvidence,
29
+ } from './reconciliation-classifier.js';
18
30
  import { validateLedger } from './validate.js';
31
+ import {
32
+ assertUniqueWorktreeIdentity,
33
+ ensureWorktreeIdentity,
34
+ identityDiagnosticDetails,
35
+ readWorktreeIdentity,
36
+ } from './worktree-identity.js';
19
37
 
20
38
  const ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
21
39
  const NAMESPACE_ID = /^wbns_[a-f0-9]{32}$/;
@@ -138,7 +156,11 @@ export async function readPublicationOutcome({ gitCommonDir, namespace, request
138
156
  } catch (error) {
139
157
  return publicationReadError(request, 'claim-store-unavailable',
140
158
  'The durable claim store is unavailable.', {
141
- reason: error?.code === 'CLAIM_LOCK_HELD' ? 'claim-store-locked' : 'claim-store-unreadable',
159
+ reason: error?.code === 'CLAIM_LOCK_HELD'
160
+ ? 'claim-store-locked'
161
+ : isClaimJournalCapacityError(error)
162
+ ? 'journal-capacity-exceeded'
163
+ : 'claim-store-unreadable',
142
164
  }, 6);
143
165
  }
144
166
  }
@@ -152,6 +174,7 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
152
174
  }
153
175
 
154
176
  const storePath = claimStorePath(gitCommonDir, namespace);
177
+ let intentAppended = false;
155
178
  const journalPath = claimJournalPath(gitCommonDir, namespace);
156
179
  try {
157
180
  return await withClaimLock(storePath, async (namespaceLock) => {
@@ -176,6 +199,15 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
176
199
  // file, so the first of them is the snapshot the rest share. The read
177
200
  // under lock, which the mutation engine still performs on its own, is
178
201
  // what makes the revision compare-and-swap meaningful.
202
+ //
203
+ // A claimed publication authorizes an item revision, so an ambiguous
204
+ // domain is refused before reconciliation classifies one. The writer
205
+ // names itself first, under this same hold, so a domain that cannot
206
+ // resolve its own identity refuses as its own invalid identity rather
207
+ // than as an unreadable roster — and so every entry this hold appends
208
+ // carries one ID resolved exactly once.
209
+ const currentWorktreeId = await ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir });
210
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
179
211
  let reconciled;
180
212
  try {
181
213
  reconciled = await reconcileClaimJournal({
@@ -185,8 +217,18 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
185
217
  replayed,
186
218
  physicalNow: new Date().toISOString(),
187
219
  targetItemId: request.item_id,
220
+ currentWorktreeId,
221
+ writeLogOnUnsafe: false,
188
222
  });
189
223
  } catch (error) {
224
+ if (isClaimJournalCapacityError(error)) {
225
+ return publicationError(request, 'claim-store-unavailable',
226
+ 'The durable claim store is unavailable.', {
227
+ reason: 'journal-capacity-exceeded',
228
+ ledger_namespace: request.ledger_namespace,
229
+ item_id: request.item_id,
230
+ }, 6);
231
+ }
190
232
  if (error?.code !== 'CLOCK_FLOOR_PERSISTENCE_FAILED') throw error;
191
233
  return publicationError(request, 'clock-floor-persistence-failed',
192
234
  'The authoritative clock floor could not be persisted.', {
@@ -233,10 +275,9 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
233
275
  active_owner_id: record.active?.owner_id ?? null,
234
276
  active_epoch: record.active?.epoch ?? null,
235
277
  }, 4);
236
- return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
278
+ return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
237
279
  }
238
- await publicationTestCheckpoint(scenario, 'before-publish-intent', ledgerDirectory);
239
- const intent = await appendClaimEntry(journalPath, {
280
+ const intentEntry = {
240
281
  type: 'publish-intent',
241
282
  operation_id: request.operation_id,
242
283
  operation_digest: operationDigest(request),
@@ -245,20 +286,55 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
245
286
  candidate_sha256: request.candidate_sha256,
246
287
  fence: request.claim_fence,
247
288
  floor: observedAt,
289
+ writer_worktree_id: currentWorktreeId,
248
290
  state: 'pending',
291
+ };
292
+ const terminalEntry = (outcome) => ({
293
+ type: 'publish-final',
294
+ operation_id: request.operation_id,
295
+ operation_digest: operationDigest(request),
296
+ ledger_namespace: request.ledger_namespace,
297
+ item_id: request.item_id,
298
+ writer_worktree_id: currentWorktreeId,
299
+ outcome,
249
300
  });
301
+ const itemPath = ledgerSnapshot.items.find((item) => item.data.id === request.item_id)?.path;
302
+ const possibleTerminals = [
303
+ terminalEntry(publicationSuccess(request, record, observedAt, itemPath, namespace)),
304
+ terminalEntry(publicationUnknown(request)),
305
+ terminalEntry(publicationError(request, 'ledger-revision-conflict',
306
+ 'The durable ledger revision no longer matches this publication.', {
307
+ ledger_namespace: request.ledger_namespace,
308
+ item_id: request.item_id,
309
+ expected_revision: request.expected_revision,
310
+ actual_revision: request.expected_revision,
311
+ }, 4)),
312
+ ];
313
+ const largestTerminal = possibleTerminals.reduce((largest, candidate) => (
314
+ Buffer.byteLength(JSON.stringify(candidate)) > Buffer.byteLength(JSON.stringify(largest))
315
+ ? candidate
316
+ : largest
317
+ ));
318
+ await assertClaimJournalCapacity(journalPath, [
319
+ intentEntry,
320
+ { type: 'clock', now: observedAt, floor: observedAt },
321
+ largestTerminal,
322
+ ]);
323
+ await publicationTestCheckpoint(scenario, 'before-publish-intent', ledgerDirectory);
324
+ const intent = await appendClaimEntry(journalPath, intentEntry);
250
325
  entries.push(intent);
326
+ intentAppended = true;
251
327
  await publicationTestCheckpoint(scenario, 'after-publish-intent', ledgerDirectory);
252
328
  const mutation = await publishClaimedCandidate({
253
329
  ledgerDirectory, request, scenario, ledgerSnapshot, namespaceLock, storePath,
254
330
  });
255
331
  if (!mutation.ok) {
256
332
  const outcome = mutationFailure(request, mutation);
257
- return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
333
+ return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
258
334
  }
259
335
  await publicationTestCheckpoint(scenario, 'after-ledger-commit', ledgerDirectory);
260
336
  const outcome = publicationSuccess(request, record, observedAt, mutation.item?.path, namespace);
261
- const terminal = await persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
337
+ const terminal = await persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
262
338
  await publicationTestCheckpoint(scenario, 'after-terminal-record', ledgerDirectory);
263
339
  return terminal;
264
340
  });
@@ -268,20 +344,132 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
268
344
  reason: 'claim-store-locked',
269
345
  }, 6);
270
346
  }
347
+ if (!intentAppended && isClaimJournalCapacityError(error)) {
348
+ return publicationError(request, 'claim-store-unavailable', 'The durable claim store is unavailable.', {
349
+ reason: 'journal-capacity-exceeded',
350
+ }, 6);
351
+ }
352
+ // An identity the coordination domain cannot resolve is a store this
353
+ // command could not read, not a mutation whose outcome is unknown: it
354
+ // refused before it appended an intent or touched an item byte.
355
+ if (error?.code === 'CLAIM_WORKTREE_IDENTITY_INVALID') {
356
+ return publicationError(request, 'claim-store-unavailable', 'The durable claim store is unavailable.', {
357
+ reason: 'claim-store-unreadable',
358
+ ...identityDiagnosticDetails(error),
359
+ }, 6);
360
+ }
361
+ if (!intentAppended) {
362
+ return publicationError(request, 'claim-store-unavailable', 'The durable claim store is unavailable.', {
363
+ reason: 'claim-store-unreadable',
364
+ }, 6);
365
+ }
271
366
  return publicationUnknown(request);
272
367
  }
273
368
  }
274
369
 
370
+ export async function hydrateClaimJournalFromHead({
371
+ ledgerDirectory,
372
+ gitCommonDir,
373
+ namespace,
374
+ replayed,
375
+ persist = true,
376
+ }) {
377
+ let log;
378
+ try {
379
+ log = await readGitTreeFile(
380
+ ledgerDirectory,
381
+ 'HEAD',
382
+ `.wowbagger/reconcile-${namespace}.md`,
383
+ );
384
+ } catch (error) {
385
+ if (error?.code === 128) return replayed;
386
+ throw error;
387
+ }
388
+ const committed = parseReconcileLog(log, namespace);
389
+ if (committed.error) throw hydrationError(committed.error.reason);
390
+ if (committed.length === 0) return replayed;
391
+ const bySequence = new Map();
392
+ for (const entry of committed) {
393
+ if (!Number.isSafeInteger(entry.seq) || entry.seq < 1 || bySequence.has(entry.seq)) {
394
+ throw hydrationError('non-contiguous-sequence');
395
+ }
396
+ bySequence.set(entry.seq, entry);
397
+ }
398
+ for (const entry of committed) {
399
+ if (entry.seq > replayed.entries.length) continue;
400
+ if (!isDeepStrictEqual(replayed.entries[entry.seq - 1], entry)) {
401
+ throw hydrationError('local-journal-diverges-from-committed-log');
402
+ }
403
+ }
404
+ const maxSequence = Math.max(...bySequence.keys());
405
+ if (replayed.entries.length >= maxSequence) return replayed;
406
+ const fallbackTime = committed
407
+ .map((entry) => entry.observed_at ?? entry.physical_now)
408
+ .find((value) => typeof value === 'string')
409
+ ?? '1970-01-01T00:00:00.000Z';
410
+ const missingEntries = [];
411
+ for (let sequence = replayed.entries.length + 1; sequence <= maxSequence; sequence += 1) {
412
+ const entry = bySequence.get(sequence);
413
+ if (entry) {
414
+ const { seq, ...withoutSequence } = entry;
415
+ missingEntries.push(withoutSequence);
416
+ } else {
417
+ missingEntries.push({
418
+ type: 'clock',
419
+ now: fallbackTime,
420
+ floor: fallbackTime,
421
+ });
422
+ }
423
+ }
424
+ if (!persist) {
425
+ const entries = [
426
+ ...replayed.entries,
427
+ ...missingEntries.map((entry, index) => ({
428
+ seq: replayed.entries.length + index + 1,
429
+ ...entry,
430
+ })),
431
+ ];
432
+ return {
433
+ state: replayClaimEntries(entries, namespace),
434
+ entries,
435
+ };
436
+ }
437
+ const journalPath = claimJournalPath(gitCommonDir, namespace);
438
+ for (const entry of missingEntries) {
439
+ await appendClaimEntry(journalPath, entry);
440
+ }
441
+ return replayClaimJournal(journalPath, namespace);
442
+ }
443
+
444
+ function hydrationError(reason) {
445
+ const error = new Error('The committed reconciliation log is invalid.');
446
+ error.code = 'CLAIM_JOURNAL_INVALID';
447
+ error.reason = reason;
448
+ return error;
449
+ }
450
+
275
451
  export async function reconcileClaimJournal({
276
452
  ledgerDirectory,
277
453
  gitCommonDir,
278
454
  namespace,
279
455
  replayed,
280
456
  physicalNow,
457
+ // The worktree this invocation speaks for, when it has established an
458
+ // identity. A caller that cannot name itself passes null, and every
459
+ // recorded writer then reads as unknown.
460
+ currentWorktreeId = null,
281
461
  targetItemId = null,
462
+ writeLogOnUnsafe = true,
463
+ writeLogWhenEmpty = true,
282
464
  }) {
283
465
  const storePath = claimStorePath(gitCommonDir, namespace);
284
466
  const journalPath = claimJournalPath(gitCommonDir, namespace);
467
+ replayed = await hydrateClaimJournalFromHead({
468
+ ledgerDirectory,
469
+ gitCommonDir,
470
+ namespace,
471
+ replayed,
472
+ });
285
473
  const ledger = await loadLedger(path.resolve(ledgerDirectory));
286
474
  const items = new Map(ledger.items.map((item) => [item.data.id, item]));
287
475
  const terminalKeys = new Set(replayed.entries
@@ -293,6 +481,16 @@ export async function reconcileClaimJournal({
293
481
  ));
294
482
  const entries = [...replayed.entries];
295
483
  const findings = [];
484
+ const findingScopes = [];
485
+ // What each finding refuses is the coordinator's own judgement, never a
486
+ // published member, so the scope rules on the write as the finding is
487
+ // recorded and never becomes a member something has to strip back out.
488
+ let unsafe = false;
489
+ const addFinding = (scope, finding) => {
490
+ unsafe ||= blocksTarget(scope, finding.item_id, targetItemId);
491
+ findings.push(finding);
492
+ findingScopes.push(scope);
493
+ };
296
494
  const observedAt = advanceClockFloor(replayed.state, physicalNow);
297
495
  try {
298
496
  entries.push(await appendClaimEntry(journalPath, {
@@ -327,6 +525,12 @@ export async function reconcileClaimJournal({
327
525
  command: intent.command,
328
526
  committed_revision: actualRevision,
329
527
  ...(intent.item_path ? { item_path: intent.item_path } : {}),
528
+ // The writer that authorized the attempt owns the revision this
529
+ // resolution ratifies, so its identity travels with the terminal
530
+ // entry. Absent on alpha.12 intents, which stay valid without it.
531
+ ...(intent.writer_worktree_id
532
+ ? { writer_worktree_id: intent.writer_worktree_id }
533
+ : {}),
330
534
  observed_at: observedAt,
331
535
  }));
332
536
  } else if (actualRevision === intent.expected_revision) {
@@ -335,6 +539,10 @@ export async function reconcileClaimJournal({
335
539
  attempt_id: intent.attempt_id,
336
540
  ledger_namespace: namespace,
337
541
  item_id: intent.item_id,
542
+ // A create abort names no predecessor revision, so its terminal must
543
+ // say which command aborted; patch and transition aborts keep their
544
+ // exact legacy shape and never carry a command.
545
+ ...(intent.command === 'create-v1' ? { command: intent.command } : {}),
338
546
  observed_revision: actualRevision,
339
547
  observed_at: observedAt,
340
548
  }));
@@ -343,7 +551,7 @@ export async function reconcileClaimJournal({
343
551
  ?? intent.item_path
344
552
  ?? null;
345
553
  const pathLabel = expectedPath ?? `item ${intent.item_id}`;
346
- findings.push({
554
+ addFinding('global', {
347
555
  code: 'legacy-mutation-outcome-unknown',
348
556
  item_id: intent.item_id,
349
557
  attempt_id: intent.attempt_id,
@@ -416,10 +624,17 @@ export async function reconcileClaimJournal({
416
624
  operation_digest: intent.operation_digest,
417
625
  ledger_namespace: namespace,
418
626
  item_id: intent.item_id,
627
+ // The writer that authorized the publication owns the revision this
628
+ // resolution ratifies, so its identity travels with the terminal entry.
629
+ // Absent on alpha.12 intents, which stay valid without it.
630
+ ...(intent.writer_worktree_id
631
+ ? { writer_worktree_id: intent.writer_worktree_id }
632
+ : {}),
419
633
  outcome,
420
634
  }));
421
635
  const unknownPath = itemPathRelativeToLedger(ledgerDirectory, item?.file);
422
- findings.push({
636
+ // A resolved intent is news, not a barrier: it refuses nothing.
637
+ addFinding(outcome.stdout.state === 'unknown' ? 'global' : 'none', {
423
638
  code: outcome.stdout.state === 'unknown'
424
639
  ? 'publication-outcome-unknown'
425
640
  : 'pending-intent-resolved',
@@ -484,7 +699,12 @@ export async function reconcileClaimJournal({
484
699
  const expectedRevision = authorizedRevisionOf(latestAuthorized);
485
700
  const authorizedRevisions = new Set(authorized.map(authorizedRevisionOf));
486
701
  for (const intent of entries) {
487
- if (intent.type === 'legacy-mutation-intent' && intent.item_id === itemId) {
702
+ // A create names no predecessor revision, so its `null` is the absence of
703
+ // one, never a ruling that absent bytes are authorized. Reading it as a
704
+ // revision would make an uncommitted create look finalized in Git.
705
+ if (intent.type === 'legacy-mutation-intent'
706
+ && intent.item_id === itemId
707
+ && intent.expected_revision !== null) {
488
708
  authorizedRevisions.add(intent.expected_revision);
489
709
  }
490
710
  }
@@ -509,7 +729,7 @@ export async function reconcileClaimJournal({
509
729
  );
510
730
  if (activeMismatch?.earlier) {
511
731
  const pathLabel = expectedPath ?? `item ${itemId}`;
512
- findings.push({
732
+ addFinding('global', {
513
733
  code: 'stale-write-detected',
514
734
  item_id: itemId,
515
735
  actual_revision: actualRevision,
@@ -528,21 +748,26 @@ export async function reconcileClaimJournal({
528
748
  });
529
749
  continue;
530
750
  }
751
+ const expectedWriter = expectedWriterOf(
752
+ latestAuthorized.writer_worktree_id ?? null,
753
+ currentWorktreeId,
754
+ );
531
755
  const diagnosis = await reconciliationDiagnosis({
532
756
  ledgerDirectory,
533
757
  actualRevision,
758
+ authorizedRevisions,
534
759
  expectedPath,
535
760
  expectedRevision,
536
761
  headRevision,
537
- workingTreeChanged,
762
+ expectedWriter,
538
763
  });
539
- findings.push({
764
+ addFinding(diagnosis.scope, {
540
765
  code: 'stale-write-detected',
541
766
  item_id: itemId,
542
767
  actual_revision: workingTreeChanged ? actualRevision : headRevision,
543
768
  expected_revision: expectedRevision,
544
769
  observed_surface: workingTreeChanged ? 'working-tree' : 'git-head',
545
- ...diagnosis,
770
+ ...diagnosis.finding,
546
771
  ...(record?.active ? {
547
772
  active_fence: {
548
773
  ledger_namespace: namespace,
@@ -575,7 +800,7 @@ export async function reconcileClaimJournal({
575
800
  ?? expected.item_path
576
801
  ?? null;
577
802
  const pathLabel = expectedPath ?? `item ${record.item_id}`;
578
- findings.push({
803
+ addFinding('global', {
579
804
  code: earlier ? 'stale-write-detected' : 'revision-regression',
580
805
  item_id: record.item_id,
581
806
  actual_revision: actualRevision,
@@ -599,11 +824,20 @@ export async function reconcileClaimJournal({
599
824
  });
600
825
  }
601
826
 
602
- await writeReconcileLog(
603
- claimReconcileLogPath(path.resolve(ledgerDirectory), namespace),
604
- namespace,
605
- entries,
606
- );
827
+ const logPath = claimReconcileLogPath(path.resolve(ledgerDirectory), namespace);
828
+ let logExists = true;
829
+ try {
830
+ await access(logPath);
831
+ } catch (error) {
832
+ if (error?.code !== 'ENOENT') throw error;
833
+ logExists = false;
834
+ }
835
+ // An unsafe reconciliation and a log with nothing to project both leave the
836
+ // working tree alone unless the caller asks for the write, so a refusal never
837
+ // has to answer for a tracked artifact it did not intend to change.
838
+ const writesLog = (writeLogWhenEmpty || logExists || entries.some(isProjectedJournalEntry))
839
+ && (writeLogOnUnsafe || !unsafe);
840
+ if (writesLog) await writeReconcileLog(logPath, namespace, entries);
607
841
  try {
608
842
  await writeClaimState(storePath, replayed.state);
609
843
  } catch {
@@ -612,8 +846,14 @@ export async function reconcileClaimJournal({
612
846
  return {
613
847
  entries,
614
848
  findings,
849
+ findingScopes,
615
850
  gitHead,
616
851
  headItems,
852
+ // Every coordinated item the journal knows and this working ledger does not
853
+ // hold. An item's number is immutable, so a caller that allocates the next
854
+ // one needs to know when an allocation exists that it cannot read; a stale
855
+ // revision of an item that is present hides no number.
856
+ missingCoordinatedItems: [...coordinatedItems].filter((itemId) => !items.has(itemId)),
617
857
  // The snapshot reconciliation judged. Reconciliation writes only the
618
858
  // journal, the claim state, and the ledger's `.wowbagger` reconcile log,
619
859
  // none of which a complete ledger load reads, so these are still the bytes
@@ -623,15 +863,12 @@ export async function reconcileClaimJournal({
623
863
  ledger,
624
864
  observedAt,
625
865
  state: replayed.state,
626
- unsafe: findings.some((finding) => (
627
- finding.code !== 'pending-intent-resolved' && blocksTarget(finding, targetItemId)
628
- )),
866
+ unsafe,
629
867
  };
630
868
  }
631
869
 
632
870
  // An item's authorized revision is whatever the journal last ruled legitimate:
633
871
  // a committed claimed publication, a legacy mutation, or an operator adoption.
634
- // Adoption is the only one of the three that changes no item byte.
635
872
  function authorizingEntries(entries, itemId) {
636
873
  return entries.filter((entry) => (
637
874
  entry.item_id === itemId
@@ -653,60 +890,110 @@ function itemPathRelativeToLedger(ledgerDirectory, file) {
653
890
  return path.relative(path.resolve(ledgerDirectory), file).split(path.sep).join('/');
654
891
  }
655
892
 
656
- // A mutation names the item it targets. Another item's unresolved publication
657
- // waits on a synchronization this mutation does not touch, so it reports as a
658
- // finding without refusing the write. A caller that names no target, such as
659
- // the `claim-verify` command, keeps every finding blocking.
660
- function blocksTarget(finding, targetItemId) {
661
- if (targetItemId === null || finding.item_id === targetItemId) return true;
662
- return finding.reason !== 'worktree-synchronization-required';
663
- }
664
-
665
893
  // Ownership is evidence, never a guess: an unreadable history or a missing
666
894
  // expected path leaves the revision unattributed instead of naming a ref.
667
895
  async function revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision) {
668
- if (!expectedPath) return { owner_unavailable: true };
896
+ if (!expectedPath) return { kind: 'unreachable' };
669
897
  try {
670
898
  return await findRevisionOwner(ledgerDirectory, expectedPath, expectedRevision);
671
899
  } catch {
672
- return { owner_unavailable: true };
900
+ return { kind: 'unreachable' };
901
+ }
902
+ }
903
+
904
+ // The classifier decides which topology this is; the sentences stay here.
905
+ // Member order is part of the published finding, so each remedy builds its own
906
+ // shape rather than sharing a base object.
907
+ function topologyFinding(decision, expectedPath, expectedRevision) {
908
+ const pathLabel = expectedPath ?? 'the item path';
909
+ const at = expectedPath ? { expected_path: expectedPath } : {};
910
+ switch (decision.remediation) {
911
+ case 'commit-in-git':
912
+ return {
913
+ reason: decision.reason,
914
+ ...at,
915
+ remediation: `Commit ${pathLabel} in Git, then run claim-verify.`,
916
+ };
917
+ case 'wait-for-named-owner':
918
+ return {
919
+ reason: decision.reason,
920
+ ...at,
921
+ owner_ref: decision.owner.ref,
922
+ owner_commit: decision.owner.commit,
923
+ remediation: `WAIT for owner ${decision.owner.ref} to publish ${decision.owner.commit}, then synchronize this worktree and run claim-verify.`,
924
+ };
925
+ case 'establish-ownership':
926
+ return {
927
+ reason: decision.reason,
928
+ ...at,
929
+ owner_unavailable: true,
930
+ 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.`,
931
+ };
932
+ // Reachable but unowned: the revision is in Git already, so the only honest
933
+ // instruction is to go read it. Naming an owner to wait for would be a wait
934
+ // with no end, which is what item #178 found in the field.
935
+ case 'inspect-reachable-history':
936
+ return {
937
+ reason: decision.reason,
938
+ ...at,
939
+ owner_unavailable: true,
940
+ remediation: `Revision ${expectedRevision} of ${pathLabel} is reachable in Git, but no active named worktree owner is established; inspect the reachable history, restore or explicitly adopt reviewed bytes, then run claim-verify.`,
941
+ };
942
+ case 'await-owner-commit':
943
+ return {
944
+ reason: decision.reason,
945
+ ...at,
946
+ owner_unavailable: true,
947
+ remediation: `Ownership of ${pathLabel} revision ${expectedRevision} is not yet reachable; wait for the owning worktree to commit, then synchronize this worktree and run claim-verify.`,
948
+ };
949
+ // Two remedies, both named, in the order that makes the cost obvious. The
950
+ // field report behind item #113 read the single restore sentence as the
951
+ // only way out and discarded reviewed, merged work to obey it.
952
+ case 'restore-or-adopt':
953
+ return {
954
+ reason: decision.reason,
955
+ ...at,
956
+ remediation: `Restore the authorized revision at ${pathLabel}, then run claim-verify; that discards the edit. Or adopt the committed revision of ${pathLabel} with claim-adopt, then run claim-verify; that keeps the edit.`,
957
+ };
958
+ default:
959
+ throw new Error(`The reconciliation topology named no remedy: ${decision.remediation}.`);
673
960
  }
674
961
  }
675
962
 
963
+ // Who the journal says wrote the authorized revision, judged against who is
964
+ // asking. An entry from before writer identity existed, or a caller that
965
+ // cannot name itself, leaves the writer unknown.
966
+ function expectedWriterOf(recordedWriter, currentWorktreeId) {
967
+ if (recordedWriter === null || currentWorktreeId === null) return 'unknown';
968
+ return recordedWriter === currentWorktreeId ? 'current' : 'other';
969
+ }
970
+
971
+ // Evidence in, scope and public finding out: the classifier rules on the
972
+ // topology, and this gathers exactly the evidence it rules on, then renders
973
+ // the sentence it prescribes.
676
974
  async function reconciliationDiagnosis({
677
975
  ledgerDirectory,
678
976
  actualRevision,
977
+ authorizedRevisions,
679
978
  expectedPath,
680
979
  expectedRevision,
681
980
  headRevision,
682
- workingTreeChanged,
981
+ expectedWriter,
683
982
  }) {
684
- const pathLabel = expectedPath ?? 'the item path';
685
- if (!workingTreeChanged && headRevision !== expectedRevision) {
686
- return {
687
- reason: 'git-finalization-required',
688
- ...(expectedPath ? { expected_path: expectedPath } : {}),
689
- remediation: `Commit ${pathLabel} in Git, then run claim-verify.`,
690
- };
691
- }
692
- if (actualRevision === null && headRevision !== expectedRevision) {
693
- const owner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
694
- return {
695
- reason: 'worktree-synchronization-required',
696
- ...(expectedPath ? { expected_path: expectedPath } : {}),
697
- ...owner,
698
- remediation: owner.owner_ref
699
- ? `WAIT for owner ${owner.owner_ref} to publish ${owner.owner_commit}, then synchronize this worktree and run claim-verify.`
700
- : `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.`,
701
- };
702
- }
703
- // Two remedies, both named, in the order that makes the cost obvious. The
704
- // field report behind item #113 read the single restore sentence as the only
705
- // way out and discarded reviewed, merged work to obey it.
983
+ const revisions = {
984
+ workingTree: normalizeRevision(actualRevision, expectedRevision, authorizedRevisions),
985
+ head: normalizeRevision(headRevision, expectedRevision, authorizedRevisions),
986
+ };
987
+ const decision = classifyReconciliation({
988
+ ...revisions,
989
+ expectedOwner: requiresOwnerEvidence(revisions)
990
+ ? await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision)
991
+ : null,
992
+ expectedWriter,
993
+ });
706
994
  return {
707
- reason: 'unauthorized-revision',
708
- ...(expectedPath ? { expected_path: expectedPath } : {}),
709
- remediation: `Restore the authorized revision at ${pathLabel}, then run claim-verify; that discards the edit. Or adopt the committed revision of ${pathLabel} with claim-adopt, then run claim-verify; that keeps the edit.`,
995
+ scope: decision.scope,
996
+ finding: topologyFinding(decision, expectedPath, expectedRevision),
710
997
  };
711
998
  }
712
999
 
@@ -771,18 +1058,32 @@ export async function verifyClaimJournal({
771
1058
  gitCommonDir,
772
1059
  namespace,
773
1060
  targetItemId = null,
1061
+ writeLogOnUnsafe = true,
1062
+ writeLogWhenEmpty = true,
774
1063
  }) {
775
1064
  const storePath = claimStorePath(gitCommonDir, namespace);
776
1065
  const journalPath = claimJournalPath(gitCommonDir, namespace);
777
1066
  try {
778
1067
  return await withClaimLock(storePath, async () => {
1068
+ // Verification writes no item byte and creates no identity: it reports on
1069
+ // the worktree it runs in, and an unidentified worktree simply cannot
1070
+ // recognize itself in a recorded writer. Its own file is judged first, so
1071
+ // bytes this worktree owns keep reporting as its own invalid identity
1072
+ // rather than as an unreadable roster.
1073
+ const currentWorktreeId = await readWorktreeIdentity({ ledgerDirectory, gitCommonDir });
1074
+ // Verification reasons from recorded writers, so it refuses an ambiguous
1075
+ // domain before it classifies anything, exactly as a mutation does.
1076
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
779
1077
  const reconciled = await reconcileClaimJournal({
780
1078
  ledgerDirectory,
781
1079
  gitCommonDir,
782
1080
  namespace,
783
1081
  replayed: await replayClaimJournal(journalPath, namespace),
784
1082
  physicalNow: new Date().toISOString(),
1083
+ currentWorktreeId,
785
1084
  targetItemId,
1085
+ writeLogWhenEmpty,
1086
+ writeLogOnUnsafe,
786
1087
  });
787
1088
  return {
788
1089
  exit: reconciled.unsafe ? 6 : 0,
@@ -795,7 +1096,17 @@ export async function verifyClaimJournal({
795
1096
  result: {
796
1097
  ledger_namespace: namespace,
797
1098
  observed_at: reconciled.observedAt,
798
- findings: reconciled.findings,
1099
+ verification_scope: targetItemId === null
1100
+ ? { mode: 'repository' }
1101
+ : { mode: 'target-item', item_id: targetItemId },
1102
+ findings: reconciled.findings.map((finding, index) => ({
1103
+ ...finding,
1104
+ blocks_verification_scope: blocksTarget(
1105
+ reconciled.findingScopes[index],
1106
+ finding.item_id,
1107
+ targetItemId,
1108
+ ),
1109
+ })),
799
1110
  ledger_validation: ledgerValidationReport(reconciled.ledger),
800
1111
  publications: publicationStatuses(reconciled.entries),
801
1112
  },
@@ -817,7 +1128,10 @@ export async function verifyClaimJournal({
817
1128
  details: {
818
1129
  reason: error?.code === 'CLAIM_LOCK_HELD'
819
1130
  ? 'claim-store-locked'
820
- : 'claim-store-unreadable',
1131
+ : isClaimJournalCapacityError(error)
1132
+ ? 'journal-capacity-exceeded'
1133
+ : 'claim-store-unreadable',
1134
+ ...identityDiagnosticDetails(error),
821
1135
  },
822
1136
  },
823
1137
  },
@@ -829,20 +1143,30 @@ export async function verifyClaimJournal({
829
1143
  // re-baselines the coordinator's authorized revision onto bytes that are
830
1144
  // already committed, and writes no item byte. It runs while reconciliation is
831
1145
  // unsafe on purpose — clearing that state is the point — so it never refuses on
832
- // `publication-reconciliation-required` the way a claim lifecycle operation
833
- // does. Every precondition is checked under the claim lock against the state
834
- // reconciliation just observed.
1146
+ // `publication-reconciliation-required` the way `claim acquire` or
1147
+ // `claim renew` do. Every precondition is checked under the claim lock against
1148
+ // the state reconciliation just observed.
835
1149
  export async function adoptItemRevision({ ledgerDirectory, gitCommonDir, namespace, request }) {
836
1150
  const storePath = claimStorePath(gitCommonDir, namespace);
837
1151
  const journalPath = claimJournalPath(gitCommonDir, namespace);
838
1152
  try {
839
1153
  return await withClaimLock(storePath, async () => {
1154
+ // Adoption writes no item byte and creates no identity, so it names
1155
+ // itself by reading the ID it already answers to. Its own file is judged
1156
+ // first, so bytes this worktree owns keep reporting as its own invalid
1157
+ // identity rather than as an unreadable roster, exactly as claim-verify
1158
+ // reports them.
1159
+ const currentWorktreeId = await readWorktreeIdentity({ ledgerDirectory, gitCommonDir });
1160
+ // Adoption re-baselines an authorized revision, so it too refuses an
1161
+ // ambiguous domain before reconciliation classifies one.
1162
+ await assertUniqueWorktreeIdentity({ ledgerDirectory });
840
1163
  const reconciled = await reconcileClaimJournal({
841
1164
  ledgerDirectory,
842
1165
  gitCommonDir,
843
1166
  namespace,
844
1167
  replayed: await replayClaimJournal(journalPath, namespace),
845
1168
  physicalNow: new Date().toISOString(),
1169
+ currentWorktreeId,
846
1170
  });
847
1171
  const refusal = adoptionRefusal(reconciled, ledgerDirectory, request);
848
1172
  if (refusal) return refusal;
@@ -901,17 +1225,26 @@ export async function adoptItemRevision({ ledgerDirectory, gitCommonDir, namespa
901
1225
  contract_version: 1,
902
1226
  state: 'unchanged',
903
1227
  error: {
904
- code: error?.code === 'CLOCK_FLOOR_PERSISTENCE_FAILED'
905
- ? 'clock-floor-persistence-failed'
906
- : 'claim-store-unavailable',
907
- message: error?.code === 'CLOCK_FLOOR_PERSISTENCE_FAILED'
908
- ? 'The authoritative clock floor could not be persisted.'
909
- : 'The durable claim store is unavailable.',
910
- details: error?.code === 'CLOCK_FLOOR_PERSISTENCE_FAILED' ? {} : {
911
- reason: error?.code === 'CLAIM_LOCK_HELD'
912
- ? 'claim-store-locked'
913
- : 'claim-store-unreadable',
914
- },
1228
+ code: isClaimJournalCapacityError(error)
1229
+ ? 'claim-store-unavailable'
1230
+ : error?.code === 'CLOCK_FLOOR_PERSISTENCE_FAILED'
1231
+ ? 'clock-floor-persistence-failed'
1232
+ : 'claim-store-unavailable',
1233
+ message: isClaimJournalCapacityError(error)
1234
+ ? 'The durable claim store is unavailable.'
1235
+ : error?.code === 'CLOCK_FLOOR_PERSISTENCE_FAILED'
1236
+ ? 'The authoritative clock floor could not be persisted.'
1237
+ : 'The durable claim store is unavailable.',
1238
+ details: isClaimJournalCapacityError(error)
1239
+ ? { reason: 'journal-capacity-exceeded' }
1240
+ : error?.code === 'CLOCK_FLOOR_PERSISTENCE_FAILED'
1241
+ ? {}
1242
+ : {
1243
+ reason: error?.code === 'CLAIM_LOCK_HELD'
1244
+ ? 'claim-store-locked'
1245
+ : 'claim-store-unreadable',
1246
+ ...identityDiagnosticDetails(error),
1247
+ },
915
1248
  },
916
1249
  },
917
1250
  };
@@ -1017,13 +1350,14 @@ export function operationDigest(request) {
1017
1350
  return `sha256:${createHash('sha256').update(canonicalJson(request)).digest('hex')}`;
1018
1351
  }
1019
1352
 
1020
- async function persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, state, storePath) {
1353
+ async function persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, state, storePath, writerWorktreeId) {
1021
1354
  const terminal = await appendClaimEntry(journalPath, {
1022
1355
  type: 'publish-final',
1023
1356
  operation_id: request.operation_id,
1024
1357
  operation_digest: operationDigest(request),
1025
1358
  ledger_namespace: request.ledger_namespace,
1026
1359
  item_id: request.item_id,
1360
+ writer_worktree_id: writerWorktreeId,
1027
1361
  outcome,
1028
1362
  });
1029
1363
  entries.push(terminal);