@dogfood-lab/ingest 1.8.0 → 1.10.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/verify-chain.js CHANGED
@@ -27,6 +27,17 @@
27
27
  * push that touched a record but not the chain), and it catches middle-deletion,
28
28
  * reorder, and forged-insertion (they break the seq run or the prev-link).
29
29
  *
30
+ * The `pre_adoption` exemption inside collectOrphanRecords (below) narrows the
31
+ * "push that touched a record but not the chain" promise one step further: it
32
+ * excuses an un-ledgered, no-integrity record from `ok` only when the record's
33
+ * OWN claimed timing.finished_at/verification.verified_at predates
34
+ * CHAIN_ADOPTION_DATE (F-1907b2bc, wave 14), so dropping a record with a
35
+ * fabricated RECENT date is still caught as a genuine orphan. Dropping one with
36
+ * a fabricated OLD date is not caught — that record is indistinguishable from
37
+ * genuine history, because both are self-reported fields the same privileged
38
+ * writer controls. That residual is the SAME already-disclosed ingest-write-
39
+ * credential trust boundary named two paragraphs up, not a new one.
40
+ *
30
41
  * KNOWN LIMITATION — tail truncation. Removing the most-recent entries (and
31
42
  * their record files) leaves a SHORTER but internally-consistent chain, which
32
43
  * verifies OK: the offline verifier has no external record of the expected head
@@ -72,7 +83,16 @@ import { findJsonFiles } from './rebuild-indexes.js';
72
83
  * @property {ChainBreak[]} [breaks] - All independent breaks (only present when
73
84
  * `collectAllBreaks` is set). The first element equals `break`.
74
85
  * @property {ChainOrphan[]} [orphans] - Records on disk but absent from the
75
- * ledger (only present when `collectOrphans` is set).
86
+ * ledger AND written by chain-aware code (only present when `collectOrphans`
87
+ * is set). These are genuine torn writes — persist-succeeded, ledger-append-
88
+ * missed — and they make `ok` false.
89
+ * @property {ChainOrphan[]} [pre_adoption] - Records on disk but absent from
90
+ * the ledger that predate the chain's adoption: no `integrity` block AND a
91
+ * self-reported timing.finished_at/verification.verified_at before
92
+ * CHAIN_ADOPTION_DATE (F-1907b2bc — a no-integrity record whose own claimed
93
+ * timestamp is on/after that date is a genuine `orphans` entry instead;
94
+ * only present when `collectOrphans` is set). Visible for operator
95
+ * awareness; does NOT make `ok` false.
76
96
  */
77
97
 
78
98
  /**
@@ -93,6 +113,10 @@ import { findJsonFiles } from './rebuild-indexes.js';
93
113
  * ledger (a torn write between record-write and ledger-append). An orphan
94
114
  * makes the result `ok: false` — the audit DETECTS the orphan instead of
95
115
  * silently passing while a real record lives outside the tamper-evident chain.
116
+ * F-29134790 (wave 12): also returns `pre_adoption[]` — records absent from
117
+ * the ledger that predate the chain's existence entirely (see
118
+ * collectOrphanRecords's adoption-boundary doc below). Pre-adoption records
119
+ * are visible for operator awareness but do NOT affect `ok`.
96
120
  * @returns {ChainVerifyResult}
97
121
  */
98
122
  export function verifyChain(repoRoot, opts = {}) {
@@ -112,7 +136,10 @@ export function verifyChain(repoRoot, opts = {}) {
112
136
  head_digest: GENESIS_DIGEST,
113
137
  break: brk,
114
138
  ...(collectAllBreaks ? { breaks: [brk] } : {}),
115
- ...(collectOrphans ? { orphans: [] } : {}),
139
+ // pre_adoption included alongside orphans (both empty here) so the
140
+ // result shape is consistent whenever collectOrphans is set, regardless
141
+ // of which early-return path produced it.
142
+ ...(collectOrphans ? { orphans: [], pre_adoption: [] } : {}),
116
143
  };
117
144
  }
118
145
 
@@ -191,8 +218,12 @@ export function verifyChain(repoRoot, opts = {}) {
191
218
  prevDigest = entry.submission_digest;
192
219
  }
193
220
 
