clearotron 0.3.2 → 0.3.3-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CONTRIBUTING.md +12 -0
  2. package/INSTALL.md +8 -0
  3. package/bin/onboard.mjs +29 -7
  4. package/bin/start.mjs +1 -1
  5. package/build-info.json +2 -2
  6. package/demo/MANIFEST.json +27 -0
  7. package/docs/INTAKE.md +8 -0
  8. package/docs/architecture/04-configuration-reference.md +1 -1
  9. package/driver/CHANGELOG.md +37 -0
  10. package/driver/clearance-variants-record.mjs +12 -1
  11. package/driver/common-law-coverage-status.mjs +113 -0
  12. package/driver/contract-audit.mjs +1 -1
  13. package/driver/contract-e3-backlog.mjs +37 -37
  14. package/driver/contract-vocabulary.mjs +8 -8
  15. package/driver/coverage-form-io.mjs +3 -1
  16. package/driver/coverage-form.mjs +38 -11
  17. package/driver/coverage-ledger.mjs +37 -7
  18. package/driver/coverage-union.mjs +2 -2
  19. package/driver/crowd-context.mjs +19 -6
  20. package/driver/drainer-identity.mjs +1 -1
  21. package/driver/engine/mcp/clarivate-server.mjs +4 -2
  22. package/driver/engine/mcp/corsearch-server.mjs +3 -1
  23. package/driver/engine/mcp/coverage-server.mjs +1 -1
  24. package/driver/engine/mcp/dispositions-server.mjs +47 -5
  25. package/driver/engine/mcp/euipo-server.mjs +2 -0
  26. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  27. package/driver/engine/mcp/gather-config.mjs +8 -2
  28. package/driver/engine/mcp/probe-server.mjs +28 -0
  29. package/driver/engine/mcp/proposal-fields.mjs +45 -0
  30. package/driver/engine/mcp/recording-server.mjs +30 -0
  31. package/driver/engine/mcp/signa-server.mjs +2 -0
  32. package/driver/engine/mcp/supplemental.mjs +89 -12
  33. package/driver/engine/mcp/unit-note-server.mjs +50 -0
  34. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  35. package/driver/engine/openai-agent.mjs +7 -0
  36. package/driver/engine/probe.mjs +67 -14
  37. package/driver/engine/tool-refusal.mjs +16 -0
  38. package/driver/enqueue-schema.mjs +2 -2
  39. package/driver/envelope-settle.mjs +82 -13
  40. package/driver/findings-model.mjs +4 -4
  41. package/driver/gateway.mjs +18 -2
  42. package/driver/manager-groups-verdict.mjs +1 -1
  43. package/driver/matter-frame-record.mjs +24 -7
  44. package/driver/named-band.mjs +1 -1
  45. package/driver/package.json +1 -1
  46. package/driver/partial-payload-baseline.json +12 -3
  47. package/driver/pipeline.mjs +141 -37
  48. package/driver/publish/index.mjs +40 -25
  49. package/driver/publish/xlsx.mjs +26 -4
  50. package/driver/queue-markers.mjs +44 -0
  51. package/driver/queue-watch-verdict.mjs +2 -2
  52. package/driver/register-availability.mjs +2 -2
  53. package/driver/register-plan.mjs +313 -21
  54. package/driver/roster-verdict.mjs +1 -1
  55. package/driver/runner.mjs +26 -2
  56. package/driver/skills/clearance-common-law/SKILL.md +2 -0
  57. package/driver/skills/clearance-register/SKILL.md +44 -3
  58. package/driver/skills/clearance-register/digest.md +5 -5
  59. package/driver/skills/clearance-register/providers/clarivate.md +1 -1
  60. package/driver/skills/clearance-register/unit.md +39 -0
  61. package/driver/skills/clearance-variants/SKILL.md +1 -1
  62. package/driver/skills/matter-frame/SKILL.md +4 -2
  63. package/driver/stages.mjs +12 -5
  64. package/driver/status-snapshot.mjs +1 -1
  65. package/driver/suite-census.json +236 -14
  66. package/driver/synthesis-record.mjs +80 -2
  67. package/driver/unit-file-drift.mjs +3 -3
  68. package/driver/unit-inventory.mjs +2 -2
  69. package/driver/unit-state-verdict.mjs +1 -1
  70. package/driver/updater-identity.mjs +2 -3
  71. package/driver/variant-manifest-model.mjs +11 -1
  72. package/driver/verify.mjs +5 -5
  73. package/driver/withheld-families.mjs +104 -0
  74. package/mcp-server/CHANGELOG.md +4 -0
  75. package/mcp-server/lib/brief.mjs +5 -7
  76. package/mcp-server/lib/runs.mjs +1 -1
  77. package/mcp-server/package.json +1 -1
  78. package/mcp-server/server.mjs +3 -2
  79. package/package.json +1 -1
  80. package/portal-ui/dist/assets/{index-DMthc7PQ.js → index-GBbbyQxc.js} +22 -4
  81. package/portal-ui/dist/index.html +1 -1
  82. package/portal-ui/package.json +1 -1
  83. package/providers/_shared/count.mjs +2 -2
  84. package/providers/_shared/enumerate.mjs +15 -2
  85. package/providers/_shared/execute-plan.mjs +19 -1
  86. package/providers/_shared/plan-guards.mjs +40 -0
  87. package/providers/clarivate/src/capabilities.js +15 -5
  88. package/providers/clarivate/src/core.js +41 -5
  89. package/providers/corsearch/src/capabilities.js +4 -0
  90. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  91. package/providers/oauth-mcp-bridge/package.json +1 -1
  92. package/providers/signa/src/capabilities.js +22 -8
  93. package/providers/signa/src/core.js +12 -1
  94. package/scripts/demo-evidence.mjs +114 -0
  95. package/scripts/engine-probe.mjs +6 -5
  96. package/scripts/env-audit.mjs +1 -1
  97. package/scripts/freeze-example-run.mjs +3 -3
  98. package/scripts/live-surface-check.mjs +26 -19
  99. package/scripts/mint-suite-census.mjs +66 -0
  100. package/scripts/package-size-budget.mjs +117 -0
  101. package/scripts/register-plan-shape.mjs +259 -0
  102. package/scripts/release-note-required.mjs +38 -1
  103. package/scripts/settings-render-check.mjs +36 -0
  104. package/scripts/travelling-predicates.mjs +1 -1
  105. package/shared/identifier-scan.mjs +22 -5
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2",
5
+ "version": "0.3.3-beta.0",
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": {
@@ -27,6 +27,14 @@
27
27
  "tool": "record_dispositions",
28
28
  "why": "no fixture yet; accumulating semantics claimed in the tool description ('Everything already recorded is kept') and not yet planted"
29
29
  },
