clearotron 0.3.2-beta.4 → 0.3.2-beta.6

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.
@@ -313,8 +313,13 @@
313
313
  rows (.rrow), secondary groups (.rgroup), the scope section title (details.scope) — print as static
314
314
  headers; the old blanket details>summary hide deleted every region/group header and the scope title
315
315
  from the exported PDF.
316
- EVERY <details> THE RENDERERS EMIT MUST BE IN ONE LIST OR THE OTHER, and render-css.test.mjs walks
317
- a rendered page to say so. `details.sub-more` (#470) was in NEITHER, so the exported PDF — the
316
+ EVERY <details> THE RENDERERS EMIT MUST BE IN ONE LIST OR THE OTHER. The instrument that says so
317
+ is a browser under print media, and it is NOT in this repository: `render-css.test.mjs`, named
318
+ here until 2026-09-16, exists in no tree. Nothing in the public suite can see this — the frozen
319
+ hash pins the renderer's bytes and the render arms grep its HTML, and on screen a mis-ruled
320
+ disclosure is correct. It is the private print control that walks a rendered page on both lanes
321
+ and refuses a disclosure ruled by neither mechanism, and it runs by hand. Two folds have now
322
+ shipped mis-ruled and been caught there rather than in CI. `details.sub-more` (#470) was in NEITHER, so the exported PDF — the
318
323
  client-facing legal document — printed an inert "MORE" label and a right-pointing COLLAPSED marker
319
324
  in the hero, directly above text that was already fully expanded. Measured in Chrome: the caption's
320
325
  own content was carried correctly and only the control leaked, which is why the commit that added
@@ -327,7 +332,14 @@
327
332
  and internal notes (.int-note, hidden above) are the only two things print ever drops. */
328
333
  details>*:not(summary){display:block!important}
329
334
  details>summary{list-style:none;cursor:default}
330
- .rrow>summary::before,.rgroup>summary::before,details.scope>summary::before{content:""!important}
335
+ /* `details.searched` is a SECTION TITLE, so it keeps its summary in print like the region rows and
336
+ the secondary groups — a printed report that drops "What was searched" loses the heading over the
337
+ counts, not just a control. What it must not print is the ▸/▾ toggle, which on paper is a
338
+ right-pointing arrow over content that is already fully expanded. Found by the print check, which
339
+ walks a rendered page and refuses any disclosure that is in neither list; the public suite does
340
+ not run it, so the commit that added this fold could ship the half it did not think about. */
341
+ .rrow>summary::before,.rgroup>summary::before,details.scope>summary::before,
342
+ details.searched>summary::before{content:""!important}
331
343
  details{border-top:1px solid var(--line)}
332
344
  /* …AND THE HERO'S FOLD IS NOT A SECTION. The rule above separates the disclosures that ARE sections
333
345
  of the document; `details.sub-more` is one paragraph of the verdict caption folded away from
@@ -404,3 +416,91 @@
404
416
  .panel.actions.scope-ran{margin-top:22px}
405
417
  .panel.actions.scope-ran h4{margin:0 0 6px;font-size:14px;letter-spacing:-.01em;
406
418
  display:flex;align-items:center;gap:9px}
419
+
420
+ /* ── The 2026-09-16 report redesign — the approved report presentation ─────────────────────────────────────────
421
+ Ported from the design's own stylesheet, with its literal colours mapped onto this sheet's tokens
422
+ so the page follows the theme rather than carrying a second palette. Layout only: every string on
423
+ the page is the delivered file's own, a label from the specification, or a count. */
424
+ .panel.about{margin:10px 0 18px;padding:12px 18px;max-width:900px}
425
+ .panel.about .label{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
426
+ .panel.about .row{display:grid;grid-template-columns:170px 1fr;gap:12px;padding:5px 0;border-top:1px solid var(--line);font-size:13.5px;line-height:1.5}
427
+ /* The goods as registered and the record's own dates, inside a finding's Full detail fold on a full
428
+ country search (the 2026-09-16 report redesign). Ported from the design with its fixed greys replaced by tokens.
429
+ `word-break` is load-bearing and not cosmetic: the goods are the register's own wording, which on a
430
+ Japanese or Korean record is one unbroken run of characters with no spaces to wrap at, and without
431
+ it that row sets the width of the whole fold. */
432
+ .record{margin:6px 0 2px}
433
+ .record .row{display:grid;grid-template-columns:150px 1fr;gap:12px;padding:4px 0;border-top:1px solid var(--line);font-size:13px;line-height:1.5}
434
+ .record .k{font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);padding-top:3px}
435
+ .record .v{overflow-wrap:anywhere;word-break:break-word}
436
+ @media(max-width:700px){.record .row{grid-template-columns:1fr;gap:2px}}
437
+ .panel.about .row:first-of-type{border-top:0}
438
+ .panel.about .k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
439
+ .panel.about .v{color:var(--ink)}
440
+ header.hero p.sub .fmark{font-weight:800;color:var(--ink)}
441
+
442
+ .heroGrid{display:block}.panel.gauge{max-width:none}
443
+ .gconc .grow{display:grid;grid-template-columns:150px 1fr;gap:14px;align-items:start}
444
+ .gconc .gv{text-align:left;display:block}
445
+ .gconds-wrap{display:grid;grid-template-columns:150px 1fr;gap:14px;align-items:start;margin-top:10px}
446
+ .gconds-wrap .gk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
447
+ .gconds{margin:0;padding-left:18px;font-size:13px;line-height:1.45;font-weight:500}
448
+ .gconds li{margin:3px 0}
449
+ .gwhy{border-top:1px solid var(--line);margin:12px 0 0;padding:12px 0 0}
450
+ .gwhy .label{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:8px}
451
+ .gwhy .fa-row{display:grid;grid-template-columns:150px 1fr;gap:14px;align-items:start;padding:7px 0;font-size:13.5px;line-height:1.5}
452
+ .gwhy .fa-k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
453
+ .fa-token{padding:2px 8px;border-radius:999px;color:#fff;font-weight:700}
454
+ .fa-token.tone-good{background:var(--clear)}.fa-token.tone-mid{background:var(--med)}.fa-token.tone-bad{background:var(--high)}
455
+ .fa-based{margin:4px 0 2px;font-size:12.5px;line-height:1.45;color:var(--slate)}
456
+ .fa-bk{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-right:6px}
457
+ .gexp,.gconf{display:grid;grid-template-columns:150px 1fr;gap:14px;border-top:1px solid var(--line);margin-top:12px;padding-top:10px;font-size:13px;line-height:1.5}
458
+ .gexp .gk,.gconf .gk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
459
+ .gexp .gv,.gconf .gv{text-align:left}
460
+ .gframe{margin-top:10px;font-size:11.5px;color:var(--faint)}
461
+
462
+ .panel.where{padding:10px 18px}
463
+ .wrow{display:grid;grid-template-columns:44px 1fr auto;gap:14px;align-items:center;padding:8px 0;border-bottom:1px solid var(--line);font-size:13.5px}
464
+ .wrow .wname{font-weight:600}.wrow .rcode{font-weight:700;color:var(--faint);font-size:12px}
465
+ .wclean{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:10px 0 4px}
466
+ .wk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--clear);margin-right:8px}
467
+ .wchip{padding:3px 9px;border-radius:999px;background:var(--clear-soft);color:var(--clear-tx);font:600 12px/1.4 var(--font)}
468
+ .panel.courtp{padding:14px 18px}.panel.courtp p{margin:0;font-size:14px;line-height:1.5}
469
+
470
+ .ask-fi{margin-left:auto;font:600 12px/1 var(--font);padding:7px 10px;border-radius:999px;border:1px solid var(--line2);background:transparent;color:var(--ink);cursor:pointer;white-space:nowrap}
471
+ .tb-ask{margin-right:8px}
472
+ .cardhead .who.mark-first{font-size:17px;font-weight:800}
473
+ .cardhead .who .where{font-weight:500;color:var(--slate);font-size:14px}
474
+ .owner-line{padding:0 16px 6px 16px;margin-top:-4px;font-size:13px;color:var(--slate)}
475
+ .owner-line .ok{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-right:6px}
476
+
477
+ .panel.alsocons{padding:12px 18px}
478
+ .card.ruled .rail{background:var(--tan)}.tier.ruled{background:var(--bg2);color:var(--slate)}
479
+ .ruled-cards .card{margin-bottom:10px}
480
+ .card.ruled .roff{margin:6px 16px 12px;font-size:13px}
481
+ .card.ruled .roff summary{cursor:pointer;color:var(--slate);font-size:12px;letter-spacing:.06em;text-transform:uppercase}
482
+ .card.ruled .roff p{margin:6px 0;line-height:1.5}
483
+ .cgroups{margin:8px 0 0}
484
+ .cgroup{margin:6px 0;background:var(--card);border-radius:12px;box-shadow:0 1px 2px rgba(37,9,2,.06)}
485
+ .cgroup>summary{display:flex;align-items:center;gap:10px;padding:12px 18px;cursor:pointer;font:600 14px/1.3 var(--font);list-style:none}
486
+ .cgroup>summary::before{content:'▸';color:var(--faint);font-size:12px}
487
+ .cgroup[open]>summary::before{content:'▾'}
488
+ .cgroup .gcount{margin-left:auto;color:var(--slate);font-weight:500;font-size:12.5px}
489
+ .cgroup .gbody{padding:0 18px 12px}
490
+ .crow{display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:start;padding:8px 0;border-top:1px solid var(--line);font-size:13px}
491
+ .crow .cm-mark{font-weight:700}.crow .cwho{color:var(--slate);line-height:1.45}
492
+ .crow .curl{color:var(--crimson);text-decoration:none;font-size:12.5px;word-break:break-all}
493
+ .cmore{padding:10px 0 2px;font-size:12.5px}.wb{color:var(--crimson);text-decoration:none;font-weight:600}
494
+
495
+ details.searched{margin:14px 0;background:var(--card);border-radius:12px;box-shadow:var(--shadow)}
496
+ details.searched>summary{display:flex;align-items:center;gap:10px;padding:12px 18px;cursor:pointer;font:600 14px/1.3 var(--font);list-style:none}
497
+ details.searched>summary::before{content:'▸';color:var(--faint);font-size:12px}
498
+ details.searched[open]>summary::before{content:'▾'}
499
+ details.searched .gcount{margin-left:auto;color:var(--slate);font-weight:500;font-size:12.5px}
500
+ details.searched .gbody{padding:0 18px 12px}
501
+ details.searched .row{display:grid;grid-template-columns:200px 1fr;gap:12px;padding:7px 0;border-top:1px solid var(--line);font-size:13.5px}
502
+ details.searched .k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
503
+ .openrows,.provwrap{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
504
+ .openrows .rk,.provwrap .rk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
505
+ .provnote{margin:0;font-size:12.5px;line-height:1.5;color:var(--slate)}
506
+ .topbar .tb-lockup{margin-right:10px;flex:0 0 auto}
@@ -478,6 +478,34 @@ export const REC = {
478
478
  // designated countries; never imply "international = global". Corsearch bodies carry the
479
479
  // per-designated-country statuses as `onomaticsJurisdictionsStatuses` (array of strings or of
480
480
  // {jurisdiction,status} objects — fail-open on either shape; null when the record lacks it).
481
+ // The 2026-09-16 report redesign — the goods the mark is REGISTERED for, in the register's own words. A finding
482
+ // argues about goods, and until now the only goods on the page were the class numbers; the record
483
+ // carried the wording all along. NOTHING IS TRANSLATED HERE. Where a record holds an English entry
484
+ // the display picks it (the register wrote it); where it holds only the local language, the local
485
+ // language is what "as registered" means, and a translation composed at render time would be this
486
+ // renderer asserting the scope of somebody's right in words no register ever granted.
487
+ goods: (r) => {
488
+ const gs = Array.isArray(r.goodsServices) ? r.goodsServices : [];
489
+ const rows = gs.map((g) => ({
490
+ classes: (Array.isArray(g?.classes) ? g.classes : []).map(Number).filter((x) => !Number.isNaN(x)),
491
+ language: String(g?.language ?? "").trim().toLowerCase(),
492
+ description: String(g?.description ?? "").trim(),
493
+ })).filter((g) => g.description);
494
+ if (!rows.length) return null;
495
+ const en = rows.filter((g) => g.language === "en" || g.language === "eng");
496
+ return (en.length ? en : rows).map((g) => ({ classes: g.classes, description: g.description }));
497
+ },
498
+ // The three dates a reader acts on, as the record holds them and never re-derived. `filingYear` and
499
+ // `regYear` above answer a different question — the one-line summary — and a year cannot say whether
500
+ // a registration lapses this month.
501
+ dates: (r) => {
502
+ const out = [];
503
+ const add = (label, v) => { const s = String(v ?? "").trim(); if (/^\d{4}-\d{2}-\d{2}/.test(s)) out.push([label, s.slice(0, 10)]); };
504
+ add("filed", r.applicationDate);
505
+ add("registered", r.registrationDate);
506
+ add("expires", r.expiryDate ?? r.renewalDate);
507
+ return out.length ? out : null;
508
+ },
481
509
  designations: (r) => {
482
510
  const j = r.onomaticsJurisdictionsStatuses ?? r.jurisdictions ?? null;
483
511
  if (!Array.isArray(j) || !j.length) return null;
package/driver/stages.mjs CHANGED
@@ -1920,7 +1920,7 @@ export const STAGES = {
1920
1920
  },
1921
1921
  "Findings section prose (both branches — on seat m, every loaded reading as its own finding with its receipt)": {
1922
1922
  class: "judgment", tokens: ["missing", "too_short", "declared_unavailable"],
1923
- why: "#850 rules the prose J. The findings-heading arm differs per seat: verify.mjs:447 for a/b, verify.mjs:252 for m (which also accepts meaning/connotation). [citation unverified]",
1923
+ why: "#850 rules the prose J. The findings-heading arm differs per seat: `commonLawStructural()` in verify.mjs for a/b, `commonLawMeaningSeat()` for m (which also accepts meaning/connotation)",
1924
1924
  },
1925
1925
  "Negative-results matrix — one receipt-carrying row per (variant x platform) cell (seats a/b only)": {
1926
1926
  class: "judgment", tokens: ["missing"],
@@ -2071,7 +2071,7 @@ export const STAGES = {
2071
2071
  contractElements: {
2072
2072
  "execute the frozen plan — ONE register_execute_plan call with {plan_path, axis, output_path}": {
2073
2073
  class: "mechanical:tool-written", tokens: ["named_band_missing", "tool_timeout"],
2074
- why: "All three args are driver values interpolated into the message; the tool writes every band block. #850 calls this already right. Note #793: `named_band_missing` and `tool_timeout` are one evidence state with two causes, and registerPlanCallKilled (verify.mjs:1265) separates them from the call log, not from the model. [citation unverified]",
2074
+ why: "All three args are driver values interpolated into the message; the tool writes every band block. #850 calls this already right. Note #793: `named_band_missing` and `tool_timeout` are one evidence state with two causes, and registerPlanCallKilled (verify.mjs:1291) separates them from the call log, not from the model. [citation unverified]",
2075
2075
  },
2076
2076
  "the dictated entry list — qid, predicate, terms, owner, nice_classes, regions, when-guard, expected_kind, covered_by": {
2077
2077
  class: "mechanical:pre-bound", tokens: [],
@@ -2123,7 +2123,7 @@ export const STAGES = {
2123
2123
  },
2124
2124
  "layer-execution declaration — whether the prose says the register layer / provider tools were not executed or not bound": {
2125
2125
  class: "mechanical:code-extracted", tokens: ["declared_not_executed"],
2126
- why: "_driver/plan-execution.json and the tool-call log already hold whether the call ran — registerPlanCallKilled (verify.mjs:1265) reads exactly that to settle the same question one arm below. This arm still decides it from the model's sentence. [citation unverified]",
2126
+ why: "_driver/plan-execution.json and the tool-call log already hold whether the call ran — registerPlanCallKilled (verify.mjs:1291) reads exactly that to settle the same question one arm below. This arm still decides it from the model's sentence. [citation unverified]",
2127
2127
  },
2128
2128
  "`CROSS-CHECK REQUIRED: <what> — <why>` — the check that is needed and why": {
2129
2129
  class: "judgment", tokens: [],
@@ -573,6 +573,12 @@
573
573
  "skips": 0,
574
574
  "todos": 0
575
575
  },
576
+ "a-query-is-not-crossed-with-its-own-variant.test.mjs": {
577
+ "tests": 4,
578
+ "asserts": 6,
579
+ "skips": 0,
580
+ "todos": 0
581
+ },
576
582
  "a-queued-job-is-visible-before-a-worker-claims-it.test.mjs": {
577
583
  "tests": 6,
578
584
  "asserts": 16,
@@ -669,6 +675,12 @@
669
675
  "skips": 0,
670
676
  "todos": 0
671
677
  },
678
+ "a-refused-query-says-whether-the-provider-declined-it.test.mjs": {
679
+ "tests": 3,
680
+ "asserts": 11,
681
+ "skips": 0,
682
+ "todos": 0
683
+ },
672
684
  "a-refused-unit-says-what-is-half-started.test.mjs": {
673
685
  "tests": 9,
674
686
  "asserts": 31,
@@ -1246,8 +1258,8 @@
1246
1258
  "todos": 0
1247
1259
  },
1248
1260
  "browser-check-membership.test.mjs": {
1249
- "tests": 8,
1250
- "asserts": 17,
1261
+ "tests": 11,
1262
+ "asserts": 26,
1251
1263
  "skips": 8,
1252
1264
  "todos": 0
1253
1265
  },
@@ -1912,8 +1924,8 @@
1912
1924
  "todos": 0
1913
1925
  },
1914
1926
  "doctor-reads-the-units-not-the-shell.test.mjs": {
1915
- "tests": 37,
1916
- "asserts": 111,
1927
+ "tests": 40,
1928
+ "asserts": 115,
1917
1929
  "skips": 1,
1918
1930
  "todos": 0
1919
1931
  },
@@ -2669,7 +2681,7 @@
2669
2681
  },
2670
2682
  "knockout-structured-read.test.mjs": {
2671
2683
  "tests": 11,
2672
- "asserts": 53,
2684
+ "asserts": 55,
2673
2685
  "skips": 0,
2674
2686
  "todos": 0
2675
2687
  },
@@ -3059,7 +3071,7 @@
3059
3071
  },
3060
3072
  "pipeline.mock.test.mjs": {
3061
3073
  "tests": 94,
3062
- "asserts": 792,
3074
+ "asserts": 795,
3063
3075
  "skips": 0,
3064
3076
  "todos": 0
3065
3077
  },
@@ -3718,8 +3730,8 @@
3718
3730
  "todos": 0
3719
3731
  },
3720
3732
  "render.test.mjs": {
3721
- "tests": 154,
3722
- "asserts": 632,
3733
+ "tests": 144,
3734
+ "asserts": 572,
3723
3735
  "skips": 0,
3724
3736
  "todos": 0
3725
3737
  },
@@ -3802,8 +3814,8 @@
3802
3814
  "todos": 0
3803
3815
  },
3804
3816
  "report-record-link-host.test.mjs": {
3805
- "tests": 3,
3806
- "asserts": 27,
3817
+ "tests": 4,
3818
+ "asserts": 28,
3807
3819
  "skips": 0,
3808
3820
  "todos": 0
3809
3821
  },
@@ -4822,8 +4834,8 @@
4822
4834
  "todos": 0
4823
4835
  },
4824
4836
  "the-knockout-page-leads-with-the-read.test.mjs": {
4825
- "tests": 28,
4826
- "asserts": 69,
4837
+ "tests": 30,
4838
+ "asserts": 81,
4827
4839
  "skips": 0,
4828
4840
  "todos": 0
4829
4841
  },
@@ -5033,7 +5045,7 @@
5033
5045
  },
5034
5046
  "the-report-carries-what-the-assessment-wrote.test.mjs": {
5035
5047
  "tests": 10,
5036
- "asserts": 41,
5048
+ "asserts": 39,
5037
5049
  "skips": 0,
5038
5050
  "todos": 0
5039
5051
  },
@@ -5189,13 +5201,13 @@
5189
5201
  },
