clearotron 0.3.2-beta.7 → 0.3.2-beta.9

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 (272) hide show
  1. package/.env.example +58 -26
  2. package/CONTRIBUTING.md +8 -8
  3. package/INSTALL.md +148 -81
  4. package/README.md +3 -3
  5. package/SECURITY.md +3 -3
  6. package/bin/brandowner.mjs +3 -3
  7. package/bin/framework-preflight.mjs +1 -1
  8. package/bin/onboard.mjs +637 -216
  9. package/bin/start.mjs +151 -27
  10. package/bin/update.mjs +58 -11
  11. package/build-info.json +2 -2
  12. package/docs/DELIVERY.md +2 -1
  13. package/docs/INTAKE.md +1 -1
  14. package/docs/ONBOARDING.md +1 -1
  15. package/docs/architecture/03-run-lifecycle.md +6 -6
  16. package/docs/architecture/04-configuration-reference.md +32 -14
  17. package/docs/architecture/05-config-governance.md +23 -8
  18. package/docs/architecture/05-customer-profiles.md +2 -2
  19. package/docs/architecture/06-operations-runbook.md +3 -3
  20. package/docs/architecture/08-development-guide.md +6 -6
  21. package/docs/configuration.md +5 -5
  22. package/docs/decisions/0003-credential-model.md +1 -1
  23. package/docs/writing-standard.md +4 -0
  24. package/driver/CHANGELOG.md +124 -0
  25. package/driver/README.md +3 -3
  26. package/driver/band-size.mjs +59 -0
  27. package/driver/binding-layers.mjs +1 -1
  28. package/driver/citation-census.json +3 -3
  29. package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
  30. package/driver/common-law-receipts.mjs +2 -2
  31. package/driver/company-bundle.mjs +3 -3
  32. package/driver/compose-read.mjs +8 -14
  33. package/driver/config-inventory.mjs +112 -9
  34. package/driver/consumption-ledger.mjs +2 -2
  35. package/driver/contract-arm2-baseline.json +2 -5
  36. package/driver/contract-dictation-registry.mjs +19 -19
  37. package/driver/contract-e3-backlog.mjs +43 -43
  38. package/driver/contract-e3-baseline.json +14 -14
  39. package/driver/contract-vocabulary.mjs +68 -27
  40. package/driver/deliver-trigger.sh +16 -16
  41. package/driver/demo-container.mjs +3 -3
  42. package/driver/dev-portal.mjs +3 -3
  43. package/driver/disposition-call.mjs +1 -1
  44. package/driver/door-gates.mjs +41 -7
  45. package/driver/doubt-ledger.mjs +2 -2
  46. package/driver/drainer-identity.mjs +34 -8
  47. package/driver/driver.config.mjs +367 -104
  48. package/driver/engine/CONTRACT.md +10 -3
  49. package/driver/engine/README.md +2 -2
  50. package/driver/engine/anthropic-agent.mjs +77 -21
  51. package/driver/engine/auth.mjs +129 -10
  52. package/driver/engine/jx-turn.mjs +7 -6
  53. package/driver/engine/mcp/README.md +1 -1
  54. package/driver/engine/mcp/dispositions-server.mjs +3 -3
  55. package/driver/engine/mcp/gather-config.mjs +9 -9
  56. package/driver/engine/mcp/perplexity-server.mjs +2 -2
  57. package/driver/engine/mcp/recording-server.mjs +18 -5
  58. package/driver/engine/openai-agent.mjs +4 -2
  59. package/driver/engine/probe.mjs +110 -23
  60. package/driver/enqueue-schema.mjs +6 -2
  61. package/driver/findings-model.mjs +6 -3
  62. package/driver/flag-snapshot.mjs +34 -8
  63. package/driver/form-neighbourhood.mjs +54 -7
  64. package/driver/framework.mjs +4 -4
  65. package/driver/gateway.mjs +36 -24
  66. package/driver/jx-lanes.mjs +23 -4
  67. package/driver/jx-units.mjs +7 -4
  68. package/driver/jx.mjs +34 -4
  69. package/driver/knockout-review-record.mjs +56 -4
  70. package/driver/known-conflicts.mjs +1 -1
  71. package/driver/matter-frame-record.mjs +90 -1
  72. package/driver/named-band.mjs +1 -1
  73. package/driver/ordinary-words.mjs +51 -0
  74. package/driver/outbox-backoff.mjs +31 -16
  75. package/driver/package.json +1 -1
  76. package/driver/partial-payload-baseline.json +2 -2
  77. package/driver/phase0.mjs +3 -3
  78. package/driver/pipeline-knockout.mjs +5 -5
  79. package/driver/pipeline.mjs +396 -81
  80. package/driver/placement-form.mjs +77 -1
  81. package/driver/placement-model.mjs +1 -1
  82. package/driver/portal-config-view.mjs +30 -1
  83. package/driver/portal-report.mjs +107 -6
  84. package/driver/portal-service.mjs +80 -14
  85. package/driver/portal-upstream.mjs +1 -1
  86. package/driver/predelivery-lint.mjs +12 -2
  87. package/driver/preserve-merge.mjs +3 -3
  88. package/driver/product-rows.mjs +2 -2
  89. package/driver/products.mjs +1 -1
  90. package/driver/profiles/README.md +3 -3
  91. package/driver/profiles/demo-brand-owner.json +2 -2
  92. package/driver/profiles.mjs +55 -17
  93. package/driver/progress.mjs +18 -8
  94. package/driver/provider-usage.mjs +8 -8
  95. package/driver/publish/index.mjs +154 -8
  96. package/driver/publish/knockout.mjs +39 -5
  97. package/driver/publish/pool-admin.mjs +1 -1
  98. package/driver/publish/publish-inputs.mjs +18 -2
  99. package/driver/publish/render-knockout.mjs +184 -31
  100. package/driver/publish/render.mjs +323 -93
  101. package/driver/publish/report-data.mjs +4 -1
  102. package/driver/publish/report-topbar.mjs +58 -0
  103. package/driver/publish/search-depth.mjs +133 -4
  104. package/driver/publish/templates/report.css +78 -4
  105. package/driver/publish/xlsx.mjs +20 -1
  106. package/driver/queue-order.mjs +2 -2
  107. package/driver/recording-agreement.mjs +1 -1
  108. package/driver/reference-score.mjs +1 -1
  109. package/driver/register-availability.mjs +2 -2
  110. package/driver/register-count.mjs +50 -5
  111. package/driver/register-coverage.mjs +161 -1
  112. package/driver/register-digest-record.mjs +236 -11
  113. package/driver/register-grant-vocabulary.mjs +1 -1
  114. package/driver/register-plan.mjs +189 -2
  115. package/driver/registry-fidelity.mjs +3 -3
  116. package/driver/repair-composers.mjs +1 -1
  117. package/driver/repair-contract.mjs +1 -1
  118. package/driver/replay-archive.mjs +6 -6
  119. package/driver/report-overview-record.mjs +2 -2
  120. package/driver/result-noun-fields.mjs +2 -2
  121. package/driver/run-economics.mjs +41 -10
  122. package/driver/run-requirements.mjs +173 -9
  123. package/driver/runner.mjs +5 -5
  124. package/driver/scope-facts.mjs +20 -5
  125. package/driver/scope-ledger.mjs +5 -5
  126. package/driver/search-policy.mjs +22 -12
  127. package/driver/skills/README.md +15 -15
  128. package/driver/skills/blind-frame/SKILL.md +2 -2
  129. package/driver/skills/case-law-citation/SKILL.md +4 -4
  130. package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
  131. package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
  132. package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
  133. package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
  134. package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
  135. package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
  136. package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
  137. package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
  138. package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
  139. package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
  140. package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
  141. package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
  142. package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
  143. package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
  144. package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
  145. package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
  146. package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
  147. package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
  148. package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
  149. package/driver/skills/frame-diff/SKILL.md +1 -1
  150. package/driver/skills/knockout-assess/SKILL.md +10 -7
  151. package/driver/skills/matter-frame/SKILL.md +3 -3
  152. package/driver/skills/narrative-refutation/SKILL.md +9 -9
  153. package/driver/skills/placement-inquiry/SKILL.md +5 -5
  154. package/driver/stage-context.mjs +1 -1
  155. package/driver/stages-knockout.mjs +4 -4
  156. package/driver/stages.mjs +65 -61
  157. package/driver/status-snapshot.mjs +2 -2
  158. package/driver/suite-census.json +340 -136
  159. package/driver/surface-exit-verdict.mjs +58 -0
  160. package/driver/systemd/README.md +9 -6
  161. package/driver/systemd/clearotron-worker.service +1 -1
  162. package/driver/terminal-clamp.mjs +109 -1
  163. package/driver/tokens.mjs +169 -3
  164. package/driver/unit-environment.mjs +42 -15
  165. package/driver/unit-inventory.mjs +19 -2
  166. package/driver/usage-ledger.mjs +1 -1
  167. package/driver/variant-manifest-model.mjs +4 -4
  168. package/driver/verify-knockout.mjs +27 -0
  169. package/driver/verify.mjs +94 -6
  170. package/driver/whatif-queue.mjs +1 -1
  171. package/driver/wordlists/en.txt +63906 -0
  172. package/mcp-server/CHANGELOG.md +8 -0
  173. package/mcp-server/README.md +1 -1
  174. package/mcp-server/lib/README.md +1 -1
  175. package/mcp-server/lib/options.mjs +8 -7
  176. package/mcp-server/lib/plan.mjs +18 -2
  177. package/mcp-server/lib/runs.mjs +1 -1
  178. package/mcp-server/lib/usage.mjs +3 -3
  179. package/mcp-server/lib/whatif.mjs +1 -1
  180. package/mcp-server/package.json +1 -1
  181. package/mcp-server/server.mjs +18 -1
  182. package/package.json +12 -11
  183. package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
  184. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-EVaSo5-g.js} +1580 -527
  185. package/portal-ui/dist/index.html +2 -2
  186. package/portal-ui/package.json +1 -1
  187. package/providers/README.md +1 -1
  188. package/providers/_shared/enumerate.mjs +6 -6
  189. package/providers/_shared/execute-plan.mjs +3 -3
  190. package/providers/_shared/ledger.mjs +119 -5
  191. package/providers/_shared/provider-text.mjs +2 -2
  192. package/providers/_shared/screen.mjs +2 -2
  193. package/providers/_shared/script-form.mjs +3 -3
  194. package/providers/_shared/territory-codes.mjs +23 -3
  195. package/providers/clarivate/README.md +1 -1
  196. package/providers/clarivate/src/capabilities.js +12 -12
  197. package/providers/clarivate/src/core.js +37 -43
  198. package/providers/corsearch/README.md +1 -1
  199. package/providers/corsearch/src/capabilities.js +5 -5
  200. package/providers/corsearch/src/core.js +3 -3
  201. package/providers/jx/README.md +2 -1
  202. package/providers/jx/src/turn-envelope.mjs +8 -3
  203. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  204. package/providers/oauth-mcp-bridge/package.json +1 -1
  205. package/providers/perplexity/src/core.js +1 -1
  206. package/providers/signa/README.md +1 -1
  207. package/providers/signa/src/capabilities.js +42 -49
  208. package/providers/signa/src/core.js +106 -29
  209. package/providers/uspto-local/README.md +1 -1
  210. package/providers/uspto-local/src/sync.js +1 -1
  211. package/scripts/README.md +1 -0
  212. package/scripts/ask-ai-render-check.mjs +127 -1
  213. package/scripts/authority-boundary-probe.mjs +8 -6
  214. package/scripts/backfill-started-at.mjs +2 -2
  215. package/scripts/census-merge-driver.mjs +33 -2
  216. package/scripts/citation-anchor-report.mjs +181 -0
  217. package/scripts/dead-names.mjs +1 -1
  218. package/scripts/deprecate-below.mjs +66 -8
  219. package/scripts/drain-preflight.mjs +1 -1
  220. package/scripts/e2e.mjs +174 -0
  221. package/scripts/env-audit.mjs +39 -6
  222. package/scripts/env-classify.mjs +20 -2
  223. package/scripts/freeze-example-run.mjs +61 -18
  224. package/scripts/generated-files-are-current.mjs +69 -4
  225. package/scripts/live-surface-check.mjs +124 -41
  226. package/scripts/markdown-link-check.mjs +1 -1
  227. package/scripts/merge-shape-check.mjs +242 -0
  228. package/scripts/mint-names-in-force.mjs +5 -5
  229. package/scripts/mint-offered-territories.mjs +72 -0
  230. package/scripts/mint-public-residue.mjs +2 -2
  231. package/scripts/mint-reference-strip-backlog.mjs +2 -2
  232. package/scripts/mint-suite-census.mjs +75 -2
  233. package/scripts/mint-writing-standard-backlog.mjs +2 -2
  234. package/scripts/purge-runs.mjs +7 -7
  235. package/scripts/reconcile-runs.mjs +2 -2
  236. package/scripts/release-approve-parked.mjs +20 -2
  237. package/scripts/release-await-cut.mjs +120 -1
  238. package/scripts/release-note-required.mjs +76 -8
  239. package/scripts/report-header-render-check.mjs +164 -0
  240. package/scripts/settings-render-check.mjs +75 -2
  241. package/scripts/test-full.mjs +96 -3
  242. package/scripts/test-run.mjs +10 -0
  243. package/shared/brand.mjs +27 -0
  244. package/shared/connect-clients.mjs +39 -11
  245. package/shared/deployment-box.mjs +7 -2
  246. package/shared/driver-dir.mjs +1 -1
  247. package/shared/env-aliases.mjs +1 -1
  248. package/shared/identifier-scan.mjs +65 -9
  249. package/shared/identifier-sentinels.mjs +22 -0
  250. package/shared/names-in-force.mjs +4 -2
  251. package/shared/offered-territories.json +738 -0
  252. package/shared/pre-rename-spellings.mjs +53 -0
  253. package/shared/reference-guard-classes.mjs +40 -2
  254. package/shared/stdio-connect.mjs +39 -4
  255. package/shared/tree-commit.mjs +48 -0
  256. /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
  257. /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
  258. /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
  259. /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
  260. /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
  261. /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
  262. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
  263. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
  264. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
  265. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
  266. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
  267. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
  268. /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
  269. /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
  270. /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
  271. /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
  272. /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
