clearotron 0.4.0-beta.3 → 0.4.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.
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "c8159f8575f90587b1c78d5151d9acf9235977e1",
3
- "version": "0.4.0-beta.3"
2
+ "commit": "9b8ab0f275a16e9b586425c3574c94b5974e225b",
3
+ "version": "0.4.0"
4
4
  }
@@ -1,5 +1,86 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New: A knockout search now also asks whether each name is already in use in the client's field, such as a game character or a drink.
8
+ - Before you upgrade: a new operator key must now name the tools it may use, for example `--verbs start_run,stop_run`. Keys already issued keep working.
9
+
10
+ For operators: a revoked key stops working even where no revocation list was set up, because every connector now reads the install's own list.
11
+
12
+ Fixed: over the network, an operator key can no longer start a what-if that its assistant is not shown.
13
+
14
+ Fixed: the portal refuses a change sent from another website, or from another app on the same computer, including a sign-in.
15
+ - New: Searching by judgment.
16
+
17
+ This release changes how a clearance decides what to search. Until now the engine followed fixed rules: a set number of spellings, a fixed list of stores, a stop after the first identical mark. Fixed rules are fast and cheap, but they miss things. On a recent matter, a lawyer's review found marks the engine had counted but never read, and others it had raised that did not matter.
18
+
19
+ So the approach changes. Wherever the engine holds a pile of results, it now looks at what is there and decides what a lawyer would raise. It carries that forward and writes down what it set aside, and why. Nothing is dropped silently. Every wider search, every spelling set aside and every store left out appears in the audit workbook with its reason.
20
+
21
+ What you will notice:
22
+ - Searches widen where it could change the advice, and narrow where it cannot.
23
+ - Near spellings no buyer would confuse with the mark are set aside, with the reason recorded.
24
+ - Searches in other scripts run only in markets that file marks in that script.
25
+ - A large company's register is read for the marks that share the searched name. The rest is counted, not fetched.
26
+ - Marketplace searches cover the stores that sell the client's goods, and say which stores were left out and why.
27
+ - The report no longer opens with the internal reviewer's notes. They reach the reviewing lawyer separately.
28
+ - A large marketplace search no longer fails because its results were too big to return in one piece.
29
+ - Where a company's framework rates through named inputs, the report shows them beside each band.
30
+
31
+ A word on regressions. While testing this line we found two. A crowded search failed before delivering its report, and a knockout's ratings moved one step away from the lawyer's. Both were caught by running the same matters against a lawyer's answers, and both are fixed here. Every build is now tested that way before it ships. We are tuning for three things at once: a report that is right the first time, delivered fast, at a sensible cost. They pull against each other, and each release is our best current balance.
32
+
33
+ ### Patch Changes
34
+
35
+ - For operators: a run now records the version of the engine program that served it, and whether that version is older than this release asks for. A search that ran on an old copy can be identified afterwards.
36
+ - Fixed: reports and their cover emails no longer carry the open-question rows or the internal review note. Both were written for the lawyer checking a run rather than the client reading the result, and they stay with the run record.
37
+ - For operators: on the Anthropic engine, a search can no longer read the install's settings or other private files.
38
+ - For operators: stages on the Anthropic engine can no longer run commands on the server; every tool that runs a command is removed from them.
39
+ - Fixed: In a crowded field, the search now reads the spellings a lawyer would raise, rather than the ones that fit under a record limit.
40
+ - Fixed: When a knockout's search for filings fails, the report and the audit workbook no longer show the raw error.
41
+ - Fixed: On Windows, a failed finding card no longer shows as covered in the audit workbook.
42
+ - Fixed: When a search could not be made, the report says so instead of giving a wrong reason.
43
+ - Fixed: A register search limited to the client's goods now searches every other office when one office cannot filter by goods.
44
+ - Fixed: a right the report keeps is no longer dropped because other registrations of the same right were ruled out.
45
+ - Fixed: A knockout search no longer drops a store listing or fan wiki page its web search found; the rating now reads every result.
46
+ - Fixed: A knockout's audit workbook also lists a part that failed as a whole, such as its filings listing or plain-language review. A filing shown without its link now says why.
47
+ - Fixed: on a Mac, `clearotron start` no longer offers to run in the background, which only works on Linux.
48
+ - Fixed: on a Mac, a search step could write into Clearotron's own protected folders by naming them in a different letter case. Those writes are now refused.
49
+ - Fixed: A register search that times out across many countries is asked again in smaller parts; one that still times out is reported as incomplete.
50
+ - Fixed: on the OpenAI engine, Codex's own sandbox no longer refuses the register searches a clearance runs.
51
+ - Fixed: Reports no longer show a gap in coverage when a search was skipped because an identical one had already run.
52
+ - For operators: on the OpenAI engine with Codex's sandbox on, the commands a search runs can no longer read the install's settings or other private files.
53
+ - Fixed: the audit workbook's What was searched tab no longer shows a web or marketplace search that found similar listings as clean.
54
+ - Fixed: Worldwide searches are no longer narrowed to an account's default territories, and results too crowded to read in full are no longer reported clean.
55
+ - Fixed: A report no longer offers a native-language investigation to a client who ordered one, and says when that investigation was not completed.
56
+ - Fixed: When a knockout's lookup of a filing owner fails, its read now says the lookup did not answer, instead of saying it found nothing.
57
+ - Fixed: Clarivate clearances no longer count look-alike spellings that mix Latin with Greek or Cyrillic letters as searches that could not be completed.
58
+ - For operators: Clarivate clearances no longer fetch a record, or repeat a count, search or owner lookup, that the same run already made.
59
+ - New: Clearotron runs natively on Windows, from PowerShell: no WSL2, no Git and no administrator rights.
60
+ - Fixed: closing the window that runs `clearotron start` now stops Clearotron. Before, it kept running in the background.
61
+ - Fixed: Creating a company in the portal no longer stops with a request to run a git command.
62
+ - Fixed: a full country search's report no longer says court decisions were searched when that search failed or its source was down.
63
+ - New: Global preliminary, multi-country and full country searches now search the web in more depth and keep more results from each search.
64
+ - For operators: Knockouts on Signa and Clarivate take their name and close-variation counts from the listing, so each is asked of the register once.
65
+ - Fixed: A new company starts with no marketplaces. The company page offers the usual ones to add, and a company with none is still searched on the general web.
66
+ - Fixed: Opposition windows appear again on register records, and renewal dates are read from where the register now publishes them.
67
+ - Fixed: opening a report no longer contacts any font service; the report carries its typeface, now Plus Jakarta Sans, inside itself.
68
+ - Fixed: searches no longer reuse earlier results.
69
+ - Before you upgrade: setup and `clearotron doctor` now ask for a newer Claude Code, because the older one quietly serves an earlier model generation.
70
+ - Fixed: Knockouts on the Signa register now say how many register hits the listed filings were drawn from.
71
+ - Fixed: Clearances on the Signa register no longer search look-alike spellings that mix Latin with Greek or Cyrillic letters; those searches found only unrelated marks.
72
+ - Fixed: skipped searches are no longer shown as unfinished in the report.
73
+ - For operators: each stage's AI program now receives only the settings it needs, and never the key that signs access keys.
74
+ - Fixed: The audit workbook now lists, on Coverage & gaps, any part of a report that could not be completed on the run. A knockout's Audit Trail also lists an owner lookup that got no answer.
75
+ - Fixed: on the OpenAI engine, a Full country search now reads case law from CourtListener and Legal Data Hunter, not EUR-Lex alone.
76
+ - Fixed: setup, `clearotron doctor` and the start of every search now check that the engine can write a file where a search writes its results.
77
+ - New: The meaning and reputation search now asks the questions the matter's framing names, in the languages whose markets matter. Where the framing names none, the report says no meaning search ran.
78
+ - For operators: the old default agent name is gone from the product. An install upgrading from a version before 0.2.2 must set its own agent name to keep seeing its earlier runs.
79
+ - Fixed: The operator tool no longer accepts instructions, which never reached the run; a call sending them is refused.
80
+ - Fixed: on the OpenAI engine, the tool that reads web pages now refuses addresses on the server's own network, such as cloud metadata addresses.
81
+ - For operators: the background worker no longer receives the key that signs access keys. Run `clearotron start --background` once to update an installed worker.
82
+ - Fixed: Expiry dates appear again on US trademark records, which had been left blank since the register moved where it publishes them.
83
+
3
84
  ## 0.4.0-beta.3
4
85
 
5
86
  ### Patch Changes
