clearotron 0.4.0 → 0.4.1-beta.1

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 (132) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/bin/README.md +1 -1
  3. package/bin/clearotron.mjs +2 -0
  4. package/bin/mcp.mjs +80 -0
  5. package/bin/start.mjs +14 -1
  6. package/build-info.json +2 -2
  7. package/demo/README.md +1 -1
  8. package/docs/SECURITY-OWASP.md +21 -13
  9. package/docs/SECURITY.md +20 -14
  10. package/docs/architecture/04-configuration-reference.md +3 -3
  11. package/docs/architecture/05-config-governance.md +1 -1
  12. package/docs/architecture/06-operations-runbook.md +5 -4
  13. package/docs/architecture/08-development-guide.md +3 -1
  14. package/docs/architecture/09-security-and-data.md +16 -11
  15. package/driver/CHANGELOG.md +29 -0
  16. package/driver/binding-layers.mjs +28 -6
  17. package/driver/citation-census.json +3 -3
  18. package/driver/common-law-coverage-status.mjs +52 -0
  19. package/driver/common-law-receipts.mjs +1 -1
  20. package/driver/compare.mjs +1 -1
  21. package/driver/connotation-search.mjs +5 -5
  22. package/driver/contract-audit.mjs +6 -6
  23. package/driver/contract-dictation-registry.mjs +1 -1
  24. package/driver/contract-e3-backlog.mjs +18 -17
  25. package/driver/contract-vocabulary.mjs +145 -132
  26. package/driver/declination-call.mjs +5 -1
  27. package/driver/degraded-parts.mjs +10 -3
  28. package/driver/dev-portal.mjs +2 -1
  29. package/driver/dispatch-record.mjs +19 -0
  30. package/driver/doubt-closure-tool.mjs +2 -2
  31. package/driver/driver.config.mjs +39 -6
  32. package/driver/engagement-receipt.mjs +1 -1
  33. package/driver/engine/CONTRACT.md +41 -1
  34. package/driver/engine/anthropic-agent.mjs +56 -4
  35. package/driver/engine/mcp/codex-config.mjs +8 -3
  36. package/driver/engine/mcp/coverage-server.mjs +1 -1
  37. package/driver/engine/mcp/gather-config.mjs +1 -1
  38. package/driver/engine/mcp/recording-server.mjs +32 -5
  39. package/driver/engine/mcp/stdio-server.mjs +45 -7
  40. package/driver/engine/mcp/supplemental.mjs +26 -1
  41. package/driver/engine/openai-agent.mjs +113 -20
  42. package/driver/engine/session-record.mjs +223 -0
  43. package/driver/enqueue-schema.mjs +2 -2
  44. package/driver/findings-model.mjs +4 -4
  45. package/driver/flag-snapshot.mjs +2 -1
  46. package/driver/form-neighbourhood.mjs +7 -7
  47. package/driver/frame-diff-model.mjs +3 -3
  48. package/driver/framework.mjs +22 -0
  49. package/driver/gateway.mjs +96 -14
  50. package/driver/jx.mjs +1 -1
  51. package/driver/knockout-review-record.mjs +55 -1
  52. package/driver/named-band.mjs +4 -0
  53. package/driver/owner-screen.mjs +7 -4
  54. package/driver/package.json +1 -1
  55. package/driver/phase0.mjs +13 -2
  56. package/driver/pipeline-knockout.mjs +15 -13
  57. package/driver/pipeline.mjs +132 -48
  58. package/driver/portal-families.mjs +3 -3
  59. package/driver/portal-service.mjs +6 -5
  60. package/driver/predelivery-lint.mjs +19 -19
  61. package/driver/products.mjs +1 -1
  62. package/driver/profiles.mjs +4 -3
  63. package/driver/publish/index.mjs +1 -1
  64. package/driver/publish/knockout.mjs +5 -4
  65. package/driver/publish/office-record-links.mjs +17 -2
  66. package/driver/publish/publish-inputs.mjs +3 -3
  67. package/driver/publish/render-knockout.mjs +17 -5
  68. package/driver/publish/render.mjs +4 -2
  69. package/driver/publish/xlsx.mjs +1 -1
  70. package/driver/record-fold.mjs +177 -0
  71. package/driver/register-count.mjs +20 -3
  72. package/driver/register-coverage.mjs +1 -1
  73. package/driver/register-plan.mjs +83 -18
  74. package/driver/register-records.mjs +7 -0
  75. package/driver/registry-fidelity.mjs +2 -2
  76. package/driver/repairs.mjs +1 -1
  77. package/driver/report-card-record.mjs +4 -4
  78. package/driver/result-noun-fields.mjs +3 -3
  79. package/driver/scope-facts.mjs +11 -3
  80. package/driver/scope-rules.mjs +2 -2
  81. package/driver/search-policy.mjs +20 -1
  82. package/driver/skills/clearance-common-law/SKILL.md +22 -22
  83. package/driver/skills/clearance-register/digest.md +3 -3
  84. package/driver/skills/clearance-register/register-recipes.md +11 -11
  85. package/driver/skills/clearance-register/status-rules.md +1 -1
  86. package/driver/skills/clearance-register/unit.md +1 -1
  87. package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
  88. package/driver/skills/clearance-search/phase2-execution.md +6 -5
  89. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  90. package/driver/skills/clearance-search/worked-examples.md +39 -39
  91. package/driver/skills/clearance-variants/SKILL.md +16 -16
  92. package/driver/skills/frame-diff/SKILL.md +2 -2
  93. package/driver/stage-context.mjs +36 -1
  94. package/driver/stages-knockout.mjs +6 -5
  95. package/driver/stages.mjs +30 -30
  96. package/driver/suite-census.json +259 -55
  97. package/driver/synthesis-record.mjs +5 -0
  98. package/driver/unit-inventory.mjs +17 -28
  99. package/driver/verify.mjs +7 -7
  100. package/mcp-server/CHANGELOG.md +8 -0
  101. package/mcp-server/CONNECT.md +9 -3
  102. package/mcp-server/demo-mode.mjs +10 -0
  103. package/mcp-server/lib/evidence.mjs +8 -8
  104. package/mcp-server/lib/knockout.mjs +1 -1
  105. package/mcp-server/lib/runs.mjs +7 -1
  106. package/mcp-server/lib/scrub.mjs +1 -1
  107. package/mcp-server/package.json +2 -2
  108. package/mcp-server/packs/README.md +1 -1
  109. package/mcp-server/server.mjs +16 -2
  110. package/package.json +4 -3
  111. package/portal-ui/package.json +3 -3
  112. package/providers/_shared/count.mjs +9 -2
  113. package/providers/_shared/enumerate.mjs +125 -41
  114. package/providers/_shared/execute-plan.mjs +15 -4
  115. package/providers/corsearch/src/capabilities.js +1 -1
  116. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  117. package/providers/oauth-mcp-bridge/package.json +2 -2
  118. package/providers/signa/src/capabilities.js +32 -2
  119. package/providers/signa/src/core.js +262 -36
  120. package/scripts/band-shape-probe.mjs +2 -2
  121. package/scripts/deprecate-below.mjs +28 -4
  122. package/scripts/e2e-first-time.mjs +17 -2
  123. package/scripts/env-audit.mjs +3 -2
  124. package/scripts/freeze-example-run.mjs +10 -10
  125. package/scripts/mcp-registry-listing.mjs +199 -0
  126. package/scripts/mint-plugin-folder.mjs +65 -0
  127. package/scripts/release-version.mjs +44 -0
  128. package/scripts/score.mjs +1 -1
  129. package/shared/register-floor.mjs +19 -0
  130. package/shared/scope.mjs +18 -2
  131. package/shared/trigger-lane.mjs +1 -1
  132. package/skills/clearotron-ops/SKILL.md +81 -24
