@dogfood-lab/ingest 1.7.0 → 1.9.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.
@@ -6,14 +6,22 @@
6
6
  * - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
7
7
  * - packages/findings/derive/load-records.js (the missing third callsite)
8
8
  *
9
- * The check rejects path-traversal substrings (`..`) and any path separator
10
- * (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
11
- * names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
12
- * schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
9
+ * The check rejects path-traversal substrings (`..`), any path separator
10
+ * (`/`, `\`), and a segment that is EXACTLY `.` (F-3d2e6edf: path.join
11
+ * normalizes a lone-dot segment away, so a schema-valid repo like `./x`
12
+ * filed records one directory level UP — records/_rejected/x/... at the org
13
+ * level — and resolved policies/repos/./x.yaml to policies/repos/x.yaml;
14
+ * the verify CLI's safe() helper already blocked `.`, and the two guards
15
+ * must agree). EMBEDDED dots remain legal because GitHub permits dotted
16
+ * org/repo names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The
17
+ * submission schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees
18
+ * on characters (though it still admits a lone-dot segment — this guard is
19
+ * the backstop).
13
20
  *
14
21
  * F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
15
- * legitimate submissions inside writeRecord. The narrower `/\.\.|[/\\]/` has
16
- * stood since wave 9; this helper is the productized form.
22
+ * legitimate submissions inside writeRecord. The narrower form (now
23
+ * `/^\.$|\.\.|[/\\]/`) has stood since wave 9; this helper is the
24
+ * productized form.
17
25
  */
18
26
 
19
27
  /**
@@ -22,7 +30,7 @@
22
30
  *
23
31
  * @type {RegExp}
24
32
  */
25
- export const UNSAFE_SEGMENT = /\.\.|[/\\]/;
33
+ export const UNSAFE_SEGMENT = /^\.$|\.\.|[/\\]/;
26
34
 
27
35
  /**
28
36
  * Predicate form: returns true when the given segment contains a path-traversal
package/load-context.js CHANGED
@@ -129,7 +129,14 @@ export function loadGlobalPolicy(repoRoot) {
129
129
  * @returns {object|null|{ __torn: true, reason: string, path: string }}
130
130
  */
131
131
  export function loadRepoPolicy(repoSlug, repoRoot) {
132
- const [org, repo] = repoSlug.split('/');
132
+ // F-54e5fde7: the submission contract is strictly two-segment (the schema's
133
+ // `repo` pattern forbids a second slash; nested GitLab subgroups are
134
+ // UNSUPPORTED). A 3+-segment slug must fail closed here — the old 2-way
135
+ // destructure silently dropped the third segment and would have resolved a
136
+ // DIFFERENT repo's policy file (group/subgroup/project → repos/group/subgroup.yaml).
137
+ const segments = repoSlug.split('/');
138
+ if (segments.length !== 2) return null;
139
+ const [org, repo] = segments;
133
140
  if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
134
141
  const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
135
142
 
@@ -215,6 +222,50 @@ export const GITHUB_SCENARIO_FETCH_ATTEMPTS = 3;
215
222
  const RETRY_BASE_MS = 250;
216
223
  const RETRY_MAX_MS = 2000;
217
224
 
225
+ /**
226
+ * V2-CROSS-BO-002: byte cap on a fetched scenario body. A scenario definition
227
+ * crosses the consumer-repo → verifier trust boundary; without a cap a
228
+ * hostile/misconfigured source repo could stream an arbitrarily large body
229
+ * into memory and into yaml.load. Real scenario files are a few KB — 1 MiB is
230
+ * three orders of magnitude of headroom. Checked against Content-Length
231
+ * BEFORE the body is read (when the response exposes it), then enforced
232
+ * WHILE the body streams (F-35486d38: the reader aborts the moment the
233
+ * running byte count crosses the cap, so a header-suppressed oversized body
234
+ * never fully buffers). Responses without a streamable body (test stubs)
235
+ * fall back to text() + a post-read length check, which then bounds only
236
+ * yaml.load, not the buffering step.
237
+ */
238
+ export const GITHUB_SCENARIO_MAX_BYTES = 1024 * 1024;
239
+
240
+ /**
241
+ * F-2750c4e8: hard ceiling on DISTINCT scenario ids fetched per submission.
242
+ * Matches the submission schema's `scenario_results` maxItems (1000) — the
243
+ * published contract bound — so a schema-valid submission can never hit it,
244
+ * while a hostile pre-schema-gate payload (the fetch loop runs before
245
+ * verify()'s schema rejection lands) cannot drive unbounded sequential
246
+ * authenticated GitHub API calls. Combined with the attempted-id dedupe in
247
+ * loadScenarios, the worst-case network spend per submission is bounded by
248
+ * this constant regardless of payload shape.
249
+ */
250
+ export const MAX_DISTINCT_SCENARIO_FETCHES = 1000;
251
+
252
+ /**
253
+ * V2-CROSS-BO-001: classified operational fault for the scenario fetch —
254
+ * mirrors the F-dac7e08c adapter discipline in
255
+ * `packages/verify/validators/provenance.js` (transport rejects and non-404
256
+ * provider statuses THROW after the bounded retry, they never masquerade as
257
+ * "the file does not exist"). The `scenario-fetch-fault:` prefix is
258
+ * registered in `@dogfood-lab/verify`'s parseRejectionReason as
259
+ * 'operational', so any surface that stringifies this error routes the
260
+ * incident to ops instead of bouncing a good submission back to the
261
+ * submitter.
262
+ */
263
+ function scenarioFetchFault(detail) {
264
+ const err = new Error(`scenario-fetch-fault: ${detail}`);
265
+ err.code = 'SCENARIO_FETCH_FAULT';
266
+ return err;
267
+ }
268
+
218
269
  /** Default async backoff. Injectable (`opts.sleepImpl`) so tests run instantly. */
219
270
  function defaultSleep(ms) {
220
271
  return new Promise((r) => setTimeout(r, ms));
@@ -231,9 +282,11 @@ function defaultSleep(ms) {
231
282
  * - `fetchWithReason(scenarioId)`: D1B-004 typed contract — always
232
283
  * returns `{ scenario, reason }` where `scenario` is the loaded
233
284
  * object on success or `null` on failure, and `reason` is one of
234
- * `'timeout' | 'not_found' | 'parse_error' | 'invalid_id'` on
235
- * failure (absent on success). The reason gives operators a
236
- * pivot key when diagnosing a stale scenario-load chain.
285
+ * `'not_found' | 'parse_error' | 'invalid_id' | 'too_large' |
286
+ * 'schema_invalid'` on failure (absent on success). The reason gives
287
+ * operators a pivot key when diagnosing a stale scenario-load chain.
288
+ * A timeout that survives every retry no longer RETURNS a typed
289
+ * reason — it throws `scenario-fetch-fault:` (F-07ab7f86, see below).
237
290
  *
238
291
  * Both surfaces honour the per-request AbortController timeout
239
292
  * (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
@@ -241,10 +294,23 @@ function defaultSleep(ms) {
241
294
  * INGEST-PROACT-003: each call makes up to `opts.attempts`
242
295
  * (`GITHUB_SCENARIO_FETCH_ATTEMPTS`) tries with exponential backoff, retrying
243
296
  * ONLY the transient classes — request timeout, HTTP 5xx, HTTP 429, and network
244
- * rejects. A 404 (`not_found`), an `invalid_id`, and a `parse_error` are
245
- * DEFINITIVE answers and are returned immediately without a retry (mirrors the
246
- * EPERM/EBUSY-only discipline in `lib/rename-with-retry.js`). The backoff sleep
247
- * is injectable (`opts.sleepImpl`) so tests do not actually wait.
297
+ * rejects. A 404 (`not_found`), an `invalid_id`, a `parse_error`, a
298
+ * `too_large`, and a `schema_invalid` are DEFINITIVE answers and are returned
299
+ * immediately without a retry (mirrors the EPERM/EBUSY-only discipline in
300
+ * `lib/rename-with-retry.js`). The backoff sleep is injectable
301
+ * (`opts.sleepImpl`) so tests do not actually wait.
302
+ *
303
+ * V2-CROSS-BO-001 + F-07ab7f86: exhausting the retry budget on a 5xx/429, a
304
+ * transport reject, OR a per-request timeout, or hitting any other non-404
305
+ * non-ok status (401/403 bad credential), THROWS a `scenario-fetch-fault:`
306
+ * classified error instead of returning a typed reason — an outage, a slow-API
307
+ * window, or a token fault is an OPERATIONAL incident, never evidence that
308
+ * the scenario file is absent. Callers (loadScenarios → run.js) propagate the
309
+ * throw so the CLI exits 2 without persisting a rejected record. The
310
+ * exhausted-timeout fault keeps the literal word 'timeout' in its detail so
311
+ * the D1B-004/L1-007 operator pivot key survives (contract updated wave 4;
312
+ * pre-fix the typed 'timeout' reason became a submission-rejecting
313
+ * `scenario-load:` reason that poisoned the run_id via the duplicate guard).
248
314
  *
249
315
  * @param {string} token - GitHub PAT
250
316
  * @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
@@ -258,7 +324,13 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
258
324
  const attempts = opts.attempts ?? GITHUB_SCENARIO_FETCH_ATTEMPTS;
259
325
  const sleep = opts.sleepImpl ?? defaultSleep;
260
326
 
261
- const [org, repo] = repoSlug.split('/');
327
+ // V2-CROSS-BO-003 (F-54e5fde7 family): the submission contract is strictly
328
+ // two-segment. The old 2-way destructure silently dropped a third segment —
329
+ // and worse, the URL below was built from the RAW slug, so `org/repo/extra`
330
+ // reached the authenticated GitHub API verbatim. Fail closed here and build
331
+ // the URL from the VALIDATED segments only.
332
+ const segments = typeof repoSlug === 'string' ? repoSlug.split('/') : [];
333
+ const [org, repo] = segments.length === 2 ? segments : [null, null];
262
334
  // commitSha is interpolated into the authenticated (Bearer-token) GitHub API
263
335
  // URL's `?ref=` — a shape guard (lowercase-hex, 7–40 chars) refuses anything
264
336
  // that could re-target the ref or inject query params, matching how org/repo
@@ -274,11 +346,14 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
274
346
  };
275
347
  }
276
348
 
277
- // One bounded attempt. Returns `{ scenario, reason, retryable }`; the loop
278
- // below decides whether to retry on `retryable`.
349
+ // One bounded attempt. Returns `{ scenario, reason, retryable, fault }`;
350
+ // the loop below retries on `retryable` and THROWS `fault` (when present)
351
+ // once the budget is exhausted — so a transient blip is ridden out, but a
352
+ // real outage surfaces as an operational fault, never as `not_found`.
279
353
  async function attemptOnce(scenarioId) {
280
354
  const path = `dogfood/scenarios/${scenarioId}.yaml`;
281
- const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
355
+ // V2-CROSS-BO-003: built from the validated org/repo, never the raw slug.
356
+ const url = `https://api.github.com/repos/${org}/${repo}/contents/${path}?ref=${commitSha}`;
282
357
 
283
358
  // D1B-004: AbortController-bounded request. Copied from
284
359
  // `packages/verify/validators/provenance.js:80-104`. The 30s default
@@ -298,33 +373,110 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
298
373
  signal: controller.signal
299
374
  });
