@dogfood-lab/verify 1.3.1 → 1.4.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
@@ -48,7 +48,8 @@ if (!result.ok) {
48
48
 
49
49
  | Validator | Purpose |
50
50
  |---|---|
51
- | `validators/schema.js` | JSON Schema check against `@dogfood-lab/schemas` |
51
+ | `validators/schema.js` | JSON Schema check against `@dogfood-lab/schemas` (SHAPE gate) |
52
+ | `validators/schema-version.js` | `schema_version` VALUE gate — refuses an incompatible MAJOR against `SUPPORTED_SCHEMA_VERSIONS` |
52
53
  | `validators/policy.js` | Per-repo policy compliance (prototype-pollution-safe deep merge) |
53
54
  | `validators/provenance.js` | GitHub Actions run-ID confirmation via API (with timeout guard) |
54
55
  | `validators/steps.js` | Step-by-step contract checks (gate accumulation, ordering) |
@@ -84,48 +85,67 @@ Provenance fields (`github_run_id`, `github_workflow_ref`) are required when `pr
84
85
 
85
86
  The verifier emits two prefix classes:
86
87
 
87
- **Submission-bad** (the submitter's payload failed a validator gate the operator should fix the submission and resubmit):
88
+ Discrimination happens by **class**, surfaced by `parseRejectionReason` (below). Every prefix maps to one of four classes: **submission-bad** (the submitter fixes the payload), **operational** (the verifier/tooling faulted), **ingest** (an ingest-side load fault), or **unknown** (unrecognized prefix).
89
+
90
+ **Submission-bad** — `class: 'submission-bad'` (the submitter's payload failed a validator gate; fix the submission and resubmit):
88
91
 
89
92
  | Prefix | Source | Meaning |
90
93
  |---|---|---|
91
94
  | `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.). |
95
+ | `policy:` | `validators/policy.js` | Per-repo policy gate failed (forbidden tags, missing required fields, surface evidence/CI requirements, etc.). |
93
96
  | `steps[<id>]:` | `validators/steps.js` | Step-level contract check failed on a specific step id (gate accumulation, ordering, evidence shape). |
94
97
  | `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`). |
98
+ | `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: …`. |
99
+ | `submission-contains-verifier-field:` | `index.js` | The submission carried a verifier-owned field (`policy_version`, `verification`, or an object `overall_verdict`) it must not author. |
100
+ | `CONTRACT_SCHEMA_TOO_NEW:` | `validators/schema-version.js` | The submission's `schema_version` declares a MAJOR **above** what this build supports (see `SUPPORTED_SCHEMA_VERSIONS` in `@dogfood-lab/schemas`). This build cannot understand a future contract — **the operator must upgrade testing-os**, but the routing class stays submission-bad (the payload as-shipped cannot be accepted by THIS build). |
101
+ | `CONTRACT_SCHEMA_TOO_OLD:` | `validators/schema-version.js` | The submission's `schema_version` declares a MAJOR **below** the supported floor. **The submitter must re-emit** against the current contract. A patch/minor delta inside the supported major range is NOT rejected. |
96
102
 
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):
103
+ **Operational** — `class: 'operational'` (the validator itself threw an internal error; investigate the verifier, do NOT bounce to the submitter):
98
104
 
99
105
  | Prefix | Source | Meaning |
100
106
  |---|---|---|
101
107
  | `VALIDATOR_FAULT_SCHEMA:` | `runValidator('schema', …)` catch | Internal exception inside the schema validator. The rest of the string carries the thrown `.message`. |
102
108
  | `VALIDATOR_FAULT_POLICY:` | `runValidator('policy', …)` catch | Internal exception inside the policy validator. |
103
109
  | `VALIDATOR_FAULT_STEPS:` | `runValidator('steps', …)` catch | Internal exception inside the steps validator. |
110
+ | `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
+ Any future `VALIDATOR_FAULT_<NEW>:` prefix is classified `operational` by family — `parseRejectionReason` matches the `VALIDATOR_FAULT_` head, so a new validator class needs no parser edit.
113
+
114
+ **Ingest** — `class: 'ingest'` (an ingest-side load fault, not a verifier gate):
115
+
116
+ | Prefix | Source | Meaning |
117
+ |---|---|---|
118
+ | `scenario-load:` | `packages/ingest/run.js` | A scenario referenced by `scenario_results` could not be loaded from the source repo (typed-reason: `timeout` / `not_found` / `parse_error` / `invalid_id`). |
104
119
 
105
120
  ### Operator hygiene
106
121
 
122
+ Discriminate by **class**, not by hand-rolled `.startsWith()` chains. `parseRejectionReason(reason)` returns `{ class, prefix, detail }`:
123
+
107
124
  ```js
