@dogfood-lab/verify 1.6.0 → 1.7.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/README.md CHANGED
@@ -92,7 +92,8 @@ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below).
92
92
  | Prefix | Source | Meaning |
93
93
  |---|---|---|
94
94
  | `schema:` | `validators/schema.js` | JSON Schema check on the submission/record envelope failed. The rest of the string carries the AJV path + message. |
95
- | `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, surface evidence/CI requirements, etc.). |
95
+ | `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, surface evidence/CI requirements, or a declarative `when`/`custom_rules` predicate matched — see the [policy DSL](https://dogfood-lab.github.io/testing-os/handbook/policy-dsl/)). |
96
+ | `policy-config:` | `validators/policy.js` | **VERIFY-F1.** A REPO custom-rule predicate hit an eval-time semantic fault the schema could not catch — an unknown leading field, a numeric operator against a non-number, or a depth/width/fan-out budget. The repo authored the bad rule, so the fix is the submitter's. (A malformed GLOBAL predicate is `VALIDATOR_FAULT_POLICY:` operational instead — see below.) |
96
97
  | `steps[<id>]:` | `validators/steps.js` | Step-level contract check failed on a specific step id (gate accumulation, ordering, evidence shape). |
97
98
  | `provenance:` | `validators/provenance.js` | The run was genuinely **absent / not confirmable** — a 404 from the provider API, or the run head did not match the submitted commit/repo. The submitter's payload points at a run that does not exist or does not bind. (Operational provider faults — 429/5xx/401/403 — are NOT this class; see `provenance-fault:` below.) |
98
99
  | `repo:` | `index.js` cross-field guard | `submission.repo` does not match the owner/repo encoded in `source.run_url` (anti-forgery guard). Emitted as `repo:mismatch: …`. |
@@ -105,7 +106,7 @@ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below).
105
106
  | Prefix | Source | Meaning |
106
107
  |---|---|---|
107
108
  | `VALIDATOR_FAULT_SCHEMA:` | `runValidator('schema', …)` catch | Internal exception inside the schema validator. The rest of the string carries the thrown `.message`. |