@@ -25,13 +25,14 @@ 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';
32
33
  import { registrationSystem } from '../jurisdiction-systems.mjs';
33
34
  import { READ_LEAD_RE } from '../report-card-record.mjs'; // D3 — the dedupe gate and the card's acceptance are ONE predicate
34
- import { REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, FAVICON_LINK, logoLockup, BRAND, confPosture } from '../../shared/brand.mjs';
35
+ import { REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, FAVICON_LINK, logoLockup, BRAND, confPosture, sectionStrip } from '../../shared/brand.mjs';
35
36
  import { NAV_CSS } from '../../shared/site-nav.mjs';
36
37
  import { isEntrypoint } from "../../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
37
38
 
@@ -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>`;
@@ -999,26 +1041,70 @@ function whereItStandsSection(findings, opts) {
999
1041
  const sd = opts && opts.searchDepth;
1000
1042
  if (!sd || !sd.counts || isFullCountry(opts)) return '';
1001
1043
  const byC = sd.counts.recordsByCountry || {};
1002
- const codes = Object.keys(byC).filter((c) => c !== 'WO');
1003
- if (!codes.length) return '';
1004
1044
  const bandBy = new Map();
1005
1045
  for (const f of findings) {
1006
1046
  const c = regionCode(f);
1007
1047
  if (!c || c === COMMON_LAW || !f.band) continue;
1008
1048
  if (!bandBy.has(c)) bandBy.set(c, f.band);
1009
1049
  }
1050
+ // WHICH COUNTRIES THIS SECTION IS ABOUT COMES FROM THE SEARCH PLAN, NOT FROM THE RECORD ARCHIVE.
1051
+ //
1052
+ // The archive is the authority on what came back and was kept; the plan is the authority on what was
1053
+ // asked of the register. Read off the archive, a provider that keeps no records produced no countries,
1054
+ // no rows, no chips and no section at all — so a register nobody could archive read exactly like a
1055
+ // register nobody searched, on the page a client acts on. Every provider that keeps partial records
1056
+ // under-reported here for the same reason.
1057
+ //
1058
+ // `planTerritories` is three-valued and the third value is why this is not a one-line swap. `null`
1059
+ // means there is no plan to read, which is every archived and legacy run: those keep the archive as
1060
+ // their only authority and re-render exactly as they always have. A plan that named nothing is a
1061
+ // different answer from no plan at all and is allowed to leave this section empty.
1062
+ //
1063
+ // THE FINDINGS' OWN COUNTRIES ARE UNIONED IN, never filtered out. A finding sitting in a territory the
1064
+ // plan does not list — an international registration designating one, most often — had its row from
1065
+ // the archive before this and must keep it: the section's first duty is that a country carrying a
1066
+ // conflict is on the page.
1067
+ //
1068
+ // The plan's unreached territories are NOT drawn. This section has two slots in the approved board, a
1069
+ // row carrying a band and a "Nothing found" chip, and a territory the provider does not cover is
1070
+ // neither: filing it under "Nothing found" would state a clean result for a register nobody read,
1071
+ // which is the one fusion this file's court-decisions section exists to keep apart.
1072
+ const plan = opts && opts.planTerritories;
1073
+ const planned = plan && Array.isArray(plan.searched) ? plan.searched.map(String) : null;
1074
+ const codes = (planned
1075
+ ? [...new Set([...planned, ...bandBy.keys()])]
1076
+ : Object.keys(byC)).filter((c) => c !== 'WO');
1077
+ if (!codes.length) return '';
1010
1078
  const alias = { EM: 'EU', GB: 'UK' };
1011
- const withF = [], clean = [];
1079
+ // ONE ROW PER COUNTRY, DEDUPED AFTER THE ALIAS AND NOT BEFORE. The archive wrote each country once, so
1080
+ // this loop never had to ask; a plan carries both spellings — the register's EM and GB alongside the EU
1081
+ // and UK a reader knows — and aliasing them afterwards produced two EU rows and two UK rows on the
1082
+ // first worldwide run measured through it. The band is looked up across every raw code that folded
1083
+ // into the key, so a conflict recorded under EM still reaches the EU row.
1084
+ const rawByKey = new Map();
1012
1085
  for (const c of codes) {
1013
1086
  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) });
1087
+ if (!rawByKey.has(key)) rawByKey.set(key, []);
1088
+ rawByKey.get(key).push(c);
1089
+ }
1090
+ const withF = [], clean = [];
1091
+ for (const [key, raws] of rawByKey) {
1092
+ const c = raws.find((r) => bandBy.has(r)) ?? raws[0];
1093
+ // THE NAME IS LOOKED UP ON THE ALIASED KEY, NOT THE RAW CODE. The register writes the EUIPO and
1094
+ // ISO spellings — EM and GB — and `alias` maps those to the codes a reader knows, EU and UK. The
1095
+ // name was resolved from the RAW code, which has no entry under either spelling, so it fell back
1096
+ // to the code itself and the row read "EU EM" and "UK GB": a country column printing a second
1097
+ // code, beside the four rows where the register happened to write the code we already knew.
1098
+ // The raw code is still tried, so anything the alias does not cover resolves exactly as before.
1099
+ const name = regionName(key) || regionName(c) || key;
1100
+ (bandBy.has(c) || bandBy.has(key) ? withF : clean).push({ code: key, name, band: bandBy.get(c) || bandBy.get(key) });
1015
1101
  }
1016
1102
  if (!withF.length && !clean.length) return '';
1017
1103
  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('');
1018
1104
  const cleanHtml = clean.length
1019
1105
  ? `<div class="wclean"><span class="wk">Nothing found</span>${clean.map((c) => `<span class="wchip" title="${escAttr(c.name)}">${esc(clean.length > 12 ? c.code : c.name)}</span>`).join('')}</div>`
1020
1106
  : '';
1021
- return `<div class="sec" id="countries"><h2>Where it stands</h2></div>
1107
+ return `<div class="sec" id="countries"><span class="num"></span><h2>Where it stands</h2></div>
1022
1108
  <div class="panel where">${rows}${cleanHtml}</div>`;
