clearotron 0.2.3 → 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 (76) hide show
  1. package/.env.example +36 -37
  2. package/CONTRIBUTING.md +8 -4
  3. package/INSTALL.md +24 -4
  4. package/README.md +7 -6
  5. package/bin/example.mjs +6 -5
  6. package/bin/onboard.mjs +175 -9
  7. package/bin/start.mjs +66 -4
  8. package/build-info.json +2 -2
  9. package/demo/README.md +1 -1
  10. package/docs/GLOSSARY.md +85 -0
  11. package/docs/README.md +1 -0
  12. package/docs/architecture/01-product-overview.md +21 -9
  13. package/docs/architecture/05-config-governance.md +5 -0
  14. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  15. package/driver/CHANGELOG.md +34 -0
  16. package/driver/README.md +25 -6
  17. package/driver/connotation-search.mjs +1 -1
  18. package/driver/contract-audit.mjs +5 -1
  19. package/driver/contract-e3-baseline.json +11 -11
  20. package/driver/doubt-selection.mjs +1 -1
  21. package/driver/drainer-identity.mjs +1 -1
  22. package/driver/effort-model.mjs +2 -2
  23. package/driver/engine/probe.mjs +45 -5
  24. package/driver/gateway.mjs +2 -2
  25. package/driver/outbox-backoff.mjs +1 -1
  26. package/driver/package.json +1 -1
  27. package/driver/pipeline.mjs +206 -101
  28. package/driver/plain-register.mjs +16 -2
  29. package/driver/portal-config-view.mjs +30 -1
  30. package/driver/portal-local-auth.mjs +5 -1
  31. package/driver/portal-service.mjs +53 -2
  32. package/driver/predelivery-lint.mjs +54 -25
  33. package/driver/publish/render.mjs +109 -14
  34. package/driver/search-policy.mjs +1 -1
  35. package/driver/stage-context.mjs +13 -0
  36. package/driver/stages.mjs +51 -4
  37. package/driver/suite-census.json +97 -31
  38. package/driver/systemd/clearotron-worker.service +3 -3
  39. package/driver/tokens.mjs +1 -1
  40. package/driver/unit-inventory.mjs +34 -4
  41. package/mcp-server/CHANGELOG.md +2 -0
  42. package/mcp-server/package.json +1 -1
  43. package/package.json +4 -10
  44. package/portal-ui/dist/assets/{index-CEYvXdqH.js → index-KFAHMgdT.js} +73 -34
  45. package/portal-ui/dist/index.html +1 -1
  46. package/portal-ui/package.json +1 -1
  47. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  48. package/providers/oauth-mcp-bridge/package.json +1 -1
  49. package/scripts/added-reference-check.mjs +45 -89
  50. package/scripts/e2e.mjs +2 -2
  51. package/scripts/generated-files-are-current.mjs +125 -0
  52. package/scripts/mint-names-in-force.mjs +4 -2
  53. package/scripts/mint-public-residue.mjs +74 -0
  54. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  55. package/scripts/mint-suite-census.mjs +5 -2
  56. package/scripts/render-check.mjs +13 -2
  57. package/scripts/report-frame-check.mjs +8 -1
  58. package/scripts/report-screenshot.mjs +6 -3
  59. package/scripts/revisit-render-check.mjs +6 -3
  60. package/scripts/score.mjs +1 -1
  61. package/scripts/strip-tracker-citations.mjs +9 -1
  62. package/scripts/test-full.mjs +226 -0
  63. package/shared/README.md +2 -1
  64. package/shared/browser-temp-root.mjs +142 -0
  65. package/shared/checkout-move.mjs +1 -1
  66. package/shared/client-door.mjs +4 -3
  67. package/shared/identifier-scan.mjs +1 -1
  68. package/shared/install-auth.mjs +35 -0
  69. package/shared/invocation.mjs +5 -1
  70. package/shared/listen.mjs +55 -3
  71. package/shared/names-in-force.mjs +4 -1
  72. package/shared/os-advice.mjs +91 -0
  73. package/shared/reap-on-exit.mjs +42 -0
  74. package/shared/reference-guard-classes.mjs +351 -0
  75. package/shared/suite-census.mjs +31 -3
  76. package/shared/withheld-paths-access.mjs +36 -18