300
375
  if (!resp.ok) {
301
- // A 5xx server error or a 429 rate-limit is transient retry. Any
302
- // other non-ok (notably 404) is a definitive answer; do not retry.
303
- const retryable = resp.status >= 500 || resp.status === 429;
304
- return { scenario: null, reason: 'not_found', retryable };
376
+ // V2-CROSS-BO-001: only a 404 means "the scenario file is absent"
377
+ // that is the sole not_found. A 5xx / 429 is a transient provider
378
+ // fault: retry, then throw operational on exhaustion. Anything else
379
+ // (401/403 bad credential, unexpected 4xx) is a definitive
380
+ // OPERATIONAL fault: throw immediately, never retried, never
381
+ // misread as a missing file.
382
+ if (resp.status === 404) {
383
+ return { scenario: null, reason: 'not_found', retryable: false };
384
+ }
385
+ const fault = scenarioFetchFault(
386
+ `GitHub API HTTP ${resp.status} loading scenario "${scenarioId}" from ${org}/${repo}`
387
+ );
388
+ if (resp.status >= 500 || resp.status === 429) {
389
+ return { scenario: null, retryable: true, fault };
390
+ }
391
+ throw fault;
392
+ }
393
+
394
+ // V2-CROSS-BO-002: byte cap at the trust boundary. Check the declared
395
+ // Content-Length first (skips even starting an oversized read when the
396
+ // response exposes headers) — the header is attacker-suppliable, so the
397
+ // read below re-enforces the cap on real bytes.
398
+ const declaredLength = typeof resp.headers?.get === 'function'
399
+ ? Number(resp.headers.get('content-length'))
400
+ : NaN;
401
+ if (declaredLength > GITHUB_SCENARIO_MAX_BYTES) {
402
+ return { scenario: null, reason: 'too_large', retryable: false };
403
+ }
404
+ // F-35486d38: stream the body with a running byte count and abort the
405
+ // moment the cap is crossed, so a header-suppressed oversized body is
406
+ // never fully buffered. Responses without a streamable body (test
407
+ // stubs, non-spec fetch impls) fall back to text() + post-read check,
408
+ // which bounds yaml.load but not the buffering step.
409
+ if (resp.body && typeof resp.body.getReader === 'function') {
410
+ const reader = resp.body.getReader();
411
+ const chunks = [];
412
+ let received = 0;
413
+ let overflow = false;
414
+ for (;;) {
415
+ const { done, value } = await reader.read();
416
+ if (done) break;
417
+ received += value.byteLength;
418
+ if (received > GITHUB_SCENARIO_MAX_BYTES) {
419
+ overflow = true;
420
+ // cancel() may reject if the connection is already torn down —
421
+ // the too_large verdict below stands either way.
422
+ await reader.cancel().catch(() => {});
423
+ break;
424
+ }
425
+ chunks.push(value);
426
+ }
427
+ if (overflow) {
428
+ return { scenario: null, reason: 'too_large', retryable: false };
429
+ }
430
+ text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
431
+ } else {
432
+ text = await resp.text();
433
+ if (Buffer.byteLength(text, 'utf8') > GITHUB_SCENARIO_MAX_BYTES) {
434
+ return { scenario: null, reason: 'too_large', retryable: false };
435
+ }
305
436
  }
