@dogfood-lab/verify 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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Policy lint (VERIFY-F3)
3
+ *
4
+ * `lintPolicy(policyDoc, { origin })` is the author-time check behind the
5
+ * `dogfood-verify lint` verb: it validates a whole policy file WITHOUT a submission,
6
+ * batch-reporting every fault. It is the `opa check` analogue named (as a deferred
7
+ * companion) in docs/policy-dsl.md and specified in docs/policy-lint.md.
8
+ *
9
+ * Three passes, mirroring the runtime's own layering:
10
+ * 1. Structural gate — `validatePayload('policy', …)` against policy.schema.json (the same
11
+ * gate loadGlobalPolicy / loadRepoPolicy run). Catches unknown op, malformed/banned field,
12
+ * mixed node, value-arity, custom_rules-under-defaults, additionalProperties.
13
+ * 2. Static predicate walk — the data-independent semantic faults the schema cannot express:
14
+ * unknown leading field, combinator over-depth, node budget (via predicate.js#lintPredicate).
15
+ * 3. `[]`-footgun advisory — a deterministic warning (never an error) on a negative op over a
16
+ * `[]` path (via predicate.js#findEmptyArrayFootguns).
17
+ *
18
+ * Coverage boundary (the VERIFY-F2 over-claim lesson): `type_mismatch` and `fanout_budget` are
19
+ * DATA-DEPENDENT and cannot be caught statically. `coverageNote` says so — a clean lint is "no
20
+ * static fault and no footgun," NOT "this policy can never produce a policy-config: rejection."
21
+ */
22
+
23
+ import { validatePayload } from '@dogfood-lab/schemas';
24
+ import { lintPredicate, findEmptyArrayFootguns } from './predicate.js';
25
+
26
+ /** Stated in every lint result so a clean verdict is never read as full coverage. */
27
+ export const COVERAGE_NOTE =
28
+ 'Static lint only — it cannot catch a `type_mismatch` (a numeric op over a non-number field) ' +
29
+ 'or a `fanout_budget` overrun; both depend on submission data. Run ' +
30
+ '`dogfood-verify --file <submission> --explain` to exercise the data-dependent path.';
31
+
32
+ /**
33
+ * Lint a parsed policy document.
34
+ *
35
+ * @param {unknown} policyDoc - The parsed policy YAML/JSON (any value; a non-object is reported
36
+ * by the schema gate).
37
+ * @param {{ origin?: 'global'|'repo'|'unknown' }} [opts] - The policy's origin, used only for
38
+ * reporting (the caller derives it from the file path).
39
+ * @returns {{
40
+ * ok: boolean, origin: string, coverageNote: string,
41
+ * errors: { label: string, code: string, location: string, field?: string, message: string }[],
42
+ * warnings: { label: string, code: string, location: string, field: string, suggestion: string, message: string }[]
43
+ * }} `ok` is true iff there are no errors; warnings (footguns) never affect `ok`.
44
+ */
45
+ export function lintPolicy(policyDoc, { origin = 'unknown' } = {}) {
46
+ const errors = [];
47
+ const warnings = [];
48
+
49
+ // 1. Structural schema gate.
50
+ const schema = validatePayload('policy', policyDoc);
51
+ for (const e of schema.errors) {
52
+ errors.push({
53
+ label: 'policy-schema:',
54
+ code: e.keyword || 'schema',
55
+ location: e.path || '/',
56
+ message: e.message || 'schema violation',
57
+ });
58
+ }
59
+
60
+ // 2 + 3. Static predicate walk + footgun advisory over every `when` location. Defensive: a
61
+ // schema-invalid doc may have malformed predicates; the walkers tolerate non-objects.
62
+ for (const { when, scope, location } of collectPredicates(policyDoc)) {
63
+ for (const f of lintPredicate(when, scope)) {
64
+ errors.push({ label: 'policy-config:', code: f.code, location, field: f.field, message: f.message });
65
+ }
66
+ for (const g of findEmptyArrayFootguns(when)) {
67
+ warnings.push({
68
+ label: 'policy-footgun:',
69
+ code: 'footgun-empty-array',
70
+ location,
71
+ field: g.field,
72
+ suggestion: g.suggestion,
73
+ message:
74
+ `negative operator "${g.op}" over the array path "${g.field}" fails OPEN on an empty or ` +
75
+ `absent array — the rule silently does not fire when there are no elements`,
76
+ });
77
+ }
78
+ }
79
+
80
+ return { ok: errors.length === 0, origin, errors, warnings, coverageNote: COVERAGE_NOTE };
81
+ }
82
+
83
+ /**
84
+ * Enumerate every `when` predicate in a policy with its evaluation scope and an operator-readable
85
+ * location. Walks the SAME two locations the runtime evaluates (validators/policy.js):
86
+ * - `global_rules[].when` at scope `rule.scope || 'submission'`
87
+ * - `surfaces.<surface>.custom_rules[].when` at scope `scenario_result`
88
+ * `custom_rules` under `defaults` is schema-forbidden, so it is not walked here — the structural
89
+ * gate already reports it if present (matching the runtime, which never evaluates it).
90
+ */
91
+ function* collectPredicates(policyDoc) {
92
+ if (policyDoc === null || typeof policyDoc !== 'object') return;
93
+
94
+ const globalRules = Array.isArray(policyDoc.global_rules) ? policyDoc.global_rules : [];
95
+ for (let i = 0; i < globalRules.length; i++) {
96
+ const rule = globalRules[i];
97
+ if (rule && typeof rule === 'object' && rule.when != null) {
98
+ yield {
99
+ when: rule.when,
100
+ scope: rule.scope || 'submission',
101
+ location: `global_rules[${i}]${rule.id ? ` (${rule.id})` : ''}`,
102
+ };
103
+ }
104
+ }
105
+
106
+ const surfaces = policyDoc.surfaces && typeof policyDoc.surfaces === 'object' ? policyDoc.surfaces : {};
107
+ for (const [surface, sp] of Object.entries(surfaces)) {
108
+ const customRules = sp && Array.isArray(sp.custom_rules) ? sp.custom_rules : [];
109
+ for (let i = 0; i < customRules.length; i++) {
110
+ const rule = customRules[i];
111
+ if (rule && typeof rule === 'object' && rule.when != null) {
112
+ yield {
113
+ when: rule.when,
114
+ scope: 'scenario_result',
115
+ location: `surfaces.${surface}.custom_rules[${i}]${rule.id ? ` (${rule.id})` : ''}`,
116
+ };
117
+ }
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Scenario lint (F-BACKEND-003)
3
+ *
4
+ * `lintScenario(scenarioDoc, { file })` is the author-time check behind the
5
+ * `dogfood-verify lint --scenario <file>` mode: it validates a whole scenario
6
+ * definition WITHOUT a submission, batch-reporting every fault. It is the
7
+ * scenario-side sibling of lint-policy.js (VERIFY-F3) and shares its shape,
8
+ * exit contract, and honest-coverage discipline.
9
+ *
10
+ * Three passes, mirroring lint-policy's layering:
11
+ * 1. Structural gate — `validatePayload('scenario', …)` against scenario.schema.json
12
+ * (the same registered schema production ingest uses to fetch + validate a
13
+ * committed scenario). Catches missing required fields, bad enums, pattern
14
+ * violations, additionalProperties, the verifiable⇒expected conditional.
15
+ * 2. Author-time value checks the schema cannot express (errors, `scenario-config:`):
16
+ * required_steps referencing an undeclared step id, and duplicate step ids.
17
+ * The scenario schema literally says required_steps "Must reference valid step
18
+ * IDs" but JSON Schema cannot enforce a cross-field id reference, and it
19
+ * validates each step in isolation so it cannot see a repeated id.
20
+ * 3. `filename → scenario_id` advisory (a WARNING, never an error,
21
+ * `scenario-footgun:`): the receiver fetches dogfood/scenarios/<scenario_id>.yaml,
22
+ * so a basename that differs from scenario_id makes the committed definition
23
+ * unreachable and required-steps enforcement silently fails open.
24
+ *
25
+ * Coverage boundary (the VERIFY-F2 over-claim lesson): a clean scenario lint is
26
+ * "no static fault," NOT "a real submission will satisfy this scenario." Static
27
+ * lint validates the definition in isolation; it cannot verify that a submission's
28
+ * step_results actually satisfy required_steps, nor that the receiver can reach the
29
+ * file at the attested commit. `coverageNote` says so.
30
+ */
31
+
32
+ import { basename } from 'node:path';
33
+
34
+ import { validatePayload } from '@dogfood-lab/schemas';
35
+
36
+ /** Stated in every scenario lint result so a clean verdict is never read as full coverage. */
37
+ export const SCENARIO_COVERAGE_NOTE =
38
+ 'Static lint only — it validates the scenario DEFINITION in isolation. It cannot verify that a ' +
39
+ "real submission's `step_results` actually satisfy `required_steps`, nor that the receiver can " +
40
+ 'fetch this file at the attested commit (the filename → scenario_id check is a heuristic, not a ' +
41
+ 'guarantee). Run a real ingest of a submission for that.';
42
+
43
+ /**
44
+ * Lint a parsed scenario document.
45
+ *
46
+ * @param {unknown} scenarioDoc - The parsed scenario YAML/JSON (any value; a non-object is
47
+ * reported by the schema gate).
48
+ * @param {{ file?: string }} [opts] - The source file path (basename → scenario_id advisory).
49
+ * When absent, the filename check is skipped (there is no basename to compare).
50
+ * @returns {{
51
+ * ok: boolean, origin: 'scenario', coverageNote: string,
52
+ * errors: { label: string, code: string, location: string, field?: string, message: string }[],
53
+ * warnings: { label: string, code: string, location: string, field: string, suggestion: string, message: string }[]
54
+ * }} `ok` is true iff there are no errors; warnings never affect `ok`.
55
+ */
56
+ export function lintScenario(scenarioDoc, { file } = {}) {
57
+ const errors = [];
58
+ const warnings = [];
59
+
60
+ // 1. Structural schema gate.
61
+ const schema = validatePayload('scenario', scenarioDoc);
62
+ for (const e of schema.errors) {
63
+ errors.push({
64
+ label: 'scenario-schema:',
65
+ code: e.keyword || 'schema',
66
+ location: e.path || '/',
67
+ message: e.message || 'schema violation',
68
+ });
69
+ }
70
+
71
+ // 2. Author-time value checks. Defensive: a schema-invalid doc may have a malformed or
72
+ // missing steps/success_criteria, so guard every access against non-arrays/non-objects
73
+ // (mirrors collectPredicates' defensiveness in lint-policy.js).
74
+ const doc = scenarioDoc && typeof scenarioDoc === 'object' ? scenarioDoc : {};
75
+ const steps = Array.isArray(doc.steps) ? doc.steps : [];
76
+
77
+ // Collect declared step ids, flagging duplicates as we go. A repeated id validates clean
78
+ // structurally (the schema checks each step in isolation) but is ambiguous at runtime: a
79
+ // step_result keyed by that id cannot say which step it satisfied.
80
+ const declaredIds = new Set();
81
+ const seenIds = new Set();
82
+ for (let i = 0; i < steps.length; i++) {
83
+ const step = steps[i];
84
+ if (!step || typeof step !== 'object') continue;
85
+ const id = step.id;
86
+ if (typeof id !== 'string' || id.length === 0) continue;
87
+ declaredIds.add(id);
88
+ if (seenIds.has(id)) {
89
+ errors.push({
90
+ label: 'scenario-config:',
91
+ code: 'duplicate_step_id',
92
+ location: `steps[${i}]`,
93
+ field: id,
94
+ message: `step id "${id}" is declared more than once — step ids must be unique so a step_result can name exactly one step`,
95
+ });
96
+ }
97
+ seenIds.add(id);
98
+ }
99
+
100
+ // required_steps must reference a declared step id. The scenario schema says so in prose but
101
+ // cannot enforce a cross-field id reference.
102
+ const sc = doc.success_criteria && typeof doc.success_criteria === 'object' ? doc.success_criteria : {};
103
+ const requiredSteps = Array.isArray(sc.required_steps) ? sc.required_steps : [];
104
+ for (let i = 0; i < requiredSteps.length; i++) {
105
+ const ref = requiredSteps[i];
106
+ if (typeof ref !== 'string') continue; // a non-string entry is a schema fault, already reported above
107
+ if (!declaredIds.has(ref)) {
108
+ errors.push({
109
+ label: 'scenario-config:',
110
+ code: 'required_step_undeclared',
111
+ location: `success_criteria.required_steps[${i}]`,
112
+ field: ref,
113
+ message: `required step "${ref}" is not declared by any steps[].id — the scenario can never pass because the receiver enforces a step the exercise never runs`,
114
+ });
115
+ }
116
+ }
117
+
118
+ // 3. filename → scenario_id advisory. The receiver fetches dogfood/scenarios/<scenario_id>.yaml,
119
+ // so a mismatch makes the committed definition unreachable and required-steps enforcement fails
120
+ // OPEN silently. Advisory only — a repo may legitimately hold a scenario under a different name
121
+ // during authoring (e.g. examples/scenario.example.yaml holds scenario_id "cli-smoke").
122
+ const scenarioId = doc.scenario_id;
123
+ if (file && typeof scenarioId === 'string' && scenarioId.length > 0) {
124
+ const base = basename(String(file)).replace(/\.ya?ml$/i, '');
125
+ if (base !== scenarioId) {
126
+ warnings.push({
127
+ label: 'scenario-footgun:',
128
+ code: 'filename_scenario_id_mismatch',
129
+ location: '/',
130
+ field: 'scenario_id',
131
+ suggestion: `rename the file to "${scenarioId}.yaml" (or change scenario_id to "${base}")`,
132
+ message:
133
+ `the file basename "${base}" does not match scenario_id "${scenarioId}" — the receiver ` +
134
+ `fetches dogfood/scenarios/${scenarioId}.yaml, so this committed definition is unreachable ` +
135
+ `and required-steps enforcement silently fails OPEN`,
136
+ });
137
+ }
138
+ }
139
+
140
+ return { ok: errors.length === 0, origin: 'scenario', errors, warnings, coverageNote: SCENARIO_COVERAGE_NOTE };
141
+ }
@@ -50,6 +50,11 @@ const KNOWN_REJECT_RULE_IDS = new Set([
50
50
  // enforced by other validators or by verify() itself (default-arm no-op is correct)
51
51
  'schema-valid',
52
52
  'provenance-confirmed',
53
+ // F-3bfc2885: enforced by validateRequiredSteps (validators/steps.js), which
54
+ // verify() runs per scenario_result when the ingest layer supplies loaded
55
+ // scenario definitions (options.scenarios). The structural half (non-empty
56
+ // step_results, dup ids, pass-vs-fail over REPORTED steps) is
57
+ // validateStepResults.
53
58
  'step-results-present',
54
59
  'step-verdict-consistent',
55
60
  'no-verdict-upgrade',
@@ -155,7 +160,10 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
155
160
  // dropping operator-authored rules on the floor: a declared rule that the
156
161
  // build does nothing with is invisible to the operator who wrote it.
157
162
  if (rule.severity === 'warn') {
158
- warnings.push(`${rule.id}: ${rule.description || 'policy warning'}`);
163
+ // F-57a0c0ad: bracketed `[id]` form — the same shape buildReason gives
164
+ // declarative rules — so one grep pattern finds every rule-attributed
165
+ // message regardless of how the rule is enforced.
166
+ warnings.push(`[${rule.id}] ${rule.description || 'policy warning'}`);
159
167
  continue;
160
168
  }
161
169
  if (rule.severity === 'info') {
@@ -134,16 +134,30 @@ function resolvePath(root, segments) {
134
134
  return frontier;
135
135
  }
136
136
 
137
- /** Validate the leading field segment against the scope's known-field set. */
138
- function checkLeadingField(field, scope) {
137
+ /**
138
+ * Decide whether a field's LEADING segment is unknown for a scope, returning the
139
+ * fault rather than throwing it. Factored out so the eval path ({@link checkLeadingField},
140
+ * fail-on-first) and the lint path ({@link lintPredicate}, batch-collect) share one
141
+ * source of truth — a divergence between author-time and runtime diagnostics is
142
+ * impossible by construction. Data-INDEPENDENT (the known-field set is derived from
143
+ * the schema), so it is exactly the kind of check the lint can run without a submission.
144
+ */
145
+ function leadingFieldFault(field, scope) {
139
146
  const leading = String(field).split('.')[0].replace('[]', '');
140
147
  const known = KNOWN_FIELDS[scope];
141
148
  if (known && !known.has(leading)) {
142
- throw new PredicateError(
149
+ return new PredicateError(
143
150
  'unknown_field',
144
151
  `field "${field}" references unknown leading field "${leading}" (known ${scope} fields: ${[...known].join(', ')})`
145
152
  );
146
153
  }
154
+ return null;
155
+ }
156
+
157
+ /** Validate the leading field segment against the scope's known-field set (eval path: throws). */
158
+ function checkLeadingField(field, scope) {
159
+ const fault = leadingFieldFault(field, scope);
160
+ if (fault) throw fault;
147
161
  }
148
162
 
149
163
  /** Apply one operator to a single resolved value + comparand. */
@@ -287,3 +301,149 @@ export function buildReason(rule, root) {
287
301
  : (rule.description ?? '');
288
302
  return `[${rule.id}] ${body}`;
289
303
  }
304
+
305
+ /* ───────────────────────── Static analysis (VERIFY-F3 policy-lint) ─────────────────────────
306
+ *
307
+ * The two functions below analyze a predicate AST WITHOUT a submission. They are the reusable
308
+ * leaf the `policy-lint` verb (and any future eager-validation path) builds on. Co-located here,
309
+ * not in a separate module, so they reuse this file's private engine internals (KNOWN_FIELDS via
310
+ * leadingFieldFault, isCombinator, the depth/node-budget constants) without exporting the guts —
311
+ * predicate.js owns *everything about a predicate node*: eval AND static analysis.
312
+ *
313
+ * Coverage boundary (the VERIFY-F2 over-claim lesson — see docs/policy-lint.md):
314
+ * - DATA-INDEPENDENT, caught here: unknown leading field, combinator over-depth, node budget.
315
+ * - STRUCTURAL (unknown op, banned/malformed segment, arity): caught earlier by the schema gate
316
+ * (`validatePayload('policy', …)`), so lintPredicate does not re-report them.
317
+ * - DATA-DEPENDENT, NOT catchable statically: `type_mismatch` (a numeric op over a non-number)
318
+ * and `fanout_budget` (a `[]` selection size) — both need a real submission. The lint says so.
319
+ */
320
+
321
+ /** A static lint finding: a machine `code` (mirrors {@link PredicateError} codes) + an operator message. */
322
+
323
+ /**
324
+ * Statically lint a predicate tree against one scope, batch-collecting the data-independent
325
+ * semantic faults the schema cannot express. Unlike {@link evaluatePredicate} this NEVER throws —
326
+ * a lint reports every fault rather than failing on the first — and it is DEFENSIVE: a malformed
327
+ * node (already flagged by the schema gate) is skipped, not crashed on. The walk mirrors the
328
+ * evaluator's own depth + node accounting (same constants), so a lint verdict agrees with what the
329
+ * engine would do at eval time, and the walk is itself bounded (it cannot become a DoS on a
330
+ * pathological policy file).
331
+ *
332
+ * @param {unknown} node - The predicate tree (any value; non-objects are skipped).
333
+ * @param {'submission'|'scenario_result'} scope - Field-resolution scope.
334
+ * @returns {{ code: string, field?: string, message: string }[]}
335
+ */
336
+ export function lintPredicate(node, scope) {
337
+ const findings = [];
338
+ walkLint(node, scope, 1, { nodes: 0, depthReported: false, budgetReported: false }, findings);
339
+ return findings;
340
+ }
341
+
342
+ function walkLint(node, scope, depth, state, findings) {
343
+ if (state.budgetReported) return;
344
+ if (++state.nodes > PREDICATE_MAX_NODES) {
345
+ if (!state.budgetReported) {
346
+ state.budgetReported = true;
347
+ findings.push({
348
+ code: 'node_budget',
349
+ message: `predicate exceeds the evaluation budget of ${PREDICATE_MAX_NODES} nodes`,
350
+ });
351
+ }
352
+ return;
353
+ }
354
+ if (node === null || typeof node !== 'object') return;
355
+
356
+ if (isCombinator(node)) {
357
+ if (depth > PREDICATE_MAX_DEPTH) {
358
+ // Mirror the evaluator, which throws when a combinator is reached past the cap; report once
359
+ // and stop descending this branch (siblings are still walked — batch reporting).
360
+ if (!state.depthReported) {
361
+ state.depthReported = true;
362
+ findings.push({
363
+ code: 'max_depth',
364
+ message: `predicate nests deeper than the limit of ${PREDICATE_MAX_DEPTH} combinator levels`,
365
+ });
366
+ }
367
+ return;
368
+ }
369
+ const children =
370
+ Array.isArray(node.all) ? node.all :
371
+ Array.isArray(node.any) ? node.any :
372
+ node.not != null ? [node.not] :
373
+ Array.isArray(node.implies) ? node.implies : [];
374
+ for (const child of children) walkLint(child, scope, depth + 1, state, findings);
375
+ return;
376
+ }
377
+
378
+ // Leaf: the only data-independent semantic check is the unknown leading field. The op set and
379
+ // path shape are schema-gated; a numeric-op type mismatch is data-dependent (not checkable here).
380
+ if (typeof node.field === 'string') {
381
+ const fault = leadingFieldFault(node.field, scope);
382
+ if (fault) findings.push({ code: fault.code, field: node.field, message: fault.message });
383
+ }
384
+ }
385
+
386
+ /** Negative operators that fail OPEN over an empty/absent `[]` selection (the footgun). */
387
+ const NEGATIVE_OPS = new Set(['not_equals', 'not_in', 'not_contains', 'not_exists']);
388
+
389
+ /** The positive counterpart used in the fail-closed `not(any(...))` rewrite suggestion. */
390
+ const POSITIVE_OF = { not_equals: 'equals', not_in: 'in', not_contains: 'contains', not_exists: 'exists' };
391
+
392
+ /** Build the deterministic, AST-derived fail-closed rewrite suggestion for a footgun leaf. */
393
+ function footgunSuggestion(field, op) {
394
+ const pos = POSITIVE_OF[op] || 'contains';
395
+ const valuePart = pos === 'exists' ? ' ' : ', value: … ';
396
+ return `if you meant "reject when no element satisfies it", use the fail-closed idiom ` +
397
+ `{ not: { any: [ { field: "${field}", op: ${pos}${valuePart}} ] } }`;
398
+ }
399
+
400
+ /**
401
+ * Find `[]`-footgun leaves: a NEGATIVE operator over a `[]` field path, which fails OPEN on an
402
+ * empty/absent array (existential vacuous truth — see docs/policy-lint.md). ADVISORY only: there
403
+ * are legitimate existential-negatives, so the caller surfaces these as warnings the author
404
+ * confirms, never as hard errors, and never auto-applies the rewrite.
405
+ *
406
+ * Suppression by negation PARITY, not a bare "is there a `not` above me" flag. A leaf inverted an
407
+ * EVEN number of times (0, 2, …) still fails open and is flagged; an ODD number of inversions makes
408
+ * it fail CLOSED, so it is suppressed. Two sources of inversion are counted: a `not` combinator,
409
+ * and the CONSEQUENT (second element) of an `implies` (since `implies:[A,C]` desugars to the
410
+ * violation `all(A, not(C))`). This refinement came from the VERIFY-F3 cross-family adversarial
411
+ * jury (deepseek-v4-pro / glm-5.2 / minimax-m3, 2026-06-30), which converged on two real defects in
412
+ * the original boolean rule: `not(not(X))` over `[]` fails open but was suppressed (false negative),
413
+ * and a negative-op consequent of `implies` fails closed but was flagged (false positive). Parity
414
+ * fixes both. Scope stays NEGATIVE-ops-only and ADVISORY (the jury's "flag every op" and "make it a
415
+ * hard error" suggestions were rejected — the former floods noise on the normal `contains` idiom,
416
+ * the latter blocks legitimate existential-negatives). False positives remain acceptable by design;
417
+ * a false negative (a silent production fail-open) is the expensive failure, so it errs toward warning.
418
+ *
419
+ * @param {unknown} node - The predicate tree.
420
+ * @returns {{ field: string, op: string, suggestion: string }[]}
421
+ */
422
+ export function findEmptyArrayFootguns(node) {
423
+ const guns = [];
424
+ walkFootgun(node, false, { nodes: 0 }, guns);
425
+ return guns;
426
+ }
427
+
428
+ function walkFootgun(node, inverted, state, guns) {
429
+ if (node === null || typeof node !== 'object') return;
430
+ if (++state.nodes > PREDICATE_MAX_NODES) return; // bounded; lintPredicate reports node_budget
431
+
432
+ if (isCombinator(node)) {
433
+ if (Array.isArray(node.all)) { for (const c of node.all) walkFootgun(c, inverted, state, guns); return; }
434
+ if (Array.isArray(node.any)) { for (const c of node.any) walkFootgun(c, inverted, state, guns); return; }
435
+ if (node.not != null) { walkFootgun(node.not, !inverted, state, guns); return; }
436
+ if (Array.isArray(node.implies)) {
437
+ // implies:[A, C] === all(A, not(C)) — only the consequent (index 1) flips parity.
438
+ node.implies.forEach((c, i) => walkFootgun(c, i === 1 ? !inverted : inverted, state, guns));
439
+ return;
440
+ }
441
+ return;
442
+ }
443
+
444
+ if (!inverted &&
445
+ typeof node.op === 'string' && NEGATIVE_OPS.has(node.op) &&
446
+ typeof node.field === 'string' && node.field.includes('[]')) {
447
+ guns.push({ field: node.field, op: node.op, suggestion: footgunSuggestion(node.field, node.op) });
448
+ }
449
+ }
@@ -261,14 +261,21 @@ describe('gitlabProvenance fetch timeout', () => {
261
261
  assert.ok(Date.now() - start < 5000, 'expected fast abort');
262
262
  });
263
263
 
264
- it('returns false (not throws) on non-AbortError transport failures', async () => {
264
+ it('throws provenance: network error on persistent transport failures (F-dac7e08c)', async () => {
265
+ // Pre-F-dac7e08c this pinned `return false` — which classified a network
266
+ // outage as submission-bad and permanently persisted a rejected record.
267
+ // The contract is now: retry within budget, then THROW (operational).
265
268
  const failingFetch = async () => { throw new Error('connection refused'); };
266
269
  const adapter = gitlabProvenance('token', {
267
270
  timeoutMs: 1000,
271
+ retries: 1,
272
+ sleepImpl: async () => {},
268
273
  fetchImpl: failingFetch
269
274
  });
270
- const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
271
- assert.equal(ok, false);
275
+ await assert.rejects(
276
+ adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
277
+ /provenance: network error: connection refused/
278
+ );
272
279
  });
273
280
  });
274
281
 
@@ -60,13 +60,27 @@ function isRetryableStatus(status) {
60
60
  return status === 429 || (status >= 500 && status <= 599);
61
61
  }
62
62
 
63
+ /**
64
+ * F-5fd3f832: ceiling on a provider-sent Retry-After wait. The per-request
65
+ * AbortController timeout bounds each REQUEST but not the inter-attempt sleep
66
+ * — without this clamp, one hostile or misconfigured `Retry-After: 86400`
67
+ * (plausible via gitlabProvenance's self-hosted `apiBase` override) would
68
+ * stall the concurrency-serialized ingest.yml queue for a day. 30s matches
69
+ * the per-request timeout: an honest throttle rarely asks for more, and a
70
+ * provider that does is better surfaced as an exhausted-retries operational
71
+ * throw than silently obeyed.
72
+ */
73
+ export const MAX_RETRY_AFTER_MS = 30_000;
74
+
63
75
  /**
64
76
  * Resolve the wait before the next attempt. A `Retry-After` header (delay in
65
- * seconds, or an HTTP-date) is honored when present and parseable; otherwise
66
- * fall back to exponential backoff. `attempt` is 1-based (1 = wait before the
67
- * 2nd request).
77
+ * seconds, or an HTTP-date) is honored when present and parseable — clamped
78
+ * to {@link MAX_RETRY_AFTER_MS} in both branches (F-5fd3f832); otherwise
79
+ * fall back to exponential backoff (already bounded by the retry budget).
80
+ * `attempt` is 1-based (1 = wait before the 2nd request).
68
81
  *
69
- * @param {Response} resp - The non-ok response carrying a possible Retry-After.
82
+ * @param {Response|null} resp - The non-ok response carrying a possible
83
+ * Retry-After, or null for a transport reject (no response → exponential).
70
84
  * @param {number} attempt - 1-based retry index.
71
85
  * @param {number} backoffMs - Base backoff.
72
86
  * @returns {number} Milliseconds to wait (never negative).
@@ -76,11 +90,11 @@ function nextBackoffMs(resp, attempt, backoffMs) {
76
90
  if (header != null && header !== '') {
77
91
  const asSeconds = Number(header);
78
92
  if (Number.isFinite(asSeconds) && asSeconds >= 0) {
79
- return Math.round(asSeconds * 1000);
93
+ return Math.min(Math.round(asSeconds * 1000), MAX_RETRY_AFTER_MS);
80
94
  }
81
95
  const asDate = Date.parse(header);
82
96
  if (!Number.isNaN(asDate)) {
83
- return Math.max(0, asDate - Date.now());
97
+ return Math.min(Math.max(0, asDate - Date.now()), MAX_RETRY_AFTER_MS);
84
98
  }
85
99
  }
86
100
  return backoffMs * 2 ** (attempt - 1);
@@ -185,11 +199,29 @@ export function githubProvenance(token, opts = {}) {
185
199
  });
186
200
  } catch (err) {
187
201
  if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
202
+ // F-8e72d0de: a per-request timeout is at least as transient as a
203
+ // connection refusal — retry within the same budget as the
204
+ // transport-reject branch below (mirrors the scenario fetcher's
205
+ // retryable-timeout discipline), then throw on exhaustion.
206
+ if (attempt < retries) {
207
+ await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
208
+ continue;
209
+ }
188
210
  throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
189
211
  }
190
- // Genuine transport error (DNS, connection refused) no run to
191
- // confirm. Reserve `return false` for this case (mirrors GitLab).
192
- return false;
212
+ // F-dac7e08c: a transport reject (DNS failure, ECONNREFUSED) means the
213
+ // provider or the runner's NETWORK is down an operational incident,
214
+ // not evidence the run is absent. Retry within the same budget as a
215
+ // 5xx (at least as transient), then THROW so the reason lands under
216
+ // the operational `provenance-fault:` prefix. The old `return false`
217
+ // persisted a REJECTED submission-bad record during a network blip,
218
+ // and the duplicate guard then blocked a clean resubmission under the
219
+ // same run_id. `return false` is reserved for HTTP 404 (mirrors GitLab).
220
+ if (attempt < retries) {
221
+ await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
222
+ continue;
223
+ }
224
+ throw new Error(`provenance: network error: ${err.message}`);
193
225
  } finally {
194
226
  clearTimeout(timer);
195
227
  }
@@ -312,8 +344,12 @@ export function gitlabProvenance(token, opts = {}) {
312
344
  if (urlRunId !== String(provider_run_id)) return false;
313
345
 
314
346
  // Bind the project path to source.repo BEFORE the network call — a forged
315
- // project claim is a cheap, offline rejection. For GitLab, submission.repo
316
- // is the full project path (which may contain nested-subgroup slashes).
347
+ // project claim is a cheap, offline rejection. V2-CROSS-BO-005
348
+ // (F-54e5fde7 contract): submission.repo is strictly two-segment the
349
+ // submission schema's `repo` pattern forbids a second slash, so nested
350
+ // GitLab subgroups are UNSUPPORTED end-to-end. A nested project path
351
+ // decoded from the run_url (group/subgroup/project) can therefore never
352
+ // equal a schema-valid source.repo and fails closed right here.
317
353
  if (source.repo && projectPath !== source.repo) return false;
318
354
 
319
355
  const projectId = encodeURIComponent(projectPath);
@@ -343,10 +379,21 @@ export function gitlabProvenance(token, opts = {}) {
343
379
  });
344
380
  } catch (err) {
345
381
  if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
382
+ // F-8e72d0de: retry timeouts within the shared budget — peer
383
+ // discipline with githubProvenance (see that adapter's note).
384
+ if (attempt < retries) {
385
+ await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
386
+ continue;
387
+ }
346
388
  throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
347
389
  }
348
- // Genuine transport error (DNS, connection refused) no run to confirm.
349
- return false;
390
+ // F-dac7e08c: transport reject = operational, retried then thrown
391
+ // mirrors githubProvenance exactly. `return false` is 404-only.
392
+ if (attempt < retries) {
393
+ await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
394
+ continue;
395
+ }
396
+ throw new Error(`provenance: network error: ${err.message}`);
350
397
  } finally {
351
398
  clearTimeout(timer);
352
399
  }
@@ -50,16 +50,24 @@ export const RUN_URL_PARSERS = {
50
50
  // PIPELINE: https://<host>/<namespace>/<project>/-/pipelines/<id>
51
51
  // The project path is everything between the host and the `/-/` run segment.
52
52
  //
53
- // NESTED-SUBGROUP MAPPING (load-bearing decision, kept consistent with how
54
- // submission.repo is expressed for GitLab): GitLab namespaces can nest —
55
- // `group/subgroup/project`. We map owner = the FULL namespace (everything
56
- // before the last path segment, slashes preserved) and repo = the LAST segment
57
- // (the project). So `${owner}/${repo}` reconstructs the full project path. For
58
- // a flat `group/project` this degenerates to owner='group', repo='project'
59
- // (same shape as GitHub). The repo-binding guard then compares
60
- // `${owner}/${repo}` against submission.repo, so for GitLab submission.repo is
61
- // the FULL project path which may contain more than one slash for nested
62
- // subgroups, unlike GitHub's strict two-segment org/repo.
53
+ // TWO-SEGMENT CONTRACT (F-54e5fde7, load-bearing decision): nested GitLab
54
+ // subgroups (`group/subgroup/project`) are UNSUPPORTED end-to-end. The
55
+ // submission schema's `repo` pattern (^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$)
56
+ // forbids a second slash, so a nested project path can never be a valid
57
+ // submission.repo, and the policy loaders (ingest load-context.js, verify
58
+ // cli.js) fail closed on 3+-segment slugs. A GitLab consumer with nested
59
+ // subgroups must submit from a top-level group/project (or mirror the repo)
60
+ // until the contract is widened deliberately schema pattern, both policy
61
+ // loaders, and the records/ path layout together, never piecemeal.
62
+ //
63
+ // The parser still maps owner = full namespace (slashes preserved) and
64
+ // repo = last segment. That is DELIBERATE fail-closed behavior, not nested
65
+ // support: for a nested run_url the reconstructed `${owner}/${repo}` carries
66
+ // 2+ slashes and can never equal a schema-valid submission.repo, so the
67
+ // binding guard rejects with repo:mismatch instead of silently skipping the
68
+ // anti-forgery check (returning null here would fail OPEN). For a flat
69
+ // `group/project` this degenerates to owner='group', repo='project' — the
70
+ // same shape as GitHub, and the only shape the contract supports.
63
71
  //
64
72
  // A single-segment path (no namespace + project, just `<project>/-/jobs/<id>`)
65
73
  // returns null — it cannot be split into owner + repo and is not a valid