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
@@ -84,6 +84,36 @@ export function editNeighbourhood(element) {
84
84
  return [...out].sort();
85
85
  }
86
86
 
87
+ // ── A one-letter neighbour that is an ordinary word with a DIFFERENT SOUND is not searched ────────────
88
+ //
89
+ // THE REVIEWING LAWYER'S TEST IS CONFUSING SIMILARITY, and edit-1 over a short ordinary word is mostly other
90
+ // ordinary words: CARE, CODE, CORD, BORE, MORE beside CORE, each a common mark with its own crowd. Measured
91
+ // on a delivered four-letter run, 2026-09-18: 1,037 of 2,098 records (49%) were reached only by the one-letter
92
+ // lists; on the dense production matter of 2026-09-16, 1,154 of 2,146 (54%). Conceptually different words are
93
+ // not confused by consumers, so fetching them is cost and not coverage. A respelling that sounds the same —
94
+ // KORE for CORE — is exactly what a search must find, and so is every neighbour of a made-up word, because two
95
+ // unknown words that sound alike have nothing conceptual to keep them apart (MALENA beside VALENA).
96
+ //
97
+ // So a neighbour is dropped only when BOTH hold: it is in the ordinary-word list, AND no Double-Metaphone key
98
+ // of it matches any key of the element. Everything else is dispatched exactly as before. There is no judgment
99
+ // of the MARK here — coined or ordinary — only of each neighbour, so a coined element keeps its whole list
100
+ // except where a neighbour is a different-sounding real word. The list only ever removes queries, and a word
101
+ // missing from it is searched: over-search is the safe direction.
102
+ //
103
+ // DOUBLE METAPHONE KEEPS VOWELS ONLY AT THE START, so a vowel change inside the word does not change the key:
104
+ // CARE, CURE and GORE all key KR, as CORE does, and are KEPT. That is the rule as specified erring towards the
105
+ // search, and it is recorded here so nobody reads those three as a defect of the list.
106
+ //
107
+ // `ordinaryWords` is a Set of lowercase words, or null. Null drops nothing — the behaviour before this rule —
108
+ // so a caller that does not load the list (every test that predates it) is unchanged. PURE.
109
+ export function ordinaryWordDifferentSound(element, ordinaryWords) {
110
+ const el = normalizeElement(element);
111
+ if (!el || !(ordinaryWords instanceof Set) || !ordinaryWords.size) return [];
112
+ const own = new Set(doubleMetaphone(el).filter(Boolean));
113
+ return editNeighbourhood(el).filter((t) => ordinaryWords.has(t)
114
+ && !doubleMetaphone(t).filter(Boolean).some((k) => own.has(k)));
115
+ }
116
+
87
117
  // ── Consonant skeleton + wildcard patterns — retrieve the phonetic VOWEL family in one bounded vendor query ──
88
118
  // VALENA → consonants V,L,N → skeleton "VLN"; vowel-slot wildcard "V?L?N?"-style patterns the vendor's Lucene
89
119
  // `?`/`*` supports (live-confirmed). This is the tractable, complete way to reach VYLONA/VILINA/VILENA without
@@ -229,13 +259,18 @@ export function transliterations(element, { scripts = SUPPORTED_SCRIPTS } = {})
229
259
  // droppedVariantFamilies). The floor stays exhaustive WITHIN the families judgment kept — this is the funnel
230
260
  // honouring a scope decision that was already written and, until 2026-07-18, ignored. edit-1 is never
231
261
  // droppable: it is the doctrine floor (radiusFor), not a family.
