clearotron 0.2.2 → 0.2.4

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 (161) hide show
  1. package/.env.example +64 -37
  2. package/.nvmrc +1 -1
  3. package/CONTRIBUTING.md +8 -4
  4. package/INSTALL.md +36 -14
  5. package/README.md +18 -22
  6. package/THIRD-PARTY-NOTICES.md +1 -1
  7. package/bin/clearotron.mjs +22 -0
  8. package/bin/example.mjs +10 -5
  9. package/bin/onboard.mjs +370 -28
  10. package/bin/start.mjs +220 -12
  11. package/build-info.json +2 -2
  12. package/demo/README.md +1 -1
  13. package/demo/full-country-search/run/_driver/framework.json +1 -1
  14. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  15. package/demo/knockout-search/run/_driver/framework.json +1 -1
  16. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  17. package/docs/E2E.md +5 -5
  18. package/docs/GLOSSARY.md +85 -0
  19. package/docs/PORTAL.md +2 -2
  20. package/docs/README.md +6 -4
  21. package/docs/SECURITY.md +17 -0
  22. package/docs/architecture/01-product-overview.md +21 -9
  23. package/docs/architecture/04-configuration-reference.md +2 -0
  24. package/docs/architecture/05-config-governance.md +19 -0
  25. package/docs/architecture/05-customer-profiles.md +2 -2
  26. package/docs/configuration.md +4 -3
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  28. package/driver/CHANGELOG.md +90 -0
  29. package/driver/README.md +25 -6
  30. package/driver/common-law-receipts.mjs +1 -1
  31. package/driver/config-staleness.mjs +1 -1
  32. package/driver/connotation-search.mjs +1 -1
  33. package/driver/contract-audit.mjs +5 -1
  34. package/driver/contract-e3-baseline.json +11 -11
  35. package/driver/dev-portal.mjs +2 -2
  36. package/driver/doubt-selection.mjs +1 -1
  37. package/driver/drainer-identity.mjs +1 -1
  38. package/driver/driver.config.mjs +1 -1
  39. package/driver/effort-model.mjs +2 -2
  40. package/driver/engine/cli-version.mjs +103 -0
  41. package/driver/engine/probe.mjs +45 -5
  42. package/driver/enqueue-schema.mjs +8 -8
  43. package/driver/feedback-store.mjs +1 -1
  44. package/driver/findings-model.mjs +52 -2
  45. package/driver/flag-snapshot.mjs +20 -0
  46. package/driver/framework.mjs +1 -1
  47. package/driver/gateway.mjs +20 -3
  48. package/driver/outbox-backoff.mjs +1 -1
  49. package/driver/package.json +3 -3
  50. package/driver/party-facts.mjs +2 -2
  51. package/driver/phase0.mjs +1 -1
  52. package/driver/pipeline.mjs +211 -106
  53. package/driver/plain-register.mjs +134 -0
  54. package/driver/portal-access.mjs +1 -1
  55. package/driver/portal-config-view.mjs +70 -2
  56. package/driver/portal-families.mjs +1 -1
  57. package/driver/portal-local-auth.mjs +5 -1
  58. package/driver/portal-report.mjs +1 -1
  59. package/driver/portal-service.mjs +79 -9
  60. package/driver/portal-upstream.mjs +2 -2
  61. package/driver/predelivery-lint.mjs +151 -2
  62. package/driver/profile-page.html +10 -2
  63. package/driver/profile-service.mjs +4 -4
  64. package/driver/profiles/README.md +3 -3
  65. package/driver/profiles.mjs +144 -12
  66. package/driver/publish/index.mjs +2 -2
  67. package/driver/publish/knockout.mjs +7 -1
  68. package/driver/publish/render-knockout.mjs +453 -118
  69. package/driver/publish/render.mjs +111 -16
  70. package/driver/reference-strip-signatures.mjs +20 -0
  71. package/driver/search-policy.mjs +3 -3
  72. package/driver/skills/blind-frame/SKILL.md +1 -1
  73. package/driver/skills/knockout-assess/SKILL.md +84 -0
  74. package/driver/skills/matter-frame/SKILL.md +6 -6
  75. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  76. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  77. package/driver/skills/prelim-search/SKILL.md +1 -1
  78. package/driver/skills/prelim-search/report-prose.md +82 -0
  79. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  80. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  81. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  82. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  83. package/driver/skills/prelim-variants/SKILL.md +3 -3
  84. package/driver/stage-context.mjs +13 -0
  85. package/driver/stages.mjs +54 -7
  86. package/driver/suite-census.json +220 -58
  87. package/driver/systemd/clearotron-worker.service +3 -3
  88. package/driver/tokens.mjs +1 -1
  89. package/driver/unit-inventory.mjs +214 -27
  90. package/driver/verify.mjs +1 -1
  91. package/driver/whatif-memo-run.mjs +26 -4
  92. package/driver/whatif-memo.mjs +19 -1
  93. package/examples/README.md +2 -2
  94. package/examples/grants.example.json +6 -7
  95. package/mcp-server/CHANGELOG.md +4 -0
  96. package/mcp-server/lib/scrub.mjs +1 -1
  97. package/mcp-server/lib/whatif.mjs +2 -2
  98. package/mcp-server/package.json +1 -1
  99. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  100. package/mcp-server/server.mjs +1 -1
  101. package/package.json +31 -19
  102. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  103. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
  104. package/portal-ui/dist/index.html +2 -2
  105. package/portal-ui/package.json +1 -1
  106. package/providers/_shared/term-shape.mjs +1 -1
  107. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  108. package/providers/oauth-mcp-bridge/package.json +1 -1
  109. package/providers/uspto-local/src/index-store.js +2 -2
  110. package/scripts/added-reference-check.mjs +45 -89
  111. package/scripts/citation-line-check.mjs +121 -2
  112. package/scripts/e2e.mjs +2 -2
  113. package/scripts/env-classify.mjs +67 -3
  114. package/scripts/generated-files-are-current.mjs +125 -0
  115. package/scripts/live-surface-check.mjs +38 -1
  116. package/scripts/mint-names-in-force.mjs +4 -2
  117. package/scripts/mint-public-residue.mjs +74 -0
  118. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  119. package/scripts/mint-suite-census.mjs +5 -2
  120. package/scripts/nightly-notice.mjs +0 -2
  121. package/scripts/preinstall-node-check.mjs +24 -0
  122. package/scripts/render-check.mjs +13 -2
  123. package/scripts/report-frame-check.mjs +8 -1
  124. package/scripts/report-screenshot.mjs +6 -3
  125. package/scripts/revisit-render-check.mjs +6 -3
  126. package/scripts/score.mjs +1 -1
  127. package/scripts/strip-tracker-citations.mjs +38 -7
  128. package/scripts/test-full.mjs +226 -0
  129. package/scripts/test-run.mjs +16 -0
  130. package/shared/README.md +2 -1
  131. package/shared/anon-overlay.mjs +3 -3
  132. package/shared/browser-temp-root.mjs +142 -0
  133. package/shared/checkout-move.mjs +1 -1
  134. package/shared/client-door.mjs +4 -3
  135. package/shared/customer-store.mjs +1 -1
  136. package/shared/identifier-scan.mjs +1 -1
  137. package/shared/install-auth.mjs +35 -0
  138. package/shared/invocation.mjs +33 -1
  139. package/shared/listen.mjs +55 -3
  140. package/shared/names-in-force.mjs +6 -1
  141. package/shared/node-floor.mjs +97 -0
  142. package/shared/os-advice.mjs +91 -0
  143. package/shared/reap-on-exit.mjs +42 -0
  144. package/shared/reference-guard-classes.mjs +351 -0
  145. package/shared/staff-domain.mjs +154 -0
  146. package/shared/store-in-repo.mjs +23 -2
  147. package/shared/suite-census.mjs +31 -3
  148. package/shared/withheld-paths-access.mjs +36 -18
  149. package/skills/clearotron-ops/SKILL.md +1 -1
  150. package/driver/recipes/README.md +0 -49
  151. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  152. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  153. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  154. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  155. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  156. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  157. package/scripts/ai-page-render-check.mjs +0 -532
  158. package/scripts/clearances-render-check.mjs +0 -480
  159. package/scripts/composer-render-check.mjs +0 -1027
  160. package/scripts/home-render-check.mjs +0 -526
  161. package/scripts/portal-lifecycle-check.mjs +0 -639
