clearotron 0.4.0 → 0.4.1-beta.0

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 (96) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/build-info.json +2 -2
  3. package/docs/SECURITY-OWASP.md +21 -13
  4. package/docs/SECURITY.md +20 -14
  5. package/docs/architecture/04-configuration-reference.md +3 -2
  6. package/docs/architecture/05-config-governance.md +1 -1
  7. package/docs/architecture/06-operations-runbook.md +1 -1
  8. package/docs/architecture/09-security-and-data.md +16 -11
  9. package/driver/CHANGELOG.md +18 -0
  10. package/driver/binding-layers.mjs +28 -6
  11. package/driver/citation-census.json +3 -3
  12. package/driver/common-law-coverage-status.mjs +52 -0
  13. package/driver/common-law-receipts.mjs +1 -1
  14. package/driver/connotation-search.mjs +5 -5
  15. package/driver/contract-dictation-registry.mjs +1 -1
  16. package/driver/contract-e3-backlog.mjs +1 -1
  17. package/driver/contract-vocabulary.mjs +18 -18
  18. package/driver/degraded-parts.mjs +10 -3
  19. package/driver/dispatch-record.mjs +19 -0
  20. package/driver/doubt-closure-tool.mjs +2 -2
  21. package/driver/driver.config.mjs +39 -6
  22. package/driver/engagement-receipt.mjs +1 -1
  23. package/driver/engine/CONTRACT.md +41 -1
  24. package/driver/engine/anthropic-agent.mjs +56 -4
  25. package/driver/engine/mcp/codex-config.mjs +1 -1
  26. package/driver/engine/mcp/coverage-server.mjs +1 -1
  27. package/driver/engine/mcp/gather-config.mjs +1 -1
  28. package/driver/engine/mcp/recording-server.mjs +31 -4
  29. package/driver/engine/mcp/stdio-server.mjs +45 -7
  30. package/driver/engine/openai-agent.mjs +100 -17
  31. package/driver/engine/session-record.mjs +223 -0
  32. package/driver/enqueue-schema.mjs +2 -2
  33. package/driver/findings-model.mjs +3 -3
  34. package/driver/form-neighbourhood.mjs +7 -7
  35. package/driver/frame-diff-model.mjs +3 -3
  36. package/driver/framework.mjs +22 -0
  37. package/driver/gateway.mjs +94 -12
  38. package/driver/named-band.mjs +2 -0
  39. package/driver/package.json +1 -1
  40. package/driver/phase0.mjs +13 -2
  41. package/driver/pipeline-knockout.mjs +1 -1
  42. package/driver/pipeline.mjs +46 -33
  43. package/driver/portal-families.mjs +3 -3
  44. package/driver/portal-service.mjs +1 -1
  45. package/driver/predelivery-lint.mjs +19 -19
  46. package/driver/publish/index.mjs +1 -1
  47. package/driver/publish/knockout.mjs +5 -4
  48. package/driver/publish/office-record-links.mjs +17 -2
  49. package/driver/publish/publish-inputs.mjs +1 -1
  50. package/driver/publish/render-knockout.mjs +17 -5
  51. package/driver/publish/render.mjs +4 -2
  52. package/driver/record-fold.mjs +177 -0
  53. package/driver/register-count.mjs +18 -1
  54. package/driver/register-plan.mjs +71 -17
  55. package/driver/register-records.mjs +7 -0
  56. package/driver/registry-fidelity.mjs +2 -2
  57. package/driver/repairs.mjs +1 -1
  58. package/driver/report-card-record.mjs +4 -4
  59. package/driver/scope-facts.mjs +11 -3
  60. package/driver/search-policy.mjs +20 -1
  61. package/driver/skills/clearance-common-law/SKILL.md +22 -22
  62. package/driver/skills/clearance-register/digest.md +3 -3
  63. package/driver/skills/clearance-register/register-recipes.md +11 -11
  64. package/driver/skills/clearance-register/status-rules.md +1 -1
  65. package/driver/skills/clearance-register/unit.md +1 -1
  66. package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
  67. package/driver/skills/clearance-search/phase2-execution.md +6 -5
  68. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  69. package/driver/skills/clearance-search/worked-examples.md +39 -39
  70. package/driver/skills/clearance-variants/SKILL.md +16 -16
  71. package/driver/skills/frame-diff/SKILL.md +2 -2
  72. package/driver/stage-context.mjs +27 -1
  73. package/driver/stages.mjs +12 -12
  74. package/driver/suite-census.json +175 -31
  75. package/driver/synthesis-record.mjs +5 -0
  76. package/driver/unit-inventory.mjs +17 -28
  77. package/driver/verify.mjs +6 -6
  78. package/mcp-server/CHANGELOG.md +4 -0
  79. package/mcp-server/lib/evidence.mjs +8 -8
  80. package/mcp-server/lib/knockout.mjs +1 -1
  81. package/mcp-server/package.json +2 -2
  82. package/package.json +2 -2
  83. package/portal-ui/package.json +1 -1
  84. package/providers/_shared/count.mjs +4 -1
  85. package/providers/_shared/enumerate.mjs +61 -22
  86. package/providers/_shared/execute-plan.mjs +2 -0
  87. package/providers/corsearch/src/capabilities.js +1 -1
  88. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  89. package/providers/oauth-mcp-bridge/package.json +2 -2
  90. package/providers/signa/src/capabilities.js +17 -2
  91. package/providers/signa/src/core.js +234 -37
  92. package/scripts/band-shape-probe.mjs +2 -2
  93. package/scripts/deprecate-below.mjs +28 -4
  94. package/scripts/e2e-first-time.mjs +16 -1
  95. package/scripts/freeze-example-run.mjs +2 -2
  96. package/shared/scope.mjs +18 -2
