wowbagger 0.1.0-alpha.1

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 +94 -0
  2. package/LICENSE +201 -0
  3. package/README.md +464 -0
  4. package/adapters/claude-code/entrypoint.js +19 -0
  5. package/adapters/claude-code/wowbagger-adapter.json +25 -0
  6. package/adapters/codex/entrypoint.js +11 -0
  7. package/adapters/codex/wowbagger-adapter.json +25 -0
  8. package/adapters/opencode/entrypoint.js +11 -0
  9. package/adapters/opencode/wowbagger-adapter.json +25 -0
  10. package/bin/wowbagger.js +7 -0
  11. package/package.json +51 -0
  12. package/skills/wowbagger/SKILL.md +136 -0
  13. package/src/adapter/approval.js +135 -0
  14. package/src/adapter/bootstrap.js +43 -0
  15. package/src/adapter/context.js +34 -0
  16. package/src/adapter/core-probe.js +231 -0
  17. package/src/adapter/describe.js +383 -0
  18. package/src/adapter/entrypoint-main.js +335 -0
  19. package/src/adapter/entrypoint-path.js +103 -0
  20. package/src/adapter/handoff.js +124 -0
  21. package/src/adapter/instructions.js +106 -0
  22. package/src/adapter/invoke.js +294 -0
  23. package/src/adapter/limits.js +26 -0
  24. package/src/adapter/manifest.js +93 -0
  25. package/src/adapter/messages.js +15 -0
  26. package/src/adapter/paths.js +88 -0
  27. package/src/adapter/process-outcome.js +1116 -0
  28. package/src/adapter/schema-helpers.js +60 -0
  29. package/src/claim-capabilities.js +54 -0
  30. package/src/claim-coordinator.js +85 -0
  31. package/src/claim-journal.js +236 -0
  32. package/src/claim-operations.js +138 -0
  33. package/src/claim-publication.js +739 -0
  34. package/src/claim-request.js +140 -0
  35. package/src/claim-store.js +198 -0
  36. package/src/cli.js +1130 -0
  37. package/src/dependencies.js +3 -0
  38. package/src/git-reconciliation.js +62 -0
  39. package/src/ledger.js +296 -0
  40. package/src/mint.js +32 -0
  41. package/src/mutation.js +1979 -0
  42. package/src/namespace.js +35 -0
  43. package/src/ready.js +85 -0
  44. package/src/request.js +246 -0
  45. package/src/schema-migration.js +300 -0
  46. package/src/validate.js +1208 -0
