clearotron 0.3.2-beta.7 → 0.3.2-beta.8

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 (85) hide show
  1. package/.env.example +24 -23
  2. package/INSTALL.md +142 -75
  3. package/README.md +3 -3
  4. package/bin/onboard.mjs +637 -216
  5. package/bin/start.mjs +133 -23
  6. package/bin/update.mjs +58 -11
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +26 -11
  9. package/docs/architecture/05-config-governance.md +17 -7
  10. package/driver/CHANGELOG.md +76 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/contract-arm2-baseline.json +1 -3
  14. package/driver/contract-e3-backlog.mjs +26 -26
  15. package/driver/contract-vocabulary.mjs +44 -10
  16. package/driver/door-gates.mjs +41 -7
  17. package/driver/driver.config.mjs +272 -59
  18. package/driver/engine/CONTRACT.md +10 -3
  19. package/driver/engine/README.md +2 -2
  20. package/driver/engine/anthropic-agent.mjs +77 -21
  21. package/driver/engine/auth.mjs +129 -10
  22. package/driver/engine/jx-turn.mjs +7 -6
  23. package/driver/engine/mcp/recording-server.mjs +13 -0
  24. package/driver/engine/openai-agent.mjs +4 -2
  25. package/driver/engine/probe.mjs +110 -23
  26. package/driver/findings-model.mjs +1 -1
  27. package/driver/flag-snapshot.mjs +28 -5
  28. package/driver/gateway.mjs +24 -18
  29. package/driver/jx-lanes.mjs +21 -2
  30. package/driver/jx-units.mjs +6 -3
  31. package/driver/jx.mjs +4 -2
  32. package/driver/matter-frame-record.mjs +90 -1
  33. package/driver/named-band.mjs +34 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +200 -23
  36. package/driver/portal-config-view.mjs +30 -1
  37. package/driver/portal-report.mjs +15 -1
  38. package/driver/portal-service.mjs +46 -6
  39. package/driver/predelivery-lint.mjs +12 -2
  40. package/driver/publish/index.mjs +46 -5
  41. package/driver/publish/knockout.mjs +10 -1
  42. package/driver/publish/render-knockout.mjs +69 -7
  43. package/driver/publish/render.mjs +170 -59
  44. package/driver/publish/report-data.mjs +4 -1
  45. package/driver/publish/report-topbar.mjs +58 -0
  46. package/driver/publish/templates/report.css +18 -1
  47. package/driver/publish/xlsx.mjs +13 -1
  48. package/driver/register-availability.mjs +2 -2
  49. package/driver/register-coverage.mjs +94 -1
  50. package/driver/register-digest-record.mjs +236 -11
  51. package/driver/register-plan.mjs +170 -0
  52. package/driver/result-noun-fields.mjs +2 -2
  53. package/driver/run-economics.mjs +41 -10
  54. package/driver/run-requirements.mjs +173 -9
  55. package/driver/runner.mjs +3 -3
  56. package/driver/stages.mjs +12 -8
  57. package/driver/suite-census.json +142 -64
  58. package/driver/systemd/README.md +7 -4
  59. package/driver/terminal-clamp.mjs +107 -1
  60. package/driver/tokens.mjs +169 -3
  61. package/driver/unit-environment.mjs +42 -15
  62. package/driver/unit-inventory.mjs +19 -2
  63. package/driver/verify.mjs +27 -0
  64. package/mcp-server/CHANGELOG.md +4 -0
  65. package/mcp-server/package.json +1 -1
  66. package/mcp-server/server.mjs +15 -1
  67. package/package.json +1 -1
  68. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  69. package/portal-ui/dist/index.html +1 -1
  70. package/portal-ui/package.json +1 -1
  71. package/providers/jx/README.md +2 -1
  72. package/providers/jx/src/turn-envelope.mjs +8 -3
  73. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  74. package/providers/oauth-mcp-bridge/package.json +1 -1
  75. package/providers/uspto-local/README.md +1 -1
  76. package/scripts/authority-boundary-probe.mjs +4 -2
  77. package/scripts/env-audit.mjs +12 -6
  78. package/scripts/freeze-example-run.mjs +49 -16
  79. package/scripts/generated-files-are-current.mjs +69 -4
  80. package/scripts/settings-render-check.mjs +75 -2
  81. package/scripts/test-full.mjs +96 -3
  82. package/scripts/test-run.mjs +10 -0
  83. package/shared/deployment-box.mjs +7 -2
  84. package/shared/driver-dir.mjs +1 -1
  85. package/shared/names-in-force.mjs +1 -1
@@ -51,7 +51,7 @@
51
51
  // once per snapshot, by a writer that is already async. So that import is DYNAMIC, and search-policy
52
52
  // never pulls the plan layer in to answer a question about a product menu.
53
53
  import { PROMPT_TERRITORIES } from "./compose-read.mjs";
54
- import { territoryTier } from "./territory-tiers.mjs";
54
+ import { territoryTier, territoryKey } from "./territory-tiers.mjs";
55
55
 
