clearotron 0.3.2-beta.3 → 0.3.2-beta.5

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;
@@ -1912,8 +1912,8 @@
1912
1912
  "todos": 0
1913
1913
  },
1914
1914
  "doctor-reads-the-units-not-the-shell.test.mjs": {
1915
- "tests": 37,
1916
- "asserts": 111,
1915
+ "tests": 40,
1916
+ "asserts": 115,
1917
1917
  "skips": 1,
1918
1918
  "todos": 0
1919
1919
  },
@@ -2669,7 +2669,7 @@
2669
2669
  },
2670
2670
  "knockout-structured-read.test.mjs": {
2671
2671
  "tests": 11,
2672
- "asserts": 53,
2672
+ "asserts": 55,
2673
2673
  "skips": 0,
2674
2674
  "todos": 0
2675
2675
  },
@@ -2872,8 +2872,8 @@
2872
2872
  "todos": 0
2873
2873
  },
2874
2874
  "no-vendor-provenance-in-the-public-cut.test.mjs": {
2875
- "tests": 4,
2876
- "asserts": 11,
2875
+ "tests": 8,
2876
+ "asserts": 23,
2877
2877
  "skips": 0,
2878
2878
  "todos": 0
2879
2879
  },
@@ -3059,7 +3059,7 @@
3059
3059
  },
3060
3060
  "pipeline.mock.test.mjs": {
3061
3061
  "tests": 94,
3062
- "asserts": 792,
3062
+ "asserts": 795,
3063
3063
  "skips": 0,
3064
3064
  "todos": 0
3065
3065
  },
@@ -3718,8 +3718,8 @@
3718
3718
  "todos": 0
3719
3719
  },
3720
3720
  "render.test.mjs": {
3721
- "tests": 154,
3722
- "asserts": 632,
3721
+ "tests": 144,
3722
+ "asserts": 572,
3723
3723
  "skips": 0,
3724
3724
  "todos": 0
3725
3725
  },
@@ -3802,8 +3802,8 @@
3802
3802
  "todos": 0
3803
3803
  },
3804
3804
  "report-record-link-host.test.mjs": {
3805
- "tests": 3,
3806
- "asserts": 27,
3805
+ "tests": 4,
3806
+ "asserts": 28,
3807
3807
  "skips": 0,
3808
3808
  "todos": 0
3809
3809
  },
@@ -4822,8 +4822,8 @@
4822
4822
  "todos": 0
4823
4823
  },
4824
4824
  "the-knockout-page-leads-with-the-read.test.mjs": {
4825
- "tests": 28,
4826
- "asserts": 69,
4825
+ "tests": 30,
4826
+ "asserts": 81,
4827
4827
  "skips": 0,
4828
4828
  "todos": 0
4829
4829
  },
@@ -5033,7 +5033,7 @@
5033
5033
  },
5034
5034
  "the-report-carries-what-the-assessment-wrote.test.mjs": {
5035
5035
  "tests": 10,
5036
- "asserts": 41,
5036
+ "asserts": 39,
5037
5037
  "skips": 0,
5038
5038
  "todos": 0
5039
5039
  },
@@ -5189,13 +5189,13 @@
5189
5189
  },
5190
5190
  "the-writing-standard-backlog-is-a-floor.test.mjs": {
5191
5191
  "tests": 3,
5192
- "asserts": 8,
5192
+ "asserts": 9,
5193
5193
  "skips": 2,
5194
5194
  "todos": 0
5195
5195
  },
5196
5196
  "the-writing-standard-check-refuses-five-classes.test.mjs": {
5197
5197
  "tests": 15,
5198
- "asserts": 60,
5198
+ "asserts": 61,
5199
5199
  "skips": 0,
5200
5200
  "todos": 0
5201
5201
  },
@@ -5721,8 +5721,8 @@
5721
5721
  "todos": 0
5722
5722
  },
5723
5723
  "http-handler.test.mjs": {
5724
- "tests": 14,
5725
- "asserts": 26,
5724
+ "tests": 17,
5725
+ "asserts": 40,
5726
5726
  "skips": 0,
5727
5727
  "todos": 0
5728
5728
  },
@@ -5859,8 +5859,8 @@
5859
5859
  "todos": 0
5860
5860
  },
5861
5861
  "the-portal-can-tell-who-has-connected-an-assistant.test.mjs": {
5862
- "tests": 10,
5863
- "asserts": 28,
5862
+ "tests": 12,
5863
+ "asserts": 33,
5864
5864
  "skips": 0,
5865
5865
  "todos": 0
5866
5866
  },