1023
1109
  }
1024
1110
 
@@ -1027,17 +1113,29 @@ function whereItStandsSection(findings, opts) {
1027
1113
  function courtDecisionsSection(opts) {
1028
1114
  const sd = opts && opts.searchDepth;
1029
1115
  if (!sd || !sd.counts || !isFullCountry(opts)) return '';
1030
- const state = sd.counts.courtDecisions;
1031
- const byC = sd.counts.recordsByCountry || {};
1116
+ const line = courtLineFor(opts, sd.counts.courtDecisions);
1117
+ if (!line) return '';
1118
+ return `<div class="sec" id="court"><span class="num"></span><h2>Court decisions</h2></div>
1119
+ <div class="panel courtp"><p>${esc(line)}</p></div>`;
1120
+ }
1121
+
1122
+ // The court section's sentence for a state, and the one the Court decisions row carries under its state
1123
+ // word when the research could not be completed — one sentence, said in both places in the same words.
1124
+ function courtLineFor(opts, state) {
1125
+ const sd = opts && opts.searchDepth;
1126
+ const byC = (sd && sd.counts && sd.counts.recordsByCountry) || {};
1032
1127
  const first = Object.keys(byC).filter((c) => c !== 'WO')[0];
1033
1128
  const where = first ? (regionName(first) || first) : '';
1129
+ // The country comes from the record listing, so a run whose provider archives nothing has no name to
1130
+ // put here — and every branch below embedded it mid-sentence. The page then read "Case-law research
1131
+ // could not be completed for ." Each clause is now attached to the name rather than assuming one:
1132
+ // same words when there is a country, one clause shorter when there is not.
1133
+ const forWhere = where ? ` for ${where}` : '';
1034
1134
  let line = '';
1035
- if (state === 'not-checked') line = `Case-law research could not be completed for ${where}.`;
1036
- else if (state === 'none-found') line = `Court decisions: none found for ${where}.`;
1037
- else if (state === 'found') line = `Court decisions were searched for ${where} and are cited against the findings above.`;
1038
- if (!line) return '';
1039
- return `<div class="sec" id="court"><h2>Court decisions</h2></div>
1040
- <div class="panel courtp"><p>${esc(line)}</p></div>`;
1135
+ if (state === 'not-checked') line = `Case-law research could not be completed${forWhere}.`;
1136
+ else if (state === 'none-found') line = `Court decisions: none found${forWhere}.`;
1137
+ else if (state === 'found') line = `Court decisions were searched${forWhere} and are cited against the findings above.`;
1138
+ return line;
1041
1139
  }
1042
1140
 
1043
1141
  // Counts only, folded closed. A count of records read is engine activity rather than something a reader
@@ -1052,16 +1150,72 @@ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri
1052
1150
  const sd = opts && opts.searchDepth;
1053
1151
  const open = (Array.isArray(coverage) ? coverage : []).filter((c) => COV_STATE[c?.state]?.cls !== 'ok');
1054
1152
  const c = (sd && sd.counts) || {}, rows = [];
1153
+ // THE REGISTER ROW, AS THE BOARDS DRAW IT: one line of totals — records read across the countries, plus
1154
+ // the international registrations apart from them — and the per-country counts as chips under it, code
1155
+ // first and the name on hover. One country is named in the line instead ("141 records read in Japan"),
1156
+ // and draws no chips: a single chip would repeat the line.
1055
1157
  const byC = c.recordsByCountry || {};
1056
- const per = Object.entries(byC).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${regionName(k) || k} ${n.toLocaleString('en-GB')}`).join(' \u00b7 ');
1057
- if (per) rows.push(['Register records read', per]);
1158
+ const intl = Number(byC.WO) || 0;
1159
+ const countries = Object.entries(byC).filter(([k, n]) => k !== 'WO' && Number(n) > 0).sort((a, b) => b[1] - a[1]);
1160
+ const read = countries.reduce((t, [, n]) => t + Number(n), 0);
1161
+ if (countries.length || intl) {
1162
+ const where = countries.length === 1 ? ` in ${regionName(countries[0][0]) || countries[0][0]}`
1163
+ : countries.length ? ` across ${countries.length.toLocaleString('en-GB')} countries` : '';
1164
+ const plus = intl ? `${countries.length ? ', plus ' : ''}${intl.toLocaleString('en-GB')} international registration${intl === 1 ? '' : 's'}` : '';
1165
+ const line = `${countries.length ? `${read.toLocaleString('en-GB')} record${read === 1 ? '' : 's'} read${where}` : ''}${plus}`;
1166
+ const chips = countries.length > 1
1167
+ ? `<div class="wchips">${countries.map(([k, n]) => `<span class="wchip" title="${escAttr(regionName(k) || k)}">${esc(k)} <b>${Number(n).toLocaleString('en-GB')}</b></span>`).join('')}</div>`
1168
+ : '';
1169
+ rows.push(['Register', { html: `${esc(line)}${chips}` }]);
1170
+ }
1058
1171
  const sw = c.sweep || {};
1059
1172
  if (sw.spellings) rows.push(['Spellings searched', String(sw.spellings)]);
1060
- if (sw.checks) rows.push(['Marketplace and web', `${sw.checks.toLocaleString('en-GB')} checks across ${sw.platforms} platforms`]);
1173
+ if (sw.checks) rows.push(['Marketplace and web', `${sw.checks.toLocaleString('en-GB')} checks on ${sw.platforms} platforms`]);
1061
1174
  if (sw.reputation) rows.push(['Reputation and meaning', `${sw.reputation.toLocaleString('en-GB')} checks`]);
1062
- rows.push(['Local-script spellings', c.localScriptSearched ? 'searched' : 'not searched']);
1063
- const cd = { 'found': 'found', 'none-found': 'none found', 'not-checked': 'could not be checked', 'not-in-scope': 'not part of this search' }[c.courtDecisions];
1064
- if (cd) rows.push(['Court decisions', cd]);
1175
+ rows.push(['Local-script spellings', c.localScriptSearched ? 'Searched' : 'Not searched']);
1176
+ // HOW DEEP THE LOCAL-LANGUAGE INVESTIGATION WENT, which is a different question from the row above it.
1177
+ // That one answers whether the spellings were searched; this one answers whether the investigation ran
1178
+ // at the depth the matter configured. The engine can run it shallower than the account asked for, and
1179
+ // it used to say so in exactly one place — a sentence a model wrote into the Methodology paragraph the
1180
+ // redesign replaced with named rows — so a run that went shallow said so on no page at all.
1181
+ //
1182
+ // EVERY ONE OF THE FOUR WORDS IS ALREADY ON THE PAGE. "Included" and "Not part of this search" are the
1183
+ // approved board's own values for this row; "Partially covered" and "Not run this run" are the coverage
1184
+ // vocabulary this file already renders, taken from COV_STATE rather than retyped so they cannot drift
1185
+ // apart from it. Nothing here composes a sentence.
1186
+ //
1187
+ // A state this table has no word for draws NO ROW, rather than the nearest word. The four are the whole
1188
+ // set the record can produce, so the fallthrough is unreachable today and is there for the fifth state
1189
+ // somebody adds: a row is a claim about a client's search, and the nearest word to an unknown state is
1190
+ // a claim nobody checked.
1191
+ const LL_WORD = {
1192
+ ran: 'Included',
1193
+ 'ran-shallow': COV_STATE['coverage-limited'].word,
1194
+ 'not-run': COV_STATE['not-searched'].word,
1195
+ 'not-in-scope': 'Not part of this search',
1196
+ };
1197
+ //
1198
+ // AND THE ROW NEEDS A LANE RECORD BEHIND IT, not just a state. The state folds to `not-in-scope` when
1199
+ // there is no lane record at all, which is right for a clearance that never asked for the
1200
+ // investigation and WRONG for a run that asked and whose record was never written. Measured on the
1201
+ // full country demo: its own coverage carries "Native-language investigation depth / ja — the
1202
+ // configured depth for this lane was full and this run delivered a depth this run cannot establish",
1203
+ // and the state beside it reads not-in-scope, so this row would have told that reader the
1204
+ // investigation was not part of their search. An empty `lanes` map is that absence, and an absence is
1205
+ // not a finding — so the row is drawn from a record and not from a default. A run whose record exists
1206
+ // draws it on every one of the four states.
1207
+ const llLanes = c.localLanguage?.lanes;
1208
+ const llRecorded = !!llLanes && typeof llLanes === 'object' && Object.keys(llLanes).length > 0;
1209
+ const ll = llRecorded ? LL_WORD[c.localLanguage?.state] : null;
1210
+ if (ll) rows.push(['Local-language investigation', ll]);
1211
+ // COURT DECISIONS, AS THE FULL COUNTRY BOARD DRAWS THE ROW: its state, and for a search that could not
1212
+ // be completed, the same sentence the court section under it prints. A search that does not include
1213
+ // court decisions draws no row, as the other boards draw none.
1214
+ const cdWord = { 'found': 'Found', 'none-found': 'None found', 'not-checked': 'Not searched' }[c.courtDecisions];
1215
+ if (cdWord && isFullCountry(opts)) {
1216
+ const why = c.courtDecisions === 'not-checked' ? courtLineFor(opts, 'not-checked') : '';
1217
+ rows.push(['Court decisions', { html: `<span class="wstate">${esc(cdWord)}</span>${why ? `<span class="wnote">${esc(why)}</span>` : ''}` }]);
1218
+ }
1065
1219
  const openHtml = open.length ? `<div class="openrows"><div class="rk">Left open</div>${coverageGrid(open)}</div>` : '';
1066
1220
  // THE LEGEND FOR WHAT IS ON THE CARDS, kept when the scope fold went. It is not the engine narrating
1067
1221
  // its own searching — it is what a linked registration number opens, why the rest are cited by number,
@@ -1073,16 +1227,30 @@ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri
1073
1227
  const prov = (hasRecordSet || hasCards)
1074
1228
  ? `<div class="provwrap"><div class="rk">Record provenance</div><p class="provnote">${hasRecordSet ? 'Registration numbers on the cards were read from the register records. ' : ''}${officeLinkNote()}${hasIndexEntry ? 'A registration shown as a register-index entry was seen in the register index; its full record was not pulled. ' : ''}\u201cInferred\u201d beside an owner\u2019s likelihood to object means we judged it from what the owner sells and holds; we had no enforcement history to read.</p></div>`
1075
1229
  : '';
1230
+ // THE BOARDS END THE SECTION WITH THE WORKBOOK: every search and its result are in the audit workbook,
1231
+ // which is where a reader who wants the record goes.
1232
+ const more = opts?.auditFile
1233
+ ? `<div class="cmore"><a class="wb" href="${escAttr(opts.auditFile)}">Every search and result, in the audit workbook</a></div>` : '';
1076
1234
  if (!rows.length && !openHtml && !prov) return '';
1077
- return `<details class="searched"><summary><span class="gname">What was searched</span><span class="gcount">Counts for this search</span></summary><div class="gbody">${
1078
- rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><span class="v">${esc(v)}</span></div>`).join('')}${openHtml}${prov}</div></details>`;
1235
+ return `<div class="sec" id="searched"><span class="num"></span><h2>What was searched</h2></div>
1236
+ <details class="searched"><summary><span class="gname">Counts for this search</span></summary><div class="gbody">${
1237
+ rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><span class="v">${typeof v === 'object' ? v.html : esc(v)}</span></div>`).join('')}${openHtml}${prov}${more}</div></details>`;
1079
1238
  }
1080
1239
 
1081
1240
  function alsoConsideredSection(ruledOut, recordsByUri = new Map(), opts = {}) {
1082
1241
  const sd = opts.searchDepth || null;
1083
1242
  const ruledCards = ruledOut.map((f) => {
1084
1243
  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';
1244
+ // THE ENGINE'S OWN SENTENCE LEADS THE CARD. The face printed a fixed line — "a different name in
1245
+ // a related field" — on every ruled-out card, whatever the run had actually concluded. `net` is the
1246
+ // finding's own one-line reason, written for a reader, and it was rendered NOWHERE: not on the
1247
+ // face, not in the fold, not anywhere on the page. So a client read the same generic sentence
1248
+ // about every name we set aside, while the specific reason we set THAT one aside was discarded at
1249
+ // render time. The fold keeps the legal and practical positions, which are the longer argument.
1250
+ // The fixed line stays as the last resort, for a finding that carries neither.
1251
+ const why = f.ruled_out_reason ? esc(f.ruled_out_reason)
1252
+ : f.net ? esc(String(f.net))
1253
+ : 'a different name in a related field — not a conflict with your mark';
1086
1254
  const legal = f.legal_position ? esc(String(f.legal_position)) : '';
1087
1255
  const practical = f.practical_position ? esc(String(f.practical_position)) : '';
1088
1256
  const fold = (legal || practical)
@@ -1094,7 +1262,7 @@ function alsoConsideredSection(ruledOut, recordsByUri = new Map(), opts = {}) {
1094
1262
  const groups = clearedGroupsHtml(sd, opts.auditFile);
1095
1263
  const web = webNamesHtml(sd);
1096
1264
  if (!ruledCards && !groups && !web) return '';
1097
- return `<div class="sec" id="also-considered"><h2>Also considered</h2></div>
1265
+ return `<div class="sec" id="also-considered"><span class="num"></span><h2>Also considered</h2></div>
1098
1266
  <div class="panel alsocons">${ruledCards ? `<div class="ruled-cards">${ruledCards}</div>` : ''}${groups || web ? `<div class="cgroups">${groups}${web}</div>` : ''}</div>`;