56
56
  /**
57
57
  * The composer display names this register can actually search.
@@ -157,3 +157,96 @@ export function registerCoverageCause(geography, territories, all = PROMPT_TERRI
157
157
  if (rule(territories, all)) return null;
158
158
  return geography === "worldwide, and nothing else" ? "register-not-worldwide" : "register-coverage";
159
159
  }
160
+
161
+ // ── the REQUEST half ────────────────────────────────────────────────────────────────────────────────
162
+ //
163
+ // The product half above asks whether a PRODUCT can be ordered at all. This asks whether THIS request's
164
+ // territories can be searched. Two questions, two rulings, deliberately not folded together:
165
+ //
166
+ // · the PRODUCT-level refusal was retired on 2026-08-31 and stays retired — a worldwide search is
167
+ // orderable on a partial register and the gap is DISCLOSED in the report.
168
+ // `coverage-is-disclosed-never-refused.test.mjs` pins that, and this function must never make it red.
169
+ // · the REQUEST-level refusal was ruled on 2026-09-17: a requester who NAMES a territory the wired
170
+ // register cannot search is told so BEFORE the run. That gap is not disclosable — there is no result
171
+ // to caveat, only a territory the client asked about and would never hear another word on.
172
+ //
173
+ // AN EMPTY LIST IS THE "NAMES NO TERRITORY" STATE, BY CONSTRUCTION, and that is why no mode string is
174
+ // read here. `effective-scope.mjs`'s ladder answers `[]` for a worldwide stamp — worldwide accepts no
175
+ // narrowing, so the account's defaults are not consulted — and `[]` again when no layer set a territory.
176
+ // So "a search that names no territory always runs" falls out of the empty set passing. A saved account
177
+ // default that DID reach the ladder is in the list and is judged with the rest, which is the ruling of
178
+ // 2026-09-17 on that question: a default counts as named once it reaches the engine.
179
+ //
180
+ // AND A NAME THE SNAPSHOT CANNOT SPEAK TO FAILS OPEN. `registerTerritories` is scoped to the composer's
181
+ // 37 display names. The other doors are not: `normalizeTerritory` passes any two-letter token through, so
182
+ // the CLI and start_run can name `VN`, which is inside clarivate's own 186-office enum and outside those
183
+ // 37. A bare membership test would refuse a search this engine runs today. Only a territory the
184
+ // snapshot's vocabulary can SPEAK TO is judged, and everything else is the pipeline's to defer as now.
185
+
186
+ /** Composer display name for a canonical key, so a refusal says "China" whether the requester wrote
187
+ * "China", "cn" or "CN". Built per call from the same list the covered set is scoped to. */
188
+ function displayByKey(all) {
189
+ const out = new Map();
190
+ for (const name of all) out.set(territoryKey(name), name);
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * The territories this request NAMES that the wired register cannot search, as composer display names.
196
+ *
197
+ * @param territories the resolved territories — `effective-scope.mjs`'s ladder answer
198
+ * @param registerTerritories what `coveredTerritoryNames` returned: `[...]`, `null` (no declared
199
+ * restriction) or `undefined` (the snapshot does not say)
200
+ * @returns `[]` — nothing to refuse, INCLUDING on `null` and `undefined`, which fail open exactly as
201
+ * they do at every other layer. Never treat an empty return as "the check did not run".
202
+ *
203
+ * Both sides are compared through `territoryKey`, which is the identity function this tree already has
204
+ * for "what makes two spellings the same place" — it folds EM/EUTM/EUIPO to EU and UK to GB. That fold
205
+ * is the whole of requirement 2: an EU-covering register refusing a request that names "European Union"
206
+ * was the defect measured on 2026-09-17, and it cannot recur while both sides go through one authority.
207
+ */
208
+ export function uncoveredTerritories(territories, registerTerritories, all = PROMPT_TERRITORIES) {
209
+ if (registerTerritories === null || registerTerritories === undefined) return [];
210
+ const covered = new Set(registerTerritories.map(territoryKey));
211
+ const display = displayByKey(all);
212
+ const out = [];
213
+ const seen = new Set();
214
+ for (const t of territories ?? []) {
215
+ const key = territoryKey(t);
216
+ // Outside the snapshot's vocabulary ⇒ this register's covered list says nothing about it. Fail open.
217
+ if (!key || !display.has(key) || covered.has(key) || seen.has(key)) continue;
218
+ seen.add(key);
219
+ out.push(display.get(key));
220
+ }
221
+ return out;
222
+ }
223
+
224
+ // One-line join rather than an import: this module's header makes its static graph part of the design
225
+ // ("search-policy.mjs imports this file … a cycle is one careless import away"), and the only other
226
+ // spelling of this lives behind scope-facts.mjs, which is not a leaf.
227
+ const joinAnd = (parts) => (parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`);
228
+
229
+ /**
230
+ * The door's sentence for a request naming territories the wired register cannot search — or null.
231
+ *
232
+ * WRITTEN ONCE, FOR EVERY DOOR. Wording approved by the owner on 2026-09-17, including naming the wired
233
+ * register, which the composer's own design note otherwise forbids on screen. The remedy clause is his
234
+ * addition of the same day: a caller with no screen — start_run, the CLI — has to be able to act on this
235
+ * sentence alone, on the next call.
236
+ *
237
+ * IT POINTS BACK RATHER THAN REPEATING. The first draft named the territories twice ("remove China and
238
+ * Japan"); the owner ruled on the plural the same day — "remove them", no need to repeat the countries —
239
+ * and the singular follows the same reason, since it repeated its one country for no better cause. The
240
+ * sentence already names them, so a screenless caller still has every territory it must drop.
241
+ *
242
+ * @param registerLabel the register's display label. Absent on a snapshot written before it was carried,
243
+ * and the sentence simply does not name it then rather than naming a key.
244
+ */
245
+ export function registerReachRefusal(uncovered, registerLabel = null) {
246
+ if (!uncovered?.length) return null;
247
+ const names = joinAnd(uncovered);
248
+ const register = String(registerLabel ?? "").trim();
249
+ const where = register ? `${register}, the register configured here` : "the register configured here";
250
+ return `${names} ${uncovered.length === 1 ? "is" : "are"} not available with ${where}`
251
+ + ` — remove ${uncovered.length === 1 ? "it" : "them"} to run this search.`;
252
+ }
@@ -88,6 +88,153 @@ export function accountingArmed(runDir) {
88
88
  return existsSync(driverDir(String(runDir ?? ""), ACCOUNTING_STAMP));
89
89
  }
90
90
 
91
+ // ── THE BAND REACHES THE SEAT IN BATCHES, AND A BATCH IS THE UNIT OF ACCOUNTING ───────────────────
92
+ //
93
+ // A dense matter carried 1,161 records into one digest turn (2026-09-16). The seat ran out of turn
94
+ // before it had accounted for them all, the call was refused with 902 outstanding, and the ladder
95
+ // re-sent the same shape: 35 minutes and 172,900 output tokens for a document that was never written.
96
+ // Nothing was wrong with the judgment. The stage was handed more records than one turn holds.
97
+ //
98
+ // SO THE DRIVER SPLITS THE OWED SET AND THE SEAT RECORDS ONE CALL PER BATCH. What made that impossible
99
+ // was not the absence of a split — the transport has taken several calls since the patch path was
100
+ // built — but WHERE THE REFUSAL LOOKED. It looked at the whole owed set on every call, and a refused
101
+ // call stores no model, so the accumulator could never start: batch 1 was refused for not being
102
+ // batches 2 to 12, and the work in it was discarded. Driven before this change, three calls of one
103
+ // record each against an owed set of three: every call refused "2 of 3", no model stored, no document
104
+ // written, and the same refusal on call 3 as on call 1.
105
+ //
106
+ // A CALL NAMING A BATCH IS JUDGED ON THAT BATCH. A call naming none is judged on the whole owed set,
107
+ // exactly as before, which is what keeps the existing rungs working unchanged: the re-classify rung
108
+ // sends the COMPLETE set of rows and the recall-reconciliation flush sends a patch of the rows it is
109
+ // ending, and neither names a batch.
110
+ export const DIGEST_BATCH_RECORDS = 100;
111
+
112
+ /**
113
+ * The owed set split into batches, in order. PURE, and deterministic across a resume: `owed` is derived
114
+ * from placements.json, not from anything the run accumulates, so batch 7 holds the same records on the
115
+ * retry as it did on the attempt that was killed.
116
+ */
117
+ export function batchesOf(owed, size = DIGEST_BATCH_RECORDS) {
118
+ const list = [...new Set((Array.isArray(owed) ? owed : []).map(joinKey).filter(Boolean))];
119
+ const n = Math.max(1, Number(size) || DIGEST_BATCH_RECORDS);
120
+ const out = [];
121
+ for (let i = 0; i < list.length; i += n) out.push(list.slice(i, i + n));
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * The records a model accounts for, by the three exits this seam recognises. PURE.
127
+ *
128
+ * ONE READER FOR TWO GATES, and that is the point of exporting it. The call-time refusal and the
129
+ * stage's exit gate have to be counting the same thing; two implementations of "accounted" would drift
130
+ * and the drift would show up as a stage that passed with records ended nowhere.
131
+ */
132
+ export function accountedUris(model, owed = []) {
133
+ const accounted = new Set();
134
+ for (const r of [...(model?.findings_rows ?? []), ...(model?.incumbent_rows ?? []), ...(model?.negative_rows ?? [])])
135
+ accounted.add(joinKey(r?.uri));
136
+ // A disagreement resolution accounts for a record when its subject names that record's uri — the
137
+ // third exit, and the one a reader is least likely to expect, so it is joined rather than assumed.
138
+ for (const d of model?.disagreement_resolutions ?? [])
139
+ for (const k of (Array.isArray(owed) ? owed : [])) if (k && lc(d?.subject).includes(k)) accounted.add(k);
140
+ accounted.delete("");
141
+ return accounted;
142
+ }
143
+
144
+ /** Every record uri a raw call carries, across the three row lists. PURE. */
145
+ export function callUris(call) {
146
+ const out = [];
147
+ for (const r of [...(call?.findings_rows ?? []), ...(call?.incumbent_rows ?? []), ...(call?.negative_rows ?? [])]) {
148
+ const k = joinKey(r?.uri);
149
+ if (k) out.push(k);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Which batch accounted each record, after this call. PURE. Returns `{ batchOf, doubled }`.
156
+ *
157
+ * DOUBLE-COUNTING IS A BATCH COLLISION, NEVER A REPEATED URI, and the distinction is the whole reason
158
+ * this is keyed rather than a membership test. `mergeDigestPatch` replaces a row by uri on purpose —
159
+ * "refreshing an ending is idempotent" — and the recall-reconciliation flush rung depends on it,
160
+ * telling the seat to re-send rows it is changing. So a uri arriving again UNDER ITS OWN BATCH is that
161
+ * legitimate refresh and is kept; the same uri arriving under a DIFFERENT batch is a record ended
162
+ * twice, which is what inflates a count nobody can reconcile, and it is refused naming both batches.
163
+ */
164
+ export function batchLedger(storedBatchOf, call) {
165
+ const batchOf = { ...(storedBatchOf ?? {}) };
166
+ const doubled = [];
167
+ const batch = call?.batch;
168
+ if (!Number.isInteger(batch) || batch < 1) return { batchOf, doubled };
169
+ for (const k of callUris(call)) {
170
+ const was = batchOf[k];
171
+ if (was !== undefined && was !== batch) { doubled.push({ uri: k, was, now: batch }); continue; }
172
+ batchOf[k] = batch;
173
+ }
174
+ return { batchOf, doubled };
175
+ }
176
+
177
+ /**
178
+ * The batch block the digest dispatch carries: how the driver split this run's band, which batches are
179
+ * outstanding, and the one rule that makes a batch call different from a whole-document one. `null` when
180
+ * the run has no owed population, because a brief that enumerates nothing reads as a rule with no work.
181
+ *
182
+ * ON A RESUME IT IS THE RESUME INSTRUCTION, and that is why it is computed rather than fixed: the
183
+ * batches already accounted for are named as done, so the seat re-reads none of them. A retry that
184
+ * starts at batch 1 is how the stage burned 35 minutes twice on the same matter.
185
+ */
186
+ export function digestBatchBrief(gap) {
187
+ if (!gap?.armed || !Array.isArray(gap.owed) || !gap.owed.length) return null;
188
+ const plan = batchesOf(gap.owed);
189
+ const outstanding = new Set(gap.unaccounted ?? []);
190
+ const todo = [];
191
+ for (let i = 0; i < plan.length; i++) if (plan[i].some((k) => outstanding.has(k))) todo.push(i + 1);
192
+ const done = plan.length - todo.length;
193
+ const lines = [
194
+ `## Your records, in ${plan.length} batch${plan.length === 1 ? "" : "es"}`,
195
+ "",
196
+ `This run carried ${gap.owed.length} record${gap.owed.length === 1 ? "" : "s"} into the digest. The driver has split them into `
197
+ + `${plan.length} batch${plan.length === 1 ? "" : "es"} of up to ${DIGEST_BATCH_RECORDS}. Record ONE `
198
+ + "`record_register_digest` call per batch, carrying `batch: <the number>` — not one call for the whole band.",
199
+ "",
200
+ "Every record in the batch you name must end in that same call: a findings row, an incumbent row, a "
201
+ + "Negative-results drop with its ground token, or a Disagreement resolution. The call is refused if "
202
+ + "one of them ends nowhere, and the refusal lists exactly which — send only those; everything you "
203
+ + "have already recorded is kept. A record ends in ONE batch: ending it again under a different "
204
+ + "batch number is refused.",
205
+ "",
206
+ `Your prose sections (opposition, merch_sweep, cross_checks, open_flags) ride any batch call and are `
207
+ + "kept when a later call omits them.",
208
+ "",
209
+ ];
210
+ if (done) lines.push(`${done} of these batches ${done === 1 ? "is" : "are"} already recorded and complete. `
211
+ + `Outstanding: batch ${todo.join(", ")}. Do not re-read or re-send the batches that are done.`, "");
212
+ else lines.push(`Outstanding: every batch, 1 to ${plan.length}.`, "");
213
+ return lines.join("\n");
214
+ }
215
+
216
+ /**
217
+ * What this run's digest still owes, read off the stored model. `{ armed, owed, accounted, unaccounted }`.
218
+ *
219
+ * THE EXIT GATE'S READ. Before batching, a digest that ended nothing failed because no document was
220
+ * ever written — the refusal on the last call was the gate. Once a batch call is accepted, the document
221
+ * EXISTS from batch 1 onwards, so that failure stops firing, and a seat that stopped after batch 6 would
222
+ * ship a document missing half the band with nothing refusing it. That inversion is what this closes,
223
+ * and it is armed by the same era stamp as the call-time refusal so archived runs are judged as they
224
+ * always were.
225
+ */
226
+ export function digestAccountingGap(runDir) {
227
+ const armed = accountingArmed(runDir);
228
+ if (!armed) return { armed: false, owed: [], accounted: [], unaccounted: [] };
229
+ const facts = readDigestFacts(runDir);
230
+ if (!Array.isArray(facts.owed)) return { armed: true, owed: null, accounted: [], unaccounted: null };
231
+ const model = lastAcceptedModel(runDir);
232
+ if (!model) return { armed: true, owed: facts.owed, accounted: [], unaccounted: facts.owed, no_model: true };
233
+ const accounted = accountedUris(model, facts.owed);
234
+ return { armed: true, owed: facts.owed, accounted: [...accounted],
235
+ unaccounted: facts.owed.filter((k) => !accounted.has(k)) };
236
+ }
237
+
91
238
  /**
92
239
  * The document's section headings, EXPORTED because three separate readers key on them and a heading
93
240
  * changed here without changing them is the failure this constant exists to make impossible.
@@ -597,6 +744,10 @@ export function acceptRegisterDigest(params, facts = emptyFacts()) {
597
744
  open_flags: str(params?.open_flags),
598
745
  instructed_checks,
599
746
  disagreement_resolutions,
747
+ // WRITTEN ONLY WHEN THERE IS ONE, so a run that never batched stores the model it always stored and
748
+ // an archived model replays byte-identical. It is the accumulator's own bookkeeping and no renderer
749
+ // reads it: `renderRegisterFindings` takes the keys it names and ignores the rest.
750
+ ...(params?.batch_of && Object.keys(params.batch_of).length ? { batch_of: params.batch_of } : {}),
600
751
  };
601
752
 
602
753
  // ASK WHAT THE ZERO MEANS. A digest that surfaced nothing AND dropped nothing has not judged the
@@ -610,16 +761,39 @@ export function acceptRegisterDigest(params, facts = emptyFacts()) {
610
761
  // Disagreement-resolutions row. Nothing new is invented here — the join is over lists this call
611
762
  // already carries.
612
763
  if (facts.armed) {
613
- const accounted = new Set();
614
- for (const r of [...model.findings_rows, ...model.incumbent_rows, ...model.negative_rows]) accounted.add(joinKey(r.uri));
615
- // A disagreement resolution accounts for a record when its subject names that record's uri — the
616
- // third exit, and the one a reader is least likely to expect, so it is joined rather than assumed.
617
- for (const d of model.disagreement_resolutions)
618
- for (const k of facts.owed) if (k && lc(d.subject).includes(k)) accounted.add(k);
619
- const unaccounted = facts.owed.filter((k) => !accounted.has(k));
764
+ // A RECORD ENDED TWICE IS ANSWERED BEFORE A RECORD ENDED NOWHERE. The ledger is the driver's
765
+ // (batchLedger, from the raw call), so this reads a decision rather than making one.
766
+ const doubled = Array.isArray(params?.batch_doubled) ? params.batch_doubled : [];
767
+ if (doubled.length) {
768
+ const show = doubled.slice(0, 5).map((d) => `${d.uri} (batch ${d.was}, again in batch ${d.now})`).join("; ");
769
+ return { ok: false, reason: `registerdigest_double_counted:${doubled.length} record(s) this call ends were already ended by another batch: ${show}${doubled.length > 5 ? ` (+${doubled.length - 5} more)` : ""}. Each record ends exactly once, in its own batch — re-send the batch that owns it if the ending was wrong, and drop it from this one` };
770
+ }
771
+ const accounted = accountedUris(model, facts.owed);
772
+ // ── THE SCOPE IS THE BATCH WHEN THE CALL NAMES ONE, AND THE WHOLE OWED SET WHEN IT DOES NOT ─────
773
+ //
774
+ // Both arms use the same accounted set and the same three exits; only the population moves. A call
775
+ // naming no batch is judged exactly as it was before batching existed, which is what every rung
776
+ // that re-sends a complete document depends on.
777
+ const batch = Number.isInteger(params?.batch) && params.batch >= 1 ? params.batch : null;
778
+ let scope = facts.owed, where = "this run carried into the digest";
779
+ if (batch !== null) {
780
+ const plan = batchesOf(facts.owed);
781
+ const slice = plan[batch - 1];
782
+ if (!slice) {
783
+ return { ok: false, reason: `registerdigest_batch_unknown:batch ${batch} — this run's band splits into ${plan.length} batch(es) of up to ${DIGEST_BATCH_RECORDS} records, so there is no batch ${batch} to record. The dispatch names the batch count; send the batches it lists` };
784
+ }
785
+ scope = slice;
786
+ where = `batch ${batch} of ${plan.length} carried`;
787
+ }
788
+ const unaccounted = scope.filter((k) => !accounted.has(k));
620
789
  if (unaccounted.length) {
621
- const show = unaccounted.slice(0, 5).join(", ");
622
- return { ok: false, reason: `registerdigest_unaccounted_records:${unaccounted.length} of ${facts.owed.length} record(s) this run carried into the digest end nowhere — neither a findings row, nor a Negative-results drop, nor a Disagreement resolution: ${show}${unaccounted.length > 5 ? ` (+${unaccounted.length - 5} more)` : ""}. Each needs one of the three, and a drop needs its ground token` };
790
+ // THE REFUSAL IS THE WORK LIST. It used to show five of them and leave the seat to infer the rest,
791
+ // which is what made "retry the digest" the only move it could read off the refusal. A batch is at
792
+ // most DIGEST_BATCH_RECORDS records, so its outstanding set is quotable in full and the seat can
793
+ // act on THIS refusal without re-reading the band.
794
+ const cap = batch !== null ? DIGEST_BATCH_RECORDS : 5;
795
+ const show = unaccounted.slice(0, cap).join(", ");
796
+ return { ok: false, reason: `registerdigest_unaccounted_records:${unaccounted.length} of ${scope.length} record(s) ${where} into the digest end nowhere — neither a findings row, nor a Negative-results drop, nor a Disagreement resolution: ${show}${unaccounted.length > cap ? ` (+${unaccounted.length - cap} more)` : ""}. Each needs one of the three, and a drop needs its ground token. Send ONLY these — everything you have already recorded is kept` };
623
797
  }
624
798
  }
625
799
 
@@ -652,7 +826,20 @@ export function recordRegisterDigest(runDir, received, { facts = null, now = ()
652
826
 
653
827
  // A PATCH call merges onto the stored model BEFORE acceptance, so the whole document is validated
654
828
  // as one thing every time — a patch cannot slip a row past a check by arriving alone.
655
- const params = received?.patch === true ? mergeDigestPatch(lastAcceptedModel(runDir), received) : received;
829
+ //
830
+ // A BATCH CALL IS A PATCH, AND IT IS NOT OPTIONAL THAT IT IS. Batches accumulate by definition: if
831
+ // batch 2 replaced the stored model rather than merging onto it, batch 1's rows would leave the
832
+ // document the moment batch 2 was accepted, and the run would reach delivery a batch short with every
833
+ // call reading as accepted. The batch ledger travels the same way — read off the stored model, so a
834
+ // resume that re-enters the stage carries what the killed attempt had already accounted for.
835
+ const batching = Number.isInteger(received?.batch) && received.batch >= 1;
836
+ const accumulates = received?.patch === true || batching;
837
+ const stored = accumulates ? lastAcceptedModel(runDir) : null;
838
+ const merged = accumulates ? mergeDigestPatch(stored, received) : received;
839
+ const ledger = batchLedger(stored?.batch_of, received);
840
+ const params = batching
841
+ ? { ...merged, batch: received.batch, batch_of: ledger.batchOf, batch_doubled: ledger.doubled }
842
+ : merged;
656
843
  const verdict = acceptRegisterDigest(params, facts ?? readDigestFacts(runDir));
657
844
  if (!verdict.ok) {
658
845
  try { writeFileSync(paths.refusals, `${JSON.stringify({ at: now(), reason: verdict.reason })}\n`, { flag: "a" }); }
@@ -682,8 +869,46 @@ export function recordRegisterDigest(runDir, received, { facts = null, now = ()
682
869
  const at = join(String(runDir ?? ""), FINDINGS_FILE);
683
870
  // The model lands BEFORE the document: a later patch merges onto what was accepted, so a write that
684
871
  // fails must not leave a stored model describing a document nobody has.
872
+ //
873
+ // ── AND UNDER BATCHING THE MODEL IS THE ACCUMULATOR, SO LOSING IT IS FATAL HERE ──────────────────
874
+ //
875
+ // Best-effort was right while one call carried the whole document: a lost model cost the next patch
876
+ // its base and said so by refusing. It is wrong once the batches ARE the document. Lose it after
877
+ // batch 3 and batch 4 merges onto nothing, batch 3's records stop being accounted, the union never
878
+ // closes, and the run ends refusing over records the seat accounted for correctly twenty minutes
879
+ // earlier — a loop with a true-looking refusal at the end of it.
880
+ let modelWriteFailed = null;
685
881
  try { writeFileSync(paths.model, JSON.stringify(verdict.model, null, 2) + "\n"); }
686
- catch { /* best-effort; a lost model costs the next patch its base, and it says so by refusing */ }
882
+ catch (e) { modelWriteFailed = String(e?.message ?? e).slice(0, 200); }
883
+ if (modelWriteFailed && Number.isInteger(received?.batch)) {
884
+ const reason = `registerdigest_model_write_failed:this run accounts for its records in batches and the driver could not store what this batch accepted (${modelWriteFailed}), so the next batch would merge onto a base missing these rows and the run would refuse over records you have already accounted for (driver-written: this is a bug, not a model defect, and re-stating the call cannot fix it)`;
885
+ try { writeFileSync(paths.refusals, `${JSON.stringify({ at: now(), reason })}\n`, { flag: "a" }); }
886
+ catch { /* the refusal record is best-effort; the refusal itself is returned regardless */ }
887
+ return { written: null, refused: reason, captured: closeCapture({ ok: false, refused: reason }), capture_failed: captureFailed };
888
+ }
889
+
890
+ // ── THE CLIENT'S DOCUMENT STAYS ALL-OR-NOTHING WHILE BATCHES ARE OUTSTANDING ─────────────────────
891
+ //
892
+ // `register-findings.md` is read by nine parsers, by the gateway through `toolWrittenArtifact` to
893
+ // decide whether this stage produced anything at all, and by a lawyer. Writing it on each accepted
894
+ // batch puts a page on disk carrying the title, every heading and a third of the records — complete
895
+ // to every one of those readers. The exit gate in `validators.registerFindings` refuses such a
896
+ // document, which catches a seat that stops half way; it catches it AFTER the partial page exists.
897
+ //
898
+ // So while any owed record is still outstanding the batch accumulates into the MODEL, which is the
899
+ // driver's own state, and the document is rendered on the call that closes the union. A run that dies
900
+ // at batch 6 then leaves no document at all — exactly what an unfinished digest has always left, so
901
+ // the older "no document" stage failure keeps working unchanged and the exit gate becomes the second
902
+ // lock rather than the only one.
903
+ const gap = digestAccountingGap(runDir);
904
+ if (Array.isArray(gap.unaccounted) && gap.unaccounted.length) {
905
+ return {
906
+ written: null, refused: null, batch_accepted: received?.batch ?? null, remaining: gap.unaccounted.length,
907
+ accounted: gap.accounted.length,
908
+ captured: closeCapture({ ok: true, batch_accepted: received?.batch ?? null, remaining: gap.unaccounted.length }),
909
+ capture_failed: captureFailed,
910
+ };
911
+ }
687
912
  try { writeFileSync(at, verdict.content); }
