clearotron 0.3.2 → 0.3.3-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 (105) hide show
  1. package/CONTRIBUTING.md +12 -0
  2. package/INSTALL.md +8 -0
  3. package/bin/onboard.mjs +29 -7
  4. package/bin/start.mjs +1 -1
  5. package/build-info.json +2 -2
  6. package/demo/MANIFEST.json +27 -0
  7. package/docs/INTAKE.md +8 -0
  8. package/docs/architecture/04-configuration-reference.md +1 -1
  9. package/driver/CHANGELOG.md +37 -0
  10. package/driver/clearance-variants-record.mjs +12 -1
  11. package/driver/common-law-coverage-status.mjs +113 -0
  12. package/driver/contract-audit.mjs +1 -1
  13. package/driver/contract-e3-backlog.mjs +37 -37
  14. package/driver/contract-vocabulary.mjs +8 -8
  15. package/driver/coverage-form-io.mjs +3 -1
  16. package/driver/coverage-form.mjs +38 -11
  17. package/driver/coverage-ledger.mjs +37 -7
  18. package/driver/coverage-union.mjs +2 -2
  19. package/driver/crowd-context.mjs +19 -6
  20. package/driver/drainer-identity.mjs +1 -1
  21. package/driver/engine/mcp/clarivate-server.mjs +4 -2
  22. package/driver/engine/mcp/corsearch-server.mjs +3 -1
  23. package/driver/engine/mcp/coverage-server.mjs +1 -1
  24. package/driver/engine/mcp/dispositions-server.mjs +47 -5
  25. package/driver/engine/mcp/euipo-server.mjs +2 -0
  26. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  27. package/driver/engine/mcp/gather-config.mjs +8 -2
  28. package/driver/engine/mcp/probe-server.mjs +28 -0
  29. package/driver/engine/mcp/proposal-fields.mjs +45 -0
  30. package/driver/engine/mcp/recording-server.mjs +30 -0
  31. package/driver/engine/mcp/signa-server.mjs +2 -0
  32. package/driver/engine/mcp/supplemental.mjs +89 -12
  33. package/driver/engine/mcp/unit-note-server.mjs +50 -0
  34. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  35. package/driver/engine/openai-agent.mjs +7 -0
  36. package/driver/engine/probe.mjs +67 -14
  37. package/driver/engine/tool-refusal.mjs +16 -0
  38. package/driver/enqueue-schema.mjs +2 -2
  39. package/driver/envelope-settle.mjs +82 -13
  40. package/driver/findings-model.mjs +4 -4
  41. package/driver/gateway.mjs +18 -2
  42. package/driver/manager-groups-verdict.mjs +1 -1
  43. package/driver/matter-frame-record.mjs +24 -7
  44. package/driver/named-band.mjs +1 -1
  45. package/driver/package.json +1 -1
  46. package/driver/partial-payload-baseline.json +12 -3
  47. package/driver/pipeline.mjs +141 -37
  48. package/driver/publish/index.mjs +40 -25
  49. package/driver/publish/xlsx.mjs +26 -4
  50. package/driver/queue-markers.mjs +44 -0
  51. package/driver/queue-watch-verdict.mjs +2 -2
  52. package/driver/register-availability.mjs +2 -2
  53. package/driver/register-plan.mjs +313 -21
  54. package/driver/roster-verdict.mjs +1 -1
  55. package/driver/runner.mjs +26 -2
  56. package/driver/skills/clearance-common-law/SKILL.md +2 -0
  57. package/driver/skills/clearance-register/SKILL.md +44 -3
  58. package/driver/skills/clearance-register/digest.md +5 -5
  59. package/driver/skills/clearance-register/providers/clarivate.md +1 -1
  60. package/driver/skills/clearance-register/unit.md +39 -0
  61. package/driver/skills/clearance-variants/SKILL.md +1 -1
  62. package/driver/skills/matter-frame/SKILL.md +4 -2
  63. package/driver/stages.mjs +12 -5
  64. package/driver/status-snapshot.mjs +1 -1
  65. package/driver/suite-census.json +236 -14
  66. package/driver/synthesis-record.mjs +80 -2
  67. package/driver/unit-file-drift.mjs +3 -3
  68. package/driver/unit-inventory.mjs +2 -2
  69. package/driver/unit-state-verdict.mjs +1 -1
  70. package/driver/updater-identity.mjs +2 -3
  71. package/driver/variant-manifest-model.mjs +11 -1
  72. package/driver/verify.mjs +5 -5
  73. package/driver/withheld-families.mjs +104 -0
  74. package/mcp-server/CHANGELOG.md +4 -0
  75. package/mcp-server/lib/brief.mjs +5 -7
  76. package/mcp-server/lib/runs.mjs +1 -1
  77. package/mcp-server/package.json +1 -1
  78. package/mcp-server/server.mjs +3 -2
  79. package/package.json +1 -1
  80. package/portal-ui/dist/assets/{index-DMthc7PQ.js → index-GBbbyQxc.js} +22 -4
  81. package/portal-ui/dist/index.html +1 -1
  82. package/portal-ui/package.json +1 -1
  83. package/providers/_shared/count.mjs +2 -2
  84. package/providers/_shared/enumerate.mjs +15 -2
  85. package/providers/_shared/execute-plan.mjs +19 -1
  86. package/providers/_shared/plan-guards.mjs +40 -0
  87. package/providers/clarivate/src/capabilities.js +15 -5
  88. package/providers/clarivate/src/core.js +41 -5
  89. package/providers/corsearch/src/capabilities.js +4 -0
  90. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  91. package/providers/oauth-mcp-bridge/package.json +1 -1
  92. package/providers/signa/src/capabilities.js +22 -8
  93. package/providers/signa/src/core.js +12 -1
  94. package/scripts/demo-evidence.mjs +114 -0
  95. package/scripts/engine-probe.mjs +6 -5
  96. package/scripts/env-audit.mjs +1 -1
  97. package/scripts/freeze-example-run.mjs +3 -3
  98. package/scripts/live-surface-check.mjs +26 -19
  99. package/scripts/mint-suite-census.mjs +66 -0
  100. package/scripts/package-size-budget.mjs +117 -0
  101. package/scripts/register-plan-shape.mjs +259 -0
  102. package/scripts/release-note-required.mjs +38 -1
  103. package/scripts/settings-render-check.mjs +36 -0
  104. package/scripts/travelling-predicates.mjs +1 -1
  105. package/shared/identifier-scan.mjs +22 -5
