@blamejs/exceptd-skills 0.16.28 → 0.16.30

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.
@@ -158,8 +158,9 @@ Modes:
158
158
  --no-network report-only: list what would be fetched, WITHOUT writing
159
159
  the cache (the dry-run opposite of --prefetch).
160
160
  --from-cache [<p>] read from prefetch cache (default .cache/upstream).
161
- Combine with --apply to upsert against cached data
162
- entirely offline. Cache must be pre-populated via --prefetch.
161
+ Combine with --apply to upsert against cached data. New-RFC
162
+ discovery still queries IETF Datatracker live; add --air-gap
163
+ for a fully offline run. Cache must be pre-populated via --prefetch.
163
164
  --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins|ghsa|osv)
164
165
  --check-advisories poll primary-source advisory feeds (Qualys TRU, RHSA, USN,
165
166
  ZDI, kernel.org, oss-security, vendor research blogs) and
@@ -300,6 +301,13 @@ const KEV_SOURCE = {
300
301
  errors.push(`KEV: no local entry for ${d.id}`);
301
302
  continue;
302
303
  }
304
+ // A de-listing flagged for review (a curated entry with strong
305
+ // exploitation signal that upstream KEV no longer lists) is surfaced
306
+ // in the report but NOT auto-applied: the curated flag, factor, score
307
+ // and dates are left intact so a maintainer can confirm a genuine
308
+ // CISA removal vs a transient / incomplete feed before the entry is
309
+ // downgraded. Skip the write here.
310
+ if (d.review_only) continue;
303
311
  catalog[d.id][d.field] = d.after;
304
312
  // A cisa_kev flip changes the entry's RWEP: the KEV factor carries
305
313
  // RWEP_WEIGHTS.cisa_kev points, and the catalog invariant requires
@@ -863,6 +871,28 @@ function readCachedJson(cacheDir, source, id, opts) {
863
871
  return parsed;
864
872
  }
865
873
 