306
- text = await resp.text();
307
437
  } catch (err) {
438
+ if (err && err.code === 'SCENARIO_FETCH_FAULT') {
439
+ throw err;
440
+ }
308
441
  if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
309
442
  // A timed-out request may succeed on a retry.
310
443
  return { scenario: null, reason: 'timeout', retryable: true };
311
444
  }
312
- // Network reject, DNS failure, etc. — transient; surface as not_found
313
- // for back-compat with the legacy null contract, but allow a retry.
314
- return { scenario: null, reason: 'not_found', retryable: true };
445
+ // V2-CROSS-BO-001: network reject, DNS failure, etc. — transient, so
446
+ // retry; but on exhaustion this is an outage (operational fault), NOT
447
+ // evidence the scenario file is absent.
448
+ return {
449
+ scenario: null,
450
+ retryable: true,
451
+ fault: scenarioFetchFault(
452
+ `network error loading scenario "${scenarioId}" from ${org}/${repo}: ${err && err.message ? err.message : String(err)}`
453
+ )
454
+ };
315
455
  } finally {
316
456
  clearTimeout(timer);
317
457
  }
318
458
 
459
+ let scenario;
319
460
  try {
320
- const scenario = yaml.load(text);
321
- if (!scenario || typeof scenario !== 'object') {
322
- return { scenario: null, reason: 'parse_error', retryable: false };
323
- }
324
- return { scenario };
461
+ scenario = yaml.load(text);
325
462
  } catch {
326
463
  return { scenario: null, reason: 'parse_error', retryable: false };
327
464
  }
465
+ if (!scenario || typeof scenario !== 'object') {
466
+ return { scenario: null, reason: 'parse_error', retryable: false };
467
+ }
468
+
469
+ // V2-CROSS-BO-002: schema-gate the loaded object before it crosses into
470
+ // verify()'s required-steps enforcement. A parses-but-nonconforming
471
+ // scenario is a SUBMISSION-SIDE fault (the source repo authored it) — it
472
+ // surfaces through loadScenarios as a typed `schema_invalid` reason and
473
+ // becomes a `scenario-load:` rejection at the ingest layer, never a
474
+ // validator fault.
475
+ const validation = validatePayload('scenario', scenario);
476
+ if (!validation.valid) {
477
+ return { scenario: null, reason: 'schema_invalid', retryable: false };
478
+ }
479
+ return { scenario };
328
480
  }
329
481
 
330
482
  async function fetchWithReason(scenarioId) {
@@ -338,6 +490,26 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
338
490
  if (last.scenario || !last.retryable || i === attempts - 1) break;
339
491
  await sleep(Math.min(RETRY_BASE_MS * (1 << i), RETRY_MAX_MS));
340
492
  }
493
+ // V2-CROSS-BO-001: a transient fault that survived every retry is an
494
+ // outage — throw the classified operational error instead of returning a
495
+ // reason the ingest layer would turn into a submission rejection.
496
+ if (last.fault) {
497
+ throw last.fault;
498
+ }
499
+ // F-07ab7f86: timeout EXHAUSTION is outage-shaped too — a sustained
500
+ // slow-API window (every attempt >timeoutMs) is the same operational
501
+ // class as exhausted ECONNREFUSED/5xx, not evidence the submission is
502
+ // bad. Throw the classified fault, keeping the literal word 'timeout'
503
+ // in the detail so the D1B-004/L1-007 operator pivot key survives in
504
+ // the error message. (This deliberately supersedes the earlier pinned
505
+ // contract where exhausted-timeout returned the typed reason and became
506
+ // a `scenario-load:` rejection — that permanently poisoned run_ids
507
+ // during slow-API windows via the _rejected duplicate guard.)
508
+ if (last.reason === 'timeout') {
509
+ throw scenarioFetchFault(
510
+ `timeout after ${attempts} attempt(s) loading scenario "${scenarioId}" from ${org}/${repo}`
511
+ );
512
+ }
341
513
  // Strip the internal `retryable` flag from the public contract.
342
514
  const { retryable: _drop, ...result } = last;
343
515
  return result;
