@dogfood-lab/ingest 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/run.js CHANGED
@@ -24,8 +24,9 @@ import { randomBytes } from 'node:crypto';
24
24
  import { verify } from '@dogfood-lab/verify';
25
25
  import { stubProvenance, provenanceForProvider } from '@dogfood-lab/verify/validators/provenance.js';
26
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';
27
+ import { loadGlobalPolicy, loadRepoPolicy, loadScenarios, githubScenarioFetcher } from './load-context.js';
28
+ import { isDuplicate, writeRecord, computeRecordPath, UnsafeRecordPathError } from './persist.js';
29
+ import { RecordValidationError } from './validate-record.js';
29
30
  import { rebuildIndexes } from './rebuild-indexes.js';
30
31
  import { verifyChain, formatChainResult } from './verify-chain.js';
31
32
  import { handleAnchorCompute, handleAnchorPost, handleAnchorVerify } from './anchor/cli.js';
@@ -44,6 +45,27 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
44
45
  * @param {object} submission
45
46
  * @returns {{ provenance: object } | { err: Error }}
46
47
  */
48
+ /**
49
+ * F-a2e40a09: the ONE definition of "running in CI" for the provenance
50
+ * branches. Truthy check on either conventional variable — CI systems export
51
+ * CI=1 / CI=yes / CI=true interchangeably. Previously the stub-forbidden
52
+ * guard used this truthy check while the real-by-default branch demanded the
53
+ * exact string 'true', so a CI=1 environment was "CI" for one branch and
54
+ * "not CI" for the other (fail-closed, but with a misleading flag-required
55
+ * error instead of the token hint).
56
+ *
57
+ * F-5ca6c91a: the literal strings 'false' and '0' are explicit OPT-OUTS
58
+ * (a convention many tools respect — `CI=false npm test` is a real idiom),
59
+ * not truthy CI signals. Without this carve-out, a CI=false environment was
60
+ * classified as CI: stub provenance forbidden and the no-flag default
61
+ * demanding a real token — fail-closed, but against the operator's stated
62
+ * intent.
63
+ */
64
+ function isCI() {
65
+ const truthy = (v) => Boolean(v && v !== 'false' && v !== '0');
66
+ return truthy(process.env.CI) || truthy(process.env.GITHUB_ACTIONS);
67
+ }
68
+
47
69
  function resolveProviderProvenance(submission) {
48
70
  const provider = (submission && submission.source && submission.source.provider) || 'github';
49
71
  const factory = provenanceForProvider(provider);
@@ -60,6 +82,71 @@ function resolveProviderProvenance(submission) {
60
82
  return { provenance: factory(token) };
61
83
  }
62
84
 
85
+ /**
86
+ * V2-INVARIAN-004: the ONE definition of "which submissions get a scenario
87
+ * fetcher" (and with it required-steps enforcement). Previously this
88
+ * condition lived inline in the CLI entrypoint where it was untestable
89
+ * without spawning a process.
90
+ *
91
+ * Returns a `githubScenarioFetcher` when ALL hold:
92
+ * - provenance is REAL (not stubProvenance — preview/test runs never hit
93
+ * the GitHub API);
94
+ * - the submission is a plain object with provider `github` (default when
95
+ * `source.provider` is absent) — GitLab has no contents-API adapter yet,
96
+ * so its required_steps gate remains unenforced (documented gap);
97
+ * - `submission.repo` and `submission.ref.commit_sha` are strings (the
98
+ * fetcher re-validates their shapes fail-closed);
99
+ * - a GitHub token is present in `env` (GITHUB_TOKEN, then GH_TOKEN — the
100
+ * same token resolveProviderProvenance validated).
101
+ *
102
+ * Otherwise returns null: verification proceeds without scenario
103
+ * definitions, exactly as before F-3bfc2885 wired the production path.
104
+ *
105
+ * @param {object} provenance - resolved provenance adapter
106
+ * @param {object} submission
107
+ * @param {NodeJS.ProcessEnv} [env]
108
+ * @returns {ReturnType<typeof githubScenarioFetcher> | null}
109
+ */
110
+ export function resolveScenarioFetcher(provenance, submission, env = process.env) {
111
+ return resolveScenarioFetcherDecision(provenance, submission, env).fetcher;
112
+ }
113
+
114
+ /**
115
+ * F-b04473d5: the reason-carrying form of {@link resolveScenarioFetcher}.
116
+ * Every ineligible branch previously returned a silent null — an operator
117
+ * auditing an accepted record could not tell "required_steps enforced and
118
+ * satisfied" from "enforcement skipped (GitLab gap / missing token)" in the
119
+ * run log. The CLI logs the decision as a `scenario_enforcement` NDJSON
120
+ * event; this function is the unit-testable seam behind it.
121
+ *
122
+ * @param {object} provenance
123
+ * @param {object} submission
124
+ * @param {NodeJS.ProcessEnv} [env]
125
+ * @returns {{ fetcher: ReturnType<typeof githubScenarioFetcher> | null,
126
+ * active: boolean, reason: string | null }} `reason` names the skip class
127
+ * when `active` is false; null when a fetcher was constructed.
128
+ */
129
+ export function resolveScenarioFetcherDecision(provenance, submission, env = process.env) {
130
+ const skip = (reason) => ({ fetcher: null, active: false, reason });
131
+ if (provenance === stubProvenance) return skip('stub-provenance');
132
+ if (!submission || typeof submission !== 'object' || Array.isArray(submission)) {
133
+ return skip('submission-malformed');
134
+ }
135
+ if (((submission.source && submission.source.provider) || 'github') !== 'github') {
136
+ // GitLab has no contents-API adapter yet — the documented gap.
137
+ return skip('provider-unsupported');
138
+ }
139
+ if (typeof submission.repo !== 'string') return skip('repo-not-string');
140
+ if (typeof submission.ref?.commit_sha !== 'string') return skip('commit-sha-not-string');
141
+ const token = env.GITHUB_TOKEN || env.GH_TOKEN;
142
+ if (!token) return skip('no-token');
143
+ return {
144
+ fetcher: githubScenarioFetcher(token, submission.repo, submission.ref.commit_sha),
145
+ active: true,
146
+ reason: null
147
+ };
148
+ }
149
+
63
150
  /**
64
151
  * SEED-1 (d3-ingest-003) — posixify a path-shaped value at the operator/log
65
152
  * SERIALIZATION boundary. `computeRecordPath`/`writeRecord` return OS-native
@@ -81,6 +168,50 @@ function posixifyPath(p) {
81
168
  return typeof p === 'string' ? p.split(sep).join('/') : p;
82
169
  }
83
170
 
171
+ /**
172
+ * F-2750c4e8: ceiling on individually-persisted `scenario-load:` rejection
173
+ * reasons. A hostile pre-schema-gate payload can produce up to
174
+ * MAX_DISTINCT_SCENARIO_FETCHES scenario-load errors; persisting one reason
175
+ * line per error bloats the evidence record without adding operator signal
176
+ * (a 1000-error submission has one root cause). Everything past the ceiling
177
+ * collapses into a single count summary.
178
+ */
179
+ const SCENARIO_LOAD_REASON_CEILING = 20;
180
+
181
+ /**
182
+ * Format loadScenarios errors as `scenario-load:` rejection reasons,
183
+ * collapsing past {@link SCENARIO_LOAD_REASON_CEILING}.
184
+ *
185
+ * @param {string[]} scenarioErrors
186
+ * @returns {string[]}
187
+ */
188
+ function scenarioLoadReasons(scenarioErrors) {
189
+ const reasons = scenarioErrors.map(e => `scenario-load: ${e}`);
190
+ if (reasons.length <= SCENARIO_LOAD_REASON_CEILING) return reasons;
191
+ return [
192
+ ...reasons.slice(0, SCENARIO_LOAD_REASON_CEILING),
193
+ `scenario-load: (+${reasons.length - SCENARIO_LOAD_REASON_CEILING} more scenario-load errors collapsed; ${reasons.length} total)`
194
+ ];
195
+ }
196
+
197
+ /**
198
+ * DESIGN RULING (wave 4): loadScenarios' `warnings` (true-404 scenario
199
+ * definitions — nothing declared, nothing to enforce) land on the v1.6.0
200
+ * accepted-with-warning channel (`verification.warnings`), NEVER on
201
+ * rejection_reasons. verify() only sets the field when policy warnings
202
+ * exist, so create-or-append here.
203
+ *
204
+ * @param {object} record - the record returned by verify()
205
+ * @param {string[]} scenarioWarnings
206
+ */
207
+ function appendScenarioWarnings(record, scenarioWarnings) {
208
+ if (!scenarioWarnings || scenarioWarnings.length === 0) return;
209
+ record.verification.warnings = [
210
+ ...(record.verification.warnings || []),
211
+ ...scenarioWarnings
212
+ ];
213
+ }
214
+
84
215
  /**
85
216
  * Emit a single structured stage-transition log line via the shared helper.
86
217
  *
@@ -146,6 +277,27 @@ function resolveCorrelationId(submission) {
146
277
  return synthCorrelationId();
147
278
  }
148
279
 
280
+ /**
281
+ * F-41872706: count how many loaded scenario definitions actually carry an
282
+ * enforceable required_steps gate. `scenarios.size` alone answers "how many
283
+ * definitions were fetched"; this answers "how many of those could fire the
284
+ * step-results-present / step-verdict-consistent reject rules" — a definition
285
+ * with no (or empty) success_criteria.required_steps enforces nothing (see
286
+ * verify/index.js: requiredSteps defaults to [] and the loop no-ops).
287
+ *
288
+ * @param {Map|null} scenarios - the Map returned by loadScenarios, or null
289
+ * @returns {number}
290
+ */
291
+ function countRequiredStepsGates(scenarios) {
292
+ if (!scenarios || typeof scenarios.values !== 'function') return 0;
293
+ let gates = 0;
294
+ for (const definition of scenarios.values()) {
295
+ const steps = definition?.success_criteria?.required_steps;
296
+ if (Array.isArray(steps) && steps.length > 0) gates++;
297
+ }
298
+ return gates;
299
+ }
300
+
149
301
  /**
150
302
  * Run the full ingestion pipeline.
151
303
  *
@@ -199,7 +351,40 @@ export async function ingest(submission, options) {
199
351
  timing: submission.timing,
200
352
  verification: { status: 'accepted' }
201
353
  };
202
- if (isDuplicate(submission.run_id, probeRecord, repoRoot)) {
354
+ // F-f8952a50 (wave 10): this probe's hardcoded `status: 'accepted'` is
355
+ // ALSO the site the finding proved a corrected repo:mismatch resubmission
356
+ // was swallowed at — isDuplicate() only asks isRetryableRejection() when
357
+ // the record it's handed claims 'accepted' (see isDuplicate's own
358
+ // comment), so this probe is the FIRST place a stale non-schema rejection
359
+ // could mask a genuine correction, before verify() ever runs. No separate
360
+ // fix belongs HERE, though: this probe and writeRecord()'s own internal
361
+ // isDuplicate() call (persist.js) share the exact same isDuplicate() /
362
+ // isRetryableRejection() code path, so persist.js's per-prefix
363
+ // `retryable` fix (parse-rejection.js) already reaches both call sites —
364
+ // widening retryability there is what unblocks this probe too, with
365
+ // nothing probe-specific to change.
366
+ // F-a37d36f5: isDuplicate -> computeRecordPath runs against RAW untrusted
367
+ // submission.repo/run_id, three steps BEFORE verify()'s schema gate. A
368
+ // malformed repo ('a/b/c', a path-traversal attempt, etc.) makes
369
+ // computeRecordPath THROW ('invalid repo format' / 'unsafe repo segment'
370
+ // / 'unsafe run_id') — good, the traversal guards hold — but letting that
371
+ // throw escape here inverts this repo's submission-bad vs operational
372
+ // doctrine: it propagates as an uncaught fault (exit 2, "operator error"
373
+ // per the CLI's own USAGE block) for input that is squarely the
374
+ // SUBMITTER's to fix, and no `_rejected` evidence record is ever
375
+ // written. A malformed repo/run_id can never collide with an existing
376
+ // record anyway, so treating an unpathable submission as "not a
377
+ // duplicate" is semantically free — verify()'s schema gate is the
378
+ // authoritative judge of bad input, and writeRecord's own
379
+ // computeRecordPath (which runs AFTER validateRecord, on the SCHEMA-
380
+ // VALIDATED persisted record) remains the real enforcement point.
381
+ let duplicate;
382
+ try {
383
+ duplicate = isDuplicate(submission.run_id, probeRecord, repoRoot);
384
+ } catch {
385
+ duplicate = false;
386
+ }
387
+ if (duplicate) {
203
388
  logStage('rejected_pre_persist', {
204
389
  submission_id: submissionId,
205
390
  correlation_id,
@@ -226,11 +411,26 @@ export async function ingest(submission, options) {
226
411
  repo_policy_present: !!repoPolicy
227
412
  });
228
413
 
229
- // 3. Load scenario definitions (non-fatal if missing — becomes rejection reason)
414
+ // 3. Load scenario definitions.
415
+ // F-3bfc2885: keep the loaded `scenarios` Map (previously discarded) and hand
416
+ // it to verify() so success_criteria.required_steps is actually enforced.
417
+ // F-efe4f893: gate on Array.isArray — this runs BEFORE the schema gate on
418
+ // untrusted input, and a string/object scenario_results must never reach the
419
+ // fetch loop (a string iterates CHARACTERS). verify() lands the
420
+ // authoritative schema rejection downstream.
421
+ // DESIGN RULING (wave 4): a true-404 definition is a WARNING (nothing
422
+ // declared, nothing to enforce), a malformed committed file is a
423
+ // `scenario-load:` rejection, and V2-CROSS-BO-001 outages (incl. exhausted
424
+ // timeout, F-07ab7f86) throw `scenario-fetch-fault:` PAST this step — the
425
+ // CLI's outer catch emits a structured operational error and exits 2.
230
426
  let scenarioErrors = [];
231
- if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
427
+ let scenarioWarnings = [];
428
+ let scenarios = null;
429
+ if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
232
430
  const result = await loadScenarios(submission, scenarioFetcher);
233
431
  scenarioErrors = result.errors;
432
+ scenarioWarnings = result.warnings || [];
433
+ scenarios = result.scenarios;
234
434
  }
235
435
 
236
436
  // 4. Call verifier — the law engine makes all decisions
@@ -238,7 +438,8 @@ export async function ingest(submission, options) {
238
438
  globalPolicy,
239
439
  repoPolicy,
240
440
  provenance,
241
- policyVersion
441
+ policyVersion,
442
+ scenarios
242
443
  });
243
444
 
244
445
  logStage('verify_complete', {
@@ -246,14 +447,25 @@ export async function ingest(submission, options) {
246
447
  correlation_id,
247
448
  status: record.verification?.status ?? null,
248
449
  rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
249
- verdict: record.overall_verdict?.verified ?? null
450
+ verdict: record.overall_verdict?.verified ?? null,
451
+ // F-41872706: the scenario_enforcement event (CLI wrapper, pre-load) logs
452
+ // scenario_count = how many scenarios the submission DECLARED. These three
453
+ // are the post-load breakdown so an operator can tell "N gates enforced"
454
+ // from "N scenarios, all true-404, enforcement skipped". scenarios_loaded
455
+ // is the Map size (definitions actually fetched); required_steps_gates_fired
456
+ // is the subset that carried a non-empty required_steps.
457
+ scenarios_loaded: scenarios ? scenarios.size : 0,
458
+ required_steps_gates_fired: countRequiredStepsGates(scenarios),
459
+ scenarios_warned: scenarioWarnings.length,
460
+ scenarios_errored: scenarioErrors.length
250
461
  });
251
462
 
252
- // 4b. Append scenario loading errors to rejection reasons if any
463
+ // 4b. Surface scenario loading outcomes: warnings (true-404 definitions) go
464
+ // to verification.warnings; errors (malformed committed files) become
465
+ // scenario-load rejections, collapsed past the reason ceiling (F-2750c4e8).
466
+ appendScenarioWarnings(record, scenarioWarnings);
253
467
  if (scenarioErrors.length > 0) {
254
- record.verification.rejection_reasons.push(
255
- ...scenarioErrors.map(e => `scenario-load: ${e}`)
256
- );
468
+ record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
257
469
  // If scenario loading failed, this is a rejection
258
470
  if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
259
471
  record.verification.status = 'rejected';
@@ -285,7 +497,72 @@ export async function ingest(submission, options) {
285
497
  return { record, path: null, written: false, duplicate: false };
286
498
  }
287
499
  const persistStart = Date.now();
288
- const { path, written } = writeRecord(record, repoRoot);
500
+ let path, written;
501
+ try {
502
+ ({ path, written } = writeRecord(record, repoRoot));
503
+ } catch (err) {
504
+ // F-4acd28d8: computeRecordPath()'s traversal guard (isUnsafeSegment) is
505
+ // STRICTER than the submission schema's repo pattern (F-bbbe2e1f — e.g.
506
+ // `../etc` is schema-valid but traversal-unsafe), so a record can reach
507
+ // here without `_skipPersist` ever having been set. The record's own
508
+ // identifier is what's unfilable — submission-bad, not an operator
509
+ // incident — so route it like `_skipPersist` above instead of letting the
510
+ // throw reach the outer CLI catch, which would misreport it as
511
+ // failed_stage:'cli_parse_payload' and exit 2 ("operator error") for
512
+ // content that is squarely the submitter's to fix.
513
+ //
514
+ // RecordValidationError is the SIBLING gap F-4036ae25's _skipPersist
515
+ // narrowing opens: dogfood-record.schema.json mirrors the submission
516
+ // schema's constraints on every source-authored field verify() copies
517
+ // verbatim (ref, source, timing, scenario_results, overall_verdict.proposed),
518
+ // so a submission that is schema-invalid on one of THOSE fields (not just
519
+ // repo/run_id/timing.finished_at) reaches writeRecord() filable-by-identity
520
+ // and still fails validateRecord() here. Only catch it when the submission
521
+ // was ALREADY schema-invalid (record.verification.schema_valid === false)
522
+ // — that is the authoritative signal this is submission-bad fallout, not a
523
+ // genuine internal defect in what verify() assembled for an
524
+ // otherwise-valid submission, which must keep crashing loudly.
525
+ const isUnfilableRecordPath = err instanceof UnsafeRecordPathError;
526
+ const isSubmissionBadRecordShape =
527
+ err instanceof RecordValidationError && record.verification.schema_valid === false;
528
+ if (!isUnfilableRecordPath && !isSubmissionBadRecordShape) {
529
+ throw err;
530
+ }
531
+
532
+ // verify() already rejected this submission for a real reason whenever
533
+ // one exists (e.g. repo:mismatch, or the schema violation itself) — those
534
+ // reasons survive untouched. When verify() had no reason to reject (an
535
+ // otherwise-accepted record whose ONLY problem is storage-unsafety),
536
+ // downgrade the verdict here — the same shape used below for a late
537
+ // scenario-load rejection.
538
+ if (isUnfilableRecordPath) {
539
+ record.verification.rejection_reasons.push(`unsafe-record-path: ${err.message}`);
540
+ if (record.verification.status === 'accepted') {
541
+ record.verification.status = 'rejected';
542
+ record.verification.policy_valid = false;
543
+ if (record.overall_verdict.verified === 'pass') {
544
+ record.overall_verdict.verified = 'fail';
545
+ record.overall_verdict.downgraded = true;
546
+ if (!record.overall_verdict.downgrade_reasons) {
547
+ record.overall_verdict.downgrade_reasons = [];
548
+ }
549
+ record.overall_verdict.downgrade_reasons.push('record path could not be safely computed');
550
+ }
551
+ }
552
+ }
553
+
554
+ logStage('rejected_pre_persist', {
555
+ submission_id: submissionId,
556
+ correlation_id,
557
+ reason: isUnfilableRecordPath ? 'unsafe_record_path' : 'record_schema_invalid_from_submission',
558
+ rejection_reasons: record.verification.rejection_reasons ?? [],
559
+ // Operator-debug only: validateRecord()'s OWN structured errors, distinct
560
+ // from (and not merged into) the submitter-facing rejection_reasons above,
561
+ // which stay exactly as verify() computed them against the submission schema.
562
+ ...(isSubmissionBadRecordShape ? { record_validation_errors: err.errors } : {})
563
+ });
564
+ return { record, path: null, written: false, duplicate: false };
565
+ }
289
566
  logStage('persist_complete', {
290
567
  submission_id: submissionId,
291
568
  correlation_id,
@@ -347,7 +624,20 @@ export async function ingest(submission, options) {
347
624
  }
348
625
  }
349
626
 
350
- return { record, path, written, duplicate: false };
627
+ // F-7b97fbd4 (wave 10): mirror persist_complete's OWN `duplicate: !written`
628
+ // (logged a few lines above, off the same `written`) instead of a bare
629
+ // `false` that silently disagreed with it. By this point `written` can be
630
+ // `false` for exactly one reason — writeRecord()'s internal isDuplicate()
631
+ // blocked the write as a collision (persist.js) — every OTHER
632
+ // non-persisting outcome in this function (record._skipPersist above; the
633
+ // UnsafeRecordPathError / RecordValidationError catch above that) already
634
+ // returns its own honest `duplicate: false` earlier and never reaches this
635
+ // line. The CLI wrapper's `if (result.duplicate)` branch depends on this
636
+ // being accurate: a blocked resubmission must take the terse exit-0
637
+ // `{status:'duplicate'}` path, the same one an early-detected duplicate
638
+ // (the pre-verify probe above) already takes, not fall through to the
639
+ // full rejected-record exit-1 shape.
640
+ return { record, path, written, duplicate: !written };
351
641
  }
352
642
 
353
643
  /**
@@ -417,11 +707,18 @@ export async function verifyOnly(submission, options) {
417
707
  repo_policy_present: !!repoPolicy
418
708
  });
419
709
 
420
- // 3. Load scenario definitions (non-fatal — becomes rejection reason)
710
+ // 3. Load scenario definitions — same gates + semantics as ingest() step 3
711
+ // (F-3bfc2885 Map pass-through, F-efe4f893 Array.isArray gate, wave-4
712
+ // ruling's warning/rejection/operational split), so verify-only and real
713
+ // ingest produce identical records for the same submission.
421
714
  let scenarioErrors = [];
422
- if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
715
+ let scenarioWarnings = [];
716
+ let scenarios = null;
717
+ if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
423
718
  const result = await loadScenarios(submission, scenarioFetcher);
424
719
  scenarioErrors = result.errors;
720
+ scenarioWarnings = result.warnings || [];
721
+ scenarios = result.scenarios;
425
722
  }
426
723
 
427
724
  // 4. Call verifier
@@ -429,7 +726,8 @@ export async function verifyOnly(submission, options) {
429
726
  globalPolicy,
430
727
  repoPolicy,
431
728
  provenance,
432
- policyVersion
729
+ policyVersion,
730
+ scenarios
433
731
  });
434
732
 
435
733
  logStage('verify_complete', {
@@ -437,15 +735,21 @@ export async function verifyOnly(submission, options) {
437
735
  correlation_id,
438
736
  status: record.verification?.status ?? null,
439
737
  rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
440
- verdict: record.overall_verdict?.verified ?? null
738
+ verdict: record.overall_verdict?.verified ?? null,
739
+ // F-41872706: same post-load scenario breakdown as ingest()'s
740
+ // verify_complete, so verify-only and real ingest produce identical NDJSON
741
+ // observability for the same submission.
742
+ scenarios_loaded: scenarios ? scenarios.size : 0,
743
+ required_steps_gates_fired: countRequiredStepsGates(scenarios),
744
+ scenarios_warned: scenarioWarnings.length,
745
+ scenarios_errored: scenarioErrors.length
441
746
  });
442
747
 
443
748
  // 4b. Mirror ingest's scenario-error verdict downgrade so verify-only and
444
749
  // real ingest produce identical records for the same submission.
750
+ appendScenarioWarnings(record, scenarioWarnings);
445
751
  if (scenarioErrors.length > 0) {
446
- record.verification.rejection_reasons.push(
447
- ...scenarioErrors.map(e => `scenario-load: ${e}`)
448
- );
752
+ record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
449
753
  if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
450
754
  record.verification.status = 'rejected';
451
755
  record.verification.policy_valid = false;
@@ -472,10 +776,12 @@ export async function verifyOnly(submission, options) {
472
776
  would_persist_to = computeRecordPath(record, repoRoot);
473
777
  } catch {
474
778
  // Defensive: if a record passes verify() but still trips path
475
- // computation (e.g., a future schema with looser constraints), keep
476
- // verify-only side-effect-free. Real ingest would surface the throw
477
- // via writeRecord; verify-only just returns null and lets the operator
478
- // see the rejection in record.verification.rejection_reasons.
779
+ // computation (e.g. F-bbbe2e1f's `../etc` — schema-valid but
780
+ // traversal-unsafe), keep verify-only side-effect-free. Real ingest
781
+ // hits the SAME underlying computeRecordPath failure inside
782
+ // writeRecord() but now catches it too (F-4acd28d8, UnsafeRecordPathError)
783
+ // rather than letting it escape; verify-only just returns null here and
784
+ // lets the operator see the rejection in record.verification.rejection_reasons.
479
785
  would_persist_to = null;
480
786
  }
481
787
  }
@@ -521,6 +827,46 @@ function emitCliErrorEvent({ failedStage, correlationId, submissionId = null, er
521
827
  // --- CLI entrypoint ---
522
828
  // When run directly, reads submission from stdin or file argument
523
829
 
830
+ // F-9a65b10c (Stage C humanization): the write-path CLI ships a USAGE block like
831
+ // every other bin (verify/cli.js, report/cli.js, portfolio/generate.js). This is
832
+ // the only operator-facing entry point that writes records, so its reference has
833
+ // to live IN the tool, not scattered across docs.
834
+ const USAGE = `ingest — persist a dogfood submission (verify → policy → provenance → write)
835
+
836
+ Usage:
837
+ node packages/ingest/run.js --file <path> --provenance=github|stub
838
+ node packages/ingest/run.js --payload '<json>' --provenance=github|stub
839
+ echo '<json>' | node packages/ingest/run.js --provenance=github|stub
840
+
841
+ Input (exactly one; stdin used when neither flag is given):
842
+ --file <path> Read the submission JSON from a file.
843
+ --payload <json> Pass the submission JSON inline.
844
+ (stdin) Pipe the submission JSON on stdin.
845
+
846
+ Provenance (required for an ingest):
847
+ --provenance=github Confirm the source run via the GitHub API.
848
+ --provenance=stub No-network local confirm (dry-run / dev only).
849
+
850
+ Modes:
851
+ --verify-only Run the full pipeline WITHOUT writing or rebuilding
852
+ indexes; report where a real ingest WOULD have landed.
853
+
854
+ Standalone audit verbs (no submission, no stdin, no --provenance):
855
+ --verify-chain Verify the append-only integrity ledger (offline).
856
+ --reconcile Also fail on genuine torn-write orphans (on-disk records
857
+ missing from the ledger). Records that predate the
858
+ integrity chain itself are reported separately and do
859
+ not fail the audit.
860
+ --all Report every independent break instead of the first.
861
+ --anchor-compute Compute + write the next XRPL anchor manifest (offline).
862
+ --anchor-post Compute if needed + post the anchor to XRPL (needs XRPL_SEED).
863
+ --anchor-verify Verify local manifests + run the truncation check (offline).
864
+
865
+ -h, --help Show this help.
866
+
867
+ Exit codes:
868
+ 0 success 1 integrity/audit break 2 operator error (flags / IO / JSON)`;
869
+
524
870
  const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'run.js');
525
871
 
526
872
  if (isMain) {
@@ -551,6 +897,7 @@ if (isMain) {
551
897
  // per-record-independent break and report every break in one pass.
552
898
  let reconcileFlag = false;
553
899
  let allBreaksFlag = false;
900
+ let helpFlag = false;
554
901
  // Anchor verbs (optional, off-by-default, operator-run). --anchor-compute and
555
902
  // --anchor-verify are fully offline (never import xrpl); --anchor-post lazily
556
903
  // loads the optional xrpl package and needs XRPL_SEED.
@@ -590,9 +937,38 @@ if (isMain) {
590
937
  const hasValue = inlineValue !== null || nextIsValue;
591
938
  const takeValue = () => (inlineValue !== null ? inlineValue : args[++i]);
592
939
 
593
- if (arg === '--provenance' && hasValue) {
940
+ // F-e0bcbc47 (amended wave 29, after the first attempt regressed
941
+ // F-INGEST-003): `flagIs(name)` both TESTS the token and RECORDS that
942
+ // `name` is a flag this chain knows about, so the catch-all below can tell
943
+ // "unknown flag" (no branch knows this name) apart from "known flag, no
944
+ // value" (a branch knows the name but its `&& hasValue` guard failed —
945
+ // exactly what F-INGEST-003 causes for `--anchor-network --anchor-compute`).
946
+ // The first attempt at this fix had a bare `else if (arg.startsWith('--'))`
947
+ // catch-all that could not distinguish the two and so reported a KNOWN flag
948
+ // as "unknown argument: --anchor-network" — a claim wider than what it
949
+ // actually checked, which is the exact class this repo keeps paying for.
950
+ //
951
+ // `knownFlagName` is DERIVED from the chain itself rather than duplicated
952
+ // into a second list of flag names: the only way a name enters the known
953
+ // set is for the chain to literally test for it one line below. A parallel
954
+ // `KNOWN_FLAGS` enumeration would be a second population to keep in sync
955
+ // (the same enumeration-vs-property class as CONTROL_CLASS / ZALGO_RUN /
956
+ // DASH_CONFUSABLES), and it would drift the first time someone adds a flag
957
+ // and forgets the list. Adding a branch here cannot desync it — the branch
958
+ // IS the registration. The one residual shape is a future branch written as
959
+ // a bare `arg === '--new'` instead of `flagIs('--new')`; that would make the
960
+ // new flag report as unknown when dangling, and is guarded by
961
+ // f-e0bcbc47-unknown-flag-rejection.test.js's chain-shape assertion.
962
+ let knownFlagName = false;
963
+ const flagIs = (name) => {
964
+ if (arg !== name) return false;
965
+ knownFlagName = true;
966
+ return true;
967
+ };
968
+
969
+ if (flagIs('--provenance') && hasValue) {
594
970
  provenanceMode = takeValue();
595
- } else if (arg === '--file' && hasValue) {
971
+ } else if (flagIs('--file') && hasValue) {
596
972
  const { readFileSync } = await import('node:fs');
597
973
  // D1B-001 family (operator-legibility): a --file read failure
598
974
  // (ENOENT/EACCES) routes through the structured error event and exits 2
@@ -612,56 +988,115 @@ if (isMain) {
612
988
  });
613
989
  process.exit(2);
614
990
  }
615
- } else if (arg === '--payload' && hasValue) {
991
+ } else if (flagIs('--payload') && hasValue) {
616
992
  submissionJson = takeValue();
617
- } else if (arg === '--verify-only') {
993
+ } else if (flagIs('--verify-only')) {
618
994
  // F-252714-058: dry-run the pipeline without writing or rebuilding
619
995
  // indexes. CI / operators preview what WOULD have been persisted.
620
996
  verifyOnlyFlag = true;
621
- } else if (arg === '--verify-chain') {
997
+ } else if (flagIs('--verify-chain')) {
622
998
  // Integrity chain v1: verify the append-only tamper-evident ledger at
623
999
  // indexes/integrity/chain.jsonl, fully offline. No submission, no stdin,
624
1000
  // no provenance — a standalone audit command.
625
1001
  verifyChainFlag = true;
626
- } else if (arg === '--reconcile') {
1002
+ } else if (flagIs('--reconcile')) {
627
1003
  // Modifier for --verify-chain: also reconcile on-disk records against the
628
1004
  // ledger and fail on any orphan (INGEST-PROACT-001).
629
1005
  reconcileFlag = true;
630
- } else if (arg === '--all') {
1006
+ } else if (flagIs('--all')) {
631
1007
  // Modifier for --verify-chain: report every per-record-independent break
632
1008
  // instead of stopping at the first (INGEST-PROACT-004).
633
1009
  allBreaksFlag = true;
634
- } else if (arg === '--anchor-compute') {
1010
+ } else if (flagIs('--anchor-compute')) {
635
1011
  // Optional XRPL anchor: compute + write the next anchor manifest. Offline.
636
1012
  anchorComputeFlag = true;
637
- } else if (arg === '--anchor-post') {
1013
+ } else if (flagIs('--anchor-post')) {
638
1014
  // Optional XRPL anchor: compute if needed + post to XRPL. Needs the
639
1015
  // optional xrpl package (lazily loaded) and XRPL_SEED.
640
1016
  anchorPostFlag = true;
641
- } else if (arg === '--anchor-verify') {
1017
+ } else if (flagIs('--anchor-verify')) {
642
1018
  // Optional XRPL anchor: verify local manifests + run the truncation check.
643
1019
  // Offline reports honest NOT-verified for the on-chain leg.
644
1020
  anchorVerifyFlag = true;
645
- } else if (arg === '--anchor-all') {
1021
+ } else if (flagIs('--anchor-all')) {
646
1022
  // Genesis snapshot mode for compute/post (covers the whole chain).
647
1023
  anchorMode = 'all';
648
- } else if (arg === '--anchor-algo' && hasValue) {
1024
+ } else if (flagIs('--anchor-algo') && hasValue) {
649
1025
  anchorAlgo = takeValue();
650
- } else if (arg === '--anchor-network' && hasValue) {
1026
+ } else if (flagIs('--anchor-network') && hasValue) {
651
1027
  anchorNetwork = takeValue();
652
- } else if (arg === '--anchor-tx' && hasValue) {
1028
+ } else if (flagIs('--anchor-tx') && hasValue) {
653
1029
  // Path to a JSON file containing a fetched XRPL tx (with Memos) for the
654
1030
  // on-chain leg of --anchor-verify. Offline-honest: omit it to run the
655
1031
  // truncation check only.
656
1032
  anchorTxFile = takeValue();
657
- } else if (arg === '--anchor-trusted' && hasValue) {
1033
+ } else if (flagIs('--anchor-trusted') && hasValue) {
658
1034
  // Comma-separated trusted anchor accounts (UNIONed with the bundled list).
659
1035
  anchorTrustedAccounts = takeValue().split(',').map((s) => s.trim()).filter(Boolean);
1036
+ } else if (flagIs('-h') || flagIs('--help') || flagIs('--usage')) {
1037
+ helpFlag = true;
1038
+ } else if (arg.startsWith('--') && knownFlagName) {
1039
+ // F-e0bcbc47 / F-INGEST-003 boundary: a KNOWN flag whose `&& hasValue`
1040
+ // guard failed — i.e. a value flag given no value, because the next
1041
+ // token was itself a flag (`--anchor-network --anchor-compute`) or it
1042
+ // ended argv. F-INGEST-003 pins that the FOLLOWING flag is still parsed
1043
+ // on its own rather than swallowed as this one's value, so this token
1044
+ // keeps its historical path (fall through to positionalArgs, continue)
1045
+ // and `--anchor-compute` runs. Rejecting here instead would break that
1046
+ // pinned contract.
1047
+ //
1048
+ // RESIDUAL, STATED (not fixed here): the dangling flag is still
1049
+ // IGNORED — `--anchor-network` with no value does not set the network
1050
+ // and does not fail the run. That is pre-existing behavior and this
1051
+ // amend does not change it. What changes is that it is no longer
1052
+ // SILENT: an operator gets a structured warn naming the flag instead of
1053
+ // the value vanishing with no trace. Making it a hard error is a real
1054
+ // contract change (it would stop `--anchor-compute` from running and
1055
+ // rewrite F-INGEST-003's pin), which is a bigger claim than a Stage C
1056
+ // humanization amend should make unilaterally — filed as a follow-up
1057
+ // rather than landed here.
1058
+ logStage('warn', {
1059
+ kind: 'cli_flag_missing_value',
1060
+ flag: arg,
1061
+ correlation_id: synthCorrelationId(),
1062
+ message: `${arg} was given no value and is being ignored (the next token is a flag, not a value)`
1063
+ });
1064
+ positionalArgs.push(args[i]);
1065
+ } else if (arg.startsWith('--')) {
1066
+ // F-e0bcbc47 (Stage C humanization): a genuinely unrecognized `--flag`
1067
+ // — no branch above knows this name at all. It used to fall through to
1068
+ // the dead `positionalArgs` sink (declared, pushed to, and never read
1069
+ // anywhere else in this file — confirmed by grep) and the CLI proceeded
1070
+ // to read stdin, hit EOF, and crashed with a raw 'Unexpected end of
1071
+ // JSON input' stack that misattributed the failure to the submission
1072
+ // payload rather than the misspelled flag (live-proven trigger:
1073
+ // `--provenance=stub --fiel <path>`, a one-character typo of --file).
1074
+ // Reject at the point of the typo instead — matches the sibling pattern
1075
+ // already correct in this same domain (packages/verify/cli.js:
1076
+ // `unknown argument: --version` -> exit 2, no stack, no fallthrough).
1077
+ // Scoped to `--`-prefixed tokens only (a bare positional stays in the
1078
+ // historical positionalArgs sink) because that is exactly the shape a
1079
+ // mistyped flag takes, and it is the shape the reachable repro above
1080
+ // hits BEFORE any value token is even read.
1081
+ emitCliErrorEvent({
1082
+ failedStage: 'cli_parse_args',
1083
+ correlationId: synthCorrelationId(),
1084
+ err: new Error(`unknown argument: ${arg}`),
1085
+ humanPrefix: 'invalid CLI invocation'
1086
+ });
1087
+ process.exit(2);
660
1088
  } else {
661
1089
  positionalArgs.push(args[i]);
662
1090
  }
663
1091
  }
664
1092
 
1093
+ // F-9a65b10c: print USAGE and exit 0 before any input resolution — help must
1094
+ // never block on stdin or demand a --provenance flag.
1095
+ if (helpFlag) {
1096
+ console.log(USAGE);
1097
+ process.exit(0);
1098
+ }
1099
+
665
1100
  // --verify-chain is a standalone, side-effect-free audit: it reads only the
666
1101
  // ledger + the record files it references, takes no submission, reads no
667
1102
  // stdin, and needs no provenance adapter. Handle it BEFORE the stdin read and
@@ -684,7 +1119,12 @@ if (isMain) {
684
1119
  // operator asked for it, so a grep of the NDJSON shows how many breaks and
685
1120
  // orphans were found, not just the first break.
686
1121
  ...(Array.isArray(result.breaks) ? { break_count: result.breaks.length } : {}),
687
- ...(Array.isArray(result.orphans) ? { orphan_count: result.orphans.length } : {})
1122
+ ...(Array.isArray(result.orphans) ? { orphan_count: result.orphans.length } : {}),
1123
+ // F-29134790: pre-adoption records are excluded from chain_ok but still
1124
+ // worth a grep-able count — an operator diffing orphan_count over time
1125
+ // should see the pre-chain figure hold steady while orphan_count reflects
1126
+ // only genuine torn writes.
1127
+ ...(Array.isArray(result.pre_adoption) ? { pre_adoption_count: result.pre_adoption.length } : {})
688
1128
  });
689
1129
  const lines = formatChainResult(result);
690
1130
  if (result.ok) {
@@ -744,6 +1184,19 @@ if (isMain) {
744
1184
  }
745
1185
 
746
1186
  if (!submissionJson) {
1187
+ // F-aa67ea9a (Stage C humanization): guard an interactive stdin. Without a
1188
+ // piped payload, `for await (…process.stdin)` blocks forever at a TTY —
1189
+ // exactly what a first-run operator who forgot to pipe input types — and
1190
+ // looks like a hang. Fail fast with the same empty-state error verify/cli.js
1191
+ // gives, naming the concrete input levers.
1192
+ if (process.stdin.isTTY) {
1193
+ console.error('ERROR: no submission provided');
1194
+ console.error(
1195
+ " hint: pass --file <path> or --payload '<json>', or pipe JSON on stdin; " +
1196
+ 'run --help for usage.'
1197
+ );
1198
+ process.exit(2);
1199
+ }
747
1200
  // Read from stdin
748
1201
  const chunks = [];
749
1202
  for await (const chunk of process.stdin) {
@@ -791,7 +1244,7 @@ if (isMain) {
791
1244
  let provenance;
792
1245
  if (provenanceMode === 'stub') {
793
1246
  // Structural anti-misuse: stub only allowed outside CI
794
- if (process.env.CI || process.env.GITHUB_ACTIONS) {
1247
+ if (isCI()) {
795
1248
  emitCliErrorEvent({
796
1249
  failedStage: 'cli_provenance_resolve',
797
1250
  correlationId: cliCorrelationId,
@@ -819,7 +1272,7 @@ if (isMain) {
819
1272
  process.exit(2);
820
1273
  }
821
1274
  provenance = resolved.provenance;
822
- } else if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
1275
+ } else if (isCI()) {
823
1276
  // In CI without an explicit flag: default to real provenance, routed by the
824
1277
  // submission's source.provider (github | gitlab).
825
1278
  const resolved = resolveProviderProvenance(submission);
@@ -845,6 +1298,34 @@ if (isMain) {
845
1298
  process.exit(2);
846
1299
  }
847
1300
 
1301
+ // F-3bfc2885: construct a scenario fetcher for the production path. Before
1302
+ // this, the CLI never passed one, so scenario definitions — and with them the
1303
+ // `step-results-present` / `step-verdict-consistent` required_steps gates —
1304
+ // were test-only: an operator-declared severity:reject rule silently passed.
1305
+ // Real (non-stub) provenance + a github-provider submission loads scenario
1306
+ // definitions from the source repo at the persisted commit, reusing the token
1307
+ // resolveProviderProvenance already validated. GitLab submissions have no
1308
+ // scenario fetcher yet (no gitlab contents-API adapter exists); their
1309
+ // required_steps gate remains unenforced — a known, documented gap.
1310
+ // V2-INVARIAN-004: the eligibility condition is the exported, unit-tested
1311
+ // resolveScenarioFetcherDecision above.
1312
+ // F-b04473d5: log the decision — an operator auditing an accepted record
1313
+ // must be able to tell "required_steps enforced" from "enforcement skipped
1314
+ // (and why)" straight from the NDJSON stream.
1315
+ const scenarioDecision = resolveScenarioFetcherDecision(provenance, submission, process.env);
1316
+ const scenarioFetcher = scenarioDecision.fetcher;
1317
+ logStage('scenario_enforcement', {
1318
+ submission_id: submission && typeof submission === 'object' && !Array.isArray(submission)
1319
+ ? (submission.run_id || null)
1320
+ : null,
1321
+ correlation_id: cliCorrelationId,
1322
+ active: scenarioDecision.active,
1323
+ ...(scenarioDecision.reason ? { reason: scenarioDecision.reason } : {}),
1324
+ scenario_count: submission && typeof submission === 'object' && Array.isArray(submission.scenario_results)
1325
+ ? submission.scenario_results.length
1326
+ : 0
1327
+ });
1328
+
848
1329
  // D1B-001: track the last successful pipeline stage so the outer catch
849
1330
  // can surface a useful `failed_stage` in its structured error event.
850
1331
  // We update it once the verify/ingest call has RETURNED — anything
@@ -856,7 +1337,7 @@ if (isMain) {
856
1337
  // when they could.
857
1338
  try {
858
1339
  if (verifyOnlyFlag) {
859
- const result = await verifyOnly(submission, { repoRoot, provenance });
1340
+ const result = await verifyOnly(submission, { repoRoot, provenance, scenarioFetcher });
860
1341
  lastSuccessfulStage = 'verify_only';
861
1342
 
862
1343
  console.log(JSON.stringify({
@@ -877,7 +1358,7 @@ if (isMain) {
877
1358
  process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
878
1359
  }
879
1360
 
880
- const result = await ingest(submission, { repoRoot, provenance });
1361
+ const result = await ingest(submission, { repoRoot, provenance, scenarioFetcher });
881
1362
  lastSuccessfulStage = 'ingest';
882
1363
 
883
1364
  if (result.duplicate) {