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
@@ -178,7 +178,83 @@ export function omittedFromRender(rows) {
178
178
  const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);
179
179
  return list.filter((r) => !rowIsSettled(r, r))
180
180
  .map((r) => ({ row_id: r?.row_id ?? null, kind: r?.kind ?? null, select: selectionOf(r), mark: r?.mark ?? null,
181
- missing: [!seatFields(r).tier ? "tier" : null, !seatFields(r).reason ? "reason" : null].filter(Boolean) }));
181
+ missing: [!seatFields(r).tier ? "tier" : null, !seatFields(r).reason ? "reason" : null].filter(Boolean),
182
+ // WHY, in the parser's own token. `missing` names only the two judgement fields, so a row refused on a
183
+ // fact the DRIVER copies — an owner the register never supplied — read as an omission with no cause
184
+ // at all, `missing: []`. That is how 141 tiered rows sat outstanding for a day with nobody able to say
185
+ // why: the answer was one parser call away and nothing made it.
186
+ cause: seatRefusal(r) ?? registerFactRefusal(r),
187
+ owed_by: seatRefusal(r) ? "seat" : "register" }));
188
+ }
189
+
190
+ // The parser tokens that speak about a fact the driver COPIES onto a register row — mark, owner,
191
+ // jurisdiction, records all come from the canonical row (renderEntry), never from the seat. On a SEAT row the
192
+ // same four are the seat's own writing.
193
+ const REGISTER_FACT_TOKENS = new Set(["placement_mark_missing", "placement_owner_missing",
194
+ "placement_jurisdiction_invalid", "placement_records_invalid"]);
195
+ const tokenOf = (e) => String(e?.message ?? e).split(/[:=\s(]/)[0] || "placement_invalid";
196
+ // Stand-ins that every driver-fact check accepts, so a check of the JUDGEMENT alone cannot be refused on a
197
+ // driver fact. They never reach a rendered file: this is a probe of the parser, not an entry.
198
+ const FACTS_PRESENT = Object.freeze({ mark: "-", owner: "-", jurisdiction: "", records: [] });
199
+
200
+ /**
201
+ * The seat's part of a row, refused — or null when the seat has done its part. On a register row that is
202
+ * the judgement alone (tier, reason, borderline), probed with the driver's facts stood in; on a seat row it
203
+ * is the whole row, because the seat wrote all of it. PURE; never throws.
204
+ */
205
+ export function seatRefusal(row) {
206
+ try {
207
+ validatePlacement(renderEntry(row?.kind === "register" ? FACTS_PRESENT : row, row), 0);
208
+ return null;
209
+ } catch (e) { return tokenOf(e); }
210
+ }
211
+
212
+ /**
213
+ * The register's part of a register row, refused — or null. A fact the register did not supply (no owner on
214
+ * the record) is not something a corrective turn can repair: the seat is told not to type these fields and
215
+ * anything it types is ignored. Null on a seat row. PURE; never throws.
216
+ */
217
+ export function registerFactRefusal(row) {
218
+ if (row?.kind !== "register") return null;
219
+ try {
220
+ validatePlacement(renderEntry(row, { tier: PLACEMENT_TIERS[0], reason: "register fact probe" }), 0);
221
+ return null;
222
+ } catch (e) { const t = tokenOf(e); return REGISTER_FACT_TOKENS.has(t) ? t : null; }
223
+ }
224
+
225
+ /**
226
+ * What a placement pass recorded, split by who owes each gap. The gate reads this; so does the run record.
227
+ *
228
+ * unjudged rows whose seat part the parser refuses — the seat owes them, a corrective turn can
229
+ * repair them, and a pass that leaves any is not a completed pass.
230
+ * register_selected register candidates the seat selected.
231
+ * register_rendered of those, how many reach placements.json.
232
+ * register_facts rows judged by the seat and still refused on a fact the register did not supply,
233
+ * counted by cause. A register that publishes no owner for a record is a real and
234
+ * ordinary case (measured: single records at offices that do not publish one), so a
235
+ * handful are recorded, not refused.
236
+ *
237
+ * `register_selected > 0 && register_rendered === 0` is the other refusal: a pass that tiered register
238
+ * candidates and delivered none of them has not made a judgement about the register, it has lost one. That
239
+ * is the shape of a register whose rows reach the fold without a field every placement needs. PURE.
240
+ */
241
+ export function placementRenderAccount(rows) {
242
+ const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);
243
+ const unjudged = [];
244
+ const registerFacts = {};
245
+ let registerSelected = 0, registerRendered = 0;
246
+ for (const r of list) {
247
+ if (!r || typeof r !== "object") continue;
248
+ const isRegister = r.kind === "register";
249
+ if (isRegister) registerSelected += 1;
250
+ const seat = seatRefusal(r);
251
+ if (seat) { unjudged.push({ row_id: r.row_id ?? null, kind: r.kind ?? null, select: selectionOf(r), cause: seat }); continue; }
252
+ if (!isRegister) continue;
253
+ if (rowIsSettled(r, r)) { registerRendered += 1; continue; }
254
+ const cause = registerFactRefusal(r) ?? "placement_invalid";
255
+ registerFacts[cause] = (registerFacts[cause] ?? 0) + 1;
256
+ }
257
+ return { unjudged, register_selected: registerSelected, register_rendered: registerRendered, register_facts: registerFacts };
182
258
  }
183
259
 