5190
5202
  "the-writing-standard-backlog-is-a-floor.test.mjs": {
5191
5203
  "tests": 3,
5192
- "asserts": 8,
5204
+ "asserts": 9,
5193
5205
  "skips": 2,
5194
5206
  "todos": 0
5195
5207
  },
5196
5208
  "the-writing-standard-check-refuses-five-classes.test.mjs": {
5197
5209
  "tests": 15,
5198
- "asserts": 60,
5210
+ "asserts": 61,
5199
5211
  "skips": 0,
5200
5212
  "todos": 0
5201
5213
  },
@@ -5721,8 +5733,8 @@
5721
5733
  "todos": 0
5722
5734
  },
5723
5735
  "http-handler.test.mjs": {
5724
- "tests": 14,
5725
- "asserts": 26,
5736
+ "tests": 17,
5737
+ "asserts": 40,
5726
5738
  "skips": 0,
5727
5739
  "todos": 0
5728
5740
  },
@@ -5859,8 +5871,8 @@
5859
5871
  "todos": 0
5860
5872
  },
5861
5873
  "the-portal-can-tell-who-has-connected-an-assistant.test.mjs": {
5862
- "tests": 10,
5863
- "asserts": 28,
5874
+ "tests": 12,
5875
+ "asserts": 33,
5864
5876
  "skips": 0,
5865
5877
  "todos": 0
5866
5878
  },
