clearotron 0.3.2-beta.7 → 0.3.2-beta.8

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 (85) hide show
  1. package/.env.example +24 -23
  2. package/INSTALL.md +142 -75
  3. package/README.md +3 -3
  4. package/bin/onboard.mjs +637 -216
  5. package/bin/start.mjs +133 -23
  6. package/bin/update.mjs +58 -11
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +26 -11
  9. package/docs/architecture/05-config-governance.md +17 -7
  10. package/driver/CHANGELOG.md +76 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/contract-arm2-baseline.json +1 -3
  14. package/driver/contract-e3-backlog.mjs +26 -26
  15. package/driver/contract-vocabulary.mjs +44 -10
  16. package/driver/door-gates.mjs +41 -7
  17. package/driver/driver.config.mjs +272 -59
  18. package/driver/engine/CONTRACT.md +10 -3
  19. package/driver/engine/README.md +2 -2
  20. package/driver/engine/anthropic-agent.mjs +77 -21
  21. package/driver/engine/auth.mjs +129 -10
  22. package/driver/engine/jx-turn.mjs +7 -6
  23. package/driver/engine/mcp/recording-server.mjs +13 -0
  24. package/driver/engine/openai-agent.mjs +4 -2
  25. package/driver/engine/probe.mjs +110 -23
  26. package/driver/findings-model.mjs +1 -1
  27. package/driver/flag-snapshot.mjs +28 -5
  28. package/driver/gateway.mjs +24 -18
  29. package/driver/jx-lanes.mjs +21 -2
  30. package/driver/jx-units.mjs +6 -3
  31. package/driver/jx.mjs +4 -2
  32. package/driver/matter-frame-record.mjs +90 -1
  33. package/driver/named-band.mjs +34 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +200 -23
  36. package/driver/portal-config-view.mjs +30 -1
  37. package/driver/portal-report.mjs +15 -1
  38. package/driver/portal-service.mjs +46 -6
  39. package/driver/predelivery-lint.mjs +12 -2
  40. package/driver/publish/index.mjs +46 -5
  41. package/driver/publish/knockout.mjs +10 -1
  42. package/driver/publish/render-knockout.mjs +69 -7
  43. package/driver/publish/render.mjs +170 -59
  44. package/driver/publish/report-data.mjs +4 -1
  45. package/driver/publish/report-topbar.mjs +58 -0
  46. package/driver/publish/templates/report.css +18 -1
  47. package/driver/publish/xlsx.mjs +13 -1
  48. package/driver/register-availability.mjs +2 -2
  49. package/driver/register-coverage.mjs +94 -1
  50. package/driver/register-digest-record.mjs +236 -11
  51. package/driver/register-plan.mjs +170 -0
  52. package/driver/result-noun-fields.mjs +2 -2
  53. package/driver/run-economics.mjs +41 -10
  54. package/driver/run-requirements.mjs +173 -9
  55. package/driver/runner.mjs +3 -3
  56. package/driver/stages.mjs +12 -8
  57. package/driver/suite-census.json +142 -64
  58. package/driver/systemd/README.md +7 -4
  59. package/driver/terminal-clamp.mjs +107 -1
  60. package/driver/tokens.mjs +169 -3
  61. package/driver/unit-environment.mjs +42 -15
  62. package/driver/unit-inventory.mjs +19 -2
  63. package/driver/verify.mjs +27 -0
  64. package/mcp-server/CHANGELOG.md +4 -0
  65. package/mcp-server/package.json +1 -1
  66. package/mcp-server/server.mjs +15 -1
  67. package/package.json +1 -1
  68. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  69. package/portal-ui/dist/index.html +1 -1
  70. package/portal-ui/package.json +1 -1
  71. package/providers/jx/README.md +2 -1
  72. package/providers/jx/src/turn-envelope.mjs +8 -3
  73. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  74. package/providers/oauth-mcp-bridge/package.json +1 -1
  75. package/providers/uspto-local/README.md +1 -1
  76. package/scripts/authority-boundary-probe.mjs +4 -2
  77. package/scripts/env-audit.mjs +12 -6
  78. package/scripts/freeze-example-run.mjs +49 -16
  79. package/scripts/generated-files-are-current.mjs +69 -4
  80. package/scripts/settings-render-check.mjs +75 -2
  81. package/scripts/test-full.mjs +96 -3
  82. package/scripts/test-run.mjs +10 -0
  83. package/shared/deployment-box.mjs +7 -2
  84. package/shared/driver-dir.mjs +1 -1
  85. package/shared/names-in-force.mjs +1 -1
@@ -25,7 +25,8 @@ import { fileURLToPath } from 'node:url';
25
25
  // its own split, which is how the two rules diverged. It calls stripTelemetry now, so the renderer holds
26
26
  // no copy of the RULE either, only a call to it.
27
27
  import { parseReport, stripInternal, stripTelemetry } from './parse.mjs';
28
- import { clientConditions } from '../terminal-clamp.mjs'; // the reader's clause per condition, shared with the cover note
28
+ import { clientConditions } from '../terminal-clamp.mjs';
29
+ import { EXPORT_TOGGLE, exportPopover, EXPORT_MENU_JS } from './report-topbar.mjs'; // the export menu's shell and behaviour, shared with the knockout template // the reader's clause per condition, shared with the cover note
29
30
  import { COMMON_LAW, normRegion, regionName, REGION_NAMES } from './regions.mjs';
30
31
  import { parseFindingsJson, bindRecommendation, sentenceCaseLead, CLIENT_TIER_BY_COMPOSITE, bandOf, compareBlockingPower, inDispositionMode, reasonedNegativeGroups } from '../findings-model.mjs';
31
32
  import { REC, inPriorityWindow, ownerDisplayName } from '../registry-fidelity.mjs';
@@ -59,6 +60,16 @@ let SEARCHED_JUR = null;
59
60
  let SCOPE_WORLDWIDE = null;