@@ -54,7 +54,7 @@
54
54
  import {
55
55
  BATCH_SCREEN_CHUNK, chunk, classifyStatus, isAllClass, normalizeBrandRow, screenVerdict,
56
56
  } from "./screen.mjs";
57
- import { makeCountProbe, parseToolText, isToolError } from "./count.mjs";
57
+ import { makeCountProbe, parseToolText, isToolError } from "./count.mjs"; import { floorOf, moreThan } from "../../shared/register-floor.mjs";
58
58
  import { guardCountCall, guardToolCall } from "./transport-guard.mjs";
59
59
  import { clipProviderText } from "./provider-text.mjs"; // — keep the discriminator
60
60
 
@@ -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 ────────────
@@ -249,6 +256,10 @@ export function makeEnumerate(deps) {
249
256
  // error on the count probe rather than as a countable total. Optional: a provider that does not
250
257
  // declare one keeps the previous behaviour exactly.
251
258
  cardinalityRefusal = null,
259
+ // — the provider's own words for refusing a LIST of spellings it would answer one by one (a ranked list
260
+ // judged too complex to run). Such a list is asked again one spelling at a time, as it was before lists
261
+ // were sent at all, never reported as a search that could not be made. Optional: absent, nothing changes.
262
+ listRefusal = null,
252
263
  } = capabilities;
253
264
 