1099
1267
  }
1100
1268
 
@@ -1284,36 +1452,19 @@ function keyPanel(findings, recordsByUri = new Map()) {
1284
1452
  // complete in one place. The rights-holder landscape panel keeps its "Common-law" group — that
1285
1453
  // panel is the index of everything, this section is the reading surface.
1286
1454
  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 '';
1455
+ // THE LAYER'S CONTRIBUTION IS ON THE CARD THAT CARRIES IT, NOT ALSO IN A LIST ABOVE THEM. A
1456
+ // block headed "what the marketplace layer added to register findings" restated, per finding, the
1457
+ // use token, the host and the evidence pair that the finding's OWN card already states in its use
1458
+ // line — measured on the delivered reports: one such line per finding with use evidence, on the
1459
+ // card, in every case the block listed. It attributed a layer to itself in the engine's own terms
1460
+ // and made a reader read the same fact twice, the second time out of the context that explains it.
1461
+ // Nothing is lost with it: the cards it linked to sit directly below.
1462
+ if (!clSecondary.length && !clOnField.length) return '';
1312
1463
  const links = clOnField.length
1313
1464
  ? `<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
1465
  : '';
1315
1466
  const cards = clSecondary.map(f => compactCard(f, cardFor(f), recordsByUri)).join('\n ');
1316
- return contribBlock + links + cards;
1467
+ return links + cards;
1317
1468
  }
1318
1469
 
1319
1470
  // Secondary findings → collapsible region groups (§2.4). Same region order as the key panel; each region a
@@ -1632,11 +1783,20 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1632
1783
  // preparation is portal-report.mjs's job, never a second render fork here.
1633
1784
  const clHead = clProfile ? `<b>Case-law${clProfile.jurisdiction ? ` (${esc(clProfile.jurisdiction)})` : ''}.</b>` : '';
1634
1785
  const clBody = !clProfile ? '' : renderProse(clProfile.body); // one report: the full body (renderProse handles ::p:: internal lines)
1635
- const caseLawStrand = clProfile
1786
+ // Only a pass that FOUND precedent puts a strand on the card. Where none was found, or the research
1787
+ // could not be completed, the body is the engine's account of the attempt and the Court decisions
1788
+ // section says the outcome plainly on its own; repeating it here said it twice, the second time in
1789
+ // the machinery's voice on the card a client reads most closely.
1790
+ const caseLawStrand = clProfile && COURT_DECISIONS !== 'not-checked' && COURT_DECISIONS !== 'none-found'
1636
1791
  ? `<div class="clstrand" style="margin:10px 0 0;padding-top:8px;border-top:1px dashed var(--line,#ddd)">${clHead}${clBody}</div>`
1637
1792
  : '';
1638
1793
  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>`;
1794
+ // NO AUDIT REFERENCE ON A CLIENT'S CARD. "audit ref F1" is the engine's handle for the finding —
1795
+ // it indexes the workbook, it means nothing to the reader holding the report, and the mock carries no
1796
+ // such line. The SOURCE LINK stays: it is the only address a reader has for the record on this card
1797
+ // until the workbook row lands beside it, and dropping both would take a fact away rather than a
1798
+ // label. With no link there is nothing left to say, so the row does not render at all.
1799
+ 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
1800
  // WP-receipts W4 — the code-owned senior-right line (Owner decision 2026-07-05: VERY SIMPLE CLEAR ENGLISH,
1641
1801
  // stated qualification, verdict untouched). Verified senior → nothing extra (the W2 receipt line on
1642
1802
  // the fetched leg is the proof). Unverified → the open item, plainly, where the finding lives.
@@ -2059,8 +2219,7 @@ window.addEventListener('hashchange',_cardHashGo);
2059
2219
  window.addEventListener('load',_cardHashGo);
2060
2220
  window.addEventListener('beforeprint',function(){document.querySelectorAll('details').forEach(function(d){d.dataset.o=d.open?'1':'';d.open=true;});});
2061
2221
  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');}}});`;
2222
+ ${EXPORT_MENU_JS}`;
2064
2223
 
2065
2224
  // Render a parsed report + findings.json to a full self-contained HTML string.
2066
2225
  // A1 — famous-neighbour context notes: knowledge-cited references kept for diligence (digest.md's "never
@@ -2080,7 +2239,7 @@ document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=doc
2080
2239
  function markAssessmentBlock(ma) {
2081
2240
  if (ma == null) return '';
2082
2241
  const structured = typeof ma.distinctiveness === 'object' || typeof ma.connotation === 'object';
2083
- const SEC = `<div class="sec"><h2>The mark itself</h2></div>`;
2242
+ const SEC = `<div class="sec"><span class="num"></span><h2>The mark itself</h2></div>`;
2084
2243
  if (!structured) {
2085
2244
  const dist = String(ma?.distinctiveness ?? '').trim(), conn = String(ma?.connotation ?? '').trim();
2086
2245
  if (!dist && !conn) return '';
@@ -2150,7 +2309,7 @@ function contextNotesList(notes = []) {
2150
2309
  // the findings-section assembly below.
2151
2310
  function contextNotesBlock(notes = []) {
2152
2311
  if (!notes.length) return '';
2153
- return `<div class="sec"><h2>Famous-mark neighbours noted</h2></div>
2312
+ return `<div class="sec"><span class="num"></span><h2>Famous-mark neighbours noted</h2></div>
2154
2313
  ${contextNotesList(notes)}`;
2155
2314
  }
