@dogfood-lab/ingest 1.2.3 → 1.3.1

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.
@@ -24,6 +24,8 @@
24
24
  */
25
25
  import { renameSync } from 'node:fs';
26
26
 
27
+ import { sleepSync } from './sleep-sync.js';
28
+
27
29
  export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs = 200 } = {}) {
28
30
  for (let i = 0; i <= retries; i++) {
29
31
  try {
@@ -32,8 +34,12 @@ export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs =
32
34
  } catch (err) {
33
35
  if ((err.code !== 'EPERM' && err.code !== 'EBUSY') || i === retries) throw err;
34
36
  const delay = Math.min(baseMs * (1 << i), maxMs);
35
- const until = Date.now() + delay;
36
- while (Date.now() < until) { /* spin */ }
37
+ // D1B-002-ingest (Stage C humanization fold): replaced the
38
+ // `while (Date.now() < until)` busy-spin with the Atomics.wait-
39
+ // based sleepSync helper. Same workspace-cycle pattern as
40
+ // `ingest/lib/atomic-write.js` — sibling helper, not a
41
+ // cross-package import.
42
+ sleepSync(delay);
37
43
  }
38
44
  }
39
45
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * sleepSync — `Atomics.wait`-based synchronous sleep.
3
+ *
4
+ * Sibling of `packages/findings/lib/file-lock.js`'s private `sleepSync`. The
5
+ * duplication exists because npm workspaces do not allow `ingest → findings`
6
+ * imports (findings already depends on ingest, and the reverse edge would
7
+ * create a workspace dependency cycle — same constraint that forced
8
+ * `packages/ingest/lib/atomic-write.js` to duplicate
9
+ * `packages/findings/lib/atomic-write.js`).
10
+ *
11
+ * The helper exists to replace the `while (Date.now() < until)` busy-spin
12
+ * loops sprinkled across synchronous backoff paths (notably
13
+ * `packages/ingest/lib/rename-with-retry.js`'s EPERM/EBUSY retry on Windows
14
+ * NTFS). Spin loops burn a full CPU core for the entire backoff window
15
+ * — Atomics.wait cedes the thread.
16
+ *
17
+ * Why synchronous: the callsites (atomic-write helpers, rebuild-indexes,
18
+ * event-log appender, rename-with-retry) are themselves synchronous and
19
+ * cascading an `await` would leak the boundary into otherwise-deterministic
20
+ * flush paths — same rationale documented at the findings sibling.
21
+ *
22
+ * @param {number} ms - Milliseconds to sleep. Non-finite or non-positive
23
+ * values are no-ops (we don't trust caller arithmetic to never produce
24
+ * negative deltas if a system clock skews).
25
+ */
26
+ export function sleepSync(ms) {
27
+ if (!Number.isFinite(ms) || ms <= 0) return;
28
+ const sab = new SharedArrayBuffer(4);
29
+ const view = new Int32Array(sab);
30
+ Atomics.wait(view, 0, 0, ms);
31
+ }
package/load-context.js CHANGED
@@ -14,8 +14,39 @@ import { readFileSync, existsSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import yaml from 'js-yaml';
16
16
 
17
+ import { validatePayload } from '@dogfood-lab/schemas';
18
+ import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
17
19
  import { isUnsafeSegment } from './lib/unsafe-segment.js';
18
20
 
21
+ /**
22
+ * D2B-005 (Phase 10 Step 1): summarise a `validatePayload('policy', …)`
23
+ * result down to a single-line operator-actionable reason string. The
24
+ * canonical seam returns `{ path, message, keyword, params }` per error;
25
+ * we collapse the first 3 violations into a `path message` join so the
26
+ * sentinel's `reason` field stays grep-friendly without truncating the
27
+ * structural detail an operator needs to find the offending YAML key.
28
+ *
29
+ * Three errors is a deliberate ceiling — a policy with 20 violations
30
+ * almost certainly has a single root cause (wrong section nesting,
31
+ * malformed root); flooding the reason string with 20 lines makes the
32
+ * error harder to read, not easier.
33
+ */
34
+ function summarizePolicyErrors(errors) {
35
+ const trimmed = errors.slice(0, 3).map(e => `${e.path || '/'} ${e.message}`);
36
+ const ellipsis = errors.length > 3 ? `; (+${errors.length - 3} more)` : '';
37
+ return trimmed.join('; ') + ellipsis;
38
+ }
39
+
40
+ /**
41
+ * D1B-006 (Stage C humanization): one structured `logStage(...)` helper,
42
+ * component-pinned to `ingest` so the NDJSON stream is greppable with the
43
+ * rest of the pipeline. Same wave-22 wrapper-strip pattern as `run.js`.
44
+ */
45
+ function logStage(stage, fields = {}) {
46
+ const { stage: _ignored, ...rest } = fields;
47
+ sharedLogStage(stage, { component: 'ingest', ...rest });
48
+ }
49
+
19
50
  /**
20
51
  * Load the global policy.
21
52
  *
@@ -46,8 +77,9 @@ export function loadGlobalPolicy(repoRoot) {
46
77
  }
47
78
  throw new Error(`Global policy unreadable: ${path} — ${e.message}`);
48
79
  }
80
+ let parsed;
49
81
  try {
50
- return yaml.load(raw);
82
+ parsed = yaml.load(raw);
51
83
  } catch (e) {
52
84
  const where = e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
53
85
  throw new Error(
@@ -55,14 +87,46 @@ export function loadGlobalPolicy(repoRoot) {
55
87
  `Fix the YAML and re-run.`
56
88
  );
57
89
  }
90
+
91
+ // D2B-005: schema-gate the parsed policy against policy.schema.json.
92
+ // Pre-fix, a YAML that parsed but didn't conform (extra fields, wrong
93
+ // enums, missing required) silently loaded and the verifier ran with
94
+ // ad-hoc field probing — a structurally-invalid policy could produce
95
+ // "verifier accepts everything." Post-H3 the canonical seam is one
96
+ // call away. Global policy is required + load-once, so a schema fault
97
+ // here is fail-loud (matches the YAML-invalid sibling above).
98
+ const validation = validatePayload('policy', parsed);
99
+ if (!validation.valid) {
100
+ throw new Error(
101
+ `Global policy schema-invalid: ${path} — ${summarizePolicyErrors(validation.errors)}\n` +
102
+ `Fix the policy to conform to policy.schema.json and re-run.`
103
+ );
104
+ }
105
+ return parsed;
58
106
  }
59
107
 
60
108
  /**
61
- * Load repo-specific policy. Returns null if no policy exists.
109
+ * Load repo-specific policy.
110
+ *
111
+ * Three discriminable return shapes:
112
+ * - `null` → no repo-policy file exists; defaults apply
113
+ * (documented design — submission is NOT rejected for absent policy).
114
+ * - parsed YAML object → policy loaded cleanly.
115
+ * - `{ __torn: true, reason, path }` → D1B-006 sentinel: the policy
116
+ * file EXISTS but cannot be parsed. Pre-fix this case returned null,
117
+ * which the verifier then treated as "no policy → defaults apply →
118
+ * accept". That sentinel inversion silently approved every
119
+ * submission against a corrupt policy. The torn sentinel surfaces
120
+ * the broken state so the verifier can reject with a
121
+ * `'policy: <reason>'` rejection AND `policy_valid=false`.
122
+ *
123
+ * The sentinel is intentionally a distinct shape (`__torn: true`) so the
124
+ * verifier pattern-match needs no schema knowledge — the existence of
125
+ * `__torn` discriminates without parsing the policy twice.
62
126
  *
63
127
  * @param {string} repoSlug - e.g. "mcp-tool-shop-org/dogfood-labs"
64
128
  * @param {string} repoRoot
65
- * @returns {object|null}
129
+ * @returns {object|null|{ __torn: true, reason: string, path: string }}
66
130
  */
67
131
  export function loadRepoPolicy(repoSlug, repoRoot) {
68
132
  const [org, repo] = repoSlug.split('/');
@@ -70,12 +134,43 @@ export function loadRepoPolicy(repoSlug, repoRoot) {
70
134
  const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
71
135
 
72
136
  if (!existsSync(path)) return null;
137
+ let parsed;
73
138
  try {
74
- return yaml.load(readFileSync(path, 'utf-8'));
75
- } catch {
76
- console.warn(`load-context: malformed YAML in repo policy for ${repoSlug}`);
77
- return null;
139
+ parsed = yaml.load(readFileSync(path, 'utf-8'));
140
+ } catch (e) {
141
+ // D1B-006: torn-policy sentinel + structured warn event for YAML
142
+ // parse failures. The verifier (verify/index.js:189-192) surfaces
143
+ // this as a `policy: <reason>` rejection.
144
+ const reason = e && e.message ? e.message : String(e);
145
+ logStage('warn', {
146
+ kind: 'repo_policy_unreadable',
147
+ repo: repoSlug,
148
+ path,
149
+ error: reason
150
+ });
151
+ return { __torn: true, reason, path };
78
152
  }
153
+
154
+ // D2B-005 (Phase 10 Step 1): schema-gate the parsed repo policy.
155
+ // Reuses the `__torn` sentinel so the verifier's existing single-
156
+ // branch handler (verify/index.js:189) catches both "YAML failed
157
+ // to parse" (D1B-006) and "YAML parsed but doesn't conform to
158
+ // policy.schema.json" (D2B-005). The verify-side reason prefix
159
+ // (`policy: repo policy unreadable — <reason>`) covers both
160
+ // classes; the embedded schema-violation detail tells the operator
161
+ // which YAML key to fix.
162
+ const validation = validatePayload('policy', parsed);
163
+ if (!validation.valid) {
164
+ const reason = `schema-invalid — ${summarizePolicyErrors(validation.errors)}`;
165
+ logStage('warn', {
166
+ kind: 'repo_policy_unreadable',
167
+ repo: repoSlug,
168
+ path,
169
+ error: reason
170
+ });
171
+ return { __torn: true, reason, path };
172
+ }
173
+ return parsed;
79
174
  }
80
175
 
81
176
  /**
@@ -96,64 +191,153 @@ export function localScenarioFetcher(repoRoot) {
96
191
  };
97
192
  }
98
193
 
194
+ /**
195
+ * Default per-request timeout for the GitHub scenario fetch. Mirrors the
196
+ * sibling `GITHUB_PROVENANCE_TIMEOUT_MS` constant at
197
+ * `packages/verify/validators/provenance.js`: a hung GitHub API call
198
+ * would otherwise stall ingest until the surrounding GitHub Actions
199
+ * runner timeout fires (default 6h). 30s fails fast with a typed
200
+ * `'timeout'` reason the operator can pivot on.
201
+ */
202
+ export const GITHUB_SCENARIO_FETCH_TIMEOUT_MS = 30000;
203
+
99
204
  /**
100
205
  * GitHub scenario fetcher. Loads scenario definitions from a source repo
101
206
  * via the GitHub API at a specific commit SHA.
102
207
  *
208
+ * Two surfaces on the returned adapter:
209
+ * - `fetch(scenarioId)`: legacy contract — returns the scenario object
210
+ * on success, `null` on any failure. Preserved for back-compat with
211
+ * callers that pattern-match on the truthiness of the result.
212
+ * - `fetchWithReason(scenarioId)`: D1B-004 typed contract — always
213
+ * returns `{ scenario, reason }` where `scenario` is the loaded
214
+ * object on success or `null` on failure, and `reason` is one of
215
+ * `'timeout' | 'not_found' | 'parse_error' | 'invalid_id'` on
216
+ * failure (absent on success). The reason gives operators a
217
+ * pivot key when diagnosing a stale scenario-load chain.
218
+ *
219
+ * Both surfaces honour the per-request AbortController timeout
220
+ * (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
221
+ *
103
222
  * @param {string} token - GitHub PAT
104
223
  * @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
105
224
  * @param {string} commitSha - Commit to fetch scenarios from
106
- * @returns {object} Scenario fetch adapter
225
+ * @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
226
+ * @returns {{ fetch(scenarioId: string): Promise<object|null>, fetchWithReason(scenarioId: string): Promise<{ scenario: object|null, reason?: string }> }}
107
227
  */
108
- export function githubScenarioFetcher(token, repoSlug, commitSha) {
228
+ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
229
+ const timeoutMs = opts.timeoutMs ?? GITHUB_SCENARIO_FETCH_TIMEOUT_MS;
230
+ const fetchImpl = opts.fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
231
+
109
232
  const [org, repo] = repoSlug.split('/');
110
233
  if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
111
- return { async fetch() { return null; } };
234
+ return {
235
+ async fetch() { return null; },
236
+ async fetchWithReason() { return { scenario: null, reason: 'invalid_id' }; }
237
+ };
112
238
  }
113
- return {
114
- async fetch(scenarioId) {
115
- if (!/^[\w-]+$/.test(scenarioId)) return null;
116
- const path = `dogfood/scenarios/${scenarioId}.yaml`;
117
- const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
118
-
119
- try {
120
- const resp = await globalThis.fetch(url, {
121
- headers: {
122
- Authorization: `Bearer ${token}`,
123
- Accept: 'application/vnd.github.raw+json',
124
- 'X-GitHub-Api-Version': '2022-11-28'
125
- }
126
- });
127
- if (!resp.ok) return null;
128
- const text = await resp.text();
129
- return yaml.load(text);
130
- } catch {
131
- return null;
239
+
240
+ async function fetchWithReason(scenarioId) {
241
+ if (!/^[\w-]+$/.test(scenarioId)) {
242
+ return { scenario: null, reason: 'invalid_id' };
243
+ }
244
+ const path = `dogfood/scenarios/${scenarioId}.yaml`;
245
+ const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
246
+
247
+ // D1B-004: AbortController-bounded request. Copied from
248
+ // `packages/verify/validators/provenance.js:80-104`. The 30s default
249
+ // matches that sibling so a single end-to-end ingest cannot stall
250
+ // longer than ~60s on cumulative GitHub-API timeouts.
251
+ const controller = new AbortController();
252
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
253
+
254
+ let text;
255
+ try {
256
+ const resp = await fetchImpl(url, {
257
+ headers: {
258
+ Authorization: `Bearer ${token}`,
259
+ Accept: 'application/vnd.github.raw+json',
260
+ 'X-GitHub-Api-Version': '2022-11-28'
261
+ },
262
+ signal: controller.signal
263
+ });
264
+ if (!resp.ok) {
265
+ return { scenario: null, reason: 'not_found' };
266
+ }
267
+ text = await resp.text();
268
+ } catch (err) {
269
+ if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
270
+ return { scenario: null, reason: 'timeout' };
132
271
  }
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' };
275
+ } finally {
276
+ clearTimeout(timer);
133
277
  }
278
+
279
+ try {
280
+ const scenario = yaml.load(text);
281
+ if (!scenario || typeof scenario !== 'object') {
282
+ return { scenario: null, reason: 'parse_error' };
283
+ }
284
+ return { scenario };
285
+ } catch {
286
+ return { scenario: null, reason: 'parse_error' };
287
+ }
288
+ }
289
+
290
+ return {
291
+ // Legacy: success returns the scenario object, failure returns null
292
+ // — every existing caller pattern-matches on truthiness.
293
+ async fetch(scenarioId) {
294
+ const result = await fetchWithReason(scenarioId);
295
+ return result.scenario;
296
+ },
297
+ fetchWithReason
134
298
  };
135
299
  }
136
300
 
137
301
  /**
138
302
  * Load all scenario definitions referenced by a submission's scenario_results.
139
303
  *
304
+ * D1B-004 / L1-007 (Wave A2 Stage C amend2): when the fetcher exposes
305
+ * the typed `fetchWithReason` surface (the canonical
306
+ * `githubScenarioFetcher` does; the legacy `localScenarioFetcher` and any
307
+ * legacy test stub may not), the discriminated `reason` (`timeout` /
308
+ * `not_found` / `parse_error` / `invalid_id`) is propagated into the
309
+ * error string so the operator can pivot on the failure class. Otherwise
310
+ * we fall back to the legacy `fetch(id)` truthiness contract.
311
+ *
140
312
  * @param {object} submission
141
- * @param {object} scenarioFetcher - { fetch(scenarioId) => Promise<object|null> }
313
+ * @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
142
314
  * @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
143
315
  */
144
316
  export async function loadScenarios(submission, scenarioFetcher) {
145
317
  const scenarios = new Map();
146
318
  const errors = [];
147
319
 
320
+ const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
321
+
148
322
  for (const sr of submission.scenario_results || []) {
149
323
  const id = sr.scenario_id;
150
324
  if (scenarios.has(id)) continue;
151
325
 
152
- const definition = await scenarioFetcher.fetch(id);
153
- if (definition) {
154
- scenarios.set(id, definition);
326
+ if (supportsTypedReason) {
327
+ const result = await scenarioFetcher.fetchWithReason(id);
328
+ if (result && result.scenario) {
329
+ scenarios.set(id, result.scenario);
330
+ } else {
331
+ const reason = result && result.reason ? result.reason : 'unknown';
332
+ errors.push(`scenario "${id}" could not be loaded from source repo (reason: ${reason})`);
333
+ }
155
334
  } else {
156
- errors.push(`scenario "${id}" could not be loaded from source repo`);
335
+ const definition = await scenarioFetcher.fetch(id);
336
+ if (definition) {
337
+ scenarios.set(id, definition);
338
+ } else {
339
+ errors.push(`scenario "${id}" could not be loaded from source repo`);
340
+ }
157
341
  }
158
342
  }
159
343
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
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
- "./lib/*": "./lib/*"
9
+ "./lib/*": "./lib/*",
10
+ "./validate-record.js": "./validate-record.js"
10
11
  },
11
12
  "scripts": {
12
13
  "test": "node --test",
@@ -29,8 +30,6 @@
29
30
  "@dogfood-lab/dogfood-swarm": "^1.2.0",
30
31
  "@dogfood-lab/schemas": "^1.2.0",
31
32
  "@dogfood-lab/verify": "^1.2.0",
32
- "ajv": "^8.18.0",
33
- "ajv-formats": "^3.0.1",
34
33
  "js-yaml": "^4.1.0"
35
34
  },
36
35
  "engines": {
@@ -28,8 +28,24 @@ import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, unlink
28
28
  import { join, relative } from 'node:path';
29
29
  import { randomBytes } from 'node:crypto';
30
30
 
31
+ import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
31
32
  import { stageWriteFileSync, promoteStaged, discardStaged } from './lib/atomic-write.js';
32
33
 
34
+ /**
35
+ * D1B-005 (Stage C humanization): one structured `logStage('warn', ...)`
36
+ * helper, component-pinned to `ingest` so the NDJSON line is greppable
37
+ * with the rest of the pipeline. Wave-22 sibling pattern — see
38
+ * `packages/ingest/run.js`'s private `logStage` wrapper for the same
39
+ * `component: 'ingest'` pinning convention.
40
+ */
41
+ function logStage(stage, fields = {}) {
42
+ // Defensive: strip any caller-supplied `stage:` before delegating so
43
+ // the outer positional stage always wins. Same shape as run.js (see
44
+ // wave-22 F-827321-035 hardening rationale).
45
+ const { stage: _ignored, ...rest } = fields;
46
+ sharedLogStage(stage, { component: 'ingest', ...rest });
47
+ }
48
+
33
49
  /**
34
50
  * Recursively find all .json files under a directory.
35
51
  *
@@ -103,12 +119,24 @@ export function rebuildIndexes(repoRoot, options = {}) {
103
119
  const { record, error } = loadRecord(f);
104
120
  if (error) {
105
121
  corrupted.push({ path: relPath, error });
106
- console.error(`[rebuild-indexes] corrupted record skipped: ${relPath} ${error}`);
122
+ // D1B-005: structured warn event so the NDJSON stream carries the
123
+ // skip with the same discipline as the ingest pipeline. Greppable
124
+ // via `"kind":"record_skipped"` + `"reason":"corrupted"`.
125
+ logStage('warn', {
126
+ kind: 'record_skipped',
127
+ reason: 'corrupted',
128
+ path: relPath,
129
+ error
130
+ });
107
131
  continue;
108
132
  }
109
133
  if (!record || !record.run_id) {
110
134
  skipped.push({ path: relPath, reason: 'missing run_id' });
111
- console.error(`[rebuild-indexes] record skipped (missing run_id): ${relPath}`);
135
+ logStage('warn', {
136
+ kind: 'record_skipped',
137
+ reason: 'missing_run_id',
138
+ path: relPath
139
+ });
112
140
  continue;
113
141
  }
114
142
  record._path = relPath;
package/run.js CHANGED
@@ -100,7 +100,7 @@ function resolveCorrelationId(submission) {
100
100
  *
101
101
  * @param {object} submission - Source-authored submission payload
102
102
  * @param {object} options
103
- * @param {string} options.repoRoot - Absolute path to dogfood-labs repo root
103
+ * @param {string} options.repoRoot - Absolute path to the dogfood-lab/testing-os repo root
104
104
  * @param {object} options.provenance - Provenance adapter (REQUIRED — no default, no implicit stub)
105
105
  * @param {object} [options.scenarioFetcher] - Scenario fetch adapter
106
106
  * @returns {Promise<{ record: object, path: string, written: boolean, duplicate: boolean }>}
@@ -435,6 +435,31 @@ export async function verifyOnly(submission, options) {
435
435
  return { record, would_persist_to, verify_only: true };
436
436
  }
437
437
 
438
+ /**
439
+ * D1B-001: emit a single structured `stage:'error'` NDJSON event for any
440
+ * CLI-toplevel exit-2 failure, mirroring the shape used by the
441
+ * `rebuild_indexes` inner catch. Truncates the stack to 20 lines so the
442
+ * event stays grep-friendly. The human-readable `console.error` line is
443
+ * preserved so log-only readers continue to get the same actionable hint.
444
+ *
445
+ * Keep this hoisted (above the `isMain` block) so it is callable from every
446
+ * branch inside the CLI body — including the JSON.parse catch which fires
447
+ * BEFORE the pipeline has assigned a correlation_id from `submission.run_id`.
448
+ */
449
+ function emitCliErrorEvent({ failedStage, correlationId, submissionId = null, err, humanPrefix }) {
450
+ const truncatedStack = err && err.stack
451
+ ? err.stack.split('\n').slice(0, 20).join('\n')
452
+ : null;
453
+ logStage('error', {
454
+ submission_id: submissionId,
455
+ correlation_id: correlationId,
456
+ failed_stage: failedStage,
457
+ message: err && err.message ? err.message : String(err),
458
+ stack: truncatedStack
459
+ });
460
+ console.error(`ERROR: ${humanPrefix}: ${err && err.message ? err.message : String(err)}`);
461
+ }
462
+
438
463
  // --- CLI entrypoint ---
439
464
  // When run directly, reads submission from stdin or file argument
440
465
 
@@ -455,7 +480,24 @@ if (isMain) {
455
480
  provenanceMode = args[++i];
456
481
  } else if (args[i] === '--file' && args[i + 1]) {
457
482
  const { readFileSync } = await import('node:fs');
458
- submissionJson = readFileSync(resolve(args[++i]), 'utf-8');
483
+ // D1B-001 family (operator-legibility): a --file read failure
484
+ // (ENOENT/EACCES) routes through the structured error event and exits 2
485
+ // — pre-fix it propagated as a raw uncaught stack + exit 1, with NO
486
+ // grep-able `"stage":"error"` NDJSON line. This read runs during
487
+ // arg-parsing, BEFORE `cliCorrelationId` is seeded below, so synth a
488
+ // correlation id here (the same pivot the JSON.parse catch uses when
489
+ // there is no submission to derive a run_id from yet).
490
+ try {
491
+ submissionJson = readFileSync(resolve(args[++i]), 'utf-8');
492
+ } catch (err) {
493
+ emitCliErrorEvent({
494
+ failedStage: 'cli_read_file',
495
+ correlationId: synthCorrelationId(),
496
+ err,
497
+ humanPrefix: 'could not read --file payload'
498
+ });
499
+ process.exit(2);
500
+ }
459
501
  } else if (args[i] === '--payload' && args[i + 1]) {
460
502
  submissionJson = args[++i];
461
503
  } else if (args[i] === '--verify-only') {
@@ -476,23 +518,53 @@ if (isMain) {
476
518
  submissionJson = Buffer.concat(chunks).toString('utf-8');
477
519
  }
478
520
 
521
+ // D1B-001 (Stage C humanization): every CLI exit-2 path emits a structured
522
+ // `logStage('error', ...)` event before `process.exit(2)` so a grep of
523
+ // `"stage":"error"` across runner logs surfaces the failure with the same
524
+ // discipline as the inner `rebuild_indexes` catch. `failed_stage` names
525
+ // the last-successful pipeline stage; `correlation_id` carries the pivot
526
+ // key (submission.run_id when available, synth `ing-…` otherwise).
527
+ let lastSuccessfulStage = 'cli_startup';
528
+ let cliCorrelationId = synthCorrelationId();
529
+
479
530
  let submission;
480
531
  try {
481
532
  submission = JSON.parse(submissionJson);
482
533
  if (typeof submission === 'string') {
483
534
  submission = JSON.parse(submission);
484
535
  }
536
+ lastSuccessfulStage = 'cli_parse_payload';
537
+ // Promote the synth id to submission.run_id when we have one.
538
+ cliCorrelationId = resolveCorrelationId(submission);
485
539
  } catch (err) {
486
- console.error(`ERROR: invalid JSON payload: ${err.message}`);
540
+ emitCliErrorEvent({
541
+ failedStage: 'cli_parse_payload',
542
+ correlationId: cliCorrelationId,
543
+ err,
544
+ humanPrefix: 'invalid JSON payload'
545
+ });
487
546
  process.exit(2);
488
547
  }
489
548
 
490
- // Resolve provenance adapter — explicit, never implicit
549
+ // Resolve provenance adapter — explicit, never implicit.
550
+ //
551
+ // L1-001 (Wave A2 amend2): every exit-2 path here routes through
552
+ // `emitCliErrorEvent` so the D1B-001 documented invariant ("every CLI
553
+ // exit-2 path emits a structured logStage('error', …) event") holds.
554
+ // `failed_stage='cli_provenance_resolve'` names the precondition; the
555
+ // `console.error` line is preserved inside the helper so log-only
556
+ // readers keep the same actionable hint.
491
557
  let provenance;
492
558
  if (provenanceMode === 'stub') {
493
559
  // Structural anti-misuse: stub only allowed outside CI
494
560
  if (process.env.CI || process.env.GITHUB_ACTIONS) {
495
- console.error('ERROR: --provenance=stub is not allowed in CI/production. Use --provenance=github.');
561
+ emitCliErrorEvent({
562
+ failedStage: 'cli_provenance_resolve',
563
+ correlationId: cliCorrelationId,
564
+ submissionId: submission && submission.run_id ? submission.run_id : null,
565
+ err: new Error('--provenance=stub is not allowed in CI/production. Use --provenance=github.'),
566
+ humanPrefix: 'provenance precondition unmet'
567
+ });
496
568
  process.exit(2);
497
569
  }
498
570
  console.error('WARNING: Using stub provenance (test/dev only). Records will NOT have real provenance verification.');
@@ -500,7 +572,13 @@ if (isMain) {
500
572
  } else if (provenanceMode === 'github') {
501
573
  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
502
574
  if (!token) {
503
- console.error('ERROR: --provenance=github requires GITHUB_TOKEN or GH_TOKEN environment variable.');
575
+ emitCliErrorEvent({
576
+ failedStage: 'cli_provenance_resolve',
577
+ correlationId: cliCorrelationId,
578
+ submissionId: submission && submission.run_id ? submission.run_id : null,
579
+ err: new Error('--provenance=github requires GITHUB_TOKEN or GH_TOKEN environment variable.'),
580
+ humanPrefix: 'provenance precondition unmet'
581
+ });
504
582
  process.exit(2);
505
583
  }
506
584
  provenance = githubProvenance(token);
@@ -508,18 +586,40 @@ if (isMain) {
508
586
  // In CI without explicit flag: default to github provenance, fail if no token
509
587
  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
510
588
  if (!token) {
511
- console.error('ERROR: Running in CI without --provenance flag and no GITHUB_TOKEN. Cannot verify provenance.');
589
+ emitCliErrorEvent({
590
+ failedStage: 'cli_provenance_resolve',
591
+ correlationId: cliCorrelationId,
592
+ submissionId: submission && submission.run_id ? submission.run_id : null,
593
+ err: new Error('Running in CI without --provenance flag and no GITHUB_TOKEN. Cannot verify provenance.'),
594
+ humanPrefix: 'provenance precondition unmet'
595
+ });
512
596
  process.exit(2);
513
597
  }
514
598
  provenance = githubProvenance(token);
515
599
  } else {
516
- console.error('ERROR: --provenance flag is required. Use --provenance=github (production) or --provenance=stub (test/dev only).');
600
+ emitCliErrorEvent({
601
+ failedStage: 'cli_provenance_resolve',
602
+ correlationId: cliCorrelationId,
603
+ submissionId: submission && submission.run_id ? submission.run_id : null,
604
+ err: new Error('--provenance flag is required. Use --provenance=github (production) or --provenance=stub (test/dev only).'),
605
+ humanPrefix: 'provenance precondition unmet'
606
+ });
517
607
  process.exit(2);
518
608
  }
519
609
 
610
+ // D1B-001: track the last successful pipeline stage so the outer catch
611
+ // can surface a useful `failed_stage` in its structured error event.
612
+ // We update it once the verify/ingest call has RETURNED — anything
613
+ // thrown inside `ingest()` or `verifyOnly()` is, by definition, a
614
+ // pipeline-runtime failure for which the wrapper itself is the failing
615
+ // boundary. `cli_pipeline` is the right label there; the inner code
616
+ // paths that throw have already emitted their own structured rejection
617
+ // events (`rejected_pre_persist`, `rebuild_indexes_complete` etc.)
618
+ // when they could.
520
619
  try {
521
620
  if (verifyOnlyFlag) {
522
621
  const result = await verifyOnly(submission, { repoRoot, provenance });
622
+ lastSuccessfulStage = 'verify_only';
523
623
 
524
624
  console.log(JSON.stringify({
525
625
  status: result.record.verification.status,
@@ -537,6 +637,7 @@ if (isMain) {
537
637
  }
538
638
 
539
639
  const result = await ingest(submission, { repoRoot, provenance });
640
+ lastSuccessfulStage = 'ingest';
540
641
 
541
642
  if (result.duplicate) {
542
643
  console.log(JSON.stringify({ status: 'duplicate', run_id: submission.run_id }));
@@ -554,7 +655,22 @@ if (isMain) {
554
655
 
555
656
  process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
556
657
  } catch (err) {
557
- console.error(`ERROR: ingest failed: ${err.message}`);
658
+ // D1B-001 (Stage C humanization): emit the structured error event
659
+ // BEFORE exit 2 so `"stage":"error"` greps land. `failed_stage` is
660
+ // the last stage that DID complete — anything inside `ingest()` /
661
+ // `verifyOnly()` that throws has, by definition, blown the boundary
662
+ // we were about to cross.
663
+ const submissionId =
664
+ submission && typeof submission === 'object' && !Array.isArray(submission)
665
+ ? (submission.run_id || null)
666
+ : null;
667
+ emitCliErrorEvent({
668
+ failedStage: lastSuccessfulStage,
669
+ correlationId: cliCorrelationId,
670
+ submissionId,
671
+ err,
672
+ humanPrefix: 'ingest failed'
673
+ });
558
674
  process.exit(2);
559
675
  }
560
676
  }
@@ -6,41 +6,30 @@
6
6
  * gate on the outbound payload — the central verifier assembles the record
7
7
  * and the persist layer must not write anything that violates the contract.
8
8
  *
9
- * Mirrors the Ajv idiom in @dogfood-lab/verify/validators/schema.js same
10
- * Ajv2020 + ajv-formats setup, lazy compile, cached compiled validator.
11
- * Kept in this package (not extracted to a shared util) because the two
12
- * call sites have different error shapes and lifecycles: submission validation
13
- * returns { valid, errors } so the verifier can build a rejection record;
14
- * record validation throws because a malformed record reaching the write
15
- * path is a programming error, not user input.
9
+ * H3 hop 4 (LAST the C1-sealing hop): delegates to the canonical
10
+ * {@link validatePayload} from `@dogfood-lab/schemas`. Pre-H3 this module
11
+ * compiled its own Ajv2020 + ajv-formats instance for the record schema,
12
+ * which was the SAME schema the verifier-side path (now also canonical)
13
+ * compiled in a SECOND instance the structural root of the C1 two-Ajv
14
+ * gap. After H3, ingest and verify share the single cached validator the
15
+ * canonical seam holds for `dogfood-record.schema.json`.
16
+ *
17
+ * Contract preserved:
18
+ * - Throws {@link RecordValidationError} on validation failure (NOT a
19
+ * return value — the persist layer treats a malformed record as a
20
+ * programming error, not user input).
21
+ * - `.code === 'RECORD_SCHEMA_INVALID'` is the structural error-codes
22
+ * gate pin (just hardened in A2.1 FX2 — see scripts/doc-drift-patterns.json
23
+ * error-codes check); the migration keeps it intact.
24
+ * - Each `errors[]` entry carries `{ path, keyword, message, params }` —
25
+ * the keyword pin (packages/ingest/ingest.test.js:208-220) asserts
26
+ * `typeof e.keyword === 'string'` for every error. The H3 keyword
27
+ * extension at the canonical seam (validate.ts ValidationError) is
28
+ * what makes this contract preservable without ingest holding its
29
+ * own Ajv instance.
16
30
  */
17
31
 
18
- import Ajv2020 from 'ajv/dist/2020.js';
19
- import addFormats from 'ajv-formats';
20
- import { readFileSync } from 'node:fs';
21
- import { createRequire } from 'node:module';
22
-
23
- const require = createRequire(import.meta.url);
24
- const SCHEMA_PATH = require.resolve('@dogfood-lab/schemas/json/dogfood-record.schema.json');
25
-
26
- let _validator = null;
27
- let _loadError = null;
28
-
29
- function getValidator() {
30
- if (_validator) return _validator;
31
- if (_loadError) throw _loadError;
32
-
33
- try {
34
- const ajv = new Ajv2020({ allErrors: true, strict: false });
35
- addFormats(ajv);
36
- const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8'));
37
- _validator = ajv.compile(schema);
38
- return _validator;
39
- } catch (e) {
40
- _loadError = new Error(`record schema load failed: ${e.message}`);
41
- throw _loadError;
42
- }
43
- }
32
+ import { validatePayload } from '@dogfood-lab/schemas';
44
33
 
45
34
  /**
46
35
  * Structured error thrown when a persisted record violates the schema.
@@ -69,15 +58,20 @@ export class RecordValidationError extends Error {
69
58
  * @throws {RecordValidationError}
70
59
  */
71
60
  export function validateRecord(record) {
72
- const validate = getValidator();
73
- const valid = validate(record);
74
- if (valid) return record;
61
+ const result = validatePayload('record', record);
62
+ if (result.valid) return record;
75
63
 
76
- const errors = (validate.errors || []).map(err => ({
77
- path: err.instancePath || '/',
64
+ // Project canonical ValidationError ingest's historical error shape:
65
+ // pre-H3 entries had `{ path, keyword, message, params }`. Canonical
66
+ // ships the same fields (keyword added in H3 hop 0). Re-construct
67
+ // explicitly so any future change to the canonical shape (extra
68
+ // fields, renames) trips an explicit migration here rather than
69
+ // silently widening RecordValidationError.errors[].
70
+ const errors = result.errors.map(err => ({
71
+ path: err.path,
78
72
  keyword: err.keyword,
79
73
  message: err.message,
80
- params: err.params
74
+ params: err.params,
81
75
  }));
82
76
  throw new RecordValidationError(errors);
83
77
  }