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
@@ -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.
@@ -554,8 +554,24 @@ const EMBED_JS = `
554
554
  parent.postMessage({source:TAG,type:'controls',commands:have},'*');
555
555
  }catch(e){}
556
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
+ }
557
571
  window.addEventListener('load',controls);
558
572
  document.addEventListener('DOMContentLoaded',controls);
573
+ window.addEventListener('load',sections);
574
+ document.addEventListener('DOMContentLoaded',sections);
559
575
  window.addEventListener('load',schedule);
560
576
  document.addEventListener('DOMContentLoaded',schedule);
561
577
  document.addEventListener('toggle',schedule,true);
@@ -570,6 +586,17 @@ const EMBED_JS = `
570
586
  // A FAILED COMMAND SAYS SO. This used to be catch(err){} — a button that did nothing, told nobody,
571
587
  // and left no console trace, which is how "Export does not work" went unnoticed through a whole
572
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
+ }
573
600
  var fn = d.command==='exportPDF' ? (typeof exportPDF==='function'?exportPDF:null)
574
601
  : d.command==='pickAll' ? (typeof pickAll==='function'?pickAll:null)
575
602
  : d.command==='openAll' ? (typeof openAll==='function'?openAll:null)
@@ -595,7 +622,7 @@ const EMBED_JS = `
595
622
  // Registered AFTER the document's own script by construction (this block is appended at the end of
596
623
  // <body>), so by the time it runs for an '#c<ordinal>' link the ancestors are already open and the
597
624
  // measured offset is the revealed position. preventDefault stops the frame's dead-end fragment
598
- // 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).
599
626
  // AND WHEN THERE IS NO PARENT, DO NOT CANCEL THE CLICK.
600
627
  //
601
628
  // Everything above is right INSIDE the portal's Result screen. Un-framed it is a dead link: parent is
@@ -630,6 +657,28 @@ const EMBED_JS = `
630
657
  try{parent.postMessage({source:TAG,type:'scrollTo',top:top},'*');}catch(e){}
631
658
  schedule();
632
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
+ });
633
682
  schedule();
634
683
  })();
635
684
  `;
@@ -746,8 +795,42 @@ const FEEDBACK_JS = `
746
795
  // Appended at the very end of <body>: the stylesheet is inlined into <head>, so a rule placed here wins
747
796
  // the cascade on equal specificity without needing !important, and the script runs with the document's
748
797
  // own functions already defined.
749
- function injectEmbedLayer(html, { feedback = false } = {}) {
750
- 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>`
751
834
  + (feedback ? `<style data-embed="feedback">${FEEDBACK_CSS}</style><script data-embed="feedback">${FEEDBACK_JS}</script>` : "");
752
835
  const i = html.lastIndexOf("</body>");
753
836
  // No </body> means markup we do not recognise; appending is still correct and still parses.
@@ -787,6 +870,10 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
787
870
  const note = (tag) => unbalanced.push(tag);
788
871
  const missingCss = [];
789
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
+
790
877
  const nav = stripBalanced(html, NAV_RE, "nav", note);
791
878
  let out = nav.html;
792
879
  const strippedNav = nav.removed;
@@ -865,11 +952,11 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
865
952
 
866
953
  // After the stylesheet, so the injected rule sits later in the cascade than the sheet it overrides —
867
954
  // and after every strip, so nothing above can match the injected markup and eat it.
868
- out = injectEmbedLayer(out, { feedback });
955
+ out = injectEmbedLayer(out, { feedback, sections });
869
956
 
870
957
  return {
871
958
  html: out, strippedNav, neutralised, mcpLeaks, tokensDropped, ratedUnderDropped, runUnderProjectDropped,
872
- internalTailsDropped, reviewerCodesDropped, unbalanced, missingCss,
959
+ internalTailsDropped, reviewerCodesDropped, unbalanced, missingCss, sections,
873
960
  };
874
961
  }
875
962
 
@@ -129,7 +129,7 @@ export const allowanceExhaustedLine = (cap, who) =>
129
129
  const INSTALL_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
130
130
 
131
131
  const READ_OFF_NOTE = "Reading a brief is not available on this instance — set the search up below.";
132
- 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";
133
133
  import { driverDir } from "../shared/driver-dir.mjs"; //
134
134
  import { createHmac, timingSafeEqual } from "node:crypto";
135
135
  import { makePrincipal, assertPrincipal, genericOrgOf, mayReadRun, reachCovers, principalView, seesEverything, mayRun,
@@ -677,7 +677,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
677
677
  };
678
678
  // THE CANONICAL INTAKE FIRST, and it is why this parameter exists. `CLEAROTRON_QUEUE_DIR` is where the
679
679
  // enqueue CLI and ops-MCP `start_run` write — which is where the PORTAL's own submissions land, since
680
- // 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`,
681
681
  // and a documented headless install has no workspaces at all: measured on the test box, the only queue
682
682
  // under the whole tree is the configured one, and it holds portal-prefixed jobs. So this scan ran ZERO
683
683
  // times there, and a submitted search was invisible on the dashboard from submit until claim — a
@@ -687,7 +687,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
687
687
  }