2156
2315
 
@@ -2283,6 +2442,13 @@ function depthStripHtml(note) {
2283
2442
  }
2284
2443
 
2285
2444
  // Signature: renderHtml(parsed, findings, coverage, opts). ONE render path — there is no client variant.
2445
+ // THE BOARD'S FIVE ENTRIES, in its order. The rule that filters them lives in shared/brand.mjs, because
2446
+ // the knockout renderer draws a strip from its own board and the filtering is the half worth having once.
2447
+ const STRIP = Object.freeze([
2448
+ ['summary', 'Summary'], ['findings', 'Findings'], ['also-considered', 'Also considered'],
2449
+ ['next', 'Next steps'], ['searched', 'What was searched'],
2450
+ ]);
2451
+
2286
2452
  export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2287
2453
  if (findings && !Array.isArray(findings)) { opts = findings; findings = []; coverage = []; } // tolerate legacy (parsed, opts)
2288
2454
  AS_OF = opts.asOf ?? null; // C2
@@ -2292,6 +2458,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2292
2458
  SEARCHED_JUR = Array.isArray(opts.searchedJurisdictions) && opts.searchedJurisdictions.length ? opts.searchedJurisdictions : null; // T6 (D4)
2293
2459
  SCOPE_WORLDWIDE = opts.scopeBasis === 'worldwide' ? true : null; // the plan's scope_basis; null ⇒ fall back to the ledger-prose sniff