@@ -49,6 +49,21 @@ export const PLAIN_FORMS = Object.freeze([
49
49
  /** The longest visible sentence a reader should meet. The issue's number, not a derived one. */
50
50
  export const SENTENCE_WORD_LIMIT = 25;
51
51
 
52
+ /**
53
+ * How a term in `PLAIN_FORMS` is looked for in prose — ONE definition, because two of them drift.
54
+ *
55
+ * THE INFLECTIONS ARE THE POINT, and they were the reason a second copy of this rule survived. The
56
+ * pre-delivery lint carried its own hand-tuned patterns — `\bproprietors?\b`, `\bprevails?\b|\bprevailing\b`
57
+ * — while this file built `\bproprietor\b` and matched neither plural. So the pinned source was the
58
+ * WEAKER of the two, and reading terms from it without this would have quietly narrowed what the live
59
+ * check catches: a consolidation that loses coverage is a regression wearing a tidy-up's clothes.
60
+ *
61
+ * A trailing `s`, `es`, `ed` or `ing` after the term, and a hyphen matching a space, which is how the
62
+ * same phrase is written in two documents by two people.
63
+ */
64
+ export const termMatcher = (term) => new RegExp(
65
+ `\\b${term.replace(/[-]/g, "[- ]").replace(/\s+/g, "\\s+")}(?:e?s|ed|ing)?\\b`, "i");
66
+
52
67
  /** Everything the run is ABOUT — the mark, its variants, the owners named. Never flagged. */
53
68
  const ownTerms = (about = {}) => {
54
69
  const out = [];
@@ -85,8 +100,7 @@ export function plainRegisterFlags(text, about = {}) {
85
100
 
86
101
  const flags = [];
87
102
  for (const [term, plain] of PLAIN_FORMS) {
88
- const re = new RegExp(`\\b${term.replace(/[-]/g, "[- ]").replace(/\s+/g, "\\s+")}\\b`, "i");
89
- if (!re.test(scan)) continue;
103
+ if (!termMatcher(term).test(scan)) continue;
90
104
  flags.push({
91
105
  kind: "vocabulary",
92
106
  term,
@@ -49,6 +49,11 @@ import { readFlagSnapshot, engineFor, providersFor, postureDisagreement } from "
49
49
  // reading is, because the question it was standing in for — does this still describe the box — now has
50
50
  // a direct answer in `lastRun.disagrees`.
51
51
  import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVED at read time, never stored
52
+ // THE ENGINE TABLE, READ FOR TWO WORDS. A row saying an engine cannot run has to name the program it
53
+ // could not find and the command that installs it, or the reader is told they have a problem and not
54
+ // what to do about it — and this table is already where the wizard and the run-door preflight read
55
+ // both of those, so naming them here adds no second description of an engine.
56
+ import { ENGINE_BINARIES } from "./driver.config.mjs";
52
57
 
53
58
  /**
54
59
  * The flag view.
@@ -60,6 +65,30 @@ import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVE
60
65
  // One projection, used for whichever posture is the answer. Extracted when the live posture became that
61
66
  // answer, so the LIVE reading and the LAST-RUN capture cannot be shaped differently and quietly invite a
62
67
  // reader to compare two things that were built by two rules.
68
+ /**
69
+ * The engine block, plus the two words a reader needs when it cannot run.
70
+ *
71
+ * DERIVED FROM THE ID AT READ TIME, never stored in the capture. A capture written by an older build
72
+ * carries neither field, and a page that read them out of the capture would go quiet about the engine
73
+ * on exactly the deployments most likely to be misconfigured. The id is in every capture there has
74
+ * ever been, and the table is in this build.
75
+ *
76
+ * NULL FOR AN ENGINE THIS BUILD DOES NOT SHIP, which the row already has its own sentence for. Naming
77
+ * a program for an engine that does not exist here would be an invented fact.
78
+ */
79
+ function withProgram(engine) {
80
+ if (!engine) return engine;
81
+ const spec = ENGINE_BINARIES[engine.id] ?? null;
82
+ return {
83
+ ...engine,
84
+ // A BARE NAME, NEVER A RESOLVED PATH. `fallback` is what the table calls the program when nothing
85
+ // overrides it — "claude" — and it is what a reader types. The resolved path is this machine's
86
+ // layout and is deliberately kept out of anything a browser renders.
87
+ program: spec?.fallback ?? null,
88
+ install: spec?.install ?? null,
89
+ };
90
+ }
91
+
63
92
  function postureView(snap) {
64
93
  return {
65
94
  flags: Object.entries(snap.flags ?? {}).map(([name, f]) => ({
@@ -72,7 +101,7 @@ function postureView(snap) {
72
101
  killSwitch: (snap.killSwitches ?? []).includes(name),
73
102
  })),
74
103
  built: snap.built ?? null,
75
- engine: engineFor(snap),
104
+ engine: withProgram(engineFor(snap)),
76
105
  engineMode: engineFor(snap) ? engineMode(engineFor(snap)) : null,
77
106
  providers: providersFor(snap),
78
107
  };
@@ -47,6 +47,7 @@ import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypt
47
47
  import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
48
48
  import { dirname, join } from "node:path";
49
49
  import { homedir } from "node:os";
50
+ import { envPrefix } from "../shared/os-advice.mjs";
50
51
 
51
52
  // ── the credential record ────────────────────────────────────────────────────────────────────────
52
53
  //
@@ -164,7 +165,10 @@ export function passphraseResetCommand({ prefix = "", credentialPath = null, env
164
165
  const base = `${prefix}clearotron passphrase --reset`;
165
166
  const path = credentialPath ?? env.PORTAL_LOCAL_CREDENTIAL ?? null;
166
167
  if (!path || path === credentialPathFor({}, home)) return base;
167
- return `PORTAL_LOCAL_CREDENTIAL=${path} ${base}`;
168
+ // `VAR=value cmd` IS POSIX-ONLY. PowerShell has no such juxtaposition — the assignment is its own
169
+ // statement there — so this line told a Windows reader their variable name was not a cmdlet, naming
170
+ // the wrong half of the command as the fault. Reported from a real run.
171
+ return `${envPrefix("PORTAL_LOCAL_CREDENTIAL", path)}${base}`;
168
172
  }
169
173
 
170
174
  /**
@@ -1333,6 +1333,48 @@ export function makePortalService({
1333
1333
  };
1334
1334
  const selectorOf = (body) => (body.recipeKey ? `recipe:${body.recipeKey}` : `product:${body.product || "the account's default"}`);
1335
1335
 
1336
+ // ── The engine program, read once, for every surface that reports it ───────────────────────────
1337
+ //
1338
+ // WHY /me NEEDS THIS AT ALL. The settings page can ask `/portal/admin/config`, which already computes
1339
+ // the live-versus-capture comparison. New clearance cannot: that route is staff-only, and the reader
1340
+ // who gets stuck here is as often a client. A screen that asked it would get a 404 and fall back to
1341
+ // the generic advice — which is the exact defect this exists to end, delivered to the exact reader
1342
+ // who reported it.
1343
+ //
1344
+ // WHAT IT COSTS ON A NORMAL PAGE VIEW: NOTHING, and that is structural rather than a promise. The
1345
+ // question only has a wrong answer in one state — the capture says there is no engine program — so
1346
+ // the live reading is taken only in that state. An install with a working engine never reaches the
1347
+ // probe, and /me is the hottest endpoint in the portal.
1348
+ //
1349
+ // AND IT IS THE SAME MECHANISM THE SETTINGS PAGE USES, deliberately: `flagView` with a live posture,
1350
+ // and the same `engine program` row out of `lastRun.disagrees`. Two surfaces answering this question
1351
+ // by two routes is how they came to contradict each other in the first place.
1352
+ //
1353
+ // NOT CACHED, AND THAT IS THE POINT OF THE CHANGE. A held reading was written first and removed: it
1354
+ // would have meant a reader who restarted the engine service went on being told to restart it for
1355
+ // as long as the window lasted — the staleness this whole change exists to end, in miniature, in
1356
+ // the one place a reader is standing when they act on it. Measured in testing, 2026-09-08: 0.28ms
1357
+ // for a reading taken cold with the imports warm, and a 0.29ms median over 50 requests in the state
1358
+ // that takes one on every request, against 0.04ms in the state that takes none. It is taken only
1359
+ // where a search is already refusing, so there is nothing here worth trading a wrong answer for.
1360
+ async function engineProgramDisputed(mode) {
1361
+ // NOT DEMO, NOTHING TO DISPUTE. The capture already sees the program, so the only disagreement
1362
+ // left is the mirror — this box cannot see what the engine could — and that one does not change
1363
+ // what this screen says: the screen is not refusing a search in that state.
1364
+ if (mode !== "demo") return false;
1365
+ try {
1366
+ const live = await livePosture();
1367
+ // `disagrees` is [] on agreement, rows on disagreement, and null when there is no capture to
1368
+ // compare against — three facts, and only the middle one is this. A null must not read as false.
1369
+ const rows = flagView(poolRoot, { live }).lastRun?.disagrees;
1370
+ return Array.isArray(rows) ? rows.some((d) => d.what === "engine program") : null;
1371
+ } catch {
1372
+ // COULD NOT LOOK, AND IT SAYS SO. Null travels to the screen as "unknown" and the screen prints
1373
+ // the general advice, which is what it printed before this existed.
1374
+ return null;
1375
+ }
1376
+ }
1377
+
1336
1378
  async function route(method, path, identity, body = {}, query = {}) {
1337
1379
  const principal = makePrincipal({ email: identity?.email, grants: grantsNow(), staffDomains });
1338
1380
  const parts = path.replace(/\/+$/, "").split("/").filter(Boolean); // ["portal", ...]
@@ -1389,8 +1431,17 @@ export function makePortalService({
1389
1431
  // writes at boot and derives the mode from `binaryPresent`, which that snapshot already
1390
1432
  // carries. NULL when there is no snapshot to read, and null means THIS CANNOT ANSWER — the UI
1391
1433
  // must leave the button alone rather than infer demo from an absent file.
1434
+ // READ ONCE. The payload names it and the program reading below is gated on it; two calls to
1435
+ // `flagView` here would be two reads of the same file that could disagree with each other.
1436
+ const meEngineMode = flagView(poolRoot).engineMode;
1392
1437
  return { status: 200, json: { role: principal.role, email: principal.email, accounts: principal.accounts, accountNames,
1393
- concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode: flagView(poolRoot).engineMode,
1438
+ concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode: meEngineMode,
1439
+ // WHETHER THE PROGRAM IS ON THIS BOX WHILE THE ENGINE CANNOT SEE IT — true, false, or null
1440
+ // for "this could not be checked". The screen above renders one of three remedies from it,
1441
+ // and they are different remedies: install the CLI, restart the service that cannot see it,
1442
+ // or the general advice when nothing could be read. Only `engineMode: "demo"` can make this
1443
+ // anything but false; see `engineProgramDisputed`.
1444
+ engineProgramDisputed: await engineProgramDisputed(meEngineMode),
1394
1445
  // HOW THIS INSTALL ARRIVED, so a screen can name the setup command the reader can actually
1395
1446
  // type. `npm run setup` and `npx clearotron install` are the same wizard and each one is
1396
1447
  // unrunnable on the other route; the no-engine notice named one of them and was wrong for
@@ -2142,7 +2193,7 @@ export function makePortalService({
2142
2193
  // command is a true fact about THIS INSTALL'S OWN DISK, useful to anyone with a shell on the
2143
2194
  // box and useless to a hosted client who has no checkout. On a local install the reader IS the
2144
2195
  // operator, which is why the split that already exists does the work an "is this deployment
2145
- // local" inference would have done badly. Agreed with overwatch before building, because it
2196
+ // local" inference would have done badly. Agreed before building, because it
2146
2197
  // changes what a signed-in staff user is shown.
2147
2198
  //
2148
2199
  // COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
@@ -23,6 +23,7 @@
23
23
  // report-derived cover text alone, because a gate that simply stops running is not a gate that passes.
24
24
 
25
25
  import { REGION_NAMES } from "./publish/regions.mjs";
26
+ import { PLAIN_FORMS, SENTENCE_WORD_LIMIT, termMatcher } from "./plain-register.mjs"; // the pinned rule, not a fourth copy
26
27
  import { canonicalJurisdictionCode } from "./jurisdiction-codes.mjs"; // one spelling of a territory code
27
28
  import { searchedCovers } from "./frame-diff-model.mjs"; // one copy of the EU-reach rule
28
29
  import { partyFactSources, partyFactViolations, partyFactMessage, canJudgePartyFacts } from "./party-facts.mjs"; //
@@ -2375,26 +2376,25 @@ const knockoutSurfaces = (findings) => {
2375
2376
  // FLAG-ONLY, AND NEVER PROJECTED. 333: "A hit is a rewrite of that line, never a disclosure and never a
2376
2377
  // run failure." These carry surface "findings", which runKnockoutLint's caller does not project onto
2377
2378
  // the cover note or the workbook — so a flag reaches whoever is fixing the run and nobody else.
2378
- const LAWYER_VOCAB = [
2379
- ["proprietor", /\bproprietors?\b/i],
2380
- ["subsisting", /\bsubsisting\b/i],
2381
- ["senior right", /\bsenior (?:right|mark|position)/i],
2382
- ["specification", /\bspecifications?\b/i],
2383
- ["formative", /\bformatives?\b/i],
2384
- ["prevail", /\bprevails?\b|\bprevailing\b/i],
2385
- ["citable", /\bcitable\b/i],
2386
- ["limb", /\bon every limb\b|\ball limbs\b/i],
2387
- ["non-use attack", /\bnon-?use attack\b/i],
2388
- ["belt-and-braces", /\bbelt-?and-?braces\b/i],
2389
- ["dispatch", /\bdispatch(?:ed|es)?\b/i],
2390
- ["lane", /\blanes?\b/i],
2391
- ["on the record as it stands", /\bon the record as it stands\b/i],
2392
- ["the marks-and-goods comparison", /\bmarks-and-goods comparison\b/i],
2393
- ["chunk", /\bchunks?\b/i],
2394
- ];
2395
-
2396
- /** The longest a default-visible sentence may run before it stops being one idea (333 rule 1). */
2397
- const PLAIN_SENTENCE_WORDS = 25;
2379
+ // ── THE RULE IS NOT WRITTEN HERE ANY MORE ───────────────────────────────────────────────────────────
2380
+ //
2381
+ // This file carried its own vocabulary list and its own sentence limit, imported nothing, and was the
2382
+ // copy that actually ran on every knockout delivery — a fourth copy of a rule the other three are pinned
2383
+ // to each other by a test. It had already drifted: it flagged `senior right`, `limb` and `lane`, which
2384
+ // appear in no pinned copy, so a seat was corrected against a rule it was never taught; and it did not
2385
+ // flag `instructed`, which the doctrine does teach. It also carried no plain form at all, naming the
2386
+ // term and deferring the replacement to a skill — and the replacement is the half the doctrine calls
2387
+ // load-bearing.
2388
+ //
2389
+ // It reads the pinned source now. The three untaught terms go with it: teaching a term is a doctrine
2390
+ // change, and `PLAIN_FORMS` is pinned to both documents by
2391
+ // `the-two-register-rule-says-the-same-thing-to-both-products.test.mjs`, so a term added here without
2392
+ // its worked swap in both would red that test rather than silently widening what a seat is corrected on.
2393
+ //
2394
+ // WHAT WAS NOT LOST IN THE CONSOLIDATION. The patterns here handled inflections and the pinned source
2395
+ // did not — `proprietors`, `prevailing`. Reading terms from the weaker matcher would have narrowed the
2396
+ // live check while looking like a tidy-up, so the inflections moved INTO the pinned source as
2397
+ // `termMatcher`, and both sides use it.
2398
2398
 
2399
2399
  /**
2400
2400
  * The fields a reader of the knockout meets before opening anything, named one by one rather than
@@ -2424,15 +2424,39 @@ function knockoutVisibleProse(findings) {
2424
2424
  * sentence carrying more than one idea. Each names the field, so a hit is a line somebody can rewrite
2425
2425
  * rather than a score.
2426
2426
  */
2427
- export function plainLanguageChecks({ findings, surface = "findings" } = {}) {
2427
+ /**
2428
+ * The checks that are internal BY DESIGN, named once so the rule lives beside the thing it governs.
2429
+ *
2430
+ * A hit on any of these is a line for whoever is fixing the run to rewrite. It is never something a
2431
+ * client is shown and it never fails a run — see the header above and the doctrine these implement.
2432
+ * Every other check on this lane is relabelled `report` as it is pushed, one line at a time, and these
2433
+ * three sit in the middle of that list: making them projectable is a one-line edit that looks exactly
2434
+ * like its neighbours and that no test would have caught, because the arms enumerating failure ids
2435
+ * filter these three out before asserting — correctly, for what those arms check.
2436
+ */
2437
+ export const NEVER_PROJECTED = Object.freeze(new Set([
2438
+ "reviewer-note-subject", "plain-language-vocabulary", "plain-language-sentence-length",
2439
+ ]));
2440
+
2441
+ /** The surface a check that must never reach a reader carries. Not `report`, and not a caller's choice. */
2442
+ export const INTERNAL_SURFACE = "findings";
2443
+
2444
+ /**
2445
+ * THE SURFACE IS NOT THE CALLER'S TO CHOOSE. It used to be a parameter with a default, so the property
2446
+ * held because every caller happened to pass nothing — a run that satisfies a rule rather than a rule.
2447
+ * A future caller passing `report` would have put a plain-language hit on the page a client reads, with
2448
+ * nothing going red.
2449
+ */
2450
+ export function plainLanguageChecks({ findings } = {}) {
2451
+ const surface = INTERNAL_SURFACE;
2428
2452
  const fields = knockoutVisibleProse(findings);
2429
2453
  const vocab = [];
2430
2454
  const longSentences = [];
2431
2455
  for (const { where, text } of fields) {
2432
- for (const [name, re] of LAWYER_VOCAB) if (re.test(text)) vocab.push(`${where}: "${name}"`);
2456
+ for (const [term] of PLAIN_FORMS) if (termMatcher(term).test(text)) vocab.push(`${where}: "${term}"`);
2433
2457
  for (const sentence of text.split(/(?<=[.!?])\s+|\n+/)) {
2434
2458
  const n = sentence.trim().split(/\s+/).filter(Boolean).length;
2435
- if (n > PLAIN_SENTENCE_WORDS) longSentences.push(`${where}: ${n} words`);
2459
+ if (n > SENTENCE_WORD_LIMIT) longSentences.push(`${where}: ${n} words`);
2436
2460
  }
2437
2461
  }
2438
2462
  // ── A NOTE THAT WILL PRINT IN THE PLACE ITS WRITER DID NOT MEAN ────────────────────────────────────
@@ -2465,7 +2489,7 @@ export function plainLanguageChecks({ findings, surface = "findings" } = {}) {
2465
2489
  check("plain-language-vocabulary", "voice", surface, vocab.length === 0,
2466
2490
  vocab.length ? `the lawyer's vocabulary on lines a reader meets before opening anything — rewrite the line in the words the reader already owns (the skill carries the swaps): ${say(vocab)}` : ""),
2467
2491
  check("plain-language-sentence-length", "voice", surface, longSentences.length === 0,
2468
- longSentences.length ? `a default-visible sentence carrying more than one idea (over ${PLAIN_SENTENCE_WORDS} words) — split it, conclusion first: ${say(longSentences)}` : ""),
2492
+ longSentences.length ? `a default-visible sentence carrying more than one idea (over ${SENTENCE_WORD_LIMIT} words) — split it, conclusion first: ${say(longSentences)}` : ""),
2469
2493
  ];
2470
2494
  }
2471
2495
 
@@ -2529,7 +2553,12 @@ export function runKnockoutLint({ findings }) {
2529
2553
  // "all", because on the clearance lane it asserts agreement BETWEEN surfaces. This lane has one
2530
2554
  // surface, so "report" is also the truer label here. The filter is deliberately not `!== "findings"`:
2531
2555
  // that would default a check added next year to PROJECTING onto a surface a client principal reads.
2532
- const onReport = (list) => list.map((c) => ({ ...c, surface: "report" }));
2556
+ // AND IT REFUSES TO PROMOTE THE THREE THAT ARE INTERNAL BY DESIGN. Pinning the surface inside
2557
+ // `plainLanguageChecks` stops a caller ASKING for a projectable one; it does not stop this line
2558
+ // relabelling the answer afterwards, which is the same edit one step later and looks like every other
2559
+ // line around it. So the rule is enforced where the surface is actually chosen. Anything added to
2560
+ // NEVER_PROJECTED is covered here by construction rather than by whoever adds it remembering.
2561
+ const onReport = (list) => list.map((c) => (NEVER_PROJECTED.has(c.id) ? c : { ...c, surface: "report" }));
2533
2562
  checks.push(...permissionProseChecks({ text: report, surface: "report", idSuffix: ":knockout", structural: true, cards: false }));
2534
2563
  checks.push(...onReport(scopeNumberProseChecks({ reportMd: report })));
2535
2564
  checks.push(...onReport(countingChecks({ report })));
@@ -416,7 +416,7 @@ const useEvidence = (m) => [USE_EVIDENCE_LABEL[m?._status], USE_SOURCE_LABEL[m?.
416
416
  // EXACT EQUALITY, exactly as EVIDENCE_LABEL maps `_status`. The sentinel itself does not move: archived
417
417
  // runs carry the old value forever and a fourth spelling of it would have to be accepted everywhere.
418
418
  const USE_CHECK_NO_RESULT = 'perplexity_research — no result';
419
- const USE_CHECK_NO_RESULT_CITE = 'Marketplace search run no result found.';
419
+ const USE_CHECK_NO_RESULT_CITE = 'Nothing found in the marketplaces searched.';
420
420
  const USE_CHECK_NO_RESULT_SHORT = 'marketplace search — no result found';
421
421
  // — MATCHED ON NORMALISED PUNCTUATION, NOT ONE SPELLING. The constant itself does not
422
422
  // move (archived runs carry it forever, the validators name it), but the SEAT emitted a hyphen where
@@ -852,7 +852,7 @@ function plainScopeNote(text) {
852
852
  if (!t) return '';
853
853
  return stripTelemetry(t).trim(); // trim: an all-telemetry note leaves only newlines, and '' is falsy
854
854
  }
855
- function scopeSection(ranBucket, coverage, coverageJudgment, methodologyText, contextNotes, fm = {}, hasRecordSet = false, hasCards = false) {
855
+ function scopeSection(ranBucket, coverage, coverageJudgment, methodologyText, contextNotes, fm = {}, hasRecordSet = false, hasCards = false, hasIndexEntry = false) {
856
856
  const parts = [];
857
857
  // B3 (spec 2026-07-30 §4) — record provenance, stated ONCE, here, instead of a hedge stamped on
858
858
  // every card. This is the single home of what "fetched", "register-index entry" and "inferred"
@@ -868,7 +868,11 @@ function scopeSection(ranBucket, coverage, coverageJudgment, methodologyText, co
868
868
  // conditions: a copy is a thing that drifts, and over-including costs one explanatory paragraph in a
869
869
  // collapsed section while under-including costs a reader an unexplained label. The fetched-records
870
870
  // sentence stays conditional on hasRecordSet, so a run WITH a record set is byte-identical to B3.
871
- if (hasRecordSet || hasCards) parts.push(`<p class="scoperead" style="margin:0 0 4px;font-weight:600">Record provenance</p><p class="provnote" style="margin:0 0 6px;font-size:13px">${hasRecordSet ? 'Registry identifiers on the finding cards are read from the official register records fetched this run. ' : ''}A registration shown as a register-index entry was seen in the register index; its full record was not pulled. An enforcer appetite marked “inferred” rests on reputation or profile signals rather than a fetched record.</p>`);
871
+ // THREE SENTENCES EXPLAINING ONE WORD, and the middle one printed on every report whether or not the
872
+ // page had a register-index entry on it — a definition of a label the reader could not see. It renders
873
+ // now only where such an entry does, and the remaining two say what "inferred" means in the words a
874
+ // reader would use for it rather than in the renderer's.
875
+ if (hasRecordSet || hasCards) parts.push(`<p class="scoperead" style="margin:0 0 4px;font-weight:600">Record provenance</p><p class="provnote" style="margin:0 0 6px;font-size:13px">${hasRecordSet ? 'Registration numbers on the cards were read from the register records. ' : ''}${hasIndexEntry ? 'A registration shown as a register-index entry was seen in the register index; its full record was not pulled. ' : ''}“Inferred” beside an owner’s likelihood to object means we judged it from what the owner sells and holds; we had no enforcement history to read.</p>`);
872
876
  // — this is the one part of §4 that does NOT fold. Same markup, same heading, same marker; it is
873
877
  // emitted beside the <details> instead of inside it, wrapped in the panel the only-you section already
874
878
  // uses so it reads as a region of the page rather than a stray heading.
@@ -896,7 +900,11 @@ function scopeSection(ranBucket, coverage, coverageJudgment, methodologyText, co
896
900
  + `<p class="covnone" style="margin:0 0 6px;font-size:13px">No coverage record was produced for this run. `
897
901
  + `This section normally lists what each search covered and what is still open; its absence here is a gap `
898
902
  + `in the record, not a finding that nothing is open. Ask us before relying on it.</p>`);
899
- if (coverageJudgment && coverageJudgment.reason) parts.push(`<p class="cov-read" style="margin:8px 0 0;font-size:13px;color:var(--faint)"><b>Coverage read (internal):</b> ${esc(String(coverageJudgment.reason))}</p>`);
903
+ // THE INTERNAL COVERAGE READ IS NOT RENDERED. It concatenated the engine's own search-unit names into
904
+ // about a thousand characters of prose — and on the measured run it ended mid-word, because it is a
905
+ // machine's working note and nothing was reading it as a sentence. Every fact in it is already in the
906
+ // coverage cells directly above, in plain words. It stays in the run's artifacts and in the workbook,
907
+ // where the reader is someone who wants it.
900
908
  const meth = plainScopeNote(methodologyText);
901
909
  if (meth) parts.push(`<p class="scoperead" style="margin:10px 0 4px;font-weight:600">How this search was run</p><div class="methnote" style="font-size:13px">${renderProse(meth)}</div>`);
902
910
  const cn = contextNotesBlock(contextNotes);
@@ -1230,11 +1238,16 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1230
1238
  // ("Download full audit (Excel)"), because that is the string the reader hunts for on
1231
1239
  // the page. NOT an inline .xlsx link: portal-report.mjs strips those, correctly — the
1232
1240
  // portal REPLACES them with its own download control (portal-ui Result.tsx).
1233
- // placeholder — a register UI exists and we do not know its per-record address. Labelled as a
1234
- // placeholder so it reads as unfinished rather than as a citation a reader can check.
1241
+ // placeholder — a register UI exists and we do not know its per-record address. It CARRIES NO NOTE:
1242
+ // the number stands on its own, because "(placeholder)" beside twenty-four
1243
+ // registrations reads to a client as a broken report rather than as a missing link.
1244
+ // THE PLACEHOLDER NOTE IS GONE. It printed " — no record link available yet (placeholder)" beside
1245
+ // every registration a register UI has no per-record address for — twenty-four times on the measured
1246
+ // page — and a client reads "placeholder" as a broken report. The number is the fact; when there is a
1247
+ // link the number IS the link, and when there is not, the number still stands on its own. The
1248
+ // workbook note stays: it tells a reader where the full record actually is.
1235
1249
  const NO_LINK_NOTE = {
1236
1250
  workbook: ' — full record in the audit workbook (“Download full audit (Excel)”)',
1237
- placeholder: ' — no record link available yet (placeholder)',
1238
1251
  };
1239
1252
  const regUri = (u, fb) => {
1240
1253
  const h = regHref(u);
@@ -1350,7 +1363,11 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1350
1363
  // D7 — the code-owned "searched, nothing found" sentinel becomes client words HERE, by exact
1351
1364
  // equality against the one constant. Any other value is a source string and rides through untouched.
1352
1365
  const useSrc = isUseCheckNoResult(f.use_check?.source) ? USE_CHECK_NO_RESULT_CITE : f.use_check?.source;
1353
- const useChk = cite(useSrc, 'Use checked.', useStatus);
1366
+ // NO EVIDENCE TAG ON AN EMPTY RESULT. The line read "Use checked. Marketplace search run — no result
1367
+ // found. Evidence: inferred", and "inferred" beside "no result" reads as a contradiction: it qualifies
1368
+ // how a FINDING was established, and there is no finding here. Nothing was found, and that is the
1369
+ // whole of what the line has to say.
1370
+ const useChk = cite(useSrc, 'Use checked.', isUseCheckNoResult(f.use_check?.source) ? null : useStatus);
1354
1371
  const ownR = cite(f.own_rights?.source, 'Own-portfolio sweep.', EVIDENCE_LABEL[f.own_rights?._status]);
1355
1372
  const proseFull = cardBlock(card, /^full detail/i);
1356
1373
  const proseFullShown = proseFull; // one report: the full prose; portal-report strips serve-time chrome, never analysis
@@ -1627,7 +1644,67 @@ const COV_STATE = {
1627
1644
  'not-searched': { cls: 'todo', ic: '→', word: 'Not run this run' },
1628
1645
  note: { cls: 'info', ic: 'i', word: 'Note' },
1629
1646
  };
1647
+ /**
1648
+ * ONE ROW PER GAP, where the driver's follow-up row and the model's own row are the same search.
1649
+ *
1650
+ * A run deferred the English word DOLPHIN and the page said so twice: once as the model wrote it — "the
1651
+ * English word DOLPHIN as a dedicated exact search · Open item" — and once as the driver composes it
1652
+ * from the envelope, "Follow-up / dolphin · Open item: dolphin — not completed this run — the search for
1653
+ * it was planned and never reached the register…". The run's own reviewer flagged the duplicate and it
1654
+ * shipped anyway, because the second row is composed HERE and the reviewer reads what the model wrote.
1655
+ *
1656
+ * RENDER-SIDE ONLY. Both rows stay in the record and in the workbook; this decides what the page draws.
1657
+ * Dropping the driver's row from `coverage[]` would change what the run recorded, and this issue is
1658
+ * presentation.
1659
+ *
1660
+ * IT ERRS TOWARD KEEPING BOTH. A surplus row is today's behaviour; a wrongly-suppressed one hides a gap
1661
+ * from the reader, which is the failure worth avoiding. So the driver's row goes only when another row
1662
+ * carries EVERY significant word of the directive it names — a near-match keeps both.
1663
+ */
1664
+ const FOLLOW_UP_PREFIX = 'Follow-up / ';
1665
+ const COV_STOPWORDS = new Set(['the', 'a', 'an', 'as', 'for', 'of', 'in', 'on', 'and', 'or', 'to', 'is',
1666
+ 'was', 'it', 'its', 'this', 'that', 'with', 'by', 'at', 'be', 'been', 'run', 'search', 'searched']);
1667
+ const covWords = (t) => new Set(String(t || '').toLowerCase().match(/[a-z0-9]+/g)?.filter((w) => !COV_STOPWORDS.has(w)) ?? []);
1668
+
1669
+ function dedupeFollowUps(coverage) {
1670
+ const composed = (c) => String(c?.area || '').startsWith(FOLLOW_UP_PREFIX);
1671
+ const written = coverage.filter((c) => !composed(c));
1672
+ if (!written.length) return coverage;
1673
+ return coverage.filter((c) => {
1674
+ if (!composed(c)) return true;
1675
+ // The directive is the note's opening clause — the same string the area was clipped from, unclipped.
1676
+ const directive = covWords(String(c.note || '').split('—')[0]);
1677
+ if (!directive.size) return true;
1678
+ // A COMPLETED SEARCH NEVER STANDS IN FOR AN UNCOMPLETED ONE. This is the discriminator the first
1679
+ // version lacked, and it hid a real gap from a client on a delivered page.
1680
+ //
1681
+ // The composed row exists to say a planned search never ran. Suppressing it requires another row
1682
+ // saying THE SAME THING about the same search — so the row that suppresses must itself be open. A
1683
+ // row reporting a search that RAN is reporting the opposite, and the two are not interchangeable
1684
+ // however many words they share.
1685
+ //
1686
+ // Found on a real run, driven on the shipped renderer: a completed saturation probe into
1687
+ // third-party dolphin-word rights suppressed the uncompleted exact-word register search for DOLPHIN,
1688
+ // because both areas contain "dolphin". The client then read a coverage section that mentioned the
1689
+ // word and disclosed nothing left undone, and the suppressed row was the page's only disclosure of
1690
+ // it. That is exactly the failure this function's header calls the one worth avoiding, and the
1691
+ // header was right while the code was not.
1692
+ //
1693
+ // WHAT THIS STILL CANNOT DO, said rather than implied: it is word containment, so two genuinely
1694
+ // different OPEN searches sharing every word of a short directive would still collapse to one. The
1695
+ // discriminator a reader would want is which search a row names, and the rows carry no identity to
1696
+ // join on — that is why the issue's own wording is "name the same search" rather than a rule. The
1697
+ // narrowing here is the strongest one the data supports, and it errs toward keeping both.
1698
+ return !written.some((w) => {
1699
+ if (COV_STATE[w?.state]?.cls === 'ok') return false; // a searched-and-clean row reports the opposite
1700
+ const theirs = covWords(w.area);
1701
+ return [...directive].every((word) => theirs.has(word));
1702
+ });
1703
+ });
1704
+ }
1705
+
1630
1706
  function coverageGrid(coverage) {
1707
+ coverage = dedupeFollowUps(coverage);
1631
1708
  if (!coverage.length) return '';
1632
1709
  const cell = (c) => {
1633
1710
  const s = COV_STATE[c.state] || COV_STATE.note;
@@ -1753,7 +1830,7 @@ document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=doc
1753
1830
  function markAssessmentBlock(ma) {
1754
1831
  if (ma == null) return '';
1755
1832
  const structured = typeof ma.distinctiveness === 'object' || typeof ma.connotation === 'object';
1756
- const SEC = `<div class="sec"><span class="num">✦</span><h2>The mark itself</h2><span class="note">standing read of the applicant's own mark advisory, carries no rating</span></div>`;
1833
+ const SEC = `<div class="sec"><span class="num">✦</span><h2>The mark itself</h2><span class="note">how strong the name is on its own</span></div>`;
1757
1834
  if (!structured) {
1758
1835
  const dist = String(ma?.distinctiveness ?? '').trim(), conn = String(ma?.connotation ?? '').trim();
1759
1836
  if (!dist && !conn) return '';
@@ -2130,7 +2207,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2130
2207
  const clNotice = (clNoticeText && CASE_LAW_BY_ORD.size)
2131
2208
  ? `<div class="panel" style="padding:12px 16px;margin:0 0 12px"><p style="margin:0 0 4px;font-weight:700;font-size:13px">Session-wide notice</p><div style="font-size:13px">${renderProse(clNoticeText)}</div></div>`
2132
2209
  : '';
2133
- const CL_SEC = (n) => `<div class="sec" id="common-law"><span class="num">${n}</span><h2>Common-law &amp; marketplace</h2><span class="note">unregistered-use signals not register rights</span></div>
2210
+ const CL_SEC = (n) => `<div class="sec" id="common-law"><span class="num">${n}</span><h2>Common-law &amp; marketplace</h2><span class="note">who is using similar names, registered or not</span></div>
2134
2211
  ${clNotice}${clBody}`;
2135
2212
  const hasCL = Boolean(clBody || clNotice);
2136
2213
  let findingsSections, covNum;
@@ -2269,7 +2346,7 @@ ${opts.nav || ''}
2269
2346
  ${ruledOutSection(ruledOut, recordsByUri)}
2270
2347
 
2271
2348
  <!-- doc-52 §3 WHAT ONLY YOU CAN CLOSE — forward decisions, plain English, after the findings. -->
2272
- ${buckets.you ? `<div class="sec" id="only-you"><span class="num">✔</span><h2>What only you can close</h2><span class="note">forward decisions only you can make — each tied to a finding above</span></div>
2349
+ ${buckets.you ? `<div class="sec" id="only-you"><span class="num">✔</span><h2>What only you can close</h2><span class="note">decisions that need you</span></div>
2273
2350
  <div class="panel actions"><div class="actgrp act-you">${renderProse(buckets.you.body)
2274
2351
  .replace(/\[Time-critical\]\s*/gi, '<span class="src cl" style="margin-right:6px">Time-critical</span> ')
2275
2352
  .replace(/\[Open question\]\s*/gi, '<span class="src" style="margin-right:6px">Open question</span> ')
@@ -2287,14 +2364,32 @@ ${opts.nav || ''}
2287
2364
 
2288
2365
  <!-- doc-52 §4 SCOPE & WHAT WE DIDN'T SEARCH — one collapsible section, last; replaces "Checks we ran"
2289
2366
  + "Methodology" + the coverage grid. Nothing here leads. -->
2290
- ${scopeSection(buckets.ran, coverage, opts.coverageJudgment, secs['Methodology'], DISPOSITION_MODE ? [] : contextNotes, fm, recordsByUri.size > 0, findings.length > 0)}
2367
+ ${scopeSection(buckets.ran, coverage, opts.coverageJudgment, secs['Methodology'], DISPOSITION_MODE ? [] : contextNotes, fm, recordsByUri.size > 0, findings.length > 0,
2368
+ // WHETHER A REGISTER-INDEX ENTRY IS ACTUALLY ON THIS PAGE, mirroring the registration render's own
2369
+ // second disjunct rather than restating it loosely: a cited registration with no fetched body, which
2370
+ // is the state that draws the "(register-index entry)" label. The provenance paragraph explains that
2371
+ // label, so it renders where the label can and stays off every page where it cannot.
2372
+ findings.some((f) => (f?.owner?.registrations ?? []).some((r) => r?.uri
2373
+ && (recordsByUri.size > 0 || !(r.status || r.filed || r.expiry || (r.classes && r.classes.length))))))}
2291
2374
 
2292
2375
  ${askAiHtml}
2293
2376
 
2294
2377
  <footer>
2295
2378
  <span>${productName ? `${esc(productName)}. ` : ''}${FRAMEWORK
2296
- ? `Working draft for legal review. Risk bands (${esc(FRAMEWORK.title)}): <span class="mono">${esc(FRAMEWORK.bands.map(b => b.label).join(' / '))}</span> — the framework in force's own vocabulary, one word per finding on every surface. Internal notes are review-only and removed on export.`
2297
- : 'Working draft for legal review. Risk levels: <span class="mono">LOW / MANAGEABLE / MEDIUM / HIGH / VERY HIGH</span> (one vocabulary on every surface); the <span class="mono">Level A–E</span> · <span class="mono">Composite 1–5</span> codes beside them are the internal legal shorthand. Internal notes are review-only and removed on export.'}<br>Matter ${esc(fm.matter || '')}${fm.run ? ` · ${esc(fm.run)}` : ''}.${fm.rated_under ? `<br>Rated under: <span class="mono">${esc(fm.rated_under)}</span>.` : ''}${fm.run_under_project ? `<br>Run under project: <span class="mono">${esc(fm.run_under_project)}</span>.` : ''}</span>
2379
+ // TWO SENTENCES, and that count is the ruled shape rather than a consequence of trimming.
2380
+ //
2381
+ // Two things left. The band note — "the framework in force's own vocabulary, one word per
2382
+ // finding on every surface" — is a note about how the renderer works, printed on every report a
2383
+ // client receives; the framework's NAME is on the "Rated under" line below, once, which is
2384
+ // where a reader who wants it will look.
2385
+ //
2386
+ // AND "Working draft for legal review.", which is a separate decision and is recorded as one.
2387
+ // A delivered clearance is not a draft, and a document that calls itself one on every page is
2388
+ // describing its own status inaccurately to the person paying for it. Raised in review because
2389
+ // the first version of this comment argued only the band note and left the reader to infer that
2390
+ // the status line had gone along for the ride.
2391
+ ? `Risk bands: <span class="mono">${esc(FRAMEWORK.bands.map(b => b.label).join(' / '))}</span>. Purple notes are for the reviewing lawyer and are removed on export.`
2392
+ : 'Risk bands: <span class="mono">LOW / MANAGEABLE / MEDIUM / HIGH / VERY HIGH</span>. Purple notes are for the reviewing lawyer and are removed on export.'}<br>Matter ${esc(fm.matter || '')}${fm.run ? ` · ${esc(fm.run)}` : ''}.${fm.rated_under ? `<br>Rated under: <span class="mono">${esc(fm.rated_under)}</span>.` : ''}${fm.run_under_project ? `<br>Run under project: <span class="mono">${esc(fm.run_under_project)}</span>.` : ''}</span>
2298
2393
  ${logoLockup({ mark: 16 })}
2299
2394
  </footer>
2300
2395
  </div>
@@ -207,7 +207,7 @@ export const PRODUCT_POLICIES = {
207
207
  // 199 of those same 420 carry one. The driver now reads this row and can cut on that tier
208
208
  // (doubt-selection.mjs).
209
209
  //
210
- //, owner-ruled 2026-08-23, product by product (reached this branch as an overwatch relay on
210
+ //, owner-ruled 2026-08-23, product by product (reached this branch on
211
211
  // that thread, not as a comment from the owner's own hand — the box has one `gh` identity).
212
212
  // It is the P2 column of that table's `placement-inquiry trace` row — the row whose typed key is
213
213
  // the placement tier. This stage's OWN row reads `bands 1+2 / band 1` on FINDING CLASS, which is
@@ -369,6 +369,19 @@ export const DISPATCH_EXTRAS = [
369
369
  { path: join(P.runDir, "_records"), dir: true, why: "assembleRunRecords — the fetched official records each claimed field is checked against; without them the check finds zero mismatches and the block silently vanishes" },
370
370
  ],
371
371
  },
372
+ {
373
+ // Rule 1 of the two-register rule, measured over the record's default-visible lines and handed to
374
+ // the pass that already rewrites. Advisory by construction: it reaches the writer, never the reader.
375
+ //
376
+ // It reads the RECORD rather than the narrative, and that is the point — the lines a client meets
377
+ // first are typed fields (each conflict's one sentence, the coverage notes, the actions, the mark
378
+ // assessment's two reads), not prose to be re-parsed out of a document. A sandbox without this file
379
+ // composes no block, which is correct and visible: the composer stamps what it built.
380
+ id: "refute-plain-register", stage: "narrative-refutation",
381
+ reads: (P) => [
382
+ { path: P.findings, why: "plainRegisterExtra reads the typed default-visible fields — net, coverage notes, actions, mark assessment — and the owners it must blank before reading" },
383
+ ],
384
+ },
372
385
  // ── — synthesis's two, and the asymmetry they close ─────────────────────────────────────────
373
386
  //
374
387
  // The reviewer received the plan-execution receipt as a code-derived table; the stage it reviews