184
260
  /**
@@ -76,7 +76,7 @@ const ENTRY_KEYS = ["mark", "owner", "jurisdiction", "records", "tier", "reason"
76
76
  // decides it — a fluent, specific, entirely wrong reason passes this test, and should, because it is
77
77
  // arguable and arguing with it is the digest's job, not the parser's. B2 puts that judgment where it
78
78
  // belongs: the digest is instructed to adopt-or-counter each tier BY ENGAGING ITS STATED REASON
79
- // (stages.mjs + prelim-register/digest.md + synthesis-rules.md §1/§2), and narrative-refutation joins
79
+ // (stages.mjs + clearance-register/digest.md + synthesis-rules.md §1/§2), and narrative-refutation joins
80
80
  // on the same JSON. This function only guarantees there is a reason to engage WITH. It is a floor
81
81
  // against a degenerate write, not a measure of quality, and it is deliberately the only test here that
82
82
  // looks at the reason's words at all.
@@ -51,11 +51,15 @@ import { readFlagSnapshot, engineFor, providersFor, postureDisagreement } from "
51
51
  // reading is, because the question it was standing in for — does this still describe the box — now has
52
52
  // a direct answer in `lastRun.disagrees`.
53
53
  import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVED at read time, never stored
54
+ import { BILLING_MODES } from "./engine/auth.mjs"; // — the three words a capture's billing mode may be shown as
54
55
  // THE ENGINE TABLE, READ FOR TWO WORDS. A row saying an engine cannot run has to name the program it
55
56
  // could not find and the command that installs it, or the reader is told they have a problem and not
56
57
  // what to do about it — and this table is already where the wizard and the run-door preflight read
57
58
  // both of those, so naming them here adds no second description of an engine.
58
59
  import { ENGINE_BINARIES } from "./driver.config.mjs";
60
+ // WHICH SETUP COMMAND THIS READER CAN TYPE, as a word and never a path: the same answer `/me` sends the
61
+ // search screen, so the two pages name the same command for the same install.
62
+ import { installRoute } from "../shared/invocation.mjs";
59
63
 
60
64
  /**
61
65
  * The flag view.
@@ -88,6 +92,12 @@ function withProgram(engine) {
88
92
  // layout and is deliberately kept out of anything a browser renders.
89
93
  program: spec?.fallback ?? null,
90
94
  install: spec?.install ?? null,
95
+ // The setting that names the program's full path, which is what an administrator sets when the
96
+ // services cannot find a program this machine has. A name, never its value.
97
+ programSetting: spec?.env ?? null,
98
+ // SETUP INSTALLS THE PROGRAM NOW, so a row saying it cannot be found names setup, the way this reader
99
+ // can run it: `packaged` or `checkout`.
100
+ setupRoute: installRoute(),
91
101
  };
92
102
  }
93
103
 
@@ -135,8 +145,27 @@ function postureView(snap) {
135
145
  * The capture does not go away; it stops being the answer. It becomes "what the last run saw", and its
136
146
  * job is to name any field on which it disagrees with the live reading.
137
147
  */