2294
2460
  CASE_LAW_BY_ORD = opts.caseLawByOrdinal instanceof Map ? opts.caseLawByOrdinal : new Map(); // T7 (E5)
2461
+ COURT_DECISIONS = opts.searchDepth?.counts?.courtDecisions ?? null; // gates the card's case-law strand
2295
2462
  ENFORCER_SIGNALS = new Map((Array.isArray(opts.enforcerSignals) ? opts.enforcerSignals : []).map((e) => [String(e.uri ?? '').toLowerCase(), e])); // T7 (E6)
2296
2463
  RECORD_ORIGIN = opts.recordOrigin ?? null; // WP-receipts W2
2297
2464
  // — `null` and `` mean DIFFERENT things and the render must not collapse them. `` is an
@@ -2429,13 +2596,13 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2429
2596
  const hasCL = Boolean(clBody);
2430
2597
  let findingsSections, covNum;
2431
2598
  if (!DISPOSITION_MODE) {
2432
- findingsSections = `${onField.length ? `<div class="sec"><h2>The conflict landscape</h2></div>
2599
+ findingsSections = `${onField.length ? `<div class="sec" id="findings"><span class="num"></span><h2>The conflict landscape</h2></div>
2433
2600
  <div class="landwrap">${quadrant(sorted)}${keyPanel(sorted, recordsByUri)}</div>
2434
2601
 
2435
- <div class="sec"><h2>Conflicts</h2></div>
2602
+ <div class="sec"><span class="num"></span><h2>Conflicts</h2></div>
2436
2603
  ${onField.map(f => findingCard(f, cardFor(f), recordsByUri)).join('\n ')}` : ''}
2437
2604
 
2438
- ${secReg.length ? `<div class="sec"><h2>Secondary &amp; watch</h2></div>
2605
+ ${secReg.length ? `<div class="sec"><span class="num"></span><h2>Secondary &amp; watch</h2></div>
2439
2606
  ${secondaryRegions(secReg, cardFor, recordsByUri)}` : ''}${hasCL ? `
2440
2607
 
2441
2608
  ${clBody}` : ''}`;
@@ -2444,10 +2611,10 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2444
2611
  let secNum = 0;
2445
2612
  const num = () => String(++secNum).padStart(2, '0');
2446
2613
  const landscape = onField.length
2447
- ? `<div class="sec"><h2>The conflict landscape</h2></div>
2614
+ ? `<div class="sec" id="findings"><span class="num"></span><h2>The conflict landscape</h2></div>
2448
2615
  <div class="landwrap">${quadrant(sorted)}${keyPanel(sorted, recordsByUri)}</div>
2449
2616
 
2450
- <div class="sec"><h2>Conflicts</h2></div>
2617
+ <div class="sec"><span class="num"></span><h2>Conflicts</h2></div>
2451
2618
  ${onField.map(f => findingCard(f, cardFor(f), recordsByUri)).join('\n ')}`
2452
2619
  : '';
2453
2620
  // THE COMMON-LAW CARDS GO WITH THE CONFLICTS, NOT UNDER THE NEXT HEADING. Appended at the end they
@@ -2474,7 +2641,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2474
2641
  const notes = contextNotes.length ? `\n <p class="fold-lead"><b>Famous-mark neighbours.</b> Diligence — no register record; not scored, does not affect the risk read.</p>
2475
2642
  ${contextNotesList(contextNotes)}` : '';
2476
2643
  if (negatives.total || notes) {
2477
- tail += `\n\n <div class="sec"><h2>Notable but manageable</h2></div>
2644
+ tail += `\n\n <div class="sec"><span class="num"></span><h2>Notable but manageable</h2></div>
2478
2645
  ${negatives.total ? reasonedNegatives(negatives.groups, cardFor, recordsByUri) : ''}${notes}`;
2479
2646
  }
2480
2647
  } else if (band2r.length || band3r.length || contextNotes.length) {
@@ -2484,7 +2651,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2484
2651
  ${secondaryRegions(band3r, cardFor, recordsByUri)}`);
2485
2652
  if (contextNotes.length) parts.push(`<p class="fold-lead"><b>Famous-mark neighbours.</b> Diligence — no register record; not scored, does not affect the risk read.</p>
2486
2653
  ${contextNotesList(contextNotes)}`);
2487
- tail += `\n\n <div class="sec"><h2>Notable but manageable</h2></div>
2654
+ tail += `\n\n <div class="sec"><span class="num"></span><h2>Notable but manageable</h2></div>
2488
2655
  ${parts.join('\n ')}`;
2489
2656
  }
2490
2657
  // The 2026-09-16 report redesign — the section heading and its routing notice go; the CARDS stay, or a
@@ -2514,7 +2681,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2514
2681
  const linkChrome = !!opts.chromeHref;
2515
2682
  const cssInline = REPORT_BASE + '\n' + (linkChrome ? '' : NAV_SHEET) + '\n' + REP_OVERRIDE;
2516
2683
  const chromeLinkTag = linkChrome ? `<link rel="stylesheet" href="${escAttr(opts.chromeHref)}">` : '';
2517
- return `<!DOCTYPE html>
2684
+ const doc = `<!DOCTYPE html>
2518
2685
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
2519
2686
  <title>${productName ? `${esc(productName)} — ` : ''}${esc(fm.title || '')} · ${esc(BRAND.name)}</title>
2520
2687
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin>
@@ -2530,11 +2697,20 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2530
2697
  <span class="sp"></span>
2531
2698
  <span class="tb-risk" style="background:var(${STOP_VAR[i]})">${riskLabel}</span>
2532
2699
  <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>` : ''}
