@dogfood-lab/ingest 1.2.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.
package/run.js ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Ingestion orchestrator
3
+ *
4
+ * Thin glue: dispatch → load context → verifier → persist → rebuild indexes.
5
+ *
6
+ * Does NOT:
7
+ * - decide verdicts on its own
8
+ * - enforce policy outside the verifier
9
+ * - inspect step results beyond passing them through
10
+ * - mutate source-authored fields except through the verifier result
11
+ *
12
+ * Does:
13
+ * - parse payload
14
+ * - gather needed inputs
15
+ * - call verifier
16
+ * - persist output
17
+ * - regenerate indexes
18
+ */
19
+
20
+ import { resolve, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { randomBytes } from 'node:crypto';
23
+
24
+ import { verify } from '@dogfood-lab/verify';
25
+ import { stubProvenance, githubProvenance } from '@dogfood-lab/verify/validators/provenance.js';
26
+ import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
27
+ import { loadGlobalPolicy, loadRepoPolicy, loadScenarios } from './load-context.js';
28
+ import { isDuplicate, writeRecord, computeRecordPath } from './persist.js';
29
+ import { rebuildIndexes } from './rebuild-indexes.js';
30
+
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+
33
+ /**
34
+ * Emit a single structured stage-transition log line via the shared helper.
35
+ *
36
+ * Pins `component: 'ingest'` so every ingest event is tagged regardless of
37
+ * caller-supplied fields. Delegates to the canonical helper at
38
+ * `@dogfood-lab/dogfood-swarm/lib/log-stage.js`, which adds the wave-17
39
+ * verdict-first human banner (TTY or DOGFOOD_LOG_HUMAN=1) on top of the
40
+ * NDJSON line that ingest.yml's CI log captures.
41
+ *
42
+ * Stages: dispatch_received | context_loaded | verify_complete |
43
+ * persist_complete | rebuild_indexes_complete | verify_only_complete |
44
+ * rejected_pre_persist | error.
45
+ *
46
+ * F-252714-061 (FT-PIPELINE-004): callers may include `correlation_id` in
47
+ * `fields` so a downstream log aggregator can pivot a multi-line NDJSON
48
+ * stream into a per-submission trace. The wrapper passes it through; the
49
+ * canonical generation site is `ingest()`/`verifyOnly()` (one ID per run).
50
+ *
51
+ * @param {string} stage
52
+ * @param {object} fields - Stage-specific fields. `submission_id` and
53
+ * `correlation_id` strongly recommended. Do NOT pass `stage` as an inner
54
+ * field — it would collide with the outer stage name and the spread is
55
+ * last-wins. For "this stage failed inside that stage" use `failed_stage`
56
+ * (e.g. `logStage('error', { failed_stage: 'rebuild_indexes', ... })`).
57
+ */
58
+ function logStage(stage, fields = {}) {
59
+ // Defensive against F-827321-035: strip any caller-supplied `stage:`
60
+ // before spreading, so the positional `stage` always wins. The shared
61
+ // helper itself spreads fields last; without this strip, an inner
62
+ // `stage:` would silently overwrite the outer name and a grep of
63
+ // `"stage":"error"` across runner logs would miss the failure.
64
+ // `correlation_id` (FT-PIPELINE-004) is destructured-and-passed: it has
65
+ // no collision with the outer stage name, but naming it explicitly here
66
+ // documents the wave-22 wrapper-strip pattern's safe-field contract.
67
+ const { stage: _ignored, correlation_id, ...rest } = fields;
68
+ sharedLogStage(stage, { component: 'ingest', correlation_id, ...rest });
69
+ }
70
+
71
+ /**
72
+ * Generate a synthetic correlation_id for ingests where the submission has
73
+ * no usable run_id (null/non-object/malformed). Format: `ing-<base36-ts>-<rand4>`.
74
+ *
75
+ * Examples: `ing-1abc234d-x7f9` — readable, sortable, distinct from real
76
+ * `run_id` values (which never start with the `ing-` prefix in practice).
77
+ */
78
+ function synthCorrelationId() {
79
+ const ts = Date.now().toString(36);
80
+ const rand = randomBytes(2).toString('hex');
81
+ return `ing-${ts}-${rand}`;
82
+ }
83
+
84
+ /**
85
+ * Resolve the correlation_id for a single ingest run.
86
+ * Prefer `submission.run_id` (operator pivots stay on the user-meaningful
87
+ * key); fall back to a synthetic id for invalid/malformed submissions.
88
+ */
89
+ function resolveCorrelationId(submission) {
90
+ if (submission && typeof submission === 'object' && !Array.isArray(submission)) {
91
+ if (typeof submission.run_id === 'string' && submission.run_id.length > 0) {
92
+ return submission.run_id;
93
+ }
94
+ }
95
+ return synthCorrelationId();
96
+ }
97
+
98
+ /**
99
+ * Run the full ingestion pipeline.
100
+ *
101
+ * @param {object} submission - Source-authored submission payload
102
+ * @param {object} options
103
+ * @param {string} options.repoRoot - Absolute path to dogfood-labs repo root
104
+ * @param {object} options.provenance - Provenance adapter (REQUIRED — no default, no implicit stub)
105
+ * @param {object} [options.scenarioFetcher] - Scenario fetch adapter
106
+ * @returns {Promise<{ record: object, path: string, written: boolean, duplicate: boolean }>}
107
+ */
108
+ export async function ingest(submission, options) {
109
+ const {
110
+ repoRoot,
111
+ provenance,
112
+ scenarioFetcher = null
113
+ } = options;
114
+
115
+ // Provenance adapter is REQUIRED. No implicit stub. Fail closed.
116
+ if (!provenance || typeof provenance.confirm !== 'function') {
117
+ throw new Error(
118
+ 'Provenance adapter is required. Use githubProvenance(token) for production ' +
119
+ 'or stubProvenance for tests. No implicit default — fail closed.'
120
+ );
121
+ }
122
+
123
+ const submissionIsObject = submission && typeof submission === 'object' && !Array.isArray(submission);
124
+ const submissionId = submissionIsObject ? (submission.run_id || null) : null;
125
+ const submissionRepo = submissionIsObject ? (submission.repo || null) : null;
126
+
127
+ // F-252714-061 (FT-PIPELINE-004): one correlation_id per ingest run, pinned
128
+ // across every stage. For valid submissions, prefer submission.run_id so
129
+ // operator pivots stay on the user-meaningful key; for invalid/malformed
130
+ // submissions (no run_id) generate a synthetic `ing-<base36-ts>-<rand4>`.
131
+ const correlation_id = resolveCorrelationId(submission);
132
+
133
+ logStage('dispatch_received', {
134
+ submission_id: submissionId,
135
+ correlation_id,
136
+ repo: submissionRepo,
137
+ has_scenario_results: !!(submissionIsObject && submission.scenario_results)
138
+ });
139
+
140
+ // 1. Check for duplicate before doing any work
141
+ // We need a minimal record shape to compute the path for duplicate check
142
+ // Guard against null/non-object submissions — those flow straight to verify()
143
+ // which produces a rejection record marked _skipPersist.
144
+ if (submissionIsObject && submission.run_id && submission.repo && submission.timing?.finished_at) {
145
+ const probeRecord = {
146
+ run_id: submission.run_id,
147
+ repo: submission.repo,
148
+ timing: submission.timing,
149
+ verification: { status: 'accepted' }
150
+ };
151
+ if (isDuplicate(submission.run_id, probeRecord, repoRoot)) {
152
+ logStage('rejected_pre_persist', {
153
+ submission_id: submissionId,
154
+ correlation_id,
155
+ reason: 'duplicate'
156
+ });
157
+ return {
158
+ record: null,
159
+ path: null,
160
+ written: false,
161
+ duplicate: true
162
+ };
163
+ }
164
+ }
165
+
166
+ // 2. Load context
167
+ const globalPolicy = loadGlobalPolicy(repoRoot);
168
+ const repoPolicy = loadRepoPolicy(submissionIsObject ? (submission.repo || '') : '', repoRoot);
169
+ const policyVersion = repoPolicy?.policy_version || globalPolicy.policy_version || '1.0.0';
170
+
171
+ logStage('context_loaded', {
172
+ submission_id: submissionId,
173
+ correlation_id,
174
+ policy_version: policyVersion,
175
+ repo_policy_present: !!repoPolicy
176
+ });
177
+
178
+ // 3. Load scenario definitions (non-fatal if missing — becomes rejection reason)
179
+ let scenarioErrors = [];
180
+ if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
181
+ const result = await loadScenarios(submission, scenarioFetcher);
182
+ scenarioErrors = result.errors;
183
+ }
184
+
185
+ // 4. Call verifier — the law engine makes all decisions
186
+ const record = await verify(submission, {
187
+ globalPolicy,
188
+ repoPolicy,
189
+ provenance,
190
+ policyVersion
191
+ });
192
+
193
+ logStage('verify_complete', {
194
+ submission_id: submissionId,
195
+ correlation_id,
196
+ status: record.verification?.status ?? null,
197
+ rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
198
+ verdict: record.overall_verdict?.verified ?? null
199
+ });
200
+
201
+ // 4b. Append scenario loading errors to rejection reasons if any
202
+ if (scenarioErrors.length > 0) {
203
+ record.verification.rejection_reasons.push(
204
+ ...scenarioErrors.map(e => `scenario-load: ${e}`)
205
+ );
206
+ // If scenario loading failed, this is a rejection
207
+ if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
208
+ record.verification.status = 'rejected';
209
+ record.verification.policy_valid = false;
210
+ // Downgrade verdict if needed
211
+ if (record.overall_verdict.verified === 'pass') {
212
+ record.overall_verdict.verified = 'fail';
213
+ record.overall_verdict.downgraded = true;
214
+ if (!record.overall_verdict.downgrade_reasons) {
215
+ record.overall_verdict.downgrade_reasons = [];
216
+ }
217
+ record.overall_verdict.downgrade_reasons.push('scenario definitions could not be loaded');
218
+ }
219
+ }
220
+ }
221
+
222
+ // 5. Persist record
223
+ // Verifier marks _skipPersist when input was null/non-object — the stub record
224
+ // lacks repo/run_id/timing.finished_at and would crash computeRecordPath().
225
+ // Surface the structured rejection cleanly without writing.
226
+ if (record._skipPersist) {
227
+ delete record._skipPersist;
228
+ logStage('rejected_pre_persist', {
229
+ submission_id: submissionId,
230
+ correlation_id,
231
+ reason: 'skip_persist',
232
+ rejection_reasons: record.verification?.rejection_reasons ?? []
233
+ });
234
+ return { record, path: null, written: false, duplicate: false };
235
+ }
236
+ const persistStart = Date.now();
237
+ const { path, written } = writeRecord(record, repoRoot);
238
+ logStage('persist_complete', {
239
+ submission_id: submissionId,
240
+ correlation_id,
241
+ path,
242
+ written,
243
+ duplicate: !written,
244
+ duration_ms: Date.now() - persistStart
245
+ });
246
+
247
+ // 6. Rebuild indexes
248
+ if (written) {
249
+ const rebuildStart = Date.now();
250
+ try {
251
+ const indexResult = rebuildIndexes(repoRoot);
252
+ logStage('rebuild_indexes_complete', {
253
+ submission_id: submissionId,
254
+ correlation_id,
255
+ duration_ms: Date.now() - rebuildStart,
256
+ accepted: indexResult.accepted,
257
+ rejected: indexResult.rejected,
258
+ corrupted_count: indexResult.corrupted?.length ?? 0
259
+ });
260
+ } catch (err) {
261
+ // failed_stage (not stage) — outer stage='error' must survive the
262
+ // spread inside the shared logStage helper. F-827321-035: an inner
263
+ // `stage:` field overwrites the outer name, hiding the error event
264
+ // from any `"stage":"error"` grep across the runner log.
265
+ logStage('error', {
266
+ submission_id: submissionId,
267
+ correlation_id,
268
+ failed_stage: 'rebuild_indexes',
269
+ message: err.message
270
+ });
271
+ console.error(`WARNING: record persisted but index rebuild failed: ${err.message} — indexes may be stale`);
272
+ }
273
+ }
274
+
275
+ return { record, path, written, duplicate: false };
276
+ }
277
+
278
+ /**
279
+ * Run the verify-only pipeline: steps 0-4 (load context + verify), assemble
280
+ * the would-be record, return it WITHOUT touching the filesystem or rebuilding
281
+ * indexes. Surfaces what `ingest()` WOULD have persisted plus `would_persist_to`
282
+ * — the path where the record would have landed.
283
+ *
284
+ * F-252714-058 (FT-PIPELINE-001): the verify pipeline already has a
285
+ * `_skipPersist` internal sentinel for null/non-object inputs; this function
286
+ * generalizes that path into a public entrypoint operators can use to dry-run
287
+ * any submission without side effects.
288
+ *
289
+ * Same logStage events fire as a real ingest EXCEPT `persist_complete` and
290
+ * `rebuild_indexes_complete` (which would lie about persistence). A
291
+ * `verify_only_complete` event takes their place so CI logs read coherently.
292
+ *
293
+ * @param {object} submission - Source-authored submission payload
294
+ * @param {object} options
295
+ * @param {string} options.repoRoot - Absolute path to repo root (still
296
+ * needed for policy + scenario lookup)
297
+ * @param {object} options.provenance - Provenance adapter (REQUIRED)
298
+ * @param {object} [options.scenarioFetcher] - Scenario fetch adapter
299
+ * @returns {Promise<{
300
+ * record: object,
301
+ * would_persist_to: string|null,
302
+ * verify_only: true
303
+ * }>}
304
+ */
305
+ export async function verifyOnly(submission, options) {
306
+ const {
307
+ repoRoot,
308
+ provenance,
309
+ scenarioFetcher = null
310
+ } = options;
311
+
312
+ // Provenance adapter is REQUIRED. Same fail-closed contract as ingest().
313
+ if (!provenance || typeof provenance.confirm !== 'function') {
314
+ throw new Error(
315
+ 'Provenance adapter is required. Use githubProvenance(token) for production ' +
316
+ 'or stubProvenance for tests. No implicit default — fail closed.'
317
+ );
318
+ }
319
+
320
+ const submissionIsObject = submission && typeof submission === 'object' && !Array.isArray(submission);
321
+ const submissionId = submissionIsObject ? (submission.run_id || null) : null;
322
+ const submissionRepo = submissionIsObject ? (submission.repo || null) : null;
323
+ const correlation_id = resolveCorrelationId(submission);
324
+
325
+ logStage('dispatch_received', {
326
+ submission_id: submissionId,
327
+ correlation_id,
328
+ repo: submissionRepo,
329
+ has_scenario_results: !!(submissionIsObject && submission.scenario_results),
330
+ verify_only: true
331
+ });
332
+
333
+ // 2. Load context (verify-only still needs policy to drive the verifier)
334
+ const globalPolicy = loadGlobalPolicy(repoRoot);
335
+ const repoPolicy = loadRepoPolicy(submissionIsObject ? (submission.repo || '') : '', repoRoot);
336
+ const policyVersion = repoPolicy?.policy_version || globalPolicy.policy_version || '1.0.0';
337
+
338
+ logStage('context_loaded', {
339
+ submission_id: submissionId,
340
+ correlation_id,
341
+ policy_version: policyVersion,
342
+ repo_policy_present: !!repoPolicy
343
+ });
344
+
345
+ // 3. Load scenario definitions (non-fatal — becomes rejection reason)
346
+ let scenarioErrors = [];
347
+ if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
348
+ const result = await loadScenarios(submission, scenarioFetcher);
349
+ scenarioErrors = result.errors;
350
+ }
351
+
352
+ // 4. Call verifier
353
+ const record = await verify(submission, {
354
+ globalPolicy,
355
+ repoPolicy,
356
+ provenance,
357
+ policyVersion
358
+ });
359
+
360
+ logStage('verify_complete', {
361
+ submission_id: submissionId,
362
+ correlation_id,
363
+ status: record.verification?.status ?? null,
364
+ rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
365
+ verdict: record.overall_verdict?.verified ?? null
366
+ });
367
+
368
+ // 4b. Mirror ingest's scenario-error verdict downgrade so verify-only and
369
+ // real ingest produce identical records for the same submission.
370
+ if (scenarioErrors.length > 0) {
371
+ record.verification.rejection_reasons.push(
372
+ ...scenarioErrors.map(e => `scenario-load: ${e}`)
373
+ );
374
+ if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
375
+ record.verification.status = 'rejected';
376
+ record.verification.policy_valid = false;
377
+ if (record.overall_verdict.verified === 'pass') {
378
+ record.overall_verdict.verified = 'fail';
379
+ record.overall_verdict.downgraded = true;
380
+ if (!record.overall_verdict.downgrade_reasons) {
381
+ record.overall_verdict.downgrade_reasons = [];
382
+ }
383
+ record.overall_verdict.downgrade_reasons.push('scenario definitions could not be loaded');
384
+ }
385
+ }
386
+ }
387
+
388
+ // 5. Compute would_persist_to without writing.
389
+ // `_skipPersist` records lack the fields needed by computeRecordPath()
390
+ // (repo, run_id, timing.finished_at). Surface null in that case — same
391
+ // semantic as the real-ingest `rejected_pre_persist` branch.
392
+ let would_persist_to = null;
393
+ if (record._skipPersist) {
394
+ delete record._skipPersist;
395
+ } else {
396
+ try {
397
+ would_persist_to = computeRecordPath(record, repoRoot);
398
+ } catch {
399
+ // Defensive: if a record passes verify() but still trips path
400
+ // computation (e.g., a future schema with looser constraints), keep
401
+ // verify-only side-effect-free. Real ingest would surface the throw
402
+ // via writeRecord; verify-only just returns null and lets the operator
403
+ // see the rejection in record.verification.rejection_reasons.
404
+ would_persist_to = null;
405
+ }
406
+ }
407
+
408
+ logStage('verify_only_complete', {
409
+ submission_id: submissionId,
410
+ correlation_id,
411
+ status: record.verification?.status ?? null,
412
+ would_persist_to
413
+ });
414
+
415
+ return { record, would_persist_to, verify_only: true };
416
+ }
417
+
418
+ // --- CLI entrypoint ---
419
+ // When run directly, reads submission from stdin or file argument
420
+
421
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'run.js');
422
+
423
+ if (isMain) {
424
+ const args = process.argv.slice(2);
425
+ const repoRoot = resolve(__dirname, '../..');
426
+
427
+ // Parse CLI flags
428
+ let submissionJson;
429
+ let provenanceMode = null;
430
+ let verifyOnlyFlag = false;
431
+ const positionalArgs = [];
432
+
433
+ for (let i = 0; i < args.length; i++) {
434
+ if (args[i] === '--provenance' && args[i + 1]) {
435
+ provenanceMode = args[++i];
436
+ } else if (args[i] === '--file' && args[i + 1]) {
437
+ const { readFileSync } = await import('node:fs');
438
+ submissionJson = readFileSync(resolve(args[++i]), 'utf-8');
439
+ } else if (args[i] === '--payload' && args[i + 1]) {
440
+ submissionJson = args[++i];
441
+ } else if (args[i] === '--verify-only') {
442
+ // F-252714-058: dry-run the pipeline without writing or rebuilding
443
+ // indexes. CI / operators preview what WOULD have been persisted.
444
+ verifyOnlyFlag = true;
445
+ } else {
446
+ positionalArgs.push(args[i]);
447
+ }
448
+ }
449
+
450
+ if (!submissionJson) {
451
+ // Read from stdin
452
+ const chunks = [];
453
+ for await (const chunk of process.stdin) {
454
+ chunks.push(chunk);
455
+ }
456
+ submissionJson = Buffer.concat(chunks).toString('utf-8');
457
+ }
458
+
459
+ let submission;
460
+ try {
461
+ submission = JSON.parse(submissionJson);
462
+ if (typeof submission === 'string') {
463
+ submission = JSON.parse(submission);
464
+ }
465
+ } catch (err) {
466
+ console.error(`ERROR: invalid JSON payload: ${err.message}`);
467
+ process.exit(2);
468
+ }
469
+
470
+ // Resolve provenance adapter — explicit, never implicit
471
+ let provenance;
472
+ if (provenanceMode === 'stub') {
473
+ // Structural anti-misuse: stub only allowed outside CI
474
+ if (process.env.CI || process.env.GITHUB_ACTIONS) {
475
+ console.error('ERROR: --provenance=stub is not allowed in CI/production. Use --provenance=github.');
476
+ process.exit(2);
477
+ }
478
+ console.error('WARNING: Using stub provenance (test/dev only). Records will NOT have real provenance verification.');
479
+ provenance = stubProvenance;
480
+ } else if (provenanceMode === 'github') {
481
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
482
+ if (!token) {
483
+ console.error('ERROR: --provenance=github requires GITHUB_TOKEN or GH_TOKEN environment variable.');
484
+ process.exit(2);
485
+ }
486
+ provenance = githubProvenance(token);
487
+ } else if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
488
+ // In CI without explicit flag: default to github provenance, fail if no token
489
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
490
+ if (!token) {
491
+ console.error('ERROR: Running in CI without --provenance flag and no GITHUB_TOKEN. Cannot verify provenance.');
492
+ process.exit(2);
493
+ }
494
+ provenance = githubProvenance(token);
495
+ } else {
496
+ console.error('ERROR: --provenance flag is required. Use --provenance=github (production) or --provenance=stub (test/dev only).');
497
+ process.exit(2);
498
+ }
499
+
500
+ try {
501
+ if (verifyOnlyFlag) {
502
+ const result = await verifyOnly(submission, { repoRoot, provenance });
503
+
504
+ console.log(JSON.stringify({
505
+ status: result.record.verification.status,
506
+ run_id: result.record.run_id ?? null,
507
+ verdict: result.record.overall_verdict?.verified ?? null,
508
+ would_persist_to: result.would_persist_to,
509
+ verify_only: true,
510
+ rejection_reasons: result.record.verification.rejection_reasons ?? []
511
+ }));
512
+
513
+ // Same accepted/rejected exit-code contract as a real ingest so CI
514
+ // wrappers can swap `--verify-only` in/out without changing their
515
+ // exit-code handling.
516
+ process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
517
+ }
518
+
519
+ const result = await ingest(submission, { repoRoot, provenance });
520
+
521
+ if (result.duplicate) {
522
+ console.log(JSON.stringify({ status: 'duplicate', run_id: submission.run_id }));
523
+ process.exit(0);
524
+ }
525
+
526
+ console.log(JSON.stringify({
527
+ status: result.record.verification.status,
528
+ run_id: result.record.run_id ?? null,
529
+ verdict: result.record.overall_verdict?.verified ?? null,
530
+ path: result.path,
531
+ written: result.written,
532
+ rejection_reasons: result.record.verification.rejection_reasons ?? []
533
+ }));
534
+
535
+ process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
536
+ } catch (err) {
537
+ console.error(`ERROR: ingest failed: ${err.message}`);
538
+ process.exit(2);
539
+ }
540
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Persisted-record schema validator.
3
+ *
4
+ * Enforces dogfood-record.schema.json at write time. The submission validator
5
+ * in @dogfood-lab/verify covers the inbound payload; this is the symmetric
6
+ * gate on the outbound payload — the central verifier assembles the record
7
+ * and the persist layer must not write anything that violates the contract.
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.
16
+ */
17
+
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
+ }
44
+
45
+ /**
46
+ * Structured error thrown when a persisted record violates the schema.
47
+ * Caller code can `instanceof RecordValidationError` to distinguish from
48
+ * IO/path errors.
49
+ */
50
+ export class RecordValidationError extends Error {
51
+ constructor(errors) {
52
+ const summary = errors
53
+ .map(e => `${e.path || '/'} ${e.message}`)
54
+ .join('; ');
55
+ super(`persisted record failed schema validation: ${summary}`);
56
+ this.name = 'RecordValidationError';
57
+ this.code = 'RECORD_SCHEMA_INVALID';
58
+ this.errors = errors;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Validate a persisted record against dogfood-record.schema.json.
64
+ * Throws RecordValidationError on failure; returns the record on success
65
+ * so callers can chain (`writeFile(validateRecord(r))`).
66
+ *
67
+ * @param {object} record
68
+ * @returns {object} the same record reference, unchanged
69
+ * @throws {RecordValidationError}
70
+ */
71
+ export function validateRecord(record) {
72
+ const validate = getValidator();
73
+ const valid = validate(record);
74
+ if (valid) return record;
75
+
76
+ const errors = (validate.errors || []).map(err => ({
77
+ path: err.instancePath || '/',
78
+ keyword: err.keyword,
79
+ message: err.message,
80
+ params: err.params
81
+ }));
82
+ throw new RecordValidationError(errors);
83
+ }