148
+ /**
149
+ * A CAPTURE'S BILLING WORD, SHOWN ONLY WHEN IT IS ONE OF THE MODES.
150
+ *
151
+ * The inventory records a billing word that is not a mode as `unknown`, because whatever was typed into
152
+ * the setting that decides who pays can be a key pasted there by mistake. A capture written before it
153
+ * did holds the typed word itself, as its mode and inside its refusal's sentence, and this page would
154
+ * print it twice: in the last-run comparison, whose live side now reads `unknown`, and under a capture
155
+ * shown in place of a live reading. So such a capture is read as `unknown`, with no refusal sentence,
156
+ * which the page does not read anyway (it words the refusal from `reason`). Until the engine service
157
+ * restarts and writes its capture again, that is the only copy of the word the page could reach.
158
+ */
159
+ function withKnownBillingWord(snap) {
160
+ const billing = snap?.engine?.billing;
161
+ if (!billing || typeof billing !== "object") return snap;
162
+ if (billing.mode == null || billing.mode === "unknown" || BILLING_MODES.includes(billing.mode)) return snap;
163
+ const { refusal: _typed, ...rest } = billing;
164
+ return { ...snap, engine: { ...snap.engine, billing: { ...rest, mode: "unknown" } } };
165
+ }
166
+
138
167
  export function flagView(poolRoot, { live = null } = {}) {
139
- const snap = readFlagSnapshot(poolRoot);
168
+ const snap = withKnownBillingWord(readFlagSnapshot(poolRoot));
140
169
 
141
170
  // NO LIVE POSTURE IS A DIFFERENT PAGE, NOT A DEGRADED ONE. A caller that supplied none cannot be
142
171
  // answered "live" at all, so this says which reading it is showing rather than presenting a capture
@@ -362,6 +362,12 @@ const MCP_HOST_RE = /https?:\/\/[A-Za-z0-9.-]+\/mcp/gi;
362
362
  // introduces it and the full stop that closes it — leaving either behind puts a stray break or an orphan
363
363
  // "." in the footer of every report.
364
364
  const RATED_UNDER_RE = /<br\s*\/?>Rated under:\s*<span class="mono">[^<]*<\/span>\./gi;
365
+ // THE PROJECT LINE IS THE SAME CLASS AND WAS MISSING ITS STRIP. "Run under project: <name>" is
366
+ // internal provenance — an end-to-end arm reads it out of the published internal report — and it sat
367
+ // beside "Rated under" in the footer with nothing removing it here, so it reached every embedded
368
+ // reader exactly as written: the engine's phrase for the folder a job was filed in, and the folder's
369
+ // name. Same shape, same removal, one line apart.
370
+ const RUN_UNDER_PROJECT_RE = /<br\s*\/?>Run under project:\s*<span class="mono">[^<]*<\/span>\./gi;
365
371
 
366
372
  const CONNECTOR_TOKEN_RE = /([?&])token=v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(&)?/g;
367
373
 
@@ -548,8 +554,24 @@ const EMBED_JS = `
548
554
  parent.postMessage({source:TAG,type:'controls',commands:have},'*');
549
555
  }catch(e){}
550
556
  }
557
+ // AND WHICH SECTIONS IT HOLDS. The renderer's own breadcrumb rides inside the report's sticky header,
558
+ // which this file strips, so the list arrives as a value (window.__CORD_SECTIONS, written above this
559
+ // script) and the shell draws it in its own header. Only the ids that are really in the document are
560
+ // announced: a report whose renderer named a section it did not draw would otherwise offer the reader
561
+ // a breadcrumb entry that jumps nowhere.
562
+ function sections(){
563
+ try{
564
+ var list=window.__CORD_SECTIONS;
565
+ if(!list||!list.length)return;
566
+ var live=[];
567
+ for(var i=0;i<list.length;i++) if(document.getElementById(list[i].id)) live.push(list[i]);
568
+ parent.postMessage({source:TAG,type:'sections',sections:live},'*');
569
+ }catch(e){}
570
+ }
551
571
  window.addEventListener('load',controls);
552
572
  document.addEventListener('DOMContentLoaded',controls);
573
+ window.addEventListener('load',sections);
574
+ document.addEventListener('DOMContentLoaded',sections);
553
575
  window.addEventListener('load',schedule);
554
576
  document.addEventListener('DOMContentLoaded',schedule);
555
577
  document.addEventListener('toggle',schedule,true);
@@ -564,6 +586,17 @@ const EMBED_JS = `
564
586
  // A FAILED COMMAND SAYS SO. This used to be catch(err){} — a button that did nothing, told nobody,
565
587
  // and left no console trace, which is how "Export does not work" went unnoticed through a whole
566
588
  // round of testing. The portal now hears about it and can say so.
589
+ // A BREADCRUMB PRESS IN THE SHELL'S HEADER. Not a verb the document defines — it is the anchor jump
590
+ // below, asked for from outside, and it answers on the same channel: reveal any collapsed ancestor,
591
+ // then post the target's document-relative top for the PARENT to scroll to, because this frame has
592
+ // no scrollport of its own. An id that is not in the document is ignored rather than failed: the
593
+ // shell only ever draws ids the document announced.
594
+ if(d.command==='section'){
595
+ var sec=document.getElementById(String(d.value||''));
596
+ if(sec){ revealTarget(sec); var t=Math.max(0,Math.ceil(sec.getBoundingClientRect().top+window.scrollY));
597
+ try{parent.postMessage({source:TAG,type:'scrollTo',top:t},'*');}catch(e){} schedule(); }
598
+ return;
599
+ }
567
600
  var fn = d.command==='exportPDF' ? (typeof exportPDF==='function'?exportPDF:null)
568
601
  : d.command==='pickAll' ? (typeof pickAll==='function'?pickAll:null)
569
602
  : d.command==='openAll' ? (typeof openAll==='function'?openAll:null)
@@ -589,7 +622,7 @@ const EMBED_JS = `
589
622
  // Registered AFTER the document's own script by construction (this block is appended at the end of
590
623
  // <body>), so by the time it runs for an '#c<ordinal>' link the ancestors are already open and the
591
624
  // measured offset is the revealed position. preventDefault stops the frame's dead-end fragment
592
- // navigation for the links the document's handler does not claim (#common-law, #only-you).
625
+ // navigation for the links the document's handler does not claim (#common-law, #next).
593
626
  // AND WHEN THERE IS NO PARENT, DO NOT CANCEL THE CLICK.
594
627
  //
595
628
  // Everything above is right INSIDE the portal's Result screen. Un-framed it is a dead link: parent is
@@ -624,6 +657,28 @@ const EMBED_JS = `
624
657
  try{parent.postMessage({source:TAG,type:'scrollTo',top:top},'*');}catch(e){}
625
658
  schedule();
626
659
  });
660
+ // A FINDING'S OWN ASK AI. The document draws the button on every finding card and the portal holds the
661
+ // control, so the press crosses to the parent like the anchor jump above, carrying which finding it was.
662
+ // A clearance numbers its findings once, in the card's id; a knockout restarts its numbers for each name,
663
+ // so the name's position rides beside it. The "Also considered" cards carry the button and no number —
664
+ // a record that was ruled out was never given one — and still post, with the number null: the parent
665
+ // opens the control about the report rather than leaving a button that does nothing. Un-framed there is
666
+ // nobody to answer, so nothing is posted.
667
+ document.addEventListener('click',function(e){
668
+ var b=e.target.closest?e.target.closest('.ask-fi'):null;
669
+ if(!b||!framed)return;
670
+ var card=b.closest('.card');
671
+ var whole=function(v){var n=parseInt(v,10);return String(n)===String(v)&&n>=0?n:null;};
672
+ var koOrd=card?card.getAttribute('data-ko-ord'):null;
673
+ var idOrd=card&&(card.id||'').charAt(0)==='c'?card.id.slice(1):null;
674
+ var ord=koOrd!==null?whole(koOrd):(idOrd!==null?whole(idOrd):null);
675
+ var koMark=card?card.getAttribute('data-ko-mark'):null;
676
+ var mi=koMark!==null?whole(koMark):null;
677
+ // A press that cannot reach the page must not vanish: the button would look pressed and do nothing,
678
+ // the same silence a broken Export once hid behind. The console is the one place left to say so.
679
+ try{parent.postMessage({source:TAG,type:'askAi',ordinal:ord,markIndex:mi},'*');}
680
+ catch(err){try{console.warn('Ask AI could not reach the page: '+(err&&err.message||err));}catch(e){}}
681
+ });
627
682
  schedule();
628
683
  })();
629
684
  `;
@@ -740,8 +795,42 @@ const FEEDBACK_JS = `
740
795
  // Appended at the very end of <body>: the stylesheet is inlined into <head>, so a rule placed here wins
