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
@@ -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');}}});`;
@@ -92,7 +92,29 @@ export function clearedNames(auditMd, recordIndex = {}) {
92
92
  uri,
93
93
  });
94
94
  } else if (!/^NR\d+/.test(title) && /common-law/i.test(layer) && !/^\(none/i.test(title)) {
95
- out.web.push({ title, url: field(block, "url"), type: field(block, "type") });
95
+ // ── A NAME IS A NAME AT A PLACE. THE READINGS ARE NOT NAMES ─────────────────────────────────
96
+ //
97
+ // The common-law layer carries two kinds of block under one heading style: a name somebody is
98
+ // trading under, and a reading of what the mark MEANS. The first has a `url` — it is a thing at a
99
+ // place a reader can go and look at. The second has none, because there is nothing to open: it is
100
+ // an etymology, a sensitivity, a piece of context.
101
+ //
102
+ // Both were listed as "Web and marketplace names", and a reading is a sentence, so the mark chip
103
+ // built for a name truncated it with an ellipsis and its right-hand column rendered empty.
104
+ // Measured on the delivered full country report: one such chip held 416px of content in a 200px
105
+ // box. Across every demo product, 7 of 31 blocks carried no url and every one of the 7 was a
106
+ // reading rather than a name.
107
+ //
108
+ // NOTHING IS LOST BY LEAVING THEM OUT, and that was measured rather than assumed: the report
109
+ // already carries each of those readings in the section written for them — the Quechua one appears
110
+ // five more times in the same document, under "Connotation & meaning" and again in the decision it
111
+ // asks the reader to take — and the audit workbook carries every block whatever this does.
112
+ //
113
+ // WHAT IT COSTS, stated rather than hidden: a marketplace name sighted with no URL recorded would
114
+ // not be listed here. None exists in any demo, and the row such a block produced was already a
115
+ // name beside an empty column. If one appears, the fix is to record where it was seen.
116
+ const url = field(block, "url");
117
+ if (url) out.web.push({ title, url, type: field(block, "type") });
96
118
  }
97
119
  }
98
120
  return out;