108
- | `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator. |
109
+ | `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator — including a **GLOBAL** declarative-rule predicate fault (VERIFY-F1): a broken `policies/global-policy.yaml` is an ops incident (the studio's own config), so its predicate fault throws here rather than bouncing to the submitter. The repo-authored counterpart is `policy-config:` submission-bad. |
109
110
  | `VALIDATOR_FAULT_STEPS:` | `runValidator('steps', …)` catch | Internal exception inside the steps validator. |
110
111
  | `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION:` | `runValidator('contract_schema_version', …)` catch | The version gate was called with an unknown contract key (a programmer error at the call site, not a submission fault). |
111
112
  | `submission-malformed:` | `index.js` null/non-object early-return | The submission itself was `null` or not an object — a malfunctioning **dispatcher** sent garbage, not a submitter who authored a bad-but-shaped payload. Page ops / inspect the dispatch pipeline; do NOT bounce it to a submitter. |
package/index.js CHANGED
@@ -265,6 +265,11 @@ export async function verify(submission, options) {
265
265
  if (policyRun.ok) {
266
266
  policyValid = policyRun.result.valid;
267
267
  reasons.push(...policyRun.result.errors.map(e => `policy: ${e}`));
268
+ // VERIFY-F1: a malformed REPO custom-rule predicate is a distinct
269
+ // `policy-config:` rejection (submission-bad — the repo authored the bad
270
+ // rule). A malformed GLOBAL predicate never reaches here; it throws and
271
+ // surfaces as VALIDATOR_FAULT_POLICY (operational) via the else branch.
272
+ reasons.push(...(policyRun.result.configErrors || []).map(e => `policy-config: ${e}`));
268
273
  warnings.push(...(policyRun.result.warnings || []).map(w => `policy: ${w}`));
269
274
  } else {
270
275
  reasons.push(policyRun.faultReason);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Central verifier for testing-os. Validates submissions against schema and policy, produces persisted records.",
6
6
  "main": "index.js",
@@ -23,7 +23,9 @@
23
23
  *
24
24
  * The prefix vocabulary below is enumerated from the ACTUAL emitters — it is
25
25
  * NOT invented:
26
- * - verify/index.js: schema:, policy:, steps[<id>]:,
26
+ * - verify/index.js: schema:, policy:, policy-config: (VERIFY-F1,
27
+ * repo custom-rule predicate fault → submission-bad),
28
+ * steps[<id>]:,
27
29
  * provenance: (run absent → submission-bad),
28
30
  * provenance-fault: (provider 429/5xx/401/403
29
31
  * → operational), repo:,
@@ -62,6 +64,13 @@
62
64
  const LITERAL_PREFIXES = [
63
65
  // submission-bad
64
66
  { match: 'schema:', prefix: 'schema:', class: 'submission-bad' },
67
+ // VERIFY-F1: a malformed REPO custom-rule predicate (eval-time semantic fault the
68
+ // schema could not catch). submission-bad — the repo authored the bad policy YAML,
69
+ // so the fix belongs to the submitter, not studio ops. Ordered BEFORE `policy:`
70
+ // to keep the most-specific-first invariant (the tokens don't overlap — `policy:`
71
+ // ends at the colon, `policy-config:` continues with `-config:` — but the order is
72
+ // explicit). A malformed GLOBAL predicate is operational (VALIDATOR_FAULT_POLICY).
73
+ { match: 'policy-config:', prefix: 'policy-config:', class: 'submission-bad' },
65
74
  { match: 'policy:', prefix: 'policy:', class: 'submission-bad' },
66
75
  // operational — a provider-side provenance FAULT (429/5xx/401/403). The
67
76
  // adapter THROWS these (verify-A-002); index.js catches the throw and emits
@@ -5,6 +5,8 @@
5
5
  * Global rules are non-overridable. Repo policies add surface-specific requirements.
6
6
  */
7
7
 
8
+ import { evaluatePredicate, buildReason, PredicateError } from './predicate.js';
9
+
8
10
  function deepMerge(target, source) {
9
11
  const result = { ...target };
10
12
  for (const key of Object.keys(source)) {
@@ -44,7 +46,6 @@ function deepMerge(target, source) {
44
46
  const KNOWN_REJECT_RULE_IDS = new Set([
45
47
  // handled by the switch in validatePolicy
46
48
  'scenario-minimum',
47
- 'attested-if-human',
48
49
  'blocked-needs-reason',
49
50
  // enforced by other validators or by verify() itself (default-arm no-op is correct)
50
51
  'schema-valid',
@@ -54,6 +55,34 @@ const KNOWN_REJECT_RULE_IDS = new Set([
54
55
  'no-verdict-upgrade',
55
56
  ]);
56
57
 
58
+ // VERIFY-F1: `attested-if-human` is intentionally NOT in the set above. It used to
59
+ // be a switch arm; it is now enforced DECLARATIVELY (it carries a `when` predicate
60
+ // in policies/global-policy.yaml and flows through the engine). If it ever appears
61
+ // WITHOUT a `when` — a misconfigured migration — it falls to the default arm and is
62
+ // NOT in KNOWN_REJECT_RULE_IDS, so it surfaces the "no enforcement" diagnostic
63
+ // (fail-closed) instead of silently doing nothing.
64
+
65
+ /**
66
+ * VERIFY-F1: evaluate a declarative rule's `when` predicate against each root and
67
+ * return one reason string (`[id] body`) per match. Throws {@link PredicateError}
68
+ * on a semantic fault; the caller decides whether that is operational (global rule)
69
+ * or submission-bad (repo custom rule).
70
+ */
71
+ function collectMatches(rule, roots, scope) {
72
+ const reasons = [];
73
+ for (const root of roots) {
74
+ if (evaluatePredicate(rule.when, root, scope)) reasons.push(buildReason(rule, root));
75
+ }
76
+ return reasons;
77
+ }
78
+
79
+ /** Route matched reasons by severity. reject -> errors, warn -> warnings, info -> logged-only. */
80
+ function routeReasons(rule, reasons, errors, warnings) {
81
+ if (rule.severity === 'reject') errors.push(...reasons);
82
+ else if (rule.severity === 'warn') warnings.push(...reasons);
83
+ // info: intentionally non-surfacing
84
+ }
85
+
57
86
  /**
58
87
  * Resolve the effective surface policy for a given product surface.
59
88
  * Repo policy overrides global defaults per surface.
@@ -80,7 +109,10 @@ function resolveSurfacePolicy(surface, globalPolicy, repoPolicy) {
80
109
  * @param {object} options
81
110
  * @param {object} options.globalPolicy
82
111
  * @param {object|null} options.repoPolicy
83
- * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
112
+ * @returns {{ valid: boolean, errors: string[], warnings: string[], configErrors: string[] }}
113
+ * `configErrors` (VERIFY-F1) are eval-time repo custom-rule predicate faults; the
114
+ * caller emits them with a `policy-config:` prefix (submission-bad). A non-empty
115
+ * `configErrors` makes `valid` false.
84
116
  */
85
117
  export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
86
118
  const errors = [];
@@ -88,12 +120,36 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
88
120
  // instead of being silently dropped. A populated warnings[] never affects
89
121
  // `valid` — the caller (index.js) routes it to verification.warnings.
90
122
  const warnings = [];
123
+ // VERIFY-F1: a malformed REPO custom-rule predicate (an eval-time semantic fault
124
+ // the schema could not catch — unknown leading field, numeric op vs non-number,
125
+ // over-depth) is a `policy-config:` submission-bad reason recorded here (the repo
126
+ // authored the bad rule; do NOT page studio ops). A non-empty configErrors flips
127
+ // `valid` false (fail-closed). The caller prefixes these `policy-config: `, not
128
+ // `policy: `. Global-rule predicate faults are NOT collected here — they throw
129
+ // (-> VALIDATOR_FAULT_POLICY, operational).
130
+ const configErrors = [];
91
131
 
92
132
  // --- Global rules (non-overridable) ---
93
133
 
94
134
  const globalRules = globalPolicy.global_rules || [];
95
135
 
96
136
  for (const rule of globalRules) {
137
+ // VERIFY-F1: a rule carrying a `when` predicate is enforced DECLARATIVELY by
138
+ // the engine; a rule WITHOUT `when` falls to the legacy severity handling +
139
+ // id switch below (the seven built-ins that stay code-enforced). A GLOBAL-rule
140
+ // predicate fault is allowed to THROW (-> VALIDATOR_FAULT_POLICY, operational):
141
+ // a broken global policy is an operator/ops incident, mirroring loadGlobalPolicy's
142
+ // fail-loud schema gate. scope 'scenario_result' evaluates per element in array
143
+ // order (one reason per offending element); 'submission' (default) evaluates once.
144
+ if (rule.when) {
145
+ const scope = rule.scope || 'submission';
146
+ const roots = scope === 'scenario_result'
147
+ ? (submission.scenario_results || [])
148
+ : [submission];
149
+ routeReasons(rule, collectMatches(rule, roots, scope), errors, warnings);
150
+ continue;
151
+ }
152
+
97
153
  // VERIFY-F4: a global warn-rule is accepted-with-warning; an info-rule logs
98
154
  // only. Neither may reject. Mirrors the PROACT-VERIFY-002 discipline of not
99
155
  // dropping operator-authored rules on the floor: a declared rule that the
@@ -115,15 +171,10 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
115
171
  }
116
172
  break;
117
173
 
118
- case 'attested-if-human':
119
- for (const sr of submission.scenario_results || []) {
120
- if ((sr.execution_mode === 'human' || sr.execution_mode === 'mixed') && !sr.attested_by) {
121
- errors.push(
122
- `[${rule.id}] scenario "${sr.scenario_id}": execution_mode is "${sr.execution_mode}" but attested_by is missing`
123
- );
124
- }
125
- }
126
- break;
174
+ // VERIFY-F1: the `attested-if-human` switch arm was removed in v1.7.0. The rule
175
+ // is now enforced declaratively via a `when` predicate (see the `if (rule.when)`
176
+ // branch above and policies/global-policy.yaml). The differential-equivalence
177
+ // gate proves the declarative form is byte-identical to this removed arm.
127
178
 
128
179
  case 'blocked-needs-reason':
129
180
  for (const sr of submission.scenario_results || []) {
@@ -215,6 +266,22 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
215
266
  }
216
267
  }
217
268
  }
269
+
270
+ // VERIFY-F1: declarative custom rules for this surface, evaluated per
271
+ // scenario_result. Additive-only (reject/warn/info, no accept verb) so a repo
272
+ // can never weaken a global gate. A predicate fault here is a `policy-config:`
273
+ // submission-bad reason (the repo authored the bad rule), NOT an ops page.
274
+ for (const customRule of surfacePolicy.custom_rules || []) {
275
+ try {
276
+ routeReasons(customRule, collectMatches(customRule, [sr], 'scenario_result'), errors, warnings);
277
+ } catch (err) {
278
+ if (err instanceof PredicateError) {
279
+ configErrors.push(`rule "${customRule.id}": ${err.message}`);
280
+ continue;
281
+ }
282
+ throw err;
283
+ }
284
+ }
218
285
  }
219
286
 
220
287
  const uniqueSurfaces = [...new Set((submission.scenario_results || []).map(sr => sr.product_surface))];
@@ -256,5 +323,10 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
256
323
  }
257
324
  }
258
325
 
259
- return { valid: errors.length === 0, errors, warnings };
326
+ return {
327
+ valid: errors.length === 0 && configErrors.length === 0,
328
+ errors,
329
+ warnings,
330
+ configErrors,
331
+ };
260
332
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Predicate engine (VERIFY-F1)
3
+ *
4
+ * A bounded, NO-EVAL interpreter for the declarative policy DSL. Operators author
5
+ * rules as structured predicates in their YAML policy file (field-selector +
6
+ * operator + value, composed with all/any/not/implies); this module evaluates
7
+ * them. There is no `eval`, `Function`, `vm`, dynamic import, or template-string
8
+ * execution anywhere in the path — the evaluator is a pure tree-walk. That is the
9
+ * whole safety thesis (see docs/policy-dsl.md).
10
+ *
11
+ * The schema (`policy.schema.json`, recursive `predicate` $def) is the FIRST gate:
12
+ * unknown operators, malformed nodes, banned path segments, and arity errors are
13
+ * rejected at policy-LOAD time by `validatePayload('policy', …)` and inherit the
14
+ * existing origin-based classification (global policy → throws/operational; repo
15
+ * policy → `__torn`/submission-bad). This module is the SECOND gate: the residual
16
+ * SEMANTIC faults the schema cannot express — an unknown leading field, a numeric
17
+ * operator against a non-number, or a predicate nested past the depth cap — surface
18
+ * as a {@link PredicateError} that the caller (policy.js) turns into a
19
+ * `policy-config:` reason, classified by predicate origin. A predicate is never
20
+ * silently skipped and never throws uncaught (fail-closed; Saltzer & Schroeder 1975).
21
+ */
22
+
23
+ import { createRequire } from 'node:module';
24
+ import { readFileSync } from 'node:fs';
25
+
26
+ /** Combinator nesting cap. A predicate nested deeper is a `max_depth` fault. */
27
+ export const PREDICATE_MAX_DEPTH = 5;
28
+
29
+ /**
30
+ * Total-work budgets (VERIFY-F1 Phase 3 hardening — the depth cap bounds NESTING
31
+ * but not WIDTH or array fan-out). The engine runs synchronously inside the ingest
32
+ * pipeline, so an unbounded tree-walk would block the event loop. Both budgets are
33
+ * generous relative to any real policy (a hand-authored rule is a handful of nodes
34
+ * over a handful of elements) and exist only to refuse a pathological / hostile
35
+ * predicate as a classified `policy-config:` fault rather than letting it spin.
36
+ */
37
+ export const PREDICATE_MAX_NODES = 10_000; // visited predicate nodes per evaluation
38
+ export const PREDICATE_MAX_FRONTIER = 500_000; // selected values from a `[]` fan-out
39
+
40
+ /** Field-path segments that must never be traversed (prototype-pollution guard). */
41
+ const POISON_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
42
+
43
+ /** The closed operator set. Mirrors the `op` enum in policy.schema.json. */
44
+ const OPERATORS = new Set([
45
+ 'equals', 'not_equals', 'in', 'not_in', 'contains', 'not_contains',
46
+ 'exists', 'not_exists', 'gt', 'gte', 'lt', 'lte',
47
+ ]);
48
+
49
+ /**
50
+ * A structured predicate fault (compile- or eval-time). The `code` is a stable
51
+ * machine token; the message is operator-actionable (names the rule + the exact
52
+ * problem). The caller maps this to a `policy-config:` rejection reason, classified
53
+ * by the predicate's origin (global → operational, repo → submission-bad).
54
+ */
55
+ export class PredicateError extends Error {
56
+ constructor(code, message) {
57
+ super(message);
58
+ this.name = 'PredicateError';
59
+ this.code = code;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Known leading-field sets per scope, derived from the submission schema so the
65
+ * allowlist never drifts from the contract (CLAUDE.md rule #5: schemas via
66
+ * createRequire). Only the LEADING path segment is validated — a deeper typo just
67
+ * resolves to `undefined` (no match), but a wrong leading field is an operator
68
+ * error worth a diagnostic.
69
+ */
70
+ const KNOWN_FIELDS = (() => {
71
+ const require = createRequire(import.meta.url);
72
+ const schemaPath = require.resolve('@dogfood-lab/schemas/json/dogfood-record-submission.schema.json');
73
+ const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
74
+ const submission = new Set(Object.keys(schema.properties || {}));
75
+ const srProps = schema.properties?.scenario_results?.items?.properties || {};
76
+ const scenario_result = new Set(Object.keys(srProps));
77
+ return { submission, scenario_result };
78
+ })();
79
+
80
+ /** Split a dotted field path into segments, normalizing the `[]` array marker. */
81
+ function splitPath(field) {
82
+ // 'scenario_results[].tags' -> ['scenario_results', '[]', 'tags']
83
+ const segments = [];
84
+ for (const raw of String(field).split('.')) {
85
+ const m = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)(\[\])?$/);
86
+ if (!m) throw new PredicateError('bad_field', `field path segment "${raw}" is malformed`);
87
+ segments.push(m[1]);
88
+ if (m[2]) segments.push('[]');
89
+ }
90
+ return segments;
91
+ }
92
+
93
+ /** Read one own-property segment without ever touching the prototype chain. */
94
+ function safeGet(value, segment) {
95
+ if (POISON_SEGMENTS.has(segment)) {
96
+ // Defense in depth — the schema `pattern` already bans these, but the
97
+ // evaluator never trusts the schema alone for a security-critical read.
98
+ throw new PredicateError('banned_segment', `field path segment "${segment}" is forbidden`);
99
+ }
100
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
101
+ return Object.hasOwn(value, segment) ? value[segment] : undefined;
102
+ }
103
+
104
+ /**
105
+ * Resolve a field path to the list of values it selects. A path with no `[]`
106
+ * yields exactly one value (possibly `undefined`, preserving "field absent" so
107
+ * `not_exists` works). Each `[]` segment expands the current frontier across the
108
+ * elements of the arrays at that position. The only iteration in the whole DSL.
109
+ */
110
+ function resolvePath(root, segments) {
111
+ let frontier = [root];
112
+ for (const seg of segments) {
113
+ if (seg === '[]') {
114
+ const next = [];
115
+ for (const v of frontier) {
116
+ if (!Array.isArray(v)) continue;
117
+ // Explicit element loop, NOT `next.push(...v)` — the spread form blows the
118
+ // call stack on a large array (RangeError) before any budget check fires.
119
+ for (const el of v) {
120
+ if (next.length >= PREDICATE_MAX_FRONTIER) {
121
+ throw new PredicateError(
122
+ 'fanout_budget',
123
+ `field path selection exceeds the limit of ${PREDICATE_MAX_FRONTIER} elements`
124
+ );
125
+ }
126
+ next.push(el);
127
+ }
128
+ }
129
+ frontier = next;
130
+ } else {
131
+ frontier = frontier.map(v => safeGet(v, seg));
132
+ }
133
+ }
134
+ return frontier;
135
+ }
136
+
137
+ /** Validate the leading field segment against the scope's known-field set. */
138
+ function checkLeadingField(field, scope) {
139
+ const leading = String(field).split('.')[0].replace('[]', '');
140
+ const known = KNOWN_FIELDS[scope];
141
+ if (known && !known.has(leading)) {
142
+ throw new PredicateError(
143
+ 'unknown_field',
144
+ `field "${field}" references unknown leading field "${leading}" (known ${scope} fields: ${[...known].join(', ')})`
145
+ );
146
+ }
147
+ }
148
+
149
+ /** Apply one operator to a single resolved value + comparand. */
150
+ function applyOp(op, value, comparand) {
151
+ switch (op) {
152
+ case 'equals': return value === comparand;
153
+ case 'not_equals': return value !== comparand;
154
+ case 'in': return Array.isArray(comparand) && comparand.includes(value);
155
+ case 'not_in': return Array.isArray(comparand) && !comparand.includes(value);
156
+ case 'contains':
157
+ if (Array.isArray(value)) return value.includes(comparand);
158
+ if (typeof value === 'string') return value.includes(comparand);
159
+ return false;
160
+ case 'not_contains':
161
+ // not_contains is the negation of contains, so a missing/non-collection
162
+ // field genuinely "does not contain" the value (matches the v1.6.0
163
+ // required_tags semantics — a tagless scenario fails required_tags).
164
+ return !applyOp('contains', value, comparand);
165
+ case 'exists': return Boolean(value);
166
+ case 'not_exists': return !value;
167
+ case 'gt': case 'gte': case 'lt': case 'lte': {
168
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
169
+ throw new PredicateError(
170
+ 'type_mismatch',
171
+ `operator "${op}" requires a numeric field value but got ${describe(value)}`
172
+ );
173
+ }
174
+ if (op === 'gt') return value > comparand;
175
+ if (op === 'gte') return value >= comparand;
176
+ if (op === 'lt') return value < comparand;
177
+ return value <= comparand;
178
+ }
179
+ default:
180
+ // Unreachable when the schema gate ran (op is enum-constrained), but the
181
+ // evaluator fails closed rather than trusting the schema.
182
+ throw new PredicateError('unknown_op', `unknown operator "${op}"`);
183
+ }
184
+ }
185
+
186
+ function describe(value) {
187
+ if (value === undefined) return 'undefined (field absent)';
188
+ if (value === null) return 'null';
189
+ if (Array.isArray(value)) return 'an array';
190
+ return `${typeof value} (${JSON.stringify(value)})`;
191
+ }
192
+
193
+ function isCombinator(node) {
194
+ return node != null && typeof node === 'object' &&
195
+ (Array.isArray(node.all) || Array.isArray(node.any) || node.not != null || Array.isArray(node.implies));
196
+ }
197
+
198
+ /** Evaluate a leaf `{field, op, value?}` against a root, with "any element" semantics. */
199
+ function evalLeaf(node, root, scope) {
200
+ if (!OPERATORS.has(node.op)) {
201
+ throw new PredicateError('unknown_op', `unknown operator "${node.op}"`);
202
+ }
203
+ checkLeadingField(node.field, scope);
204
+ const values = resolvePath(root, splitPath(node.field));
205
+ // The predicate is true when ANY selected value satisfies the operator. For a
206
+ // scalar (no `[]`) path this is just that single value; for an empty `[]`
207
+ // selection there is nothing to match, so the leaf is false.
208
+ for (const v of values) {
209
+ if (applyOp(node.op, v, node.value)) return true;
210
+ }
211
+ return false;
212
+ }
213
+
214
+ /**
215
+ * Evaluate a predicate node against a root object (the whole submission for
216
+ * `submission` scope, or a single scenario_result for `scenario_result` scope).
217
+ *
218
+ * @param {object} node - The predicate tree.
219
+ * @param {object} root - The data to evaluate against.
220
+ * @param {'submission'|'scenario_result'} scope - Field-resolution scope.
221
+ * @returns {boolean} True when the (violation) predicate matches.
222
+ * @throws {PredicateError} On a semantic fault (unknown field, type mismatch, depth, budget).
223
+ */
224
+ export function evaluatePredicate(node, root, scope) {
225
+ // The budget is per-evaluation (one call over one root). It bounds the WIDTH a
226
+ // single predicate can consume, complementing the per-node depth cap.
227
+ return evalNode(node, root, scope, 1, { nodes: 0 });
228
+ }
229
+
230
+ function evalNode(node, root, scope, depth, budget) {
231
+ if (++budget.nodes > PREDICATE_MAX_NODES) {
232
+ throw new PredicateError(
233
+ 'node_budget',
234
+ `predicate exceeds the evaluation budget of ${PREDICATE_MAX_NODES} nodes`
235
+ );
236
+ }
237
+ if (isCombinator(node)) {
238
+ if (depth > PREDICATE_MAX_DEPTH) {
239
+ throw new PredicateError(
240
+ 'max_depth',
241
+ `predicate nests deeper than the limit of ${PREDICATE_MAX_DEPTH} combinator levels`
242
+ );
243
+ }
244
+ if (Array.isArray(node.all)) return node.all.every(c => evalNode(c, root, scope, depth + 1, budget));
245
+ if (Array.isArray(node.any)) return node.any.some(c => evalNode(c, root, scope, depth + 1, budget));
246
+ if (node.not != null) return !evalNode(node.not, root, scope, depth + 1, budget);
247
+ // implies: [antecedent, consequent] is the VIOLATION all(antecedent, not(consequent))
248
+ // — it matches the inputs that BREAK the implication antecedent => consequent.
249
+ const [antecedent, consequent] = node.implies;
250
+ return evalNode(antecedent, root, scope, depth + 1, budget)
251
+ && !evalNode(consequent, root, scope, depth + 1, budget);
252
+ }
253
+ return evalLeaf(node, root, scope);
254
+ }
255
+
256
+ /**
257
+ * Interpolate a `reason_template`'s `{slot}` placeholders from a root object.
258
+ * Slots are read via the same prototype-safe accessor as the field selector and
259
+ * interpolated RAW (no escaping) — the differential-equivalence oracle wraps
260
+ * values in literal quotes, and any escaping would diverge byte-for-byte. A slot
261
+ * that resolves to nothing renders empty.
262
+ */
263
+ function renderTemplate(template, root) {
264
+ return String(template).replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, slot) => {
265
+ // A poison slot is rendered empty — never traversed, never thrown. The own-
266
+ // property read avoids the prototype chain entirely (no inherited values leak).
267
+ if (POISON_SEGMENTS.has(slot)) return '';
268
+ const present = root !== null && typeof root === 'object' && Object.hasOwn(root, slot);
269
+ const v = present ? root[slot] : undefined;
270
+ return v === undefined || v === null ? '' : String(v);
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Build the full reason string for a matched rule: `[<id>] <body>`, where the
276
+ * body is the rendered `reason_template` or, absent that, the rule's
277
+ * `description`. The `policy:` prefix is NOT added here — verify/index.js prepends
278
+ * it to every policy error.
279
+ *
280
+ * @param {object} rule - The policy rule ({ id, description?, reason_template? }).
281
+ * @param {object} root - The matched element (scenario_result) or submission.
282
+ * @returns {string}
283
+ */
284
+ export function buildReason(rule, root) {
285
+ const body = rule.reason_template != null
286
+ ? renderTemplate(rule.reason_template, root)
287
+ : (rule.description ?? '');
288
+ return `[${rule.id}] ${body}`;
289
+ }