60
61
  // T7 — the two deterministic evidence joins (set per render from opts):
61
62
  let CASE_LAW_BY_ORD = new Map(); // E5: ordinal → grounded case-law profile
63
+ // Whether the case-law pass found anything: 'found' | 'none-found' | 'not-checked' | null.
64
+ // The CARD strand is suppressed on the two states that mean there is no precedent to cite, because the
65
+ // profile body is then the engine's account of WHY — adapter names, session error codes, which sources
66
+ // were out of scope — a true sentence about the machinery and not the client's answer, and the Court
67
+ // decisions section states that outcome in the reader's own words already.
68
+ //
69
+ // NULL IS NOT ONE OF THEM. A report with no court state carries no Court decisions section either (it
70
+ // is full-country only), so suppressing there would take case-law off the page with nothing left
71
+ // saying so. Absence of a state is not a statement that nothing was found.
72
+ let COURT_DECISIONS = null;
62
73
  let ENFORCER_SIGNALS = new Map(); // E6: registration uri (lowercase) → {aggression, oppositions, owner}
63
74
  // WP-receipts W2 — per-render provider record-link origin + label, resolved by publish from the run's
64
75
  // OWN _driver/receipts.json provider (never the currently-configured provider — a re-published archive
@@ -143,11 +154,19 @@ function frameworkTickIndex(findings) {
143
154
  // verdict with EVERY condition, "Why <band>" as the four answers with the basis each rests on, the
144
155
  // highest exposure, and what was searched to get there. Nothing here is composed — the conditions are
145
156
  // the sidecar's own client-voice clauses, the answers are the engine's, and the coverage line is counts.
146
- function ratingExtras(fm, findings, coverage, fourAnswers, opts, bandWord) {
157
+ function ratingExtras(fm, findings, coverage, fourAnswers, opts, bandWord, conditionsInVerdict = false) {
147
158
  const out = [];
148
- const conds = clientConditions(VERDICT_INFO || {});
149
- if (conds.length) out.push(`<div class="gconds-wrap"><span class="gk">Conditions</span><ul class="gconds">${
150
- conds.map((c) => `<li>${esc(c)}</li>`).join('')}</ul></div>`);
159
+ // THE CONDITIONS HAVE ONE HOME PER PATH, AND IT IS NEVER NONE. Where the verdict row could take
160
+ // them — a sidecar whose composed statement carries the "conditional on:" lede — they render there,
161
+ // inside the verdict, which is where the mock puts them and where they read as the terms the verdict
162
+ // is conditional on rather than a list beside it. Where it could not, this row renders exactly as it
163
+ // did: the legacy gauge has no lede to hang them from, and dropping the row there would hide every
164
+ // condition on precisely the archived runs that cannot be re-rendered with better text.
165
+ if (!conditionsInVerdict) {
166
+ const conds = clientConditions(VERDICT_INFO || {});
167
+ if (conds.length) out.push(`<div class="gconds-wrap"><span class="gk">Conditions</span><ul class="gconds">${
168
+ conds.map((c) => `<li>${esc(c)}</li>`).join('')}</ul></div>`);
169
+ }
151
170
  const fa = fourAnswers && typeof fourAnswers === 'object' ? fourAnswers : null;
152
171
  if (fa) {
153
172
  const rows = FOUR_ANSWER_LABELS.map(([key, label], i) => {
@@ -240,8 +259,27 @@ function frameworkGauge(fm, findings, coverage = [], fourAnswers = null, opts =
240
259
  ? (bindRecommendation(fm.recommendation, VERDICT_INFO.verdict, []) || VERDICT_INFO.tier || '')
241
260
  : (fm.recommendation || fm.overall_label || '');
242
261
  const recLabel = VERDICT_INFO?.statement ? 'Verdict' : 'Recommendation';
262
+ // THE VERDICT CARRIES EVERY CONDITION, NOT THE FIRST AND A COUNT. The composed statement ends
263
+ // "(and N more)" because it is also a ONE-LINE surface — the email lede, the registry row — where a
264
+ // list cannot go. On the page there is room for all of them, and a condition a client is told exists
265
+ // but is not told is one they cannot act on. The lede is taken from the statement's OWN prefix rather
266
+ // than re-composed, so the tier wording stays the engine's and only the truncated tail is replaced;
267
+ // the clauses are the sidecar's client-voice ones, the same list the separate row used to carry.
268
+ // The legacy `gauge()` below keeps the composed line as it stands: an archived run with no framework
269
+ // sidecar renders byte-identically, which is the contract stated there.
270
+ // THE LIST RENDERS ON BOTH PATHS. A sidecar with a composed statement gets the lede and its
271
+ // conditions; a LEGACY one — reasons, no statement, no client-voice clause — has no lede to match, and
272
+ // dropping the list there would hide every condition on exactly the archived runs that cannot be
273
+ // re-rendered with better text. Those still render under the bound recommendation, which is where
274
+ // they were before this row moved.
275
+ const verdictConds = clientConditions(VERDICT_INFO || {});
276
+ const ledeMatch = typeof rec === 'string' ? rec.match(/^(.*?conditional on:)/i) : null;
277
+ const conditionsInVerdict = Boolean(ledeMatch && verdictConds.length);
278
+ const recHtml = conditionsInVerdict
279
+ ? `${esc(ledeMatch[1])}<ul class="gconds">${verdictConds.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
280
+ : esc(rec);
243
281
  const conc = [
244
- rec && `<div class="grow"><span class="gk">${recLabel}</span><span class="gv gv-rec">${esc(rec)}</span></div>`,
282
+ rec && `<div class="grow"><span class="gk">${recLabel}</span><span class="gv gv-rec">${recHtml}</span></div>`,
245
283
  ].filter(Boolean).join('');
246
284
  // — THE FRAMEWORK IS NAMED WHERE ITS WORDS ARE READ, not only in the footer. `.ticks` below spells
247
285
  // a vocabulary ("Manageable", "Moderate") that is meaningless without the framework in force, and the
@@ -255,7 +293,7 @@ function frameworkGauge(fm, findings, coverage = [], fourAnswers = null, opts =
255
293
  <div class="scale" style="background:${grad}">${marker}</div>
256
294
  <div class="ticks">${ticks}</div>
257
295
  <div class="gconc">${conc}</div>
258
- ${ratingExtras(fm, findings, coverage, fourAnswers, opts, label)}
296
+ ${ratingExtras(fm, findings, coverage, fourAnswers, opts, label, conditionsInVerdict)}
259
297
  </div>`;
260
298
  }
261
299
 
@@ -514,7 +552,6 @@ const useEvidence = (m) => [USE_EVIDENCE_LABEL[m?._status], USE_SOURCE_LABEL[m?.
514
552
  // runs carry the old value forever and a fourth spelling of it would have to be accepted everywhere.
515
553
  const USE_CHECK_NO_RESULT = 'perplexity_research — no result';
516
554
  const USE_CHECK_NO_RESULT_CITE = 'Nothing found in the marketplaces searched.';
517
- const USE_CHECK_NO_RESULT_SHORT = 'marketplace search — no result found';
518
555
  // — MATCHED ON NORMALISED PUNCTUATION, NOT ONE SPELLING. The constant itself does not
519
556
  // move (archived runs carry it forever, the validators name it), but the SEAT emitted a hyphen where
520
557
  // the doctrine writes an em dash, and exact equality let the raw tool name through to a delivered
@@ -963,8 +1000,13 @@ function clearedGroupsHtml(searchDepth, auditFile) {
963
1000
  const items = reg.filter((c) => c.group === g);
964
1001
  if (!items.length) return '';
965
1002
  const shown = items.slice(0, NAMES_CAP);
1003
+ // "Cl." and a lower-case status, which is how the mock reads and how a lawyer writes it. The
1004
+ // register hands the status back in capitals — REGISTERED, CANCELLED — and shouting a neutral
1005
+ // fact at a reader is the register's habit, not ours. Only the case changes; the word is the
1006
+ // register's own and is not translated.
966
1007
  const rows = shown.map((c) => `<div class="crow"><span class="cm-mark">${esc(c.mark || c.term || '')}</span><span class="cwho">${
967
- esc([c.owner, regionName(c.country) || c.country, c.classes ? `Class ${c.classes}` : '', c.status].filter(Boolean).join(' \u00b7 '))}</span></div>`).join('');
1008
+ esc([c.owner, regionName(c.country) || c.country, c.classes ? `Cl. ${c.classes}` : '',
1009
+ c.status ? String(c.status).toLowerCase() : ''].filter(Boolean).join(' \u00b7 '))}</span></div>`).join('');
968
1010
  return `<details class="cgroup"><summary><span class="gname">${esc(CLEARED_GROUP_LABEL[g] || g)}</span><span class="gcount">${
969
1011
  items.length.toLocaleString('en-GB')} ${items.length === 1 ? 'name' : 'names'}</span></summary><div class="gbody">${rows}${
970
1012
  items.length > shown.length ? link(items.length) : ''}</div></details>`;
@@ -1011,7 +1053,14 @@ function whereItStandsSection(findings, opts) {
1011
1053
  const withF = [], clean = [];
1012
1054
  for (const c of codes) {
1013
1055
  const key = alias[c] || c;
1014
- (bandBy.has(c) || bandBy.has(key) ? withF : clean).push({ code: key, name: regionName(c) || c, band: bandBy.get(c) || bandBy.get(key) });
1056
+ // THE NAME IS LOOKED UP ON THE ALIASED KEY, NOT THE RAW CODE. The register writes the EUIPO and
1057
+ // ISO spellings — EM and GB — and `alias` maps those to the codes a reader knows, EU and UK. The
1058
+ // name was resolved from the RAW code, which has no entry under either spelling, so it fell back
1059
+ // to the code itself and the row read "EU EM" and "UK GB": a country column printing a second
1060
+ // code, beside the four rows where the register happened to write the code we already knew.
1061
+ // The raw code is still tried, so anything the alias does not cover resolves exactly as before.
1062
+ const name = regionName(key) || regionName(c) || key;
1063
+ (bandBy.has(c) || bandBy.has(key) ? withF : clean).push({ code: key, name, band: bandBy.get(c) || bandBy.get(key) });
1015
1064
  }
1016
1065
  if (!withF.length && !clean.length) return '';
1017
1066
  const rows = withF.map((c) => `<div class="wrow"><span class="rcode">${esc(c.code)}</span><span class="wname">${esc(c.name)}</span><span class="kc">${esc(c.band)}</span></div>`).join('');
@@ -1082,7 +1131,16 @@ function alsoConsideredSection(ruledOut, recordsByUri = new Map(), opts = {}) {
1082
1131
  const sd = opts.searchDepth || null;
1083
1132
  const ruledCards = ruledOut.map((f) => {
1084
1133
  const who = recordOwner(f, recordsByUri) || f.owner?.name || '';
1085
- const why = f.ruled_out_reason ? esc(f.ruled_out_reason) : 'a different name in a related field — not a conflict with your mark';
1134
+ // THE ENGINE'S OWN SENTENCE LEADS THE CARD. The face printed a fixed line — "a different name in
1135
+ // a related field" — on every ruled-out card, whatever the run had actually concluded. `net` is the
1136
+ // finding's own one-line reason, written for a reader, and it was rendered NOWHERE: not on the
1137
+ // face, not in the fold, not anywhere on the page. So a client read the same generic sentence
1138
+ // about every name we set aside, while the specific reason we set THAT one aside was discarded at
1139
+ // render time. The fold keeps the legal and practical positions, which are the longer argument.
1140
+ // The fixed line stays as the last resort, for a finding that carries neither.
1141
+ const why = f.ruled_out_reason ? esc(f.ruled_out_reason)
1142
+ : f.net ? esc(String(f.net))
1143
+ : 'a different name in a related field — not a conflict with your mark';
1086
1144
  const legal = f.legal_position ? esc(String(f.legal_position)) : '';
1087
1145
  const practical = f.practical_position ? esc(String(f.practical_position)) : '';
1088
1146
  const fold = (legal || practical)
@@ -1284,36 +1342,19 @@ function keyPanel(findings, recordsByUri = new Map()) {
1284
1342
  // complete in one place. The rights-holder landscape panel keeps its "Common-law" group — that
1285
1343
  // panel is the index of everything, this section is the reading surface.
1286
1344
  function commonLawSection(clSecondary, clOnField, cardFor, recordsByUri = new Map(), allFindings = []) {
1287
- // T7 (E4) — "what the marketplace layer added": for every REGISTER finding whose use
1288
- // evidence came from the common-law layer (a use_check cite), one attributed line with the A4
1289
- // confidence four-tuple + source class — the layer's contribution is visible and attributed, not
1290
- // buried in prose. Renders even when there are no common-law FINDINGS (contributions alone earn
1291
- // the section). On-field common-law conflicts keep their FULL cards in the risk-ordered band above
1292
- // (blocking-power ordering wins; E2's grouping complaint was the per-jurisdiction Marks list, which
1293
- // secondary CL left in A5) — cross-linked from here.
1294
- const contrib = (allFindings ?? [])
1295
- .filter((f) => f && f.disposition !== 'withdrawn' && f.use_check?.source && regionCode(f) !== COMMON_LAW)
1296
- .map((f) => {
1297
- // D4 — the evidence pair is LABELLED here too, and it reads out of the one USE_SOURCE_LABEL.
1298
- const st = useEvidence(f.meters?.use);
1299
- // D7 — the sentinel is mapped to client words BEFORE the URL parse is attempted. It is not a
1300
- // URL, so `new URL` threw and the catch printed `host.slice(0, 40)` — and the sentinel is 31
1301
- // characters, so the page printed the raw tool name, whole.
1302
- const raw = String(f.use_check.source);
1303
- let where;
1304
- if (isUseCheckNoResult(raw)) where = USE_CHECK_NO_RESULT_SHORT;
1305
- else { try { where = new URL((raw.match(/https?:\/\/[^\s,|]+/) || [raw])[0]).host; } catch { where = raw.slice(0, 40); } }
1306
- return `<li style="margin:3px 0"><a href="#c${f.ordinal}">#${f.ordinal} ${esc(f.mark)}</a> — use ${esc(humanize(f.meters?.use?.token ?? 'unknown'))} — ${esc(where)}${st ? ` <i class="evstat">(evidence: ${esc(st)})</i>` : ''}</li>`;
1307
- }).join('');
1308
- const contribBlock = contrib
1309
- ? `<p style="margin:4px 0 2px;font-size:13px"><b>What the marketplace layer added to register findings</b></p><ul style="margin:0 0 10px;padding-left:20px;font-size:13px">${contrib}</ul>`
1310
- : '';
1311
- if (!clSecondary.length && !clOnField.length && !contribBlock) return '';
1345
+ // THE LAYER'S CONTRIBUTION IS ON THE CARD THAT CARRIES IT, NOT ALSO IN A LIST ABOVE THEM. A
1346
+ // block headed "what the marketplace layer added to register findings" restated, per finding, the
1347
+ // use token, the host and the evidence pair that the finding's OWN card already states in its use
1348
+ // line — measured on the delivered reports: one such line per finding with use evidence, on the
1349
+ // card, in every case the block listed. It attributed a layer to itself in the engine's own terms
1350
+ // and made a reader read the same fact twice, the second time out of the context that explains it.
1351
+ // Nothing is lost with it: the cards it linked to sit directly below.
1352
+ if (!clSecondary.length && !clOnField.length) return '';
1312
1353
  const links = clOnField.length
1313
1354
  ? `<p class="clx" style="margin:4px 0 10px;font-size:13.5px">On-field common-law conflicts (full cards above): ${clOnField.map(f => `<a href="#c${f.ordinal}">#${f.ordinal} ${esc(f.mark)}</a>`).join(' · ')}</p>`
1314
1355
  : '';
1315
1356
  const cards = clSecondary.map(f => compactCard(f, cardFor(f), recordsByUri)).join('\n ');
1316
- return contribBlock + links + cards;
1357
+ return links + cards;
1317
1358
  }
1318
1359
 
1319
1360
  // Secondary findings → collapsible region groups (§2.4). Same region order as the key panel; each region a
@@ -1632,11 +1673,20 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1632
1673
  // preparation is portal-report.mjs's job, never a second render fork here.
1633
1674
  const clHead = clProfile ? `<b>Case-law${clProfile.jurisdiction ? ` (${esc(clProfile.jurisdiction)})` : ''}.</b>` : '';
1634
1675
  const clBody = !clProfile ? '' : renderProse(clProfile.body); // one report: the full body (renderProse handles ::p:: internal lines)
1635
- const caseLawStrand = clProfile
1676
+ // Only a pass that FOUND precedent puts a strand on the card. Where none was found, or the research
1677
+ // could not be completed, the body is the engine's account of the attempt and the Court decisions
1678
+ // section says the outcome plainly on its own; repeating it here said it twice, the second time in
1679
+ // the machinery's voice on the card a client reads most closely.
1680
+ const caseLawStrand = clProfile && COURT_DECISIONS !== 'not-checked' && COURT_DECISIONS !== 'none-found'
1636
1681
  ? `<div class="clstrand" style="margin:10px 0 0;padding-top:8px;border-top:1px dashed var(--line,#ddd)">${clHead}${clBody}</div>`
1637
1682
  : '';
1638
1683
  const link = f.source?.resolved_link;
1639
- const prov = link ? `<div class="prov">audit ref F${f.ordinal} · <a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link.replace(/^https?:\/\//, '').slice(0, 48))}</a></div>` : `<div class="prov">audit ref F${f.ordinal}</div>`;
1684
+ // NO AUDIT REFERENCE ON A CLIENT'S CARD. "audit ref F1" is the engine's handle for the finding —
1685
+ // it indexes the workbook, it means nothing to the reader holding the report, and the mock carries no
1686
+ // such line. The SOURCE LINK stays: it is the only address a reader has for the record on this card
1687
+ // until the workbook row lands beside it, and dropping both would take a fact away rather than a
1688
+ // label. With no link there is nothing left to say, so the row does not render at all.
1689
+ const prov = link ? `<div class="prov"><a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link.replace(/^https?:\/\//, '').slice(0, 48))}</a></div>` : '';
1640
1690
  // WP-receipts W4 — the code-owned senior-right line (Owner decision 2026-07-05: VERY SIMPLE CLEAR ENGLISH,
1641
1691
  // stated qualification, verdict untouched). Verified senior → nothing extra (the W2 receipt line on
1642
1692
  // the fetched leg is the proof). Unverified → the open item, plainly, where the finding lives.
@@ -2059,8 +2109,7 @@ window.addEventListener('hashchange',_cardHashGo);
2059
2109
  window.addEventListener('load',_cardHashGo);
2060
2110
  window.addEventListener('beforeprint',function(){document.querySelectorAll('details').forEach(function(d){d.dataset.o=d.open?'1':'';d.open=true;});});
2061
2111
  window.addEventListener('afterprint',function(){document.querySelectorAll('details').forEach(function(d){d.open=d.dataset.o==='1';});_hidden.forEach(function(c){c.classList.remove('print-hidden');});_hidden=[];});
2062
- document.addEventListener('click',function(e){var t=e.target.closest('.tb-exp-toggle'),pop=document.querySelector('.tb-exp-pop');if(t){if(pop){pop.hidden=!pop.hidden;t.setAttribute('aria-expanded',String(!pop.hidden));}return;}if(pop&&!pop.hidden&&!e.target.closest('.tb-exp-pop')){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}});
2063
- document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=document.querySelector('.tb-exp-pop');if(pop&&!pop.hidden){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}}});`;
2112
+ ${EXPORT_MENU_JS}`;
2064
2113
 
2065
2114
  // Render a parsed report + findings.json to a full self-contained HTML string.
2066
2115
  // A1 — famous-neighbour context notes: knowledge-cited references kept for diligence (digest.md's "never
@@ -2292,6 +2341,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2292
2341
  SEARCHED_JUR = Array.isArray(opts.searchedJurisdictions) && opts.searchedJurisdictions.length ? opts.searchedJurisdictions : null; // T6 (D4)
2293
2342
  SCOPE_WORLDWIDE = opts.scopeBasis === 'worldwide' ? true : null; // the plan's scope_basis; null ⇒ fall back to the ledger-prose sniff
2294
2343
  CASE_LAW_BY_ORD = opts.caseLawByOrdinal instanceof Map ? opts.caseLawByOrdinal : new Map(); // T7 (E5)
2344
+ COURT_DECISIONS = opts.searchDepth?.counts?.courtDecisions ?? null; // gates the card's case-law strand
2295
2345
  ENFORCER_SIGNALS = new Map((Array.isArray(opts.enforcerSignals) ? opts.enforcerSignals : []).map((e) => [String(e.uri ?? '').toLowerCase(), e])); // T7 (E6)
2296
2346
  RECORD_ORIGIN = opts.recordOrigin ?? null; // WP-receipts W2
2297
2347
  // — `null` and `` mean DIFFERENT things and the render must not collapse them. `` is an
@@ -2530,11 +2580,20 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2530
2580
  <span class="sp"></span>
2531
2581
  <span class="tb-risk" style="background:var(${STOP_VAR[i]})">${riskLabel}</span>
2532
2582
  <span class="mono tb-matter" style="font-size:11px;color:var(--faint)">${esc(fm.matter || opts.runId || '')}${fm.title ? ' / ' + esc(fm.title) : ''}</span>
2533
- ${opts.issued ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued on ${esc(opts.issued)}</span>` : ''}
2583
+ ${/* A DATE, NOT A TIMESTAMP. This read "Issued on 2026-09-17 · 08:36 GMT+2" — the minute the file
2584
+ was written, and the zone the machine that wrote it happened to be in. Neither tells a reader
2585
+ anything, and on work that spans days it implies a precision the work does not have. Every mock
2586
+ issues the date alone. Taken by PATTERN rather than by cutting at the separator, so a value in
2587
+ some other shape falls through whole instead of being truncated at whatever character sits
2588
+ there — an archived run's stamp is not this publisher's to assume. */''}
2589
+ ${(() => {
2590
+ const d = (String(opts.issued ?? '').match(/^\d{4}-\d{2}-\d{2}/) || [])[0] ?? opts.issued;
2591
+ return d ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued on ${esc(d)}</span>` : '';
2592
+ })()}
2534
2593
  <button type="button" class="tbbtn tb-ask no-print">✦ <span class="tb-lbl">Ask AI</span></button>