@@ -0,0 +1,739 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { access } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import {
6
+ appendClaimEntry,
7
+ claimJournalPath,
8
+ claimReconcileLogPath,
9
+ replayClaimJournal,
10
+ writeReconcileLog,
11
+ } from './claim-journal.js';
12
+ import { advanceClockFloor, readBack } from './claim-operations.js';
13
+ import { claimStorePath, withClaimLock, writeClaimState } from './claim-store.js';
14
+ import { loadLedger, parseLedgerItemSource } from './ledger.js';
15
+ import { readGitHeadLedger } from './git-reconciliation.js';
16
+ import { publishClaimedCandidate, revisionFor } from './mutation.js';
17
+ import { validateLedger } from './validate.js';
18
+
19
+ const ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
20
+ const NAMESPACE_ID = /^wbns_[a-f0-9]{32}$/;
21
+ const OPERATION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
22
+ const REVISION = /^sha256:[a-f0-9]{64}$/;
23
+ const EPOCH = /^(?:[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|18446744[0-6][0-9]{11}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{4}|184467440737095[0-4][0-9]{3}|1844674407370955[0-9]{2}|18446744073709551[0-5])$/;
24
+ const MAX_CANDIDATE_BYTES = 8388608;
25
+ const MAX_CANDIDATE_BASE64_CHARS = Math.ceil(MAX_CANDIDATE_BYTES / 3) * 4;
26
+
27
+ export function validatePublicationRequest(request) {
28
+ const required = [
29
+ 'candidate_sha256',
30
+ 'candidate_source_base64',
31
+ 'claim_fence',
32
+ 'expected_revision',
33
+ 'item_id',
34
+ 'ledger_namespace',
35
+ 'operation_id',
36
+ ];
37
+ if (!isExactObject(request, required)
38
+ || !OPERATION_ID.test(request.operation_id)
39
+ || !NAMESPACE_ID.test(request.ledger_namespace)
40
+ || !ITEM_ID.test(request.item_id)
41
+ || !REVISION.test(request.expected_revision)
42
+ || !REVISION.test(request.candidate_sha256)
43
+ || !isExactObject(request.claim_fence, ['epoch', 'item_id', 'ledger_namespace', 'owner_id'])
44
+ || !NAMESPACE_ID.test(request.claim_fence.ledger_namespace)
45
+ || !ITEM_ID.test(request.claim_fence.item_id)
46
+ || typeof request.claim_fence.owner_id !== 'string'
47
+ || request.claim_fence.owner_id.length === 0
48
+ || !EPOCH.test(request.claim_fence.epoch)
49
+ || typeof request.candidate_source_base64 !== 'string') {
50
+ return publicationError(request, 'invalid-request', 'The request does not match publish-claimed version 1.', {}, 2);
51
+ }
52
+ if (request.candidate_source_base64.length > MAX_CANDIDATE_BASE64_CHARS) {
53
+ return canonicalBase64Error(request);
54
+ }
55
+ let candidate;
56
+ try {
57
+ candidate = Buffer.from(request.candidate_source_base64, 'base64');
58
+ } catch {
59
+ return canonicalBase64Error(request);
60
+ }
61
+ if (candidate.length > MAX_CANDIDATE_BYTES
62
+ || candidate.toString('base64') !== request.candidate_source_base64) {
63
+ return canonicalBase64Error(request);
64
+ }
65
+ if (revisionFor(candidate) !== request.candidate_sha256) {
66
+ return publicationError(
67
+ request,
68
+ 'candidate-digest-mismatch',
69
+ 'The candidate digest does not match the candidate source.',
70
+ { operation_id: request.operation_id },
71
+ 2,
72
+ );
73
+ }
74
+ return null;
75
+ }
76
+
77
+ export function validatePublicationReadRequest(request) {
78
+ const required = ['item_id', 'ledger_namespace', 'operation_id'];
79
+ if (!isExactObject(request, required)
80
+ || !OPERATION_ID.test(request.operation_id ?? '')
81
+ || !NAMESPACE_ID.test(request.ledger_namespace ?? '')
82
+ || !ITEM_ID.test(request.item_id ?? '')) {
83
+ return publicationReadError(request, 'invalid-request',
84
+ 'The request does not match ledger-publication.read version 1.', {
85
+ expected_members: required,
86
+ });
87
+ }
88
+ return null;
89
+ }
90
+
91
+ export async function readPublicationOutcome({ gitCommonDir, namespace, request }) {
92
+ const storePath = claimStorePath(gitCommonDir, namespace);
93
+ const journalPath = claimJournalPath(gitCommonDir, namespace);
94
+ try {
95
+ return await withClaimLock(storePath, async () => {
96
+ const replayed = await replayClaimJournal(journalPath, namespace);
97
+ const outcome = replayed.entries.find((entry) => (
98
+ entry.type === 'publish-final'
99
+ && entry.operation_id === request.operation_id
100
+ && entry.item_id === request.item_id
101
+ ));
102
+ if (!outcome) {
103
+ return publicationReadError(request, 'operation-not-found',
104
+ 'The publication operation outcome was not found.', {
105
+ operation_id: request.operation_id,
106
+ ledger_namespace: request.ledger_namespace,
107
+ item_id: request.item_id,
108
+ });
109
+ }
110
+ return {
111
+ exit: 0,
112
+ stdout: {
113
+ ok: true,
114
+ namespace: 'ledger-publication',
115
+ command: 'read',
116
+ contract_version: 1,
117
+ state: 'committed',
118
+ operation_id: request.operation_id,
119
+ result: {
120
+ operation_id: request.operation_id,
121
+ ledger_namespace: request.ledger_namespace,
122
+ item_id: request.item_id,
123
+ operation_digest: outcome.operation_digest,
124
+ outcome: structuredClone(outcome.outcome.stdout),
125
+ },
126
+ },
127
+ };
128
+ });
129
+ } catch (error) {
130
+ return publicationReadError(request, 'claim-store-unavailable',
131
+ 'The durable claim store is unavailable.', {
132
+ reason: error?.code === 'CLAIM_LOCK_HELD' ? 'claim-store-locked' : 'claim-store-unreadable',
133
+ }, 6);
134
+ }
135
+ }
136
+
137
+ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace, request, scenario }) {
138
+ if (request.ledger_namespace !== namespace) {
139
+ return publicationError(request, 'ledger-namespace-unbound',
140
+ 'The ledger namespace is not provisioned for this endpoint.', {
141
+ ledger_namespace: request.ledger_namespace,
142
+ }, 2);
143
+ }
144
+
145
+ const storePath = claimStorePath(gitCommonDir, namespace);
146
+ const journalPath = claimJournalPath(gitCommonDir, namespace);
147
+ try {
148
+ return await withClaimLock(storePath, async () => {
149
+ let replayed = await replayClaimJournal(journalPath, namespace);
150
+ const digest = operationDigest(request);
151
+ const prior = replayed.entries.find((entry) => (
152
+ entry.type === 'publish-final'
153
+ && entry.operation_id === request.operation_id
154
+ ));
155
+ if (prior) {
156
+ return prior.operation_digest === digest
157
+ ? prior.outcome
158
+ : idempotencyConflict(request.operation_id, prior.operation_digest, digest);
159
+ }
160
+ const terminalKeys = new Set(replayed.entries
161
+ .filter((entry) => entry.type === 'publish-final')
162
+ .map((entry) => `${entry.operation_id}\0${entry.item_id}`));
163
+ if (replayed.entries.some((entry) => (
164
+ entry.type === 'publish-intent'
165
+ && !terminalKeys.has(`${entry.operation_id}\0${entry.item_id}`)
166
+ ))) {
167
+ const reconciled = await reconcileClaimJournal({
168
+ ledgerDirectory,
169
+ gitCommonDir,
170
+ namespace,
171
+ replayed,
172
+ physicalNow: new Date().toISOString(),
173
+ });
174
+ if (reconciled.unsafe) return publicationUnknown(request);
175
+ replayed = { entries: reconciled.entries, state: reconciled.state };
176
+ const reconciledPrior = replayed.entries.find((entry) => (
177
+ entry.type === 'publish-final'
178
+ && entry.operation_id === request.operation_id
179
+ ));
180
+ if (reconciledPrior) {
181
+ return reconciledPrior.operation_digest === digest
182
+ ? reconciledPrior.outcome
183
+ : idempotencyConflict(request.operation_id, reconciledPrior.operation_digest, digest);
184
+ }
185
+ }
186
+ const candidateError = await validateCandidateLedger(ledgerDirectory, request);
187
+ if (candidateError) return candidateError;
188
+ const physicalNow = new Date().toISOString();
189
+ const observedAt = advanceClockFloor(replayed.state, physicalNow);
190
+ let clockEntry;
191
+ try {
192
+ clockEntry = await appendClaimEntry(journalPath, {
193
+ type: 'clock',
194
+ now: physicalNow,
195
+ floor: observedAt,
196
+ });
197
+ } catch {
198
+ return publicationError(request, 'clock-floor-persistence-failed',
199
+ 'The authoritative clock floor could not be persisted.', {
200
+ ledger_namespace: request.ledger_namespace,
201
+ item_id: request.item_id,
202
+ }, 6);
203
+ }
204
+ const entries = [...replayed.entries, clockEntry];
205
+ const record = replayed.state.claims.find((entry) => entry.item_id === request.item_id)
206
+ ?? { item_id: request.item_id, last_epoch: '0', active: null };
207
+ const rejection = fenceRejectionReason(request, record.active, observedAt);
208
+ if (rejection) {
209
+ const outcome = publicationError(request, 'claim-fence-rejected',
210
+ 'The supplied claim fence is not the active owner generation.', {
211
+ ledger_namespace: request.ledger_namespace,
212
+ item_id: request.item_id,
213
+ observed_at: observedAt,
214
+ reason: rejection,
215
+ supplied_owner_id: request.claim_fence.owner_id,
216
+ supplied_epoch: request.claim_fence.epoch,
217
+ active_owner_id: record.active?.owner_id ?? null,
218
+ active_epoch: record.active?.epoch ?? null,
219
+ }, 4);
220
+ return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
221
+ }
222
+ const intent = await appendClaimEntry(journalPath, {
223
+ type: 'publish-intent',
224
+ operation_id: request.operation_id,
225
+ operation_digest: operationDigest(request),
226
+ item_id: request.item_id,
227
+ expected_revision: request.expected_revision,
228
+ candidate_sha256: request.candidate_sha256,
229
+ fence: request.claim_fence,
230
+ floor: observedAt,
231
+ state: 'pending',
232
+ });
233
+ entries.push(intent);
234
+ const mutation = await publishClaimedCandidate(ledgerDirectory, request, scenario);
235
+ if (!mutation.ok) {
236
+ const outcome = mutationFailure(request, mutation);
237
+ return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
238
+ }
239
+ await publicationTestCheckpoint(scenario, 'after-ledger-commit');
240
+ const outcome = publicationSuccess(request, record, observedAt);
241
+ return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
242
+ });
243
+ } catch (error) {
244
+ if (error?.code === 'CLAIM_LOCK_HELD') {
245
+ return publicationError(request, 'claim-store-unavailable', 'The durable claim store is unavailable.', {
246
+ reason: 'claim-store-locked',
247
+ }, 6);
248
+ }
249
+ return publicationUnknown(request);
250
+ }
251
+ }
252
+
253
+ export async function reconcileClaimJournal({
254
+ ledgerDirectory,
255
+ gitCommonDir,
256
+ namespace,
257
+ replayed,
258
+ physicalNow,
259
+ }) {
260
+ const storePath = claimStorePath(gitCommonDir, namespace);
261
+ const journalPath = claimJournalPath(gitCommonDir, namespace);
262
+ const ledger = await loadLedger(path.resolve(ledgerDirectory));
263
+ const items = new Map(ledger.items.map((item) => [item.data.id, item]));
264
+ const terminalKeys = new Set(replayed.entries
265
+ .filter((entry) => entry.type === 'publish-final')
266
+ .map((entry) => `${entry.operation_id}\0${entry.item_id}`));
267
+ const pending = replayed.entries.filter((entry) => (
268
+ entry.type === 'publish-intent'
269
+ && !terminalKeys.has(`${entry.operation_id}\0${entry.item_id}`)
270
+ ));
271
+ const entries = [...replayed.entries];
272
+ const findings = [];
273
+ const observedAt = advanceClockFloor(replayed.state, physicalNow);
274
+ try {
275
+ entries.push(await appendClaimEntry(journalPath, {
276
+ type: 'clock',
277
+ now: physicalNow,
278
+ floor: observedAt,
279
+ }));
280
+ } catch (cause) {
281
+ const error = new Error('The authoritative clock floor could not be persisted.', { cause });
282
+ error.code = 'CLOCK_FLOOR_PERSISTENCE_FAILED';
283
+ throw error;
284
+ }
285
+
286
+ for (const intent of pending) {
287
+ const item = items.get(intent.item_id);
288
+ const actualRevision = item ? revisionFor(item.bytes) : null;
289
+ const record = replayed.state.claims.find((entry) => entry.item_id === intent.item_id)
290
+ ?? { item_id: intent.item_id, last_epoch: '0', active: null };
291
+ const higherEpoch = BigInt(record.last_epoch) > BigInt(intent.fence.epoch);
292
+ let outcome;
293
+ if (actualRevision === intent.candidate_sha256 && !higherEpoch) {
294
+ outcome = publicationSuccess({
295
+ operation_id: intent.operation_id,
296
+ ledger_namespace: namespace,
297
+ item_id: intent.item_id,
298
+ candidate_sha256: intent.candidate_sha256,
299
+ claim_fence: intent.fence,
300
+ }, record, observedAt);
301
+ } else if (higherEpoch) {
302
+ outcome = publicationError({
303
+ operation_id: intent.operation_id,
304
+ }, 'claim-fence-rejected',
305
+ 'The supplied claim fence is not the active owner generation.', {
306
+ ledger_namespace: namespace,
307
+ item_id: intent.item_id,
308
+ observed_at: observedAt,
309
+ reason: 'epoch-mismatch',
310
+ supplied_owner_id: intent.fence.owner_id,
311
+ supplied_epoch: intent.fence.epoch,
312
+ active_owner_id: record.active?.owner_id ?? null,
313
+ active_epoch: record.active?.epoch ?? null,
314
+ }, 4);
315
+ } else if (
316
+ actualRevision === intent.expected_revision
317
+ || entries.some((entry) => (
318
+ entry.type === 'publish-final'
319
+ && entry.item_id === intent.item_id
320
+ && entry.outcome?.stdout?.state === 'committed'
321
+ && entry.outcome.stdout.result.committed_revision === actualRevision
322
+ ))
323
+ ) {
324
+ outcome = publicationError({
325
+ operation_id: intent.operation_id,
326
+ }, 'ledger-revision-conflict',
327
+ 'The durable ledger revision no longer matches this publication.', {
328
+ ledger_namespace: namespace,
329
+ item_id: intent.item_id,
330
+ expected_revision: intent.candidate_sha256,
331
+ actual_revision: actualRevision,
332
+ }, 4);
333
+ } else {
334
+ outcome = publicationUnknown({
335
+ operation_id: intent.operation_id,
336
+ ledger_namespace: namespace,
337
+ item_id: intent.item_id,
338
+ });
339
+ }
340
+ entries.push(await appendClaimEntry(journalPath, {
341
+ type: 'publish-final',
342
+ operation_id: intent.operation_id,
343
+ operation_digest: intent.operation_digest,
344
+ ledger_namespace: namespace,
345
+ item_id: intent.item_id,
346
+ outcome,
347
+ }));
348
+ findings.push({
349
+ code: outcome.stdout.state === 'unknown'
350
+ ? 'publication-outcome-unknown'
351
+ : 'pending-intent-resolved',
352
+ item_id: intent.item_id,
353
+ operation_id: intent.operation_id,
354
+ outcome: outcome.stdout.state,
355
+ });
356
+ }
357
+
358
+ const headItems = new Map();
359
+ let gitHead = null;
360
+ try {
361
+ await access(path.join(gitCommonDir, 'HEAD'));
362
+ gitHead = await readGitHeadLedger(ledgerDirectory);
363
+ } catch (error) {
364
+ if (error?.code !== 'ENOENT') throw error;
365
+ }
366
+ if (gitHead) {
367
+ for (const [file, bytes] of gitHead.items) {
368
+ const parsed = parseLedgerItemSource(bytes.toString('utf8'));
369
+ if (!parsed.error && typeof parsed.data?.id === 'string') {
370
+ headItems.set(parsed.data.id, { bytes, file });
371
+ }
372
+ }
373
+ const finalizedKeys = new Set(entries
374
+ .filter((entry) => entry.type === 'publish-finalization')
375
+ .map((entry) => `${entry.operation_id}\0${entry.item_id}`));
376
+ for (const terminal of entries) {
377
+ if (
378
+ terminal.type !== 'publish-final'
379
+ || terminal.outcome?.stdout?.state !== 'committed'
380
+ || finalizedKeys.has(`${terminal.operation_id}\0${terminal.item_id}`)
381
+ ) continue;
382
+ const committedRevision = terminal.outcome.stdout.result.committed_revision;
383
+ const headItem = headItems.get(terminal.item_id);
384
+ if (!headItem || revisionFor(headItem.bytes) !== committedRevision) continue;
385
+ const finalization = await appendClaimEntry(journalPath, {
386
+ type: 'publish-finalization',
387
+ operation_id: terminal.operation_id,
388
+ item_id: terminal.item_id,
389
+ committed_revision: committedRevision,
390
+ git_commit: gitHead.commit,
391
+ });
392
+ entries.push(finalization);
393
+ finalizedKeys.add(`${terminal.operation_id}\0${terminal.item_id}`);
394
+ }
395
+ }
396
+
397
+ const finalizedItems = new Set(entries
398
+ .filter((entry) => entry.type === 'publish-finalization')
399
+ .map((entry) => entry.item_id));
400
+ for (const itemId of finalizedItems) {
401
+ const published = entries.filter((entry) => (
402
+ entry.type === 'publish-final'
403
+ && entry.item_id === itemId
404
+ && entry.outcome?.stdout?.state === 'committed'
405
+ ));
406
+ const finalizations = entries.filter((entry) => (
407
+ entry.type === 'publish-finalization' && entry.item_id === itemId
408
+ ));
409
+ const expectedRevision = published.at(-1)?.outcome.stdout.result.committed_revision;
410
+ const finalizedRevision = finalizations.at(-1)?.committed_revision;
411
+ if (!expectedRevision || !finalizedRevision) continue;
412
+ const item = items.get(itemId);
413
+ const actualRevision = item ? revisionFor(item.bytes) : null;
414
+ const headItem = headItems.get(itemId);
415
+ const headRevision = headItem ? revisionFor(headItem.bytes) : null;
416
+ const workingTreeChanged = actualRevision !== expectedRevision;
417
+ const gitHeadChanged = gitHead !== null
418
+ && headRevision !== expectedRevision
419
+ && headRevision !== finalizedRevision;
420
+ if (!workingTreeChanged && !gitHeadChanged) continue;
421
+ const record = replayed.state.claims.find((entry) => entry.item_id === itemId);
422
+ findings.push({
423
+ code: 'stale-write-detected',
424
+ item_id: itemId,
425
+ actual_revision: workingTreeChanged ? actualRevision : headRevision,
426
+ expected_revision: expectedRevision,
427
+ observed_surface: workingTreeChanged ? 'working-tree' : 'git-head',
428
+ ...(record?.active ? {
429
+ active_fence: {
430
+ ledger_namespace: namespace,
431
+ item_id: itemId,
432
+ owner_id: record.active.owner_id,
433
+ epoch: record.active.epoch,
434
+ },
435
+ } : {}),
436
+ });
437
+ }
438
+
439
+
440
+ for (const record of replayed.state.claims) {
441
+ if (finalizedItems.has(record.item_id)) continue;
442
+ if (record.active === null || observedAt >= record.active.expires_at) continue;
443
+ const committed = entries.filter((entry) => (
444
+ entry.type === 'publish-final'
445
+ && entry.item_id === record.item_id
446
+ && entry.outcome?.stdout?.state === 'committed'
447
+ && entry.outcome.stdout.result.claim_fence.epoch === record.active.epoch
448
+ ));
449
+ const expected = committed.at(-1);
450
+ if (!expected) continue;
451
+ const item = items.get(record.item_id);
452
+ const actualRevision = item ? revisionFor(item.bytes) : null;
453
+ const expectedRevision = expected.outcome.stdout.result.committed_revision;
454
+ if (actualRevision === expectedRevision) continue;
455
+ const earlier = entries.find((entry) => (
456
+ entry.type === 'publish-final'
457
+ && entry.item_id === record.item_id
458
+ && entry.outcome?.stdout?.state === 'committed'
459
+ && entry.outcome.stdout.result.committed_revision === actualRevision
460
+ && BigInt(entry.outcome.stdout.result.claim_fence.epoch) < BigInt(record.active.epoch)
461
+ ));
462
+ findings.push({
463
+ code: earlier ? 'stale-write-detected' : 'revision-regression',
464
+ item_id: record.item_id,
465
+ actual_revision: actualRevision,
466
+ expected_revision: expectedRevision,
467
+ active_fence: {
468
+ ledger_namespace: namespace,
469
+ item_id: record.item_id,
470
+ owner_id: record.active.owner_id,
471
+ epoch: record.active.epoch,
472
+ },
473
+ ...(earlier ? {
474
+ stale_fence: structuredClone(earlier.outcome.stdout.result.claim_fence),
475
+ } : {}),
476
+ });
477
+ }
478
+
479
+ await writeReconcileLog(
480
+ claimReconcileLogPath(path.dirname(path.resolve(ledgerDirectory)), namespace),
481
+ namespace,
482
+ entries,
483
+ );
484
+ try {
485
+ await writeClaimState(storePath, replayed.state);
486
+ } catch {
487
+ // The snapshot is a rebuildable memo. The fsync'd journal is authoritative.
488
+ }
489
+ return {
490
+ entries,
491
+ findings,
492
+ observedAt,
493
+ state: replayed.state,
494
+ unsafe: findings.some((finding) => finding.code !== 'pending-intent-resolved'),
495
+ };
496
+ }
497
+
498
+ export async function verifyClaimJournal({ ledgerDirectory, gitCommonDir, namespace }) {
499
+ const storePath = claimStorePath(gitCommonDir, namespace);
500
+ const journalPath = claimJournalPath(gitCommonDir, namespace);
501
+ try {
502
+ return await withClaimLock(storePath, async () => {
503
+ const reconciled = await reconcileClaimJournal({
504
+ ledgerDirectory,
505
+ gitCommonDir,
506
+ namespace,
507
+ replayed: await replayClaimJournal(journalPath, namespace),
508
+ physicalNow: new Date().toISOString(),
509
+ });
510
+ return {
511
+ exit: reconciled.unsafe ? 6 : 0,
512
+ stdout: {
513
+ ok: !reconciled.unsafe,
514
+ namespace: 'work-claim',
515
+ command: 'claim-verify',
516
+ contract_version: 1,
517
+ state: reconciled.unsafe ? 'unknown' : 'committed',
518
+ result: {
519
+ ledger_namespace: namespace,
520
+ observed_at: reconciled.observedAt,
521
+ findings: reconciled.findings,
522
+ },
523
+ },
524
+ };
525
+ });
526
+ } catch (error) {
527
+ return {
528
+ exit: 6,
529
+ stdout: {
530
+ ok: false,
531
+ namespace: 'work-claim',
532
+ command: 'claim-verify',
533
+ contract_version: 1,
534
+ state: 'unchanged',
535
+ error: {
536
+ code: 'claim-store-unavailable',
537
+ message: 'The durable claim store is unavailable.',
538
+ details: {
539
+ reason: error?.code === 'CLAIM_LOCK_HELD'
540
+ ? 'claim-store-locked'
541
+ : 'claim-store-unreadable',
542
+ },
543
+ },
544
+ },
545
+ };
546
+ }
547
+ }
548
+
549
+ export function operationDigest(request) {
550
+ return `sha256:${createHash('sha256').update(canonicalJson(request)).digest('hex')}`;
551
+ }
552
+
553
+ async function persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, state, storePath) {
554
+ const terminal = await appendClaimEntry(journalPath, {
555
+ type: 'publish-final',
556
+ operation_id: request.operation_id,
557
+ operation_digest: operationDigest(request),
558
+ ledger_namespace: request.ledger_namespace,
559
+ item_id: request.item_id,
560
+ outcome,
561
+ });
562
+ entries.push(terminal);
563
+ const repoRoot = path.dirname(path.resolve(ledgerDirectory));
564
+ await writeReconcileLog(claimReconcileLogPath(repoRoot, namespace), namespace, entries);
565
+ try {
566
+ await writeClaimState(storePath, state);
567
+ } catch {
568
+ // The snapshot is a rebuildable memo. The fsync'd journal is authoritative.
569
+ }
570
+ return outcome;
571
+ }
572
+
573
+ async function validateCandidateLedger(ledgerDirectory, request) {
574
+ const ledger = await loadLedger(path.resolve(ledgerDirectory));
575
+ const candidateBytes = Buffer.from(request.candidate_source_base64, 'base64');
576
+ const source = candidateBytes.toString('utf8');
577
+ const parsed = parseLedgerItemSource(source);
578
+ const target = ledger.items.find((item) => item.data.id === request.item_id);
579
+ if (parsed.error || parsed.data?.id !== request.item_id || !target) {
580
+ return publicationError(request, 'ledger-invalid', 'The candidate ledger is invalid.', {
581
+ item_id: request.item_id,
582
+ reason: parsed.error?.code ?? (target ? 'item-id-mismatch' : 'item-not-found'),
583
+ }, 3);
584
+ }
585
+ const candidate = {
586
+ path: target.path,
587
+ file: target.file,
588
+ bytes: candidateBytes,
589
+ source,
590
+ body: parsed.body,
591
+ data: parsed.data,
592
+ };
593
+ const validation = validateLedger({
594
+ errors: ledger.errors,
595
+ items: ledger.items.map((item) => item.data.id === request.item_id ? candidate : item),
596
+ });
597
+ if (!validation.valid) {
598
+ return publicationError(request, 'ledger-invalid', 'The candidate ledger is invalid.', validation.errors, 3);
599
+ }
600
+ return null;
601
+ }
602
+
603
+ function fenceRejectionReason(request, active, observedAt) {
604
+ if (request.claim_fence.ledger_namespace !== request.ledger_namespace) return 'ledger-namespace-mismatch';
605
+ if (request.claim_fence.item_id !== request.item_id) return 'item-id-mismatch';
606
+ if (active === null) return 'no-active-claim';
607
+ if (active.owner_id !== request.claim_fence.owner_id) return 'owner-mismatch';
608
+ if (active.epoch !== request.claim_fence.epoch) return 'epoch-mismatch';
609
+ if (observedAt >= active.expires_at) return 'claim-expired';
610
+ return null;
611
+ }
612
+
613
+ function mutationFailure(request, mutation) {
614
+ if (mutation.error?.code === 'revision-conflict') {
615
+ return publicationError(request, 'ledger-revision-conflict',
616
+ 'The durable ledger revision no longer matches this publication.', {
617
+ ledger_namespace: request.ledger_namespace,
618
+ item_id: request.item_id,
619
+ expected_revision: request.expected_revision,
620
+ actual_revision: mutation.error.details.actual_revision,
621
+ }, 4);
622
+ }
623
+ if (mutation.error?.code === 'candidate-invalid') {
624
+ return publicationError(request, 'ledger-invalid', 'The candidate ledger is invalid.', mutation.error.details.validation_errors, 3);
625
+ }
626
+ return publicationUnknown(request);
627
+ }
628
+
629
+ function publicationSuccess(request, record, observedAt) {
630
+ return {
631
+ exit: 0,
632
+ stdout: {
633
+ ok: true,
634
+ namespace: 'ledger-publication',
635
+ command: 'publish-claimed',
636
+ contract_version: 1,
637
+ state: 'committed',
638
+ operation_id: request.operation_id,
639
+ result: {
640
+ ledger_namespace: request.ledger_namespace,
641
+ item_id: request.item_id,
642
+ committed_revision: request.candidate_sha256,
643
+ claim_fence: structuredClone(request.claim_fence),
644
+ claim_read_back: readBack(request.ledger_namespace, request.item_id, observedAt, record),
645
+ },
646
+ },
647
+ };
648
+ }
649
+
650
+ function publicationUnknown(request) {
651
+ return publicationError(request, 'publication-outcome-unknown',
652
+ 'The publication outcome could not be determined.', {
653
+ operation_id: request.operation_id,
654
+ ledger_namespace: request.ledger_namespace,
655
+ item_id: request.item_id,
656
+ }, 6, 'unknown');
657
+ }
658
+
659
+ function canonicalBase64Error(request) {
660
+ return publicationError(request, 'invalid-request', 'The candidate source is not canonical base64.', {
661
+ field: 'candidate_source_base64',
662
+ }, 2);
663
+ }
664
+
665
+ function idempotencyConflict(operationId, expectedDigest, actualDigest) {
666
+ return {
667
+ exit: 4,
668
+ stdout: {
669
+ ok: false,
670
+ namespace: 'ledger-publication',
671
+ command: 'publish-claimed',
672
+ contract_version: 1,
673
+ state: 'unchanged',
674
+ operation_id: operationId,
675
+ error: {
676
+ code: 'idempotency-conflict',
677
+ message: 'The operation identity is already bound to a different request.',
678
+ details: {
679
+ operation_id: operationId,
680
+ expected_operation_digest: expectedDigest,
681
+ actual_operation_digest: actualDigest,
682
+ },
683
+ },
684
+ },
685
+ };
686
+ }
687
+
688
+ function publicationError(request, code, message, details, exit, state = 'unchanged') {
689
+ return {
690
+ exit,
691
+ stdout: {
692
+ ok: false,
693
+ namespace: 'ledger-publication',
694
+ command: 'publish-claimed',
695
+ contract_version: 1,
696
+ state,
697
+ ...(request?.operation_id ? { operation_id: request.operation_id } : {}),
698
+ error: { code, message, details },
699
+ },
700
+ };
701
+ }
702
+ function publicationReadError(request, code, message, details, exit = 2) {
703
+ return {
704
+ exit,
705
+ stdout: {
706
+ ok: false,
707
+ namespace: 'ledger-publication',
708
+ command: 'read',
709
+ contract_version: 1,
710
+ state: 'unchanged',
711
+ ...(request?.operation_id ? { operation_id: request.operation_id } : {}),
712
+ error: { code, message, details },
713
+ },
714
+ };
715
+ }
716
+
717
+
718
+ function canonicalJson(value) {
719
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
720
+ if (value !== null && typeof value === 'object') {
721
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
722
+ }
723
+ return JSON.stringify(value);
724
+ }
725
+
726
+ function isExactObject(value, keys) {
727
+ return value !== null
728
+ && typeof value === 'object'
729
+ && !Array.isArray(value)
730
+ && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
731
+ }
732
+
733
+
734
+ async function publicationTestCheckpoint(scenario, point) {
735
+ if (scenario !== `fail:${point}`) return;
736
+ const error = new Error(`test checkpoint failed: ${point}`);
737
+ error.code = 'TEST_CHECKPOINT';
738
+ throw error;
739
+ }