@@ -583,8 +583,8 @@ export function parseAskClosureLines(text) {
583
583
  * could-not-look, and the caller below fails toward leaving the ask OPEN rather than closing it on a
584
584
  * file it could not read.
585
585
  *
586
- * ✕ NEVER a substring search of the serialized document. `"DELFIN" in JSON.stringify(findings)` is true
587
- * when the findings name DELFIN TECHNOLOGIES OY and nothing else — a membership test that matches every
586
+ * ✕ NEVER a substring search of the serialized document. `"KORFIN" in JSON.stringify(findings)` is true
587
+ * when the findings name KORFIN TECHNOLOGIES OY and nothing else — a membership test that matches every
588
588
  * longer name inflates whatever it is counting and reads as a clean result. The field, or nothing.
589
589
  * PURE.
590
590
  */
@@ -159,9 +159,9 @@ const recordUriFile = (uri) => String(uri ?? "").toLowerCase().replace(/^\/mark\
159
159
  // /mark/ae/229552 the bare uri (the only form that resolved) → ae-229552.json
160
160
  // https://tm.corsearch.com/mark/ae/229552 a provider URL (the VENZY join defect's shape)
161
161
  // `/mark/ae/229552`, "…/mark/ae/229552." a cite carrying markdown/punctuation
162
- // /mark/ch/57860 vs ch-57860-2014.json the store holds the registration-INSTANCE uri while
163
- // judgment cites the record (screen-gate.mjs:99 — the
164
- // DELPHINOL false hard-halt, same fact at a different
162
+ // /mark/ch/30419 vs ch-30419-2014.json the store holds the registration-INSTANCE uri while
163
+ // judgment cites the record (`findScreenGateViolations` in screen-gate.mjs — a
164
+ // false hard-halt, the same fact at a different
165
165
  // granularity)
166
166
  // So: canonicalise the CITE through normalizeRecordUri (registry-fidelity.mjs) — the canonical form
167
167
  // pipeline, recall-reconciliation and presence-reconciliation already join on, and the
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
13
13
  import { join, dirname, basename } from "node:path";
14
14
  import { driverDir } from "../shared/driver-dir.mjs"; //
15
15
  import { tmpdir } from "node:os";
16
- import { config, resolveModel, modelFamily, modelSnapshotKind, envOn, envGateOn, preflightEngineBinary } from "./driver.config.mjs";
16
+ import { config, resolveModel, modelFamily, modelSnapshotKind, envOn, envGateOn, preflightEngineBinary, olderThanFloor, ENGINE_BINARIES } from "./driver.config.mjs";
17
17
  import { probeCliVersion } from "./engine/cli-version.mjs";
18
18
  import { stageLog, runLog, note, outputMeta } from "./log.mjs";
19
19
  // — the closed disposition set has ONE author; this file dictates it and must not retype it.
@@ -1185,8 +1185,30 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1185
1185
  const cli = (() => {
1186
1186
  try {
1187
1187
  const pre = preflightEngineBinary(process.env);
1188
- return { ...probeCliVersion(pre?.resolved ?? null), source: pre?.source ?? null };
1189
- } catch (e) { return { version: null, probe: "unreadable", why: String(e?.message ?? e).slice(0, 160), source: null }; }
1188
+ const probed = probeCliVersion(pre?.resolved ?? null);
1189
+ // ── THE FLOOR, COMPARED WHERE THE RUN CAN SEE IT ────────────────────────────────────────
1190
+ //
1191
+ // The build declares a minimum version for each engine's program, and until now nothing
1192
+ // compared a running copy against it. The floor was passed to the installer, so a copy this
1193
+ // product installs cannot land beneath it — but resolution walks PATH first and takes the
1194
+ // first hit, so a copy already on the machine wins and was never measured against anything.
1195
+ // That is the common case, not the edge one: most machines have their own copy. A stage then
1196
+ // runs on a program below the floor and is served whatever that program serves, silently.
1197
+ //
1198
+ // RECORDED, NOT REFUSED. Refusing here would stop every run on any machine whose copy is
1199
+ // behind — including ones already deployed and working — and that is a decision about those
1200
+ // deployments rather than a defect fix. What was missing is the fact, so the fact is what is
1201
+ // written: an operator, and anyone reading the run afterwards, can now see it. Whether it
1202
+ // should also refuse is a separate question and is not answered here.
1203
+ //
1204
+ // THREE-VALUED and written unconditionally, like the model fields below: true, false, or null
1205
+ // when either side names no version this build can compare — an unknown is an unknown, never
1206
+ // an all-clear. The floor itself rides along so the record says what it was measured against
1207
+ // rather than leaving a later reader to guess which build's floor applied.
1208
+ const floor = ENGINE_BINARIES[pre?.engine]?.floor ?? null;
1209
+ const belowFloor = floor ? olderThanFloor(probed?.version ?? null, floor) : null;
1210
+ return { ...probed, source: pre?.source ?? null, floor, belowFloor };
1211
+ } catch (e) { return { version: null, probe: "unreadable", why: String(e?.message ?? e).slice(0, 160), source: null, floor: null, belowFloor: null }; }
1190
1212
  })();
1191
1213
  if (modelActual) lastModelWire = modelActual; // — never overwritten with null
1192
1214
  // The comparison is by FAMILY (driver.config modelFamily), because `--model haiku` legitimately comes
@@ -1658,6 +1680,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1658
1680
  // different from "this record predates the gauge".
1659
1681
  modelActual, modelBasis, modelSnapshot, modelMismatch, providerReported,
1660
1682
  cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}), cliSource: cli.source,
1683
+ cliFloor: cli.floor ?? null, cliBelowFloor: cli.belowFloor ?? null,
1661
1684
  // W3 billing telemetry: which engine ran + the RESOLVED billing mode (subscription vs api-key). This
1662
1685
  // records INTENT (the mode the engine was configured to bill under), not independent billing evidence
1663
1686
  // — the actual proof is the provider console (claude's stream also reports apiKeySource; codex does
@@ -1797,6 +1820,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1797
1820
  // ran. `model` stays the requested resolution (its existing readers); `modelActual` is the wire.
1798
1821
  model: modelRequested, modelActual, modelBasis, modelSnapshot, modelMismatch, providerReported,
1799
1822
  cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}), cliSource: cli.source,
1823
+ cliFloor: cli.floor ?? null, cliBelowFloor: cli.belowFloor ?? null,
1800
1824
  wrote, warm: warm || undefined, warmEscalated: attempt === warmEscalatedAt || undefined,
1801
1825
  rescued: rescued ?? undefined, killed: killed || undefined,
1802
1826
  quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.0-beta.3",
5
+ "version": "0.4.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -70,7 +70,6 @@ import { rollupTokens, stampTokenRollup } from "./tokens.mjs";
70
70
  import { recordRunConsumption } from "./consumption-ledger.mjs";
71
71
  import { writeSettleStamp } from "./settle-stamp.mjs"; // — the pool copy's own terminal state
72
72
  import { stopReason } from "../shared/stop-reason.mjs"; //
73
- import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
74
73
  // The scoped owner lookup a promoted register filing is owed. Bounded, deduplicated
75
74
  // per owner, and structurally unable to withhold a report.
76
75
  import { ownersOwedACheck, runOwnerChecks } from "./owner-use-check.mjs";
