clearotron 0.4.0 → 0.4.1-beta.1

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 (132) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/bin/README.md +1 -1
  3. package/bin/clearotron.mjs +2 -0
  4. package/bin/mcp.mjs +80 -0
  5. package/bin/start.mjs +14 -1
  6. package/build-info.json +2 -2
  7. package/demo/README.md +1 -1
  8. package/docs/SECURITY-OWASP.md +21 -13
  9. package/docs/SECURITY.md +20 -14
  10. package/docs/architecture/04-configuration-reference.md +3 -3
  11. package/docs/architecture/05-config-governance.md +1 -1
  12. package/docs/architecture/06-operations-runbook.md +5 -4
  13. package/docs/architecture/08-development-guide.md +3 -1
  14. package/docs/architecture/09-security-and-data.md +16 -11
  15. package/driver/CHANGELOG.md +29 -0
  16. package/driver/binding-layers.mjs +28 -6
  17. package/driver/citation-census.json +3 -3
  18. package/driver/common-law-coverage-status.mjs +52 -0
  19. package/driver/common-law-receipts.mjs +1 -1
  20. package/driver/compare.mjs +1 -1
  21. package/driver/connotation-search.mjs +5 -5
  22. package/driver/contract-audit.mjs +6 -6
  23. package/driver/contract-dictation-registry.mjs +1 -1
  24. package/driver/contract-e3-backlog.mjs +18 -17
  25. package/driver/contract-vocabulary.mjs +145 -132
  26. package/driver/declination-call.mjs +5 -1
  27. package/driver/degraded-parts.mjs +10 -3
  28. package/driver/dev-portal.mjs +2 -1
  29. package/driver/dispatch-record.mjs +19 -0
  30. package/driver/doubt-closure-tool.mjs +2 -2
  31. package/driver/driver.config.mjs +39 -6
  32. package/driver/engagement-receipt.mjs +1 -1
  33. package/driver/engine/CONTRACT.md +41 -1
  34. package/driver/engine/anthropic-agent.mjs +56 -4
  35. package/driver/engine/mcp/codex-config.mjs +8 -3
  36. package/driver/engine/mcp/coverage-server.mjs +1 -1
  37. package/driver/engine/mcp/gather-config.mjs +1 -1
  38. package/driver/engine/mcp/recording-server.mjs +32 -5
  39. package/driver/engine/mcp/stdio-server.mjs +45 -7
  40. package/driver/engine/mcp/supplemental.mjs +26 -1
  41. package/driver/engine/openai-agent.mjs +113 -20
  42. package/driver/engine/session-record.mjs +223 -0
  43. package/driver/enqueue-schema.mjs +2 -2
  44. package/driver/findings-model.mjs +4 -4
  45. package/driver/flag-snapshot.mjs +2 -1
  46. package/driver/form-neighbourhood.mjs +7 -7
  47. package/driver/frame-diff-model.mjs +3 -3
  48. package/driver/framework.mjs +22 -0
  49. package/driver/gateway.mjs +96 -14
  50. package/driver/jx.mjs +1 -1
  51. package/driver/knockout-review-record.mjs +55 -1
  52. package/driver/named-band.mjs +4 -0
  53. package/driver/owner-screen.mjs +7 -4
  54. package/driver/package.json +1 -1
  55. package/driver/phase0.mjs +13 -2
  56. package/driver/pipeline-knockout.mjs +15 -13
  57. package/driver/pipeline.mjs +132 -48
  58. package/driver/portal-families.mjs +3 -3
  59. package/driver/portal-service.mjs +6 -5
  60. package/driver/predelivery-lint.mjs +19 -19
  61. package/driver/products.mjs +1 -1
  62. package/driver/profiles.mjs +4 -3
  63. package/driver/publish/index.mjs +1 -1
  64. package/driver/publish/knockout.mjs +5 -4
  65. package/driver/publish/office-record-links.mjs +17 -2
  66. package/driver/publish/publish-inputs.mjs +3 -3
  67. package/driver/publish/render-knockout.mjs +17 -5
  68. package/driver/publish/render.mjs +4 -2
  69. package/driver/publish/xlsx.mjs +1 -1
  70. package/driver/record-fold.mjs +177 -0
  71. package/driver/register-count.mjs +20 -3
  72. package/driver/register-coverage.mjs +1 -1
  73. package/driver/register-plan.mjs +83 -18
  74. package/driver/register-records.mjs +7 -0
  75. package/driver/registry-fidelity.mjs +2 -2
  76. package/driver/repairs.mjs +1 -1
  77. package/driver/report-card-record.mjs +4 -4
  78. package/driver/result-noun-fields.mjs +3 -3
  79. package/driver/scope-facts.mjs +11 -3
  80. package/driver/scope-rules.mjs +2 -2
  81. package/driver/search-policy.mjs +20 -1
  82. package/driver/skills/clearance-common-law/SKILL.md +22 -22
  83. package/driver/skills/clearance-register/digest.md +3 -3
  84. package/driver/skills/clearance-register/register-recipes.md +11 -11
  85. package/driver/skills/clearance-register/status-rules.md +1 -1
  86. package/driver/skills/clearance-register/unit.md +1 -1
  87. package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
  88. package/driver/skills/clearance-search/phase2-execution.md +6 -5
  89. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  90. package/driver/skills/clearance-search/worked-examples.md +39 -39
  91. package/driver/skills/clearance-variants/SKILL.md +16 -16
  92. package/driver/skills/frame-diff/SKILL.md +2 -2
  93. package/driver/stage-context.mjs +36 -1
  94. package/driver/stages-knockout.mjs +6 -5
  95. package/driver/stages.mjs +30 -30
  96. package/driver/suite-census.json +259 -55
  97. package/driver/synthesis-record.mjs +5 -0
  98. package/driver/unit-inventory.mjs +17 -28
  99. package/driver/verify.mjs +7 -7
  100. package/mcp-server/CHANGELOG.md +8 -0
  101. package/mcp-server/CONNECT.md +9 -3
  102. package/mcp-server/demo-mode.mjs +10 -0
  103. package/mcp-server/lib/evidence.mjs +8 -8
  104. package/mcp-server/lib/knockout.mjs +1 -1
  105. package/mcp-server/lib/runs.mjs +7 -1
  106. package/mcp-server/lib/scrub.mjs +1 -1
  107. package/mcp-server/package.json +2 -2
  108. package/mcp-server/packs/README.md +1 -1
  109. package/mcp-server/server.mjs +16 -2
  110. package/package.json +4 -3
  111. package/portal-ui/package.json +3 -3
  112. package/providers/_shared/count.mjs +9 -2
  113. package/providers/_shared/enumerate.mjs +125 -41
  114. package/providers/_shared/execute-plan.mjs +15 -4
  115. package/providers/corsearch/src/capabilities.js +1 -1
  116. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  117. package/providers/oauth-mcp-bridge/package.json +2 -2
  118. package/providers/signa/src/capabilities.js +32 -2
  119. package/providers/signa/src/core.js +262 -36
  120. package/scripts/band-shape-probe.mjs +2 -2
  121. package/scripts/deprecate-below.mjs +28 -4
  122. package/scripts/e2e-first-time.mjs +17 -2
  123. package/scripts/env-audit.mjs +3 -2
  124. package/scripts/freeze-example-run.mjs +10 -10
  125. package/scripts/mcp-registry-listing.mjs +199 -0
  126. package/scripts/mint-plugin-folder.mjs +65 -0
  127. package/scripts/release-version.mjs +44 -0
  128. package/scripts/score.mjs +1 -1
  129. package/shared/register-floor.mjs +19 -0
  130. package/shared/scope.mjs +18 -2
  131. package/shared/trigger-lane.mjs +1 -1
  132. package/skills/clearotron-ops/SKILL.md +81 -24