254
265
  if (countProbe === "endpoint" && typeof count !== "function") {
@@ -258,8 +269,7 @@ export function makeEnumerate(deps) {
258
269
  throw new Error('[enumerate-kernel] capabilities.screenSource === "search-row" requires a rowScreen() dependency');
259
270
  }
260
271
 
261
- const crowdReason = (total, ceiling) =>
262
- `total_hits ${total} exceeds the enumerate ceiling ${ceiling} — this is a CROWD, not a named exact/near band. Record it as a count+sample descriptor and hand it up to judgment; the funnel does NOT narrow-and-retry a crowd here. Whether a narrower NAMED enumeration is warranted, and whether this slice is material, is judgment's call (Layer B) — never accept it as clean.`;
272
+ const crowdReason = (total, ceiling) => `total_hits ${total} exceeds the enumerate ceiling ${ceiling} — ${CROWD_IS}`;
263
273
 
264
274
  // ── count-first per-term rescue ───────────────────────────────────────────────────────────────────
265
275
  // A multi-name OR-stack that crowds over the ceiling used to return ONE blind `incomplete` — a rare
@@ -288,19 +298,19 @@ export function makeEnumerate(deps) {
288
298
  // asks a register "how many", so the two can never drift. `null` on any failure is preserved
289
299
  // exactly: a term whose probe failed is dispositioned `error`, never `verified-zero`.
290
300
  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
301
 
296
- async function countFirstRescue(auth, params, names, ceiling, stackTotal, tctx, incomplete) {
302
+ // `floor` is the register's "at least N" when it answered the whole stack with one instead of a count
303
+ // (shared/register-floor.mjs): the stack's total then stays null and the figure rides beside it.
304
+ async function countFirstRescue(auth, params, names, ceiling, stackTotal, tctx, incomplete, floor = null) {
297
305
  const term_counts = {};
298
306
  for (const t of names) {
299
- const n = await probeTermCount(auth, params, t, tctx);
300
- 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" };
307
+ const probe = await countHits(auth, { ...params, [namesKey]: [t] }, tctx);
308
+ const n = probe.ok ? probe.total : null;
309
+ const said = countsOf(probe); // what this spelling's own count counts, when the register said
310
+ if (n == null) { term_counts[t] = floorCount(probe) ?? { total_hits: null, disposition: "error" }; continue; }
311
+ if (n === 0) term_counts[t] = { total_hits: 0, disposition: "verified-zero", ...said };
312
+ else if (n > ceiling) term_counts[t] = { total_hits: n, disposition: "crowd", ...said };
313
+ else term_counts[t] = { total_hits: n, disposition: "unenumerated", ...said };
304
314
  }
305
315
  const records = [];
306
316
  const tally = { "verified-zero": 0, enumerated: 0, crowd: 0, unenumerated: 0, error: 0 };
@@ -317,13 +327,14 @@ export function makeEnumerate(deps) {
317
327
  // the identical question, that is the best case a matter can have (a mark nobody has registered)
318
328
  // producing a run that searches almost nothing. The ordinary search path has always returned
319
329
  // `enumerated` for a zero-record answer; this rescue path was the one place that did not.
320
- if (unresolved === 0) {
330
+ // A stack the register answered with a floor is never a complete zero, whatever its spellings say.
331
+ if (unresolved === 0 && floor === null) {
321
332
  // every term verified zero ⇒ nobody has filed any of these names: a complete band whose answer is zero.
322
333
  return { type: "text", text: JSON.stringify({ state: "enumerated", total_hits: stackTotal, count: records.length, records, term_counts }, null, 2) };
323
334
  }
324
- return incomplete(stackTotal, records.length, records,
325
- `stack total_hits ${stackTotal} exceeds the enumerate ceiling ${ceiling}; each spelling was counted and none was read (${names.length} terms: ${tally["verified-zero"]} verified-zero, ${tally.unenumerated} counted and not read, ${tally.crowd} crowd, ${tally.error} error). term_counts carries each spelling's count: which to read, narrow or leave is the reading step's decision, and a populated term is never recorded 0.`,
326
- { term_counts, records });
335
+ return incomplete(floor === null ? stackTotal : null, records.length, records,
336
+ `stack total_hits ${floor === null ? stackTotal : moreThan(floor)} exceeds the enumerate ceiling ${ceiling}; each spelling was counted and none was read (${names.length} terms: ${tally["verified-zero"]} verified-zero, ${tally.unenumerated} counted and not read, ${tally.crowd} crowd, ${tally.error} error). term_counts carries each spelling's count: which to read, narrow or leave is the reading step's decision, and a populated term is never recorded 0.`,
337
+ { term_counts, records, ...floorStamp(floor) });
327
338
  }
328
339
 
329
340
  // ── count-first per-CLASS rescue ──────────────────────────────────────────────────────────────────
@@ -338,26 +349,27 @@ export function makeEnumerate(deps) {
338
349
  // a populated class can never be recorded 0 — it is in `class_counts`, and if tractable its records
339
350
  // are carried. NOTE a record filed in several classes sits in each leg's enumeration; the merge
340
351
  // dedupes it, so per-class totals may legitimately sum past the stack total.
341
- async function classSplitRescue(auth, params, classes, ceiling, stackTotal, tctx, incomplete) {
352
+ async function classSplitRescue(auth, params, classes, ceiling, stackTotal, tctx, incomplete, floor = null) {
342
353
  const class_counts = {};
343
354
  const tractable = [];
344
355
  for (const c of classes) {
345
356
  const probe = await countHits(auth, { ...params, nice_classes: [c] }, tctx);
346
357
  const n = probe.ok ? probe.total : null;
347
- 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 });
358
+ const said = countsOf(probe); // what this class's own count counts, when the register said (ruling 712)
359
+ if (n == null) { class_counts[c] = floorCount(probe) ?? { total_hits: null, disposition: "error" }; continue; }
360
+ if (n === 0) class_counts[c] = { total_hits: 0, disposition: "verified-zero", ...said };
361
+ else if (n > ceiling) class_counts[c] = { total_hits: n, disposition: "crowd", ...said };
362
+ else tractable.push({ c, n, said });
351
363
  }
352
364
  tractable.sort((a, b) => a.n - b.n); // cheapest first — maximizes fully-enumerated legs under the budget
353
365
  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; }
366
+ for (const { c, n, said } of tractable) {
367
+ if (merged.size + n > ceiling) { class_counts[c] = { total_hits: n, disposition: "unenumerated", ...said }; continue; }
356
368
  const r = await enumerate(auth, { ...params, nice_classes: [c] }, tctx);
357
369
  const parsed = isToolError(r) ? null : parseToolText(r);
358
- if (!parsed) { class_counts[c] = { total_hits: n, disposition: "error" }; continue; }
370
+ if (!parsed) { class_counts[c] = { total_hits: n, disposition: "error", ...said }; continue; }
359
371
  if (parsed.state === "enumerated") {
360
- class_counts[c] = { total_hits: n, disposition: "enumerated" };
372
+ class_counts[c] = { total_hits: n, disposition: "enumerated", ...said };
361
373
  for (const rec of (parsed.records ?? [])) {
362
374
  const k = recordKeyOf(rec);
363
375
  if (!merged.has(k)) merged.set(k, rec);
@@ -365,7 +377,7 @@ export function makeEnumerate(deps) {
365
377
  } else {
366
378
  // drifted past the probe count (provider re-count) or hit the provider window — honest per-leg state
367
379
  const m = parsed.total_hits ?? n;
368
- class_counts[c] = { total_hits: m, disposition: m > ceiling ? "crowd" : "error" };
380
+ class_counts[c] = { total_hits: m, disposition: m > ceiling ? "crowd" : "error", ...(Number.isFinite(parsed.total_hits) ? countsOf(parsed) : said) };
369
381
  }
370
382
  }
371
383
  const records = [...merged.values()];
@@ -374,14 +386,14 @@ export function makeEnumerate(deps) {
374
386
  const unresolved = Object.values(class_counts).filter(isUnresolvedCount).length;
375
387
  // Same rule, same reason, same correction as the OR-stack rescue above: a stack in which every class
376
388
  // came back a verified zero is a complete band whose answer is zero, not a question nobody answered.
377
- if (unresolved === 0) {
389
+ if (unresolved === 0 && floor === null) {
378
390
  // every class resolved to verified-zero or fully-enumerated ⇒ the union of per-class enumerations
379
391
  // IS the complete stack (every record carries ≥1 in-filter class) — a true band, the class rescue.
380
392
  return { type: "text", text: JSON.stringify({ state: "enumerated", total_hits: stackTotal, count: records.length, records, class_counts }, null, 2) };
381
393
  }
382
- return incomplete(stackTotal, records.length, records,
383
- `owner-scoped total_hits ${stackTotal} exceeds the enumerate ceiling ${ceiling}; count-first per-CLASS rescue ran (${classes.length} classes: ${tally["verified-zero"]} verified-zero, ${tally.enumerated} enumerated with records carried, ${tally.crowd} crowd, ${tally.unenumerated} unenumerated on budget, ${tally.error} error). Saturated class legs stay a CROWD descriptor for judgment; class_counts is the per-class truth — a populated class leg is never recorded 0 and never "unopened".`,
384
- { class_counts, records });
394
+ return incomplete(floor === null ? stackTotal : null, records.length, records,
395
+ `owner-scoped total_hits ${floor === null ? stackTotal : moreThan(floor)} exceeds the enumerate ceiling ${ceiling}; count-first per-CLASS rescue ran (${classes.length} classes: ${tally["verified-zero"]} verified-zero, ${tally.enumerated} enumerated with records carried, ${tally.crowd} crowd, ${tally.unenumerated} unenumerated on budget, ${tally.error} error). Saturated class legs stay a CROWD descriptor for judgment; class_counts is the per-class truth — a populated class leg is never recorded 0 and never "unopened".`,
396
+ { class_counts, records, ...floorStamp(floor) });
385
397
  }
386
398
 
387
399
  // ── A QUESTION SLOWER THAN THE REGISTER'S GATEWAY WAITS IS ASKED AGAIN IN REGION HALVES ───────────────
@@ -399,7 +411,7 @@ export function makeEnumerate(deps) {
399
411
  // half's own reason, as a names window does: a clean never ships over a part that did not run.
400
412
  // `region_split` rides the answer, naming the timeout that started it and the size of every part that
401
413
  // answered, so a reader can see the question was asked in pieces and where.
402
- async function regionHalves(auth, params, tctx, { ceiling, incomplete, cause }) {
414
+ async function regionHalves(auth, params, tctx, { ceiling, incomplete, cause, hear = () => {} }) {
403
415
  const regions = params.regions;
404
416
  const mid = Math.ceil(regions.length / 2);
405
417
  const halves = [regions.slice(0, mid), regions.slice(mid)];
@@ -435,9 +447,10 @@ export function makeEnumerate(deps) {
435
447
  // A half that timed out while its sibling answered is the one place fewer regions may help: it is
436
448
  // halved in turn, under the same rule.
437
449
  const r = timedOut({ r: first })
438
- ? await regionHalves(auth, { ...params, regions: half }, tctx, { ceiling, incomplete, cause: parseToolText(first)?.reason ?? cause })
450
+ ? await regionHalves(auth, { ...params, regions: half }, tctx, { ceiling, incomplete, cause: parseToolText(first)?.reason ?? cause, hear })
439
451
  : first;
440
452
  const parsed = isToolError(r) ? null : parseToolText(r);
453
+ hear(parsed);
441
454
  const which = `the ${half.length}-region half ${half[0]}${half.length > 1 ? `…${half[half.length - 1]}` : ""}`;
442
455
  // A stall from a deeper split keeps its own reason, stall first, so the executor reads it however
443
456
  // deep the split went and however short the reason is later cut.
@@ -464,8 +477,22 @@ export function makeEnumerate(deps) {
464
477
  ...(screenLift ? { screen_lift: screenLift } : {}), region_split: { cause, parts } }, null, 2) };
465
478
  }
466
479
 
467
- async function enumerate(auth, params, tctx, { split = true } = {}) {
480
+ // ── WHAT THE TOTAL COUNTS, STATED ONCE FOR THE WHOLE ANSWER ────────────────────────────────────────
481
+ //
482
+ // A register can say that a total counts records, one per country a mark covers, rather than marks
483
+ // (`total_counts` on a provider's parsed answer). The answer to the question states it, whichever of the
484
+ // exits below produced it: the page loop, the name windows, the region halves and the rescues each tell
485
+ // the question what they heard, and it is stamped here, on the one way out. Recorded only: the ceiling
486
+ // still reads the register's own number (ruled 2026-10-02). An answer with no `total_counts` is one whose
487
+ // register said nothing about it, never a count of marks.
488
+ async function enumerate(auth, params, tctx, opts = {}) {
489
+ const said = { totalCounts: null };
490
+ return stampTotalCounts(await enumerateQuestion(auth, params, tctx, opts, said), said.totalCounts);
491
+ }
492
+
493
+ async function enumerateQuestion(auth, params, tctx, { split = true, oneByOne = false } = {}, said = { totalCounts: null }) {
468
494
  if (!hasAnyElement(params)) return { type: "text", text: missingElementError };
495
+ const hear = (p) => { if (typeof p?.total_counts === "string") said.totalCounts = p.total_counts; };
469
496
 
470
497
  // The tuned ceiling, then narrowed by any window the QUERY SHAPE imposes. Min, never max:
471
498
  // a shape window is a vendor limit, and a band that pages past it does not return more records, it
@@ -499,7 +526,9 @@ export function makeEnumerate(deps) {
499
526
  // descriptor total — `count` is the deduped truth); the resource ceiling applies to the RUNNING total
500
527
  // so a crowd still returns an incomplete descriptor. ANY chunk error/incomplete makes the WHOLE slice
501
528
  // incomplete — a clean can never ship over a partially-executed slice.
502
- const namesChunk = envInt("CLEAROTRON_ENUMERATE_NAMES_CHUNK", namesChunkDefault);
529
+ const shapeChunk = oneByOne ? 1 : typeof namesChunkFor === "function" ? namesChunkFor(params) : null;
530
+ const namesChunk = Number.isFinite(shapeChunk) && shapeChunk >= 1
531
+ ? Math.floor(shapeChunk) : envInt("CLEAROTRON_ENUMERATE_NAMES_CHUNK", namesChunkDefault);
503
532
  const allNames = Array.isArray(params[namesKey]) ? params[namesKey].filter(Boolean) : null;
504
533
  if (allNames && allNames.length > namesChunk) {
505
534
  const merged = new Map();
@@ -507,6 +536,9 @@ export function makeEnumerate(deps) {
507
536
  const rescueNotes = []; // per-window rescue summaries for the final descriptor
508
537
  const enumeratedWindowTerms = []; // terms whose whole window enumerated cleanly (per-term truth by superset)
509
538
  let totalSum = 0;
539
+ // The largest floor a window answered with ("at least N"): the slice's total is then unknown, never a
540
+ // sum that leaves the floor out, and the figure rides as the slice's own floor.
541
+ let floorMax = null;
510
542
  const chunks = Math.ceil(allNames.length / namesChunk);
511
543
  const mergeRecs = (recs) => {
512
544
  for (const rec of (recs ?? [])) {
@@ -518,6 +550,7 @@ export function makeEnumerate(deps) {
518
550
  const part = allNames.slice(i * namesChunk, (i + 1) * namesChunk);
519
551
  const r = await enumerate(auth, { ...params, [namesKey]: part }, tctx);
520
552
  const parsed = parseToolText(r);
553
+ hear(parsed);
521
554
  if (isToolError(r) || !parsed) {
522
555
  return incomplete(totalSum, merged.size, [...merged.values()],
523
556
  `chunk ${i + 1}/${chunks} (${part.length} names) failed: ${String(r?.text ?? "unparseable").slice(0, 140)}`,
@@ -527,6 +560,7 @@ export function makeEnumerate(deps) {
527
560
  // A rescued window (term_counts attached) carries its per-term truth — aggregate and CONTINUE so
528
561
  // the remaining windows still get their accounting; the slice stays incomplete either way, but a
529
562
  // partial abort here would leave later windows' terms unaccounted and the clean-gate blind.
563
+ if (Number.isFinite(parsed.total_floor)) floorMax = Math.max(floorMax ?? 0, parsed.total_floor);
530
564
  if (countFirst && parsed.term_counts && typeof parsed.term_counts === "object") {
531
565
  Object.assign(termCounts, parsed.term_counts);
532
566
  totalSum += parsed.total_hits ?? 0;
@@ -534,9 +568,10 @@ export function makeEnumerate(deps) {
534
568
  rescueNotes.push(`chunk ${i + 1}/${chunks}: ${String(parsed.reason ?? "").slice(0, 140)}`);
535
569
  continue;
536
570
  }
537
- return incomplete(totalSum + (parsed.total_hits ?? 0), merged.size + (parsed.fetched ?? 0),
571
+ return incomplete(floorMax === null ? totalSum + (parsed.total_hits ?? 0) : null, merged.size + (parsed.fetched ?? 0),
538
572
  [...merged.values(), ...(parsed.sample ?? [])],
539
- `chunk ${i + 1}/${chunks} (${part.length} names) ${parsed.state}: ${String(parsed.reason ?? "").slice(0, 180)}`);
573
+ `chunk ${i + 1}/${chunks} (${part.length} names) ${parsed.state}: ${String(parsed.reason ?? "").slice(0, 180)}`,
574
+ floorStamp(floorMax));
540
575
  }
541
576
  totalSum += parsed.total_hits ?? 0;
542
577
  mergeRecs(parsed.records);
@@ -555,9 +590,9 @@ export function makeEnumerate(deps) {
555
590
  for (const t of enumeratedWindowTerms) {
556
591
  if (!(t in termCounts)) termCounts[t] = { total_hits: null, disposition: "enumerated" };
557
592
  }
558
- return incomplete(totalSum, merged.size, [...merged.values()],
593
+ return incomplete(floorMax === null ? totalSum : null, merged.size, [...merged.values()],
559
594
  `count-first per-term rescue ran in ${rescueNotes.length}/${chunks} name chunks — saturated terms stay CROWD descriptors for judgment; term_counts is the per-term truth (a populated term is never recorded 0). ${rescueNotes.join(" · ")}`.slice(0, 900),
560
- { term_counts: termCounts, records: [...merged.values()] });
595
+ { term_counts: termCounts, records: [...merged.values()], ...floorStamp(floorMax) });
561
596
  }
562
597
  return { type: "text", text: JSON.stringify({ state: "enumerated", total_hits: totalSum, count: merged.size, records: [...merged.values()], ...(Object.keys(termCounts).length ? { term_counts: termCounts } : {}) }, null, 2) };
563
598
  }
@@ -599,10 +634,11 @@ export function makeEnumerate(deps) {
599
634
  + `Provider's own words: ${clipProviderText(c?.reason ?? "", COUNT_PROBE_BUDGET)}`,
600
635
  { crowd_basis: "provider-refused-count", count_unavailable: true });
601
636
  }
602
- if (splittable && isGatewayTimeout(c?.reason)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason) });
637
+ if (splittable && isGatewayTimeout(c?.reason)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason), hear });
603
638
  return incomplete(0, 0, [],
604
639
  `provider error on the count probe before enumeration: ${String(c?.reason ?? "count unavailable")}`, timeoutMark(c?.reason));
605
640
  }
641
+ hear(c);
606
642
  total = Number.isFinite(c.total) ? c.total : 0;
607
643
  probeTotal = total;
608
644
  if (total > ceiling) {
@@ -625,11 +661,15 @@ export function makeEnumerate(deps) {
625
661
  for (let page = 0; ; page += 1) {
626
662
  const r = await search(auth, { ...params, ...pageParams(page, pageSize, prevParsed) }, tctx);
627
663
  if (isToolError(r)) {
628
- if (splittable && isGatewayTimeout(r.text)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text) });
664
+ if (page === 0 && !oneByOne && allNames && allNames.length > 1 && listRefusal?.test(String(r.text ?? ""))) {
665
+ return enumerateQuestion(auth, params, tctx, { split, oneByOne: true }, said);
666
+ }
667
+ if (splittable && isGatewayTimeout(r.text)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text), hear });
629
668
  return incomplete(total, results.length, results, `provider error during enumeration (page ${page}): ${String(r.text ?? "")}`, timeoutMark(r.text));
630
669
  }
631
670
  const parsed = parseToolText(r);
632
671
  if (!parsed) return incomplete(total, results.length, results, `unparseable search response during enumeration (page ${page})`);
672
+ hear(parsed);
633
673
  // ── the number the completeness claim rests on must BE a number ────────────────────────────────
634
674
  // On the "cheap" seam the page response IS the count — the ceiling is tested off `total_hits` and
635
675
  // nothing else. A response that parsed but carries no usable total therefore cannot support a
@@ -638,6 +678,24 @@ export function makeEnumerate(deps) {
638
678
  // where the "enumerated" verdict is actually minted, so it cannot depend on every future adapter
639
679
  // remembering. total_hits rides out NULL — unknown — never a fabricated 0.
640
680
  if (countProbe === "cheap" && !Number.isFinite(parsed.total_hits)) {
681
+ // A FLOOR IS AN ANSWER (ruled 2026-10-02). A register that says "at least 10,000" counted, and gave a
682
+ // floor instead of a figure: the search ran and is too large to read. So it is a crowd, with the
683
+ // register's own figure beside a total that stays null. Never a provider error, which would ask it
684
+ // again for the same answer and write a 0 in its place.
685
+ const floor = floorOf(parsed);
686
+ if (floor !== null) {
687
+ // AS FOR ANY CROWD OVER THE CEILING (the page-0 branch below): a stack of spellings, or an owner's
688
+ // classes, is counted part by part first, so a rare spelling never vanishes inside the floor.
689
+ if (page === 0 && countFirst && allNames && allNames.length > 1) {
690
+ return countFirstRescue(auth, params, allNames, ceiling, null, tctx, incomplete, floor);
691
+ }
692
+ if (page === 0 && countFirst && splitClasses.length > 1) {
693
+ return classSplitRescue(auth, params, splitClasses, ceiling, null, tctx, incomplete, floor);
694
+ }
695
+ return incomplete(null, results.length + (parsed.results?.length ?? 0), [...results, ...(parsed.results ?? [])],
696
+ `the register answered "${moreThan(floor)}" for this question: a floor, not a count, so the set is too large to read. ${CROWD_IS[0].toUpperCase()}${CROWD_IS.slice(1)}`,
697
+ floorStamp(floor));
698
+ }
641
699
  return incomplete(null, results.length, results,
642
700
  `provider error during enumeration (page ${page}): the search response carried no usable total_hits, and on this provider the response IS the count — so the enumerate ceiling could not be tested and this band cannot be read as either a completed enumeration or a sanctioned crowd. The total is UNKNOWN (null, never 0).`);
643
701
  }
@@ -811,3 +869,29 @@ export function makeEnumerate(deps) {
811
869
 
812
870
  return { enumerate, countFirstRescue, classSplitRescue };
813
871
  }
872
+
873
+ /** The stamps a crowd carries when the register answered with a floor: the figure, and why the total is null. */
874
+ function floorStamp(floor) {
875
+ return floor === null ? {} : { crowd_basis: "register-floor", total_floor: floor };
876
+ }
877
+
878
+ /** A part the register answered with a floor is itself a crowd with the register's figure, never an error. */
879
+ function floorCount(probe) {
880
+ return Number.isFinite(probe?.floor) ? { total_hits: null, total_floor: probe.floor, disposition: "crowd" } : null;
881
+ }
882
+
883
+ /** `{ total_counts }` from a probe or a parsed answer that carries it, else nothing to spread. PURE. */
884
+ function countsOf(p) {
885
+ return typeof p?.total_counts === "string" ? { total_counts: p.total_counts } : {};
886
+ }
887
+
888
+ /** The answer with what its total counts stated on it; an error, or nothing heard, passes unchanged. */
889
+ function stampTotalCounts(r, totalCounts) {
890
+ if (!totalCounts || isToolError(r)) return r;
891
+ const parsed = parseToolText(r);
892
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return r;
893
+ return { ...r, text: JSON.stringify({ ...parsed, total_counts: totalCounts }, null, 2) };
894
+ }
895
+
896
+ // What a crowd is, in the words every crowd's reason ends with: a band the funnel hands up, never a clean.
897
+ const CROWD_IS = "this is a CROWD, not a named exact/near band. Record it as a count+sample descriptor and hand it up to judgment; the funnel does NOT narrow-and-retry a crowd here. Whether a narrower NAMED enumeration is warranted, and whether this slice is material, is judgment's call (Layer B) — never accept it as clean.";
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
- import { nativeScriptIndexGap } from "./script-form.mjs";
19
+ import { nativeScriptIndexGap } from "./script-form.mjs"; import { floorOf, moreThan } from "../../shared/register-floor.mjs";
20
20
  import { entryTermIssues, goodsTermsList } from "./term-shape.mjs";
21
21
  import { awaitsReadingTurn, releasedFamiliesFile } from "./plan-guards.mjs";
22
22
  import { faultText, guardToolCall } from "./transport-guard.mjs";
@@ -500,8 +500,16 @@ export function makeExecutePlan(deps) {
500
500
  && Number.isFinite(parsed.total_hits) && Number(parsed.total_hits) === 0
501
501
  && parsed.owner_resolution != null && !ownerNameResolved(parsed.owner_resolution);
502
502
  const counted = !!parsed && !ownerUnverified;
503
+ // A floor ("at least 10,000") is the register's answer: the total stays null and its figure rides beside
504
+ // it, never a 0 (ruled 2026-10-02; shared/register-floor.mjs). An answer that carried no number at all
505
+ // keeps a null total as well. `?? 0` wrote it down as a counted zero, which read as an empty field and
506
+ // hid it from the band shape's uncountable-slice reading. A call that failed keeps the 0 it always had
507
+ // beside error:true, the placeholder every refused slice carries.
508
+ const floor = floorOf(parsed);
509
+ const untotalled = counted && !Number.isFinite(parsed.total_hits);
503
510
  blocks.push({ state: "incomplete", ...base,
504
- total_hits: ownerUnverified ? null : (parsed?.total_hits ?? 0), fetched: parsed?.results?.length ?? 0,
511
+ total_hits: ownerUnverified || floor !== null || untotalled ? null : (parsed?.total_hits ?? 0), fetched: parsed?.results?.length ?? 0,
512
+ ...(floor !== null ? { total_floor: floor, crowd_basis: "register-floor" } : {}),
505
513
  sample: (parsed?.results ?? []).slice(0, 5),
506
514
  ...(counted ? {} : { error: true }),
507
515
  ...(countGap || ownerUnverified ? { deferred: true } : {}),
@@ -509,6 +517,8 @@ export function makeExecutePlan(deps) {
509
517
  // the resolution the sweep actually ran on, verbatim — a reader must be able to see WHICH
510
518
  // applicant styling was asked for, on a verified zero as much as on an unverified one.
511
519
  ...(parsed?.owner_resolution ? { owner_resolution: parsed.owner_resolution } : {}),
520
+ // what the counted total counts, when the register said (enumerate.mjs, "what the total counts")
521
+ ...(counted && typeof parsed?.total_counts === "string" ? { total_counts: parsed.total_counts } : {}),
512
522
  reason: ownerUnverified ? unresolvedOwnerCountReason(e.term ?? (e.terms ?? [])[0], coveredBy)
513
523
  : parsed ? descriptorReason
514
524
  : `provider error on the count probe (after one in-tool retry): ${clipProviderText(r?.text ?? "", 100)}` });
@@ -643,7 +653,8 @@ export function makeExecutePlan(deps) {
643
653
  // EACH SPELLING'S COUNT, IN THE REPLY. A crowded stack comes back counted and unread, and the reading
644
654
  // step decides which spellings to read, narrow or leave (ruled 2026-09-26), so it is handed the counts
645
655
  // here rather than left to find them in the band file: a number for a spelling counted and not read,
646
- // 0 for a verified zero, "crowd N" for one over the ceiling, "error" for a count that failed.
656
+ // 0 for a verified zero, "crowd N" for one over the ceiling, "error" for a count that failed. A spelling the
657
+ // register answered with a floor reads "crowd more than 10,000", its figure as every page prints it.
647
658
  //
648
659
  // THE GATE IS "ANYTHING LEFT TO DECIDE", not one disposition, and that is the whole of this block's
649
660
  // correctness. Keyed on `unenumerated` alone it dropped the two stacks the rule exists for: one where
@@ -659,7 +670,7 @@ export function makeExecutePlan(deps) {
659
670
  const spellingCounts = Object.fromEntries(blocks
660
671
  .filter((b) => b?.term_counts && Object.values(b.term_counts).some(isUnresolvedCount))
661
672
  .map((b) => [b.qid, Object.fromEntries(Object.entries(b.term_counts).map(([t, v]) => [t,
662
- v?.disposition === "crowd" ? `crowd ${v.total_hits}` : v?.disposition === "error" ? "error" : (v?.total_hits ?? "error")]))]));
673
+ v?.disposition === "crowd" ? `crowd ${Number.isFinite(v.total_floor) ? moreThan(v.total_floor) : v.total_hits}` : v?.disposition === "error" ? "error" : (v?.total_hits ?? "error")]))]));
663
674
  return { type: "text", text: JSON.stringify({
664
675
  written: outPath, blocks: blocks.length + preserved.length, executed: blocks.length, preserved: preserved.length, skipped,
665
676
  states: Object.fromEntries([...stateByQid].filter(([q]) => !seeded.has(q))),
@@ -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,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.1-beta.1
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.1-beta.0
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.4.0
4
12
 
5
13
  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.1",
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,36 @@ 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,
141
+ // ── THE SOUND-ALIKE BAND TAKES A LIST TOO, UP TO 10 SPELLINGS AND 30 WORDS ─────────────────────────
142
+ //
143
+ // The register takes up to 10 spellings, and 30 words in all, when a channel other than `identical`
144
+ // and `lookalike` is asked. On the sound-alike channels (`identical`, `phonetic`) it answers a list of
145
+ // 10 with the same records, by id, as one search per spelling, and each record's `match.terms` names the
146
+ // spellings that found it alone (ruled 2026-10-09).
147
+ //
148
+ // The typo channels (`identical`, `fuzzy`, `embedded`, `lookalike`) stay one spelling per request. The
149
+ // register refuses some typo lists of 10 as "too complex to run" where each spelling alone answers, and
150
+ // which lists it refuses depends on the spellings, so no fixed width is safe there.
151
+ phoneticOrWidth: 10,
152
+ listWordBudget: 30,
126
153
  // filters.nice_classes[] is a top-level OR filter — one call, no fan-out.
127
154
  classFilter: "native",
128
155
  // ── THE GOODS-AND-SERVICES TEXT FILTER ──────────────────────────────────────────────────────────
@@ -395,6 +422,9 @@ export const CAPABILITIES = Object.freeze({
395
422
  pageGuard: 60,
396
423
  ceilingDefault: 600,
397
424
  namesChunkDefault: 1,
425
+ // A ranked list the register will not run (HTTP 400, "The search query is too complex to run."). Which
426
+ // lists it refuses depends on the spellings, so a refused one is asked again one spelling at a time.
427
+ listRefusal: /too complex to run/i,
398
428
  providerWindow: "cursor window; exact total via options.include_total (approximate totals saturate at 10000 and are reported UNKNOWN, never as a count)",
399
429
  }),
400
430
  });