@dogfood-lab/ingest 1.5.0 → 1.6.0
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/README.md +6 -0
- package/anchor/cli.js +30 -0
- package/anchor/post-anchor.js +38 -9
- package/load-context.js +69 -13
- package/package.json +1 -1
- package/run.js +32 -3
- package/verify-chain.js +183 -45
package/README.md
CHANGED
|
@@ -59,6 +59,12 @@ Flags:
|
|
|
59
59
|
- `--provenance=stub` — accept the claimed provenance without an API call. **Test/dev only — refused in CI.**
|
|
60
60
|
- `--verify-only` — verify and report, write nothing.
|
|
61
61
|
|
|
62
|
+
Standalone audit verb (no submission, no stdin, no `--provenance` — fully offline):
|
|
63
|
+
|
|
64
|
+
- `--verify-chain` — verify the append-only tamper-evident ledger at `indexes/integrity/chain.jsonl`: every record's recomputed digest matches both the ledger's claim and the record's self-claim, the `prev_digest` links are intact, and `seq` is monotonic. Exits `0` when the chain verifies, `1` on the first break (operator-legible output, no raw stack traces).
|
|
65
|
+
- `--reconcile` — also walk `records/` and flag any record file whose `run_id`/`seq` is absent from the ledger (a torn persist that wrote the record but missed the ledger append). An orphan makes the audit exit `1` even with zero chain breaks.
|
|
66
|
+
- `--all` — continue past the first per-record-independent break (digest-mismatch, missing-file) and report every break in one pass. A structural break (non-monotonic `seq`, broken `prev_digest`) still stops the walk. The default stops at the first break — the fail-fast CI gate.
|
|
67
|
+
|
|
62
68
|
Exit codes:
|
|
63
69
|
|
|
64
70
|
- `0` — the record was accepted (and, without `--verify-only`, persisted).
|
package/anchor/cli.js
CHANGED
|
@@ -109,6 +109,36 @@ export async function handleAnchorPost(repoRoot, opts = {}) {
|
|
|
109
109
|
event: { stage: 'error', failed_stage: 'anchor_post', engine_result: receipt.engine_result },
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
// INGEST-PROACT-002: the anchor LANDED on-chain (receipt.ok) but writing the
|
|
114
|
+
// receipt back into the local manifest failed. This is NOT a failure of the
|
|
115
|
+
// anchor — the on-chain fact is final. Surface a loud WARNING and exit 0 so
|
|
116
|
+
// the operator does not re-post (which would double-spend the fee). The event
|
|
117
|
+
// still carries tx_hash so the on-chain fact survives a log grep, and the
|
|
118
|
+
// recovery path (re-run --anchor-post to repair the manifest idempotently) is
|
|
119
|
+
// named in the message.
|
|
120
|
+
if (receipt.record_failed) {
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
exitCode: 0,
|
|
124
|
+
lines: [
|
|
125
|
+
`anchor-post: anchor LANDED on-chain (tx ${receipt.tx_hash}) on ${receipt.network}`,
|
|
126
|
+
` ledger_index: ${receipt.ledger_index ?? '(unavailable)'}`,
|
|
127
|
+
` WARNING: recording the receipt locally FAILED: ${receipt.record_failed.error}`,
|
|
128
|
+
` The on-chain anchor is final — do NOT re-post (it would spend another fee).`,
|
|
129
|
+
` Recover: re-run --anchor-post to repair the manifest idempotently, or re-fetch`,
|
|
130
|
+
` tx ${receipt.tx_hash} and run --anchor-verify --anchor-tx <file>.`,
|
|
131
|
+
],
|
|
132
|
+
event: {
|
|
133
|
+
stage: 'anchor_post_record_failed',
|
|
134
|
+
anchor_seq: receipt.anchor_seq,
|
|
135
|
+
tx_hash: receipt.tx_hash,
|
|
136
|
+
ledger_index: receipt.ledger_index,
|
|
137
|
+
record_error: receipt.record_failed.error,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
return {
|
|
113
143
|
ok: true,
|
|
114
144
|
exitCode: 0,
|
package/anchor/post-anchor.js
CHANGED
|
@@ -222,10 +222,22 @@ export async function postAnchor(repoRoot, opts = {}) {
|
|
|
222
222
|
// (1) Ensure there is a manifest to post. Re-running compute is idempotent
|
|
223
223
|
// (append-only). If nothing new is pending, post the LAST anchor manifest
|
|
224
224
|
// so a re-post after a transient network failure still binds the same root.
|
|
225
|
-
|
|
225
|
+
//
|
|
226
|
+
// Thread the LAST anchor's algo into the recompute. compute defaults to the
|
|
227
|
+
// v2 algo, so a chain previously anchored with the v1 (historical-
|
|
228
|
+
// reproduction) algo would otherwise be recomputed as v2 — re-posting that
|
|
229
|
+
// anchor would either bind a divergent v2 root or collide append-only with
|
|
230
|
+
// the stored v1 manifest. Reproducing the SAME algo keeps a re-post
|
|
231
|
+
// idempotent and binds the root the operator actually anchored.
|
|
232
|
+
const lastAnchor = readLastAnchor(repoRoot);
|
|
233
|
+
const computed = computeAnchor(repoRoot, {
|
|
234
|
+
mode,
|
|
235
|
+
network,
|
|
236
|
+
...(lastAnchor?.algo ? { algo: lastAnchor.algo } : {}),
|
|
237
|
+
});
|
|
226
238
|
let manifest;
|
|
227
239
|
if (computed.empty) {
|
|
228
|
-
manifest =
|
|
240
|
+
manifest = lastAnchor;
|
|
229
241
|
if (!manifest) {
|
|
230
242
|
const e = new Error(`nothing to anchor: ${computed.reason}`);
|
|
231
243
|
e.code = 'ANCHOR_NOTHING_TO_POST';
|
|
@@ -285,14 +297,31 @@ export async function postAnchor(repoRoot, opts = {}) {
|
|
|
285
297
|
|
|
286
298
|
// (4) Record the on-chain facts back into the anchor manifest. Injected in
|
|
287
299
|
// tests; in production it patches the manifest file in place.
|
|
300
|
+
//
|
|
301
|
+
// INGEST-PROACT-002: the anchor has ALREADY LANDED on-chain at this point
|
|
302
|
+
// (receipt.ok) — an irreversible, fee-paid, citable fact. If recordPost
|
|
303
|
+
// throws (disk full, EPERM, the manifest deleted between compute and post),
|
|
304
|
+
// the on-chain landing MUST NOT be reported as a failure: a false failure
|
|
305
|
+
// invites a re-post that double-spends the fee against a divergent state.
|
|
306
|
+
// We keep receipt.ok true and attach a distinct `record_failed` outcome so
|
|
307
|
+
// the caller surfaces a loud WARNING (exit 0, not exit 2) naming the
|
|
308
|
+
// tx_hash and the idempotent recovery path. The on-chain fact survives on
|
|
309
|
+
// the receipt regardless of whether the local manifest patch succeeded.
|
|
288
310
|
if (receipt.ok && typeof opts.deps?.recordPost === 'function') {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
311
|
+
try {
|
|
312
|
+
opts.deps.recordPost(manifest.anchor_seq, {
|
|
313
|
+
tx_hash: receipt.tx_hash,
|
|
314
|
+
ledger_index: receipt.ledger_index,
|
|
315
|
+
close_time_iso: receipt.close_time_iso,
|
|
316
|
+
wallet_address: receipt.wallet_address,
|
|
317
|
+
network,
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
receipt.record_failed = {
|
|
321
|
+
error: err && err.message ? err.message : String(err),
|
|
322
|
+
code: err && err.code ? err.code : null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
296
325
|
}
|
|
297
326
|
|
|
298
327
|
return receipt;
|
package/load-context.js
CHANGED
|
@@ -201,6 +201,25 @@ export function localScenarioFetcher(repoRoot) {
|
|
|
201
201
|
*/
|
|
202
202
|
export const GITHUB_SCENARIO_FETCH_TIMEOUT_MS = 30000;
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Default number of fetch attempts (INGEST-PROACT-003). A transient GitHub-API
|
|
206
|
+
* fault (5xx, 429, network blip) should not turn a loadable scenario into a hard
|
|
207
|
+
* rejection; a small bounded retry rides out the hiccup. Kept small — three
|
|
208
|
+
* attempts is enough to clear a momentary throttle without amplifying a real
|
|
209
|
+
* outage into a multi-minute stall (the AbortController timeout still bounds each
|
|
210
|
+
* attempt, and a 404/invalid-id is never retried).
|
|
211
|
+
*/
|
|
212
|
+
export const GITHUB_SCENARIO_FETCH_ATTEMPTS = 3;
|
|
213
|
+
|
|
214
|
+
/** Initial backoff before the first retry (ms); doubles per attempt, capped. */
|
|
215
|
+
const RETRY_BASE_MS = 250;
|
|
216
|
+
const RETRY_MAX_MS = 2000;
|
|
217
|
+
|
|
218
|
+
/** Default async backoff. Injectable (`opts.sleepImpl`) so tests run instantly. */
|
|
219
|
+
function defaultSleep(ms) {
|
|
220
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
221
|
+
}
|
|
222
|
+
|
|
204
223
|
/**
|
|
205
224
|
* GitHub scenario fetcher. Loads scenario definitions from a source repo
|
|
206
225
|
* via the GitHub API at a specific commit SHA.
|
|
@@ -219,28 +238,45 @@ export const GITHUB_SCENARIO_FETCH_TIMEOUT_MS = 30000;
|
|
|
219
238
|
* Both surfaces honour the per-request AbortController timeout
|
|
220
239
|
* (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
|
|
221
240
|
*
|
|
241
|
+
* INGEST-PROACT-003: each call makes up to `opts.attempts`
|
|
242
|
+
* (`GITHUB_SCENARIO_FETCH_ATTEMPTS`) tries with exponential backoff, retrying
|
|
243
|
+
* ONLY the transient classes — request timeout, HTTP 5xx, HTTP 429, and network
|
|
244
|
+
* rejects. A 404 (`not_found`), an `invalid_id`, and a `parse_error` are
|
|
245
|
+
* DEFINITIVE answers and are returned immediately without a retry (mirrors the
|
|
246
|
+
* EPERM/EBUSY-only discipline in `lib/rename-with-retry.js`). The backoff sleep
|
|
247
|
+
* is injectable (`opts.sleepImpl`) so tests do not actually wait.
|
|
248
|
+
*
|
|
222
249
|
* @param {string} token - GitHub PAT
|
|
223
250
|
* @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
|
|
224
251
|
* @param {string} commitSha - Commit to fetch scenarios from
|
|
225
|
-
* @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
|
|
252
|
+
* @param {{ timeoutMs?: number, fetchImpl?: typeof fetch, attempts?: number, sleepImpl?: (ms: number) => Promise<void> }} [opts]
|
|
226
253
|
* @returns {{ fetch(scenarioId: string): Promise<object|null>, fetchWithReason(scenarioId: string): Promise<{ scenario: object|null, reason?: string }> }}
|
|
227
254
|
*/
|
|
228
255
|
export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
229
256
|
const timeoutMs = opts.timeoutMs ?? GITHUB_SCENARIO_FETCH_TIMEOUT_MS;
|
|
230
257
|
const fetchImpl = opts.fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
|
|
258
|
+
const attempts = opts.attempts ?? GITHUB_SCENARIO_FETCH_ATTEMPTS;
|
|
259
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
231
260
|
|
|
232
261
|
const [org, repo] = repoSlug.split('/');
|
|
233
|
-
|
|
262
|
+
// commitSha is interpolated into the authenticated (Bearer-token) GitHub API
|
|
263
|
+
// URL's `?ref=` — a shape guard (lowercase-hex, 7–40 chars) refuses anything
|
|
264
|
+
// that could re-target the ref or inject query params, matching how org/repo
|
|
265
|
+
// and scenarioId are guarded below. encodeURIComponent alone would NOT reject
|
|
266
|
+
// a re-targeted ref (e.g. a branch name), so the shape guard is the floor.
|
|
267
|
+
if (
|
|
268
|
+
!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo) ||
|
|
269
|
+
typeof commitSha !== 'string' || !/^[0-9a-f]{7,40}$/.test(commitSha)
|
|
270
|
+
) {
|
|
234
271
|
return {
|
|
235
272
|
async fetch() { return null; },
|
|
236
273
|
async fetchWithReason() { return { scenario: null, reason: 'invalid_id' }; }
|
|
237
274
|
};
|
|
238
275
|
}
|
|
239
276
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
277
|
+
// One bounded attempt. Returns `{ scenario, reason, retryable }`; the loop
|
|
278
|
+
// below decides whether to retry on `retryable`.
|
|
279
|
+
async function attemptOnce(scenarioId) {
|
|
244
280
|
const path = `dogfood/scenarios/${scenarioId}.yaml`;
|
|
245
281
|
const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
|
|
246
282
|
|
|
@@ -262,16 +298,20 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
262
298
|
signal: controller.signal
|
|
263
299
|
});
|
|
264
300
|
if (!resp.ok) {
|
|
265
|
-
|
|
301
|
+
// A 5xx server error or a 429 rate-limit is transient — retry. Any
|
|
302
|
+
// other non-ok (notably 404) is a definitive answer; do not retry.
|
|
303
|
+
const retryable = resp.status >= 500 || resp.status === 429;
|
|
304
|
+
return { scenario: null, reason: 'not_found', retryable };
|
|
266
305
|
}
|
|
267
306
|
text = await resp.text();
|
|
268
307
|
} catch (err) {
|
|
269
308
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
270
|
-
|
|
309
|
+
// A timed-out request may succeed on a retry.
|
|
310
|
+
return { scenario: null, reason: 'timeout', retryable: true };
|
|
271
311
|
}
|
|
272
|
-
// Network reject, DNS failure, etc. — surface as not_found
|
|
273
|
-
// back-compat with the legacy null contract.
|
|
274
|
-
return { scenario: null, reason: 'not_found' };
|
|
312
|
+
// Network reject, DNS failure, etc. — transient; surface as not_found
|
|
313
|
+
// for back-compat with the legacy null contract, but allow a retry.
|
|
314
|
+
return { scenario: null, reason: 'not_found', retryable: true };
|
|
275
315
|
} finally {
|
|
276
316
|
clearTimeout(timer);
|
|
277
317
|
}
|
|
@@ -279,12 +319,28 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
279
319
|
try {
|
|
280
320
|
const scenario = yaml.load(text);
|
|
281
321
|
if (!scenario || typeof scenario !== 'object') {
|
|
282
|
-
return { scenario: null, reason: 'parse_error' };
|
|
322
|
+
return { scenario: null, reason: 'parse_error', retryable: false };
|
|
283
323
|
}
|
|
284
324
|
return { scenario };
|
|
285
325
|
} catch {
|
|
286
|
-
return { scenario: null, reason: 'parse_error' };
|
|
326
|
+
return { scenario: null, reason: 'parse_error', retryable: false };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function fetchWithReason(scenarioId) {
|
|
331
|
+
if (!/^[\w-]+$/.test(scenarioId)) {
|
|
332
|
+
return { scenario: null, reason: 'invalid_id' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let last;
|
|
336
|
+
for (let i = 0; i < attempts; i++) {
|
|
337
|
+
last = await attemptOnce(scenarioId);
|
|
338
|
+
if (last.scenario || !last.retryable || i === attempts - 1) break;
|
|
339
|
+
await sleep(Math.min(RETRY_BASE_MS * (1 << i), RETRY_MAX_MS));
|
|
287
340
|
}
|
|
341
|
+
// Strip the internal `retryable` flag from the public contract.
|
|
342
|
+
const { retryable: _drop, ...result } = last;
|
|
343
|
+
return result;
|
|
288
344
|
}
|
|
289
345
|
|
|
290
346
|
return {
|
package/package.json
CHANGED
package/run.js
CHANGED
|
@@ -544,6 +544,13 @@ if (isMain) {
|
|
|
544
544
|
let provenanceMode = null;
|
|
545
545
|
let verifyOnlyFlag = false;
|
|
546
546
|
let verifyChainFlag = false;
|
|
547
|
+
// --verify-chain modifiers (only meaningful alongside it):
|
|
548
|
+
// --reconcile → collectOrphans (INGEST-PROACT-001): also flag on-disk records
|
|
549
|
+
// absent from the ledger (a torn persist). Makes the audit fail on orphans.
|
|
550
|
+
// --all → collectAllBreaks (INGEST-PROACT-004): continue past the first
|
|
551
|
+
// per-record-independent break and report every break in one pass.
|
|
552
|
+
let reconcileFlag = false;
|
|
553
|
+
let allBreaksFlag = false;
|
|
547
554
|
// Anchor verbs (optional, off-by-default, operator-run). --anchor-compute and
|
|
548
555
|
// --anchor-verify are fully offline (never import xrpl); --anchor-post lazily
|
|
549
556
|
// loads the optional xrpl package and needs XRPL_SEED.
|
|
@@ -574,7 +581,13 @@ if (isMain) {
|
|
|
574
581
|
arg = arg.slice(0, eq);
|
|
575
582
|
}
|
|
576
583
|
}
|
|
577
|
-
|
|
584
|
+
// A space-form value is the NEXT token only when it is not itself a flag —
|
|
585
|
+
// otherwise `--flag --next` would swallow `--next` as `--flag`'s value and
|
|
586
|
+
// silently drop it. A `--`-prefixed next token means this flag has no value,
|
|
587
|
+
// so it falls through to its "requires a value" path (for `--provenance`,
|
|
588
|
+
// the downstream "--provenance flag is required" error).
|
|
589
|
+
const nextIsValue = args[i + 1] !== undefined && !args[i + 1].startsWith('--');
|
|
590
|
+
const hasValue = inlineValue !== null || nextIsValue;
|
|
578
591
|
const takeValue = () => (inlineValue !== null ? inlineValue : args[++i]);
|
|
579
592
|
|
|
580
593
|
if (arg === '--provenance' && hasValue) {
|
|
@@ -610,6 +623,14 @@ if (isMain) {
|
|
|
610
623
|
// indexes/integrity/chain.jsonl, fully offline. No submission, no stdin,
|
|
611
624
|
// no provenance — a standalone audit command.
|
|
612
625
|
verifyChainFlag = true;
|
|
626
|
+
} else if (arg === '--reconcile') {
|
|
627
|
+
// Modifier for --verify-chain: also reconcile on-disk records against the
|
|
628
|
+
// ledger and fail on any orphan (INGEST-PROACT-001).
|
|
629
|
+
reconcileFlag = true;
|
|
630
|
+
} else if (arg === '--all') {
|
|
631
|
+
// Modifier for --verify-chain: report every per-record-independent break
|
|
632
|
+
// instead of stopping at the first (INGEST-PROACT-004).
|
|
633
|
+
allBreaksFlag = true;
|
|
613
634
|
} else if (arg === '--anchor-compute') {
|
|
614
635
|
// Optional XRPL anchor: compute + write the next anchor manifest. Offline.
|
|
615
636
|
anchorComputeFlag = true;
|
|
@@ -648,14 +669,22 @@ if (isMain) {
|
|
|
648
669
|
// stdin or demand a --provenance flag. Exit 0 when the chain verifies, 1 on
|
|
649
670
|
// the first break (operator-legible output, no raw stack traces).
|
|
650
671
|
if (verifyChainFlag) {
|
|
651
|
-
const result = verifyChain(repoRoot
|
|
672
|
+
const result = verifyChain(repoRoot, {
|
|
673
|
+
collectOrphans: reconcileFlag,
|
|
674
|
+
collectAllBreaks: allBreaksFlag,
|
|
675
|
+
});
|
|
652
676
|
logStage(result.ok ? 'verify_chain_complete' : 'error', {
|
|
653
677
|
correlation_id: synthCorrelationId(),
|
|
654
678
|
...(result.ok ? {} : { failed_stage: 'verify_chain' }),
|
|
655
679
|
verified: result.count,
|
|
656
680
|
head_digest: result.head_digest,
|
|
657
681
|
chain_ok: result.ok,
|
|
658
|
-
...(result.break ? { break_seq: result.break.seq, break_reason: result.break.reason } : {})
|
|
682
|
+
...(result.break ? { break_seq: result.break.seq, break_reason: result.break.reason } : {}),
|
|
683
|
+
// INGEST-PROACT-004 / -001: surface the full corruption scope when the
|
|
684
|
+
// operator asked for it, so a grep of the NDJSON shows how many breaks and
|
|
685
|
+
// orphans were found, not just the first break.
|
|
686
|
+
...(Array.isArray(result.breaks) ? { break_count: result.breaks.length } : {}),
|
|
687
|
+
...(Array.isArray(result.orphans) ? { orphan_count: result.orphans.length } : {})
|
|
659
688
|
});
|
|
660
689
|
const lines = formatChainResult(result);
|
|
661
690
|
if (result.ok) {
|
package/verify-chain.js
CHANGED
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import { existsSync, readFileSync } from 'node:fs';
|
|
42
|
-
import { join } from 'node:path';
|
|
42
|
+
import { join, relative, sep } from 'node:path';
|
|
43
43
|
|
|
44
44
|
import { submissionDigest, GENESIS_DIGEST } from './lib/integrity.js';
|
|
45
45
|
import { readChainManifest } from './lib/chain-manifest.js';
|
|
46
|
+
import { findJsonFiles } from './rebuild-indexes.js';
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* @typedef {object} ChainBreak
|
|
@@ -51,111 +52,221 @@ import { readChainManifest } from './lib/chain-manifest.js';
|
|
|
51
52
|
* @property {string} reason - Operator-legible description of what failed.
|
|
52
53
|
*/
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {object} ChainOrphan
|
|
57
|
+
* @property {string|null} run_id - The orphan record's run_id (from its
|
|
58
|
+
* integrity block or its body), if known.
|
|
59
|
+
* @property {number|null} seq - The orphan record's self-claimed integrity.seq.
|
|
60
|
+
* @property {string} path - Repo-relative, forward-slashed path to the orphan file.
|
|
61
|
+
* @property {string} reason - Operator-legible description of why it is an orphan.
|
|
62
|
+
*/
|
|
63
|
+
|
|
54
64
|
/**
|
|
55
65
|
* @typedef {object} ChainVerifyResult
|
|
56
|
-
* @property {boolean} ok - True when the whole chain verifies
|
|
66
|
+
* @property {boolean} ok - True when the whole chain verifies (and, when
|
|
67
|
+
* collectOrphans is set, no orphan records exist).
|
|
57
68
|
* @property {number} count - Number of entries verified (0 for an empty chain).
|
|
58
69
|
* @property {string} head_digest - submission_digest of the last entry, or
|
|
59
70
|
* GENESIS_DIGEST for an empty chain.
|
|
60
71
|
* @property {ChainBreak|null} break - First break, or null when ok.
|
|
72
|
+
* @property {ChainBreak[]} [breaks] - All independent breaks (only present when
|
|
73
|
+
* `collectAllBreaks` is set). The first element equals `break`.
|
|
74
|
+
* @property {ChainOrphan[]} [orphans] - Records on disk but absent from the
|
|
75
|
+
* ledger (only present when `collectOrphans` is set).
|
|
61
76
|
*/
|
|
62
77
|
|
|
63
78
|
/**
|
|
64
79
|
* Verify the integrity chain at `<repoRoot>/indexes/integrity/chain.jsonl`.
|
|
65
80
|
*
|
|
66
81
|
* @param {string} repoRoot - Absolute path to the testing-os repo root.
|
|
82
|
+
* @param {object} [opts]
|
|
83
|
+
* @param {boolean} [opts.collectAllBreaks=false] - INGEST-PROACT-004: continue
|
|
84
|
+
* past the FIRST break and return a `breaks[]` array of every
|
|
85
|
+
* per-record-independent break (digest-mismatch, missing-file). The default
|
|
86
|
+
* (false) stops at the first break — the fail-fast CI gate semantics. A
|
|
87
|
+
* structural break (non-monotonic seq, broken prev-link) still stops the
|
|
88
|
+
* walk even under this flag, because everything after it is unverifiable
|
|
89
|
+
* relative to a now-untrustworthy chain order.
|
|
90
|
+
* @param {boolean} [opts.collectOrphans=false] - INGEST-PROACT-001: after the
|
|
91
|
+
* chain walk, reconcile the on-disk records against the ledger and return an
|
|
92
|
+
* `orphans[]` array of any record file whose seq/run_id is absent from the
|
|
93
|
+
* ledger (a torn write between record-write and ledger-append). An orphan
|
|
94
|
+
* makes the result `ok: false` — the audit DETECTS the orphan instead of
|
|
95
|
+
* silently passing while a real record lives outside the tamper-evident chain.
|
|
67
96
|
* @returns {ChainVerifyResult}
|
|
68
97
|
*/
|
|
69
|
-
export function verifyChain(repoRoot) {
|
|
98
|
+
export function verifyChain(repoRoot, opts = {}) {
|
|
99
|
+
const collectAllBreaks = opts.collectAllBreaks === true;
|
|
100
|
+
const collectOrphans = opts.collectOrphans === true;
|
|
101
|
+
|
|
70
102
|
let entries;
|
|
71
103
|
try {
|
|
72
104
|
entries = readChainManifest(repoRoot);
|
|
73
105
|
} catch (err) {
|
|
74
|
-
// A corrupt (non-JSON) manifest line is itself a tamper signal.
|
|
106
|
+
// A corrupt (non-JSON) manifest line is itself a tamper signal. We cannot
|
|
107
|
+
// trust the ledger at all here, so orphan reconciliation is not meaningful.
|
|
108
|
+
const brk = { seq: -1, run_id: null, reason: err.message };
|
|
75
109
|
return {
|
|
76
110
|
ok: false,
|
|
77
111
|
count: 0,
|
|
78
112
|
head_digest: GENESIS_DIGEST,
|
|
79
|
-
break:
|
|
113
|
+
break: brk,
|
|
114
|
+
...(collectAllBreaks ? { breaks: [brk] } : {}),
|
|
115
|
+
...(collectOrphans ? { orphans: [] } : {}),
|
|
80
116
|
};
|
|
81
117
|
}
|
|
82
118
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
119
|
+
const headDigest = entries.length === 0
|
|
120
|
+
? GENESIS_DIGEST
|
|
121
|
+
: (entries[entries.length - 1].submission_digest ?? GENESIS_DIGEST);
|
|
87
122
|
|
|
123
|
+
const breaks = [];
|
|
88
124
|
let prevDigest = GENESIS_DIGEST;
|
|
89
125
|
|
|
90
126
|
for (let i = 0; i < entries.length; i++) {
|
|
91
127
|
const entry = entries[i];
|
|
92
128
|
const seq = entry.seq;
|
|
93
129
|
const runId = entry.run_id ?? null;
|
|
130
|
+
const record = (reason) => breaks.push({ seq, run_id: runId, reason });
|
|
94
131
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// (3) Monotonic seq: 0,1,2,…
|
|
132
|
+
// (3) Monotonic seq and (2) chain-link are STRUCTURAL: a break here means
|
|
133
|
+
// the chain order itself is untrustworthy, so everything downstream is
|
|
134
|
+
// unverifiable relative to it. Record the break and stop — even under
|
|
135
|
+
// collectAllBreaks — rather than emitting a cascade of meaningless
|
|
136
|
+
// downstream prev-link errors.
|
|
103
137
|
if (seq !== i) {
|
|
104
|
-
|
|
138
|
+
record(`seq is not monotonic: expected ${i}, found ${seq}`);
|
|
139
|
+
break;
|
|
105
140
|
}
|
|
106
|
-
|
|
107
|
-
// (2) Chain link: this entry's prev_digest must equal the previous entry's
|
|
108
|
-
// submission_digest (GENESIS for the first entry).
|
|
109
141
|
if (entry.prev_digest !== prevDigest) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
142
|
+
record(`broken prev_digest link: expected ${prevDigest}, found ${entry.prev_digest}`);
|
|
143
|
+
break;
|
|
113
144
|
}
|
|
114
145
|
|
|
115
|
-
// (1)
|
|
146
|
+
// (1) Per-record-INDEPENDENT checks: missing-file and digest-mismatch.
|
|
147
|
+
// Each is judged on this record alone, so under collectAllBreaks we record
|
|
148
|
+
// the break and CONTINUE to surface the full corruption scope. Under the
|
|
149
|
+
// default we record the first and stop.
|
|
116
150
|
const recordPath = join(repoRoot, entry.path);
|
|
117
151
|
if (!existsSync(recordPath)) {
|
|
118
|
-
|
|
152
|
+
record(`record file missing at ${entry.path} (could not read to recompute digest)`);
|
|
153
|
+
if (!collectAllBreaks) break;
|
|
154
|
+
prevDigest = entry.submission_digest;
|
|
155
|
+
continue;
|
|
119
156
|
}
|
|
120
157
|
|
|
121
|
-
let
|
|
158
|
+
let parsed;
|
|
122
159
|
try {
|
|
123
|
-
|
|
160
|
+
parsed = JSON.parse(readFileSync(recordPath, 'utf-8'));
|
|
124
161
|
} catch (err) {
|
|
125
|
-
|
|
162
|
+
record(`record file at ${entry.path} is not valid JSON: ${err.message}`);
|
|
163
|
+
if (!collectAllBreaks) break;
|
|
164
|
+
prevDigest = entry.submission_digest;
|
|
165
|
+
continue;
|
|
126
166
|
}
|
|
127
167
|
|
|
128
|
-
const recomputed = submissionDigest(
|
|
129
|
-
|
|
130
|
-
// The recomputed digest must equal the ledger's claim.
|
|
168
|
+
const recomputed = submissionDigest(parsed);
|
|
131
169
|
if (recomputed !== entry.submission_digest) {
|
|
132
|
-
|
|
170
|
+
record(
|
|
133
171
|
`digest mismatch: recomputed ${recomputed} but manifest claims ${entry.submission_digest} ` +
|
|
134
172
|
`— record at ${entry.path} was modified after persist`
|
|
135
173
|
);
|
|
174
|
+
if (!collectAllBreaks) break;
|
|
175
|
+
prevDigest = entry.submission_digest;
|
|
176
|
+
continue;
|
|
136
177
|
}
|
|
137
178
|
|
|
138
|
-
|
|
139
|
-
const selfDigest = record.integrity?.submission_digest;
|
|
179
|
+
const selfDigest = parsed.integrity?.submission_digest;
|
|
140
180
|
if (selfDigest !== recomputed) {
|
|
141
|
-
|
|
181
|
+
record(
|
|
142
182
|
`record self-digest mismatch: record.integrity.submission_digest is ` +
|
|
143
183
|
`${selfDigest ?? '(absent)'} but recomputed ${recomputed} — record at ${entry.path} ` +
|
|
144
184
|
`was modified after persist`
|
|
145
185
|
);
|
|
186
|
+
if (!collectAllBreaks) break;
|
|
187
|
+
prevDigest = entry.submission_digest;
|
|
188
|
+
continue;
|
|
146
189
|
}
|
|
147
190
|
|
|
148
191
|
prevDigest = entry.submission_digest;
|
|
149
192
|
}
|
|
150
193
|
|
|
194
|
+
const orphans = collectOrphans ? collectOrphanRecords(repoRoot, entries) : null;
|
|
195
|
+
|
|
196
|
+
const ok = breaks.length === 0 && (orphans === null || orphans.length === 0);
|
|
151
197
|
return {
|
|
152
|
-
ok
|
|
198
|
+
ok,
|
|
153
199
|
count: entries.length,
|
|
154
|
-
head_digest:
|
|
155
|
-
break: null,
|
|
200
|
+
head_digest: headDigest,
|
|
201
|
+
break: breaks.length > 0 ? breaks[0] : null,
|
|
202
|
+
...(collectAllBreaks ? { breaks } : {}),
|
|
203
|
+
...(collectOrphans ? { orphans } : {}),
|
|
156
204
|
};
|
|
157
205
|
}
|
|
158
206
|
|
|
207
|
+
/**
|
|
208
|
+
* INGEST-PROACT-001 — reconcile on-disk records against the ledger.
|
|
209
|
+
*
|
|
210
|
+
* Walks every `*.json` under `records/` (accepted AND `_rejected/`) and flags
|
|
211
|
+
* any record file whose `(run_id, seq)` identity is absent from the ledger.
|
|
212
|
+
* Such a file is an ORPHAN: persist wrote the record but the ledger append did
|
|
213
|
+
* not happen (a crash in the torn window between the two steps), so the record
|
|
214
|
+
* exists OUTSIDE the tamper-evident chain and the plain chain-walk is blind to
|
|
215
|
+
* it. The chain-manifest's own ledger lines are NOT under `records/`, so they
|
|
216
|
+
* are never mistaken for orphans.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} repoRoot
|
|
219
|
+
* @param {Array<object>} entries - The ledger entries (already read).
|
|
220
|
+
* @returns {ChainOrphan[]}
|
|
221
|
+
*/
|
|
222
|
+
function collectOrphanRecords(repoRoot, entries) {
|
|
223
|
+
// Index the ledger by run_id and by repo-relative path so a record matches if
|
|
224
|
+
// EITHER identity is present — a record is ledgered as long as its line exists.
|
|
225
|
+
const ledgerRunIds = new Set();
|
|
226
|
+
const ledgerPaths = new Set();
|
|
227
|
+
for (const e of entries) {
|
|
228
|
+
if (e.run_id) ledgerRunIds.add(e.run_id);
|
|
229
|
+
if (e.path) ledgerPaths.add(e.path);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const orphans = [];
|
|
233
|
+
const recordsDir = join(repoRoot, 'records');
|
|
234
|
+
for (const absPath of findJsonFiles(recordsDir)) {
|
|
235
|
+
const relPath = relative(repoRoot, absPath).split(sep).join('/');
|
|
236
|
+
if (ledgerPaths.has(relPath)) continue;
|
|
237
|
+
|
|
238
|
+
let record;
|
|
239
|
+
try {
|
|
240
|
+
record = JSON.parse(readFileSync(absPath, 'utf-8'));
|
|
241
|
+
} catch {
|
|
242
|
+
// A record file we cannot even parse, that is also absent from the ledger,
|
|
243
|
+
// is still an orphan — report it with what little identity we have.
|
|
244
|
+
orphans.push({
|
|
245
|
+
run_id: null,
|
|
246
|
+
seq: null,
|
|
247
|
+
path: relPath,
|
|
248
|
+
reason: `record file present on disk but absent from the ledger, and not valid JSON`,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const runId = record.run_id ?? null;
|
|
254
|
+
if (runId && ledgerRunIds.has(runId)) continue;
|
|
255
|
+
|
|
256
|
+
orphans.push({
|
|
257
|
+
run_id: runId,
|
|
258
|
+
seq: record.integrity?.seq ?? null,
|
|
259
|
+
path: relPath,
|
|
260
|
+
reason:
|
|
261
|
+
`record present on disk but absent from the ledger — a torn persist ` +
|
|
262
|
+
`(record written, ledger append missed). Re-run ingest for this record ` +
|
|
263
|
+
`or rebuild the chain to admit it into the tamper-evident ledger.`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return orphans;
|
|
268
|
+
}
|
|
269
|
+
|
|
159
270
|
/**
|
|
160
271
|
* Render a verifyChain result as operator-legible lines (no raw stack traces).
|
|
161
272
|
* Returned as an array so callers can choose the sink (console.log/console.error).
|
|
@@ -165,14 +276,41 @@ export function verifyChain(repoRoot) {
|
|
|
165
276
|
*/
|
|
166
277
|
export function formatChainResult(result) {
|
|
167
278
|
if (result.ok) {
|
|
168
|
-
|
|
279
|
+
const lines = [
|
|
169
280
|
`integrity chain OK: ${result.count} record(s) verified`,
|
|
170
281
|
`head digest: ${result.head_digest}`,
|
|
171
282
|
];
|
|
283
|
+
if (Array.isArray(result.orphans)) {
|
|
284
|
+
lines.push(`reconciliation: 0 orphan record(s) on disk`);
|
|
285
|
+
}
|
|
286
|
+
return lines;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const lines = [];
|
|
290
|
+
|
|
291
|
+
// INGEST-PROACT-004: when collectAllBreaks populated breaks[], list every
|
|
292
|
+
// independent break so the operator sees the full corruption scope in one
|
|
293
|
+
// pass. Otherwise report the single first break (the CI-gate default).
|
|
294
|
+
const allBreaks = Array.isArray(result.breaks) ? result.breaks : (result.break ? [result.break] : []);
|
|
295
|
+
if (allBreaks.length > 1) {
|
|
296
|
+
lines.push(`integrity chain BROKEN: ${allBreaks.length} break(s) found`);
|
|
297
|
+
for (const b of allBreaks) {
|
|
298
|
+
lines.push(` - seq ${b.seq}${b.run_id ? ` (run_id: ${b.run_id})` : ''}: ${b.reason}`);
|
|
299
|
+
}
|
|
300
|
+
} else if (allBreaks.length === 1) {
|
|
301
|
+
const b = allBreaks[0];
|
|
302
|
+
lines.push(`integrity chain BROKEN at seq ${b.seq}${b.run_id ? ` (run_id: ${b.run_id})` : ''}`);
|
|
303
|
+
lines.push(`reason: ${b.reason}`);
|
|
172
304
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
305
|
+
|
|
306
|
+
// INGEST-PROACT-001: an orphan makes the result not-ok even with zero chain
|
|
307
|
+
// breaks — name each orphan file + run_id and the recovery action.
|
|
308
|
+
if (Array.isArray(result.orphans) && result.orphans.length > 0) {
|
|
309
|
+
lines.push(`reconciliation: ${result.orphans.length} orphan record(s) on disk but absent from the ledger`);
|
|
310
|
+
for (const o of result.orphans) {
|
|
311
|
+
lines.push(` - ${o.path}${o.run_id ? ` (run_id: ${o.run_id})` : ''}: ${o.reason}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return lines;
|
|
178
316
|
}
|