clearotron 0.2.1 → 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/INSTALL.md +5 -4
  2. package/bin/example.mjs +9 -5
  3. package/bin/onboard.mjs +19 -19
  4. package/bin/stop.mjs +65 -3
  5. package/build-info.json +2 -2
  6. package/driver/CHANGELOG.md +21 -0
  7. package/driver/declination-call.mjs +32 -0
  8. package/driver/driver.config.mjs +20 -0
  9. package/driver/engine/mcp/recording-server.mjs +4 -0
  10. package/driver/gateway.mjs +8 -3
  11. package/driver/knockout-assess-record.mjs +5 -1
  12. package/driver/package.json +1 -1
  13. package/driver/pipeline.mjs +83 -2
  14. package/driver/predelivery-lint.mjs +22 -4
  15. package/driver/publish/knockout.mjs +12 -6
  16. package/driver/publish/render-knockout.mjs +133 -23
  17. package/driver/publish/report-data.mjs +13 -3
  18. package/driver/record-carry.mjs +2 -2
  19. package/driver/reference-score.mjs +1 -1
  20. package/driver/result-noun-fields.mjs +7 -0
  21. package/driver/skills/knockout-assess/SKILL.md +10 -4
  22. package/driver/stages-knockout.mjs +1 -1
  23. package/driver/stages.mjs +1 -1
  24. package/driver/suite-census.json +66 -18
  25. package/driver/unit-inventory.mjs +47 -0
  26. package/driver/unit-state-verdict.mjs +8 -8
  27. package/driver/verify-knockout.mjs +9 -1
  28. package/driver/whatif-memo-run.mjs +45 -4
  29. package/mcp-server/CHANGELOG.md +2 -0
  30. package/mcp-server/lib/brief.mjs +15 -0
  31. package/mcp-server/lib/driver.mjs +6 -0
  32. package/mcp-server/lib/knockout.mjs +435 -0
  33. package/mcp-server/lib/scrub.mjs +1 -1
  34. package/mcp-server/package.json +1 -1
  35. package/mcp-server/server.mjs +69 -4
  36. package/package.json +1 -1
  37. package/portal-ui/package.json +1 -1
  38. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  39. package/providers/oauth-mcp-bridge/package.json +1 -1
  40. package/scripts/drain-preflight.mjs +2 -2
  41. package/scripts/freeze-example-run.mjs +3 -3
  42. package/scripts/headless-page.mjs +51 -2
  43. package/scripts/live-surface-check.mjs +86 -17
  44. package/scripts/render-check.mjs +61 -2
  45. package/scripts/deploy-test.sh +0 -309