@@ -15,7 +15,7 @@ import { riskTier, TONE_TIER, regenIndex, regenSurfaces, auditRouteFor, markRepo
15
15
  import { runKnockoutLint, deliveryFlagLines } from '../predelivery-lint.mjs';
16
16
  import { note } from '../log.mjs';
17
17
  import { addSheet } from './xlsx.mjs';
18
- import { COUNT_PREDICATES, COUNT_BASIS, countsForMark, countLine, countedMarks, variantFormsLine, disclosedFloor, moreThan } from '../register-count.mjs';
18
+ import { COUNT_PREDICATES, COUNT_BASIS, countsForMark, countLine, countedMarks, variantFormsLine, disclosedFloor, moreThan, countNote } from '../register-count.mjs';
19
19
  import { RECORD_BASIS, recordsForMark, recordsLine, listedMarks, normalizeRegisterRecordLinks } from '../register-records.mjs';
20
20
  import { reportIdentityFor, productCoverageNote, kebab } from '../search-policy.mjs';
21
21
  import { batchMarkName } from '../mark-name.mjs';
@@ -235,10 +235,11 @@ export async function buildKnockoutWorkbook(findings, receipts, outPath, registe
235
235
  row['Close variation forms'] = variantFormsLine(e) ?? 'not recorded — this run predates the close-variation column';
236
236
  row['Register'] = registerCounts.providerLabel ?? registerCounts.provider ?? '';
237
237
  row['Basis'] = COUNT_BASIS;
238
- // The verbatim provider reason for anything missing — the auditable half of "not available".
238
+ // The reader's line for anything missing. The provider's own reason, or an exception's message, stays
239
+ // in the counts record where it was written (register-count.mjs, countNote).
239
240
  row['Notes'] = COUNT_PREDICATES
240
241
  .filter((p) => !Number.isFinite(e?.counts?.[p.key]?.total) && disclosedFloor(e?.counts?.[p.key]) === null)
241
- .map((p) => `${p.label}: ${e?.counts?.[p.key]?.unavailable ?? 'no count recorded'}`)
242
+ .map((p) => `${p.label}: ${countNote(e?.counts?.[p.key]) ?? 'no count recorded'}`)
242
243
  .join(' · ');
243
244
  return row;
244
245
  });
