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.
- package/CHANGELOG.md +249 -0
- package/README.md +27 -15
- package/docs/host-contract.md +7 -1
- package/docs/mutation-contract.md +114 -21
- package/docs/work-claim-contract.md +292 -67
- package/package.json +2 -2
- package/schemas/core-envelope.json +4 -3
- package/schemas/index.json +18 -0
- package/schemas/ledger-repair-proposal.json +170 -0
- package/schemas/ledger-repair-request.json +61 -0
- package/schemas/ledger-repair-response.json +90 -0
- package/skills/wowbagger/SKILL.md +134 -22
- package/src/claim-coordinator.js +17 -3
- package/src/claim-journal.js +125 -4
- package/src/claim-publication.js +122 -93
- package/src/claim-request.js +9 -0
- package/src/cli.js +127 -36
- package/src/git-autocommit.js +26 -21
- package/src/launch.js +2 -2
- package/src/ledger-repair.js +1170 -0
- package/src/mutation.js +15 -2
- package/src/reconciliation-classifier.js +117 -0
- package/src/version-drift.js +98 -0
package/src/claim-journal.js
CHANGED
|
@@ -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
|
-
&&
|
|
286
|
-
&&
|
|
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
|
-
&&
|
|
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
|
-
&&
|
|
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
|
}
|
package/src/claim-publication.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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/claim-request.js
CHANGED
|
@@ -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.`)];
|