@@ -2454,6 +2454,13 @@ export function flagLines(failures) {
2454
2454
  // vendor vocabulary, no ids, no paths, no field names, no instructions to whoever regenerates the run,
2455
2455
  // never "certified"/"signed". deliveryVocabViolations() below enforces that mechanically.
2456
2456
  const DELIVERY_LINES = {
2457
+ // Keyed on the FULL check id, which deliveryFlagLines resolves before the base — see the note there.
2458
+ // The sentence states the CONSEQUENCE, in the receipt's own terms: not "a check failed" but "the rule
2459
+ // was applied to nothing". Eleven graded runs delivered carrying this, two of them orderable product
2460
+ // demos, and the only place it was written was one row of a fifty-odd-row receipt nobody routes.
2461
+ "narrative-write-ups:could-not-read":
2462
+ "The depth rules for the written-up findings were applied to nothing on this run — the narrative "
2463
+ + "carries no block this check can read, so neither the ranking cut nor the length cap was verified.",
2457
2464
  // template — the summary table against the names actually searched
2458
2465
  "names-cell-populated": "The summary table does not name the mark that was searched.",
2459
2466
  "assessment-row": "A searched name has no assessment of its own in the summary table.",
@@ -2555,15 +2562,26 @@ export function deliveryVocabViolations(line) {
2555
2562
  export function deliveryFlagLines(failures) {
2556
2563
  const groups = new Map();
2557
2564
  for (const f of failures ?? []) {
2558
- const base = String(f?.id ?? "").split(":")[0];
2565
+ const id = String(f?.id ?? "");
2566
+ const base = id.split(":")[0];
2559
2567
  const family = String(f?.family ?? "");
2560
- const key = base || `family/${family}`;
2561
- const g = groups.get(key) ?? { base, family, n: 0 };
2568
+ // A CHECK WHOSE DISTINCTION LIVES IN ITS SUFFIX COULD NOT BE SAID HERE (tracker issue 267).
2569
+ //
2570
+ // Grouping on `base` alone is right for the common case — a word-cap violation on nine write-ups is
2571
+ // one delivery line, not nine. But it also collapsed `narrative-write-ups:could-not-read` into the
2572
+ // same bucket as an ordinary depth violation, and those are different facts: one says a rule was
2573
+ // broken, the other says the rule was ENFORCED ON NOTHING. The second had no sentence of its own to
2574
+ // reach, so it degraded to the generic "a machine check did not pass" and became invisible.
2575
+ //
2576
+ // So: an entry keyed on the FULL id wins where one exists, and everything else groups by base
2577
+ // exactly as before. This adds no line to any run that did not already produce one.
2578
+ const key = DELIVERY_LINES[id] ? id : (base || `family/${family}`);
2579
+ const g = groups.get(key) ?? { base, family, id: DELIVERY_LINES[id] ? id : null, n: 0 };
2562
2580
  g.n += 1;
2563
2581
  groups.set(key, g);
2564
2582
  }
2565
2583
  return [...groups.values()].map((g) => {
2566
- const sentence = DELIVERY_LINES[g.base] ?? DELIVERY_FAMILY_LINES[g.family] ?? DELIVERY_GENERIC;
2584
+ const sentence = (g.id && DELIVERY_LINES[g.id]) ?? DELIVERY_LINES[g.base] ?? DELIVERY_FAMILY_LINES[g.family] ?? DELIVERY_GENERIC;
2567
2585
  // Belt and braces: a table entry that trips the house rules degrades to the generic instead of
2568
2586
  // shipping. This is what makes "no engine vocabulary reaches a reader" structural rather than a
2569
2587
  // matter of everyone remembering the rule when they add a check.
@@ -68,9 +68,13 @@ export const knockoutStatement = (framework, marks) =>
68
68
  // 11pt, no <!DOCTYPE>, no <head>, no <meta charset> — and they rendered a Depth 2 report as a grey
69
69
  // spreadsheet with the register hit-counts the customer paid for buried in a table cell. The report now
70
70
  // renders through publish/render-knockout.mjs in the product's own design language, off the same shared
71
- // stylesheet and brand tokens as the clearance report. Internal working material (the purple staff notes,
72
- // the model's registerEstimate) is not stripped from the report — it is not IN the report; it lives in
73
- // the audit workbook, which is what an internal artifact is for.
71
+ // stylesheet and brand tokens as the clearance report.
72
+ //
73
+ // THE REVIEWER'S NOTES ARE ON THE REPORT SINCE 2026-09-07 (owner ruling, tracker issue 274). This
74
+ // paragraph used to end "internal working material (the purple staff notes, the model's registerEstimate)
75
+ // is not IN the report; it lives in the audit workbook". That is now true of `registerEstimate` only: the
76
+ // notes render on the page, labelled, and the workbook keeps its copy. See render-knockout.mjs's header
77
+ // for the ruling and reviewerNotesBlock for the shape.
74
78
 
75
79
  // ── The workbook: the skill's three sheet names + columns, plus the conditional sheets below ────────
76
80
  // ── ONE DRILL-THROUGH KEY: `<MARK> #<ordinal>`, per mark ──────────────────────────────────────
@@ -250,9 +254,11 @@ export async function buildKnockoutWorkbook(findings, receipts, outPath, registe
250
254
  // ── The email: a COVER NOTE. Headline band, one line per mark, the report link. Nothing else. ────────
251
255
  // The report is the deliverable and the email points at it — the same doctrine the clearance lane
252
256
  // follows. The old `delivery.email === 'table'` overlay inlined the full review table into the mail
253
- // body, and it inlined the INTERNAL variant: purple staff notes and the model's register estimate went
254
- // out over the wire to whoever the mail reached. A second rendering of the findings in a second dialect
255
- // is also a second thing to keep true. Per-lawyer client formatting is drafted by the assistant from the
257
+ // body, which put the findings in front of whoever the mail reached rather than whoever opened the
258
+ // report, and made a second rendering of them in a second dialect a second thing to keep true.
259
+ // (That overlay's other charge, that it shipped an INTERNAL variant, no longer describes anything: the
260
+ // 2026-09-07 ruling put the reviewer's notes on the one report. The reason above is the load-bearing one
261
+ // and it is unaffected — the mail stays a cover note.) Per-lawyer client formatting is drafted by the assistant from the
256
262
  // run's report-data.json, not by a template knob in here.
257
263
  // `reports` is publishKnockout's own list — `[{mark, url}]`, one per published document — and
258
264
  // `auditUrl` is composed by the publisher from the pool URL it already holds. Neither is derived here.
@@ -21,10 +21,20 @@
21
21
  // ~60 lines of duplicated document shell, and it is the right price: the alternative is exporting the
22
22
  // shell out of the frozen file, which is a bigger break than the duplication saves.
23
23
  //
24
- // ONE report. There is no client variant and no internal variant. What used
25
- // to render purplethe staff notes and the model's register estimate is not stripped here, it is
26
- // simply NOT PART OF THE REPORT; it lives in the audit workbook, which is where internal working material
27
- // belongs. That removes the whole class of "the wrong link got sent".
24
+ // ONE report. There is no client variant and no internal variant, and that is
25
+ // still the ruleit is what removes the whole class of "the wrong link got sent".
26
+ //
27
+ // WHAT CHANGED, 2026-09-07 (owner ruling, tracker issue 274): the one report now CARRIES the reviewer's
28
+ // notes rather than routing them to the audit workbook alone. The earlier reading of "one report" was
29
+ // that internal working material is simply not part of it; the ruling is that there is one report and the
30
+ // person who ran Clearotron reads it, so holding material back "just adds confusion for where data is
31
+ // lost". Six notes written for the reviewing lawyer on a delivered run reached the spreadsheet and
32
+ // nothing else, which is the measurement behind the ruling.
33
+ //
34
+ // So: the notes render, LABELLED, in report.css's existing `.internal` purple convention (see
35
+ // reviewerNotesBlock) — never merged into the client-voiced body. The workbook keeps them as well; this
36
+ // added a surface and moved none. The model's `registerEstimate` is NOT covered by the ruling and stays
37
+ // off the page.
28
38
  import { readFileSync } from 'node:fs';
29
39
  import { join, dirname } from 'node:path';
30
40
  import { fileURLToPath } from 'node:url';
@@ -176,6 +186,14 @@ const KO_CSS = `
176
186
  .ko-findev{margin:0;font-size:12.5px;line-height:1.6;word-break:break-word}
177
187
  .ko-count{font-family:var(--mono);font-size:13px;color:var(--ink);margin:0}
178
188
  .ko-degraded{font-size:13px;color:var(--med-tx,#82550A);margin:8px 0 0;font-style:italic}
189
+ /* The reviewer-notes legend. NOT .ko-legend — that name is taken by the framework attribution row
190
+ above, and reusing it would restyle the caption. It names the purple convention report.css already
191
+ draws for .internal, so the colour is stated once and never re-specified. */
192
+ .ko-refnote{margin:0;padding:11px 24px 0;font-size:12px;color:#6a2b6e;font-style:italic}
193
+ /* The notes sit inside a mark's column, so the shared .internal block's bullets keep the column's
194
+ own list indent and do not fight the paragraph above them. */
195
+ .ko-row .internal .ko-bul{margin:0 0 4px}
196
+ .ko-row .internal .ko-bul:last-child{margin-bottom:0}
179
197
  .ko-counts table{width:100%;border-collapse:collapse;font-size:14px}
180
198
  .ko-counts th{text-align:left;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);
181
199
  font-weight:700;padding:14px 24px 8px;border-bottom:1px solid var(--line)}
@@ -728,9 +746,13 @@ function registerCardViews(mark, framework, registerRecords) {
728
746
  * is gone; what tells the two apart on the page is where the finding came from, which is the vocabulary
729
747
  * the clearance reports already use for exactly this.
730
748
  *
731
- * NO BAND CHIP AND NO `bandStop`, still, and for the reason the old note gave: a rung of the run's ladder
732
- * here would be a rating nobody performed. The rail takes --faint so the card reads off the risk ramp
733
- * rather than on it.
749
+ * THE BAND CHIP IS DRAWN ONLY WHEN THE RATER SENT ONE, and that condition is the whole rule. The old note
750
+ * here said "no band chip and no `bandStop`, ever: a rung of the run's ladder would be a rating nobody
751
+ * performed" which was true for as long as nothing could carry the rater's rating of an individual
752
+ * filing. `registerReads[].band` now can (tracker issue 274), so the objection is answered where it was
753
+ * raised: with a band, the rating WAS performed, by the seat, in the framework's own words, and the chip
754
+ * states it. With no band the old rule stands unchanged — --faint rail, no chip, nothing claimed — and
755
+ * that is still what every archived run renders.
734
756
  *
735
757
  * THE LINE UNDER THE STATEMENT DESCRIBES THE CARD, NOT THE RATING. It used to deny
736
758
  * that the rating turned on this filing, which this function has no way to know and which a delivered
@@ -759,24 +781,56 @@ function sourceChips(v) {
759
781
  return `${commonLaw ? '<span class="src cl">Common law</span>' : ''}${register ? '<span class="src reg">Register</span>' : ''}`;
760
782
  }
761
783
 
762
- function registerFindingBlock(v, markIndex, reads = null) {
784
+ // ── THE REVIEWER'S NOTES, ON THE REPORT ─────────────────────────────────────────────────
785
+ //
786
+ // OWNER RULING, 2026-09-07: there is one report and the person who ran Clearotron reads it — keeping
787
+ // this material off the page "just adds confusion for where data is lost". This replaces the earlier
788
+ // split under which the notes went to the audit workbook alone (the header of this file and
789
+ // publish/knockout.mjs carried that rule; both now say what replaced it). The workbook keeps them too:
790
+ // this adds a surface, it does not move one.
791
+ //
792
+ // LABELLED, NEVER MERGED. They render in the established `.internal` purple convention report.css
793
+ // already defines — the same one the clearance report uses for this material — so a reader can see whose
794
+ // voice a line is in. Merging them into the client-voiced body would make the reviewer's asides read as
795
+ // findings about the mark, which is the one way this ruling could produce a worse document.
796
+ const REVIEWER_NOTES_LEGEND = 'Information for your reference/context that likely does not need to be shared with the business is shown in purple.';
797
+
798
+ function reviewerNotesBlock(m) {
799
+ const notes = (Array.isArray(m?.purpleNotes) ? m.purpleNotes : [])
800
+ .map((n) => String(n ?? '').trim()).filter(Boolean);
801
+ if (!notes.length) return '';
802
+ return `<div class="internal">
803
+ <span class="tag">For your reference</span>
804
+ ${notes.map((n) => `<p class="ko-bul">${inlineMd(n)}</p>`).join('')}
805
+ </div>`;
806
+ }
807
+
808
+ function registerFindingBlock(v, markIndex, reads = null, framework = null) {
763
809
  const r = v.record;
764
810
  // — THE RATER'S OWN READ OF THIS FILING, when it recorded one. `registerReads` is
765
811
  // joined to the record store by the validator, so a row that reaches here names a filing this run
766
812
  // actually holds. With no row the neutral line stands, and the neutral line is TRUE: it describes the
767
813
  // card. That is why the field can be optional without the page ever asserting something false.
768
- const read = (Array.isArray(reads) ? reads : [])
769
- .map((x) => ({ id: String(x?.recordId ?? '').trim(), text: String(x?.read ?? '').trim() }))
770
- .find((x) => x.id && x.id === String(r?.recordId ?? '').trim())?.text;
814
+ const row = (Array.isArray(reads) ? reads : [])
815
+ .map((x) => ({ id: String(x?.recordId ?? '').trim(), text: String(x?.read ?? '').trim(), band: String(x?.band ?? '').trim() }))
816
+ .find((x) => x.id && x.id === String(r?.recordId ?? '').trim());
817
+ const read = row?.text;
818
+ // The band rides the SAME row as the read and is independently optional: a read with no band prints
819
+ // exactly as it did before this field existed. The validator has already checked the word against the
820
+ // frozen ladder, so `bandStop` resolves a real stop rather than falling through to a default that would
821
+ // colour an unknown word as though it were rated.
822
+ const band = row?.band || null;
823
+ const stop = band ? bandStop(framework, band) : null;
771
824
  const meta = [r.owner ? esc(r.owner) : 'proprietor not stated', r.territory ? esc(r.territory) : null]
772
825
  .filter(Boolean).join(' · ');
773
826
  const receipt = isHttpUrl(r.url) ? linkOrText(r.url) : esc(r.recordId ?? 'no record address supplied');
774
827
  return `<div class="card ko-find" data-ko-mark="${Number(markIndex)}" data-ko-ord="${Number(v.ordinal)}">
775
828
  <div class="top">
776
- <div class="rail" style="background:var(--faint)"></div>
829
+ <div class="rail" style="background:var(${stop ?? '--faint'})"></div>
777
830
  <div class="body">
778
831
  <div class="cardhead">
779
- <span class="fnum">${esc(v.ref)}</span><span class="who">${esc(r.mark ?? 'Unnamed filing')}</span><span class="src reg">Register</span>
832
+ <span class="fnum">${esc(v.ref)}</span><span class="who">${esc(r.mark ?? 'Unnamed filing')}</span>${
833
+ band ? `<span class="ko-findband" style="background:var(${stop})">${esc(band)}</span>` : ''}<span class="src reg">Register</span>
780
834
  </div>
781
835
  <p class="ko-findmeta">${meta}</p>
782
836
  <p class="ko-findnet">${esc(v.statement)}</p>
@@ -925,7 +979,7 @@ function analysisSection(marks, framework, { registerCounts = null, probeRan = f
925
979
  // under compareKnockoutBlockingPower, so appending them is the ladder's own rule applied to a row
926
980
  // that has no rung, not a new one invented for the register.
927
981
  const reg = registerCardViews(m, framework, registerRecords);
928
- const regBlocks = reg.cards.map((v) => registerFindingBlock(v, markIndex, m?.registerReads)).join('');
982
+ const regBlocks = reg.cards.map((v) => registerFindingBlock(v, markIndex, m?.registerReads, framework)).join('');
929
983
  const overflow = reg.promoted > reg.cards.length
930
984
  ? `<p class="ko-bul">${esc(`${reg.promoted - reg.cards.length} further filing${reg.promoted - reg.cards.length === 1 ? '' : 's'} for this name met the same test — every one of them is in the filings section below.`)}</p>`
931
985
  : '';
@@ -950,11 +1004,17 @@ function analysisSection(marks, framework, { registerCounts = null, probeRan = f
950
1004
  <div>
951
1005
  ${body || '<p class="ko-bul">No adverse signals recorded for this name on this screen.</p>'}
952
1006
  ${m.degraded ? `<p class="ko-degraded">${esc(DEGRADED_NOTE)}</p>` : ''}
1007
+ ${reviewerNotesBlock(m)}
953
1008
  <p class="ko-reg">${esc(registerLine(m, registerCounts, probeRan, registerRecords, reg.cards))}</p>
954
1009
  </div>
955
1010
  </div>`;
956
1011
  }).join('');
957
- return `<div class="panel ko-glance">${cards}</div>`;
1012
+ // The legend rides the panel and only when a note is actually on it — a standing sentence explaining a
1013
+ // colour no reader can see would be the report describing a convention it did not use.
1014
+ const anyNotes = marks.some((m) => (Array.isArray(m?.purpleNotes) ? m.purpleNotes : [])
1015
+ .some((n) => String(n ?? '').trim()));
1016
+ const legend = anyNotes ? `<p class="ko-refnote">${esc(REVIEWER_NOTES_LEGEND)}</p>` : '';
1017
+ return `<div class="panel ko-glance">${legend}${cards}</div>`;
958
1018
  }
959
1019
 
960
1020
  // The method line names EXACTLY what ran, and its wording is doctrine — with counts on the page, "register
@@ -1324,6 +1384,39 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
1324
1384
  classesDriving: m.classesDriving ?? [],
1325
1385
  degraded: Boolean(m.degraded),
1326
1386
  points: m.bullets ?? [],
1387
+ // ── THE ASSESSMENT THE RUN ALREADY WROTE (tracker issue 274) ──────────────────────────────
1388
+ //
1389
+ // Seven keys the assess stage writes into knockout-findings.json reached this file as nothing.
1390
+ // They were not withheld by a rule — no code decided against them; this projection is an explicit
1391
+ // whitelist and nobody had added them, so an assistant drafting from this file could not see the
1392
+ // reasoning behind a rating it was drafting about.
1393
+ //
1394
+ // `assessment` is the sharpest case: publish substitutes it into `batch.executiveSummary` ONLY on
1395
+ // a multi-mark batch, so on a single-mark run — the common knockout — the mark's own opening read
1396
+ // was written, validated, and then dropped by both surfaces at once.
1397
+ //
1398
+ // `purpleNotes` is the reviewer's notes, and it is here by the owner's ruling of 2026-09-07: one
1399
+ // report, read by the person who ran Clearotron, because keeping material off it "just adds
1400
+ // confusion for where data is lost". They are LABELLED on the page rather than merged into the
1401
+ // client-voiced body — see reviewerNotesBlock — so a reader can tell whose voice they are in.
1402
+ assessment: (() => { const t = String(m.assessment ?? '').trim(); return t || null; })(),
1403
+ counterFactors: m.counterFactors ?? [],
1404
+ mitigation: (() => { const t = String(m.mitigation ?? '').trim(); return t || null; })(),
1405
+ // `null`, not `false`, when the rater said nothing: a crowded field is a mitigant the reasoning
1406
+ // turns on, and an unstated one must not read as a stated "no".
1407
+ crowdedField: typeof m.crowdedField === 'boolean' ? m.crowdedField : null,
1408
+ reviewerNotes: m.purpleNotes ?? [],
1409
+ // The proof-of-search rows — the same defensibility record list_searches answers from.
1410
+ negatives: (m.negatives ?? []).map((n) => ({
1411
+ term: n?.term ?? null, source: n?.source ?? null, note: n?.note ?? null,
1412
+ })),
1413
+ // The rater's read of a filing it weighed, keyed by the record id the card prints, so a consumer
1414
+ // can join a read to its filing without matching on prose.
1415
+ registerReads: (m.registerReads ?? []).map((x) => ({
1416
+ recordId: x?.recordId ?? null,
1417
+ read: x?.read ?? null,
1418
+ band: String(x?.band ?? '').trim() || null,
1419
+ })),
1327
1420
  // — the typed finding, ranked, with the reference the report prints. The old key was
1328
1421
  // `evidence[]` and it was `(m.findings ?? []).filter((f) => f?.url)`: a typed finding has no
1329
1422
  // `url`, so this file — the one the assistant drafts client mail from — silently carried zero
@@ -1345,14 +1438,31 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
1345
1438
  // A promoted filing the brief omitted would be the report and the brief disagreeing about
1346
1439
  // what the run found.
1347
1440
  //
1348
- // `band: null` and `shape: 'register'` are the load-bearing fields: a consumer can tell a
1349
- // weighed conflict from a pointed-at filing without parsing prose, and `net` carries the
1350
- // not-weighed sentence so a consumer that reads only `net` still cannot overstate it.
1351
- ...registerCardViews(m, framework, registerRecords).cards.map((v) => ({
1352
- ref: v.ref, ordinal: v.ordinal, name: v.record.mark, owner: v.record.owner, band: null,
1353
- type: 'Register filing', net: `${v.statement} It was ${NOT_WEIGHED}.`, basis: NOT_WEIGHED,
1354
- evidence: v.evidence, shape: 'register',
1355
- })),
1441
+ // `shape: 'register'` is the load-bearing field: a consumer can tell a weighed conflict from a
1442
+ // pointed-at filing without parsing prose.
1443
+ //
1444
+ // `band` AND `basis` NOW CARRY THE RATER'S READ WHEN IT SENT ONE (tracker issue 274). Before
1445
+ // this, both were constants `band: null` and the not-weighed sentence — on every filing of
1446
+ // every run, including runs where the seat had written a full read of that exact filing and the
1447
+ // HTML card was already printing it. The page and this file disagreed, and this file is the one
1448
+ // the MCP brief answers from, so the surface a reader asks "what did you make of that
1449
+ // registration?" was the surface that had dropped the answer.
1450
+ //
1451
+ // The unmatched case is unchanged and must stay that way: no row for this recordId ⇒ `band:
1452
+ // null` and the not-weighed line, which describes the card and claims nothing.
1453
+ ...registerCardViews(m, framework, registerRecords).cards.map((v) => {
1454
+ const row = (Array.isArray(m?.registerReads) ? m.registerReads : [])
1455
+ .find((x) => String(x?.recordId ?? '').trim() && String(x?.recordId ?? '').trim() === String(v.record?.recordId ?? '').trim());
1456
+ const read = String(row?.read ?? '').trim() || null;
1457
+ const band = String(row?.band ?? '').trim() || null;
1458
+ return {
1459
+ ref: v.ref, ordinal: v.ordinal, name: v.record.mark, owner: v.record.owner, band,
1460
+ type: 'Register filing',
1461
+ net: read ? v.statement : `${v.statement} It was ${NOT_WEIGHED}.`,
1462
+ basis: read ?? NOT_WEIGHED,
1463
+ evidence: v.evidence, shape: 'register',
1464
+ };
1465
+ }),
1356
1466
  ],
1357
1467
  registerCounts: counted?.counts
1358
1468
  ? {
@@ -4,9 +4,19 @@
4
4
  // report-data.json since the one-report collapse; the clearance lane's native-render branch existed in
5
5
  // the portal with NO producer anywhere (B3's "the aspirational path") — this is that producer.
6
6
  //
7
- // Two readers, one file (the knockout doctrine, verbatim): the assistant drafts client-facing mail from
8
- // it, and the portal's native-render path reads it instead of iframing baked HTML. It is the CLIENT CUT
9
- // by construction, not by serve-time stripping:
7
+ // Two readers, one file (the knockout doctrine, as it stood): the assistant drafts client-facing mail
8
+ // from it, and the portal's native-render path reads it instead of iframing baked HTML.
9
+ //
10
+ // THE TWO LANES DIVERGED ON 2026-09-07 AND THIS FILE DID NOT MOVE. The owner ruled (tracker issue 274)
11
+ // that the KNOCKOUT report carries the reviewer's notes, on the ground that one report is read by the
12
+ // person who ran Clearotron and holding material back confuses where data went; render-knockout.mjs
13
+ // projects them under `reviewerNotes`. That ruling was about those notes on that lane. It did not touch
14
+ // what this producer strips — withdrawn findings, verification receipts, `::p::` tails, `[internal]`
15
+ // labels and engine-internal narration are all still cut here, and the guard test still polices them.
16
+ // Do not read the knockout change as licence to relax this list; if the ruling is meant to extend, it
17
+ // extends by being asked, not by inference.
18
+ //
19
+ // It remains the CLIENT CUT by construction, not by serve-time stripping:
10
20
  // • only LIVE findings (a withdrawn finding renders nowhere — it does not exist here either);
11
21
  // • no verification receipts (use_check / own_rights / bears_on — those are the reviewer's evidence
12
22
  // trail and live in the audit workbook), no withdrawn_reason, no quarantine records;
@@ -840,7 +840,7 @@ export function silentlyLostFindings({ reconciliation = null, carryRows = null,
840
840
  * It also anticipated this gap in writing: "nine divergences from a digest finding-ending, every one of
841
841
  * them `step-stated`". Nine of the shape nothing checked.
842
842
  *
843
- * MEASURED ON R2 `russet-kestrel`, delivered 2026-09-06. The sibling ran and reported
843
+ * MEASURED ON A DELIVERED R2 RUN, 2026-09-06. The sibling ran and reported
844
844
  * `{checked:5, matched:5, lost:0}` — correctly. On that same delivery two marks from the lawyer's final
845
845
  * list, `OSLER DELPHI` and `DELFITY`, one rated HIGH, are absent from `findings.json`. They were dropped
846
846
  * WITH a reason, so they sat outside the sibling's population by design:
@@ -880,7 +880,7 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
880
880
  //
881
881
  // The first cut of this function gated on the reconciliation's finding-ended positions, mirroring the
882
882
  // sibling. That inherited the sibling's BLIND SPOT along with its shape, and the check was inert on
883
- // the very delivery it was written for. Replayed against R2 `russet-kestrel`:
883
+ // the very delivery it was written for. Replayed against that delivery:
884
884
  //
885
885
  // silentlyLostFindings checked=5 matched=5 lost=0
886
886
  // statedDivergenceFindings checked=5 matched=5 diverged=0 ← should have named two marks
@@ -674,7 +674,7 @@ export function scoreRecall({ reference, findings = [], retrieved = [], scopeCla
674
674
  // proprietor with more than one mark. A large filer can perfectly well have one mark the run withheld
675
675
  // and a DIFFERENT mark, not in the reference, that it surfaced. Both rows are true.
676
676
  //
677
- // It fired on R2 russet-kestrel: `Novartis AG: reference "DELFITY" is withheld, surfaced "DELPHINA"
677
+ // It fired on a delivered R2 run: `<large filer>: reference "DELFITY" is withheld, surfaced "DELPHINA"
678
678
  // is noise`. Different marks, different records, one proprietor that files a great many. And because
679
679
  // score.mjs prints a collision as "do not read the recall numbers above", ONE such proprietor
680
680
  // suppressed the whole run's recall measurement — a real 88% → 63% movement went unquoted on the
@@ -95,6 +95,13 @@ export const RESULT_NOUN_FIELDS = Object.freeze([
95
95
  { file: "driver/skills-store-provenance.mjs", noun: "outcome", sites: 6, atWriteSite: 0, verdict: "out-of-scope" },
96
96
  { file: "driver/status-snapshot.mjs", noun: "outcome", sites: 1, atWriteSite: 0, verdict: "out-of-scope" },
97
97
  { file: "mcp-server/lib/events.mjs", noun: "recovered", sites: 1, atWriteSite: 0, verdict: "out-of-scope" },
98
+ // Read at the writing site, as this table requires. All three sites are in `knockoutSearches`, a
99
+ // READ-ONLY projection that composes the rows `list_searches` answers with: it opens no artifact and
100
+ // writes nothing, so nothing here can reach `_driver/*.json` or `run.jsonl`. Each value is a literal
101
+ // chosen from the run's own stored record — "found" or "no-hit" from whether the mark has findings,
102
+ // and "recorded" for a register term whose stored row says it did NOT answer. None is a call's return
103
+ // read back as a verdict, which is the defect this table exists to find.
104
+ { file: "mcp-server/lib/knockout.mjs", noun: "outcome", sites: 3, atWriteSite: 0, verdict: "out-of-scope" },
98
105
  { file: "mcp-server/lib/ops.mjs", noun: "settled", sites: 1, atWriteSite: 0, verdict: "out-of-scope" },
99
106
  { file: "mcp-server/server.mjs", noun: "outcome", sites: 1, atWriteSite: 0, verdict: "out-of-scope" },
100
107
  { file: "scripts/backfill-started-at.mjs", noun: "outcome", sites: 5, atWriteSite: 0, verdict: "out-of-scope" },
@@ -162,9 +162,14 @@ your job. These two fields are how what you concluded reaches the reader instead
162
162
  every id against that store and refuses one it does not hold — so the report can state a filing's
163
163
  source as a fact rather than as a word you typed about your own sourcing.
164
164
 
165
- - **`registerReads`** — on the MARK. Rows of `{recordId, read}` for a filing you weighed that did **not**
166
- become a `findings[]` record. `read` is what you concluded about *that* filing: whether it bears on the
167
- rating, and why. It prints on that filing's card.
165
+ - **`registerReads`** — on the MARK. Rows of `{recordId, read, band?}` for a filing you weighed that did
166
+ **not** become a `findings[]` record. `read` is what you concluded about *that* filing: whether it bears
167
+ on the rating, and why. It prints on that filing's card. `band` is **optional** and is how you rate that
168
+ filing on its own — in the framework's band words, the same vocabulary as the mark's rating. Send it
169
+ whenever you formed a view on the filing itself: a registered right is often the one hard legal fact on
170
+ the page, and with no band its card is the only one carrying no rating while softer uses around it carry
171
+ one, which reads as though the registration mattered least. Omit it and the read still prints, claiming
172
+ no rating.
168
173
  - **`weighedFilings`** — on a FINDING. The `recordId`s whose evidence your reasoning for that conflict
169
174
  actually used. The report derives the finding's source labelling from this, so list one only if you
170
175
  reasoned from it.
@@ -205,7 +210,8 @@ line, which describes the card and claims nothing about the rating. So:
205
210
  "classesDriving": [8], "bullets": ["..."], "purpleNotes": ["..."],
206
211
  "registerEstimate": "...", "parodyNote": null, "crowdedField": false,
207
212
  "registerReads": [ { "recordId": "<verbatim from the filings you were given>",
208
- "read": "<what you concluded about THAT filing>" } ],
213
+ "read": "<what you concluded about THAT filing>",
214
+ "band": "<OPTIONAL — your rating of THAT filing, in the ladder's words>" } ],
209
215
  "basis": "<ONE sentence: why this band, for this name, in these classes>",
210
216
  "factors": ["<2-4 load-bearing observations, one line each>"],
211
217
  "counterFactors": ["<1-3: what holds it at this band rather than the next>"],
@@ -301,7 +301,7 @@ export const KO_STAGES = {
301
301
  // a finding with no register evidence. Omitting either field is always safe: the card keeps its
302
302
  // neutral line, which describes the card and is true.
303
303
  existsSync(K.registerRecords)
304
- ? `WHEN YOU WEIGH ONE OF THOSE FILINGS, SAY SO BY ITS OWN ID — copy "recordId" verbatim from the file above. "registerReads" on the MARK: rows of {recordId, read} for a filing you weighed that did NOT become a findings[] record; "read" is what you concluded about THAT filing — whether it bears on the rating and why — and it prints on that filing's card in the reader's own report. "weighedFilings" on a FINDING: the recordIds whose evidence your reasoning for that conflict actually used, because the report derives that finding's source labelling from it. Both are optional and both are joined against the filings you were given, so an id we do not hold is refused by name. A filing you did not weigh simply gets no row: do not invent a read to fill one, and never write "not weighed" as a read.`
304
+ ? `WHEN YOU WEIGH ONE OF THOSE FILINGS, SAY SO BY ITS OWN ID — copy "recordId" verbatim from the file above. "registerReads" on the MARK: rows of {recordId, read, band?} for a filing you weighed that did NOT become a findings[] record; "read" is what you concluded about THAT filing — whether it bears on the rating and why — and it prints on that filing's card in the reader's own report. "band" is OPTIONAL and is your rating of THAT filing on its own, in the framework's band words: send it whenever you formed a view on the filing itself, because a registered right left unbanded is the only card on the page with no rating while softer uses beside it carry one, which reads as though the registration mattered least. Omit it and your read still prints, claiming no rating. "weighedFilings" on a FINDING: the recordIds whose evidence your reasoning for that conflict actually used, because the report derives that finding's source labelling from it. Both are optional and both are joined against the filings you were given, so an id we do not hold is refused by name. A filing you did not weigh simply gets no row: do not invent a read to fill one, and never write "not weighed" as a read.`
305
305
  : "",
306
306
  `A DEGRADED mark's row must carry degraded:true and the purple "Manual verification recommended"`,
307
307
  `note; a mark WITH a payload must never claim degraded (the validator joins both against the disk).`,
package/driver/stages.mjs CHANGED
@@ -3267,7 +3267,7 @@ export const STAGES = {
3267
3267
  `DECLINATIONS: what you do not deliver, you decline BY NAME through the \`record_declination\` tool. A record that reached your findings surface leaves this stage as a finding in ${P.findings} or as a declination, and there is no third way out.`,
3268
3268
  ...(Array.isArray(findingsSurface) && findingsSurface.length ? [
3269
3269
  `DECLINATIONS (MANDATORY): the register digest carried ${findingsSurface.length} record(s) onto your findings surface. Each one leaves this stage by one of two routes and there is no third: it becomes a finding in ${P.findings}, or you decline it BY NAME with a reason and a ground. A record you simply do not mention is reported as a defect of this run, named individually in its trace and shipped as an open doubt, so silence costs you more than a declination ever will.`,
3270
- `Decline by calling the \`record_declination\` tool. Its schema names the fields it takes and what each is for — read them there, not here. The one thing to know before you call: you cite a record by its POSITION in the list below — there is no field for a mark name or a uri, so a record you were not handed cannot be expressed at all. The reason vocabulary is closed: ${DECLINATION_REASON_TOKENS.join(" / ")}, each an omission synthesis-rules.md already authorises, and if your ground is none of them the rules do not let you omit the record. The grounds you write are one or two lines in your OWN words on why THIS record does not earn a line — never machine-parsed, and what the reviewing lawyer reads. Send them in one batch where you can; a refused row never voids its neighbours, and the answer names what is still undecided so you can finish in this turn.`,
3270
+ `Decline by calling the \`record_declination\` tool. Its schema names the fields it takes and what each is for — read them there, not here. The one thing to know before you call: you cite a record by its POSITION in the list below — there is no field for a mark name or a uri, so a record you were not handed cannot be expressed at all. The reason vocabulary is closed: ${DECLINATION_REASON_TOKENS.join(" / ")}, each an omission synthesis-rules.md already authorises, and if your ground is none of them the rules do not let you omit the record. ONE OF THEM IS NOT AVAILABLE ON A LIVE RECORD IN AN INSTRUCTED CLASS: \`not-worth-the-line\` is a statement about the report's budget rather than about the mark, and a live registration inside one of the matter's own classes is never omitted for want of room — carry it, or decline it on something true of THAT MARK. The call is refused if you try, so it costs you a turn. The grounds you write are one or two lines in your OWN words on why THIS record does not earn a line — never machine-parsed, and what the reviewing lawyer reads. Send them in one batch where you can; a refused row never voids its neighbours, and the answer names what is still undecided so you can finish in this turn.`,
3271
3271
  `A REFUSAL FROM THAT TOOL IS ABOUT BOOKKEEPING, NEVER ABOUT YOUR LEGAL JUDGMENT. There is exactly ONE case: declining a mark IDENTICAL to the applied-for mark, live, in one of the matter's own filed classes, on a discretionary ground — because synthesis-rules.md orders that an on-point identical mark in the relevant class is never dropped, "regardless of filer profile", so such a declination contradicts the instruction you are already following. Nothing else is refused. If that record really is the applicant's own, or already delivered under another record, say exactly that with own-right or duplicate-of-delivered and it is accepted. Whether goods are related, whether a name is off-field, whether a conflict is worth the line — those are your calls and the tool does not have an opinion about any of them.`,
3272
3272
  `The records on your findings surface, by position:`,
3273
3273
  ...findingsSurface.map((r, i) => ` ${i}. ${r.mark ?? "(unnamed)"}${r.owner ? ` — ${r.owner}` : ""}${r.tier ? ` [${r.tier}]` : ""}${r.uri ? ` ${r.uri}` : ""}`),