clearotron 0.3.2-beta.13 → 0.3.2-beta.15

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 (45) hide show
  1. package/bin/clearotron.mjs +7 -1
  2. package/bin/example.mjs +49 -22
  3. package/build-info.json +2 -2
  4. package/driver/CHANGELOG.md +22 -0
  5. package/driver/contract-vocabulary.mjs +5 -5
  6. package/driver/engine/mcp/codex-config.mjs +3 -11
  7. package/driver/named-band.mjs +1 -1
  8. package/driver/package.json +1 -1
  9. package/driver/pipeline-knockout.mjs +3 -1
  10. package/driver/pipeline.mjs +30 -7
  11. package/driver/publish/index.mjs +10 -1
  12. package/driver/publish/render-knockout.mjs +31 -6
  13. package/driver/publish/render.mjs +54 -2
  14. package/driver/publish/templates/report.css +16 -1
  15. package/driver/register-availability.mjs +2 -2
  16. package/driver/register-plan.mjs +229 -55
  17. package/driver/skills/clearance-register/unit.md +1 -1
  18. package/driver/skills/clearance-variants/SKILL.md +10 -4
  19. package/driver/stages.mjs +1 -1
  20. package/driver/suite-census.json +45 -3
  21. package/driver/variant-manifest-model.mjs +39 -1
  22. package/mcp-server/CHANGELOG.md +8 -0
  23. package/mcp-server/lib/audit-view.mjs +5 -3
  24. package/mcp-server/lib/brief.mjs +10 -4
  25. package/mcp-server/lib/runs.mjs +35 -1
  26. package/mcp-server/package.json +1 -1
  27. package/mcp-server/server.mjs +6 -3
  28. package/package.json +1 -1
  29. package/portal-ui/dist/assets/{index-7Lq-dXDV.css → index-5CCwiJG7.css} +10 -0
  30. package/portal-ui/dist/assets/{index-w8GFZftk.js → index-DMthc7PQ.js} +8 -1
  31. package/portal-ui/dist/index.html +2 -2
  32. package/portal-ui/package.json +1 -1
  33. package/providers/_shared/execute-plan.mjs +11 -1
  34. package/providers/_shared/term-shape.mjs +76 -0
  35. package/providers/clarivate/src/capabilities.js +36 -0
  36. package/providers/clarivate/src/core.js +119 -0
  37. package/providers/corsearch/src/capabilities.js +24 -0
  38. package/providers/corsearch/src/core.js +7 -0
  39. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  40. package/providers/oauth-mcp-bridge/package.json +1 -1
  41. package/providers/signa/src/capabilities.js +29 -0
  42. package/providers/signa/src/core.js +17 -0
  43. package/shared/demo-start-args.mjs +10 -0
  44. package/shared/stdio-connect.mjs +41 -10
  45. package/shared/toml-string.mjs +25 -0
@@ -285,8 +285,10 @@ const TIMELINE_FIELDS = ["ts", "seq", "kind", "phase", "stage", "decision", "tri
285
285
  "changedFromPrevious", "attempt", "axes", "axis", "escalated", "verdict", "display", "cause",
286
286
  "recovered", "count", "uris", "findings", "negatives", "audit", "snapshot", "resume"];
287
287
 
288
- // `state` and `verdict` TRAVEL — the timeline's own conclusion, and dropping them here while
289
- // accountTrace keeps `judgment.verdict` would have been two surfaces disagreeing about one fact.
288
+ // `state` and `tier` TRAVEL — the run's state and its BAND. The band replaced the gate's word here (824):
289
+ // CLEAR / CONDITIONAL / BLOCKING is engine vocabulary, and a client's assistant reading it beside a
290
+ // rating of Medium reported the run as delivered BLOCKING. The gate's decisions are not lost — every one
291
+ // of them is a timeline entry and a `verdictHistory` row, which is what this surface exists to narrate.
290
292
  //
291
293
  // `riskLadderAvailable` and `note` do NOT, together and for one reason: the flag exists only to say
292
294
  // whether `diff_artifact` could show the word-by-word change, and `diff_artifact` is sealed. A flag
@@ -303,7 +305,7 @@ export function accountTimeline(result, { brandName = "The firm" } = {}) {
303
305
  return out;
304
306
  };