@@ -5942,8 +5942,8 @@
5942
5942
  "todos": 0
5943
5943
  },
5944
5944
  "composerProduct.test.ts": {
5945
- "tests": 30,
5946
- "asserts": 113,
5945
+ "tests": 32,
5946
+ "asserts": 124,
5947
5947
  "skips": 0,
5948
5948
  "todos": 0
5949
5949
  },
@@ -6213,7 +6213,7 @@
6213
6213
  },
6214
6214
  "screenCopy.test.ts": {
6215
6215
  "tests": 63,
6216
- "asserts": 239,
6216
+ "asserts": 240,
6217
6217
  "skips": 0,
6218
6218
  "todos": 0
6219
6219
  },
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.5
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.4
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.3
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.3",
3
+ "version": "0.3.2-beta.5",
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.3",
4
+ "version": "0.3.2-beta.5",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -14063,7 +14063,7 @@ function missingPieces(names, classes, goods) {
14063
14063
  if (!classes.length && !goods.trim()) out.push("Say what the name is used for — pick classes, or describe the goods and services. Either one is enough.");
14064
14064
  return out;
14065
14065
  }
14066
- function blockers(d, product, names = 0) {
14066
+ function blockers(d, product, names = 0, inheritedTerritories = []) {
14067
14067
  const out = [];
14068
14068
  if (!product) {
14069
14069
  out.push("Pick a search above. The four differ in where they look and how deep they read.");
@@ -14071,7 +14071,7 @@ function blockers(d, product, names = 0) {
14071
14071
  }
14072
14072
  const budget = nameBudget(product, names);
14073
14073
  if (budget) out.push(budget.allowed === 1 ? `A ${product.name} reads one name at a time, and you have ${budget.allowed + budget.over}.` : `A ${product.name} reads ${budget.allowed} names at a time, and you have ${budget.allowed + budget.over}.`);
14074
- const named = d.territories;
14074
+ const named = product.geography !== "worldwide, and nothing else" && !d.territories.length ? inheritedTerritories : d.territories;
14075
14075
  const countries = named.filter((t) => tierOf(t) === "country");
14076
14076
  const regions = named.filter((t) => tierOf(t) === "region");
14077
14077
  switch (product.geography) {
@@ -14091,6 +14091,10 @@ function blockers(d, product, names = 0) {
14091
14091
  }
14092
14092
  function readiness(i) {
14093
14093
  const NOTHING_PICKED = "Pick one of the searches above to begin.";
14094
+ if (i.untouched) return {
14095
+ ready: false,
14096
+ blockedBy: "Add a name and pick a search."
14097
+ };
14094
14098
  const blocking = [
14095
14099
  [i.nameStops.length > 0, i.nameStops[0] ?? ""],
14096
14100
  [i.gaps.length > 0, i.gaps[0] ?? ""],
@@ -18607,20 +18611,22 @@ function NewClearance({ ctx }) {
18607
18611
  kind: searches.kind,
18608
18612
  onRetry: retrySearches
18609
18613
  });
18610
- const stops = blockers(draft.pick, activeLevel, names.length);
18614
+ const stops = blockers(draft.pick, activeLevel, names.length, own.territories);
18611
18615
  const overlong = searches.value.maxMarkName == null ? [] : names.filter((n) => n.length > (searches.value.maxMarkName ?? Infinity));
18612
18616
  const markLimit = searches.value.maxMarkName;
18613
18617
  const nameStops = overlong.map((n) => `“${n.slice(0, 30)}…” is ${n.length} characters. A mark name may be at most ${markLimit} — it becomes this run's name and part of every report link, so it cannot carry a description. Put the goods and the description in their own fields below.`);
18614
18618
  const budget = nameBudget(activeLevel, names.length);
18615
18619
  const exhausted = searchesLeft(usage) === 0;
18616
18620
  const gaps = missingPieces(names, classes, draft.goods);
18621
+ const untouched = JSON.stringify(draft) === JSON.stringify(EMPTY);
18617
18622
  const { ready, blockedBy } = readiness({
18618
18623
  gaps,
18619
18624
  stops,
18620
18625
  nameStops,
18621
18626
  budget,
18622
18627
  exhausted,
18623
- hasProduct: activeLevel != null
18628
+ hasProduct: activeLevel != null,
18629
+ untouched
18624
18630
  });
18625
18631
  const startedFrom = draft.savedSearch ? savedRow ? displayLabel(savedRow) : "a search template" : activeLevel?.name ?? "no search picked yet";
18626
18632
  const nativeOn = activeLevel?.nativeLanguage === "automatic" || nativeControl === "toggle" && (draft.savedSearch ? savedRow?.nativeLanguage === true : draft.pick.nativeLanguage);
@@ -19286,7 +19292,7 @@ function NewClearance({ ctx }) {
19286
19292
  })
19287
19293
  })]
19288
19294
  }),