194
- const orphans = collectOrphans ? collectOrphanRecords(repoRoot, entries) : null;
221
+ const reconciled = collectOrphans ? collectOrphanRecords(repoRoot, entries) : null;
222
+ const orphans = reconciled ? reconciled.orphans : null;
223
+ const preAdoption = reconciled ? reconciled.preAdoption : null;
195
224
 
225
+ // Only genuine orphans (post-adoption, ledger-eligible records) affect ok.
226
+ // pre_adoption is informational — see collectOrphanRecords's doc comment.
196
227
  const ok = breaks.length === 0 && (orphans === null || orphans.length === 0);
197
228
  return {
198
229
  ok,
@@ -200,36 +231,168 @@ export function verifyChain(repoRoot, opts = {}) {
200
231
  head_digest: headDigest,
201
232
  break: breaks.length > 0 ? breaks[0] : null,
202
233
  ...(collectAllBreaks ? { breaks } : {}),
203
- ...(collectOrphans ? { orphans } : {}),
234
+ ...(collectOrphans ? { orphans, pre_adoption: preAdoption } : {}),
204
235
  };
205
236
  }
206
237
 
238
+ /**
239
+ * The commit that gave persist.js's writeRecord its UNCONDITIONAL
240
+ * integrity-stamping logic — f4ca987 "feat(integrity): tamper-evident hash
241
+ * chain over records/", 2026-06-21T18:35:36-04:00 (2026-06-21T22:35:36Z).
242
+ * Every record genuinely written by writeRecord() from this date onward
243
+ * carries a fully-formed `integrity` block; a record with none at all can
244
+ * only predate it — see collectOrphanRecords' F-1907b2bc doc paragraph below.
245
+ *
246
+ * Set to the START of the commit's UTC calendar day, not its exact
247
+ * timestamp, so the boundary errs toward NOT excusing a borderline record
248
+ * (the fail-loud direction) rather than toward hair-splitting a commit
249
+ * second no real record is anywhere near — the actual gap in this repo's own
250
+ * history is five weeks wide (last genuine pre-chain record 2026-05-26,
251
+ * first genuine ledgered record 2026-07-03; see F-29134790's doc paragraph
252
+ * below), so this constant has a wide margin on both sides.
253
+ */
254
+ export const CHAIN_ADOPTION_DATE = new Date('2026-06-21T00:00:00Z');
255
+
256
+ /**
257
+ * The later of a record's own self-reported `timing.finished_at` /
258
+ * `verification.verified_at`, or `null` when neither is present and
259
+ * parseable. F-1907b2bc: used only to sanity-check a would-be `preAdoption`
260
+ * bucketing — this is NOT a trust boundary (the same privileged writer that
261
+ * can drop a record with no `integrity` block can also forge these fields;
262
+ * see the top-of-file threat-model paragraph). It only stops the tool from
263
+ * asserting "predates the chain" when the record's own data already
264
+ * contradicts that claim, or supports no claim at all.
265
+ *
266
+ * F-a85b7e84 (wave 16, LOW): a candidate must be a `string` before it is
267
+ * handed to `new Date()`. JSON has no Date type — every field a real
268
+ * writeRecord() call ever produces here is a string (Date.prototype.toJSON
269
+ * always serializes to one) or absent — so `typeof value === 'string'` costs
270
+ * nothing against genuine records. Without it, `new Date()`'s permissive
271
+ * ToPrimitive coercion accepts non-date JSON values that a bare falsy check
272
+ * (`if (!value) continue`) does not filter out: `new Date(true)` coerces the
273
+ * boolean to the number 1 (1970-01-01T00:00:00.001Z), a bare integer is read
274
+ * as an epoch-millisecond offset, and an array's comma-joined
275
+ * `toString()` (e.g. `[2026, 3, 1]` -> `'2026,3,1'`) fallback-parses as a
276
+ * real date — all landing well before CHAIN_ADOPTION_DATE with none of the
277
+ * "type a plausible fake ISO string" effort the surviving forgery residual
278
+ * (this function's own doc paragraph above) assumes. A non-string value now
279
+ * falls through to the same "no readable timestamp" orphan branch as an
280
+ * absent field, exactly like an unparseable string already did.
281
+ *
282
+ * @param {object} record
283
+ * @returns {Date|null}
284
+ */
285
+ function latestClaimedTimestamp(record) {
286
+ const candidates = [record.timing?.finished_at, record.verification?.verified_at];
287
+ let latest = null;
288
+ for (const value of candidates) {
289
+ if (typeof value !== 'string') continue;
290
+ const parsed = new Date(value);
291
+ if (isNaN(parsed.getTime())) continue;
292
+ if (latest === null || parsed.getTime() > latest.getTime()) latest = parsed;
293
+ }
294
+ return latest;
295
+ }
296
+
207
297
  /**
208
298
  * INGEST-PROACT-001 — reconcile on-disk records against the ledger.
209
299
  *
210
300
  * 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
301
+ * any record file whose repo-relative PATH is absent from the ledger. Such a
302
+ * file is an ORPHAN: persist wrote the record but the ledger append did not
303
+ * happen (a crash in the torn window between the two steps), so the record
214
304
  * exists OUTSIDE the tamper-evident chain and the plain chain-walk is blind to
215
305
  * it. The chain-manifest's own ledger lines are NOT under `records/`, so they
216
306
  * are never mistaken for orphans.
217
307
  *
308
+ * F-d4bcf5d0 (wave 10, HIGH): this used to ALSO fall back to a run_id-only
309
+ * match — "a record is accounted for if EITHER its path OR its run_id is
310
+ * ledgered anywhere". That fallback was unsound: a single run_id can
311
+ * legitimately own TWO on-disk records at TWO different paths (the
312
+ * accepted-after-a-prior-rejection resubmission flow persist.js's
313
+ * isRetryableRejection carve-out exists for — see
314
+ * F-0f9e4077/F-4036ae25/F-f8952a50), each needing its OWN ledger line. Once
315
+ * ANY entry for a run_id was ledgered, the fallback treated EVERY later
316
+ * record sharing that run_id as accounted for — including one at a
317
+ * genuinely un-ledgered path, exactly the torn-write shape this
318
+ * reconciliation pass exists to catch (proven live: a ledgered `_rejected`
319
+ * record followed by an accepted record for the SAME run_id whose OWN
320
+ * ledger line was then lost — the plain run_id match hid the orphan).
321
+ * Path identity is both the necessary AND the sufficient check: every ledger
322
+ * line carries an exact `path` field (lib/chain-manifest.js's documented
323
+ * line shape, always populated by persist.js's appendChainEntry call), and
324
+ * this function's own `relPath` computation (`relative(repoRoot,
325
+ * absPath).split(sep).join('/')`) is the identical normalization persist.js
326
+ * uses to produce that field. Dropping the run_id fallback removes the
327
+ * unsound generalization without weakening real coverage — see
328
+ * ingest-proact-001-004-verify-chain-reconcile-allbreaks.test.js's
329
+ * 'shared run_id, different paths' regression test.
330
+ *
331
+ * F-29134790 (wave 12, HIGH): path-identity membership is correct for records
332
+ * the chain COULD have ledgered, but on its own it has no ADOPTION BOUNDARY —
333
+ * every un-ledgered path reads identically, so a record that predates the
334
+ * chain feature's existence is indistinguishable from a genuine crash-window
335
+ * torn write. Proven live against this repo's own `records/` tree: 53 files
336
+ * with no ledger line, every one dated 2026-03-19 through 2026-05-26 — all
337
+ * BEFORE persist.js gained its integrity-stamping logic (f4ca987,
338
+ * 2026-06-21) — versus the 7 real ledgered records, all dated 2026-07-03
339
+ * onward. Pre-fix, `verifyChain({ collectOrphans: true })` reported all 53 as
340
+ * indistinguishable "torn persist" orphans (a 53:0 signal-to-noise ratio; a
341
+ * genuine orphan today would be the 54th unlabeled line).
342
+ *
343
+ * The fix needs no new marker file or backfill tool: persist.js's writeRecord
344
+ * already stamps `record.integrity = { submission_digest, prev_digest, seq }`
345
+ * UNCONDITIONALLY, in memory, BEFORE it writes the record file and BEFORE it
346
+ * appends the ledger line (see persist.js — the stamp happens ahead of
347
+ * validateRecord() and the atomic file write). That ordering guarantees a
348
+ * genuine post-adoption torn write (file landed, ledger append lost) still
349
+ * carries a fully-formed `integrity` block on disk; only a record written
350
+ * before persist.js gained this stamping logic can lack one entirely. So
351
+ * "does this record carry an `integrity` block" is not a proxy for the
352
+ * adoption boundary — it IS the adoption boundary, already recorded per-record
353
+ * by the exact code change that introduced the chain, for free. A record
354
+ * missing `integrity` is bucketed into the returned `preAdoption` array
355
+ * (visible, does not fail `ok`); a record WITH an `integrity` block that is
356
+ * still un-ledgered remains a genuine `orphans` entry exactly as before.
357
+ *
358
+ * F-1907b2bc (wave 14, MEDIUM): the paragraph above treats "does this record
359
+ * carry an `integrity` block" as the WHOLE adoption boundary, but that
360
+ * equivalence only holds for records that actually passed through the real
361
+ * writeRecord(). A record dropped directly onto disk — bypassing writeRecord
362
+ * entirely — also has no `integrity` block, and pre-fix was silently
363
+ * absorbed into `preAdoption` with a "predates the chain" reason string that
364
+ * is provably FALSE whenever the dropped record's own claimed timestamp is on
365
+ * or after CHAIN_ADOPTION_DATE (persist.js has stamped `integrity`
366
+ * UNCONDITIONALLY on every record it writes since that date — see the
367
+ * constant's doc comment above — so a record genuinely written from then on
368
+ * could not lack one). The fix cross-checks a would-be `preAdoption` record's
369
+ * own `timing.finished_at`/`verification.verified_at` (the later of the two
370
+ * — see `latestClaimedTimestamp`) against CHAIN_ADOPTION_DATE before
371
+ * accepting the "predates the chain" narrative; a record with no verifiable
372
+ * timestamp of its own, or one on/after the cutoff, is bucketed into
373
+ * `orphans` instead, with a reason string honest about why. This closes the
374
+ * LOW-effort version of dropping a forged record (not bothering to fake a
375
+ * plausible pre-adoption date) but not a DILIGENT one that also forges an old
376
+ * timing/verification block — no purely offline, record-content-based check
377
+ * can tell a forged old date from a genuine one; see the threat-model
378
+ * paragraph at the top of this file.
379
+ *
218
380
  * @param {string} repoRoot
219
381
  * @param {Array<object>} entries - The ledger entries (already read).
220
- * @returns {ChainOrphan[]}
382
+ * @returns {{ orphans: ChainOrphan[], preAdoption: ChainOrphan[] }} `orphans`
383
+ * are un-ledgered records stamped by chain-aware code — genuine torn
384
+ * writes. `preAdoption` are un-ledgered records with no `integrity` block —
385
+ * they predate the chain and were never ledgerable. Only `orphans` affects
386
+ * verifyChain's `ok`.
221
387
  */