741
796
  // the cascade on equal specificity without needing !important, and the script runs with the document's
742
797
  // own functions already defined.
743
- function injectEmbedLayer(html, { feedback = false } = {}) {
744
- const block = `<style data-embed="portal">${EMBED_CSS}</style><script data-embed="portal">${EMBED_JS}</script>`
798
+ /**
799
+ * THE SECTION BREADCRUMB, LIFTED OUT BEFORE THE HEADER GOES (owner, 2026-09-18).
800
+ *
801
+ * The renderers emit the breadcrumb INSIDE `.rep-stickyhead` — one header, one sticky surface — and this
802
+ * function strips that header, because the portal draws its own. So the breadcrumb has to leave the
803
+ * document and arrive as data: the portal draws it in `.report-head`, beside READS, where it stays on
804
+ * top of a six-thousand-pixel read.
805
+ *
806
+ * It could not stay in the frame whatever this stripped. The frame is sized to its content
807
+ * (Result.tsx), so the framed document has no scrollport of its own and `position:sticky` inside it
808
+ * pins to nothing — which is exactly what a reader met: a bar sitting over the confidentiality line
809
+ * that scrolled away with the page.
810
+ *
811
+ * READ OFF THE MARKUP THE RENDERER WROTE, never recomposed from a list of section names here. A second
812
+ * copy of the vocabulary is how a renderer comes to disagree with the shell about what a report holds.
813
+ * Returns [] for a document with no strip — `sectionStrip()` emits none under two live sections — and
814
+ * the shell then draws no breadcrumb, which is the right answer rather than an empty row.
815
+ */
816
+ const STRIP_NAV_RE = /<nav class="[^"]*\bstrip\b[^"]*"[^>]*>([\s\S]*?)<\/nav>/i;
817
+ const STRIP_LINK_RE = /<a\b[^>]*\bdata-sec="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
818
+ export function sectionsOf(html) {
819
+ const nav = STRIP_NAV_RE.exec(String(html ?? ""));
820
+ if (!nav) return [];
821
+ const out = [];
822
+ for (const m of nav[1].matchAll(STRIP_LINK_RE)) {
823
+ const label = m[2].replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
824
+ if (m[1] && label) out.push({ id: m[1], label });
825
+ }
826
+ return out;
827
+ }
828
+
829
+ function injectEmbedLayer(html, { feedback = false, sections = [] } = {}) {
830
+ // The breadcrumb list is a VALUE the injected script reads, not markup: `<` is escaped so a label can
831
+ // never close the script element it rides inside.
832
+ const secs = JSON.stringify(sections ?? []).replace(/</g, "\\u003c");
833
+ const block = `<style data-embed="portal">${EMBED_CSS}</style><script data-embed="portal">window.__CORD_SECTIONS=${secs};${EMBED_JS}</script>`
745
834
  + (feedback ? `<style data-embed="feedback">${FEEDBACK_CSS}</style><script data-embed="feedback">${FEEDBACK_JS}</script>` : "");
746
835
  const i = html.lastIndexOf("</body>");
747
836
  // No </body> means markup we do not recognise; appending is still correct and still parses.
@@ -781,6 +870,10 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
781
870
  const note = (tag) => unbalanced.push(tag);
782
871
  const missingCss = [];
783
872
 
873
+ // READ BEFORE ANYTHING IS STRIPPED. The breadcrumb rides inside `.rep-stickyhead`, which the chrome
874
+ // strip below removes, so this is the last moment it exists.
875
+ const sections = sectionsOf(html);
876
+
784
877
  const nav = stripBalanced(html, NAV_RE, "nav", note);
785
878
  let out = nav.html;
786
879
  const strippedNav = nav.removed;
@@ -796,8 +889,16 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
796
889
 
797
890
  // The footer fingerprint. Counted like the rest so a renderer change that moves it shows up as a zero
798
891
  // rather than as a line that quietly starts shipping again.
892
+ //
893
+ // ONE COUNTER PER LINE, AND THAT IS THE WHOLE POINT OF COUNTING. These two strips shared a counter, so
894
+ // the check that exists to catch a renderer moving this markup could not tell WHICH line moved: the
895
+ // renderer could rename one of them, that strip would match nothing, the other would still fire, and
896
+ // the count would read 1 exactly as it does when both work. A count that cannot fall to zero for one
897
+ // of the two things it counts is not a measurement of either.
799
898
  let ratedUnderDropped = 0;
899
+ let runUnderProjectDropped = 0;
800
900
  out = out.replace(RATED_UNDER_RE, () => { ratedUnderDropped += 1; return ""; });
901
+ out = out.replace(RUN_UNDER_PROJECT_RE, () => { runUnderProjectDropped += 1; return ""; });
801
902
 
802
903
  // ── THE ASK-AI BAND COMES OUT FOR EVERY READER ──────────────────────────────────────────────────
803
904
  //
@@ -851,11 +952,11 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
851
952
 
852
953
  // After the stylesheet, so the injected rule sits later in the cascade than the sheet it overrides —
853
954
  // and after every strip, so nothing above can match the injected markup and eat it.
854
- out = injectEmbedLayer(out, { feedback });
955
+ out = injectEmbedLayer(out, { feedback, sections });
855
956
 
856
957
  return {
857
- html: out, strippedNav, neutralised, mcpLeaks, tokensDropped, ratedUnderDropped,
858
- internalTailsDropped, reviewerCodesDropped, unbalanced, missingCss,
958
+ html: out, strippedNav, neutralised, mcpLeaks, tokensDropped, ratedUnderDropped, runUnderProjectDropped,
959
+ internalTailsDropped, reviewerCodesDropped, unbalanced, missingCss, sections,
859
960
  };
860
961
  }
861
962
 
@@ -98,6 +98,24 @@ import { fileURLToPath } from "node:url";
98
98
  // re-declared: one number, one count, and every existing importer of this module keeps working.
99
99
  export { DEFAULT_CLIENT_DAILY_RUNS, accountUsage };
100
100
 
101
+ /**
102
+ * What a requester is told when the day's allowance is gone.
103
+ *
104
+ * ONE SENTENCE, TWO PLACES. The server refuses with it and the screen warns with it, and a reader who
105
+ * meets both must not meet two different sentences about the same fact. The browser cannot import this
106
+ * module, so the portal composes its own copy and `portal-ui/test/allowanceParity.test.ts` pins the two
107
+ * together — the same arrangement the effort model already has.
108
+ *
109
+ * NAMED, NOT ADDRESSED. It read "ask your <name> contact", which puts a possessive and a noun around a
110
+ * name that is already the answer: on a deployment that has configured no brand the portal's fallback is
111
+ * the words "the operator", and the sentence rendered "ask your the operator contact to run this one for
112
+ * you". The line beside it on the same screen had always said "ask <name>", and that is the one the
113
+ * approved design draws.
114
+ */
115
+ export const allowanceExhaustedLine = (cap, who) =>
116
+ `You have used all ${cap} of this account's searches for today. `
117
+ + `The allowance resets at midnight UTC — or ask ${who} to run this one for you.`;
118
+
101
119
  // One sentence, two places (the capability payload and the route that refuses). Never names the
102
120
  // missing credential: this reaches a client screen, and our plumbing is not their business.
103
121
  //
@@ -111,7 +129,7 @@ export { DEFAULT_CLIENT_DAILY_RUNS, accountUsage };
111
129
  const INSTALL_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
112
130
 
113
131
  const READ_OFF_NOTE = "Reading a brief is not available on this instance — set the search up below.";
114
- import { basename, dirname, join, resolve as pathResolve } from "node:path";
132
+ import { basename, dirname, join, resolve as pathResolve } from "node:path"; import { studioDirFor } from "../shared/pre-rename-spellings.mjs";
115
133
  import { driverDir } from "../shared/driver-dir.mjs"; //
116
134
  import { createHmac, timingSafeEqual } from "node:crypto";
117
135
  import { makePrincipal, assertPrincipal, genericOrgOf, mayReadRun, reachCovers, principalView, seesEverything, mayRun,
@@ -130,7 +148,7 @@ import { productRows, productRow, baseTurnaroundFor } from "./product-rows.mjs";
130
148
  import { resolveForDoor, gateResolvedRequest } from "./door-gates.mjs";
131
149
  import { resolveEffectiveScope } from "./effective-scope.mjs";
132
150
  import { quoteForJob } from "./run-quote.mjs";
133
- import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor, caseLawReadyFor } from "./flag-snapshot.mjs";
151
+ import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor, registerLabelFor, caseLawReadyFor } from "./flag-snapshot.mjs";
134
152
  import { isDemo, demoPostureLine } from "./demo-posture.mjs";