@@ -36,6 +36,7 @@
36
36
  // and the axis bands are never touched — this pass mutates no existing artifact.
37
37
 
38
38
  import { NON_MATERIAL_AXES } from "./coverage-ledger.mjs";
39
+ import { containsFormSubstitution } from "./register-plan.mjs";
39
40
 
40
41
  // ── caps — these bound SPEND, never sufficiency ─────────────────────────────────────────────────────
41
42
  // Each cap limits how many provider calls / how many fetched records one evidence pass may buy. They
@@ -180,14 +181,22 @@ export function selectCrowdSlices(ledgerRows, planContext = {}) {
180
181
  // the counts describe the SAME crowd the ledger row is about. An owner-scoped slice carries its
181
182
  // `owner` onto EVERY minted entry for the same reason — un-owned counts would describe the wider
182
183
  // formative crowd while claiming to describe the owner's slice.
183
- export function mintSliceCountEntries(slice, i, { maxTerms = CROWD_MAX_TERMS_PER_SLICE } = {}) {
184
+ //
185
+ // A TERM SHORTER THAN THE REGISTER'S CONTAINS FLOOR is counted on the exact form instead, the same
186
+ // rule the frozen plan's goods-narrowed questions and saturation probes follow: a register that
187
+ // refuses the contains form for a term that short answers no count at all, so the crowd this pass
188
+ // exists to describe would read "count unavailable". Same classes, same two counts per term, and the
189
+ // entry carries `contains_substituted` so the figure is never taken for a containing count.
190
+ export function mintSliceCountEntries(slice, i, { maxTerms = CROWD_MAX_TERMS_PER_SLICE, capabilities = null } = {}) {
184
191
  const terms = slice.terms.slice(0, maxTerms);
185
192
  const base = { axis: CROWD_CONTEXT_AXIS, regions: slice.regions ?? [], expected_kind: "count",
186
193
  ...(typeof slice.owner === "string" && slice.owner ? { owner: slice.owner } : {}) };
187
194
  const out = [];
188
195
  terms.forEach((t, j) => {
189
- out.push({ ...base, qid: `crowdctx:s${i}-t${j}-${slug(t)}-all`, predicate: "default", term: t, nice_classes: [] });
190
- out.push({ ...base, qid: `crowdctx:s${i}-t${j}-${slug(t)}-cls`, predicate: "default", term: t, nice_classes: slice.nice_classes ?? [] });
196
+ const sub = containsFormSubstitution(t, capabilities);
197
+ const form = sub ? { predicate: "exact", contains_substituted: sub } : { predicate: "default" };
198
+ out.push({ ...base, qid: `crowdctx:s${i}-t${j}-${slug(t)}-all`, ...form, term: t, nice_classes: [] });
199
+ out.push({ ...base, qid: `crowdctx:s${i}-t${j}-${slug(t)}-cls`, ...form, term: t, nice_classes: slice.nice_classes ?? [] });
191
200
  });
192
201
  out.push({
193
202
  ...base, qid: `crowdctx:s${i}-exact-count`, predicate: "exact",
@@ -325,10 +334,12 @@ const compactRecord = (r) => ({
325
334
  * @param opts.executor INJECTED: async (entries) => band blocks (tests stub it; the pipeline passes
326
335
  * the planExec-lane adapter). Null/absent ⇒ no lane ⇒ null (logged, non-fatal).
327
336
  * @param opts.caps { maxSlices?, maxTermsPerSlice?, enumCap? } — spend bounds only.
337
+ * @param opts.capabilities the active register's declared capabilities, read for the contains floor
338
+ * only (containsMinLength). Absent ⇒ every term is counted on the contains form.
328
339
  * @param opts.note / opts.log observability hooks (default no-ops; pipeline wires note()/runLog).
329
340
  * @returns { json, md, stats } | null
330
341
  */
331
- export async function buildCrowdContext({ ledger, planContext = {}, executor, caps = {}, note = () => {}, log = () => {} } = {}) {
342
+ export async function buildCrowdContext({ ledger, planContext = {}, executor, caps = {}, capabilities = null, note = () => {}, log = () => {} } = {}) {
332
343
  const maxSlices = caps.maxSlices ?? CROWD_MAX_SLICES;
333
344
  const maxTermsPerSlice = caps.maxTermsPerSlice ?? CROWD_MAX_TERMS_PER_SLICE;
334
345
  const enumCap = caps.enumCap ?? CROWD_ENUM_CAP;
@@ -344,7 +355,7 @@ export async function buildCrowdContext({ ledger, planContext = {}, executor, ca
344
355
  if (selected.length > slices.length) note(`crowd-context: ${selected.length} qualifying slice(s), gathering the first ${slices.length} (spend cap — the rest keep their ledger disclosure unchanged)`);
345
356
  try {
346
357
  // ── phase 1: one batched executor call for every count probe ─────────────────────────────────
347
- const countEntries = slices.flatMap((s, i) => mintSliceCountEntries(s, i, { maxTerms: maxTermsPerSlice }));
358
+ const countEntries = slices.flatMap((s, i) => mintSliceCountEntries(s, i, { maxTerms: maxTermsPerSlice, capabilities }));
348
359
  const byQid = new Map((await executor(countEntries) ?? []).filter((b) => b && b.qid).map((b) => [b.qid, b]));
349
360
  // ── phase 2: enumerate each exact subset the count proved tractable (0 < hits ≤ cap) ─────────
350
361
  // A verified-zero count needs no call (enumerating an empty subset returns the empty subset);
@@ -366,7 +377,9 @@ export async function buildCrowdContext({ ledger, planContext = {}, executor, ca
366
377
  const all = byQid.get(`crowdctx:s${i}-t${j}-${slug(t)}-all`);
367
378
  const cls = byQid.get(`crowdctx:s${i}-t${j}-${slug(t)}-cls`);
368
379
  const bad = !all || all.error || !cls || cls.error;
369
- return { term: t, all_classes: Number(all?.total_hits) || 0, in_scope: Number(cls?.total_hits) || 0, ...(bad ? { error: true } : {}) };
380
+ const sub = containsFormSubstitution(t, capabilities);
381
+ return { term: t, all_classes: Number(all?.total_hits) || 0, in_scope: Number(cls?.total_hits) || 0,
382
+ ...(bad ? { error: true } : {}), ...(sub ? { contains_substituted: sub } : {}) };
370
383
  });
371
384
  const c = byQid.get(`crowdctx:s${i}-exact-count`);
372
385
  const hits = Number(c?.total_hits) || 0;
@@ -190,7 +190,7 @@ export function drainerVerdict({ stamp, headCommit, isAlive, processes, ppidOf =
190
190
  }
191
191
 
192
192
  if (!head) {
193
- return { state: "fail", message: `drainer pid ${pid} is alive on ${short(held)}${via}, but the checkout's own HEAD `
193
+ return { state: "skip", blocked: true, message: `drainer pid ${pid} is alive on ${short(held)}${via}, but the checkout's own HEAD `
194
194
  + `could not be read, so the two could not be compared.${strayNote}` };
195
195
  }
196
196
 
@@ -33,6 +33,7 @@ import {
33
33
  CAPABILITIES, doSearch, doRecordFetch, doImageFetch, doBatchScreen, doEnumerate, doExecutePlan, DEFAULT_BASE,
34
34
  } from "../../../providers/clarivate/src/core.js";
35
35
  import { proposeSupplemental } from "./supplemental.mjs";
36
+ import { narrowingFields } from "./proposal-fields.mjs";
36
37
 
37
38
  const API_KEY = process.env.CLARIVATE_API_KEY || "";
38
39
  const BASE = process.env.CLARIVATE_API_BASE || DEFAULT_BASE;
@@ -136,8 +137,9 @@ serve({
136
137
  romanization: { type: "string", description: "The Latin-script form of a NON-LATIN term — plain ASCII letters/digits, syllable-separated by single spaces, no tone marks or diacritics (华威豹 → \"HUA WEI BAO\", ティキスラッシュ → \"TIKI SURASSHU\"). MANDATORY beside a non-Latin term: without it this register cannot answer the characters and the slice defers. Single-term proposals only (never an OR-stack, never predicate:owner), and never on a term that is already Latin." },
137
138
  owner: { type: "string", description: "OPTIONAL owner scope field on a MARK-TEXT proposal: the query is the owner×term intersection (the owner's filings within the term band). Not allowed on predicate:owner (there the owner name IS the term)." },
138
139
  nice_classes: { type: "array", items: {} },
139
- regions: { type: "array", items: { type: "string" },
140
- description: "OPTIONAL. Omit to inherit the frozen plan's regions (the matter's territorial scope) — this provider REQUIRES at least one office on every request, so an omitted list is backfilled from the plan, never treated as a worldwide sweep. Supply it only to search a NARROWER set than the matter's scope." },
140
+ // The narrowing fields every register serves (proposal-fields.mjs); this register's two facts ride in.
141
+ ...narrowingFields({ regions: "OPTIONAL. Omit to inherit the frozen plan's regions (the matter's territorial scope) — this provider REQUIRES at least one office on every request, so an omitted list is backfilled from the plan, never treated as a worldwide sweep. Supply it only to search a NARROWER set than the matter's scope.",
142
+ goodsNote: "One office in this provider's vocabulary refuses the field and fails the whole call, so it is left out of that office's request and recorded as asked-without-goods rather than dropped in silence." }),
141
143
  rationale: { type: "string" },
142
144
  term_literal: { type: "boolean", description: "TRUE only when the term genuinely IS the mark verbatim (a multi-word slogan mark, a mark carrying an anchored star) — it bypasses the term-shape lint. Never use it to push a label through." },
143
145
  } } },
@@ -9,6 +9,7 @@
9
9
  import { serve } from "./stdio-server.mjs";
10
10
  import { CAPABILITIES, doSearch, doRecordFetch, doImageFetch, doExpandPhoneme, doBatchScreen, doEnumerate, doExecutePlan } from "../../../providers/corsearch/src/core.js";
11
11
  import { proposeSupplemental } from "./supplemental.mjs";
12
+ import { narrowingFields } from "./proposal-fields.mjs";
12
13
 
13
14
  const COOKIE = process.env.CORSEARCH_SESSION_KEY || "";
14
15
  const tctx = (kind) => ({
@@ -118,7 +119,8 @@ serve({
118
119
  term: { type: "string" }, terms: { type: "array", items: { type: "string" } },
119
120
  romanization: { type: "string", description: "OPTIONAL on this provider (its index holds the characters and answers them directly), but STATE IT anyway for a non-Latin term — the plan is provider-neutral and the entry carries both forms for whichever register expresses it. Latin-script form only: plain ASCII letters/digits, syllable-separated by single spaces, no tone marks or diacritics. Single-term proposals only; never on an already-Latin term." },
120
121
  owner: { type: "string", description: "OPTIONAL owner scope field on a MARK-TEXT proposal: the query is the owner×term intersection (the owner's filings within the term band). Not allowed on predicate:owner (there the owner name IS the term)." },
121
- nice_classes: { type: "array", items: {} }, regions: { type: "array", items: { type: "string" }, description: "UPPERCASE 2-letter region codes, e.g. ['US','EU','CH'] — never spelled-out names (recognized display names are normalized; unknown values are rejected)" },
122
+ nice_classes: { type: "array", items: {} },
123
+ ...narrowingFields(), // the narrowing fields every register serves (proposal-fields.mjs)
122
124
  rationale: { type: "string" },
123
125
  term_literal: { type: "boolean", description: "TRUE only when the term genuinely IS the mark verbatim (a multi-word slogan mark, a mark carrying an anchored star) — it bypasses the term-shape lint. Never use it to push a label through." },
124
126
  } } },
@@ -55,7 +55,7 @@ serve({
55
55
  description: `Up to ${MAX_ROWS_PER_CALL} rows per call. Send more in a further call; the answer tells you what is left.`,
56
56
  items: { type: "object", properties: {
57
57
  row_id: { type: "string", description: "A driver row's id, exactly as the dispatch's obligations block lists it. Omit on a seat row you are adding — the driver mints seat row ids." },
58
- status: { type: "string", description: "EXACTLY one bare token of confirmed-clean / coverage-limited / deferred. Qualifiers go in the reason." },
58
+ status: { type: "string", description: "EXACTLY one bare token of confirmed-clean / coverage-limited / deferred / withheld-by-judgment. Qualifiers go in the reason." },
59
59
  reason: { type: "string", description: "The sentence the lawyer reads — say what was searched and what was not, in a lawyer's words, never the engine's." },
60
60
  kind: { type: "string", description: "\"seat\" on a row you add for a coverage unit the plan does not contain. Never anything else." },
61
61
  axis: { type: "string", description: "Seat rows only: EXACTLY one bare token of the closed register-axis vocabulary the dispatch lists." },
@@ -56,6 +56,9 @@ import { validateGridSpec } from "../../../providers/perplexity/src/core.js";
56
56
  // the disk work and disposition-call.mjs owns the decision. One direction of import, no second opinion.
57
57
  import { recordDispositions } from "../../disposition-tool.mjs";
58
58
  import { MAX_ROWS_PER_CALL } from "../../disposition-call.mjs";
59
+ // The lane's second statement, and a different one: which coverage units were searched to what end. Its
60
+ // own module and its own file, never a row in the disposition form — see record_coverage_status below.
61
+ import { recordCoverageStatus, COMMON_LAW_COVERAGE_STATUSES } from "../../common-law-coverage-status.mjs";
59
62
 
60
63
  // ── B — THE TYPED DISPOSITION TRANSPORT ─────────────────────────────────────────────────────────────
61
64
  //
@@ -68,15 +71,21 @@ import { MAX_ROWS_PER_CALL } from "../../disposition-call.mjs";
68
71
  // THE SPEC PATH IS THE SEAT'S ONLY PATH ARGUMENT, and it is the same driver-written file the grid tool
69
72
  // was given. That rule: the path is the DRIVER'S, taken from the spec it wrote. Two derivations of one
70
73
  // filename is the drift that cost weeks.
71
- async function record_dispositions(params) {
72
- const { grid_spec_path, rows } = params ?? {};
74
+ function specFrom(params) {
75
+ const { grid_spec_path } = params ?? {};
73
76
  if (!grid_spec_path)
74
- return { isError: true, text: "ERROR: grid_spec_path is required — it is the same driver-written spec path the grid tool was given. Do not compose a path." };
77
+ return { error: "ERROR: grid_spec_path is required — it is the same driver-written spec path the grid tool was given. Do not compose a path." };
75
78
  let spec;
76
79
  try { spec = validateGridSpec(JSON.parse(readFileSync(grid_spec_path, "utf8"))); }
77
- catch (err) { return { isError: true, text: `ERROR: grid_spec_path unreadable/invalid (${err.message}). The driver writes this file; do not hand-author it.` }; }
80
+ catch (err) { return { error: `ERROR: grid_spec_path unreadable/invalid (${err.message}). The driver writes this file; do not hand-author it.` }; }
78
81
  if (!/\/studio\/(?:prelim|clearance)-search\//.test(spec.output_path)) // either spelling: an install keeps the studio segment it has
79
- return { isError: true, text: `ERROR: grid spec.output_path must be within a studio/clearance-search run dir; got ${spec.output_path}` };
82
+ return { error: `ERROR: grid spec.output_path must be within a studio/clearance-search run dir; got ${spec.output_path}` };
83
+ return { spec };
84
+ }
85
+
86
+ async function record_dispositions(params) {
87
+ const { spec, error } = specFrom(params);
88
+ if (error) return { isError: true, text: error };
80
89
  // NEVER THROWN PAST THIS POINT. An exception surfaces to the seat as a tool error naming no row, which
81
90
  // tells it nothing about what to fix — the failure mode this transport exists to end.
82
91
  try {
@@ -97,6 +106,23 @@ async function record_dispositions(params) {
97
106
  }
98
107
  }
99
108
 
109
+ // ── THE COVERAGE STATUS, THE LANE'S SECOND TOOL ON ITS OWN KEY ───────────────────────────────────────
110
+ //
111
+ // A sibling of record_dispositions, not a field on it. A meaning ruling is addressed by an obligation's
112
+ // number; a coverage status by a coverage unit. `record_coverage` and `record_register_digest` stay two
113
+ // tools for the same reason. The key is still granted by exactly one lane's group list, so no other seat
114
+ // gains a writer. Both tools resolve the spec through specFrom() above, one resolution for both.
115
+ async function record_coverage_status(params) {
116
+ const { spec, error } = specFrom(params);
117
+ if (error) return { isError: true, text: error };
118
+ try {
119
+ const r = recordCoverageStatus(spec, params);
120
+ return { isError: !r.ok, text: r.text };
121
+ } catch (e) {
122
+ return { isError: true, text: `ERROR: the driver could not record this call (${String(e?.message ?? e).slice(0, 200)}). This is a driver fault, not a fault in your statuses — do not re-type them.` };
123
+ }
124
+ }
125
+
100
126
  serve({
101
127
  name: "dispositions", version: "0.1.0",
102
128
  tools: [{
@@ -135,5 +161,21 @@ serve({
135
161
  } },
136
162
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
137
163
  handler: record_dispositions,
164
+ }, {
165
+ name: "record_coverage_status",
166
+ description: "Record the status of each row of your coverage ledger. Send VALUES, not a file: one entry per ledger row, and the driver writes the record. Entries that validate are kept even when others in the same call are refused, and a later entry for the same unit replaces the earlier one.",
167
+ inputSchema: { type: "object", required: ["grid_spec_path", "rows"], properties: {
168
+ grid_spec_path: { type: "string", description: "Absolute path to the driver-written grid spec — the same one the grid tool was given." },
169
+ rows: {
170
+ type: "array",
171
+ description: "One entry per coverage ledger row.",
172
+ items: { type: "object", required: ["unit", "status"], properties: {
173
+ unit: { type: "string", description: "The coverage unit, exactly as your ledger row names it." },
174
+ status: { type: "string", enum: [...COMMON_LAW_COVERAGE_STATUSES], description: `EXACTLY one bare token of ${COMMON_LAW_COVERAGE_STATUSES.join(" / ")}. Qualifiers go in the ledger row.` },
175
+ } },
176
+ },
177
+ } },
178
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
179
+ handler: record_coverage_status,
138
180
  }],
139
181
  });
@@ -33,6 +33,7 @@ import {
33
33
  doEnumerate, doExecutePlan,
34
34
  } from "../../../providers/euipo/src/core.js";
35
35
  import { proposeSupplemental } from "./supplemental.mjs";
36
+ import { narrowingFields } from "./proposal-fields.mjs";
36
37
 
37
38
  // The core resolves credentials from the environment; AUTH stays an object so a future knob (a pinned
38
39
  // environment, a second subscription) does not change every call site.
@@ -154,6 +155,7 @@ serve({
154
155
  romanization: { type: "string", description: "The Latin-script form of a NON-LATIN term. On THIS source it is NOT used to rescue the slice — nativeScriptIndex is true, so the characters are sent as themselves and the romanisation is carried for the reader only." },
155
156
  owner: { type: "string", description: "OPTIONAL owner scope field on a MARK-TEXT proposal: the owner×term intersection. Not allowed on predicate:owner (there the owner name IS the term)." },
156
157
  nice_classes: { type: "array", items: {} },
158
+ ...narrowingFields(), // the narrowing fields every register serves (proposal-fields.mjs)
157
159
  rationale: { type: "string" },
158
160
  term_literal: { type: "boolean", description: "TRUE only when the term genuinely IS the mark verbatim (a multi-word slogan mark, a mark carrying an anchored star) — it bypasses the term-shape lint. Never use it to push a label through." },
159
161
  } } },
@@ -34,6 +34,7 @@ import {
34
34
  CAPABILITIES, doSearch, doRecordFetch, doBatchScreen, doImageFetch, doEnumerate, doExecutePlan,
35
35
  } from "../../../providers/free-tier/src/core.js";
36
36
  import { proposeSupplemental } from "./supplemental.mjs";
37
+ import { narrowingFields } from "./proposal-fields.mjs";
37
38
 
38
39
  // NULL, and it is not a placeholder. Each member core resolves its OWN credentials from the environment
39
40
  // — EUIPO its OAuth pair, the index its file path — so there is no single auth object a composite could
@@ -167,6 +168,7 @@ serve({
167
168
  romanization: { type: "string" },
168
169
  owner: { type: "string" },
169
170
  nice_classes: { type: "array", items: {} },
171
+ ...narrowingFields(), // the narrowing fields every register serves (proposal-fields.mjs)
170
172
  rationale: { type: "string" },
171
173
  term_literal: { type: "boolean" },
172
174
  } } },
@@ -678,7 +678,13 @@ const LOCAL = {
678
678
  // `mcp__dispositions__record_dispositions`). That is a real argv-surface change on four stages, so it
679
679
  // ships status:merged-awaiting-e2e — the byte pins in recording-grant-preservation.test.mjs move with
680
680
  // it and no live run has exercised the new name.
681
- dispositions: { script: "dispositions-server.mjs", tools: ["record_dispositions"] },
681
+ //
682
+ // ── AND ITS SECOND TOOL, `record_coverage_status` — an allowlist growing by one token on an
683
+ // ALREADY-TOOLED key that exactly one lane holds, so no other seat gains a writer and no argv-surface
684
+ // transition fires. It is not a field on `record_dispositions`: a meaning ruling and a coverage status are
685
+ // two statements, addressed two ways, as `record_coverage` and `record_register_digest` are. Ordered by
686
+ // driver/skills/clearance-common-law/SKILL.md beside the coverage ledger.
687
+ dispositions: { script: "dispositions-server.mjs", tools: ["record_dispositions", "record_coverage_status"] },
682
688
  // ── UNIT-NOTE: the register unit's audit note, and the first own-key transport that MOVES an artifact ─
683
689
  //
684
690
  // `coverage`'s and `declination`'s shape, chosen for a reason those two did not have. Those stages keep
@@ -700,7 +706,7 @@ const LOCAL = {
700
706
  // ONE TOOL ON ITS OWN KEY, not on `register` — that key is the funnel's and a record tool added to it
701
707
  // would be enumerated into every register-unit seat's grant AND every other holder's. Same rule the
702
708
  // three entries above follow.
703
- "unit-note": { script: "unit-note-server.mjs", tools: ["record_unit_note"] },
709
+ "unit-note": { script: "unit-note-server.mjs", tools: ["record_unit_note", "record_withheld_families"] },
704
710
  // ── RECORDING — DERIVED from the registry above, one entry per stage, in registry order ──────────
705
711
  //
706
712
  // These rows were hand-written here until the collapse. They are LAST in this object on purpose:
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ // engine/mcp/probe-server.mjs — the one tool the engine probe asks the engine to call.
5
+ //
6
+ // A turn with no tools proves the credential and the model, and nothing about whether the engine can use
7
+ // the tools every search stage is given. On some hosts codex's own sandbox refuses every tool call while
8
+ // the turn reports success, and a probe with no tool passed there. So the probe hands the engine this
9
+ // server, asks it to call `ping` once, and passes only when the reply carries what `ping` returned.
10
+ //
11
+ // WHAT IT RETURNS IS THE PROOF. Its one argument is a random word the probe mints for each
12
+ // turn and gives only to this process, so a reply that carries it cannot be the model guessing. Read-only;
13
+ // it touches no file, no network and no run.
14
+ import { serve } from "./stdio-server.mjs";
15
+
16
+ serve({
17
+ name: "probe", version: "0.1.0",
18
+ tools: [{
19
+ name: "ping",
20
+ description: "Return the word this check is waiting for.",
21
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
22
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
23
+ handler: async () => {
24
+ const word = String(process.argv[2] ?? "").trim();
25
+ return word ? word : { isError: true, text: "ping: this server was started without a word to return" };
26
+ },
27
+ }],
28
+ });
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // proposal-fields.mjs — the narrowing fields of `register_propose_supplemental`, ONE definition for every
4
+ // register's server.
5
+ //
6
+ // The reading turn narrows a crowded identical question through this tool: by goods words, by market
7
+ // (`regions`), and it names the crowd it replaces (`narrows`). The shared mint (supplemental.mjs) has
8
+ // handled all three for every register, but only the Clarivate server declared them, and Corsearch
9
+ // declared `regions` alone. A model reads the schema it is served, so on Signa the reading turn could
10
+ // narrow only by class. The product is register-agnostic: every server now spreads these same
11
+ // properties into its proposal schema, and what differs by register is a capability fact the server
12
+ // passes in, never a missing field.
13
+ //
14
+ // The words are the shipped words, moved rather than rewritten: the goods and `narrows` text is what the
15
+ // Clarivate server already served, and each server keeps the `regions` sentence it (or its sibling
16
+ // register) already carried. A register that cannot search goods text, or cannot offer alternatives in
17
+ // one goods clause, is handled in the mint, the same way the compiler handles it.
18
+
19
+ const GOODS_WORDS = "OPTIONAL goods narrowing on a MARK-TEXT proposal: the same question, limited to filings whose "
20
+ + "goods and services description carries one of these words. This is the FIRST move when the identical mark comes "
21
+ + "back as a count instead of a list — the words are the ones the variants stage already wrote for this matter, the "
22
+ + "client's own wording plus the synonyms. Single words or short phrases as a specification would write them, no "
23
+ + "wildcards. Not allowed on predicate:owner.";
24
+
25
+ const NARROWS = "OPTIONAL: the qid of the CROWDED question this proposal replaces. Put it on a narrowing — the same "
26
+ + "question limited by goods, by market, by the dominant word or to one class — so the record shows the crowd and the "
27
+ + "question that answered it side by side, each with its own count. A narrowing that does not name what it replaced "
28
+ + "leaves the crowd looking unanswered.";
29
+
30
+ /** The `regions` sentence a register whose requests need no office carries (shipped on Corsearch). */
31
+ export const REGIONS_CODES = "UPPERCASE 2-letter region codes, e.g. ['US','EU','CH'] — never spelled-out names "
32
+ + "(recognized display names are normalized; unknown values are rejected)";
33
+
34
+ /**
35
+ * The three narrowing properties, for a proposal item's `properties`.
36
+ * @param opts.regions the register's `regions` description (REGIONS_CODES unless the register states more)
37
+ * @param opts.goodsNote a sentence appended to the goods description, for a register fact the model must know
38
+ */
39
+ export function narrowingFields({ regions = REGIONS_CODES, goodsNote = "" } = {}) {
40
+ return {
41
+ goods_words: { type: "array", items: { type: "string" }, description: goodsNote ? `${GOODS_WORDS} ${goodsNote}` : GOODS_WORDS },
42
+ regions: { type: "array", items: { type: "string" }, description: regions },
43
+ narrows: { type: "string", description: NARROWS },
44
+ };
45
+ }
@@ -497,6 +497,19 @@ serve({
497
497
  },
498
498
  watchlist_owners: { type: "array", items: { type: "string" },
499
499
  description: "Real register owners the plan compiles owner lanes from — never sectors or descriptions." },
500
+ goods_words: { type: "array", items: { type: "string" },
501
+ description:
502
+ "The words the register search is narrowed to: the client's own goods and services wording " +
503
+ "first, then the words other filings use for the same goods that it does not already " +
504
+ "contain. Single words or short phrases as a specification would write them, no wildcards, " +
505
+ "at most 24. Each is matched against the goods and services description of registered " +
506
+ "marks, so use words a specification would contain; a word broader than the goods widens " +
507
+ "the search instead of narrowing it. The register cannot read the words and, or, not, adj " +
508
+ "or near inside an item — an item containing one is searched without it. Send an EMPTY " +
509
+ "LIST when you have considered the goods and no word is worth narrowing by; omit the " +
510
+ "field only when the matter states no goods at all. Those are different answers and the " +
511
+ "run records which one you gave.",
512
+ },
500
513
  scope_ledger: {
501
514
  type: "array",
502
515
  description:
@@ -826,6 +839,23 @@ serve({
826
839
  },
827
840
  },
828
841
  },
842
+ // THE SAME DEFECT TWICE MORE, and the same fix. The dispatch asks for both of these in its own
843
+ // imperative, the acceptor validates and records them, and the plan compile reads the classes —
844
+ // and until now neither was a property here, so a frame following its schema could send neither.
845
+ // A real run's frame carried no added class although the goods reached one. Shape only: the
846
+ // dispatch already says what to send and why, so no sentence the model reads is added here.
847
+ identified_classes: {
848
+ type: "array",
849
+ items: {
850
+ type: "object",
851
+ required: ["class", "reason"],
852
+ properties: {
853
+ class: { type: "integer", minimum: 1, maximum: 45 },
854
+ reason: { type: "string" },
855
+ },
856
+ },
857
+ },
858
+ ratified_forms: { type: "array", items: { type: "string" } },
829
859
  // OFFERED HERE, OR NEVER SENT. The acceptor took this field for a beta and the plan acted on it,
830
860
  // and no frame ever proposed one, because the schema a model is given did not offer it. Worded as
831
861
  // the owner approved it; it is model-facing prose, so its wording is his.
@@ -11,6 +11,7 @@
11
11
  import { serve } from "./stdio-server.mjs";
12
12
  import { doSearch, doRecordFetch, doEnumerate, doExecutePlan, DEFAULT_BASE } from "../../../providers/signa/src/core.js";
13
13
  import { proposeSupplemental } from "./supplemental.mjs";
14
+ import { narrowingFields } from "./proposal-fields.mjs";
14
15
  import { CAPABILITIES } from "../../../providers/signa/src/capabilities.js";
15
16
 
16
17
  const API_KEY = process.env.SIGNA_API_KEY || "";
@@ -101,6 +102,7 @@ serve({
101
102
  romanization: { type: "string", description: "The Latin-script form of a NON-LATIN term. On THIS source it is NOT used to rescue the slice — nativeScriptIndex is true, so the characters are sent as themselves and the romanisation is carried for the reader only." },
102
103
  owner: { type: "string", description: "OPTIONAL owner scope field on a MARK-TEXT proposal: the owner×term intersection, served by filters.owner_name in the same request. Not allowed on predicate:owner (there the owner name IS the term)." },
103
104
  nice_classes: { type: "array", items: {} },
105
+ ...narrowingFields(), // the narrowing fields every register serves (proposal-fields.mjs)
104
106
  rationale: { type: "string" },
105
107
  term_literal: { type: "boolean", description: "TRUE only when the term genuinely IS the mark verbatim (a multi-word slogan mark, a mark carrying an anchored star) — it bypasses the term-shape lint. Never use it to push a label through." },
106
108
  } } },
@@ -30,7 +30,7 @@
30
30
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
33
- import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions, houseElementOf, withoutHouseElementTerms } from "../../register-plan.mjs";
33
+ import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
34
34
  import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
35
35
  import { isNonLatinTerm, romanizationRefusal, romanizationSpellings, nativeScriptIndexGap } from "../../../providers/_shared/script-form.mjs";
36
36
 
@@ -64,13 +64,37 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
64
64
  // first, non-priority ones keep their relative order behind them. The cap VALUES are unchanged
65
65
  // (no count threshold moves), and with no priorityClasses the order is byte-identical to before.
66
66
  const prio = new Set((priorityClasses ?? []).map((c) => String(c).trim()).filter(Boolean));
67
- const indexed = (proposals ?? []).map((p, i) => ({ p: p ?? {}, i }));
67
+ // ONE QUESTION PER GOODS WORD where the register cannot offer alternatives in one goods clause — the
68
+ // compiler's own rule for such a register (register-plan.mjs, goodsTextListOr). Joined, the words would
69
+ // be intersected and the answer would narrow as the list grew; the connector refuses that shape
70
+ // outright. Each word keeps the proposal's index, so a refusal still names the proposal it came from.
71
+ const perWord = capabilities?.goodsTextSearch === true && capabilities?.goodsTextListOr === false;
72
+ // ONE QUESTION PER NAME where the register has no OR at all (maxOrWidth 1) — the compiler's own width
73
+ // for such a register. A batch of names went out as windows of one name each under ONE question, and
74
+ // the enumerate kernel stopped the whole batch at the first name that crowded or failed, so every name
75
+ // after it was never sent. Split, each name is its own question: its own count, its own crowd decision
76
+ // and its own receipt row. The names of one batch still spend ONE slot of the per-call and per-axis
77
+ // caps between them, as the batch did; `split_of` names the batch so the next call counts it once.
78
+ const perName = Number(capabilities?.maxOrWidth) === 1;
79
+ const indexed = (proposals ?? []).flatMap((p, i) => {
80
+ const names = perName && Array.isArray(p?.terms) ? p.terms.map((t) => String(t ?? "").trim()).filter(Boolean) : [];
81
+ const splitOf = names.length > 1 && names.length <= PLAN_MAX_OR_WIDTH
82
+ ? fingerprint({ axis, predicate: String(p?.predicate ?? "default"), names: [...names].sort(), i }) : null;
83
+ const byName = splitOf ? names.map((t) => { const { terms: _batch, ...rest } = p; return { ...rest, term: t }; }) : [p ?? {}];
84
+ return byName.flatMap((q) => {
85
+ const words = perWord && Array.isArray(q?.goods_words) ? q.goods_words.map((w) => String(w ?? "").trim()).filter(Boolean) : [];
86
+ return words.length > 1
87
+ ? words.map((w, k) => ({ p: { ...q, goods_words: [w] }, i, slot: `${i}:${k}`, splitOf }))
88
+ : [{ p: q, i, slot: `${i}:0`, splitOf }];
89
+ });
90
+ });
91
+ const slots = new Set();
68
92
  const ordered = prio.size
69
93
  ? [...indexed.filter(({ p }) => inPriority(p, prio)), ...indexed.filter(({ p }) => !inPriority(p, prio))]
70
94
  : indexed;
71
- for (const { p, i } of ordered) {
95
+ for (const { p, i, slot, splitOf } of ordered) {
72
96
  const issue = (msg) => rejected.push({ index: i, issue: msg, proposal: compactProposal(p) });
73
- if (minted.length >= perCall) { issue(`per-call cap ${perCall} reached`); continue; }
97
+ if (!slots.has(slot) && slots.size >= perCall) { issue(`per-call cap ${perCall} reached`); continue; }
74
98
  const predicate = String(p.predicate ?? "default");
75
99
  if (!PLAN_PREDICATES.includes(predicate)) { issue(`unknown predicate "${predicate.slice(0, 20)}" (one of: ${PLAN_PREDICATES.join(", ")})`); continue; }
76
100
  const terms = Array.isArray(p.terms) ? p.terms.map((t) => String(t ?? "").trim()).filter(Boolean) : null;
@@ -107,6 +131,15 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
107
131
  if (!isNonLatinTerm(term)) { issue(`a romanization belongs ONLY on a non-Latin term — "${term.slice(0, 40)}" is already Latin script, so this romanization transliterates a DIFFERENT string; drop it or fix the term`); continue; }
108
132
  romanizedTerms = romanizationSpellings(romanRaw);
109
133
  }
134
+ // THE GOODS NARROWING, validated where every other field is. Whole words or short phrases as a
135
+ // specification writes them; a wildcard is a hard refusal on this field at the register, and an
136
+ // operator word inside an item is stripped downstream by the shared reader, so nothing here needs
137
+ // to invent a second rule for either.
138
+ const goodsWords = (Array.isArray(p.goods_words) ? p.goods_words : (typeof p.goods_words === "string" ? [p.goods_words] : []))
139
+ .map((w) => String(w ?? "").trim()).filter(Boolean);
140
+ if (p.goods_words != null && !goodsWords.length) { issue("goods_words, when present, must carry at least one word"); continue; }
141
+ if (goodsWords.some((w) => /[*?]/.test(w))) { issue("a goods word carries a wildcard, which this register refuses on that field"); continue; }
142
+ if (goodsWords.length && predicate === "owner") { issue("goods_words narrows a MARK-TEXT question; on predicate:owner the owner name is the term"); continue; }
110
143
  const term_literal = p.term_literal === true;
111
144
  // A1 — the same term-shape/term-predicate lint the plan freeze enforces, at the PROPOSAL seam:
112
145
  // the model gets the reason IN-TURN (rejected[]) and can re-propose the mark-shaped term — a
@@ -205,6 +238,19 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
205
238
  for (const r of covered) regions.push(r);
206
239
  }
207
240
  }
241
+ // A CONTAINS-FORM PROPOSAL ON A TERM SHORTER THAN THE REGISTER'S FLOOR is asked on the exact form,
242
+ // the rule the compiler applies to its own goods-narrowed questions and saturation probes: the
243
+ // register refuses the contains form for a term that short, so the question as proposed would come
244
+ // back as an error rather than an answer. Same classes, goods words and scope, one entry for one,
245
+ // and the entry says so. Decided BEFORE the fingerprint, so the qid names the question actually
246
+ // asked. A stack is switched only when every member is below the floor; a mixed stack keeps the
247
+ // form the model chose, and its short members are refused and disclosed on their own.
248
+ let askedPredicate = predicate;
249
+ let substituted = null;
250
+ if (predicate === "default") {
251
+ const subs = (terms ?? [term]).map((t) => containsFormSubstitution(t, capabilities));
252
+ if (subs.length && subs.every(Boolean)) { askedPredicate = "exact"; substituted = subs[0]; }
253
+ }
208
254
  const anchor = terms ? terms[0] : term;
209
255
  // The fingerprint (⇒ the qid) deliberately EXCLUDES the romanization: the qid names the QUESTION
210
256
  // (which term, which predicate, which scope) and the romanisation is carriage, not a different
@@ -212,37 +258,68 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
212
258
  // with the Latin form added — the exact wedge shape the regions inheritance above exists to kill.
213
259
  // Instead a re-proposal that adds a romanisation to a stored bare qid ENRICHES it (below), the
214
260
  // same field-level, never-term-changing merge extendRegisterPlan applies to the dictated plan.
215
- const fp = String(fingerprint({ predicate, term: term || null, terms: terms || null, nice_classes: nice, regions, ...(owner ? { owner } : {}) })).replace(/^fnv1a:/, "");
216
- const qid = `supp:${axis}:${predicate}:${slug(anchor)}:${fp.slice(0, 8)}`;
261
+ // …and it deliberately INCLUDES the goods narrowing, for the opposite reason. A goods-limited
262
+ // re-ask of a crowded question is the same term, predicate, classes and scope — it differs only by
263
+ // what the filings must cover. Excluded, it would mint the crowd's own qid and be read as a
264
+ // re-proposal of the question it exists to replace, so the first move against a crowd would
265
+ // silently become no move at all.
266
+ const fp = String(fingerprint({ predicate: askedPredicate, term: term || null, terms: terms || null, nice_classes: nice, regions,
267
+ ...(owner ? { owner } : {}), ...(goodsWords.length ? { goods: [...goodsWords].sort() } : {}) })).replace(/^fnv1a:/, "");
268
+ const qid = `supp:${axis}:${askedPredicate}:${slug(anchor)}:${fp.slice(0, 8)}`;
217
269
  if (existingQids.has(qid) || minted.some((e) => e.qid === qid)) {
218
270
  reused.push(qid);
219
271
  if (romanizedTerms && existingQids.has(qid)) enriched.push({ qid, term, romanizedTerms });
220
272
  continue;
221
273
  }
222
- if (budget <= 0) { issue(`per-axis cap ${axisMax} reached — assess whether an existing supplemental already covers this`); continue; }
223
- budget -= 1;
274
+ if (!slots.has(slot)) {
275
+ if (budget <= 0) { issue(`per-axis cap ${axisMax} reached — assess whether an existing supplemental already covers this`); continue; }
276
+ budget -= 1;
277
+ }
278
+ slots.add(slot);
224
279
  const entry = {
225
- qid, axis, predicate,
280
+ qid, axis, predicate: askedPredicate,
281
+ ...(substituted ? { contains_substituted: substituted } : {}),
226
282
  ...(terms ? { terms } : { term }),
227
283
  ...(romanizedTerms ? { romanizedTerms } : {}),
228
284
  ...(owner ? { owner } : {}),
285
+ // Carried as `goods_text`, the field the plan and every connector already speak — the tool calls
286
+ // it `goods_words` because that is what the manual and the manifest call it to the model.
287
+ ...(goodsWords.length ? { goods_text: goodsWords } : {}),
288
+ // What this question REPLACED. The ledger shows the crowd and its narrowing together, each with
289
+ // its own count, so a crowd never reads as a question nobody answered.
290
+ ...(typeof p.narrows === "string" && p.narrows.trim() ? { narrows: p.narrows.trim().slice(0, 200) } : {}),
229
291
  ...(term_literal ? { term_literal: true } : {}),
230
292
  nice_classes: nice, regions,
231
293
  expected_kind: "enumerate",
232
294
  origin: "supplemental",
295
+ ...(splitOf ? { split_of: splitOf } : {}),
233
296
  ...(typeof p.rationale === "string" && p.rationale.trim() ? { rationale: p.rationale.trim().slice(0, 200) } : {}),
234
297
  };
235
298
  // F1 — an owner×term slice on a provider that cannot intersect them is minted as an UNSUPPORTED
236
299
  // entry (→ the executor's deferred lane → a disclosed coverage row), exactly like a missing
237
300
  // predicate at compile time. Never rejected (the gap belongs on the record) and never silently
238
301
  // widened into an owner-less sweep.
239
- const ownerGap = ownerIntersectionGap(entry, capabilities);
240
- if (ownerGap) { entry.unsupported = true; entry.unsupported_reason = ownerGap; }
302
+ // …and a goods narrowing on a register that cannot search goods text is the same kind of gap, with
303
+ // the compiler's own reason (goodsTextGap): recorded, never run on the class alone, which would ask the
304
+ // crowd the narrowing exists to cut.
305
+ const gap = ownerIntersectionGap(entry, capabilities) ?? goodsTextGap(entry, capabilities);
306
+ if (gap) { entry.unsupported = true; entry.unsupported_reason = gap; }
241
307
  minted.push(entry);
242
308
  }
243
309
  return { minted, reused, rejected, enriched, narrowed };
244
310
  }
245
311
 
312
+ /** The per-axis cap's count of what is already on file: the names of one split batch are one slot. PURE. */
313
+ export function supplementalSlots(entries) {
314
+ const batches = new Set();
315
+ let n = 0;
316
+ for (const e of (entries ?? [])) {
317
+ if (e?.split_of) { if (!batches.has(e.split_of)) { batches.add(e.split_of); n += 1; } }
318
+ else n += 1;
319
+ }
320
+ return n;
321
+ }
322
+
246
323
  /**
247
324
  * Append mint-rejection rows to a supplemental-plan doc's `rejected[]` (append-only, beside
248
325
  * entries[] — one sidecar per axis holds BOTH what folded into the plan and what died at the seam,
@@ -326,7 +403,7 @@ export async function proposeSupplemental(params, tctx, deps) {
326
403
  return { type: "text", text: JSON.stringify({ minted: [], reused: [], rejected: [], excluded_house_element: excludedHouse, executed: false }, null, 2) };
327
404
  const existingQids = new Set(supp.entries.map((e) => e.qid));
328
405
  const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
329
- { existingQids, perCall, axisMax, existingCount: supp.entries.length, capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
406
+ { existingQids, perCall, axisMax, existingCount: supplementalSlots(supp.entries), capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
330
407
 
331
408
  // Field-level romanisation enrichment of a REUSED qid (2026-07-30 review round): the natural retry —
332
409
  // a bare non-Latin proposal deferred at the wire, the model re-proposes it WITH the romanisation —