19289
- gaps.length ? (0, import_jsx_runtime.jsxs)("div", {
19295
+ !untouched && gaps.length ? (0, import_jsx_runtime.jsxs)("div", {
19290
19296
  className: "notice",
19291
19297
  style: {
19292
19298
  borderColor: "var(--tone-medium)",
@@ -19304,7 +19310,7 @@ function NewClearance({ ctx }) {
19304
19310
  }, `g${i}`))
19305
19311
  })]
19306
19312
  }) : null,
19307
- stops.length || nameStops.length ? (0, import_jsx_runtime.jsxs)("div", {
19313
+ !untouched && (stops.length || nameStops.length) ? (0, import_jsx_runtime.jsxs)("div", {
19308
19314
  className: "notice",
19309
19315
  style: {
19310
19316
  borderColor: "var(--tone-medium)",
@@ -565,6 +565,26 @@ table.data.fixed td {
565
565
  overflow-wrap: anywhere;
566
566
  }
567
567
 
568
+ /* BELOW PHONE WIDTH A FIXED TABLE HAS NOTHING LEFT TO DIVIDE, so it stops dividing and scrolls instead.
569
+ Fixed layout shares the container out as percentages, which is what lets this table fit any desktop
570
+ width without a scrollbar. On a phone the shares become slivers: measured at 400px, the Risk word
571
+ printed through the Updated date, ordinary marks broke mid-word, and the count pills rendered as
572
+ circles with their text outside them. The page itself then scrolled sideways, because the row's own
573
+ controls are wider than the column they were given.
574
+ Under this breakpoint the table becomes an ordinary content-sized one — which is what `table.data`
575
+ already is, floor included, so no width is invented here — and its wrapper, which has always had
576
+ `overflow-x: auto`, does the scrolling. A table a reader drags sideways is readable; one printed
577
+ through itself is not.
578
+ THE FLOOR IS RESTORED ONLY HERE, and that is the whole point. `table.data.fixed` sets `min-width: 0`
579
+ above, because a floor on a nearly-wide-enough container is what puts a 19px scrollbar on a
580
+ client-facing page for no reason. That argument is about desktop widths and it still holds there. */
581
+ @media (max-width: 560px) {
582
+ table.data.fixed {
583
+ table-layout: auto;
584
+ min-width: 860px;
585
+ }
586
+ }
587
+
568
588
  /* An expanded read: quiet enough to read as subordinate to the row it belongs to. The indent is on the
569
589
  cells, not here, because it is structural — see ReadRow. */
570
590
  table.data tr.read-row > td {
@@ -49,8 +49,8 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-Bki5jT_N.js"></script>
53
- <link rel="stylesheet" crossorigin href="/portal/assets/index-De2RFLbT.css">
52
+ <script type="module" crossorigin src="/portal/assets/index-5UyqAyNM.js"></script>
53
+ <link rel="stylesheet" crossorigin href="/portal/assets/index-CVOIvdhc.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="root"></div>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.3",
5
+ "version": "0.3.2-beta.5",
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": {
@@ -106,8 +106,8 @@ export function romanizationRefusal(value) {
106
106
  }
107
107
 
108
108
  /** The two spellings a register may hold for one romanisation — syllable-separated as the record
109
- * writes it, and run together as a searcher would type it. Probed: identical counts on CN/TW/GR/KR/TH,
110
- * but EG differs (7 vs 10), so BOTH are emitted and OR-joined rather than picking one. */
109
+ * writes it, and run together as a searcher would type it. On most scripts a register holds the two
110
+ * spellings alike, but not on all of them — so BOTH are emitted and OR-joined rather than picking one. */
111
111
  export function romanizationSpellings(value) {
112
112
  const spaced = String(value ?? "").normalize("NFC").trim().replace(/\s+/g, " ");
113
113
  if (!spaced) return [];