232
- export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [] } = {}) {
262
+ export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [], ordinaryWords = null } = {}) {
233
263
  const radius = radiusFor(element);
234
264
  const el = radius.element;
235
- if (!el) return { element: "", radius, exactQueries: [], wildcardPatterns: [], phoneticKeys: [], confusables: [], transliterations: [], ledger: { disclosed: radius.note, axes: [] } };
265
+ if (!el) return { element: "", radius, exactQueries: [], wildcardPatterns: [], phoneticKeys: [], confusables: [], transliterations: [], ordinaryWordDifferentSound: [], ledger: { disclosed: radius.note, axes: [] } };
236
266
 
237
267
  const drop = new Set(droppedAxes ?? []);
238
- const edits = editNeighbourhood(el);
268
+ const generatedEdits = editNeighbourhood(el);
269
+ // Only edit-1 is filtered. A term another generator also produces (a confusable, a transliteration) is
270
+ // still dispatched as that generator's — those families are unchanged by this rule.
271
+ const notSearched = ordinaryWordDifferentSound(el, ordinaryWords);
272
+ const skip = new Set(notSearched);
273
+ const edits = generatedEdits.filter((t) => !skip.has(t));
239
274
  const confs = drop.has("visual-confusable") ? [] : visualConfusables(el);
240
275
  const trans = drop.has("transliteration") ? [] : transliterations(el, { scripts });
241
276
  const wildcards = drop.has("phonetic-family") ? [] : skeletonPatterns(el);
@@ -261,11 +296,15 @@ export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_S
261
296
  phoneticKeys: keys,
262
297
  confusables: confs,
263
298
  transliterations: trans,
299
+ ordinaryWordDifferentSound: notSearched,
264
300
  ledger: {
265
301
  disclosed: radius.note,
266
302
  dropped_axes: [...drop].sort(),
267
303
  axes: [
268
- { axis: "edit-1", count: edits.length, mechanism: "Damerau-Levenshtein edit-1, exhaustive" },
304
+ { axis: "edit-1", count: edits.length, generated: generatedEdits.length, not_searched: notSearched.length,
305
+ mechanism: notSearched.length
306
+ ? "Damerau-Levenshtein edit-1, exhaustive, less the neighbours that are ordinary words with a different sound"
307
+ : "Damerau-Levenshtein edit-1, exhaustive" },
269
308
  // NO PATTERN is a THIRD state, and it is disclosed in the same voice as a judgment drop. An
270
309
  // element with too few consonants to anchor a skeleton wildcard (X, and anything normalizing to
271
310
  // one character) yields no retrieval pattern at all — see skeletonPatterns. Silence here would
@@ -349,7 +388,7 @@ export function coverageGaps(band, { dispatched = [], explained = [] } = {}) {
349
388
  // exhaustive edit-1 neighbourhood: 1,736 junk exact queries on AquaPlus 2026-07-17, 4,524 on the 07-16 run.
350
389
  // Measured 2026-07-18: 10 of 20 recent runs carried one of these. The JSON field is a validated scalar and
351
390
  // cannot swallow a sentence.
352
- export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [], model = null, mark = "" } = {}) {
391
+ export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [], model = null, mark = "", ordinaryWords = null } = {}) {
353
392
  const { seeds, seededFrom, rejected } = floorSeeds(manifestMd, { model, mark });
354
393
  // The reason travels WITH the throw. It used to say only "the job states no mark", which is one
355
394
  // of two causes and not the one that actually fires: a non-Latin mark states a mark perfectly well and
@@ -361,7 +400,7 @@ export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts
361
400
  || "manifest names no Dominant element / Formative root, and the job states no mark";
362
401
  throw new Error(`form_neighbourhood_no_element: ${why} — nothing to seed the mechanical form band`);
363
402
  }
364
- const elements = seeds.map(({ element, role }) => ({ element, role, band: formNeighbourhood(element, { markets, scripts, droppedAxes }) }));
403
+ const elements = seeds.map(({ element, role }) => ({ element, role, band: formNeighbourhood(element, { markets, scripts, droppedAxes, ordinaryWords }) }));
365
404
  const families = variantFloorFamilies(elements, { mark, droppedAxes });
366
405
  return JSON.stringify({
367
406
  schema_version: 2,
@@ -596,10 +635,11 @@ export function spacingPunctuationForms(mark) {
596
635
  export function variantFloorFamilies(elements, { mark = "", droppedAxes = [] } = {}) {
597
636
  const drop = new Set(droppedAxes ?? []);
598
637
  const edit = new Set(), visual = new Set(), translit = new Set(), other = new Set();
599
- const wildcards = new Set(), keys = new Set();
638
+ const wildcards = new Set(), keys = new Set(), notSearched = new Set();
600
639
  for (const el of elements ?? []) {
601
640
  const band = el?.band;
602
641
  if (!band) continue;
642
+ for (const t of band.ordinaryWordDifferentSound ?? []) notSearched.add(t);
603
643
  const edits = new Set(editNeighbourhood(el.element));
604
644
  const confs = new Set(band.confusables ?? []);
605
645
  const trans = new Set(band.transliterations ?? []);
@@ -639,6 +679,12 @@ export function variantFloorFamilies(elements, { mark = "", droppedAxes = [] } =
639
679
  dropped: drop.has("phonetic-family"), terms: sorted(wildcards), phonetic_keys: sorted(keys) },
640
680
  ...(other.size ? [{ family: "other", category: "other", generator: "formNeighbourhood (dedupeOnFold residue)",
641
681
  enumeration: "a dispatched term no single generator claims — recorded rather than hidden", dropped: false, terms: sorted(other) }] : []),
682
+ // NOT SEARCHED, and listed in full so the plan says what it left out and why. Not a crowd and not a
683
+ // gap: a deliberate rule (ordinaryWordDifferentSound). `searched: false` keeps it out of the floor the
684
+ // merge below counts, so a model that proposes one of these words is recorded as its own addition.
685
+ ...(notSearched.size ? [{ family: "ordinary-word-different-sound", category: "phonetic", generator: "editNeighbourhood",
686
+ enumeration: "edit-1 neighbours that are ordinary English words and share no Double-Metaphone key with the element",
687
+ dispatch: "not searched — ordinary word, different sound", searched: false, dropped: true, terms: sorted(notSearched) }] : []),
642
688
  ];
643
689
  }
644
690
 
@@ -674,6 +720,7 @@ export function mergeVariantFloor(floorFamilies, modelVariants, { rejectedSeeds
674
720
  const families = Array.isArray(floorFamilies) ? floorFamilies : [];
675
721
  const floorByKey = new Map();
676
722
  for (const f of families) {
723
+ if (f?.searched === false) continue; // listed for disclosure, never searched — not floor
677
724
  for (const t of f?.terms ?? []) {
678
725
  const k = formKey(t);
679
726
  if (!k || floorByKey.has(k)) continue;
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
3
  // framework.mjs — the risk-framework MANIFEST layer (doc 50: the framework in force rates the matter).
4
4
  //
5
- // The framework itself is a PROSE deck (skills/prelim-search/risk-framework*.md) — the customer's own legal
5
+ // The framework itself is a PROSE deck (skills/clearance-search/risk-framework*.md) — the customer's own legal
6
6
  // judgment written down, which synthesis reads and reasons WITH. This module carries the small
7
7
  // machine-readable sidecar (<framework>.manifest.json) that lets validators, the renderer, the archive index
8
8
  // and the profile UI consume the framework's VOCABULARY — band words, severity order, entity label, source —
@@ -18,12 +18,12 @@ import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
 
20
20
  // ── selection (the ?? fallback IS the "Generic default rates the matter" rule) ────────────────────────────
21
- export const DEFAULT_FRAMEWORK = "skills/prelim-search/risk-framework.md";
22
- export const DEFAULT_WORKED_EXAMPLES = "skills/prelim-search/worked-examples.md";
21
+ export const DEFAULT_FRAMEWORK = "skills/clearance-search/risk-framework.md";
22
+ export const DEFAULT_WORKED_EXAMPLES = "skills/clearance-search/worked-examples.md";
23
23
  export const frameworkFor = (profile) => profile?.frameworkPath ?? DEFAULT_FRAMEWORK;
24
24
  export const workedExamplesFor = (profile) => profile?.workedExamplesPath ?? DEFAULT_WORKED_EXAMPLES;
25
25
 
26
- /** skills/prelim-search/risk-framework-x.md → skills/prelim-search/risk-framework-x.manifest.json.
26
+ /** skills/clearance-search/risk-framework-x.md → skills/clearance-search/risk-framework-x.manifest.json.
27
27
  * The manifest path is DERIVED, never a profile knob — the profile names only the .md. */
28
28
  export const manifestPathFor = (fwPath) => String(fwPath).replace(/\.md$/, ".manifest.json");
29
29
 
@@ -34,7 +34,7 @@ import { MODEL_FILE as BLIND_FRAME_MODEL_FILE } from "./blind-frame-record.mjs";
34
34
  import { FLAGS_FILE as SKEPTIC_FLAGS_FILE } from "./skeptic-record.mjs";
35
35
  import { MODEL_FILE as FRAME_DIFF_MODEL_FILE, PROSE_FILE as FRAME_DIFF_PROSE_FILE } from "./frame-diff-record.mjs";
36
36
  import { MATTER_CONTEXT_FILE } from "./matter-frame-record.mjs";
37
- import { MODEL_FILE as VARIANT_MODEL_FILE, PROSE_FILE as VARIANT_PROSE_FILE } from "./prelim-variants-record.mjs";
37
+ import { MODEL_FILE as VARIANT_MODEL_FILE, PROSE_FILE as VARIANT_PROSE_FILE } from "./clearance-variants-record.mjs";
38
38
  import { PROSE_FILE as REPORT_OVERVIEW_FILE } from "./report-overview-record.mjs";
39
39
  import { NARRATIVE_FILE, FINDINGS_FILE, refusalsFor } from "./synthesis-record.mjs";
40
40
  import { FINDINGS_FILE as REGISTER_FINDINGS_FILE, refusalsFor as registerDigestRefusalsFor } from "./register-digest-record.mjs";
@@ -96,8 +96,8 @@ export const TOOL_WRITTEN_ARTIFACTS = new Map([
96
96
  // scope-ledger.json is deliberately ABSENT: it was already driver-written before this conversion (the
97
97
  // driver derived it), so it is outside this conversion's claim. What changed is where its values come
98
98
  // from, not who writes it.
99
- [VARIANT_MODEL_FILE, { tool: "record_prelim_variants", what: "the variant manifest" }],
100
- [VARIANT_PROSE_FILE, { tool: "record_prelim_variants", what: "the variant manifest" }],
99
+ [VARIANT_MODEL_FILE, { tool: "record_clearance_variants", what: "the variant manifest" }],
100
+ [VARIANT_PROSE_FILE, { tool: "record_clearance_variants", what: "the variant manifest" }],
101
101
  // Conversion 4 — ONE basename, and the first row whose artifact a CLIENT reads. report-overview.md is
102
102
  // the delivered report's front-matter and its Actions section; assembleReportMd splices the code-built
103
103
  // sections into it and publishes the result. So a repair that arrives naming this file and gets handed
@@ -261,6 +261,7 @@ import { unionDispositionForm, formSidecarPath } from "./disposition-union.mjs";
261
261
  import { unionCoverageForm } from "./coverage-union.mjs";
262
262
  import { coverageFormStamp, readCoverageForm, readCoverageFormInput, writeCoverageForm } from "./coverage-form-io.mjs";
263
263
  import { unionPlacementForm } from "./placement-union.mjs";
264
+ import { placementRenderAccount } from "./placement-form.mjs";
264
265
  import { placementFormStamp, readPlacementForm, readPlacementFormInput, readSubmittedPlacementForm, writePlacementForm, renderPlacementsFile } from "./placement-form-io.mjs";
265
266
  // The register-axis vocabulary, quoted verbatim into the coverage-form axis hint. ONE source: the same
266
267
  // constant `rowIsSettled` refuses against, so the hint can never name a set the gate does not accept.
@@ -741,7 +742,12 @@ export function syncPlacementForm(files) {
741
742
  // never put an unparseable deliverable on disk, and on a throw the previous file is left alone.
742
743
  const r = renderPlacementsFile(runDir, u.form.rows);
743
744
  if (!r.ok) note(`[placement-form] placements.json NOT re-rendered: ${r.error} — the previous file stands and the omission is on the form`);
744
- return { ...u, rendered: r.ok ? r.placements : null, render_error: r.error };
745
+ // WHO OWES EACH ROW THE RENDER LEFT OUT — the same account validators.placement judges with, carried onto
746
+ // the attempt row so an omission is never read without its cause.
747
+ const a = placementRenderAccount(u.form.rows);
748
+ const account = { unjudged: a.unjudged.length, registerSelected: a.register_selected,
749
+ registerRendered: a.register_rendered, registerFacts: a.register_facts };
750
+ return { ...u, rendered: r.ok ? r.placements : null, render_error: r.error, account };
745
751
  }
746
752
  return null;
747
753
  }
@@ -1699,7 +1705,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1699
1705
  // destroyed. `unresolved` counts selections the fold does not hold. RECORDING ONLY.
1700
1706
  placements: lastPlacementUnion ? { settled: lastPlacementUnion.settled, outstanding: lastPlacementUnion.outstanding,
1701
1707
  carried: lastPlacementUnion.carried, total: lastPlacementUnion.total, seatRows: lastPlacementUnion.seat_rows,
1702
- unresolved: lastPlacementUnion.unresolved, rendered: lastPlacementUnion.rendered } : undefined,
1708
+ unresolved: lastPlacementUnion.unresolved, rendered: lastPlacementUnion.rendered, account: lastPlacementUnion.account } : undefined,
1703
1709
  //: how many in-dispatch form repairs THIS attempt bought (absent = none). Its own rows
1704
1710
  // sit immediately above with the defect each one was dispatched to fix.
1705
1711
  formRepairs: formRepairsThisAttempt || undefined,
@@ -2088,7 +2094,7 @@ function refusalsInWindow(files, runDir, from, to) {
2088
2094
  }
2089
2095
 
2090
2096
  function rel(p) {
2091
- const i = p.indexOf("/prelim-search/");
2097
+ const i = Math.max(p.indexOf("/clearance-search/"), p.indexOf("/prelim-search/")); // either spelling of the studio segment
2092
2098
  return i >= 0 ? p.slice(i + 1) : p;
2093
2099
  }
2094
2100
 
@@ -183,10 +183,10 @@ export function decideJxLanes({ job, profile, searchPolicy } = {}) {
183
183
  *
184
184
  * ── THE GATE THAT WAS DECIDING, AND DECIDING OFF ────────────────────────────────────────────────────
185
185
  *
186
- * This function opened with `resolvedPolicy.level !== "prelim"`. `resolveSearchPolicy` now returns
186
+ * This function opened with `resolvedPolicy.level !== "clearance"`. `resolveSearchPolicy` now returns
187
187
  * `level` = THE PRODUCT ID for all four searches, so that leg was false on every live run and the whole
188
188
  * recommendation was dead — silently, with both callers (pipeline.mjs and runner.mjs) live and 3,754
189
- * driver tests green, because the one test that covered it hand-fed `{ level: "prelim" }`, a value
189
+ * driver tests green, because the one test that covered it hand-fed `{ level: "clearance" }`, a value
190
190
  * nothing produces any more. A retired slug does not stop deciding when it is retired; it starts
191
191
  * deciding one way.
192
192
  *
@@ -144,7 +144,7 @@ function ledgerRow(runDir, row) {
144
144
  try { appendFileSync(driverDir(runDir, "jx-completions.jsonl"), JSON.stringify(row) + "\n"); } catch { /* receipts best-effort */ }
145
145
  }
146
146
 
147
- const runPrefix = (run) => `prelim-${run?.slug ?? "run"}-${run?.codename ?? "local"}-`;
147
+ const runPrefix = (run) => `clearance-${run?.slug ?? "run"}-${run?.codename ?? "local"}-`;
148
148
 
149
149
  // ── Executor chains (the resolveJxExecutor idiom: injected → CLEAROTRON_JX_FIXTURES → live) ─────────────
150
150
  export function resolveSerpExecutor(opts, { mark, lane }) {
package/driver/jx.mjs CHANGED
@@ -186,6 +186,13 @@ export function deriveJxSliceStatement({ sidecar, units = null, env = process.en
186
186
  //
187
187
  // PURE. Takes the parsed sidecar and the slice statement `deriveJxSliceStatement` just produced.
188
188
  export function deriveLaneDepthVerdicts({ sidecar, slices } = {}) {
189
+ // EITHER SHAPE, AND THAT IS NOT POLITENESS. This reads `slices.candidates`, and the publish path handed
190
+ // it `deriveJxSliceStatement`'s whole return — `{executes, slices}` — so the lookup found nothing and
191
+ // every lane reported `ran: null` whatever it had done. Nothing threw and nothing was logged: the
192
+ // failure arrived as a delivered client report saying a search had not run when it had. The two shapes
193
+ // are told apart with certainty rather than guessed at — a statement carries a `slices` object and a
194
+ // slice map carries slice names — so a caller cannot pass the wrong one and be quietly misread.
195
+ const bySlice = slices?.slices && typeof slices.slices === "object" ? slices.slices : slices;
189
196
  const declared = sidecar?.lanes ?? {};
190
197
  const out = {};
191
198
  for (const lane of Object.keys(declared)) {
@@ -194,10 +201,10 @@ export function deriveLaneDepthVerdicts({ sidecar, slices } = {}) {
194
201
  // lane that gains a slice later is covered without this function being edited — and so a lane that
195
202
  // has none is a measured fact rather than a hardcoded assumption about ja/ko.
196
203
  const deep = JX_SLICES.filter((s) => s.slice > 1 && s.lane === lane);
197
- const deepStates = deep.map((s) => ({ name: s.name, state: slices?.[s.name]?.state ?? null, why: slices?.[s.name]?.why ?? null }));
204
+ const deepStates = deep.map((s) => ({ name: s.name, state: bySlice?.[s.name]?.state ?? null, why: bySlice?.[s.name]?.why ?? null }));
198
205
  const deepRan = deepStates.filter((d) => d.state === "ran").map((d) => d.name);
199
206
  // Slice 1 is the multi-lane one: its per-lane state lives in `.lanes`, never in a scalar `.state`.
200
- const candidatesState = slices?.candidates?.lanes?.[lane] ?? null;
207
+ const candidatesState = bySlice?.candidates?.lanes?.[lane] ?? null;
201
208
 
202
209
  const ran = deepRan.length ? "full" : candidatesState === "ran" ? "candidates" : null;
203
210
  const shortfall = asked === "full" && ran !== "full";
@@ -225,6 +232,27 @@ export function deriveLaneDepthVerdicts({ sidecar, slices } = {}) {
225
232
  return out;
226
233
  }
227
234
 
235
+ /**
236
+ * THE VERDICT A PUBLISHED REPORT SHOULD CARRY: the one the RUN stated, not a fresh one. PURE.
237
+ *
238
+ * `stateJxFold` writes `fold.depth` at delivery, derived from this run's own environment — which is the
239
+ * reason the seam exists: the arms are environment, so a verdict derived on some later box speaks for
240
+ * that box and not for the run. Publishing derived its own, and the two disagreed on a delivered client
241
+ * report: the run said the ja lane ran as a candidate lane, the publisher said no lane ran, and the page
242
+ * printed "Not run this run" over a search that had happened.
243
+ *
244
+ * Deriving stays, for a run delivered before the stamp existed — an old artifact still gets the best
245
+ * answer its record supports, rather than none.
246
+ *
247
+ * @param {object|null} sidecar parsed _driver/jx-lanes.json
248
+ * @param {object|null} units parsed _driver/jx/units.json, or null
249
+ */
250
+ export function laneDepthOfRun({ sidecar, units = null, env = process.env } = {}) {
251
+ const stamped = sidecar?.fold?.depth;
252
+ if (stamped && typeof stamped === "object" && !Array.isArray(stamped)) return stamped;
253
+ return deriveLaneDepthVerdicts({ sidecar, slices: deriveJxSliceStatement({ sidecar, units, env }) });
254
+ }
255
+
228
256
  const flag = (name) => ["1", "true", "yes", "on"].includes(String(process.env[name] ?? "").trim().toLowerCase());
229
257
  // — the private copy is gone; laneArmed (driver.config.mjs) is the one reader.
230
258
 
@@ -43,6 +43,9 @@ import { captureCall, stampVerdict } from "./call-capture.mjs";
43
43
  import { refuseUndeclared as refuseUndeclaredShared, lastAccepted, acceptedEnvelope } from "./preserve-merge.mjs";
44
44
  import { knockoutVisibleProse } from "./predelivery-lint.mjs"; // the walk is the authority on what a reader sees
45
45
  import { plainRegisterFlags } from "./plain-register.mjs"; // the pinned rule, one copy
46
+ // THE HINT'S fold, and it is imported rather than rebuilt — see nearestAddress. It is a comparison key
47
+ // for saying WHICH line was probably meant; it never decides whether an address resolves.
48
+ import { queryKey } from "./connotation-search.mjs";
46
49
 
47
50
  const SCHEMA_VERSION = 1;
48
51
 
@@ -142,6 +145,42 @@ export function addressKey(at) {
142
145
  return [a.field, a.mark ?? "", a.index ?? "", a.ordinal ?? ""].join(ADDRESS_SEP);
143
146
  }
144
147
 
148
+ /**
149
+ * THE ADDRESS A SEAT PROBABLY MEANT, FOR THE REFUSAL TO NAME — and for nothing else.
150
+ *
151
+ * The lookup above stays EXACT and that is a ruling, not an oversight (owner, 2026-09-16). Punctuation
152
+ * can be part of a mark's identity, so two marks differing by an apostrophe form are not assumed to be
153
+ * one mark and a rewrite is never applied by analogy. What was wrong was the refusal, not the matching:
154
+ * a seat that re-typed a name with a right single quotation mark where the record has an apostrophe was
155
+ * told its rewrite "names no line on this record", which describes the record rather than the difference,
156
+ * and the rewrite was dropped with nobody able to see why. One character, invisible in most fonts.
157
+ *
158
+ * So this names the nearest and changes nothing. It folds ONLY typographic form — `queryKey`, the same
159
+ * key the meaning gate was folded onto after a production clearance stopped on exactly this character —
160
+ * and it is imported rather than copied, because two private folds are how the two gates drift apart.
161
+ * That key deliberately does not fold accents, so it cannot quietly propose that Café and Cafe are the
162
+ * same mark.
163
+ *
164
+ * It requires an EXACT match on every other part of the address — field, index, ordinal — so the hint is
165
+ * always the same line of the same record, differing only in how the mark's name was typed. A candidate
166
+ * whose mark is byte-identical is not a hint: the caller only asks when the exact lookup already failed,
167
+ * and pointing at the address that was just refused would say nothing.
168
+ *
169
+ * @returns {object|null} the address on this record, or null when nothing differs by form alone
170
+ */
171
+ export function nearestAddress(at, knownAts) {
172
+ const want = at ?? {};
173
+ if (want.mark == null) return null; // no name to have mistyped
174
+ const same = (a, b) => (a ?? "") === (b ?? "");
175
+ for (const k of knownAts ?? []) {
176
+ if (!k || k.field !== want.field) continue;
177
+ if (!same(k.index, want.index) || !same(k.ordinal, want.ordinal)) continue;
178
+ const kn = String(k.mark ?? ""), wn = String(want.mark ?? "");
179
+ if (kn !== wn && queryKey(kn) === queryKey(wn)) return k;
180
+ }
181
+ return null;
182
+ }
183
+
145
184
  /** Is this a well-formed address for a field the pass may reach? The reason, or null. */
146
185
  export function addressFault(at) {
147
186
  if (!at || typeof at !== "object" || Array.isArray(at)) return "an address must be an object";
@@ -296,15 +335,21 @@ export function validateKnockoutReviewFile(file, text) {
296
335
  try { merged = JSON.parse(readFileSync(findingsFile, "utf8")); }
297
336
  catch (e) { return { ok: false, reason: `the merged record will not parse, so no address could be checked: ${e.message}` }; }
298
337
 
299
- const known = new Set(knockoutVisibleProse(merged).map((v) => addressKey(v.at)));
338
+ const knownAts = knockoutVisibleProse(merged).map((v) => v.at);
339
+ const known = new Set(knownAts.map((a) => addressKey(a)));
300
340
  const owned = new Set(knockoutVisibleProse(merged).filter((v) => v.at?.engineOwned).map((v) => addressKey(v.at)));
341
+ // Appended to a refusal, never consulted before one: the address still has to match exactly.
342
+ const hint = (at) => {
343
+ const n = nearestAddress(at, knownAts);
344
+ return n ? ` — this record carries "${n.mark}" at that line, which differs from "${at.mark}" only in how it is typed; the two are not assumed to be the same mark, so re-send the rewrite naming the record's spelling` : "";
345
+ };
301
346
  for (const r of doc.rewrites ?? []) {
302
347
  const key = addressKey(r.at);
303
348
  if (owned.has(key)) return { ok: false, reason: `the rewrite for ${r.at.field} names a caveat the engine wrote, not a seat — it is not offered and cannot be changed` };
304
- if (!known.has(key)) return { ok: false, reason: `the rewrite for ${r.at.field}${r.at.mark ? ` on "${r.at.mark}"` : ""} names no line on this record` };
349
+ if (!known.has(key)) return { ok: false, reason: `the rewrite for ${r.at.field}${r.at.mark ? ` on "${r.at.mark}"` : ""} names no line on this record${hint(r.at)}` };
305
350
  }
306
351
  for (const d of doc.declined ?? []) {
307
- if (!known.has(addressKey(d.at))) return { ok: false, reason: `the declined row for ${d.at.field}${d.at.mark ? ` on "${d.at.mark}"` : ""} names no line on this record` };
352
+ if (!known.has(addressKey(d.at))) return { ok: false, reason: `the declined row for ${d.at.field}${d.at.mark ? ` on "${d.at.mark}"` : ""} names no line on this record${hint(d.at)}` };
308
353
  }
309
354
  return { ok: true };
310
355
  }
@@ -340,7 +385,14 @@ export function applyKnockoutReview(merged, review) {
340
385
  for (const r of review?.rewrites ?? []) {
341
386
  const key = addressKey(r.at);
342
387
  const seen = byKey.get(key);
343
- if (!seen) { unresolved.push({ at: r.at, why: "no such line on this record" }); continue; }
388
+ if (!seen) {
389
+ // Same hint as the validator's, for the same reason: the apply path is reached on a record this
390
+ // pass did not validate, and an unresolved row that cannot say WHY is the state that cost a
391
+ // rewrite silently. It still changes nothing — `unresolved` is a report.
392
+ const near = nearestAddress(r.at, [...byKey.values()].map((v) => v.at));
393
+ unresolved.push({ at: r.at, why: near ? `no such line on this record — it carries "${near.mark}" there, differing from "${r.at.mark}" only in how it is typed` : "no such line on this record" });
394
+ continue;
395
+ }
344
396
  if (seen.at?.engineOwned) { refused.push({ at: r.at, why: "the engine wrote this caveat, not a seat" }); continue; }
345
397
  const at = r.at, text = String(r.text);
346
398
  // THE WALK IS THE BOUNDS CHECK. `seen` came from this same record, so a resolved address names a
@@ -8,7 +8,7 @@
8
8
  // (copper-causeway could not see teal-conduit's VENERET: different noref slugs for the same VENZY).
9
9
  // The store therefore lives one level up, keyed by the MARK:
10
10
  //
11
- // <studioRoot>/_known-conflicts/<kebab(mark)>.json (studioRoot = workspace-<agent>/studio/prelim-search)
11
+ // <studioRoot>/_known-conflicts/<kebab(mark)>.json (studioRoot = workspace-<agent>/studio/clearance-search)
12
12
  //
13
13
  // One file per searched mark name; workspace-per-agent keeps customers separated. Each file keeps the
14
14
  // EXACT inner shape the tripwire already reads ({schema_version, marks:{"<mark key>":[rows]}}), so
@@ -29,23 +29,6 @@ import { abbrev } from "./repair-contract.mjs";
29
29
 
30
30
  export const BAND_STATES = ["enumerated", "incomplete"];
31
31
 
32
- // ── WHAT ONE QUERY MAY PUT INTO THE BAND ─────────────────────────────────────────────────────────
33
- //
34
- // THE DEFECT (production run, 2026-09-16). A 2,146-record band, of which 1,154 — 54% — were reachable
35
- // from two queries and nothing else. Both were machine-built forms of an ordinary short word, so they
36
- // matched every mark containing that word across four registers. Measured on the preserved band: the
37
- // three biggest queries returned 589, 583 and 271 records; the fourth returned 185. A ceiling at 200
38
- // therefore bites exactly those three and leaves the rest of that plan untouched, which is why it is
39
- // the number rather than a rounder one.
40
- //
41
- // AN OVER-CAP QUERY IS NOT TRUNCATED, IT IS RECLASSIFIED. The band already has a word for "this query
42
- // matched more than we carried": `incomplete`, which produces a crowd descriptor carrying the full
43
- // count. So the excess is DISCLOSED with its number rather than dropped — judgment reads the crowd and
44
- // can say the ground was too broad to enumerate, which is a true statement about the search. Silently
45
- // keeping the first two hundred would be the one outcome worse than the flood: a narrower band that
46
- // reads as complete.
47
- export const BAND_QUERY_CAP = 200;
48
-
49
32
  /**
50
33
  * Parse + lightly validate the named-band artifact. Returns { enumerated:[…records], crowds:[…descriptors] }.
51
34
  * Throws `named_band_*` tokens (token FIRST) so the stage validator + corrective-retry can key on the defect,
@@ -112,22 +95,7 @@ export function parseNamedBand(raw) {
112
95
  // at band-shape.mjs's `record_id` filter — the same loss, one step further from anything that
113
96
  // could name it.
114
97
  const recs = Array.isArray(b.records) ? b.records : [];
115
- const kept = [];
116
- for (const r of recs) { if (r && typeof r === "object" && !Array.isArray(r)) kept.push({ ...r, ...prov }); }
117
- if (kept.length > BAND_QUERY_CAP) {
118
- // The count the provider reported is the truth about the ground; `kept.length` is only what this
119
- // block carried. Prefer the reported total and fall back to what we hold, so the descriptor never
120
- // claims a smaller crowd than it can prove.
121
- const total = countOrNull(b.total_hits) ?? kept.length;
122
- for (const r of kept.slice(0, BAND_QUERY_CAP)) enumerated.push(r);
123
- crowds.push({
124
- query, total_hits: total, fetched: BAND_QUERY_CAP, sample: [],
125
- reason: `one query returned ${kept.length} record(s), over the ${BAND_QUERY_CAP}-record ceiling any single query may add to this band; the first ${BAND_QUERY_CAP} are carried and the rest are disclosed here as a crowd rather than enumerated`,
126
- ...(typeof b.qid === "string" && b.qid ? { qid: b.qid } : {}),
127
- });
128
- } else {
129
- for (const r of kept) enumerated.push(r);
130
- }
98
+ for (const r of recs) { if (r && typeof r === "object" && !Array.isArray(r)) enumerated.push({ ...r, ...prov }); }
131
99
  } else {
132
100
  // count-first rescue (2026-07-10, copper-lattice): a crowd descriptor may carry per-term truth —
133
101
  // `term_counts` (each term's tool-derived count + disposition) and the fully-enumerated tractable
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // ordinary-words.mjs — the impure edge of the one-letter neighbourhood rule: it reads the word list off disk.
4
+ //
5
+ // form-neighbourhood.mjs is PURE and takes the list as a Set; this is the one place that turns the file into
6
+ // that Set, so there is one reading of it and not one per caller. The file's own header states its source,
7
+ // its date and its licence; lines starting with `#` are that header and are skipped.
8
+ //
9
+ // A LIST THAT WILL NOT LOAD REMOVES NOTHING. The rule only ever takes queries away, so the safe failure is an
10
+ // empty Set — every neighbour searched, exactly as before the rule existed — and the caller is told, so the
11
+ // run record can say the rule did not apply rather than read as a mark with no ordinary-word neighbours.
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+
19
+ /** Where each language's list lives. English only; other markets add a file and a key. */
20
+ export const WORD_LIST_PATHS = Object.freeze({ en: join(HERE, "wordlists", "en.txt") });
21
+
22
+ /** Parse a list file's text into a Set of lowercase words. PURE. */
23
+ export function parseWordList(text) {
24
+ const out = new Set();
25
+ for (const line of String(text ?? "").split("\n")) {
26
+ const w = line.trim();
27
+ if (!w || w.startsWith("#")) continue;
28
+ out.add(w.toLowerCase());
29
+ }
30
+ return out;
31
+ }
32
+
33
+ const cache = new Map();
34
+
35
+ /**
36
+ * The ordinary-word Set for a language, read once per process.
37
+ * @returns {{ words: Set<string>, error: string|null }}
38
+ */
39
+ export function loadOrdinaryWords(lang = "en") {
40
+ if (cache.has(lang)) return cache.get(lang);
41
+ const p = WORD_LIST_PATHS[lang];
42
+ let r;
43
+ if (!p) r = { words: new Set(), error: `no word list for language "${lang}"` };
44
+ else {
45
+ try { r = { words: parseWordList(readFileSync(p, "utf8")), error: null }; }
46
+ catch (e) { r = { words: new Set(), error: `word list ${lang} unreadable: ${String(e?.message ?? e).slice(0, 120)}` }; }
47
+ if (!r.error && !r.words.size) r = { words: r.words, error: `word list ${lang} holds no words` };
48
+ }
49
+ cache.set(lang, r);
50
+ return r;
51
+ }
@@ -15,7 +15,7 @@
15
15
  // sendPending with no .sent, so a lost/given-up marker (or an overnight finish) is retried
16
16
  // on the prelim-outbox.timer cadence independent of heartbeat activeHours.
17
17
  //
18
- // TIGHT-LOOP INVARIANT (load-bearing): prelim-outbox.path is PathExistsGlob=…/prelim-outbox/*.pending —
18
+ // TIGHT-LOOP INVARIANT (load-bearing): prelim-outbox.path is PathExistsGlob=…/clearance-outbox/*.pending —
19
19
  // level-triggered, so a RETAINED marker re-triggers the service the moment it deactivates. The backoff
20
20
  // sidecars therefore live in <outbox>/backoff/ — a subdirectory the glob can never match (`*` does not
21
21
  // cross `/`, and "backoff" has no .pending suffix; inotify on the outbox dir doesn't recurse either) —
@@ -26,7 +26,7 @@
26
26
  // ("due" / "retry 300") — a broken helper must degrade to retried wakes, never to lost deliveries.
27
27
 
28
28
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
29
- import { join, basename } from "node:path";
29
+ import { join, basename } from "node:path"; import { studioDirFor } from "../shared/pre-rename-spellings.mjs";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { config } from "./driver.config.mjs";
32
32
  import { atomicWrite } from "./progress.mjs";
@@ -130,20 +130,35 @@ export function settleWake(agent, { code, stdout }, now = Date.now()) {
130
130
  // Which *.pending markers currently route to <agent>? Mirrors deliver-trigger.sh's grouping exactly: a
131
131
  // JSON packet carries an "agent" field; a legacy delivered marker's first-line body IS the agent id.
132
132
  export function markersForAgent(agent) {
133
- let names = [];
134
- try { names = readdirSync(config.outboxDir).filter((f) => f.endsWith(".pending")); } catch { return []; }
133
+ // BOTH DIRECTORIES, AND THE OLD ONE IS NOT OPTIONAL. The default outbox was renamed with the `clearance`
134
+ // identifier; a box that never pinned `CLEAROTRON_OUTBOX_DIR` still has markers under the old name.
135
+ // Reading only the new directory would leave them there for ever, and each one is a report a client
136
+ // is owed — with nothing to see, because an outbox nobody reads looks exactly like an empty one.
137
+ // Each marker keeps the path it was found at, so an ack removes the file that actually exists.
135
138
  const out = [];
136
- for (const file of names.sort()) {
137
- let raw;
138
- try { raw = readFileSync(join(config.outboxDir, file), "utf8"); } catch { continue; } // raced an ack — skip
139
- let who = null, kind = "delivered";
140
- if (raw.trimStart().startsWith("{")) {
141
- try { const j = JSON.parse(raw); who = j.agent != null ? String(j.agent) : null; kind = j.kind != null ? String(j.kind) : "delivered"; }
142
- catch { who = null; }
143
- } else {
144
- who = raw.split("\n")[0].trim();
139
+ // THE SECOND DIRECTORY IS SKIPPED WHEN IT IS THE FIRST ONE. The two accessors answered the same path
140
+ // once, and this loop then listed every marker twice — the same file handed out as two pieces of work,
141
+ // with the strike count that quarantines a stuck marker counting each wake twice. A guard rather than a
142
+ // dedupe at the end: two directories that are the same directory is a configuration fact worth costing
143
+ // nothing, and a dedupe would also hide a genuine duplicate filename across two real directories.
144
+ const seen = new Set();
145
+ for (const dir of [config.outboxDir, config.legacyOutboxDir]) {
146
+ if (!dir || seen.has(dir)) continue;
147
+ seen.add(dir);
148
+ let names = [];
149
+ try { names = readdirSync(dir).filter((f) => f.endsWith(".pending")); } catch { continue; }
150
+ for (const file of names.sort()) {
151
+ let raw;
152
+ try { raw = readFileSync(join(dir, file), "utf8"); } catch { continue; } // raced an ack — skip
153
+ let who = null, kind = "delivered";
154
+ if (raw.trimStart().startsWith("{")) {
155
+ try { const j = JSON.parse(raw); who = j.agent != null ? String(j.agent) : null; kind = j.kind != null ? String(j.kind) : "delivered"; }
156
+ catch { who = null; }
157
+ } else {
158
+ who = raw.split("\n")[0].trim();
159
+ }
160
+ if (who === agent) out.push({ file, path: join(dir, file), kind });
145
161
  }
146
- if (who === agent) out.push({ file, path: join(config.outboxDir, file), kind });
147
162
  }
148
163
  return out;
149
164
  }
@@ -188,7 +203,7 @@ function* eachRunDir() {
188
203
  let workspaces = [];
189
204
  try { workspaces = readdirSync(config.workspaceRoot).filter((n) => n.startsWith("workspace-")); } catch { return; }
190
205
  for (const ws of workspaces) {
191
- const studio = join(config.workspaceRoot, ws, "studio", "prelim-search");
206
+ const studio = studioDirFor(join(config.workspaceRoot, ws));
192
207
  let slugs = [];
193
208
  try { slugs = readdirSync(studio); } catch { continue; }
194
209
  for (const slug of slugs) {
@@ -311,7 +326,7 @@ export function rescanOwedRuns() {
311
326
  let workspaces = [];
312
327
  try { workspaces = readdirSync(config.workspaceRoot).filter((n) => n.startsWith("workspace-")); } catch { return dropped; }
313
328
  for (const ws of workspaces) {
314
- const studio = join(config.workspaceRoot, ws, "studio", "prelim-search");
329
+ const studio = studioDirFor(join(config.workspaceRoot, ws));
315
330
  let slugs = [];
316
331
  try { slugs = readdirSync(studio); } catch { continue; }
317
332
  for (const slug of slugs) {
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.8",
5
+ "version": "0.3.2-beta.9",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -59,7 +59,7 @@
59
59
  "why": "a caption-only call renders 149B, clears the 120-char floor, and ships a SECTION OF THE CLIENT'S ONE REPORT without the 'Checks we ran' bullets, the methodology or the handling note. Traced: report-overview.md is the head of report.md (assembleReportMd() in pipeline.mjs) which renders to the HTML (publish/index.mjs:649 publishReport). The 'Only you can close these' register is NOT lost — it is code-built from findings.json under spec 64's 'code wins' ruling."
60
60
  },
61
61
  {
62
- "tool": "record_prelim_variants",
62
+ "tool": "record_clearance_variants",
63
63
  "fields": [
64
64
  "incumbent_classes",
65
65
  "watchlist_owners",
@@ -84,7 +84,7 @@
84
84
  "why": "the schema the seat is handed declares `fields` REQUIRED and acceptBlindFrame takes a call without it — the goods/on-field half of the cold threat model"
85
85
  },
86
86
  {
87
- "tool": "record_prelim_variants",
87
+ "tool": "record_clearance_variants",
88
88
  "fields": [
89
89
  "elements",
90
90
  "scope_ledger"