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 +2 -2
- package/driver/CHANGELOG.md +81 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/engine/mcp/band-server.mjs +3 -3
- package/driver/gateway.mjs +27 -3
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +12 -2
- package/driver/pipeline.mjs +47 -5
- package/driver/provider-usage.mjs +16 -0
- package/driver/publish/index.mjs +11 -5
- package/driver/record-carry.mjs +1 -1
- package/driver/reviewer-open-points.mjs +20 -23
- package/driver/score-redaction.mjs +32 -5
- package/driver/screen-gate.mjs +3 -3
- package/driver/suite-census.json +37 -13
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/core.js +88 -3
- package/scripts/e2e.mjs +133 -9
package/build-info.json
CHANGED
package/driver/CHANGELOG.md
CHANGED
|
@@ -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
|
package/driver/ask-ledger.mjs
CHANGED
|
@@ -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. `"
|
|
587
|
-
* when the findings name
|
|
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/
|
|
163
|
-
// judgment cites the record (screen-gate.mjs
|
|
164
|
-
//
|
|
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
|
package/driver/gateway.mjs
CHANGED
|
@@ -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
|
-
|
|
1189
|
-
|
|
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
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.0
|
|
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,
|
|
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
|
package/driver/pipeline.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
7690
|
-
const
|
|
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
|
-
|
|
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
|
|
package/driver/publish/index.mjs
CHANGED
|
@@ -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
|
|
1716
|
-
//
|
|
1717
|
-
|
|
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
|
-
|
|
1830
|
-
|
|
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>`;
|
package/driver/record-carry.mjs
CHANGED
|
@@ -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,
|
|
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.
|
|
7
|
-
//
|
|
8
|
-
// the run record under `_driver/`,
|
|
9
|
-
// report,
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
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;
|
package/driver/screen-gate.mjs
CHANGED
|
@@ -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/
|
|
103
|
-
// SLASH (→ /mark/ch/
|
|
104
|
-
// other instance) suffix is not a false-negative on the membership test —
|
|
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
|
package/driver/suite-census.json
CHANGED
|
@@ -1216,14 +1216,14 @@
|
|
|
1216
1216
|
"todos": 0
|
|
1217
1217
|
},
|
|
1218
1218
|
"a-run-records-the-tool-that-served-it.test.mjs": {
|
|
1219
|
-
"tests":
|
|
1220
|
-
"asserts":
|
|
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":
|
|
1226
|
-
"asserts":
|
|
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":
|
|
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":
|
|
4640
|
-
"asserts":
|
|
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":
|
|
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":
|
|
5948
|
-
"asserts":
|
|
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-
|
|
6168
|
+
"the-reviewers-open-points-stay-in-the-run-record.test.mjs": {
|
|
6157
6169
|
"tests": 6,
|
|
6158
|
-
"asserts":
|
|
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":
|
|
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,
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.4.0
|
|
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
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.0
|
|
5
|
+
"version": "0.4.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,
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
|
294
|
+
body.match = match; // sending similarity alongside is a 4xx, not a preference
|
|
234
295
|
} else {
|
|
235
|
-
body.
|
|
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)}
|
|
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.
|
|
3490
|
-
//
|
|
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},
|
|
3509
|
-
failures++; toInvestigate.push(`${ref}: batch delivered with no per-report URL stamped
|
|
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
|
|
3512
|
-
//
|
|
3513
|
-
|
|
3514
|
-
|
|
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
|
|