@@ -1156,7 +1155,18 @@ export async function knockoutInner(ctx, job, opts = {}) {
1156
1155
  const published = await publishKnockout({
1157
1156
  runId, codename: run.codename, runDir: run.runDir,
1158
1157
  findings: merged, plan, framework: ctx.framework, overall,
1159
- poolRoot: config.poolRoot, poolUrl: config.poolUrl ?? envFrom(process.env, "CLEAROTRON_REPORTS_URL") ?? null,
1158
+ poolRoot: config.poolRoot,
1159
+ // THE FALLBACK HERE COULD NOT FIRE, AND COULD NOT HAVE HELPED IF IT HAD. It read
1160
+ // `config.poolUrl ?? envFrom(process.env, "CLEAROTRON_REPORTS_URL") ?? null`. Two things were wrong
1161
+ // with it and both were invisible: the getter returns "" when the variable is unset, and `??` only
1162
+ // falls through on null or undefined — so the second operand was unreachable — and that second
1163
+ // operand read the same `process.env` the getter had just read, so reaching it would have returned
1164
+ // the same empty answer. A reader saw a safety net twice over where there was none.
1165
+ //
1166
+ // `|| null` instead, which says the one true thing: no base URL configured, so no link. That is the
1167
+ // state `reportUrlFor` already handles by returning null per report rather than an empty string, and
1168
+ // a null link is what the delivery packet is specified to carry when no pool URL is set.
1169
+ poolUrl: config.poolUrl || null,
1160
1170
  customerKey: ctx.profile?.profileKey ?? "generic",
1161
1171
  // — the frozen profile's delivery overlay decides the confidentiality marking, exactly as it
1162
1172
  // does on the clearance lane. Absent (an unbound run, or a profile silent on the field) is the
@@ -10,7 +10,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, copyFil
10
10
  import { createHash } from "node:crypto";
11
11
  import { join, dirname, basename, resolve } from "node:path"; // resolve: the resume line must work from any cwd
12
12
  import { driverDir, driverRel, ensureDriverDir } from "../shared/driver-dir.mjs";
13
- import { REVIEWER_OPEN_QUESTIONS_FILE, reviewerOpenPointsForEmail } from "./reviewer-open-points.mjs"; // the run-record file the reviewer's open points go to // — one definition of where `_driver/` is
13
+ import { REVIEWER_OPEN_QUESTIONS_FILE } from "./reviewer-open-points.mjs"; // the run-record file the reviewer's open points go to, and the only thing that reads them now
14
14
  import { goodsOf } from "./queue-markers.mjs"; // — one reading of "does this job name goods", shared with the intake gate
15
15
  import { terminalClampDecision, orderClausesForLede, clientConditions, clauseForDefect } from "./terminal-clamp.mjs"; // — deliver and clamp, never withhold
16
16
  import { recordSpan } from "./attributed-span.mjs"; // — driver work the decomposition can attribute
@@ -47,7 +47,7 @@ import { readRegisterTaint, readActiveTaintAxes } from "./register-taint.mjs";
47
47
  import { parseNamedBand, mergeNamedBands, findCollapsedBands, quarantineUnknownStates, taintQuarantineCleanBlocks, bandRecords } from "./named-band.mjs";
48
48
  import { recordOriginsFor } from "./record-origins.mjs";
49
49
  import { REGISTER_PROVIDER } from "./driver.config.mjs";
50
- import { noteRegisterServed, registersServedFrom, providerUsageCaveat } from "./register-served.mjs"; // which register actually served this run, from the resolver the dispatch itself uses
50
+ import { noteRegisterServed, registersServedFrom, providerUsageCaveat } from "./register-served.mjs"; // which registers actually served this run — noted eagerly from the dispatch's own resolver, and reconciled at publish against the ledger rows, which carry the vendor per call
51
51
  import { FACTS_FILE as DIGEST_FACTS_FILE, ACCOUNTING_STAMP as DIGEST_ACCOUNTING_STAMP, recordedFindingUris,
52
52
  digestAccountingGap, digestBatchBrief, batchesOf } from "./register-digest-record.mjs"; // conversion 11 — the render's facts sidecar and the accounting era stamp
53
53
  import { buildBandShape, dominantElementComposites, deriveRegisterPositions, floorTierByMark, floorMarkKey } from "./band-shape.mjs"; // PR-8 — the deterministic reading layer; P2-A — candidates + positions
@@ -7685,9 +7685,27 @@ export function buildOnlyYouSection(actions, findings, { nowMs = Date.now(), wit
7685
7685
  // addressed TO the reader; monitoring and filing-routine are standing items. Same tags as before, so
7686
7686
  // a reader who knows the old list reads the new one unchanged, one level down.
7687
7687
  const ASK_KINDS = new Set(["client-fact", "commercial-decision"]);
7688
+ // ── THE KINDS THAT DO NOT REACH A CLIENT SURFACE ────────────────────────────────────────────────
7689
+ //
7690
+ // Owner ruling, 2026-10-01, on the clearance emails production sent in the preceding day: the
7691
+ // "[Open question]" rows are non-critical and confuse, and they come OUT of the client's report and
7692
+ // email. They are not reworded and nothing replaces them.
7693
+ //
7694
+ // THE FILTER IS HERE AND NOT ON `advisories` ABOVE, which is the whole care in this change. That list
7695
+ // also feeds `actionDates`, so dropping a kind from it would silently take a declared deadline out of
7696
+ // the date set three predelivery checks key on — a client-facing removal quietly changing a date
7697
+ // check. Filtering at the point of RENDERING removes the rows from the document and touches nothing
7698
+ // else: the register in findings.json is unchanged, the kind stays valid, an advisory still never
7699
+ // conditions reliance, and the verdict and its bound read from the register rather than from these
7700
+ // lines.
7701
+ //
7702
+ // So the row is gone from what the client reads and the fact is still on the run, which is what the
7703
+ // ruling asks for.
7704
+ const NOT_FOR_CLIENT = new Set(["client-fact"]);
7688
7705
  const advisoryLine = (a) => `- **${ADVISORY_TAG[a.kind]}** ${askLine(a)}${a.deadline?.date ? ` (by ${a.deadline.date})` : ""}`;
7689
- const askLines = advisories.filter((a) => ASK_KINDS.has(a.kind)).map(advisoryLine);
7690
- const watchLines = advisories.filter((a) => !ASK_KINDS.has(a.kind)).map(advisoryLine);
7706
+ const forClient = (a) => !NOT_FOR_CLIENT.has(a.kind);
7707
+ const askLines = advisories.filter((a) => ASK_KINDS.has(a.kind)).filter(forClient).map(advisoryLine);
7708
+ const watchLines = advisories.filter((a) => !ASK_KINDS.has(a.kind)).filter(forClient).map(advisoryLine);
7691
7709
  const groups = [
7692
7710
  ["Before you can rely on this result", conditionLines],
7693
7711
  ["We need an answer from you", askLines],
@@ -15184,6 +15202,23 @@ async function pipelineInner(job, opts = {}) {
15184
15202
  // same record, so the two register fields in one status cannot silently disagree: the served list
15185
15203
  // says two, and this says the tally covers both and could only be filed under one. Splitting the
15186
15204
  // tally by register is a second question and is not answered here.
15205
+ // ── THE LEDGER IS WHAT KNOWS, SO ASK IT BEFORE READING THE RECORD ───────────────────────────
15206
+ //
15207
+ // `registersServed` was written from ONE place: the adapter wrapper the record fetcher, the record
15208
+ // lister and a gated plan executor pass through. A clearance that takes counts and never fetches a
15209
+ // record went through none of them and recorded no register at all — the run could not say which
15210
+ // vendor answered it, and `registersServedFrom` returned [] for a run that had called a register
15211
+ // hundreds of times.
15212
+ //
15213
+ // Every one of those calls wrote a ledger row, and every row carries the vendor that produced it, so
15214
+ // the tally just computed already holds the answer per call. Noting them here is not a second source
15215
+ // of truth competing with the first: it is the same memoised setter, which unions and writes only on
15216
+ // change, so the eager note during the run and this reconciliation at publish cannot disagree.
15217
+ //
15218
+ // WHY NOT `activeProvider()` HERE. That resolves the register active at publish, which is the
15219
+ // confident-and-wrong answer this whole field exists to end — and on a run that changed register it
15220
+ // would name one vendor while the rows name two. The rows are per call; the resolver is per moment.
15221
+ for (const id of usage.providers) noteRegisterServed(run.runDir, id);
15187
15222
  const served = registersServedFrom(readRunStatus(run.runDir));
15188
15223
  const spans = providerUsageCaveat(served);
15189
15224
  runLog(run.runDir, { event: "provider-usage", provider, ...(served.length > 1 ? { spans: served } : {}), ...usage });
@@ -15251,7 +15286,14 @@ async function pipelineInner(job, opts = {}) {
15251
15286
  // rule still forbids is the ENGINE'S OWN WORDS getting there: composeEmailHtml enumerates
15252
15287
  // predelivery-lint's code-owned projection (deliveryFlagLines), never the checks' raw `detail` —
15253
15288
  // this mail is addressed to job.forwarderEmail, which on a client-principal run is the client.
15254
- emailVerdictOpts.reviewerOpenPointsMd = reviewerOpenPointsForEmail(job, dirname(P.report)); writeFileSync(P.emailBody, composeEmailHtml(P.report, published.url, published.auditFile, emailNames, deliveryForRun(ctx), emailVerdictOpts));
15289
+ // THE REVIEWER'S OPEN POINTS NO LONGER RIDE THE EMAIL (owner ruling, 2026-10-01). This line read them
15290
+ // out of the run record and handed them to the cover, which is how the sentence naming the independent
15291
+ // reviewer reached a client after the 2026-09-24 ruling had already taken it off the report page: the
15292
+ // section stopped being spliced into the body and started being posted to the email instead. Measured
15293
+ // on the last thirty days of production, the report carries neither the heading nor that sentence since
15294
+ // that ruling, and the email carries both. The record is still written beside the report for the
15295
+ // reviewing lawyer; nothing reads it onto a client surface.
15296
+ writeFileSync(P.emailBody, composeEmailHtml(P.report, published.url, published.auditFile, emailNames, deliveryForRun(ctx), emailVerdictOpts));
15255
15297
  // ctx.verdict is set BEFORE the packet is composed, because the packet's copy reads it.
15256
15298
  ctx.verdict = verdict;
15257
15299
  // DELIVERY (Phase 2). The driver writes a self-contained delivery packet and leaves
@@ -115,6 +115,14 @@ function emptyTally() {
115
115
  // confused with "the ledger used a name this module doesn't know" again.
116
116
  by_tool: {},
117
117
  unclassified: 0,
118
+ // WHICH VENDORS ACTUALLY ANSWERED THIS RUN, read off the rows rather than resolved from the
119
+ // environment. Every ledger row carries a `provider` discriminator (providers/_shared/ledger.mjs), so
120
+ // the set of vendors a run used is a fact the ledger already holds — and it is the ONLY place that
121
+ // holds it per call. A label resolved at publish instead names whichever register happened to be
122
+ // active then, which is wrong for a run whose register changed part-way; this cannot be, because each
123
+ // row says who produced it. Empty is a real answer and means the ledger carried no row for this run,
124
+ // which the four `ledger.*` provenance facts below then explain.
125
+ providers: [],
118
126
  // …and the PROVENANCE of the zeros above. Same house rule, the last place in this module still broken
119
127
  // by it: a ledger that is missing, mis-pointed or unreadable returned a clean all-zero tally that was
120
128
  // indistinguishable from "the run made no provider calls", and the note line printed `total=0 ((none))`
@@ -163,6 +171,7 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
163
171
  out.ledger.readable = true;
164
172
 
165
173
  const firstFetchSession = new Map(); // record_fetch target → the session that first (network-)fetched it
174
+ const providersSeen = new Set(); // the vendors this run's rows name, in the order the ledger names them
166
175
 
167
176
  for (const line of text.split("\n")) {
168
177
  if (!line.trim()) continue;
@@ -179,6 +188,11 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
179
188
  const isCacheHit = row.cache_hit === true;
180
189
  if (isCacheHit) out.cache_hits++;
181
190
 
191
+ // Collected for EVERY matching row, whatever tool it rode — a count-only run rides `count`-shaped
192
+ // rows and no record fetch, and that is exactly the run whose register went unrecorded before.
193
+ const rowProvider = typeof row.provider === "string" ? row.provider.trim().toLowerCase() : "";
194
+ if (rowProvider) providersSeen.add(rowProvider);
195
+
182
196
  const toolName = typeof row.tool === "string" && row.tool ? row.tool : "(none)";
183
197
  out.by_tool[toolName] = (out.by_tool[toolName] ?? 0) + 1;
184
198
  if (KINDS.includes(row.tool)) out[row.tool]++;
@@ -221,6 +235,8 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
221
235
  }
222
236
  }
223
237
  }
238
+ // Sorted so two runs with the same vendors compare equal whatever order the ledger happened to append in.
239
+ out.providers = [...providersSeen].sort();
224
240
  return out;
225
241
  }
226
242
 
@@ -1712,9 +1712,13 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1712
1712
  const { fm, secs } = parseReport(reportMdPath);
1713
1713
  const auditUrl = auditUrlFor(url, auditFile);
1714
1714
 
1715
- // 1) internal review headline — short: the bottom line + any "Reviewer's open questions" already in # Summary.
1716
- // Heading-neutral match: accept the new "Reviewer's open questions" and the legacy "Open questions for the reviewer".
1717
- const oq = (secs['Summary'] || '').match(/\*\*(?:Reviewer's open questions|Open questions for the reviewer)[\s\S]*?(?=\n\n[^*\d])/i);
1715
+ // 1) internal review headline — short: the bottom line, and nothing of the reviewer's own notes.
1716
+ //
1717
+ // THE SCRAPE THAT STOOD HERE IS GONE (owner ruling, 2026-10-01). It lifted a "Reviewer's open questions"
1718
+ // block out of the report's own # Summary and reprinted it on the cover. Two ways that reached a client:
1719
+ // a report that still carried an authored section of that name, and any archived report re-rendered
1720
+ // later. Removing the producer is not enough while something downstream goes looking for the text, so
1721
+ // this goes with it.
1718
1722
  // The report link rides HIGH — right under the bottom line in the headline, not buried below the table.
1719
1723
  const reportLink = hrefAttr(url)
1720
1724
  ? `<p style="margin:0 0 10px"><a href="${hrefAttr(url)}" style="color:#1a4fd6;font-weight:bold;font-size:12pt;text-decoration:none">▶ Open the full report</a>`
@@ -1826,8 +1830,10 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1826
1830
  // second rung on any shipped build. Nothing renders a failover note into a report.
1827
1831
  // B5b checkpoint 4 — a customer named after the analysis was written ships as a delivery note, never silently.
1828
1832
  + (fm.late_bind_note ? `<p style="margin:0 0 8px;color:#7a2b12"><b>Applicant named mid-run:</b> ${cell(fm.late_bind_note)}</p>` : '')
1829
- + (oq ? `<div style="margin:0 0 8px">${mdBlock(oq[0])}</div>` : '') // …and the reviewer's open points from the run record (opts.reviewerOpenPointsMd), never from the report
1830
- + (opts.reviewerOpenPointsMd ? `<div style="margin:0 0 8px">${mdBlock(String(opts.reviewerOpenPointsMd).replace(/^#+\s*(.+)$/m, '**$1**'))}</div>` : '')
1833
+ // The reviewer's open points stood here, from the run record. They do not ride the cover any more
1834
+ // (owner ruling, 2026-10-01): the sentence naming the independent reviewer reads as a human declining
1835
+ // to sign, and the points themselves are the engine's own vocabulary. Both stay on the run, beside the
1836
+ // report, for the reviewing lawyer. Nothing is reworded and nothing replaces them.
1831
1837
  // wp50: the two-bucket # Actions list no longer rides the email — it renders on the report itself
1832
1838
  // (the single master document); the cover keeps only the headline, link, and surviving flags.
1833
1839
  + `</div>`;
@@ -902,7 +902,7 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
902
902
  // statedDivergenceFindings checked=5 matched=5 diverged=0 ← should have named two marks
903
903
  //
904
904
  // The reconciliation names five finding-ended positions and they are five OTHER marks — VELTRIN
905
- // bioenergetische Kosmetik, KORPHIC HSE, KORPHI, DELPHIN & EMERENCE, KORPHI DIAGNOSTICS. The two that
905
+ // bioenergetische Kosmetik, KORPHIC HSE, KORPHI, KORPHIN & ACME, KORPHI DIAGNOSTICS. The two that
906
906
  // were lost sit in the CARRY rows and the reconciliation never mentions them:
907
907
  //
908
908
  // HALVER KORPHI reach=placed stopped_at=digest reason_source=step-stated reason=digest:reasoned-negative
@@ -3,31 +3,28 @@
3
3
  //
4
4
  // reviewer-open-points.mjs — where the reviewer's open points are kept, and how the audit workbook reads them.
5
5
  //
6
- // Owner ruling, 2026-09-24: reviewer notes never reach the client page. The pipeline still builds the
7
- // section from the review and the corrective observation (buildReviewerOpenPointsSection) and writes it to
8
- // the run record under `_driver/`, for the reviewing lawyer. Nothing a client can open carries it: not the
9
- // report, and not the audit workbook, whose link rides the cover note a client principal receives.
6
+ // Owner ruling, 2026-09-24: reviewer notes never reach the client page. Owner ruling, 2026-10-01: they do
7
+ // not reach the email either, on any run. The pipeline still builds the section from the review and the
8
+ // corrective observation (buildReviewerOpenPointsSection) and writes it to the run record under `_driver/`,
9
+ // beside the report, for the reviewing lawyer who opens the run. Nothing sends it anywhere.
10
10
  //
11
- // One module owns the file's name and its reading, so the pipeline that writes it and the email that reads
12
- // it cannot disagree about where it is, and the reader does not import the pipeline to learn a file name.
13
-
14
- import { readFileSync, existsSync } from "node:fs";
15
- import { driverDir } from "../shared/driver-dir.mjs";
11
+ // WHAT THE SECOND RULING REMOVED, and why it is not a narrowing of the first. The first took the section off
12
+ // the report page and left it on the email, gated on whether the run's forwarder was the client. The email
13
+ // then carried the sentence naming the independent reviewer — which reads as a human declining to sign a
14
+ // report — and carried the points in the engine's own vocabulary. Measured over the thirty days to
15
+ // 2026-10-01: the report page carries neither since the first ruling, and the email carried both. So the
16
+ // gate was holding a door open that is now shut: nothing reads this file onto a surface that is sent.
17
+ //
18
+ // One module owns the file's name, so the pipeline that writes it and the workbook reader that skips it
19
+ // cannot disagree about where it is, and a reader does not import the pipeline to learn a file name.
16
20
 
17
21
  /** The run-record file the reviewer's open points are written to, under the run's `_driver/`. */
18
22
  export const REVIEWER_OPEN_QUESTIONS_FILE = "reviewer-open-questions.md";
19
23
 
20
- /**
21
- * The recorded open points for the email the run sends, or null where that email could reach a client.
22
- *
23
- * The run's email goes to the job's forwarder, and on a client-started run (`clientPrincipal`) that address
24
- * is the client itself. There the open points stay in the run record alone. Everywhere else the recipient
25
- * is the reviewing lawyer, who reads them in the email's review headline, in the record's own words.
26
- * Null too when the run recorded none: the reviewer signed and nothing is open.
27
- */
28
- export function reviewerOpenPointsForEmail(job, runDir) {
29
- if (job?.clientPrincipal === true || !runDir) return null;
30
- const file = driverDir(runDir, REVIEWER_OPEN_QUESTIONS_FILE);
31
- if (!existsSync(file)) return null;
32
- try { return readFileSync(file, "utf8").trim() || null; } catch { return null; }
33
- }
24
+ // THE EMAIL READER IS GONE (owner ruling, 2026-10-01). It returned this file's text for a run whose
25
+ // forwarder was not the client, and the pipeline handed that to the email cover. Both halves are removed:
26
+ // the reader here, and the hand-off there. A gate on the recipient is not what the ruling asked for — the
27
+ // points leave every surface that is sent, so there is no recipient to test.
28
+ //
29
+ // NOT LEFT IN PLACE UNUSED. An exported reader with no caller is the shape somebody wires back up, and the
30
+ // thing that made this reachable in the first place was a function that existed and looked safe to call.
@@ -524,14 +524,41 @@ let authoredPrint = null;
524
524
  * them into strings would change the payload's shape.
525
525
  */
526
526
  export function redactDeep(value, { redactValue, redactKey }) {
527
- const walk = (v) => {
527
+ const walk = (v, path = "") => {
528
528
  if (typeof v === "string") return redactValue(v);
529
- if (Array.isArray(v)) return v.map(walk);
529
+ if (Array.isArray(v)) return v.map((x) => walk(x, path));
530
530
  if (v && typeof v === "object") {
531
531
  const out = {};
532
- // The key through the AUTHORED redactor, the value through the full one, in one pass so the two
533
- // can never drift apart by a caller forgetting one of them.
534
- for (const [k, x] of Object.entries(v)) out[redactKey(k)] = walk(x);
532
+ // TWO SIBLING KEYS CAN REDUCE TO ONE TOKEN, and the later write would take the earlier field with
533
+ // it. The matcher tolerates a trailing plural, so a protected name and that name plus `s` both
534
+ // match; it is case-insensitive, so two spellings of one name collide too. Rebuilding the object
535
+ // key by key, the second write wins and the first field is gone — valid JSON, no warning, and a
536
+ // consumer cannot tell a field ever existed. That is the failure mode this whole module exists to
537
+ // prevent, so it refuses rather than guessing which field to keep.
538
+ //
539
+ // MEASURED BEFORE BEING BUILT: across four real scored payloads, 585 objects and 1634 protected
540
+ // names, there is no such pair today. The mechanism is real and the path is not reachable on
541
+ // anything this box produces, which is why this is a guard and not a repair.
542
+ //
543
+ // THE REFUSAL NAMES THE TOKEN AND THE PATH, NEVER THE KEYS. Saying which keys collided would print
544
+ // the very name being withheld — the keys are the protected string, that is why they collided. A
545
+ // reader who needs them asks with `--names`, where nothing is withheld and the collision cannot
546
+ // happen.
547
+ const seen = new Map();
548
+ for (const [k, x] of Object.entries(v)) {
549
+ const rk = redactKey(k);
550
+ if (seen.has(rk)) {
551
+ const e = new Error(`two sibling keys reduce to the same token ${rk} at ${path || "the payload root"}`
552
+ + ` — this payload cannot be rendered addressably, so it is refused rather than written with a`
553
+ + ` field silently dropped. Run again with --names to see which keys they are.`);
554
+ e.code = "REDACTED_KEY_COLLISION";
555
+ e.token = rk;
556
+ e.at = path || "(root)";
557
+ throw e;
558
+ }
559
+ seen.set(rk, true);
560
+ out[rk] = walk(x, path ? `${path}.${rk}` : rk);
561
+ }
535
562
  return out;
536
563
  }
537
564
  return v;
@@ -99,9 +99,9 @@ function parseVerdict(notes) {
99
99
  */
100
100
  export function findScreenGateViolations(findingsContent, fetchedUriSet) {
101
101
  // URI-granularity normalization: the record_fetch ledger logs the registration-INSTANCE URI (e.g.
102
- // /mark/ch/57860/2014) while the gate parses each Notes URI through URI_RE, which stops at the first
103
- // SLASH (→ /mark/ch/57860). Reduce BOTH sides through the SAME regex so a slash-separated /<year> (or
104
- // other instance) suffix is not a false-negative on the membership test — the DELPHINOL false hard-halt
102
+ // /mark/ch/30419/2014) while the gate parses each Notes URI through URI_RE, which stops at the first
103
+ // SLASH (→ /mark/ch/30419). Reduce BOTH sides through the SAME regex so a slash-separated /<year> (or
104
+ // other instance) suffix is not a false-negative on the membership test — a false hard-halt
105
105
  // that blocked a live pharma matter twice on 2026-06-17 (the record WAS fetched, logged as …/2014).
106
106
  //
107
107
  // …and CASE-FOLD, for the same reason at a different granularity (2026-07-28). The fetched universe is
@@ -1216,14 +1216,14 @@
1216
1216
  "todos": 0
1217
1217
  },
1218
1218
  "a-run-records-the-tool-that-served-it.test.mjs": {
1219
- "tests": 13,
1220
- "asserts": 33,
1219
+ "tests": 16,
1220
+ "asserts": 42,
1221
1221
  "skips": 0,
1222
1222
  "todos": 0
1223
1223
  },
1224
1224
  "a-run-records-which-register-served-it.test.mjs": {
1225
- "tests": 9,
1226
- "asserts": 22,
1225
+ "tests": 14,
1226
+ "asserts": 30,
1227
1227
  "skips": 0,
1228
1228
  "todos": 0
1229
1229
  },
@@ -2829,6 +2829,12 @@
2829
2829
  "skips": 1,
2830
2830
  "todos": 0
2831
2831
  },
2832
+ "e2e-a-row-states-what-it-measured.test.mjs": {
2833
+ "tests": 3,
2834
+ "asserts": 7,
2835
+ "skips": 0,
2836
+ "todos": 0
2837
+ },
2832
2838
  "e2e-assertions.test.mjs": {
2833
2839
  "tests": 87,
2834
2840
  "asserts": 251,
@@ -2859,6 +2865,12 @@
2859
2865
  "skips": 0,
2860
2866
  "todos": 0
2861
2867
  },
2868
+ "e2e-scenario-names-its-register.test.mjs": {
2869
+ "tests": 13,
2870
+ "asserts": 28,
2871
+ "skips": 0,
2872
+ "todos": 0
2873
+ },
2862
2874
  "e2e-scenario-ops.test.mjs": {
2863
2875
  "tests": 28,
2864
2876
  "asserts": 94,
@@ -3935,7 +3947,7 @@
3935
3947
  },
3936
3948
  "pipeline.mock.test.mjs": {
3937
3949
  "tests": 103,
3938
- "asserts": 863,
3950
+ "asserts": 865,
3939
3951
  "skips": 0,
3940
3952
  "todos": 0
3941
3953
  },
@@ -4636,8 +4648,8 @@
4636
4648
  "todos": 0
4637
4649
  },
4638
4650
  "report-assemble.test.mjs": {
4639
- "tests": 21,
4640
- "asserts": 109,
4651
+ "tests": 22,
4652
+ "asserts": 115,
4641
4653
  "skips": 0,
4642
4654
  "todos": 0
4643
4655
  },
@@ -5670,7 +5682,7 @@
5670
5682
  "the-engine-sets-no-sampling-and-forces-no-tool.test.mjs": {
5671
5683
  "tests": 6,
5672
5684
  "asserts": 9,
5673
- "skips": 0,
5685
+ "skips": 2,
5674
5686
  "todos": 0
5675
5687
  },
5676
5688
  "the-engine-step-cannot-loop-on-a-menu-nothing-changed.test.mjs": {
@@ -5944,8 +5956,8 @@
5944
5956
  "todos": 0
5945
5957
  },
5946
5958
  "the-payload-is-redacted-as-a-structure.test.mjs": {
5947
- "tests": 11,
5948
- "asserts": 22,
5959
+ "tests": 15,
5960
+ "asserts": 30,
5949
5961
  "skips": 0,
5950
5962
  "todos": 0
5951
5963
  },
@@ -6153,9 +6165,9 @@
6153
6165
  "skips": 0,
6154
6166
  "todos": 0
6155
6167
  },
6156
- "the-reviewers-open-points-reach-the-reviewing-lawyer-never-a-client.test.mjs": {
6168
+ "the-reviewers-open-points-stay-in-the-run-record.test.mjs": {
6157
6169
  "tests": 6,
6158
- "asserts": 15,
6170
+ "asserts": 17,
6159
6171
  "skips": 0,
6160
6172
  "todos": 0
6161
6173
  },
@@ -7735,7 +7747,7 @@
7735
7747
  },
7736
7748
  "providers/signa/test/an-owner-portfolio-sweep-is-a-search.test.mjs": {
7737
7749
  "tests": 4,
7738
- "asserts": 17,
7750
+ "asserts": 18,
7739
7751
  "skips": 0,
7740
7752
  "todos": 0
7741
7753
  },
@@ -7751,6 +7763,18 @@
7751
7763
  "skips": 0,
7752
7764
  "todos": 0
7753
7765
  },
7766
+ "providers/signa/test/the-ranked-lane-asks-for-similarity-channels.test.mjs": {
7767
+ "tests": 9,
7768
+ "asserts": 15,
7769
+ "skips": 0,
7770
+ "todos": 0
7771
+ },
7772
+ "providers/signa/test/the-registers-own-warnings-reach-the-run.test.mjs": {
7773
+ "tests": 7,
7774
+ "asserts": 11,
7775
+ "skips": 0,
7776
+ "todos": 0
7777
+ },
7754
7778
  "providers/signa/test/the-screen-speaks-the-shared-verdicts.test.mjs": {
7755
7779
  "tests": 2,
7756
7780
  "asserts": 4,
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.0-beta.3
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.4.0-beta.3",
3
+ "version": "0.4.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.4.0-beta.3",
4
+ "version": "0.4.0",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.0-beta.3",
5
+ "version": "0.4.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -80,7 +80,7 @@ export function termPredicateIssue(term, predicate) {
80
80
  // R2b died at fan-in on a plan carrying its own section headings as search terms:
81
81
  //
82
82
  // term="**Core (BIOVELTRIN, BIO VELTRIN, BIO-VELTRIN, etc.)**" predicate=default
83
- // term="**Formative root (VELTRIN, DELPHIN, DELPHINUS, etc.)**" predicate=default
83
+ // term="**Formative root (VELTRIN, KORPHIN, KORPHINUS, etc.)**" predicate=default
84
84
  //
85
85
  // Twenty minutes earlier the same matter on the same commit delivered clean, and the only difference
86
86
  // was those two strings. Both happened to be 6 words, so they tripped the >4-word arm below by luck;
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.0-beta.3
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.0-beta.3",
3
+ "version": "0.4.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -182,6 +182,52 @@ export function rememberableAnswer(method, status, body, parseError) {
182
182
  // answering as though it were the one requested.
183
183
  const DETERMINISTIC_MATCH = new Set(["similar", "exact", "starts_with", "ends_with", "contains"]);
184
184
 
185
+ // ── THE RANKED STRATEGIES, AS SIMILARITY CHANNELS ────────────────────────────────────────────────
186
+ //
187
+ // The register retired `strategies` in favour of `similarity` and retired `query` in favour of `q`, both
188
+ // on the same date, and says so in `search_meta.deprecations` on every response that still uses the old
189
+ // names. This is that migration.
190
+ //
191
+ // THE TABLE IS NOT A RENAME, and assuming it was is the way to lose coverage quietly. Each old strategy
192
+ // expands to a SET of channels, and the sets are not the strategy's own name: `fuzzy` alone applies four.
193
+ // Every row below was read off the register's own `similarity_applied` for the old parameter — so the
194
+ // register derived this table, not us — and each was then confirmed by sending the new form and comparing
195
+ // what came back against the old form's answer, on a neutral term, not by reading the names across:
196
+ //
197
+ // strategy similarity channels
198
+ // exact identical, lookalike
199
+ // phonetic identical, phonetic
200
+ // fuzzy identical, fuzzy, embedded, lookalike
201
+ // prefix identical, embedded
202
+ //
203
+ // For `exact` the comparison was of the returned SETS, paged to exhaustion both ways and compared by
204
+ // record, not of the totals — two totals agreeing is not two sets agreeing. Several strategies in one call
205
+ // UNION their channels, confirmed on a combination the same way. The figures, the date and the term they
206
+ // were taken on are on the tracker: this directory is public and vendor measurements do not live here.
207
+ //
208
+ // `similarity` REFUSES ANYTHING ELSE — "similarity must be one of: identical, fuzzy, embedded, phonetic,
209
+ // lookalike" — which is why `prefix` has no channel of its own and why an unknown strategy must not be
210
+ // forwarded verbatim: it would 4xx the call rather than narrow it, but only at run time and only for the
211
+ // plan that asked. Unknown names map to the exact channels and the caller's word is kept in the request's
212
+ // own record by the plan, not invented here.
213
+ const SIMILARITY_FOR = Object.freeze({
214
+ exact: ["identical", "lookalike"],
215
+ phonetic: ["identical", "phonetic"],
216
+ fuzzy: ["identical", "fuzzy", "embedded", "lookalike"],
217
+ prefix: ["identical", "embedded"],
218
+ });
219
+ const SIMILARITY_CHANNELS = Object.freeze(["identical", "fuzzy", "embedded", "phonetic", "lookalike"]);
220
+
221
+ /** The channels a list of ranked strategies asks for: the union of each one's, in the register's own order. */
222
+ export function similarityFor(strategies) {
223
+ const want = new Set();
224
+ const list = Array.isArray(strategies) && strategies.length ? strategies : ["exact"];
225
+ for (const s of list) {
226
+ for (const c of (SIMILARITY_FOR[String(s ?? "").trim().toLowerCase()] ?? SIMILARITY_FOR.exact)) want.add(c);
227
+ }
228
+ return SIMILARITY_CHANNELS.filter((c) => want.has(c));
229
+ }
230
+
185
231
  // ── filters: ONE builder, because the two shapes drifting apart is how `status` survived ───────────
186
232
  //
187
233
  // `filters.status` was sent by both branches below and NO SUCH KEY EXISTS. The API rejects unknown
@@ -227,12 +273,27 @@ export function buildSearchRequest(p) {
227
273
  // serializes away anyway, but writing it conditionally is what makes the owner-only shape legible here
228
274
  // rather than an accident of JSON.stringify.
229
275
  const body = {};
230
- if (String(p.query ?? "").trim()) body.query = p.query;
276
+ // ── `q` CARRIES ONE TERM, AND AN ARRAY HERE WOULD BE A DIFFERENT SEARCH ────────────────────────
277
+ //
278
+ // A scalar `q` is a RANKED query: the similarity channels below apply to it. A LIST in the same field
279
+ // is not a wider ranked query — it is an exact-text filter, and the register refuses to combine it with
280
+ // similarity at all ("a list is an exact-text filter, not a ranked query"). Measured: the ranked form
281
+ // returns a live mark the register itself tiers `identical` via its lookalike channel, and the list form
282
+ // does not, because that mark's text does not contain the term. So an array reaching this line would
283
+ // silently drop look-alike coverage while answering 200 — a narrowed query wearing a complete answer,
284
+ // the failure this connector already refuses a multi-term stack to prevent.
285
+ //
286
+ // It is refused rather than joined or first-element-picked, for the same reason the stack is.
287
+ if (Array.isArray(p.query))
288
+ throw new Error("[signa] `query` reached the request builder as a list. A list in `q` is an exact-text "
289
+ + "filter on this register and cannot carry the similarity channels a ranked band asks for, so it "
290
+ + "would drop look-alike matches while answering 200. Send one term per request.");
291
+ if (String(p.query ?? "").trim()) body.q = p.query;
231
292
  const match = typeof p.match === "string" ? p.match.trim() : "";
232
293
  if (match && DETERMINISTIC_MATCH.has(match)) {
233
- body.match = match; // sending strategies alongside is a 4xx, not a preference
294
+ body.match = match; // sending similarity alongside is a 4xx, not a preference
234
295
  } else {
235
- body.strategies = Array.isArray(p.strategies) && p.strategies.length ? p.strategies : ["exact"];
296
+ body.similarity = similarityFor(p.strategies);
236
297
  }
237
298
  const filters = buildFilters(p);
238
299
  if (Object.keys(filters).length) body.filters = filters;
@@ -488,6 +549,30 @@ export function normalizeSearchResponse(body, echoQuery) {
488
549
  strategies_used: meta.strategies_used ?? [],
489
550
  match: meta.match ?? null,
490
551
  search_id: meta.search_id ?? null,
552
+ // ── THE REGISTER'S OWN WARNINGS, CARRIED WHOLE ──────────────────────────────────────────────
553
+ //
554
+ // Passed through as the register sent them rather than filtered to the codes we happen to know. A
555
+ // recorder keyed to a fixed list of codes looks like coverage and is an empty column the day the
556
+ // register adds one, and there is no way to tell those two apart from the outside. An empty array is
557
+ // the ordinary case: a clean response carries no `warnings` key at all.
558
+ //
559
+ // TWO CODES ARE KNOWN TO ARRIVE HERE, and they behave oppositely — which is the reason to keep the
560
+ // list whole rather than reason about either one.
561
+ //
562
+ // `mixed_script` is emitted on the exact-text filter path and NOT on the ranked path, because a
563
+ // ranked query folds look-alike letters through its own similarity channel and so has nothing to
564
+ // warn about. Every sweep this connector sends is ranked, so this code will not appear, and an
565
+ // empty list is NOT evidence that a query carried no mixed-script risk. Whatever guards that on the
566
+ // ranked path still has to.
567
+ //
568
+ // `expanded_fallback` DOES arrive on requests this connector sends. Measured: it fires on
569
+ // `offices` together with `nice_classes`, and on `goods_services_text`, which rides every
570
+ // goods-narrowed question. It says the grouped view cannot serve that filter, so the answer comes
571
+ // back one row per RECORD instead of one row per MARK — and the register's own note says that
572
+ // inflates the total. The total is what the enumerate ceiling reads to call a band a crowd, so a
573
+ // band can be declared a crowd on a number that counts designations rather than marks. This field
574
+ // is what makes that visible; it does not yet make it safe.
575
+ warnings: Array.isArray(meta.warnings) ? meta.warnings : [],
491
576
  // The corpus total when the vendor counted it exactly; null when it did not answer, when the
492
577
  // total was not requested, and when the figure it returned is an approximation. NEVER the page
493
578
  // size — `data.length` is `count`, and conflating the two is how a page reads as a corpus.
package/scripts/e2e.mjs CHANGED
@@ -337,6 +337,75 @@ const allScenarios = () => {
337
337
  .sort((a, b) => byScenarioNumber(a.id, b.id));
338
338
  };
339
339
 
340
+ // ── A SCENARIO MAY NAME THE REGISTER ITS NUMBERS WERE MEASURED AGAINST ──────────────────────────────
341
+ //
342
+ // THE DEFECT THIS CLOSES. A scenario's register floors are counts, and a count is an answer one register
343
+ // gave. Run the same scenario against a different register and the floor decides nothing about the
344
+ // engine: measured 2026-09-30 on a knockout whose floors are `identical: 8` and `containing: 300`, the
345
+ // other register answered 2 and 471 for the SAME mark. One floor failed and one passed, in one run, and
346
+ // neither outcome was about the engine. A red like that costs a round: it reads as a regression, it is
347
+ // investigated as one, and the run that produced it is already spent.
348
+ //
349
+ // WHAT THIS DOES NOT DO, deliberately. It does not encode which register returns more. The measurement
350
+ // says the two DISAGREE — 2 against 8 on one predicate and 471 against 300 on another, in opposite
351
+ // directions — and nothing here has established why. A floor per register would need that why, and a
352
+ // harness rule must not smuggle one in. So a scenario states which register its numbers came from, and
353
+ // the harness refuses to spend the run anywhere else.
354
+ //
355
+ // OPTIONAL BY CONSTRUCTION, because the store is a DIFFERENT REPO. A scenario that declares nothing
356
+ // behaves exactly as it does today. There is no ordering problem to manage and no day on which this
357
+ // harness refuses a store that has not caught up.
358
+ //
359
+ // WHY THIS DOES NOT ASK THE DRIVER. `driver.config.mjs` resolves the provider, and this file may not
360
+ // import it — its unset-env defaults are PRODUCTION, which is the rule stated at four points above. The
361
+ // variable is read here directly and with no fallback: unset stays unset, and unset is reported as its
362
+ // own state rather than resolved to anything.
363
+ //
364
+ // CONFIGURED, NOT SERVED, and the difference is a live gap. What a run was configured for is the
365
+ // launch value; what actually served it is a separate record the runs do not yet carry (measured
366
+ // 2026-09-30: absent on all four runs of a round, on both the knockout and the clearance path). When
367
+ // that record exists, an assertion could read what served. Until then this is a PRE-RUN refusal on the
368
+ // configured value, which is the honest instrument available — and it is placed before the spend, where
369
+ // being approximately right still saves the money.
370
+ export const REGISTER_ENV = "CLEAROTRON_DATABASE";
371
+
372
+ /** The registers a scenario declares its numbers were measured against, lowercased. `[]` when it declares none. PURE. */
373
+ export function registersDeclaredBy(sc) {
374
+ const v = sc?.register;
375
+ const list = Array.isArray(v) ? v : v == null ? [] : [v];
376
+ return list.map((x) => String(x ?? "").trim().toLowerCase()).filter(Boolean);
377
+ }
378
+
379
+ /** The register this process is configured for, or `null` when the variable names none. No default, ever. PURE apart from the read. */
380
+ export function configuredRegister(env = process.env) {
381
+ return String(env?.[REGISTER_ENV] ?? "").trim().toLowerCase() || null;
382
+ }
383
+
384
+ /**
385
+ * The refusal text for running `sc` here, or `null` when there is nothing to refuse. PURE.
386
+ *
387
+ * Three cases, and the middle one is the reason this returns text rather than a boolean: a scenario that
388
+ * declares a register and an environment that names none is NOT a match and NOT a mismatch. Running it
389
+ * would spend against whatever the driver defaults to, which this file is not allowed to ask about, so
390
+ * the refusal says exactly that rather than guessing either way.
391
+ */
392
+ export function registerRefusal(sc, env = process.env) {
393
+ const want = registersDeclaredBy(sc);
394
+ if (!want.length) return null;
395
+ const have = configuredRegister(env);
396
+ const declares = want.length === 1 ? want[0] : `one of ${want.join(", ")}`;
397
+ if (!have) {
398
+ return `${sc.id} states that its numbers were measured against ${declares}, and ${REGISTER_ENV} names no register `
399
+ + `on this instance. This harness does not resolve a default — the module that would is the one it may not import. `
400
+ + `Set ${REGISTER_ENV} to ${declares} and run again.`;
401
+ }
402
+ if (want.includes(have)) return null;
403
+ return `${sc.id} states that its numbers were measured against ${declares}, and this instance is configured for ${have}. `
404
+ + `What one register answered is not what another answers, so here this scenario would judge the register `
405
+ + `and not the engine — and a red read as a regression arrives on a run that is already spent. `
406
+ + `Set ${REGISTER_ENV} to ${declares}, or run a scenario whose numbers were measured against ${have}.`;
407
+ }
408
+
340
409
  // ── — the store is the input to the most expensive thing this repo does ─────────────────────────
341
410
  //
342
411
  // Every job block in the store, through BOTH admission gates, against the outcome the scenario declares.
@@ -440,6 +509,20 @@ export function lintScenarios(scenarios) {
440
509
  for (const f of sc.expect?.artifacts ?? []) {
441
510
  if (/^\/|\.\./.test(f)) wrong.push(`${label}: artifact ${JSON.stringify(f)} must be a plain run-relative name`);
442
511
  }
512
+ // ── `register`, IF STATED, MUST BE STATABLE ────────────────────────────────────────────────────
513
+ //
514
+ // SHAPE ONLY, AND NOT THE NAME. Checking the value against the known providers would mean importing
515
+ // `driver.config.mjs`, which this file may not do. That is not a hole worth patching another way: a
516
+ // misspelled register never runs anywhere, and the refusal it produces prints the scenario's word
517
+ // and the instance's word side by side, which is a typo shown rather than described. What IS worth
518
+ // refusing is a value nothing can compare at all — an empty string, a number, an empty list — since
519
+ // that would declare a register and then silently compare against nothing.
520
+ if (Object.prototype.hasOwnProperty.call(sc, "register")) {
521
+ const raw = Array.isArray(sc.register) ? sc.register : [sc.register];
522
+ const bad = raw.length === 0 || raw.some((x) => typeof x !== "string" || !x.trim());
523
+ if (bad) wrong.push(`${label}: \`register\` is ${JSON.stringify(sc.register)} — it must be a non-empty register name, or a non-empty list of them. `
524
+ + `A scenario states this when its numbers are counts one register returned, so that a run against another is refused before it spends.`);
525
+ }
443
526
  const allAsserts = [...(sc.expect?.assert ?? []), ...(sc.cases ?? []).flatMap((c) => c.expect?.assert ?? [])];
444
527
  for (const a of allAsserts) {
445
528
  if (/^_driver\/scope-ledger\.json/.test(String(a.path ?? ""))) {
@@ -2185,7 +2268,11 @@ export function provenanceLines(cost, today = new Date()) {
2185
2268
 
2186
2269
  function cmdList() {
2187
2270
  console.log("\nE2E scenarios — each is one complete clearance unless marked $0");
2188
- console.log(`store: ${storeLine(STORE)}\n`);
2271
+ console.log(`store: ${storeLine(STORE)}`);
2272
+ // WHAT THIS INSTANCE IS CONFIGURED FOR, once at the top, because it decides which of the scenarios
2273
+ // below `run` will start. Printed even when nothing declares a register: a reader choosing a scenario
2274
+ // needs to know the variable names none BEFORE the refusal tells them.
2275
+ console.log(`register: ${configuredRegister() ?? `${REGISTER_ENV} names none`}\n`);
2189
2276
  sweepStoreOrDie();
2190
2277
  for (const s of allScenarios()) {
2191
2278
  const cost = s.cost?.measured ? `~${s.cost.wallMinutes} min` : "UNMEASURED";
@@ -2200,6 +2287,16 @@ function cmdList() {
2200
2287
  // all, and an absent benchmark is not a met one.
2201
2288
  for (const l of turnaroundVerdict(bandForScenario(s)).lines) console.log(` ${l}`);
2202
2289
  for (const l of provenanceLines(s.cost)) console.log(` ${l}`);
2290
+ // WHETHER `run` WOULD START THIS ONE HERE, at a glance, so the choice is made in the list rather
2291
+ // than discovered one scenario at a time. The refusal's own words, not a second wording of them.
2292
+ {
2293
+ const declared = registersDeclaredBy(s);
2294
+ if (declared.length) {
2295
+ const no = registerRefusal(s);
2296
+ console.log(` register: measured against ${declared.join(", ")} — ${no ? "WOULD REFUSE HERE" : "runnable here"}`);
2297
+ if (no) console.log(` ${no}`);
2298
+ }
2299
+ }
2203
2300
  // — which scenarios prove the register HIT path, at a glance. Unconditional, and an
2204
2301
  // unstated label prints as loudly as a stated one.
2205
2302
  {
@@ -2263,6 +2360,13 @@ async function cmdRun(id) {
2263
2360
  // Which store this came from, on the record before the run spends. The synthetic and the real scenario
2264
2361
  // share an ID, so the ledger afterwards cannot tell you which one ran unless the run says so now.
2265
2362
  console.log(`store: ${storeLine(STORE)}`);
2363
+ // BEFORE THE SPEND, because that is the only place this check is worth anything: a scenario whose
2364
+ // floors were measured against another register produces a red that reads as an engine regression,
2365
+ // and by then the run is paid for. `die` rather than a warning — a warning printed above a three-hour
2366
+ // run is a warning nobody reads until the verdict.
2367
+ { const no = registerRefusal(s); if (no) die(`REFUSING: ${no}`); }
2368
+ console.log(`register: ${configuredRegister() ?? `${REGISTER_ENV} names none`}${
2369
+ registersDeclaredBy(s).length ? ` — the scenario states its numbers were measured against ${registersDeclaredBy(s).join(", ")}` : ""}`);
2266
2370
  // Every scenario spends, R0 included — so every scenario refuses on stale code. R0 was exempted here
2267
2371
  // on the belief that it is refused at the door before any model call, which its own `why` also claimed.
2268
2372
  // It is not: R0d's FIRST submission is expected to admit (that is how it produces a duplicate to
@@ -3486,8 +3590,27 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3486
3590
  // A multi-name knockout stamps NO single url BY DESIGN: one address would be the first
3487
3591
  // name standing for the batch, so the packet carries `reports[{mark, url}]` instead. Read it —
3488
3592
  // from the run dir, or from the pool meta once the run dir is archived — and probe every per-mark
3489
- // address exactly as the single-url arm does. Only a delivered run with neither a url nor a
3490
- // reports[] list is the CLEAROTRON_REPORTS_URL absence this arm was written for.
3593
+ // address exactly as the single-url arm does.
3594
+ //
3595
+ // ── WHAT THE TWO ABSENCE ARMS BELOW MEASURE, AND WHAT THEY CANNOT SEE ───────────────────────────
3596
+ //
3597
+ // They measure one thing: no entry carries a url. They used to REPORT a second thing they never
3598
+ // looked at — that CLEAROTRON_REPORTS_URL is unset — and the row said it flatly, as a fact about
3599
+ // the instance.
3600
+ //
3601
+ // IT IS NOT ALWAYS TRUE, measured on a test instance 2026-09-30: the variable was present and
3602
+ // non-empty both in the instance's env file and in the worker process's own environment, and the
3603
+ // run's eight report entries each carried a `url` KEY WITH AN EMPTY VALUE. The stamp had run and
3604
+ // produced nothing. On that run the stated cause was simply wrong, and the cause of the empty
3605
+ // stamp is still unknown.
3606
+ //
3607
+ // WHAT IT COSTS TO STATE A CAUSE YOU DID NOT MEASURE: acting on the row, the obvious repair is to
3608
+ // restore the variable from a backup — overwriting a working value with an older one and calling
3609
+ // it a fix. That was nearly done here.
3610
+ //
3611
+ // So the row states the measurement and the investigate line keeps the hypothesis, marked as one.
3612
+ // A reader who wants the cause reads the variable on the instance and the stamp's own output; this
3613
+ // check is not in a position to tell them.
3491
3614
  const batchReports = (() => {
3492
3615
  try { return JSON.parse(readFileSync(driverDir(runDir, "delivery.json"), "utf8")).reports ?? []; } catch { /* archived or pre-batch */ }
3493
3616
  try { return JSON.parse(readFileSync(join(poolDir, "meta.json"), "utf8")).reports ?? []; } catch { /* no pool entry */ }
@@ -3505,13 +3628,14 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3505
3628
  else if (error) { failures++; toInvestigate.push(`${ref}: could not probe ${r.mark}'s report URL (${error}) — ${r.url}`); }
3506
3629
  }
3507
3630
  } else if (batchReports.length) {
3508
- console.log(` [FAIL] the run's stamped URL resolves\n delivered as a batch of ${batchReports.length}, but no report entry carries a url — CLEAROTRON_REPORTS_URL is unset on this instance`);
3509
- failures++; toInvestigate.push(`${ref}: batch delivered with no per-report URL stamped (CLEAROTRON_REPORTS_URL unset?)`);
3631
+ console.log(` [FAIL] the run's stamped URL resolves\n delivered as a batch of ${batchReports.length}, and not one of those entries carries a url`);
3632
+ failures++; toInvestigate.push(`${ref}: batch delivered with no per-report URL stamped — cause NOT measured here; read CLEAROTRON_REPORTS_URL on the instance AND what the stamp wrote, because a set variable can still stamp empty`);
3510
3633
  } else {
3511
- // A delivered run with no URL at all is the same absence one step earlier: CLEAROTRON_REPORTS_URL unset
3512
- // makes publishReport stamp null, and the handoff packet then carries no address for anyone to open.
3513
- console.log(` [FAIL] the run's stamped URL resolves\n delivered, but status.json carries no url — CLEAROTRON_REPORTS_URL is unset on this instance`);
3514
- failures++; toInvestigate.push(`${ref}: delivered with no report URL stamped (CLEAROTRON_REPORTS_URL unset?)`);
3634
+ // A delivered run with no URL at all is the same absence one step earlier, and the same limit
3635
+ // applies: this arm sees that the record carries no address, never why. The handoff packet then
3636
+ // carries no address for anyone to open, whatever produced that.
3637
+ console.log(` [FAIL] the run's stamped URL resolves\n delivered, and status.json carries no url`);
3638
+ failures++; toInvestigate.push(`${ref}: delivered with no report URL stamped — cause NOT measured here; read CLEAROTRON_REPORTS_URL on the instance AND what the stamp wrote, because a set variable can still stamp empty`);
3515
3639
  }
3516
3640
  }
3517
3641