135
153
  import { triggerCapGap, triggerCapWarning } from "./trigger-cap.mjs"; // F51 — one answer, three surfaces
136
154
  import { makeUpstream } from "./portal-upstream.mjs";
@@ -468,7 +486,16 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
468
486
  // zombie face this state exists to end. pausedKind "operator" tells the UI which words to use.
469
487
  const paused = s.state === "postponed" || s.state === "recovering" || s.state === "parked-for-human";
470
488
  out.push({ ...(liveRetired ? { retired: true } : {}),
471
- runId: s.runId, account: owner, ...(owner === "generic" ? { organisation: p.organisation ?? null } : {}), title: s.markName ?? s.slug, kind: s.lane === "knockout" ? "knockout-batch" : "clearance",
489
+ runId: s.runId, account: owner, ...(owner === "generic" ? { organisation: p.organisation ?? null } : {}), title: s.markName ?? s.slug,
490
+ // THE RUN’S OWN PIPELINE, off the frozen policy sidecar this branch already reads — the same
491
+ // source the `product` field two lines down takes, so a row cannot contradict its own product.
492
+ // `s.lane` is the runner’s queue directory and it is not that: a knockout claimed from any other
493
+ // lane arrived here as a clearance, and Home then quoted it the clearance turnaround — hours,
494
+ // against a search that finishes in minutes. The queued rows below were fixed for exactly this
495
+ // and this row was left on the weaker field. The lane stays as the fallback for a run whose
496
+ // sidecar predates the stamp, and an unplaceable product stays a clearance, which is what every
497
+ // listing has always shown for something it could not identify.
498
+ kind: (policyFor(sp?.level)?.pipeline ?? (s.lane === "knockout" ? "knockout" : null)) === "knockout" ? "knockout-batch" : "clearance",
472
499
  markName: typeof s.markName === "string" ? s.markName : null,
473
500
  // The project, straight off the frozen sidecar this branch already reads as `p`. A LIVE run needs
474
501
  // no publish stamp and no back-fill — the sidecar is right there, and freezeProfile has written
@@ -650,7 +677,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
650
677
  };
651
678
  // THE CANONICAL INTAKE FIRST, and it is why this parameter exists. `CLEAROTRON_QUEUE_DIR` is where the
652
679
  // enqueue CLI and ops-MCP `start_run` write — which is where the PORTAL's own submissions land, since
653
- // its `trigger` is an ops-MCP hop. The walk below finds only `workspace-*/studio/prelim-search/queue`,
680
+ // its `trigger` is an ops-MCP hop. The walk below finds only `workspace-*/studio/clearance-search/queue`,
654
681
  // and a documented headless install has no workspaces at all: measured on the test box, the only queue
655
682
  // under the whole tree is the configured one, and it holds portal-prefixed jobs. So this scan ran ZERO
656
683
  // times there, and a submitted search was invisible on the dashboard from submit until claim — a
@@ -660,7 +687,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
660
687
  }