2700
+ ${/* A DATE, NOT A TIMESTAMP. This read "Issued on 2026-09-17 · 08:36 GMT+2" — the minute the file
2701
+ was written, and the zone the machine that wrote it happened to be in. Neither tells a reader
2702
+ anything, and on work that spans days it implies a precision the work does not have. Every mock
2703
+ issues the date alone. Taken by PATTERN rather than by cutting at the separator, so a value in
2704
+ some other shape falls through whole instead of being truncated at whatever character sits
2705
+ there — an archived run's stamp is not this publisher's to assume. */''}
2706
+ ${(() => {
2707
+ const d = (String(opts.issued ?? '').match(/^\d{4}-\d{2}-\d{2}/) || [])[0] ?? opts.issued;
2708
+ return d ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued on ${esc(d)}</span>` : '';
2709
+ })()}
2534
2710
  <button type="button" class="tbbtn tb-ask no-print">✦ <span class="tb-lbl">Ask AI</span></button>
2535
2711
  <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>
2712
+ ${EXPORT_TOGGLE}
2713
+ ${exportPopover(`
2538
2714
  <div class="tb-pop-title">Export &amp; audit</div>
2539
2715
  <button class="util primary" onclick="exportPDF()">⬇ Export PDF (ticked findings)</button>
2540
2716
  ${excelBtn2}
@@ -2542,15 +2718,16 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2542
2718
  <div class="tb-row"><button class="util" onclick="pickAll(true)">Select all</button><button class="util" onclick="pickAll(false)">Select none</button></div>
2543
2719
  <div class="tb-row"><button class="util" onclick="openAll(true)">Expand all</button><button class="util" onclick="openAll(false)">Collapse all</button></div>
2544
2720
  <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>
2721
+ `)}
2546
2722
  </div>
2547
2723
  </div>
2724
+ <!--SECTION-STRIP-->
2548
2725
  </div>
2549
2726
 
2550
2727
  <div class="wrap">
2551
2728
  <!-- doc-52 §1 THE VERDICT — the answer leads: mark · band · one plain "subject to" line · the lawyer's
2552
2729
  questions answered · the time-critical alert. Process + coverage detail are below and collapsible. -->
2553
- <header class="hero">
2730
+ <header class="hero" id="summary">
2554
2731
  ${demoBannerHtml(opts.demoData === true)}
2555
2732
  ${confLineHtml(opts.delivery, productName)}
2556
2733
  <h1 class="mark">${esc(fm.title || '')}</h1>
@@ -2576,7 +2753,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2576
2753
  ${alsoConsideredSection(ruledOut, recordsByUri, opts)}
2577
2754
 
2578
2755
  <!-- doc-52 §3 WHAT ONLY YOU CAN CLOSE — forward decisions, plain English, after the findings. -->
2579
- ${buckets.you ? `<div class="sec" id="only-you"><h2>What only you can close</h2></div>
2756
+ ${buckets.you ? `<div class="sec" id="next"><span class="num"></span><h2>What happens next</h2></div>
2580
2757
  <div class="panel actions"><div class="actgrp act-you">${renderProse(buckets.you.body)
2581
2758
  .replace(/\[Time-critical\]\s*/gi, '<span class="src cl" style="margin-right:6px">Time-critical</span> ')
2582
2759
  .replace(/\[Open question\]\s*/gi, '<span class="src" style="margin-right:6px">Open question</span> ')
@@ -2598,25 +2775,57 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2598
2775
  ${whatWasSearchedSection(opts, coverage, findings, recordsByUri)}
2599
2776
 
2600
2777
  <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>
2778
+ ${/* ONE CLIENT LINE, AND IT NAMES WHAT EACH DATE IS. It ran to four.
2779
+ "Rated under" STAYS, and it is not an oversight: the document carries the reviewer's
2780
+ provenance and portal-report strips that one line for every embedded reader at serve time,
2781
+ which is stated there and held by an arm. Deleting it here would take provenance off the
2782
+ reviewer's copy to save the portal a job it already does.
2783
+ "Run under project" also STAYS on the document, for the same reason and with the same
2784
+ remedy. It is internal provenance — an end-to-end arm reads it out of the published internal
2785
+ report, through the front-matter seam — and it was reaching clients only because nothing
2786
+ removed it at serve time. It does now, beside the provenance line, so the document keeps what
2787
+ review needs and the client reads neither. Deleting it here would have taken provenance off
2788
+ the internal copy to fix a leak that belongs in the same place the other one is fixed.
2789
+ The product name lead goes: the identity line already says which search this is.
2790
+ The run string is composed as "<date> · <provider>", and it was printed with neither part
2791
+ labelled, so a bare date sat next to a matter identifier meaning nothing in particular. It is
2792
+ split on the DATE by pattern — the same way `dateOf` does it one file over — and the remainder
2793
+ is the provider. A run string with no date in it is not this renderer's to take apart, so it
2794
+ is printed whole and unlabelled rather than guessed at. */''}
2795
+ <!-- WHICH MODELS SERVED THIS RUN. It used to close the scope fold, and the 2026-09-16 redesign
2796
+ deleted that fold — so the line was re-homed rather than dropped with its container, which
2797
+ would have removed a statement of provenance from the client's page as a side effect of a
2798
+ merge. Owner ruling, 2026-09-17: it belongs in the footer, beside the matter and the framework.
2799
+ It renders as '' on a run that recorded no models, so an archived run republishes exactly as
2800
+ delivered, and the call sits ADJACENT so the whitespace around it belongs to the line and goes
2801
+ with it — the freeze tool sets this paragraph aside by a pattern that takes the space before
2802
+ it. -->${servedModelsLine(opts.servedModels)}
2803
+ <span>${(() => {
2804
+ const runStr = String(fm.run ?? '').trim();
2805
+ const searchedOn = (runStr.match(/\d{4}-\d{2}-\d{2}/) || [])[0] ?? null;
2806
+ const provider = searchedOn
2807
+ ? runStr.replace(searchedOn, '').replace(/^[\s\u00b7]+|[\s\u00b7]+$/g, '').trim()
2808
+ : runStr;
2809
+ const issuedOn = (String(opts.issued ?? '').match(/^\d{4}-\d{2}-\d{2}/) || [])[0] ?? opts.issued ?? null;
2810
+ const parts = [
2811
+ fm.matter ? `Matter ${esc(fm.matter)}` : '',
2812
+ searchedOn ? `searched on ${esc(searchedOn)}` : '',
2813
+ issuedOn ? `issued on ${esc(issuedOn)}` : '',
2814
+ provider ? esc(provider) : '',
2815
+ ].filter(Boolean);
2816
+ const line = parts.length ? `${parts.join(' \u00b7 ')}.` : '';
2817
+ // The reviewer's provenance rides BELOW the client line, where portal-report removes it.
2818
+ // Both provenance lines ride BELOW the client line, where portal-report removes them.
2819
+ return line
2820
+ + (fm.rated_under ? `<br>Rated under: <span class="mono">${esc(fm.rated_under)}</span>.` : '')
2821
+ + (fm.run_under_project ? `<br>Run under project: <span class="mono">${esc(fm.run_under_project)}</span>.` : '');
2822
+ })()}</span>
2615
2823
  ${logoLockup({ mark: 16 })}
2616
2824
  </footer>
2617
2825
  </div>
2618
2826
  <script>${PAGE_JS}</script>
2619
2827
  </body></html>`;
2828
+ return doc.replace('<!--SECTION-STRIP-->', sectionStrip(doc, STRIP));
2620
2829
  }
2621
2830
 
2622
2831
  // CLI: node render.mjs <report.md> [findings.json] [outDir]
@@ -2658,3 +2867,24 @@ function officeLinkNote() {
2658
2867
  return (linked ? 'A registration number shown as a link opens the office’s own page for that record. ' : '')
2659
2868
  + officeReasonSentences(RECORD_LINKS).map((s) => `${esc(s)} `).join('');
2660
2869
  }
2870
+
2871
+ // THE MODELS THAT SERVED THIS SEARCH, as one closing line of the scope section (2026-09-14). The ids are
2872
+ // the ones the engine reported for its turns (tokens.mjs servedModels), never the tier a stage asked
2873
+ // for in place of a model the engine named: every tier goes to the program as the vendor's alias, and an
2874
+ // alias names no model. Both report
2875
+ // kinds call this, so they say it in the same words. '' when the run recorded none, so a run published
2876
+ // before the record existed renders exactly as it was delivered.
2877
+ //
2878
+ // A TIER WORD IS CLAUDE'S. servedModels lists a turn served under a company's own deployment name as the
2879
+ // tier it asked for ("Opus"), never the name, so the list may read "claude-opus-5, Haiku". Both are
2880
+ // Claude's and the line says so once; in a list that also names another vendor, the word says it itself
2881
+ // ("Claude Opus"). The four words, Fable among them, are the ones servedModels writes. They are kept here
2882
+ // rather than imported, because tokens.mjs loads the driver's settings and this module renders without them.
2883
+ const CLAUDE_TIER_WORD_RE = /^(?:Opus|Sonnet|Haiku|Fable)$/;
2884
+ export function servedModelsLine(ids) {
2885
+ const list = (Array.isArray(ids) ? ids : []).map((s) => String(s ?? '').trim()).filter(Boolean);
2886
+ if (!list.length) return '';
2887
+ const claude = list.every((id) => /^claude-/i.test(id) || CLAUDE_TIER_WORD_RE.test(id));
2888
+ const shown = claude ? list : list.map((id) => (CLAUDE_TIER_WORD_RE.test(id) ? `Claude ${id}` : id));
2889
+ return `<p class="servedby" style="margin:10px 0 0;font-size:13px">Prepared with${claude ? ' Claude' : ''}: ${shown.map(esc).join(', ')}.</p>`;
2890
+ }