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.
- package/CHANGELOG.md +94 -0
- package/LICENSE +201 -0
- package/README.md +464 -0
- package/adapters/claude-code/entrypoint.js +19 -0
- package/adapters/claude-code/wowbagger-adapter.json +25 -0
- package/adapters/codex/entrypoint.js +11 -0
- package/adapters/codex/wowbagger-adapter.json +25 -0
- package/adapters/opencode/entrypoint.js +11 -0
- package/adapters/opencode/wowbagger-adapter.json +25 -0
- package/bin/wowbagger.js +7 -0
- package/package.json +51 -0
- package/skills/wowbagger/SKILL.md +136 -0
- package/src/adapter/approval.js +135 -0
- package/src/adapter/bootstrap.js +43 -0
- package/src/adapter/context.js +34 -0
- package/src/adapter/core-probe.js +231 -0
- package/src/adapter/describe.js +383 -0
- package/src/adapter/entrypoint-main.js +335 -0
- package/src/adapter/entrypoint-path.js +103 -0
- package/src/adapter/handoff.js +124 -0
- package/src/adapter/instructions.js +106 -0
- package/src/adapter/invoke.js +294 -0
- package/src/adapter/limits.js +26 -0
- package/src/adapter/manifest.js +93 -0
- package/src/adapter/messages.js +15 -0
- package/src/adapter/paths.js +88 -0
- package/src/adapter/process-outcome.js +1116 -0
- package/src/adapter/schema-helpers.js +60 -0
- package/src/claim-capabilities.js +54 -0
- package/src/claim-coordinator.js +85 -0
- package/src/claim-journal.js +236 -0
- package/src/claim-operations.js +138 -0
- package/src/claim-publication.js +739 -0
- package/src/claim-request.js +140 -0
- package/src/claim-store.js +198 -0
- package/src/cli.js +1130 -0
- package/src/dependencies.js +3 -0
- package/src/git-reconciliation.js +62 -0
- package/src/ledger.js +296 -0
- package/src/mint.js +32 -0
- package/src/mutation.js +1979 -0
- package/src/namespace.js +35 -0
- package/src/ready.js +85 -0
- package/src/request.js +246 -0
- package/src/schema-migration.js +300 -0
- 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
|
+
}
|