wowbagger 0.1.0-alpha.13 → 0.1.0-alpha.17

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.
@@ -2,6 +2,7 @@ import { constants } from 'node:fs';
2
2
  import { lstat, mkdir, open, readFile, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { validateClaimRequest } from './claim-request.js';
5
+ import { isCalendarDate } from './validate.js';
5
6
 
6
7
  import { advanceClockFloor, claimAcquire, claimRelease, claimRenew } from './claim-operations.js';
7
8
  import { emptyClaimState } from './claim-store.js';
@@ -19,7 +20,11 @@ const JOURNAL_ENTRY_TYPES = new Set([
19
20
  'publish-finalization',
20
21
  'publish-intent',
21
22
  'revision-adoption',
23
+ 'number-repair-intent',
24
+ 'number-repair-final',
22
25
  ]);
26
+ // Every command the legacy mutation fence journals.
27
+ const LEGACY_MUTATION_COMMANDS = new Set(['patch-v1', 'transition-v1', 'create-v1']);
23
28
  // The reconciliation log is a tracked derived artifact. Entry types that every
24
29
  // invocation appends, including refusals and read-only verification, stay out
25
30
  // of it so a mutation that changes nothing leaves the working tree unchanged.
@@ -177,6 +182,9 @@ async function readJournalEntries(journalPath, namespace = null) {
177
182
  const lines = source.split('\n').filter(Boolean);
178
183
  if (lines.length > MAX_JOURNAL_ENTRIES) throw journalCapacityExceeded();
179
184
  const entries = lines.map((line) => JSON.parse(line));
185
+ const createAttempts = new Map();
186
+ const repairIntents = new Map();
187
+ const repairFinals = new Set();
180
188
  for (let index = 0; index < entries.length; index += 1) {
181
189
  if (!Number.isSafeInteger(entries[index]?.seq) || entries[index].seq !== index + 1) {
182
190
  throw journalInvalid('non-contiguous-sequence');
@@ -187,6 +195,12 @@ async function readJournalEntries(journalPath, namespace = null) {
187
195
  if (!validJournalEntry(entries[index], namespace)) {
188
196
  throw journalInvalid('invalid-entry');
189
197
  }
198
+ if (!validCreateResolution(entries[index], createAttempts)) {
199
+ throw journalInvalid('invalid-entry');
200
+ }
201
+ if (!validRepairResolution(entries[index], repairIntents, repairFinals)) {
202
+ throw journalInvalid('invalid-entry');
203
+ }
190
204
  }
191
205
  return entries;
192
206
  }
@@ -282,8 +296,10 @@ function validJournalEntry(entry, namespace) {
282
296
  && typeof entry.ledger_namespace === 'string'
283
297
  && (namespace === null || entry.ledger_namespace === namespace)
284
298
  && typeof entry.item_id === 'string'
285
- && ['patch-v1', 'transition-v1'].includes(entry.command)
286
- && typeof entry.expected_revision === 'string'
299
+ && LEGACY_MUTATION_COMMANDS.has(entry.command)
300
+ && (entry.command === 'create-v1'
301
+ ? entry.expected_revision === null
302
+ : typeof entry.expected_revision === 'string')
287
303
  && typeof entry.candidate_revision === 'string'
288
304
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
289
305
  && (!Object.hasOwn(entry, 'writer_worktree_id')
@@ -295,7 +311,7 @@ function validJournalEntry(entry, namespace) {
295
311
  && typeof entry.ledger_namespace === 'string'
296
312
  && (namespace === null || entry.ledger_namespace === namespace)
297
313
  && typeof entry.item_id === 'string'
298
- && ['patch-v1', 'transition-v1'].includes(entry.command)
314
+ && LEGACY_MUTATION_COMMANDS.has(entry.command)
299
315
  && typeof entry.committed_revision === 'string'
300
316
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
301
317
  && (!Object.hasOwn(entry, 'writer_worktree_id')
@@ -303,11 +319,19 @@ function validJournalEntry(entry, namespace) {
303
319
  && typeof entry.observed_at === 'string';
304
320
  }
305
321
  if (entry.type === 'legacy-mutation-abort') {
322
+ // A create abort names no predecessor revision, so the absence of one is
323
+ // its evidence. Patch and transition aborts keep their exact legacy shape
324
+ // and never carry a command.
325
+ const validAbortRevision = (
326
+ !Object.hasOwn(entry, 'command') && typeof entry.observed_revision === 'string'
327
+ ) || (
328
+ entry.command === 'create-v1' && entry.observed_revision === null
329
+ );
306
330
  return typeof entry.attempt_id === 'string'
307
331
  && typeof entry.ledger_namespace === 'string'
308
332
  && (namespace === null || entry.ledger_namespace === namespace)
309
333
  && typeof entry.item_id === 'string'
310
- && typeof entry.observed_revision === 'string'
334
+ && validAbortRevision
311
335
  && typeof entry.observed_at === 'string';
312
336
  }
313
337
  if (entry.type === 'revision-adoption') {
@@ -322,6 +346,12 @@ function validJournalEntry(entry, namespace) {
322
346
  && typeof entry.git_commit === 'string'
323
347
  && (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string');
324
348
  }
349
+ if (entry.type === 'number-repair-intent') {
350
+ return validRepairIntent(entry, namespace);
351
+ }
352
+ if (entry.type === 'number-repair-final') {
353
+ return validRepairFinal(entry, namespace);
354
+ }
325
355
  if (entry.type === 'publish-intent') {
326
356
  return typeof entry.operation_id === 'string'
327
357
  && typeof entry.operation_digest === 'string'
@@ -351,6 +381,97 @@ function validJournalEntry(entry, namespace) {
351
381
  && typeof entry.git_commit === 'string';
352
382
  }
353
383
 
384
+ const REPAIR_ID = /^nr_\d{8}_\d{4}$/;
385
+ const REPAIR_ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
386
+ const REPAIR_REVISION = /^sha256:[0-9a-f]{64}$/;
387
+ const REPAIR_STAGING_ID = /^[A-Za-z0-9_-]{1,80}$/;
388
+
389
+ function validRepairIntent(entry, namespace) {
390
+ return REPAIR_ID.test(entry.repair_id)
391
+ && typeof entry.ledger_namespace === 'string'
392
+ && (namespace === null || entry.ledger_namespace === namespace)
393
+ && REPAIR_REVISION.test(entry.ledger_snapshot_revision)
394
+ && isCalendarDate(entry.date)
395
+ && REPAIR_STAGING_ID.test(entry.staging_id)
396
+ && validRepairItems(entry.items, { includeExpected: true });
397
+ }
398
+
399
+ function validRepairFinal(entry, namespace) {
400
+ return REPAIR_ID.test(entry.repair_id)
401
+ && typeof entry.ledger_namespace === 'string'
402
+ && (namespace === null || entry.ledger_namespace === namespace)
403
+ && REPAIR_STAGING_ID.test(entry.staging_id)
404
+ && typeof entry.observed_at === 'string'
405
+ && validRepairItems(entry.items, { includeCommitted: true });
406
+ }
407
+
408
+ function validRepairItems(items, options) {
409
+ if (!Array.isArray(items) || items.length === 0) return false;
410
+ const seen = new Set();
411
+ for (const item of items) {
412
+ if (!isRecord(item)
413
+ || !REPAIR_ITEM_ID.test(item.item_id)
414
+ || seen.has(item.item_id)
415
+ || typeof item.item_path !== 'string'
416
+ || !REPAIR_REVISION.test(item.candidate_revision)) {
417
+ return false;
418
+ }
419
+ if (options.includeExpected
420
+ && (!REPAIR_REVISION.test(item.expected_revision)
421
+ || !Number.isSafeInteger(item.expected_number)
422
+ || item.expected_number < 1
423
+ || !Number.isSafeInteger(item.replacement_number)
424
+ || item.replacement_number < 1)) {
425
+ return false;
426
+ }
427
+ if (options.includeCommitted && !REPAIR_REVISION.test(item.committed_revision)) return false;
428
+ seen.add(item.item_id);
429
+ }
430
+ return true;
431
+ }
432
+
433
+ function validRepairResolution(entry, intents, finals) {
434
+ if (entry.type === 'number-repair-intent') {
435
+ if (intents.has(entry.repair_id)) return false;
436
+ intents.set(entry.repair_id, entry);
437
+ return true;
438
+ }
439
+ if (entry.type !== 'number-repair-final') return true;
440
+ const intent = intents.get(entry.repair_id);
441
+ if (!intent || finals.has(entry.repair_id) || intent.staging_id !== entry.staging_id) return false;
442
+ const expected = new Map(intent.items.map((item) => [item.item_id, item.candidate_revision]));
443
+ if (entry.items.length !== expected.size) return false;
444
+ for (const item of entry.items) {
445
+ if (expected.get(item.item_id) !== item.candidate_revision) return false;
446
+ }
447
+ finals.add(entry.repair_id);
448
+ return true;
449
+ }
450
+
451
+ // A journal-fenced create is only meaningful as a pair: the terminal that ends
452
+ // an attempt must name the intent that opened it, so a standalone create
453
+ // terminal names no authorized attempt and fails closed. Patch and transition
454
+ // entries remain valid standing alone, so only create-v1 attempts are tracked.
455
+ function validCreateResolution(entry, attempts) {
456
+ if (entry.command !== 'create-v1') return true;
457
+ if (entry.type === 'legacy-mutation-intent') {
458
+ attempts.set(entry.attempt_id, entry);
459
+ return true;
460
+ }
461
+ if (entry.type !== 'legacy-mutation' && entry.type !== 'legacy-mutation-abort') return true;
462
+ const intent = attempts.get(entry.attempt_id);
463
+ if (intent === undefined || intent.item_id !== entry.item_id) return false;
464
+ // A committed create publishes exactly the revision its intent proposed; an
465
+ // aborted one publishes none.
466
+ if (entry.type === 'legacy-mutation' && intent.candidate_revision !== entry.committed_revision) {
467
+ return false;
468
+ }
469
+ // One attempt resolves once, so the intent is consumed here and a repeated
470
+ // resolution finds no open attempt to end.
471
+ attempts.delete(entry.attempt_id);
472
+ return true;
473
+ }
474
+
354
475
  function isRecord(value) {
355
476
  return value !== null && typeof value === 'object' && !Array.isArray(value);
356
477
  }
@@ -18,6 +18,12 @@ import { loadLedger, parseLedgerItemSource } from './ledger.js';
18
18
  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
+ import {
22
+ blocksTarget,
23
+ classifyReconciliation,
24
+ normalizeRevision,
25
+ requiresOwnerEvidence,
26
+ } from './reconciliation-classifier.js';
21
27
  import { validateLedger } from './validate.js';
22
28
  import {
23
29
  assertUniqueWorktreeIdentity,
@@ -403,6 +409,14 @@ export async function reconcileClaimJournal({
403
409
  ));
404
410
  const entries = [...replayed.entries];
405
411
  const findings = [];
412
+ // What each finding refuses is the coordinator's own judgement, never a
413
+ // published member, so the scope rules on the write as the finding is
414
+ // recorded and never becomes a member something has to strip back out.
415
+ let unsafe = false;
416
+ const addFinding = (scope, finding) => {
417
+ unsafe ||= blocksTarget(scope, finding.item_id, targetItemId);
418
+ findings.push(finding);
419
+ };
406
420
  const observedAt = advanceClockFloor(replayed.state, physicalNow);
407
421
  try {
408
422
  entries.push(await appendClaimEntry(journalPath, {
@@ -451,6 +465,10 @@ export async function reconcileClaimJournal({
451
465
  attempt_id: intent.attempt_id,
452
466
  ledger_namespace: namespace,
453
467
  item_id: intent.item_id,
468
+ // A create abort names no predecessor revision, so its terminal must
469
+ // say which command aborted; patch and transition aborts keep their
470
+ // exact legacy shape and never carry a command.
471
+ ...(intent.command === 'create-v1' ? { command: intent.command } : {}),
454
472
  observed_revision: actualRevision,
455
473
  observed_at: observedAt,
456
474
  }));
@@ -459,7 +477,7 @@ export async function reconcileClaimJournal({
459
477
  ?? intent.item_path
460
478
  ?? null;
461
479
  const pathLabel = expectedPath ?? `item ${intent.item_id}`;
462
- findings.push({
480
+ addFinding('global', {
463
481
  code: 'legacy-mutation-outcome-unknown',
464
482
  item_id: intent.item_id,
465
483
  attempt_id: intent.attempt_id,
@@ -541,7 +559,8 @@ export async function reconcileClaimJournal({
541
559
  outcome,
542
560
  }));
543
561
  const unknownPath = itemPathRelativeToLedger(ledgerDirectory, item?.file);
544
- findings.push({
562
+ // A resolved intent is news, not a barrier: it refuses nothing.
563
+ addFinding(outcome.stdout.state === 'unknown' ? 'global' : 'none', {
545
564
  code: outcome.stdout.state === 'unknown'
546
565
  ? 'publication-outcome-unknown'
547
566
  : 'pending-intent-resolved',
@@ -606,7 +625,12 @@ export async function reconcileClaimJournal({
606
625
  const expectedRevision = authorizedRevisionOf(latestAuthorized);
607
626
  const authorizedRevisions = new Set(authorized.map(authorizedRevisionOf));
608
627
  for (const intent of entries) {
609
- if (intent.type === 'legacy-mutation-intent' && intent.item_id === itemId) {
628
+ // A create names no predecessor revision, so its `null` is the absence of
629
+ // one, never a ruling that absent bytes are authorized. Reading it as a
630
+ // revision would make an uncommitted create look finalized in Git.
631
+ if (intent.type === 'legacy-mutation-intent'
632
+ && intent.item_id === itemId
633
+ && intent.expected_revision !== null) {
610
634
  authorizedRevisions.add(intent.expected_revision);
611
635
  }
612
636
  }
@@ -631,7 +655,7 @@ export async function reconcileClaimJournal({
631
655
  );
632
656
  if (activeMismatch?.earlier) {
633
657
  const pathLabel = expectedPath ?? `item ${itemId}`;
634
- findings.push({
658
+ addFinding('global', {
635
659
  code: 'stale-write-detected',
636
660
  item_id: itemId,
637
661
  actual_revision: actualRevision,
@@ -650,13 +674,10 @@ export async function reconcileClaimJournal({
650
674
  });
651
675
  continue;
652
676
  }
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';
677
+ const expectedWriter = expectedWriterOf(
678
+ latestAuthorized.writer_worktree_id ?? null,
679
+ currentWorktreeId,
680
+ );
660
681
  const diagnosis = await reconciliationDiagnosis({
661
682
  ledgerDirectory,
662
683
  actualRevision,
@@ -665,15 +686,14 @@ export async function reconcileClaimJournal({
665
686
  expectedRevision,
666
687
  headRevision,
667
688
  expectedWriter,
668
- workingTreeChanged,
669
689
  });
670
- findings.push({
690
+ addFinding(diagnosis.scope, {
671
691
  code: 'stale-write-detected',
672
692
  item_id: itemId,
673
693
  actual_revision: workingTreeChanged ? actualRevision : headRevision,
674
694
  expected_revision: expectedRevision,
675
695
  observed_surface: workingTreeChanged ? 'working-tree' : 'git-head',
676
- ...diagnosis,
696
+ ...diagnosis.finding,
677
697
  ...(record?.active ? {
678
698
  active_fence: {
679
699
  ledger_namespace: namespace,
@@ -706,7 +726,7 @@ export async function reconcileClaimJournal({
706
726
  ?? expected.item_path
707
727
  ?? null;
708
728
  const pathLabel = expectedPath ?? `item ${record.item_id}`;
709
- findings.push({
729
+ addFinding('global', {
710
730
  code: earlier ? 'stale-write-detected' : 'revision-regression',
711
731
  item_id: record.item_id,
712
732
  actual_revision: actualRevision,
@@ -730,9 +750,6 @@ export async function reconcileClaimJournal({
730
750
  });
731
751
  }
732
752
 
733
- const unsafe = findings.some((finding) => (
734
- finding.code !== 'pending-intent-resolved' && blocksTarget(finding, targetItemId)
735
- ));
736
753
  const logPath = claimReconcileLogPath(path.resolve(ledgerDirectory), namespace);
737
754
  let logExists = true;
738
755
  try {
@@ -757,6 +774,11 @@ export async function reconcileClaimJournal({
757
774
  findings,
758
775
  gitHead,
759
776
  headItems,
777
+ // Every coordinated item the journal knows and this working ledger does not
778
+ // hold. An item's number is immutable, so a caller that allocates the next
779
+ // one needs to know when an allocation exists that it cannot read; a stale
780
+ // revision of an item that is present hides no number.
781
+ missingCoordinatedItems: [...coordinatedItems].filter((itemId) => !items.has(itemId)),
760
782
  // The snapshot reconciliation judged. Reconciliation writes only the
761
783
  // journal, the claim state, and the ledger's `.wowbagger` reconcile log,
762
784
  // none of which a complete ledger load reads, so these are still the bytes
@@ -793,15 +815,6 @@ function itemPathRelativeToLedger(ledgerDirectory, file) {
793
815
  return path.relative(path.resolve(ledgerDirectory), file).split(path.sep).join('/');
794
816
  }
795
817
 
796
- // A mutation names the item it targets. Another item's unresolved publication
797
- // waits on a synchronization this mutation does not touch, so it reports as a
798
- // finding without refusing the write. A caller that names no target, such as
799
- // the `claim-verify` command, keeps every finding blocking.
800
- function blocksTarget(finding, targetItemId) {
801
- if (targetItemId === null || finding.item_id === targetItemId) return true;
802
- return finding.reason !== 'worktree-synchronization-required';
803
- }
804
-
805
818
  // Ownership is evidence, never a guess: an unreadable history or a missing
806
819
  // expected path leaves the revision unattributed instead of naming a ref.
807
820
  async function revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision) {
@@ -813,18 +826,76 @@ async function revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevi
813
826
  }
814
827
  }
815
828
 
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
- };
829
+ // The classifier decides which topology this is; the sentences stay here.
830
+ // Member order is part of the published finding, so each remedy builds its own
831
+ // shape rather than sharing a base object.
832
+ function topologyFinding(decision, expectedPath, expectedRevision) {
833
+ const pathLabel = expectedPath ?? 'the item path';
834
+ const at = expectedPath ? { expected_path: expectedPath } : {};
835
+ switch (decision.remediation) {
836
+ case 'commit-in-git':
837
+ return {
838
+ reason: decision.reason,
839
+ ...at,
840
+ remediation: `Commit ${pathLabel} in Git, then run claim-verify.`,
841
+ };
842
+ case 'wait-for-named-owner':
843
+ return {
844
+ reason: decision.reason,
845
+ ...at,
846
+ owner_ref: decision.owner.ref,
847
+ owner_commit: decision.owner.commit,
848
+ remediation: `WAIT for owner ${decision.owner.ref} to publish ${decision.owner.commit}, then synchronize this worktree and run claim-verify.`,
849
+ };
850
+ case 'establish-ownership':
851
+ return {
852
+ reason: decision.reason,
853
+ ...at,
854
+ owner_unavailable: true,
855
+ 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.`,
856
+ };
857
+ // Reachable but unowned: the revision is in Git already, so the only honest
858
+ // instruction is to go read it. Naming an owner to wait for would be a wait
859
+ // with no end, which is what item #178 found in the field.
860
+ case 'inspect-reachable-history':
861
+ return {
862
+ reason: decision.reason,
863
+ ...at,
864
+ owner_unavailable: true,
865
+ 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.`,
866
+ };
867
+ case 'await-owner-commit':
868
+ return {
869
+ reason: decision.reason,
870
+ ...at,
871
+ owner_unavailable: true,
872
+ 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.`,
873
+ };
874
+ // Two remedies, both named, in the order that makes the cost obvious. The
875
+ // field report behind item #113 read the single restore sentence as the
876
+ // only way out and discarded reviewed, merged work to obey it.
877
+ case 'restore-or-adopt':
878
+ return {
879
+ reason: decision.reason,
880
+ ...at,
881
+ 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.`,
882
+ };
883
+ default:
884
+ throw new Error(`The reconciliation topology named no remedy: ${decision.remediation}.`);
885
+ }
886
+ }
887
+
888
+ // Who the journal says wrote the authorized revision, judged against who is
889
+ // asking. An entry from before writer identity existed, or a caller that
890
+ // cannot name itself, leaves the writer unknown.
891
+ function expectedWriterOf(recordedWriter, currentWorktreeId) {
892
+ if (recordedWriter === null || currentWorktreeId === null) return 'unknown';
893
+ return recordedWriter === currentWorktreeId ? 'current' : 'other';
826
894
  }
827
895
 
896
+ // Evidence in, scope and public finding out: the classifier rules on the
897
+ // topology, and this gathers exactly the evidence it rules on, then renders
898
+ // the sentence it prescribes.
828
899
  async function reconciliationDiagnosis({
829
900
  ledgerDirectory,
830
901
  actualRevision,
@@ -833,64 +904,22 @@ async function reconciliationDiagnosis({
833
904
  expectedRevision,
834
905
  headRevision,
835
906
  expectedWriter,
836
- workingTreeChanged,
837
907
  }) {
838
- const pathLabel = expectedPath ?? 'the item path';
839
- // Two remedies, both named, in the order that makes the cost obvious. The
840
- // field report behind item #113 read the single restore sentence as the only
841
- // way out and discarded reviewed, merged work to obey it.
842
- const unauthorizedRevision = {
843
- reason: 'unauthorized-revision',
844
- ...(expectedPath ? { expected_path: expectedPath } : {}),
845
- 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.`,
908
+ const revisions = {
909
+ workingTree: normalizeRevision(actualRevision, expectedRevision, authorizedRevisions),
910
+ head: normalizeRevision(headRevision, expectedRevision, authorizedRevisions),
911
+ };
912
+ const decision = classifyReconciliation({
913
+ ...revisions,
914
+ expectedOwner: requiresOwnerEvidence(revisions)
915
+ ? await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision)
916
+ : null,
917
+ expectedWriter,
918
+ });
919
+ return {
920
+ scope: decision.scope,
921
+ finding: topologyFinding(decision, expectedPath, expectedRevision),
846
922
  };
847
- const hasOutOfProtocolLocalState = (
848
- (actualRevision !== null && !authorizedRevisions.has(actualRevision))
849
- || (headRevision !== null && !authorizedRevisions.has(headRevision))
850
- || (actualRevision === null && headRevision !== null)
851
- );
852
- if (hasOutOfProtocolLocalState) return unauthorizedRevision;
853
- let expectedOwner = null;
854
- if (headRevision !== null && actualRevision === headRevision
855
- && headRevision !== expectedRevision) {
856
- expectedOwner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
857
- if (expectedOwner.kind === 'named-sibling') return namedOwnerFinding(expectedOwner, expectedPath);
858
- }
859
- if (!workingTreeChanged && headRevision !== expectedRevision) {
860
- return {
861
- reason: 'git-finalization-required',
862
- ...(expectedPath ? { expected_path: expectedPath } : {}),
863
- remediation: `Commit ${pathLabel} in Git, then run claim-verify.`,
864
- };
865
- }
866
- if (actualRevision === null && headRevision !== expectedRevision) {
867
- const owner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
868
- if (owner.kind === 'named-sibling') return namedOwnerFinding(owner, expectedPath);
869
- return {
870
- reason: 'worktree-synchronization-required',
871
- ...(expectedPath ? { expected_path: expectedPath } : {}),
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.`,
874
- };
875
- }
876
- if (authorizedRevisions.has(actualRevision)) {
877
- expectedOwner ??= await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
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') {
885
- return {
886
- reason: 'worktree-synchronization-required',
887
- ...(expectedPath ? { expected_path: expectedPath } : {}),
888
- owner_unavailable: true,
889
- 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.`,
890
- };
891
- }
892
- }
893
- return unauthorizedRevision;
894
923
  }
895
924
 
896
925
  function activePublicationMismatch(entries, record, actualRevision, observedAt) {
@@ -4,6 +4,7 @@
4
4
  // requestSchemaError encodes for the reference model — production code must not
5
5
  // depend on test code, so the rules are duplicated here rather than imported.
6
6
  import { pointer } from './request.js';
7
+ import { validateLedgerRepairRequest } from './ledger-repair.js';
7
8
 
8
9
  const NAMESPACE_ID = /^wbns_[a-f0-9]{32}$/;
9
10
  const ITEM_ID = /^wb_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -24,7 +25,15 @@ const REQUIRED_MEMBERS = {
24
25
  // Returns an array of {path, code, message} issues (empty when the request is valid).
25
26
  // The request must already be JSON-parsed and deep-normalized (plain objects/arrays,
26
27
  // JsonNumber unwrapped) — this module only checks shape, not JSON syntax.
28
+ //
29
+ // `number-repair` is a ledger-repair request, not a work-claim one. It is
30
+ // answered here because the claim journal validates every entry it replays
31
+ // through this seam, so a repair request the journal accepts must be the exact
32
+ // request the command accepts. Its members are the repair domain's own.
27
33
  export function validateClaimRequest(operation, request) {
34
+ if (operation === 'number-repair') {
35
+ return validateLedgerRepairRequest(request);
36
+ }
28
37
  const required = REQUIRED_MEMBERS[operation];
29
38
  if (!isPlainObject(request)) {
30
39
  return [problem([], 'invalid-type', `The ${operation} request must be a JSON object.`)];