305
307
  return {
306
- ...pick(result, ["runId", "state", "verdict", "_note"]),
308
+ ...pick(result, ["runId", "state", "tier", "_note"]),
307
309
  timeline: Array.isArray(result.timeline) ? result.timeline.map(entry) : [],
308
310
  verdictHistory: Array.isArray(result.verdictHistory)
309
311
  ? result.verdictHistory.map((v) => pick(v, ["ts", "kind", "verdict", "stage"]))
@@ -74,9 +74,12 @@ export function buildBrief(run) {
74
74
  const delivered = run.state === "delivered" || Boolean(run.deliveredAt);
75
75
  const lines = [];
76
76
 
77
- const overall = clearance?.verdict?.band ?? clearance?.verdict?.verdict
77
+ // THE BAND, AND NEVER THE GATE'S WORD. This chain used to fall through to the delivery verdict — the
78
+ // sidecar's `verdict`, then the run's — so a run whose report reads Medium could be briefed as BLOCKING.
79
+ // The band is what the report shows; where no band is recorded the line is not drawn at all.
80
+ const overall = clearance?.verdict?.band ?? clearance?.verdict?.tier
78
81
  ?? (koDocs.length === 1 ? (koDocs[0].overall ?? null) : null)
79
- ?? fm.overall_label ?? run.verdict ?? null;
82
+ ?? fm.overall_label ?? run.tier ?? null;
80
83
 
81
84
  // headline — the mark, the product THIS run actually is, and the run date. A null product prints
82
85
  // nothing rather than a fallback name.
@@ -100,7 +103,10 @@ export function buildBrief(run) {
100
103
  const paused = run.state === "postponed" ? ` — paused on a usage-limit cap, auto-resumes ${run.resetsAt ? `at ${String(run.resetsAt).replace("T", " ").slice(0, 16)} UTC` : "when the cap resets"}`
101
104
  : run.state === "recovering" ? ` — auto-recovery backoff, resumes ${run.recoveryResumesAt ? `at ${String(run.recoveryResumesAt).replace("T", " ").slice(0, 16)} UTC` : "on its own"}`
102
105
  : run.state === "parked-for-human" ? ` — parked by a runner stop (deploy/restart), resumes on the next runner activation` : "";
103
- lines.push(`Status: ${run.state}${paused}${run.verdict ? ` (reviewer verdict: ${run.verdict})` : ""}.`);
106
+ lines.push(`Status: ${run.state}${paused}.`);
107
+ // The run's own sentence, composed once by the driver and rendered on every client surface. It says
108
+ // what the gate word used to be reached for, in the words the report itself uses.
109
+ if (run.statement) lines.push(String(run.statement));
104
110
  }
105
111
 
106
112
  let source = "none";
@@ -198,7 +204,7 @@ export function buildBrief(run) {
198
204
  return {
199
205
  runId: run.runId, markName: run.markName ?? clearance?.markName ?? fm.title ?? null,
200
206
  product,
201
- overall, verdict: run.verdict ?? null, state: run.state ?? null, date: run.date ?? null,
207
+ overall, tier: run.tier ?? null, statement: run.statement ?? null, state: run.state ?? null, date: run.date ?? null,
202
208
  source, brief: lines.join("\n"),
203
209
  };
204
210
  }
@@ -36,6 +36,28 @@ function findStatusFiles(root, depth, acc) {
36
36
  * workspace, lists exactly as before, and an empty list from either still means an empty list. PURE given
37
37
  * its inputs.
38
38
  */
39
+ // The three words the delivery gate decides in. They are engine vocabulary and never a rating.
40
+ const GATE_WORDS = new Set(["CLEAR", "CONDITIONAL", "BLOCKING"]);
41
+ /** A recorded outcome word read as a rating band, or null where it is the gate's decision. PURE. */
42
+ export const bandWord = (v) => {
43
+ const w = String(v ?? "").trim();
44
+ return w && !GATE_WORDS.has(w.toUpperCase()) ? w : null;
45
+ };
46
+
47
+ /**
48
+ * The band a run recorded before `status.json` carried one: the verdict record's own `tier`. Read only
49
+ * where the status has neither a band nor a band-shaped outcome word, so a current run costs no read.
50
+ * Null where there is no record to read — an absence, never a guess. PURE of everything but the file.
51
+ */
52
+ export function tierFromRecord(runDir) {
53
+ if (!runDir) return null;
54
+ try {
55
+ const v = JSON.parse(readFileSync(driverDir(runDir, "verdict.json"), "utf8"));
56
+ const t = String(v?.tier ?? "").trim();
57
+ return t || null;
58
+ } catch { return null; }
59
+ }
60
+
39
61
  export function unreadableRunsReason({ workSet, workRoot, workExists, poolSet }) {
40
62
  if (workSet || workExists || poolSet) return null;
41
63
  return `no searches can be read here: CLEAROTRON_WORK_DIR is unset and ${workRoot} does not exist, `
@@ -68,7 +90,19 @@ function runFromStatusFile(statusFile, agent) {
68
90
  runId: s.runId ?? `${s.slug}-${s.date}-${s.codename}`,
69
91
  slug: s.slug, codename: s.codename, date: s.date,
70
92
  agent: s.agent ?? agent,
71
- state: s.state ?? null, verdict: s.verdict ?? null, url: s.url ?? null,
93
+ // The BAND and the run's own composed sentence, never the delivery gate's word: `verdict` is
94
+ // engine vocabulary (CLEAR / CONDITIONAL / BLOCKING) and stays in the run record.
95
+ //
96
+ // ONE FIELD, TWO LANES. The knockout lane records its BAND in `verdict` — "High", "Manageable" — and
97
+ // every archived run of either lane has only that field, so the band is read from it where it is a
98
+ // band and dropped where it is the gate's word. A clearance run recorded since 2026-09-20 carries
99
+ // `tier` and needs no such reading.
100
+ // THE BAND IS ALWAYS HERE, and the gate's word is not. Reading `bandWord` alone left a run recorded
101
+ // before the band was written with NO band at all — `bandWord` answers null for a gate word — so an
102
+ // assistant saw the gate's word and nothing beside it, which is the shape this whole item is about.
103
+ // The verdict record holds the band for those runs, so it is read from there.
104
+ state: s.state ?? null,
105
+ tier: s.tier ?? bandWord(s.verdict) ?? tierFromRecord(runDir), statement: s.statement ?? null, url: s.url ?? null,
72
106
  markName: s.markName ?? null, ref: s.ref ?? null, classes: s.classes ?? null,
73
107
  stepN: s.stepN ?? null, stepLabel: s.stepLabel ?? null, stepTotal: s.stepTotal ?? null,
74
108
  failedStage: s.failedStage ?? null, reason: s.reason ?? null,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2-beta.13",
3
+ "version": "0.3.2-beta.15",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -207,7 +207,8 @@ function runSummary(run) {
207
207
  // once. null where the registry cannot name it — the row says nothing rather than guessing, because
208
208
  // a hardcoded fallback is how a knockout once announced itself as a product it provably was not.
209
209
  product: productIdentityFor(run),
210
- state: run.state, location: run.location, verdict: run.verdict, url: run.url,
210
+ // The band and the run's own sentence; the gate's word stays in the run record (824).
211
+ state: run.state, location: run.location, tier: run.tier, statement: run.statement, url: run.url,
211
212
  markName: run.markName, ref: run.ref, classes: run.classes,
212
213
  step: s.stepN ? `${s.stepN}/${s.stepTotal} ${s.stepLabel ?? ""}`.trim() : null,
213
214
  startedAt: run.startedAt, updatedAt: run.updatedAt, deliveredAt: run.deliveredAt,
@@ -480,7 +481,9 @@ const tools = {
480
481
  // a generic _history dir for some OTHER stage does not make register-findings diffable.
481
482
  const riskLadderAvailable = listArtifactVersions(run.P, run.runDir, "register-digest", null).length > 1;
482
483
  return {
483
- runId: run.runId, state: run.state, verdict: run.verdict, verdictHistory, timeline,
484
+ // The chain narrates the gate's decisions, and every one of them is on the timeline and in
485
+ // `verdictHistory`, which is where they belong. The run's own headline is its BAND (824).
486
+ runId: run.runId, state: run.state, tier: run.tier, verdictHistory, timeline,
484
487
  riskLadderAvailable,
485
488
  note: riskLadderAvailable
486
489
  ? "A prior register-findings (digest) snapshot exists — diff_artifact can show the word-by-word change."
@@ -497,7 +500,7 @@ const tools = {
497
500
  let changes = timeline;
498
501
  if (Array.isArray(kinds) && kinds.length) changes = changes.filter((c) => kinds.includes(c.kind));
499
502
  return {
500
- runId: run.runId, state: run.state, verdict: run.verdict, since: since ?? null, cursor,
503
+ runId: run.runId, state: run.state, tier: run.tier, since: since ?? null, cursor,
501
504
  count: changes.length, changes,
502
505
  note: "Poll again with since=cursor (a stable sequence number) for only newer events — MCP has no push. cursor is independent of the kinds filter.",
503
506
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.2-beta.13",
4
+ "version": "0.3.2-beta.15",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -2922,6 +2922,16 @@ details[open] > .fold-summary .fold-chev {
2922
2922
  border: 1px solid var(--border-hairline); border-radius: 11px; background: var(--surface-raised);
2923
2923
  }
2924
2924
 
2925
+ /* THE WAIT IS NOT A FAULT, AND IT MUST NOT BE READ AS ONE. It looks like `.home2-notice` because both
2926
+ are a quiet line above the lists, but it is deliberately NOT that class: `.home2-notice` is where
2927
+ this page states a fault, and home-render-check reads it as exactly that. Drawing the wait there
2928
+ made a page with nothing wrong report a fault in both themes — the very thing the wait was added to
2929
+ stop, arriving through the class attribute. */
2930
+ .home2-waiting {
2931
+ margin: 0 0 13px; padding: 11px 14px; font-size: 13px; color: var(--text-body);
2932
+ border: 1px solid var(--border-hairline); border-radius: 11px; background: var(--surface-raised);
2933
+ }
2934
+
2925
2935
  /* 340px minimum is LOAD-BEARING: the depth label must never be clipped, and three of the five depths
2926
2936
  differ only in their suffix. */
2927
2937
  .home2-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 13px; }
@@ -13299,7 +13299,10 @@ function AppShell({ render }) {
13299
13299
  setDrawer(false);
13300
13300
  setAvatarOpen(false);
13301
13301
  }, [path]);
13302
- if (!meResult) return (0, import_jsx_runtime.jsx)("div", { className: "screen" });
13302
+ if (!meResult) return (0, import_jsx_runtime.jsx)("div", {
13303
+ className: "screen",
13304
+ children: (0, import_jsx_runtime.jsx)("p", { children: "Loading…" })
13305
+ });
13303
13306
  if (sessionEnded || meResult && meResult.kind === "signedOut") return (0, import_jsx_runtime.jsx)(SessionEnded, {});
13304
13307
  if (!meResult || meResult.kind !== "ok") return (0, import_jsx_runtime.jsx)("div", {
13305
13308
  className: "screen",
@@ -15530,6 +15533,10 @@ function Home({ ctx }) {
15530
15533
  label: "Filter by company"
15531
15534
  })
15532
15535
  }),
15536
+ answer === "loading" ? (0, import_jsx_runtime.jsx)("p", {
15537
+ className: "home2-waiting",
15538
+ children: "Loading…"
15539
+ }) : null,
15533
15540
  answer === "error" ? (0, import_jsx_runtime.jsx)("p", {
15534
15541
  className: "home2-notice",
15535
15542
  children: result?.kind === "rateLimited" ? "Too many requests just now. The portal is pacing itself; this will refresh on its own." : "This did not load. Nothing is wrong with your runs — the list will try again."
@@ -49,8 +49,8 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-w8GFZftk.js"></script>
53
- <link rel="stylesheet" crossorigin href="/portal/assets/index-7Lq-dXDV.css">
52
+ <script type="module" crossorigin src="/portal/assets/index-DMthc7PQ.js"></script>
53
+ <link rel="stylesheet" crossorigin href="/portal/assets/index-5CCwiJG7.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="root"></div>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.13",
5
+ "version": "0.3.2-beta.15",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -17,7 +17,7 @@
17
17
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
  import { nativeScriptIndexGap } from "./script-form.mjs";
20
- import { entryTermIssues } from "./term-shape.mjs";
20
+ import { entryTermIssues, goodsTermsList } from "./term-shape.mjs";
21
21
  import { faultText, guardToolCall } from "./transport-guard.mjs";
22
22
  import { clipProviderText } from "./provider-text.mjs"; // — keep the discriminator
23
23
 
@@ -240,6 +240,16 @@ export function defaultBuildEntryQuery(e, pp) {
240
240
  // F1 owner×term intersection: a mark-text entry carrying `owner` rides it as an additional
241
241
  // owner filter beside the name clause (see the doc block above defaultBuildEntryQuery).
242
242
  ...(!__owner && typeof e.owner === "string" && e.owner.trim() ? { owner: e.owner.trim() } : {}),
243
+ // The goods-and-services narrowing, carried the same way and for the same reason as `owner`: an
244
+ // extra FIELD on the same request, never a second query. A provider that cannot send it declares
245
+ // `goodsTextSearch` false and the entry is refused before the query is built (goodsTextGap), so
246
+ // this line never reaches a connector that would quietly drop the clause and run the wide sweep.
247
+ ...(goodsTermsList(e).length ? { goods_text: goodsTermsList(e) } : {}),
248
+ // …and the DISTANCES those words stood at. The compiler stripped the register's operator words
249
+ // once and stored what will be asked, so a term arrives here with nothing left to strip: without
250
+ // the gaps a connector would join "controllers peripherals" as a plain adjacency, which is the
251
+ // query that matches nothing. The plan states the distances; this carries them.
252
+ ...(Array.isArray(e?.goods_text_gaps) && e.goods_text_gaps.length ? { goods_text_gaps: e.goods_text_gaps } : {}),
243
253
  ...modeParams,
244
254
  nice_classes: (e.nice_classes ?? []).map(Number).filter(Number.isFinite),
245
255
  ...(Array.isArray(e.regions) && e.regions.length ? { regions: e.regions } : {}),
@@ -249,3 +249,79 @@ export function termSubstanceIssue(term) {
249
249
  + `match, under any predicate. Refused at the builder rather than bounced by the provider and `
250
250
  + `disclosed as a coverage gap the run could never have closed`;
251
251
  }
252
+
253
+ // ── THE GOODS-AND-SERVICES TERMS AN ENTRY CARRIES ─────────────────────────────────────────────────
254
+ //
255
+ // ONE definition, because three places must agree on what "this entry asks for goods text" means: the
256
+ // plan compiler stamping the capability gap, the executor building the query, and each connector
257
+ // writing the clause. Two hand-rolled readings of the same field is how `filters.status` stayed wrong
258
+ // for two months on one provider while looking right on the other.
259
+ //
260
+ // A scalar and a one-element list are the SAME request. Blanks are dropped and duplicates collapse, so
261
+ // an entry asking for the same term twice compiles byte-identically to one asking once — the plan is a
262
+ // pure function of its input, and that must survive this field like every other. PURE.
263
+ export function goodsTermsList(entry) {
264
+ const raw = Array.isArray(entry?.goods_text) ? entry.goods_text
265
+ : (typeof entry?.goods_text === "string" ? [entry.goods_text] : []);
266
+ const out = [];
267
+ for (const t of raw) {
268
+ const s = String(t ?? "").trim();
269
+ if (s && !out.includes(s)) out.push(s);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ // ── A GOODS TERM CARRYING A WORD THE REGISTER READS AS AN OPERATOR ────────────────────────────────
275
+ //
276
+ // `AND`, `OR`, `NOT`, `ADJ` and `NEAR` are operators INSIDE the value string on the register this
277
+ // engine runs on in production, and that field has no escape syntax. A goods term carrying one is not
278
+ // a narrower search there — it is a 400, and because the list rides ONE OR-joined value, a single bad
279
+ // term takes the whole narrowing down with it for the run.
280
+ //
281
+ // `NEAR` is why this is its own function rather than a reused check: the connector's own term
282
+ // validator tests AND/OR/NOT only, so "near field communication" passes every offline check and fails
283
+ // on the wire — the shape that reads as working right up until it does not.
284
+ //
285
+ // THE WORD IS REMOVED, THE ITEM IS NOT. "near field communication" still narrows usefully as
286
+ // "field communication", and dropping it whole would throw away a term the model chose on account of
287
+ // one word the vendor happens to reserve. Only an item that is NOTHING BUT reserved words disappears.
288
+ // Every removal is reported so the caller can disclose it: a term that reached the wire in a
289
+ // different shape than it was written must never do so silently.
290
+ //
291
+ // PURE.
292
+ const GOODS_RESERVED_WORDS = new Set(["and", "or", "not", "adj", "near"]);
293
+
294
+ /**
295
+ * Strip the words this register parses as operators out of a goods term.
296
+ *
297
+ * `{ words, gaps, removed, cleaned }`. `gaps[i]` is the distance from `words[i]` to `words[i+1]` — 1
298
+ * when they were adjacent, 2 when one word was taken out between them, and so on.
299
+ *
300
+ * THE GAP IS THE WHOLE POINT and it is the rule the mark field already follows. Removing a word from
301
+ * the middle of a phrase leaves the survivors further apart than they were written: "controllers and
302
+ * peripherals" asked as a strict adjacency finds nothing, because no filing says "controllers
303
+ * peripherals". Widened by the gap it finds what was meant. A word taken off the FRONT or the BACK
304
+ * changes no distance between the words that remain, so "near field communication" stays a strict
305
+ * adjacency of "field" and "communication".
306
+ *
307
+ * PURE.
308
+ */
309
+ export function stripGoodsReservedWords(term) {
310
+ const removed = [];
311
+ const words = [];
312
+ const gaps = [];
313
+ let owed = 1; // the distance owed to the NEXT kept word
314
+ for (const tok of String(term ?? "").trim().split(/\s+/)) {
315
+ if (!tok) continue;
316
+ // `ADJ2`/`NEAR3` are the numbered forms of the same operators.
317
+ if (GOODS_RESERVED_WORDS.has(tok.toLowerCase().replace(/\d+$/, ""))) {
318
+ removed.push(tok);
319
+ if (words.length) owed += 1; // …only widens a gap once there is something to widen it FROM
320
+ continue;
321
+ }
322
+ if (words.length) gaps.push(owed);
323
+ words.push(tok);
324
+ owed = 1;
325
+ }
326
+ return { words, gaps, removed, cleaned: words.join(" ") };
327
+ }
@@ -214,6 +214,42 @@ export const CAPABILITIES = Object.freeze({
214
214
  // (expandOwnerTerms → assertSearchableTerm → degrade-to-unresolved) applies to the owner value on
215
215
  // this path exactly as on a bare owner sweep — resolution stays additive-only.
216
216
  ownerTermIntersection: true,
217
+ // ── CAN THE REGISTER BE ASKED WHAT A FILING COVERS, NOT JUST WHICH BUCKET IT SITS IN? ────────────
218
+ // `true` — `INT_GOODS_SERVICES_DESCRIPTION` is in the vendor's search-field enum, and it AND-joins
219
+ // with the mark and class clauses in one request exactly as the owner field does. That is the whole
220
+ // lever behind narrowing a crowded contains sweep: the Nice class is a filing bucket that holds
221
+ // headphones and jukeboxes alike, so class-scoping alone cannot cut a crowd on a common word.
222
+ //
223
+ // A provider that does not declare this gets a DISCLOSED DEFERRED ROW for any goods-narrowed slice
224
+ // (register-plan.mjs goodsTextGap → `unsupported`). It must never fall back to the un-narrowed
225
+ // sweep: that would return the crowd the narrowing exists to avoid and record it under the narrowed
226
+ // slice's qid — a widened search wearing a narrow slice's name.
227
+ goodsTextSearch: true,
228
+ // Does this register match a MULTI-WORD goods term as a phrase? YES, but only through `ADJ`, and the
229
+ // connector must do the joining. A BARE SPACE ON THIS FIELD IS AN IMPLICIT OR: both word orders
230
+ // return the same population, that population equals the explicit OR, and the explicit AND is a
231
+ // fraction of it. So a two-word value sent as written WIDENS the sweep to either word, answers 200
232
+ // and reads like a filter that worked — a clause meant to narrow doing the opposite, silently.
233
+ // `A ADJ B` is ordered and is the form core.js emits.
234
+ // What a MULTI-WORD goods term means on this register, named rather than flagged: the two registers
235
+ // that accept one do ENTIRELY DIFFERENT THINGS with it, and a shared boolean said only "yes".
236
+ // "ordered-phrase" — the words in that order, adjacent. Here, via the ADJ operator.
237
+ // "word-intersection" — filings whose description carries every word, anywhere, in any order.
238
+ // null — unmeasured or unsupported: a multi-word term must not be sent.
239
+ goodsTextMultiWord: "ordered-phrase",
240
+ // Several goods terms ride ONE clause joined by OR — this register expresses a list natively.
241
+ goodsTextListOr: true,
242
+ // The operator the goods clause rides. `EQUALS` on WHOLE WORDS: `CONTAINS` is a hard 400 here
243
+ // exactly as it is on APPLICANT_NAME, and so is a mid-word wildcard. Several words are asked for
244
+ // with `OR` inside the value.
245
+ //
246
+ // That makes this field the opposite of the mark field, where every mode is EQUALS with `*TERM*`
247
+ // infix wildcards. The two are NOT interchangeable, and the vendor's own documentation does not
248
+ // separate them. Do not "make it consistent" with the mark modes.
249
+ goodsTextOperator: "EQUALS",
250
+ // Whole words only: no wildcard may be sent on this field, so core.js splits a multi-word term into
251
+ // its words and ORs them rather than compiling an adjacency the field would reject.
252
+ goodsTextWholeWordOnly: true,
217
253
  // ── WHICH FORM OF A NON-LATIN MARK DOES THE INDEX HOLD? ──────────────────────────────────────────
218
254
  // `false` = the TRANSLITERATION ONLY. The characters are not indexed, so searching them returns 0
219
255
  // with no error — the exact false-clean shape a reader calls CLEAN:
@@ -41,6 +41,7 @@ import { makeCountProbe } from "../../_shared/count.mjs";
41
41
  import { CAPABILITY_GAP_MARKER, defaultBuildEntryQuery, makeExecutePlan, makeRegionRequiredBuildEntryQuery, planPredicateParams } from "../../_shared/execute-plan.mjs";
42
42
  import { isNonLatinTerm } from "../../_shared/script-form.mjs";
43
43
  import { CAPABILITIES, CLARIVATE_OFFICE_CODES } from "./capabilities.js";
44
+ import { stripGoodsReservedWords } from "../../_shared/term-shape.mjs"; // the goods field parses these words as operators
44
45
 
45
46
  export const DEFAULT_BASE = "https://api.clarivate.com/compumark-content/api/v1";
46
47
 
@@ -116,6 +117,11 @@ const errText = (r) => r?.body?.errorMessage ?? r?.body?.message ?? (r?.raw ? St
116
117
  // OR-stack — the operator stays EQUALS and the value does the vendor's query-string work.
117
118
  export const MARK_FIELD = "WORD_MARK_SPECIFICATION";
118
119
  export const OWNER_FIELD = "APPLICANT_NAME";
120
+ // The goods-and-services DESCRIPTION field — the text of what a filing covers, not the Nice class
121
+ // number it was filed under. The two are separate fields here and they answer separate questions.
122
+ export const GOODS_FIELD = "INT_GOODS_SERVICES_DESCRIPTION";
123
+ // Declared in capabilities.js, read here — one constant, so the operator changes in one place.
124
+ export const GOODS_OPERATOR = CAPABILITIES.goodsTextOperator ?? "EQUALS";
119
125
 
120
126
  /**
121
127
  * match_mode → { name, operator, pre, post, … }.
@@ -496,6 +502,18 @@ export function ownerTermsOf(p) {
496
502
  return out;
497
503
  }
498
504
 
505
+ /**
506
+ * The goods-and-services terms a request carries. Accepts a scalar or a list; a one-element list and
507
+ * a scalar are the same request. Never an element on its own — see hasAnyElement below.
508
+ */
509
+ export function goodsTermsOf(p) {
510
+ const out = [];
511
+ for (const t of (Array.isArray(p?.goods_text) ? p.goods_text : [p?.goods_text])) {
512
+ if (t != null && String(t).trim()) out.push(String(t).trim());
513
+ }
514
+ return out;
515
+ }
516
+
499
517
  export function hasAnyElement(p) {
500
518
  return markTermsOf(p).length > 0 || ownerTermsOf(p).length > 0 || !!p?.representative;
501
519
  }
@@ -651,6 +669,107 @@ export function buildSearchRequest(p) {
651
669
  searchFields.push({ operator: "EQUALS", name: "INT_CLASS_NUMBER", value: joinOrValue([...new Set(classes)]) });
652
670
  }
653
671
 
672
+ // ── the goods-and-services NARROWING ─────────────────────────────────────────────────────────────
673
+ //
674
+ // A class is a filing bucket, not a specification: class 9 holds headphones and jukeboxes alike, so
675
+ // a contains sweep scoped to the class alone crowds out on any common word. This clause asks the
676
+ // description text instead, and it AND-joins with the mark and class clauses like every other field
677
+ // here — narrowing the same sweep rather than running a second one.
678
+ //
679
+ // THIS FIELD IS NOT THE MARK FIELD, and assuming it was would ship a 400 on every narrowed sweep:
680
+ //
681
+ // EQUALS, whole word → works
682
+ // CONTAINS → hard 400, as on APPLICANT_NAME
683
+ // a mid-word wildcard (`*foo*`) → hard 400
684
+ // `WORD1 OR WORD2` → works, and is how several words are asked for
685
+ //
686
+ // So the value is a list of WHOLE WORDS joined by OR, with no wildcards anywhere — the opposite of
687
+ // the `*TERM*` infix every mark mode uses. THE CLAUSE MUST NOT ROUTE THROUGH `MATCH_MODE_TO_FIELD`:
688
+ // every mode there carries `pre:"*", post:"*"`, so a goods clause built through that map would
689
+ // inherit the wrap, pass every offline test, and 400 on the wire.
690
+ //
691
+ // A MULTI-WORD TERM IS REFUSED RATHER THAN SPLIT. Splitting "computer software" into
692
+ // `computer OR software` would match a filing that only ever says "computer" — a WIDER search than
693
+ // the caller asked for, arriving silently, which is the one thing this engine never does. The word
694
+ // list's contract is whole words (the variants manual says so), so a phrase here is a caller defect
695
+ // and it fails at the door. What the wire does with a two-word value is unprobed, and guessing it
696
+ // into an OR is the same guess wearing a different hat.
697
+ //
698
+ // PLURALS COME FROM THE VENDOR (queryOptions.plurals), SYNONYMS DO NOT. "headphones" does not reach
699
+ // "earphones" on any documented surface here. Nothing in this file invents one: a synonym list is a
700
+ // recall decision about what a search is allowed to miss, and it belongs to whoever writes the word
701
+ // list, never to the connector spending it.
702
+ const goodsTerms = goodsTermsOf(p);
703
+ if (goodsTerms.length) {
704
+ // A NARROWING WITH NOTHING TO NARROW IS NOT A NARROW SEARCH — IT IS A WIDE ONE. Goods text is not
705
+ // a search element (hasAnyElement excludes it deliberately): on its own it asks for every filing
706
+ // in these classes whose description carries the word, from every owner, which is far wider than
707
+ // the sweep it was added to cut. The request would succeed and read as a narrowed slice.
708
+ if (!markTerms.length && !ownerTermsOf(p).length && !p?.representative) {
709
+ throw new Error(
710
+ "goods_text narrows a search and is not one: this request carries no mark term, owner or "
711
+ + "representative, so the goods clause would be the whole query — every filing in these classes "
712
+ + "whose description carries the word. Send it alongside the term it narrows.");
713
+ }
714
+ const words = [];
715
+ let gi = -1;
716
+ for (const t of goodsTerms) {
717
+ gi += 1;
718
+ // A PHRASE IS NEVER PASSED AS TYPED. A bare space on this field is an implicit OR — both word
719
+ // orders return the same population, it equals the explicit OR, and the explicit AND is a
720
+ // fraction of it. So "wireless headphones" sent as written would quietly search for EITHER word:
721
+ // a WIDER sweep than was asked for, answering 200 and reading like a filter that worked.
722
+ //
723
+ // `ADJ` is the operator that expresses a real phrase here, and it is ORDERED. So a multi-word
724
+ // term becomes its words joined by ADJ, and the list of terms is joined by OR — "either of these
725
+ // things, and this one is two words in this order".
726
+ // A WORD THIS FIELD READS AS AN OPERATOR COMES OUT HERE TOO, AND DOES NOT THROW. AND, OR, NOT,
727
+ // ADJ and NEAR are parsed inside the value and there is no escape syntax — and `NEAR` is
728
+ // ordinary specification language, so this is not a contrived input. The plan compiler already
729
+ // strips them and discloses it; this is the backstop for the paths that never go through a plan.
730
+ //
731
+ // It must STRIP rather than refuse: the list rides one OR-joined value, so throwing here would
732
+ // take every good term down with the bad one and leave the crowd a crowd. Refusing loudly is
733
+ // right when the alternative is a wrong answer; here the alternative is a narrower one, and the
734
+ // narrower one is what the caller asked for minus a word the vendor happens to reserve.
735
+ // THE ADJACENCY IS WIDENED BY WHAT WAS TAKEN OUT, which is the rule the mark field already
736
+ // follows: no filing says "controllers peripherals", so a strict adjacency of the survivors
737
+ // finds nothing where `controllers ADJ2 peripherals` finds the phrase that was meant. A word
738
+ // removed from the front or the back changes no distance between the words that remain.
739
+ //
740
+ // THE DISTANCES COME FROM THE PLAN WHERE THERE IS ONE. The compiler strips once and stores what
741
+ // will be asked, so a planned term arrives here already stripped and re-stripping it finds
742
+ // nothing to remove — the gaps would come back all 1 and the query would assert an adjacency
743
+ // that was never written. Stripping again locally is right only for the paths that never went
744
+ // through a plan, and it is a no-op on the ones that did.
745
+ const planned = Array.isArray(p?.goods_text_gaps) ? p.goods_text_gaps[gi] : null;
746
+ const local = stripGoodsReservedWords(t);
747
+ const { words: stripped, removed } = local;
748
+ const gaps = Array.isArray(planned) && planned.length ? planned : local.gaps;
749
+ if (!stripped.length) continue;
750
+ const parts = [];
751
+ for (const w of stripped) {
752
+ const safe = assertSearchableTerm(w, { allowWildcard: false });
753
+ if (safe) parts.push(safe);
754
+ }
755
+ if (!parts.length) continue;
756
+ if (parts.length > 1 && !CAPABILITIES.goodsTextMultiWord) {
757
+ throw new Error(
758
+ `goods term ${JSON.stringify(String(t).slice(0, 40))} is more than one word, and this register `
759
+ + `is not known to match a phrase as a phrase. Send the words you mean, one per entry.`);
760
+ }
761
+ // Single word: itself. Several: an adjacency chain whose every step carries the distance the
762
+ // words actually stood at, so a removal in the middle widens it and a removal at either end
763
+ // does not.
764
+ let value = parts[0];
765
+ for (let i = 1; i < parts.length; i++) {
766
+ value += ` ADJ${gaps[i - 1] > 1 ? gaps[i - 1] : ""} ${parts[i]}`;
767
+ }
768
+ if (!words.includes(value)) words.push(value);
769
+ }
770
+ if (words.length) searchFields.push({ operator: GOODS_OPERATOR, name: GOODS_FIELD, value: joinOrValue(words) });
771
+ }
772
+
654
773
  const queryOptions = {};
655
774
  if (p?.active_only != null) queryOptions.activeOnly = !!p.active_only;
656
775
  if (p?.plurals != null) queryOptions.plurals = !!p.plurals;
@@ -109,6 +109,30 @@ export const CAPABILITIES = Object.freeze({
109
109
  // with the term"). Declared as data so the planner/mint/executor hang off the declaration, never the
110
110
  // vendor name.
111
111
  ownerTermIntersection: true,
112
+ // ── GOODS-AND-SERVICES TEXT ──────────────────────────────────────────────────────────────────────
113
+ // `assembleQuery` builds a `product:` clause — this vendor's name for the goods description the
114
+ // others search too, documented in its own screening help as the written description of goods and
115
+ // services. The clause existed here long before anything passed it, which is the only reason this
116
+ // capability was ever false.
117
+ //
118
+ // A capability describes what a request through this adapter will actually do, never what the vendor
119
+ // is capable of — declaring `true` while the connector dropped the clause would compile narrowed
120
+ // slices into silently un-narrowed searches, the exact widened-search-wearing-a-narrow-name failure
121
+ // the deferral lane exists to prevent. So this flipped in the same commit that passed `product`
122
+ // through, and not before.
123
+ //
124
+ // Several words become several `product:` clauses; within one field this query language ORs them
125
+ // implicitly, which is the same "any of these words" the other two write with an explicit OR.
126
+ goodsTextSearch: true,
127
+ // A multi-word goods term: UNMEASURED on this vendor, so refused rather than guessed. A space that
128
+ // means AND on one register and OR on another changes the population either way and still answers
129
+ // 200, which is the failure that never announces itself.
130
+ // Unmeasured here, so a multi-word term is not sent at all. See clarivate/capabilities.js for the
131
+ // vocabulary; `null` is "we do not know what it would mean", which is not the same as "no".
132
+ goodsTextMultiWord: null,
133
+ // Several goods terms become several `product:` clauses, and within one field this query language
134
+ // ORs them implicitly — the same "any of these words" the explicit OR writes elsewhere.
135
+ goodsTextListOr: true,
112
136
  // ── WHICH FORM OF A NON-LATIN MARK DOES THE INDEX HOLD? ──────────────────────────────────────────
113
137
  // `true` = the CHARACTERS. A native-script term is a legitimate, productive query here and MUST be
114
138
  // sent — the shared executor's script-form refusal (providers/_shared/script-form.mjs) is switched
@@ -15,6 +15,7 @@
15
15
  import { makeLedger } from "../../_shared/ledger.mjs";
16
16
  import { nonAnswerBodyError, parseJsonBody, unparsedBodyError } from "../../_shared/http-body.mjs";
17
17
  import { normalizeTerritory } from "../../_shared/territory-codes.mjs";
18
+ import { goodsTermsList } from "../../_shared/term-shape.mjs"; // the shared reader for the goods words
18
19
  import {
19
20
  BATCH_SCREEN_CHUNK, chunk, classifyStatus, isAllClass, normalizeBrandRow, screenVerdict,
20
21
  } from "../../_shared/screen.mjs";
@@ -133,6 +134,12 @@ export function assembleQuery(p) {
133
134
  if (p.owner) parts.push(clause("", "owner", p.owner));
134
135
  if (Array.isArray(p.owners)) for (const o of p.owners) parts.push(clause("", "owner", o)); // OR-stack of owner names (same-field implicit OR — mirrors `names`)
135
136
  if (p.product) parts.push(clause("", "product", p.product));
137
+ // The goods-and-services narrowing, in the vocabulary every provider shares. `product:` is this
138
+ // vendor's name for the same field the others call a goods description, and it was already built
139
+ // here — nothing passed it until now, which is why the capability declared false.
140
+ // Several words become several clauses: within one field the clauses implicitly OR, which is the
141
+ // same "any of these words" the other connectors write with an explicit OR.
142
+ for (const g of goodsTermsList(p)) parts.push(clause("", "product", g));
136
143
  if (p.representative) parts.push(clause("", "representative", p.representative));
137
144
 
138
145
  // Filters (each value must be backtick-quoted)
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.2-beta.15
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.14
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.13
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.2-beta.13",
3
+ "version": "0.3.2-beta.15",
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).",