@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/persist.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * duplicate detection by run_id, directory creation.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
9
+ import { existsSync, mkdirSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs';
10
10
  import { join, dirname, relative, sep } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
 
@@ -14,6 +14,8 @@ import { validateRecord } from './validate-record.js';
14
14
  import { isUnsafeSegment } from './lib/unsafe-segment.js';
15
15
  import { submissionDigest } from './lib/integrity.js';
16
16
  import { readChainHead, appendChainEntry } from './lib/chain-manifest.js';
17
+ import { parseRejectionReason } from '@dogfood-lab/verify';
18
+ import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
17
19
 
18
20
  /**
19
21
  * Error thrown when writeRecord loses a TOCTOU race for the same canonical path.
@@ -29,6 +31,40 @@ export class DuplicateRunIdError extends Error {
29
31
  }
30
32
  }
31
33
 
34
+ /**
35
+ * Error thrown when writeRecord's SECOND computeRecordPath() call — the one
36
+ * that computes the real write target, reached only AFTER validateRecord()
37
+ * has already confirmed the record is schema-valid — still cannot produce a
38
+ * safe path.
39
+ *
40
+ * F-bbbe2e1f: computeRecordPath()'s isUnsafeSegment check (lib/unsafe-segment.js)
41
+ * is STRICTER than the record schema's own repo pattern
42
+ * (`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$`), which permits an embedded `..` or a
43
+ * lone `.` segment (e.g. `../etc`). A record can therefore pass
44
+ * validateRecord() above and still fail here — this is never a schema
45
+ * problem, it is the traversal backstop firing on schema-valid input. Pre-fix
46
+ * that let a bare, unclassified computeRecordPath Error (no `.code`) escape
47
+ * uncaught — the exact shape F-a37d36f5 eliminated at the FIRST
48
+ * computeRecordPath call (the isDuplicate probe below) but not this second
49
+ * one, three lines after validateRecord(). Classifying it here matches
50
+ * RecordValidationError's discipline and stays fail-closed: nothing below
51
+ * this point has touched the filesystem yet (mkdirSync/openSync/writeFileSync
52
+ * all come after), so no partial write is possible either way.
53
+ */
54
+ export class UnsafeRecordPathError extends Error {
55
+ constructor(record, cause) {
56
+ super(
57
+ `record passed schema validation but its path could not be safely computed ` +
58
+ `(repo: ${record.repo}, run_id: ${record.run_id}): ${cause.message}`,
59
+ { cause }
60
+ );
61
+ this.name = 'UnsafeRecordPathError';
62
+ this.code = 'UNSAFE_RECORD_PATH';
63
+ this.repo = record.repo;
64
+ this.runId = record.run_id;
65
+ }
66
+ }
67
+
32
68
  /**
33
69
  * Compute the canonical file path for a persisted record.
34
70
  *
@@ -43,7 +79,15 @@ export function computeRecordPath(record, repoRoot) {
43
79
  const status = record.verification?.status;
44
80
  const base = status === 'rejected' ? 'records/_rejected' : 'records';
45
81
 
46
- const [org, repo] = (record.repo || '').split('/');
82
+ // V2-CROSS-BO-003 (F-54e5fde7 family): strictly two segments — the old
83
+ // destructure silently dropped a third segment (`group/subgroup/project`
84
+ // filed under `group/subgroup/`), sharding the record into the WRONG
85
+ // repo's path. Fail closed instead.
86
+ const segments = (record.repo || '').split('/');
87
+ if (segments.length !== 2) {
88
+ throw new Error(`invalid repo format: ${record.repo}`);
89
+ }
90
+ const [org, repo] = segments;
47
91
  if (!org || !repo) {
48
92
  throw new Error(`invalid repo format: ${record.repo}`);
49
93
  }
@@ -80,9 +124,180 @@ export function computeRecordPath(record, repoRoot) {
80
124
  return join(repoRoot, base, org, repo, year, month, day, filename);
81
125
  }
82
126
 
127
+ /**
128
+ * F-4036ae25: a prior `_rejected` record whose rejection_reasons are ALL
129
+ * retryable-class is "the submitter can fix this and resubmit", not an
130
+ * unappealable verdict about the run — the same doctrine that already exempts
131
+ * an unfilable (`_skipPersist`) rejection from consuming its run_id. Narrowing
132
+ * `_skipPersist` in verify/index.js means a filable-but-rejected submission
133
+ * now DOES persist (the fleet-wide audit trail F-4036ae25 restores), so this
134
+ * is the other half of that fix: persisting the evidence must not resurrect
135
+ * the exact run_id-poisoning pathology F-82429f90 eliminated for
136
+ * validator-crash faults.
137
+ *
138
+ * F-f8952a50 (wave 10): this used to check `parseRejectionReason(r).prefix
139
+ * === 'schema:'` — ONE of the ten prefixes parse-rejection.js's own "Prefix
140
+ * taxonomy" documents under `class: 'submission-bad'` ("the submitter fixes
141
+ * the payload and resubmits"). Proven live with `repo:mismatch` (a pure
142
+ * identity/addressing mistake — the run happened, only the repo/run_url
143
+ * pairing was mis-stated): a corrected resubmission's payload never even
144
+ * reached verify() a second time, because both isDuplicate() call sites
145
+ * treated the stale `repo:` rejection as an unconditional block — see
146
+ * f-0f9e4077-retry-collision-duplicate-rejection.test.js's 'F-f8952a50'
147
+ * describe block for the end-to-end proof.
148
+ *
149
+ * The fix reads `parseRejectionReason(r).retryable` — the classifier's OWN
150
+ * per-prefix routing decision (see parse-rejection.js's file header for the
151
+ * full split) — instead of re-deriving a prefix allowlist here. A future
152
+ * prefix parse-rejection.js adds is automatically retryable or not with no
153
+ * edit to this file. This is intentionally NARROWER than "every submission-bad
154
+ * class": `retryable` is false for `policy:` and `provenance:` even though
155
+ * both are class `submission-bad` — those two prefixes are a rendered VERDICT
156
+ * on the run's own reported content (see parse-rejection.js), and letting a
157
+ * resubmission retry past one would let a submitter launder a genuinely-bad
158
+ * run into an accepted one by resubmitting different self-reported content
159
+ * under the same run_id. That boundary is independently pinned end-to-end by
160
+ * schema-invalid-skip-persist.test.js's "REGRESSION GUARD" test and the
161
+ * "persist-a-verdict doctrine is preserved" describe block — this fix must
162
+ * not (and, via the per-prefix flag, does not) touch it.
163
+ *
164
+ * `'operational'` / `'ingest'` / `'unknown'`-class reasons are always
165
+ * `retryable: false` too (see parse-rejection.js). Most `'operational'`-class
166
+ * reasons are not actually reachable here in production (F-82429f90 and its
167
+ * provenance-fault:/scenario-fetch-fault: siblings THROW instead of
168
+ * persisting a `_rejected` record — see runValidator in verify/index.js).
169
+ *
170
+ * F-51780da9 (wave 22, confirming audit of F-be0deacd): `CONTRACT_SCHEMA_TOO_NEW:`
171
+ * is the one documented exception where the frozen `retryable: false` is NOT
172
+ * the whole story. `validators/schema-version.js` RETURNS it as an ordinary
173
+ * rejection string rather than throwing, so a too-new-major submission
174
+ * genuinely does persist to `_rejected/` and IS read by this function.
175
+ * `retryable` is a STATIC per-prefix classification stamped at parse time —
176
+ * it has no way to observe that testing-os has SINCE been upgraded past the
177
+ * declared major, so trusting it alone lets one stale TOO_NEW rejection
178
+ * permanently poison a run_id even after the exact condition it was
179
+ * rejecting no longer holds (proven live: seed a `_rejected` record with a
180
+ * TOO_NEW reason declaring a major the CURRENT build already understands —
181
+ * pre-fix this function still returns `false` forever, because it never asks
182
+ * whether the stored major is still actually too new). `reevaluateTooNewRetry`
183
+ * below is the correction: for this ONE prefix, re-derive whether the STORED
184
+ * rejection's declared major is still above the CURRENT build's
185
+ * `SUPPORTED_SCHEMA_VERSIONS.<contract>.maxMajor`, instead of trusting the
186
+ * frozen boolean. The other nine prefixes were swept for the same trap (any
187
+ * retryable semantics that depend on mutable environment rather than the
188
+ * submission itself) and none qualify: schema:/policy-config:/repo:/
189
+ * submission-contains-verifier-field:/unsafe-record-path:/steps[<id>]:/
190
+ * CONTRACT_SCHEMA_TOO_OLD: are already retryable:true (an already-permissive
191
+ * prefix has no "stuck forever" direction to fix); policy:/provenance: are a
192
+ * DELIBERATE, permanent anti-gaming verdict on the run's own reported
193
+ * content, not an environment-dependent snapshot that goes stale;
194
+ * provenance-fault:/scenario-fetch-fault:/submission-malformed:/
195
+ * VALIDATOR_FAULT_*: all throw rather than persist (never reach this
196
+ * function); scenario-load: is evaluated against an immutable git
197
+ * commit_sha, not a build-version boundary, so the same committed content at
198
+ * the same ref parses the same way forever — there is no analogous integer
199
+ * ceiling to re-derive the way TOO_NEW's `maxMajor` comparison allows.
200
+ *
201
+ * Any read/parse failure fails closed (blocking): a corrupted or unreadable
202
+ * evidence file must never silently unblock a path collision. The same
203
+ * failure mode covers a CONTRACT_SCHEMA_TOO_NEW: reason whose shape
204
+ * `reevaluateTooNewRetry` cannot parse, or whose contract key is no longer
205
+ * registered in `SUPPORTED_SCHEMA_VERSIONS` — both fail closed to "still
206
+ * blocking" rather than guessing.
207
+ *
208
+ * @param {string} rejectedPath - Absolute path already confirmed to exist.
209
+ * @returns {boolean} true when every rejection reason is individually
210
+ * retryable (see parseRejectionReason's `retryable` field), OR — for
211
+ * CONTRACT_SCHEMA_TOO_NEW: specifically — no longer applicable because
212
+ * testing-os has since been upgraded past the declared major.
213
+ */
214
+
215
+ /**
216
+ * F-51780da9: matches a `CONTRACT_SCHEMA_TOO_NEW:` reason string emitted by
217
+ * `validators/schema-version.js` — `CONTRACT_SCHEMA_TOO_NEW: <contract>
218
+ * schema v<major>.<minor>.<patch> ...` — capturing the contract key and the
219
+ * declared MAJOR, the two pieces of information needed to re-ask, against
220
+ * the CURRENT build, the exact question the stored rejection answered
221
+ * against a possibly-older one. Anchored to the literal TOO_NEW prefix only
222
+ * (never TOO_OLD:, a distinct literal) and requires a trailing space after
223
+ * the three-part version so it matches both the full production message
224
+ * (`... but this build supports v...`) and a shortened test fixture
225
+ * (`... — upgrade testing-os`) without over-matching a malformed variant.
226
+ */
227
+ const TOO_NEW_DECLARED_VERSION = /^CONTRACT_SCHEMA_TOO_NEW: (\S+) schema v(\d+)\.\d+\.\d+ /;
228
+
229
+ /**
230
+ * F-51780da9: re-evaluate a single STORED `CONTRACT_SCHEMA_TOO_NEW:` reason
231
+ * against the CURRENT build's `SUPPORTED_SCHEMA_VERSIONS`, instead of
232
+ * trusting the frozen `retryable: false` parse-rejection.js stamps on this
233
+ * prefix. `validators/schema-version.js` emits this prefix precisely when
234
+ * `declaredMajor > supported.maxMajor`; this re-runs that identical
235
+ * comparison against whatever `maxMajor` is TODAY, which a testing-os
236
+ * upgrade may have since raised past the stored declared major.
237
+ *
238
+ * @param {string} reason - a single rejection_reasons[] entry.
239
+ * @returns {boolean} `true` when the declared major is no longer above the
240
+ * current build's supported ceiling (the rejection has been resolved by a
241
+ * since-applied upgrade); `false` when it is still genuinely too new, the
242
+ * contract key is unrecognized, or `reason` is not a
243
+ * CONTRACT_SCHEMA_TOO_NEW: reason at all — every non-match fails closed to
244
+ * "still blocking" rather than guessing.
245
+ */
246
+ function reevaluateTooNewRetry(reason) {
247
+ const match = typeof reason === 'string' ? reason.match(TOO_NEW_DECLARED_VERSION) : null;
248
+ if (!match) return false;
249
+ const [, contract, declaredMajorStr] = match;
250
+ const supported = SUPPORTED_SCHEMA_VERSIONS[contract];
251
+ if (!supported) return false;
252
+ return Number(declaredMajorStr) <= supported.maxMajor;
253
+ }
254
+
255
+ function isRetryableRejection(rejectedPath) {
256
+ let parsed;
257
+ try {
258
+ parsed = JSON.parse(readFileSync(rejectedPath, 'utf-8'));
259
+ } catch {
260
+ return false;
261
+ }
262
+ const reasons = parsed?.verification?.rejection_reasons;
263
+ if (parsed?.verification?.status !== 'rejected' || !Array.isArray(reasons) || reasons.length === 0) {
264
+ return false;
265
+ }
266
+ // F-51780da9: a reason counts as retryable either via the classifier's own
267
+ // static per-prefix flag, or — for CONTRACT_SCHEMA_TOO_NEW: specifically —
268
+ // via the re-derived, current-environment check above. Every other prefix
269
+ // is unaffected: reevaluateTooNewRetry returns false for any reason it
270
+ // does not recognize, so `.every()`'s existing behavior is unchanged for
271
+ // schema:/policy:/repo:/provenance:/etc.
272
+ return reasons.every(r => parseRejectionReason(r).retryable === true || reevaluateTooNewRetry(r));
273
+ }
274
+
83
275
  /**
84
276
  * Check if a record with this run_id already exists (accepted or rejected).
85
277
  *
278
+ * F-0f9e4077 (wave-6 regression, fixed wave-8): computeRecordPath() is keyed
279
+ * on (run_id, date, accepted|rejected) — never on rejection_reasons content.
280
+ * So once a `_rejected` record occupies a path, ANY new record whose OWN
281
+ * status is STILL 'rejected' is headed to that exact same path no matter
282
+ * which violation it reports (an unexpected field with a different name, a
283
+ * different value, even a byte-identical resubmission all collide
284
+ * identically — computeRecordPath never looks at content). The
285
+ * isRetryableRejection carve-out below exists so a stale retryable-class
286
+ * rejection (F-f8952a50: any prefix parse-rejection.js's taxonomy flags
287
+ * `retryable: true` — shape/addressing mistakes, not just schema:) does not
288
+ * block a DIFFERENT-status (now accepted) record from reaching ITS OWN,
289
+ * different path — it was never a promise that the
290
+ * REJECTED path specifically is free. Pre-fix, skipping the status check let
291
+ * an uncorrected retry fall through as "not a duplicate," reach
292
+ * writeRecord()'s exclusive-create, and throw DuplicateRunIdError — a
293
+ * purely sequential, single-writer collision masquerading as the two-writer
294
+ * TOCTOU race that error class exists for. Gating on
295
+ * `record.verification.status === 'accepted'` restores the carve-out to
296
+ * exactly the case it can actually help (a record now headed elsewhere)
297
+ * while making a still-rejected collision an unconditional, ordinary
298
+ * duplicate — so writeRecord() short-circuits before ever attempting the
299
+ * write, and no throw is reachable for this class anymore.
300
+ *
86
301
  * @param {string} runId
87
302
  * @param {object} record - The record (used for repo/timing to compute path)
88
303
  * @param {string} repoRoot
@@ -97,7 +312,14 @@ export function isDuplicate(runId, record, repoRoot) {
97
312
  // Check rejected path
98
313
  const rejectedRecord = { ...record, verification: { ...record.verification, status: 'rejected' } };
99
314
  const rejectedPath = computeRecordPath(rejectedRecord, repoRoot);
100
- if (existsSync(rejectedPath)) return true;
315
+ if (existsSync(rejectedPath)) {
316
+ // A record that is itself still rejected can only ever land at THIS
317
+ // path — asking whether the OLD occupant is retryable is moot when the
318
+ // NEW attempt has nowhere else to go. Only a record now bound for the
319
+ // accepted path (real forward progress) gets to ask that question.
320
+ if (record.verification?.status !== 'accepted') return true;
321
+ return !isRetryableRejection(rejectedPath);
322
+ }
101
323
 
102
324
  return false;
103
325
  }
@@ -122,7 +344,21 @@ export function isDuplicate(runId, record, repoRoot) {
122
344
  * @throws {DuplicateRunIdError} when a concurrent writer won the race
123
345
  */
124
346
  export function writeRecord(record, repoRoot) {
125
- if (isDuplicate(record.run_id, record, repoRoot)) {
347
+ // F-a37d36f5: family sibling of the ingest.js pre-check fix — writeRecord
348
+ // carries its OWN premature isDuplicate call, reached even when the
349
+ // caller's own duplicate check was skipped or already cleared. A record
350
+ // whose repo/run_id computeRecordPath rejects (invalid format, unsafe
351
+ // segment) cannot possibly collide with an existing file, so treating the
352
+ // throw as "not a duplicate" here is the same semantically-free
353
+ // short-circuit as the run.js fix — it lets validateRecord() below be the
354
+ // authoritative judge instead of a raw path-computation throw.
355
+ let duplicate;
356
+ try {
357
+ duplicate = isDuplicate(record.run_id, record, repoRoot);
358
+ } catch {
359
+ duplicate = false;
360
+ }
361
+ if (duplicate) {
126
362
  const path = computeRecordPath(record, repoRoot);
127
363
  return { path, written: false };
128
364
  }
@@ -152,7 +388,16 @@ export function writeRecord(record, repoRoot) {
152
388
  // schema is the contract every downstream consumer relies on.
153
389
  validateRecord(record);
154
390
 
155
- const path = computeRecordPath(record, repoRoot);
391
+ // F-bbbe2e1f: see UnsafeRecordPathError's doc comment. Wrap this SECOND
392
+ // computeRecordPath() call — the first is inside isDuplicate() above,
393
+ // already guarded by F-a37d36f5 — so a schema-valid-but-unsafe repo (e.g.
394
+ // `../etc`) throws a classified error instead of a bare one.
395
+ let path;
396
+ try {
397
+ path = computeRecordPath(record, repoRoot);
398
+ } catch (err) {
399
+ throw new UnsafeRecordPathError(record, err);
400
+ }
156
401
  const dir = dirname(path);
157
402
 
158
403
  mkdirSync(dir, { recursive: true });
@@ -281,13 +281,29 @@ export function rebuildIndexes(repoRoot, options = {}) {
281
281
 
282
282
  // --- latest-by-repo.json ---
283
283
  // Keyed by repo, then product_surface. Only accepted records count.
284
- const latestByRepo = {};
284
+ //
285
+ // F-89b7dcd5: `record.repo` and `sr.product_surface` come from loadRecord()
286
+ // (JSON.parse only, no schema gate — see its JSDoc) rather than validated
287
+ // submission input, so a hand-committed record with repo: '__proto__' would
288
+ // otherwise resolve `latestByRepo['__proto__']` to Object.prototype itself
289
+ // (truthy, on a plain `{}`), skip the init branch below, and land the
290
+ // surface write ON Object.prototype — real global prototype pollution for
291
+ // the rest of this process. Object.create(null) removes the prototype
292
+ // chain entirely: `latestByRepo['__proto__']` is a plain (absent) data
293
+ // property, not the special accessor. JSON.stringify (below, at the
294
+ // commitGroupRename call) serializes a null-prototype object identically
295
+ // to a plain one, so this costs nothing on the write side. Not reachable
296
+ // from the ingest write path today (writeRecord's validateRecord() gate
297
+ // constrains repo's shape before persist.js ever writes a file), so this
298
+ // is defense-in-depth against a hand-committed or otherwise out-of-band
299
+ // record file, not a live vulnerability.
300
+ const latestByRepo = Object.create(null);
285
301
 
286
302
  for (const record of allRecords) {
287
303
  if (record.verification?.status !== 'accepted') continue;
288
304
 
289
305
  const repo = record.repo;
290
- if (!latestByRepo[repo]) latestByRepo[repo] = {};
306
+ if (!latestByRepo[repo]) latestByRepo[repo] = Object.create(null);
291
307
 
292
308
  for (const sr of record.scenario_results || []) {
293
309
  const surface = sr.product_surface;