@dogfood-lab/ingest 1.3.2 → 1.5.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.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * verify-anchor — public verifiability for XRPL-anchored Merkle roots, and the
3
+ * truncation detector the offline chain cannot provide.
4
+ *
5
+ * Ported from repomesh's verify-anchor.mjs. Two responsibilities:
6
+ *
7
+ * 1. verifyAnchorTx() — PURE on-chain anchor verification. Given a fetched XRPL
8
+ * tx + its decoded memo + the locally recomputed root/manifestHash/count,
9
+ * assert the anchor is real and authoritative:
10
+ * - tx.validated === true (finalized on-chain)
11
+ * - meta.TransactionResult === tesSUCCESS (it actually succeeded)
12
+ * - tx.Account ∈ trustedAnchorAccounts (the authorization root — any
13
+ * funded wallet can post a memo, so the account allowlist is the trust)
14
+ * - memo.r/h/c bind to the local root/manifestHash/leaf_count
15
+ * No network, no fs — the network fetch is the caller's job.
16
+ *
17
+ * 2. verifyChainAgainstAnchors() — the TRUNCATION detector. Given the local
18
+ * anchor manifests + the live chain, assert the chain is AT LEAST as long as
19
+ * the highest anchored (seq_range, leaf_count). A chain that is SHORTER than
20
+ * an anchored point is provably truncated — exactly the gap the offline
21
+ * tamper-evident chain could not catch, and the reason the anchor upgrades
22
+ * it from tamper-evident to tamper-PROOF below an anchored point.
23
+ *
24
+ * OFFLINE-HONEST: when no on-chain tx is available (the common offline case),
25
+ * verifyAnchorState() reports `xrpl_verified: false` ("XRPL NOT verified")
26
+ * rather than passing. An offline run can still prove the local truncation
27
+ * check, but it must NEVER claim the on-chain witness was checked when it wasn't.
28
+ *
29
+ * This module NEVER imports xrpl. Fetching a tx for the on-chain leg is the
30
+ * operator/CLI's job (and it lazily loads xrpl there).
31
+ */
32
+
33
+ import { createHash } from 'node:crypto';
34
+
35
+ import { merkleRootForAlgo } from './merkle.js';
36
+ import { canonicalize } from '../lib/integrity.js';
37
+ import { readChainManifest } from '../lib/chain-manifest.js';
38
+ import { readAnchorManifests, MANIFEST_VERSION } from './compute-root.js';
39
+
40
+ function sha256hex(str) {
41
+ return createHash('sha256').update(str, 'utf-8').digest('hex');
42
+ }
43
+
44
+ function hexToString(hex) {
45
+ return Buffer.from(hex, 'hex').toString('utf8');
46
+ }
47
+
48
+ /**
49
+ * Decode an anchor memo from an XRPL tx's Memos array. Returns the parsed memo
50
+ * dataObj, or null when no anchor memo is present.
51
+ *
52
+ * @param {object} tx - The XRPL tx result (with a Memos array).
53
+ * @param {string} memoType - The self-identifying MemoType to locate.
54
+ * @returns {object|null}
55
+ */
56
+ export function decodeAnchorMemo(tx, memoType = 'testing-os-anchor-v1') {
57
+ const memos = tx?.Memos || [];
58
+ const found = memos.find((m) => {
59
+ try {
60
+ return hexToString(m.Memo?.MemoType || '') === memoType;
61
+ } catch {
62
+ return false;
63
+ }
64
+ });
65
+ if (!found) return null;
66
+ try {
67
+ return JSON.parse(hexToString(found.Memo.MemoData));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * PURE on-chain anchor verification. No network, no fs.
75
+ *
76
+ * @param {object} params
77
+ * @param {object} params.tx - The fetched XRPL tx ({ validated, Account, meta }).
78
+ * @param {object} params.memo - The decoded anchor memo ({ r, h, c, ... }).
79
+ * @param {string} params.localRoot - Locally recomputed Merkle root.
80
+ * @param {string} params.localManifestHash - Locally recomputed manifest hash.
81
+ * @param {number} params.leafCount - Local leaf count.
82
+ * @param {string[]} params.trustedAnchorAccounts - The account allowlist.
83
+ * @returns {{ ok: boolean, reason: string, checks: object }}
84
+ */
85
+ export function verifyAnchorTx({ tx, memo, localRoot, localManifestHash, leafCount, trustedAnchorAccounts }) {
86
+ const checks = {};
87
+ const trusted = new Set(trustedAnchorAccounts || []);
88
+
89
+ // The tx must be validated (final) on the ledger.
90
+ checks.validated = tx?.validated === true;
91
+ if (!checks.validated) {
92
+ return { ok: false, reason: 'tx.validated is not true (anchor not finalized on-chain)', checks };
93
+ }
94
+
95
+ // The tx must have actually succeeded.
96
+ const txResult = tx?.meta?.TransactionResult;
97
+ checks.tesSUCCESS = txResult === 'tesSUCCESS';
98
+ if (!checks.tesSUCCESS) {
99
+ return { ok: false, reason: `meta.TransactionResult is "${txResult}" (expected tesSUCCESS)`, checks };
100
+ }
101
+
102
+ // The signing wallet must be a trusted anchor account — any funded wallet can
103
+ // post a memo, so the account allowlist is the authorization root.
104
+ checks.account = trusted.has(tx?.Account);
105
+ if (!checks.account) {
106
+ return {
107
+ ok: false,
108
+ reason: `tx.Account "${tx?.Account}" is not in trustedAnchorAccounts — any funded wallet can post a memo; the account allowlist is the authorization root.`,
109
+ checks,
110
+ };
111
+ }
112
+
113
+ // The on-chain memo must bind to the local root / manifestHash / count.
114
+ checks.root = memo?.r === localRoot;
115
+ if (!checks.root) {
116
+ return { ok: false, reason: `Merkle root mismatch: memo.r=${memo?.r} local=${localRoot}`, checks };
117
+ }
118
+ checks.manifestHash = memo?.h === localManifestHash;
119
+ if (!checks.manifestHash) {
120
+ return { ok: false, reason: `manifestHash mismatch: memo.h=${memo?.h} local=${localManifestHash}`, checks };
121
+ }
122
+ checks.count = Number(memo?.c) === Number(leafCount);
123
+ if (!checks.count) {
124
+ return { ok: false, reason: `leaf count mismatch: memo.c=${memo?.c} local=${leafCount}`, checks };
125
+ }
126
+
127
+ return { ok: true, reason: 'all anchor checks passed', checks };
128
+ }
129
+
130
+ /**
131
+ * Recompute the local root + manifest hash for an anchor manifest from the
132
+ * CURRENT chain, so a verifier can compare them against the on-chain memo
133
+ * WITHOUT trusting the manifest's own stored root.
134
+ *
135
+ * Re-derives the leaves from the live chain over the manifest's `seq_range`, then
136
+ * recomputes the Merkle root with the manifest's declared algo and the canonical
137
+ * manifest hash. Returns `{ ok: false, reason }` if the live chain can no longer
138
+ * cover the anchored range (which is itself a truncation/tamper signal).
139
+ *
140
+ * @param {string} repoRoot
141
+ * @param {object} manifest - An anchor manifest.
142
+ * @returns {{ ok: boolean, reason?: string, localRoot?: string, localManifestHash?: string, leafCount?: number }}
143
+ */
144
+ export function recomputeLocalForAnchor(repoRoot, manifest) {
145
+ const entries = readChainManifest(repoRoot);
146
+ const [from, to] = manifest.seq_range || [];
147
+ if (typeof from !== 'number' || typeof to !== 'number') {
148
+ return { ok: false, reason: 'anchor manifest has no usable seq_range' };
149
+ }
150
+ const partition = entries.filter((e) => e.seq >= from && e.seq <= to);
151
+ // If the live chain cannot cover the anchored range, it has been truncated or
152
+ // mutated below the anchor — surface it here, not as a silent wrong root.
153
+ if (partition.length < manifest.leaf_count) {
154
+ return {
155
+ ok: false,
156
+ reason:
157
+ `local chain covers only ${partition.length} of the anchored ${manifest.leaf_count} entries ` +
158
+ `in seq_range [${from}, ${to}] — the chain is shorter than the anchored point (truncation).`,
159
+ };
160
+ }
161
+ const leaves = partition.map((e) => e.submission_digest);
162
+ const algo = manifest.algo || 'sha256-merkle-v1';
163
+ const localRoot = merkleRootForAlgo(leaves, algo);
164
+ const headDigest = partition[partition.length - 1].submission_digest;
165
+ const base = {
166
+ anchor_seq: manifest.anchor_seq,
167
+ manifest_version: manifest.manifest_version ?? MANIFEST_VERSION,
168
+ algo,
169
+ root: localRoot,
170
+ leaf_count: leaves.length,
171
+ seq_range: [from, to],
172
+ head_digest: headDigest,
173
+ prev_anchor_root: manifest.prev_anchor_root ?? null,
174
+ network: manifest.network,
175
+ };
176
+ return {
177
+ ok: true,
178
+ localRoot,
179
+ localManifestHash: sha256hex(canonicalize(base)),
180
+ leafCount: leaves.length,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * THE TRUNCATION DETECTOR — the gap the offline tamper-evident chain cannot
186
+ * close, and the reason the XRPL anchor upgrades the chain to tamper-PROOF below
187
+ * an anchored point.
188
+ *
189
+ * Given the local anchor manifests and the live chain, assert the chain reaches
190
+ * at least the HIGHEST anchored `seq_range[1]` and carries at least the anchored
191
+ * cumulative leaf count up to that seq. A chain that is SHORTER than an anchored
192
+ * (seq_range, count) is provably truncated — fail LOUD.
193
+ *
194
+ * Each anchor's range is also re-derived against the live chain (via
195
+ * recomputeLocalForAnchor): if the live chain can no longer reproduce an
196
+ * anchored root, that is reported too (a mutation/deletion below the anchor).
197
+ *
198
+ * @param {string} repoRoot - Absolute path to the testing-os repo root.
199
+ * @returns {{ ok: boolean, reason: string, anchored_through_seq: number|null,
200
+ * anchored_count: number, chain_head_seq: number|null, chain_count: number,
201
+ * anchor_count: number, failures: Array<object> }}
202
+ */
203
+ export function verifyChainAgainstAnchors(repoRoot) {
204
+ const anchors = readAnchorManifests(repoRoot);
205
+ const entries = readChainManifest(repoRoot);
206
+ const chainCount = entries.length;
207
+ const chainHeadSeq = chainCount === 0 ? null : entries[entries.length - 1].seq;
208
+
209
+ if (anchors.length === 0) {
210
+ // No anchors yet — there is no external head/count to enforce. Honest: this
211
+ // is not a PASS of "the chain is complete", it is "nothing is anchored".
212
+ return {
213
+ ok: true,
214
+ reason: 'no anchors present — nothing to enforce (chain is only tamper-evident, not anchored)',
215
+ anchored_through_seq: null,
216
+ anchored_count: 0,
217
+ chain_head_seq: chainHeadSeq,
218
+ chain_count: chainCount,
219
+ anchor_count: 0,
220
+ failures: [],
221
+ };
222
+ }
223
+
224
+ // The highest seq any anchor has committed to on-chain.
225
+ const anchoredThroughSeq = Math.max(...anchors.map((a) => a.seq_range?.[1] ?? -1));
226
+ // The cumulative entries that must exist at or below that seq: the highest
227
+ // anchored `to` + 1 (seqs are 0-based and contiguous), which equals the count
228
+ // of entries the anchors collectively certify must still be present.
229
+ const anchoredCount = anchoredThroughSeq + 1;
230
+
231
+ const failures = [];
232
+
233
+ // (1) Tail-truncation: the live chain head must reach the highest anchored seq.
234
+ if (chainHeadSeq === null || chainHeadSeq < anchoredThroughSeq) {
235
+ failures.push({
236
+ kind: 'truncation',
237
+ reason:
238
+ `chain head seq is ${chainHeadSeq === null ? '(empty chain)' : chainHeadSeq} but anchors certify ` +
239
+ `entries through seq ${anchoredThroughSeq} — the chain is shorter than the anchored point (TRUNCATION).`,
240
+ });
241
+ }
242
+ // (2) Count floor: the chain must carry at least the anchored cumulative count.
243
+ if (chainCount < anchoredCount) {
244
+ failures.push({
245
+ kind: 'count-shortfall',
246
+ reason:
247
+ `chain has ${chainCount} entries but anchors certify at least ${anchoredCount} ` +
248
+ `(through seq ${anchoredThroughSeq}) — entries are missing (TRUNCATION).`,
249
+ });
250
+ }
251
+
252
+ // (3) Each anchored range must still be reproducible from the live chain. A
253
+ // mutation/deletion BELOW an anchor that left the head intact would slip
254
+ // past (1) and (2) but fail here when the recomputed root no longer matches.
255
+ for (const anchor of anchors) {
256
+ const local = recomputeLocalForAnchor(repoRoot, anchor);
257
+ if (!local.ok) {
258
+ failures.push({ kind: 'range-unreproducible', anchor_seq: anchor.anchor_seq, reason: local.reason });
259
+ continue;
260
+ }
261
+ if (local.localRoot !== anchor.root) {
262
+ failures.push({
263
+ kind: 'root-drift',
264
+ anchor_seq: anchor.anchor_seq,
265
+ reason:
266
+ `anchor ${anchor.anchor_seq} root no longer reproduces from the live chain: ` +
267
+ `recomputed ${local.localRoot} vs anchored ${anchor.root} — entries in seq_range ` +
268
+ `[${anchor.seq_range?.[0]}, ${anchor.seq_range?.[1]}] were mutated or reordered.`,
269
+ });
270
+ }
271
+ }
272
+
273
+ const ok = failures.length === 0;
274
+ return {
275
+ ok,
276
+ reason: ok
277
+ ? `chain reaches anchored seq ${anchoredThroughSeq} (${chainCount} entries ≥ ${anchoredCount} anchored)`
278
+ : failures.map((f) => f.reason).join(' | '),
279
+ anchored_through_seq: anchoredThroughSeq,
280
+ anchored_count: anchoredCount,
281
+ chain_head_seq: chainHeadSeq,
282
+ chain_count: chainCount,
283
+ anchor_count: anchors.length,
284
+ failures,
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Offline-honest combined verification used by the CLI's --anchor-verify.
290
+ *
291
+ * ALWAYS runs the local truncation check (verifyChainAgainstAnchors). When a
292
+ * fetched tx is supplied, ALSO runs verifyAnchorTx for the matching anchor and
293
+ * reports `xrpl_verified: true/false`. When no tx is supplied (offline), reports
294
+ * `xrpl_verified: false` with an honest "XRPL NOT verified" note — it never
295
+ * claims the on-chain witness was checked when it wasn't.
296
+ *
297
+ * @param {string} repoRoot
298
+ * @param {object} [opts]
299
+ * @param {object} [opts.tx] - A fetched XRPL tx (with Memos), or undefined offline.
300
+ * @param {string[]} [opts.trustedAnchorAccounts] - The account allowlist.
301
+ * @param {string} [opts.memoType='testing-os-anchor-v1']
302
+ * @returns {{ ok: boolean, truncation: object, xrpl_verified: boolean, xrpl: object|null, note: string }}
303
+ */
304
+ export function verifyAnchorState(repoRoot, opts = {}) {
305
+ const truncation = verifyChainAgainstAnchors(repoRoot);
306
+
307
+ let xrplVerified = false;
308
+ let xrpl = null;
309
+ let note;
310
+
311
+ if (opts.tx) {
312
+ const memo = decodeAnchorMemo(opts.tx, opts.memoType || 'testing-os-anchor-v1');
313
+ if (!memo) {
314
+ xrpl = { ok: false, reason: 'no anchor memo found in the supplied tx', checks: {} };
315
+ note = 'XRPL NOT verified — the supplied tx carries no anchor memo.';
316
+ } else {
317
+ const anchors = readAnchorManifests(repoRoot);
318
+ const target = anchors.find((a) => a.root === memo.r) || anchors.find((a) => a.anchor_seq === memo.s);
319
+ if (!target) {
320
+ xrpl = { ok: false, reason: `no local anchor manifest matches the on-chain memo (root ${memo.r})`, checks: {} };
321
+ note = 'XRPL NOT verified — no local anchor manifest matches the on-chain memo.';
322
+ } else {
323
+ const local = recomputeLocalForAnchor(repoRoot, target);
324
+ if (!local.ok) {
325
+ xrpl = { ok: false, reason: local.reason, checks: {} };
326
+ note = `XRPL NOT verified — ${local.reason}`;
327
+ } else {
328
+ xrpl = verifyAnchorTx({
329
+ tx: opts.tx,
330
+ memo,
331
+ localRoot: local.localRoot,
332
+ localManifestHash: local.localManifestHash,
333
+ leafCount: local.leafCount,
334
+ trustedAnchorAccounts: opts.trustedAnchorAccounts || [],
335
+ });
336
+ xrplVerified = xrpl.ok;
337
+ note = xrpl.ok
338
+ ? 'XRPL verified — the on-chain anchor binds to the local chain.'
339
+ : `XRPL NOT verified — ${xrpl.reason}`;
340
+ }
341
+ }
342
+ }
343
+ } else {
344
+ note = 'XRPL NOT verified (offline) — no tx supplied. The local truncation check ran; ' +
345
+ 'fetch the anchor tx to confirm the on-chain head/count witness.';
346
+ }
347
+
348
+ return {
349
+ // Overall ok requires the local truncation check to pass. The on-chain leg,
350
+ // when present, must also pass; when absent it does NOT flip ok to true on
351
+ // its own (offline cannot upgrade tamper-evident to tamper-proof).
352
+ ok: truncation.ok && (opts.tx ? xrplVerified : true),
353
+ truncation,
354
+ xrpl_verified: xrplVerified,
355
+ xrpl,
356
+ note,
357
+ };
358
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Chain manifest — the append-only ledger backing the tamper-evident record
3
+ * chain. One JSON line per persisted record (accepted AND rejected) at
4
+ * `indexes/integrity/chain.jsonl`. The manifest IS the ordered chain: its last
5
+ * line is the head, and `prev_digest`/`seq` thread every line back to genesis.
6
+ *
7
+ * This is the analog of an event-sourced `events.jsonl` ledger. Each line:
8
+ * { seq, run_id, repo, status, path, submission_digest, prev_digest, persisted_at }
9
+ *
10
+ * Atomicity / concurrency assumption (LOCKED CONTRACT step 4 + step "serialized
11
+ * ingest"): ingest is serialized — `ingest.yml` runs with workflow-level
12
+ * concurrency and `writeRecord` is synchronous. So a read-head-then-append cycle
13
+ * is race-free in production. We therefore implement the append as a
14
+ * read-current-file + rewrite-whole via temp+rename (`atomicWriteFileSync`):
15
+ * the rewrite is atomic (a reader sees either the pre-append or post-append
16
+ * file, never a torn half-line), which is the strongest non-tear guarantee
17
+ * available without a real append-with-fsync primitive. A plain `appendFileSync`
18
+ * would also be effectively atomic for a single sub-PIPE_BUF line, but the
19
+ * rewrite keeps us inside the repo's established temp+rename doctrine and is
20
+ * robust to a torn previous write.
21
+ *
22
+ * OUT OF SCOPE (documented, not built): truly-concurrent ingest (two writers
23
+ * appending at once) would need a fork — file locking or a serializing queue —
24
+ * because two read-head cycles could both observe the same head and assign the
25
+ * same seq. The serialized-ingest assumption above is what makes this safe; a
26
+ * concurrent-ingest fork is explicitly deferred.
27
+ */
28
+
29
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
30
+ import { join, dirname } from 'node:path';
31
+
32
+ import { atomicWriteFileSync } from './atomic-write.js';
33
+ import { GENESIS_DIGEST } from './integrity.js';
34
+
35
+ /** Repo-root-relative path to the append-only chain ledger. */
36
+ export const CHAIN_MANIFEST_REL = 'indexes/integrity/chain.jsonl';
37
+
38
+ /** Absolute path to the chain manifest for a given repo root. */
39
+ export function chainManifestPath(repoRoot) {
40
+ return join(repoRoot, 'indexes', 'integrity', 'chain.jsonl');
41
+ }
42
+
43
+ /**
44
+ * Read every chain entry in order. Returns [] when the manifest does not exist
45
+ * yet (an empty chain is a valid chain — genesis with zero entries).
46
+ *
47
+ * @param {string} repoRoot
48
+ * @returns {Array<object>} Parsed entries, in file (chain) order.
49
+ */
50
+ export function readChainManifest(repoRoot) {
51
+ const path = chainManifestPath(repoRoot);
52
+ if (!existsSync(path)) return [];
53
+ const raw = readFileSync(path, 'utf-8');
54
+ const lines = raw.split('\n').filter(line => line.trim().length > 0);
55
+ return lines.map((line, i) => {
56
+ try {
57
+ return JSON.parse(line);
58
+ } catch (err) {
59
+ // A non-JSON line is itself tamper/corruption evidence. Surface it with
60
+ // the line index rather than letting JSON.parse throw an opaque error.
61
+ const e = new Error(`chain.jsonl line ${i + 1} is not valid JSON: ${err.message}`);
62
+ e.code = 'CHAIN_MANIFEST_CORRUPT';
63
+ e.line = i + 1;
64
+ throw e;
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Read the head (last entry) of the chain, or a synthetic genesis head when the
71
+ * chain is empty. The returned `{ submission_digest, seq }` is exactly what an
72
+ * appender needs to compute the next entry's `prev_digest` and `seq`.
73
+ *
74
+ * @param {string} repoRoot
75
+ * @returns {{ submission_digest: string, seq: number }} Genesis when empty:
76
+ * `{ submission_digest: GENESIS_DIGEST, seq: -1 }` so `seq + 1 === 0`.
77
+ */
78
+ export function readChainHead(repoRoot) {
79
+ const entries = readChainManifest(repoRoot);
80
+ if (entries.length === 0) {
81
+ return { submission_digest: GENESIS_DIGEST, seq: -1 };
82
+ }
83
+ const head = entries[entries.length - 1];
84
+ return { submission_digest: head.submission_digest, seq: head.seq };
85
+ }
86
+
87
+ /**
88
+ * Append one entry to the chain ledger atomically (read-current + rewrite-whole
89
+ * via temp+rename). The trailing newline is normalized so the file is always a
90
+ * clean newline-terminated JSONL.
91
+ *
92
+ * @param {string} repoRoot
93
+ * @param {object} entry - A fully-formed chain line (caller computes seq/digests).
94
+ */
95
+ export function appendChainEntry(repoRoot, entry) {
96
+ const path = chainManifestPath(repoRoot);
97
+ // The ledger lives under indexes/integrity/, which may not exist on the first
98
+ // write into a fresh repo root (or a test sandbox). Create it idempotently.
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ const existing = existsSync(path) ? readFileSync(path, 'utf-8') : '';
101
+ // Normalize: strip any trailing whitespace/newlines, re-add exactly one \n
102
+ // per line so a previously torn write cannot leave a double-blank line.
103
+ const body = existing.replace(/\s+$/, '');
104
+ const next = (body.length > 0 ? body + '\n' : '') + JSON.stringify(entry) + '\n';
105
+ atomicWriteFileSync(path, next);
106
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Integrity chain v1 — the trust-critical core of the tamper-EVIDENT record chain.
3
+ *
4
+ * This module is the ONLY place a record digest is computed. Both the persist
5
+ * layer (which stamps `record.integrity` and appends a manifest line) and the
6
+ * chain verifier (`verify-chain.js`, which recomputes the digest to detect
7
+ * tampering) import `canonicalize` + `submissionDigest` from here, so the two
8
+ * sides agree BY CONSTRUCTION — there is no second implementation that could
9
+ * drift and silently make the verifier disagree with what persist wrote.
10
+ *
11
+ * Honesty note (threat model): this is tamper-EVIDENT, not tamper-PROOF. An
12
+ * actor holding the ingest write credential can rewrite a record AND recompute
13
+ * its digest AND rewrite the manifest line, producing a chain that re-verifies
14
+ * clean. What the chain buys is detection of any in-place mutation not
15
+ * consistently reflected in both the record and the ledger — disk corruption,
16
+ * an out-of-band edit, a partial restore, a malicious push that touched a record
17
+ * file but not the ledger — plus middle-deletion / reorder / insertion. It does
18
+ * NOT detect tail TRUNCATION (a shorter chain still verifies); only an external
19
+ * head/count anchor outside the writer's control (the optional XRPL anchor) can.
20
+ * The coordinator owns the full threat-model writeup; this module just builds
21
+ * the mechanism honestly.
22
+ */
23
+
24
+ import { createHash } from 'node:crypto';
25
+
26
+ /**
27
+ * GENESIS_DIGEST — the sentinel `prev_digest` for the first chain entry (seq 0).
28
+ * 64 hex zeros: a value no real sha256 hex digest will ever collide with in
29
+ * practice, and visually obvious in a manifest as "this is the head of chain".
30
+ */
31
+ export const GENESIS_DIGEST = '0'.repeat(64);
32
+
33
+ /**
34
+ * Deterministic JSON serialization.
35
+ *
36
+ * Canonicalization rule (EXACT — this is a contract, document any change):
37
+ * - Objects: keys are emitted in ASCENDING (default JS string `<`) order,
38
+ * recursively at every depth, including objects nested inside arrays.
39
+ * - Arrays: element ORDER is preserved (arrays are positional data; reordering
40
+ * them is a real content change, so they are NOT sorted).
41
+ * - No insignificant whitespace — equivalent to `JSON.stringify(value)` over a
42
+ * deeply key-sorted clone (the two-arg `JSON.stringify(value, replacer)`
43
+ * form already drops all whitespace; the replacer below only reorders keys).
44
+ * - Primitives (string/number/boolean/null) serialize exactly as
45
+ * `JSON.stringify` would. `undefined` object properties are dropped, matching
46
+ * `JSON.stringify` — records are plain JSON so this never bites in practice.
47
+ *
48
+ * Implementation note: a `JSON.stringify` replacer is called with each value
49
+ * AFTER `JSON.stringify` has already enumerated its keys, so we cannot reorder
50
+ * via the replacer alone. Instead we build a deep, key-sorted clone first, then
51
+ * stringify it with no spacing argument. This is the canonical "sort-then-
52
+ * stringify" approach and is stable across V8 versions because we never rely on
53
+ * native key-enumeration order for the output.
54
+ *
55
+ * @param {*} value - Any JSON-serializable value.
56
+ * @returns {string} Deterministic JSON string.
57
+ */
58
+ export function canonicalize(value) {
59
+ return JSON.stringify(sortDeep(value));
60
+ }
61
+
62
+ /**
63
+ * Deep clone with object keys sorted ascending at every depth. Arrays keep
64
+ * order (only their element objects are key-sorted). Primitives pass through.
65
+ */
66
+ function sortDeep(value) {
67
+ if (Array.isArray(value)) {
68
+ return value.map(sortDeep);
69
+ }
70
+ if (value !== null && typeof value === 'object') {
71
+ const sorted = {};
72
+ for (const key of Object.keys(value).sort()) {
73
+ sorted[key] = sortDeep(value[key]);
74
+ }
75
+ return sorted;
76
+ }
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * Compute the canonical sha256 digest of a record, EXCLUDING its `integrity`
82
+ * block.
83
+ *
84
+ * The digest is computed over the record WITHOUT `integrity` because the digest
85
+ * is itself stored inside `integrity.submission_digest` — you cannot hash the
86
+ * digest into itself. Stripping `integrity` also makes the digest stable: a
87
+ * record's digest is identical before and after persist stamps its integrity
88
+ * block, which is exactly what lets the verifier recompute it later and compare.
89
+ *
90
+ * The strip is on a SHALLOW CLONE — the caller's record object is never mutated.
91
+ *
92
+ * @param {object} record - The record (may or may not already carry `integrity`).
93
+ * @returns {string} Lowercase 64-char hex sha256 of `canonicalize(record-without-integrity)`.
94
+ */
95
+ export function submissionDigest(record) {
96
+ const { integrity: _omit, ...withoutIntegrity } = record;
97
+ return createHash('sha256').update(canonicalize(withoutIntegrity), 'utf-8').digest('hex');
98
+ }
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
6
6
  "main": "run.js",
7
7
  "exports": {
8
8
  ".": "./run.js",
9
9
  "./lib/*": "./lib/*",
10
- "./validate-record.js": "./validate-record.js"
10
+ "./anchor/*": "./anchor/*",
11
+ "./validate-record.js": "./validate-record.js",
12
+ "./verify-chain.js": "./verify-chain.js"
11
13
  },
12
14
  "scripts": {
13
15
  "test": "node --test",
@@ -18,8 +20,10 @@
18
20
  "persist.js",
19
21
  "rebuild-indexes.js",
20
22
  "validate-record.js",
23
+ "verify-chain.js",
21
24
  "load-context.js",
22
25
  "lib/",
26
+ "anchor/",
23
27
  "README.md",
24
28
  "LICENSE"
25
29
  ],
package/persist.js CHANGED
@@ -7,11 +7,13 @@
7
7
  */
8
8
 
9
9
  import { existsSync, mkdirSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
10
- import { join, dirname } from 'node:path';
10
+ import { join, dirname, relative, sep } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
 
13
13
  import { validateRecord } from './validate-record.js';
14
14
  import { isUnsafeSegment } from './lib/unsafe-segment.js';
15
+ import { submissionDigest } from './lib/integrity.js';
16
+ import { readChainHead, appendChainEntry } from './lib/chain-manifest.js';
15
17
 
16
18
  /**
17
19
  * Error thrown when writeRecord loses a TOCTOU race for the same canonical path.
@@ -125,6 +127,26 @@ export function writeRecord(record, repoRoot) {
125
127
  return { path, written: false };
126
128
  }
127
129
 
130
+ // Integrity chain v1 — stamp the tamper-evident integrity block BEFORE
131
+ // validating + writing, so the persisted record self-certifies.
132
+ //
133
+ // Serialized-ingest assumption (LOCKED CONTRACT step 4): ingest.yml is
134
+ // concurrency-serialized and writeRecord is synchronous, so reading the chain
135
+ // head and then appending after the write is race-free. A fork for
136
+ // truly-concurrent ingest (two writers assigning the same seq) is OUT OF SCOPE
137
+ // — see lib/chain-manifest.js. The order is: read head → compute digest over
138
+ // the record WITHOUT integrity (stable regardless of the block) → stamp
139
+ // integrity → write the record → append the manifest line. The append happens
140
+ // ONLY after the record write succeeds, and only on this real-write path
141
+ // (never on the duplicate short-circuit above).
142
+ const head = readChainHead(repoRoot);
143
+ const digest = submissionDigest(record);
144
+ record.integrity = {
145
+ submission_digest: digest,
146
+ prev_digest: head.submission_digest,
147
+ seq: head.seq + 1,
148
+ };
149
+
128
150
  // Enforce dogfood-record.schema.json BEFORE touching the filesystem.
129
151
  // Better to throw loudly than silently persist a malformed record — the
130
152
  // schema is the contract every downstream consumer relies on.
@@ -168,5 +190,23 @@ export function writeRecord(record, repoRoot) {
168
190
  throw err;
169
191
  }
170
192
 
193
+ // Append the chain ledger line AFTER the record write succeeds. The manifest
194
+ // append is atomic (temp+rename rewrite — see lib/chain-manifest.js); a torn
195
+ // append cannot leave a half-line. `path` field is the record path RELATIVE to
196
+ // repoRoot, forward-slashed, so the ledger is portable across OSes and a line
197
+ // copy-pasted into a raw.githubusercontent URL is not a broken link (mirrors
198
+ // the posixify-at-the-boundary doctrine in run.js / rebuild-indexes.js).
199
+ const relPath = relative(repoRoot, path).split(sep).join('/');
200
+ appendChainEntry(repoRoot, {
201
+ seq: record.integrity.seq,
202
+ run_id: record.run_id,
203
+ repo: record.repo,
204
+ status: record.verification?.status ?? 'accepted',
205
+ path: relPath,
206
+ submission_digest: record.integrity.submission_digest,
207
+ prev_digest: record.integrity.prev_digest,
208
+ persisted_at: new Date().toISOString(),
209
+ });
210
+
171
211
  return { path, written: true };
172
212
  }