clearotron 0.3.2-beta.0 → 0.3.2-beta.2

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/.env.example CHANGED
@@ -480,6 +480,25 @@ CLEAROTRON_CUT_REF=
480
480
  # effect: tuning
481
481
  CLEAROTRON_RELEASE_WAIT_MS=
482
482
 
483
+ # A GitHub token carrying Actions read and write. The release workflow supplies it from a repository
484
+ # secret so that a cut does not wait for a person to approve the version pull request's parked CI run:
485
+ # that run is authored by the repository's own Actions bot, and GitHub parks bot-authored runs as
486
+ # `action_required`. The built-in token cannot approve a run — self-approval is blocked deliberately —
487
+ # which is why this is a second, separate credential.
488
+ #
489
+ # NEVER SET THIS ON A DEPLOYMENT. No installed instance reads it and no clearance run reaches it. It is
490
+ # written down here because shipped code reads it, and every name shipped code reads has a row; the
491
+ # catalogue is the contract, not a list of things you are expected to set.
492
+ #
493
+ # UNSET IS THE ORDINARY CASE AND NOT A MISCONFIGURATION. The script names the absent token on stdout
494
+ # and exits successfully, and the version run then waits for a person exactly as it did before the
495
+ # token existed. Nothing fails and no run changes its conclusion.
496
+ #
497
+ # It carries no effect declaration because the closed vocabulary has no class that is true of it: the
498
+ # credential class claims a run refuses at preflight when the name is absent, and this one does not
499
+ # refuse at all. Read by scripts/release-approve-parked.mjs.
500
+ ACTIONS_APPROVE_TOKEN=
501
+
483
502
  # The trickle floor: the fewest output tokens per second of ACTIVE time (elapsed minus tool wait) a
484
503
  # model turn may produce before it is stopped as a stall. Unset uses 1. A stage streaming a token every
485
504
  # few seconds holds off both other clocks — the stall clock resets on any byte, and the no-progress
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "0bc01b5d6cda940a40dc2fb7da2ae5f83caf3f5b",
3
- "version": "0.3.2-beta.0"
2
+ "commit": "8393489759f73ec4177b2fce74005780b7b4a567",
3
+ "version": "0.3.2-beta.2"
4
4
  }
@@ -296,6 +296,7 @@ because the rule is about what PRODUCT CODE reads, not about what a run reads.
296
296
  | `CLEAROTRON_UPDATER_STAMP` | `_updater-identity.json` beside the update script, in the directory the updater runs from | The full path of the file in which the updater that deploys this box records which copy of itself ran. The updater writes it and deploy health reads it under this one name, so a box that moves the stamp sets it once for both. On a box with no updater unit, setting it says an updater exists elsewhere and is to be judged. Effect class `deployment`. |
297
297
  | `CLEAROTRON_CUT_REF` | `HEAD` | Which ref the cut decision reads the version from. **Read only by the release workflow, never set on a deployment.** The jobs that ask about `main` set it to `origin/main` explicitly, because their checkout is pinned to the run's own ref and `HEAD` there is that ref rather than the branch they are deciding about. A job that asks the wrong subject gets a confident wrong answer. |
298
298
  | `CLEAROTRON_RELEASE_WAIT_MS` | 25 minutes | How long a requested cut waits for the version pull request to merge itself before giving up. **Read only by the release workflow.** A rehearsal sets it to `0` so the wiring is exercised without holding a runner. Giving up is a quiet success by design — the scheduled run underneath catches a cut whose wait expired — so this budget failing shows up as a slow job rather than a red one. |
299
+ | `ACTIONS_APPROVE_TOKEN` | unset | A GitHub token with Actions read and write, used to approve the version pull request's parked CI run so a cut does not wait for a person. **Read only by the release workflow, never set on a deployment.** The built-in token cannot approve a run — GitHub blocks self-approval — so this is a second, separate credential. Unset is the ordinary case and not an error: the script names the absent token and exits successfully, and the version run waits for a person as it did before. Actions write is broader than approval alone — it also dispatches workflows, cancels any run in the repository and deletes run logs. |
299
300
  | `CLEAROTRON_AGENT_MCP_URL` | unset (⇒ `null`) | The API-key MCP door advertised to a signed-in client. Null until that door is deployed, and the UI keeps its honest empty state rather than inventing a URL. |
300
301
  | `CLEAROTRON_AGENTS` | derived | Comma-separated agent ids for `scripts/purge-runs.mjs` to sweep. |
301
302
  | `CLEAROTRON_E2E_DIR` | **none — the script refuses without it** | The config repo's `e2e/` directory. There is one suite and it is not in this repo (ruling 2026-08-07), so the comparison script names the variable rather than defaulting anywhere. |
@@ -495,7 +495,15 @@ origin; all portal config lives server-side in portal-service.
495
495
 
496
496
  These are read by the release workflow and by nothing a deployment runs. They are listed here because a
497
497
  name absent from this register is a name nobody can look up, not because an operator has any reason to set