package/driver/verify.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  import { readFileSync, existsSync, readdirSync } from "node:fs";
11
11
  import { dirname, join, basename } from "node:path";
12
12
  import { driverDir } from "../shared/driver-dir.mjs"; //
13
- import { findReceiptViolations, findGridLedgerViolations, findPlatformIdentityViolations, parsePrRiskQueries, MEANING_SEAT } from "./common-law-receipts.mjs";
13
+ import { findReceiptViolations, findGridLedgerViolations, findPlatformIdentityViolations, parsePrRiskQueries, MEANING_SEAT, erroredConnotationQueriesAmong } from "./common-law-receipts.mjs";
14
14
  // Conversion 2 — the discriminator the two rulings above key on. PURE-ish: one existsSync-shaped read.
15
15
  import { matterFrameWasRecorded, frameRatifiedForms } from "./matter-frame-record.mjs";
16
16
  import { findConnotationViolations, parsePrRiskResults, MEANING_ANGLES_RE,
@@ -413,7 +413,33 @@ function commonLawMeaningSeat(p, c) {
413
413
  // The gate cannot tell the two apart and is not being asked to. There is no identity to join on —
414
414
  // that is the entire reason the dictated-versus-recorded comparison exists. So the label states the
415
415
  // observation, the remedy carries both cases, and no threshold decides which one a seat is told.
416
+ // ── A QUERY THE PROVIDER REFUSED IS NOT A QUERY NOBODY RAN ────────────────────────────────────
417
+ //
418
+ // The plugin's contract is to append `<query> | connotation | <exception>` to the ledger's gaps and
419
+ // carry on, so a query it threw on says so IN THE FILE THIS GATE JUST READ. The gate did not look:
420
+ // it refused on receipt membership alone, and a query the provider had already refused got the same
421
+ // sentence as one nobody ever issued — the two repairs, "edit the wording" and "run it", neither of
422
+ // which is the remedy when the provider itself declined.
423
+ //
424
+ // It still FAILS, and deliberately: `findErroredConnotationQueries` says so in its own words —
425
+ // laundering an honest error into a clean receipt would be a worse defect than this one. What
426
+ // changes is only what the seat is told, and therefore how many attempts it spends being told the
427
+ // wrong thing. On the clearance that prompted this, the half failed eight attempts across two
428
+ // recovery cycles.
429
+ //
430
+ // The missing list is THIS gate's own, joined per half on `queryKey`; the helper joins gap rows on
431
+ // `norm`, which is the one author of what a gap row names. Two populations, one key.
432
+ let ledgerParsed = null;
433
+ try { ledgerParsed = JSON.parse(ledgerRaw); } catch { /* unparseable was refused above */ }
434
+ const gapRows = (Array.isArray(ledgerParsed) ? ledgerParsed : [ledgerParsed])
435
+ .flatMap((b) => (Array.isArray(b?.gaps) ? b.gaps : []));
436
+ const reportedError = new Map(
437
+ erroredConnotationQueriesAmong(dropped, { gaps: gapRows }).map((e) => [e.query, e.error]));
416
438
  const parts = dropped.slice(0, 3).map((q) => {
439
+ const reported = reportedError.get(q);
440
+ // Named separately because the remedy is different: the search was attempted and the provider
441
+ // declined it, so re-running it unchanged is the one repair that cannot work.
442
+ if (reported) return `${abbrev(q, 40)} [the provider REPORTED an error on this query: ${abbrev(reported, 60)}]`;
417
443
  const n = nearest(q);
418
444
  return n
419
445
  ? `${abbrev(q, 40)} [unmatched; nearest recorded: ${abbrev(n, 40)}]`
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.6
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.5
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.4
4
12
 
5
13
  No changes in this release.
@@ -40,14 +40,26 @@ export function summarize(body) {
40
40
  return out;
41
41
  }
42
42
 
43
- export function appendAudit({ email, sub, body, status, transport, path = DEFAULT_AUDIT_PATH }) {
43
+ export function appendAudit({ email, sub, body, status, transport, door, path = DEFAULT_AUDIT_PATH }) {
44
44
  // `sub` = the inner-token PRINCIPAL (ops-token issuance, INSTALL.md §8) — distinguishes
45
45
  // two automations sharing a transport identity. null for internal/user sessions without a sub claim.
46
46
  //
47
47
  // `transport` is written only where it changes an answer: "stdio" marks a call that arrived over the
48
48
  // local route, which has no signed-in identity to record and therefore no email. Omitted on the HTTP
49
49
  // doors, whose records are identified by the email they already carry.
50
- const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}) }) + "\n";
50
+ //
51
+ // `door` NAMES WHICH SURFACE TOOK THE CALL, and it is a separate field from `transport` on purpose.
52
+ // `transport` answers "how did this arrive" and the reader tests it for exactly one value, `"stdio"`,
53
+ // to mean the local route. Two HTTP doors write to this one file — the staff surface and the client
54
+ // surface a customer's own assistant connects through — and "which door" is a different question from
55
+ // "which route". Folding the second into the first would make a client-key call read as a new kind of
56
+ // transport, and any reader that tested `transport` for the local route would keep working while
57
+ // meaning something it was never asked.
58
+ //
59
+ // WRITTEN ONLY WHEN GIVEN, the same rule as `transport` and for the same reason: the existing log
60
+ // shape must not move for records that have no answer to this. A door that does not name itself is a
61
+ // record with no `door` key, not a record claiming to be from nowhere.
62
+ const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}), ...(door ? { door } : {}) }) + "\n";
51
63
  try { mkdirSync(dirname(path), { recursive: true }); appendFileSync(path, line); } catch { /* best-effort */ }