874
+ // A curated entry carries strong human-curated exploitation signal when its
875
+ // active_exploitation is confirmed/suspected, or it has a non-empty PoC
876
+ // description / verification sources. A de-listing of such an entry is far
877
+ // more likely a transient or incomplete upstream feed than a genuine CISA
878
+ // removal, so it is surfaced for review rather than auto-applied.
879
+ function hasCuratedExploitSignal(entry) {
880
+ if (!entry || typeof entry !== "object") return false;
881
+ const ae = typeof entry.active_exploitation === "string" ? entry.active_exploitation.toLowerCase() : "";
882
+ if (ae === "confirmed" || ae === "suspected") return true;
883
+ if (typeof entry.poc_description === "string" && entry.poc_description.trim()) return true;
884
+ if (Array.isArray(entry.verification_sources) && entry.verification_sources.length > 0) return true;
885
+ return false;
886
+ }
887
+
888
+ // Below this many entries the cached KEV feed is treated as truncated /
889
+ // incomplete rather than a genuine CISA snapshot. CISA KEV has carried well
890
+ // over a thousand entries since 2021 and only grows; a feed this small means
891
+ // a partial download or a tampered cache, and trusting it to de-list curated
892
+ // entries would silently erase confirmed-exploitation intel. De-listings are
893
+ // refused wholesale when the feed is implausibly small.
894
+ const KEV_FEED_MIN_PLAUSIBLE = 500;
895
+
866
896
  function kevDiffFromCache(ctx) {
867
897
  const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities", { forceStale: ctx.forceStale });
868
898
  if (!feed) {
@@ -876,12 +906,39 @@ function kevDiffFromCache(ctx) {
876
906
  if (v.dateAdded) kevDates.set(v.cveID, v.dateAdded);
877
907
  }
878
908
  }
909
+ // An implausibly small feed cannot be trusted to de-list curated entries.
910
+ // First-listings (false→true) still flow — a small feed never invents new
911
+ // exploitation; only the de-list direction is suppressed.
912
+ const feedComplete = kevSet.size >= KEV_FEED_MIN_PLAUSIBLE;
879
913
  const diffs = [];
880
914
  for (const [id, entry] of Object.entries(ctx.cveCatalog)) {
881
915
  if (!/^CVE-\d{4}-\d{4,7}$/.test(id)) continue;
882
916
  const upstream = kevSet.has(id);
883
917
  if (typeof entry.cisa_kev === "boolean" && entry.cisa_kev !== upstream) {
884
- diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
918
+ const isDelist = entry.cisa_kev === true && upstream === false;
919
+ // Symmetric with the NVD path's curated-downgrade guard: never silently
920
+ // regress curated exploitation intel against an upstream that disagrees.
921
+ // A de-listing of a curated entry with strong exploitation signal, OR
922
+ // any de-listing when the feed is implausibly small, is re-tagged as a
923
+ // review-only diff — surfaced in the report so a maintainer confirms a
924
+ // genuine CISA removal, but NOT auto-applied (applyDiff skips
925
+ // review_only diffs, leaving cisa_kev / rwep / dates intact).
926
+ if (isDelist && (!feedComplete || hasCuratedExploitSignal(entry))) {
927
+ diffs.push({
928
+ id,
929
+ field: "cisa_kev",
930
+ before: entry.cisa_kev,
931
+ after: upstream,
932
+ severity: "high",
933
+ review_only: true,
934
+ kev_delist_review: true,
935
+ note: !feedComplete
936
+ ? `KEV de-listing held for review: cached feed has only ${kevSet.size} entries (< ${KEV_FEED_MIN_PLAUSIBLE}), likely incomplete. Confirm against a complete CISA KEV snapshot before de-listing.`
937
+ : "KEV de-listing held for review: entry carries curated exploitation signal (active_exploitation / PoC / verification sources). Confirm a genuine CISA removal vs a transient feed gap before de-listing.",
938
+ });
939
+ } else {
940
+ diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
941
+ }
885
942
  }
886
943
  const upDate = kevDates.get(id) || null;
887
944
  // First listings arrive with a null local date — emit the date diff
@@ -1552,10 +1609,54 @@ async function main() {
1552
1609
  // refresh loop and could egress + write refresh-report.json despite
1553
1610
  // --no-network.
1554
1611
  if (opts.prefetch || opts.noNetwork) {
1612
+ // Validate --source against the prefetchable (cache-backed) subset BEFORE
1613
+ // delegating to prefetch.js. prefetch.js only knows kev/nvd/epss/rfc/pins;
1614
+ // the refresh-only sources (ghsa, osv, advisories, cve-regression-watcher)
1615
+ // resolve advisories by live id lookup and have no cache layer. Without
1616
+ // this guard, `refresh --prefetch --source osv` reached prefetch.js and
1617
+ // died with `prefetch: fatal: unknown source "osv"` — leaking the internal
1618
+ // verb name (the operator typed `refresh`) and calling a source "unknown"
1619
+ // that the refresh help just listed as valid. Emit a refresh-prefixed,
1620
+ // actionable message instead and forward only the cacheable subset.
1621
+ const PREFETCHABLE = new Set(["kev", "nvd", "epss", "rfc", "pins"]);
1622
+ let forwardSource = opts.source;
1623
+ if (opts.source) {
1624
+ const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
1625
+ if (names.length === 0) {
1626
+ process.stderr.write(JSON.stringify({
1627
+ ok: false,
1628
+ verb: "refresh",
1629
+ error: `refresh: --source given but resolved to no source names (empty or comma-only value); prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
1630
+ }) + "\n");
1631
+ process.exitCode = 2;
1632
+ return;
1633
+ }
1634
+ const unsupported = names.filter((n) => !PREFETCHABLE.has(n) && ALL_SOURCES[n]);
1635
+ const unknown = names.filter((n) => !PREFETCHABLE.has(n) && !ALL_SOURCES[n]);
1636
+ if (unknown.length > 0) {
1637
+ process.stderr.write(JSON.stringify({
1638
+ ok: false,
1639
+ verb: "refresh",
1640
+ error: `refresh: unknown source ${unknown.map((n) => `"${n}"`).join(", ")}; prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
1641
+ }) + "\n");
1642
+ process.exitCode = 2;
1643
+ return;
1644
+ }
1645
+ if (unsupported.length > 0) {
1646
+ process.stderr.write(JSON.stringify({
1647
+ ok: false,
1648
+ verb: "refresh",
1649
+ error: `refresh: source ${unsupported.map((n) => `"${n}"`).join(", ")} has no prefetch cache layer (live id lookup only); prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
1650
+ }) + "\n");
1651
+ process.exitCode = 2;
1652
+ return;
1653
+ }
1654
+ forwardSource = names.join(",");
1655
+ }
1555
1656
  const { spawnSync } = require("child_process");
1556
1657
  const pfArgs = [require.resolve("./prefetch.js")];
1557
1658
  if (opts.noNetwork) pfArgs.push("--no-network");
1558
- if (opts.source) pfArgs.push("--source", opts.source);
1659
+ if (forwardSource) pfArgs.push("--source", forwardSource);
1559
1660
  if (opts.quiet) pfArgs.push("--quiet");
1560
1661
  const r = spawnSync(process.execPath, pfArgs, { stdio: "inherit" });
1561
1662
  process.exitCode = r.status == null ? 1 : r.status;
@@ -1739,4 +1840,4 @@ if (require.main === module) {
1739
1840
  });
1740
1841
  }
1741
1842
 
1742
- module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache };
1843
+ module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache, kevDiffFromCache };
package/lib/scoring.js CHANGED
@@ -214,7 +214,23 @@ function scoreCustom(factors, opts) {
214
214
  // which propagates NaN through the final clamp, defeating the [0,100]
215
215
  // contract. Number.isFinite + Number() coercion catches all four classes:
216
216
  // NaN, Infinity, undefined, stringified-number.
217
- const brRaw = Number.isFinite(Number(blast_radius)) ? Number(blast_radius) : 0;
217
+ // Match validateFactors' contract exactly. Bare `Number()` coercion would
218
+ // turn `true` into 1 and a single-element array like `[7]` into 7 — both of
219
+ // which validateFactors rejects as "expected number" — so the scorer would
220
+ // silently add a blast contribution the validator says is invalid. Accept
221
+ // only a finite number or a trimmed-nonempty numeric string; everything
222
+ // else (boolean, array, object, NaN, Infinity, empty/whitespace string)
223
+ // contributes 0.
224
+ let brRaw = 0;
225
+ if (typeof blast_radius === 'number' && Number.isFinite(blast_radius)) {
226
+ brRaw = blast_radius;
227
+ } else if (
228
+ typeof blast_radius === 'string' &&
229
+ blast_radius.trim() !== '' &&
230
+ Number.isFinite(Number(blast_radius))
231
+ ) {
232
+ brRaw = Number(blast_radius);
233
+ }
218
234
  const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
219
235
  score += brClamped;
220
236
  score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
@@ -303,9 +319,21 @@ function compare(cveId, catalog, opts) {
303
319
  } else {
304
320
  rwep = entry.rwep_score;
305
321
  }
306
- const cvss = entry.cvss_score;
307
- const cvssEquivalent = cvss * 10;
308
- const delta = rwep - cvssEquivalent;
322
+ // Normalize cvss before any arithmetic. An absent or non-finite cvss_score
323
+ // must not flow into `cvss * 10` — `undefined * 10 === NaN` poisons the
324
+ // delta, and NaN fails every `delta > 10` / `delta < -10` band, so the
325
+ // entry would otherwise fall through to the "broadly aligned" arm and
326
+ // assert an alignment that was never computed. Treat absent CVSS as
327
+ // not-comparable instead.
328
+ const cvss = (typeof entry.cvss_score === 'number' && Number.isFinite(entry.cvss_score))
329
+ ? entry.cvss_score
330
+ : null;
331
+ const cvssAbsent = cvss == null;
332
+ const cvssEquivalent = cvssAbsent ? 0 : cvss * 10;
333
+ // delta is null (not NaN) when there is no CVSS to compare against, so the
334
+ // emitted result serializes cleanly and never claims a numeric divergence
335
+ // that does not exist.
336
+ const delta = cvssAbsent ? null : rwep - cvssEquivalent;
309
337
 
310
338
  // narrow the "broadly aligned" band from ±20 to ±10. The old
311
339
  // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
@@ -321,6 +349,11 @@ function compare(cveId, catalog, opts) {
321
349
  // catalog gap rather than a false sense of alignment.
322
350
  if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
323
351
  explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
352
+ } else if (cvssAbsent) {
353
+ // RWEP carries a real signal but there is no CVSS to compare it against.
354
+ // The two scores are not comparable, so do not assert alignment or a
355
+ // divergence direction — surface the missing CVSS instead.
356
+ explanation = 'CVSS absent — RWEP is the only usable score for this CVE; no CVSS comparison is possible. Backfill cvss_score in the catalog to enable the comparison.';
324
357
  } else if (delta > 10) {
325
358
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
326
359
  const driving = [];
@@ -460,13 +493,24 @@ function validate(catalog) {
460
493
  active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
461
494
  patch_available: entry.patch_available === true ? RWEP_WEIGHTS.patch_available : 0,
462
495
  live_patch_available: entry.live_patch_available === true ? RWEP_WEIGHTS.live_patch_available : 0,
463
- reboot_required: entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0,
464
496
  };
465
497
  for (const [k, want] of Object.entries(implied)) {
466
498
  if (k in f && typeof f[k] === 'number' && f[k] !== want) {
467
499
  errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
468
500
  }
469
501
  }
502
+ // The reboot contribution has ONE implied weight but TWO accepted
503
+ // spellings — `reboot_required` (canonical) and `patch_required_reboot`
504
+ // (the catalog field-name alias scoreCustom also honors). Check both
505
+ // against that single weight; keying only on `reboot_required` let a
506
+ // contradictory value stored under the alias slip past the coherence
507
+ // gate, inflating the derived score.
508
+ const rebootWant = entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0;
509
+ for (const rebootKey of ['reboot_required', 'patch_required_reboot']) {
510
+ if (rebootKey in f && typeof f[rebootKey] === 'number' && f[rebootKey] !== rebootWant) {
511
+ errors.push(`${cveId}: rwep_factors.${rebootKey} is ${f[rebootKey]} but the entry's source fields imply ${rebootWant}`);
512
+ }
513
+ }
470
514
  }
471
515
  const calculatedRwep = scoreCustom({
472
516
  cisa_kev: entry.cisa_kev,
@@ -264,8 +264,20 @@ function additionalChecks(key, entry, ctx) {
264
264
  for (const { field, set, file } of REF_FIELDS) {
265
265
  if (!set) continue; // catalog absent — skip silently (defense-in-depth)
266
266
  const refs = entry[field];
267
- if (!Array.isArray(refs)) continue;
268
- for (const ref of refs) {
267
+ // Most ref fields are arrays of id strings (atlas_refs, cwe_refs, ...),
268
+ // but framework_control_gaps is a map keyed BY control id. An
269
+ // Array.isArray-only guard skipped the map form entirely, so orphaned
270
+ // control ids never surfaced. Derive the candidate ids from whichever
271
+ // shape the field uses: array elements, or the keys of a plain object.
272
+ let ids;
273
+ if (Array.isArray(refs)) {
274
+ ids = refs;
275
+ } else if (refs && typeof refs === 'object') {
276
+ ids = Object.keys(refs);
277
+ } else {
278
+ continue;
279
+ }
280
+ for (const ref of ids) {
269
281
  if (typeof ref !== 'string') continue;
270
282
  if (!set.has(ref)) {
271
283
  warnings.push(
@@ -67,6 +67,11 @@ function main() {
67
67
  const manifest = JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8"));
68
68
  const liveSources = new Set();
69
69
  liveSources.add("manifest.json");
70
+ // README.md is consumed by the stale-content index builder (badge-count
71
+ // drift), so build-indexes hashes it as a source. Keep this validator's
72
+ // source set in sync — otherwise the hashed README looks like a removed
73
+ // file here. (Mirrors liveSourceSet() in scripts/build-indexes.js.)
74
+ if (fs.existsSync(ABS("README.md"))) liveSources.add("README.md");
70
75
  // use lstat to detect symlinks. A symlinked .json under data/
71
76
  // would be hashed via the followed target, allowing a malicious checkout
72
77
  // (or a misconfigured filesystem) to swap data origin without tripping the
@@ -25,6 +25,14 @@
25
25
  * - phases.detect.indicators[].cve_ref → data/cve-catalog.json
26
26
  * - phases.detect.false_positive_profile[].indicator_id
27
27
  * → phases.detect.indicators[].id
28
+ * - directives[].applies_to.cve → data/cve-catalog.json keys
29
+ * - directives[].applies_to.atlas_ttp → data/atlas-ttps.json keys
30
+ * - directives[].applies_to.attack_technique → data/attack-techniques.json
31
+ * - directives[].phase_overrides.govern.jurisdiction_obligations[].clock_starts
32
+ * → closed clock_starts vocabulary
33
+ * - directives[].phase_overrides.direct.rwep_threshold → ordering + range
34
+ * - directives[].phase_overrides.close.notification_actions[].obligation_ref
35
+ * → effective jurisdiction_obligations
28
36
  *
29
37
  * Internal consistency:
30
38
  * - Indicator ids are unique within a playbook.
@@ -438,28 +446,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
438
446
  }
439
447
 
440
448
  // rwep_threshold ordering. Hard error — a misordered threshold actively
441
- // breaks the scoring path.
442
- const rwep = direct.rwep_threshold || {};
443
- if (
444
- typeof rwep.close === 'number' &&
445
- typeof rwep.monitor === 'number' &&
446
- typeof rwep.escalate === 'number'
447
- ) {
448
- if (!(rwep.close <= rwep.monitor && rwep.monitor <= rwep.escalate)) {
449
- err(
450
- `phases.direct.rwep_threshold: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
451
- );
452
- }
453
- for (const [k, v] of [
454
- ['close', rwep.close],
455
- ['monitor', rwep.monitor],
456
- ['escalate', rwep.escalate],
457
- ]) {
458
- if (v < 0 || v > 100) {
459
- err(`phases.direct.rwep_threshold.${k}: ${v} outside 0..100`);
460
- }
461
- }
462
- }
449
+ // breaks the scoring path. Factored into a helper so the same check runs
450
+ // against the playbook-level direct phase AND any directive-level
451
+ // phase_overrides.direct copy the runner deep-merges at run time.
452
+ checkRwepThreshold(direct.rwep_threshold, 'phases.direct.rwep_threshold');
453
+
454
+ // clock_starts closed-vocab against the base govern phase. Factored into a
455
+ // helper for the same reason — an override-supplied jurisdiction_obligations
456
+ // copy must pass the same closed-vocabulary gate.
457
+ checkClockStarts(govern.jurisdiction_obligations, 'phases.govern.jurisdiction_obligations');
463
458
 
464
459
  // notification_actions obligation_ref resolution.
465
460
  const obligationKeys = new Set(
@@ -553,22 +548,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
553
548
  );
554
549
  }
555
550
 
556
- // Closed controlled-vocabulary enums: a value outside the schema's closed
557
- // enum is an error, not a warning, so a typo cannot ship. Unlike the
558
- // evolving-drift enums (artifact `type`, indicator `type`) which the generic
559
- // validator keeps at warning, these vocabularies are fixed and load-bearing
560
- // (clock_starts decides when a notification deadline starts counting;
561
- // frameworks_in_scope drives gap-analysis routing).
562
- if (ctx.clockStartsEnum) {
563
- for (const [i, o] of (govern.jurisdiction_obligations || []).entries()) {
564
- if (!o || typeof o !== 'object') continue;
565
- if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
566
- err(
567
- `phases.govern.jurisdiction_obligations[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
568
- );
569
- }
570
- }
571
- }
551
+ // frameworks_in_scope closed vocabulary. A value outside the schema's closed
552
+ // enum is an error, not a warning, so a typo cannot ship frameworks_in_scope
553
+ // drives gap-analysis routing.
572
554
  if (ctx.frameworksEnum) {
573
555
  for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
574
556
  if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
@@ -579,7 +561,120 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
579
561
  }
580
562
  }
581
563
 
564
+ // Directive-level coverage. A directive's applies_to fields and its
565
+ // phase_overrides both reach the runner live (the runner selects directives
566
+ // by id, deep-merges phase_overrides into the base phase, and surfaces
567
+ // applies_to in the discovery API) but neither was cross-referenced or
568
+ // re-validated — so a stale CVE/TTP reference or a tampered override
569
+ // (bogus clock_starts, out-of-range rwep_threshold) shipped past this gate
570
+ // even though the identical content is a hard error at playbook level.
571
+ for (const [i, d] of (playbook.directives || []).entries()) {
572
+ if (!d || typeof d !== 'object') continue;
573
+ const label = d.id ? `directives[${i}] (${d.id})` : `directives[${i}]`;
574
+
575
+ // applies_to.{cve,atlas_ttp,attack_technique} resolution, mirroring the
576
+ // domain-ref checks at warning severity (promoted to error under --strict).
577
+ const at = d.applies_to;
578
+ if (at && typeof at === 'object') {
579
+ if (at.cve && !ctx.cveKeys.has(at.cve)) {
580
+ warn(`${label}.applies_to.cve: unresolved "${at.cve}" (not in data/cve-catalog.json)`);
581
+ }
582
+ if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
583
+ warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
584
+ }
585
+ // Guard the attack catalog the same way domain.attack_refs does:
586
+ // attack-techniques.json is loaded via readJsonIfExists and may be null.
587
+ if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
588
+ warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
589
+ }
590
+ }
591
+
592
+ // phase_overrides re-validation. The runner merges these into the base
593
+ // phase before govern()/close() consume them, so an override-supplied
594
+ // clock_starts or rwep_threshold must pass the same gates as the base
595
+ // phase or the regulatory clock / scoring path breaks at run time.
596
+ const ov = d.phase_overrides;
597
+ if (ov && typeof ov === 'object') {
598
+ if (ov.govern && typeof ov.govern === 'object') {
599
+ checkClockStarts(
600
+ ov.govern.jurisdiction_obligations,
601
+ `${label}.phase_overrides.govern.jurisdiction_obligations`,
602
+ );
603
+ }
604
+ if (ov.direct && typeof ov.direct === 'object') {
605
+ checkRwepThreshold(
606
+ ov.direct.rwep_threshold,
607
+ `${label}.phase_overrides.direct.rwep_threshold`,
608
+ );
609
+ }
610
+ // An override-supplied notification obligation_ref must resolve against
611
+ // the EFFECTIVE obligation set the runner sees after the merge: the
612
+ // base govern obligations, plus any the override adds. Warning severity,
613
+ // matching the base-phase obligation_ref precedent.
614
+ if (ov.close && typeof ov.close === 'object' && Array.isArray(ov.close.notification_actions)) {
615
+ const overrideObligations =
616
+ (ov.govern && Array.isArray(ov.govern.jurisdiction_obligations))
617
+ ? ov.govern.jurisdiction_obligations
618
+ : (govern.jurisdiction_obligations || []);
619
+ const effectiveKeys = new Set(overrideObligations.map(obligationKey));
620
+ for (const [j, na] of ov.close.notification_actions.entries()) {
621
+ if (!na || typeof na !== 'object') continue;
622
+ if (na.obligation_ref && !effectiveKeys.has(na.obligation_ref)) {
623
+ warn(
624
+ `${label}.phase_overrides.close.notification_actions[${j}].obligation_ref: unresolved "${na.obligation_ref}" — no matching jurisdiction_obligations entry (synthesized as "<jurisdiction>/<regulation> <window_hours>h")`,
625
+ );
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+
582
632
  return findings;
633
+
634
+ // ---- local helpers (hoisted; close over `findings`/`ctx`/`err`) ----
635
+
636
+ // rwep_threshold ordering + range. close <= monitor <= escalate, each in
637
+ // 0..100. Error severity — a misordered or out-of-range threshold actively
638
+ // breaks the scoring path. `pathPrefix` keeps the message accurate whether
639
+ // the source is the base phase or a directive override.
640
+ function checkRwepThreshold(rwepObj, pathPrefix) {
641
+ const rwep = rwepObj || {};
642
+ if (
643
+ typeof rwep.close === 'number' &&
644
+ typeof rwep.monitor === 'number' &&
645
+ typeof rwep.escalate === 'number'
646
+ ) {
647
+ if (!(rwep.close <= rwep.monitor && rwep.monitor <= rwep.escalate)) {
648
+ err(
649
+ `${pathPrefix}: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
650
+ );
651
+ }
652
+ for (const [k, v] of [
653
+ ['close', rwep.close],
654
+ ['monitor', rwep.monitor],
655
+ ['escalate', rwep.escalate],
656
+ ]) {
657
+ if (v < 0 || v > 100) {
658
+ err(`${pathPrefix}.${k}: ${v} outside 0..100`);
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ // clock_starts closed-vocabulary check over a jurisdiction_obligations list.
665
+ // Error severity — clock_starts decides when a notification deadline starts
666
+ // counting; an out-of-vocabulary value silently never starts the clock.
667
+ function checkClockStarts(obligations, pathPrefix) {
668
+ if (!ctx.clockStartsEnum || !Array.isArray(obligations)) return;
669
+ for (const [i, o] of obligations.entries()) {
670
+ if (!o || typeof o !== 'object') continue;
671
+ if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
672
+ err(
673
+ `${pathPrefix}[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
674
+ );
675
+ }
676
+ }
677
+ }
583
678
  }
584
679
 
585
680
  /* Cross-playbook mutex-reciprocity check.