2535
2594
  <div class="tb-menu">
2536
- <button type="button" class="tbbtn primary tb-exp-toggle" aria-haspopup="true" aria-expanded="false">⬇ <span class="tb-lbl">Export</span> ▾</button>
2537
- <div class="tb-pop tb-exp-pop" hidden>
2595
+ ${EXPORT_TOGGLE}
2596
+ ${exportPopover(`
2538
2597
  <div class="tb-pop-title">Export &amp; audit</div>
2539
2598
  <button class="util primary" onclick="exportPDF()">⬇ Export PDF (ticked findings)</button>
2540
2599
  ${excelBtn2}
@@ -2542,7 +2601,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2542
2601
  <div class="tb-row"><button class="util" onclick="pickAll(true)">Select all</button><button class="util" onclick="pickAll(false)">Select none</button></div>
2543
2602
  <div class="tb-row"><button class="util" onclick="openAll(true)">Expand all</button><button class="util" onclick="openAll(false)">Collapse all</button></div>
2544
2603
  <p class="tb-hint">Tick a finding to keep it in the exported PDF; untick to drop it. Internal (review-only) notes are removed on export.</p>
2545
- </div>
2604
+ `)}
2546
2605
  </div>
2547
2606
  </div>
2548
2607
  </div>
@@ -2598,20 +2657,51 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2598
2657
  ${whatWasSearchedSection(opts, coverage, findings, recordsByUri)}
