clearotron 0.3.2-beta.14 → 0.3.2-beta.15

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 (38) hide show
  1. package/build-info.json +2 -2
  2. package/driver/CHANGELOG.md +14 -0
  3. package/driver/contract-vocabulary.mjs +5 -5
  4. package/driver/named-band.mjs +1 -1
  5. package/driver/package.json +1 -1
  6. package/driver/pipeline-knockout.mjs +3 -1
  7. package/driver/pipeline.mjs +30 -7
  8. package/driver/publish/index.mjs +10 -1
  9. package/driver/publish/render.mjs +54 -2
  10. package/driver/publish/templates/report.css +16 -1
  11. package/driver/register-availability.mjs +2 -2
  12. package/driver/register-plan.mjs +229 -55
  13. package/driver/skills/clearance-register/unit.md +1 -1
  14. package/driver/skills/clearance-variants/SKILL.md +10 -4
  15. package/driver/stages.mjs +1 -1
  16. package/driver/suite-census.json +32 -2
  17. package/driver/variant-manifest-model.mjs +39 -1
  18. package/mcp-server/CHANGELOG.md +4 -0
  19. package/mcp-server/lib/audit-view.mjs +5 -3
  20. package/mcp-server/lib/brief.mjs +10 -4
  21. package/mcp-server/lib/runs.mjs +35 -1
  22. package/mcp-server/package.json +1 -1
  23. package/mcp-server/server.mjs +6 -3
  24. package/package.json +1 -1
  25. package/portal-ui/dist/assets/{index-7Lq-dXDV.css → index-5CCwiJG7.css} +10 -0
  26. package/portal-ui/dist/assets/{index-w8GFZftk.js → index-DMthc7PQ.js} +8 -1
  27. package/portal-ui/dist/index.html +2 -2
  28. package/portal-ui/package.json +1 -1
  29. package/providers/_shared/execute-plan.mjs +11 -1
  30. package/providers/_shared/term-shape.mjs +76 -0
  31. package/providers/clarivate/src/capabilities.js +36 -0
  32. package/providers/clarivate/src/core.js +119 -0
  33. package/providers/corsearch/src/capabilities.js +24 -0
  34. package/providers/corsearch/src/core.js +7 -0
  35. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  36. package/providers/oauth-mcp-bridge/package.json +1 -1
  37. package/providers/signa/src/capabilities.js +29 -0
  38. package/providers/signa/src/core.js +17 -0
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "94613f865f0ca4af3a17122f5d51cbfb7615f738",
3
- "version": "0.3.2-beta.14"
2
+ "commit": "a166b8a008f6094b60dc44c6725c68bcbc7327b5",
3
+ "version": "0.3.2-beta.15"
4
4
  }
@@ -1,5 +1,19 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.2-beta.15
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: A run's summary and its delivery email now give the rating and the report's own sentence. The internal delivery-check word no longer appears.
8
+ - Fixed: A report's answers to your instructions now show one line each, with the rest of each answer behind a fold.
9
+ - New: a register search can now be narrowed to the goods and services a filing actually covers, not only the numbered grouping it was filed under.
10
+
11
+ A common word in a busy grouping returns more filings than any search can read through. Narrowing by what each filing covers reaches the ones that matter.
12
+
13
+ New: searches no longer spend queries on owner portfolios guessed before any filing had been read. Owners now come from the filings a search returns.
14
+ - Fixed: A delivered report's conditions now carry only the conditions written for the client. The reviewer's own notes stay in the run record.
15
+ - Fixed: the portal now says it is loading while your clearances arrive, instead of showing an empty page that reads as nothing found.
16
+
3
17
  ## 0.3.2-beta.14
4
18
 
5
19
  ### Patch Changes
@@ -21,7 +21,7 @@
21
21
  // D4 verify.mjs parseCoverageLedgerJson, same shape
22
22
  // D5 verify.mjs:1504 fail(`${unaccounted[0].token}:…`) — token minted in a DATA ROW
23
23
  // D6 verify.mjs:1567 fail(`${violations[0].token}…`) — validatePlanFeasibility in register-plan.mjs
24
- // D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2270 disclosureTextByAxis
24
+ // D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2424 disclosureTextByAxis
25
25
  // D8 verify.mjs:2470 caseLawLedgerFail fail(caseLawLedgerFail(…)) — token built in case-law-ledger.mjs:195 caseLawLedgerFail
