clearotron 0.4.0-beta.0 → 0.4.0-beta.2

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 (293) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +4 -31
  3. package/README.md +5 -5
  4. package/bin/brandowner.mjs +1 -1
  5. package/bin/onboard.mjs +18 -74
  6. package/bin/start.mjs +58 -14
  7. package/bin/update.mjs +10 -2
  8. package/build-info.json +2 -2
  9. package/demo/full-country-search/run/_driver/search-policy.json +1 -1
  10. package/demo/full-country-search/run/status.json +1 -1
  11. package/demo/global-preliminary-search/run/_driver/search-policy.json +1 -1
  12. package/demo/global-preliminary-search/run/status.json +1 -1
  13. package/demo/knockout-search/run/_driver/search-policy.json +1 -1
  14. package/demo/knockout-search/run/status.json +1 -1
  15. package/demo/multi-country-focus-search/run/_driver/search-policy.json +1 -1
  16. package/demo/multi-country-focus-search/run/status.json +1 -1
  17. package/docs/README.md +1 -0
  18. package/docs/SECURITY-OWASP.md +298 -0
  19. package/docs/SECURITY.md +18 -13
  20. package/docs/architecture/02-architecture.md +5 -5
  21. package/docs/architecture/03-run-lifecycle.md +28 -29
  22. package/docs/architecture/04-configuration-reference.md +5 -10
  23. package/docs/architecture/05-config-governance.md +5 -7
  24. package/docs/architecture/06-operations-runbook.md +0 -12
  25. package/docs/architecture/07-quality-and-audit.md +12 -13
  26. package/docs/architecture/09-security-and-data.md +9 -4
  27. package/driver/CHANGELOG.md +70 -0
  28. package/driver/ask-ledger.mjs +4 -2
  29. package/driver/authority-trees.mjs +90 -8
  30. package/driver/band-shape.mjs +13 -13
  31. package/driver/blind-frame-model.mjs +1 -1
  32. package/driver/case-law-ledger.mjs +19 -3
  33. package/driver/claim-liveness.mjs +30 -1
  34. package/driver/clearance-variants-record.mjs +10 -3
  35. package/driver/common-law-receipts.mjs +64 -14
  36. package/driver/commonlaw-carry.mjs +2 -2
  37. package/driver/compare.mjs +2 -2
  38. package/driver/connotation-search.mjs +33 -104
  39. package/driver/contract-arm2-baseline.json +6 -5
  40. package/driver/contract-e3-backlog.mjs +62 -62
  41. package/driver/contract-vocabulary.mjs +38 -31
  42. package/driver/coverage-form-io.mjs +13 -1
  43. package/driver/coverage-form.mjs +42 -4
  44. package/driver/coverage-ledger.mjs +3 -5
  45. package/driver/cross-check-wait.mjs +43 -0
  46. package/driver/declination-call.mjs +34 -10
  47. package/driver/declination-tool.mjs +27 -15
  48. package/driver/deferral-row.mjs +139 -0
  49. package/driver/degraded-parts.mjs +377 -0
  50. package/driver/deliver-trigger.sh +1 -1
  51. package/driver/demo-container.mjs +4 -1
  52. package/driver/dev-portal.mjs +5 -5
  53. package/driver/dispatch-record.mjs +2 -2
  54. package/driver/driver.config.mjs +193 -96
  55. package/driver/e2e/README.md +1 -1
  56. package/driver/effective-scope.mjs +58 -2
  57. package/driver/engine/anthropic-agent.mjs +103 -27
  58. package/driver/engine/auth.mjs +2 -2
  59. package/driver/engine/child-record.mjs +11 -0
  60. package/driver/engine/cli-version.mjs +4 -3
  61. package/driver/engine/common.mjs +17 -2
  62. package/driver/engine/deny-authority-write.mjs +3 -1
  63. package/driver/engine/engine-env.mjs +174 -0
  64. package/driver/engine/engine-spawn.mjs +179 -0
  65. package/driver/engine/mcp/band-server.mjs +1 -1
  66. package/driver/engine/mcp/clarivate-server.mjs +2 -1
  67. package/driver/engine/mcp/codex-config.mjs +112 -3
  68. package/driver/engine/mcp/corsearch-server.mjs +1 -0
  69. package/driver/engine/mcp/declination-server.mjs +9 -8
  70. package/driver/engine/mcp/dispositions-server.mjs +2 -1
  71. package/driver/engine/mcp/euipo-server.mjs +1 -0
  72. package/driver/engine/mcp/fetch-server.mjs +14 -11
  73. package/driver/engine/mcp/free-tier-server.mjs +1 -1
  74. package/driver/engine/mcp/gather-config.mjs +23 -11
  75. package/driver/engine/mcp/perplexity-server.mjs +97 -17
  76. package/driver/engine/mcp/probe-server.mjs +43 -21
  77. package/driver/engine/mcp/public-fetch.mjs +195 -0
  78. package/driver/engine/mcp/recording-server.mjs +79 -14
  79. package/driver/engine/mcp/signa-server.mjs +1 -0
  80. package/driver/engine/mcp/supplemental.mjs +14 -14
  81. package/driver/engine/mcp/unit-note-server.mjs +50 -1
  82. package/driver/engine/mcp/uspto-local-server.mjs +1 -0
  83. package/driver/engine/openai-agent.mjs +109 -28
  84. package/driver/engine/probe.mjs +152 -19
  85. package/driver/enqueue-schema.mjs +5 -5
  86. package/driver/feedback-issues.mjs +1 -1
  87. package/driver/feedback-store.mjs +1 -1
  88. package/driver/findings-model.mjs +67 -16
  89. package/driver/flag-snapshot.mjs +1 -1
  90. package/driver/floor-duty.mjs +23 -7
  91. package/driver/form-neighbourhood.mjs +52 -17
  92. package/driver/frame-diff-model.mjs +3 -3
  93. package/driver/framework-method.mjs +304 -0
  94. package/driver/gateway.mjs +49 -21
  95. package/driver/hand-off-exits.mjs +129 -0
  96. package/driver/jx-lanes.mjs +1 -1
  97. package/driver/jx.mjs +8 -5
  98. package/driver/knockout-assess-record.mjs +7 -1
  99. package/driver/knockout-frame-record.mjs +96 -34
  100. package/driver/knockout-review-record.mjs +7 -2
  101. package/driver/log.mjs +2 -2
  102. package/driver/matter-frame-record.mjs +31 -2
  103. package/driver/methodology-witness.mjs +6 -3
  104. package/driver/named-band.mjs +1 -1
  105. package/driver/owner-use-check.mjs +45 -10
  106. package/driver/package.json +1 -1
  107. package/driver/partial-payload-baseline.json +4 -0
  108. package/driver/phase0.mjs +2 -2
  109. package/driver/pipeline-knockout.mjs +256 -129
  110. package/driver/pipeline.mjs +417 -614
  111. package/driver/placement-carry.mjs +22 -3
  112. package/driver/placement-form-io.mjs +18 -4
  113. package/driver/placement-form.mjs +53 -4
  114. package/driver/placement-union.mjs +32 -5
  115. package/driver/portal-report.mjs +3 -3
  116. package/driver/portal-service.mjs +6 -5
  117. package/driver/portal-static.mjs +11 -18
  118. package/driver/predelivery-lint.mjs +10 -10
  119. package/driver/profile-page.html +5 -5
  120. package/driver/profiles.mjs +5 -5
  121. package/driver/progress.mjs +6 -4
  122. package/driver/provider-usage.mjs +25 -2
  123. package/driver/publish/index.mjs +97 -25
  124. package/driver/publish/knockout.mjs +136 -14
  125. package/driver/publish/profiles-page.mjs +2 -0
  126. package/driver/publish/publish-inputs.mjs +10 -4
  127. package/driver/publish/render-knockout.mjs +48 -21
  128. package/driver/publish/render.mjs +13 -8
  129. package/driver/publish/report-data.mjs +2 -0
  130. package/driver/publish/search-depth.mjs +77 -4
  131. package/driver/publish/templates/report.css +1 -1
  132. package/driver/publish/xlsx.mjs +34 -15
  133. package/driver/reasoning-tripwires.mjs +2 -160
  134. package/driver/recall-receipt.mjs +27 -0
  135. package/driver/recall-reconciliation.mjs +1 -1
  136. package/driver/record-carry.mjs +14 -14
  137. package/driver/recording-agreement.mjs +2 -2
  138. package/driver/reference-score.mjs +169 -47
  139. package/driver/register-count.mjs +61 -4
  140. package/driver/register-digest-record.mjs +14 -1
  141. package/driver/register-plan.mjs +161 -80
  142. package/driver/register-records.mjs +37 -4
  143. package/driver/registration-scripts.mjs +22 -0
  144. package/driver/registry-fidelity.mjs +4 -4
  145. package/driver/repair-composers.mjs +23 -7
  146. package/driver/repair-contract.mjs +1 -1
  147. package/driver/repairs.mjs +5 -2
  148. package/driver/reviewer-open-points.mjs +33 -0
  149. package/driver/rule-shape.mjs +8 -8
  150. package/driver/run-economics.mjs +3 -3
  151. package/driver/run-integrity.mjs +2 -2
  152. package/driver/runner.mjs +17 -5
  153. package/driver/scope-facts.mjs +89 -12
  154. package/driver/score-redaction.mjs +247 -0
  155. package/driver/screen-gate.mjs +1 -1
  156. package/driver/search-policy.mjs +17 -0
  157. package/driver/skills/README.md +2 -2
  158. package/driver/skills/blind-frame/SKILL.md +2 -2
  159. package/driver/skills/clearance-common-law/SKILL.md +29 -36
  160. package/driver/skills/clearance-common-law/perplexity-prompts.md +8 -30
  161. package/driver/skills/clearance-register/SKILL.md +15 -16
  162. package/driver/skills/clearance-register/digest.md +15 -31
  163. package/driver/skills/clearance-register/register-recipes.md +14 -56
  164. package/driver/skills/clearance-register/unit.md +37 -48
  165. package/driver/skills/clearance-search/SKILL.md +6 -8
  166. package/driver/skills/clearance-search/delivery-contract.md +0 -7
  167. package/driver/skills/clearance-search/firm-wide-reasoning.md +5 -4
  168. package/driver/skills/clearance-search/phase2-execution.md +5 -6
  169. package/driver/skills/clearance-search/report-prose.md +5 -5
  170. package/driver/skills/clearance-search/risk-framework-triage.md +2 -2
  171. package/driver/skills/clearance-search/synthesis-rules.md +9 -9
  172. package/driver/skills/clearance-search/template-formatting.md +2 -2
  173. package/driver/skills/clearance-variants/SKILL.md +8 -8
  174. package/driver/skills/clearance-variants/transliteration-scripts.md +2 -2
  175. package/driver/skills/frame-diff/SKILL.md +2 -2
  176. package/driver/skills/knockout-assess/SKILL.md +40 -10
  177. package/driver/skills/knockout-frame/SKILL.md +42 -10
  178. package/driver/skills/matter-frame/SKILL.md +12 -5
  179. package/driver/skills/matter-frame/watchlist-reference.md +1 -1
  180. package/driver/skills/narrative-refutation/SKILL.md +5 -3
  181. package/driver/skills/placement-inquiry/SKILL.md +5 -1
  182. package/driver/stage-context.mjs +27 -6
  183. package/driver/stages-knockout.mjs +92 -79
  184. package/driver/stages.mjs +111 -68
  185. package/driver/stray-artifacts.mjs +9 -5
  186. package/driver/suite-census.json +696 -150
  187. package/driver/synthesis-record.mjs +26 -4
  188. package/driver/systemd/README.md +5 -6
  189. package/driver/systemd/clearotron-client-mcp.service +24 -0
  190. package/driver/systemd/clearotron-mcp-face.service +24 -0
  191. package/driver/systemd/clearotron-portal.service +24 -0
  192. package/driver/systemd/clearotron-worker.service +29 -0
  193. package/driver/tokens.mjs +10 -9
  194. package/driver/turnaround-bands.mjs +1 -1
  195. package/driver/unit-inventory.mjs +24 -43
  196. package/driver/variant-manifest-model.mjs +22 -3
  197. package/driver/verify-knockout.mjs +163 -4
  198. package/driver/verify.mjs +42 -16
  199. package/driver/web-grid.mjs +150 -0
  200. package/driver/whatif-memo-run.mjs +1 -1
  201. package/driver/withheld-families.mjs +113 -4
  202. package/driver/worker-heartbeat.mjs +13 -2
  203. package/mcp-server/CHANGELOG.md +8 -0
  204. package/mcp-server/README.md +1 -1
  205. package/mcp-server/lib/knockout.mjs +1 -1
  206. package/mcp-server/lib/ops.mjs +10 -10
  207. package/mcp-server/lib/scrub.mjs +1 -1
  208. package/mcp-server/lib/trace.mjs +3 -2
  209. package/mcp-server/package.json +1 -1
  210. package/mcp-server/server.mjs +3 -3
  211. package/package.json +1 -1
  212. package/portal-ui/dist/assets/{index-DVtz44vH.js → index-BPAUjcI0.js} +3 -2
  213. package/portal-ui/dist/assets/{index-5CCwiJG7.css → index-D2wrw9cH.css} +18 -2
  214. package/portal-ui/dist/assets/plus-jakarta-sans-BUCHxqJ-.woff2 +0 -0
  215. package/portal-ui/dist/index.html +2 -15
  216. package/portal-ui/package.json +1 -1
  217. package/providers/_shared/README.md +1 -1
  218. package/providers/_shared/answer-memory.mjs +199 -0
  219. package/providers/_shared/enumerate.mjs +155 -50
  220. package/providers/_shared/execute-plan.mjs +62 -16
  221. package/providers/_shared/ledger-path.mjs +1 -1
  222. package/providers/_shared/ledger.mjs +48 -6
  223. package/providers/_shared/plan-guards.mjs +7 -0
  224. package/providers/_shared/script-form.mjs +24 -5
  225. package/providers/_shared/term-shape.mjs +5 -5
  226. package/providers/clarivate/src/capabilities.js +11 -0
  227. package/providers/clarivate/src/core.js +193 -18
  228. package/providers/corsearch/README.md +1 -2
  229. package/providers/corsearch/src/core.js +2 -2
  230. package/providers/jx-subclass/lookup.mjs +1 -1
  231. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  232. package/providers/oauth-mcp-bridge/README.md +8 -7
  233. package/providers/oauth-mcp-bridge/bridge.mjs +21 -5
  234. package/providers/oauth-mcp-bridge/package.json +1 -1
  235. package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
  236. package/providers/oauth-mcp-bridge/warm-server.mjs +16 -5
  237. package/providers/perplexity/src/core.js +246 -21
  238. package/providers/signa/src/capabilities.js +24 -0
  239. package/providers/signa/src/core.js +93 -25
  240. package/scripts/README.md +2 -1
  241. package/scripts/added-reference-check.mjs +2 -1
  242. package/scripts/backup-recall-stores.mjs +5 -5
  243. package/scripts/demo-evidence.mjs +2 -1
  244. package/scripts/deprecate-below.mjs +114 -2
  245. package/scripts/drive-env-check.mjs +2 -1
  246. package/scripts/e2e-first-time.mjs +469 -0
  247. package/scripts/e2e-scenario-ops.mjs +441 -0
  248. package/scripts/e2e.mjs +104 -16
  249. package/scripts/env-audit.mjs +8 -0
  250. package/scripts/freeze-example-run.mjs +7 -7
  251. package/scripts/generated-files-are-current.mjs +2 -1
  252. package/scripts/hand-off-exits-probe.mjs +75 -0
  253. package/scripts/import-cycle-check.mjs +2 -1
  254. package/scripts/merge-shape-check.mjs +2 -1
  255. package/scripts/package-size-budget.mjs +2 -1
  256. package/scripts/record-carry-probe.mjs +1 -1
  257. package/scripts/release-entry-catch-up.mjs +77 -8
  258. package/scripts/release-install-check.mjs +4 -1
  259. package/scripts/release-note-required.mjs +139 -9
  260. package/scripts/release-rehearsal-version.mjs +60 -0
  261. package/scripts/release-sbom.mjs +104 -0
  262. package/scripts/release-visible-check.mjs +7 -5
  263. package/scripts/render-check.mjs +5 -2
  264. package/scripts/report-offline-render-check.mjs +169 -0
  265. package/scripts/score.mjs +79 -8
  266. package/scripts/strip-titles-and-attributions.mjs +2 -1
  267. package/scripts/strip-tracker-citations.mjs +2 -1
  268. package/scripts/test-full.mjs +2 -1
  269. package/scripts/test-run.mjs +10 -3
  270. package/scripts/third-party-notices.mjs +3 -1
  271. package/scripts/travelling-predicates.mjs +1 -1
  272. package/scripts/writing-standard-check.mjs +2 -1
  273. package/shared/brand-fonts.mjs +59 -0
  274. package/shared/brand.mjs +6 -6
  275. package/shared/browser-temp-root.mjs +3 -2
  276. package/shared/doctrine-overlay.mjs +1 -1
  277. package/shared/driver-dir.mjs +47 -11
  278. package/shared/fonts/OFL-fira-code.txt +93 -0
  279. package/shared/fonts/OFL-plus-jakarta-sans.txt +93 -0
  280. package/shared/fonts/README.md +37 -0
  281. package/shared/fonts/fira-code.woff2 +0 -0
  282. package/shared/fonts/plus-jakarta-sans.woff2 +0 -0
  283. package/shared/names-in-force.mjs +2 -0
  284. package/shared/npm-cli.mjs +23 -0
  285. package/shared/os-advice.mjs +14 -2
  286. package/shared/path-seps.mjs +42 -0
  287. package/shared/process-table.mjs +71 -3
  288. package/shared/reference-guard-classes.mjs +30 -6
  289. package/shared/root-doc-commands.mjs +6 -2
  290. package/shared/running-start.mjs +24 -7
  291. package/shared/scope.mjs +2 -2
  292. package/shared/wsl.mjs +1 -1
  293. package/driver/known-conflicts.mjs +0 -327