@@ -104,9 +126,18 @@ export function clearedNames(auditMd, recordIndex = {}) {
104
126
  * EVERY COUNTRY THE RUN READ, including the ones that came back clean — those are the whole point. A
105
127
  * count keyed off the findings would list only countries with a conflict, which is the gap this closes.
106
128
  *
107
- * @param {string[]} recordFileNames the `_records/` directory listing, named `<cc>-<id>.json`
129
+ * THREE-VALUED, in the house pattern outputMeta already uses for a stage's output: `null` in means the
130
+ * run has NO `_records/` store, and `null` comes back out — we cannot say how many records were read.
131
+ * An empty ARRAY is the other thing entirely: the store is there and holds nothing, which is a real zero
132
+ * and renders as one. Collapsing the two is what this fixes; they arrived here as the same `[]` and the
133
+ * renderer could only drop the section, so a register that archives nothing read as a register nobody
134
+ * searched.
135
+ *
136
+ * @param {string[]|null} recordFileNames the `_records/` listing, named `<cc>-<id>.json`; null = no store
137
+ * @returns {object|null} counts by country code, or null when the run cannot say
108
138
  */
109
139
  export function recordsByCountry(recordFileNames = []) {
140
+ if (recordFileNames === null) return null;
110
141
  const out = {};
111
142
  for (const name of recordFileNames) {
112
143
  const cc = (String(name).match(/^([a-z]{2})-/i) || [])[1];
@@ -115,6 +146,16 @@ export function recordsByCountry(recordFileNames = []) {
115
146
  return out;
116
147
  }
117
148
 
149
+ /** Band record ids → `<office>-<id>` names, one per distinct record, in the archive's own naming. PURE. */
150
+ export function recordNamesFromIds(ids) {
151
+ const out = new Set();
152
+ for (const id of ids ?? []) {
153
+ const m = /^\/mark\/([a-z]{2,4})\/(.+)$/i.exec(String(id ?? ""));
154
+ if (m) out.add(`${m[1].toLowerCase()}-${m[2]}`);
155
+ }
156
+ return [...out];
157
+ }
158
+
118
159
  /** Marketplace, web, reputation and meaning checks, from the deterministic grid the tools wrote. PURE. */
119
160
  export function sweepCounts(commonLawGrid, auditMd = "") {
120
161
  const cells = Array.isArray(commonLawGrid?.cells) ? commonLawGrid.cells : [];
@@ -148,6 +189,81 @@ export function courtDecisionsState(caseLawText) {
148
189
  return "found";
149
190
  }
150
191
 
192
+ /**
193
+ * WHICH TERRITORIES THE RUN SEARCHED, AND WHICH IT COULD NOT REACH — from the register PLAN. PURE.
194
+ *
195
+ * The plan is the authority on what was asked of the register; the `_records/` archive is only the
196
+ * authority on what came back and was kept. Reading "what was searched" off the archive is why a
197
+ * provider that keeps no records read as a provider nobody asked: no records, no countries, no section.
198
+ * Both halves are on the plan whether or not anything is archived — `entries[].regions` is what it will
199
+ * query, and `deferred_coverage` is what this provider does not cover, carrying the reason for each.
200
+ *
201
+ * Null for a run with no plan to read, which is an archived or legacy run: that is "cannot say", and it
202
+ * is not the same answer as a plan that named nothing.
203
+ *
204
+ * @param {object|null} plan the parsed `register-plan.json`, or null when there is none
205
+ */
206
+ export function planTerritoriesOf(plan) {
207
+ if (!plan || typeof plan !== "object") return null;
208
+ const entryRegions = (plan.entries ?? []).flatMap((e) => (Array.isArray(e?.regions) ? e.regions : []));
209
+ // `plan.regions` is the older shape and is the fallback, not a second source: a plan carrying entries
210
+ // has already said which regions it will query, and unioning the two would report a region the
211
+ // compiler moved OUT of `regions` into the deferral list as though it had been searched.
212
+ const searched = entryRegions.length ? [...new Set(entryRegions.map(String))]
213
+ : [...new Set((Array.isArray(plan.regions) ? plan.regions : []).map(String))];
214
+ // A WORLDWIDE PLAN THAT NAMES NO REGION CANNOT SAY WHERE IT REACHED. On a provider that takes no region
215
+ // list, worldwide compiles to queries with no jurisdiction clause at all — the whole database — so the
216
+ // plan names nothing, and read as "searched these" that is "searched nowhere": the section that says
217
+ // where a search reached disappeared on exactly the searches that reached furthest. Null is the answer
218
+ // the plan can actually give, and the page then reads the countries off what the register returned.
219
+ // Only when nothing was deferred: a worldwide order with deferrals is not an unrestricted sweep.
220
+ if (plan.scope_basis === "worldwide" && !searched.length
221
+ && !(Array.isArray(plan.deferred_coverage) && plan.deferred_coverage.length)) return null;
222
+ const unreached = (Array.isArray(plan.deferred_coverage) ? plan.deferred_coverage : [])
223
+ .map((d) => ({ jurisdiction: String(d?.jurisdiction ?? "").trim(), reason: String(d?.reason ?? "").trim() }))
224
+ .filter((d) => d.jurisdiction);
225
+ return { searched, unreached };
226
+ }
227
+
228
+ /**
229
+ * HOW DEEP THE LOCAL-LANGUAGE INVESTIGATION ACTUALLY WENT, against what the matter configured. PURE.
230
+ *
231
+ * The engine can run this investigation shallower than the account asked for, and until now it said so
232
+ * in exactly one place: a sentence a model wrote in the Methodology paragraph. The redesigned report
233
+ * replaces that paragraph with counts and named rows, so a run that went shallow said so on no page at
234
+ * all. This is the field behind that row.
235
+ *
236
+ * DERIVED FROM THE RUN'S OWN RECORD, NEVER FROM PROSE, and not derived here either: the caller hands in
237
+ * what `deriveLaneDepthVerdicts` produced, which is the one author of asked-versus-ran and reads the
238
+ * frozen lane sidecar against the slices that executed. A second opinion computed in the publish path
239
+ * would be a second answer to a question the engine has already answered.
240
+ *
241
+ * THE FOUR STATES, and the order they are decided in matters:
242
+ * not-in-scope no lane was asked for anything — a plain clearance, or every lane switched off
243
+ * not-run lanes were asked and none of them ran
244
+ * ran-shallow a lane fell short of its ask, or was asked and did not run while another did
245
+ * ran every lane that was asked ran at the depth it was asked for
246
+ *
247
+ * `ran: null` IS NOT `candidates`. A lane whose slices settle to nothing readable cannot say what it
248
+ * delivered, and the jx verdicts are careful to report that as unestablished rather than as the lesser
249
+ * depth. Folding it to `ran` here would put that claim back on a client's page, so it counts as short.
250
+ *
251
+ * @param {object|null} verdicts per-lane `{asked, ran, shortfall}` from deriveLaneDepthVerdicts
252
+ */
253
+ export function localLanguageDepth(verdicts) {
254
+ if (!verdicts || typeof verdicts !== "object") return { state: "not-in-scope", lanes: {} };
255
+ const lanes = {};
256
+ for (const [lane, v] of Object.entries(verdicts)) {
257
+ lanes[lane] = { configured: v?.asked ?? null, achieved: v?.ran ?? null };
258
+ }
259
+ const asked = Object.entries(verdicts).filter(([, v]) => v?.asked && v.asked !== "off");
260
+ if (!asked.length) return { state: "not-in-scope", lanes };
261
+ const ran = asked.filter(([, v]) => v?.ran);
262
+ if (!ran.length) return { state: "not-run", lanes };
263
+ const short = asked.some(([, v]) => v?.shortfall === true || !v?.ran);
264
+ return { state: short ? "ran-shallow" : "ran", lanes };
265
+ }
266
+
151
267
  /** Was the name searched in a non-Latin script? Read off the plan's own terms, never asserted. PURE. */
152
268
  export function localScriptSearched(registerPlan) {
153
269
  const entries = Array.isArray(registerPlan?.entries) ? registerPlan.entries : [];
@@ -159,7 +275,16 @@ export function localScriptSearched(registerPlan) {
159
275
  *
160
276
  * @returns {{schemaVersion: number, cleared: object, counts: object}}
161
277
  */
162
- export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNames = [], commonLawGrid = null, caseLawText = "", registerPlan = null } = {}) {
278
+ export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNames = [], bandRecordIds = null, commonLawGrid = null, caseLawText = "", registerPlan = null, laneDepthVerdicts = null } = {}) {
279
+ // `recordFileNames: null` travels all the way to the page — see recordsByCountry. The default stays `[]`
280
+ // because that is "the caller said nothing", not "the store is absent"; only the publish path knows the
281
+ // difference and it is the one producer.
282
+ //
283
+ // A PROVIDER THAT ARCHIVES NO RECORDS STILL RETURNED THEM. Its search answer is the band, and every
284
+ // record in it carries its office in its own id (`/mark/<office>/<id>`). So where there is no archive
285
+ // the band is what was read: counted by record, filed by office. Without it a run that read 785 register
286
+ // records reported "cannot say" and the report carried no register row and no country at all.
287
+ if (recordFileNames === null && Array.isArray(bandRecordIds)) recordFileNames = recordNamesFromIds(bandRecordIds);
163
288
  const cleared = clearedNames(auditMd, recordIndex);
164
289
  const groups = {};
165
290
  for (const key of CLEARED_GROUPS) groups[key] = 0;
@@ -169,10 +294,14 @@ export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNa
169
294
  cleared: { register: cleared.register, web: cleared.web, groups },
170
295
  counts: {
171
296
  recordsByCountry: recordsByCountry(recordFileNames),
172
- recordsRead: recordFileNames.length,
297
+ recordsRead: recordFileNames === null ? null : recordFileNames.length,
173
298
  sweep: sweepCounts(commonLawGrid, auditMd),
174
299
  localScriptSearched: localScriptSearched(registerPlan),
175
300
  courtDecisions: courtDecisionsState(caseLawText),
301
+ // `localScriptSearched` above answers whether the spellings were searched; this answers how deep
302
+ // the investigation went against what was configured. Two different facts, and the row the report
303
+ // reserves is for the second.
304
+ localLanguage: localLanguageDepth(laneDepthVerdicts),
176
305
  },
177
306
  };
178
307
  }
@@ -113,6 +113,39 @@
113
113
  .panel.actions p,.panel.actions li{font-size:13.5px;color:var(--slate);line-height:1.55}
114
114
  .panel.actions ul{margin:0;padding-left:20px} .panel.actions li{margin:3px 0}
115
115
 
116
+ /* ── THE BOARD'S OWN SECTION STRIP, lifted from the approved mock rather than written here ────────
117
+ Five entries and the board's own labels, marked not to print because the board marks it so: a strip
118
+ that follows the reader down the page is a control, and paper has nothing for it to follow. Every
119
+ anchor it names is drawn by the renderer above it.
120
+
121
+ THE SECTION NUMBER IS DRAWN AND HIDDEN, and that is the board's doing, not an oversight here: the
122
+ mock emits `<span class="num"></span>` on every section and then sets `display:none` on it further
123
+ down its own stylesheet. Carried faithfully, so the delivered document and the approved one hold the
124
+ same elements — this changes no pixel, and a reader looking for a visible number will not find one
125
+ in the board either. */
126
+ /* THE BREADCRUMB IS A ROW OF THE HEADER, not a bar under it (owner, 2026-09-18).
127
+ It used to be a SIBLING of .rep-stickyhead, pinned on its own at top:var(--tb-h,52px) — and
128
+ --tb-h is set nowhere in this product, so the fallback was a guess at a bar that measures ~46px:
129
+ content showed through the slit between the two, and z-index:20 put the strip UNDER the header's
130
+ 100 whenever the guess was wrong. The strip is now emitted inside .rep-stickyhead, so the header
131
+ and the breadcrumb are one sticky surface that pins and unpins together and can never gap or
132
+ overlap, whatever the bar measures or how it wraps.
133
+ No background and no bottom hairline of its own: the wrapper carries the blurred surface and the
134
+ single edge, and the border-top here is the divider between the two rows. It is on .strip rather
135
+ than on any wrapper because sectionStrip() emits NOTHING when fewer than two of its sections are
136
+ live — a wrapper would leave a stray line on those reports.
137
+ The gutter matches the topbar's, so the first entry lines up under the back button. */
138
+ .strip{display:flex;gap:4px;align-items:center;padding:4px max(26px,calc((100% - 1120px)/2)) 6px;
139
+ border-top:1px solid var(--line);font:600 12px/1 'Satoshi','Helvetica Neue',Arial,sans-serif;
140
+ letter-spacing:.04em;overflow-x:auto;scrollbar-width:none}
141
+ .strip::-webkit-scrollbar{display:none}
142
+ .strip a{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border-radius:999px;color:#6b5d50;text-decoration:none;white-space:nowrap}
143
+ .strip a i{width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor;box-sizing:border-box}
144
+ .strip a.done{color:#4c7a4c}
145
+ .strip a.done i{background:#4c7a4c;border-color:#4c7a4c}
146
+ .strip a.now{color:#250902;background:rgba(0,0,0,.06)}
147
+ .strip a.now i{border-color:#860F09;border-width:3px}
148
+ .sec .num{display:none}
116
149
  .sec{margin:56px 0 18px;display:flex;align-items:baseline;gap:15px;border-top:2px solid var(--ink);padding-top:14px}
117
150
  .sec .num{font-family:var(--mono);font-size:13px;color:var(--crimson);font-weight:600}
118
151
  .sec h2{font-weight:900;font-size:25px;margin:0;letter-spacing:-.015em}
@@ -239,7 +272,19 @@
239
272
  .tb-hint{font-size:11px;color:var(--faint);line-height:1.4;margin:2px 2px 0}
240
273
  @media(max-width:860px){.tb-matter{display:none}}
241
274
  @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}}
275
+ @media(max-width:560px){.wm small{display:none}.tb-back-lbl{display:none}.tb-lbl{display:none}
276
+ /* THE SAME ROW, CLOSER TOGETHER — the ladder above already collapses this bar's LABELS at these
277
+ widths, and it was still 35 to 51px wider than a phone screen, so the whole document scrolled
278
+ sideways: the reader dragged the page left and right to read a paragraph. Nothing is hidden and
279
+ nothing moves; the gutter, the gaps and one dead margin come in. The margin is dead here by the
280
+ line above — it separated the wordmark from a tagline that is no longer displayed. */
281
+ .topbar{gap:4px;padding-left:12px;padding-right:12px}}
282
+ /* AND ON A SMALL PHONE, ONE THING GOES. Closing 375px and below is not a spacing problem — the row's
283
+ own items are wider than the screen — so something has to go, and which one is a decision about what
284
+ a reader keeps rather than a number to tune. Ruled: the back arrow. The product's name and the risk
285
+ label stay, because they say where the reader is and what the answer was; the arrow is a route back
286
+ to a list, and a reader on a phone has the browser's own. (owner, 2026-09-17) */
287
+ @media(max-width:375px){.topbar .tb-back{display:none}}
243
288
 
244
289
  /* hero: conclusion card (dial + verdict) + scope card jurisdiction chips */
245
290
  .heroGrid .gauge{display:flex;flex-direction:column;justify-content:flex-start}
@@ -268,7 +313,26 @@
268
313
 
269
314
 
270
315
  /* cross-region rights-holder rows (.rrow) + secondary region groups (.rgroup) */
316
+ /* A PANEL THAT SCROLLS SIDEWAYS SAYS SO, the same way the knockout's tables do.
317
+ `overflow-y:auto` alone makes the OTHER axis compute to auto as well, so this panel has scrolled
318
+ horizontally for as long as it has scrolled vertically — and drawn nothing to say it. Measured on a
319
+ delivered global preliminary at 390px: 377px of rows in a 336px box, 41px unreachable, no bar.
320
+ A reader who cannot see that it drags does not drag it, and a holder's jurisdiction sits in the part
321
+ that is off the edge.
322
+
323
+ THE TWO INSTRUCTIONS CANNOT SIT TOGETHER, and that was measured rather than reasoned on the
324
+ knockout: setting the standard scrollbar-width alongside the pseudo-elements makes the engine take
325
+ the standard path and ignore them, and on the engine that publishes these reports the standard path
326
+ draws an overlay bar with no layout height at all — both together 0px, the pseudo-elements alone
327
+ 9px, the standard properties alone 0px. So the standard ones sit behind a support query the engines
328
+ carrying the pseudo-elements never enter, and each engine gets exactly one instruction. Written the
329
+ way anyone would write it — belt and braces — it renders nothing and the stylesheet gives no sign. */
271
330
  .keypanel{overflow-y:auto}
331
+ .keypanel::-webkit-scrollbar{height:9px;-webkit-appearance:none}
332
+ .keypanel::-webkit-scrollbar-track{background:transparent}
333
+ .keypanel::-webkit-scrollbar-thumb{background:var(--faint);border-radius:5px}
334
+ @supports not selector(::-webkit-scrollbar){
335
+ .keypanel{scrollbar-width:thin;scrollbar-color:var(--faint) transparent}}
272
336
  .rrow{border-top:1px solid var(--line)}
273
337
  .rrow:first-of-type{border-top:0}
274
338
  .rrow>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:9px;padding:8px 4px}
@@ -332,9 +396,11 @@
332
396
  and internal notes (.int-note, hidden above) are the only two things print ever drops. */
333
397
  details>*:not(summary){display:block!important}
334
398
  details>summary{list-style:none;cursor:default}
335
- /* `details.searched` is a SECTION TITLE, so it keeps its summary in print like the region rows and
336
- the secondary groups — a printed report that drops "What was searched" loses the heading over the
337
- counts, not just a control. What it must not print is the ▸/▾ toggle, which on paper is a
399
+ /* `details.searched` keeps its summary in print like the region rows and the secondary groups. The
400
+ reason has CHANGED and the behaviour has not: the summary used to carry the section's own heading,
401
+ so dropping it in print lost the heading over the counts. The heading is now an <h2> in a section
402
+ of its own, as the approved board draws it, and print cannot lose it. What the summary still
403
+ carries is the sub-label over the counts, which is worth printing on its own. What it must not print is the ▸/▾ toggle, which on paper is a
338
404
  right-pointing arrow over content that is already fully expanded. Found by the print check, which
339
405
  walks a rendered page and refuses any disclosure that is in neither list; the public suite does
340
406
  not run it, so the commit that added this fold could ship the half it did not think about. */
@@ -507,9 +573,17 @@
507
573
  details.searched[open]>summary::before{content:'▾'}
508
574
  details.searched .gcount{margin-left:auto;color:var(--slate);font-weight:500;font-size:12.5px}
509
575
  details.searched .gbody{padding:0 18px 12px}
576
+ .wchips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
577
+ details.searched .wstate{font-weight:600}
578
+ details.searched .wnote{display:block;margin-top:4px;font-size:12.5px;line-height:1.45;color:var(--slate)}
510
579
  details.searched .row{display:grid;grid-template-columns:200px 1fr;gap:12px;padding:7px 0;border-top:1px solid var(--line);font-size:13.5px}
511
580
  details.searched .k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
512
581
  .openrows,.provwrap{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
513
582
  .openrows .rk,.provwrap .rk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
514
583
  .provnote{margin:0;font-size:12.5px;line-height:1.5;color:var(--slate)}
515
584
  .topbar .tb-lockup{margin-right:10px;flex:0 0 auto}
585
+ /* AFTER the rule it overrides, not beside its siblings at the breakpoint above: both selectors carry
586
+ the same weight, so the later one wins and a phone rule written earlier in this file did nothing at
587
+ all — measured, not assumed. The margin separates the wordmark from the tagline, and the tagline is
588
+ already hidden at this width. */
589
+ @media(max-width:560px){.topbar .tb-lockup{margin-right:0}}
@@ -618,7 +618,26 @@ 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
+ //
633
+ // AND THE PROBES THE RUN DECIDED ON AND DID NOT MAKE, on the same footing. The recall net's owner
634
+ // budget drops its excess with the party and the probe id recorded; until this row the excess reached
635
+ // no reader, so a search that decided on nineteen ownership checks and made five read as one that
636
+ // made the checks it wanted. Same builder, same four columns, same State colour: this is not a second
637
+ // shape and not a new sheet, and the words are the run receipt's own.
638
+ addSheet(wb, 'Coverage & gaps', COVERAGE_COLS,
639
+ [...coverageRows(coverage), ...coverageRows(contract?.droppedConditions || []),
640
+ ...coverageRows(contract?.undispatchedProbes || [])], (row, _d, kept) => {
622
641
  if (!kept.has('State')) return;
623
642
  const st = row.getCell('State'); const f = STATE_FILL[String(st.value).trim()];
624
643
  if (f) { st.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF' + f } }; st.font = { bold: true }; }
@@ -21,7 +21,7 @@
21
21
  // parse set, or prelim-driver.path's watch — and reordering must NOT wake the runner.
22
22
 
23
23
  import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync } from "node:fs";
24
- import { join, dirname } from "node:path";
24
+ import { join, dirname } from "node:path"; import { studioDirFor } from "../shared/pre-rename-spellings.mjs";
25
25
 
26
26
  export function queueOrderPath(qdir) { return join(dirname(qdir), ".queue-order.json"); }
27
27
 
@@ -69,7 +69,7 @@ export function queueDirsUnder(workspaceRoot) {
69
69
  try { names = readdirSync(workspaceRoot); } catch { return out; }
70
70
  for (const n of names) {
71
71
  if (!n.startsWith("workspace-")) continue;
72
- const q = join(workspaceRoot, n, "studio", "prelim-search", "queue");
72
+ const q = join(studioDirFor(join(workspaceRoot, n)), "queue");
73
73
  try { readdirSync(q); out.push(q); } catch { /* no queue in this workspace */ }
74
74
  }
75
75
  return out.sort();
@@ -70,7 +70,7 @@ export const SURFACE = "surface";
70
70
  * performs anyway. Since the warm and cold rungs DERIVE the tool name from `TOOL_WRITTEN_ARTIFACTS`,
71
71
  * so adding that row — step 1 of every conversion — makes both rungs name the record tool, and (a) went
72
72
  * quiet for it whatever the dispatch said. Measured on conversion 3 before its dispatch was touched: the
73
- * grant carried `record_prelim_variants`, the dispatch did not mention it, both repair rungs did, and (a)
73
+ * grant carried `record_clearance_variants`, the dispatch did not mention it, both repair rungs did, and (a)
74
74
  * was silent. The guard asked a question the conversion's own bookkeeping answered — the tautology shape
75
75
  * `b04d6d58` and the `RECORDING_TOOLS` non-derivation both exist to remove.
76
76
  *
@@ -1107,7 +1107,7 @@ export function planSubQueries({ plan = null, execution = null, scopeTerritories
1107
1107
  // ── THE IN-SCOPE SWEEP'S OWN STATE — what decides whether a narrow was OWED at all ───────────────
1108
1108
  //
1109
1109
  // `subQueryState` returns `none` for a territory with no entry of its own, and that one value covers
1110
- // two opposite situations. The doctrine (`skills/prelim-register/SKILL.md`, Recipe 1 §2b) says a slice
1110
+ // two opposite situations. The doctrine (`skills/clearance-register/SKILL.md`, Recipe 1 §2b) says a slice
1111
1111
  // gets its own `register_enumerate` ONLY on the guarded crowd-narrow path — when Step 2, the
1112
1112
  // region-scoped in-scope sweep, returned `incomplete` and a major may sit in the un-paged remainder.
1113
1113
  // When Step 2 returns `enumerated` the complete set provably contains every in-scope slice and the
@@ -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
@@ -336,10 +336,24 @@ export async function countRegisterHits({
336
336
  try { r = await counter(term, p, { classes: scoped, regions }); }
337
337
  catch (e) { r = { ok: false, total: null, reason: `count threw: ${String(e?.message ?? e).slice(0, 200)}` }; }
338
338
  const ok = Boolean(r?.ok) && Number.isFinite(r?.total);
339
+ // A DISCLOSED APPROXIMATION IS AN ANSWER, AND IT WAS BEING READ AS A FAILURE. When the register
340
+ // says "more than ten thousand", it has answered — it has answered with a floor instead of a
341
+ // count, which is a different thing from not answering at all. `ok` is false for it because the
342
+ // total is deliberately not finite (an approximation must never become a number), so everything
343
+ // downstream saw a dead probe and the client was told the count was not available: the same words
344
+ // the report uses when the register could not be reached. The direction was inverted, too —
345
+ // saturation is a finding about the mark, and the denser the crowd the more certainly it was
346
+ // suppressed.
347
+ const approximated = !ok && Boolean(r?.ok) && r?.approximate === true && Number.isFinite(r?.floor);
348
+ // THE REFUSAL FIELD IS `cause` ON THIS PROVIDER AND `reason` ON OTHERS, and reading only one of
349
+ // them is why an honest refusal arrived as the fallback string with the receipts ledger recording
350
+ // "unknown". The same mismatch is described above `listRecords`, where it was fixed; this is the
351
+ // other half of it. Both are read, in the order a caller would expect.
352
+ const refusal = r?.reason ?? r?.cause ?? null;
339
353
  // A CLIENT-SIDE refusal (the provider's query language cannot express this question — a term
340
354
  // carrying parentheses, an office outside its vocabulary) is
341
355
  // deterministic: no retry and no resume can change it, so it settles rather than re-billing.
342
- const deterministic = !ok && (isCapabilityGap(r?.reason) || Boolean(r?.unsupported));
356
+ const deterministic = !ok && !approximated && (isCapabilityGap(refusal) || Boolean(r?.unsupported));
343
357
  if (ledgerPath) {
344
358
  try {
345
359
  appendFileSync(ledgerPath, JSON.stringify({
@@ -350,13 +364,18 @@ export async function countRegisterHits({
350
364
  ...(form ? { term, variant_form: form } : {}),
351
365
  classes: scoped, regions, provider, probe: r?.probe ?? null,
352
366
  ok, total: ok ? r.total : null, took_ms: Date.now() - started,
353
- ...(ok ? {} : { cause: String(r?.reason ?? "unknown").slice(0, 300) }),
367
+ // An approximation is recorded as what it is. It billed and it answered, so a receipt
368
+ // calling it a failure with cause "unknown" misreports both halves.
369
+ ...(approximated ? { approximate: true, floor: r.floor } : {}),
370
+ ...(ok || approximated ? {} : { cause: String(refusal ?? "unknown").slice(0, 300) }),
354
371
  }) + "\n");
355
372
  } catch { /* receipts are best-effort, never fatal — same as the sweep ledger */ }
356
373
  }
357
- return ok
358
- ? { total: r.total }
359
- : { total: null, unavailable: String(r?.reason ?? "the count could not be taken").slice(0, 300), ...(deterministic ? { deterministic: true } : {}) };
374
+ if (ok) return { total: r.total };
375
+ // `total` stays null for an approximation and that is the rule, not an oversight: the floor is not
376
+ // a count and may never be filled in as one. What travels beside it is the disclosure.
377
+ if (approximated) return { total: null, approximate: true, floor: r.floor };
378
+ return { total: null, unavailable: String(refusal ?? "the count could not be taken").slice(0, 300), ...(deterministic ? { deterministic: true } : {}) };
360
379
  };
361
380
 
362
381
  // The aggregate: the provider's EXACT predicate, once per generated form, summed.
@@ -503,12 +522,38 @@ export function countsForMark(doc, name) {
503
522
  * Office CODES, not names: `scope.regions` on the same artifact is already codes, and the alternative
504
523
  * is inventing a display layer that has to stay in step with the office vocabulary of six providers.
505
524
  */
525
+ /**
526
+ * The register's own floor for a count it stopped taking, or null.
527
+ *
528
+ * ONE READING FOR EVERY PAGE THAT SHOWS A COUNT. The glance line printed the floor and the counts table,
529
+ * the coverage clause and the workbook beside it still printed "not available", so one report said two
530
+ * different things about the same cell. A floor counts only with the register's flag AND the number —
531
+ * a flag with no number says no more than "unknown" does.
532
+ */
533
+ export function disclosedFloor(c) {
534
+ return c?.approximate === true && Number.isFinite(c?.floor) ? c.floor : null;
535
+ }
536
+
537
+ /** A floor as every page prints it: the register's own figure, and no sentence around it. */
538
+ export const moreThan = (floor) => `more than ${floor.toLocaleString("en-US")}`;
539
+
506
540
  export function countLine(entry) {
507
541
  if (!entry?.counts) return null;
508
542
  const parts = COUNT_PREDICATES.map((p) => {
509
543
  const c = entry.counts[p.key];
510
544
  const word = p.glance ?? p.label.toLowerCase();
511
545
  if (Number.isFinite(c?.total)) return `${c.total} ${word}`;
546
+ // A REGISTER THAT ANSWERS WITH A FLOOR HAS ANSWERED. Some registers stop counting and report
547
+ // "more than ten thousand" rather than a total; that is the register's own figure and it is what
548
+ // the client is shown, as a number. Rendering it as "not available" beside a register that could
549
+ // not be reached at all tells a reader the same thing about two different facts, and the one the
550
+ // reader would act on — go and look elsewhere — is wrong for this one.
551
+ //
552
+ // No sentence, no adjective: the rest of this line is figures and what they counted, and a
553
+ // qualification written here would be the only prose on it.
554
+ if (disclosedFloor(c) !== null) return `${word}: ${moreThan(disclosedFloor(c))}`;
555
+ // Left for a register this deployment could not reach or could not ask. Those have no figure at
556
+ // all, which is what this phrase now means and the only thing it means.
512
557
  return `${word}: not available`;
513
558
  });
514
559
  const scope = entry.classScope === "all-classes"