@@ -369,7 +370,7 @@ export function composeKnockoutEmail({ findings, framework, overall, reports = [
369
370
  // says how to read the list instead: a "full report" link on a batch could only be one name standing
370
371
  // for all of them. A single-mark run keeps its head link exactly as before — same bytes, same place.
371
372
  const only = n === 1 ? urlByMark.get((findings.marks ?? [])[0]?.name) ?? null : null;
372
- const head = `<p style="${FONT}">Knockout trademark review${job?.ref ? ` — ${esc(job.ref)}` : ''}: <b>${n} mark${n === 1 ? '' : 's'}</b>, worst band <b style="color:${t.txt}">${esc(overall ?? '')}</b>.`
373
+ const head = `<p style="${FONT}">Knockout trademark review${job?.ref ? ` — ${esc(job.ref)}` : ''}: <b>${n} mark${n === 1 ? '' : 's'}</b>, ${n === 1 ? 'rating' : 'highest rating'} <b style="color:${t.txt}">${esc(overall ?? '')}</b>.`
373
374
  + (only ? ` <a href="${esc(only)}">Open the full report</a>${auditUrl ? ` · <a href="${esc(auditUrl)}">audit workbook</a>` : ''}.` : '')
374
375
  + (!only && n > 1 ? ` One report per name below${auditUrl ? `, and one <a href="${esc(auditUrl)}">audit workbook</a> for the batch` : ''}.` : '') + `</p>`;
375
376
  // — THE FIRST NON-BLANK, never element 0. `?? fallback` catches null and undefined
@@ -122,10 +122,25 @@ export function officeRecordLink(rec, uri = "") {
122
122
  const page = OFFICE_RECORD_PAGES[office];
123
123
  const own = designation ? clean(rec.irNumber) : clean(rec.applicationNumber) || clean(rec.registrationNumber);
124
124
  const unlinked = (reason) => ({ office, label: labelOf(office, own), href: null, reason });
125
+ const a = page?.address ? (designation ? madridMonitor([rec.irNumber]) : page.address(rec)) : null;
126
+ if (a) return { office, label: labelOf(office, a.number), href: a.href, reason: null };
127
+ // THE REGISTER'S OWN LINK, ONLY WHERE THE TABLE GIVES NONE. Some registers hand over the office's page
128
+ // for a record (`officeUrl`). The table above stays first: each of its addresses was checked against a
129
+ // real record, and on the records measured the register's link pointed at the same office's site in a
130
+ // different form, or at the designated office's page where the table opens the international
131
+ // registration. So the register's link fills only a gap — an office with no address here, or a number
132
+ // the address cannot take — and never replaces an address the table builds. Only an https address, and
133
+ // only beside a number the reader can be shown.
134
+ const given = registerGivenUrl(rec.officeUrl);
135
+ if (given && own) return { office, label: labelOf(office, own), href: given, reason: null };
125
136
  if (!page) return unlinked("unknown-office");
126
137
  if (!page.address) return unlinked("no-page");
127
- const a = designation ? madridMonitor([rec.irNumber]) : page.address(rec);
128
- return a ? { office, label: labelOf(office, a.number), href: a.href, reason: null } : unlinked("unaddressable");
138
+ return unlinked("unaddressable");
139
+ }
140
+
141
+ function registerGivenUrl(v) {
142
+ if (typeof v !== "string") return null;
143
+ try { return new URL(v).protocol === "https:" ? v : null; } catch { return null; }
129
144
  }
130
145
 
131
146
  /**
@@ -121,8 +121,8 @@ export const NOT_READ_BY_NAME = {
121
121
  // absent one is the caller's error and already throws. Declared so the source scan can tell "argument"
122
122
  // from "undeclared store" instead of demanding a gating for something that has none.
123
123
  export const CALLER_SUPPLIED = {
124
- "report.md": "the reportMd ARGUMENT — read unconditionally at index.mjs:653; an unreadable one throws, which is correct (there is no report to publish)",
125
- "audit.md": "the auditMd ARGUMENT — existence-gated at index.mjs:942-943; a run with no audit markdown legitimately publishes without a workbook",
124
+ "report.md": "the reportMd ARGUMENT — read unconditionally by publishReport() in index.mjs; an unreadable one throws, which is correct (there is no report to publish)",
125
+ "audit.md": "the auditMd ARGUMENT — existence-gated in publishReport() in index.mjs; a run with no audit markdown legitimately publishes without a workbook",
126
126
  };
127
127
 
128
128
  /**
@@ -133,7 +133,7 @@ export const CALLER_SUPPLIED = {
133
133
  * publish/index.mjs's source — reading a 1472-line sibling on every import to answer a question that
134
134
  * only changes when someone edits the file is the wrong place for it; the dead-key/undeclared-store
135
135
  * half runs in publish-input-coverage.test.mjs against the real source, on the
136
- * dependency-repair.test.mjs:76-95 precedent.
136
+ * dependency-repair.test.mjs precedent (its 15a and 15b tests).
137
137
  *
138
138
  * `tables` is injectable so the gate can be exercised against a partition nobody shipped, which is
139
139
  * otherwise unreachable from a test.
@@ -51,7 +51,7 @@ import { SUMMARY_BLOCK_LINE, parseSummaryBlocks } from '../../shared/summary-blo
51
51
  const STRIP = Object.freeze([
52
52
  ['summary', 'Summary'], ['findings', 'Findings'], ['filings', 'Also considered'], ['next', 'Next steps'],
53
53
  ]);
54
- import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine, disclosedFloor, moreThan } from '../register-count.mjs';
54
+ import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine, disclosedFloor, moreThan, countNote } from '../register-count.mjs';
55
55
  import { RECORD_BASIS, recordsForMark, recordsLine } from '../register-records.mjs';
56
56
  import { officeLinkSentences } from './office-record-links.mjs';
57
57
  import { knockoutFindingViews, splitKnockoutNotes } from '../findings-model.mjs';
@@ -66,6 +66,7 @@ import { makeClassifyStatus, isAllClass } from '../../providers/_shared/screen.m
66
66
  import { saysSomethingNew } from '../../shared/says-something-new.mjs';
67
67
  import { isNextStepHeading } from '../knockout-next-step.mjs';
68
68
  import OFFERED from '../../shared/offered-territories.json' with { type: 'json' }; // the order form's own territory names
69
+ import { normalizeTerritory } from '../../providers/_shared/territory-codes.mjs'; // a territory as written → its code
69
70
 
70
71
  const HERE = dirname(fileURLToPath(import.meta.url));
71
72
 
@@ -492,6 +493,17 @@ const orderedTerritoryName = (code) => {
492
493
  return ORDER_FORM_NAMES.get(c === 'EM' ? 'EU' : c) ?? '';
493
494
  };
494
495
 
496
+ /**
497
+ * A territory as the order wrote it, as a code. An order carries a code ("EU", "UK") or a country's name
498
+ * ("Norway"), and both name lookups on this page take codes: a name reached them unconverted, came back
499
+ * empty and left the page without a trace (a delivered knockout, 2026-10-08, named two of the four
500
+ * territories it searched). The shared normaliser turns a name into its code; UK is the order form's GB.
501
+ */
502
+ const orderCode = (t) => {
503
+ const c = normalizeTerritory(t) || String(t ?? '').trim().toUpperCase();
504
+ return c === 'UK' ? 'GB' : c;
505
+ };
506
+
495
507
  /** "a, b and c" — the reader's list, not a join on commas. */
496
508
  function listWords(items) {
497
509
  const xs = (items ?? []).filter(Boolean);
@@ -561,7 +573,7 @@ function countsSection(marks, registerCounts, positions = '') {
561
573
  // archived run re-rendering through today's renderer has no close-variation cell because the
562
574
  // column did not exist when it was counted, and "no count recorded" would read as a defect in a
563
575
  // report that has none. The hover says which.
564
- const why = c?.unavailable ?? (c
576
+ const why = countNote(c) ?? (c
565
577
  ? 'no count recorded'
566
578
  : 'not counted on this run — it predates this column, and an archived report re-renders as what it was');
567
579
  if (Number.isFinite(c?.total)) return `<td class="num">${esc(String(c.total))}</td>`;
@@ -1252,7 +1264,7 @@ function ownerCheckFor(ownerChecks, recordId) {
1252
1264
  function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext = '', searched = '', registerCounts = null) {
1253
1265
  const goods = String(scope?.goods ?? '').trim();
1254
1266
  const classes = (Array.isArray(scope?.classes) ? scope.classes : []).filter((c) => c || c === 0);
1255
- const jx = (Array.isArray(scope?.jurisdictions) ? scope.jurisdictions : []).map((t) => territoryName(t)).filter(Boolean);
1267
+ const jx = (Array.isArray(scope?.jurisdictions) ? scope.jurisdictions : []).map((t) => territoryName(orderCode(t))).filter(Boolean);
1256
1268
  const asked = goods;
1257
1269
  const classLine = classes.length ? classes.join(', ') : '';
1258
1270
  const where = jx.length ? listWords(jx) : '';
@@ -1279,9 +1291,9 @@ function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext =
1279
1291
  // a territory nothing was counted in.
1280
1292
  const on = provider ? `, on ${provider}` : '';
1281
1293
  const sc = registerCounts?.scope ?? {};
1282
- const deferred = new Set((sc.deferredJurisdictions ?? []).map((c) => String(c ?? '').trim().toUpperCase()));
1294
+ const deferred = new Set((sc.deferredJurisdictions ?? []).map(orderCode));
1283
1295
  const ordered = [...new Set((sc.jurisdictions ?? [])
1284
- .filter((c) => !deferred.has(String(c ?? '').trim().toUpperCase())).map(orderedTerritoryName))];
1296
+ .filter((c) => !deferred.has(orderCode(c))).map((c) => orderedTerritoryName(orderCode(c))))];
1285
1297
  const namesTheOrder = sc.worldwide !== true && !(sc.unreachableOffices ?? []).length
1286
1298
  && ordered.length > 0 && ordered.every(Boolean);
1287
1299
  const counted = namesTheOrder ? `${listWords(ordered)}${on}.`
@@ -1705,9 +1705,11 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1705
1705
  // 404-card caveat (2026-07-22) — a cited record the V4-2 closure fetch DEFINITIVELY could not
1706
1706
  // retrieve (joinEvidenceStatus stamped `_recordFetchFailure` from the persisted failure list). ONE
1707
1707
  // deterministic code-owned line on the card, client render included: the reader must never take this
1708
- // card's registry values as record-verified. Absent stamp (no join ran / archived runs) ⇒ nothing.
1708
+ // card's registry values as record-verified. Absent stamp (no join ran / archived runs) ⇒ nothing. The
1709
+ // failure's cause is the record's (the persisted failure list and the stamp keep it), never the card's:
1710
+ // it can be an exception's message.
1709
1711
  const fetchFailLine = f._recordFetchFailure
1710
- ? `<li class="openitem"><b>Official register record could not be retrieved (${esc(f._recordFetchFailure.cause || 'fetch failed')})</b> — registry details in this card are unverified.</li>`
1712
+ ? `<li class="openitem"><b>Official register record could not be retrieved</b> — registry details in this card are unverified.</li>`
1711
1713
  : '';
1712
1714
  const regs = (f.owner?.registrations || []).map(r => {
1713
1715
  const uri = String(r.uri || '').toLowerCase();
@@ -574,7 +574,7 @@ function coverageJudgmentSummaryRows(cj) {
574
574
  const rows = Array.isArray(cj?.rows) ? cj.rows : [];
575
575
  if (!rows.length) return [];
576
576
  return [{ Field: '', Value: '' }, { Field: 'Coverage judgment — slices considered', Value: '', _head: true },
577
- ...rows.map((r) => ({ Field: ` ${plainNote(r.area)}`, Value: plainNote(r.note) }))];
577
+ ...rows.map((r) => ({ Field: ` ${plainNote(r.areaLabel || r.area)}`, Value: plainNote(r.note) }))];
578
578
  }
579
579
  function correctionsSummaryRows(c) {
580
580
  const entries = Array.isArray(c?.entries) ? c.entries : [];
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // THE FOLDED RECORD IS THE RECORD FROM THE DELIVERY SEAM ON (design, 2026-10-03).
5
+ //
6
+ // Before the overview and the cards are built, the driver folds a second filing of the same conflict into
7
+ // one finding and renumbers the rest. A synthesis save after that seam — the pre-delivery lint's narrative
8
+ // repair, the stale repair, the fix pass — wrote the model's own record back over the folded one, and
9
+ // nothing folded it again. Measured on a test run: the folded filing stood as its own finding again, every
10
+ // finding after it moved down one place, 19 cards were rebuilt for findings that had only moved, and the
11
+ // last finding shipped without the prose card it was owed, because no card had ever been built at its
12
+ // ordinal.
13
+ //
14
+ // So once the seam is passed, every accepted save folds again on acceptance, and findings.json — what the
15
+ // reviewer after the seam, the cards and the delivery read — is always the folded record. Two things stay
16
+ // in the model's numbering, because the model wrote them in it: its own record, which stays the base for
17
+ // its patches (the call records the save keeps), and whatever is named against that record — a flag's
18
+ // ordinals, the ordinals a save records it touched, the narrative's finding headings. So the record the
19
+ // save wrote is kept beside the folded file while a fold is in force, with the fold's map both ways, and a
20
+ // reader that joins something in the model's numbering reads that record (`modelRecordPath`).
21
+ //
22
+ // THE STATE, in _driver/fold.json:
23
+ // seamPassed — this pass has passed the seam. A pass opens with it cleared (`openPass`) and the seam's
24
+ // own fold sets it, so a save before the seam on a resumed pass writes the model's record,
25
+ // as every save before the seam always has.
26
+ // map — {modelToFolded, foldedToModel} for findings.json as it stands; null when the file is
27
+ // the record as the save wrote it.
28
+ // and _driver/findings-unfolded.json — that record, in the model's numbering, kept only while a fold is
29
+ // in force.
30
+ import { readFileSync, writeFileSync, existsSync, renameSync, rmSync, mkdirSync } from "node:fs";
31
+ import { dirname } from "node:path";
32
+ import { driverDir } from "../shared/driver-dir.mjs";
33
+ import { consolidateFindings, remapActionOrdinals, parseFindingsJson } from "./findings-model.mjs";
34
+ import { parseFrameworkManifest } from "./framework.mjs";
35
+
36
+ export const FOLD_FILE = "fold.json";
37
+ export const UNFOLDED_FILE = "findings-unfolded.json";
38
+
39
+ const bothWays = (ordinalMap) => {
40
+ const modelToFolded = {}, foldedToModel = {};
41
+ for (const [m, f] of ordinalMap) { modelToFolded[m] = f; (foldedToModel[f] ??= []).push(m); }
42
+ return { modelToFolded, foldedToModel };
43
+ };
44
+
45
+ /**
46
+ * Fold a findings document as the delivery seam does: a second filing of the same conflict folds into one
47
+ * finding, the findings renumber, and the actions follow the renumber. `map` is null when nothing folds,
48
+ * and the document is then returned as it came. PURE.
49
+ */
50
+ export function foldRecord(doc, manifest = null) {
51
+ const list = Array.isArray(doc?.findings) ? doc.findings : [];
52
+ if (list.length < 2) return { doc, merges: [], map: null };
53
+ const { findings, merges, ordinalMap } = consolidateFindings(list, (doc.schema_version ?? 1) >= 4 ? manifest : null);
54
+ if (!merges.length) return { doc, merges: [], map: null };
55
+ const next = { ...doc, findings };
56
+ // spec 64 — a merged-away ordinal remaps to its kept finding's new ordinal
57
+ if (Array.isArray(doc.actions)) next.actions = remapActionOrdinals(doc.actions, ordinalMap);
58
+ return { doc: next, merges, map: bothWays(ordinalMap) };
59
+ }
60
+
61
+ /** The fold state of a run, or null before any seam. Never throws. */
62
+ export function readFold(runDir) {
63
+ try { return JSON.parse(readFileSync(driverDir(String(runDir ?? ""), FOLD_FILE), "utf8")); } catch { return null; }
64
+ }
65
+
66
+ function writeFold(runDir, { seamPassed, map = null, merges = [], error = null }) {
67
+ const at = driverDir(String(runDir ?? ""), FOLD_FILE);
68
+ mkdirSync(dirname(at), { recursive: true });
69
+ const tmp = `${at}.${process.pid}.tmp`;
70
+ writeFileSync(tmp, JSON.stringify({
71
+ _provenance: "the fold of findings.json as it stands: whether this pass has passed the delivery seam, and the map from the model's ordinals to the folded ones and back",
72
+ at: new Date().toISOString(), seamPassed: Boolean(seamPassed), map,
73
+ // ordinals only: the names stay in the record itself
74
+ merges: (merges ?? []).map((m) => ({ kept: m.kept, dropped: m.dropped })),
75
+ ...(error ? { error } : {}),
76
+ }, null, 2) + "\n");
77
+ renameSync(tmp, at);
78
+ }
79
+
80
+ function writeAtomic(file, text) {
81
+ const tmp = `${file}.${process.pid}.tmp`;
82
+ writeFileSync(tmp, text);
83
+ renameSync(tmp, file);
84
+ }
85
+
86
+ /**
87
+ * Fold findings.json in place and record the fold. The seam calls it with `seam: true`; every accepted save
88
+ * calls it after writing the record, and it folds only once this pass has passed the seam. Before the seam
89
+ * the save wrote the model's record as it always has, and the state then says the file is unfolded.
90
+ *
91
+ * Returns `{merges}`, empty when nothing folded, and `error` when a fold could not be made. Never throws: a
92
+ * record that cannot be folded stays as the save wrote it, which is what every run did before this.
93
+ */
94
+ export function foldFindingsFile(runDir, findingsPath, manifest, { seam = false } = {}) {
95
+ const dir = String(runDir ?? "");
96
+ const state = readFold(dir);
97
+ const unfoldedAt = driverDir(dir, UNFOLDED_FILE);
98
+ let folded = null; // the fold, once findings.json holds it
99
+ try {
100
+ if (!seam && state?.seamPassed !== true) {
101
+ if (state?.map) { writeFold(dir, { seamPassed: false }); rmSync(unfoldedAt, { force: true }); }
102
+ return { merges: [] };
103
+ }
104
+ if (!existsSync(findingsPath)) { writeFold(dir, { seamPassed: true }); return { merges: [] }; }
105
+ const raw = readFileSync(findingsPath, "utf8");
106
+ const { doc, merges, map } = foldRecord(JSON.parse(raw), manifest);
107
+ if (!merges.length) {
108
+ // Nothing folds. On a resumed pass the file can be the earlier pass's fold with no save since: the
109
+ // record and the map it was folded by both stand.
110
+ const held = seam && state?.map && existsSync(unfoldedAt);
111
+ writeFold(dir, held ? { seamPassed: true, map: state.map, merges: state.merges } : { seamPassed: true });
112
+ if (!held) rmSync(unfoldedAt, { force: true });
113
+ return { merges: [] };
114
+ }
115
+ parseFindingsJson(JSON.stringify(doc)); // the folded record must still validate; a throw keeps the record as written
116
+ writeAtomic(unfoldedAt, raw);
117
+ writeAtomic(findingsPath, `${JSON.stringify(doc, null, 2)}\n`);
118
+ folded = { map, merges };
119
+ writeFold(dir, { seamPassed: true, map, merges });
120
+ return { merges };
121
+ } catch (e) {
122
+ // A fold that cannot be made leaves the record as the save wrote it, and the state must say so: a map
123
+ // and a kept record from an earlier fold would send every reader of the model's numbering to a record
124
+ // this save has replaced. The failure is recorded, because nothing else reads this function's return.
125
+ const error = String(e?.message ?? e).replace(/\s+/g, " ").slice(0, 160);
126
+ try {
127
+ const seamPassed = seam || state?.seamPassed === true;
128
+ if (folded) writeFold(dir, { seamPassed, ...folded, error });
129
+ else { rmSync(unfoldedAt, { force: true }); writeFold(dir, { seamPassed, error }); }
130
+ } catch { /* best-effort */ }
131
+ return { merges: [], error };
132
+ }
133
+ }
134
+
135
+ /** The fold an accepted save makes, with the run's frozen framework manifest. Never throws. */
136
+ export function foldAfterSave(runDir, findingsPath) {
137
+ let manifest = null;
138
+ try { manifest = parseFrameworkManifest(readFileSync(driverDir(String(runDir ?? ""), "framework.json"), "utf8")); }
139
+ catch { manifest = null; } // a run with no frozen manifest folds as the seam does for it
140
+ return foldFindingsFile(runDir, findingsPath, manifest);
141
+ }
142
+
143
+ /** A pass opens before the seam. The map stands: the file on disk is still what the last writer left. */
144
+ export function openPass(runDir) {
145
+ const state = readFold(runDir);
146
+ if (!state?.seamPassed) return;
147
+ try { writeFold(runDir, { ...state, seamPassed: false }); } catch { /* best-effort: the seam rewrites it */ }
148
+ }
149
+
150
+ /** The record in the model's numbering: the one the last save wrote while a fold is in force, else findings.json. */
151
+ export function modelRecordPath(runDir, findingsPath) {
152
+ const at = driverDir(String(runDir ?? ""), UNFOLDED_FILE);
153
+ return readFold(runDir)?.map && existsSync(at) ? at : findingsPath;
154
+ }
155
+
156
+ /** The fold as it stands, to put back if a pass that changed it is rolled back. Never throws. */
157
+ export function snapshotFold(runDir) {
158
+ const state = readFold(runDir);
159
+ let unfolded = null;
160
+ try { if (state?.map) unfolded = readFileSync(driverDir(String(runDir ?? ""), UNFOLDED_FILE), "utf8"); } catch { unfolded = null; }
161
+ return { state, unfolded };
162
+ }
163
+
164
+ /** Put a snapshot's fold back beside the findings.json it described. This pass's seam stays as it is. */
165
+ export function restoreFold(runDir, snap) {
166
+ const dir = String(runDir ?? "");
167
+ try {
168
+ const seamPassed = readFold(dir)?.seamPassed === true;
169
+ if (snap?.state?.map && snap.unfolded != null) {
170
+ writeAtomic(driverDir(dir, UNFOLDED_FILE), snap.unfolded);
171
+ writeFold(dir, { seamPassed, map: snap.state.map, merges: snap.state.merges });
172
+ } else {
173
+ rmSync(driverDir(dir, UNFOLDED_FILE), { force: true });
174
+ if (readFold(dir)) writeFold(dir, { seamPassed });
175
+ }
176
+ } catch { /* best-effort: the next save or seam rewrites the state */ }
177
+ }
@@ -90,12 +90,12 @@
90
90
  import { existsSync, readFileSync, appendFileSync } from "node:fs";
91
91
  import { join } from "node:path";
92
92
 
93
- import { kebab } from "./search-policy.mjs";
93
+ import { kebab } from "./search-policy.mjs"; import { moreThan } from "../shared/register-floor.mjs";
94
94
  import { resolveRegions } from "./register-plan.mjs";
95
95
  import { reachableRegions } from "./register-availability.mjs"; // — pure; the env binding is injected
96
96
  import { variantForms, VARIANT_RULES, VARIANT_CAP } from "./register-variants.mjs";
97
97
  import { isCapabilityGap } from "../providers/_shared/execute-plan.mjs";
98
- import { plainDeferralReason } from "./deferral-row.mjs"; // — the reader's line for a count left open
98
+ import { plainDeferralReason, plainCause } from "./deferral-row.mjs"; // — the reader's line for a count left open
99
99
 
100
100
  /** The three questions, in report order. `matchMode` is the provider-neutral predicate name.
101
101
  *
@@ -592,7 +592,24 @@ export function disclosedFloor(c) {
592
592
  }
593
593
 
594
594
  /** A floor as every page prints it: the register's own figure, and no sentence around it. */
595
- export const moreThan = (floor) => `more than ${floor.toLocaleString("en-US")}`;
595
+ export { moreThan } from "../shared/register-floor.mjs";
596
+
597
+ /**
598
+ * THE READER'S LINE FOR A COUNT THAT DID NOT LAND. A count's `unavailable` text is the counts record's: it
599
+ * can carry the provider's own refusal, or an exception's message (`count threw: …`), and that text
600
+ * belongs to the record, never to a cell a client reads. Two lines the counter writes for a reader stay as
601
+ * they are: a name with no near-form, and how many of the close-variation forms went uncounted (without the
602
+ * raw causes that follow it). Anything else reads as the shipped line for a count left open
603
+ * (deferral-row.mjs, plainCause). `null` when the cell has no text. PURE.
604
+ */
605
+ export function countNote(cell) {
606
+ const t = String(cell?.unavailable ?? "").trim();
607
+ if (!t) return null;
608
+ if (/^no close-variation forms could be generated from ".*" — the name has no near-form under any rule in the table, so there was nothing to count$/.test(t)) return t;
609
+ const partial = /^\d+ of \d+ variant form\(s\) could not be counted, so the total would understate(?= — |$)/.exec(t);
610
+ if (partial) return partial[0];
611
+ return plainCause(t);
612
+ }
596
613
 
597
614
  export function countLine(entry) {
598
615
  if (!entry?.counts) return null;
@@ -32,7 +32,7 @@
32
32
  // ── AND WHY IT DELEGATES TO resolveRegions RATHER THAN RE-IMPLEMENTING IT ───────────────────────────
33
33
  //
34
34
  // Five vocabularies sit between a display name and a covered office: display → `normalizeTerritory` →
35
- // `canonicalJurisdictionCode` → `offices.translate` → `covered.has`. `register-plan.mjs:206-216` records
35
+ // `canonicalJurisdictionCode` → `offices.translate` → `covered.has`. resolveRegions() in register-plan.mjs records
36
36
  // the copper-bastion incident from re-implementing that chain — the composer submitted display names,
37
37
  // corsearch's ISO-passthrough translate sent them to the wire verbatim, the vendor answered 500 rather
38
38
  // than 400, and recovery burned its park budget re-sending a query that could never succeed.
@@ -366,6 +366,20 @@ export const planMaxOrWidth = (capabilities) =>
366
366
  Number.isFinite(capabilities?.maxOrWidth) && capabilities.maxOrWidth >= 1
367
367
  ? Math.floor(capabilities.maxOrWidth) : PLAN_MAX_OR_WIDTH;
368
368
 
369
+ /**
370
+ * The OR-stack width for an EXACT spelling band. A provider whose general width is narrower than what it
371
+ * can take for exact spellings declares `exactOrWidth` (signa: one term per request in general, up to 100
372
+ * exact spellings as one ranked list); every other provider stacks exact spellings at its general width.
373
+ */
374
+ export const planExactOrWidth = (capabilities) =>
375
+ Number.isFinite(capabilities?.exactOrWidth) && capabilities.exactOrWidth >= 1
376
+ ? Math.floor(capabilities.exactOrWidth) : planMaxOrWidth(capabilities);
377
+
378
+ /** The OR-stack width for a SOUND-ALIKE band, by the same rule: `phoneticOrWidth` where a provider declares one. */
379
+ export const planPhoneticOrWidth = (capabilities) =>
380
+ Number.isFinite(capabilities?.phoneticOrWidth) && capabilities.phoneticOrWidth >= 1
381
+ ? Math.floor(capabilities.phoneticOrWidth) : planMaxOrWidth(capabilities);
382
+
369
383
  // The plan emits ONE `wildcard` predicate; the provider contract splits it into three sub-capabilities.
370
384
  // Mirrors the executor's planPredicateParams anchoring exactly (trailing * → prefix/starts-with,
371
385
  // leading * → suffix/ends-with, both/neither → infix over the raw pattern).
@@ -1111,26 +1125,31 @@ export function compileRegisterPlan({ manifest, job, form = null, skillVersion =
1111
1125
  // anywhere: the exact silent-wrong-query false-clean class this carriage fix exists to kill,
1112
1126
  // quieter than the loud deferral it replaced. formKey now strips accents from LATIN bases only
1113
1127
  // and preserves every non-Latin combining mark (width still folds via NFKD).
1114
- // - and if two variants STILL share a key while dictating different romanisations (the model
1115
- // contradicting itself about one term), the key is poisoned rather than resolved by position:
1116
- // those entries compile BARE, and the romanisation-index provider's refusal turns the
1117
- // contradiction into a loud disclosed deferral — never into whichever answer came first.
1128
+ // - and two variants that share a key while dictating DIFFERENT romanisations carry EVERY one of
1129
+ // them, on every entry for that key. One string of characters can have two readings: the same
1130
+ // Han characters are read one way in Chinese and another in Japanese, and the manifest lists the
1131
+ // string once per reading. This rule used to read that as the model contradicting itself and
1132
+ // compile the key bare, so the romanisation-index provider refused it and the slice reached the
1133
+ // client as "not searched" — while the model, seeing the refusal, searched both readings as
1134
+ // supplementals and got its answer (a delivered report, 2026-10-08). Carrying every reading still never
1135
+ // resolves by position, and never sends a sibling's form in place of the dictated one: each
1136
+ // reading is asked, in the same request, and a reading that is simply wrong costs one spelling.
1118
1137
  const romanByValue = new Map();
1119
1138
  for (const v of manifest.variants) {
1120
1139
  if (!v.romanization) continue;
1121
1140
  const key = formKey(v.value);
1122
- const prior = romanByValue.get(key);
1123
- if (prior === undefined) romanByValue.set(key, v.romanization);
1124
- else if (prior !== null && romanizationSpellings(prior)[0] !== romanizationSpellings(v.romanization)[0])
1125
- romanByValue.set(key, null); // conflicting dictates ⇒ stamp NOTHING for this key (loud backstop)
1141
+ const readings = romanByValue.get(key) ?? [];
1142
+ const reading = romanizationSpellings(v.romanization)[0];
1143
+ if (reading && !readings.some((r) => romanizationSpellings(r)[0].toUpperCase() === reading.toUpperCase()))
1144
+ readings.push(v.romanization);
1145
+ romanByValue.set(key, readings);
1126
1146
  }
1127
1147
  const romanStamp = (e) => {
1128
1148
  // An owner NAME is not mark text and rides its own field (the executor drops the carrier on an
1129
1149
  // owner query anyway). An OR-stack is chunked, not substituted — one non-Latin member must never
1130
1150
  // silently replace a whole chunk's names, so a `terms[]` entry never carries the field.
1131
1151
  if (e.predicate === "owner" || Array.isArray(e.terms)) return {};
1132
- const roman = romanByValue.get(formKey(e.term));
1133
- const spellings = roman ? romanizationSpellings(roman) : [];
1152
+ const spellings = [...new Set((romanByValue.get(formKey(e.term)) ?? []).flatMap(romanizationSpellings))];
1134
1153
  return spellings.length ? { romanizedTerms: spellings } : {};
1135
1154
  };
1136
1155
 
@@ -1266,7 +1285,7 @@ export function compileRegisterPlan({ manifest, job, form = null, skillVersion =
1266
1285
  // this compiler's own equivalence: `norm` and `formKey` both strip separators, so "BIO VELTRIS",
1267
1286
  // "BIOVELTRIS", "BIO-VELTRIS" and "BIO.VELTRIS" are ONE key. The variant loop below already drops a
1268
1287
  // model variant that restates the mark that way, on exactly that test. Dispatching the generated set
1269
- // would send the mark's own exact entry three more times under a different spelling — the PARADISE
1288
+ // would send the mark's own exact entry three more times under a different spelling — the Drivers Haven
1270
1289
  // shape (five diacritic terms, same 424 hits, the run's record budget gone), re-imported. The family
1271
1290
  // is enumerated and marked in form-neighbourhood.json because it is genuinely mechanical and the
1272
1291
  // surfaces that do NOT fold spacing are real; wiring it to those is not this change.
@@ -1472,8 +1491,11 @@ export function compileRegisterPlan({ manifest, job, form = null, skillVersion =
1472
1491
  const formTerms = [...formBand.exactQueries];
1473
1492
  const latinFormTerms = formTerms.filter((t) => !isNonLatinTerm(t));
1474
1493
  const nativeFormTerms = formTerms.filter((t) => isNonLatinTerm(t));
1475
- for (let i = 0; i < latinFormTerms.length; i += maxOrWidth)
1476
- push({ axis: "primary-sweep", predicate: "exact", terms: latinFormTerms.slice(i, i + maxOrWidth), expected_kind: "enumerate", provenance: "floor", qidSuffix: "+form" });
1494
+ // An exact band stacks at the provider's EXACT width, which is wider than its general one where the
1495
+ // register takes a list of exact spellings in one request (planExactOrWidth).
1496
+ const exactWidth = planExactOrWidth(caps);
1497
+ for (let i = 0; i < latinFormTerms.length; i += exactWidth)
1498
+ push({ axis: "primary-sweep", predicate: "exact", terms: latinFormTerms.slice(i, i + exactWidth), expected_kind: "enumerate", provenance: "floor", qidSuffix: "+form" });
1477
1499
  for (const t of nativeFormTerms)
1478
1500
  push({ axis: "primary-sweep", predicate: "exact", term: t, expected_kind: "enumerate", provenance: "floor", qidSuffix: "+form" });
1479
1501
  }
@@ -2173,7 +2195,10 @@ export function validatePlanFeasibility(plan, { capabilities = null, maxOrWidth
2173
2195
  if (!PLAN_PREDICATES.includes(e?.predicate)) add("unexecutable", `unknown predicate "${e?.predicate}"`);
2174
2196
  const names = Array.isArray(e?.terms) ? e.terms : e?.term != null ? [e.term] : [];
2175
2197
  if (!names.length || names.some((t) => !String(t ?? "").trim())) add("unexecutable", "empty term(s)");
2176
- if (Array.isArray(e?.terms) && e.terms.length > maxOrWidth) add("repairable", `OR-stack of ${e.terms.length} names exceeds the executor bound (${maxOrWidth}) — the executor runs it chunked`);
2198
+ const width = !capabilities ? maxOrWidth
2199
+ : e?.predicate === "exact" ? Math.max(maxOrWidth, planExactOrWidth(capabilities))
2200
+ : e?.predicate === "phonetic" ? Math.max(maxOrWidth, planPhoneticOrWidth(capabilities)) : maxOrWidth;
2201
+ if (Array.isArray(e?.terms) && e.terms.length > width) add("repairable", `OR-stack of ${e.terms.length} names exceeds the executor bound (${width}) — the executor runs it chunked`);
2177
2202
  for (const t of names) if (String(t).length > maxNameLength) add("repairable", `name exceeds ${maxNameLength} chars ("${String(t).slice(0, 40)}…") — provider-side truncation risk only`);
2178
2203
  if ((e?.nice_classes ?? []).some((c) => !Number.isFinite(Number(c)))) add("unexecutable", "non-numeric nice_class");
2179
2204
  if (e?.when && guardParentQid(e.when) != null && !qids.has(guardParentQid(e.when))) add("unexecutable", `when-guard targets unknown qid "${guardParentQid(e.when)}"`);
@@ -2359,7 +2384,10 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
2359
2384
  // and the report's coverage line claims to: it reads "skipped after a broader search came back
2360
2385
  // crowded", which is true when the parent was `incomplete` and false when the parent FAILED or
2361
2386
  // never ran. The state that decides it is read one line above and was thrown away here.
2362
- if (parentState !== "enumerated") { skipped.push({ qid: e.qid, guard: e.when.runs_if_enumerated, parent_state: parentState || null }); continue; }
2387
+ // AND WHETHER IT ERRED, because the state cannot say: a band has two states, and the executor writes
2388
+ // every provider error as `incomplete` with `error: true` beside it. Read off the state alone, a
2389
+ // broader search that failed was a broader search that crowded.
2390
+ if (parentState !== "enumerated") { skipped.push({ qid: e.qid, guard: e.when.runs_if_enumerated, parent_state: parentState || null, ...(parent?.error === true ? { parent_error: true } : {}) }); continue; }
2363
2391
  }
2364
2392
  }
2365
2393
  const b = byQid.get(e.qid);
@@ -2404,6 +2432,16 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
2404
2432
  state: String(b.state ?? "").toLowerCase(),
2405
2433
  records: recs,
2406
2434
  total_hits: Number.isFinite(hits) ? hits : null,
2435
+ // A SLICE THAT RAN AND COULD NOT BE ANSWERED. The executor stamps `deferred: true` with no error on an
2436
+ // owner sweep whose name the register never resolved: the call ran, so it is executed, and the band
2437
+ // says `incomplete` with no count. The deferral is the only thing that tells it from a listing that
2438
+ // overflowed, so it rides with the row rather than stopping at the block.
2439
+ ...(b.deferred === true ? { deferred: true } : {}),
2440
+ // WHAT THE TOTAL COUNTS, when the register said: `records`, one per country a mark covers. Every
2441
+ // reader of this row reads the count as the register's own number (ruled 2026-10-02).
2442
+ ...(typeof b.total_counts === "string" ? { total_counts: b.total_counts } : {}),
2443
+ // The register's floor when it answered "at least N" instead of a count: total_hits stays null beside it.
2444
+ ...(Number.isFinite(b.total_floor) ? { total_floor: b.total_floor } : {}),
2407
2445
  });
2408
2446
  }
2409
2447
  const planQids = new Set(plan.entries.map((e) => e.qid));
@@ -2613,9 +2651,30 @@ export function deriveCoverageSkeleton(plan, join) {
2613
2651
  const deferredQids = new Set((join.deferred ?? []).map((x) => x.qid));
2614
2652
  const awaitingQids = new Set((join.awaiting ?? []).map((x) => x.qid));
2615
2653
  const askedQids = new Set((join.asked ?? []).map((x) => x.qid));
2654
+ // ── A COUNT THE PLAN ALSO READ IN FULL IS NOT A CROWD ──────────────────────────────────────────
2655
+ // A count-only descriptor (the saturation probe's shape) sizes a question and reads none of its
2656
+ // records, so it lands `incomplete` and its axis reads as a crowd. Where another entry asked the
2657
+ // IDENTICAL question — predicate, term, classes, territories, owner and goods — and listed every
2658
+ // record of the same total, nothing the count describes is unread. Leaving the axis `incomplete` told
2659
+ // the reading seat a set was unreviewed that had been read in full, and a delivered report made its
2660
+ // verdict conditional on reviewing it (2026-10-08). Anything short of that stays a crowd.
2661
+ const entryByQid = new Map(plan.entries.map((e) => [e.qid, e]));
2662
+ const question = (e) => (!e || Array.isArray(e.terms) ? null : JSON.stringify([
2663
+ String(e.predicate ?? ""), formKey(String(e.term ?? "")),
2664
+ [...(e.nice_classes ?? [])].map(String).sort(), [...(e.regions ?? [])].map(String).sort(),
2665
+ String(e.owner ?? ""), goodsTermsList(e)]));
2666
+ const readInFull = new Map();
2667
+ for (const x of join.executed) {
2668
+ if (x.state !== "enumerated" || !Number.isInteger(x.total_hits) || x.records !== x.total_hits) continue;
2669
+ const q = question(entryByQid.get(x.qid));
2670
+ if (q) readInFull.set(q, x.total_hits);
2671
+ }
2672
+ const totalByQid = new Map(join.executed.map((x) => [x.qid, x.total_hits]));
2673
+ const readElsewhere = (e) => e.expected_kind === "count" && Number.isInteger(totalByQid.get(e.qid))
2674
+ && readInFull.get(question(e)) === totalByQid.get(e.qid);
2616
2675
  const axes = new Map();
2617
2676
  for (const e of plan.entries) {
2618
- if (!axes.has(e.axis)) axes.set(e.axis, { axis: e.axis, entries: 0, executed: 0, crowds: 0, missing: [], skipped: 0, deferred: [], awaiting: 0, asked: 0 });
2677
+ if (!axes.has(e.axis)) axes.set(e.axis, { axis: e.axis, entries: 0, executed: 0, crowds: 0, missing: [], skipped: 0, deferred: [], awaiting: 0, asked: 0, readElsewhere: 0 });
2619
2678
  const a = axes.get(e.axis);
2620
2679
  a.entries++;
2621
2680
  // COUNTED BEFORE `skipped`, and separately from it. With the families waiting on every matter this is the ordinary state of
@@ -2631,7 +2690,10 @@ export function deriveCoverageSkeleton(plan, join) {
2631
2690
  // a capability gap is NOT executed — it is a disclosed hole in the axis
2632
2691
  if (deferredQids.has(e.qid)) { a.deferred.push(e.qid); continue; }
2633
2692
  a.executed++;
2634
- if (stateByQid.get(e.qid) === "incomplete") a.crowds++;
2693
+ if (stateByQid.get(e.qid) === "incomplete") {
2694
+ if (readElsewhere(e)) a.readElsewhere++;
2695
+ else a.crowds++;
2696
+ }
2635
2697
  }
2636
2698
  return [...axes.values()].map((a) => ({
2637
2699
  axis: a.axis,
@@ -2655,6 +2717,7 @@ export function deriveCoverageSkeleton(plan, join) {
2655
2717
  entries: a.entries, executed: a.executed, crowds: a.crowds, skipped: a.skipped, missing: a.missing,
2656
2718
  ...(a.awaiting ? { awaiting: a.awaiting } : {}),
2657
2719
  ...(a.asked ? { asked: a.asked } : {}),
2720
+ ...(a.readElsewhere ? { read_elsewhere: a.readElsewhere } : {}),
2658
2721
  ...(a.deferred.length ? { deferred: a.deferred } : {}),
2659
2722
  }));
2660
2723
  }
@@ -2777,8 +2840,10 @@ export function openBlocksByAxis(skeleton, bandBlocksByAxis, plan) {
2777
2840
  // under the deleted prose join (the qid, or this number standalone). Both are now written INTO the
2778
2841
  // form's row by the driver, so the equivalence the join had to test for is structural — see
2779
2842
  // coverage-form.mjs. The field stays because the row, the render and the failure token all carry it.
2843
+ // A null total stays off the row: `Number(null)` is 0, which printed "(0 counted)" for a register's floor
2844
+ // ("at least 10,000") and for a count the register refused to give.
2780
2845
  unverified.push({ qid: e.qid,
2781
- ...(Number.isInteger(Number(b.total_hits)) ? { total_hits: Number(b.total_hits) } : {}),
2846
+ ...(b.total_hits != null && Number.isInteger(Number(b.total_hits)) ? { total_hits: Number(b.total_hits) } : {}),
2782
2847
  ...(unaccounted.length ? { unaccounted: unaccounted.slice(0, 8) } : {}),
2783
2848
  ...(unaccountedClasses.length ? { unaccounted_classes: unaccountedClasses.slice(0, 8) } : {}) });
2784
2849
  }
@@ -149,6 +149,9 @@ function toRecord(row, { term, basis, provider }) {
149
149
  registrationNumber: row?.registration_number ?? null,
150
150
  irNumber: row?.ir_number ?? null,
151
151
  filingRoute: row?.filing_route ?? null,
152
+ // The office's page as the register gave it, where it gave one; publish uses it only when the numbers
153
+ // above do not address the office's page.
154
+ officeUrl: row?.office_url ?? null,
152
155
  // WHICH QUESTION FOUND IT. Without this a reader cannot tell a filing on the name from a filing on
153
156
  // a generated variant, and the two mean very different things to the person deciding on the name.
154
157
  matchedForm: term,
@@ -248,6 +251,7 @@ export async function listRegisterRecords({
248
251
  classes: scoped, regions, provider, ok, requested: want, fetched,
249
252
  total: Number.isFinite(r?.total) ? r.total : null, took_ms: Date.now() - started,
250
253
  ...(ok && r?.approximate === true ? { approximate: true, floor: Number.isFinite(r?.floor) ? r.floor : null } : {}),
254
+ ...(ok && Array.isArray(r?.warnings) && r.warnings.length ? { warnings: r.warnings } : {}),
251
255
  ...(ok ? {} : { cause: String(r?.reason ?? "unknown").slice(0, 300) }),
252
256
  }) + "\n");
253
257
  } catch { /* receipts are best-effort, never fatal */ }
@@ -260,6 +264,9 @@ export async function listRegisterRecords({
260
264
  // An approximation is carried as one, exactly as the count lane records it: no number in
261
265
  // `total`, the register's floor beside it. Only a provider whose listing states it sets it.
262
266
  ...(ok && r?.approximate === true ? { approximate: true, floor: Number.isFinite(r?.floor) ? r.floor : null } : {}),
267
+ // The register's own notes on this answer, as it sent them — absent when it sent none, which is
268
+ // not evidence that the answer carried no caveat on a register that sends none at all.
269
+ ...(ok && Array.isArray(r?.warnings) && r.warnings.length ? { warnings: r.warnings } : {}),
263
270
  ...(ok ? {} : { reason: String(r?.reason ?? "the filings could not be fetched").slice(0, 300) }),
264
271
  };
265
272
  });