222
388
  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
389
  const ledgerPaths = new Set();
227
390
  for (const e of entries) {
228
- if (e.run_id) ledgerRunIds.add(e.run_id);
229
391
  if (e.path) ledgerPaths.add(e.path);
230
392
  }
231
393
 
232
394
  const orphans = [];
395
+ const preAdoption = [];
233
396
  const recordsDir = join(repoRoot, 'records');
234
397
  for (const absPath of findJsonFiles(recordsDir)) {
235
398
  const relPath = relative(repoRoot, absPath).split(sep).join('/');
@@ -240,7 +403,9 @@ function collectOrphanRecords(repoRoot, entries) {
240
403
  record = JSON.parse(readFileSync(absPath, 'utf-8'));
241
404
  } catch {
242
405
  // 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.
406
+ // is still an orphan — report it with what little identity we have. We
407
+ // cannot read `.integrity` to classify it as pre-adoption either, so this
408
+ // fails loud rather than being silently excused.
244
409
  orphans.push({
245
410
  run_id: null,
246
411
  seq: null,
@@ -250,11 +415,67 @@ function collectOrphanRecords(repoRoot, entries) {
250
415
  continue;
251
416
  }
252
417
 
253
- const runId = record.run_id ?? null;
254
- if (runId && ledgerRunIds.has(runId)) continue;
418
+ // A value that parses but is not a plain record object (null, an array, a
419
+ // bare primitive) is not something we can read `.integrity` off to
420
+ // classify as pre-adoption. Mirroring the not-valid-JSON branch above:
421
+ // when we cannot positively identify what the record actually IS, fail
422
+ // loud as a genuine orphan rather than silently excusing it into
423
+ // preAdoption — the adoption boundary is a claim we can only make about
424
+ // something shaped like a record.
425
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
426
+ orphans.push({
427
+ run_id: null,
428
+ seq: null,
429
+ path: relPath,
430
+ reason: `record file present on disk but absent from the ledger, and its JSON content is not a record object`,
431
+ });
432
+ continue;
433
+ }
434
+
435
+ // Adoption boundary — see the F-29134790 doc paragraph above. "No
436
+ // integrity block" is NECESSARY for pre-adoption but, on its own, not
437
+ // SUFFICIENT — see the F-1907b2bc doc paragraph above. Cross-check the
438
+ // record's own claimed timestamp against CHAIN_ADOPTION_DATE before
439
+ // accepting the "predates the chain" narrative.
440
+ if (record.integrity == null) {
441
+ const claimedAt = latestClaimedTimestamp(record);
442
+ const isPreAdoption = claimedAt !== null && claimedAt.getTime() < CHAIN_ADOPTION_DATE.getTime();
443
+
444
+ if (isPreAdoption) {
445
+ preAdoption.push({
446
+ run_id: record.run_id ?? null,
447
+ seq: null,
448
+ path: relPath,
449
+ reason:
450
+ `record predates the integrity chain (no integrity block — written ` +
451
+ `before tamper-evident chaining was introduced); expected for ` +
452
+ `historical records, not counted as an audit failure.`,
453
+ });
454
+ continue;
455
+ }
456
+
457
+ orphans.push({
458
+ run_id: record.run_id ?? null,
459
+ seq: null,
460
+ path: relPath,
461
+ reason: claimedAt === null
462
+ ? `record has no integrity block, and no readable timing.finished_at ` +
463
+ `or verification.verified_at either — its "predates the chain" claim ` +
464
+ `cannot be supported by anything on the record itself, so it is ` +
465
+ `treated as a genuine orphan rather than silently excused as historical.`
466
+ : `record has no integrity block, but its own timing.finished_at / ` +
467
+ `verification.verified_at (latest: ${claimedAt.toISOString()}) is on or ` +
468
+ `after the chain-adoption date (${CHAIN_ADOPTION_DATE.toISOString()}) — ` +
469
+ `a record genuinely written by writeRecord() from that date onward ` +
470
+ `always carries an integrity block, so this one cannot genuinely ` +
471
+ `predate the chain. Re-run ingest for this record or investigate how ` +
472
+ `it reached disk without one.`,
473
+ });
474
+ continue;
475
+ }
255
476
 
256
477
  orphans.push({
257
- run_id: runId,
478
+ run_id: record.run_id ?? null,
258
479
  seq: record.integrity?.seq ?? null,
259
480
  path: relPath,
260
481
  reason:
@@ -264,7 +485,7 @@ function collectOrphanRecords(repoRoot, entries) {
264
485
  });
265
486
  }
266
487
 
267
- return orphans;
488
+ return { orphans, preAdoption };
268
489
  }
269
490
 
270
491
  /**
@@ -282,6 +503,13 @@ export function formatChainResult(result) {
282
503
  ];
283
504
  if (Array.isArray(result.orphans)) {
284
505
  lines.push(`reconciliation: 0 orphan record(s) on disk`);
506
+ // F-29134790: ok:true only guarantees zero GENUINE orphans — pre-adoption
507
+ // records (no integrity block) are excused from ok but still worth
508
+ // surfacing so an operator isn't left wondering why the disk record
509
+ // count exceeds the ledgered count.
510
+ if (Array.isArray(result.pre_adoption) && result.pre_adoption.length > 0) {
511
+ lines.push(` (${result.pre_adoption.length} pre-chain record(s) predate the integrity ledger and are excluded from reconciliation)`);
512
+ }
285
513
  }
286
514
  return lines;
287
515
  }
@@ -312,5 +540,13 @@ export function formatChainResult(result) {
312
540
  }
313
541
  }
314
542
 
543
+ // F-29134790: pre-adoption records never make ok false, but when the result
544
+ // IS not-ok for some other reason (a break, or a genuine orphan), still name
545
+ // the excluded count so an operator triaging the failure isn't misled into
546
+ // thinking every un-ledgered record on disk is part of the break.
547
+ if (Array.isArray(result.pre_adoption) && result.pre_adoption.length > 0) {
548
+ lines.push(`reconciliation: ${result.pre_adoption.length} pre-chain record(s) predate the integrity ledger (excluded from reconciliation, not an audit failure)`);
549
+ }
550
+
315
551
  return lines;
316
552
  }