688
688
  try {
689
689
  for (const ws of readdirSync(workspaceRoot).filter((n) => n.startsWith("workspace-"))) {
690
- const studio = join(workspaceRoot, ws, "studio", "prelim-search");
690
+ const studio = studioDirFor(join(workspaceRoot, ws));
691
691
  let slugs = []; try { slugs = readdirSync(studio); } catch { continue; }
692
692
  for (const slug of slugs) {
693
693
  // Still walked, so a deployment whose queue is not in `queueDirs` keeps working. The union is
@@ -1332,7 +1332,24 @@ export function makePortalService({
1332
1332
  // explains a control that cannot be used, `coverageNote` qualifies one that can. Folding this
1333
1333
  // into the first would make every caller of `productAvailability` — the portal, the MCP door,
1334
1334
  // the dev cockpit — read a disclosure as a refusal, which is the behaviour the ruling removes.
1335
- 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.
1336
1353
  // ── — THE PRODUCT DECLARES WHAT IT NEEDS, so the row can say so ─────
1337
1354
  //
1338
1355
  // The owner ordered the one product carrying `caseLaw: true` and first heard of the lane in the
@@ -1349,7 +1366,7 @@ export function makePortalService({
1349
1366
  // a warning on every deployment whose writer has not run since is worse than the silence it
1350
1367
  // replaces.
1351
1368
  return { ...l, available: cause === null, unavailableNote: cause ? UNAVAILABLE_NOTE[cause] : null,
1352
- coverageNote: coverage?.note ?? null,
1369
+ coverageNote: null,
1353
1370
  capabilityNote: l.caseLaw && caseLawReady === false ? CASE_LAW_DARK_NOTE : null };
1354
1371
  }),
1355
1372
  // ── the TERRITORY affordance ─────────────────────────────────────────────────────────────
@@ -1742,7 +1759,7 @@ export function makePortalService({
1742
1759
  // The recipeKey arm that used to sit here (a 422 when saved searches were "not switched on") went
1743
1760
  // with CLEAROTRON_RECIPES_MODE on 2026-07-27: a saved search is now honoured wherever it resolves.
1744
1761
  // Asked of the RESOLVED product, not of the body: a request that names none resolves through
1745
- // 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"`)
1746
1763
  // answered about a product nobody had chosen. `resolveFor` fails open to a null resolution, and a
1747
1764
  // null one is not judged here — validateJob and the scope rules below still run, and the runner
1748
1765
  // is the wall.
@@ -2503,8 +2520,14 @@ async function connectorDoorKind(url) {
2503
2520
  //
2504
2521
  // COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
2505
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.
2506
2529
  const stdio = seesEverything(principal)
2507
- ? 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() })
2508
2531
  : null;
2509
2532
 
2510
2533
  // ── THE PAGE IS HANDED ANSWERS, NOT FACTS TO REASON FROM ─────────────
@@ -3051,7 +3074,10 @@ async function connectorDoorKind(url) {
3051
3074
  // who signed in. Dynamic import deliberately: `profiles.mjs` captures the store directory at
3052
3075
  // MODULE LOAD (see this file's note above), so it is never pulled in at our own load time.
3053
3076
  const { companyFactsOf } = await import("./profiles.mjs");
3054
- 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 } : {}) } };
3055
3081
  }
3056
3082
  // /portal/admin/config — what this deployment actually has switched on. Read from the SNAPSHOT,
3057
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.
@@ -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);
@@ -405,7 +405,7 @@ export function nativeLanguageMode(product) {
405
405
  *
406
406
  * Exported because two surfaces outside this module have to say it and both used to say something else:
407
407
  * the resolution-time recommendation (jx-lanes.mjs zhScopeDepthNotes) and the DELIVERED REPORT's own
408
- * coverage row (pipeline.mjs scriptScopeDisclosure). Both named `prelim-jx` and `Depth 5` — an internal
408
+ * coverage row (pipeline.mjs scriptScopeDisclosure). Both named `clearance-jx` and `Depth 5` — an internal
409
409
  * product key and a rung on a ladder — so the coverage row named a remedy that was a product
410
410
  * deleted, quoted at a number that no longer exists, and could not have ordered either.
411
411
  *
@@ -53,8 +53,8 @@ consumes it; a unit test greps each symbol, so a field with no live consumer fai
53
53
  | `delivery` | delivery | `{ email: "summary", privileged: bool }` — the "Privileged & Confidential" header. Two further optional sub-keys are accepted: `style` (a prose string, guarded by the same anti-rule check as a context pack, dictated into the `report-overview` and `report-card` stages as presentation tone and never to `synthesis`, the rating stage) and `template` (a report-template name; `"standard"` is the only one that exists, and it is the default). **Absent ⇒ neutral default** (`{email:"summary"}` — deliberately SILENT on `privileged`, which is three-state on every surface that reads it (`confPosture`): `true` extends the marking to "Attorney Work Product", `false` is a deliberate OFF, and absent is no opinion, which gets the plain "Privileged & Confidential" every legal deliverable carries. Saying `false` in the neutral overlay read as an instruction to strip the marking, and a Generic-default clearance shipped with no line at all). `email` no longer selects anything: every run's mail is a COVER NOTE pointing at the one report. The old `"table"` value — a full review table inlined into the mail body — is **retired**; it is still accepted at load so stored profiles keep validating, and folded to `"summary"` by `normalizeDelivery`. A customer who wants their own house format gets it drafted by the assistant from the run's `report-data.json`, where a person reads it before it goes |
54
54
  | `riskAppetite` | context | a PROSE-POSTURE string (optional) that flavours **emphasis + recommended follow-up** in delivery curation — the two stages that are dictated it, `report-overview` and `report-card` — **never the Level/Composite**. A load-time anti-threshold guard rejects numeric/threshold phrasing (`>50%`, `Level C or above`, `threshold`); the "never decides" invariance itself is gated by review |
55
55
  | `marketplaceDensity` | structural | `"sparse"` (default) \| `"dense"` — selects the per-profile grid cell budget so a byte-heavy marketplace's verbatim stdout fits the worker output channel (sparse ⇒ 98-cell budget; dense ⇒ 16). Dense fits long retail listings (beverages and supplements); gaming stores stay sparse |
56
- | `frameworkPath` | rating authority | optional path to the customer's OWN risk framework (`skills/prelim-search/<file>.md`, path-escape-blocked). **The framework in force RATES the matter** — the customer's own if on file, else the Generic default `risk-framework.md`; nothing in between. Each framework is a prose deck (the client's own rubric, reasoned WITH) plus a `.manifest.json` sidecar carrying its band vocabulary (`test/framework-lint.test.mjs` guards the pair). Git + legal-team gated; the config UI shows which is in force, read-only |
57
- | `workedExamplesPath` | context | optional path to a per-customer worked-examples set (`skills/prelim-search/<file>.md`). The analysis DEPTH TARGET in `synthesis`, calibrated under that customer's framework; **absent ⇒ the Generic default `worked-examples.md`** |
56
+ | `frameworkPath` | rating authority | optional path to the customer's OWN risk framework (`skills/clearance-search/<file>.md`, path-escape-blocked). **The framework in force RATES the matter** — the customer's own if on file, else the Generic default `risk-framework.md`; nothing in between. Each framework is a prose deck (the client's own rubric, reasoned WITH) plus a `.manifest.json` sidecar carrying its band vocabulary (`test/framework-lint.test.mjs` guards the pair). Git + legal-team gated; the config UI shows which is in force, read-only |
57
+ | `workedExamplesPath` | context | optional path to a per-customer worked-examples set (`skills/clearance-search/<file>.md`). The analysis DEPTH TARGET in `synthesis`, calibrated under that customer's framework; **absent ⇒ the Generic default `worked-examples.md`** |
58
58
  | `defaultProduct` | entitlement | which of the four searches runs when a request names none (`search-policy.mjs`). May be left unset, and that is not a gap — a clearance that names no product is then named by its own resolved territories |
59
59
  | `allowedRecipes[]` | entitlement | the closed menu of searches this account may trigger; non-empty when present, and **absent ⇒ everything allowed** |
60
60
  | `jxPolicy` | entitlement | the native-language deepening POSTURE — declared lanes / escalation / provider stance. Policy, never capability; frozen into the run sidecar so a resume keeps the posture the run started under |
@@ -90,7 +90,7 @@ value into a profile file is a load-time error.
90
90
  the write door: the same profile saved through the editor is a validated auto-commit with no PR in
91
91
  it, and the shared load-time validators are what both paths cannot get past.
92
92
  3. **Rewrite check:** the skills are platform-agnostic (they follow the dictated list), but read
93
- `skills/prelim-common-law/SKILL.md` + `perplexity-prompts.md`, `skills/prelim-search/SKILL.md` +
93
+ `skills/clearance-common-law/SKILL.md` + `perplexity-prompts.md`, `skills/clearance-search/SKILL.md` +
94
94
  `phase2-execution.md`, and the driver message prose in `stages.mjs` once against the new
95
95
  industry — worked examples are gaming-flavored by history.
96
96
  4. ONE validation run against a known/synthetic matter: confirm the grid swept exactly the
@@ -17,8 +17,8 @@
17
17
  "delivery": { "email": "summary", "privileged": false },
18
18
  "riskAppetite": "Evidence-first. Lead with the register position, then say plainly what the common-law picture adds or fails to settle. Name coverage limits in the body rather than in a footnote — an unread source is a fact about the search, not a caveat about the report. Keep crowded-field noise brief.",
19
19
  "defaultProduct": "multi-country-focus-search",
20
- "frameworkPath": "skills/prelim-search/risk-framework-demo.md",
21
- "workedExamplesPath": "skills/prelim-search/worked-examples-demo.md",
20
+ "frameworkPath": "skills/clearance-search/risk-framework-demo.md",
21
+ "workedExamplesPath": "skills/clearance-search/worked-examples-demo.md",
22
22
  "jxPolicy": { "laneDepth": { "ja": "full", "ko": "candidates" } },
23
23
  "runCaps": { "dailyRuns": 12, "maxQueued": 2 }
24
24
  }
@@ -346,9 +346,12 @@ export const FIELD_CONSUMERS = {
346
346
 
347
347
  // Per-customer reasoning-skill selection. frameworkPath is the customer's OWN risk framework — under doc 50
348
348
  // it RATES the matter; absent ⇒ the Generic default rates it (DEFAULT_FRAMEWORK in framework.mjs). Constrained to
349
- // the prelim-search skill dir + a .md suffix so a profile cannot point the synthesis read at an arbitrary
349
+ // the clearance-search skill dir + a .md suffix so a profile cannot point the synthesis read at an arbitrary
350
350
  // path. The SHARED doctrine lives identically across the per-customer frameworks; only the examples diverge.
351
- const SKILL_PATH_RE = /^skills\/prelim-search\/[A-Za-z0-9._-]+\.md$/;
351
+ // The folder's pre-rename spelling is accepted as the same folder: a store written before the rename names it,
352
+ // and one unmigrated profile must not take a company, or the whole roster, off the list. The file is read
353
+ // from whichever spelling the store holds (config.resolveSkillPath).
354
+ const SKILL_PATH_RE = /^skills\/(?:clearance|prelim)-search\/[A-Za-z0-9._-]+\.md$/;
352
355
 
353
356
  // The delivery overlay every run gets. `email` is no longer a choice: every run's mail is a COVER NOTE
354
357
  // pointing at the report (one report, one shape, per-lawyer client mail drafted by the assistant).
@@ -566,11 +569,11 @@ function validateProfileShape(key, p, { sparse = false } = {}) {
566
569
  die(`marketplaceDensity must be ${MARKETPLACE_DENSITIES.map((v) => `"${v}"`).join(" or ")} `
567
570
  + `(got "${p.marketplaceDensity}")`);
568
571
  // frameworkPath / workedExamplesPath (optional, Phase 2): a per-customer reasoning-skill file. Constrained
569
- // to skills/prelim-search/*.md (no path escape) — a profile selects a SHIPPED skill, never an arbitrary path.
572
+ // to skills/clearance-search/*.md (no path escape) — a profile selects a SHIPPED skill, never an arbitrary path.
570
573
  for (const k of ["frameworkPath", "workedExamplesPath"]) {
571
574
  if (p[k] == null) continue;
572
575
  if (typeof p[k] !== "string" || !SKILL_PATH_RE.test(p[k]) || p[k].includes(".."))
573
- die(`${k} must be a path of the form "skills/prelim-search/<file>.md" (got ${JSON.stringify(p[k])})`);
576
+ die(`${k} must be a path of the form "skills/clearance-search/<file>.md" (got ${JSON.stringify(p[k])})`);
574
577
  }
575
578
  // defaultProduct (optional): one of the four in the offering, by id. A typo hard-fails at load rather
576
579
  // than silently running the wrong-priced product on every job.
@@ -679,25 +682,42 @@ let cache = null;
679
682
  /** Read one directory of profiles/<key>.json → Map(key → profile). No generic requirement and no
680
683
  * matchDomains check here: both are properties of the MERGED roster, not of one layer, and asserting
681
684
  * them per-layer would refuse a base+overlay pair that is perfectly valid once combined. */
682
- function readProfilesLayer(dir) {
685
+ // ONE COMPANY THAT CANNOT BE READ MUST NOT TAKE THE OTHERS WITH IT — on the deployment's own store. Strict,
686
+ // every file or none, is right for an explicit directory: that is how this loader's rules are checked. On the
687
+ // configured store it refused every company over one file, and the portal then offered `generic` alone with
688
+ // no error, so a client's clearances looked deleted. So `tolerant` isolates each file: a company that fails is
689
+ // left out, its reason is kept on the returned map as `unreadable`, and asking for it by key refuses with that
690
+ // reason (resolveProfile). `generic` is never tolerated: it is the fallback every unprofiled job rates under,
691
+ // and replacing a deployment's own with the bundled one would change every such answer without a word.
692
+ function readProfilesLayer(dir, { tolerant = false } = {}) {
683
693
  const profiles = new Map();
694
+ const unreadable = [];
684
695
  for (const f of readdirSync(dir).filter((n) => n.endsWith(".json")).sort()) {
685
696
  const key = f.replace(/\.json$/, "");
686
- let p;
687
- try { p = JSON.parse(readFileSync(join(dir, f), "utf8")); }
688
- catch (e) { throw new Error(`profiles/${f}: unparseable JSON (${e.message})`); }
689
- validateProfileShape(key, p);
690
- // Context pack (Phase 1): a sibling `<key>.context.md`, attached AFTER validateProfileShape so the
691
- // deny-unknown-key gate (which governs the JSON shape) never sees it. Optional — absent ⇒ no pack.
692
- // Validated at load (rule-shape + size budget) so a bad pack fails loudly here, like riskAppetite (F8).
693
- const packPath = join(dir, CONTEXT_PACK_FILE(key));
694
- const contextPack = existsSync(packPath) ? readFileSync(packPath, "utf8").trim() : "";
695
- if (contextPack) assertContextPackShape(contextPack, `profiles/${CONTEXT_PACK_FILE(key)}`);
696
- profiles.set(key, { key, ...p, ...(contextPack ? { contextPack } : {}) });
697
+ try {
698
+ let p;
699
+ try { p = JSON.parse(readFileSync(join(dir, f), "utf8")); }
700
+ catch (e) { throw new Error(`profiles/${f}: unparseable JSON (${e.message})`); }
701
+ validateProfileShape(key, p);
702
+ // Context pack (Phase 1): a sibling `<key>.context.md`, attached AFTER validateProfileShape so the
703
+ // deny-unknown-key gate (which governs the JSON shape) never sees it. Optional — absent ⇒ no pack.
704
+ // Validated at load (rule-shape + size budget) so a bad pack fails loudly here, like riskAppetite (F8).
705
+ const packPath = join(dir, CONTEXT_PACK_FILE(key));
706
+ const contextPack = existsSync(packPath) ? readFileSync(packPath, "utf8").trim() : "";
707
+ if (contextPack) assertContextPackShape(contextPack, `profiles/${CONTEXT_PACK_FILE(key)}`);
708
+ profiles.set(key, { key, ...p, ...(contextPack ? { contextPack } : {}) });
709
+ } catch (e) {
710
+ if (!tolerant || key === "generic") throw e;
711
+ unreadable.push({ key, file: f, reason: String(e?.message ?? e) });
712
+ }
697
713
  }
714
+ Object.defineProperty(profiles, "unreadable", { value: unreadable, enumerable: false });
698
715
  return profiles;
699
716
  }
700
717
 
718
+ /** The companies a roster left out because their file would not load: `[{ key, file, reason }]`. */
719
+ export const unreadableProfiles = (profiles) => (Array.isArray(profiles?.unreadable) ? profiles.unreadable : []);
720
+
701
721
  /** Load every profiles/<key>.json → Map(key → profile), OVERLAY OVER BASE. Hard-fails loudly
702
722
  * on a generic.json missing from BOTH layers (the universal fallback — its absence would mis-profile
703
723
  * EVERY job), on a configured-but-unreadable overlay, and on any matchDomains overlap in the merged
@@ -746,7 +766,7 @@ export function loadProfiles({ dir, force = false, includeTestFixtures, includeD
746
766
  // the universal fallback the module REQUIRES by name, the thing every unprofiled job resolves to. That
747
767
  // is the one file whose absence makes an empty store a refusal, so that is the only one that falls
748
768
  // through. Everything else in a deployment's roster is the deployment's own.
749
- const profiles = overlay ? readProfilesLayer(overlay) : readProfilesLayer(baseDir);
769
+ const profiles = overlay ? readProfilesLayer(overlay, { tolerant: true }) : readProfilesLayer(baseDir);
750
770
 
751
771
  // ── A TEST FIXTURE IS PRESENT AND NEVER OFFERED ─────────────────────────────────────────────────
752
772
  //
@@ -855,6 +875,15 @@ export function loadProfiles({ dir, force = false, includeTestFixtures, includeD
855
875
  export function resolveProfile(job, { profiles = loadProfiles() } = {}) {
856
876
  const key = String(job?.profileKey ?? "").trim();
857
877
  if (key && profiles.has(key)) return profiles.get(key);
878
+ // A company whose file would not load is not an unknown company: say which, and why.
879
+ const unread = unreadableProfiles(profiles);
880
+ const failed = key ? unread.find((u) => u.key === key) : null;
881
+ if (failed) {
882
+ const err = new Error(`profile_unreadable:${key} — ${failed.reason}. The company exists but its profile could not be read, `
883
+ + `so this run is refused rather than rated under another company's settings.`);
884
+ err.code = "profile_unreadable";
885
+ throw err;
886
+ }
858
887
  // A NAMED-but-unknown key is not a graceful-degradation case, it is a roster mismatch — the two
859
888
  // sides disagree about which config store is real, and falling back to `generic` silently strips
860
889
  // the client's platforms, their self-exclusion seed and the framework that RATES the matter. That
@@ -875,6 +904,15 @@ export function resolveProfile(job, { profiles = loadProfiles() } = {}) {
875
904
  if (dom === dl || dom.endsWith(`.${dl}`)) return p;
876
905
  }
877
906
  }
907
+ // WITH A COMPANY UNREAD, "no company matched" is not known: the unread one's domains are not in hand, and
908
+ // this job may be its. Falling to `generic` here is the silent wrong-company deliverable, so refuse.
909
+ if (unread.length) {
910
+ const err = new Error(`profile_roster_incomplete — ${unread.length} company profile(s) could not be read `
911
+ + `(${unread.map((u) => u.file).join(", ")}), so whether this job belongs to one of them cannot be decided. `
912
+ + `Name the company on the job, or fix the file.`);
913
+ err.code = "profile_roster_incomplete";
914
+ throw err;
915
+ }
878
916
  return profiles.get("generic");
879
917
  }
880
918