@dogfood-lab/ingest 1.2.2 → 1.2.3

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 CHANGED
@@ -1,540 +1,560 @@
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
- }
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
+ //
266
+ // `rebuildIndexes()` is called as one unit — there is no partial
267
+ // `indexResult` to surface from this catch (counts only exist on the
268
+ // success path above). The structured event surfaces what the operator
269
+ // actually needs: throw site (stack, truncated), where the record
270
+ // landed, and the recovery path. The console warning mirrors the same
271
+ // shape so log-only readers get the same actionable hint.
272
+ const truncatedStack = err.stack
273
+ ? err.stack.split('\n').slice(0, 20).join('\n')
274
+ : null;
275
+ const stackPreview = err.stack
276
+ ? err.stack.split('\n').slice(0, 5).join(' / ')
277
+ : 'n/a';
278
+ logStage('error', {
279
+ submission_id: submissionId,
280
+ correlation_id,
281
+ failed_stage: 'rebuild_indexes',
282
+ message: err.message,
283
+ stack: truncatedStack,
284
+ record_persisted_at: path,
285
+ recovery: 'next ingest will trigger a full rebuild of indexes/'
286
+ });
287
+ console.error(
288
+ `WARNING: record persisted at ${path}, but index rebuild failed: ${err.message}\n` +
289
+ ` indexes/ may be stale until next ingest. To force rebuild now, re-run any test ingest.\n` +
290
+ ` stack: ${stackPreview}`
291
+ );
292
+ }
293
+ }
294
+
295
+ return { record, path, written, duplicate: false };
296
+ }
297
+
298
+ /**
299
+ * Run the verify-only pipeline: steps 0-4 (load context + verify), assemble
300
+ * the would-be record, return it WITHOUT touching the filesystem or rebuilding
301
+ * indexes. Surfaces what `ingest()` WOULD have persisted plus `would_persist_to`
302
+ * — the path where the record would have landed.
303
+ *
304
+ * F-252714-058 (FT-PIPELINE-001): the verify pipeline already has a
305
+ * `_skipPersist` internal sentinel for null/non-object inputs; this function
306
+ * generalizes that path into a public entrypoint operators can use to dry-run
307
+ * any submission without side effects.
308
+ *
309
+ * Same logStage events fire as a real ingest EXCEPT `persist_complete` and
310
+ * `rebuild_indexes_complete` (which would lie about persistence). A
311
+ * `verify_only_complete` event takes their place so CI logs read coherently.
312
+ *
313
+ * @param {object} submission - Source-authored submission payload
314
+ * @param {object} options
315
+ * @param {string} options.repoRoot - Absolute path to repo root (still
316
+ * needed for policy + scenario lookup)
317
+ * @param {object} options.provenance - Provenance adapter (REQUIRED)
318
+ * @param {object} [options.scenarioFetcher] - Scenario fetch adapter
319
+ * @returns {Promise<{
320
+ * record: object,
321
+ * would_persist_to: string|null,
322
+ * verify_only: true
323
+ * }>}
324
+ */
325
+ export async function verifyOnly(submission, options) {
326
+ const {
327
+ repoRoot,
328
+ provenance,
329
+ scenarioFetcher = null
330
+ } = options;
331
+
332
+ // Provenance adapter is REQUIRED. Same fail-closed contract as ingest().
333
+ if (!provenance || typeof provenance.confirm !== 'function') {
334
+ throw new Error(
335
+ 'Provenance adapter is required. Use githubProvenance(token) for production ' +
336
+ 'or stubProvenance for tests. No implicit default — fail closed.'
337
+ );
338
+ }
339
+
340
+ const submissionIsObject = submission && typeof submission === 'object' && !Array.isArray(submission);
341
+ const submissionId = submissionIsObject ? (submission.run_id || null) : null;
342
+ const submissionRepo = submissionIsObject ? (submission.repo || null) : null;
343
+ const correlation_id = resolveCorrelationId(submission);
344
+
345
+ logStage('dispatch_received', {
346
+ submission_id: submissionId,
347
+ correlation_id,
348
+ repo: submissionRepo,
349
+ has_scenario_results: !!(submissionIsObject && submission.scenario_results),
350
+ verify_only: true
351
+ });
352
+
353
+ // 2. Load context (verify-only still needs policy to drive the verifier)
354
+ const globalPolicy = loadGlobalPolicy(repoRoot);
355
+ const repoPolicy = loadRepoPolicy(submissionIsObject ? (submission.repo || '') : '', repoRoot);
356
+ const policyVersion = repoPolicy?.policy_version || globalPolicy.policy_version || '1.0.0';
357
+
358
+ logStage('context_loaded', {
359
+ submission_id: submissionId,
360
+ correlation_id,
361
+ policy_version: policyVersion,
362
+ repo_policy_present: !!repoPolicy
363
+ });
364
+
365
+ // 3. Load scenario definitions (non-fatal — becomes rejection reason)
366
+ let scenarioErrors = [];
367
+ if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
368
+ const result = await loadScenarios(submission, scenarioFetcher);
369
+ scenarioErrors = result.errors;
370
+ }
371
+
372
+ // 4. Call verifier
373
+ const record = await verify(submission, {
374
+ globalPolicy,
375
+ repoPolicy,
376
+ provenance,
377
+ policyVersion
378
+ });
379
+
380
+ logStage('verify_complete', {
381
+ submission_id: submissionId,
382
+ correlation_id,
383
+ status: record.verification?.status ?? null,
384
+ rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
385
+ verdict: record.overall_verdict?.verified ?? null
386
+ });
387
+
388
+ // 4b. Mirror ingest's scenario-error verdict downgrade so verify-only and
389
+ // real ingest produce identical records for the same submission.
390
+ if (scenarioErrors.length > 0) {
391
+ record.verification.rejection_reasons.push(
392
+ ...scenarioErrors.map(e => `scenario-load: ${e}`)
393
+ );
394
+ if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
395
+ record.verification.status = 'rejected';
396
+ record.verification.policy_valid = false;
397
+ if (record.overall_verdict.verified === 'pass') {
398
+ record.overall_verdict.verified = 'fail';
399
+ record.overall_verdict.downgraded = true;
400
+ if (!record.overall_verdict.downgrade_reasons) {
401
+ record.overall_verdict.downgrade_reasons = [];
402
+ }
403
+ record.overall_verdict.downgrade_reasons.push('scenario definitions could not be loaded');
404
+ }
405
+ }
406
+ }
407
+
408
+ // 5. Compute would_persist_to without writing.
409
+ // `_skipPersist` records lack the fields needed by computeRecordPath()
410
+ // (repo, run_id, timing.finished_at). Surface null in that case — same
411
+ // semantic as the real-ingest `rejected_pre_persist` branch.
412
+ let would_persist_to = null;
413
+ if (record._skipPersist) {
414
+ delete record._skipPersist;
415
+ } else {
416
+ try {
417
+ would_persist_to = computeRecordPath(record, repoRoot);
418
+ } catch {
419
+ // Defensive: if a record passes verify() but still trips path
420
+ // computation (e.g., a future schema with looser constraints), keep
421
+ // verify-only side-effect-free. Real ingest would surface the throw
422
+ // via writeRecord; verify-only just returns null and lets the operator
423
+ // see the rejection in record.verification.rejection_reasons.
424
+ would_persist_to = null;
425
+ }
426
+ }
427
+
428
+ logStage('verify_only_complete', {
429
+ submission_id: submissionId,
430
+ correlation_id,
431
+ status: record.verification?.status ?? null,
432
+ would_persist_to
433
+ });
434
+
435
+ return { record, would_persist_to, verify_only: true };
436
+ }
437
+
438
+ // --- CLI entrypoint ---
439
+ // When run directly, reads submission from stdin or file argument
440
+
441
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'run.js');
442
+
443
+ if (isMain) {
444
+ const args = process.argv.slice(2);
445
+ const repoRoot = resolve(__dirname, '../..');
446
+
447
+ // Parse CLI flags
448
+ let submissionJson;
449
+ let provenanceMode = null;
450
+ let verifyOnlyFlag = false;
451
+ const positionalArgs = [];
452
+
453
+ for (let i = 0; i < args.length; i++) {
454
+ if (args[i] === '--provenance' && args[i + 1]) {
455
+ provenanceMode = args[++i];
456
+ } else if (args[i] === '--file' && args[i + 1]) {
457
+ const { readFileSync } = await import('node:fs');
458
+ submissionJson = readFileSync(resolve(args[++i]), 'utf-8');
459
+ } else if (args[i] === '--payload' && args[i + 1]) {
460
+ submissionJson = args[++i];
461
+ } else if (args[i] === '--verify-only') {
462
+ // F-252714-058: dry-run the pipeline without writing or rebuilding
463
+ // indexes. CI / operators preview what WOULD have been persisted.
464
+ verifyOnlyFlag = true;
465
+ } else {
466
+ positionalArgs.push(args[i]);
467
+ }
468
+ }
469
+
470
+ if (!submissionJson) {
471
+ // Read from stdin
472
+ const chunks = [];
473
+ for await (const chunk of process.stdin) {
474
+ chunks.push(chunk);
475
+ }
476
+ submissionJson = Buffer.concat(chunks).toString('utf-8');
477
+ }
478
+
479
+ let submission;
480
+ try {
481
+ submission = JSON.parse(submissionJson);
482
+ if (typeof submission === 'string') {
483
+ submission = JSON.parse(submission);
484
+ }
485
+ } catch (err) {
486
+ console.error(`ERROR: invalid JSON payload: ${err.message}`);
487
+ process.exit(2);
488
+ }
489
+
490
+ // Resolve provenance adapter — explicit, never implicit
491
+ let provenance;
492
+ if (provenanceMode === 'stub') {
493
+ // Structural anti-misuse: stub only allowed outside CI
494
+ if (process.env.CI || process.env.GITHUB_ACTIONS) {
495
+ console.error('ERROR: --provenance=stub is not allowed in CI/production. Use --provenance=github.');
496
+ process.exit(2);
497
+ }
498
+ console.error('WARNING: Using stub provenance (test/dev only). Records will NOT have real provenance verification.');
499
+ provenance = stubProvenance;
500
+ } else if (provenanceMode === 'github') {
501
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
502
+ if (!token) {
503
+ console.error('ERROR: --provenance=github requires GITHUB_TOKEN or GH_TOKEN environment variable.');
504
+ process.exit(2);
505
+ }
506
+ provenance = githubProvenance(token);
507
+ } else if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
508
+ // In CI without explicit flag: default to github provenance, fail if no token
509
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
510
+ if (!token) {
511
+ console.error('ERROR: Running in CI without --provenance flag and no GITHUB_TOKEN. Cannot verify provenance.');
512
+ process.exit(2);
513
+ }
514
+ provenance = githubProvenance(token);
515
+ } else {
516
+ console.error('ERROR: --provenance flag is required. Use --provenance=github (production) or --provenance=stub (test/dev only).');
517
+ process.exit(2);
518
+ }
519
+
520
+ try {
521
+ if (verifyOnlyFlag) {
522
+ const result = await verifyOnly(submission, { repoRoot, provenance });
523
+
524
+ console.log(JSON.stringify({
525
+ status: result.record.verification.status,
526
+ run_id: result.record.run_id ?? null,
527
+ verdict: result.record.overall_verdict?.verified ?? null,
528
+ would_persist_to: result.would_persist_to,
529
+ verify_only: true,
530
+ rejection_reasons: result.record.verification.rejection_reasons ?? []
531
+ }));
532
+
533
+ // Same accepted/rejected exit-code contract as a real ingest so CI
534
+ // wrappers can swap `--verify-only` in/out without changing their
535
+ // exit-code handling.
536
+ process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
537
+ }
538
+
539
+ const result = await ingest(submission, { repoRoot, provenance });
540
+
541
+ if (result.duplicate) {
542
+ console.log(JSON.stringify({ status: 'duplicate', run_id: submission.run_id }));
543
+ process.exit(0);
544
+ }
545
+
546
+ console.log(JSON.stringify({
547
+ status: result.record.verification.status,
548
+ run_id: result.record.run_id ?? null,
549
+ verdict: result.record.overall_verdict?.verified ?? null,
550
+ path: result.path,
551
+ written: result.written,
552
+ rejection_reasons: result.record.verification.rejection_reasons ?? []
553
+ }));
554
+
555
+ process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
556
+ } catch (err) {
557
+ console.error(`ERROR: ingest failed: ${err.message}`);
558
+ process.exit(2);
559
+ }
560
+ }