@@ -357,32 +529,91 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
357
529
  /**
358
530
  * Load all scenario definitions referenced by a submission's scenario_results.
359
531
  *
360
- * D1B-004 / L1-007 (Wave A2 Stage C amend2): when the fetcher exposes
361
- * the typed `fetchWithReason` surface (the canonical
362
- * `githubScenarioFetcher` does; the legacy `localScenarioFetcher` and any
363
- * legacy test stub may not), the discriminated `reason` (`timeout` /
364
- * `not_found` / `parse_error` / `invalid_id`) is propagated into the
365
- * error string so the operator can pivot on the failure class. Otherwise
366
- * we fall back to the legacy `fetch(id)` truthiness contract.
532
+ * F-efe4f893: this function now runs BEFORE the schema gate on untrusted
533
+ * consumer input (the F-3bfc2885 production wiring), so it must be defensive:
534
+ * - a non-array `scenario_results` returns empty-handed (the schema
535
+ * rejection downstream in verify() is the authoritative signal);
536
+ * - an entry that is not a non-null object carrying a string `scenario_id`
537
+ * is NEVER fetched pre-fix, `/^[\w-]+$/.test(undefined)` coerced to the
538
+ * string 'undefined' and issued a real authenticated GitHub API request
539
+ * per malformed entry. It pushes a typed `malformed_entry` error instead.
540
+ *
541
+ * F-2750c4e8: every ATTEMPTED id is cached (successes AND typed failures), so
542
+ * a repeated id — loaded or failed — costs exactly one fetch, and the number
543
+ * of distinct fetches is capped at {@link MAX_DISTINCT_SCENARIO_FETCHES}
544
+ * (the schema's own maxItems bound). Entries beyond the cap get a typed
545
+ * `fetch_cap` error without touching the network. Thrown
546
+ * `scenario-fetch-fault:`s are deliberately NOT cached — they propagate
547
+ * immediately (see below), so outage semantics survive the dedupe.
548
+ *
549
+ * DESIGN RULING (wave 4) — three-way failure split:
550
+ * - true 404 (`not_found`, typed surface only) → NOT a rejection. The fleet
551
+ * is mixed (some consumers commit dogfood/scenarios/, some don't);
552
+ * nothing declared means nothing to enforce. Surfaces on `warnings` so
553
+ * run.js lands it on verification.warnings (the v1.6.0
554
+ * accepted-with-warning channel) and required-steps enforcement is
555
+ * simply skipped for that scenario.
556
+ * - malformed committed file (`parse_error` / `schema_invalid` /
557
+ * `too_large` / `invalid_id`) → `errors` → submission-bad
558
+ * `scenario-load:` rejection (unchanged).
559
+ * - outage (`scenario-fetch-fault:` throw, incl. exhausted timeout) →
560
+ * PROPAGATES deliberately; the CLI's outer catch emits a structured
561
+ * operational error and exits 2 without persisting (V2-CROSS-BO-001).
562
+ *
563
+ * The legacy `fetch(id)` truthiness surface cannot discriminate not_found
564
+ * from a malformed file, so its `null` keeps the historical
565
+ * rejection-on-error behavior.
367
566
  *
368
567
  * @param {object} submission
369
568
  * @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
370
- * @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
569
+ * @returns {Promise<{ scenarios: Map<string, object>, errors: string[], warnings: string[] }>}
371
570
  */