package/driver/verify.mjs CHANGED
@@ -16,7 +16,7 @@ import { matterFrameWasRecorded, frameRatifiedForms } from "./matter-frame-recor
16
16
  import { findConnotationViolations, parsePrRiskResults, prRiskPopulation,
17
17
  CONNOTATION_UNMATCHED_MARK, CONNOTATION_NO_RESEMBLANCE_MARK, MEANING_ANGLES_RE,
18
18
  parseDispositionForm, CONNOTATION_UNRULED_REASONS, queryKey } from "./connotation-search.mjs";
19
- import { formSidecarName, formSidecarPath } from "./disposition-union.mjs"; import { coverageStatusAsData } from "./common-law-coverage-status.mjs";
19
+ import { formSidecarName, formSidecarPath } from "./disposition-union.mjs";
20
20
  // B — the transport's own four failure states. The audit reads the run's records; this file locates them.
21
21
  import { auditDispositionCalls, CALL_FAILURE_REASONS } from "./disposition-call-audit.mjs";
22
22
  import { callRecordPaths } from "./disposition-tool.mjs";
@@ -37,7 +37,7 @@ import { registerPlanCallKilled } from "./tool-calls.mjs"; // — did the dict
37
37
  import { parseFindingsJson, parseFindingsJsonLenient, CLIENT_TIER_BY_COMPOSITE, isUnconditionalProceed, joinFindingToBlock, parseBlockOrd } from "./findings-model.mjs";
38
38
  import { parsePlacementsJson } from "./placement-model.mjs"; import { placementFormSidecarName, parsePlacementForm, placementRenderAccount } from "./placement-form.mjs";
39
39
  import { parseCaseLawLedger, findCaseLawLedgerViolations, caseLawLedgerFail } from "./case-law-ledger.mjs";
40
- import { parseFrameworkManifest, aboveLowestBand, normalizeBand } from "./framework.mjs"; import { readFrozenMethod, FROZEN_METHOD_FILE } from "./framework-method.mjs";
40
+ import { parseFrameworkManifest, materialBand, normalizeBand } from "./framework.mjs"; import { readFrozenMethod, FROZEN_METHOD_FILE } from "./framework-method.mjs";
41
41
  import { parseNamedBand, findCollapsedBands } from "./named-band.mjs";
42
42
  import { parseBlindFrameModel } from "./blind-frame-model.mjs";
43
43
  import { parseFrameDiff } from "./frame-diff-model.mjs";
@@ -681,7 +681,7 @@ function commonLawStructural(c, p = null) {
681
681
  needsSection(c, "coverage-ledger", [/coverage[\s-]ledger/i], "coverage-ledger"),
682
682
  needsSection(c, "audit-trail", [/audit[\s-]trail/i], "audit-trail"),
683
683
  needs(c, [/\|/], "platform matrix"),
684
- coverageStatusAsData(p) || hasCoverageLedgerRow(c) ? ok() : fail("no_coverage_status_row"), // the recorded status first, the word as fallback: coverageStatusAsData() in common-law-coverage-status.mjs
684
+ // no status check here (ruling 2026-10-01): a ledger with no status word and no record passes, and code records what it states — recordStatusesFromFindings() in common-law-coverage-status.mjs
685
685
  );
686
686
  }