108
- // Discriminate by prefix
125
+ import { parseRejectionReason } from '@dogfood-lab/verify';
126
+
109
127
  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);
128
+ const { class: cls, prefix, detail } =
129
+ parseRejectionReason(r);
130
+ switch (cls) {
131
+ case 'operational':
132
+ // Verifier-side fault. Page ops; do NOT bounce
133
+ // back to the submitter as "fix your payload".
134
+ notifyOps(prefix, detail);
135
+ break;
136
+ case 'submission-bad':
137
+ // The payload failed a gate surface to the
138
+ // submitter so they fix it and resubmit.
139
+ surfaceToSubmitter(prefix, detail);
140
+ break;
141
+ case 'ingest':
142
+ // Ingest-side scenario fetch. The typed reason in
143
+ // `detail` (timeout vs not_found/…) decides triage.
144
+ triageScenarioLoad(detail);
145
+ break;
146
+ default: // 'unknown'
147
+ // Unrecognized prefix — log + surface raw text.
148
+ log.warn('unknown rejection_reason', r);
129
149
  }
130
150
  }
131
151
  ```
package/index.js CHANGED
@@ -9,7 +9,19 @@
9
9
  import { validateSubmissionSchema as _defaultValidateSubmissionSchema } from './validators/schema.js';
10
10
  import { validatePolicy as _defaultValidatePolicy } from './validators/policy.js';
11
11
  import { validateStepResults as _defaultValidateStepResults } from './validators/steps.js';
12
+ import { validateSchemaVersion as _defaultValidateSchemaVersion } from './validators/schema-version.js';
12
13
  import { computeVerdict } from './validators/verdict.js';
14
+ import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
15
+
16
+ // F1-CONTRACTS-003: re-export the rejection-reason classifier from the package
17
+ // root so consumers `import { parseRejectionReason } from '@dogfood-lab/verify'`
18
+ // instead of hand-rolling .startsWith() chains over the prefix taxonomy.
19
+ export { parseRejectionReason } from './parse-rejection.js';
20
+
21
+ // F1-CONTRACTS-001: the persisted record's `schema_version` is the SINGLE
22
+ // source of truth from the contract package — not a hardcoded literal that
23
+ // can drift from `SUPPORTED_SCHEMA_VERSIONS.record.current`.
24
+ const RECORD_SCHEMA_VERSION = SUPPORTED_SCHEMA_VERSIONS.record.current;
13
25
 
14
26
  /**
15
27
  * D1B-003 (Stage C humanization): the SOLE catch wrapper for synchronous
@@ -75,7 +87,7 @@ export async function verify(submission, options) {
75
87
  // timing.finished_at). Mark _skipPersist so the ingest layer surfaces the
76
88
  // rejection without crashing the persist layer with `invalid repo format: undefined`.
77
89
  return {
78
- schema_version: '1.0.0',
90
+ schema_version: RECORD_SCHEMA_VERSION,
79
91
  _skipPersist: true,
80
92
  verification: {
81
93
  status: 'rejected',
@@ -95,6 +107,7 @@ export async function verify(submission, options) {
95
107
  const validateSubmissionSchema = validatorOverrides.validateSubmissionSchema || _defaultValidateSubmissionSchema;
96
108
  const validateStepResults = validatorOverrides.validateStepResults || _defaultValidateStepResults;
97
109
  const validatePolicy = validatorOverrides.validatePolicy || _defaultValidatePolicy;
110
+ const validateSchemaVersion = validatorOverrides.validateSchemaVersion || _defaultValidateSchemaVersion;
98
111
  const now = new Date().toISOString();
99
112
  const reasons = [];
100
113
 
@@ -134,6 +147,26 @@ export async function verify(submission, options) {
134
147
  reasons.push(schemaRun.faultReason);
135
148
  }
136
149
 
150
+ // 1b. schema_version VALUE gate (F1-CONTRACTS-001)
151
+ // The schema check above gates `schema_version` by PATTERN only. This gate
152
+ // compares the declared MAJOR against `SUPPORTED_SCHEMA_VERSIONS` (the
153
+ // single source of truth in @dogfood-lab/schemas) and refuses an
154
+ // incompatible major instead of silently mis-validating a future contract
155
+ // against the live 1.x schema. It runs INDEPENDENT of `schemaResult.valid`:
156
+ // a future-major payload may also fail shape, but the version refusal is the
157
+ // operator-actionable signal and must land regardless. The validator emits a
158
+ // fully-prefixed `CONTRACT_SCHEMA_TOO_NEW:` / `CONTRACT_SCHEMA_TOO_OLD:`
159
+ // reason; an unknown-contract throw surfaces as
160
+ // `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION` via the same runValidator seam.
161
+ const versionRun = runValidator('contract_schema_version', () => validateSchemaVersion(submission, 'recordSubmission'));
162
+ if (versionRun.ok) {
163
+ if (!versionRun.result.valid) {
164
+ reasons.push(...versionRun.result.errors);
165
+ }
166
+ } else {
167
+ reasons.push(versionRun.faultReason);
168
+ }
169
+
137
170
  // 2. Reject if submission includes verifier-owned fields
138
171
  const verifierFields = ['policy_version', 'verification'];
139
172
  for (const field of verifierFields) {
@@ -219,7 +252,7 @@ export async function verify(submission, options) {
219
252
 
220
253
  // 7. Assemble persisted record
221
254
  const persisted = {
222
- schema_version: '1.0.0',
255
+ schema_version: RECORD_SCHEMA_VERSION,
223
256
  policy_version: policyVersion,
224
257
  run_id: submission.run_id,
225
258
  repo: submission.repo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",
@@ -156,7 +156,15 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
156
156
 
157
157
  if (ciReqs.coverage_min != null) {
158
158
  const coverageCheck = submission.ci_checks?.find(c => c.kind === 'coverage');
159
- if (!coverageCheck) {
159
+ // ci_checks[].value is OPTIONAL in the submission schema (only id/kind/status
160
+ // are required). A value-less coverage check must be treated the SAME as a
161
+ // missing one: at the trust boundary the verifier rejects incomplete proof.
162
+ // Without this guard, `undefined < coverage_min` is false (and a coerced
163
+ // null/NaN is likewise not a real measurement), so the gate silently passed
164
+ // with no measured coverage.
165
+ const measured = coverageCheck && typeof coverageCheck.value === 'number'
166
+ && Number.isFinite(coverageCheck.value);
167
+ if (!coverageCheck || !measured) {
160
168
  errors.push(
161
169
  `surface[${surface}]: coverage_min is ${ciReqs.coverage_min}% but no coverage data provided`
162
170
  );
@@ -0,0 +1,111 @@
1
+ /**
2
+ * schema_version VALUE gate — validators/schema-version.js
3
+ *
4
+ * F1-CONTRACTS-001 (Wave 2, HIGH). The contract JSON schemas gate
5
+ * `schema_version` by PATTERN only (`^\d+\.\d+\.\d+$`). Nothing compared the
6
+ * declared value against the set this build actually supports, so a
7
+ * submission declaring `schema_version: '2.0.0'` (or `99.0.0`) validated
8
+ * clean against the live 1.x schema whenever its shape happened to fit — a
9
+ * genuinely-incompatible future major was silently mis-validated instead of
10
+ * cleanly refused.
11
+ *
12
+ * This validator compares a payload's MAJOR against
13
+ * SUPPORTED_SCHEMA_VERSIONS (the single source of truth imported from
14
+ * `@dogfood-lab/schemas` — NOT a hand-copied literal) for the named contract
15
+ * and returns a TYPED rejection-reason string:
16
+ *
17
+ * - major > maxMajor → `CONTRACT_SCHEMA_TOO_NEW: ...` (operator must upgrade
18
+ * testing-os — this build does not understand the future contract).
19
+ * - major < minMajor → `CONTRACT_SCHEMA_TOO_OLD: ...` (submitter must
20
+ * re-emit against the current contract).
21
+ * - in range → no reason (PASS; a patch/minor delta inside the range
22
+ * is accepted — do NOT reject on a non-major bump).
23
+ * - malformed / absent version → no reason (the JSON-schema PATTERN gate
24
+ * already owns shape; this validator only owns the VALUE comparison and
25
+ * stays silent rather than double-reporting a shape fault).
26
+ *
27
+ * Unlike the `schema`/`policy`/`steps` validators (whose caller prepends a
28
+ * single fixed prefix), the TOO_NEW vs TOO_OLD discriminant is known HERE, at
29
+ * the branch that fired — so this validator emits the FULL prefixed reason
30
+ * string. `index.js` pushes it verbatim into `verification.rejection_reasons`
31
+ * via the SAME `runValidator` seam (a thrown unknown-contract fault still
32
+ * surfaces as `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION`). The
33
+ * `CONTRACT_SCHEMA_*` prefixes join the documented prefix taxonomy (see
34
+ * verify/README.md → "Error shape").
35
+ *
36
+ * Return contract: `{ valid: boolean, errors: string[] }` — `errors` holds
37
+ * the already-prefixed `CONTRACT_SCHEMA_*: ...` reason(s).
38
+ */
39
+
40
+ import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
41
+
42
+ /**
43
+ * Parse the MAJOR component of a semver-ish string. Returns null when the
44
+ * value is not a `\d+\.\d+\.\d+` triple — the shape gate owns that fault.
45
+ *
46
+ * @param {unknown} version
47
+ * @returns {number | null}
48
+ */
49
+ function parseMajor(version) {
50
+ if (typeof version !== 'string') return null;
51
+ const m = version.match(/^(\d+)\.\d+\.\d+$/);
52
+ if (!m) return null;
53
+ return Number(m[1]);
54
+ }
55
+
56
+ /**
57
+ * Gate a payload's declared `schema_version` against the supported MAJOR
58
+ * range for a named contract.
59
+ *
60
+ * @param {object} payload - Payload carrying a `schema_version` field.
61
+ * @param {string} [contract='recordSubmission'] - SUPPORTED_SCHEMA_VERSIONS key.
62
+ * @returns {{ valid: boolean, errors: string[] }}
63
+ */
64
+ export function validateSchemaVersion(payload, contract = 'recordSubmission') {
65
+ // Only gate payloads that actually declare a schema_version. A null/absent
66
+ // value is the shape gate's concern, not the value gate's.
67
+ if (!payload || typeof payload !== 'object') {
68
+ return { valid: true, errors: [] };
69
+ }
70
+ const declared = payload.schema_version;
71
+ if (declared === undefined || declared === null) {
72
+ return { valid: true, errors: [] };
73
+ }
74
+
75
+ const supported = SUPPORTED_SCHEMA_VERSIONS[contract];
76
+ if (!supported) {
77
+ // Unknown contract key is a programmer error in the call site, not a
78
+ // submission fault — throw so the runValidator seam surfaces it as a
79
+ // VALIDATOR_FAULT_* operational incident rather than a submission reason.
80
+ throw new Error(`validateSchemaVersion: unknown contract "${contract}"`);
81
+ }
82
+
83
+ const major = parseMajor(declared);
84
+ if (major === null) {
85
+ // Malformed shape — defer to the JSON-schema PATTERN gate; do not
86
+ // double-report.
87
+ return { valid: true, errors: [] };
88
+ }
89
+
90
+ if (major > supported.maxMajor) {
91
+ return {
92
+ valid: false,
93
+ errors: [
94
+ `CONTRACT_SCHEMA_TOO_NEW: ${contract} schema v${declared} but this build supports ` +
95
+ `v${supported.current} (major ${supported.minMajor}–${supported.maxMajor}) — upgrade testing-os`,
96
+ ],
97
+ };
98
+ }
99
+
100
+ if (major < supported.minMajor) {
101
+ return {
102
+ valid: false,
103
+ errors: [
104
+ `CONTRACT_SCHEMA_TOO_OLD: ${contract} schema v${declared} is below the supported floor ` +
105
+ `v${supported.current} (major ${supported.minMajor}–${supported.maxMajor}) — re-emit against the current contract`,
106
+ ],
107
+ };
108
+ }
109
+
110
+ return { valid: true, errors: [] };
111
+ }
@@ -25,7 +25,11 @@ export function validateStepResults(scenarioResult) {
25
25
  return errors;
26
26
  }
27
27
 
28
- const VALID_STATUSES = new Set(['pass', 'fail', 'blocked', 'skip']);
28
+ // Must match the step_results[].status enum in BOTH dogfood-record-submission.schema.json
29
+ // and dogfood-record.schema.json (["pass","fail","blocked","skip","partial"]). `partial`
30
+ // is a first-class, contract-blessed step status (verdict.js ranks it 2/3); omitting it
31
+ // here falsely rejected schema-valid submissions as "unknown status".
32
+ const VALID_STATUSES = new Set(['pass', 'fail', 'blocked', 'skip', 'partial']);
29
33
 
30
34
  for (let i = 0; i < step_results.length; i++) {
31
35
  const step = step_results[i];