498
- one — and setting either on a box does nothing at all.
498
+ one — and setting any of them on a box does nothing at all.
499
+
500
+ `ACTIONS_APPROVE_TOKEN` (unset) — a GitHub token with Actions read and write, used to approve the
501
+ version pull request's parked CI run so that a cut does not wait for someone to click. That run is
502
+ authored by the repository's own Actions bot and GitHub parks bot-authored runs; the built-in token
503
+ cannot release one, because self-approval is blocked deliberately. Unset is the ordinary case: the
504
+ script names the absent token and exits successfully, and the run waits for a person as before.
505
+ Actions write is wider than approving — it also dispatches workflows, cancels any run in the
506
+ repository and deletes run logs — so it is worth rotating on the same schedule as a deploy key.
499
507
 
500
508
  `CLEAROTRON_CUT_REF` (default `HEAD`) — which ref the cut decision reads the version from. The jobs that
501
509
  ask about `main` set it to `origin/main` explicitly, because their checkout is pinned to the run's own ref
@@ -1,5 +1,18 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.2-beta.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 8642064: Fixed: On one register the "Filings containing the name" figure counted only identical filings. A report could therefore show a field as far less crowded than it really is. That column now asks the register the question its label promises. Where a register cannot answer a given kind of search, the figure is reported as unavailable rather than filled in from a narrower one.
8
+
9
+ ## 0.3.2-beta.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 2a81abc: New: A report can now show how much was searched to reach its answer. It records the names read and cleared, the records read in each country, and the checks made. Countries where nothing was found are included.
14
+ - 2a81abc: Fixed: The conditions listed on a report are now written in plain legal English, matching the summary line above them. One condition could previously appear as an internal engine note with counts and identifiers in it.
15
+
3
16
  ## 0.3.2-beta.0
4
17
 
5
18
  ### Patch Changes
@@ -638,7 +638,7 @@ export const E3_BACKLOG = [
638
638
  where: "driver/stages.mjs:3470",
639
639
  surface: "stage-message",
640
640
  evidence: "EVERY \"Grounded profile\" section MUST start its body with the line \"- ord: <N>\" naming which finding it grounds (use the ordinal from this list; a profile that grounds no listed finding omits the line)",
641
- reparsedBy: "driver/publish/parse.mjs:339 parseCaseLawProfiles (\"the optional '- ord: <N>' first body line … gives an EXACT join\"); driver/findings-model.mjs:273 /^-\\s*ord:\\s*(\\d+)\\s*$/m; driver/publish/index.mjs:776",
641
+ reparsedBy: "driver/publish/parse.mjs:339 parseCaseLawProfiles (\"the optional '- ord: <N>' first body line … gives an EXACT join\"); driver/findings-model.mjs:273 /^-\\s*ord:\\s*(\\d+)\\s*$/m; driver/publish/index.mjs:778 runOrigins",
642
642
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
643
643
  },