2599
2658
 
2600
2659
  <footer>
2601
- <span>${productName ? `${esc(productName)}. ` : ''}${FRAMEWORK
2602
- // TWO SENTENCES, and that count is the ruled shape rather than a consequence of trimming.
2603
- //
2604
- // Two things left. The band note — "the framework in force's own vocabulary, one word per
2605
- // finding on every surface" — is a note about how the renderer works, printed on every report a
2606
- // client receives; the framework's NAME is on the "Rated under" line below, once, which is
2607
- // where a reader who wants it will look.
2608
- //
2609
- // AND "Working draft for legal review.", which is a separate decision and is recorded as one.
2610
- // A delivered clearance is not a draft, and a document that calls itself one on every page is
2611
- // describing its own status inaccurately to the person paying for it. Raised in review because
2612
- // the first version of this comment argued only the band note and left the reader to infer that
2613
- // the status line had gone along for the ride.
2614
- ? '' : ''}<br>Matter ${esc(fm.matter || '')}${fm.run ? ` · ${esc(fm.run)}` : ''}.${fm.rated_under ? `<br>Rated under: <span class="mono">${esc(fm.rated_under)}</span>.` : ''}${fm.run_under_project ? `<br>Run under project: <span class="mono">${esc(fm.run_under_project)}</span>.` : ''}</span>
2660
+ ${/* ONE CLIENT LINE, AND IT NAMES WHAT EACH DATE IS. It ran to four.
2661
+ "Rated under" STAYS, and it is not an oversight: the document carries the reviewer's
2662
+ provenance and portal-report strips that one line for every embedded reader at serve time,
2663
+ which is stated there and held by an arm. Deleting it here would take provenance off the
2664
+ reviewer's copy to save the portal a job it already does.
2665
+ "Run under project" also STAYS on the document, for the same reason and with the same
2666
+ remedy. It is internal provenance — an end-to-end arm reads it out of the published internal
2667
+ report, through the front-matter seam — and it was reaching clients only because nothing
2668
+ removed it at serve time. It does now, beside the provenance line, so the document keeps what
2669
+ review needs and the client reads neither. Deleting it here would have taken provenance off
2670
+ the internal copy to fix a leak that belongs in the same place the other one is fixed.
2671
+ The product name lead goes: the identity line already says which search this is.
2672
+ The run string is composed as "<date> · <provider>", and it was printed with neither part
2673
+ labelled, so a bare date sat next to a matter identifier meaning nothing in particular. It is
2674
+ split on the DATE by pattern — the same way `dateOf` does it one file over — and the remainder
2675
+ is the provider. A run string with no date in it is not this renderer's to take apart, so it
2676
+ is printed whole and unlabelled rather than guessed at. */''}
2677
+ <!-- WHICH MODELS SERVED THIS RUN. It used to close the scope fold, and the 2026-09-16 redesign
2678
+ deleted that fold — so the line was re-homed rather than dropped with its container, which
2679
+ would have removed a statement of provenance from the client's page as a side effect of a
2680
+ merge. Owner ruling, 2026-09-17: it belongs in the footer, beside the matter and the framework.
2681
+ It renders as '' on a run that recorded no models, so an archived run republishes exactly as
2682
+ delivered, and the call sits ADJACENT so the whitespace around it belongs to the line and goes
2683
+ with it — the freeze tool sets this paragraph aside by a pattern that takes the space before
2684
+ it. -->${servedModelsLine(opts.servedModels)}
2685
+ <span>${(() => {
2686
+ const runStr = String(fm.run ?? '').trim();
2687
+ const searchedOn = (runStr.match(/\d{4}-\d{2}-\d{2}/) || [])[0] ?? null;
2688
+ const provider = searchedOn
2689
+ ? runStr.replace(searchedOn, '').replace(/^[\s\u00b7]+|[\s\u00b7]+$/g, '').trim()
2690
+ : runStr;
2691
+ const issuedOn = (String(opts.issued ?? '').match(/^\d{4}-\d{2}-\d{2}/) || [])[0] ?? opts.issued ?? null;
2692
+ const parts = [
2693
+ fm.matter ? `Matter ${esc(fm.matter)}` : '',
2694
+ searchedOn ? `searched on ${esc(searchedOn)}` : '',
2695
+ issuedOn ? `issued on ${esc(issuedOn)}` : '',
2696
+ provider ? esc(provider) : '',
2697
+ ].filter(Boolean);
2698
+ const line = parts.length ? `${parts.join(' \u00b7 ')}.` : '';
2699
+ // The reviewer's provenance rides BELOW the client line, where portal-report removes it.
2700
+ // Both provenance lines ride BELOW the client line, where portal-report removes them.
2701
+ return line
2702
+ + (fm.rated_under ? `<br>Rated under: <span class="mono">${esc(fm.rated_under)}</span>.` : '')
2703
+ + (fm.run_under_project ? `<br>Run under project: <span class="mono">${esc(fm.run_under_project)}</span>.` : '');
2704
+ })()}</span>
2615
2705
  ${logoLockup({ mark: 16 })}