688
913
  catch (e) {
689
914
  return { written: null, refused: null, write_failed: String(e?.message ?? e).slice(0, 200),
@@ -573,6 +573,176 @@ export function mintSupplementalQid({ prefix, term, used }) {
573
573
  * driver/register-capabilities.mjs). Omitted ⇒ the pre-phase-3 corsearch-shaped
574
574
  * behaviour, byte-identical (no entry gains a key, no jurisdiction is translated).
575
575
  */
576
+ /**
577
+ * The receipt that ARMS the house-element exclusion, and the only thing that may.
578
+ *
579
+ * A SEPARATE FILE FROM THE FRAME'S PROPOSAL, deliberately, and for the reason the digest's accounting
580
+ * stamp is separate from its facts: the proposal is what a model said, the receipt is what the register
581
+ * answered, and a reader who cannot tell those apart cannot tell a judgement from evidence. Absent means
582
+ * the question was never asked, which is the same as unverified and excludes nothing.
583
+ */
584
+ export const HOUSE_ELEMENT_RECEIPT = "house-element.json";
585
+
586
+ /**
587
+ * Verify that the client actually owns the proposed house element, on the register, by owner.
588
+ *
589
+ * IT SITS BESIDE THE TRANSFORM IT GATES, for the reason `accountingArmed` sits beside the refusal it
590
+ * arms: the gate and the thing gated go stale together or not at all, and a reader meeting one finds the
591
+ * other. Nothing else may arm this exclusion.
592
+ *
593
+ * FAIL-CLOSED ON EVERY PATH, and that is the whole design. The frame PROPOSED this element from its
594
+ * reading of the matter; acting on the proposal alone would drop an element from a client's search on a
595
+ * model's assertion, and an element nobody swept is a clean report over unswept ground — the one defect
596
+ * that reaches a client as a confident wrong answer rather than as a visible failure. So every way of
597
+ * not knowing lands in the same place: not verified, no exclusion, the element searched in full, and a
598
+ * reason on the receipt saying which way it was. An outage, an unknown client name, a lookup that threw,
599
+ * a dead registration, a registration in some other class — none of them excludes anything.
600
+ *
601
+ * `lookup` is injected, exactly as `runOwnerChecks` takes its `exec` and `countRegisterHits` its
602
+ * `counter`: the fixture path that makes this product testable at no cost covers this call too, and a
603
+ * test never reaches a provider. NEVER THROWS and never rejects.
604
+ *
605
+ * @param owners the client's own names — the profile's trading names and the matter's customer. Empty
606
+ * is a real answer and it means NOT VERIFIED: with no name to match an owner against,
607
+ * "the client owns it" cannot be established by anything this function can see.
608
+ * @returns the receipt, always. `verified: true` is the only value that may arm an exclusion.
609
+ */
610
+ export async function verifyHouseElementOwnership({
611
+ element, classes = [], owners = [], lookup, now = () => new Date().toISOString(),
612
+ }) {
613
+ const el = String(element ?? "").trim();
614
+ const wanted = (Array.isArray(classes) ? classes : []).map(String).map((c) => c.trim()).filter(Boolean);
615
+ const names = (Array.isArray(owners) ? owners : []).map((o) => String(o ?? "").trim()).filter(Boolean);
616
+ const receipt = (verified, reason, records = []) =>
617
+ ({ verified, element: el, owners_checked: names, classes: wanted, records, reason, ts: now() });
618
+
619
+ if (!el) return receipt(false, "house_element_absent: nothing was proposed");
620
+ if (!names.length)
621
+ return receipt(false, "client_owner_unknown: this run holds no trading name for the client, so an owner on the register cannot be matched to it");
622
+ if (!wanted.length)
623
+ return receipt(false, "instructed_classes_absent: ownership is only decisive in the classes the matter is instructed in");
624
+ if (typeof lookup !== "function")
625
+ return receipt(false, "lookup_unavailable: no register lookup was wired, so ownership was never asked");
626
+
627
+ let rows = [];
628
+ try {
629
+ const r = await lookup({ element: el, owners: names, classes: wanted });
630
+ if (!r?.ok) return receipt(false, `lookup_did_not_answer: ${String(r?.reason ?? "no reason given").slice(0, 200)}`);
631
+ rows = Array.isArray(r.records) ? r.records : [];
632
+ } catch (e) {
633
+ return receipt(false, `lookup_threw: ${String(e?.message ?? e).slice(0, 200)}`);
634
+ }
635
+
636
+ // A MATCH IS ALL THREE AT ONCE — the client's own name, alive, in an instructed class. Checking them
637
+ // separately would let a dead registration in class 9 and a live one in class 25 held by someone else
638
+ // combine into an ownership nobody has.
639
+ const norm = (v) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
640
+ const ours = names.map(norm).filter(Boolean);
641
+ const live = (st) => { const t = norm(st); return Boolean(t) && !/(dead|expired|cancell?ed|withdrawn|refused|lapsed|abandoned)/.test(t); };
642
+ const matched = rows.filter((r) => {
643
+ const owner = norm(r?.owner_name);
644
+ if (!owner || !ours.some((o) => owner === o || owner.includes(o) || o.includes(owner))) return false;
645
+ if (!live(r?.status)) return false;
646
+ const rc = (Array.isArray(r?.classes) ? r.classes : []).map(String).map((c) => c.trim());
647
+ return rc.some((c) => wanted.includes(c));
648
+ });
649
+
650
+ if (!matched.length)
651
+ return receipt(false, `no_live_owned_registration: ${rows.length} record(s) came back and none is a live registration held by this client in an instructed class`, []);
652
+ // The records are the EVIDENCE the report states the exclusion on, so they ride the receipt.
653
+ return receipt(true, `verified: ${matched.length} live registration(s) held by this client in an instructed class`,
654
+ matched.map((r) => ({ record_id: String(r?.record_id ?? r?.uri ?? ""), owner_name: String(r?.owner_name ?? ""),
655
+ status: String(r?.status ?? ""), classes: (Array.isArray(r?.classes) ? r.classes : []).map(String) })));
656
+ }
657
+
658
+ /**
659
+ * The manifest with the client's own house element taken out of the conflict analysis. PURE.
660
+ *
661
+ * THE DEFECT (production run, 2026-09-16). The mark was the client's own famous house mark followed by
662
+ * a tagline. `dominant_element` was the house element, so the machine-built one-letter mutation forms
663
+ * were built from it — and because those mutations are ordinary short words they pull every mark
664
+ * containing them across four registers. Two such queries put 1,154 records into a 2,146-record band,
665
+ * 54% of it, reachable from nothing else. The reviewing lawyer's method for the same matter was three
666
+ * searches: the whole phrase, the shorter phrase, the last word alone.
667
+ *
668
+ * WHAT IS NOT DONE HERE, AND IT IS THE POINT. This does not decide that the client owns the element —
669
+ * it is called only where the driver has already verified ownership on the register by owner and written
670
+ * its receipt. A `house` argument that arrived from a model's assertion would be an element nobody
671
+ * searched because a model said it was safe.
672
+ *
673
+ * THE WHOLE PHRASE SURVIVES, and that is the lawyer's method rather than a softening of it. `mark` is
674
+ * untouched, so the exact search for the full phrase still runs. What stops is treating the house
675
+ * element as an AXIS — its mutations, its forms, its own dominant-element sweep — because that is where
676
+ * the band came from, not from the one exact query.
677
+ *
678
+ * Returns `{ manifest, confirmation, refused }`. `refused` non-null means the exclusion was NOT applied
679
+ * and the returned manifest is the input: the caller plans as it would have with no receipt at all.
680
+ */
681
+ export function excludeHouseElement(manifest, house) {
682
+ const element = String(house?.element ?? "").trim();
683
+ const remainder = String(house?.remainder ?? "").trim();
684
+ const keep = (why) => ({ manifest, confirmation: null, refused: why });
685
+ if (!element || !remainder) return keep("house_element_incomplete");
686
+
687
+ const words = remainder.split(/\s+/).filter(Boolean);
688
+ // ── THE FLOOR IS THE JOIN TO THIS MANIFEST'S OWN MARK ───────────────────────────────────────────
689
+ //
690
+ // The direction that reaches a client is naming too MUCH as the house element: ownership verifies
691
+ // perfectly, nothing distinctive is left, and no count downstream catches it because "queries on the
692
+ // house element: 0" is satisfied by a plan holding no queries at all. The frame refuses the shapes it
693
+ // can see — an empty remainder, a remainder equal to the element, an element containing it.
694
+ //
695
+ // WHAT THE FRAME CANNOT SEE IS THIS MARK. The proposal is made against the matter, the exclusion is
696
+ // applied against a compiled manifest, and nothing until here has joined the two. A remainder that is
697
+ // not part of the mark being planned means the receipt and the manifest are describing different
698
+ // things — a re-frame, a second ratified form, a resumed run whose manifest moved — and cutting the
699
+ // dominant element down to a word that is not in the mark would leave the plan searching something
700
+ // the client never applied for. So the exclusion applies only where both halves are in the mark.
701
+ const lcMark = String(manifest?.mark ?? "").toLowerCase();
702
+ if (!lcMark) return keep("house_element_no_mark_to_join");
703
+ if (!lcMark.includes(remainder.toLowerCase())) return keep("house_element_remainder_not_in_mark");
704
+ if (!lcMark.includes(element.toLowerCase())) return keep("house_element_not_in_mark");
705
+
706
+ const lcEl = element.toLowerCase();
707
+ const lcWords = words.map((w) => w.toLowerCase());
708
+ // A word of the remainder, by word boundary rather than by containment: `includes` would count the
709
+ // remainder word "on" inside an unrelated mutation and keep a query this exists to drop.
710
+ const namesRemainder = (v) => {
711
+ const t = String(v ?? "").toLowerCase();
712
+ return lcWords.some((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\p{L}\\p{N}]|$)`, "u").test(t));
713
+ };
714
+
715
+ // THE DOMINANT WORD OF THE REMAINDER: longest, ties broken by the LAST of them. The lawyer's own
716
+ // phrasing was "the last word alone", and on the matter that produced this the two agree; longest is
717
+ // the better rule where they do not, because a trailing article is last and carries nothing.
718
+ let dominant = words[0];
719
+ for (const w of words) if (w.length >= dominant.length) dominant = w;
720
+
721
+ const variants = (Array.isArray(manifest?.variants) ? manifest.variants : []).filter((v) => namesRemainder(v?.value));
722
+ const next = {
723
+ ...manifest,
724
+ // UNTOUCHED: the exact search for the full phrase is the first of the lawyer's three.
725
+ mark: manifest?.mark,
726
+ dominant_element: dominant,
727
+ elements: (Array.isArray(manifest?.elements) ? manifest.elements : []).filter((e) => String(e?.value ?? "").trim().toLowerCase() !== lcEl),
728
+ // The shorter phrase is the second of the three, and it has to be PUSHED: with `dominant_element`
729
+ // now a single word and `mark` the full phrase, nothing else in the compile would search the
730
+ // remainder as a phrase on its own.
731
+ // `exact-phrase` because that is what it is, and because the category vocabulary is CLOSED —
732
+ // `parseVariantManifestModel` refuses anything outside it, so a descriptive category invented here
733
+ // would fail the manifest on its way into the compile.
734
+ variants: [{ category: "exact-phrase", value: remainder,
735
+ rationale: "the distinctive remainder once the client's own registered element is set aside" },
736
+ ...variants],
737
+ };
738
+ // REQUIREMENT 3: the element is still checked ONCE, as a confirmation of the client's own live
739
+ // registrations rather than as a conflict search — so the exclusion is evidenced on the report by a
740
+ // query that ran, not by a sentence saying one would have.
741
+ const confirmation = { axis: "incumbent-class", predicate: "owner", term: element,
742
+ expected_kind: "enumerate", provenance: "mark", house_element_confirmation: true };
743
+ return { manifest: next, confirmation, refused: null };
744
+ }
745
+
576
746
  export function compileRegisterPlan({ manifest, job, form = null, skillVersion = "", capabilities = null, unavailableOffices = [] }) {
577
747
  const classes = (job?.classes ?? []).map(String).filter(Boolean);
578
748
  if (!classes.length) throw new Error("register_plan_classes_missing: a plan is always class-scoped — compile with the matter's in-scope Nice classes");
@@ -81,8 +81,8 @@ export const RESULT_NOUN_FIELDS = Object.freeze([
81
81
  + "is the fact the disclosure downstream depends on" },
82
82
  { file: "driver/pipeline.mjs", noun: "settled", sites: 5, atWriteSite: 4, verdict: "result",
83
83
  why: "counts off the union and the doubt ledger" },
84
- { file: "driver/pipeline.mjs", noun: "verified", sites: 3, atWriteSite: 1, verdict: "result",
85
- why: "`rows.filter(r => r.verified).length`, where each row's flag is `srRecords.has(senior.uri)` — a lookup, not a call" },
84
+ { file: "driver/pipeline.mjs", noun: "verified", sites: 4, atWriteSite: 2, verdict: "result",
85
+ why: "two sites, both measured states rather than calls returning. (1) `rows.filter(r => r.verified).length`, where each row's flag is `srRecords.has(senior.uri)` — a lookup, not a call. (2) the house-element ownership row's `verified: receipt.verified`, which the verifier sets only where a returned register record matched the client's own name AND was live AND carried an instructed class; the lookup CALL returning is a separate field on the same receipt (`reason`), and an unanswered lookup writes this false" },
86
86
  { file: "driver/repairs.mjs", noun: "closed", sites: 2, atWriteSite: 1, verdict: "result",
87
87
  why: "`effect.closed`, measured against `effect.asked`" },
88
88
  { file: "driver/repairs.mjs", noun: "outcome", sites: 1, atWriteSite: 1, verdict: "result",