clearotron 0.3.2-beta.8 → 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 (238) hide show
  1. package/.env.example +34 -3
  2. package/CONTRIBUTING.md +8 -8
  3. package/INSTALL.md +6 -6
  4. package/SECURITY.md +3 -3
  5. package/bin/brandowner.mjs +3 -3
  6. package/bin/framework-preflight.mjs +1 -1
  7. package/bin/start.mjs +18 -4
  8. package/build-info.json +2 -2
  9. package/docs/DELIVERY.md +2 -1
  10. package/docs/INTAKE.md +1 -1
  11. package/docs/ONBOARDING.md +1 -1
  12. package/docs/architecture/03-run-lifecycle.md +6 -6
  13. package/docs/architecture/04-configuration-reference.md +6 -3
  14. package/docs/architecture/05-config-governance.md +6 -1
  15. package/docs/architecture/05-customer-profiles.md +2 -2
  16. package/docs/architecture/06-operations-runbook.md +3 -3
  17. package/docs/architecture/08-development-guide.md +6 -6
  18. package/docs/configuration.md +5 -5
  19. package/docs/decisions/0003-credential-model.md +1 -1
  20. package/docs/writing-standard.md +4 -0
  21. package/driver/CHANGELOG.md +48 -0
  22. package/driver/README.md +3 -3
  23. package/driver/binding-layers.mjs +1 -1
  24. package/driver/citation-census.json +3 -3
  25. package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
  26. package/driver/common-law-receipts.mjs +2 -2
  27. package/driver/company-bundle.mjs +3 -3
  28. package/driver/compose-read.mjs +8 -14
  29. package/driver/consumption-ledger.mjs +2 -2
  30. package/driver/contract-arm2-baseline.json +2 -3
  31. package/driver/contract-dictation-registry.mjs +19 -19
  32. package/driver/contract-e3-backlog.mjs +19 -19
  33. package/driver/contract-e3-baseline.json +14 -14
  34. package/driver/contract-vocabulary.mjs +24 -17
  35. package/driver/deliver-trigger.sh +16 -16
  36. package/driver/demo-container.mjs +3 -3
  37. package/driver/dev-portal.mjs +3 -3
  38. package/driver/disposition-call.mjs +1 -1
  39. package/driver/doubt-ledger.mjs +2 -2
  40. package/driver/drainer-identity.mjs +34 -8
  41. package/driver/driver.config.mjs +95 -45
  42. package/driver/engine/mcp/README.md +1 -1
  43. package/driver/engine/mcp/dispositions-server.mjs +3 -3
  44. package/driver/engine/mcp/gather-config.mjs +9 -9
  45. package/driver/engine/mcp/perplexity-server.mjs +2 -2
  46. package/driver/engine/mcp/recording-server.mjs +5 -5
  47. package/driver/enqueue-schema.mjs +6 -2
  48. package/driver/findings-model.mjs +5 -2
  49. package/driver/flag-snapshot.mjs +6 -3
  50. package/driver/form-neighbourhood.mjs +54 -7
  51. package/driver/framework.mjs +4 -4
  52. package/driver/gateway.mjs +12 -6
  53. package/driver/jx-lanes.mjs +2 -2
  54. package/driver/jx-units.mjs +1 -1
  55. package/driver/jx.mjs +30 -2
  56. package/driver/knockout-review-record.mjs +56 -4
  57. package/driver/known-conflicts.mjs +1 -1
  58. package/driver/named-band.mjs +1 -33
  59. package/driver/ordinary-words.mjs +51 -0
  60. package/driver/outbox-backoff.mjs +31 -16
  61. package/driver/package.json +1 -1
  62. package/driver/partial-payload-baseline.json +2 -2
  63. package/driver/phase0.mjs +3 -3
  64. package/driver/pipeline-knockout.mjs +5 -5
  65. package/driver/pipeline.mjs +206 -68
  66. package/driver/placement-form.mjs +77 -1
  67. package/driver/placement-model.mjs +1 -1
  68. package/driver/portal-report.mjs +92 -5
  69. package/driver/portal-service.mjs +34 -8
  70. package/driver/portal-upstream.mjs +1 -1
  71. package/driver/preserve-merge.mjs +3 -3
  72. package/driver/product-rows.mjs +2 -2
  73. package/driver/products.mjs +1 -1
  74. package/driver/profiles/README.md +3 -3
  75. package/driver/profiles/demo-brand-owner.json +2 -2
  76. package/driver/profiles.mjs +55 -17
  77. package/driver/progress.mjs +18 -8
  78. package/driver/provider-usage.mjs +8 -8
  79. package/driver/publish/index.mjs +110 -5
  80. package/driver/publish/knockout.mjs +29 -4
  81. package/driver/publish/pool-admin.mjs +1 -1
  82. package/driver/publish/publish-inputs.mjs +18 -2
  83. package/driver/publish/render-knockout.mjs +115 -24
  84. package/driver/publish/render.mjs +153 -34
  85. package/driver/publish/search-depth.mjs +133 -4
  86. package/driver/publish/templates/report.css +60 -3
  87. package/driver/publish/xlsx.mjs +8 -1
  88. package/driver/queue-order.mjs +2 -2
  89. package/driver/recording-agreement.mjs +1 -1
  90. package/driver/reference-score.mjs +1 -1
  91. package/driver/register-count.mjs +50 -5
  92. package/driver/register-coverage.mjs +67 -0
  93. package/driver/register-grant-vocabulary.mjs +1 -1
  94. package/driver/register-plan.mjs +19 -2
  95. package/driver/registry-fidelity.mjs +3 -3
  96. package/driver/repair-composers.mjs +1 -1
  97. package/driver/repair-contract.mjs +1 -1
  98. package/driver/replay-archive.mjs +6 -6
  99. package/driver/report-overview-record.mjs +2 -2
  100. package/driver/run-requirements.mjs +3 -3
  101. package/driver/runner.mjs +2 -2
  102. package/driver/scope-facts.mjs +20 -5
  103. package/driver/scope-ledger.mjs +5 -5
  104. package/driver/search-policy.mjs +22 -12
  105. package/driver/skills/README.md +15 -15
  106. package/driver/skills/blind-frame/SKILL.md +2 -2
  107. package/driver/skills/case-law-citation/SKILL.md +4 -4
  108. package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
  109. package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
  110. package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
  111. package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
  112. package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
  113. package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
  114. package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
  115. package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
  116. package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
  117. package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
  118. package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
  119. package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
  120. package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
  121. package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
  122. package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
  123. package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
  124. package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
  125. package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
  126. package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
  127. package/driver/skills/frame-diff/SKILL.md +1 -1
  128. package/driver/skills/knockout-assess/SKILL.md +10 -7
  129. package/driver/skills/matter-frame/SKILL.md +3 -3
  130. package/driver/skills/narrative-refutation/SKILL.md +9 -9
  131. package/driver/skills/placement-inquiry/SKILL.md +5 -5
  132. package/driver/stage-context.mjs +1 -1
  133. package/driver/stages-knockout.mjs +4 -4
  134. package/driver/stages.mjs +53 -53
  135. package/driver/status-snapshot.mjs +2 -2
  136. package/driver/suite-census.json +218 -92
  137. package/driver/surface-exit-verdict.mjs +58 -0
  138. package/driver/systemd/README.md +2 -2
  139. package/driver/systemd/clearotron-worker.service +1 -1
  140. package/driver/terminal-clamp.mjs +2 -0
  141. package/driver/usage-ledger.mjs +1 -1
  142. package/driver/variant-manifest-model.mjs +4 -4
  143. package/driver/verify-knockout.mjs +27 -0
  144. package/driver/verify.mjs +67 -6
  145. package/driver/whatif-queue.mjs +1 -1
  146. package/driver/wordlists/en.txt +63906 -0
  147. package/mcp-server/CHANGELOG.md +4 -0
  148. package/mcp-server/README.md +1 -1
  149. package/mcp-server/lib/README.md +1 -1
  150. package/mcp-server/lib/options.mjs +8 -7
  151. package/mcp-server/lib/plan.mjs +18 -2
  152. package/mcp-server/lib/runs.mjs +1 -1
  153. package/mcp-server/lib/usage.mjs +3 -3
  154. package/mcp-server/lib/whatif.mjs +1 -1
  155. package/mcp-server/package.json +1 -1
  156. package/mcp-server/server.mjs +3 -0
  157. package/package.json +12 -11
  158. package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
  159. package/portal-ui/dist/assets/{index-6jzO9HiX.js → index-EVaSo5-g.js} +1459 -482
  160. package/portal-ui/dist/index.html +2 -2
  161. package/portal-ui/package.json +1 -1
  162. package/providers/README.md +1 -1
  163. package/providers/_shared/enumerate.mjs +6 -6
  164. package/providers/_shared/execute-plan.mjs +3 -3
  165. package/providers/_shared/ledger.mjs +119 -5
  166. package/providers/_shared/provider-text.mjs +2 -2
  167. package/providers/_shared/screen.mjs +2 -2
  168. package/providers/_shared/script-form.mjs +3 -3
  169. package/providers/_shared/territory-codes.mjs +23 -3
  170. package/providers/clarivate/README.md +1 -1
  171. package/providers/clarivate/src/capabilities.js +12 -12
  172. package/providers/clarivate/src/core.js +37 -43
  173. package/providers/corsearch/README.md +1 -1
  174. package/providers/corsearch/src/capabilities.js +5 -5
  175. package/providers/corsearch/src/core.js +3 -3
  176. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  177. package/providers/oauth-mcp-bridge/package.json +1 -1
  178. package/providers/perplexity/src/core.js +1 -1
  179. package/providers/signa/README.md +1 -1
  180. package/providers/signa/src/capabilities.js +42 -49
  181. package/providers/signa/src/core.js +106 -29
  182. package/providers/uspto-local/src/sync.js +1 -1
  183. package/scripts/README.md +1 -0
  184. package/scripts/ask-ai-render-check.mjs +127 -1
  185. package/scripts/authority-boundary-probe.mjs +4 -4
  186. package/scripts/backfill-started-at.mjs +2 -2
  187. package/scripts/census-merge-driver.mjs +33 -2
  188. package/scripts/citation-anchor-report.mjs +181 -0
  189. package/scripts/dead-names.mjs +1 -1
  190. package/scripts/deprecate-below.mjs +66 -8
  191. package/scripts/drain-preflight.mjs +1 -1
  192. package/scripts/e2e.mjs +174 -0
  193. package/scripts/env-audit.mjs +27 -0
  194. package/scripts/env-classify.mjs +20 -2
  195. package/scripts/freeze-example-run.mjs +20 -10
  196. package/scripts/live-surface-check.mjs +124 -41
  197. package/scripts/markdown-link-check.mjs +1 -1
  198. package/scripts/merge-shape-check.mjs +242 -0
  199. package/scripts/mint-names-in-force.mjs +5 -5
  200. package/scripts/mint-offered-territories.mjs +72 -0
  201. package/scripts/mint-public-residue.mjs +2 -2
  202. package/scripts/mint-reference-strip-backlog.mjs +2 -2
  203. package/scripts/mint-suite-census.mjs +75 -2
  204. package/scripts/mint-writing-standard-backlog.mjs +2 -2
  205. package/scripts/purge-runs.mjs +7 -7
  206. package/scripts/reconcile-runs.mjs +2 -2
  207. package/scripts/release-approve-parked.mjs +20 -2
  208. package/scripts/release-await-cut.mjs +120 -1
  209. package/scripts/release-note-required.mjs +76 -8
  210. package/scripts/report-header-render-check.mjs +164 -0
  211. package/shared/brand.mjs +27 -0
  212. package/shared/connect-clients.mjs +39 -11
  213. package/shared/env-aliases.mjs +1 -1
  214. package/shared/identifier-scan.mjs +65 -9
  215. package/shared/identifier-sentinels.mjs +22 -0
  216. package/shared/names-in-force.mjs +3 -1
  217. package/shared/offered-territories.json +738 -0
  218. package/shared/pre-rename-spellings.mjs +53 -0
  219. package/shared/reference-guard-classes.mjs +40 -2
  220. package/shared/stdio-connect.mjs +39 -4
  221. package/shared/tree-commit.mjs +48 -0
  222. /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
  223. /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
  224. /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
  225. /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
  226. /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
  227. /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
  228. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
  229. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
  230. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
  231. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
  232. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
  233. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
  234. /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
  235. /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
  236. /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
  237. /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
  238. /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
