clearotron 0.3.2 → 0.3.3-beta.1

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.
Files changed (123) hide show
  1. package/CONTRIBUTING.md +12 -0
  2. package/INSTALL.md +8 -0
  3. package/bin/onboard.mjs +109 -13
  4. package/bin/start.mjs +1 -1
  5. package/build-info.json +2 -2
  6. package/demo/MANIFEST.json +27 -0
  7. package/docs/INTAKE.md +8 -0
  8. package/docs/architecture/04-configuration-reference.md +29 -11
  9. package/driver/CHANGELOG.md +49 -0
  10. package/driver/citation-census.json +3 -3
  11. package/driver/clearance-variants-record.mjs +12 -1
  12. package/driver/common-law-coverage-status.mjs +113 -0
  13. package/driver/config-inventory.mjs +1 -1
  14. package/driver/contract-audit.mjs +1 -1
  15. package/driver/contract-e3-backlog.mjs +37 -37
  16. package/driver/contract-vocabulary.mjs +8 -8
  17. package/driver/coverage-form-io.mjs +3 -1
  18. package/driver/coverage-form.mjs +38 -11
  19. package/driver/coverage-ledger.mjs +37 -7
  20. package/driver/coverage-union.mjs +2 -2
  21. package/driver/crowd-context.mjs +19 -6
  22. package/driver/dev-portal.mjs +3 -3
  23. package/driver/drainer-identity.mjs +1 -1
  24. package/driver/driver.config.mjs +80 -9
  25. package/driver/engine/CONTRACT.md +3 -2
  26. package/driver/engine/anthropic-agent.mjs +34 -7
  27. package/driver/engine/mcp/clarivate-server.mjs +4 -2
  28. package/driver/engine/mcp/corsearch-server.mjs +3 -1
  29. package/driver/engine/mcp/coverage-server.mjs +1 -1
  30. package/driver/engine/mcp/dispositions-server.mjs +47 -5
  31. package/driver/engine/mcp/euipo-server.mjs +2 -0
  32. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  33. package/driver/engine/mcp/gather-config.mjs +8 -2
  34. package/driver/engine/mcp/probe-server.mjs +37 -0
  35. package/driver/engine/mcp/proposal-fields.mjs +45 -0
  36. package/driver/engine/mcp/recording-server.mjs +30 -0
  37. package/driver/engine/mcp/signa-server.mjs +2 -0
  38. package/driver/engine/mcp/supplemental.mjs +89 -12
  39. package/driver/engine/mcp/unit-note-server.mjs +50 -0
  40. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  41. package/driver/engine/openai-agent.mjs +7 -0
  42. package/driver/engine/probe.mjs +67 -14
  43. package/driver/engine/tool-refusal.mjs +16 -0
  44. package/driver/enqueue-schema.mjs +2 -2
  45. package/driver/envelope-settle.mjs +82 -13
  46. package/driver/findings-model.mjs +4 -4
  47. package/driver/gateway.mjs +18 -2
  48. package/driver/manager-groups-verdict.mjs +1 -1
  49. package/driver/matter-frame-record.mjs +24 -7
  50. package/driver/named-band.mjs +1 -1
  51. package/driver/package.json +1 -1
  52. package/driver/partial-payload-baseline.json +12 -3
  53. package/driver/pipeline-knockout.mjs +3 -3
  54. package/driver/pipeline.mjs +154 -50
  55. package/driver/plan-run-agreement-verdict.mjs +49 -0
  56. package/driver/portal-service.mjs +8 -4
  57. package/driver/progress.mjs +14 -3
  58. package/driver/publish/index.mjs +41 -26
  59. package/driver/publish/report-data.mjs +4 -3
  60. package/driver/publish/xlsx.mjs +26 -4
  61. package/driver/queue-markers.mjs +44 -0
  62. package/driver/queue-watch-verdict.mjs +2 -2
  63. package/driver/reference-score.mjs +10 -2
  64. package/driver/register-availability.mjs +2 -2
  65. package/driver/register-plan.mjs +313 -21
  66. package/driver/roster-verdict.mjs +1 -1
  67. package/driver/runner.mjs +26 -2
  68. package/driver/settle-stamp.mjs +10 -3
  69. package/driver/skills/clearance-common-law/SKILL.md +2 -0
  70. package/driver/skills/clearance-register/SKILL.md +44 -3
  71. package/driver/skills/clearance-register/digest.md +5 -5
  72. package/driver/skills/clearance-register/providers/clarivate.md +1 -1
  73. package/driver/skills/clearance-register/unit.md +39 -0
  74. package/driver/skills/clearance-variants/SKILL.md +1 -1
  75. package/driver/skills/matter-frame/SKILL.md +4 -2
  76. package/driver/stages.mjs +12 -5
  77. package/driver/status-snapshot.mjs +2 -2
  78. package/driver/suite-census.json +293 -29
  79. package/driver/synthesis-record.mjs +80 -2
  80. package/driver/unit-file-drift.mjs +3 -3
  81. package/driver/unit-inventory.mjs +2 -2
  82. package/driver/unit-state-verdict.mjs +1 -1
  83. package/driver/updater-identity.mjs +2 -3
  84. package/driver/variant-manifest-model.mjs +11 -1
  85. package/driver/verify.mjs +5 -5
  86. package/driver/withheld-families.mjs +104 -0
  87. package/mcp-server/CHANGELOG.md +8 -0
  88. package/mcp-server/lib/brief.mjs +16 -12
  89. package/mcp-server/lib/runs.mjs +1 -1
  90. package/mcp-server/package.json +1 -1
  91. package/mcp-server/server.mjs +3 -2
  92. package/package.json +2 -2
  93. package/portal-ui/dist/assets/{index-DMthc7PQ.js → index-GBbbyQxc.js} +22 -4
  94. package/portal-ui/dist/index.html +1 -1
  95. package/portal-ui/package.json +1 -1
  96. package/providers/_shared/count.mjs +2 -2
  97. package/providers/_shared/enumerate.mjs +15 -2
  98. package/providers/_shared/execute-plan.mjs +19 -1
  99. package/providers/_shared/plan-guards.mjs +40 -0
  100. package/providers/clarivate/src/capabilities.js +15 -5
  101. package/providers/clarivate/src/core.js +41 -5
  102. package/providers/corsearch/src/capabilities.js +4 -0
  103. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  104. package/providers/oauth-mcp-bridge/package.json +1 -1
  105. package/providers/signa/src/capabilities.js +22 -8
  106. package/providers/signa/src/core.js +12 -1
  107. package/scripts/demo-evidence.mjs +114 -0
  108. package/scripts/e2e.mjs +1 -1
  109. package/scripts/engine-probe.mjs +6 -5
  110. package/scripts/env-audit.mjs +1 -1
  111. package/scripts/freeze-example-run.mjs +3 -3
  112. package/scripts/live-surface-check.mjs +32 -33
  113. package/scripts/mint-suite-census.mjs +66 -0
  114. package/scripts/package-size-budget.mjs +117 -0
  115. package/scripts/register-plan-shape.mjs +259 -0
  116. package/scripts/release-note-required.mjs +38 -1
  117. package/scripts/repo-writes.mjs +1 -1
  118. package/scripts/report-sections-render-check.mjs +7 -3
  119. package/scripts/score.mjs +7 -1
  120. package/scripts/settings-render-check.mjs +36 -0
  121. package/scripts/travelling-predicates.mjs +1 -1
  122. package/shared/identifier-scan.mjs +22 -5
  123. package/shared/scroll-settle.mjs +67 -0
