@dogfood-lab/verify 1.9.0 → 1.10.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.
@@ -14,6 +14,24 @@
14
14
  * `source.provider`, so the provider enum, the run_url parsers
15
15
  * (validators/repo-binding.js), and the adapters here stay in lockstep —
16
16
  * coverage tests fail CI if any of the three drifts.
17
+ *
18
+ * F-936c83f4 (wave 39, feature-pass integration finding): this module's
19
+ * `--provenance=stub|github` (the flag both packages/verify/cli.js and
20
+ * packages/ingest/run.js parse, both delegating to the adapters here) answers
21
+ * ONE of three structurally-similar-but-distinct 'how do we know this claim is
22
+ * true' questions living in this repo: (1) THIS module — does a submitted CI
23
+ * run really exist and match its claims; (2) dogfood-finding.schema.json's
24
+ * `evidence[].evidence_kind` enum — what evidence backs a distilled cross-repo
25
+ * lesson (see that property's own cross-reference note); (3) the swarm
26
+ * control-plane's incoming `verified_how`
27
+ * (independent|self_attested|operator_evidence,
28
+ * docs/trajectory-and-closure.dispatch.md's C2 `swarm close` verb) — how a
29
+ * finding CLOSURE was verified. This is NOT a request to unify the three
30
+ * enums — CI-run provenance, cross-repo-lesson evidence, and
31
+ * closure-verification-method are genuinely different axes, and forcing one
32
+ * shared enum would misrepresent that. The pointer exists only so the next
33
+ * reader of any of the three doesn't rediscover the pattern from scratch a
34
+ * fourth time.
17
35
  */
18
36
 
19
37
  /**
@@ -105,6 +123,67 @@ function defaultSleep(ms) {
105
123
  return new Promise(resolve => setTimeout(resolve, ms));
106
124
  }
107
125
 
126
+ /**
127
+ * F-2a5ddafa: emit ONE structured NDJSON warn line to stderr each time a
128
+ * retry is about to happen, so an operator tailing CI logs sees a degrading
129
+ * provider BEFORE the retry budget exhausts and throws. Pre-fix, every
130
+ * `await sleep(...); continue;` branch in both adapters below was completely
131
+ * silent — a submission that succeeded only after 1-2 retries against a
132
+ * degrading GitHub/GitLab API was byte-for-byte indistinguishable in the log
133
+ * from one that succeeded on the first try. The eventual exhausted-retry
134
+ * failure is already loud (an operational throw); this closes the gap on
135
+ * the way there, which is exactly the window an operator could act in
136
+ * (e.g. correlate a slow ingest with a known provider incident).
137
+ *
138
+ * Deliberately NOT named `logStage` and NOT importing
139
+ * `@dogfood-lab/dogfood-swarm/lib/log-stage.js`: packages/verify depends on
140
+ * nothing but `@dogfood-lab/schemas` + `js-yaml` today (see package.json),
141
+ * and the accepted workspace cycle (root CLAUDE.md, "Workspace dependency
142
+ * graph") is `findings -> ingest -> dogfood-swarm -> findings`. `ingest`
143
+ * ALSO depends on `verify`, so a NEW `verify -> dogfood-swarm` edge would
144
+ * not reuse that cycle — it would close a SECOND, larger one:
145
+ * `verify -> dogfood-swarm -> findings -> ingest -> verify`. This mirrors
146
+ * the repo's existing precedent for exactly this constraint —
147
+ * `packages/ingest/lib/sleep-sync.js` duplicates
148
+ * `packages/findings/lib/file-lock.js`'s private `sleepSync` verbatim,
149
+ * per that file's own header, rather than take a disallowed cross-edge —
150
+ * so this emits the same NDJSON stage-line SHAPE independently instead of
151
+ * importing the shared helper.
152
+ * `packages/ingest/wave22-log-stage-discipline.test.js` (Class #9 sweep)
153
+ * enforces that no file under `packages/**` defines its OWN `logStage`
154
+ * without delegating to the shared helper; this helper uses a distinct
155
+ * name for exactly that reason and is not a competing definition of that
156
+ * convention.
157
+ *
158
+ * Injectable via `opts.onRetryWarn` (mirrors the already-injectable
159
+ * `opts.fetchImpl` / `opts.sleepImpl` on this file) so tests assert the
160
+ * exact fields without scraping stderr.
161
+ *
162
+ * @param {{ kind: string, provider: string, attempt: number, status_or_reason: string|number, next_backoff_ms: number }} fields
163
+ */
164
+ function defaultOnRetryWarn(fields) {
165
+ const line = { ts: new Date().toISOString(), component: 'verify', stage: 'warn', ...fields };
166
+ try {
167
+ console.error(JSON.stringify(line));
168
+ } catch {
169
+ // The logger must never throw and mask a real retry — same last-resort
170
+ // discipline as the shared dogfood-swarm helper (log-stage.js). F-f50e779b:
171
+ // this fallback itself must not throw either — a broken stderr fd
172
+ // (EPIPE/ENOSPC) fails BOTH console.error calls, and pre-fix this second
173
+ // call was unguarded, so its throw escaped defaultOnRetryWarn uncaught and
174
+ // turned a retry that was about to SUCCEED into a rejected confirm() (the
175
+ // observability feature converting a good outcome into a discarded
176
+ // submission — see log-stage.js's own two-level guard for the same shape,
177
+ // which this now mirrors exactly).
178
+ try {
179
+ console.error('{"stage":"warn","kind":"log_serialization_failed"}');
180
+ } catch {
181
+ // stderr itself is broken; nothing further to do, but must not crash
182
+ // the caller — there is no third fallback to hand the failure to.
183
+ }
184
+ }
185
+ }
186
+
108
187
  /**
109
188
  * Stub provenance adapter. Always confirms.
110
189
  * Use in tests and local development.
@@ -139,6 +218,7 @@ export function githubProvenance(token, opts = {}) {
139
218
  const retries = opts.retries ?? PROVENANCE_RETRIES;
140
219
  const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
141
220
  const sleep = opts.sleepImpl ?? defaultSleep;
221
+ const onRetryWarn = opts.onRetryWarn ?? defaultOnRetryWarn;
142
222
  return {
143
223
  /**
144
224
  * @param {object} source - submission.source (provider-scoped run claim)
@@ -204,7 +284,11 @@ export function githubProvenance(token, opts = {}) {
204
284
  // transport-reject branch below (mirrors the scenario fetcher's
205
285
  // retryable-timeout discipline), then throw on exhaustion.
206
286
  if (attempt < retries) {
207
- await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
287
+ const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
288
+ // F-2a5ddafa: visibility BEFORE the budget exhausts (see
289
+ // defaultOnRetryWarn's doc above for why this is 'warn').
290
+ onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: 'timeout', next_backoff_ms: waitMs });
291
+ await sleep(waitMs);
208
292
  continue;
209
293
  }
210
294
  throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
@@ -218,7 +302,9 @@ export function githubProvenance(token, opts = {}) {
218
302
  // and the duplicate guard then blocked a clean resubmission under the
219
303
  // same run_id. `return false` is reserved for HTTP 404 (mirrors GitLab).
220
304
  if (attempt < retries) {
221
- await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
305
+ const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
306
+ onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: err && err.message ? err.message : 'network_error', next_backoff_ms: waitMs });
307
+ await sleep(waitMs);
222
308
  continue;
223
309
  }
224
310
  throw new Error(`provenance: network error: ${err.message}`);
@@ -238,7 +324,9 @@ export function githubProvenance(token, opts = {}) {
238
324
  // last 429/5xx throws so a real outage still surfaces (operational).
239
325
  if (resp.status === 404) return false;
240
326
  if (isRetryableStatus(resp.status) && attempt < retries) {
241
- await sleep(nextBackoffMs(resp, attempt + 1, backoffMs));
327
+ const waitMs = nextBackoffMs(resp, attempt + 1, backoffMs);
328
+ onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: resp.status, next_backoff_ms: waitMs });
329
+ await sleep(waitMs);
242
330
  continue;
243
331
  }
244
332
  throw new Error(`provenance: GitHub API returned ${resp.status}`);
@@ -312,6 +400,7 @@ export function gitlabProvenance(token, opts = {}) {
312
400
  const retries = opts.retries ?? PROVENANCE_RETRIES;
313
401
  const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
314
402
  const sleep = opts.sleepImpl ?? defaultSleep;
403
+ const onRetryWarn = opts.onRetryWarn ?? defaultOnRetryWarn;
315
404
  return {
316
405
  /**
317
406
  * @param {object} source - submission.source (provider-scoped run claim)
@@ -382,7 +471,9 @@ export function gitlabProvenance(token, opts = {}) {
382
471
  // F-8e72d0de: retry timeouts within the shared budget — peer
383
472
  // discipline with githubProvenance (see that adapter's note).
384
473
  if (attempt < retries) {
385
- await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
474
+ const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
475
+ onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: 'timeout', next_backoff_ms: waitMs });
476
+ await sleep(waitMs);
386
477
  continue;
387
478
  }
388
479
  throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
@@ -390,7 +481,9 @@ export function gitlabProvenance(token, opts = {}) {
390
481
  // F-dac7e08c: transport reject = operational, retried then thrown —
391
482
  // mirrors githubProvenance exactly. `return false` is 404-only.
392
483
  if (attempt < retries) {
393
- await sleep(nextBackoffMs(null, attempt + 1, backoffMs));
484
+ const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
485
+ onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: err && err.message ? err.message : 'network_error', next_backoff_ms: waitMs });
486
+ await sleep(waitMs);
394
487
  continue;
395
488
  }
396
489
  throw new Error(`provenance: network error: ${err.message}`);
@@ -408,7 +501,9 @@ export function gitlabProvenance(token, opts = {}) {
408
501
  // budget. 401/403 = non-transient operational, throw immediately.
409
502
  if (resp.status === 404) return false;
410
503
  if (isRetryableStatus(resp.status) && attempt < retries) {
411
- await sleep(nextBackoffMs(resp, attempt + 1, backoffMs));
504
+ const waitMs = nextBackoffMs(resp, attempt + 1, backoffMs);
505
+ onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: resp.status, next_backoff_ms: waitMs });
506
+ await sleep(waitMs);
412
507
  continue;
413
508
  }
414
509
  throw new Error(`provenance: GitLab API returned ${resp.status}`);
@@ -459,10 +554,34 @@ export const PROVENANCE_ADAPTERS = {
459
554
  /**
460
555
  * Resolve the provenance-adapter factory for a `source.provider`.
461
556
  *
557
+ * F-2965699b: `PROVENANCE_ADAPTERS[provider]` alone resolves inherited
558
+ * `Object.prototype` keys (`provider: 'valueOf'` returns
559
+ * `Object.prototype.valueOf`, a truthy function) — the `??` operator only
560
+ * catches `null`/`undefined`, so the `if (!factory)` unknown-provider gate at
561
+ * this function's ONE caller (`resolveProviderProvenance`, packages/ingest/
562
+ * run.js) never fires for the whole Object.prototype key space, and
563
+ * `factory(token)` goes on to call an unrelated Object.prototype method with
564
+ * an unbound `this`, producing an uncaught TypeError (`valueOf`/`__proto__`)
565
+ * or a misleading downstream error (`constructor`/`toString`/
566
+ * `isPrototypeOf`) instead of the correct "unknown provenance provider"
567
+ * message. This site is reached on FULLY UNTRUSTED input — `submission.
568
+ * source.provider` — BEFORE verify()'s schema gate constrains it to the
569
+ * [github, gitlab] enum.
570
+ *
571
+ * `Object.hasOwn` restricts the lookup to the registry's own properties,
572
+ * matching the idiom `safeGet()` already uses in validators/predicate.js —
573
+ * the two prototype-safety sites now read the same.
574
+ *
462
575
  * @param {string} provider The `source.provider` token (e.g. 'github', 'gitlab').
463
576
  * @returns {((token: string, opts?: object) => { confirm: Function }) | null}
464
- * The adapter factory, or `null` when the provider has no registered adapter.
577
+ * The adapter factory, or `null` when the provider has no registered adapter
578
+ * (including every Object.prototype key — `constructor`, `toString`,
579
+ * `valueOf`, `__proto__`, `hasOwnProperty`, `isPrototypeOf`,
580
+ * `propertyIsEnumerable`, `toLocaleString` — none of which are OWN
581
+ * properties of PROVENANCE_ADAPTERS).
465
582
  */
466
583
  export function provenanceForProvider(provider) {
467
- return PROVENANCE_ADAPTERS[provider] ?? null;
584
+ return typeof provider === 'string' && Object.hasOwn(PROVENANCE_ADAPTERS, provider)
585
+ ? PROVENANCE_ADAPTERS[provider]
586
+ : null;
468
587
  }
@@ -88,13 +88,28 @@ export const RUN_URL_PARSERS = {
88
88
  /**
89
89
  * Decode the owner/repo a run_url attests to, for the given provider.
90
90
  *
91
+ * F-7ce07baa: family sibling of F-2965699b (validators/provenance.js). Plain
92
+ * bracket access resolves inherited Object.prototype keys, so
93
+ * `RUN_URL_PARSERS[provider]` for `provider: '__proto__'` (etc.) returns a
94
+ * truthy Object.prototype method instead of `undefined` — the `if (!parser)`
95
+ * unknown-provider gate cannot fire for that key space, and `parser(runUrl)`
96
+ * goes on to call an unrelated Object.prototype method. This site is reached
97
+ * even EARLIER in the trust chain than provenanceForProvider: verify/
98
+ * index.js's repo-binding guard runs at step 0, before the schema gate, so
99
+ * `submission.source.provider` is raw untrusted input here too.
100
+ * `Object.hasOwn` matches the safeGet()/provenanceForProvider idiom so every
101
+ * prototype-safety site in this codebase reads the same.
102
+ *
91
103
  * @param {string} provider The `source.provider` token (e.g. 'github').
92
104
  * @param {string} runUrl The `source.run_url`.
93
105
  * @returns {RunUrlRepo | null} `{ owner, repo }`, or `null` when the provider
94
- * has no parser or the URL does not match the provider's shape.
106
+ * has no parser (including every Object.prototype key) or the URL does not
107
+ * match the provider's shape.
95
108
  */
96
109
  export function parseRunUrlRepo(provider, runUrl) {
97
- const parser = RUN_URL_PARSERS[provider];
110
+ const parser = typeof provider === 'string' && Object.hasOwn(RUN_URL_PARSERS, provider)
111
+ ? RUN_URL_PARSERS[provider]
112
+ : null;
98
113
  if (!parser) return null;
99
114
  return parser(runUrl);
100
115
  }
@@ -29,6 +29,19 @@ const schemaPath = require.resolve(
29
29
  const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
30
30
  const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
31
31
 
32
+ /**
33
+ * F-a2fb624f: sibling table to provenance-registry.test.js's — the pre-fix
34
+ * tripwire here ('returns null for a provider with no parser') only probed
35
+ * 'bitbucket', a NON-prototype key, so it gave zero coverage for the actual
36
+ * F-7ce07baa defect class (every Object.prototype own-property name, which
37
+ * `RUN_URL_PARSERS[provider]` resolved as a truthy inherited method before
38
+ * the Object.hasOwn fix).
39
+ */
40
+ const OBJECT_PROTOTYPE_KEYS = [
41
+ 'constructor', 'toString', 'valueOf', '__proto__',
42
+ 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString',
43
+ ];
44
+
32
45
  describe('repo-binding guard coverage (verify-B-001)', () => {
33
46
  it('exposes the provider enum it is meant to cover (sanity)', () => {
34
47
  assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
@@ -115,4 +128,31 @@ describe('repo-binding guard coverage (verify-B-001)', () => {
115
128
  it('returns null for a provider with no parser (no throw)', () => {
116
129
  assert.equal(parseRunUrlRepo('bitbucket', 'https://bitbucket.org/a/b/pipelines/1'), null);
117
130
  });
131
+
132
+ describe('F-7ce07baa: parseRunUrlRepo never resolves an inherited Object.prototype key', () => {
133
+ for (const key of OBJECT_PROTOTYPE_KEYS) {
134
+ it(`returns null (not the inherited method) for provider="${key}"`, () => {
135
+ // Deletion/emptiness proof: revert parseRunUrlRepo to a bare
136
+ // `RUN_URL_PARSERS[provider]` lookup and this goes red for every key
137
+ // here — each currently resolves a truthy inherited Object.prototype
138
+ // value the `if (!parser)` guard cannot catch, and `parser(runUrl)`
139
+ // then calls an unrelated built-in method.
140
+ assert.equal(
141
+ parseRunUrlRepo(key, 'https://github.com/acme/widget/actions/runs/1'),
142
+ null,
143
+ `provider="${key}" must resolve to null, not an inherited Object.prototype member`
144
+ );
145
+ });
146
+ }
147
+
148
+ it('a non-string provider (object, number, null, undefined) never throws and returns null', () => {
149
+ for (const bad of [{}, 42, null, undefined, ['github']]) {
150
+ assert.equal(
151
+ parseRunUrlRepo(bad, 'https://github.com/acme/widget/actions/runs/1'),
152
+ null,
153
+ `provider=${JSON.stringify(bad)} must resolve to null`
154
+ );
155
+ }
156
+ });
157
+ });
118
158
  });
@@ -72,6 +72,51 @@ export function validateStepResults(scenarioResult) {
72
72
  }
73
73
  }
74
74
 
75
+ // F-88fb37ff: mirror of the pass-direction check above. A scenario cannot
76
+ // claim "fail"/"blocked" while every reported step says otherwise either —
77
+ // computeVerdict() (validators/verdict.js) trusts scenario_results[].verdict
78
+ // verbatim and never re-derives it from step_results, so without this check
79
+ // a self-reported "blocked" verdict backed by zero failing/blocked steps
80
+ // sailed through with no rejection reason at all.
81
+ //
82
+ // F-e42e8f80 (wave 20, amends F-88fb37ff): the original gate required at
83
+ // least one step to ACTIVELY report fail/blocked, which wrongly rejected
84
+ // the common honest shape "the scenario was blocked before any step could
85
+ // run, so every step reports 'skip'" — 'skip' and 'partial' are NEUTRAL
86
+ // (no evidence either way), not "evidence of no failure." Only 'pass' is an
87
+ // ACTIVE CONTRADICTION of a fail/blocked verdict (the step ran and claimed
88
+ // success). The gate now fires only when EVERY reported step actively says
89
+ // "pass" — a single skip/partial/fail/blocked step is enough to keep a
90
+ // fail/blocked verdict internally consistent.
91
+ //
92
+ // F-cc198701 (wave 22, confirming audit of F-e42e8f80): this condition and
93
+ // its validateRequiredSteps mirror below enumerated only 2 of the 4 legal
94
+ // values in scenario_results[].verdict's own schema enum (dogfood-record-
95
+ // submission.schema.json: ["pass","fail","blocked","partial"]) — 'partial'
96
+ // was never checked in either direction. A submitter could self-report
97
+ // verdict:'partial' with EVERY step actively 'fail' (strictly worse,
98
+ // more self-contradictory evidence than the fail/blocked direction already
99
+ // rejects) and sail through with zero rejection reason, because
100
+ // computeVerdict() never re-derives the verdict from step_results either —
101
+ // the ONLY guard against a dishonest self-report was this exact check, and
102
+ // it had a verdict-enum-shaped hole. Widened to also fire for 'partial',
103
+ // reusing the identical "all present steps actively pass" bar: 'partial'
104
+ // backed by zero non-pass evidence is exactly as self-contradictory as
105
+ // 'blocked'/'fail' backed by zero non-pass evidence. Deliberately does NOT
106
+ // touch the pass-direction check above (line ~59) — 'partial' backed by
107
+ // SOME failing steps is not inherently contradictory the way 'partial'
108
+ // backed by all-pass steps is.
109
+ if (verdict === 'fail' || verdict === 'blocked' || verdict === 'partial') {
110
+ const allStepsActivelyPass = step_results.every(
111
+ s => s != null && s.status === 'pass'
112
+ );
113
+ if (allStepsActivelyPass) {
114
+ errors.push(
115
+ `scenario verdict is "${verdict}" but no step reports status fail/blocked`
116
+ );
117
+ }
118
+ }
119
+
75
120
  return errors;
76
121
  }
77
122
 
@@ -136,5 +181,37 @@ export function validateRequiredSteps(scenarioResult, requiredSteps) {
136
181
  }
137
182
  }
138
183
 
184
+ // F-88fb37ff: mirror of the pass-direction block above, scoped to REQUIRED
185
+ // steps (the sibling check in validateStepResults enforces the same rule
186
+ // over ALL reported steps regardless of which are required).
187
+ //
188
+ // F-e42e8f80 (wave 20, amends F-88fb37ff): "no required step reports
189
+ // fail/blocked" was too narrow a bar — a required step honestly reporting
190
+ // 'skip' (never ran because the scenario was blocked upstream) or 'partial'
191
+ // is NEUTRAL, not an active contradiction, and must not force a rejection.
192
+ // The gate now fires only when every PRESENT required step actively says
193
+ // "pass" — mirroring validateStepResults' all-pass bar. A required step
194
+ // that is simply MISSING is "not an active pass" exactly like skip/partial
195
+ // would be, so it never by itself forces this check to fire (`result !=
196
+ // null && result.status === 'pass'` is false for a missing step too); its
197
+ // absence is already rejected unconditionally by the [step-results-present]
198
+ // loop above, regardless of verdict, so this check deliberately does not
199
+ // pile a second, verdict-specific error onto the exact same gap.
200
+ //
201
+ // F-cc198701 (wave 22): mirrors the identical widening in
202
+ // validateStepResults above — 'partial' was the one legal scenario-verdict
203
+ // enum value this scoped-to-required-steps check never enumerated either.
204
+ if ((verdict === 'fail' || verdict === 'blocked' || verdict === 'partial') && requiredSteps.length > 0) {
205
+ const allPresentRequiredStepsActivelyPass = requiredSteps.every(stepId => {
206
+ const result = resultMap.get(stepId);
207
+ return result != null && result.status === 'pass';
208
+ });
209
+ if (allPresentRequiredStepsActivelyPass) {
210
+ errors.push(
211
+ `[step-verdict-consistent] scenario verdict is "${verdict}" but no required step reports status fail/blocked`
212
+ );
213
+ }
214
+ }
215
+
139
216
  return errors;
140
217
  }
@@ -6,7 +6,15 @@
6
6
  * Verdict severity (highest to lowest): fail > blocked > partial > pass
7
7
  */
8
8
 
9
- const VERDICT_RANK = { fail: 0, blocked: 1, partial: 2, pass: 3 };
9
+ // F-937733ee: family sibling of F-2965699b/F-7ce07baa. Object.create(null)
10
+ // removes the prototype chain entirely — `VERDICT_RANK['constructor']` is
11
+ // `undefined`, not `Object.prototype.constructor` — so the `== null` guards
12
+ // below fire correctly for every Object.prototype key, not just for a
13
+ // literal typo. Currently unreachable in production (index.js:326 only ever
14
+ // passes schema-enum-valid verdicts into `scenarioResults`), but sealing it
15
+ // here keeps a future caller that skips the schema gate from silently
16
+ // reopening the class.
17
+ const VERDICT_RANK = Object.assign(Object.create(null), { fail: 0, blocked: 1, partial: 2, pass: 3 });
10
18
 
11
19
  /**
12
20
  * Compute the verified verdict.