@dogfood-lab/verify 1.2.2 → 1.2.3

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/index.js CHANGED
@@ -1,176 +1,176 @@
1
- /**
2
- * dogfood-labs verifier
3
- *
4
- * Central law engine. Takes a submission payload and produces a persisted record.
5
- * Validates schema, policy, provenance. Sets verifier-owned fields.
6
- * Never upgrades a proposed verdict.
7
- */
8
-
9
- import { validateSubmissionSchema } from './validators/schema.js';
10
- import { validatePolicy } from './validators/policy.js';
11
- import { validateStepResults } from './validators/steps.js';
12
- import { computeVerdict } from './validators/verdict.js';
13
-
14
- /**
15
- * Verify a dogfood submission and produce a persisted record.
16
- *
17
- * @param {object} submission - Source-authored submission payload
18
- * @param {object} options
19
- * @param {object} options.globalPolicy - Parsed global policy
20
- * @param {object|null} options.repoPolicy - Parsed repo policy (null if none)
21
- * @param {object} options.provenance - Provenance adapter { confirm(source) => Promise<boolean> }
22
- * @param {string} options.policyVersion - Semver of the policy set being applied
23
- * @returns {Promise<object>} Persisted record (accepted or rejected)
24
- */
25
- export async function verify(submission, options) {
26
- if (!submission || typeof submission !== 'object' || Array.isArray(submission)) {
27
- const now = new Date().toISOString();
28
- // Null/non-object input cannot drive computeRecordPath() (needs repo + run_id +
29
- // timing.finished_at). Mark _skipPersist so the ingest layer surfaces the
30
- // rejection without crashing the persist layer with `invalid repo format: undefined`.
31
- return {
32
- schema_version: '1.0.0',
33
- _skipPersist: true,
34
- verification: {
35
- status: 'rejected',
36
- verified_at: now,
37
- provenance_confirmed: false,
38
- schema_valid: false,
39
- policy_valid: false,
40
- rejection_reasons: ['submission is null or not an object']
41
- }
42
- };
43
- }
44
-
45
- const { globalPolicy, repoPolicy, provenance, policyVersion } = options;
46
- const now = new Date().toISOString();
47
- const reasons = [];
48
-
49
- // 0. Cross-field guard: submission.repo MUST match the owner/repo encoded in
50
- // source.run_url. Without this, a submitter can claim
51
- // submission.repo='victim-org/victim-repo' while supplying source.run_url for a
52
- // real, legitimate run from their own repo. Provenance would confirm (the run
53
- // exists), and the persist layer would file the record under victim-org's path
54
- // — a forged "pass" verdict for a repo the submitter does not control.
55
- // Format: https://github.com/{owner}/{repo}/actions/runs/{id}
56
- if (submission.repo && submission.source?.run_url) {
57
- const m = submission.source.run_url.match(
58
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/\d+$/
59
- );
60
- if (m) {
61
- const sourceRepo = `${m[1]}/${m[2]}`;
62
- if (sourceRepo !== submission.repo) {
63
- reasons.push(
64
- `repo:mismatch: submission.repo (${submission.repo}) does not match source.run_url repo (${sourceRepo})`
65
- );
66
- }
67
- }
68
- }
69
-
70
- // 1. Schema validation
71
- let schemaResult = { valid: false, errors: [] };
72
- try {
73
- schemaResult = validateSubmissionSchema(submission);
74
- if (!schemaResult.valid) {
75
- reasons.push(...schemaResult.errors.map(e => `schema: ${e}`));
76
- }
77
- } catch (e) {
78
- reasons.push('validator error: ' + e.message);
79
- }
80
-
81
- // 2. Reject if submission includes verifier-owned fields
82
- const verifierFields = ['policy_version', 'verification'];
83
- for (const field of verifierFields) {
84
- if (field in submission) {
85
- reasons.push(`submission-contains-verifier-field: ${field}`);
86
- }
87
- }
88
- if (typeof submission.overall_verdict === 'object') {
89
- reasons.push('submission-contains-verifier-field: overall_verdict must be a string in submissions');
90
- }
91
-
92
- // 3. Provenance check
93
- let provenanceConfirmed = false;
94
- if (schemaResult.valid && submission.source) {
95
- try {
96
- provenanceConfirmed = await provenance.confirm(submission.source);
97
- } catch (err) {
98
- reasons.push(`provenance: verification failed: ${err.message}`);
99
- }
100
- if (!provenanceConfirmed && !reasons.some(r => r.startsWith('provenance:'))) {
101
- reasons.push('provenance: source run could not be confirmed');
102
- }
103
- }
104
-
105
- // 4. Step results validation (only if schema passed)
106
- if (schemaResult.valid && submission.scenario_results) {
107
- 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);
113
- }
114
- }
115
- }
116
-
117
- // 5. Policy evaluation (only if schema passed)
118
- let policyValid = false;
119
- 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);
126
- }
127
- }
128
-
129
- // 6. Compute verdict
130
- const proposedVerdict = typeof submission.overall_verdict === 'string'
131
- ? submission.overall_verdict
132
- : null;
133
-
134
- const hasErrors = reasons.length > 0;
135
- const status = hasErrors ? 'rejected' : 'accepted';
136
-
137
- const verdictResult = computeVerdict(proposedVerdict, {
138
- schemaValid: schemaResult.valid,
139
- policyValid,
140
- provenanceConfirmed,
141
- scenarioResults: schemaResult.valid ? submission.scenario_results : [],
142
- reasons
143
- });
144
-
145
- // 7. Assemble persisted record
146
- const persisted = {
147
- schema_version: '1.0.0',
148
- policy_version: policyVersion,
149
- run_id: submission.run_id,
150
- repo: submission.repo,
151
- ref: submission.ref,
152
- source: submission.source,
153
- timing: submission.timing,
154
- ...(submission.ci_checks ? { ci_checks: submission.ci_checks } : {}),
155
- scenario_results: submission.scenario_results || [],
156
- overall_verdict: {
157
- proposed: proposedVerdict,
158
- verified: verdictResult.verified,
159
- downgraded: verdictResult.downgraded,
160
- ...(verdictResult.downgrade_reasons.length > 0
161
- ? { downgrade_reasons: verdictResult.downgrade_reasons }
162
- : {})
163
- },
164
- verification: {
165
- status,
166
- verified_at: now,
167
- provenance_confirmed: provenanceConfirmed,
168
- schema_valid: schemaResult.valid,
169
- policy_valid: policyValid,
170
- rejection_reasons: reasons
171
- },
172
- ...(submission.notes ? { notes: submission.notes } : {})
173
- };
174
-
175
- return persisted;
176
- }
1
+ /**
2
+ * dogfood-labs verifier
3
+ *
4
+ * Central law engine. Takes a submission payload and produces a persisted record.
5
+ * Validates schema, policy, provenance. Sets verifier-owned fields.
6
+ * Never upgrades a proposed verdict.
7
+ */
8
+
9
+ import { validateSubmissionSchema } from './validators/schema.js';
10
+ import { validatePolicy } from './validators/policy.js';
11
+ import { validateStepResults } from './validators/steps.js';
12
+ import { computeVerdict } from './validators/verdict.js';
13
+
14
+ /**
15
+ * Verify a dogfood submission and produce a persisted record.
16
+ *
17
+ * @param {object} submission - Source-authored submission payload
18
+ * @param {object} options
19
+ * @param {object} options.globalPolicy - Parsed global policy
20
+ * @param {object|null} options.repoPolicy - Parsed repo policy (null if none)
21
+ * @param {object} options.provenance - Provenance adapter { confirm(source) => Promise<boolean> }
22
+ * @param {string} options.policyVersion - Semver of the policy set being applied
23
+ * @returns {Promise<object>} Persisted record (accepted or rejected)
24
+ */
25
+ export async function verify(submission, options) {
26
+ if (!submission || typeof submission !== 'object' || Array.isArray(submission)) {
27
+ const now = new Date().toISOString();
28
+ // Null/non-object input cannot drive computeRecordPath() (needs repo + run_id +
29
+ // timing.finished_at). Mark _skipPersist so the ingest layer surfaces the
30
+ // rejection without crashing the persist layer with `invalid repo format: undefined`.
31
+ return {
32
+ schema_version: '1.0.0',
33
+ _skipPersist: true,
34
+ verification: {
35
+ status: 'rejected',
36
+ verified_at: now,
37
+ provenance_confirmed: false,
38
+ schema_valid: false,
39
+ policy_valid: false,
40
+ rejection_reasons: ['submission is null or not an object']
41
+ }
42
+ };
43
+ }
44
+
45
+ const { globalPolicy, repoPolicy, provenance, policyVersion } = options;
46
+ const now = new Date().toISOString();
47
+ const reasons = [];
48
+
49
+ // 0. Cross-field guard: submission.repo MUST match the owner/repo encoded in
50
+ // source.run_url. Without this, a submitter can claim
51
+ // submission.repo='victim-org/victim-repo' while supplying source.run_url for a
52
+ // real, legitimate run from their own repo. Provenance would confirm (the run
53
+ // exists), and the persist layer would file the record under victim-org's path
54
+ // — a forged "pass" verdict for a repo the submitter does not control.
55
+ // Format: https://github.com/{owner}/{repo}/actions/runs/{id}
56
+ if (submission.repo && submission.source?.run_url) {
57
+ const m = submission.source.run_url.match(
58
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/\d+$/
59
+ );
60
+ if (m) {
61
+ const sourceRepo = `${m[1]}/${m[2]}`;
62
+ if (sourceRepo !== submission.repo) {
63
+ reasons.push(
64
+ `repo:mismatch: submission.repo (${submission.repo}) does not match source.run_url repo (${sourceRepo})`
65
+ );
66
+ }
67
+ }
68
+ }
69
+
70
+ // 1. Schema validation
71
+ let schemaResult = { valid: false, errors: [] };
72
+ try {
73
+ schemaResult = validateSubmissionSchema(submission);
74
+ if (!schemaResult.valid) {
75
+ reasons.push(...schemaResult.errors.map(e => `schema: ${e}`));
76
+ }
77
+ } catch (e) {
78
+ reasons.push('validator error: ' + e.message);
79
+ }
80
+
81
+ // 2. Reject if submission includes verifier-owned fields
82
+ const verifierFields = ['policy_version', 'verification'];
83
+ for (const field of verifierFields) {
84
+ if (field in submission) {
85
+ reasons.push(`submission-contains-verifier-field: ${field}`);
86
+ }
87
+ }
88
+ if (typeof submission.overall_verdict === 'object') {
89
+ reasons.push('submission-contains-verifier-field: overall_verdict must be a string in submissions');
90
+ }
91
+
92
+ // 3. Provenance check
93
+ let provenanceConfirmed = false;
94
+ if (schemaResult.valid && submission.source) {
95
+ try {
96
+ provenanceConfirmed = await provenance.confirm(submission.source);
97
+ } catch (err) {
98
+ reasons.push(`provenance: verification failed: ${err.message}`);
99
+ }
100
+ if (!provenanceConfirmed && !reasons.some(r => r.startsWith('provenance:'))) {
101
+ reasons.push('provenance: source run could not be confirmed');
102
+ }
103
+ }
104
+
105
+ // 4. Step results validation (only if schema passed)
106
+ if (schemaResult.valid && submission.scenario_results) {
107
+ 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);
113
+ }
114
+ }
115
+ }
116
+
117
+ // 5. Policy evaluation (only if schema passed)
118
+ let policyValid = false;
119
+ 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);
126
+ }
127
+ }
128
+
129
+ // 6. Compute verdict
130
+ const proposedVerdict = typeof submission.overall_verdict === 'string'
131
+ ? submission.overall_verdict
132
+ : null;
133
+
134
+ const hasErrors = reasons.length > 0;
135
+ const status = hasErrors ? 'rejected' : 'accepted';
136
+
137
+ const verdictResult = computeVerdict(proposedVerdict, {
138
+ schemaValid: schemaResult.valid,
139
+ policyValid,
140
+ provenanceConfirmed,
141
+ scenarioResults: schemaResult.valid ? submission.scenario_results : [],
142
+ reasons
143
+ });
144
+
145
+ // 7. Assemble persisted record
146
+ const persisted = {
147
+ schema_version: '1.0.0',
148
+ policy_version: policyVersion,
149
+ run_id: submission.run_id,
150
+ repo: submission.repo,
151
+ ref: submission.ref,
152
+ source: submission.source,
153
+ timing: submission.timing,
154
+ ...(submission.ci_checks ? { ci_checks: submission.ci_checks } : {}),
155
+ scenario_results: submission.scenario_results || [],
156
+ overall_verdict: {
157
+ proposed: proposedVerdict,
158
+ verified: verdictResult.verified,
159
+ downgraded: verdictResult.downgraded,
160
+ ...(verdictResult.downgrade_reasons.length > 0
161
+ ? { downgrade_reasons: verdictResult.downgrade_reasons }
162
+ : {})
163
+ },
164
+ verification: {
165
+ status,
166
+ verified_at: now,
167
+ provenance_confirmed: provenanceConfirmed,
168
+ schema_valid: schemaResult.valid,
169
+ policy_valid: policyValid,
170
+ rejection_reasons: reasons
171
+ },
172
+ ...(submission.notes ? { notes: submission.notes } : {})
173
+ };
174
+
175
+ return persisted;
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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",
@@ -29,7 +29,7 @@
29
29
  "js-yaml": "^4.1.0"
