@dogfood-lab/verify 1.2.3 → 1.3.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
@@ -30,9 +30,11 @@ const result = verify(submission, {
30
30
  });
31
31
 
32
32
  if (!result.ok) {
33
+ // rejection_reasons is an array of STRINGS with stable prefixes (see
34
+ // "Error shape" below). Operators discriminate failure class via the
35
+ // prefix; the rest of the string carries the human-readable detail.
33
36
  for (const reason of result.rejection_reasons) {
34
- console.error(`[${reason.code}] ${reason.message}`);
35
- if (reason.hint) console.error(` hint: ${reason.hint}`);
37
+ console.error(reason);
36
38
  }
37
39
  process.exit(1);
38
40
  }
@@ -76,17 +78,60 @@ Provenance fields (`github_run_id`, `github_workflow_ref`) are required when `pr
76
78
 
77
79
  ## Error shape
78
80
 
79
- Each `rejection_reasons[]` entry follows the testing-os structured error shape:
81
+ `rejection_reasons[]` is an array of **strings** — the persisted-record schema (`dogfood-record.schema.json` → `verification.rejection_reasons`) enforces `items: { type: 'string' }`. Machine-readable discrimination happens via **stable string prefixes**.
80
82
 
81
- ```ts
82
- {
83
- code: 'POLICY_GATE_FAILED' | 'SCHEMA_MISMATCH' | 'PROVENANCE_UNVERIFIED' | ...,
84
- message: string,
85
- path: string, // JSON path to the offending field
86
- hint?: string, // operator-facing remediation hint
83
+ ### Prefix taxonomy
84
+
85
+ The verifier emits two prefix classes:
86
+
87
+ **Submission-bad** (the submitter's payload failed a validator gate — the operator should fix the submission and resubmit):
88
+
89
+ | Prefix | Source | Meaning |
90
+ |---|---|---|
91
+ | `schema:` | `validators/schema.js` | JSON Schema check on the submission/record envelope failed. The rest of the string carries the AJV path + message. |
92
+ | `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, version-floor violation, etc.). |
93
+ | `steps[<id>]:` | `validators/steps.js` | Step-level contract check failed on a specific step id (gate accumulation, ordering, evidence shape). |
94
+ | `provenance:` | `validators/provenance.js` | The GitHub run-id confirmation could not match the submitted commit/repo at the GitHub API. |
95
+ | `scenario-load:` | `packages/ingest/load-context.js` | A scenario referenced by `scenario_results` could not be loaded from the source repo (typed-reason: `timeout` / `not_found` / `parse_error` / `invalid_id`). |
96
+
97
+ **Validator-crashed** (the validator itself threw an internal error — this is an operational fault, NOT submission-bad; the operator should investigate the verifier itself):
98
+
99
+ | Prefix | Source | Meaning |
100
+ |---|---|---|
101
+ | `VALIDATOR_FAULT_SCHEMA:` | `runValidator('schema', …)` catch | Internal exception inside the schema validator. The rest of the string carries the thrown `.message`. |
102
+ | `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator. |
103
+ | `VALIDATOR_FAULT_STEPS:` | `runValidator('steps', …)` catch | Internal exception inside the steps validator. |
104
+
105
+ ### Operator hygiene
106
+
107
+ ```js
108
+ // Discriminate by prefix
109
+ for (const r of result.rejection_reasons) {
110
+ if (r.startsWith('VALIDATOR_FAULT_')) {
111
+ // Operational incident — verifier-side. Page someone; do NOT route
112
+ // back to the submitter as a "fix your payload" message.
113
+ notifyOps(r);
114
+ } else if (r.startsWith('schema:') || r.startsWith('policy:') || r.startsWith('steps[')) {
115
+ // Submission-bad — surface to the submitter.
116
+ surfaceToSubmitter(r);
117
+ } else if (r.startsWith('provenance:')) {
118
+ // May be either class — GitHub API timeouts are operational; a real
119
+ // commit/repo mismatch is submission-bad. The string detail carries
120
+ // the discriminator.
121
+ triageProvenance(r);
122
+ } else if (r.startsWith('scenario-load:')) {
123
+ // Ingest-side: scenario fetch reason determines class. timeout is
124
+ // operational; not_found / parse_error / invalid_id are submission-bad.
125
+ triageScenarioLoad(r);
126
+ } else {
127
+ // Unknown prefix — log and surface as raw text.
128
+ log.warn('unknown rejection_reason prefix', r);
129
+ }
87
130
  }
88
131
  ```
89
132
 
133
+ Persistence note: every entry above is round-tripped verbatim through `verification.rejection_reasons` in the persisted-record JSON; the schema enforces `array of string` so any consumer of the audit-DB ground truth sees the same prefix vocabulary.
134
+
90
135
  ## Docs
91
136
 
92
137
  📖 Full handbook: **<https://dogfood-lab.github.io/testing-os/handbook/>**
package/index.js CHANGED
@@ -6,11 +6,49 @@
6
6
  * Never upgrades a proposed verdict.
7
7
  */
8
8
 
9
- import { validateSubmissionSchema } from './validators/schema.js';
10
- import { validatePolicy } from './validators/policy.js';
11
- import { validateStepResults } from './validators/steps.js';
9
+ import { validateSubmissionSchema as _defaultValidateSubmissionSchema } from './validators/schema.js';
10
+ import { validatePolicy as _defaultValidatePolicy } from './validators/policy.js';
11
+ import { validateStepResults as _defaultValidateStepResults } from './validators/steps.js';
12
12
  import { computeVerdict } from './validators/verdict.js';
13
13
 
14
+ /**
15
+ * D1B-003 (Stage C humanization): the SOLE catch wrapper for synchronous
16
+ * validator calls. Distinguishes operator-actionable signals:
17
+ *
18
+ * - Validator returned a structured result → caller pushes the existing
19
+ * `'<class>: <details>'` prefix into `rejection_reasons` (unchanged
20
+ * submission-bad path; back-compat is preserved).
21
+ * - Validator THREW (operational incident — ajv compile fault, policy
22
+ * merge cycle, out-of-memory) → wrapper synthesizes a STABLE coded
23
+ * prefix `'VALIDATOR_FAULT_<NAME>: <details>'` for the caller to push.
24
+ * The prefix is greppable across runner logs:
25
+ * `grep -E '"VALIDATOR_FAULT_' …` → every operational incident
26
+ * `grep -E '^(schema|policy|steps): ' …` → every submission-bad signal
27
+ *
28
+ * `name` is upper-cased to match the prefix vocabulary documented in
29
+ * verify/README.md (SCHEMA / POLICY / STEPS). Returns:
30
+ * { ok: true, result } when fn() returned cleanly
31
+ * { ok: false, faultReason } when fn() threw
32
+ *
33
+ * The helper is the canonical seam for any new synchronous validator
34
+ * — adding a 4th validator class becomes a one-call site, not a
35
+ * five-line try/catch boilerplate.
36
+ *
37
+ * @param {string} name - Validator class name (will be upper-cased).
38
+ * @param {() => T} fn - The validator call.
39
+ * @returns {{ ok: true, result: T } | { ok: false, faultReason: string }}
40
+ * @template T
41
+ */
42
+ function runValidator(name, fn) {
43
+ try {
44
+ return { ok: true, result: fn() };
45
+ } catch (e) {
46
+ const cls = name.toUpperCase();
47
+ const detail = e && e.message ? e.message : String(e);
48
+ return { ok: false, faultReason: `VALIDATOR_FAULT_${cls}: ${detail}` };
49
+ }
50
+ }
51
+
14
52
  /**
15
53
  * Verify a dogfood submission and produce a persisted record.
16
54
  *
@@ -20,6 +58,14 @@ import { computeVerdict } from './validators/verdict.js';
20
58
  * @param {object|null} options.repoPolicy - Parsed repo policy (null if none)
21
59
  * @param {object} options.provenance - Provenance adapter { confirm(source) => Promise<boolean> }
22
60
  * @param {string} options.policyVersion - Semver of the policy set being applied
61
+ * @param {object} [options.validators] - Test-only override hook (D1B-003).
62
+ * Pass `{ validateSubmissionSchema, validateStepResults, validatePolicy }`
63
+ * to swap in fault-injecting stubs. Production callers leave this unset
64
+ * and the helper falls back to the module-level imports. The override
65
+ * exists so the wrapper's catch behaviour can be tested deterministically
66
+ * without monkey-patching ESM modules (which is a no-op for live bindings).
67
+ * Documented but not part of the public stability contract — the parameter
68
+ * may shift shape in future minors.
23
69
  * @returns {Promise<object>} Persisted record (accepted or rejected)
24
70
  */
25
71
  export async function verify(submission, options) {
@@ -42,7 +88,13 @@ export async function verify(submission, options) {
42
88
  };
43
89
  }
44
90
 
45
- const { globalPolicy, repoPolicy, provenance, policyVersion } = options;
91
+ const { globalPolicy, repoPolicy, provenance, policyVersion, validators: validatorOverrides = {} } = options;
92
+ // Resolve the three validators per-call so tests can inject fault-
93
+ // injecting stubs. Production callers leave `validators` unset and the
94
+ // module-level imports flow through unchanged.
95
+ const validateSubmissionSchema = validatorOverrides.validateSubmissionSchema || _defaultValidateSubmissionSchema;
96
+ const validateStepResults = validatorOverrides.validateStepResults || _defaultValidateStepResults;
97
+ const validatePolicy = validatorOverrides.validatePolicy || _defaultValidatePolicy;
46
98
  const now = new Date().toISOString();
47
99
  const reasons = [];
48
100
 
@@ -68,14 +120,18 @@ export async function verify(submission, options) {
68
120
  }
69
121
 
70
122
  // 1. Schema validation
123
+ // D1B-003: route both happy + fault paths through `runValidator` so
124
+ // submission-bad (`'schema: …'`) and operational incidents
125
+ // (`'VALIDATOR_FAULT_SCHEMA: …'`) emit DISTINCT, greppable prefixes.
71
126
  let schemaResult = { valid: false, errors: [] };
72
- try {
73
- schemaResult = validateSubmissionSchema(submission);
127
+ const schemaRun = runValidator('schema', () => validateSubmissionSchema(submission));
128
+ if (schemaRun.ok) {
129
+ schemaResult = schemaRun.result;
74
130
  if (!schemaResult.valid) {
75
131
  reasons.push(...schemaResult.errors.map(e => `schema: ${e}`));
76
132
  }
77
- } catch (e) {
78
- reasons.push('validator error: ' + e.message);
133
+ } else {
134
+ reasons.push(schemaRun.faultReason);
79
135
  }
80
136
 
81
137
  // 2. Reject if submission includes verifier-owned fields
@@ -103,26 +159,45 @@ export async function verify(submission, options) {
103
159
  }
104
160
 
105
161
  // 4. Step results validation (only if schema passed)
162
+ // D1B-003: same submission-bad-vs-operational-fault split as schema.
106
163
  if (schemaResult.valid && submission.scenario_results) {
107
164
  for (const scenario of submission.scenario_results) {
108
- try {
109
- const stepErrors = validateStepResults(scenario);
110
- reasons.push(...stepErrors.map(e => `steps[${scenario.scenario_id}]: ${e}`));
111
- } catch (e) {
112
- reasons.push('validator error: ' + e.message);
165
+ const stepsRun = runValidator('steps', () => validateStepResults(scenario));
166
+ if (stepsRun.ok) {
167
+ reasons.push(...stepsRun.result.map(e => `steps[${scenario.scenario_id}]: ${e}`));
168
+ } else {
169
+ reasons.push(stepsRun.faultReason);
113
170
  }
114
171
  }
115
172
  }
116
173
 
117
174
  // 5. Policy evaluation (only if schema passed)
175
+ // D1B-003: see runValidator JSDoc for the split. Policy-fault on
176
+ // `globalPolicy` corruption surfaces as `VALIDATOR_FAULT_POLICY:` and
177
+ // is the operator's signal that the POLICY FILE (not the submission)
178
+ // is broken.
179
+ //
180
+ // D1B-006: a torn repo-policy file (sentinel `{ __torn: true, reason }`
181
+ // returned by `loadRepoPolicy`) MUST reject the submission with
182
+ // `policy_valid=false` + a `policy: repo policy unreadable` rejection
183
+ // reason. Pre-fix the sentinel was a silent `null` — the verifier
184
+ // happily ran defaults against a corrupt policy. Catch the sentinel
185
+ // BEFORE handing the (broken) policy to `validatePolicy`, so the
186
+ // operator sees a real "policy:" rejection in `rejection_reasons`.
118
187
  let policyValid = false;
119
188
  if (schemaResult.valid) {
120
- try {
121
- const policyResult = validatePolicy(submission, { globalPolicy, repoPolicy });
122
- policyValid = policyResult.valid;
123
- reasons.push(...policyResult.errors.map(e => `policy: ${e}`));
124
- } catch (e) {
125
- reasons.push('validator error: ' + e.message);
189
+ if (repoPolicy && repoPolicy.__torn === true) {
190
+ const detail = repoPolicy.reason || 'repo policy YAML failed to parse';
191
+ reasons.push(`policy: repo policy unreadable — ${detail}`);
192
+ // policyValid stays false.
193
+ } else {
194
+ const policyRun = runValidator('policy', () => validatePolicy(submission, { globalPolicy, repoPolicy }));
195
+ if (policyRun.ok) {
196
+ policyValid = policyRun.result.valid;
197
+ reasons.push(...policyRun.result.errors.map(e => `policy: ${e}`));
198
+ } else {
199
+ reasons.push(policyRun.faultReason);
200
+ }
126
201
  }
127
202
  }
128
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.2.3",
3
+ "version": "1.3.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",
@@ -24,8 +24,6 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@dogfood-lab/schemas": "^1.2.0",
27
- "ajv": "^8.18.0",
28
- "ajv-formats": "^3.0.1",
29
27
  "js-yaml": "^4.1.0"
30
28
  },
31
29
  "engines": {
@@ -1,37 +1,24 @@
1
1
  /**
2
- * Schema validator — validates submissions against dogfood-record-submission.schema.json
2
+ * Schema validator — validates submissions against dogfood-record-submission.schema.json.
3
+ *
4
+ * H3 hop 3: delegates to the canonical {@link validatePayload} from
5
+ * `@dogfood-lab/schemas`. Pre-H3 this module compiled its own
6
+ * Ajv2020 + ajv-formats instance for the submission schema; that
7
+ * duplicated the verifier's compile path against the inbound payload
8
+ * AND was the third of four sites contributing to the C1 two-Ajv
9
+ * structural gap. The migration collapses submission-validation to
10
+ * the single cached validator the canonical seam shares with the
11
+ * rest of the workspace.
12
+ *
13
+ * Return contract preserved: `{ valid, errors: string[] }`. The
14
+ * string-prefix shape is consumed by verify/index.js, which prepends
15
+ * `schema: ` / `policy: ` / `VALIDATOR_FAULT_SCHEMA: ` per the
16
+ * Stage C D1B-003 operator-legibility cluster. The migration keeps
17
+ * the `${path} ${message}` projection so downstream prefixes stay
18
+ * exact.
3
19
  */
4
20
 
5
- import Ajv2020 from 'ajv/dist/2020.js';
6
- import addFormats from 'ajv-formats';
7
- import { readFileSync } from 'node:fs';
8
- import { dirname } from 'node:path';
9
- import { createRequire } from 'node:module';
10
-
11
- const require = createRequire(import.meta.url);
12
- // Resolve the schemas package's json directory via its subpath export.
13
- const SCHEMA_DIR = dirname(
14
- require.resolve('@dogfood-lab/schemas/json/dogfood-record-submission.schema.json')
15
- );
16
-
17
- let _validator = null;
18
-
19
- function getValidator() {
20
- if (_validator) return _validator;
21
-
22
- try {
23
- const ajv = new Ajv2020({ allErrors: true, strict: false });
24
- addFormats(ajv);
25
-
26
- const schemaPath = `${SCHEMA_DIR}/dogfood-record-submission.schema.json`;
27
- const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
28
-
29
- _validator = ajv.compile(schema);
30
- return _validator;
31
- } catch (e) {
32
- return { __loadError: 'Schema loading failed: ' + e.message };
33
- }
34
- }
21
+ import { validatePayload } from '@dogfood-lab/schemas';
35
22
 
36
23
  /**
37
24
  * Validate a submission payload against the submission JSON Schema.
@@ -40,20 +27,32 @@ function getValidator() {
40
27
  * @returns {{ valid: boolean, errors: string[] }}
41
28
  */
42
29
  export function validateSubmissionSchema(submission) {
43
- const validate = getValidator();
44
- if (validate.__loadError) {
45
- return { valid: false, errors: [validate.__loadError] };
30
+ let result;
31
+ try {
32
+ result = validatePayload('recordSubmission', submission);
33
+ } catch (e) {
34
+ // The canonical compile path can throw at compileSchema time if the
35
+ // schema file is unreadable or malformed (Ajv compile fault). Pre-H3
36
+ // the corresponding fault was surfaced via a `{ __loadError }` sentinel
37
+ // that the runValidator helper in verify/index.js never actually
38
+ // routed through `VALIDATOR_FAULT_*` — `getValidator` returned a plain
39
+ // object and the call site special-cased its `__loadError` key. The
40
+ // post-H3 path is cleaner: a thrown error here propagates up to
41
+ // runValidator which already wraps thrown validators in
42
+ // `VALIDATOR_FAULT_SCHEMA` (D1B-003 humanization). Same operator-
43
+ // facing prefix, just routed through the lawful seam instead of a
44
+ // sentinel. No catch — propagate.
45
+ throw e;
46
46
  }
47
- const valid = validate(submission);
48
47
 
49
- if (valid) {
48
+ if (result.valid) {
50
49
  return { valid: true, errors: [] };
51
50
  }
52
51
 
53
- const errors = (validate.errors || []).map(err => {
54
- const path = err.instancePath || '/';
55
- return `${path} ${err.message}`;
56
- });
52
+ // String-prefix projection: pre-H3 wrote `${path} ${message}` directly
53
+ // from Ajv.errors. The canonical ValidationError has the same
54
+ // `{ path, message }` shape, so the formatting is verbatim.
55
+ const errors = result.errors.map(err => `${err.path} ${err.message}`);
57
56
 
58
57
  return { valid: false, errors };
59
58
  }