687
687
 
@@ -1203,9 +1203,9 @@ function checkFindingsSibling(p, c) {
1203
1203
  const v4 = (parsed.schemaVersion ?? 1) >= 4;
1204
1204
  if (v4 && !fw.manifest) return fail("framework_manifest_missing_for_v4: schema_version 4 findings need the frozen _driver/framework.json to judge band words");
1205
1205
  const v3 = (parsed.schemaVersion ?? 1) >= 3; // gates key on schema_version — v2/archived runs and replay never flip
1206
- // "material finding" — the line the receipt gates key on: composite>=3 on the legacy scale; on v4,
1207
- // banded ABOVE the framework's lowest band (judgment-free re-expression of the same line).
1208
- const material = (f) => v4 ? (f.band != null && aboveLowestBand(fw.manifest, f.band)) : f.composite >= 3;
1206
+ // "material finding" — the line the receipt gates key on: composite>=3 on the legacy scale; on v4, banded on
1207
+ // a rung whose own tone is medium risk or higher (materialBand — by tone, never by position on the ladder).
1208
+ const material = (f) => v4 ? (f.band != null && materialBand(fw.manifest, f.band)) : f.composite >= 3;
1209
1209
  for (const f of parsed.findings) {
1210
1210
  if (material(f) && f.use_check != null && !String(f.use_check.source ?? "").trim())
1211
1211
  return fail(`finding_use_check_source_missing:${f.ordinal}`);
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.1-beta.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.0
4
8
 
5
9
  No changes in this release.
@@ -294,7 +294,7 @@ const MATCH_SHAPES = [
294
294
  // `*` wildcard, and the term extractor must see the word, not the markup.
295
295
  const deMarkup = (s) => String(s ?? "").replace(/\*\*|__/g, "");
296
296
 
297
- // Record COUNTS ride along in parentheses — `PARADISE (424)`, `パラダイス (34) / 파라다이스 (20)`. They are
297
+ // Record COUNTS ride along in parentheses — `HAVEN (424)`, `ヘイヴン (34) / 헤이븐 (20)`. They are
298
298
  // process exhaust rather than the query, and dropping them first is what lets the rest of the string be
299
299
  // recognised as the bare term it is.
300
300
  const deCount = (s) => String(s ?? "").replace(/\s*\(\s*~?\s*[\d,]+\s*\)/g, "");
@@ -302,26 +302,26 @@ const deCount = (s) => String(s ?? "").replace(/\s*\(\s*~?\s*[\d,]+\s*\)/g, "");
302
302
  // A searched term is recognised three ways, in order, and NOT recognised otherwise:
303
303
  // 1. explicitly quoted — `exact "Drivers Haven" (default)`
304
304
  // 2. the whole string, when it carries no query scaffolding at all. This is how the common-law sweep
305
- // writes them and it is the bulk of the corpus: `레이서스 파라다이스`, `赛车天堂`, `RACERZ`,
306
- // `PARADISE WORLD`. Those scripts are caseless, so a capitals rule cannot see them at all.
305
+ // writes them and it is the bulk of the corpus: `드라이버스 헤이븐`, `车手港湾`, `DRIVERZ`,
306
+ // `HAVEN WORLD`. Those scripts are caseless, so a capitals rule cannot see them at all.
307
307
  // "No scaffolding" is checked positively: no match-shape keyword, no residual parenthetical, and
308
308
  // none of the structural punctuation the audit uses for anything OTHER than a query (a record path,
309
309
  // a bracketed class list, an em-dash clause, a key: value tail).
310
- // 3. an ALL-CAPS token — `exact PARADISE`, `*PARADISE / fuzzy`. Two-letter tokens (ZH, JP, DE) are
310
+ // 3. an ALL-CAPS token — `exact HAVEN`, `*HAVEN / fuzzy`. Two-letter tokens (ZH, JP, DE) are
311
311
  // language/jurisdiction codes, not marks, and the register offices above are offices, not marks.
312
312
  const SCAFFOLD = /[[\]`:—]|\/mark\//;
313
313
 
314
314
  // "Does this read as a NAME?" — the property that separates a searched mark from a description of a
315
315
  // search. Every alphabetic token must start upper-case or be in a caseless script (CJK, Hangul, Kana,
316
- // Arabic); a token that is entirely lower-case Latin is prose. So `Racer's Paradise`, `PARADISE WORLD`,
317
- // `Вензи` and `레이서스 파라다이스` qualify and `national register`, `formative phrases` and
316
+ // Arabic); a token that is entirely lower-case Latin is prose. So `Driver's Haven`, `HAVEN WORLD`,
317
+ // `Вензи` and `드라이버스 헤이븐` qualify and `national register`, `formative phrases` and
318
318
  // `one-swap lead-noun neighbours` do not. A property, not a list of banned words — a description nobody
319
319
  // anticipated still fails it.
320
320
  function looksLikeName(s) {
321
321
  const tokens = String(s).split(/\s+/).filter((t) => /\p{L}/u.test(t));
322
322
  if (!tokens.length || tokens.length > 5) return false;
323
323
  // A lone two-letter capital is a language or jurisdiction code (ZH, DE, JP), never a mark. Caseless
324
- // scripts are exempt from the length floor — 天堂 is a whole word in two characters.
324
+ // scripts are exempt from the length floor — 港湾 is a whole word in two characters.
325
325
  const cased = tokens.filter((t) => /\p{Lu}|\p{Ll}/u.test(t));
326
326
  if (cased.length && cased.every((t) => t.replace(/[^\p{L}]/gu, "").length <= 2)) return false;
327
327
  return tokens.every((t) => !/^\p{Ll}/u.test(t.replace(/^[^\p{L}\p{N}]+/u, "")));
@@ -361,7 +361,7 @@ function extractTerm(raw) {
361
361
  const asClasses = (nums) => [...new Set(nums.map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= 45))];
362
362
 
363
363
  // In a SEARCH TERM, classes are read only where they are LABELLED ("cl 9/28/41/42", "cl.25", "class 35") —
364
- // never from a bare parenthesised number, which in this corpus is a record COUNT ("PARADISE (424)").
364
+ // never from a bare parenthesised number, which in this corpus is a record COUNT ("HAVEN (424)").
365
365
  function extractClasses(s) {
366
366
  const m = String(s ?? "").match(/\bcl(?:ass(?:es)?)?\.?\s*([\d]{1,2}(?:\s*[/,]\s*\d{1,2})*)/i);
367
367
  return m ? asClasses(m[1].split(/[/,]/)) : [];
@@ -388,7 +388,7 @@ export function traceKnockout(run, target, events = []) {
388
388
  kind: "verdict",
389
389
  verdict: doc?.batch?.overall ?? runVerdictFromEvents(events) ?? null,
390
390
  marks: marksOf(doc).map((m) => ({ mark: m.name, band: m.rating ?? null, basis: m.basis ?? null })),
391
- note: "A Knockout verdict is the batch's worst band across its marks; each mark's own band and the "
391
+ note: "A Knockout verdict is the highest rating across the batch's marks; each mark's own rating and the "
392
392
  + "one-sentence ground for it are listed here.",
393
393
  });
394
394
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-beta.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -13,7 +13,7 @@
13
13
  "test:full": "npm run test"
14
14
  },
15
15
  "dependencies": {
16
- "@modelcontextprotocol/sdk": "^1.29.0",
16
+ "@modelcontextprotocol/sdk": "^1.31.0",
17
17
  "jose": "^6.2.3"
18
18
  },
19
19
  "engines": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.4.1-beta.0",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -73,7 +73,7 @@
73
73
  "globals": "^17.11.0"
74
74
  },
75
75
  "dependencies": {
76
- "@modelcontextprotocol/sdk": "^1.29.0",
76
+ "@modelcontextprotocol/sdk": "^1.31.0",
77
77
  "buffers": "file:vendor/buffers",
78
78
  "exceljs": "^4.4.0",
79
79
  "jose": "^6.2.3",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.0",
5
+ "version": "0.4.1-beta.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -112,6 +112,9 @@ export function makeCountProbe(deps) {
112
112
  return { ok: false, total: null, probe: "cheap",
113
113
  reason: "the search response carried no usable total_hits, so nothing was counted — the number is UNKNOWN, which is not the same as none" };
114
114
  }
115
- return { ok: true, total: parsed.total_hits, probe: "cheap", reason: null };
115
+ // What the total counts rides with it when the register said (a provider's `total_counts`, see
116
+ // enumerate.mjs): a per-class or per-spelling count keeps the register's number and says so.
117
+ return { ok: true, total: parsed.total_hits, probe: "cheap", reason: null,
118
+ ...(typeof parsed.total_counts === "string" ? { total_counts: parsed.total_counts } : {}) };
116
119
  };
117
120
  }
@@ -220,6 +220,13 @@ export function makeEnumerate(deps) {
220
220
  // against the tuned ceiling below and can only ever narrow it — `CLEAROTRON_ENUMERATE_CEILING` cannot
221
221
  // raise a band past a window the vendor answers with an HTTP 400.
222
222
  ceilingFor = null,
223
+ // ── HOW MANY NAMES ONE REQUEST CARRIES, WHEN THAT IS A PROPERTY OF THE QUERY ──────────────────
224
+ //
225
+ // `namesChunkDefault` models a provider with one width for every names band. A register can take a
226
+ // list for one kind of question and not another — signa takes up to 100 exact spellings as one ranked
227
+ // list, and one term per request for everything else. `(params) => number|null`; `null` means the
228
+ // shape declares nothing and the default (or `CLEAROTRON_ENUMERATE_NAMES_CHUNK`) stands.
229
+ namesChunkFor = null,
223
230
  } = deps;
224
231
 
225
232
  // ── the I/O seam: a network REJECTION degrades like a 503, it does not abort the stage ────────────
@@ -288,19 +295,17 @@ export function makeEnumerate(deps) {
288
295
  // asks a register "how many", so the two can never drift. `null` on any failure is preserved
289
296
  // exactly: a term whose probe failed is dispositioned `error`, never `verified-zero`.
290
297
  const countHits = makeCountProbe({ search, count, capabilities: { countProbe }, cheapCountParams });
291
- const probeTermCount = async (auth, params, term, tctx) => {
292
- const c = await countHits(auth, { ...params, [namesKey]: [term] }, tctx);
293
- return c.ok ? c.total : null;
294
- };
295
298
 
296
299
  async function countFirstRescue(auth, params, names, ceiling, stackTotal, tctx, incomplete) {
297
300
  const term_counts = {};
298
301
  for (const t of names) {
299
- const n = await probeTermCount(auth, params, t, tctx);
302
+ const probe = await countHits(auth, { ...params, [namesKey]: [t] }, tctx);
303
+ const n = probe.ok ? probe.total : null;
304
+ const said = countsOf(probe); // what this spelling's own count counts, when the register said
300
305
  if (n == null) { term_counts[t] = { total_hits: null, disposition: "error" }; continue; }
301
- if (n === 0) term_counts[t] = { total_hits: 0, disposition: "verified-zero" };
302
- else if (n > ceiling) term_counts[t] = { total_hits: n, disposition: "crowd" };
303
- else term_counts[t] = { total_hits: n, disposition: "unenumerated" };
306
+ if (n === 0) term_counts[t] = { total_hits: 0, disposition: "verified-zero", ...said };
307
+ else if (n > ceiling) term_counts[t] = { total_hits: n, disposition: "crowd", ...said };
308
+ else term_counts[t] = { total_hits: n, disposition: "unenumerated", ...said };
304
309
  }
305
310
  const records = [];
306
311
  const tally = { "verified-zero": 0, enumerated: 0, crowd: 0, unenumerated: 0, error: 0 };
@@ -344,20 +349,21 @@ export function makeEnumerate(deps) {
344
349
  for (const c of classes) {
345
350
  const probe = await countHits(auth, { ...params, nice_classes: [c] }, tctx);
346
351
  const n = probe.ok ? probe.total : null;
352
+ const said = countsOf(probe); // what this class's own count counts, when the register said (ruling 712)
347
353
  if (n == null) { class_counts[c] = { total_hits: null, disposition: "error" }; continue; }
348
- if (n === 0) class_counts[c] = { total_hits: 0, disposition: "verified-zero" };
349
- else if (n > ceiling) class_counts[c] = { total_hits: n, disposition: "crowd" };
350
- else tractable.push({ c, n });
354
+ if (n === 0) class_counts[c] = { total_hits: 0, disposition: "verified-zero", ...said };
355
+ else if (n > ceiling) class_counts[c] = { total_hits: n, disposition: "crowd", ...said };
356
+ else tractable.push({ c, n, said });
351
357
  }
352
358
  tractable.sort((a, b) => a.n - b.n); // cheapest first — maximizes fully-enumerated legs under the budget
353
359
  const merged = new Map();
354
- for (const { c, n } of tractable) {
355
- if (merged.size + n > ceiling) { class_counts[c] = { total_hits: n, disposition: "unenumerated" }; continue; }
360
+ for (const { c, n, said } of tractable) {
361
+ if (merged.size + n > ceiling) { class_counts[c] = { total_hits: n, disposition: "unenumerated", ...said }; continue; }
356
362
  const r = await enumerate(auth, { ...params, nice_classes: [c] }, tctx);
357
363
  const parsed = isToolError(r) ? null : parseToolText(r);
358
- if (!parsed) { class_counts[c] = { total_hits: n, disposition: "error" }; continue; }
364
+ if (!parsed) { class_counts[c] = { total_hits: n, disposition: "error", ...said }; continue; }
359
365
  if (parsed.state === "enumerated") {
360
- class_counts[c] = { total_hits: n, disposition: "enumerated" };
366
+ class_counts[c] = { total_hits: n, disposition: "enumerated", ...said };
361
367
  for (const rec of (parsed.records ?? [])) {
362
368
  const k = recordKeyOf(rec);
363
369
  if (!merged.has(k)) merged.set(k, rec);
@@ -365,7 +371,7 @@ export function makeEnumerate(deps) {
365
371
  } else {
366
372
  // drifted past the probe count (provider re-count) or hit the provider window — honest per-leg state
367
373
  const m = parsed.total_hits ?? n;
368
- class_counts[c] = { total_hits: m, disposition: m > ceiling ? "crowd" : "error" };
374
+ class_counts[c] = { total_hits: m, disposition: m > ceiling ? "crowd" : "error", ...(Number.isFinite(parsed.total_hits) ? countsOf(parsed) : said) };
369
375
  }
370
376
  }
371
377
  const records = [...merged.values()];
@@ -399,7 +405,7 @@ export function makeEnumerate(deps) {
399
405
  // half's own reason, as a names window does: a clean never ships over a part that did not run.
400
406
  // `region_split` rides the answer, naming the timeout that started it and the size of every part that
401
407
  // answered, so a reader can see the question was asked in pieces and where.
402
- async function regionHalves(auth, params, tctx, { ceiling, incomplete, cause }) {
408
+ async function regionHalves(auth, params, tctx, { ceiling, incomplete, cause, hear = () => {} }) {
403
409
  const regions = params.regions;
404
410
  const mid = Math.ceil(regions.length / 2);
405
411
  const halves = [regions.slice(0, mid), regions.slice(mid)];
@@ -435,9 +441,10 @@ export function makeEnumerate(deps) {
435
441
  // A half that timed out while its sibling answered is the one place fewer regions may help: it is
436
442
  // halved in turn, under the same rule.
437
443
  const r = timedOut({ r: first })
438
- ? await regionHalves(auth, { ...params, regions: half }, tctx, { ceiling, incomplete, cause: parseToolText(first)?.reason ?? cause })
444
+ ? await regionHalves(auth, { ...params, regions: half }, tctx, { ceiling, incomplete, cause: parseToolText(first)?.reason ?? cause, hear })
439
445
  : first;
440
446
  const parsed = isToolError(r) ? null : parseToolText(r);
447
+ hear(parsed);
441
448
  const which = `the ${half.length}-region half ${half[0]}${half.length > 1 ? `…${half[half.length - 1]}` : ""}`;
442
449
  // A stall from a deeper split keeps its own reason, stall first, so the executor reads it however
443
450
  // deep the split went and however short the reason is later cut.
@@ -464,8 +471,22 @@ export function makeEnumerate(deps) {
464
471
  ...(screenLift ? { screen_lift: screenLift } : {}), region_split: { cause, parts } }, null, 2) };
465
472
  }
466
473
 
467
- async function enumerate(auth, params, tctx, { split = true } = {}) {
474
+ // ── WHAT THE TOTAL COUNTS, STATED ONCE FOR THE WHOLE ANSWER ────────────────────────────────────────
475
+ //
476
+ // A register can say that a total counts records, one per country a mark covers, rather than marks
477
+ // (`total_counts` on a provider's parsed answer). The answer to the question states it, whichever of the
478
+ // exits below produced it: the page loop, the name windows, the region halves and the rescues each tell
479
+ // the question what they heard, and it is stamped here, on the one way out. Recorded only: the ceiling
480
+ // still reads the register's own number (ruled 2026-10-02). An answer with no `total_counts` is one whose
481
+ // register said nothing about it, never a count of marks.
482
+ async function enumerate(auth, params, tctx, opts = {}) {
483
+ const said = { totalCounts: null };
484
+ return stampTotalCounts(await enumerateQuestion(auth, params, tctx, opts, said), said.totalCounts);
485
+ }
486
+
487
+ async function enumerateQuestion(auth, params, tctx, { split = true } = {}, said = { totalCounts: null }) {
468
488
  if (!hasAnyElement(params)) return { type: "text", text: missingElementError };
489
+ const hear = (p) => { if (typeof p?.total_counts === "string") said.totalCounts = p.total_counts; };
469
490
 
470
491
  // The tuned ceiling, then narrowed by any window the QUERY SHAPE imposes. Min, never max:
471
492
  // a shape window is a vendor limit, and a band that pages past it does not return more records, it
@@ -499,7 +520,9 @@ export function makeEnumerate(deps) {
499
520
  // descriptor total — `count` is the deduped truth); the resource ceiling applies to the RUNNING total
500
521
  // so a crowd still returns an incomplete descriptor. ANY chunk error/incomplete makes the WHOLE slice
501
522
  // incomplete — a clean can never ship over a partially-executed slice.
502
- const namesChunk = envInt("CLEAROTRON_ENUMERATE_NAMES_CHUNK", namesChunkDefault);
523
+ const shapeChunk = typeof namesChunkFor === "function" ? namesChunkFor(params) : null;
524
+ const namesChunk = Number.isFinite(shapeChunk) && shapeChunk >= 1
525
+ ? Math.floor(shapeChunk) : envInt("CLEAROTRON_ENUMERATE_NAMES_CHUNK", namesChunkDefault);
503
526
  const allNames = Array.isArray(params[namesKey]) ? params[namesKey].filter(Boolean) : null;
504
527
  if (allNames && allNames.length > namesChunk) {
505
528
  const merged = new Map();
@@ -518,6 +541,7 @@ export function makeEnumerate(deps) {
518
541
  const part = allNames.slice(i * namesChunk, (i + 1) * namesChunk);
519
542
  const r = await enumerate(auth, { ...params, [namesKey]: part }, tctx);
520
543
  const parsed = parseToolText(r);
544
+ hear(parsed);
521
545
  if (isToolError(r) || !parsed) {
522
546
  return incomplete(totalSum, merged.size, [...merged.values()],
523
547
  `chunk ${i + 1}/${chunks} (${part.length} names) failed: ${String(r?.text ?? "unparseable").slice(0, 140)}`,
@@ -599,10 +623,11 @@ export function makeEnumerate(deps) {
599
623
  + `Provider's own words: ${clipProviderText(c?.reason ?? "", COUNT_PROBE_BUDGET)}`,
600
624
  { crowd_basis: "provider-refused-count", count_unavailable: true });
601
625
  }
602
- if (splittable && isGatewayTimeout(c?.reason)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason) });
626
+ if (splittable && isGatewayTimeout(c?.reason)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason), hear });
603
627
  return incomplete(0, 0, [],
604
628
  `provider error on the count probe before enumeration: ${String(c?.reason ?? "count unavailable")}`, timeoutMark(c?.reason));
605
629
  }
630
+ hear(c);
606
631
  total = Number.isFinite(c.total) ? c.total : 0;
607
632
  probeTotal = total;
608
633
  if (total > ceiling) {
@@ -625,11 +650,12 @@ export function makeEnumerate(deps) {
625
650
  for (let page = 0; ; page += 1) {
626
651
  const r = await search(auth, { ...params, ...pageParams(page, pageSize, prevParsed) }, tctx);
627
652
  if (isToolError(r)) {
628
- if (splittable && isGatewayTimeout(r.text)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text) });
653
+ if (splittable && isGatewayTimeout(r.text)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text), hear });
629
654
  return incomplete(total, results.length, results, `provider error during enumeration (page ${page}): ${String(r.text ?? "")}`, timeoutMark(r.text));
630
655
  }
631
656
  const parsed = parseToolText(r);
632
657
  if (!parsed) return incomplete(total, results.length, results, `unparseable search response during enumeration (page ${page})`);
658
+ hear(parsed);
633
659
  // ── the number the completeness claim rests on must BE a number ────────────────────────────────
634
660
  // On the "cheap" seam the page response IS the count — the ceiling is tested off `total_hits` and
635
661
  // nothing else. A response that parsed but carries no usable total therefore cannot support a
@@ -811,3 +837,16 @@ export function makeEnumerate(deps) {
811
837
 
812
838
  return { enumerate, countFirstRescue, classSplitRescue };
813
839
  }
840
+
841
+ /** `{ total_counts }` from a probe or a parsed answer that carries it, else nothing to spread. PURE. */
842
+ function countsOf(p) {
843
+ return typeof p?.total_counts === "string" ? { total_counts: p.total_counts } : {};
844
+ }
845
+
846
+ /** The answer with what its total counts stated on it; an error, or nothing heard, passes unchanged. */
847
+ function stampTotalCounts(r, totalCounts) {
848
+ if (!totalCounts || isToolError(r)) return r;
849
+ const parsed = parseToolText(r);
850
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return r;
851
+ return { ...r, text: JSON.stringify({ ...parsed, total_counts: totalCounts }, null, 2) };
852
+ }
@@ -509,6 +509,8 @@ export function makeExecutePlan(deps) {
509
509
  // the resolution the sweep actually ran on, verbatim — a reader must be able to see WHICH
510
510
  // applicant styling was asked for, on a verified zero as much as on an unverified one.
511
511
  ...(parsed?.owner_resolution ? { owner_resolution: parsed.owner_resolution } : {}),
512
+ // what the counted total counts, when the register said (enumerate.mjs, "what the total counts")
513
+ ...(counted && typeof parsed?.total_counts === "string" ? { total_counts: parsed.total_counts } : {}),
512
514
  reason: ownerUnverified ? unresolvedOwnerCountReason(e.term ?? (e.terms ?? [])[0], coveredBy)
513
515
  : parsed ? descriptorReason
514
516
  : `provider error on the count probe (after one in-tool retry): ${clipProviderText(r?.text ?? "", 100)}` });
@@ -38,7 +38,7 @@ export const CAPABILITIES = Object.freeze({
38
38
  countStatusFilter: "none",
39
39
  // HTTP-414 URI guard: the whole query is a GET query string, so ~80 backtick-quoted names (≈2–4KB
40
40
  // encoded) is the safe OR-stack. This MIRRORS the executor's ENUMERATE_NAMES_CHUNK_DEFAULT — the
41
- // planner must never dictate an OR-stack the executor would have to chunk-rescue (Wilderness 414).
41
+ // planner must never dictate an OR-stack the executor would have to chunk-rescue (Open Country 414).
42
42
  maxOrWidth: 80,
43
43
  // `nice-class` clauses ride the same query string (implicit OR within the field) — one call, no fan-out.
44
44
  classFilter: "native",
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.1-beta.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.0
4
8
 
5
9
  No changes in this release.
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-beta.0",
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).",
7
7
  "type": "module",
8
8
  "main": "bridge.mjs",
9
9
  "dependencies": {
10
- "@modelcontextprotocol/sdk": "^1.29.0"
10
+ "@modelcontextprotocol/sdk": "^1.31.0"
11
11
  },
12
12
  "engines": { "node": ">=22.13.0" }
13
13
  }
@@ -120,9 +120,24 @@ export const CAPABILITIES = Object.freeze({
120
120
  // NOTE the key. `filters.status` does not exist and the API rejects unknown filter keys outright
121
121
  // (`HTTP 400 Unrecognized key: status`) — which is what core.js was sending until this issue.
122
122
  countStatusFilter: "native",
123
- // The request carries ONE `query` string. There is no OR-stack surface at all — an N-name band is N
124
- // calls, so the planner must emit one term per entry.
123
+ // The request carries ONE `query` string for every predicate but one, so the general OR width stays 1:
124
+ // a phonetic, wildcard or owner band is one call per term, and anything that reads this width as
125
+ // "no OR at all" (the supplemental batch's one-question-per-name split) keeps reading it that way.
125
126
  maxOrWidth: 1,
127
+ // ── THE ONE PREDICATE THAT TAKES A LIST: exact spellings, up to 100 per request ─────────────────
128
+ //
129
+ // A list in `q` is a ranked search over every spelling through the channels the request names, and
130
+ // each record says which spellings found it (`match.terms`). The register takes up to 100 spellings
131
+ // when the channels are only `identical` and `lookalike` — which is exactly what an exact question
132
+ // sends — and 10 otherwise. Measured live against one search per spelling, by record id and paged to
133
+ // exhaustion: the same records on the exact band at 32 and at 100 spellings, look-alike-only records
134
+ // included, with every record's `match.terms` naming exactly the spellings that found it alone. The
135
+ // figures are on the tracker. The one documented difference — a list skips the word-by-word fuzzy
136
+ // retry — belongs to a channel the exact question does not send.
137
+ //
138
+ // So the planner stacks an exact spelling band at this width, and the kernel sends each stack as one
139
+ // request (`namesChunkFor` in core.js); every other predicate stays at `maxOrWidth`.
140
+ exactOrWidth: 100,
126
141
  // filters.nice_classes[] is a top-level OR filter — one call, no fan-out.
127
142
  classFilter: "native",
128
143
  // ── THE GOODS-AND-SERVICES TEXT FILTER ──────────────────────────────────────────────────────────