30
30
  },
31
31
  "engines": {
32
- "node": ">=20"
32
+ "node": ">=22"
33
33
  },
34
34
  "author": "mcp-tool-shop",
35
35
  "license": "MIT",
@@ -1,121 +1,121 @@
1
- /**
2
- * Provenance adapters
3
- *
4
- * The verifier checks that a source run actually exists and matches claims.
5
- * Two adapters:
6
- * - stub: always confirms (for tests and local development)
7
- * - github: confirms via GitHub Actions API (for production)
8
- */
9
-
10
- /**
11
- * Default per-request timeout for the GitHub provenance fetch.
12
- * A hung GitHub API call would otherwise stall every consumer's ingest until
13
- * the surrounding GitHub Actions runner timeout fires (default 6h). Fail fast
14
- * with a clear AbortError so the verifier records 'provenance: timeout' in
15
- * rejection_reasons.
16
- */
17
- export const GITHUB_PROVENANCE_TIMEOUT_MS = 30000;
18
-
19
- /**
20
- * Stub provenance adapter. Always confirms.
21
- * Use in tests and local development.
22
- */
23
- export const stubProvenance = {
24
- async confirm(_source) {
25
- return true;
26
- }
27
- };
28
-
29
- /**
30
- * Stub provenance adapter that always rejects.
31
- * Use in tests to verify rejection paths.
32
- */
33
- export const rejectingProvenance = {
34
- async confirm(_source) {
35
- return false;
36
- }
37
- };
38
-
39
- /**
40
- * GitHub provenance adapter.
41
- * Confirms a workflow run exists and matches the claimed repo, SHA, and workflow.
42
- *
43
- * @param {string} token - GitHub PAT with actions:read scope
44
- * @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
45
- * @returns {object} Provenance adapter
46
- */
47
- export function githubProvenance(token, opts = {}) {
48
- const timeoutMs = opts.timeoutMs ?? GITHUB_PROVENANCE_TIMEOUT_MS;
49
- const fetchImpl = opts.fetchImpl ?? fetch;
50
- return {
51
- async confirm(source) {
52
- if (source.provider !== 'github') {
53
- throw new Error(`unsupported provider: ${source.provider}`);
54
- }
55
-
56
- const { provider_run_id, run_url } = source;
57
- if (!provider_run_id || !run_url) {
58
- return false;
59
- }
60
-
61
- // Extract owner/repo from run_url
62
- // Format: https://github.com/{owner}/{repo}/actions/runs/{id}
63
- const match = run_url.match(
64
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)$/
65
- );
66
- if (!match) return false;
67
-
68
- const [, owner, repo, urlRunId] = match;
69
-
70
- // run_id in URL must match claimed provider_run_id
71
- if (urlRunId !== String(provider_run_id)) return false;
72
-
73
- const apiUrl = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${provider_run_id}`;
74
-
75
- // Per-request timeout. Without this, a hung GitHub API call (rate-limit
76
- // throttle, regional outage, slow connection) blocks ingest indefinitely.
77
- // AbortController fires AbortError on timeout — we re-throw with a clear
78
- // message so the verifier records it in rejection_reasons instead of
79
- // silently treating it as 'provenance returned false.'
80
- const controller = new AbortController();
81
- const timer = setTimeout(() => controller.abort(), timeoutMs);
82
-
83
- let run;
84
- try {
85
- const resp = await fetchImpl(apiUrl, {
86
- headers: {
87
- Authorization: `Bearer ${token}`,
88
- Accept: 'application/vnd.github+json',
89
- 'X-GitHub-Api-Version': '2022-11-28'
90
- },
91
- signal: controller.signal
92
- });
93
-
94
- if (!resp.ok) return false;
95
-
96
- run = await resp.json();
97
- } catch (err) {
98
- if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
99
- throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
100
- }
101
- return false;
102
- } finally {
103
- clearTimeout(timer);
104
- }
105
-
106
- if (run.id !== Number(provider_run_id)) return false;
107
-
108
- // Contract: provenance confirms the workflow run actually EXECUTED
109
- // (status === 'completed'). Pass/fail is a separate signal carried
110
- // by submission.ci_checks and scenario verdicts — the verifier still
111
- // persists failed runs, it just refuses to accept a record before the
112
- // underlying CI evidence exists. Rejects 'queued' / 'in_progress' / 'waiting'.
113
- if (run.status !== 'completed') return false;
114
-
115
- if (source.commit_sha && run.head_sha !== source.commit_sha) return false;
116
- if (source.repo && run.repository?.full_name !== source.repo) return false;
117
-
118
- return true;
119
- }
120
- };
121
- }
1
+ /**
2
+ * Provenance adapters
3
+ *
4
+ * The verifier checks that a source run actually exists and matches claims.
5
+ * Two adapters:
6
+ * - stub: always confirms (for tests and local development)
7
+ * - github: confirms via GitHub Actions API (for production)
8
+ */
9
+
10
+ /**
11
+ * Default per-request timeout for the GitHub provenance fetch.
12
+ * A hung GitHub API call would otherwise stall every consumer's ingest until
13
+ * the surrounding GitHub Actions runner timeout fires (default 6h). Fail fast
14
+ * with a clear AbortError so the verifier records 'provenance: timeout' in
15
+ * rejection_reasons.
16
+ */
17
+ export const GITHUB_PROVENANCE_TIMEOUT_MS = 30000;
18
+
19
+ /**
20
+ * Stub provenance adapter. Always confirms.
21
+ * Use in tests and local development.
22
+ */
23
+ export const stubProvenance = {
24
+ async confirm(_source) {
25
+ return true;
26
+ }
27
+ };
28
+
29
+ /**
30
+ * Stub provenance adapter that always rejects.
31
+ * Use in tests to verify rejection paths.
32
+ */
33
+ export const rejectingProvenance = {
34
+ async confirm(_source) {
35
+ return false;
36
+ }
37
+ };
38
+
39
+ /**
40
+ * GitHub provenance adapter.
41
+ * Confirms a workflow run exists and matches the claimed repo, SHA, and workflow.
42
+ *
43
+ * @param {string} token - GitHub PAT with actions:read scope
44
+ * @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
45
+ * @returns {object} Provenance adapter
46
+ */
47
+ export function githubProvenance(token, opts = {}) {
48
+ const timeoutMs = opts.timeoutMs ?? GITHUB_PROVENANCE_TIMEOUT_MS;
49
+ const fetchImpl = opts.fetchImpl ?? fetch;
50
+ return {
51
+ async confirm(source) {
52
+ if (source.provider !== 'github') {
53
+ throw new Error(`unsupported provider: ${source.provider}`);
54
+ }
55
+
56
+ const { provider_run_id, run_url } = source;
57
+ if (!provider_run_id || !run_url) {
58
+ return false;
59
+ }
60
+
61
+ // Extract owner/repo from run_url
62
+ // Format: https://github.com/{owner}/{repo}/actions/runs/{id}
63
+ const match = run_url.match(
64
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)$/
65
+ );
66
+ if (!match) return false;
67
+
68
+ const [, owner, repo, urlRunId] = match;
69
+
70
+ // run_id in URL must match claimed provider_run_id
71
+ if (urlRunId !== String(provider_run_id)) return false;
72
+
73
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${provider_run_id}`;
74
+
75
+ // Per-request timeout. Without this, a hung GitHub API call (rate-limit
76
+ // throttle, regional outage, slow connection) blocks ingest indefinitely.
77
+ // AbortController fires AbortError on timeout — we re-throw with a clear
78
+ // message so the verifier records it in rejection_reasons instead of
79
+ // silently treating it as 'provenance returned false.'
80
+ const controller = new AbortController();
81
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
82
+
83
+ let run;
84
+ try {
85
+ const resp = await fetchImpl(apiUrl, {
86
+ headers: {
87
+ Authorization: `Bearer ${token}`,
88
+ Accept: 'application/vnd.github+json',
89
+ 'X-GitHub-Api-Version': '2022-11-28'
90
+ },
91
+ signal: controller.signal
92
+ });
93
+
94
+ if (!resp.ok) return false;
95
+
96
+ run = await resp.json();
97
+ } catch (err) {
98
+ if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
99
+ throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
100
+ }
101
+ return false;
102
+ } finally {
103
+ clearTimeout(timer);
104
+ }
105
+
106
+ if (run.id !== Number(provider_run_id)) return false;
107
+
108
+ // Contract: provenance confirms the workflow run actually EXECUTED
109
+ // (status === 'completed'). Pass/fail is a separate signal carried
110
+ // by submission.ci_checks and scenario verdicts — the verifier still
111
+ // persists failed runs, it just refuses to accept a record before the
112
+ // underlying CI evidence exists. Rejects 'queued' / 'in_progress' / 'waiting'.
113
+ if (run.status !== 'completed') return false;
114
+
115
+ if (source.commit_sha && run.head_sha !== source.commit_sha) return false;
116
+ if (source.repo && run.repository?.full_name !== source.repo) return false;
117
+
118
+ return true;
119
+ }
120
+ };
121
+ }
@@ -1,59 +1,59 @@
1
- /**
2
- * Schema validator — validates submissions against dogfood-record-submission.schema.json
3
- */
4
-
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
- }
35
-
36
- /**
37
- * Validate a submission payload against the submission JSON Schema.
38
- *
39
- * @param {object} submission
40
- * @returns {{ valid: boolean, errors: string[] }}
41
- */
42
- export function validateSubmissionSchema(submission) {
43
- const validate = getValidator();
44
- if (validate.__loadError) {
45
- return { valid: false, errors: [validate.__loadError] };
46
- }
47
- const valid = validate(submission);
48
-
49
- if (valid) {
50
- return { valid: true, errors: [] };
51
- }
52
-
53
- const errors = (validate.errors || []).map(err => {
54
- const path = err.instancePath || '/';
55
- return `${path} ${err.message}`;
56
- });
57
-
58
- return { valid: false, errors };
59
- }
1
+ /**
2
+ * Schema validator — validates submissions against dogfood-record-submission.schema.json
3
+ */
4
+
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
+ }
35
+
36
+ /**
37
+ * Validate a submission payload against the submission JSON Schema.
38
+ *
39
+ * @param {object} submission
40
+ * @returns {{ valid: boolean, errors: string[] }}
41
+ */
42
+ export function validateSubmissionSchema(submission) {
43
+ const validate = getValidator();
44
+ if (validate.__loadError) {
45
+ return { valid: false, errors: [validate.__loadError] };
46
+ }
47
+ const valid = validate(submission);
48
+
49
+ if (valid) {
50
+ return { valid: true, errors: [] };
51
+ }
52
+
53
+ const errors = (validate.errors || []).map(err => {
54
+ const path = err.instancePath || '/';
55
+ return `${path} ${err.message}`;
56
+ });
57
+
58
+ return { valid: false, errors };
59
+ }
@@ -1,102 +1,102 @@
1
- /**
2
- * Step results validator
3
- *
4
- * Enforces the bridge between scenario definitions and record evidence:
5
- * - Every required step must have a matching step_result
6
- * - A scenario cannot be "pass" if any required step is "fail" or "blocked"
7
- */
8
-
9
- /**
10
- * Validate step results for a single scenario result.
11
- *
12
- * Note: Without access to the source repo's scenario definition, we validate
13
- * structural integrity. The full required_steps check is done by policy
14
- * evaluation when scenario definitions are available.
15
- *
16
- * @param {object} scenarioResult - A single scenario_results[] item
17
- * @returns {string[]} Array of error messages (empty if valid)
18
- */
19
- export function validateStepResults(scenarioResult) {
20
- const errors = [];
21
- const { step_results, verdict, scenario_id } = scenarioResult;
22
-
23
- if (!step_results || step_results.length === 0) {
24
- errors.push('step_results is required and must have at least one entry');
25
- return errors;
26
- }
27
-
28
- const VALID_STATUSES = new Set(['pass', 'fail', 'blocked', 'skip']);
29
-
30
- for (let i = 0; i < step_results.length; i++) {
31
- const step = step_results[i];
32
- if (step == null || typeof step !== 'object' || typeof step.step_id !== 'string') {
33
- errors.push(`step_results[${i}] is malformed: must be a non-null object with a string step_id`);
34
- }
35
- }
36
-
37
- const seenIds = new Set();
38
- for (const step of step_results) {
39
- if (step == null || typeof step !== 'object') continue;
40
- if (seenIds.has(step.step_id)) {
41
- errors.push(`duplicate step_id: ${step.step_id}`);
42
- }
43
- seenIds.add(step.step_id);
44
- if (step.status != null && !VALID_STATUSES.has(step.status)) {
45
- errors.push(`step "${step.step_id}" has unknown status: "${step.status}"`);
46
- }
47
- }
48
-
49
- // A scenario cannot be "pass" if any step is "fail" or "blocked"
50
- if (verdict === 'pass') {
51
- const failingSteps = step_results.filter(
52
- s => s.status === 'fail' || s.status === 'blocked'
53
- );
54
- if (failingSteps.length > 0) {
55
- const ids = failingSteps.map(s => s.step_id).join(', ');
56
- errors.push(
57
- `scenario verdict is "pass" but steps [${ids}] have status fail/blocked`
58
- );
59
- }
60
- }
61
-
62
- return errors;
63
- }
64
-
65
- /**
66
- * Validate step results against a scenario definition's required_steps.
67
- * Used when the scenario definition is available (policy evaluation phase).
68
- *
69
- * @param {object} scenarioResult - A single scenario_results[] item
70
- * @param {string[]} requiredSteps - Step IDs from scenario definition's success_criteria.required_steps
71
- * @returns {string[]} Array of error messages (empty if valid)
72
- */
73
- export function validateRequiredSteps(scenarioResult, requiredSteps) {
74
- const errors = [];
75
- const { step_results, verdict } = scenarioResult;
76
-
77
- if (!step_results) return ['step_results missing'];
78
-
79
- const resultMap = new Map(step_results.map(s => [s.step_id, s]));
80
-
81
- // Every required step must have a matching step_result
82
- for (const stepId of requiredSteps) {
83
- const result = resultMap.get(stepId);
84
- if (!result) {
85
- errors.push(`required step "${stepId}" has no matching step_result`);
86
- }
87
- }
88
-
89
- // A scenario cannot be "pass" if any required step is fail/blocked
90
- if (verdict === 'pass') {
91
- for (const stepId of requiredSteps) {
92
- const result = resultMap.get(stepId);
93
- if (result && (result.status === 'fail' || result.status === 'blocked')) {
94
- errors.push(
95
- `scenario verdict is "pass" but required step "${stepId}" has status "${result.status}"`
96
- );
97
- }
98
- }
99
- }
100
-
101
- return errors;
102
- }
1
+ /**
2
+ * Step results validator
3
+ *
4
+ * Enforces the bridge between scenario definitions and record evidence:
5
+ * - Every required step must have a matching step_result
6
+ * - A scenario cannot be "pass" if any required step is "fail" or "blocked"
7
+ */
8
+
9
+ /**
10
+ * Validate step results for a single scenario result.
11
+ *
12
+ * Note: Without access to the source repo's scenario definition, we validate
13
+ * structural integrity. The full required_steps check is done by policy
14
+ * evaluation when scenario definitions are available.
15
+ *
16
+ * @param {object} scenarioResult - A single scenario_results[] item
17
+ * @returns {string[]} Array of error messages (empty if valid)
18
+ */
19
+ export function validateStepResults(scenarioResult) {
20
+ const errors = [];
21
+ const { step_results, verdict, scenario_id } = scenarioResult;
22
+
23
+ if (!step_results || step_results.length === 0) {
24
+ errors.push('step_results is required and must have at least one entry');
25
+ return errors;
26
+ }
27
+
28
+ const VALID_STATUSES = new Set(['pass', 'fail', 'blocked', 'skip']);
29
+
30
+ for (let i = 0; i < step_results.length; i++) {
31
+ const step = step_results[i];
32
+ if (step == null || typeof step !== 'object' || typeof step.step_id !== 'string') {
33
+ errors.push(`step_results[${i}] is malformed: must be a non-null object with a string step_id`);
34
+ }
35
+ }
36
+
37
+ const seenIds = new Set();
38
+ for (const step of step_results) {
39
+ if (step == null || typeof step !== 'object') continue;
40
+ if (seenIds.has(step.step_id)) {
41
+ errors.push(`duplicate step_id: ${step.step_id}`);
42
+ }
43
+ seenIds.add(step.step_id);
44
+ if (step.status != null && !VALID_STATUSES.has(step.status)) {
45
+ errors.push(`step "${step.step_id}" has unknown status: "${step.status}"`);
46
+ }
47
+ }
48
+
49
+ // A scenario cannot be "pass" if any step is "fail" or "blocked"
50
+ if (verdict === 'pass') {
51
+ const failingSteps = step_results.filter(
52
+ s => s.status === 'fail' || s.status === 'blocked'
53
+ );
54
+ if (failingSteps.length > 0) {
55
+ const ids = failingSteps.map(s => s.step_id).join(', ');
56
+ errors.push(
57
+ `scenario verdict is "pass" but steps [${ids}] have status fail/blocked`
58
+ );
59
+ }
60
+ }
61
+
62
+ return errors;
63
+ }
64
+
65
+ /**
66
+ * Validate step results against a scenario definition's required_steps.
67
+ * Used when the scenario definition is available (policy evaluation phase).
68
+ *
69
+ * @param {object} scenarioResult - A single scenario_results[] item
70
+ * @param {string[]} requiredSteps - Step IDs from scenario definition's success_criteria.required_steps
71
+ * @returns {string[]} Array of error messages (empty if valid)
72
+ */
73
+ export function validateRequiredSteps(scenarioResult, requiredSteps) {
74
+ const errors = [];
75
+ const { step_results, verdict } = scenarioResult;
76
+
77
+ if (!step_results) return ['step_results missing'];
78
+
79
+ const resultMap = new Map(step_results.map(s => [s.step_id, s]));
80
+
81
+ // Every required step must have a matching step_result
82
+ for (const stepId of requiredSteps) {
83
+ const result = resultMap.get(stepId);
84
+ if (!result) {
85
+ errors.push(`required step "${stepId}" has no matching step_result`);
86
+ }
87
+ }
88
+
89
+ // A scenario cannot be "pass" if any required step is fail/blocked
90
+ if (verdict === 'pass') {
91
+ for (const stepId of requiredSteps) {
92
+ const result = resultMap.get(stepId);
93
+ if (result && (result.status === 'fail' || result.status === 'blocked')) {
94
+ errors.push(
95
+ `scenario verdict is "pass" but required step "${stepId}" has status "${result.status}"`
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ return errors;
102
+ }
@@ -1,93 +1,93 @@
1
- /**
2
- * Verdict computation
3
- *
4
- * Core rule: verifier may confirm or downgrade the proposed verdict, never upgrade.
5
- *
6
- * Verdict severity (highest to lowest): fail > blocked > partial > pass
7
- */
8
-
9
- const VERDICT_RANK = { fail: 0, blocked: 1, partial: 2, pass: 3 };
10
-
11
- /**
12
- * Compute the verified verdict.
13
- *
14
- * @param {string|null} proposed - Source-proposed verdict
15
- * @param {object} context
16
- * @param {boolean} context.schemaValid
17
- * @param {boolean} context.policyValid
18
- * @param {boolean} context.provenanceConfirmed
19
- * @param {object[]} context.scenarioResults - scenario_results from submission
20
- * @param {string[]} context.reasons - accumulated rejection reasons
21
- * @returns {{ verified: string, downgraded: boolean, downgrade_reasons: string[] }}
22
- */
23
- export function computeVerdict(proposed, context) {
24
- const { schemaValid, policyValid, provenanceConfirmed, scenarioResults, reasons } = context;
25
- const downgrade_reasons = [];
26
-
27
- // If fundamentals fail, verdict is "fail" regardless
28
- if (!schemaValid || !provenanceConfirmed) {
29
- const verified = 'fail';
30
- if (proposed && proposed !== 'fail') {
31
- downgrade_reasons.push('schema or provenance validation failed');
32
- }
33
- return {
34
- verified,
35
- downgraded: proposed != null && VERDICT_RANK[verified] < VERDICT_RANK[proposed],
36
- downgrade_reasons
37
- };
38
- }
39
-
40
- // Compute the worst scenario verdict
41
- let worstScenarioRank = VERDICT_RANK.pass;
42
- for (const sr of scenarioResults || []) {
43
- let rank = VERDICT_RANK[sr.verdict];
44
- if (rank == null) {
45
- rank = VERDICT_RANK.fail;
46
- downgrade_reasons.push('verdict: unrecognized scenario verdict "' + sr.verdict + '", treating as fail');
47
- }
48
- if (rank < worstScenarioRank) {
49
- worstScenarioRank = rank;
50
- }
51
- }
52
-
53
- // Determine the floor verdict from evidence
54
- let floorVerdict = Object.entries(VERDICT_RANK)
55
- .find(([, rank]) => rank === worstScenarioRank)?.[0] || 'pass';
56
-
57
- // Policy failure forces at least "fail"
58
- if (!policyValid) {
59
- floorVerdict = 'fail';
60
- downgrade_reasons.push('policy validation failed');
61
- }
62
-
63
- // The verified verdict is the worse of proposed and floor
64
- // (we never upgrade, so if proposed is worse than floor, keep proposed)
65
- if (proposed && VERDICT_RANK[proposed] == null) {
66
- downgrade_reasons.push('verdict: unrecognized proposed verdict "' + proposed + '", treating as fail');
67
- }
68
- if (!proposed) {
69
- downgrade_reasons.push('verdict: no proposed verdict provided, defaulting to fail');
70
- }
71
- const proposedRank = proposed ? (VERDICT_RANK[proposed] ?? VERDICT_RANK.fail) : VERDICT_RANK.fail;
72
- const floorRank = VERDICT_RANK[floorVerdict];
73
-
74
- let verified;
75
- if (floorRank < proposedRank) {
76
- // Floor is worse (lower rank = more severe) — downgrade
77
- verified = floorVerdict;
78
- if (proposed && proposed !== floorVerdict) {
79
- downgrade_reasons.push(
80
- `scenario/policy evidence requires "${floorVerdict}" but source proposed "${proposed}"`
81
- );
82
- }
83
- } else {
84
- // Proposed is same or worse — keep proposed (never upgrade)
85
- verified = proposed || 'fail';
86
- }
87
-
88
- return {
89
- verified,
90
- downgraded: proposed != null && VERDICT_RANK[verified] < VERDICT_RANK[proposed],
91
- downgrade_reasons
92
- };
93
- }
1
+ /**
2
+ * Verdict computation
3
+ *
4
+ * Core rule: verifier may confirm or downgrade the proposed verdict, never upgrade.
5
+ *
6
+ * Verdict severity (highest to lowest): fail > blocked > partial > pass
7
+ */
8
+
9
+ const VERDICT_RANK = { fail: 0, blocked: 1, partial: 2, pass: 3 };
10
+
11
+ /**
12
+ * Compute the verified verdict.
13
+ *
14
+ * @param {string|null} proposed - Source-proposed verdict
15
+ * @param {object} context
16
+ * @param {boolean} context.schemaValid
17
+ * @param {boolean} context.policyValid
18
+ * @param {boolean} context.provenanceConfirmed
19
+ * @param {object[]} context.scenarioResults - scenario_results from submission
20
+ * @param {string[]} context.reasons - accumulated rejection reasons
21
+ * @returns {{ verified: string, downgraded: boolean, downgrade_reasons: string[] }}
22
+ */
23
+ export function computeVerdict(proposed, context) {
24
+ const { schemaValid, policyValid, provenanceConfirmed, scenarioResults, reasons } = context;
25
+ const downgrade_reasons = [];
26
+
27
+ // If fundamentals fail, verdict is "fail" regardless
28
+ if (!schemaValid || !provenanceConfirmed) {
29
+ const verified = 'fail';
30
+ if (proposed && proposed !== 'fail') {
31
+ downgrade_reasons.push('schema or provenance validation failed');
32
+ }
33
+ return {
34
+ verified,
35
+ downgraded: proposed != null && VERDICT_RANK[verified] < VERDICT_RANK[proposed],
36
+ downgrade_reasons
37
+ };
38
+ }
39
+
40
+ // Compute the worst scenario verdict
41
+ let worstScenarioRank = VERDICT_RANK.pass;
42
+ for (const sr of scenarioResults || []) {
43
+ let rank = VERDICT_RANK[sr.verdict];
44
+ if (rank == null) {
45
+ rank = VERDICT_RANK.fail;
46
+ downgrade_reasons.push('verdict: unrecognized scenario verdict "' + sr.verdict + '", treating as fail');
47
+ }
48
+ if (rank < worstScenarioRank) {
49
+ worstScenarioRank = rank;
50
+ }
51
+ }
52
+
53
+ // Determine the floor verdict from evidence
54
+ let floorVerdict = Object.entries(VERDICT_RANK)
55
+ .find(([, rank]) => rank === worstScenarioRank)?.[0] || 'pass';
56
+
57
+ // Policy failure forces at least "fail"
58
+ if (!policyValid) {
59
+ floorVerdict = 'fail';
60
+ downgrade_reasons.push('policy validation failed');
61
+ }
62
+
63
+ // The verified verdict is the worse of proposed and floor
64
+ // (we never upgrade, so if proposed is worse than floor, keep proposed)
65
+ if (proposed && VERDICT_RANK[proposed] == null) {
66
+ downgrade_reasons.push('verdict: unrecognized proposed verdict "' + proposed + '", treating as fail');
67
+ }
68
+ if (!proposed) {
69
+ downgrade_reasons.push('verdict: no proposed verdict provided, defaulting to fail');
70
+ }
71
+ const proposedRank = proposed ? (VERDICT_RANK[proposed] ?? VERDICT_RANK.fail) : VERDICT_RANK.fail;
72
+ const floorRank = VERDICT_RANK[floorVerdict];
73
+
74
+ let verified;
75
+ if (floorRank < proposedRank) {
76
+ // Floor is worse (lower rank = more severe) — downgrade
77
+ verified = floorVerdict;
78
+ if (proposed && proposed !== floorVerdict) {
79
+ downgrade_reasons.push(
80
+ `scenario/policy evidence requires "${floorVerdict}" but source proposed "${proposed}"`
81
+ );
82
+ }
83
+ } else {
84
+ // Proposed is same or worse — keep proposed (never upgrade)
85
+ verified = proposed || 'fail';
86
+ }
87
+
88
+ return {
89
+ verified,
90
+ downgraded: proposed != null && VERDICT_RANK[verified] < VERDICT_RANK[proposed],
91
+ downgrade_reasons
92
+ };
93
+ }