@@ -194,6 +194,16 @@ const TERMINAL_STATES = new Set(["delivered", "failed", "cancelled"]);
194
194
  * preflight before an unattended run remains the operational guard for that case; this closes the
195
195
  * invisibility for every failure short of it, which is the honest claim.
196
196
  */
197
+ // ── THE REVIEWER'S SIGN-OFF IS NOT THE CLEARANCE'S ANSWER (ruled 2026-09-22) ─────────────────────────
198
+ //
199
+ // The narrative-refutation stage signs the draft off CLEAR, CONDITIONAL or BLOCKING. At the top of the run
200
+ // record as `verdict` it read as the clearance's answer, competed with the rating, and a BLOCKING one was
201
+ // printed to clients as "on hold" although nothing holds delivery on it. It now lives under the stage
202
+ // that produced it, named for what it is: `review.signoff`. The rating (`tier`) is the run's headline.
203
+ // A record written before the move carries the old field; `readSignoff` reads either.
204
+ export const signoffPatch = (signoff) => ({ review: { signoff: signoff ?? null } });
205
+ export const readSignoff = (s) => s?.review?.signoff ?? s?.verdict ?? null;
206
+
197
207
  export function writeRunStatus(ctx, patch = {}, runDirOverride = null, { critical = false } = {}) {
198
208
  const runDir = runDirOverride ?? ctx?.run?.runDir;
199
209
  // — A STATE WRITE THAT CANNOT FIND ITS RUN DIRECTORY SAYS SO. It used to `return` here, in
@@ -222,6 +232,7 @@ export function writeRunStatus(ctx, patch = {}, runDirOverride = null, { critica
222
232
  const { __stateReset, ...rest } = patch;
223
233
  const old = readRunStatus(runDir);
224
234
  const merged = { ...old, ...rest, updatedAt: nowISO() };
235
+ delete merged.verdict; // the retired top-level field: a record rewritten here carries `review.signoff` alone
225
236
  if (typeof rest.stepIndex === "number" && typeof old.stepIndex === "number" && old.stepIndex > rest.stepIndex) {
226
237
  // keep the furthest step ever reached (label/n/total move together with the index)
227
238
  merged.stepIndex = old.stepIndex;
@@ -378,7 +389,7 @@ export function identitySeed() {
378
389
  // first-write-wins makes it the honest wall-clock start across any number of resumes. A resume instead
379
390
  // records itself: resumedAt (this resume's clock) + attempts (fresh run = 1, each resume +1), and
380
391
  // threads __stateReset because the resume guard has deliberately cleared a terminal sentinel. The
381
- // verdict/failedStage/reason resets stay (a resumed run owes a fresh outcome);
392
+ // review/failedStage/reason resets stay (a resumed run owes a fresh outcome);
382
393
  // recoveryAttempts/recoveryHistory stay OUT of the seed (they are the park budget's memory).
383
394
  export function seedRunStatus(ctx, { resume = false } = {}) {
384
395
  const { job, run, agent } = ctx;
@@ -412,7 +423,7 @@ export function seedRunStatus(ctx, { resume = false } = {}) {
412
423
  stepIndex: first.index, stepLabel: first.label, stepN: first.n, stepTotal: first.total,
413
424
  currentStep: currentStepOf(first),
414
425
  lastStage: null,
415
- verdict: null,
426
+ review: null,
416
427
  url: null,
417
428
  failedStage: null,
418
429
  reason: null,
@@ -489,7 +500,7 @@ export function lineFor(s) {
489
500
  // The rollup now carries the send state loudly; clearotron-deliver flips sendPending:false on send.
490
501
  const pending = s.sendPending === true ? " — 📮 SEND PENDING (email/WhatsApp NOT yet out — run clearotron-deliver)" : "";
491
502
  if (s.state === "delivered") {
492
- const v = s.verdict ? ` (${s.verdict})` : "";
503
+ const v = s.tier ? ` (${s.tier})` : ""; // the rating, not the reviewer's sign-off word
493
504
  return `- ${head} — delivered${v}${s.url ? ` — ${s.url}` : ""}${pending}`;
494
505
  }
495
506
  if (s.state === "failed") {
@@ -14,7 +14,7 @@ import { parseReport, parseAudit, parseSections, parseBlocks, stripInternal, par
14
14
  import { renderHtml, parseActionBuckets, actYouConditions } from './render.mjs';
15
15
  import { buildAudit } from './xlsx.mjs';
16
16
  import { parseFindingsJson, parseFindingsJsonLenient, deriveDisplayVerdict, joinFindingToBlock, CLIENT_TIER_BY_COMPOSITE, projectCoverageJudgment } from '../findings-model.mjs';
17
- import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; // — and why an absence did not close
17
+ import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; import { coverageFormStamp, readCoverageForm } from '../coverage-form-io.mjs'; import { coverageUnitLabel } from '../coverage-ledger.mjs'; // — and why an absence did not close
18
18
  import { clearanceReportData } from './report-data.mjs';
19
19
  import { searchDepthRecord, planTerritoriesOf } from './search-depth.mjs'; import { bandRecords } from '../named-band.mjs'; // how much was read to reach the answer, as counts and tokens
20
20
  import { parseFrameworkManifest } from '../framework.mjs';
@@ -251,11 +251,11 @@ function indexRows(runs, { reportFile, linkPrefix = '', showAudit = true, client
251
251
  <td>${anonMark(r.title, { key: ck, run: r.runId })}</td>
252
252
  <td>${anonClient(r.client, ck)}</td>
253
253
  <td><span class="b b-${attrValue(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${!client && !qcFailed && r.clientGate?.inputsAbsent?.length ? ` <span class="disc" title="${escAttr(`published without ${r.clientGate.inputsAbsent.join(', ')} — each declared optional, so the release stands; the report was built without it`)}">◦ built without an input</span>` : ''}${
254
- // spec 64 — the stance clause of THE one risk statement beside the (labelled) band pill, so the
255
- // index can never show a bare severity word that reads as the whole answer. The tier word leads
256
- // the statement; the pill already shows it, so the cell carries the clause after the first " — ".
257
- // Legacy meta.json (no statement) renders this cell byte-identically.
258
- r.statement ? `<span class="stmt" title="${escAttr(r.statement)}">${esc(String(r.statement).split(' — ').slice(1).join(' — ') || r.statement)}</span>` : ''}</td>
254
+ // The report's own conclusion beside the band pill (ruled 2026-09-22: the report is the master),
255
+ // never a second summary. A meta.json written before the caption was kept shows the pill alone:
256
+ // its stored statement could say "on hold", which was never true. The pill carries the rating,
257
+ // the single headline on this short surface.
258
+ r.caption ? `<span class="stmt" title="${escAttr(r.caption)}">${esc(r.caption)}</span>` : ''}</td>
259
259
  <td>${runCell}</td>${showAudit ? `
260
260
  <td>${r.auditFile ? `<a ${runAttrs} href="${linkPrefix}${attrValue(r.runId)}/${attrValue(r.auditFile)}">audit.xlsx</a>` : '—'}</td>` : ''}
261
261
  </tr>`;
@@ -872,6 +872,15 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
872
872
  } catch { /* the workbook row is the record that matters; this line is the second copy */ }
873
873
  }
874
874
 
875
+ // ── THE WAITING FAMILIES THE READING TURN WITHHELD (withheld-families.mjs) ───────────────────────
876
+ //
877
+ // Ruled 2026-09-22: a family the reading turn chose not to ask is recorded with its reason in the run's
878
+ // record and in the audit workbook, and NOT in the report. The coverage form keeps these rows out of
879
+ // the ledger the report is built from, so this sheet is their one reader-facing place. Same builder,
880
+ // same four columns and the same state as the probes above; the area is the driver's own label and the
881
+ // words are the reading turn's reason.
882
+ const withheldFamilies = withheldFamilyRows(runDir ?? dirname(reportMd));
883
+
875
884
  // doc 50 — the run's FROZEN framework manifest (band vocabulary). Present on band-doctrine runs;
876
885
  // absent on every archived run (they render byte-identically on the legacy paths).
877
886
  let framework = null;
@@ -1134,7 +1143,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1134
1143
  // the same rule (the workbook's own BANNED gate had already started firing on the raw detail —
1135
1144
  // advisory, so CI stayed green). reviewReceipts.lint keeps its raw detail for the internal
1136
1145
  // readers above (fetchState reads registry-record-coverage's URIs out of it).
1137
- counts = await buildAudit({ droppedConditions, undispatchedProbes, findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1146
+ counts = await buildAudit({ droppedConditions, undispatchedProbes, withheldFamilies, findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1138
1147
  grpRead(join(poolRunDir, auditFile), 0o640);
1139
1148
  if (counts?.gateViolations?.length) console.warn(`[audit-workbook] advisory: ${counts.gateViolations.join(' | ')}`);
1140
1149
  } catch (e) {
@@ -1342,7 +1351,8 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1342
1351
  // writer). Absent on legacy runs — regenIndex re-reads every historical meta.json, so consumers
1343
1352
  // null-guard and old rows render byte-identically.
1344
1353
  statement: verdictInfo?.statement ?? undefined,
1345
- verdict: verdictInfo?.verdict ?? undefined,
1354
+ caption: fm.overall_caption || undefined, // the report's own conclusion, which the run list quotes
1355
+ review: verdictInfo?.verdict ? { signoff: verdictInfo.verdict } : undefined, // the reviewer's sign-off, not the clearance's answer (ruled 2026-09-22)
1346
1356
  // doc 50 — which framework rated this run (custom vs Generic default) + its ladder, for the archive
1347
1357
  // card, the email table sort/colour and the per-customer index; absent on archived runs forever.
1348
1358
  framework: framework ? { key: framework.framework_key, title: framework.title,
@@ -1369,7 +1379,8 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1369
1379
  // `<origin>/<runId>/report.html` — where the documents sit on disk, which is not an application route.
1370
1380
  // Composed rather than spelled here so the report link is built the same way the audit link always was.
1371
1381
  const url = reportRouteFor(poolUrl, runId);
1372
- return { runId, auditFile, counts, total, url, poolRunDir, auditError, findingsError, customerKey: customerKey || 'generic', clientGate };
1382
+ return { runId, auditFile, counts, total, url, poolRunDir, auditError, findingsError, customerKey: customerKey || 'generic', clientGate,
1383
+ caption: fm.overall_caption || null }; // the report's own conclusion, for the run record's short surfaces
1373
1384
  }
1374
1385
 
1375
1386
  // Compose the notification email body — MARKDOWN (the mail tool renders markdown→HTML; raw HTML gets escaped).
@@ -1739,17 +1750,13 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1739
1750
  // the engine clamp reasons (opts.conditions = verdict.json reasons). Machinery-only clamps (no client
1740
1751
  // item authored) degrade to one generic plain line — never raw engine jargon, never truncated mid-sentence.
1741
1752
  const emailConditions = opts?.verdict === 'CONDITIONAL' ? actYouConditions(parseActionBuckets(secs['Actions']).you) : [];
1742
- // THE BANNER SPEAKS THE RUN'S OWN SENTENCE. It used to print the delivery gate's word — "Delivered as
1743
- // BLOCKING" — which is engine vocabulary and, beside a Medium rating on the report, reads as a
1744
- // contradiction the reader cannot resolve (measured 2026-09-20). The statement is composed once by the
1745
- // sidecar writer and is what the report and the index already show; a run recorded before statements
1746
- // were persisted keeps the old line, which is all such a run has.
1747
- const bound = String(opts?.statement ?? '').trim();
1748
- const boundHead = bound
1749
- ? esc(bound)
1750
- : `Delivered as ${esc(opts?.verdict ?? '')}${opts?.verdict === 'CONDITIONAL' ? ' — subject to:' : '.'}`;
1751
- const verdictBound = (opts?.verdict && opts.verdict !== 'CLEAR')
1752
- ? `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>${boundHead}</b>${opts.verdict === 'CONDITIONAL' ? `<br>${(emailConditions.length ? emailConditions : ['the open items set out in the report, before relying on a clean result']).map((r) => `• ${cell(String(r))}`).join('<br>')}` : ''}</p>`
1753
+ // THE REPORT IS THE MASTER (ruled 2026-09-22). The banner used to print a second summary composed
1754
+ // beside the report — "On hold — …" on a run the report called conditional, although nothing is ever
1755
+ // held — and before that the delivery gate's word. It now quotes the report: the rating, then the
1756
+ // report's own conclusion (below), and where the report names what only the client can close, those
1757
+ // items under the report's own heading. Nothing here composes a sentence of its own.
1758
+ const verdictBound = opts?.verdict === 'CONDITIONAL'
1759
+ ? `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>Only you can close these</b><br>${(emailConditions.length ? emailConditions : ['the open items set out in the report, before relying on a clean result']).map((r) => `• ${cell(String(r))}`).join('<br>')}</p>`
1753
1760
  : '';
1754
1761
 
1755
1762
  const reviewHeadline = `<div style="${FONT};font-size:11pt;color:#1a1a2e;margin:0 0 14px">`
@@ -1757,12 +1764,11 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1757
1764
  + verdictBound
1758
1765
  + `<p style="margin:0 0 8px"><b>${[opts?.productName ? esc(String(opts.productName)) : '', esc(fm.title || '')].filter(Boolean).join(' — ')} (${esc(fm.matter || '')})</b></p>`
1759
1766
  // T2 (H5): the derived tier (verdict sidecar, passed by the driver) outranks the
1760
- // model-authored fm label — one authority on every surface.
1761
- // spec 64: with a composed statement the headline reads band + stance as ONE sentence ("High —
1762
- // conditional on: …"); legacy (no statement) keeps the labelled tier line byte-identically.
1763
- + (opts?.statement
1764
- ? `<p style="margin:0 0 6px"><b>${esc(opts.statement)}</b> ${cell(fm.overall_caption || '')}</p>`
1765
- : ((opts?.tier ?? fm.overall_label) ? `<p style="margin:0 0 6px"><b>Overall risk: ${esc(opts?.tier ?? fm.overall_label)}.</b> ${cell(fm.overall_caption || '')}</p>` : ''))
1767
+ // model-authored fm label — one authority on every surface. The rating leads, then the report's own
1768
+ // conclusion verbatim (ruling 244); with no rating recorded, the conclusion stands alone.
1769
+ + ((opts?.tier ?? fm.overall_label)
1770
+ ? `<p style="margin:0 0 6px"><b>Overall risk: ${esc(opts?.tier ?? fm.overall_label)}.</b> ${cell(fm.overall_caption || '')}</p>`
1771
+ : (fm.overall_caption ? `<p style="margin:0 0 6px">${cell(fm.overall_caption)}</p>` : ''))
1766
1772
  + reportLink
1767
1773
  + (fm.handling_note ? `<p style="margin:0 0 8px;color:#7a2b12"><b>Handling note:</b> ${cell(fm.handling_note)}</p>` : '')
1768
1774
  // T9 (K3): methodology identity — which profile/framework rated this run (+ verifiable sha).
@@ -1811,3 +1817,12 @@ function officeLinksFor(findings, recordsByUri, runOrigins) {
1811
1817
  if (links) console.log(`[record-links] ${links.summary}`);
1812
1818
  return links;
1813
1819
  }
1820
+
1821
+ /** The coverage form's family rows judged withheld, as audit-workbook coverage rows. Never throws. */
1822
+ export function withheldFamilyRows(runDir) {
1823
+ try {
1824
+ const { rows } = readCoverageForm(runDir, coverageFormStamp(runDir).formName);
1825
+ return (rows ?? []).filter((r) => r?.kind === 'family' && r.status === 'withheld-by-judgment' && r.reason)
1826
+ .map((r) => ({ area: coverageUnitLabel(r.unit), state: 'not-searched', note: String(r.reason) }));
1827
+ } catch { return []; }
1828
+ }
@@ -98,9 +98,10 @@ export function clearanceReportData({
98
98
  auditFile: auditFile || null,
99
99
  level: { searchLevel: searchLevel ?? null, stageLabel: stageLabel ?? null },
100
100
  framework: framework ? { key: framework.framework_key, title: framework.title, bands: framework.bands.map((b) => ({ label: b.label, tone: b.tone })) } : null,
101
- // The one risk statement + its derivation — the exact record every other surface joins (spec 64).
102
- verdict: verdictInfo ? {
103
- verdict: verdictInfo.verdict ?? null,
101
+ // The rating + the one risk statement — the exact record every other surface joins (spec 64). Named
102
+ // `rating` (ruled 2026-09-22): as `verdict` it read as the clearance's answer and carried the reviewer's
103
+ // sign-off word beside the band. The sign-off stays in the review stage's own record, not the client's.
104
+ rating: verdictInfo ? {
104
105
  tier: verdictInfo.tier ?? null,
105
106
  badge: verdictInfo.badge ?? null,
106
107
  band: verdictInfo.band ?? null,
@@ -302,7 +302,7 @@ export function searchRows(auditParsed, { findings = [], joinedTerms = null, reg
302
302
  const byTerm = new Map();
303
303
  for (const n of cl) {
304
304
  const key = (n.search_term || '').trim();
305
- if (!byTerm.has(key)) { byTerm.set(key, { plats: new Set(), results: new Set(), notes: [], gaps: new Set() }); order.push(key); }
305
+ if (!byTerm.has(key)) { byTerm.set(key, { plats: new Set(), results: new Set(), notes: [], gaps: new Set(), gapReasons: new Map() }); order.push(key); }
306
306
  const g = byTerm.get(key);
307
307
  if (n.platform) g.plats.add(n.platform);
308
308
  if (n.result) g.results.add(n.result);
@@ -310,7 +310,17 @@ export function searchRows(auditParsed, { findings = [], joinedTerms = null, reg
310
310
  // — the surfaces that could NOT be searched are counted separately from the ones that came back
311
311
  // empty. The dedup unions results across platforms, so one gapped surface among many used to vanish
312
312
  // entirely into a term-level "0 — clean".
313
- if (notSearched(n, n.result, n.notes)) g.gaps.add(n.platform || '?');
313
+ // — THE REGISTER'S OWN WORDS, KEPT. A surface that could not be searched arrives here with the
314
+ // reason it gave (`audit-from-spine` puts the gap's `error` into `notes`), and this row used to
315
+ // record only that it was a gap. So a query a provider REFUSED — "content policy (HTTP 400)" —
316
+ // and one that was simply never run rendered as the same sentence, and the reason was in the
317
+ // workbook's hand the whole time. `audit.md` kept it; this file dropped it.
318
+ if (notSearched(n, n.result, n.notes)) {
319
+ const surface = n.platform || '?';
320
+ g.gaps.add(surface);
321
+ const said = plainNote(n.notes || '').trim();
322
+ if (said) g.gapReasons.set(surface, said);
323
+ }
314
324
  }
315
325
  push({ 'Search term / variant': COMMON_LAW_SECTION, Scope: '', Result: '', Outcome: '', Note: '', _section: true });
316
326
  for (const term of order) {
@@ -329,7 +339,19 @@ export function searchRows(auditParsed, { findings = [], joinedTerms = null, reg
329
339
  // searched at all. The gap count is carried beside the hit flag, never folded into it.
330
340
  const gaps = [...g.gaps];
331
341
  const ran = Math.max(0, n - gaps.length);
332
- const gapNote = gaps.length ? ` ${gaps.length} surface${gaps.length === 1 ? '' : 's'} could not be searched (${gaps.slice(0, 4).join(', ')}${gaps.length > 4 ? ', …' : ''}) — this term is NOT closed across them.` : '';
342
+ // A REFUSAL AND A DROP ARE DIFFERENT FACTS, and only one of them is about the register. Where the
343
+ // surface said why, its words are quoted; where nothing was recorded, the row says that instead of
344
+ // implying a reason it does not have. A reader deciding whether to re-run needs to know which:
345
+ // a content-policy refusal will refuse again, a query that never ran may simply run.
346
+ const said = gaps.map((s) => [s, g.gapReasons.get(s)]).filter(([, r]) => r);
347
+ const silent = gaps.filter((s) => !g.gapReasons.has(s));
348
+ const reasonNote = said.length
349
+ ? ` The surface${said.length === 1 ? '' : 's'} gave a reason: ${said.map(([s, r]) => `${s} — ${r}`).join('; ')}.`
350
+ : '';
351
+ const silentNote = silent.length && said.length
352
+ ? ` ${silent.length} recorded no reason (${silent.slice(0, 4).join(', ')}${silent.length > 4 ? ', …' : ''}).`
353
+ : silent.length ? ' No reason was recorded for any of them.' : '';
354
+ const gapNote = gaps.length ? ` ${gaps.length} surface${gaps.length === 1 ? '' : 's'} could not be searched (${gaps.slice(0, 4).join(', ')}${gaps.length > 4 ? ', …' : ''}) — this term is NOT closed across them.${reasonNote}${silentNote}` : '';
333
355
  const outcome = anyHit ? (joined ? '→ Findings' : 'Reviewed — not carried as a conflict')
334
356
  : gaps.length && !ran ? NOT_SEARCHED_OUTCOME // nothing ran at all: never a closure claim
335
357
  : gaps.length ? 'No conflict — partial' // some ran clean, some never ran: say both
@@ -637,7 +659,7 @@ export async function buildAudit(contract, auditParsed, outPath, mark = '', fm =
637
659
  // shape and not a new sheet, and the words are the run receipt's own.
638
660
  addSheet(wb, 'Coverage & gaps', COVERAGE_COLS,
639
661
  [...coverageRows(coverage), ...coverageRows(contract?.droppedConditions || []),
640
- ...coverageRows(contract?.undispatchedProbes || [])], (row, _d, kept) => {
662
+ ...coverageRows(contract?.undispatchedProbes || []), ...coverageRows(contract?.withheldFamilies || [])], (row, _d, kept) => {
641
663
  if (!kept.has('State')) return;
642
664
  const st = row.getCell('State'); const f = STATE_FILL[String(st.value).trim()];
643
665
  if (f) { st.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF' + f } }; st.font = { bold: true }; }
@@ -78,6 +78,50 @@ export const PROSE_PARTS = {
78
78
  campaignShape: ".campaignShape.txt", // P2-C (Round-2 §8a): campaign-shape facts — stated launch shape
79
79
  };
80
80
 
81
+ // ── ONE GOODS DESCRIPTION, TWO SPELLINGS, AND EVERY READER GETS THE SAME ANSWER ────────────────────
82
+ //
83
+ // Intake accepts a goods description under the current field or under the older spelling a forwarding
84
+ // agent may still send. That acceptance was the gate's private knowledge: the scope stamp read the
85
+ // current field alone, so a job written the older way passed intake and recorded that the matter named
86
+ // no goods. Both sites were individually right and nothing compared them.
87
+ //
88
+ // Fixing the scope stamp alone moved the seam rather than closing it. Ten other readers — the clearance
89
+ // and knockout prompts, the pharmaceutical test, the product-context derivation, the portal's own row
90
+ // and the plan preview — still read the current field, so the scope file would carry a value the
91
+ // prompts never saw, and the validator comparing the two would report the disagreement as a fault in
92
+ // the frame.
93
+ //
94
+ // SO THE FOLD HAPPENS ONCE, WHERE THE JOB IS ASSEMBLED FOR A RUN, and every reader downstream sees one
95
+ // field. Nothing is written back to the queue file: the manifest stays exactly as it was filed, which is
96
+ // the same rule the intake gate follows about stamping derived values onto a job.
97
+ //
98
+ // Here, in the module that already owns the queue's own vocabulary, because the gate and the assembly
99
+ // must never hold two opinions about what counts. A hand-copied predicate is how they drifted the first
100
+ // time.
101
+
102
+ /** The field names a goods description may arrive under, newest first. */
103
+ export const GOODS_FIELDS = ["goods", "use"];
104
+
105
+ /** The goods description a job carries under any accepted spelling, or null. Pure. */
106
+ export function goodsOf(job) {
107
+ for (const f of GOODS_FIELDS) {
108
+ const v = job?.[f];
109
+ if (v != null && String(v).trim()) return v;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * The same job with its goods description under the current field, whichever spelling it arrived in.
116
+ * Returns the job unchanged when there is nothing to fold, so a job already on the current field is
117
+ * byte-identical and no caller has to ask which it got.
118
+ */
119
+ export function withFoldedGoods(job) {
120
+ const goods = goodsOf(job);
121
+ if (goods == null || job?.goods === goods) return job;
122
+ return { ...job, goods };
123
+ }
124
+
81
125
  /** A consumed claim's sidecars, swept together by runner.mjs cleanupClaimSidecars. */
82
126
  export const CLAIM_SIDECAR_SUFFIXES = [".pid", ".meta", ".skips"];
83
127
 
@@ -91,8 +91,8 @@ export function queueWatchVerdict({ queueDirs, watched, unitPath, unitError = nu
91
91
  // anywhere else; a privilege-limited read that answers "fine" is the exact failure this family of
92
92
  // checks exists to refuse, and it is the one that would make this guard decoration on the box that
93
93
  // matters most.
94
- if (unitError) return { state: "skip", message: `the .path unit could not be read — ${unitPath}: ${unitError}` };
95
- if (resolveError) return { state: "skip", message: `the queue dirs could not be resolved — ${resolveError}` };
94
+ if (unitError) return { state: "skip", blocked: true, message: `the .path unit could not be read — ${unitPath}: ${unitError}` };
95
+ if (resolveError) return { state: "skip", blocked: true, message: `the queue dirs could not be resolved — ${resolveError}` };
96
96
 
97
97
  const q = Array.isArray(queueDirs) ? queueDirs : null;
98
98
  const w = Array.isArray(watched) ? watched : [];
@@ -45,6 +45,7 @@
45
45
 
46
46
  import { normalizeElement, consonantSkeleton } from "./form-neighbourhood.mjs";
47
47
  import { canonicalJurisdictionCode } from "./jurisdiction-codes.mjs";
48
+ import { territoryTier } from "./territory-tiers.mjs";
48
49
 
49
50
  export const REFERENCE_SCHEMA_VERSION = 1;
50
51
 
@@ -378,6 +379,12 @@ export function inScope(entry, scopeClasses = [], scopeTerritories = []) {
378
379
  // that scenario it is five of fifteen entries, which is the difference between a bad round and a fine
379
380
  // one. Same shape as the class rule: an entry naming no territory is in scope, and a run that recorded
380
381
  // no scope cannot exclude anything.
382
+ // A WORLDWIDE SCOPE EXCLUDES NOTHING ON TERRITORY, for the reason a missing scope excludes nothing: it
383
+ // names no territory an entry could fall outside. Without this a worldwide run compared every entry
384
+ // with the word itself (`GLOBAL`), no entry names that, and the whole reference landed in `excluded`,
385
+ // an empty score that read as a clean one. Which words mean worldwide is the intake's own list
386
+ // (`territoryTier`), not a pattern restated here; `canonTerritory` still guesses nothing about `intl`.
387
+ if (scopeTerritories.some((t) => territoryTier(t) === "worldwide")) return true;
381
388
  const terr = (entry?.jurisdictions ?? []).map(canonTerritory).filter(Boolean);
382
389
  const scopeTerr = scopeTerritories.map(canonTerritory).filter(Boolean);
383
390
  if (scopeTerr.length && terr.length && !terr.some((t) => scopeTerr.includes(t))) return false;
@@ -911,7 +918,8 @@ export function readVerdict({ verdictDoc = null, knockoutFindings = null, status
911
918
  const rows = marks
912
919
  .map((m) => [m.name, [m.rating, m.ratingQualifier ? `(${m.ratingQualifier})` : null].filter(Boolean).join(" ")].filter(Boolean).join(": "))
913
920
  .filter((s) => s.includes(":"));
914
- const text = [status?.verdict ? `worst band ${status.verdict}` : null, ...rows].filter(Boolean).join(" · ");
921
+ const worst = status?.tier ?? status?.verdict ?? null; // `verdict` on a quick-search record written before the move
922
+ const text = [worst ? `worst band ${worst}` : null, ...rows].filter(Boolean).join(" · ");
915
923
  if (text) return { clean: clean(text), text, source: "knockout-findings.json + status.json — this lane writes no _driver/verdict.json", why: null };
916
924
  }
917
925
  return { clean: null, text: null, source: null,
@@ -2194,7 +2202,7 @@ export function deliveryLine(run) {
2194
2202
  }
2195
2203
  if (run.poolMeta) {
2196
2204
  const issued = run.poolMeta.issuedAt ? ` published ${run.poolMeta.issuedAt}` : " publication time not recorded";
2197
- const verdict = run.poolMeta.verdict ?? run.poolMeta.overall;
2205
+ const verdict = run.poolMeta.review?.signoff ?? run.poolMeta.verdict ?? run.poolMeta.overall;
2198
2206
  // NOT PRESERVED stays the honest answer for a pool copy with no stamp — a run archived before the
2199
2207
  // stamp existed, or one whose best-effort write failed. An absent stamp is unknown, not a refusal.
2200
2208
  return `delivered: NOT PRESERVED — this is a pool copy carrying no settle stamp, so the terminal`
@@ -41,9 +41,9 @@
41
41
  // CORRECTED 2026-08-11 — THIS PARAGRAPH USED TO SAY "one qid per office … which is the shape
42
42
  // joinPlanToBands already has", AND THAT WAS FALSE ABOUT THE COMPILER IT DOCUMENTS. There is no
43
43
  // per-office qid. compileRegisterPlan narrows ONE shared `regions` array and hands it to every entry
44
- // (register-plan.mjs:846 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
44
+ // (register-plan.mjs compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
45
45
  // nothing ever reaches joinPlanToBands' deferred bucket — whose only source is a block stamped
46
- // `error:true && deferred:true` (register-plan.mjs:1489 extendRegisterPlan).
46
+ // `error:true && deferred:true` (register-plan.mjs extendRegisterPlan).
47
47
  //
48
48
  // The consequence was not academic. `deferred_coverage` rode the plan and nothing that a reader sees
49
49
  // read it: coverage-form.mjs seeded its deferred rows from skeleton qids alone, so an EU+US matter on a