52
64
  }
53
65
 
@@ -83,6 +83,26 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
83
83
  if (tokenOnly && devMode) throw new Error("makeHttpHandler: tokenOnly and devMode are mutually exclusive (devMode's synthetic identity would defeat the mandatory key)");
84
84
  if (tokenOnly && verify) throw new Error("makeHttpHandler: tokenOnly is for a door with no auth proxy in front — pass verify:null");
85
85
 
86
+ // ── A TURNED-AWAY CALL IS A RECORD, NOT A GAP ─────────────────────────────────────────────────────
87
+ //
88
+ // Every refusal below used to return without writing anything, so this log held calls that SUCCEEDED
89
+ // and nothing else. An absent line then meant either "never asked" or "asked and was turned away",
90
+ // and nobody reading it back can tell those apart — which is the one question the log exists to
91
+ // answer. Measured on the production access log, 2026-09-16: zero lines in its most recent five
92
+ // thousand carry a refused or error status, which is what never being able to write one looks like.
93
+ //
94
+ // IT INVENTS NO IDENTITY. The key refusals fire BEFORE any identity is established. `user` is in
95
+ // scope there, but only as the placeholder this function starts with, and writing that would name a
96
+ // caller who does not exist. Those lines carry no email and no principal, and say a call was refused
97
+ // at this door at this time — which is true, and is the shape the local route already uses for the
98
+ // same reason: a synthesized identity would match somebody who did nothing.
99
+ const door = clientSurface ? "client" : undefined;
100
+ const refuse = (res, status, obj, who = {}) => {
101
+ try { appendAudit({ email: who.email ?? null, sub: who.sub ?? null, body: who.body ?? null, status: "refused", door }); }
102
+ catch { /* best-effort: a write failure must never change what the caller is told */ }
103
+ return send(res, status, obj);
104
+ };
105
+
86
106
  /**
87
107
  * Has this identity been taken off the guest list since its session was opened? Null when it has not,
88
108
  * or when the question does not apply — and the body to send back when it has.
@@ -182,7 +202,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
182
202
  // reach it — the protection is the filesystem — so it is disclosure rather than exposure,
183
203
  // and it buys nothing: an operator needs to know a local socket is where to look, and
184
204
  // already has the path in the boot line and the unit file. A stranger gets nothing usable.
185
- return send(res, 401, { error: "this listener takes an auth-proxy JWT and never an access key — a key has no door here"
205
+ return refuse(res, 401, { error: "this listener takes an auth-proxy JWT and never an access key — a key has no door here"
186
206
  + (keyDoorPath ? ". A key is taken on this deployment's local socket; the engine's boot line names it" : ". This deployment has no key door configured") });
187
207
  }
188
208
  log(`auth reject ${status}: ${e.message}`);
@@ -197,7 +217,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
197
217
  const tok = readInnerToken(url, req.headers, { allowAuthorization: true });
198
218
  if (!tok) {
199
219
  log("auth reject 401: no key presented on the token-only door");
200
- return send(res, 401, { error: "this address needs an access key — put it in your assistant's API-key field, or add ?token=<key> to the URL" });
220
+ return refuse(res, 401, { error: "this address needs an access key — put it in your assistant's API-key field, or add ?token=<key> to the URL" });
201
221
  }
202
222
  let t;
203
223
  try { t = verifyToken(tok); }
@@ -209,26 +229,26 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
209
229
  // the original defect survived, silently, on every default install.
210
230
  if (e.code === "REVOCATION_UNCHECKABLE") {
211
231
  log(`DOOR FAULT — refusing every key: ${e.message} Create it, or point TRADEMARK_MCP_TOKEN_DENYLIST at the list this install actually uses; \`clearotron doctor\` reports the state.`);
212
- return send(res, 401, { error: `this install cannot check whether keys have been revoked, so it is refusing all of them: ${e.message}` });
232
+ return refuse(res, 401, { error: `this install cannot check whether keys have been revoked, so it is refusing all of them: ${e.message}` });
213
233
  }
214
234
  log(`auth reject 401: ${e.message}`);
215
- return send(res, 401, { error: `invalid access key: ${e.message}` });
235
+ return refuse(res, 401, { error: `invalid access key: ${e.message}` });
216
236
  }
217
237
  user = { email: t.sub || t.runId || t.jti || "unnamed-key" };
218
238
  }
219
- if (!limiter.take(user.email)) return send(res, 429, { error: "rate limit exceeded — retry shortly" });
239
+ if (!limiter.take(user.email)) return refuse(res, 429, { error: "rate limit exceeded — retry shortly" }, { email: user.email });
220
240
 
221
241
  if (req.method === "POST") {
222
242
  let body;
223
243
  try { body = await readJsonBody(req, maxBody); }
224
- catch (e) { return send(res, 400, { error: `bad request body: ${e.message}` }); }
244
+ catch (e) { return refuse(res, 400, { error: `bad request body: ${e.message}` }, { email: user.email }); }
225
245
 
226
246
  const sid = hdr(req.headers["mcp-session-id"]);
227
247
  let entry = sid ? sessions.get(sid) : null;
228
248
  let stampScope = null;
229
249
  if (!entry) {
230
- if (sid) return send(res, 404, { error: "unknown or expired session" });
231
- if (!isInitializeRequest(body)) return send(res, 400, { error: "no session — the first request must be an MCP initialize" });
250
+ if (sid) return refuse(res, 404, { error: "unknown or expired session" }, { email: user.email, body });
251
+ if (!isInitializeRequest(body)) return refuse(res, 400, { error: "no session — the first request must be an MCP initialize" }, { email: user.email, body });
232
252
  if (sessions.size >= sessionMax) evictOldest(sessions);
233
253
  // INNER authz token (rides the /mcp?token= query or the X-Trademark-Token header) → the session's
234
254
  // scope: ops (full), run-bound user (read-only one run), or — no token but firm staff — internal
@@ -274,20 +294,39 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
274
294
  } else {
275
295
  if (entry.email && entry.email !== user.email) {
276
296
  log(`session owner mismatch: ${user.email} presented a session created by another identity`);
277
- return send(res, 403, { error: "session belongs to another identity" });
297
+ return refuse(res, 403, { error: "session belongs to another identity" }, { email: user.email, sub: entry.sub ?? null, body });
278
298
  }
279
299
  const gone = revokedMidSession(entry);
280
- if (gone) return send(res, 403, gone);
300
+ if (gone) return refuse(res, 403, gone, { email: user.email, sub: entry.sub ?? null, body });
281
301
  entry.lastSeen = Date.now();
282
302
  }
283
303
  // OPS-TOKENS item 6 — automation principals get their own (lower) bucket, keyed by the token's
284
304
  // sub, ON TOP of the transport-identity limit above: a runaway connector throttles itself, not
285
305
  // the interactive staff sharing the proxy identity.
286
306
  if (opsLimiter && entry.kind === "ops" && !opsLimiter.take(`ops:${entry.sub ?? "unnamed"}`))
287
- return send(res, 429, { error: "ops principal rate limit exceeded — retry shortly" });
307
+ return refuse(res, 429, { error: "ops principal rate limit exceeded — retry shortly" }, { email: user.email, sub: entry.sub ?? null, body });
288
308
  // Audit AFTER scope resolution so the line names the PRINCIPAL (token sub), not just the
289
- // transport identity — still strictly before any tool dispatch. Best-effort, never blocks.
290
- try { appendAudit({ email: user.email, sub: entry.sub ?? null, body }); } catch { /* best-effort */ }
309
+ // transport identity. Best-effort, never blocks.
310
+ //
311
+ // ── ONE LINE, WRITTEN WHEN THE OUTCOME IS KNOWN ────────────────────────────────────────────
312
+ //
313
+ // This used to be written here, before dispatch, and therefore with `status: null` — a record
314
+ // that a call was made and no record of what happened to it. On a deployed log every one of the
315
+ // five thousand most recent lines carries a null status, so "did this key read that report" was
316
+ // answerable only as "it asked".
317
+ //
318
+ // THE TRADE MADE, SAID PLAINLY: writing before dispatch survives a crash mid-call, and writing
319
+ // on `finish` does not. One line carrying the outcome is what a reader needs and what this was
320
+ // asked for, and a crash that loses the line also loses the response, so the caller is not left
321
+ // believing a lost call succeeded. The refusals above are recorded at their own sites and do not
322
+ // depend on this hook at all, which is the case a review actually asks about.
323
+ //
324
+ // `finish` fires once the response is fully sent, which is when `res.statusCode` is the answer
325
+ // the caller got rather than the default it started as.
326
+ res.once("finish", () => {
327
+ try { appendAudit({ email: user.email, sub: entry.sub ?? null, body, status: res.statusCode, door }); }
328
+ catch { /* best-effort */ }
329
+ });
291
330
  const answered = entry.transport.handleRequest(req, res, body);
