@dogfood-lab/ingest 1.5.0 → 1.7.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 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,
@@ -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
- const computed = computeAnchor(repoRoot, { mode, network });
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 = readLastAnchor(repoRoot);
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
- opts.deps.recordPost(manifest.anchor_seq, {
290
- tx_hash: receipt.tx_hash,
291
- ledger_index: receipt.ledger_index,
292
- close_time_iso: receipt.close_time_iso,
293
- wallet_address: receipt.wallet_address,
294
- network,
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
- if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
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
- async function fetchWithReason(scenarioId) {
241
- if (!/^[\w-]+$/.test(scenarioId)) {
242
- return { scenario: null, reason: 'invalid_id' };
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
- return { scenario: null, reason: 'not_found' };
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
- return { scenario: null, reason: 'timeout' };
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 for
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
6
6
  "main": "run.js",
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
- const hasValue = inlineValue !== null || args[i + 1] !== undefined;
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: { seq: -1, run_id: null, reason: err.message },
113
+ break: brk,
114
+ ...(collectAllBreaks ? { breaks: [brk] } : {}),
115
+ ...(collectOrphans ? { orphans: [] } : {}),
80
116
  };
81
117
  }
82
118
 
83
- if (entries.length === 0) {
84
- // An empty chain is trivially valid: genesis with no entries.
85
- return { ok: true, count: 0, head_digest: GENESIS_DIGEST, break: null };
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
- const fail = (reason) => ({
96
- ok: false,
97
- count: entries.length,
98
- head_digest: entries[entries.length - 1].submission_digest ?? GENESIS_DIGEST,
99
- break: { seq, run_id: runId, reason },
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
- return fail(`seq is not monotonic: expected ${i}, found ${seq}`);
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
- return fail(
111
- `broken prev_digest link: expected ${prevDigest}, found ${entry.prev_digest}`
112
- );
142
+ record(`broken prev_digest link: expected ${prevDigest}, found ${entry.prev_digest}`);
143
+ break;
113
144
  }
114
145
 
115
- // (1) Load the record and recompute its digest.
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
- return fail(`record file missing at ${entry.path} (could not read to recompute digest)`);
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 record;
158
+ let parsed;
122
159
  try {
123
- record = JSON.parse(readFileSync(recordPath, 'utf-8'));
160
+ parsed = JSON.parse(readFileSync(recordPath, 'utf-8'));
124
161
  } catch (err) {
125
- return fail(`record file at ${entry.path} is not valid JSON: ${err.message}`);
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(record);
129
-
130
- // The recomputed digest must equal the ledger's claim.
168
+ const recomputed = submissionDigest(parsed);
131
169
  if (recomputed !== entry.submission_digest) {
132
- return fail(
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
- // …AND the record's own self-certified digest.
139
- const selfDigest = record.integrity?.submission_digest;
179
+ const selfDigest = parsed.integrity?.submission_digest;
140
180
  if (selfDigest !== recomputed) {
141
- return fail(
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: true,
198
+ ok,
153
199
  count: entries.length,
154
- head_digest: entries[entries.length - 1].submission_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
- return [
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
- const b = result.break;
174
- return [
175
- `integrity chain BROKEN at seq ${b.seq}${b.run_id ? ` (run_id: ${b.run_id})` : ''}`,
176
- `reason: ${b.reason}`,
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
  }