@@ -45,7 +45,7 @@ import {
45
45
  import { SUMMARY_BLOCK_LINE, parseSummaryBlocks } from '../../shared/summary-blocks.mjs';
46
46
  import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine } from '../register-count.mjs';
47
47
  import { RECORD_BASIS, recordsForMark, recordsLine } from '../register-records.mjs';
48
- import { knockoutFindingViews } from '../findings-model.mjs';
48
+ import { knockoutFindingViews, splitKnockoutNotes } from '../findings-model.mjs';
49
49
  import { demoBannerHtml } from './render.mjs'; // — the SAME banner the clearance template renders, not a second wording
50
50
  // — the two facts the register card is allowed to read off a raw record, and NEITHER is minted
51
51
  // here. `makeClassifyStatus` and `isAllClass` are the screening lane's own, already shipped, already
@@ -220,6 +220,28 @@ const KO_CSS = `
220
220
  saved to PDF would drop the narrative this block exists to preserve. The beforeprint handler below
221
221
  opens them; this hides the now-pointless toggle in print. */
222
222
  @media print{.ko-full summary{display:none}}
223
+ /* THE LABELS THE READER ACTUALLY READS. ko-lbl above is 9.5px uppercase grey — the owner's
224
+ "what holds what?" was asked of a label in that style, so a new word in the same style is the
225
+ same defect with different letters. These are body size and body colour, and they carry the
226
+ band's own word ("Why High"), which is what makes them answerable without a legend. */
227
+ .ko-lbl2{display:block;font-size:14px;font-weight:700;color:var(--ink);margin:0 0 5px;letter-spacing:0}
228
+ /* The card's fold. ko-full rides with it so openAll() and the beforeprint handler reach it —
229
+ what folds on screen still prints, which is the rule this template already states for the
230
+ narrative fold. ko-why carries only the spacing a card needs inside its own body. */
231
+ .ko-why{margin:6px 0 5px}
232
+ .ko-why summary{font-size:12px;text-transform:none;letter-spacing:0;color:var(--rose,var(--faint));font-weight:600}
233
+ /* "About this request" — what was asked, and any flag on the asking. It sits in the hero because a
234
+ mis-scoped request makes every number below it answer the wrong question. */
235
+ .ko-req{margin:14px 0 0;padding:13px 16px;border:1px solid var(--line);border-radius:3px}
236
+ .ko-req .ko-lbl2{margin-bottom:6px}
237
+ .ko-req p{margin:0 0 6px;font-size:13.5px;color:var(--slate);line-height:1.6}
238
+ .ko-req p:last-child{margin-bottom:0}
239
+ .ko-reqflag{border-left:3px solid var(--rose,var(--line));padding-left:11px;margin-top:9px}
240
+ /* THE PURPLE NOTES COME OFF THE EXPORT, exactly as the clearance page removes its internal notes
241
+ ("Internal (review-only) notes are removed on export"). The knockout's export is window.print()
242
+ via exportPDF(), so print is the whole export path and this rule is the whole strip. Without it
243
+ a knockout PDF forwarded to a client carries the reviewing lawyer's notes. */
244
+ @media print{.internal{display:none !important}}
223
245
  .ko-reg{margin:11px 0 0;padding:8px 0 0;border-top:1px solid var(--line);
224
246
  font-family:var(--mono);font-size:12px;color:var(--faint);line-height:1.5}
225
247
  .ko-filings{padding:14px 0 2px;border-bottom:1px solid var(--line)}
@@ -297,8 +319,111 @@ function glanceSection(marks, framework, registerCounts) {
297
319
  // all-classes figure alongside a class-scoped one would be a second count — one more provider call per
298
320
  // mark per predicate, billable on Corsearch — and that is a spend the owner has not ruled on. Raised as
299
321
  // a follow-up rather than assumed here (tracker issue 717).
322
+ // ── TERRITORIES IN WORDS, NEVER CODES (tracker issue 331 C) ─────────────────────────────────────────
323
+ //
324
+ // The line read "territories: EM, US, WO". On a worldwide run the same line printed every register code
325
+ // the provider offers — roughly two hundred, AD through ZZ, internal groupings among them — which the
326
+ // owner called a meaningless list, correctly: a reader cannot tell anything from two hundred codes and
327
+ // cannot tell much from three.
328
+ //
329
+ // TWO CODES ARE REGISTERS RATHER THAN COUNTRIES and neither is an ISO region, so neither can be resolved
330
+ // by a country table: EM is the EU Intellectual Property Office and WO is WIPO's international register.
331
+ // They are named here because they are the two a reader meets constantly.
332
+ //
333
+ // A CODE THIS TABLE CANNOT NAME IS DROPPED, NOT PRINTED. The provider's internal groupings (XA, XG, XS,
334
+ // XW, ZZ and their like) name no register a reader could look up, and printing one is the defect this
335
+ // change exists to remove. Dropping them is safe because the COUNT of registers is stated separately and
336
+ // is taken before any naming — so a reader is never told about fewer registers than were counted.
337
+ // TWO FORMS PER REGISTER, because English needs both: "Counted in the United States" takes the article
338
+ // and "United States application" refuses it. One map with one form produced "the United States
339
+ // application (pending)" on a card, which is why they are separate fields rather than a regex over one.
340
+ const REGISTER_NAMES = Object.freeze({
341
+ EM: { bare: 'European Union', phrase: 'the European Union' },
342
+ EU: { bare: 'European Union', phrase: 'the European Union' },
343
+ WO: { bare: 'international', phrase: 'the WIPO register' },
344
+ US: { bare: 'United States', phrase: 'the United States' },
345
+ GB: { bare: 'United Kingdom', phrase: 'the United Kingdom' },
346
+ UK: { bare: 'United Kingdom', phrase: 'the United Kingdom' },
347
+ CH: { bare: 'Swiss', phrase: 'Switzerland' },
348
+ });
349
+
350
+ /**
351
+ * A register code as a reader's words, or '' when this build cannot name it. NEVER returns a code.
352
+ * `bare: true` gives the attributive form for a phrase like "United States application".
353
+ */
354
+ function territoryName(code, { bare = false } = {}) {
355
+ const c = String(code ?? '').trim().toUpperCase();
356
+ if (!c) return '';
357
+ if (REGISTER_NAMES[c]) return bare ? REGISTER_NAMES[c].bare : REGISTER_NAMES[c].phrase;
358
+ // The provider's internal groupings all sit in the X* and Z* space and name no lookup-able register.
359
+ if (/^[XZ]/.test(c)) return '';
360
+ try {
361
+ const name = new Intl.DisplayNames(['en'], { type: 'region' }).of(c);
362
+ // Intl hands the CODE back when it knows no region by that name — which would print exactly the
363
+ // thing this function exists to prevent, so an unresolved code is an empty string.
364
+ return name && name.toUpperCase() !== c ? name : '';
365
+ } catch { return ''; }
366
+ }
367
+
368
+ /** "a, b and c" — the reader's list, not a join on commas. */
369
+ function listWords(items) {
370
+ const xs = (items ?? []).filter(Boolean);
371
+ if (xs.length <= 1) return xs[0] ?? '';
372
+ return `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`;
373
+ }
374
+
375
+ /**
376
+ * The counted scope, in one sentence (tracker issue 331 C). Three shapes, and the shape is chosen by
377
+ * how many registers were counted rather than by which they were:
378
+ *
379
+ * every register the provider offers -> "Counted worldwide, 190 registers, on Clarivate Compumark."
380
+ * up to six -> named in full
381
+ * more than six, not all -> the count, and where the list is
382
+ *
383
+ * The provider is named here and nowhere else on the page; 331 D takes the vendor's name off the cards.
384
+ */
385
+ const TERRITORY_NAME_CAP = 6;
386
+ function territoriesLine(registerCounts) {
387
+ const provider = registerCounts?.providerLabel ?? registerCounts?.provider ?? 'the register';
388
+ const regions = (registerCounts?.scope?.regions ?? []).filter(Boolean);
389
+ const worldwide = registerCounts?.scope?.worldwide === true || regions.length === 0;
390
+ if (worldwide) {
391
+ const n = regions.length;
392
+ return n
393
+ ? `Counted worldwide, ${n} registers, on ${provider}.`
394
+ : `Counted worldwide on ${provider}.`;
395
+ }
396
+ const named = regions.map(territoryName).filter(Boolean);
397
+ if (named.length && named.length <= TERRITORY_NAME_CAP) {
398
+ return `Counted in ${listWords(named)}, on ${provider}.`;
399
+ }
400
+ return `Counted on ${regions.length} registers, listed on the workbook's Register Counts sheet, on ${provider}.`;
401
+ }
402
+
403
+ /** What the counts do and do not say — the one line that replaces the count-basis paragraph on the page. */
404
+ const COUNTS_READER_LINE = 'Counts include live, pending and dead filings. '
405
+ + 'A count is not a conflict; the cards above say which filings matter.';
406
+
300
407
  function countsSection(marks, registerCounts) {
301
- const head = COUNT_PREDICATES.map((p) => `<th>${esc(p.label)}</th>`).join('');
408
+ // ── THE DEFINITION MOVES INTO THE COLUMN HEADER (tracker issue 331 B) ────────────────────────────
409
+ //
410
+ // The 70-word count-basis paragraph existed because three one-word headers — Identical, Containing,
411
+ // Close variations — did not say what they counted, so the definition had to go somewhere. Put it in
412
+ // the header and the paragraph has nothing left to do.
413
+ //
414
+ // ONE NAME ⇒ THE HEADER NAMES IT, because "Exactly ORBIT" needs no gloss at all. Several names share
415
+ // one table and no header can name one of them, so they keep the general form and each row's own
416
+ // forms line (already rendered, unchanged) carries that row's near-spellings.
417
+ const single = marks.length === 1 ? String(marks[0]?.name ?? '').trim() : '';
418
+ const forms = single
419
+ ? ((countsForMark(registerCounts, single)?.counts?.close?.forms ?? []).map((f) => String(f?.form ?? '').trim()).filter(Boolean))
420
+ : [];
421
+ const headLabel = (pk) => {
422
+ if (pk === 'identical') return single ? `Exactly ${single}` : 'Exactly the name';
423
+ if (pk === 'containing') return single ? `Contains ${single}` : 'Contains the name';
424
+ return forms.length ? `Near-spellings (${forms.join(', ')})` : 'Near-spellings';
425
+ };
426
+ const head = COUNT_PREDICATES.map((p) => `<th>${esc(headLabel(p.key))}</th>`).join('');
302
427
  const rows = marks.map((m) => {
303
428
  const e = countsForMark(registerCounts, m.name);
304
429
  const cells = COUNT_PREDICATES.map((p) => {
@@ -326,11 +451,13 @@ function countsSection(marks, registerCounts) {
326
451
  return `<tr><td><b>${esc(m.name)}</b><br><span class="ko-classes">${esc(scope)}</span></td>${cells}</tr>`
327
452
  + (forms ? `<tr class="ko-forms"><td colspan="${COUNT_PREDICATES.length + 1}">${esc(forms)}</td></tr>` : '');
328
453
  }).join('');
329
- const provider = registerCounts.providerLabel ?? registerCounts.provider ?? 'the register';
330
- const territories = (registerCounts.scope?.regions ?? []).join(', ') || 'worldwide';
454
+ // COUNT_BASIS IS OFF THE PAGE, NOT OUT OF THE RECORD (tracker issue 331 B). It is still written to
455
+ // report-data.json (knockoutReportData -> registerCountBasis) and still on the workbook, which is where
456
+ // 331 says it belongs; what it stops doing is printing twice on a page whose headers now say the same
457
+ // thing in three words each.
331
458
  return `<div class="panel">
332
459
  <div class="ko-counts ko-scroll"><table><thead><tr><th>Name</th>${head}</tr></thead><tbody>${rows}</tbody></table></div>
333
- <p class="ko-basis">${esc(COUNT_BASIS)}<br>Counted on <b>${esc(provider)}</b> · territories: ${esc(territories)}.</p>
460
+ <p class="ko-basis">${esc(COUNTS_READER_LINE)}<br>${esc(territoriesLine(registerCounts))}</p>
334
461
  </div>`;
335
462
  }
336
463
 
@@ -532,6 +659,29 @@ function tierAbsenceLine(registerCounts, probeRan) {
532
659
  // A NAME is deliberately not emitted here for the control to send. `ref` is right there and reads
533
660
  // nicely, but portal-ui/src/contract/reportFrame.ts refuses a mark from the document side on purpose,
534
661
  // and the composite key IS a mark name. The server reads `ref` back off disk once it has resolved.
662
+ /**
663
+ * THE CARD'S DETAIL, FOLDED (tracker issue 331 A.3).
664
+ *
665
+ * Visible on a card: the reference, the name, the band chip, the source chip, the one-sentence read and
666
+ * the evidence links. Folded: the paragraph that argues the band, and on a register card the use-check
667
+ * line and the record link with it.
668
+ *
669
+ * WHY A FOLD AND NOT A CUT. The paragraph is the reasoning, and 331 rejects "shortening by dropping the
670
+ * reasons" in as many words. It is one click away and it still prints — `ko-full` is on the element, so
671
+ * openAll() and the beforeprint handler both reach it and a saved PDF carries the whole argument.
672
+ *
673
+ * EMPTY IN, EMPTY OUT. A card with no detail renders no fold rather than an empty one, which is also the
674
+ * archived-run path: a run whose findings carry no `basis` renders exactly the card it was delivered.
675
+ */
676
+ function whyBandFold(inner) {
677
+ const body = String(inner ?? '').trim();
678
+ if (!body) return '';
679
+ return `<details class="ko-full ko-why"><summary>${esc(WHY_BAND_LABEL)}</summary>${body}</details>`;
680
+ }
681
+
682
+ /** The card fold's own word. Stated once so the renderer and its tests read the same string. */
683
+ const WHY_BAND_LABEL = 'Why this band';
684
+
535
685
  function findingBlock(v, framework, markIndex) {
536
686
  const meta = [v.type, v.owner].filter(Boolean).map((s) => esc(s)).join(' · ');
537
687
  const ev = v.evidence.map((u) => linkOrText(u)).join(' · ');
@@ -564,7 +714,7 @@ function findingBlock(v, framework, markIndex) {
564
714
  </div>
565
715
  ${meta ? `<p class="ko-findmeta">${meta}</p>` : ''}
566
716
  ${v.lead ? `<p class="ko-findnet">${inlineMd(v.lead)}</p>` : ''}
567
- ${v.detail ? `<p class="ko-findbasis">${inlineMd(v.detail)}</p>` : ''}
717
+ ${whyBandFold(v.detail ? `<p class="ko-findbasis">${inlineMd(v.detail)}</p>` : '')}
568
718
  ${ev ? `<p class="ko-findev">Evidence: ${ev}</p>` : ''}
569
719
  </div>
570
720
  </div>
@@ -714,18 +864,52 @@ export function promotableRecords(entry, mark) {
714
864
  /** One sentence of what the filing IS, composed from the register's own fields. No adjective that the
715
865
  * record did not supply, and every absent field says it is absent rather than rendering as a blank —
716
866
  * a blank cell and an unstated field read alike to a human and only one is honest. */
867
+ // ── THE OFFICE, NOT THE VENDOR (tracker issue 331 D) ────────────────────────────────────────────────
868
+ //
869
+ // The sentence read "A filing of the identical name on Clarivate Compumark — held by ..., status
870
+ // PENDING, classes 9, 38, 45, territory us." Clarivate Compumark is the search vendor; the filing is at
871
+ // the United States office. Naming the vendor in the position where a reader expects the register says
872
+ // the filing exists somewhere it does not, and prints a raw two-letter territory code besides.
873
+ //
874
+ // The provider is still named — once, in scope, as the data source (territoriesLine), which is what it
875
+ // is. A card states the office, the kind of right, its status, its owner and its classes.
876
+ //
877
+ // STATUS IS THE RECORD'S OWN WORD, lower-cased for the sentence and never re-classified: "pending" here
878
+ // is the register's PENDING, not this renderer's reading of it. A record that states none says so.
879
+ const REGISTRATION_WORDS = /^(?:reg|registered|registration)/i;
880
+
881
+ /** "United States application (pending)" — the office and the kind of right, from the record's own fields. */
882
+ function registerRightPhrase(r) {
883
+ const office = territoryName(r?.territory, { bare: true });
884
+ const status = String(r?.status ?? '').trim();
885
+ // A right whose status names a registration is a registration; anything else is an application. The
886
+ // record's raw word rides in the bracket either way, so the reader never has to trust the reading.
887
+ const kind = REGISTRATION_WORDS.test(status) ? 'registration' : 'application';
888
+ const where = office ? `${office} ` : '';
889
+ // The bracket exists to show the register's OWN word where that word adds something. On a record whose
890
+ // status already names the kind ("REGISTERED" beside "registration") it adds nothing but noise, so it
891
+ // is dropped — the reader has the same fact either way, in one word instead of two.
892
+ // "registration (registered)" and "application (application)" say the kind twice. Both roots are
893
+ // tested rather than stemmed, because a stemmer that turns "registered" and "registration" into one
894
+ // token also turns "pending" and "pended" into one, and "(pending)" is a fact the reader wants.
895
+ const echoes = (kind === 'registration' && REGISTRATION_WORDS.test(status))
896
+ || (kind === 'application' && /^(?:appl|filed?)/i.test(status));
897
+ const bracket = status && !echoes ? ` (${status.toLowerCase()})` : '';
898
+ return `${where}${kind}${bracket}`.trim();
899
+ }
900
+
717
901
  function registerStatement(r, provider) {
718
- const what = r.matchedBasis === 'close'
719
- ? `A filing on ${provider} found under the close variation ${r.matchedForm ?? '—'}`
720
- : `A filing of the identical name on ${provider}`;
902
+ const name = String(r?.mark ?? r?.name ?? '').trim();
721
903
  const classes = (Array.isArray(r.classes) ? r.classes : []).filter((n) => Number.isFinite(Number(n)));
722
- const bits = [
723
- r.owner ? `held by ${r.owner}` : 'proprietor not stated on the record',
724
- r.status ? `status ${r.status}` : 'status not stated on the record',
725
- classes.length ? `class${classes.length === 1 ? '' : 'es'} ${classes.join(', ')}` : 'classes not stated on the record',
726
- r.territory ? `territory ${r.territory}` : null,
727
- ].filter(Boolean);
728
- return `${what} ${bits.join(', ')}.`;
904
+ const lead = [name, registerRightPhrase(r)].filter(Boolean).join(', ');
905
+ const owner = r.owner ? `by ${r.owner}` : 'owner not stated on the record';
906
+ const cls = classes.length
907
+ ? `class${classes.length === 1 ? '' : 'es'} ${classes.join(', ')}`
908
+ : 'classes not stated on the record';
909
+ // A close-variation card says WHICH near-form matched, because the name at the head of the sentence is
910
+ // then not the name the client asked about and a reader must not have to guess which it is.
911
+ const via = r.matchedBasis === 'close' && r.matchedForm ? ` Found under the close variation ${r.matchedForm}.` : '';
912
+ return `${lead} ${owner}, ${cls}.${via}`;
729
913
  }
730
914
 
731
915
  /**
@@ -741,6 +925,72 @@ function registerStatement(r, provider) {
741
925
  * form would point a reader at a row that does not exist. Its receipt is the register record itself,
742
926
  * which the card links.
743
927
  */
928
+ /**
929
+ * WHICH OF A MARK'S REGISTER CARDS THE PAGE DRAWS (tracker issue 331 A.3).
930
+ *
931
+ * THIS FILTERS THE PAGE, NEVER THE RECORD, and that separation is the whole point. 331 rejects dropping
932
+ * anything from report-data.json or the workbook to make the page shorter, so `registerCardViews` still
933
+ * builds every promoted filing and still assigns the ordinals it always did — knockoutReportData reads
934
+ * that, unchanged. This function decides only what is DRAWN.
935
+ *
936
+ * Keeping one projection also keeps the flag path honest: portal-service resolves a flag as
937
+ * `marks[i].findings.find(f => f.ordinal === ordinal)`, so a card on the page carries the ordinal its
938
+ * own data entry has. A card the page does not draw cannot be flagged, because there is nothing to click.
939
+ *
940
+ * ONLY AN EXPLICIT LOWEST-RUNG BAND SUPPRESSES A CARD. Absence never does, and that is the whole rule —
941
+ * there is no second condition and no archived-run special case, because this one covers both.
942
+ *
943
+ * The first cut read an absent band as "lowest" and needed a clause-F escape hatch beside it: if no
944
+ * filing on the mark carried a band, keep them all. That escape hatch is what a wrong default looks
945
+ * like. It handled the run with NO bands and silently mishandled the run with SOME: `band` is optional
946
+ * per row, so a rater who bands the filings that matter and leaves the rest alone is doing what the
947
+ * shape invites — and the mark whose one typed band happened to be the bottom rung lost every card it
948
+ * had, including the filings nobody ruled on.
949
+ *
950
+ * `atLowestBand` below already argues this, one function down: a band it cannot place on the ladder
951
+ * is kept, because "the rater said something, and the safe reading of something we cannot rank is that
952
+ * it is worth pointing at". An absent band is strictly LESS information than an unrankable one, so if
953
+ * unknown keeps, absent keeps. Dropping it would be this renderer deciding a filing is immaterial on
954
+ * the rater's behalf — the same claim tracker issue 274 removed from these cards for the same reason.
955
+ *
956
+ * A run that carries no bands at all still renders every card it was delivered with, which is what
957
+ * clause F asks for; it now falls out of the rule instead of being carved around it.
958
+ */
959
+ function registerCardsOnPage(cards, mark, framework) {
960
+ return cards.filter((v) => !atLowestBand(framework, readBandFor(mark, v?.record?.recordId)));
961
+ }
962
+
963
+ /**
964
+ * DID THE RATER PUT THIS FILING ON THE LADDER'S LOWEST RUNG? (tracker issue 331 A.3)
965
+ *
966
+ * The ladder runs worst-first, so the lowest rung is the LAST entry. A filing the rater put there is one
967
+ * the rater called manageable, and 331 keeps those in the filings table rather than on a card: three
968
+ * cards whose own text says the filing does not bear materially on the rating cost the reader more than
969
+ * they told him.
970
+ *
971
+ * Stated as "is it AT the lowest rung" rather than "is it above it", because that is the only direction
972
+ * a caller may act on. Every other answer — a rung higher up, a word this build cannot place on the
973
+ * run's own ladder, no band at all — is a filing that keeps its card. An unplaceable word is not a band
974
+ * this function may quietly demote: the rater said something, and the safe reading of something we
975
+ * cannot rank is that it is worth pointing at. Absence says even less, so it keeps the card too.
976
+ */
977
+ function atLowestBand(framework, band) {
978
+ const ladder = Array.isArray(framework?.bands) ? framework.bands : [];
979
+ const word = String(band ?? '').trim().toLowerCase();
980
+ if (!word || !ladder.length) return false;
981
+ const lowest = String(ladder[ladder.length - 1]?.label ?? '').trim().toLowerCase();
982
+ return Boolean(lowest) && word === lowest;
983
+ }
984
+
985
+ /** The rater's band for one filing, off the row that names it. `null` when the rater gave none. */
986
+ function readBandFor(mark, recordId) {
987
+ const id = String(recordId ?? '').trim();
988
+ if (!id) return null;
989
+ const row = (Array.isArray(mark?.registerReads) ? mark.registerReads : [])
990
+ .find((x) => String(x?.recordId ?? '').trim() === id);
991
+ return String(row?.band ?? '').trim() || null;
992
+ }
993
+
744
994
  function registerCardViews(mark, framework, registerRecords) {
745
995
  if (!registerRecords || registerRecords.unavailable) return { cards: [], entry: null, promoted: 0 };
746
996
  const entry = recordsForMark(registerRecords, mark?.name);
@@ -819,7 +1069,7 @@ function sourceChips(v) {
819
1069
  // already defines — the same one the clearance report uses for this material — so a reader can see whose
820
1070
  // voice a line is in. Merging them into the client-voiced body would make the reviewer's asides read as
821
1071
  // findings about the mark, which is the one way this ruling could produce a worse document.
822
- const REVIEWER_NOTES_LEGEND = 'Information for your reference/context that likely does not need to be shared with the business is shown in purple.';
1072
+ const REVIEWER_NOTES_LEGEND = 'Purple notes are for the reviewing lawyer. Remove them before this goes to the client.';
823
1073
 
824
1074
  // The clearance lane's own label, copied rather than re-worded (tracker issue 276). One spelling across
825
1075
  // both products is the point: a reader who has seen it on a clearance report knows what it means here.
@@ -833,12 +1083,46 @@ function ownerCheckFor(ownerChecks, recordId) {
833
1083
  .find((c) => (Array.isArray(c?.recordIds) ? c.recordIds : []).some((x) => String(x).trim() === id)) ?? null;
834
1084
  }
835
1085
 
1086
+ // The two-place sort lives in findings-model.mjs (splitKnockoutNotes) because the predelivery lint
1087
+ // needs the same answer: the page files each note, and the reviewer warns a writer whose note will file
1088
+ // the way they did not intend. One reader, so the two can never disagree.
1089
+ /**
1090
+ * "About this request" — what was asked, and any flag on the asking (tracker issue 331 A.1).
1091
+ *
1092
+ * WHAT WAS ASKED IS CODE-OWNED. It is read off the run's own frozen instructed scope, never off model
1093
+ * prose, so the line states the request the run was given rather than a paraphrase of it. An archived run
1094
+ * that carries no such sidecar renders the flags alone, and a run with neither renders nothing at all —
1095
+ * the block never appears empty and never appears with a heading over nothing.
1096
+ *
1097
+ * THE FLAGS ARE THE REVIEWER'S OWN WORDS, unedited. This surface moves them; it does not rewrite them.
1098
+ * They keep the purple convention here exactly as they wear it under the cards, which is also what keeps
1099
+ * them off the export: the strip is one rule on .internal and it reaches both places.
1100
+ */
1101
+ function aboutRequestBlock(scope, requestNotes) {
1102
+ const goods = String(scope?.goods ?? '').trim();
1103
+ const classes = (Array.isArray(scope?.classes) ? scope.classes : []).filter((c) => c || c === 0);
1104
+ const jx = (Array.isArray(scope?.jurisdictions) ? scope.jurisdictions : []).map((t) => territoryName(t)).filter(Boolean);
1105
+ const asked = [
1106
+ classes.length ? `Class${classes.length === 1 ? '' : 'es'} ${classes.join(', ')}` : '',
1107
+ goods,
1108
+ ].filter(Boolean).join(' — ');
1109
+ const where = jx.length ? `Searched in ${listWords(jx)}.` : '';
1110
+ const flags = (requestNotes ?? []).filter(Boolean);
1111
+ if (!asked && !where && !flags.length) return '';
1112
+ return `<div class="ko-req">
1113
+ <span class="ko-lbl2">About this request</span>
1114
+ ${asked ? `<p>${esc(asked)}</p>` : ''}
1115
+ ${where ? `<p>${esc(where)}</p>` : ''}
1116
+ ${flags.length ? `<div class="internal ko-reqflag">${
1117
+ flags.map((n) => `<p class="ko-bul">${inlineMd(n)}</p>`).join('')}</div>` : ''}
1118
+ </div>`;
1119
+ }
1120
+
836
1121
  function reviewerNotesBlock(m) {
837
- const notes = (Array.isArray(m?.purpleNotes) ? m.purpleNotes : [])
838
- .map((n) => String(n ?? '').trim()).filter(Boolean);
1122
+ const notes = splitKnockoutNotes(m).name;
839
1123
  if (!notes.length) return '';
840
1124
  return `<div class="internal">
841
- <span class="tag">For your reference</span>
1125
+ <span class="tag">For the reviewing lawyer</span>
842
1126
  ${notes.map((n) => `<p class="ko-bul">${inlineMd(n)}</p>`).join('')}
843
1127
  </div>`;
844
1128
  }
@@ -1003,34 +1287,117 @@ function coverageClause(mark, registerCounts, probeRan) {
1003
1287
  // NOTHING IS DISCARDED. Where a run carries both the typed fields and prose bullets the bullets survive
1004
1288
  // under a collapsed "Full narrative" — the reader who wants the long form still has it, one click away,
1005
1289
  // and no sentence the stage wrote is dropped on the floor.
1006
- function readBlock(m) {
1290
+ /**
1291
+ * THE LABELS, IN THE READER'S WORDS (tracker issue 331 A.2).
1292
+ *
1293
+ * The old pair was "What holds it there" and "What would move it", 9.5px grey capitals. The owner read
1294
+ * the first one and asked "what holds what?" — so the fix is not a synonym in the same style, it is a
1295
+ * label that carries the band's own word and answers the question by itself.
1296
+ *
1297
+ * factors → "Why High" (the mark's own band)
1298
+ * counterFactors → "Why not Very High" (the rung ABOVE it on the run's own ladder)
1299
+ * mitigation → "What would lower the risk"
1300
+ *
1301
+ * The ladder runs worst-first, so the rung above is the PREVIOUS entry. At the top rung there is no
1302
+ * higher band to name and the honest label is "What keeps it here" — the same question, asked where the
1303
+ * comparative form has no answer. A band this build cannot find on the ladder gets that label too,
1304
+ * rather than a comparative naming a rung that may not exist.
1305
+ */
1306
+ function counterLabel(framework, band) {
1307
+ const ladder = Array.isArray(framework?.bands) ? framework.bands : [];
1308
+ const i = ladder.findIndex((b) => String(b?.label ?? '').trim().toLowerCase() === String(band ?? '').trim().toLowerCase());
1309
+ const up = i > 0 ? String(ladder[i - 1]?.label ?? '').trim() : '';
1310
+ return up ? `Why not ${up}` : 'What keeps it here';
1311
+ }
1312
+
1313
+ const ASSESSMENT_FOLD_LABEL = 'Read the full assessment';
1314
+
1315
+ /** The scope block's own words, as one string, so the caveat filter reads exactly what the page prints. */
1316
+ const SCOPE_BLOCK_TEXT = 'What this is. A fast screen for obvious blockers to using each name, from '
1317
+ + 'marketplace and web use plus a count of register filings. What it is not. A clearance search. We '
1318
+ + 'drew no register conclusions and give no filing advice. A name that passes here is not clear; it '
1319
+ + 'goes on to clearance. Every conflict above links to the material we found. The audit workbook holds '
1320
+ + 'every search run, every empty result and the working notes. Register data.';
1321
+
1322
+ /** Words that carry no claim, so their presence or absence says nothing about what a sentence asserts. */
1323
+ const STOPWORDS = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'been', 'but', 'by', 'can', 'do',
1324
+ 'does', 'each', 'for', 'from', 'has', 'have', 'here', 'in', 'is', 'it', 'its', 'no', 'not', 'of', 'on',
1325
+ 'or', 'that', 'the', 'their', 'them', 'there', 'these', 'they', 'this', 'to', 'up', 'was', 'we', 'were',
1326
+ 'what', 'when', 'which', 'will', 'with', 'you', 'your']);
1327
+
1328
+ const contentWords = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ')
1329
+ .split(/\s+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
1330
+
1331
+ /**
1332
+ * Does this line assert anything the reference text does not already assert?
1333
+ *
1334
+ * TRUE unless every content word in the line is already in the reference. An empty line has nothing to
1335
+ * say and returns false; a line with one unfamiliar word is kept. Singular/plural is folded so that
1336
+ * "conclusion" does not read as new beside "conclusions".
1337
+ */
1338
+ function saysSomethingNew(line, reference) {
1339
+ // The stem must be IDEMPOTENT on the singular, or the fold does nothing: an earlier form stripped
1340
+ // "es" and turned "gives" into "giv" while leaving "give" alone, so the two never matched and every
1341
+ // caveat looked new. Strip one trailing "s" and nothing else.
1342
+ const stem = (w) => w.replace(/ies$/, 'y').replace(/s$/, '');
1343
+ const known = new Set(contentWords(reference).map(stem));
1344
+ const words = contentWords(line);
1345
+ if (!words.length) return false;
1346
+ return words.some((w) => !known.has(stem(w)));
1347
+ }
1348
+
1349
+ function readBlock(m, framework) {
1007
1350
  const factors = (m.factors ?? []).filter((s) => typeof s === 'string' && s.trim());
1008
1351
  const counter = (m.counterFactors ?? []).filter((s) => typeof s === 'string' && s.trim());
1009
1352
  const structured = Boolean(m.basis || factors.length || counter.length || m.mitigation);
1010
1353
  const bullets = (m.bullets ?? []).map((b) => `<li>${inlineMd(b)}</li>`).join('');
1011
1354
  if (!structured) return bullets ? `<ul class="ko-bul">${bullets}</ul>` : '';
1355
+ const assessment = String(m.assessment ?? '').trim();
1356
+ const band = String(m.rating ?? '').trim();
1012
1357
  return [
1013
1358
  m.basis ? `<p class="ko-basisline">${inlineMd(m.basis)}</p>` : '',
1014
- factors.length ? `<ul class="ko-bul">${factors.map((f) => `<li>${inlineMd(f)}</li>`).join('')}</ul>` : '',
1015
- counter.length ? `<div class="ko-counter"><span class="ko-lbl">What holds it there</span><ul class="ko-bul">${
1359
+ factors.length ? `<div class="ko-why-band">${band ? `<span class="ko-lbl2">Why ${esc(band)}</span>` : ''}<ul class="ko-bul">${
1360
+ factors.map((f) => `<li>${inlineMd(f)}</li>`).join('')}</ul></div>` : '',
1361
+ counter.length ? `<div class="ko-counter"><span class="ko-lbl2">${esc(counterLabel(framework, band))}</span><ul class="ko-bul">${
1016
1362
  counter.map((f) => `<li>${inlineMd(f)}</li>`).join('')}</ul></div>` : '',
1017
- m.mitigation ? `<div class="ko-mitig"><span class="ko-lbl">What would move it</span><p>${inlineMd(m.mitigation)}</p></div>` : '',
1018
- bullets ? `<details class="ko-full"><summary>Full narrative</summary><ul class="ko-bul">${bullets}</ul></details>` : '',
1363
+ m.mitigation ? `<div class="ko-mitig"><span class="ko-lbl2">What would lower the risk</span><p>${inlineMd(m.mitigation)}</p></div>` : '',
1364
+ // ── THE ASSESSMENT REPLACES THE "FULL NARRATIVE" FOLD (tracker issue 331 A.2) ────────────────────
1365
+ //
1366
+ // The model writes both. `bullets` (five) say in other words what `factors` (four) already say
1367
+ // visible above; `assessment` is the long-form read under its own four headings, and until now the
1368
+ // page rendered it NOWHERE — validated, carried in report-data.json and the workbook, never shown.
1369
+ // The owner found the bullets fold by accident and called it the most useful block on the page; the
1370
+ // assessment is the better version of the same thing.
1371
+ //
1372
+ // `bullets` is NOT dropped from the record — knockoutReportData still carries it and the workbook
1373
+ // still writes it. What retires is one fold on one page, replaced by a longer answer to the same
1374
+ // question. 331 rejects dropping the field itself, and this does not.
1375
+ //
1376
+ // CLAUSE F. An archived run carries bullets and no assessment: it keeps the fold it was delivered
1377
+ // with, under its own old label. The fallback is the old block, never an empty new one.
1378
+ assessment
1379
+ ? `<details class="ko-full"><summary>${esc(ASSESSMENT_FOLD_LABEL)}</summary><div class="ko-assess">${mdParagraphs(assessment)}</div></details>`
1380
+ : (bullets ? `<details class="ko-full"><summary>Full narrative</summary><ul class="ko-bul">${bullets}</ul></details>` : ''),
1019
1381
  ].filter(Boolean).join('');
1020
1382
  }
1021
1383
 
1022
1384
  function analysisSection(marks, framework, { registerCounts = null, probeRan = false, registerRecords = null, ownerChecks = [] } = {}) {
1023
1385
  const cards = marks.map((m, markIndex) => {
1024
1386
  const stop = bandStop(framework, m.rating);
1025
- const bullets = readBlock(m);
1387
+ const bullets = readBlock(m, framework);
1026
1388
  // — the promoted register filings, in the SAME list as the common-law conflicts. They render
1027
1389
  // after the typed findings and that position is not a rank: a card with no band already sorts last
1028
1390
  // under compareKnockoutBlockingPower, so appending them is the ladder's own rule applied to a row
1029
1391
  // that has no rung, not a new one invented for the register.
1030
1392
  const reg = registerCardViews(m, framework, registerRecords);
1031
- const regBlocks = reg.cards.map((v) => registerFindingBlock(v, markIndex, m?.registerReads, framework, ownerChecks)).join('');
1032
- const overflow = reg.promoted > reg.cards.length
1033
- ? `<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>`
1393
+ const drawn = registerCardsOnPage(reg.cards, m, framework);
1394
+ const regBlocks = drawn.map((v) => registerFindingBlock(v, markIndex, m?.registerReads, framework, ownerChecks)).join('');
1395
+ // The overflow counts what is NOT on the page against what met the promotion test — cards held back
1396
+ // by the cap and cards the rater put on the lowest rung alike, because from the reader's side they
1397
+ // are the same fact: further filings for this name, all of them listed below.
1398
+ const held = reg.promoted - drawn.length;
1399
+ const overflow = held > 0
1400
+ ? `<p class="ko-bul">${esc(`${held} further filing${held === 1 ? '' : 's'} for this name met the same test — every one of them is in the filings section below.`)}</p>`
1034
1401
  : '';
1035
1402
  // THE DEFECT THIS CLOSES. This line was `(m.findings ??).filter((f) => f?.url)`, and a
1036
1403
  // typed finding has no `url` — so every conflict was dropped from the page and a mark whose findings
@@ -1121,87 +1488,6 @@ function depthStrip(note) {
1121
1488
  // says what the lines under it are. It is not a filter and it rewrites no caveat.
1122
1489
  const CAVEAT_LEAD = 'This screen also carries the following limits:';
1123
1490
 
1124
- /**
1125
- * WHAT THIS RUN SEARCHED AND DID NOT, FROM THE RUN'S OWN POLICY.
1126
- *
1127
- * The line this replaces was composed from `registerCounts` — an ARTIFACT — and said "Common-law/
1128
- * marketplace screen only, register searches are addressed separately" whenever no counts were present.
1129
- * That reads the absence of a result as a statement of scope, which is the class this repo keeps paying
1130
- * for: a run INSTRUCTED to take register counts whose provider refused printed the same sentence as a
1131
- * run never instructed to take them at all. One of those is a scope statement and the other is a gap in
1132
- * the record, and a client cannot tell them apart.
1133
- *
1134
- * The clearance report composes its scope from the run's frozen components (`productCoverageNote`,
1135
- * search-policy.mjs) and this now does the same, from the same object, so the two lanes cannot describe
1136
- * the same component in different words.
1137
- *
1138
- * THE ARTIFACT IS STILL REPORTED — separately, and as what it is. Where the policy says a component ran
1139
- * and its output is missing, that is disclosed as a gap rather than folded into "we did not do it".
1140
- *
1141
- * @returns {string[]} lines, most load-bearing first. Empty when there is no policy to read, and the
1142
- * caller falls back to the artifact-derived sentence rather than printing nothing.
1143
- */
1144
- export function knockoutScopeLines(policy, { registerCounts = null, probeRan = false } = {}) {
1145
- const c = policy?.components;
1146
- if (!c) return [];
1147
- const lines = [];
1148
- lines.push('This is a screen, not a clearance search: it triages names against use we can see, '
1149
- + 'and makes no filing recommendation.');
1150
- if (c.registerProbe) {
1151
- const who = registerCounts?.providerLabel ?? registerCounts?.provider ?? null;
1152
- // THE BASIS RIDES WITH THE COMPONENT THAT NEEDS IT, in methodLine's own words rather than new ones.
1153
- // Composing this section from the policy moved methodLine off the page for every run that HAS a
1154
- // policy — which is every live run — and took COUNT_BASIS and "Counting is not searching" with it.
1155
- // That is the sentence saying a count weighs nothing and decides nothing, on the page that prints
1156
- // the counts. `runner.knockout-e2e.test.mjs` caught it and this branch called it pre-existing;
1157
- // it is not — origin/main's renderer passes that arm 3/3 in the same worktree.
1158
- lines.push(registerCounts
1159
- ? `Registers were searched for a hit-count per name${who ? ` on ${who}` : ''}, and the filings behind `
1160
- + `the narrow counts are listed below. ${COUNT_BASIS} Counting is not searching: no register `
1161
- + `conclusions, no filing advice, and a full register clearance is a separate piece of work.`
1162
- : 'Registers were searched for a hit-count per name, and no counts reached this report — '
1163
- + 'that is a gap in the record for this run, not a statement that registers were left out.');
1164
- } else if (c.registerProbe === false) {
1165
- lines.push(probeRan
1166
- ? 'Registers were not part of this search. A register count screen ran separately; full register analysis is its own piece of work.'
1167
- : 'Registers were not searched in this search.');
1168
- }
1169
- // A COMPONENT THE POLICY DOES NOT MENTION IS NOT A COMPONENT THAT WAS OFF. Same rule as the missing
1170
- // policy above, one level down: `undefined` is silence, and silence is not `false`. Reached only when
1171
- // `components` arrives without this key — every row in search-policy.mjs states all three, so the
1172
- // product table cannot produce it; `{ ...policyFor(product)?.components, ...r.components }` can, when
1173
- // the product resolves to nothing. Driven before this line existed: an empty `components` printed
1174
- // "Registers were not searched in this search" on a report that was carrying a register hit-count
1175
- // table, which is the document contradicting itself in the section that exists to state scope. Saying
1176
- // nothing is the honest answer, and it needs no new sentence in front of a client.
1177
- //
1178
- // THE TWO BELOW STAY `!== true`, and the difference is the population rather than the principle.
1179
- // `registerProbe` is true on two knockout rows (knockout-search, knockout-register), so silence about
1180
- // it is genuinely ambiguous. `commonLawGrid` and `jxLanes` are false on EVERY row this template
1181
- // renders — they are what the clearance search is — so silence there states a thing that is true of
1182
- // the whole population, and making them conditional would drop a disclosure the clearance report
1183
- // makes about itself. Symmetry here would cost a client the sentence naming the lane they did not buy.
1184
- // Stated because the clearance report states its own absence of them, in these words. A reader
1185
- // comparing two documents must not have to infer that a lane is missing from its silence.
1186
- if (c.commonLawGrid !== true) {
1187
- lines.push('The systematic common-law grid sweep is not part of this search — that is the clearance search.');
1188
- }
1189
- if (c.jxLanes !== true) {
1190
- lines.push('The per-jurisdiction native-script deep dive is not part of this search.');
1191
- }
1192
- return lines;
1193
- }
1194
-
1195
- function methodLine(registerCounts, probeRan, { citedFindings = 0, uncitedFindings = 0 } = {}) {
1196
- const receipts = (citedFindings && !uncitedFindings)
1197
- ? ` Every conflict named above cites material held in this run's own research payload for that name.`
1198
- : '';
1199
- return (registerCounts
1200
- ? `Common-law/marketplace screen, plus a register HIT-COUNT per name on ${registerCounts.providerLabel ?? registerCounts.provider ?? 'the register'}. ${COUNT_BASIS} `
1201
- + `Counting is not searching: no register conclusions, no filing advice, and a full register clearance is a separate piece of work.`
1202
- : `Common-law/marketplace screen only — register searches are addressed separately${probeRan ? ' (a register count screen ran; full register analysis follows separately)' : ''}. `
1203
- + `A knockout screen is triage, not a clearance search: no register conclusions, no filing advice.`) + receipts;
1204
- }
1205
1491
 
1206
1492
  /**
1207
1493
  * Render the knockout report.
@@ -1231,6 +1517,12 @@ export function renderKnockoutHtml(findings, framework, {
1231
1517
  // instructed to do rather than what its artifacts happen to show. Defaults to null, and an archived
1232
1518
  // run that carries none keeps the artifact-derived sentence it was delivered with.
1233
1519
  searchPolicy = null,
1520
+ // — the run's own frozen instructed scope (tracker issue 331 A.1): the goods, classes and
1521
+ // territories the requester asked for, in the requester's words. Defaults to null, and an archived run
1522
+ // that carries no such sidecar renders "About this request" from its request-level notes alone, or not
1523
+ // at all when it has none. Never derived from model prose — this is what was ASKED, not what was said
1524
+ // about it.
1525
+ instructedScope = null,
1234
1526
  // — the run's frozen delivery overlay. It defaults to null, which is the NO-OPINION state and
1235
1527
  // renders the same plain "Privileged & Confidential" this template always printed — so the ~15 unit
1236
1528
  // fixtures and both render-check scripts, none of which pass one, are unchanged by its arrival.
@@ -1257,7 +1549,12 @@ export function renderKnockoutHtml(findings, framework, {
1257
1549
  const allViews = marks.flatMap((m) => knockoutFindingViews(m, { manifest: framework }));
1258
1550
  const citedFindings = allViews.filter((v) => v.evidence.length).length;
1259
1551
  const uncitedFindings = allViews.length - citedFindings;
1260
- const glance = glanceSection(marks, framework, registerCounts);
1552
+ // ── A ONE-NAME PAGE HAS NO INDEX (tracker issue 331 A.6) ────────────────────────────────────────
1553
+ //
1554
+ // The glance row exists to let a reader of a BATCH find the name they care about. With one name there
1555
+ // is nothing to index, and the row printed that name, its band and its classes for the first of three
1556
+ // times before the reader reached a single sentence of reading. On a batch it stays exactly as it was.
1557
+ const glance = marks.length > 1 ? glanceSection(marks, framework, registerCounts) : '';
1261
1558
  const hasCounts = Boolean(registerCounts?.marks?.length);
1262
1559
  // The register block is a table when counts were taken and a SENTENCE when they were not — never
1263
1560
  // nothing. It renders in the place the numbers would have occupied, at the top of the section that
@@ -1266,7 +1563,24 @@ export function renderKnockoutHtml(findings, framework, {
1266
1563
  const counts = hasCounts
1267
1564
  ? countsSection(marks, registerCounts)
1268
1565
  : `<div class="panel"><p class="ko-tier">${esc(tierLine)}</p></div>`;
1566
+ // ── A CAVEAT THAT SAYS NOTHING THE SCOPE BLOCK HAS NOT SAID IS NOT RENDERED (tracker issue 331 E) ──
1567
+ //
1568
+ // The four model-written caveats on the measured run overlap the fixed text completely — 331 item 9,
1569
+ // and 333 rule 4 ("say it once") fixes it at the source for runs written under that doctrine. This is
1570
+ // the reader for the ones already written.
1571
+ //
1572
+ // THE TEST IS SUBSET, NOT SIMILARITY, and the difference is the whole safety argument. A caveat is
1573
+ // dropped only when EVERY content word in it already appears in the scope block. A caveat making any
1574
+ // new claim brings at least one new content word with it and is kept — so this cannot silently delete
1575
+ // a disclosure, which is the one failure that would matter on a client-facing page. A similarity score
1576
+ // could; that is why there isn't one.
1577
+ const newCaveats = caveats.filter((c) => saysSomethingNew(c, SCOPE_BLOCK_TEXT));
1269
1578
  const analysis = analysisSection(marks, framework, { registerCounts, probeRan, registerRecords, ownerChecks });
1579
+ // The request-level notes, gathered across every mark on the document and rendered ONCE at the top.
1580
+ // They are about the asking, not about a name, so a batch repeating them per mark would be the same
1581
+ // sentence three times. reviewerNotesBlock renders the rest, under that mark's own cards.
1582
+ const requestNotes = marks.flatMap((m) => splitKnockoutNotes(m).request);
1583
+ const aboutRequest = aboutRequestBlock(instructedScope, requestNotes);
1270
1584
  const provider = hasCounts ? (registerCounts.providerLabel ?? registerCounts.provider ?? 'the register') : null;
1271
1585
  // The filings appendix renders only when the run produced a listing artifact — never on its absence,
1272
1586
  // and never as an empty table. A knockout with no sidecar publishes exactly the counts-only document.
@@ -1277,7 +1591,10 @@ export function renderKnockoutHtml(findings, framework, {
1277
1591
  // promoted filing that sentence sent a reader to a row that does not exist. A false pointer on the
1278
1592
  // audit trail is worse than no pointer, and the honest fix is to narrow the claim rather than mint a
1279
1593
  // second drill-through key for a card whose receipt is the register record itself.
1280
- const registerCardCount = marks.reduce((n, m) => n + registerCardViews(m, framework, registerRecords).cards.length, 0);
1594
+ // Counted over what the page DRAWS: the sentence explains a REG reference a reader can see, so a run
1595
+ // whose register cards are all held back needs no sentence about them.
1596
+ const registerCardCount = marks.reduce((n, m) =>
1597
+ n + registerCardsOnPage(registerCardViews(m, framework, registerRecords).cards, m, framework).length, 0);
1281
1598
  const hasRecords = Boolean(registerRecords && (registerRecords.marks?.length || registerRecords.unavailable));
1282
1599
  const filings = hasRecords ? filingsSection(marks, registerRecords) : '';
1283
1600
  const onFieldSec = `<div class="sec"><span class="num">${num()}</span><h2>On-field conflicts</h2><span class="note">${hasCounts ? `register hit-counts (${esc(provider)}) and the read, per name` : 'what we found, per name'}</span></div>
@@ -1338,6 +1655,7 @@ window.addEventListener('beforeprint',o);})();</script>
1338
1655
  ${depthStrip(depthNote)}
1339
1656
  <h1 class="mark">${esc(title)}</h1>
1340
1657
  ${productContext ? `<p class="ko-classes" style="font-size:13.5px;margin:0 0 16px">${inlineMd(productContext)}</p>` : ''}
1658
+ ${aboutRequest}
1341
1659
  ${summary ? `<div class="sub">${mdParagraphs(summary)}</div>` : ''}
1342
1660
  </header>
1343
1661
 
@@ -1359,14 +1677,31 @@ window.addEventListener('beforeprint',o);})();</script>
1359
1677
  // FROM THE POLICY WHERE THERE IS ONE (tracker issue 1935). The fallback is not a tidy default: an
1360
1678
  // archived run froze no policy, and re-rendering it must not invent a scope claim about a run
1361
1679
  // nobody can now ask. It keeps the sentence it was delivered with.
1362
- const lines = knockoutScopeLines(searchPolicy, { registerCounts, probeRan });
1363
- if (!lines.length) return `<p class="ko-scope">${esc(methodLine(registerCounts, probeRan, { citedFindings, uncitedFindings }))}</p>`;
1364
- const receipts = citedFindings && !uncitedFindings
1365
- ? '<p class="ko-scope">Every conflict named above cites material held in this run\'s own research payload for that name.</p>'
1680
+ // ── THE SCOPE BLOCK, WHOLE (tracker issue 331 E) ──────────────────────────────────────────────
1681
+ //
1682
+ // The composed lines said one thing five times in 362 words: "not a clearance" three times,
1683
+ // "proceeds to clearance" twice, and the count-basis sentence a second time after the counts table
1684
+ // had already carried it. This is the owner's replacement text, and its two halves are the two
1685
+ // questions a reader of a screen actually has.
1686
+ //
1687
+ // "Every conflict above links to the material we found" STAYS CONDITIONAL and that is not a
1688
+ // stylistic carry-over: said unconditionally it is an absence claim wider than what was examined,
1689
+ // which this file refuses elsewhere in the same words. It prints only when every rendered conflict
1690
+ // cites something.
1691
+ const linked = citedFindings && !uncitedFindings
1692
+ ? ' Every conflict above links to the material we found.'
1693
+ : '';
1694
+ const source = registerCounts
1695
+ ? ` Register data: ${registerCounts.providerLabel ?? registerCounts.provider ?? 'the register'}.`
1366
1696
  : '';
1367
- return `${lines.map((l) => `<p class="ko-scope">${esc(l)}</p>`).join('')}${receipts}`;
1697
+ return `<p class="ko-scope"><b>What this is.</b> A fast screen for obvious blockers to using each `
1698
+ + `name, from marketplace and web use plus a count of register filings.</p>`
1699
+ + `<p class="ko-scope"><b>What it is not.</b> A clearance search. We drew no register conclusions `
1700
+ + `and give no filing advice. A name that passes here is not clear; it goes on to clearance.</p>`
1701
+ + `<p class="ko-scope">${linked.trim()}${linked ? ' ' : ''}The audit workbook holds every search run, `
1702
+ + `every empty result and the working notes.${source}</p>`;
1368
1703
  })()}
1369
- ${caveats.length ? `<p class="ko-scope" style="border-top:1px solid var(--line)">${CAVEAT_LEAD}<br>${caveats.map((c) => inlineMd(c)).join('<br>')}</p>` : ''}
1704
+ ${newCaveats.length ? `<p class="ko-scope" style="border-top:1px solid var(--line)">${CAVEAT_LEAD}<br>${newCaveats.map((c) => inlineMd(c)).join('<br>')}</p>` : ''}
1370
1705
  ${auditFile ? `<p class="ko-scope" style="border-top:1px solid var(--line)"><a href="${escAttr(auditFile)}">Download the audit workbook (Excel)</a> — every search run, every negative result, and the working notes behind these ratings.${
1371
1706
  citedFindings ? ` The reference beside each common-law conflict above (for example <span class="mono">${esc(firstRef(marks, framework))}</span>) is its row on the workbook's Findings sheet.${
1372
1707
  registerCardCount ? ` A <span class="mono">REG</span> reference is a register filing rather than a common-law conflict — it has no Findings row, and its receipt is the register record the card links.` : ''}` : ''}</p>` : ''}