661
688
  try {
662
689
  for (const ws of readdirSync(workspaceRoot).filter((n) => n.startsWith("workspace-"))) {
663
- const studio = join(workspaceRoot, ws, "studio", "prelim-search");
690
+ const studio = studioDirFor(join(workspaceRoot, ws));
664
691
  let slugs = []; try { slugs = readdirSync(studio); } catch { continue; }
665
692
  for (const slug of slugs) {
666
693
  // Still walked, so a deployment whose queue is not in `queueDirs` keeps working. The union is
@@ -684,7 +711,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
684
711
  // A failure `reason` is NOT client-safe, and the listing must not hand it to a browser.
685
712
  //
686
713
  // The engine writes `reason = String(e?.stack ?? e)` truncated to 200 characters
687
- // (pipeline.mjs:5366, pipeline-knockout.mjs:311). That is a raw stack trace: absolute filesystem
714
+ // (pipeline.mjs, pipeline-knockout.mjs). That is a raw stack trace: absolute filesystem
688
715
  // paths, internal module names, provider error text. Useful to staff, and none of a client's business
689
716
  // — and redacting it in the UI would be theatre, because the field still travels over the wire and
690
717
  // sits in devtools whatever React chooses to render.
@@ -1230,6 +1257,10 @@ export function makePortalService({
1230
1257
  // THREE answers survive the read: null (unrestricted), an array, or undefined (the snapshot does not
1231
1258
  // say — fail open). registerTerritoriesFor is what keeps them apart.
1232
1259
  readTerritories = () => registerTerritoriesFor(readFlagSnapshot(poolRoot)),
1260
+ // What to CALL the wired register on screen. Beside `readTerritories` and injected for the same
1261
+ // reason: the covered set says a territory cannot be searched, and this says by what — the screen
1262
+ // cannot name the register from the key without printing an engine identifier at a client.
1263
+ readRegisterLabel = () => registerLabelFor(readFlagSnapshot(poolRoot)),
1233
1264
  // — whether this deployment has a case-law source enrolled at all. Same
1234
1265
  // injection and same snapshot as the two above, and the same tri-state: null is "the snapshot does not
1235
1266
  // say", which must not render as a warning on a working box.
@@ -1280,6 +1311,7 @@ export function makePortalService({
1280
1311
  const built = readBuilt();
1281
1312
  const canCount = readCanCount();
1282
1313
  const territories = readTerritories();
1314
+ const registerLabel = readRegisterLabel();
1283
1315
  const caseLawReady = readCaseLaw();
1284
1316
  return {
1285
1317
  // Every product is LISTED whatever its state. A product that vanishes when unavailable leaves a
@@ -1300,7 +1332,24 @@ export function makePortalService({
1300
1332
  // explains a control that cannot be used, `coverageNote` qualifies one that can. Folding this
1301
1333
  // into the first would make every caller of `productAvailability` — the portal, the MCP door,
1302
1334
  // the dev cockpit — read a disclosure as a refusal, which is the behaviour the ruling removes.
1303
- const coverage = coverageDisclosure(l.geography, territories);
1335
+ // ── THE PRODUCT ROWS CARRY NO COVERAGE SENTENCE ───────────────────────────────────────────
1336
+ //
1337
+ // This row printed a four-line paragraph — what the wired register reaches, of how many places
1338
+ // this search can name, and that the rest would be disclosed in the report as deferred coverage
1339
+ // rather than reported as clear. The owner met it on a running install and ruled it out on
1340
+ // 2026-09-18: it is on no board, and it describes the behaviour that naming an unreachable
1341
+ // territory no longer has. A named territory the register cannot reach is now refused at the
1342
+ // door, in one sentence naming the territory — so a paragraph promising to defer it instead
1343
+ // tells the reader the opposite of what the engine will do.
1344
+ //
1345
+ // NOTHING REPLACES IT, AND NO OTHER ROW GETS ONE. What a worldwide search on a partial register
1346
+ // actually reached belongs in the report that describes the search that ran, not in the form
1347
+ // that orders it.
1348
+ //
1349
+ // THE SENTENCE STILL EXISTS, one surface later, and deliberately: the review step before the
1350
+ // spend still states what is being bought. That is a different question asked at a different
1351
+ // moment — this row is "which search", that screen is "this is what you are committing to" —
1352
+ // and it was a separate ruling. `coverageDisclosure` keeps composing it for that caller.
1304
1353
  // ── — THE PRODUCT DECLARES WHAT IT NEEDS, so the row can say so ─────
1305
1354
  //
1306
1355
  // The owner ordered the one product carrying `caseLaw: true` and first heard of the lane in the
@@ -1317,7 +1366,7 @@ export function makePortalService({
1317
1366
  // a warning on every deployment whose writer has not run since is worse than the silence it
1318
1367
  // replaces.
1319
1368
  return { ...l, available: cause === null, unavailableNote: cause ? UNAVAILABLE_NOTE[cause] : null,
1320
- coverageNote: coverage?.note ?? null,
1369
+ coverageNote: null,
1321
1370
  capabilityNote: l.caseLaw && caseLawReady === false ? CASE_LAW_DARK_NOTE : null };
1322
1371
  }),
1323
1372
  // ── the TERRITORY affordance ─────────────────────────────────────────────────────────────
@@ -1335,6 +1384,16 @@ export function makePortalService({
1335
1384
  // not told me", which fails open. A `covered ?? []` anywhere on this path offers zero territories
1336
1385
  // on a production box.
1337
1386
  ...(territories === undefined ? {} : { territories }),
1387
+ // THE REGISTER'S NAME, sent whenever there is one. The covered set alone lets the screen mark a
1388
+ // territory as unreachable; it cannot say by WHAT, and the sentence the door refuses with names
1389
+ // the register ("China is not available with Signa, the register configured here"). A screen that
1390
+ // marked a chip without naming the register would be a second, vaguer wording for one fact.
1391
+ //
1392
+ // OMITTED, never nulled, exactly as `territories` above: absent means this deployment did not say,
1393
+ // and the screen must then mark the territory without naming a register rather than invent one.
1394
+ // It is the register's own display label, never the provider key — the key is an engine
1395
+ // identifier and has no place on a client's screen.
1396
+ ...(registerLabel ? { registerLabel } : {}),
1338
1397
  // `base` and `nativeLanguage` ride the LIST row, not just the record: the composer has to say what
1339
1398
  // geography a saved search accepts while the row is being clicked, and fetching the record per
1340
1399
  // selection would put that answer one round trip behind the Review button. Only `true` travels for
@@ -1700,7 +1759,7 @@ export function makePortalService({
1700
1759
  // The recipeKey arm that used to sit here (a 422 when saved searches were "not switched on") went
1701
1760
  // with CLEAROTRON_RECIPES_MODE on 2026-07-27: a saved search is now honoured wherever it resolves.
1702
1761
  // Asked of the RESOLVED product, not of the body: a request that names none resolves through
1703
- // the account's default and its own territories, and the old read (`body.searchLevel || "prelim"`)
1762
+ // the account's default and its own territories, and the old read (`body.searchLevel || "clearance"`)
1704
1763
  // answered about a product nobody had chosen. `resolveFor` fails open to a null resolution, and a
1705
1764
  // null one is not judged here — validateJob and the scope rules below still run, and the runner
1706
1765
  // is the wall.
@@ -1842,9 +1901,7 @@ export function makePortalService({
1842
1901
  return null;
1843
1902
  }
1844
1903
  if (used.today + 1 <= limit) return null;
1845
- return { status: 429, json: { ok: false, errors: [
1846
- `You have used all ${limit} of this account's searches for today. The allowance resets at midnight UTC — or ask your ${BRAND.name} contact to run this one for you.`,
1847
- ] } };
1904
+ return { status: 429, json: { ok: false, errors: [allowanceExhaustedLine(limit, BRAND.name)] } };
1848
1905
  };
1849
1906
 
1850
1907
  // /portal/api/run/plan — the confirmation gate (no spend)
@@ -2463,8 +2520,14 @@ async function connectorDoorKind(url) {
2463
2520
  //
2464
2521
  // COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
2465
2522
  // path, so the three surfaces stating this route cannot drift apart even if someone tries.
2523
+ //
2524
+ // AND IT NAMES THE DISTRIBUTION, like every other surface that states this route. Without the
2525
+ // target this one composer answers as it does for an install that is not under WSL at all, so on
2526
+ // a WSL box this field alone carried the inside-WSL line while the rows beside it led with the
2527
+ // Windows-side one. Nothing draws this field today; it is on the wire, and a field that answers
2528
+ // differently from the rows is a trap for whoever draws it next.
2466
2529
  const stdio = seesEverything(principal)
2467
- ? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null })
2530
+ ? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null, wsl: wslTarget() })
2468
2531
  : null;
2469
2532
 
2470
2533
  // ── THE PAGE IS HANDED ANSWERS, NOT FACTS TO REASON FROM ─────────────
@@ -3011,7 +3074,10 @@ async function connectorDoorKind(url) {
3011
3074
  // who signed in. Dynamic import deliberately: `profiles.mjs` captures the store directory at
3012
3075
  // MODULE LOAD (see this file's note above), so it is never pulled in at our own load time.
3013
3076
  const { companyFactsOf } = await import("./profiles.mjs");
3014
- return { status: 200, json: { customers: [...profiles.values()].map((p) => ({ key: p.key, name: p.name, ...companyFactsOf(p) })) } };
3077
+ // A company whose file would not load is named with its reason (staff-only route), never left out
3078
+ // in silence: a switcher with one company where there were twenty-five reads as deleted work.
3079
+ const unreadable = Array.isArray(profiles?.unreadable) ? profiles.unreadable.map((u) => ({ key: u.key, reason: u.reason })) : [];
3080
+ return { status: 200, json: { customers: [...profiles.values()].map((p) => ({ key: p.key, name: p.name, ...companyFactsOf(p) })), ...(unreadable.length ? { unreadable } : {}) } };
3015
3081
  }
3016
3082
  // /portal/admin/config — what this deployment actually has switched on. Read from the SNAPSHOT,
3017
3083
  // never from process.env: this process has no engine environment, so asking its own env would
@@ -98,7 +98,7 @@ export const PATH_FIELDS = ["frameworkPath", "workedExamplesPath"];
98
98
  /**
99
99
  * The code-owned values, READ-ONLY, for display. The page shows them badged; it cannot send them.
100
100
  *
101
- * Two of them are PATHS INSIDE THE ENGINE — `skills/prelim-search/risk-framework-<customer>.md` — and they
101
+ * Two of them are PATHS INSIDE THE ENGINE — `skills/clearance-search/risk-framework-<customer>.md` — and they
102
102
  * are withheld from a client here, on the server, where the role is already in hand. The React page has
103
103
  * filtered them out of its own render since the rebuild, but a filter in the browser is a display
104
104
  * convenience and not a wall: the value still crossed the wire and was one devtools tab away.
@@ -32,7 +32,7 @@ import { writeUpViolations, writeUpMessage } from "./narrative-write-ups.mjs";
32
32
  import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } from "./registry-fidelity.mjs";
33
33
  import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
34
34
  import { normalizeBand } from "./framework.mjs";
35
- import { clientConditions, ENGINE_TOKEN_RE } from "./terminal-clamp.mjs"; // the reader's clause per condition, and the token shape it may never carry
35
+ import { clientConditions, unrenderableConditions, ENGINE_TOKEN_RE } from "./terminal-clamp.mjs"; // the reader's clause per condition, and the token shape it may never carry
36
36
  import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
37
37
  import { isEngineAppendedCaveat } from "./verify-knockout.mjs"; // ONE derivation for "the engine appended this caveat, not a seat"
38
38
 
@@ -1976,10 +1976,20 @@ export function clientConditionVoiceChecks({ verdictDoc }) {
1976
1976
  const carrying = clientConditions(verdictDoc)
1977
1977
  .map((c) => ({ c, m: String(c).match(ENGINE_TOKEN_RE) }))
1978
1978
  .filter((x) => x.m);
1979
+ // THE SECOND CHECK IS WHAT KEEPS THE FIRST ONE HONEST. The fallback now composes the reader's
1980
+ // sentence from a pre-split reason's own counts, and DROPS a token-bearing reason it cannot compose
1981
+ // one for. Both outcomes leave the first check passing, and they mean opposite things: one is the
1982
+ // condition reaching the client in a lawyer's nouns, the other is the condition reaching nobody. A
1983
+ // single check reporting a clean page for both would be an absence read as a pass.
1984
+ const dropped = unrenderableConditions(verdictDoc);
1979
1985
  return [check("client-condition-voice", "verdict", "report", carrying.length === 0,
1980
1986
  carrying.length
1981
1987
  ? `${carrying.length} delivered condition${carrying.length === 1 ? "" : "s"} carr${carrying.length === 1 ? "ies" : "y"} an engine identifier, so the client's "conditional on:" list reads the run record rather than the reader's sentence: ${carrying.slice(0, 3).map((x) => `"${x.m[0]}"`).join(", ")}${carrying.length > 3 ? ` (+${carrying.length - 3} more)` : ""}. The clause exists at the clamp site; persist it and this list takes it. A sidecar written before clauses were persisted flags until the run is re-generated.`
1982
- : "")];
1988
+ : ""),
1989
+ check("client-condition-dropped", "verdict", "report", dropped.length === 0,
1990
+ dropped.length
1991
+ ? `${dropped.length} condition${dropped.length === 1 ? " was" : "s were"} recorded for this run and reach no client surface: the run-record reason carries an engine identifier and no reader's sentence was stored or could be composed from it, so it is dropped rather than printed in engine voice. The report ships without it and the run record keeps it: ${dropped.slice(0, 2).map((r) => `"${r.slice(0, 80)}…"`).join(", ")}${dropped.length > 2 ? ` (+${dropped.length - 2} more)` : ""}. Add the defect's sentence to the clause authority in terminal-clamp.mjs, or persist a clause at the clamp site.`
1992
+ : "")];
1983
1993
  }
1984
1994
 
1985
1995
  export function statementCoherenceChecks({ verdictDoc }) {
@@ -9,7 +9,7 @@
9
9
  // the fifteen return-path transports were measured with that hole:
10
10
  //
11
11
  // record_report_overview actions, methodology, handling_note (a section of the client's report)
12
- // record_prelim_variants incumbent_classes, watchlist_owners, search_floor
12
+ // record_clearance_variants incumbent_classes, watchlist_owners, search_floor
13
13
  // record_blind_frame sources
14
14
  // record_matter_frame scope_jurisdictions, excluded_jurisdictions
15
15
  // record_unit_note null_result, note
@@ -65,8 +65,8 @@ export function refuseUndeclared(params, declared, token, path = "") {
65
65
  // does not declare it: `narrative.corrections`, accepted and dropped. That is what this refuses.
66
66
  //
67
67
  // Refusing unknown TOP-LEVEL keys as well was the first cut, and it was wrong. Real traffic carries
68
- // envelope fields the tool schema does not declare — the prelim-variants mock sends
69
- // `schema_version`, which `acceptPrelimVariants` ignores because it writes its OWN
68
+ // envelope fields the tool schema does not declare — the clearance-variants mock sends
69
+ // `schema_version`, which `acceptClearanceVariants` ignores because it writes its OWN
70
70
  // `schema_version: SCHEMA_VERSION` into the model. Inert for as long as it has existed, and the
71
71
  // strict version made it FATAL: the whole stage refused, the run dead, for a key nobody reads.
72
72
  //
@@ -25,7 +25,7 @@
25
25
  //
26
26
  // So: a third module that contaminates neither, and one place to pin.
27
27
 
28
- import { ORDERABLE_PRODUCTS, PRODUCT_POLICIES, RETIRED_POLICIES, policyFor } from "./search-policy.mjs";
28
+ import { ORDERABLE_PRODUCTS, PRODUCT_POLICIES, RETIRED_POLICIES, policyFor, productKeyAsRenamed } from "./search-policy.mjs";
29
29
  import { maxNamesFor, productSpec } from "./products.mjs";
30
30
  import { leversFromResolved, turnaround, turnaroundHours } from "./effort-model.mjs";
31
31
 
@@ -96,7 +96,7 @@ export function baseTurnaroundFor(policy) {
96
96
  * exactly what shipped before.
97
97
  */
98
98
  export function productRow(key) {
99
- const k = String(key ?? "").trim().toLowerCase();
99
+ const k = productKeyAsRenamed(key); // a pre-rename key names the same product
100
100
  const p = PRODUCT_POLICIES[k] ?? RETIRED_POLICIES[k];
101
101
  if (!p) return null;
102
102
  const base = baseTurnaroundFor(p);