644
644
  {
@@ -1184,8 +1184,16 @@ export const PROVIDERS = {
1184
1184
  // modes, not strategies, and the API rejects them in the strategies array. These wrappers
1185
1185
  // hand-built the vendor shape and so were untouched by that fix — the translator is the one
1186
1186
  // place that knows which mode rides which request shape, and every caller must go through it.
1187
- const r = await core.doCountHits(process.env.SIGNA_API_KEY, base,
1188
- core.toSignaParams({ name, match_mode: matchMode || "exact", nice_classes: classes, regions }),
1187
+ // A MODE THIS PROVIDER CANNOT EXPRESS REFUSES THE CELL, rather than being answered by a
1188
+ // different predicate's number. The translator names it; this turns it into an honest unknown,
1189
+ // which the count kernel already treats as a disclosed gap. The alternative is what this
1190
+ // issue was: a narrower query answering under the wider query's label, invisibly.
1191
+ const signaParams = core.toSignaParams({ name, match_mode: matchMode || "exact", nice_classes: classes, regions });
1192
+ if (signaParams.unsupported_match_mode) {
1193
+ return { ok: false, total: null, unsupported: true,
1194
+ cause: `this register provider cannot express the "${signaParams.unsupported_match_mode}" match mode, so this count is UNKNOWN — it is not answered with a different predicate's number` };
1195
+ }
1196
+ const r = await core.doCountHits(process.env.SIGNA_API_KEY, base, signaParams,
1189
1197
  { kind: "count", agentId, sessionKey, sessionId: null, recordLog });
1190
1198
  const text = typeof r?.text === "string" ? r.text : "";
1191
1199
  if (text.startsWith("ERROR")) return { ok: false, cause: text.slice(0, 200) };
@@ -1207,8 +1215,16 @@ export const PROVIDERS = {
1207
1215
  catch (e) { return { ok: false, records: null, reason: `plugin core unavailable: ${e.message}` }; }
1208
1216
  const base = process.env.SIGNA_BASE_URL || core.DEFAULT_BASE;
1209
1217
  try {
1218
+ // The listing takes the same refusal as the count above, and for the same reason one level on:
1219
+ // a narrower search here returns FEWER records under the wider query's name, so the listing
1220
+ // would under-report and read as complete.
1221
+ const signaListParams = core.toSignaParams({ name, match_mode: matchMode || "exact", nice_classes: classes, regions });
1222
+ if (signaListParams.unsupported_match_mode) {
1223
+ return { ok: false, records: null,
1224
+ reason: `this register provider cannot express the "${signaListParams.unsupported_match_mode}" match mode, so this listing was not taken — it is not answered with a narrower search` };
1225
+ }
1210
1226
  const r = await core.doSearch(process.env.SIGNA_API_KEY, base,
1211
- { ...core.toSignaParams({ name, match_mode: matchMode || "exact", nice_classes: classes, regions }), limit },
1227
+ { ...signaListParams, limit },
1212
1228
  { kind: "search", agentId, sessionKey, sessionId: null, recordLog });
1213
1229
  const text = typeof r?.text === "string" ? r.text : "";
1214
1230
  if (text.startsWith("ERROR")) return { ok: false, records: null, reason: text.slice(0, 200) };
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.0",
5
+ "version": "0.3.2-beta.2",
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": {
@@ -56,7 +56,7 @@
56
56
  "methodology",
57
57
  "handling_note"
58
58
  ],
59
- "why": "a caption-only call renders 149B, clears the 120-char floor, and ships a SECTION OF THE CLIENT'S ONE REPORT without the 'Checks we ran' bullets, the methodology or the handling note. Traced: report-overview.md is the head of report.md (assembleReportMd() in pipeline.mjs) which renders to the HTML (publish/index.mjs:648). The 'Only you can close these' register is NOT lost — it is code-built from findings.json under spec 64's 'code wins' ruling."
59
+ "why": "a caption-only call renders 149B, clears the 120-char floor, and ships a SECTION OF THE CLIENT'S ONE REPORT without the 'Checks we ran' bullets, the methodology or the handling note. Traced: report-overview.md is the head of report.md (assembleReportMd() in pipeline.mjs) which renders to the HTML (publish/index.mjs:649 publishReport). The 'Only you can close these' register is NOT lost — it is code-built from findings.json under spec 64's 'code wins' ruling."
60
60
  },
61
61
  {
62
62
  "tool": "record_prelim_variants",
@@ -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"; // — one definition of where `_driver/` is
13
- import { terminalClampDecision, orderClausesForLede } from "./terminal-clamp.mjs"; // — deliver and clamp, never withhold
13
+ import { terminalClampDecision, orderClausesForLede, clientConditions } from "./terminal-clamp.mjs"; // — deliver and clamp, never withhold
14
14
  import { recordSpan } from "./attributed-span.mjs"; // — driver work the decomposition can attribute
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { runStage, correctionHint, gridLedgerNameFor, draftCarryEligible, toolWrittenArtifact, selectEngine } from "./gateway.mjs";
@@ -13006,7 +13006,7 @@ async function pipelineInner(job, opts = {}) {
13006
13006
  const statement = riskStatement({ tier: derived.tier, verdict, reasons: reasonsOut, clauses: orderedClauses,
13007
13007
  basis: isRegisterOnly(ctx.searchPolicy) ? "register-only" : null });
13008
13008
  const tmp = driverDir(run.runDir, "verdict.json.tmp");
13009
- writeFileSync(tmp, JSON.stringify({ ts: new Date().toISOString(), verdict, reasons: reasonsOut, kinds: kindsOut,
13009
+ writeFileSync(tmp, JSON.stringify({ ts: new Date().toISOString(), verdict, reasons: reasonsOut, clauses: orderedClauses, kinds: kindsOut,
13010
13010
  tier: derived.tier, badge: derived.badge, gaugeIndex: derived.gaugeIndex, maxComposite: derived.maxComposite,
13011
13011
  band: derived.band ?? null, statement, stance: verdictStance(verdict) }, null, 2));
13012
13012
  renameSync(tmp, driverDir(run.runDir, "verdict.json"));
@@ -14859,7 +14859,7 @@ async function pipelineInner(job, opts = {}) {
14859
14859
  let emailVerdictOpts = { productName: emailProductName ?? undefined };
14860
14860
  // SPREAD, never reassign: this used to replace the whole object, which would now drop productName
14861
14861
  // above on every run that has a verdict sidecar — i.e. on every healthy run, and on no test.
14862
- try { const v = JSON.parse(readFileSync(driverDir(run.runDir, "verdict.json"), "utf8")); emailVerdictOpts = { ...emailVerdictOpts, verdict: v.verdict, conditions: v.reasons, tier: v.tier, statement: v.statement ?? null }; } catch { /* legacy path — no row */ }
14862
+ try { const v = JSON.parse(readFileSync(driverDir(run.runDir, "verdict.json"), "utf8")); emailVerdictOpts = { ...emailVerdictOpts, verdict: v.verdict, conditions: clientConditions(v), tier: v.tier, statement: v.statement ?? null }; } catch { /* legacy path — no row */ }
14863
14863
  // wp50 — thread the findings too: the table overlay's rating cells are code-bound to the canonical
14864
14864
  // ratings (joinFindingToBlock), never the summary's own words.
14865
14865
  try { emailVerdictOpts.findings = parseFindingsJsonLenient(readFileSync(P.findings, "utf8"))?.findings ?? undefined; } catch { /* no findings — table falls back to the summary words */ }
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // ── why this is a sidecar and not a field in meta.json ────────────────────────────────────────────────
11
11
  //
12
- // The same reason `archive-tags.json` is one, recorded at publish/index.mjs:338: "meta.json is rewritten
12
+ // The same reason `archive-tags.json` is one, recorded at publish/index.mjs:377 ARCHIVE_TAGS_FILE: "meta.json is rewritten
13
13
  // on every republish so a flag there would be lost." That is not hypothetical here — a `rerender-all`
14
14
  // pass over the pool is a live plan, and a family written into meta would be erased by the very operation
15
15
  // meant to bring old reports up to date. Curation state that a person entered by hand must outlive a
@@ -32,6 +32,7 @@ import { writeUpViolations, writeUpMessage } from "./narrative-write-ups.mjs";
32
32
  import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } from "./registry-fidelity.mjs";
33
33
  import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
34
34
  import { normalizeBand } from "./framework.mjs";
35
+ import { clientConditions, ENGINE_TOKEN_RE } from "./terminal-clamp.mjs"; // the reader's clause per condition, and the token shape it may never carry
35
36
  import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
36
37
  import { isEngineAppendedCaveat } from "./verify-knockout.mjs"; // ONE derivation for "the engine appended this caveat, not a seat"
37
38
 
@@ -1961,6 +1962,26 @@ export function verdictActionsCoherenceChecks({ actionsRegister, findings, verdi
1961
1962
  // the conditional FORM riskStatement composes ("<Tier> — conditional on: <facts>"), never a wording
1962
1963
  // regex. Legacy sidecars (no stance) get the tier + unconditional-proceed checks only — judged
1963
1964
  // against the wording THEIR era composed (the retired-phrase exemption below), never the new form.
1965
+ // ── THE CONDITIONS A CLIENT READS SPEAK A LAWYER'S NOUNS ────────────────────────────────────────────
1966
+ // The sidecar carries two texts per condition — a run-record `reason` that may name tokens, counts and
1967
+ // record ids, and the reader's `clause`. `clientConditions` is the one reader of that pair, so this
1968
+ // check asks the question over what a surface actually renders rather than over either array.
1969
+ //
1970
+ // IT FIRES ON A LEGACY SIDECAR, DELIBERATELY. A run recorded before clauses were persisted has none, so
1971
+ // every condition falls back to its run-record reason and a token-bearing one flags here. That is the
1972
+ // honest reading: the delivered page for that run does carry the token, and re-generating the run is
1973
+ // what clears it. A flag, never a withholding — the report ships and the operator sees where the gap is.
1974
+ export function clientConditionVoiceChecks({ verdictDoc }) {
1975
+ if (!verdictDoc) return [];
1976
+ const carrying = clientConditions(verdictDoc)
1977
+ .map((c) => ({ c, m: String(c).match(ENGINE_TOKEN_RE) }))
1978
+ .filter((x) => x.m);
1979
+ return [check("client-condition-voice", "verdict", "report", carrying.length === 0,
1980
+ carrying.length
1981
+ ? `${carrying.length} delivered condition${carrying.length === 1 ? "" : "s"} carr${carrying.length === 1 ? "ies" : "y"} an engine identifier, so the client's "conditional on:" list reads the run record rather than the reader's sentence: ${carrying.slice(0, 3).map((x) => `"${x.m[0]}"`).join(", ")}${carrying.length > 3 ? ` (+${carrying.length - 3} more)` : ""}. The clause exists at the clamp site; persist it and this list takes it. A sidecar written before clauses were persisted flags until the run is re-generated.`
1982
+ : "")];
1983
+ }
1984
+
1964
1985
  export function statementCoherenceChecks({ verdictDoc }) {
1965
1986
  if (!verdictDoc?.statement) return [];
1966
1987
  const st = String(verdictDoc.statement), tier = String(verdictDoc.tier ?? "");
@@ -2292,7 +2313,8 @@ export function runLint({ depth, commonLawGrid, matterContext, clientPartyName,
2292
2313
  if (findings && clientSummaryMd) checks.push(...clientTierChecks({ clientSummaryMd, findings, manifest })); // A2 (doc 50: band words via the frozen manifest)
2293
2314
  if (verdictDoc?.tier && reportMd) checks.push(...overallTierChecks({ reportMd, verdictDoc })); // wp50/wi2
2294
2315
  checks.push(...verdictActionsCoherenceChecks({ actionsRegister, findings, verdictDoc })); // spec 64
2295
- checks.push(...statementCoherenceChecks({ verdictDoc })); // spec 64
2316
+ checks.push(...statementCoherenceChecks({ verdictDoc }));
2317
+ checks.push(...clientConditionVoiceChecks({ verdictDoc })); // — the reader's clause, not the run record // spec 64
2296
2318
  checks.push(...conditionalTextCoherenceChecks({ reportMd, clientSummaryMd, verdictDoc })); // qw/verdict-text-coherence — CONDITIONAL badge vs clean-outcome prose
2297
2319
  checks.push(...prescriptionProseChecks({ reportMd, clientSummaryMd, findings, fourAnswers })); // PR-3 report voice — facts that condition, never advice; flag-only (P5: the findings-derived prose is a delivered surface too)
2298
2320
  checks.push(...onlyYouRegisterChecks({ actionsRegister, findings, reportMd })); // spec 64
@@ -16,6 +16,7 @@ import { buildAudit } from './xlsx.mjs';
16
16
  import { parseFindingsJson, parseFindingsJsonLenient, deriveDisplayVerdict, joinFindingToBlock, CLIENT_TIER_BY_COMPOSITE, projectCoverageJudgment } from '../findings-model.mjs';
17
17
  import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; // — and why an absence did not close
18
18
  import { clearanceReportData } from './report-data.mjs';
19
+ import { searchDepthRecord } from './search-depth.mjs'; // how much was read to reach the answer, as counts and tokens
19
20
  import { parseFrameworkManifest } from '../framework.mjs';
20
21
  import { rollupTokens } from '../tokens.mjs';
21
22
  import { reportIdentityFor, productCoverageNote, isRegisterOnly } from '../search-policy.mjs';
@@ -1053,7 +1054,29 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1053
1054
  // worse demo than no toggle.
1054
1055
  const reportNav = siteNav(poolRoot, 'report', null, '../', { anon: false });
1055
1056
  // `demoData` is resolved above the report.md write — one answer, every surface.
1056
- writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion }));
1057
+ // ── HOW MUCH WAS READ TO REACH THE ANSWER ──────────────────────────────────────────────────────
1058
+ // Derived here rather than in a stage so a REPUBLISH of an archived run picks the fields up with no
1059
+ // re-run: every source below is something the run already wrote. Best-effort per source, in the house
1060
+ // pattern — a run with no grid or no case-law layer still gets its register counts, and the absent
1061
+ // ones report themselves as zero or `not-in-scope` rather than as a gap nobody can see.
1062
+ let searchDepth = null;
1063
+ try {
1064
+ const runBase = runDir ?? dirname(reportMd);
1065
+ const recDir = join(runBase, '_records');
1066
+ const rdText = (p2) => { try { return readFileSync(p2, 'utf8'); } catch { return ''; } };
1067
+ const rdJson = (p2) => { try { return JSON.parse(readFileSync(p2, 'utf8')); } catch { return null; } };
1068
+ searchDepth = searchDepthRecord({
1069
+ auditMd: (auditMd && existsSync(auditMd)) ? rdText(auditMd) : '',
1070
+ recordIndex: recordsByUri ?? {},
1071
+ recordFileNames: existsSync(recDir) ? readdirSync(recDir) : [],
1072
+ commonLawGrid: rdJson(join(runBase, 'common-law-grid.json')),
1073
+ caseLawText: rdText(join(dirname(reportMd), 'case-law-findings.md')),
1074
+ registerPlan: rdJson(driverDir(runBase, 'register-plan.json')),
1075
+ });
1076
+ writeRO('search-depth.json', JSON.stringify(searchDepth, null, 2));
1077
+ } catch { /* the depth record is additive — a publish never fails for want of it */ }
1078
+
1079
+ writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion, searchDepth }));
1057
1080
  // ONE report (spec 2026-07-30 §5): report.client.html is no longer written. The knockout lane's own
1058
1081
  // collapse note is the precedent: "two renderings of one run is how the wrong link gets sent". The
1059
1082
  // client host serves the same report.html through the portal's readReport() (cleaning built in) — its
@@ -32,6 +32,7 @@
32
32
  // testable offline and a republished archived run reproduces its file deterministically.
33
33
  import { stripInternal, dropLabelledInternals, stripEngineInternals } from './parse.mjs';
34
34
  import { DISPOSITION_GROUP, deriveActionConditions, projectAssessmentField } from '../findings-model.mjs';
35
+ import { clientConditions } from '../terminal-clamp.mjs'; // the reader's clause per condition — one definition, shared with the email
35
36
 
36
37
  // The client scrub choke point: the three existing rules, in the order the client HTML applies them.
37
38
  // Structural values (uris, enum tokens, dates, numbers) do not route through here — they carry no prose.
@@ -101,7 +102,7 @@ export function clearanceReportData({
101
102
  badge: verdictInfo.badge ?? null,
102
103
  band: verdictInfo.band ?? null,
103
104
  statement: clientText(verdictInfo.statement),
104
- conditions: (Array.isArray(verdictInfo.reasons) ? verdictInfo.reasons : []).map(clientText).filter(Boolean),
105
+ conditions: clientConditions(verdictInfo).map(clientText).filter(Boolean),
105
106
  } : null,
106
107
  caption: clientText(caption),
107
108
  jurisdiction: clientText(jurisdiction),
@@ -0,0 +1,178 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // ── HOW MUCH WAS READ TO REACH THE ANSWER, AS MACHINE FIELDS ────────────────────────────────────────
5
+ //
6
+ // A deeper search reads more and finds no more, and until now the report could not say so: a run that
7
+ // read 1,455 register records and cleared 432 near-names delivered a page showing 13 findings and
8
+ // nothing of the rest. The negative evidence — the work that came back clean — existed only in the
9
+ // audit workbook, in the engine's own working voice.
10
+ //
11
+ // This module derives that body of negative evidence from artifacts the run has ALREADY written. It
12
+ // starts no search, asks no model and composes no sentence: every field here is a count, a token, or a
13
+ // fact copied from a record. That is the whole design constraint, and it is why this lives at publish
14
+ // time rather than in a stage — a republish of an archived run picks the fields up with no re-run.
15
+ //
16
+ // THE GROUP KEY IS THE PART THAT COULD HAVE GONE WRONG. Each cleared near-name is grouped by WHY it was
17
+ // cleared, and the honest source is the register providers' closed screening vocabulary, which every
18
+ // provider computes identically: `drop:dead` (a confidently dead status), `drop:out-of-class` (live but
19
+ // no in-scope-class overlap), `surface:in-scope-live` / `surface:all-class` (a real in-scope candidate),
20
+ // `deepfetch:ambiguous` (status unrecognised). A verdict and the record's own status are facts. The
21
+ // engine's `result` paragraph is not a fact about the record, it is prose about the reasoning, and
22
+ // classifying on it is how a report ends up asserting a reason the record does not support.
23
+ //
24
+ // SO THIS EMITS THREE GROUPS, NOT FOUR, AND THAT IS DELIBERATE. `dead-filing` and `different-goods` fall
25
+ // straight out of the vocabulary. Everything else is `other` — read, in scope, and cleared on judgment.
26
+ // A fourth group, "different word", cannot be derived from a screening verdict or a status: a name
27
+ // cleared because it reads as a different word was `surface:in-scope-live` like any other real
28
+ // candidate, and only the reasoning says otherwise. Splitting it would mean either reading that prose
29
+ // or inventing a similarity rule here, and a wrong group on a client page is worse than a coarse
30
+ // honest one. Recorded on the issue rather than guessed at.
31
+ //
32
+ // BREAK MATRIX:
33
+ // · a dead status groups as dead-filing → break: drop the status arm, arm 1 red
34
+ // · an out-of-class verdict groups as goods → break: map it to other, arm 2 red
35
+ // · an in-scope live name groups as other → break: classify on the result prose, arm 3 red
36
+ // · a per-country count names every country read → break: count only countries with a finding, arm 5 red
37
+ // · court decisions distinguishes four states → break: collapse none-found into not-checked, arm 6 red
38
+
39
+ /** The closed set a group key may take. The renderer's headings are keyed on these, never on prose. */
40
+ export const CLEARED_GROUPS = Object.freeze(["dead-filing", "different-goods", "other"]);
41
+
42
+ /** Statuses a register reports for a filing that is no longer live. Surfaced by the provider, never date-cut. */
43
+ const DEAD_STATUS_RE = /^(?:CANCELLED|CANCELED|EXPIRED|ABANDONED|WITHDRAWN|REFUSED|DEAD|LAPSED|INVALID|SURRENDERED)\b/i;
44
+
45
+ /**
46
+ * Why this near-name was cleared, from the two fields the engine records per name. PURE.
47
+ *
48
+ * @param {{screenVerdict?: string, status?: string}} a
49
+ * @returns {"dead-filing"|"different-goods"|"other"}
50
+ */
51
+ export function groupForCleared({ screenVerdict, status } = {}) {
52
+ const v = String(screenVerdict ?? "").trim().toLowerCase();
53
+ const s = String(status ?? "").trim();
54
+ if (v.startsWith("drop:dead") || DEAD_STATUS_RE.test(s)) return "dead-filing";
55
+ if (v.startsWith("drop:out-of-class")) return "different-goods";
56
+ return "other";
57
+ }
58
+
59
+ /** One `## NRn` block's `- key: value` lines. The audit is written by a deterministic builder, so this is a contract. */
60
+ const field = (block, key) => (block.match(new RegExp(`^- ${key}:\\s*(.*)$`, "m")) || [])[1]?.trim() ?? "";
61
+ const noteField = (notes, key) => (notes.match(new RegExp(`${key}=([^;]+)`)) || [])[1]?.trim() ?? "";
62
+
63
+ /**
64
+ * Every near-name the run read and cleared, register and web, as facts. PURE.
65
+ *
66
+ * The register rows carry no sentence: mark, owner, country, class, status, group and the record URI.
67
+ * The reasoning stays in the audit workbook, where the engine already wrote it — the owner ruled against
68
+ * a second client-facing sentence per name (2026-09-16), so this deliberately does not carry `result`.
69
+ *
70
+ * @param {string} auditMd the run's `audit.md`
71
+ * @param {Record<string, object>} recordIndex fetched records by URI, for the fuller mark and owner
72
+ */
73
+ export function clearedNames(auditMd, recordIndex = {}) {
74
+ const out = { register: [], web: [] };
75
+ for (const block of String(auditMd ?? "").split(/^## /m).slice(1)) {
76
+ const title = block.split("\n")[0].trim();
77
+ const layer = field(block, "source_layer");
78
+ if (/^NR\d+/.test(title) && /register/i.test(layer)) {
79
+ const notes = field(block, "notes");
80
+ const uri = (notes.match(/URI (\S+?);/) || [])[1] ?? "";
81
+ const screenVerdict = noteField(notes, "screen_verdict");
82
+ const status = noteField(notes, "status");
83
+ const classes = noteField(notes, "class");
84
+ const country = ((uri.match(/^\/mark\/([a-z]{2})\//) || [])[1] ?? "").toUpperCase();
85
+ const rec = recordIndex[uri] ?? {};
86
+ out.register.push({
87
+ term: field(block, "search_term"),
88
+ mark: rec.markText || rec.mark || field(block, "search_term"),
89
+ owner: rec.owner ?? "", country, classes: rec.classes || classes,
90
+ status: rec.statusText || status,
91
+ group: groupForCleared({ screenVerdict, status: rec.statusText || status }),
92
+ uri,
93
+ });
94
+ } else if (!/^NR\d+/.test(title) && /common-law/i.test(layer) && !/^\(none/i.test(title)) {
95
+ out.web.push({ title, url: field(block, "url"), type: field(block, "type") });
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Register records read, per country, from the run's own `_records/` listing. PURE.
103
+ *
104
+ * EVERY COUNTRY THE RUN READ, including the ones that came back clean — those are the whole point. A
105
+ * count keyed off the findings would list only countries with a conflict, which is the gap this closes.
106
+ *
107
+ * @param {string[]} recordFileNames the `_records/` directory listing, named `<cc>-<id>.json`
108
+ */
109
+ export function recordsByCountry(recordFileNames = []) {
110
+ const out = {};
111
+ for (const name of recordFileNames) {
112
+ const cc = (String(name).match(/^([a-z]{2})-/i) || [])[1];
113
+ if (cc) out[cc.toUpperCase()] = (out[cc.toUpperCase()] ?? 0) + 1;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** Marketplace, web, reputation and meaning checks, from the deterministic grid the tools wrote. PURE. */
119
+ export function sweepCounts(commonLawGrid, auditMd = "") {
120
+ const cells = Array.isArray(commonLawGrid?.cells) ? commonLawGrid.cells : [];
121
+ const counts = {
122
+ checks: cells.length,
123
+ platforms: new Set(cells.map((c) => c?.platform).filter(Boolean)).size,
124
+ spellings: new Set(cells.map((c) => c?.term).filter(Boolean)).size,
125
+ reputation: Array.isArray(commonLawGrid?.extras?.pr_risk) ? commonLawGrid.extras.pr_risk.length : 0,
126
+ };
127
+ // A run old enough to predate the grid has no machine record; its per-term log is the audit's own
128
+ // common-law rows. Not a fallback masking a defect — those runs have nothing else to read.
129
+ if (!counts.checks && auditMd) counts.checks = (String(auditMd).match(/^- source_layer: Common-law/gm) || []).length;
130
+ return counts;
131
+ }
132
+
133
+ /**
134
+ * What the court-decisions pass came back with, as one token. PURE.
135
+ *
136
+ * FOUR STATES, BECAUSE THREE OF THEM MEAN DIFFERENT THINGS TO A READER. "None found" is a result and
137
+ * "could not be checked" is a gap; collapsing them would let an unreachable source read as a clean
138
+ * negative, which is the one thing a clearance may never do. "Not in scope" is neither — the product
139
+ * offers the pass on a full country search only.
140
+ *
141
+ * @returns {"found"|"none-found"|"not-checked"|"not-in-scope"}
142
+ */
143
+ export function courtDecisionsState(caseLawText) {
144
+ const t = String(caseLawText ?? "");
145
+ if (!t.trim()) return "not-in-scope";
146
+ if (/source unreachable|could not be reached|CONNECTION_CLOSED|not reachable|quota/i.test(t)) return "not-checked";
147
+ if (/No on-point precedent found|none found|no decisions found/i.test(t)) return "none-found";
148
+ return "found";
149
+ }
150
+
151
+ /** Was the name searched in a non-Latin script? Read off the plan's own terms, never asserted. PURE. */
152
+ export function localScriptSearched(registerPlan) {
153
+ const entries = Array.isArray(registerPlan?.entries) ? registerPlan.entries : [];
154
+ return entries.some((e) => /[^\x00-\x7F]/.test(String(e?.term ?? "")));
155
+ }
156
+
157
+ /**
158
+ * The whole record, composed from what the run holds. PURE — every argument is already-written material.
159
+ *
160
+ * @returns {{schemaVersion: number, cleared: object, counts: object}}
161
+ */
162
+ export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNames = [], commonLawGrid = null, caseLawText = "", registerPlan = null } = {}) {
163
+ const cleared = clearedNames(auditMd, recordIndex);
164
+ const groups = {};
165
+ for (const key of CLEARED_GROUPS) groups[key] = 0;
166
+ for (const c of cleared.register) groups[c.group] += 1;
167
+ return {
168
+ schemaVersion: 1,
169
+ cleared: { register: cleared.register, web: cleared.web, groups },
170
+ counts: {
171
+ recordsByCountry: recordsByCountry(recordFileNames),
172
+ recordsRead: recordFileNames.length,
173
+ sweep: sweepCounts(commonLawGrid, auditMd),
174
+ localScriptSearched: localScriptSearched(registerPlan),
175
+ courtDecisions: courtDecisionsState(caseLawText),
176
+ },
177
+ };
178
+ }
@@ -141,6 +141,12 @@
141
141
  "skips": 0,
142
142
  "todos": 0
143
143
  },
144
+ "a-client-condition-never-carries-an-engine-token.test.mjs": {
145
+ "tests": 6,
146
+ "asserts": 10,
147
+ "skips": 0,
148
+ "todos": 0
149
+ },
144
150
  "a-codex-login-survives-its-own-refresh.test.mjs": {
145
151
  "tests": 4,
146
152
  "asserts": 18,
@@ -1643,7 +1649,7 @@
1643
1649
  },
1644
1650
  "coverage-is-disclosed-never-refused.test.mjs": {
1645
1651
  "tests": 10,
1646
- "asserts": 38,
1652
+ "asserts": 40,
1647
1653
  "skips": 0,
1648
1654
  "todos": 0
1649
1655
  },
@@ -3688,8 +3694,8 @@
3688
3694
  "todos": 0
3689
3695
  },
3690
3696
  "release-pipeline.test.mjs": {
3691
- "tests": 93,
3692
- "asserts": 356,
3697
+ "tests": 99,
3698
+ "asserts": 376,
3693
3699
  "skips": 0,
3694
3700
  "todos": 0
3695
3701
  },
@@ -5001,6 +5007,12 @@
5001
5007
  "skips": 1,
5002
5008
  "todos": 0
5003
5009
  },
5010
+ "the-report-can-say-how-much-was-read.test.mjs": {
5011
+ "tests": 8,
5012
+ "asserts": 23,
5013
+ "skips": 0,
5014
+ "todos": 0
5015
+ },
5004
5016
  "the-report-carries-what-the-assessment-wrote.test.mjs": {
5005
5017
  "tests": 10,
5006
5018
  "asserts": 41,
@@ -5979,7 +5991,7 @@
5979
5991
  },
5980
5992
  "home.test.ts": {
5981
5993
  "tests": 56,
5982
- "asserts": 196,
5994
+ "asserts": 197,
5983
5995
  "skips": 0,
5984
5996
  "todos": 0
5985
5997
  },
@@ -6365,6 +6377,12 @@
6365
6377
  "skips": 0,
6366
6378
  "todos": 0
6367
6379
  },
6380
+ "providers/signa/test/match-mode-is-not-approximated.test.mjs": {
6381
+ "tests": 4,
6382
+ "asserts": 11,
6383
+ "skips": 0,
6384
+ "todos": 0
6385
+ },
6368
6386
  "providers/uspto-local/test/backfile-window.test.mjs": {
6369
6387
  "tests": 6,
6370
6388
  "asserts": 11,
@@ -106,3 +106,44 @@ export function orderClausesForLede(clauses, reasons, guardSet) {
106
106
  const ordered = [...aligned.filter((x) => !guardSet?.has?.(x.c)), ...aligned.filter((x) => guardSet?.has?.(x.c))];
107
107
  return { clauses: ordered.map((x) => x.c), reasons: ordered.map((x) => x.r) };
108
108
  }
109
+
110
+ /**
111
+ * THE CLIENT'S CONDITION LIST, from a verdict sidecar. PURE.
112
+ *
113
+ * TWO TEXTS, NEVER ONE — stated at the top of this module, and until now honoured at only one of the
114
+ * two ends. Every clamp site composes a run-record `reason` (token, counts, record ids) and a reader
115
+ * `clause` (the same fact in a lawyer's nouns), and `terminalClampDecision` refuses a clause that
116
+ * carries an engine identifier. The sidecar then persisted `reasons` alone and threw the clauses away,
117
+ * so every client surface had only the run-record text to render. A delivered report's conditions
118
+ * opened with `floor_duty_undischarged:4 of 430 floor row(s)…` while the SAME run's risk statement, one
119
+ * line above, read the clean clause: `riskStatement` already prefers `clauses`, so the page contradicted
120
+ * its own headline. This function is the other end of that rule.
121
+ *
122
+ * THE CLAUSE WINS WHERE THERE IS ONE, and the fallback is not politeness. Three machinery sites push
123
+ * the reason AS the clause ("machinery reasons ARE factual open-states"), so for those entries the two
124
+ * texts are one string and this returns it unchanged — correctly, because no second text exists to
125
+ * prefer. A legacy sidecar written before this change carries no `clauses` key at all and falls back
126
+ * entirely, which is what keeps archived runs republishable.
127
+ *
128
+ * ALIGNMENT IS BY INDEX AND THE REASONS ARE THE COUNT AUTHORITY. `orderClausesForLede` pairs the two
129
+ * arrays and reorders them together; a BLOCKING verdict then appends its grounds to the reasons alone,
130
+ * so `clauses` is legitimately SHORTER. Mapping over reasons and reaching for `clauses[i]` is what
131
+ * makes that safe — never `clauses.map`, which would silently drop the appended grounds.
132
+ *
133
+ * BREAK MATRIX:
134
+ * · a clause replaces its token-bearing reason → break: return reasons unchanged, arm 1 red
135
+ * · a clean reason with no clause survives → break: return "" for a missing clause, arm 2 red
136
+ * · a legacy sidecar still yields its conditions → break: require the clauses key, arm 3 red
137
+ * · clauses shorter than reasons loses nothing → break: map over clauses, arm 4 red
138
+ *
139
+ * @param {{reasons?: string[], clauses?: string[]}} sidecar the parsed `_driver/verdict.json`
140
+ * @returns {string[]} one condition per reason, in the sidecar's own order
141
+ */
142
+ export function clientConditions({ reasons, clauses } = {}) {
143
+ const rs = Array.isArray(reasons) ? reasons : [];
144
+ const cs = Array.isArray(clauses) ? clauses : [];
145
+ return rs.map((r, i) => {
146
+ const clause = typeof cs[i] === "string" ? cs[i].trim() : "";
147
+ return clause || String(r ?? "").trim();
148
+ }).filter(Boolean);
149
+ }
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.2
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.1
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.0
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2-beta.0",
3
+ "version": "0.3.2-beta.2",
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.3.2-beta.0",
4
+ "version": "0.3.2-beta.2",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",