292
331
  if (stampScope) { try { await answered; } finally { stampScope(); } }
293
332
  return answered;
@@ -13,7 +13,7 @@
13
13
  // because a lawyer defending a filing decision needs the records, and it is bounded by a different
14
14
  // mechanism (a structured-field projection, never prose) in lib/evidence.mjs. Read that file's header
15
15
  // before widening anything here; the line it draws is evidence vs METHOD, and method stays sealed.
16
- // publish/render.mjs renders the Methodology section to the client (scopeSection → plainScopeNote) and
16
+ // publish/render.mjs no longer renders a Methodology section to the client (the 2026-09-16 report redesign) and
17
17
  // deliberately names the register provider (provenance honesty, receipts, enforcement telemetry). A scrubber
18
18
  // stricter than the report would delete content the client was already sent, and would quietly make the MCP
19
19
  // a different product from the PDF in their inbox.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2-beta.4",
3
+ "version": "0.3.2-beta.6",
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.4",
4
+ "version": "0.3.2-beta.6",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.4",
5
+ "version": "0.3.2-beta.6",
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": {
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.2-beta.6
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.5
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.4
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.2-beta.4",
3
+ "version": "0.3.2-beta.6",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -35,7 +35,7 @@
35
35
  // its own population needs an exception list — which would rebuild this issue's defect inside its fix:
36
36
  //
37
37
  // · `driver/stage-freshness.mjs` creates a CHILD, `join(runDir, "_driver", STAMP_DIR)`.
38
- // · `driver/pipeline.mjs:15755 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
38
+ // · `driver/pipeline.mjs:15757 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
39
39
  // directory. It is a run-dir-SHAPED base, which is why the parameter is `base` and not `runDir`.
40
40
  //
41
41
  // ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────