@@ -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}
@@ -280,7 +313,26 @@
280
313
 
281
314
 
282
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. */
283
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}}
284
336
  .rrow{border-top:1px solid var(--line)}
285
337
  .rrow:first-of-type{border-top:0}
286
338
  .rrow>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:9px;padding:8px 4px}
@@ -344,9 +396,11 @@
344
396
  and internal notes (.int-note, hidden above) are the only two things print ever drops. */
345
397
  details>*:not(summary){display:block!important}
346
398
  details>summary{list-style:none;cursor:default}
347
- /* `details.searched` is a SECTION TITLE, so it keeps its summary in print like the region rows and
348
- the secondary groups — a printed report that drops "What was searched" loses the heading over the
349
- 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
350
404
  right-pointing arrow over content that is already fully expanded. Found by the print check, which
351
405
  walks a rendered page and refuses any disclosure that is in neither list; the public suite does
352
406
  not run it, so the commit that added this fold could ship the half it did not think about. */
@@ -519,6 +573,9 @@
519
573
  details.searched[open]>summary::before{content:'▾'}
520
574
  details.searched .gcount{margin-left:auto;color:var(--slate);font-weight:500;font-size:12.5px}
521
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)}
522
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}
523
580
  details.searched .k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
524
581
  .openrows,.provwrap{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
@@ -629,8 +629,15 @@ export async function buildAudit(contract, auditParsed, outPath, mark = '', fm =
629
629
  // THE ROW IS AN ORDINARY COVERAGE ROW, built by the same function as every other, so it carries the
630
630
  // same four columns and takes the same State colour. It is not a second shape and not a new sheet.
631
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.
632
638
  addSheet(wb, 'Coverage & gaps', COVERAGE_COLS,
633
- [...coverageRows(coverage), ...coverageRows(contract?.droppedConditions || [])], (row, _d, kept) => {
639
+ [...coverageRows(coverage), ...coverageRows(contract?.droppedConditions || []),
640
+ ...coverageRows(contract?.undispatchedProbes || [])], (row, _d, kept) => {
634
641
  if (!kept.has('State')) return;
635
642
  const st = row.getCell('State'); const f = STATE_FILL[String(st.value).trim()];
636
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
@@ -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"
@@ -250,3 +250,70 @@ export function registerReachRefusal(uncovered, registerLabel = null) {
250
250
  return `${names} ${uncovered.length === 1 ? "is" : "are"} not available with ${where}`
251
251
  + ` — remove ${uncovered.length === 1 ? "it" : "them"} to run this search.`;
252
252
  }
253
+
254
+ // ── WHAT THE FORM SHOULD OFFER: EVERY PLACE A REGISTER THIS PRODUCT SUPPORTS CAN SEARCH ────────────────
255
+ //
256
+ // The form offered 37 places with no rule behind them: Bulgaria and Greece, but not Denmark, Portugal,
257
+ // Vietnam or Colombia, while a worldwide search on the wider register already swept every one of its 186
258
+ // offices. The rule is the registers' own reach: a place is offered when at least one supported register
259
+ // provider can search it, decided by the SAME resolution the plan compiler runs (`resolveRegions`, one code
260
+ // at a time), so the offer cannot promise a place the compiler would defer everywhere. What THIS install's
261
+ // register does not reach is marked per deployment by `coveredTerritoryNames` and the door's refusal — the
262
+ // offer is the product's; the marking is the install's.
263
+ //
264
+ // A provider with no enumerable coverage (a global aggregator declaring `covered: null`) widens nothing:
265
+ // "everywhere" is not a list, and letting it in would offer every code the engine holds, including places
266
+ // no register answers for.
267
+ //
268
+ // NAMES ARE NEVER WRITTEN HERE. A place the form already offers keeps its shipped label; any other is named
269
+ // by the runtime's standard English region names (CLDR), the same data the territory vocabulary already
270
+ // resolves typed names against. A code with neither — the regional systems no label exists for — is left
271
+ // out and listed, never given a name composed in code. Provider extension codes (X-, ZZ) are not places.
272
+ //
273
+ // @returns {Promise<{ offered: {code: string, name: string}[], unnamed: string[] }>} regions first, then
274
+ // countries by name.
275
+ export async function searchableTerritories() {
276
+ const [{ KNOWN_JURISDICTION_CODES, canonicalJurisdictionCode }, { normalizeTerritory }, { PROVIDER_CAPABILITIES }, { resolveRegions }]
277
+ = await Promise.all([import("./jurisdiction-codes.mjs"), import("../providers/_shared/territory-codes.mjs"),
278
+ import("./register-capabilities.mjs"), import("./register-plan.mjs")]);
279
+ const enumerable = Object.values(PROVIDER_CAPABILITIES).filter((caps) => Array.isArray(caps?.offices?.covered));
280
+ const shippedName = new Map(LABELS_OFFERED_BEFORE_THE_RULE.map((n) => [canonicalJurisdictionCode(normalizeTerritory(n) ?? ""), n]));
281
+ let cldr = null;
282
+ try { cldr = new Intl.DisplayNames(["en"], { type: "region" }); } catch { cldr = null; }
283
+ const offered = [], unnamed = [];
284
+ const seen = new Set();
285
+ for (const raw of KNOWN_JURISDICTION_CODES) {
286
+ const code = canonicalJurisdictionCode(raw);
287
+ if (!code || seen.has(code) || /^(X.|ZZ)$/.test(code)) continue;
288
+ seen.add(code);
289
+ const searched = enumerable.some((caps) => {
290
+ const { regions, deferred } = resolveRegions([code], caps);
291
+ return deferred.length === 0 && regions.length > 0;
292
+ });
293
+ if (!searched) continue;
294
+ let name = shippedName.get(code) ?? null;
295
+ if (!name && cldr) { try { const n = cldr.of(code); if (n && n !== code && !/^unknown/i.test(n)) name = n; } catch { /* no name */ } }
296
+ if (!name) { unnamed.push(code); continue; }
297
+ offered.push({ code, name });
298
+ }
299
+ // Regions first, in the order the form already showed them; then countries by name.
300
+ const regional = new Set(["EU", "BX", "AP", "OA", "EA", "WO"]);
301
+ const shippedAt = (t) => { const i = LABELS_OFFERED_BEFORE_THE_RULE.indexOf(t.name); return i < 0 ? Infinity : i; };
302
+ offered.sort((a, b) => (regional.has(b.code) - regional.has(a.code))
303
+ || (regional.has(a.code) ? shippedAt(a) - shippedAt(b) : 0) || a.name.localeCompare(b.name, "en"));
304
+ return { offered, unnamed: unnamed.sort() };
305
+ }
306
+
307
+ // THE 37 LABELS THE FORM CARRIED BEFORE THIS RULE, frozen. They are shipped strings, so each place keeps
308
+ // the label a client already reads — "Hong Kong", "Macau", "Turkey", where the runtime's standard names
309
+ // differ — and the two regional systems whose only names are these. Frozen HERE rather than read from the
310
+ // form's live list, because that list is now minted FROM this function, and a rule that read its own
311
+ // output would lose its labels the first time the file was regenerated from scratch.
312
+ export const LABELS_OFFERED_BEFORE_THE_RULE = Object.freeze([
313
+ "European Union", "Benelux", "African Regional (ARIPO)",
314
+ "United States", "United Kingdom", "Ireland", "France", "Germany", "Spain", "Italy", "Netherlands",
315
+ "Switzerland", "Austria", "Sweden", "Norway", "Poland", "Bulgaria", "Greece", "Turkey", "Canada",
316
+ "Mexico", "Brazil", "Argentina", "China", "Hong Kong", "Taiwan", "Macau", "Japan", "South Korea",
317
+ "Singapore", "India", "Thailand", "Australia", "New Zealand", "United Arab Emirates", "Saudi Arabia",
318
+ "South Africa",
319
+ ]);
@@ -69,5 +69,5 @@ export function grantVocabularySentence(provider = null) {
69
69
  + `whole, and the frozen plan's entries are fetched by the executor, not by you. So reaching for one `
70
70
  + `of these means the query is wrong, not that a capability is missing: go back to the sweep and fix `
71
71
  + `its scope. This list is the tools THIS provider serves; another deployment's differs, and `
72
- + `\`skills/prelim-register/providers/<name>.md\` is where the provider-specific vocabulary lives.`;
72
+ + `\`skills/clearance-register/providers/<name>.md\` is where the provider-specific vocabulary lives.`;
73
73
  }
@@ -743,7 +743,24 @@ export function excludeHouseElement(manifest, house) {
743
743
  return { manifest: next, confirmation, refused: null };
744
744
  }
745
745
 
746
- export function compileRegisterPlan({ manifest, job, form = null, skillVersion = "", capabilities = null, unavailableOffices = [] }) {
746
+ export function compileRegisterPlan({ manifest, job, form = null, skillVersion = "", capabilities = null, unavailableOffices = [], houseElement = null }) {
747
+ // ── AN EXCLUDED ELEMENT'S FORM BAND MUST BE UNREACHABLE, NOT MERELY UNASKED-FOR ─────────────────
748
+ //
749
+ // THE DEFECT THIS CLOSES, found by following the seam rather than by a failing arm. `bandFor` falls
750
+ // back to `elements[0]` when it cannot find the element it was asked for. The house-element exclusion
751
+ // changes `dominant_element` to the remainder's dominant word — a word the form neighbourhood, derived
752
+ // earlier from the original manifest, may carry no band for. The lookup would then MISS and the
753
+ // fallback would hand back the first element's band, which is the house element's: the exclusion would
754
+ // appear to work, `dominant_element` would read correctly on the plan, and the one-letter mutation
755
+ // floor of the very element being excluded would compile anyway. Silently, and it is the whole flood.
756
+ //
757
+ // So the element is removed from the form document here, where every `bandFor` call in this compile
758
+ // reads it. Unreachable beats un-asked-for: a fallback cannot select what is not there.
759
+ if (houseElement && form?.elements) {
760
+ const lcHouse = String(houseElement).trim().toLowerCase();
761
+ const kept = form.elements.filter((e) => String(e?.element ?? "").trim().toLowerCase() !== lcHouse);
762
+ form = { ...form, elements: kept };
763
+ }
747
764
  const classes = (job?.classes ?? []).map(String).filter(Boolean);
748
765
  if (!classes.length) throw new Error("register_plan_classes_missing: a plan is always class-scoped — compile with the matter's in-scope Nice classes");
749
766
  const caps = capabilities ?? null;
@@ -962,7 +979,7 @@ export function compileRegisterPlan({ manifest, job, form = null, skillVersion =
962
979
  // visible from the definition rather than inferred from an absence.
963
980
  const STRIPPED_CATEGORIES = new Set(["phonetic", "transliteration", "visual"]);
964
981
  // — THE DOCTRINE'S OWN DISPATCH TABLE, NOW BINDABLE. The universal-categories table
965
- // (prelim-variants SKILL.md) states the mode per tag: `exact-element` sweeps default, `plural-root`
982
+ // (clearance-variants SKILL.md) states the mode per tag: `exact-element` sweeps default, `plural-root`
966
983
  // is a root (the contains match is its whole purpose), and `formative-family` is "never exact-only".
967
984
  // Until the enum accepted these tags the mandate bound to nothing — measured: three root-shaped
968
985
  // strings dispatched exact, 4/2/4 records, the family they exist to reach retrieved zero times.
@@ -15,7 +15,7 @@
15
15
  // ordering, and renewal/expiry cycle arithmetic (renewals fall at year 10/20/… from registration — the
16
16
  // check that catches "registration 2013 … renewed 2025" from the document alone).
17
17
 
18
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
18
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, openSync, readSync, closeSync } from "node:fs"; import { runPrefixSpellings } from "../shared/pre-rename-spellings.mjs";
19
19
  import { join } from "node:path";
20
20
  import { driverDir, ensureDriverDir } from "../shared/driver-dir.mjs"; // — one definition of where `_driver/` is
21
21
  import { ledgerPath, runRecordLogPath, ledgerDeprecationNotice, retiredGlobalRecordLogNotice }
@@ -34,8 +34,8 @@ function stripGatewayNs(s) {
34
34
  return typeof s === "string" ? s.replace(/^agent:[^:]+:/, "") : "";
35
35
  }
36
36
  function rowMatchesRun(row, runPrefix) {
37
- return stripGatewayNs(row.sessionKey).startsWith(runPrefix)
38
- || stripGatewayNs(row.sessionId ?? "").startsWith(runPrefix);
37
+ return runPrefixSpellings(runPrefix).some((rp) => stripGatewayNs(row.sessionKey).startsWith(rp)
38
+ || stripGatewayNs(row.sessionId ?? "").startsWith(rp)); // either spelling: a run resumed across the rename
39
39
  }
40
40
 
41
41
 
@@ -586,7 +586,7 @@ export const REPAIR_COMPOSERS = [
586
586
  //
587
587
  // `samplesForStage` IS REQUIRED, and for the reason `*:lint-repair`'s note above gives rather than the
588
588
  // one it looks like: a `stage: "*"` composer is walked for EVERY recording stage, so the fixed sample's
589
- // `record_frame_diff` is read as an order handed to blind-frame, matter-frame, prelim-variants,
589
+ // `record_frame_diff` is read as an order handed to blind-frame, matter-frame, clearance-variants,
590
590
  // report-overview, report-card and doubt-closure — six ordered-but-not-granted findings that are
591
591
  // artifacts of the SAMPLE, not of the tree. At dispatch the tool is always the walking stage's own,
592
592
  // because it is derived from `out`. Removing this hook reproduces all six.
@@ -116,7 +116,7 @@ export function failingTarget(lastFail, files = []) {
116
116
  if (!named) return null;
117
117
  const list = (Array.isArray(files) ? files : [files]).filter(Boolean).map(String);
118
118
  if (list.length <= 1) return list[0] ?? null;
119
- // longest suffix match wins: "x/register-findings.md" identifies ".../prelim-search/x/register-findings.md"
119
+ // longest suffix match wins: "x/register-findings.md" identifies ".../clearance-search/x/register-findings.md"
120
120
  const hit = list.find((f) => f === named || f.endsWith(`/${named}`) || named.endsWith(`/${f}`));
121
121
  return hit ?? null;
122
122
  }
@@ -22,14 +22,14 @@
22
22
  // an INTENDED fix (then --update on the merged result).
23
23
  //
24
24
  // Env: CLEAROTRON_REPLAY_ROOTS colon-separated corpus roots
25
- // (default: <workspaceRoot>/workspace-*/studio/prelim-search — live slugs + archive/)
26
- // CLEAROTRON_REPLAY_SNAPSHOT snapshot path (default: ~/.prelim-replay-snapshot.json)
25
+ // (default: <workspaceRoot>/workspace-*/studio/clearance-search — live slugs + archive/)
26
+ // CLEAROTRON_REPLAY_SNAPSHOT snapshot path (default: ~/.clearance-replay-snapshot.json)
27
27
 
28
28
  import "../shared/env-local.mjs"; // — FIRST: the CLEAROTRON_* translation must land before any
29
29
  // module-top capture below it evaluates. A call in this file's BODY
30
30
  // would run too late — that was the repair that left this open.
31
31
  import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from "node:fs";
32
- import { join, basename } from "node:path";
32
+ import { join, basename } from "node:path"; import { studioDirFor } from "../shared/pre-rename-spellings.mjs";
33
33
  import { DRIVER_DIR, driverDir } from "../shared/driver-dir.mjs"; //
34
34
  import { homedir } from "node:os";
35
35
  import { validators } from "./verify.mjs";
@@ -62,7 +62,7 @@ function looksLikeRunDir(p) {
62
62
  return n.includes(DRIVER_DIR) || n.some((f) => FILE_CHECKS[f]);
63
63
  }
64
64
 
65
- // Corpus roots → sorted run dirs. Layout per root (a workspace's studio/prelim-search):
65
+ // Corpus roots → sorted run dirs. Layout per root (a workspace's studio/clearance-search):
66
66
  // <slug>/<date>-<codename>/ (live slugs)
67
67
  // archive/<YYYY-MM>/<slug>/<date>-<codename>/ (archived)
68
68
  export function discoverRuns(roots) {
@@ -219,12 +219,12 @@ function main() {
219
219
  const args = new Set(process.argv.slice(2));
220
220
  // ON-DISK NAME, NOT A PRODUCT NAME: an install that never set the variable already has this file, so
221
221
  // renaming the default points the reader at one that does not exist. Ruling.
222
- const snapshotPath = process.env.CLEAROTRON_REPLAY_SNAPSHOT || join(homedir(), ".prelim-replay-snapshot.json");
222
+ const snapshotPath = process.env.CLEAROTRON_REPLAY_SNAPSHOT || [join(homedir(), ".prelim-replay-snapshot.json"), join(homedir(), ".clearance-replay-snapshot.json")].find((f, i) => i === 1 || existsSync(f)); // the file the install already has wins
223
223
  const roots = process.env.CLEAROTRON_REPLAY_ROOTS
224
224
  ? process.env.CLEAROTRON_REPLAY_ROOTS.split(":").filter(Boolean)
225
225
  : names(config.workspaceRoot)
226
226
  .filter((d) => config.agentIdFromWorkspaceName(d) != null)
227
- .map((d) => join(config.workspaceRoot, d, "studio", "prelim-search"));
227
+ .map((d) => studioDirFor(join(config.workspaceRoot, d)));
228
228
 
229
229
  const runDirs = discoverRuns(roots);
230
230
  if (!runDirs.length) {