2616
2706
  </footer>
2617
2707
  </div>
@@ -2658,3 +2748,24 @@ function officeLinkNote() {
2658
2748
  return (linked ? 'A registration number shown as a link opens the office’s own page for that record. ' : '')
2659
2749
  + officeReasonSentences(RECORD_LINKS).map((s) => `${esc(s)} `).join('');
2660
2750
  }
2751
+
2752
+ // THE MODELS THAT SERVED THIS SEARCH, as one closing line of the scope section (2026-09-14). The ids are
2753
+ // the ones the engine reported for its turns (tokens.mjs servedModels), never the tier a stage asked
2754
+ // for in place of a model the engine named: every tier goes to the program as the vendor's alias, and an
2755
+ // alias names no model. Both report
2756
+ // kinds call this, so they say it in the same words. '' when the run recorded none, so a run published
2757
+ // before the record existed renders exactly as it was delivered.
2758
+ //
2759
+ // A TIER WORD IS CLAUDE'S. servedModels lists a turn served under a company's own deployment name as the
2760
+ // tier it asked for ("Opus"), never the name, so the list may read "claude-opus-5, Haiku". Both are
2761
+ // Claude's and the line says so once; in a list that also names another vendor, the word says it itself
2762
+ // ("Claude Opus"). The four words, Fable among them, are the ones servedModels writes. They are kept here
2763
+ // rather than imported, because tokens.mjs loads the driver's settings and this module renders without them.
2764
+ const CLAUDE_TIER_WORD_RE = /^(?:Opus|Sonnet|Haiku|Fable)$/;
2765
+ export function servedModelsLine(ids) {
2766
+ const list = (Array.isArray(ids) ? ids : []).map((s) => String(s ?? '').trim()).filter(Boolean);
2767
+ if (!list.length) return '';
2768
+ const claude = list.every((id) => /^claude-/i.test(id) || CLAUDE_TIER_WORD_RE.test(id));
2769
+ const shown = claude ? list : list.map((id) => (CLAUDE_TIER_WORD_RE.test(id) ? `Claude ${id}` : id));
2770
+ return `<p class="servedby" style="margin:10px 0 0;font-size:13px">Prepared with${claude ? ' Claude' : ''}: ${shown.map(esc).join(', ')}.</p>`;
2771
+ }
@@ -68,7 +68,7 @@ function assessmentField(v) {
68
68
  * "no data file this publish" and stamps meta.reportSchema only on success.
69
69
  */
70
70
  export function clearanceReportData({
71
- runId, codename, matter, markName, title, customerKey, issued, url, auditFile, engineCommit = null,
71
+ runId, codename, matter, markName, title, customerKey, issued, url, auditFile, engineCommit = null, servedModels = null,
72
72
  searchLevel, stageLabel, framework, verdictInfo, findings, coverage, contextNotes,
73
73
  markAssessment, fourAnswers, askAnswers, actions, jurisdiction, searchedJurisdictions, scopeBasis, caption,
74
74
  } = {}) {
@@ -91,6 +91,9 @@ export function clearanceReportData({
91
91
  issued: issued || null,
92
92
  // The engine build that produced this report — the join from a flagged finding to a diff.
93
93
  engineCommit: engineCommit || null,
94
+ // The models that served the run, in first-use order, as tokens.mjs servedModels names them for a
95
+ // client: a deployment's own name never, its tier instead. null when nothing was read.
96
+ servedModels: Array.isArray(servedModels) ? servedModels : null,
94
97
  url: url || null,
95
98
  auditFile: auditFile || null,
96
99
  level: { searchLevel: searchLevel ?? null, stageLabel: stageLabel ?? null },
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // report-topbar.mjs — THE EXPORT MENU, ONCE, FOR BOTH REPORT TEMPLATES.
4
+ //
5
+ // Two templates draw a report: the clearance renderer and the knockout renderer. Each emits its own top
6
+ // bar, which is right — the boards draw two different bars, with different items in a different order.
7
+ // What they must not each own is the EXPORT MENU: the toggle, the popover it opens, the aria wiring that
8
+ // says it is a menu, and the two listeners that open and close it. Those are one control, and until this
9
+ // module they were two copies of one control, in two files, with nothing to say when they drifted.
10
+ //
11
+ // ── WHAT IS SHARED AND WHAT IS NOT, AND WHY THE LINE IS THERE ───────────────────────────────────────
12
+ //
13
+ // The SHELL is shared: the button, the panel, the behaviour. The ENTRIES are not. The clearance report
14
+ // filters its export to the findings a reader has ticked, so its menu says so and offers a select-all;
15
+ // the knockout has no tick boxes at all, deliberately, because it has nothing to filter, and a menu
16
+ // offering to tick there would name a control that cannot exist. An entry list is a statement about what
17
+ // a template can do, and the two templates can do different things.
18
+ //
19
+ // ── THE BYTES DO NOT MOVE ───────────────────────────────────────────────────────────────────────────
20
+ //
21
+ // `driver/publish/render.mjs` is frozen at a content hash, and the freeze's checklist asks whether a
22
+ // change is reachable from a republish. This one is: pool-admin re-renders archived runs through that
23
+ // module. So the contract here is stricter than "it looks the same" — the composed markup is the bytes
24
+ // the two templates already emitted, character for character, and the commit that introduces this module
25
+ // records a real archived run rendered through both the old module and the new one and byte-compared, in
26
+ // the internal pass and the client pass. A shared definition that changed a delivered report while
27
+ // tidying it up would be the worst of both.
28
+
29
+ /**
30
+ * The button that opens the export menu. One spelling, both templates.
31
+ *
32
+ * `aria-haspopup` and `aria-expanded` are part of the control rather than decoration: the listener below
33
+ * keeps `aria-expanded` in step, and a second copy of this button that forgot either would announce
34
+ * itself to a screen reader as an ordinary button that does nothing.
35
+ */
36
+ export const EXPORT_TOGGLE = '<button type="button" class="tbbtn primary tb-exp-toggle" aria-haspopup="true" aria-expanded="false">⬇ <span class="tb-lbl">Export</span> ▾</button>';
37
+
38
+ /**
39
+ * The panel the toggle opens, around whatever entries a template offers. PURE.
40
+ *
41
+ * @param {string} entries the template's own menu contents, already escaped
42
+ * @returns {string}
43
+ */
44
+ export const exportPopover = (entries) => `<div class="tb-pop tb-exp-pop" hidden>${entries}</div>`;
45
+
46
+ /**
47
+ * Open on the button, close on a click outside and on Escape.
48
+ *
49
+ * GLOBAL, NOT WRAPPED, and that is the same reason the templates' own verbs are global: the portal
50
+ * frames a served report and drives it by looking names up on the page. These two listeners need no
51
+ * name, but they sit in the same script as the verbs that do, and a wrapper around the pair would be one
52
+ * more difference between the two files for no gain.
53
+ *
54
+ * NO BACKTICK IN THIS STRING. It is interpolated into a template literal in both templates, and a
55
+ * backtick ends that literal — the failure arrives at import time naming a token nobody wrote.
56
+ */
57
+ export const EXPORT_MENU_JS = `document.addEventListener('click',function(e){var t=e.target.closest('.tb-exp-toggle'),pop=document.querySelector('.tb-exp-pop');if(t){if(pop){pop.hidden=!pop.hidden;t.setAttribute('aria-expanded',String(!pop.hidden));}return;}if(pop&&!pop.hidden&&!e.target.closest('.tb-exp-pop')){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}});
58
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=document.querySelector('.tb-exp-pop');if(pop&&!pop.hidden){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}}});`;
@@ -239,7 +239,19 @@
239
239
  .tb-hint{font-size:11px;color:var(--faint);line-height:1.4;margin:2px 2px 0}
240
240
  @media(max-width:860px){.tb-matter{display:none}}
241
241
  @media(max-width:640px){.tb-issued{display:none}}
242
- @media(max-width:560px){.wm small{display:none}.tb-back-lbl{display:none}.tb-lbl{display:none}}
242
+ @media(max-width:560px){.wm small{display:none}.tb-back-lbl{display:none}.tb-lbl{display:none}
243
+ /* THE SAME ROW, CLOSER TOGETHER — the ladder above already collapses this bar's LABELS at these
244
+ widths, and it was still 35 to 51px wider than a phone screen, so the whole document scrolled
245
+ sideways: the reader dragged the page left and right to read a paragraph. Nothing is hidden and
246
+ nothing moves; the gutter, the gaps and one dead margin come in. The margin is dead here by the
247
+ line above — it separated the wordmark from a tagline that is no longer displayed. */
248
+ .topbar{gap:4px;padding-left:12px;padding-right:12px}}
249
+ /* AND ON A SMALL PHONE, ONE THING GOES. Closing 375px and below is not a spacing problem — the row's
250
+ own items are wider than the screen — so something has to go, and which one is a decision about what
251
+ a reader keeps rather than a number to tune. Ruled: the back arrow. The product's name and the risk
252
+ label stay, because they say where the reader is and what the answer was; the arrow is a route back
253
+ to a list, and a reader on a phone has the browser's own. (owner, 2026-09-17) */
254
+ @media(max-width:375px){.topbar .tb-back{display:none}}
243
255
 
244
256
  /* hero: conclusion card (dial + verdict) + scope card jurisdiction chips */
245
257
  .heroGrid .gauge{display:flex;flex-direction:column;justify-content:flex-start}
@@ -513,3 +525,8 @@
513
525
  .openrows .rk,.provwrap .rk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
514
526
  .provnote{margin:0;font-size:12.5px;line-height:1.5;color:var(--slate)}
515
527
  .topbar .tb-lockup{margin-right:10px;flex:0 0 auto}
528
+ /* AFTER the rule it overrides, not beside its siblings at the breakpoint above: both selectors carry
529
+ the same weight, so the later one wins and a phone rule written earlier in this file did nothing at
530
+ all — measured, not assumed. The margin separates the wordmark from the tagline, and the tagline is
531
+ already hidden at this width. */
532
+ @media(max-width:560px){.topbar .tb-lockup{margin-right:0}}
@@ -618,7 +618,19 @@ export async function buildAudit(contract, auditParsed, outPath, mark = '', fm =
618
618
  });
619
619
 
620
620
  // 4 · Coverage & gaps — the honest completeness ledger, state coloured.
621
- addSheet(wb, 'Coverage & gaps', COVERAGE_COLS, coverageRows(coverage), (row, _d, kept) => {
621
+ // ── A CONDITION THAT REACHED NO PAGE IS A GAP, AND IT BELONGS ON THE GAPS SHEET ──────────────────
622
+ //
623
+ // A report republished from a run recorded before conditions carried two texts can hold a condition
624
+ // whose reader-facing sentence was never stored and cannot be composed. It is dropped rather than
625
+ // printed in the engine's own words, and a drop nobody records is the disclosure closing quietly —
626
+ // which is worse than the sentence it replaced. Ruled 2026-09-17: one row here, one line in the run
627
+ // record, no new wording, and delivery never fails for it.
628
+ //
629
+ // THE ROW IS AN ORDINARY COVERAGE ROW, built by the same function as every other, so it carries the
630
+ // same four columns and takes the same State colour. It is not a second shape and not a new sheet.
631
+ // The words in it are the run record's own: nothing here composes prose.
632
+ addSheet(wb, 'Coverage & gaps', COVERAGE_COLS,
633
+ [...coverageRows(coverage), ...coverageRows(contract?.droppedConditions || [])], (row, _d, kept) => {
622
634
  if (!kept.has('State')) return;
623
635
  const st = row.getCell('State'); const f = STATE_FILL[String(st.value).trim()];
624
636
  if (f) { st.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF' + f } }; st.font = { bold: true }; }
@@ -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:572 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
44
+ // (register-plan.mjs:779 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:1115 extendRegisterPlan).
46
+ // `error:true && deferred:true` (register-plan.mjs:1317 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