30
+ {
31
+ "tool": "record_coverage_status",
32
+ "why": "both declared fields are required, so a call has no top-level key to omit; its accumulation by unit (a later call keeps the units it omits) is pinned in a-common-law-coverage-status-is-read-as-data.test.mjs"
33
+ },
34
+ {
35
+ "tool": "record_withheld_families",
36
+ "why": "its one declared field is required, so a call has no top-level key to omit; its accumulation by qid (a later call keeps the families it omits) is pinned in every-waiting-family-is-asked-or-withheld.test.mjs"
37
+ },
30
38
  {
31
39
  "tool": "record_unit_note",
32
40
  "why": "no fixture yet; every field optional, which is the exposed shape — this row is the highest-priority one to close"
@@ -61,11 +69,12 @@
61
69
  {
62
70
  "tool": "record_clearance_variants",
63
71
  "fields": [
72
+ "goods_words",
64
73
  "incumbent_classes",
65
- "watchlist_owners",
66
- "search_floor"
74
+ "search_floor",
75
+ "watchlist_owners"
67
76
  ],
68
- "why": "a partial silently empties the register-axis search floor, narrowing the run's own definition of coverage"
77
+ "why": "a partial silently empties the register-axis search floor, narrowing the run's own definition of coverage. `goods_words` joins them for the same reason and one worse: an emptied goods list reads as \"this matter named no goods\" rather than as a partial call, so the crowded sweep it exists to narrow goes back to returning more filings than anyone can read, and nothing says why. The MERGE preserves all four (mergeClearanceVariantsCall, keep-if-absent) — these rows record that the ACCEPTOR alone does not."
69
78
  },
70
79
  {
71
80
  "tool": "record_blind_frame",
@@ -10,6 +10,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, copyFil
10
10
  import { createHash } from "node:crypto";
11
11
  import { join, dirname, basename, resolve } from "node:path"; // resolve: the resume line must work from any cwd
12
12
  import { driverDir, driverRel, ensureDriverDir } from "../shared/driver-dir.mjs"; // — one definition of where `_driver/` is
13
+ import { goodsOf } from "./queue-markers.mjs"; // — one reading of "does this job name goods", shared with the intake gate
13
14
  import { terminalClampDecision, orderClausesForLede, clientConditions, clauseForDefect } from "./terminal-clamp.mjs"; // — deliver and clamp, never withhold
14
15
  import { recordSpan } from "./attributed-span.mjs"; // — driver work the decomposition can attribute
15
16
  import { fileURLToPath } from "node:url";
@@ -39,8 +40,8 @@ import { parseVerdict, countCitedDefects, parseCorrectionKinds, parseCorrections
39
40
  import { readAcceptedFlags } from "./narrative-refutation-record.mjs"; // T3b — the typed flags, not the re-parse
40
41
  import { evidenceClaimViolations, evidenceClaimTable } from "./evidence-claim-invariant.mjs"; //
41
42
  import { buildCorrectionsApplied, correctionsWorklist, correctionsAppliedTable, correctionScope, scopeDrift, unresolvedFlags, reportLines, linesOf, REPORT_LINE_KEY, REPORT_LINE_LABEL } from "./corrections-feedforward.mjs";
42
- import { parseCoverageLedgerJson, parseCoverageLedgerFull, deriveCoverageStatus, classTokensFromScopeText, coerceToolAbsenceDeferred, applyTaintDeferred, decideRegisterGap, splitDeferredByCloseability, coverageLedgerTableRows, coverageUnitLabel, NON_MATERIAL_AXES, COVERAGE_STATUSES } from "./coverage-ledger.mjs";
43
- import { receiptSettled, readEnvelopeDecision, settleReceipt, settledDeferralsSection } from "./envelope-settle.mjs";
43
+ import { parseCoverageLedgerJson, parseCoverageLedgerFull, deriveCoverageStatus, classTokensFromScopeText, coerceToolAbsenceDeferred, applyTaintDeferred, decideRegisterGap, splitDeferredByCloseability, formRowUnitKey, coverageLedgerTableRows, coverageUnitLabel, NON_MATERIAL_AXES, COVERAGE_STATUSES } from "./coverage-ledger.mjs";
44
+ import { receiptSettled, readEnvelopeDecision, settleReceipt, settledDeferralsSection, readStickyGaps } from "./envelope-settle.mjs";
44
45
  import { readRegisterTaint, readActiveTaintAxes } from "./register-taint.mjs";
45
46
  import { parseNamedBand, mergeNamedBands, findCollapsedBands, quarantineUnknownStates, taintQuarantineCleanBlocks, bandRecords } from "./named-band.mjs";
46
47
  import { recordOriginsFor } from "./record-origins.mjs";
@@ -97,7 +98,7 @@ import { publishReport, composeEmailHtml, deliverySubject } from "./publish/inde
97
98
  import { parseCaseLawProfiles, joinCaseLawProfiles } from "./publish/parse.mjs";
98
99
  import { buildAuditMd, parseSpineFindingBlocks } from "./publish/audit-from-spine.mjs";
99
100
  import { deriveRegisterPresence } from "./publish/register-presence.mjs"; // — the audit stores every live in-scope record
100
- import { lastAcceptedMatterFrame, frameIdentifiedClasses, frameHouseElementCandidate } from "./matter-frame-record.mjs"; // — the frame's inferred scope, when nothing was instructed; and the classes it judged necessary beyond the instructed ones, which the plan compile unions in
101
+ import { lastAcceptedMatterFrame, frameIdentifiedClasses, frameIdentifiedClassRows, frameHouseElementCandidate } from "./matter-frame-record.mjs"; // — the frame's inferred scope, when nothing was instructed; and the classes it judged necessary beyond the instructed ones, each with its reason, which the plan compile gives one identical-mark question apiece (decision 18)
101
102
  import { romanizedTermsFromPlan, mintSupplementalQid } from "./register-plan.mjs";
102
103
  import { excludeHouseElement, verifyHouseElementOwnership, resolveRegions as resolvePlanRegions, HOUSE_ELEMENT_RECEIPT } from "./register-plan.mjs"; // 647 — the client's own element leaves the conflict analysis only on a verified receipt
103
104
  import { resolveRecordExecutor } from "./register-records.mjs"; // — the stamp the late lanes never met
@@ -463,8 +464,35 @@ export function readPlanExecution(ctx) { // @internal
463
464
  const p = ctx?.paths?.planExecution;
464
465
  try { return p && existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null; } catch { return null; }
465
466
  }
467
+ /**
468
+ * A qid this run has accepted as a capability gap (envelope-settle.mjs stickyGapsAfter) leaves `missing`
469
+ * for `deferred`, with the reason it was accepted under, so no re-join can put it back on the ladder.
470
+ * Returns the join unchanged when nothing is held. PURE.
471
+ */
472
+ export function holdStickyGapsIn(join, sticky) { // @internal
473
+ const held = (join?.missing ?? []).filter((q) => sticky?.has(q));
474
+ if (!held.length) return join;
475
+ return { ...join, missing: join.missing.filter((q) => !sticky.has(q)),
476
+ deferred: [...(join.deferred ?? []), ...held.map((q) => ({ qid: q, reason: String(sticky.get(q)?.reason ?? "").slice(0, 300) }))] };
477
+ }
478
+
479
+ /**
480
+ * The coverage units of this run's sticky capability gaps, as ledgerUnitKey()s, found through the coverage
481
+ * form's own deferred rows (the driver writes each with its qid). The envelope, the escalation and the
482
+ * skeptic's ledger split then hold those rows whatever reason the seat wrote for them. A run with no form,
483
+ * or no sticky gap, holds nothing extra, exactly as before.
484
+ */
485
+ export function stickyGapUnits(P) { // @internal
486
+ const sticky = readStickyGaps(P);
487
+ if (!sticky.size) return new Set();
488
+ const stamp = coverageFormStamp(P.runDir);
489
+ const rows = stamp.required ? (readCoverageForm(P.runDir, stamp.formName).rows ?? []) : [];
490
+ return new Set(rows.filter((r) => r?.kind === "deferred" && sticky.has(String(r.qid ?? ""))).map(formRowUnitKey));
491
+ }
492
+
466
493
  export function writePlanExecutionReceipt(ctx, joinRes) { // @internal
467
494
  const P = ctx.paths;
495
+ joinRes = holdStickyGapsIn(joinRes, readStickyGaps(P));
468
496
  // — the ONE place the receipt is written is the one place this decision is taken. The
469
497
  // reclassification is keyed off the receipt already on disk (ladderExhaustedQids), so every writer
470
498
  // agrees without any of them knowing about it: the fan-in, the envelope's re-join after its close
@@ -2384,7 +2412,12 @@ function attachRegisterPlan(ctx, { frozenOnly = false } = {}) {
2384
2412
  // for the documented normal case. Harmless on corsearch (an absent region clause is a worldwide
2385
2413
  // sweep); fatal on a provider whose regions[] is mandatory, where every entry then errored on its
2386
2414
  // count probe and the whole plan joined MISSING at fan-in (review finding 11).
2387
- job: { jobKey: ctx.run.slug, classes: [...new Set([...inScopeClassList(ctx.job, ctx.profile).map(String), ...frameIdentifiedClasses(P.runDir)])], jurisdictions: registerJurisdictions(ctx.job, ctx.profile) },
2415
+ // DECISION 18: the frame's added classes are no longer UNIONED into the plan's class scope. They
2416
+ // ride one identical-mark entry each instead, which is what an added class is supposed to cost —
2417
+ // unioning put every added class on every variant and every family. The instructed scope is what
2418
+ // it always was, and `addedClasses` only ever appends.
2419
+ job: { jobKey: ctx.run.slug, classes: inScopeClassList(ctx.job, ctx.profile).map(String), jurisdictions: registerJurisdictions(ctx.job, ctx.profile) },
2420
+ addedClasses: frameIdentifiedClassRows(P.runDir),
2388
2421
  form, skillVersion: "clearance-register@spec48",
2389
2422
  // — WHICH ELEMENT THE EXCLUSION TOOK OUT, so the compile can make its form band unreachable
2390
2423
  // rather than merely unasked-for. Null unless the ownership receipt verified, which is the same
@@ -6219,10 +6252,11 @@ export function skepticDeferralExtra(ctx) { // @internal
6219
6252
  const gapAxes = capabilityGapAxes(ctx.registerPlan, receipt);
6220
6253
  const fullyDeferred = new Set((ctx.registerPlan ? fullyDeferredAxes(ctx.registerPlan) : []).map((a) => String(a.axis).toLowerCase()));
6221
6254
  const axes = [...new Set(rows.map((r) => String(r.axis ?? "").toLowerCase()).filter(Boolean))];
6255
+ const heldUnits = stickyGapUnits(P);
6222
6256
  const ledgerLines = coverageLedgerTableRows(rows);
6223
6257
  const held = [], closeable = [];
6224
6258
  for (const a of axes) {
6225
- const s = splitDeferredByCloseability(rows, a, gapAxes, { fullyDeferred: fullyDeferred.has(a) });
6259
+ const s = splitDeferredByCloseability(rows, a, gapAxes, { fullyDeferred: fullyDeferred.has(a), heldUnits });
6226
6260
  for (const r of s.held) held.push(`${a} / ${r.unit} — ${String(r.reason ?? "").replace(/\s+/g, " ").slice(0, 160)}`);
6227
6261
  for (const r of s.closeable) closeable.push(`${a} / ${r.unit} — ${String(r.reason ?? "").replace(/\s+/g, " ").slice(0, 160)}`);
6228
6262
  }
@@ -6230,6 +6264,16 @@ export function skepticDeferralExtra(ctx) { // @internal
6230
6264
  const deferredQids = deferredList.slice(0, 24)
6231
6265
  .map((d) => `- ${d.qid} — ${String(d.reason ?? "").replace(/\s+/g, " ").slice(0, 200)}`);
6232
6266
  const more = deferredList.length > 24 ? [`- …and ${deferredList.length - 24} more (read ${P.planExecution} for the rest)`] : [];
6267
+ // THE CLASSES THE FRAME ADDED, AND WHAT EACH RETURNED. This block names what was refused and what is
6268
+ // open, and nothing that ran, so a class the frame added beyond the instructed ones — asked and
6269
+ // answered with its records listed — was absent from it, and the skeptic reported it as never swept.
6270
+ const addedClassRows = (ctx.registerPlan?.entries ?? []).filter((e) => e?.added_class_reason).map((e) => {
6271
+ const ran = (receipt?.executed ?? []).find((x) => x.qid === e.qid);
6272
+ const answer = ran ? `${ran.state}${Number.isFinite(ran.records) ? `, ${ran.records} records` : ""}`
6273
+ : (receipt?.deferred ?? []).some((d) => d.qid === e.qid) ? "refused (listed above)"
6274
+ : (receipt?.missing ?? []).includes(e.qid) ? "not run (missing)" : "not in the receipt";
6275
+ return `- ${e.qid} (class ${(e.nice_classes ?? []).join(", ")}) — ${answer}`;
6276
+ });
6233
6277
  const ownerNegative = ownerScreenNegative(readOwnerScreen(P));
6234
6278
  return lines(
6235
6279
  `COVERAGE + EXECUTION, DRIVER-COMPUTED — do NOT re-derive any of this from the findings prose. These rows come from ${P.registerCoverageLedger} and ${P.planExecution}, the machine artifacts the driver wrote; both are also yours to read directly, but the answer to "what is still open, and can a re-run close it" is already below.`,
@@ -6240,6 +6284,7 @@ export function skepticDeferralExtra(ctx) { // @internal
6240
6284
  deferredQids.length
6241
6285
  ? lines(`Plan-execution receipt — queries the ACTIVE PROVIDER REFUSED deterministically (${deferredList.length} of ${(receipt.executed?.length ?? 0) + deferredList.length + (receipt.missing?.length ?? 0)} planned), with the mechanical reason per query:`, ...deferredQids, ...more)
6242
6286
  : "Plan-execution receipt: no query was deterministically refused by the provider this run.",
6287
+ addedClassRows.length ? lines("", "Classes the frame added beyond the instructed ones — each question, and what it returned:", ...addedClassRows) : "",
6243
6288
  "",
6244
6289
  closeable.length
6245
6290
  ? `CLOSEABLE floor obligations (a warm re-run reaches these — escalate them if they are material): ${closeable.join("; ")}.`
@@ -6401,7 +6446,7 @@ function plainRegisterExtra(ctx) {
6401
6446
  }
6402
6447
  }
6403
6448
 
6404
- // ── THE RECEIPT, AND THE THREE CLASSES, STATED ONCE ────────────────────────────────────────────────
6449
+ // ── THE RECEIPT, AND THE GRADED CLASSES, STATED ONCE ────────────────────────────────────────────────
6405
6450
  //
6406
6451
  //. The receipt this block tabulates is the answer to "did that search run", and until now exactly
6407
6452
  // one stage got it: the REVIEWER. `synthesis` — the stage that writes the claim the reviewer then
@@ -6419,7 +6464,7 @@ function plainRegisterExtra(ctx) {
6419
6464
  // out twice, once per seat, is the shape — one rule in two places, drifting from the day the second
6420
6465
  // copy is typed — and the classes are the part that must never drift, because class (1) is the blocking
6421
6466
  // condition.
6422
- // THE HEAD AND THE THREE CLASSES ARE NOT DECLARED HERE. They live in register-plan.mjs, beside the
6467
+ // THE HEAD AND THE GRADED CLASSES ARE NOT DECLARED HERE. They live in register-plan.mjs, beside the
6423
6468
  // derivation that assigns the states, because the gateway's corrective hint is a THIRD reader of the
6424
6469
  // same grading and `pipeline.mjs` imports `gateway.mjs` — so a constant declared here could never
6425
6470
  // reach it.
@@ -6447,8 +6492,13 @@ function planAuditExtra(ctx, { stage = "narrative-refutation" } = {}) {
6447
6492
  `- executed: ${exec.executed.length} entr${exec.executed.length === 1 ? "y" : "ies"} (${crowds.length} crowd/incomplete${crowds.length ? `: ${crowds.slice(0, 4).map((x) => x.qid).join("; ")}` : ""})`,
6448
6493
  `- missing (no band block): ${exec.missing.length}${exec.missing.length ? ` — ${exec.missing.slice(0, 4).join("; ")}` : ""}`,
6449
6494
  `- skipped (crowd-gated fringe): ${exec.skipped.length}`,
6495
+ // The families waiting for the reading turn, and those it asked. Without these lines the table's
6496
+ // own buckets summed to the whole plan less the waiting families, and a reviewer read it as
6497
+ // "no family waiting" while the receipt held 156.
6498
+ `- awaiting the reading turn's ask: ${exec.awaiting?.length ?? 0}`,
6499
+ exec.asked?.length ? `- asked by the reading turn (a waiting family's question, asked by another entry): ${exec.asked.length}` : "",
6450
6500
  exec.unplanned?.length ? `- unplanned qid-stamped blocks: ${exec.unplanned.length}` : "",
6451
- ...(exec.skeleton ?? []).map((s) => `- axis ${s.axis}: ${s.state} (${s.executed}/${s.entries} executed, ${s.crowds} crowd)`),
6501
+ ...(exec.skeleton ?? []).map((s) => `- axis ${s.axis}: ${s.state} (${s.executed}/${s.entries} executed, ${s.crowds} crowd${s.awaiting ? `, ${s.awaiting} awaiting` : ""})`),
6452
6502
  ];
6453
6503
  } catch (e) { rows = [`- (receipt table unavailable — read + audit the receipt file directly: ${P.planExecution})`]; note(`plan-audit receipt table (non-fatal): ${e.message}`); }
6454
6504
  return lines(
@@ -6716,7 +6766,7 @@ export function readFindingsForReport(P) {
6716
6766
  // The matter's FULL in-scope Nice-class set (incl. services 42/44) — declared classes (top-level OR per-mark)
6717
6767
  // else the profile defaults. The dangerous-band floor + its coverage gate must span ALL of these, never a
6718
6768
  // goods-only subset (the VELTRIPHEN services-class miss). Strings, deduped.
6719
- function inScopeClassList(job, profile) {
6769
+ export function inScopeClassList(job, profile) { // @internal — also read by scripts/register-plan-shape.mjs, which must resolve scope exactly as the run did
6720
6770
  const fromMarks = Array.isArray(job?.marks) ? job.marks.flatMap((m) => (Array.isArray(m?.classes) ? m.classes : [])) : [];
6721
6771
  const declared = [...(Array.isArray(job?.classes) ? job.classes : []), ...fromMarks];
6722
6772
  return [...new Set((declared.length ? declared : (profile?.defaultClasses ?? [])).map(String))];
@@ -7035,7 +7085,14 @@ export function coverageRowAreaLabel(axis, unit) { // @internal
7035
7085
  export function coverageJudgmentRows(ledgerRows, planExecution) { // @internal
7036
7086
  const open = [];
7037
7087
  for (const r of ledgerRows ?? []) {
7038
- if (!r || String(r.status ?? "").toLowerCase() === "confirmed-clean") continue;
7088
+ // `withheld-by-judgment` joins `confirmed-clean` in NOT reaching the reader, and for the opposite
7089
+ // reason. A clean row has nothing to disclose. A withheld one has something to say, and it is
7090
+ // ruled to belong in the run record and the coverage ledger only: nothing is added to the report
7091
+ // (owner, 2026-09-18). A family the reading turn chose not to open, having read the identical question as
7092
+ // a list and found what it needed, is not a gap in the client's search — it is where the work was
7093
+ // spent — and a row saying otherwise would read to a lawyer as an incomplete job.
7094
+ const status = String(r?.status ?? "").toLowerCase();
7095
+ if (!r || status === "confirmed-clean" || status === "withheld-by-judgment") continue;
7039
7096
  const reason = String(r.reason ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
7040
7097
  const axis = String(r.axis ?? "").toLowerCase();
7041
7098
  open.push({ axis, area: coverageRowArea(axis, r.unit), areaLabel: coverageRowAreaLabel(axis, r.unit),
@@ -8455,6 +8512,42 @@ function postponeRun(e, run, meta = {}) {
8455
8512
  return { ok: false, postponed: true, resetsAt, codename, fromStage: e.stage, runDir: run?.runDir ?? null };
8456
8513
  }
8457
8514
 
8515
+ /**
8516
+ * The instructed scope a job asks for — what the MATTER named, before any model ran.
8517
+ *
8518
+ * Pulled out of the run so it can be driven straight from a job, because the defect it closes lived
8519
+ * exactly in the seam between the intake gate and this object: the gate counts a job as carrying a
8520
+ * goods description under EITHER spelling, and this stamped only the current one. A job written the
8521
+ * older way passed the gate and landed `goods: null` — the scope file saying the matter named no
8522
+ * goods while the request plainly did — and every reader that asks what the matter covers reads this
8523
+ * file. Nothing said so, because nothing compared the two sites.
8524
+ *
8525
+ * PURE: a job in, a plain object out, no IO.
8526
+ */
8527
+ export function instructedScopeOf(job) {
8528
+ const markNames = Array.isArray(job?.marks)
8529
+ ? job.marks.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
8530
+ : (job?.markName ?? job?.name ?? null);
8531
+ return {
8532
+ marks: markNames,
8533
+ classes: job?.classes ?? null,
8534
+ jurisdictions: job?.jurisdictions ?? null,
8535
+ // THE GATE'S OWN READING, imported rather than restated. A run's job is folded onto one field at
8536
+ // assembly, so this is normally reading what is already there; it stays for a job handed to this
8537
+ // function directly, and because the gate and the scope disagreeing is the defect it closes.
8538
+ goods: goodsOf(job),
8539
+ customer: job?.customer ?? null,
8540
+ // the geography stamp (enqueue-schema.mjs, "the GEOGRAPHY STAMP": {mode, origin}) — copied
8541
+ // VERBATIM, never recomputed: foldRecipeScope mutates job.jurisdictions on later passes (and
8542
+ // re-stamps origin "saved-search" when it does), so by read time the stamp is the only surviving
8543
+ // record of where the territories came from. Without it here, the frame reconstructs that
8544
+ // provenance from the request prose — a reconstruction validators.matterContext cannot check.
8545
+ // null = the job predates the stamp ("unrecorded", effective-scope.mjs) — an explicit state,
8546
+ // never a missing key.
8547
+ geography: job?.geography ?? null,
8548
+ };
8549
+ }
8550
+
8458
8551
  async function pipelineInner(job, opts = {}) {
8459
8552
  assertTierSanity();
8460
8553
  // The engine binary, first and UNCONDITIONALLY — before the register preflight, which two lanes skip.
@@ -8872,24 +8965,7 @@ async function pipelineInner(job, opts = {}) {
8872
8965
  // frame validator compares against THIS file, never against the frame's own paraphrase) —
8873
8966
  // paraphrase drift between the request and the frame is a defect, not a style choice.
8874
8967
  try {
8875
- const markNames = Array.isArray(job.marks)
8876
- ? job.marks.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
8877
- : (job.markName ?? job.name ?? null);
8878
- writeFileSync(P.instructedScope, JSON.stringify({
8879
- marks: markNames,
8880
- classes: job.classes ?? null,
8881
- jurisdictions: job.jurisdictions ?? null,
8882
- goods: job.goods ?? null,
8883
- customer: job.customer ?? null,
8884
- // the geography stamp (enqueue-schema.mjs, "the GEOGRAPHY STAMP": {mode, origin}) — copied
8885
- // VERBATIM, never recomputed: foldRecipeScope mutates job.jurisdictions on later passes (and
8886
- // re-stamps origin "saved-search" when it does), so by read time the stamp is the only
8887
- // surviving record of where the territories came from. Without it here, the frame reconstructs
8888
- // that provenance from the request prose — a reconstruction validators.matterContext cannot
8889
- // check. null = the job predates the stamp ("unrecorded", effective-scope.mjs) — an explicit
8890
- // state, never a missing key.
8891
- geography: job.geography ?? null,
8892
- }, null, 2) + "\n");
8968
+ writeFileSync(P.instructedScope, JSON.stringify(instructedScopeOf(job), null, 2) + "\n");
8893
8969
  } catch (e) { note(`instructed-scope write failed (non-fatal): ${e.message}`); }
8894
8970
  // THE STORED DEFAULTS THE ENGINE CANNOT SEARCH — recorded by the run, not only by the plan preview.
8895
8971
  //
@@ -9820,6 +9896,19 @@ async function pipelineInner(job, opts = {}) {
9820
9896
  const fanInRepairs = [];
9821
9897
  const dispatchPlanQids = async (a, qids, repairId, max = 1) => {
9822
9898
  if (!planExec || !ctx.registerPlan) return null;
9899
+ // A qid this run has accepted as a capability gap is never sent to the provider again, under any plan
9900
+ // version (envelope-settle.mjs stickyGapsAfter). A whole-axis dispatch becomes the axis's other qids;
9901
+ // the executor's qid-ownership merge keeps the gap's own block as it stands.
9902
+ const sticky = readStickyGaps(P);
9903
+ if (sticky.size) {
9904
+ const wanted = qids?.length ? qids : (ctx.registerPlan.entries ?? []).filter((e) => e.axis === a).map((e) => e.qid);
9905
+ const held = wanted.filter((q) => sticky.has(q));
9906
+ if (held.length) {
9907
+ runLog(run.runDir, { event: "plan-qids-sticky-gap", axis: a, qids: held, action: repairId });
9908
+ qids = wanted.filter((q) => !sticky.has(q));
9909
+ if (!qids.length) return null;
9910
+ }
9911
+ }
9823
9912
  if (!repairLedger.canAttempt(repairId, a, { max, epoch: repairEpoch })) return null;
9824
9913
  note(`register-unit ${a}: ${repairId} — direct executor dispatch (code, no agent turn) for ${qids?.length ?? "all"} dictated slice(s)`);
9825
9914
  let outcome;
@@ -10280,7 +10369,15 @@ async function pipelineInner(job, opts = {}) {
10280
10369
  // been a log line. Settling here is idempotent and makes the decision durable.
10281
10370
  if (!receiptSettled(P, priorReceipt).settled) await settleEnvelopeAtReceipt("receipt-reuse");
10282
10371
  } else {
10283
- let joinRes = joinPlanToBands(ctx.registerPlan, readBands());
10372
+ // A qid this run has accepted as a capability gap does not ride the ladder again. It leaves `missing`
10373
+ // for `deferred` with the reason it was accepted under, before any dispatch or followup reads the
10374
+ // join, so neither the direct executor nor the plan-join followup re-dictates it.
10375
+ const holdStickyGaps = (j) => {
10376
+ const held = holdStickyGapsIn(j, readStickyGaps(P));
10377
+ if (held !== j) runLog(run.runDir, { event: "plan-qids-sticky-gap", qids: j.missing.filter((q) => !held.missing.includes(q)), action: "held-from-missing" });
10378
+ return held;
10379
+ };
10380
+ let joinRes = holdStickyGaps(joinPlanToBands(ctx.registerPlan, readBands()));
10284
10381
  const missingEntriesByAxis = () => {
10285
10382
  const byQid = new Map(ctx.registerPlan.entries.map((e) => [e.qid, e]));
10286
10383
  const m = new Map();
@@ -10302,7 +10399,7 @@ async function pipelineInner(job, opts = {}) {
10302
10399
  runLog(run.runDir, { event: "plan-qids-missing", axis: a, qids, action: "plan-direct-execute" });
10303
10400
  await dispatchPlanQids(a, qids, "plan-direct-execute", 2);
10304
10401
  }
10305
- joinRes = joinPlanToBands(ctx.registerPlan, readBands());
10402
+ joinRes = holdStickyGaps(joinPlanToBands(ctx.registerPlan, readBands()));
10306
10403
  if (!joinRes.missing.length) deriveNamedBand(ctx); // dispatch landed blocks — re-merge so Layer B reads them
10307
10404
  }
10308
10405
  if (joinRes.missing.length) {
@@ -10327,7 +10424,7 @@ async function pipelineInner(job, opts = {}) {
10327
10424
  const wf = await stage("register-unit", { ...ctx, axis: a }, { force: true, followup, sessionKey: unitKey[a], trigger: "plan-join" });
10328
10425
  if (!wf.ok) note(`register-unit ${a}: warm plan-join followup failed (${wf.fail}) — the plan-unexecuted StageFailure below holds the line`);
10329
10426
  }
10330
- joinRes = joinPlanToBands(ctx.registerPlan, readBands());
10427
+ joinRes = holdStickyGaps(joinPlanToBands(ctx.registerPlan, readBands()));
10331
10428
  deriveNamedBand(ctx); // the followup appended band blocks — re-merge so Layer B reads them
10332
10429
  }
10333
10430
  const skeleton = writeExecution(joinRes);
@@ -10402,6 +10499,7 @@ async function pipelineInner(job, opts = {}) {
10402
10499
  { failClass, repairs: fanInRepairs, quantity: joinRes.missing.length });
10403
10500
  }
10404
10501
  runLog(run.runDir, { event: "plan-execution", executed: joinRes.executed.length, skipped: joinRes.skipped.length, unplanned: joinRes.unplanned.length,
10502
+ awaiting: joinRes.awaiting?.length ?? 0, asked: joinRes.asked?.length ?? 0,
10405
10503
  axes: skeleton.map((s) => `${s.axis}:${s.state}`) });
10406
10504
  // Decide the deferrals now — before placement-inquiry, which on the evidence run started one second
10407
10505
  // after this point on inputs the run had just recorded as unfinished.
@@ -11610,7 +11708,7 @@ async function pipelineInner(job, opts = {}) {
11610
11708
  // that designates no floor.
11611
11709
  if (owned.length > 0 && !floorBreachAxes.has(a.toLowerCase())) {
11612
11710
  const split = splitDeferredByCloseability(ledger, a, escalationGapAxes,
11613
- { fullyDeferred: escalationFullyDeferred.has(a.toLowerCase()) });
11711
+ { fullyDeferred: escalationFullyDeferred.has(a.toLowerCase()), heldUnits: stickyGapUnits(P) });
11614
11712
  const openNonDeferred = owned.some((r) => r.status !== "deferred" && r.status !== "coverage-limited");
11615
11713
  if (split.held.length > 0 && split.closeable.length === 0 && !openNonDeferred) {
11616
11714
  note(`escalation skipped ${a} — capability-gap deferral: the active register provider cannot express those slices, so a re-run re-derives the same refusal (the gap stays open and disclosed)`);
@@ -11686,7 +11784,7 @@ async function pipelineInner(job, opts = {}) {
11686
11784
  // while ALSO carrying held rows — and the resumed unit must not be left to think those are
11687
11785
  // work it failed to do. Name them, exactly as the envelope's close followup does.
11688
11786
  const escHeld = splitDeferredByCloseability(ledger, a, escalationGapAxes,
11689
- { fullyDeferred: escalationFullyDeferred.has(a.toLowerCase()) }).held;
11787
+ { fullyDeferred: escalationFullyDeferred.has(a.toLowerCase()), heldUnits: stickyGapUnits(P) }).held;
11690
11788
  if (escHeld.length) {
11691
11789
  followup += `\n\nNOT YOURS TO CLOSE — the active register provider cannot express these slices at all, so no re-run can reach them. Leave these Coverage-ledger rows exactly as they are (\`deferred\`, same reason), and do not restate them as searched or clean:\n${escHeld.map((r) => `| ${r.unit} | deferred | ${r.reason} |`).join("\n")}`;
11692
11790
  }
@@ -11762,8 +11860,9 @@ async function pipelineInner(job, opts = {}) {
11762
11860
  // time, still deferred, still an open floor, still disclosed.
11763
11861
  const gapAxes = capabilityGapAxes(ctx.registerPlan, readPlanExecution(ctx));
11764
11862
  const fullyDeferred = new Set((ctx.registerPlan ? fullyDeferredAxes(ctx.registerPlan) : []).map((a) => String(a.axis).toLowerCase()));
11863
+ const stickyUnitsNow = stickyGapUnits(P); // a gap this run already accepted is held whatever its row's reason says
11765
11864
  const closeabilityByAxis = new Map(deferredAxes.map((a) => [a,
11766
- splitDeferredByCloseability(ledgerNow, a, gapAxes, { fullyDeferred: fullyDeferred.has(String(a).toLowerCase()) })]));
11865
+ splitDeferredByCloseability(ledgerNow, a, gapAxes, { fullyDeferred: fullyDeferred.has(String(a).toLowerCase()), heldUnits: stickyUnitsNow })]));
11767
11866
  const heldAxes = deferredAxes.filter((a) => {
11768
11867
  const s = closeabilityByAxis.get(a);
11769
11868
  // The `!hasBreach &&` term is retired with the ⭐ floor: it kept an axis carrying a
@@ -11805,6 +11904,10 @@ async function pipelineInner(job, opts = {}) {
11805
11904
  ? `\n\nThese rows on this axis are NOT yours to close and must stay exactly as they are — the active register provider cannot express those slices at all, so no re-run can reach them. Leave them \`deferred\`, keep their reason, and do not restate them as searched or clean:\n${split.held.map((r) => `| ${r.unit} | deferred | ${r.reason} |`).join("\n")}`
11806
11905
  : "";
11807
11906
  note(`envelope: closing deferred coverage on ${a} (deadline permits — ${decision.reason})`);
11907
+ // What the re-opened unit is asked to close, and what it is told to leave: the record of the split
11908
+ // this close acted on, so a gap handed back as work is visible in the run's own log.
11909
+ runLog(run.runDir, { event: "envelope-close-rows", axis: a,
11910
+ closeable: split.closeable.map((r) => String(r.unit ?? "")), held: split.held.map((r) => String(r.unit ?? "")) });
11808
11911
  const followup = repairFollowup("register-unit:envelope-close", { paths: P, axis: a, rows: rows + heldRows,
11809
11912
  supplementalLane: !!ctx.registerPlan?.contract?.supplemental_lane });
11810
11913
  const r = await stage("register-unit", { ...ctx, axis: a }, { force: true, followup, sessionKey: unitKey[a], trigger: "envelope" });
@@ -12241,6 +12344,7 @@ async function pipelineInner(job, opts = {}) {
12241
12344
  ledger: loadCoverageLedger(run.runDir).rows, // fresh read — post-reopen/re-digest, same source as every gate
12242
12345
  planContext: { entries: ctx.registerPlan.entries ?? [], niceClasses: ctx.registerPlan.nice_classes ?? [], regions: ctx.registerPlan.regions ?? [] },
12243
12346
  executor: ccExecutor,
12347
+ capabilities: registerCapabilities(), // the contains floor, read the way the plan compile reads it
12244
12348
  note: (m) => note(m),
12245
12349
  log: (row) => runLog(run.runDir, row), // the orchestrator's own crowd-context-failed row lands in run.jsonl
12246
12350
  });
@@ -15295,7 +15399,7 @@ async function pipelineInner(job, opts = {}) {
15295
15399
  {
15296
15400
  const mark = job.markName ?? job.name ?? job.ref ?? "the matter";
15297
15401
  const ref = job.ref ? ` (${job.ref})` : "";
15298
- const vtag = verdict ? ` — verdict ${verdict}` : "";
15402
+ const vtag = emailVerdictOpts.tier ? ` Overall risk: ${emailVerdictOpts.tier}.` : ""; // the rating, never the reviewer's sign-off word (ruled 2026-09-22)
15299
15403
  // self-contained packet (the email body HTML is embedded so the courier needs no path resolution
15300
15404
  // across the archive move). The courier sends EXACTLY this — same subject, text and recipient the
15301
15405
  // deleted send stages composed, now composed in code.
@@ -15317,7 +15421,7 @@ async function pipelineInner(job, opts = {}) {
15317
15421
  // stated reason when no number is held. It used to go to AGENT_WHATSAPP[agent], which is the
15318
15422
  // operator on every run because every user shares one agent id.
15319
15423
  ...whatsappRouting(job, agent),
15320
- whatsappText: `✅ Clearotron search for ${mark}${ref}${vtag} is done. Report: ${published.url}`,
15424
+ whatsappText: `✅ Clearotron search for ${mark}${ref} is done.${vtag} Report: ${published.url}`,
15321
15425
  url: published.url, verdict, markName: job.markName ?? job.name ?? null,
15322
15426
  };
15323
15427
  // A NEW send supersedes any previous one: .sent is PER-SEND idempotence, not per-run-lifetime.
@@ -15364,7 +15468,7 @@ async function pipelineInner(job, opts = {}) {
15364
15468
  // .published + .delivered on disk must never read "7/9" on any status surface (nothing runs after
15365
15469
  // the packet, so no stage transition would ever finish the display sequence). finalStepFields ⇒ 9/9.
15366
15470
  const deliveredAt = new Date().toISOString();
15367
- writeRunStatus(ctx, { state: "delivered", verdict, statement: emailVerdictOpts.statement ?? undefined, url: published.url, deliveredAt, sendPending: true, ...finalStepFields() });
15471
+ writeRunStatus(ctx, { state: "delivered", verdict, statement: emailVerdictOpts.statement ?? undefined, caption: published.caption ?? undefined, url: published.url, deliveredAt, sendPending: true, ...finalStepFields() });
15368
15472
  // — THE POOL COPY LEARNS ITS OWN TERMINAL STATE, HERE AND NOWHERE ELSE.
15369
15473
  // `meta.json` cannot carry this: it is composed inside publish, before this line runs, so the state
15370
15474
  // did not exist yet when it was written. This is the one moment where the terminal state and the
@@ -14,7 +14,7 @@ import { parseReport, parseAudit, parseSections, parseBlocks, stripInternal, par
14
14
  import { renderHtml, parseActionBuckets, actYouConditions } from './render.mjs';
15
15
  import { buildAudit } from './xlsx.mjs';
16
16
  import { parseFindingsJson, parseFindingsJsonLenient, deriveDisplayVerdict, joinFindingToBlock, CLIENT_TIER_BY_COMPOSITE, projectCoverageJudgment } from '../findings-model.mjs';
17
- import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; // — and why an absence did not close
17
+ import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; import { coverageFormStamp, readCoverageForm } from '../coverage-form-io.mjs'; import { coverageUnitLabel } from '../coverage-ledger.mjs'; // — and why an absence did not close
18
18
  import { clearanceReportData } from './report-data.mjs';
19
19
  import { searchDepthRecord, planTerritoriesOf } from './search-depth.mjs'; import { bandRecords } from '../named-band.mjs'; // how much was read to reach the answer, as counts and tokens
20
20
  import { parseFrameworkManifest } from '../framework.mjs';
@@ -251,11 +251,11 @@ function indexRows(runs, { reportFile, linkPrefix = '', showAudit = true, client
251
251
  <td>${anonMark(r.title, { key: ck, run: r.runId })}</td>
252
252
  <td>${anonClient(r.client, ck)}</td>
253
253
  <td><span class="b b-${attrValue(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${!client && !qcFailed && r.clientGate?.inputsAbsent?.length ? ` <span class="disc" title="${escAttr(`published without ${r.clientGate.inputsAbsent.join(', ')} — each declared optional, so the release stands; the report was built without it`)}">◦ built without an input</span>` : ''}${
254
- // spec 64 — the stance clause of THE one risk statement beside the (labelled) band pill, so the
255
- // index can never show a bare severity word that reads as the whole answer. The tier word leads
256
- // the statement; the pill already shows it, so the cell carries the clause after the first " — ".
257
- // Legacy meta.json (no statement) renders this cell byte-identically.
258
- r.statement ? `<span class="stmt" title="${escAttr(r.statement)}">${esc(String(r.statement).split(' — ').slice(1).join(' — ') || r.statement)}</span>` : ''}</td>
254
+ // The report's own conclusion beside the band pill (ruled 2026-09-22: the report is the master),
255
+ // never a second summary. A meta.json written before the caption was kept shows the pill alone:
256
+ // its stored statement could say "on hold", which was never true. The pill carries the rating,
257
+ // the single headline on this short surface.
258
+ r.caption ? `<span class="stmt" title="${escAttr(r.caption)}">${esc(r.caption)}</span>` : ''}</td>
259
259
  <td>${runCell}</td>${showAudit ? `
260
260
  <td>${r.auditFile ? `<a ${runAttrs} href="${linkPrefix}${attrValue(r.runId)}/${attrValue(r.auditFile)}">audit.xlsx</a>` : '—'}</td>` : ''}
261
261
  </tr>`;
@@ -872,6 +872,15 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
872
872
  } catch { /* the workbook row is the record that matters; this line is the second copy */ }
873
873
  }
874
874
 
875
+ // ── THE WAITING FAMILIES THE READING TURN WITHHELD (withheld-families.mjs) ───────────────────────
876
+ //
877
+ // Ruled 2026-09-22: a family the reading turn chose not to ask is recorded with its reason in the run's
878
+ // record and in the audit workbook, and NOT in the report. The coverage form keeps these rows out of
879
+ // the ledger the report is built from, so this sheet is their one reader-facing place. Same builder,
880
+ // same four columns and the same state as the probes above; the area is the driver's own label and the
881
+ // words are the reading turn's reason.
882
+ const withheldFamilies = withheldFamilyRows(runDir ?? dirname(reportMd));
883
+
875
884
  // doc 50 — the run's FROZEN framework manifest (band vocabulary). Present on band-doctrine runs;
876
885
  // absent on every archived run (they render byte-identically on the legacy paths).
877
886
  let framework = null;
@@ -1134,7 +1143,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1134
1143
  // the same rule (the workbook's own BANNED gate had already started firing on the raw detail —
1135
1144
  // advisory, so CI stayed green). reviewReceipts.lint keeps its raw detail for the internal
1136
1145
  // readers above (fetchState reads registry-record-coverage's URIs out of it).
1137
- counts = await buildAudit({ droppedConditions, undispatchedProbes, findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1146
+ counts = await buildAudit({ droppedConditions, undispatchedProbes, withheldFamilies, findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1138
1147
  grpRead(join(poolRunDir, auditFile), 0o640);
1139
1148
  if (counts?.gateViolations?.length) console.warn(`[audit-workbook] advisory: ${counts.gateViolations.join(' | ')}`);
1140
1149
  } catch (e) {
@@ -1342,6 +1351,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1342
1351
  // writer). Absent on legacy runs — regenIndex re-reads every historical meta.json, so consumers
1343
1352
  // null-guard and old rows render byte-identically.
1344
1353
  statement: verdictInfo?.statement ?? undefined,
1354
+ caption: fm.overall_caption || undefined, // the report's own conclusion, which the run list quotes
1345
1355
  verdict: verdictInfo?.verdict ?? undefined,
1346
1356
  // doc 50 — which framework rated this run (custom vs Generic default) + its ladder, for the archive
1347
1357
  // card, the email table sort/colour and the per-customer index; absent on archived runs forever.
@@ -1369,7 +1379,8 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1369
1379
  // `<origin>/<runId>/report.html` — where the documents sit on disk, which is not an application route.
1370
1380
  // Composed rather than spelled here so the report link is built the same way the audit link always was.
1371
1381
  const url = reportRouteFor(poolUrl, runId);
1372
- return { runId, auditFile, counts, total, url, poolRunDir, auditError, findingsError, customerKey: customerKey || 'generic', clientGate };
1382
+ return { runId, auditFile, counts, total, url, poolRunDir, auditError, findingsError, customerKey: customerKey || 'generic', clientGate,
1383
+ caption: fm.overall_caption || null }; // the report's own conclusion, for the run record's short surfaces
1373
1384
  }
1374
1385
 
1375
1386
  // Compose the notification email body — MARKDOWN (the mail tool renders markdown→HTML; raw HTML gets escaped).
@@ -1739,17 +1750,13 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1739
1750
  // the engine clamp reasons (opts.conditions = verdict.json reasons). Machinery-only clamps (no client
1740
1751
  // item authored) degrade to one generic plain line — never raw engine jargon, never truncated mid-sentence.
1741
1752
  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:' : '.'}`;
1751
- const verdictBound = (opts?.verdict && opts.verdict !== 'CLEAR')
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>`
1753
+ // THE REPORT IS THE MASTER (ruled 2026-09-22). The banner used to print a second summary composed
1754
+ // beside the report — "On hold — …" on a run the report called conditional, although nothing is ever
1755
+ // held — and before that the delivery gate's word. It now quotes the report: the rating, then the
1756
+ // report's own conclusion (below), and where the report names what only the client can close, those
1757
+ // items under the report's own heading. Nothing here composes a sentence of its own.
1758
+ const verdictBound = opts?.verdict === 'CONDITIONAL'
1759
+ ? `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>Only you can close these</b><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>`
1753
1760
  : '';
1754
1761
 
1755
1762
  const reviewHeadline = `<div style="${FONT};font-size:11pt;color:#1a1a2e;margin:0 0 14px">`
@@ -1757,12 +1764,11 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1757
1764
  + verdictBound
1758
1765
  + `<p style="margin:0 0 8px"><b>${[opts?.productName ? esc(String(opts.productName)) : '', esc(fm.title || '')].filter(Boolean).join(' — ')} (${esc(fm.matter || '')})</b></p>`
1759
1766
  // T2 (H5): the derived tier (verdict sidecar, passed by the driver) outranks the
1760
- // model-authored fm label — one authority on every surface.
1761
- // spec 64: with a composed statement the headline reads band + stance as ONE sentence ("High —
1762
- // conditional on: …"); legacy (no statement) keeps the labelled tier line byte-identically.
1763
- + (opts?.statement
1764
- ? `<p style="margin:0 0 6px"><b>${esc(opts.statement)}</b> ${cell(fm.overall_caption || '')}</p>`
1765
- : ((opts?.tier ?? fm.overall_label) ? `<p style="margin:0 0 6px"><b>Overall risk: ${esc(opts?.tier ?? fm.overall_label)}.</b> ${cell(fm.overall_caption || '')}</p>` : ''))
1767
+ // model-authored fm label — one authority on every surface. The rating leads, then the report's own
1768
+ // conclusion verbatim (ruling 244); with no rating recorded, the conclusion stands alone.
1769
+ + ((opts?.tier ?? fm.overall_label)
1770
+ ? `<p style="margin:0 0 6px"><b>Overall risk: ${esc(opts?.tier ?? fm.overall_label)}.</b> ${cell(fm.overall_caption || '')}</p>`
1771
+ : (fm.overall_caption ? `<p style="margin:0 0 6px">${cell(fm.overall_caption)}</p>` : ''))
1766
1772
  + reportLink
1767
1773
  + (fm.handling_note ? `<p style="margin:0 0 8px;color:#7a2b12"><b>Handling note:</b> ${cell(fm.handling_note)}</p>` : '')
1768
1774
  // T9 (K3): methodology identity — which profile/framework rated this run (+ verifiable sha).
@@ -1811,3 +1817,12 @@ function officeLinksFor(findings, recordsByUri, runOrigins) {
1811
1817
  if (links) console.log(`[record-links] ${links.summary}`);
1812
1818
  return links;
1813
1819
  }
1820
+
1821
+ /** The coverage form's family rows judged withheld, as audit-workbook coverage rows. Never throws. */
1822
+ export function withheldFamilyRows(runDir) {
1823
+ try {
1824
+ const { rows } = readCoverageForm(runDir, coverageFormStamp(runDir).formName);
1825
+ return (rows ?? []).filter((r) => r?.kind === 'family' && r.status === 'withheld-by-judgment' && r.reason)
1826
+ .map((r) => ({ area: coverageUnitLabel(r.unit), state: 'not-searched', note: String(r.reason) }));
1827
+ } catch { return []; }
1828
+ }
@@ -302,7 +302,7 @@ export function searchRows(auditParsed, { findings = [], joinedTerms = null, reg
302
302
  const byTerm = new Map();
303
303
  for (const n of cl) {
304
304
  const key = (n.search_term || '').trim();
305
- if (!byTerm.has(key)) { byTerm.set(key, { plats: new Set(), results: new Set(), notes: [], gaps: new Set() }); order.push(key); }
305
+ if (!byTerm.has(key)) { byTerm.set(key, { plats: new Set(), results: new Set(), notes: [], gaps: new Set(), gapReasons: new Map() }); order.push(key); }
306
306
  const g = byTerm.get(key);
307
307
  if (n.platform) g.plats.add(n.platform);
308
308
  if (n.result) g.results.add(n.result);
@@ -310,7 +310,17 @@ export function searchRows(auditParsed, { findings = [], joinedTerms = null, reg
310
310
  // — the surfaces that could NOT be searched are counted separately from the ones that came back
311
311
  // empty. The dedup unions results across platforms, so one gapped surface among many used to vanish
312
312
  // entirely into a term-level "0 — clean".
313
- if (notSearched(n, n.result, n.notes)) g.gaps.add(n.platform || '?');
313
+ // — THE REGISTER'S OWN WORDS, KEPT. A surface that could not be searched arrives here with the
314
+ // reason it gave (`audit-from-spine` puts the gap's `error` into `notes`), and this row used to
315
+ // record only that it was a gap. So a query a provider REFUSED — "content policy (HTTP 400)" —
316
+ // and one that was simply never run rendered as the same sentence, and the reason was in the
317
+ // workbook's hand the whole time. `audit.md` kept it; this file dropped it.
318
+ if (notSearched(n, n.result, n.notes)) {
319
+ const surface = n.platform || '?';
320
+ g.gaps.add(surface);
321
+ const said = plainNote(n.notes || '').trim();
322
+ if (said) g.gapReasons.set(surface, said);
323
+ }
314
324
  }
315
325
  push({ 'Search term / variant': COMMON_LAW_SECTION, Scope: '', Result: '', Outcome: '', Note: '', _section: true });
316
326
  for (const term of order) {
@@ -329,7 +339,19 @@ export function searchRows(auditParsed, { findings = [], joinedTerms = null, reg
329
339
  // searched at all. The gap count is carried beside the hit flag, never folded into it.
330
340
  const gaps = [...g.gaps];
331
341
  const ran = Math.max(0, n - gaps.length);
332
- const gapNote = gaps.length ? ` ${gaps.length} surface${gaps.length === 1 ? '' : 's'} could not be searched (${gaps.slice(0, 4).join(', ')}${gaps.length > 4 ? ', …' : ''}) — this term is NOT closed across them.` : '';
342
+ // A REFUSAL AND A DROP ARE DIFFERENT FACTS, and only one of them is about the register. Where the
343
+ // surface said why, its words are quoted; where nothing was recorded, the row says that instead of
344
+ // implying a reason it does not have. A reader deciding whether to re-run needs to know which:
345
+ // a content-policy refusal will refuse again, a query that never ran may simply run.
346
+ const said = gaps.map((s) => [s, g.gapReasons.get(s)]).filter(([, r]) => r);
347
+ const silent = gaps.filter((s) => !g.gapReasons.has(s));
348
+ const reasonNote = said.length
349
+ ? ` The surface${said.length === 1 ? '' : 's'} gave a reason: ${said.map(([s, r]) => `${s} — ${r}`).join('; ')}.`
350
+ : '';
351
+ const silentNote = silent.length && said.length
352
+ ? ` ${silent.length} recorded no reason (${silent.slice(0, 4).join(', ')}${silent.length > 4 ? ', …' : ''}).`
353
+ : silent.length ? ' No reason was recorded for any of them.' : '';
354
+ const gapNote = gaps.length ? ` ${gaps.length} surface${gaps.length === 1 ? '' : 's'} could not be searched (${gaps.slice(0, 4).join(', ')}${gaps.length > 4 ? ', …' : ''}) — this term is NOT closed across them.${reasonNote}${silentNote}` : '';
333
355
  const outcome = anyHit ? (joined ? '→ Findings' : 'Reviewed — not carried as a conflict')
334
356
  : gaps.length && !ran ? NOT_SEARCHED_OUTCOME // nothing ran at all: never a closure claim
335
357
  : gaps.length ? 'No conflict — partial' // some ran clean, some never ran: say both
@@ -637,7 +659,7 @@ export async function buildAudit(contract, auditParsed, outPath, mark = '', fm =
637
659
  // shape and not a new sheet, and the words are the run receipt's own.
638
660
  addSheet(wb, 'Coverage & gaps', COVERAGE_COLS,
639
661
  [...coverageRows(coverage), ...coverageRows(contract?.droppedConditions || []),
640
- ...coverageRows(contract?.undispatchedProbes || [])], (row, _d, kept) => {
662
+ ...coverageRows(contract?.undispatchedProbes || []), ...coverageRows(contract?.withheldFamilies || [])], (row, _d, kept) => {
641
663
  if (!kept.has('State')) return;
642
664
  const st = row.getCell('State'); const f = STATE_FILL[String(st.value).trim()];
643
665
  if (f) { st.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF' + f } }; st.font = { bold: true }; }