@@ -31,7 +31,8 @@
31
31
  // `register-records.jsonl`; providers/_shared/ledger-path.mjs still resolves the older
32
32
  // `corsearch-*` names on a box that already has them.)
33
33
 
34
- import { makeLedger } from "../../_shared/ledger.mjs";
34
+ import { makeLedger, heldRecordBodies } from "../../_shared/ledger.mjs";
35
+ import { answerKey, compareAnswers, forgetAnswer, noteAnswer, openAnswerMemory, recallAnswer, rememberAnswer } from "../../_shared/answer-memory.mjs";
35
36
  import { nonAnswerBodyError, parseJsonBody, unparsedBodyError } from "../../_shared/http-body.mjs";
36
37
  import {
37
38
  BATCH_SCREEN_CHUNK, chunk, isAllClass, makeClassifyStatus, screenVerdict,
@@ -76,13 +77,36 @@ export function refToOffice(ref) {
76
77
  }
77
78
 
78
79
  // ── HTTP helper (with the metered chokepoint) ──────────────────────────────────────────────────────
79
- export async function clarivateFetch(apiKey, base, path, { body = null, method = "POST", retries = 1, tctx = null } = {}) {
80
+ //
81
+ // THE RUN MEMORY (providers/_shared/answer-memory.mjs) answers an identical count, search or owner lookup
82
+ // from what this run already asked: the question is the base address, the path and the whole body, never
83
+ // the key. Records are held by id instead (heldRecords, below), because a record is the same whichever
84
+ // search found it. Images and filing dates are not repeated in a run and go to the register every time.
85
+ // With no memory for the run, this is the plain fetch it always was.
86
+ //
87
+ // `ledgerExtra` rides on the call ledger's row: a /text call names the record ids it fetched, which is
88
+ // what lets a run's usage count a record fetched twice (driver/provider-usage.mjs).
89
+ const REMEMBERED_PATHS = new Set(["/count", "/search", "/resolution/company"]);
90
+
91
+ export async function clarivateFetch(apiKey, base, path, { body = null, method = "POST", retries = 1, tctx = null, ledgerExtra = null } = {}) {
80
92
  const url = `${base}${path}`;
81
93
  const headers = { "X-ApiKey": apiKey, "Accept": "application/json" };
82
94
  const init = { method, headers };
83
95
  if (body !== null) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); }
84
96
 
85
97
  const t0 = Date.now();
98
+ const mem = REMEMBERED_PATHS.has(path) ? openAnswerMemory(tctx?.recordLog) : null;
99
+ const key = mem ? answerKey({ base, method, path, body }) : null;
100
+ const held = mem ? recallAnswer(mem, key) : null;
101
+ const note = (row) => noteAnswer(mem, { ts: new Date().toISOString(), mode: mem.mode, key, method, path, tool: tctx?.kind ?? null,
102
+ via: tctx?.recordLog ? "driver" : "tool-server", session: tctx?.sessionKey ?? null, ...row });
103
+ if (held && mem.mode === "on") {
104
+ const { body: parsed, parseError } = parseJsonBody(held.raw);
105
+ const ok = held.status >= 200 && held.status < 300;
106
+ logCall(tctx, { http_status: held.status, ok, attempts: 0, took_ms: Date.now() - t0, bytes: held.raw.length, cache_hit: true });
107
+ note({ held: true, served: true, status: held.status });
108
+ return { status: held.status, ok, url, body: parsed, raw: held.raw, parseError };
109
+ }
86
110
  let attempts = 0;
87
111
  let resp;
88
112
  try {
@@ -96,17 +120,91 @@ export async function clarivateFetch(apiKey, base, path, { body = null, method =
96
120
  await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
97
121
  }
98
122
  } catch (err) {
99
- logCall(tctx, { http_status: 0, ok: false, attempts, took_ms: Date.now() - t0, bytes: 0, cache_hit: false });
123
+ logCall(tctx, { http_status: 0, ok: false, attempts, took_ms: Date.now() - t0, bytes: 0, cache_hit: false, ...(ledgerExtra ?? {}) });
124
+ if (mem) note({ held: Boolean(held), status: 0 });
100
125
  throw err;
101
126
  }
102
127
 
103
128
  const raw = await resp.text();
104
129
  // The parse failure travels on `parseError` instead of being swallowed — providers/_shared/http-body.mjs.
105
130
  const { body: parsed, parseError } = parseJsonBody(raw);
106
- logCall(tctx, { http_status: resp.status, ok: resp.ok, attempts, took_ms: Date.now() - t0, bytes: raw.length, cache_hit: false });
131
+ // A FAILED CALL'S BODY IS KEPT WHOLE on its row. Everything downstream shortens it — the error text
132
+ // quotes 200 characters of it and a band's reason 240 — and a gateway's error body is often the only
133
+ // sentence that says what happened. A successful body is never written here: the record ledger holds
134
+ // records, and this row is the call's.
135
+ logCall(tctx, { http_status: resp.status, ok: resp.ok, attempts, took_ms: Date.now() - t0, bytes: raw.length, cache_hit: false, ...(ledgerExtra ?? {}),
136
+ ...(resp.ok ? {} : { error_body: raw }) });
137
+ if (mem) {
138
+ const summary = rememberableAnswer(path, resp.status, parsed, parseError);
139
+ const row = { held: Boolean(held), status: resp.status, rememberable: Boolean(summary) };
140
+ if (held && summary) Object.assign(row, compareAnswers(held.summary, summary));
141
+ if (summary && !held) row.stored = rememberAnswer(mem, key, { status: resp.status, raw, summary });
142
+ note(row);
143
+ }
107
144
  return { status: resp.status, ok: resp.ok, url, body: parsed, raw, parseError };
108
145
  }
109
146
 
147
+ /**
148
+ * What the run memory may keep from a count, a search or an owner lookup, as the short summary a watch
149
+ * compares — or null when the question must be asked again next time.
150
+ *
151
+ * KEPT: a 200 that parsed into the answer its path gives — `counts{}` for /count, `ids{}` for /search
152
+ * (isSearchResponseBody), `companies[]` for /resolution/company.
153
+ *
154
+ * NEVER KEPT: anything else, the refusals included — a result past the ceiling, a query the service
155
+ * refused as written, an error envelope served with a 200, a body that did not parse. Those are the
156
+ * answers this core reports as failures, and remembering one would turn a passing fault into the run's
157
+ * permanent answer. No answer here points at a next page: /search returns the whole set at once.
158
+ */
159
+ export function rememberableAnswer(path, status, body, parseError) {
160
+ if (status !== 200 || parseError || body == null || typeof body !== "object" || Array.isArray(body)) return null;
161
+ if (path === "/count") {
162
+ if (!body.counts || typeof body.counts !== "object" || Array.isArray(body.counts)) return null;
163
+ const entries = Object.entries(body.counts).filter(([, n]) => Number.isFinite(Number(n)));
164
+ return { total: entries.reduce((t, [, n]) => t + Number(n), 0), ids: entries.map(([o, n]) => `${o}:${Number(n)}`).sort(), next_page: false };
165
+ }
166
+ if (path === "/search") {
167
+ if (!isSearchResponseBody(body)) return null;
168
+ const ids = Object.entries(body.ids).flatMap(([o, gs]) => (Array.isArray(gs) ? gs : []).map((g) => `${o}/${g}`));
169
+ return { total: ids.length, ids, next_page: false };
170
+ }
171
+ if (path === "/resolution/company") {
172
+ if (!Array.isArray(body.companies)) return null;
173
+ const ids = body.companies.map((c) => `${c?.applicantName ?? ""}|${c?.registrationOfficeCode ?? ""}|${c?.confidenceScore ?? ""}`);
174
+ return { total: ids.length, ids, next_page: false };
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * THE RECORDS THIS RUN ALREADY HOLDS, by id, for a /text call about to be made — Map<lowercased guid, raw
181
+ * record>. Read off the run's record log, which every process that fetches a record for the run writes
182
+ * to (providers/_shared/ledger.mjs heldRecordBodies), and only while the run memory is `on`.
183
+ *
184
+ * The raw vendor record is what comes back, not the normalized one stored beside it, so the caller runs
185
+ * it through exactly the normalization and screening a fetched record gets, with this request's own office
186
+ * hint. A held record therefore yields the row a fresh fetch would, and a record whose office segment
187
+ * differs between two searches is addressed the way this search addresses it.
188
+ *
189
+ * Why no watch period (the owner's ruling): across 38 past runs that fetched the same record twice, every
190
+ * second copy was byte-identical apart from the order of one list inside the vendor's raw record, and
191
+ * every normalized field matched.
192
+ */
193
+ function heldRecords(tctx, guids) {
194
+ const out = new Map();
195
+ if (openAnswerMemory(tctx?.recordLog)?.mode !== "on") return out;
196
+ const log = typeof tctx?.recordLog === "string" && tctx.recordLog.trim()
197
+ ? tctx.recordLog.trim() : String(process.env.CLEAROTRON_REGISTER_RECORD_LOG ?? "").trim();
198
+ for (const [g, body] of heldRecordBodies(log, guids)) if (body?._raw && typeof body._raw === "object") out.set(g, body._raw);
199
+ return out;
200
+ }
201
+
202
+ /** The one ledger row for records served from the run's own store: no call made, none paid for. */
203
+ function logHeldRecords(tctx, held) {
204
+ if (!held.size) return;
205
+ logCall({ ...tctx, target: `held:${held.size}` }, { http_status: 200, ok: true, attempts: 0, took_ms: 0, bytes: 0, cache_hit: true, records: [...held.keys()] });
206
+ }
207
+
110
208
  const errText = (r) => r?.body?.errorMessage ?? r?.body?.message ?? (r?.raw ? String(r.raw).slice(0, 200) : "");
111
209
 
112
210
  // ══ QUERY BUILDING ═════════════════════════════════════════════════════════════════════════════════
@@ -192,7 +290,7 @@ const PHRASE_OPERATOR = "ADJ";
192
290
  // once `exact`:
193
291
  // "SLUSH FREEZE, SLUSH ICE, SLUSH POP" three terms crammed into one
194
292
  // "TIKTOK / TIK- famous-neighbour family" a description of a family, not a name
195
- // "TIKE, TIPI one-keystroke neighbours of TIKI"
293
+ // "WAVU, WAPO one-keystroke neighbours of WAVO"
196
294
  //
197
295
  // Searched, they return 0 — and a 0 here reads as CLEAN, which is the one outcome this contract must
198
296
  // never produce by accident. On that run the `default` twins deferred loudly while the `exact` twins
@@ -305,7 +403,7 @@ export function compilePhraseValue(term, { pre = "", post = "", dropReserved = f
305
403
  const last = kept.length - 1;
306
404
  const tailWrap = (kept.length > 1 && kept[last].length === 1) ? "" : post;
307
405
  // THE WILDCARD PREDICATE CARRIES ITS OWN STAR, so `post` is empty there and the two rules above
308
- // cannot reach it: the caller writes `STEAL A*` and the star is part of the token. Same shape, same
406
+ // cannot reach it: the caller writes `FOLD A*` and the star is part of the token. Same shape, same
309
407
  // refusal, so the same subtraction — the star comes off a one-character final token. Nothing else is
310
408
  // touched: a longer final token keeps the caller's pattern exactly as written, and a `?` is a
311
409
  // single-character class rather than a sub-query, so it is not this.
@@ -561,7 +659,7 @@ export function resolveOffices(regions) {
561
659
  * answers the deduplicated union of the deleted 4-call per-class fan-out.
562
660
  */
563
661
  // ── THE APOSTROPHE IS NOT SEARCHABLE IN APPLICANT_NAME ────────────────────────────────────────────
564
- // An apostrophe is not searchable: "TRADER VIC'S", "MCDONALD'S CORPORATION" and
662
+ // An apostrophe is not searchable: "TRADER VIC'S", a company's possessive name and
565
663
  // "…L'ETAT DU DELAWARE" all answer HTTP 400 — "the following characters are not searchable by
566
664
  // themselves" — while the same names without the apostrophe answer 200. It is the character itself,
567
665
  // not the elision and not the possessive; an earlier fix narrowed this to elided articles only, on the
@@ -569,7 +667,7 @@ export function resolveOffices(regions) {
569
667
  // is exactly what failed next.
570
668
  //
571
669
  // Dropping the name is the wrong remedy: `?` is a native single-character wildcard on this field, so
572
- // the apostrophe is EXPRESSIBLE. "MCDONALD?S CORPORATION" returns 27 where the literal returns 400.
670
+ // the apostrophe is EXPRESSIBLE. That possessive name written with "?" returns 27 where the literal returns 400.
573
671
  //
574
672
  // Emit BOTH spellings OR-joined, because registers hold both: "TRADER VIC?S" matches the apostrophe'd
575
673
  // record and "TRADER VICS" the stripped one (probe: 3 records each). One form alone silently loses
@@ -1107,6 +1205,7 @@ export async function doSearch(apiKey, base, params, tctx) {
1107
1205
  if (!isSearchResponseBody(r.body)) {
1108
1206
  return { type: "text", text: nonAnswerBodyError("clarivate_search", r, "a search response (no ids{} — the one key POST /search answers with)", ` query=${String(echo).slice(0, 200)}`) };
1109
1207
  }
1208
+ forgetContradictedAnswers(base, body, r.body, tctx);
1110
1209
  const out = normalizeSearchResponse(r.body, echo, p.match_mode || "default");
1111
1210
  // The resolution note rides on the answer, exactly as it does on the enumerate result: a reader has
1112
1211
  // to be able to see WHICH applicant styling this sweep actually asked for, and a zero over an owner
@@ -1115,6 +1214,22 @@ export async function doSearch(apiKey, base, params, tctx) {
1115
1214
  return { type: "text", text: JSON.stringify(out, null, 2) };
1116
1215
  }
1117
1216
 
1217
+ // A SEARCH SHORTER THAN ITS OWN COUNT IS NOT KEPT, AND NEITHER IS THE COUNT. The enumerate kernel reads the
1218
+ // shortfall as the register contradicting itself and hands the slice to the repair ladder to ask again
1219
+ // (the count/search reconciliation in providers/_shared/enumerate.mjs). Kept, both answers would be served
1220
+ // again on every retry, and the slice could not be cured within the attempt. The two share one body, so
1221
+ // the count held for this search is found under the same question.
1222
+ function forgetContradictedAnswers(base, body, searchBody, tctx) {
1223
+ const mem = openAnswerMemory(tctx?.recordLog);
1224
+ if (!mem) return;
1225
+ const countKey = answerKey({ base, method: "POST", path: "/count", body });
1226
+ const counted = recallAnswer(mem, countKey)?.summary?.total;
1227
+ const returned = rememberableAnswer("/search", 200, searchBody, null)?.total;
1228
+ if (!Number.isFinite(counted) || !Number.isFinite(returned) || returned >= counted) return;
1229
+ forgetAnswer(mem, countKey);
1230
+ forgetAnswer(mem, answerKey({ base, method: "POST", path: "/search", body }));
1231
+ }
1232
+
1118
1233
  // ── Count ─────────────────────────────────────────────────────────────────────────────────────────
1119
1234
  // POST /count takes the SAME SearchRequest body as /search, is cheap, works at ANY magnitude and
1120
1235
  // returns PER-OFFICE counts in one call. This is the enumerate
@@ -1269,7 +1384,7 @@ async function fetchText(apiKey, base, group, testMode, tctx) {
1269
1384
  const body = { ids: group };
1270
1385
  if (testMode) body.test = true;
1271
1386
  const target = group.slice(0, 5).join(",") + (group.length > 5 ? `+${group.length - 5}` : "");
1272
- const r = await clarivateFetch(apiKey, base, "/text", { body, tctx: { ...tctx, target } });
1387
+ const r = await clarivateFetch(apiKey, base, "/text", { body, tctx: { ...tctx, target }, ledgerExtra: { records: group.map((g) => String(g).toLowerCase()) } });
1273
1388
  if (!r.ok) return { ok: false, raw: [], error: `HTTP ${r.status} for a ${group.length}-id chunk: ${errText(r)}` };
1274
1389
  // On this provider /text is the ONLY source of mark text, classes, status and owner (the search
1275
1390
  // returns bare guids), and the kernel's contentFromScreen seam reads a chunk error to decide whether
@@ -1305,21 +1420,31 @@ export async function doRecordFetch(apiKey, base, params, tctx) {
1305
1420
  if (inputs.length === 0) return { type: "text", text: "ERROR: clarivate_record_fetch — record_ids is required (non-empty array of refs or guids)." };
1306
1421
 
1307
1422
  const { officeByGuid, guids } = splitRefs(inputs);
1308
- const groups = chunk(guids, TEXT_BATCH_MAX);
1423
+ // A record this run already holds is not fetched again (heldRecords); test-mode bodies are obfuscated
1424
+ // and are never held.
1425
+ const held = params.test_mode ? new Map() : heldRecords(tctx, guids);
1426
+ logHeldRecords(tctx, held);
1427
+ const groups = chunk(guids.filter((g) => !held.has(String(g).toLowerCase())), TEXT_BATCH_MAX);
1309
1428
  const records = [];
1429
+ const fetched = [];
1310
1430
  const errors = [];
1431
+ for (const rec of held.values()) { const nr = normalizeRecord(rec, rec?.id ? officeByGuid[rec.id] : null); records.push(nr); fetched.push(nr); }
1311
1432
  for (const group of groups) {
1312
1433
  const { ok, raw, error } = await fetchText(apiKey, base, group, params.test_mode, tctx);
1313
1434
  if (!ok) { errors.push(error); continue; }
1314
- for (const rec of raw) records.push(normalizeRecord(rec, rec?.id ? officeByGuid[rec.id] : null));
1435
+ for (const rec of raw) { const nr = normalizeRecord(rec, rec?.id ? officeByGuid[rec.id] : null); records.push(nr); fetched.push(nr); }
1315
1436
  }
1316
1437
  if (!records.length && errors.length) {
1317
1438
  return { type: "text", text: `ERROR: clarivate_record_fetch — ${errors.join("; ")}` };
1318
1439
  }
1319
1440
  // A1: persist each normalized record keyed by its synthetic ref so the driver can field-verify
1320
1441
  // registry identifiers and archive the record into the run. (test_mode bodies are obfuscated — skip.)
1442
+ // A HELD RECORD IS WRITTEN TOO, under the address this request gives it. The screen gate matches exact
1443
+ // addresses against this log, and a record first fetched under another office's address was otherwise
1444
+ // absent under this one, so a drop citing it read as a record nobody examined. The log keeps one row per
1445
+ // address (writeRecordOnce), so a held record already logged under this address writes nothing.
1321
1446
  if (!params.test_mode) {
1322
- for (const nr of records) if (nr?.uri) logRecordBody({ ...tctx, kind: "record_fetch" }, nr.uri, nr);
1447
+ for (const nr of fetched) if (nr?.uri) logRecordBody({ ...tctx, kind: "record_fetch" }, nr.uri, nr);
1323
1448
  }
1324
1449
  return { type: "text", text: JSON.stringify({ count: records.length, records, errors: errors.length ? errors : undefined }, null, 2) };
1325
1450
  }
@@ -1337,15 +1462,21 @@ export async function doBatchScreen(apiKey, base, params, tctx) {
1337
1462
  ? params.in_scope_classes.map(Number).filter(Number.isFinite) : [];
1338
1463
 
1339
1464
  const { officeByGuid, guids } = splitRefs(inputs);
1340
- const groups = chunk(guids, TEXT_BATCH_MAX);
1465
+ // A record this run already holds is screened from its stored raw copy and not fetched again
1466
+ // (heldRecords); only the rest go to /text. Test-mode bodies are obfuscated and are never held.
1467
+ const held = params.test_mode ? new Map() : heldRecords(tctx, guids);
1468
+ logHeldRecords(tctx, held);
1469
+ const groups = chunk(guids.filter((g) => !held.has(String(g).toLowerCase())), TEXT_BATCH_MAX);
1341
1470
  const rows = [];
1342
1471
  const errors = [];
1343
1472
  const normalized = [];
1344
- for (const group of groups) {
1345
- const { ok, raw, error } = await fetchText(apiKey, base, group, params.test_mode, tctx);
1473
+ const answers = held.size ? [{ ok: true, raw: [...held.values()], held: true }] : [];
1474
+ for (const group of groups) answers.push({ ...(await fetchText(apiKey, base, group, params.test_mode, tctx)), held: false });
1475
+ for (const { ok, raw, error } of answers) {
1346
1476
  if (!ok) { errors.push(error); continue; }
1347
1477
  for (const rec of raw) {
1348
1478
  const nr = normalizeRecord(rec, rec?.id ? officeByGuid[rec.id] : null);
1479
+ // A held record is logged as well, under this request's address, for the reason doRecordFetch gives.
1349
1480
  normalized.push(nr);
1350
1481
  const row = {
1351
1482
  uri: nr.uri,
@@ -1398,6 +1529,7 @@ export async function doBatchScreen(apiKey, base, params, tctx) {
1398
1529
  for (const row of rows) verdict_summary[row.screen_verdict] = (verdict_summary[row.screen_verdict] ?? 0) + 1;
1399
1530
  return { type: "text", text: JSON.stringify({
1400
1531
  requested: inputs.length, returned: rows.length, chunks: groups.length,
1532
+ ...(held.size ? { held: held.size } : {}),
1401
1533
  in_scope_classes: inScopeClasses.length ? inScopeClasses : "NOT PROVIDED — live marks fail-safe to surface:in-scope-live (no class-drop)",
1402
1534
  errors: errors.length ? errors : undefined,
1403
1535
  note: "Per-row `screen_verdict` (CLOSED SET) is the keep/drop authority — NOT the mark name or owner. drop:dead = a confidently dead status; drop:out-of-class = live but no in-scope-class overlap and not all_class — these two are batch-screen-authoritative drops. surface:in-scope-live and surface:all-class = a real in-scope candidate: decide it on the record's goods & services (this provider returns them on this very call). deepfetch:ambiguous = status unrecognised, never auto-drop.",
@@ -1645,6 +1777,33 @@ export async function doFilingDate(apiKey, base, params, tctx) {
1645
1777
  return { type: "text", text: JSON.stringify({ count: rows.length, registers: rows }, null, 2) };
1646
1778
  }
1647
1779
 
1780
+ // ── ONE OFFICE WITH NO GOODS FIELD REFUSES THE WHOLE REQUEST ─────────────────────────────────────
1781
+ // The vendor answers a goods-narrowed request with HTTP 400 "Search field INT_GOODS_SERVICES_DESCRIPTION
1782
+ // is not supported for registrationOfficeCode XX" when any one office in it lacks the field, so every
1783
+ // other office goes unasked. A retry of the same request cannot change that; the request without that
1784
+ // office can. Ask again without it, as often as another office refuses, and name each office left out on
1785
+ // the result. No call is added unless an office refuses.
1786
+ export function officeRefusingGoodsField(reason) {
1787
+ const m = new RegExp(`Search field ${GOODS_FIELD} is not supported for registrationOfficeCode ([A-Z]{2})\\b`).exec(String(reason ?? ""));
1788
+ return m ? m[1] : null;
1789
+ }
1790
+
1791
+ export async function withoutOfficesRefusingGoods(params, ask, reasonOf) {
1792
+ const leftOut = [];
1793
+ let p = params;
1794
+ let out = await ask(p);
1795
+ for (let office = officeRefusingGoodsField(reasonOf(out)); office && !leftOut.includes(office); office = officeRefusingGoodsField(reasonOf(out))) {
1796
+ const regions = (Array.isArray(p?.regions) ? p.regions : []).filter((r) => !resolveOffices([r]).codes.includes(office));
1797
+ // The refusing office is not in the request as the caller wrote it, or it is the only one: the
1798
+ // refusal stands as it was given.
1799
+ if (!regions.length || regions.length === (p?.regions ?? []).length) break;
1800
+ leftOut.push(office);
1801
+ p = { ...p, regions };
1802
+ out = await ask(p);
1803
+ }
1804
+ return { out, params: p, leftOut };
1805
+ }
1806
+
1648
1807
  // ── Enumerate — WIRED FROM THE SHARED KERNEL ──────────────────────────────────────────────────────
1649
1808
  //
1650
1809
  // The control flow (states, ceilings, the wide-`names` chunking, the count-first per-term rescue) lives
@@ -1685,8 +1844,14 @@ export async function doEnumerate(apiKey, base, params, tctx) {
1685
1844
  const chunked = Array.isArray(p?.names) && p.names.filter(Boolean).length > namesChunk;
1686
1845
 
1687
1846
  const sink = [];
1688
- let r = await __enumerate({ apiKey, base }, { ...p, __countSink: sink }, tctx);
1689
- let parsed = parseToolText(r);
1847
+ const asked = await withoutOfficesRefusingGoods(p, async (q) => {
1848
+ sink.length = 0;
1849
+ const rr = await __enumerate({ apiKey, base }, { ...q, __countSink: sink }, tctx);
1850
+ return { rr, parsed: parseToolText(rr) };
1851
+ }, (o) => o.parsed?.reason);
1852
+ p = asked.params;
1853
+ let r = asked.out.rr;
1854
+ let parsed = asked.out.parsed;
1690
1855
  if (!parsed) return r;
1691
1856
 
1692
1857
  // ── the ADDITIVE invariant, ENFORCED AT RUNTIME TOO ─────────────────────────────────────────────
@@ -1711,7 +1876,7 @@ export async function doEnumerate(apiKey, base, params, tctx) {
1711
1876
  // without it the kernel's calls underneath this fallback would re-resolve the very names whose
1712
1877
  // expansion the provider has just rejected — reinstating the stack the fallback exists to drop.
1713
1878
  // The un-resolved sweep has to be genuinely un-resolved, at every seam.
1714
- const rawOnly = { ...params, resolve_owner: false };
1879
+ const rawOnly = { ...params, regions: p.regions, resolve_owner: false };
1715
1880
  delete rawOnly.owners;
1716
1881
  const sink2 = [];
1717
1882
  const r2 = await __enumerate({ apiKey, base }, { ...rawOnly, __countSink: sink2 }, tctx);
@@ -1732,6 +1897,9 @@ export async function doEnumerate(apiKey, base, params, tctx) {
1732
1897
  }
1733
1898
  }
1734
1899
 
1900
+ // After the owner fallback, which may replace the answer: it asks with the same reduced offices.
1901
+ if (asked.leftOut.length) parsed.offices_without_goods_field = asked.leftOut;
1902
+
1735
1903
  if (parsed.state === "incomplete") {
1736
1904
  // The 30000 ceiling is the provider truthfully reporting a CROWD, not a fault. Restate it as a
1737
1905
  // crowd descriptor — and strip the kernel's generic "provider error" framing, because an
@@ -1750,6 +1918,13 @@ export async function doEnumerate(apiKey, base, params, tctx) {
1750
1918
  if (chunked) {
1751
1919
  parsed.per_office_counts = null;
1752
1920
  parsed.per_office_counts_unavailable = `the ${p.names.length}-name stack was enumerated in windows of ${namesChunk} (the provider's OR-width bound), so no whole-stack per-office count exists — per-window counts would misreport the jurisdictional shape of this crowd.`;
1921
+ } else if (parsed.region_split) {
1922
+ // ASKED IN REGION HALVES after a gateway timeout, so sink[0] may be one half's count. Each count
1923
+ // covers exactly its own offices, and an office's count is the same whichever count asked it, so
1924
+ // the first count that names an office answers for it.
1925
+ const perOffice = {};
1926
+ for (const entry of sink) for (const [office, n] of Object.entries(entry?.per_office ?? {})) if (!(office in perOffice)) perOffice[office] = n;
1927
+ if (Object.keys(perOffice).length) parsed.per_office_counts = perOffice;
1753
1928
  } else if (sink.length && sink[0]?.per_office) {
1754
1929
  parsed.per_office_counts = sink[0].per_office;
1755
1930
  }
@@ -13,8 +13,7 @@ a global aggregator, so it declares no enumerable covered office set; see the la
13
13
  | `test/` | Characterisation and fault-lane tests |
14
14
 
15
15
  Environment: `CORSEARCH_SESSION_KEY` is required, and it is a **session cookie**, not a bearer token —
16
- `core.js` sends it as `Cookie: sessionKey=…`. `src/index.js` will also take it from the gateway's
17
- `plugins.entries.clawdi-corsearch.config.sessionKey`. There is no base-URL variable: `BASE_SEARCH`,
16
+ `core.js` sends it as `Cookie: sessionKey=…`. There is no base-URL variable: `BASE_SEARCH`,
18
17
  `BASE_DETAIL` and `BASE_IMAGE` are constants in `core.js`. The per-call ledger paths come from
19
18
  `CLEAROTRON_REGISTER_CALL_LOG` / `CLEAROTRON_REGISTER_RECORD_LOG`, resolved in
20
19
  [`../_shared/ledger-path.mjs`](../_shared/ledger-path.mjs) and shared with the other register adapters
@@ -54,7 +54,7 @@ export const MATCH_MODE_PREFIX = {
54
54
  // `provider` discriminator on every row.
55
55
  //
56
56
  // SECURITY — the ids logged (agentId / sessionKey / sessionId) are the GATEWAY tool-call context
57
- // (e.g. "clawdi", "clearotron-acme-…"), the per-run attribution. They are NOT the Corsearch `sessionKey`
57
+ // (e.g. "localagent", "clearotron-acme-…"), the per-run attribution. They are NOT the Corsearch `sessionKey`
58
58
  // COOKIE (the live credential, which merely shares the name). logCall is only ever handed `tctx`
59
59
  // (kind + gateway ids + target) and response metrics — the cookie is never passed in. Keep it so.
60
60
  export const { logCall, logRecordBody, tctxOf } = makeLedger("corsearch");
@@ -269,7 +269,7 @@ export function normalizeSearchResponse(body, echoQuery, matchMode) {
269
269
  // ── Search (with per-run dedup) ───────────────────────────────────────────────
270
270
  // The plugin runs in the long-lived gateway daemon, so this module-level Map persists across tool
271
271
  // calls within a run. A run issues the SAME search more than once (~12% of this build's baseline run
272
- // were exact-duplicate sweeps — name:CLAWDI, the JP and CN transliteration sweeps each issued twice),
272
+ // were exact-duplicate sweeps — the name sweep and the JP and CN transliteration sweeps, each issued twice),
273
273
  // invisible to telemetry because the dup counter watched record_fetch only. We cache the normalized
274
274
  // result keyed on the GATEWAY session key (per-run, unique → no cross-run collision) + the FULL
275
275
  // canonical upstream query tail (so a different page / field-set is NOT a dup). On an exact repeat we
@@ -83,7 +83,7 @@ export function subclassesFor(db, { country, term, niceClass }) {
83
83
  cn_goods_code: row.group_code, // what the 12th says, kept even when the 13th governs
84
84
  })),
85
85
  source: from13 === ruled.length
86
- ? "NCL 13-2026 concordance — the 13th edition governs the group code (#1391); CNIPA's 区分表 found the good"
86
+ ? "NCL 13-2026 concordance — the 13th edition governs the group code; CNIPA's 区分表 found the good"
87
87
  : from13 === 0
88
88
  ? "CNIPA 区分表, 12th edition (2023 text) — the 13th edition holds no group for this good, so there is nothing it disagrees with"
89
89
  : `mixed: ${from13} of ${ruled.length} matched goods take the 13th edition's group, the rest have no 13th-edition group at all`,
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.0-beta.2
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.0-beta.1
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.4.0-beta.0
4
12
 
5
13
  No changes in this release.
@@ -43,11 +43,12 @@ The bridge owns `<creds-dir>/<server>.json` from the moment the recipe below
43
43
  writes it, and refreshes are written back there. mcporter's own credential store
44
44
  can rotate or clear without affecting the bridge.
45
45
 
46
- **Where `<creds-dir>` is.** `bridge.mjs` reads `--creds-dir`, else
47
- `OAUTH_BRIDGE_CREDS_DIR`, else `~/.config/trademark-oauth-mcp`. `warm-server.mjs`
48
- reads `--creds-dir`, else `~/.config/clawdi/oauth-mcp` — it has no env override,
49
- so a deployment that runs both must pass the same directory to the warm server
50
- explicitly or keep the credentials where each default looks.
46
+ **Where `<creds-dir>` is.** Both `bridge.mjs` and `warm-server.mjs` read
47
+ `--creds-dir`, else `OAUTH_BRIDGE_CREDS_DIR`, else `~/.config/trademark-oauth-mcp`.
48
+ They disagreed until 2026-09-27 — the warm server defaulted elsewhere and took no
49
+ variable at all, so a deployment running both had to pass the directory explicitly.
50
+ A deployment whose credentials sit in the folder an earlier build used points
51
+ `OAUTH_BRIDGE_CREDS_DIR` at that folder.
51
52
 
52
53
  ## One-time setup (per remote MCP server)
53
54
 
@@ -247,7 +248,7 @@ that way.
247
248
 
248
249
  Two caveats on "courtlistener is warm", both about WHERE. First, no box runs a unit of
249
250
  that name: `driver/unit-inventory.mjs` declares the shipped one an orphan and records the
250
- live service as `clawdi-courtlistener-mcp`, run from another checkout — so editing
251
+ live service under a name of its own, run from another checkout — so editing
251
252
  `warm-server.mjs` or the unit file here changes nothing on the running proxy. Second, the
252
253
  clearance engine does not reach the warm service at all: `driver/engine/mcp/gather-config.mjs`
253
254
  mounts courtlistener as a per-session `bridge.mjs` spawn, exactly like legaldatahunter, so
@@ -274,7 +275,7 @@ checkout.
274
275
 
275
276
  **Why this unit does not take `CLEAROTRON_CHECKOUT_DIR` the way the driver units do**: those load
276
277
  `EnvironmentFile=%h/.env` and expand `${CLEAROTRON_CHECKOUT_DIR}` in `ExecStart`. This one deliberately
277
- loads no environment file — it reads the OAuth cache at `~/.config/clawdi/oauth-mcp/courtlistener.json`
278
+ loads no environment file — it reads the OAuth cache under the credentials directory
278
279
  and needs nothing else — and on systemd an `EnvironmentFile` **overrides** the unit's own `Environment=`
279
280
  lines, so adding one to gain the variable would also let `~/.env` silently replace this unit's `PATH`.
280
281
  Editing one line is the smaller cost. Keep `loginctl enable-linger <user>` on if the service must
@@ -33,13 +33,13 @@ import { driverDir } from "../../shared/driver-dir.mjs"; //
33
33
  import { RefreshLock } from "./refresh-lock.mjs";
34
34
  import { exitOnStdinClose } from "./stdin-guard.mjs";
35
35
 
36
- // Creds dir: env-overridable (OAUTH_BRIDGE_CREDS_DIR). An existing deployment migrating from the
37
- // origin monorepo keeps its credentials by pointing this at the old ~/.config/clawdi/oauth-mcp.
36
+ // Creds dir: env-overridable (OAUTH_BRIDGE_CREDS_DIR). A deployment whose credentials still sit in the
37
+ // folder an earlier build used keeps them by pointing this variable at that folder.
38
38
  const DEFAULT_CREDS_DIR = process.env.OAUTH_BRIDGE_CREDS_DIR || path.join(homedir(), ".config", "trademark-oauth-mcp");
39
39
 
40
40
  // Per-server tool allowlist. Upstream MCP servers may add or rename tools
41
41
  // without notice — we explicitly opt in. To add a tool: verify it's read-only
42
- // (or otherwise intended for clawdi use) against the upstream API docs, then
42
+ // (or otherwise intended for this product's use) against the upstream API docs, then
43
43
  // add the unprefixed upstream name below and restart the bridge.
44
44
  const ALLOWED_TOOLS = {
45
45
  courtlistener: new Set([
@@ -146,7 +146,7 @@ process.on("exit", () => refreshLock.releaseSync());
146
146
  // of holding the upstream HTTP transport (and any in-flight upstream call) open forever (see
147
147
  // stdin-guard.mjs for the 3.5-day PPID-1 orphan this closes). The 'exit' hook above releases the
148
148
  // refresh lock on the way out.
149
- exitOnStdinClose({ name: `clawdi-oauth-bridge:${serverName}` });
149
+ exitOnStdinClose({ name: `clearotron-oauth-bridge:${serverName}` });
150
150
 
151
151
  class CachedOAuthProvider {
152
152
  constructor(initialCreds) {
@@ -278,6 +278,20 @@ function resultBytes(res) {
278
278
  try { return JSON.stringify(res ?? null).length; } catch { return null; }
279
279
  }
280
280
 
281
+ /**
282
+ * AN ERROR ANSWER IS A FAILED CALL. Under MCP a tool reports its own failure, a quota or an outage behind
283
+ * the server, inside its result as `isError: true`, and the call itself returns. Logged as ok, that answer
284
+ * read as a source that answered: a case-law pass whose every query met a failing source would then read
285
+ * as having searched and found nothing. Returns the source's words, cut like a thrown error's, or null
286
+ * for an answer that is not an error.
287
+ */
288
+ function toolError(res) {
289
+ if (res?.isError !== true) return null;
290
+ const said = (Array.isArray(res.content) ? res.content : [])
291
+ .filter((c) => c?.type === "text").map((c) => String(c.text ?? "")).join(" ").trim();
292
+ return (said || "the tool answered with an error").slice(0, 200);
293
+ }
294
+
281
295
  function logCall(server, tool, args, result) {
282
296
  if (!AUDIT_RUN_DIR) return;
283
297
  try {
@@ -341,7 +355,9 @@ async function main() {
341
355
  }
342
356
  try {
343
357
  const res = await upstream.callTool({ name, arguments: req.params.arguments });
344
- logCall(serverName, name, req.params.arguments, { ok: true, bytes: resultBytes(res) });
358
+ const refused = toolError(res);
359
+ if (refused === null) logCall(serverName, name, req.params.arguments, { ok: true, bytes: resultBytes(res) });
360
+ else logCall(serverName, name, req.params.arguments, { ok: false, error: refused });
345
361
  return res;
346
362
  } catch (err) {
347
363
  // A FAILED CALL IS LOGGED TOO, and it is the half that matters: without it, "the search ran and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.0-beta.0",
3
+ "version": "0.4.0-beta.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -5,7 +5,7 @@ After=network-online.target
5
5
  Wants=network-online.target
6
6
 
7
7
  [Service]
8
- # Reads the OAuth cache at ~/.config/clawdi/oauth-mcp/courtlistener.json (no ~/.env needed).
8
+ # Reads the OAuth cache at <creds-dir>/courtlistener.json (no ~/.env needed).
9
9
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
10
10
  # ── RESOLVED AT INSTALL (option B) ───────────────────────────────────────────────────────────
11
11
  # `@CLEAROTRON_CHECKOUT_DIR@` is substituted by render-units.mjs when it writes the resolved copy.
@@ -53,8 +53,19 @@ import { randomUUID } from "node:crypto";
53
53
  import http from "node:http";
54
54
  import path from "node:path";
55
55
  import { listenOrDie } from "../../shared/listen.mjs"; // — a taken port is a sentence, not a stack
56
+ import { warnRetiredEnv } from "../../shared/env-aliases.mjs";
56
57
 
57
- const DEFAULT_CREDS_DIR = path.join(homedir(), ".config", "clawdi", "oauth-mcp");
58
+ // THE REFUSAL ONLY, NO ENVIRONMENT FILE. This process reads one variable and deliberately loads no
59
+ // `.env` — its unit says so and explains why — but an operator who renamed the retired settings in that
60
+ // unit must not have this process silently stop seeing them. Reading the environment is what owes the
61
+ // refusal, and until 2026-09-27 this file read none, which is why it owed nothing.
62
+ warnRetiredEnv();
63
+
64
+ // Creds dir: env-overridable (OAUTH_BRIDGE_CREDS_DIR), and the same folder `bridge.mjs` already
65
+ // defaults to. The two entry points disagreed — this one named a folder after the old agent id and took
66
+ // no override at all, which is what made an install on the default unable to keep its sign-in. A
67
+ // deployment whose credentials sit in the old folder points the variable at it.
68
+ const DEFAULT_CREDS_DIR = process.env.OAUTH_BRIDGE_CREDS_DIR || path.join(homedir(), ".config", "trademark-oauth-mcp");
58
69
 
59
70
  // Per-server tool allowlist — upstream servers may add/rename tools without
60
71
  // notice, so we explicitly opt in. Kept in sync with bridge.mjs ALLOWED_TOOLS.
@@ -132,7 +143,7 @@ if (!allowedTools) {
132
143
  }
133
144
 
134
145
  const log = (msg) =>
135
- process.stderr.write(`[clawdi-warm-mcp:${serverName}] ${msg}\n`);
146
+ process.stderr.write(`[clearotron-warm-mcp:${serverName}] ${msg}\n`);
136
147
 
137
148
  async function loadCreds() {
138
149
  let raw;
@@ -199,7 +210,7 @@ class WarmOAuthProvider {
199
210
  get clientMetadata() {
200
211
  return (
201
212
  this._creds.clientInfo?.metadata ?? {
202
- client_name: "clawdi-oauth-mcp-bridge",
213
+ client_name: "clearotron-oauth-mcp-bridge",
203
214
  grant_types: ["authorization_code", "refresh_token"],
204
215
  response_types: ["code"],
205
216
  redirect_uris: [],
@@ -279,7 +290,7 @@ function scheduleKeepWarm(expiresInSec) {
279
290
  // warm upstream through the serialization mutex, applying the allowlist.
280
291
  function makeProxyServer() {
281
292
  const server = new Server(
282
- { name: `clawdi-warm-${serverName}`, version: "0.1.0" },
293
+ { name: `clearotron-warm-${serverName}`, version: "0.1.0" },
283
294
  { capabilities: { tools: {} } },
284
295
  );
285
296
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -344,7 +355,7 @@ async function main() {
344
355
  const authProvider = new WarmOAuthProvider(creds);
345
356
 
346
357
  upstream = new Client(
347
- { name: "clawdi-warm-mcp-client", version: "0.1.0" },
358
+ { name: "clearotron-warm-mcp-client", version: "0.1.0" },
348
359
  { capabilities: {} },
349
360
  );
350
361
  const clientTransport = new StreamableHTTPClientTransport(