26
26
  //
27
27
  // A partition built on the 60 tokens a regex CAN see would run green while blind to the rest, which is
@@ -139,10 +139,10 @@ export const VOCABULARY = [
139
139
  { token: "coverage_form_missing", stages: ["register-digest"], site: "driver/verify.mjs" },
140
140
  { token: "coverage_form_empty", stages: ["register-digest"], site: "driver/verify.mjs" },
141
141
  { token: "coverage_status_offenum", stages: ["register-digest"], site: "driver/verify.mjs:2050" },
142
- { token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:1947 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
143
- { token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1681 validatePlanFeasibility", dynamic: "D6" },
144
- { token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2042 searchedJurisdictionsFromPlan", dynamic: "D6" },
145
- { token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2270 disclosureTextByAxis", dynamic: "D7" },
142
+ { token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:2101 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
143
+ { token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1835 validatePlanFeasibility", dynamic: "D6" },
144
+ { token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2196 searchedJurisdictionsFromPlan", dynamic: "D6" },
145
+ { token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2424 disclosureTextByAxis", dynamic: "D7" },
146
146
  { token: "coverage_clean_tainted", stages: ["register-digest"], site: "driver/verify.mjs" },
147
147
  { token: "coverage_ledger_", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs (parseCoverageLedgerJson token-first throws)", dynamic: "D4" },
148
148
  { token: "coverage_key_unknown", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
@@ -137,7 +137,7 @@ export function parseNamedBand(raw) {
137
137
  // byte-identical to a slice the plan deliberately counted without fetching. Measured on a real
138
138
  // run: four capability-gap blocks carried `error:true, deferred:true` into this function and
139
139
  // reached record-carry.json with both fields gone and a sentence claiming the run "has a hit
140
- // COUNT for this slice". register-plan.mjs:1681 validatePlanFeasibility already enforces the same rule one layer up
140
+ // COUNT for this slice". register-plan.mjs:1835 validatePlanFeasibility already enforces the same rule one layer up
141
141
  // ("a transient must not ship indistinguishable from a sanctioned descriptor") — it reads the
142
142
  // RAW blocks, which is why it could. Every consumer that reads THIS projection could not.
143
143
  // Conditional like the four keys above, so old bands carry neither key and nothing shifts.
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.14",
5
+ "version": "0.3.2-beta.15",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -1090,7 +1090,9 @@ export async function knockoutInner(ctx, job, opts = {}) {
1090
1090
  writeFileSync(join(config.outboxDir, `${packet.runId}.pending`), `${agent}\n`);
1091
1091
  } catch (e) { note(`delivery: outbox marker write skipped (${String(e.message).slice(0, 100)})`); }
1092
1092
  const deliveredAt = new Date().toISOString();
1093
- writeRunStatus(ctx, { state: "delivered", verdict: overall, statement: published.statement, url: published.url, reports: published.reports.map((r) => ({ mark: r.mark, url: r.url })), deliveredAt, sendPending: true, stepIndex: STEPS.length - 1, stepLabel: STEPS[STEPS.length - 1], stepN: STEPS.length, stepTotal: STEPS.length });
1093
+ // `tier` beside `verdict` — the same band word under the name the clearance lane records it by, so a
1094
+ // reader of either lane's status finds the rating in one place. `verdict` stays as it was.
1095
+ writeRunStatus(ctx, { state: "delivered", verdict: overall, tier: overall, statement: published.statement, url: published.url, reports: published.reports.map((r) => ({ mark: r.mark, url: r.url })), deliveredAt, sendPending: true, stepIndex: STEPS.length - 1, stepLabel: STEPS[STEPS.length - 1], stepN: STEPS.length, stepTotal: STEPS.length });
1094
1096
  // — the knockout lane's pool copy learns its terminal state the same way,
1095
1097
  // for the same reason: publish returns the pool dir, and `state: "delivered"` is decided after it
1096
1098
  // returns. Same seam, same best-effort contract, no lane-specific exception to write down.
@@ -13379,6 +13379,17 @@ async function pipelineInner(job, opts = {}) {
13379
13379
  // rationale lives on the function so the arms can DRIVE it rather than pin this call site's text.
13380
13380
  const { clauses: orderedClauses, reasons: orderedReasons } = orderClausesForLede(clampClauses, clampReasons, guardClauses);
13381
13381
  const reasonsOut = blockingGrounds.length ? [...orderedReasons, ...blockingGrounds] : orderedReasons;
13382
+ // — AND EACH APPENDED GROUND CARRIES AN EXPLICIT NULL CLAUSE, which is what puts it in the
13383
+ // run record ALONE. The block above says "no client surface moves", and that was true of the
13384
+ // statement and the bound recommendation, both fixed strings on BLOCKING. It was never true of the
13385
+ // CONDITIONS LIST: `clientConditions` walks the reasons and prints any reason that has no clause
13386
+ // and no engine token, so on a delivered BLOCKING run the reviewer's own cited correction lines
13387
+ // printed to the client as their conditions — 8 of the 13 conditions on the run this was measured
13388
+ // on (2026-09-20). A stored null is the shipped way to say "recorded, not rendered", and the
13389
+ // arrays stay index-aligned because `orderClausesForLede` returns them the same length.
13390
+ const clausesOut = blockingGrounds.length
13391
+ ? [...orderedClauses, ...blockingGrounds.map(() => null)]
13392
+ : orderedClauses;
13382
13393
  const kindsOut = verdict === "BLOCKING" ? { ...clampKinds, reviewerCited } : clampKinds;
13383
13394
  // The invariant itself, stated over the value that is WRITTEN rather than over the branch that fills
13384
13395
  // it — a guard that only holds while the code above is remembered is not a guard. Unreachable today
@@ -13396,13 +13407,19 @@ async function pipelineInner(job, opts = {}) {
13396
13407
  // CONDITIONAL statement reads "<Tier> — conditional on: <factual open-state> (and N more)." —
13397
13408
  // clampClauses carries the fact-voice clauses; the structured `stance` field is what every
13398
13409
  // consumer keys on (never the statement's wording — the magic-string coupling is retired).
13399
- const statement = riskStatement({ tier: derived.tier, verdict, reasons: reasonsOut, clauses: orderedClauses,
13410
+ const statement = riskStatement({ tier: derived.tier, verdict, reasons: reasonsOut, clauses: clausesOut,
13400
13411
  basis: isRegisterOnly(ctx.searchPolicy) ? "register-only" : null });
13401
13412
  const tmp = driverDir(run.runDir, "verdict.json.tmp");
13402
- writeFileSync(tmp, JSON.stringify({ ts: new Date().toISOString(), verdict, reasons: reasonsOut, clauses: orderedClauses, kinds: kindsOut,
13413
+ writeFileSync(tmp, JSON.stringify({ ts: new Date().toISOString(), verdict, reasons: reasonsOut, clauses: clausesOut, kinds: kindsOut,
13403
13414
  tier: derived.tier, badge: derived.badge, gaugeIndex: derived.gaugeIndex, maxComposite: derived.maxComposite,
13404
13415
  band: derived.band ?? null, statement, stance: verdictStance(verdict) }, null, 2));
13405
13416
  renameSync(tmp, driverDir(run.runDir, "verdict.json"));
13417
+ // — THE STATUS RECORD CARRIES THE BAND AND THE SENTENCE, so a reader of the run does not
13418
+ // have to reach for `verdict` to say something about the outcome. `verdict` is the gate's decision
13419
+ // (CLEAR / CONDITIONAL / BLOCKING) and is engine vocabulary; the band and the composed statement are
13420
+ // what every client surface already speaks. Measured 2026-09-20: an assistant summarising a run read
13421
+ // `verdict` and told the client "BLOCKING" beside a Medium rating, because status.json held no band.
13422
+ writeRunStatus(ctx, { tier: derived.tier ?? null, statement: statement ?? null });
13406
13423
  return derived;
13407
13424
  };
13408
13425
  try { writeVerdictSidecar(); }
@@ -15902,11 +15919,17 @@ export function reconstructCtx(job, opts) { // @internal
15902
15919
  // axes drive which register-unit files placement/digest/skeptic/synthesis reference. Mirror the cold path
15903
15920
  // (pipeline) exactly: decideAxes("") defaults ALL axes ON, so a missing manifest never silently UNDER-spawns.
15904
15921
  ctx.axes = decideAxes(existsSync(P.variantManifest) ? readFileSync(P.variantManifest, "utf8") : "");
15905
- // …including the cold path's axis-from-plan UNION ( B3): the frozen plan is the search
15906
- // authority, and the F2 owner lane routinely puts entries on an axis prose decideAxes won't activate
15907
- // (a watchlist-owners-only manifest has no incumbent alert, yet its owner lane lives on
15908
- // incumbent-class). Without the union a stale-repair/--experiment of register-digest/placement would
15909
- // exclude the executed lane from the per-axis unit list and the declared inputs/freshness stamps.
15922
+ // …including the cold path's axis-from-plan UNION ( B3), and the reason is now the plainer one:
15923
+ // THE FROZEN PLAN IS THE SEARCH AUTHORITY, and what it carries need not match what prose activation
15924
+ // would have chosen. `decideAxes` reads the manifest; the plan is what actually ran.
15925
+ //
15926
+ // The two diverge in both directions and each has a live case. A plan frozen before the guessed
15927
+ // owner lane was removed still carries its owner entries on incumbent-class, and a resumed run must
15928
+ // execute the plan it froze rather than the axes today's manifest would activate. Going the other
15929
+ // way, any future lane that mints entries on an axis the manifest does not announce lands here too.
15930
+ //
15931
+ // Without the union a stale-repair/--experiment of register-digest/placement would exclude the
15932
+ // executed lane from the per-axis unit list and the declared inputs/freshness stamps.
15910
15933
  // Best-effort read — a legacy run without a frozen plan reconstructs exactly as before.
15911
15934
  try {
15912
15935
  const plan = JSON.parse(readFileSync(P.registerPlan, "utf8"));
@@ -1739,8 +1739,17 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1739
1739
  // the engine clamp reasons (opts.conditions = verdict.json reasons). Machinery-only clamps (no client
1740
1740
  // item authored) degrade to one generic plain line — never raw engine jargon, never truncated mid-sentence.
1741
1741
  const emailConditions = opts?.verdict === 'CONDITIONAL' ? actYouConditions(parseActionBuckets(secs['Actions']).you) : [];
1742
+ // THE BANNER SPEAKS THE RUN'S OWN SENTENCE. It used to print the delivery gate's word — "Delivered as
1743
+ // BLOCKING" — which is engine vocabulary and, beside a Medium rating on the report, reads as a
1744
+ // contradiction the reader cannot resolve (measured 2026-09-20). The statement is composed once by the
1745
+ // sidecar writer and is what the report and the index already show; a run recorded before statements
1746
+ // were persisted keeps the old line, which is all such a run has.
1747
+ const bound = String(opts?.statement ?? '').trim();
1748
+ const boundHead = bound
1749
+ ? esc(bound)
1750
+ : `Delivered as ${esc(opts?.verdict ?? '')}${opts?.verdict === 'CONDITIONAL' ? ' — subject to:' : '.'}`;
1742
1751
  const verdictBound = (opts?.verdict && opts.verdict !== 'CLEAR')
1743
- ? `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>Delivered as ${esc(opts.verdict)}${opts.verdict === 'CONDITIONAL' ? ' — subject to:' : '.'}</b>${opts.verdict === 'CONDITIONAL' ? `<br>${(emailConditions.length ? emailConditions : ['the open items set out in the report, before relying on a clean result']).map((r) => `• ${cell(String(r))}`).join('<br>')}` : ''}</p>`
1752
+ ? `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>${boundHead}</b>${opts.verdict === 'CONDITIONAL' ? `<br>${(emailConditions.length ? emailConditions : ['the open items set out in the report, before relying on a clean result']).map((r) => `• ${cell(String(r))}`).join('<br>')}` : ''}</p>`
1744
1753
  : '';
1745
1754
 
1746
1755
  const reviewHeadline = `<div style="${FONT};font-size:11pt;color:#1a1a2e;margin:0 0 14px">`
@@ -874,7 +874,7 @@ function actionsPanel(actionsText) {
874
874
  if (!body) continue;
875
875
  const kind = /answers to your instructions/i.test(label) ? 'ans' : /checks we ran|what we found/i.test(label) ? 'ran' : 'you';
876
876
  const ic = kind === 'ans' ? '✎' : kind === 'ran' ? '✓' : '→';
877
- parts.push(`<div class="actgrp act-${kind}"><h3><span class="actic">${ic}</span> ${inline(label)}</h3>${renderProse(body)}</div>`);
877
+ parts.push(`<div class="actgrp act-${kind}"><h3><span class="actic">${ic}</span> ${inline(label)}</h3>${kind === 'ans' ? answersHtml(body) : renderProse(body)}</div>`);
878
878
  }
879
879
  return parts.length ? `<div class="panel actions" id="actions">${parts.join('')}</div>` : '';
880
880
  }
@@ -900,10 +900,62 @@ export function parseActionBuckets(actionsText) {
900
900
  }
901
901
  return out;
902
902
  }
903
+ // ── ONE LINE PER ANSWER, THE REST FOLDED (owner, on the delivered report, 2026-09-20) ────────────────
904
+ //
905
+ // Every answer to a client's instruction printed at full length, one after another, and the section read
906
+ // as a wall of text. The acceptance brief had already ruled the shape — the question and the answer's
907
+ // opening visible, the explanation behind a fold — and it was never built, because no demo run carries
908
+ // intake asks and so no demo and no mock ever rendered this section.
909
+ //
910
+ // NOTHING IS CUT AND NO WORDS ARE WRITTEN. The line is the answer's FIRST SENTENCE, by the same
911
+ // splitFirstSentence the hero caption folds on (one rule, one set of abbreviations), and the remainder is
912
+ // the rest of the same string. `report.md`, `report-data.json` and the workbook are untouched.
913
+ //
914
+ // WHERE THE FOLD MAY NOT GO, and this is the whole care in the function. An internal note runs from its
915
+ // label to the END OF ITS LINE, and a client surface removes it by cutting to the close of the element
916
+ // that contains it (portal-report.mjs). So:
917
+ // · a wholly internal answer is dropped, exactly as renderProse drops one;
918
+ // · an answer whose remainder is ONLY an internal tail folds inside a wrapper the client strip takes
919
+ // whole, so that reader gets the line and no empty disclosure;
920
+ // · an answer that is internal from its first word takes the whole row with it: the strip would
921
+ // otherwise leave the question standing over a dangling arrow, which portal-report.mjs names as the
922
+ // empty-labelled-row defect;
923
+ // · otherwise the remainder folds, tail included, and the client cut trims it inside the fold's own
924
+ // paragraph while the public remainder stays.
925
+ // Splitting on the public head alone is what makes that true: the visible line can never carry a label.
926
+ function answersHtml(body) {
927
+ const lines = stripInternal(String(body ?? ''), { client: false }).split('\n').map((l) => l.trim());
928
+ const items = [];
929
+ for (const line of lines) {
930
+ if (!line) continue;
931
+ if (/^[-*]\s/.test(line)) items.push(line.replace(/^[-*]\s+/, ''));
932
+ else if (items.length) items[items.length - 1] += ` ${line}`;
933
+ else return renderProse(body); // not the bulleted register — render it as it always was
934
+ }
935
+ if (!items.length) return renderProse(body);
936
+ const rows = [];
937
+ for (const item of items) {
938
+ if (isInternalLabelled(item)) continue;
939
+ const at = item.indexOf('→');
940
+ const label = at < 0 ? '' : item.slice(0, at + 1).trim();
941
+ const answer = at < 0 ? item : item.slice(at + 1).trim();
942
+ const mark = answer.search(/\[internal\]/i);
943
+ const { first, rest } = splitFirstSentence(mark < 0 ? answer : answer.slice(0, mark));
944
+ const tail = mark < 0 ? '' : answer.slice(mark).trim();
945
+ const head = `${label ? `${inline(label)} ` : ''}${inline(first)}`;
946
+ if (!rest && !tail) { rows.push(`<li>${head}</li>`); continue; }
947
+ if (!first && !rest) { rows.push(`<li class="int-note">${head}${inline(tail)}</li>`); continue; }
948
+ const fold = `<details class="ans-more"><summary>More</summary><p>${inline(rest && tail ? `${rest} ${tail}` : rest || tail)}</p></details>`;
949
+ rows.push(`<li>${head}${rest ? fold : `<div class="int-note">${fold}</div>`}</li>`);
950
+ }
951
+ return rows.length ? `<ul class="answers">${rows.join('')}</ul>` : '';
952
+ }
953
+
903
954
  function actionGroup(bucket, kind) {
904
955
  if (!bucket || !bucket.body) return '';
905
956
  const ic = kind === 'ans' ? '✎' : kind === 'ran' ? '✓' : '→';
906
- return `<div class="actgrp act-${kind}"><h3><span class="actic">${ic}</span> ${inline(bucket.label)}</h3>${renderProse(bucket.body)}</div>`;
957
+ const body = kind === 'ans' ? answersHtml(bucket.body) : renderProse(bucket.body);
958
+ return `<div class="actgrp act-${kind}"><h3><span class="actic">${ic}</span> ${inline(bucket.label)}</h3>${body}</div>`;
907
959
  }
908
960
 
909
961
  // doc-52 — the "Conditional — subject to:" banner speaks the SAME plain wording as "Only you can close
@@ -112,6 +112,18 @@
112
112
  .panel.actions .act-you .actic{background:var(--med-soft);color:var(--med-tx)}
113
113
  .panel.actions p,.panel.actions li{font-size:13.5px;color:var(--slate);line-height:1.55}
114
114
  .panel.actions ul{margin:0;padding-left:20px} .panel.actions li{margin:3px 0}
115
+ /* Each answer shows one line; the rest folds. Styled here and not in the frozen renderer, and the
116
+ @media print rule that forces every <details> open already covers it — an exported PDF carries the
117
+ whole answer. The disclosure takes the caption fold's look, which is the only other fold of a
118
+ sentence into its remainder on this page. */
119
+ .panel.actions ul.answers li{margin:7px 0}
120
+ .panel.actions details.ans-more{margin:3px 0 0}
121
+ .panel.actions details.ans-more>summary{cursor:pointer;list-style:none;font-size:11px;font-weight:700;
122
+ letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}
123
+ .panel.actions details.ans-more>summary::-webkit-details-marker{display:none}
124
+ .panel.actions details.ans-more>summary::before{content:"\25b8";margin-right:6px;display:inline-block;transition:.15s;color:var(--rose)}
125
+ .panel.actions details.ans-more[open]>summary::before{transform:rotate(90deg)}
126
+ .panel.actions details.ans-more p{margin:5px 0 0}
115
127
 
116
128
  /* ── THE BOARD'S OWN SECTION STRIP, lifted from the approved mock rather than written here ────────
117
129
  Five entries and the board's own labels, marked not to print because the board marks it so: a strip
@@ -388,7 +400,10 @@
388
400
  in the hero, directly above text that was already fully expanded. Measured in Chrome: the caption's
389
401
  own content was carried correctly and only the control leaked, which is why the commit that added
390
402
  the fold could check the half it thought about and ship the half it did not. */
391
- details.drill>summary,details.method>summary,details.sub-more>summary{display:none!important}
403
+ details.drill>summary,details.method>summary,details.sub-more>summary,
404
+ /* An answer's "More" is a pure toggle: it carries no content of its own, so on paper it would be an
405
+ inert label over text the rule below has already expanded — the defect #470 shipped. */
406
+ details.ans-more>summary{display:none!important}
392
407
  /* The caption fold made this line load-bearing for the document's CONTENT, not just its completeness. The
393
408
  legal/practical reads now live inside .drillbody, so without this rule the exported PDF would
394
409
  carry the one-line verdicts and none of the reasoning behind them. The export prints everything a
@@ -41,9 +41,9 @@
41
41
  // CORRECTED 2026-08-11 — THIS PARAGRAPH USED TO SAY "one qid per office … which is the shape
42
42
  // joinPlanToBands already has", AND THAT WAS FALSE ABOUT THE COMPILER IT DOCUMENTS. There is no
43
43
  // per-office qid. compileRegisterPlan narrows ONE shared `regions` array and hands it to every entry
44
- // (register-plan.mjs:805 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
44
+ // (register-plan.mjs:846 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
45
45
  // nothing ever reaches joinPlanToBands' deferred bucket — whose only source is a block stamped
46
- // `error:true && deferred:true` (register-plan.mjs:1317 extendRegisterPlan).
46
+ // `error:true && deferred:true` (register-plan.mjs:1489 extendRegisterPlan).
47
47
  //
48
48
  // The consequence was not academic. `deferred_coverage` rode the plan and nothing that a reader sees
49
49
  // read it: coverage-form.mjs seeded its deferred rows from skeleton qids alone, so an EU+US matter on a