wowbagger 0.1.0-alpha.13 → 0.1.0-alpha.14

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.
@@ -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) {
package/src/cli.js CHANGED
@@ -1654,20 +1654,18 @@ async function runClaimCommand(claimCommand, argumentsList) {
1654
1654
  }
1655
1655
  try {
1656
1656
  const envelope = await withClaimLock(storePath, async () => {
1657
- let replayed;
1658
- try {
1659
- replayed = await replayClaimJournal(journalPath, namespace);
1660
- } catch (error) {
1661
- throw taggedFailure('CLAIM_STORE_UNREADABLE', error);
1662
- }
1663
1657
  // A claim lifecycle command classifies item reconciliation and refuses
1664
1658
  // on it, so it must reason from the same writer evidence every other
1665
1659
  // classifying surface uses; otherwise one command grants a claim on
1666
1660
  // exactly the state another command refuses to write. It writes no item
1667
1661
  // byte and no writer-attributed entry, so it reads the identity it
1668
- // already answers to rather than creating one.
1662
+ // already answers to rather than creating one. Journal and identity are
1663
+ // both store state this command only reads, so neither reading failure
1664
+ // is anything but an unreadable store.
1665
+ let replayed;
1669
1666
  let currentWorktreeId;
1670
1667
  try {
1668
+ replayed = await replayClaimJournal(journalPath, namespace);
1671
1669
  currentWorktreeId = await readWorktreeIdentity({
1672
1670
  ledgerDirectory: parsedOptions.options.ledger,
1673
1671
  gitCommonDir,
@@ -119,17 +119,25 @@ async function finalize({
119
119
  }) {
120
120
  const { root, prefix } = placement;
121
121
  const logPath = ledgerRelative(ledgerDirectory, claimReconcileLogPath(path.resolve(ledgerDirectory), namespace));
122
- const journalOwned = command !== 'create';
122
+ // Every mutation, create included, appends its own journal entries and
123
+ // rebuilds the derived reconciliation log, so every commit set carries that
124
+ // log alongside the item.
125
+ //
126
+ // Log dirt that predates the invocation is a separate question. A mutation on
127
+ // an existing item rebuilds residue its own prior claim operations left in
128
+ // this checkout; a create allocates a fresh identity and has no standing to
129
+ // absorb bytes that were already there, so it refuses instead.
130
+ const absorbsPriorLogDirt = command !== 'create';
123
131
 
124
132
  // Preflight, before any Wowbagger write. Any staged path and every dirty
125
- // ledger path except the journal-owning command's derived log refuse: a broad
126
- // commit would otherwise absorb foreign work.
133
+ // ledger path except an absorbable derived log refuse: a broad commit would
134
+ // otherwise take in foreign work.
127
135
  const before = await inspectWorktree(root, prefix);
128
136
  if (before.staged.length > 0) {
129
137
  return shape.preflightFailed('staged-paths-present', { staged_paths: bounded(before.staged) });
130
138
  }
131
139
  const foreignDirtyBefore = before.dirtyLedger.filter((entry) => (
132
- !(journalOwned && entry === logPath)
140
+ !(absorbsPriorLogDirt && entry === logPath)
133
141
  ));
134
142
  if (foreignDirtyBefore.length > 0) {
135
143
  return shape.preflightFailed('ledger-not-clean', { dirty_paths: bounded(foreignDirtyBefore) });
@@ -165,13 +173,13 @@ async function finalize({
165
173
  });
166
174
  }
167
175
 
168
- // Reconciliation rewrites the tracked log. Only a command that will commit
169
- // that log may proceed with it dirty; create commits the item alone.
176
+ // Reconciliation rewrites the tracked log. Every command here will commit
177
+ // that log, so its own rewrite is never stray; anything else is.
170
178
  const afterVerify = await inspectWorktree(root, prefix);
171
179
  if (afterVerify.staged.length > 0) {
172
180
  return shape.preflightFailed('staged-paths-present', { staged_paths: bounded(afterVerify.staged) });
173
181
  }
174
- const strayBefore = afterVerify.dirtyLedger.filter((entry) => !(journalOwned && entry === logPath));
182
+ const strayBefore = afterVerify.dirtyLedger.filter((entry) => entry !== logPath);
175
183
  if (strayBefore.length > 0) {
176
184
  return shape.preflightFailed('ledger-not-clean', { dirty_paths: bounded(strayBefore) });
177
185
  }
@@ -210,10 +218,10 @@ async function finalize({
210
218
  const itemChanged = await itemDiffersFromHead(root, prefix, published.path, published.revision);
211
219
  const commitSet = [
212
220
  ...(itemChanged ? [published.path] : []),
213
- ...(journalOwned ? [logPath] : []),
221
+ logPath,
214
222
  ].sort(compareText);
215
223
  const subject = commitSubject(command, published.number, published.id);
216
- const witness = journalOwned ? shape.terminalWitness(outcome, published) : null;
224
+ const witness = shape.terminalWitness(outcome, published);
217
225
  const context = {
218
226
  command,
219
227
  head,
@@ -232,7 +240,6 @@ async function finalize({
232
240
  context,
233
241
  commitSet,
234
242
  digests,
235
- journalOwned,
236
243
  ledgerDirectory,
237
244
  logPath,
238
245
  ownArtifacts: shape.recoveryArtifactPaths(outcome),
@@ -287,7 +294,7 @@ async function finalize({
287
294
  gitCommonDir,
288
295
  namespace,
289
296
  targetItemId: published.id,
290
- writeLogWhenEmpty: journalOwned,
297
+ writeLogWhenEmpty: true,
291
298
  });
292
299
  const reconciliation = reconciliationFailureReason(reconciled, commit.commit, context.operationId, published.id);
293
300
  if (reconciliation) {
@@ -305,7 +312,7 @@ async function finalize({
305
312
  // Every check that must hold between publication and staging. Returning a
306
313
  // reason here means the item is published and no Git write has happened yet.
307
314
  async function prepareCommitSet({
308
- context, commitSet, digests, journalOwned, ledgerDirectory, logPath, ownArtifacts, prefix, root, witness,
315
+ context, commitSet, digests, ledgerDirectory, logPath, ownArtifacts, prefix, root, witness,
309
316
  }) {
310
317
  let itemBytes;
311
318
  try {
@@ -330,7 +337,6 @@ async function prepareCommitSet({
330
337
  ));
331
338
  if (stray.length > 0) return { stage: 'prepare-commit-set', reason: 'tree-changed' };
332
339
 
333
- if (!journalOwned) return null;
334
340
  let logBytes;
335
341
  try {
336
342
  logBytes = await readFile(path.join(path.resolve(ledgerDirectory), logPath));
@@ -852,7 +858,6 @@ export async function finalizeFromRecoveryToken({ ledgerDirectory, token }) {
852
858
 
853
859
  async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload, placement }) {
854
860
  const { root, prefix } = placement;
855
- const journalOwned = payload.command !== 'create';
856
861
  const logPath = ledgerRelative(ledgerDirectory, claimReconcileLogPath(path.resolve(ledgerDirectory), namespace));
857
862
  const witness = payload.terminal_witness ?? null;
858
863
 
@@ -877,14 +882,15 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
877
882
  const itemChanged = await itemDiffersFromHead(root, prefix, derived.path, payload.published_revision);
878
883
  commitSet = [
879
884
  ...(itemChanged ? [derived.path] : []),
880
- ...(journalOwned ? [logPath] : []),
885
+ logPath,
881
886
  ].sort(compareText);
882
887
  } else {
883
888
  commitSet = [...tokenPaths].sort(compareText);
884
889
  }
885
- const allowedCommitSets = journalOwned
886
- ? [[logPath], [derived.path, logPath]]
887
- : [[derived.path]];
890
+ // A token is only ever issued by the auto-commit path above, where every
891
+ // command owns its reconciliation log, so recovery accepts the same two
892
+ // shapes for every command.
893
+ const allowedCommitSets = [[logPath], [derived.path, logPath]];
888
894
  const tokenMatchesMutation = allowedCommitSets.some((allowed) => sameSet(tokenPaths, allowed));
889
895
  if (payload.item_path !== derived.path
890
896
  || !tokenMatchesMutation
@@ -910,7 +916,7 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
910
916
  }
911
917
 
912
918
  const refusal = await finalizePreconditions({
913
- commitSet, derived, digests, gitPaths, journalOwned, ledgerDirectory, logPath, payload, prefix, root, witness,
919
+ commitSet, derived, digests, gitPaths, ledgerDirectory, logPath, payload, prefix, root, witness,
914
920
  });
915
921
  if (refusal) return finalizeRefused(refusal.reason, refusal.details);
916
922
 
@@ -952,7 +958,7 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
952
958
  }
953
959
 
954
960
  async function finalizePreconditions({
955
- commitSet, derived, digests, gitPaths, journalOwned, ledgerDirectory, logPath, payload, prefix, root, witness,
961
+ commitSet, derived, digests, gitPaths, ledgerDirectory, logPath, payload, prefix, root, witness,
956
962
  }) {
957
963
  let itemBytes;
958
964
  try {
@@ -973,7 +979,6 @@ async function finalizePreconditions({
973
979
  }
974
980
  const stray = observed.dirtyLedger.filter((entry) => !commitSet.includes(entry));
975
981
  if (stray.length > 0) return { reason: 'ledger-not-clean', details: { dirty_paths: bounded(stray) } };
976
- if (!journalOwned) return null;
977
982
  let logBytes;
978
983
  try {
979
984
  logBytes = await readFile(path.join(path.resolve(ledgerDirectory), logPath));
package/src/mutation.js CHANGED
@@ -230,11 +230,15 @@ export async function createItem(ledgerDirectory, request, scenario) {
230
230
  ledgerDirectory,
231
231
  request.id,
232
232
  'create-v1',
233
- (authorize, ledgerSnapshot) => createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnapshot),
233
+ (authorize, ledgerSnapshot) => createItemUnfenced(
234
+ ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
235
+ ),
234
236
  );
235
237
  }
236
238
 
237
- async function createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnapshot) {
239
+ async function createItemUnfenced(
240
+ ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
241
+ ) {
238
242
  const root = path.resolve(ledgerDirectory);
239
243
  const id = request.id;
240
244
  const readPreLockLedger = snapshotReader(root, ledgerSnapshot);
@@ -362,6 +366,15 @@ async function createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnap
362
366
  }));
363
367
  }
364
368
 
369
+ // The allocation this create proposes becomes journal-visible before any
370
+ // byte reaches the ledger, so a sibling worktree that cannot see this
371
+ // item's publication cannot hand the same number out again. The intent is
372
+ // appended only once the candidate is known publishable, so a refusal
373
+ // this command would have returned anyway records no attempt.
374
+ if (authorize) {
375
+ await authorize(null, revisionFor(bytes), relativeFinalPath);
376
+ }
377
+
365
378
  temporaryPath = path.join(finalDirectory, `.wowbagger-tmp-${id}-${randomSuffix()}`);
366
379
  const temporaryFailure = await prepareTemporary(temporaryPath, bytes, null, scenario);
367
380
  if (temporaryFailure) {
@@ -0,0 +1,117 @@
1
+ // The reconciliation topology, decided once, from evidence alone.
2
+ //
3
+ // Every command that reconciles a claim journal has to answer the same
4
+ // question about a drifted item: which of the recognized topologies is this,
5
+ // and does it block the write in front of us? Answering it inside each command
6
+ // is how the answers drifted apart, so the decision lives here, pure: no Git,
7
+ // no filesystem, no prose. Callers gather the evidence, render the sentences,
8
+ // and keep the scope to themselves.
9
+ //
10
+ // The vocabulary:
11
+ //
12
+ // revision state where a surface's bytes stand against the journal —
13
+ // `expected` the authorized revision itself, `authorized`
14
+ // some other revision the journal once ruled legitimate,
15
+ // `unknown` bytes no ruling covers, `absent` no bytes at all.
16
+ // owner evidence `{ kind, ref?, commit? }` from `findRevisionOwner`: which
17
+ // live worktree, if any, carries the expected revision.
18
+ // expected writer `current` when the journal names this worktree as the
19
+ // writer of the expected revision, `other` when it names
20
+ // another, `unknown` when nothing can be attributed.
21
+ // scope who a finding blocks: `global` every write, `target` only
22
+ // a write against the item it names, `none` nobody.
23
+ // remediation which remedy the topology prescribes; the caller renders
24
+ // the sentence, so the kinds carry no wording.
25
+
26
+ // Where one surface's bytes stand against the journal. `expected` is a
27
+ // refinement of `authorized`, so it is tested first.
28
+ export function normalizeRevision(revision, expectedRevision, authorizedRevisions) {
29
+ if (revision === null) return 'absent';
30
+ if (revision === expectedRevision) return 'expected';
31
+ return authorizedRevisions.has(revision) ? 'authorized' : 'unknown';
32
+ }
33
+
34
+ // Owner evidence costs a walk of every live worktree's history, so the two
35
+ // topologies that never consult it must not pay for it. The predicate answers
36
+ // from the same states the classifier judges, through the same helper, so
37
+ // neither can drift from the other.
38
+ export function requiresOwnerEvidence({ workingTree, head }) {
39
+ return !isUnattributed(workingTree, head) && workingTree !== 'expected';
40
+ }
41
+
42
+ // Bytes no ruling covers, on either surface, and a working tree that is gone
43
+ // while another surface still holds bytes. Nothing here is attributable to a
44
+ // writer or an owner: the local state is simply out of protocol.
45
+ function isUnattributed(workingTree, head) {
46
+ return workingTree === 'unknown'
47
+ || head === 'unknown'
48
+ || (workingTree === 'absent' && head !== 'absent');
49
+ }
50
+
51
+ // One topology, one member. `expectedOwner` is required exactly when
52
+ // `requiresOwnerEvidence` says so, and is never read otherwise.
53
+ export function classifyReconciliation({ workingTree, head, expectedOwner, expectedWriter }) {
54
+ if (isUnattributed(workingTree, head)) return UNAUTHORIZED_REVISION;
55
+ // The authorized bytes are here and Git has yet to record them. Nothing is
56
+ // in doubt but the commit.
57
+ if (workingTree === 'expected') {
58
+ return { scope: 'global', reason: 'git-finalization-required', remediation: 'commit-in-git' };
59
+ }
60
+ // An item absent from both local surfaces has never existed in this
61
+ // checkout. A sibling may carry the expected revision, but that does not
62
+ // establish ownership for a checkout with no local history or item path.
63
+ if (workingTree === 'absent') {
64
+ return {
65
+ scope: 'target',
66
+ reason: 'worktree-synchronization-required',
67
+ remediation: 'establish-ownership',
68
+ };
69
+ }
70
+ // A live named worktree carries the expected revision, so there is a ref to
71
+ // wait on and a commit to name. This outranks the remaining synchronization
72
+ // answers, because it is the only one that names an owner.
73
+ if (expectedOwner.kind === 'named-sibling') {
74
+ return {
75
+ scope: 'target',
76
+ reason: 'worktree-synchronization-required',
77
+ owner: expectedOwner,
78
+ remediation: 'wait-for-named-owner',
79
+ };
80
+ }
81
+ // Advice to wait for an owning worktree needs an owner that could still
82
+ // appear. When the journal names this worktree as the writer of the expected
83
+ // revision, or this worktree's own history reaches it, the successor exists
84
+ // nowhere but in the journal: there is nothing to synchronize from, and the
85
+ // authorized bytes on disk are simply the wrong ones.
86
+ if (expectedWriter !== 'current' && expectedOwner.kind !== 'current') {
87
+ return {
88
+ scope: 'target',
89
+ reason: 'worktree-synchronization-required',
90
+ // Waiting is only truthful while the commit is still missing. Git already
91
+ // reaches a `reachable-unowned` revision through a tag, a remote-tracking
92
+ // ref, an unchecked branch, or a detached HEAD, so telling a reader to
93
+ // wait for a commit names a wait that can never end: the bytes are there
94
+ // to inspect, and no named worktree will publish them.
95
+ remediation: expectedOwner.kind === 'reachable-unowned'
96
+ ? 'inspect-reachable-history'
97
+ : 'await-owner-commit',
98
+ };
99
+ }
100
+ return UNAUTHORIZED_REVISION;
101
+ }
102
+
103
+ const UNAUTHORIZED_REVISION = Object.freeze({
104
+ scope: 'global',
105
+ reason: 'unauthorized-revision',
106
+ remediation: 'restore-or-adopt',
107
+ });
108
+
109
+ // Scope, never reason text, decides what a finding refuses. A mutation names
110
+ // the item it targets, and a synchronization another item waits on is a wait
111
+ // this mutation does not touch. A caller that names no target, such as the
112
+ // `claim-verify` command, keeps every finding blocking.
113
+ export function blocksTarget(scope, itemId, targetItemId) {
114
+ if (scope === 'none') return false;
115
+ if (scope === 'global') return true;
116
+ return targetItemId === null || itemId === targetItemId;
117
+ }