372
571
  export async function loadScenarios(submission, scenarioFetcher) {
373
572
  const scenarios = new Map();
374
573
  const errors = [];
574
+ const warnings = [];
575
+
576
+ const results = submission && typeof submission === 'object'
577
+ ? submission.scenario_results
578
+ : undefined;
579
+ if (!Array.isArray(results)) {
580
+ // Non-array shapes (string, object, null) iterate wrong or crash —
581
+ // notably a STRING iterates characters. Skip entirely; verify()'s schema
582
+ // gate rejects the submission with the authoritative reason.
583
+ return { scenarios, errors, warnings };
584
+ }
375
585
 
376
586
  const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
587
+ // F-2750c4e8: id → true once an id has been resolved (success OR typed
588
+ // failure), so repeats never re-fetch. Faults are uncached: they throw.
589
+ const attempted = new Set();
590
+ let distinctFetches = 0;
377
591
 
378
- for (const sr of submission.scenario_results || []) {
592
+ for (const sr of results) {
593
+ if (!sr || typeof sr !== 'object' || Array.isArray(sr) || typeof sr.scenario_id !== 'string') {
594
+ errors.push(
595
+ 'scenario_results entry is not an object with a string scenario_id (reason: malformed_entry)'
596
+ );
597
+ continue;
598
+ }
379
599
  const id = sr.scenario_id;
380
- if (scenarios.has(id)) continue;
600
+ if (attempted.has(id)) continue;
601
+ attempted.add(id);
602
+
603
+ if (distinctFetches >= MAX_DISTINCT_SCENARIO_FETCHES) {
604
+ errors.push(
605
+ `scenario "${id}" not fetched — distinct-scenario fetch cap (${MAX_DISTINCT_SCENARIO_FETCHES}) reached (reason: fetch_cap)`
606
+ );
607
+ continue;
608
+ }
609
+ distinctFetches++;
381
610
 
382
611
  if (supportsTypedReason) {
383
612
  const result = await scenarioFetcher.fetchWithReason(id);
384
613
  if (result && result.scenario) {
385
614
  scenarios.set(id, result.scenario);
615
+ } else if (result && result.reason === 'not_found') {
616
+ warnings.push(`scenario definition not found for "${id}" — required_steps unenforced`);
386
617
  } else {
387
618
  const reason = result && result.reason ? result.reason : 'unknown';
388
619
  errors.push(`scenario "${id}" could not be loaded from source repo (reason: ${reason})`);
@@ -397,5 +628,5 @@ export async function loadScenarios(submission, scenarioFetcher) {
397
628
  }
398
629
  }
399
630
 
400
- return { scenarios, errors };
631
+ return { scenarios, errors, warnings };
401
632
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
6
6
  "main": "run.js",
package/persist.js CHANGED
@@ -43,7 +43,15 @@ export function computeRecordPath(record, repoRoot) {
43
43
  const status = record.verification?.status;
44
44
  const base = status === 'rejected' ? 'records/_rejected' : 'records';
45
45
 
46
- const [org, repo] = (record.repo || '').split('/');
46
+ // V2-CROSS-BO-003 (F-54e5fde7 family): strictly two segments — the old
47
+ // destructure silently dropped a third segment (`group/subgroup/project`
48
+ // filed under `group/subgroup/`), sharding the record into the WRONG
49
+ // repo's path. Fail closed instead.
50
+ const segments = (record.repo || '').split('/');
51
+ if (segments.length !== 2) {
52
+ throw new Error(`invalid repo format: ${record.repo}`);
53
+ }
54
+ const [org, repo] = segments;
47
55
  if (!org || !repo) {
48
56
  throw new Error(`invalid repo format: ${record.repo}`);
49
57
  }
package/run.js CHANGED
@@ -24,7 +24,7 @@ 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';
27
+ import { loadGlobalPolicy, loadRepoPolicy, loadScenarios, githubScenarioFetcher } from './load-context.js';
28
28
  import { isDuplicate, writeRecord, computeRecordPath } from './persist.js';
29
29
  import { rebuildIndexes } from './rebuild-indexes.js';
30
30
  import { verifyChain, formatChainResult } from './verify-chain.js';
@@ -44,6 +44,27 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
44
44
  * @param {object} submission
45
45
  * @returns {{ provenance: object } | { err: Error }}
46
46
  */
47
+ /**
48
+ * F-a2e40a09: the ONE definition of "running in CI" for the provenance
49
+ * branches. Truthy check on either conventional variable — CI systems export
50
+ * CI=1 / CI=yes / CI=true interchangeably. Previously the stub-forbidden
51
+ * guard used this truthy check while the real-by-default branch demanded the
52
+ * exact string 'true', so a CI=1 environment was "CI" for one branch and
53
+ * "not CI" for the other (fail-closed, but with a misleading flag-required
54
+ * error instead of the token hint).
55
+ *
56
+ * F-5ca6c91a: the literal strings 'false' and '0' are explicit OPT-OUTS
57
+ * (a convention many tools respect — `CI=false npm test` is a real idiom),
58
+ * not truthy CI signals. Without this carve-out, a CI=false environment was
59
+ * classified as CI: stub provenance forbidden and the no-flag default
60
+ * demanding a real token — fail-closed, but against the operator's stated
61
+ * intent.
62
+ */
63
+ function isCI() {
64
+ const truthy = (v) => Boolean(v && v !== 'false' && v !== '0');
65
+ return truthy(process.env.CI) || truthy(process.env.GITHUB_ACTIONS);
66
+ }
67
+
47
68
  function resolveProviderProvenance(submission) {
48
69
  const provider = (submission && submission.source && submission.source.provider) || 'github';
49
70
  const factory = provenanceForProvider(provider);
@@ -60,6 +81,71 @@ function resolveProviderProvenance(submission) {
60
81
  return { provenance: factory(token) };
61
82
  }
62
83
 
84
+ /**
85
+ * V2-INVARIAN-004: the ONE definition of "which submissions get a scenario
86
+ * fetcher" (and with it required-steps enforcement). Previously this
87
+ * condition lived inline in the CLI entrypoint where it was untestable
88
+ * without spawning a process.
89
+ *
90
+ * Returns a `githubScenarioFetcher` when ALL hold:
91
+ * - provenance is REAL (not stubProvenance — preview/test runs never hit
92
+ * the GitHub API);
93
+ * - the submission is a plain object with provider `github` (default when
94
+ * `source.provider` is absent) — GitLab has no contents-API adapter yet,
95
+ * so its required_steps gate remains unenforced (documented gap);
96
+ * - `submission.repo` and `submission.ref.commit_sha` are strings (the
97
+ * fetcher re-validates their shapes fail-closed);
98
+ * - a GitHub token is present in `env` (GITHUB_TOKEN, then GH_TOKEN — the
99
+ * same token resolveProviderProvenance validated).
100
+ *
101
+ * Otherwise returns null: verification proceeds without scenario
102
+ * definitions, exactly as before F-3bfc2885 wired the production path.
103
+ *
104
+ * @param {object} provenance - resolved provenance adapter
105
+ * @param {object} submission
106
+ * @param {NodeJS.ProcessEnv} [env]
107
+ * @returns {ReturnType<typeof githubScenarioFetcher> | null}
108
+ */
109
+ export function resolveScenarioFetcher(provenance, submission, env = process.env) {
110
+ return resolveScenarioFetcherDecision(provenance, submission, env).fetcher;
111
+ }
112
+
113
+ /**
114
+ * F-b04473d5: the reason-carrying form of {@link resolveScenarioFetcher}.
115
+ * Every ineligible branch previously returned a silent null — an operator
116
+ * auditing an accepted record could not tell "required_steps enforced and
117
+ * satisfied" from "enforcement skipped (GitLab gap / missing token)" in the
118
+ * run log. The CLI logs the decision as a `scenario_enforcement` NDJSON
119
+ * event; this function is the unit-testable seam behind it.
120
+ *
121
+ * @param {object} provenance
122
+ * @param {object} submission
123
+ * @param {NodeJS.ProcessEnv} [env]
124
+ * @returns {{ fetcher: ReturnType<typeof githubScenarioFetcher> | null,
125
+ * active: boolean, reason: string | null }} `reason` names the skip class
126
+ * when `active` is false; null when a fetcher was constructed.
127
+ */
128
+ export function resolveScenarioFetcherDecision(provenance, submission, env = process.env) {
129
+ const skip = (reason) => ({ fetcher: null, active: false, reason });
130
+ if (provenance === stubProvenance) return skip('stub-provenance');
131
+ if (!submission || typeof submission !== 'object' || Array.isArray(submission)) {
132
+ return skip('submission-malformed');
133
+ }
134
+ if (((submission.source && submission.source.provider) || 'github') !== 'github') {
135
+ // GitLab has no contents-API adapter yet — the documented gap.
136
+ return skip('provider-unsupported');
137
+ }
138
+ if (typeof submission.repo !== 'string') return skip('repo-not-string');
139
+ if (typeof submission.ref?.commit_sha !== 'string') return skip('commit-sha-not-string');
140
+ const token = env.GITHUB_TOKEN || env.GH_TOKEN;
141
+ if (!token) return skip('no-token');
142
+ return {
143
+ fetcher: githubScenarioFetcher(token, submission.repo, submission.ref.commit_sha),
144
+ active: true,
145
+ reason: null
146
+ };
147
+ }
148
+
63
149
  /**
64
150
  * SEED-1 (d3-ingest-003) — posixify a path-shaped value at the operator/log
65
151
  * SERIALIZATION boundary. `computeRecordPath`/`writeRecord` return OS-native
@@ -81,6 +167,50 @@ function posixifyPath(p) {
81
167
  return typeof p === 'string' ? p.split(sep).join('/') : p;
82
168
  }
83
169
 
170
+ /**
171
+ * F-2750c4e8: ceiling on individually-persisted `scenario-load:` rejection
172
+ * reasons. A hostile pre-schema-gate payload can produce up to
173
+ * MAX_DISTINCT_SCENARIO_FETCHES scenario-load errors; persisting one reason
174
+ * line per error bloats the evidence record without adding operator signal
175
+ * (a 1000-error submission has one root cause). Everything past the ceiling
176
+ * collapses into a single count summary.
177
+ */
178
+ const SCENARIO_LOAD_REASON_CEILING = 20;
179
+
180
+ /**
181
+ * Format loadScenarios errors as `scenario-load:` rejection reasons,
182
+ * collapsing past {@link SCENARIO_LOAD_REASON_CEILING}.
183
+ *
184
+ * @param {string[]} scenarioErrors
185
+ * @returns {string[]}
186
+ */
187
+ function scenarioLoadReasons(scenarioErrors) {
188
+ const reasons = scenarioErrors.map(e => `scenario-load: ${e}`);
189
+ if (reasons.length <= SCENARIO_LOAD_REASON_CEILING) return reasons;
190
+ return [
191
+ ...reasons.slice(0, SCENARIO_LOAD_REASON_CEILING),
192
+ `scenario-load: (+${reasons.length - SCENARIO_LOAD_REASON_CEILING} more scenario-load errors collapsed; ${reasons.length} total)`
193
+ ];
194
+ }
195
+
196
+ /**
197
+ * DESIGN RULING (wave 4): loadScenarios' `warnings` (true-404 scenario
198
+ * definitions — nothing declared, nothing to enforce) land on the v1.6.0
199
+ * accepted-with-warning channel (`verification.warnings`), NEVER on
200
+ * rejection_reasons. verify() only sets the field when policy warnings
201
+ * exist, so create-or-append here.
202
+ *
203
+ * @param {object} record - the record returned by verify()
204
+ * @param {string[]} scenarioWarnings
205
+ */
206
+ function appendScenarioWarnings(record, scenarioWarnings) {
207
+ if (!scenarioWarnings || scenarioWarnings.length === 0) return;
208
+ record.verification.warnings = [
209
+ ...(record.verification.warnings || []),
210
+ ...scenarioWarnings
211
+ ];
212
+ }
213
+
84
214
  /**
85
215
  * Emit a single structured stage-transition log line via the shared helper.
86
216
  *
@@ -146,6 +276,27 @@ function resolveCorrelationId(submission) {
146
276
  return synthCorrelationId();
147
277
  }
148
278
 
279
+ /**
280
+ * F-41872706: count how many loaded scenario definitions actually carry an
281
+ * enforceable required_steps gate. `scenarios.size` alone answers "how many
282
+ * definitions were fetched"; this answers "how many of those could fire the
283
+ * step-results-present / step-verdict-consistent reject rules" — a definition
284
+ * with no (or empty) success_criteria.required_steps enforces nothing (see
285
+ * verify/index.js: requiredSteps defaults to [] and the loop no-ops).
286
+ *
287
+ * @param {Map|null} scenarios - the Map returned by loadScenarios, or null
288
+ * @returns {number}
289
+ */
290
+ function countRequiredStepsGates(scenarios) {
291
+ if (!scenarios || typeof scenarios.values !== 'function') return 0;
292
+ let gates = 0;
293
+ for (const definition of scenarios.values()) {
294
+ const steps = definition?.success_criteria?.required_steps;
295
+ if (Array.isArray(steps) && steps.length > 0) gates++;
296
+ }
297
+ return gates;
298
+ }
299
+
149
300
  /**
150
301
  * Run the full ingestion pipeline.
151
302
  *
@@ -226,11 +377,26 @@ export async function ingest(submission, options) {
226
377
  repo_policy_present: !!repoPolicy
227
378
  });
228
379
 
229
- // 3. Load scenario definitions (non-fatal if missing — becomes rejection reason)
380
+ // 3. Load scenario definitions.
381
+ // F-3bfc2885: keep the loaded `scenarios` Map (previously discarded) and hand
382
+ // it to verify() so success_criteria.required_steps is actually enforced.
383
+ // F-efe4f893: gate on Array.isArray — this runs BEFORE the schema gate on
384
+ // untrusted input, and a string/object scenario_results must never reach the
385
+ // fetch loop (a string iterates CHARACTERS). verify() lands the
386
+ // authoritative schema rejection downstream.
387
+ // DESIGN RULING (wave 4): a true-404 definition is a WARNING (nothing
388
+ // declared, nothing to enforce), a malformed committed file is a
389
+ // `scenario-load:` rejection, and V2-CROSS-BO-001 outages (incl. exhausted
390
+ // timeout, F-07ab7f86) throw `scenario-fetch-fault:` PAST this step — the
391
+ // CLI's outer catch emits a structured operational error and exits 2.
230
392
  let scenarioErrors = [];
231
- if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
393
+ let scenarioWarnings = [];
394
+ let scenarios = null;
395
+ if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
232
396
  const result = await loadScenarios(submission, scenarioFetcher);
233
397
  scenarioErrors = result.errors;
398
+ scenarioWarnings = result.warnings || [];
399
+ scenarios = result.scenarios;
234
400
  }
235
401
 
236
402
  // 4. Call verifier — the law engine makes all decisions
@@ -238,7 +404,8 @@ export async function ingest(submission, options) {
238
404
  globalPolicy,
239
405
  repoPolicy,
240
406
  provenance,
241
- policyVersion
407
+ policyVersion,
408
+ scenarios
242
409
  });
243
410
 
244
411
  logStage('verify_complete', {
@@ -246,14 +413,25 @@ export async function ingest(submission, options) {
246
413
  correlation_id,
247
414
  status: record.verification?.status ?? null,
248
415
  rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
249
- verdict: record.overall_verdict?.verified ?? null
416
+ verdict: record.overall_verdict?.verified ?? null,
417
+ // F-41872706: the scenario_enforcement event (CLI wrapper, pre-load) logs
418
+ // scenario_count = how many scenarios the submission DECLARED. These three
419
+ // are the post-load breakdown so an operator can tell "N gates enforced"
420
+ // from "N scenarios, all true-404, enforcement skipped". scenarios_loaded
421
+ // is the Map size (definitions actually fetched); required_steps_gates_fired
422
+ // is the subset that carried a non-empty required_steps.
423
+ scenarios_loaded: scenarios ? scenarios.size : 0,
424
+ required_steps_gates_fired: countRequiredStepsGates(scenarios),
425
+ scenarios_warned: scenarioWarnings.length,
426
+ scenarios_errored: scenarioErrors.length
250
427
  });
251
428
 
252
- // 4b. Append scenario loading errors to rejection reasons if any
429
+ // 4b. Surface scenario loading outcomes: warnings (true-404 definitions) go
430
+ // to verification.warnings; errors (malformed committed files) become
431
+ // scenario-load rejections, collapsed past the reason ceiling (F-2750c4e8).
432
+ appendScenarioWarnings(record, scenarioWarnings);
253
433
  if (scenarioErrors.length > 0) {
254
- record.verification.rejection_reasons.push(
255
- ...scenarioErrors.map(e => `scenario-load: ${e}`)
256
- );
434
+ record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
257
435
  // If scenario loading failed, this is a rejection
258
436
  if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
259
437
  record.verification.status = 'rejected';
@@ -417,11 +595,18 @@ export async function verifyOnly(submission, options) {
417
595
  repo_policy_present: !!repoPolicy
418
596
  });
419
597
 
420
- // 3. Load scenario definitions (non-fatal becomes rejection reason)
598
+ // 3. Load scenario definitions — same gates + semantics as ingest() step 3
599
+ // (F-3bfc2885 Map pass-through, F-efe4f893 Array.isArray gate, wave-4
600
+ // ruling's warning/rejection/operational split), so verify-only and real
601
+ // ingest produce identical records for the same submission.
421
602
  let scenarioErrors = [];
422
- if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
603
+ let scenarioWarnings = [];
604
+ let scenarios = null;
605
+ if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
423
606
  const result = await loadScenarios(submission, scenarioFetcher);
424
607
  scenarioErrors = result.errors;
608
+ scenarioWarnings = result.warnings || [];
609
+ scenarios = result.scenarios;
425
610
  }
426
611
 
427
612
  // 4. Call verifier
@@ -429,7 +614,8 @@ export async function verifyOnly(submission, options) {
429
614
  globalPolicy,
430
615
  repoPolicy,
431
616
  provenance,
432
- policyVersion
617
+ policyVersion,
618
+ scenarios
433
619
  });
434
620
 
435
621
  logStage('verify_complete', {
@@ -437,15 +623,21 @@ export async function verifyOnly(submission, options) {
437
623
  correlation_id,
438
624
  status: record.verification?.status ?? null,
439
625
  rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
440
- verdict: record.overall_verdict?.verified ?? null
626
+ verdict: record.overall_verdict?.verified ?? null,
627
+ // F-41872706: same post-load scenario breakdown as ingest()'s
628
+ // verify_complete, so verify-only and real ingest produce identical NDJSON
629
+ // observability for the same submission.
630
+ scenarios_loaded: scenarios ? scenarios.size : 0,
631
+ required_steps_gates_fired: countRequiredStepsGates(scenarios),
632
+ scenarios_warned: scenarioWarnings.length,
633
+ scenarios_errored: scenarioErrors.length
441
634
  });
442
635
 
443
636
  // 4b. Mirror ingest's scenario-error verdict downgrade so verify-only and
444
637
  // real ingest produce identical records for the same submission.
638
+ appendScenarioWarnings(record, scenarioWarnings);
445
639
  if (scenarioErrors.length > 0) {
446
- record.verification.rejection_reasons.push(
447
- ...scenarioErrors.map(e => `scenario-load: ${e}`)
448
- );
640
+ record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
449
641
  if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
450
642
  record.verification.status = 'rejected';
451
643
  record.verification.policy_valid = false;
@@ -521,6 +713,43 @@ function emitCliErrorEvent({ failedStage, correlationId, submissionId = null, er
521
713
  // --- CLI entrypoint ---
522
714
  // When run directly, reads submission from stdin or file argument
523
715
 
716
+ // F-9a65b10c (Stage C humanization): the write-path CLI ships a USAGE block like
717
+ // every other bin (verify/cli.js, report/cli.js, portfolio/generate.js). This is
718
+ // the only operator-facing entry point that writes records, so its reference has
719
+ // to live IN the tool, not scattered across docs.
720
+ const USAGE = `ingest — persist a dogfood submission (verify → policy → provenance → write)
721
+
722
+ USAGE:
723
+ node packages/ingest/run.js --file <path> --provenance=github|stub
724
+ node packages/ingest/run.js --payload '<json>' --provenance=github|stub
725
+ echo '<json>' | node packages/ingest/run.js --provenance=github|stub
726
+
727
+ INPUT (exactly one; stdin used when neither flag is given):
728
+ --file <path> Read the submission JSON from a file.
729
+ --payload <json> Pass the submission JSON inline.
730
+ (stdin) Pipe the submission JSON on stdin.
731
+
732
+ PROVENANCE (required for an ingest):
733
+ --provenance=github Confirm the source run via the GitHub API.
734
+ --provenance=stub No-network local confirm (dry-run / dev only).
735
+
736
+ MODES:
737
+ --verify-only Run the full pipeline WITHOUT writing or rebuilding
738
+ indexes; report where a real ingest WOULD have landed.
739
+
740
+ STANDALONE AUDIT VERBS (no submission, no stdin, no --provenance):
741
+ --verify-chain Verify the append-only integrity ledger (offline).
742
+ --reconcile Also fail on on-disk records missing from the ledger.
743
+ --all Report every independent break instead of the first.
744
+ --anchor-compute Compute + write the next XRPL anchor manifest (offline).
745
+ --anchor-post Compute if needed + post the anchor to XRPL (needs XRPL_SEED).
746
+ --anchor-verify Verify local manifests + run the truncation check (offline).
747
+
748
+ -h, --help Show this help.
749
+
750
+ EXIT CODES:
751
+ 0 success 1 integrity/audit break 2 operator error (flags / IO / JSON)`;
752
+
524
753
  const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'run.js');
525
754
 
526
755
  if (isMain) {
@@ -551,6 +780,7 @@ if (isMain) {
551
780
  // per-record-independent break and report every break in one pass.
552
781
  let reconcileFlag = false;
553
782
  let allBreaksFlag = false;
783
+ let helpFlag = false;
554
784
  // Anchor verbs (optional, off-by-default, operator-run). --anchor-compute and
555
785
  // --anchor-verify are fully offline (never import xrpl); --anchor-post lazily
556
786
  // loads the optional xrpl package and needs XRPL_SEED.
@@ -657,11 +887,20 @@ if (isMain) {
657
887
  } else if (arg === '--anchor-trusted' && hasValue) {
658
888
  // Comma-separated trusted anchor accounts (UNIONed with the bundled list).
659
889
  anchorTrustedAccounts = takeValue().split(',').map((s) => s.trim()).filter(Boolean);
890
+ } else if (arg === '-h' || arg === '--help' || arg === '--usage') {
891
+ helpFlag = true;
660
892
  } else {
661
893
  positionalArgs.push(args[i]);
662
894
  }
663
895
  }
664
896
 
897
+ // F-9a65b10c: print USAGE and exit 0 before any input resolution — help must
898
+ // never block on stdin or demand a --provenance flag.
899
+ if (helpFlag) {
900
+ console.log(USAGE);
901
+ process.exit(0);
902
+ }
903
+
665
904
  // --verify-chain is a standalone, side-effect-free audit: it reads only the
666
905
  // ledger + the record files it references, takes no submission, reads no
667
906
  // stdin, and needs no provenance adapter. Handle it BEFORE the stdin read and
@@ -744,6 +983,19 @@ if (isMain) {
744
983
  }
745
984
 
746
985
  if (!submissionJson) {
986
+ // F-aa67ea9a (Stage C humanization): guard an interactive stdin. Without a
987
+ // piped payload, `for await (…process.stdin)` blocks forever at a TTY —
988
+ // exactly what a first-run operator who forgot to pipe input types — and
989
+ // looks like a hang. Fail fast with the same empty-state error verify/cli.js
990
+ // gives, naming the concrete input levers.
991
+ if (process.stdin.isTTY) {
992
+ console.error('ERROR: no submission provided');
993
+ console.error(
994
+ " hint: pass --file <path> or --payload '<json>', or pipe JSON on stdin; " +
995
+ 'run --help for usage.'
996
+ );
997
+ process.exit(2);
998
+ }
747
999
  // Read from stdin
748
1000
  const chunks = [];
749
1001
  for await (const chunk of process.stdin) {
@@ -791,7 +1043,7 @@ if (isMain) {
791
1043
  let provenance;
792
1044
  if (provenanceMode === 'stub') {
793
1045
  // Structural anti-misuse: stub only allowed outside CI
794
- if (process.env.CI || process.env.GITHUB_ACTIONS) {
1046
+ if (isCI()) {
795
1047
  emitCliErrorEvent({
796
1048
  failedStage: 'cli_provenance_resolve',
797
1049
  correlationId: cliCorrelationId,
@@ -819,7 +1071,7 @@ if (isMain) {
819
1071
  process.exit(2);
820
1072
  }
821
1073
  provenance = resolved.provenance;
822
- } else if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
1074
+ } else if (isCI()) {
823
1075
  // In CI without an explicit flag: default to real provenance, routed by the
824
1076
  // submission's source.provider (github | gitlab).
825
1077
  const resolved = resolveProviderProvenance(submission);
@@ -845,6 +1097,34 @@ if (isMain) {
845
1097
  process.exit(2);
846
1098
  }
847
1099
 
1100
+ // F-3bfc2885: construct a scenario fetcher for the production path. Before
1101
+ // this, the CLI never passed one, so scenario definitions — and with them the
1102
+ // `step-results-present` / `step-verdict-consistent` required_steps gates —
1103
+ // were test-only: an operator-declared severity:reject rule silently passed.
1104
+ // Real (non-stub) provenance + a github-provider submission loads scenario
1105
+ // definitions from the source repo at the persisted commit, reusing the token
1106
+ // resolveProviderProvenance already validated. GitLab submissions have no
1107
+ // scenario fetcher yet (no gitlab contents-API adapter exists); their
1108
+ // required_steps gate remains unenforced — a known, documented gap.
1109
+ // V2-INVARIAN-004: the eligibility condition is the exported, unit-tested
1110
+ // resolveScenarioFetcherDecision above.
1111
+ // F-b04473d5: log the decision — an operator auditing an accepted record
1112
+ // must be able to tell "required_steps enforced" from "enforcement skipped
1113
+ // (and why)" straight from the NDJSON stream.
1114
+ const scenarioDecision = resolveScenarioFetcherDecision(provenance, submission, process.env);
1115
+ const scenarioFetcher = scenarioDecision.fetcher;
1116
+ logStage('scenario_enforcement', {
1117
+ submission_id: submission && typeof submission === 'object' && !Array.isArray(submission)
1118
+ ? (submission.run_id || null)
1119
+ : null,
1120
+ correlation_id: cliCorrelationId,
1121
+ active: scenarioDecision.active,
1122
+ ...(scenarioDecision.reason ? { reason: scenarioDecision.reason } : {}),
1123
+ scenario_count: submission && typeof submission === 'object' && Array.isArray(submission.scenario_results)
1124
+ ? submission.scenario_results.length
1125
+ : 0
1126
+ });
1127
+
848
1128
  // D1B-001: track the last successful pipeline stage so the outer catch
849
1129
  // can surface a useful `failed_stage` in its structured error event.
850
1130
  // We update it once the verify/ingest call has RETURNED — anything
@@ -856,7 +1136,7 @@ if (isMain) {
856
1136
  // when they could.
857
1137
  try {
858
1138
  if (verifyOnlyFlag) {
859
- const result = await verifyOnly(submission, { repoRoot, provenance });
1139
+ const result = await verifyOnly(submission, { repoRoot, provenance, scenarioFetcher });
860
1140
  lastSuccessfulStage = 'verify_only';
861
1141
 
862
1142
  console.log(JSON.stringify({
@@ -877,7 +1157,7 @@ if (isMain) {
877
1157
  process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
878
1158
  }
879
1159
 
880
- const result = await ingest(submission, { repoRoot, provenance });
1160
+ const result = await ingest(submission, { repoRoot, provenance, scenarioFetcher });
881
1161
  lastSuccessfulStage = 'ingest';
882
1162
 
883
1163
  if (result.duplicate) {