clearotron 0.4.1 → 0.5.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +1 -0
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/bin/start.mjs +33 -8
  4. package/build-info.json +2 -2
  5. package/driver/CHANGELOG.md +23 -0
  6. package/driver/ask-ledger.mjs +86 -15
  7. package/driver/band-shape.mjs +61 -11
  8. package/driver/band-size.mjs +10 -0
  9. package/driver/close-verify.mjs +29 -2
  10. package/driver/common-law-coverage-status.mjs +1 -1
  11. package/driver/connotation-search.mjs +7 -9
  12. package/driver/contract-e3-backlog.mjs +34 -34
  13. package/driver/contract-e3-baseline.json +5 -5
  14. package/driver/contract-vocabulary.mjs +12 -12
  15. package/driver/corrections-delivered.mjs +113 -0
  16. package/driver/corrections-feedforward.mjs +16 -6
  17. package/driver/corrective-arm.mjs +11 -8
  18. package/driver/coverage-form.mjs +3 -0
  19. package/driver/coverage-union.mjs +11 -2
  20. package/driver/digest-queue.mjs +1 -1
  21. package/driver/disposition-call.mjs +1 -1
  22. package/driver/document-coverage.mjs +3 -2
  23. package/driver/effective-scope.mjs +2 -2
  24. package/driver/engine/mcp/band-server.mjs +77 -13
  25. package/driver/engine/mcp/dispositions-server.mjs +2 -2
  26. package/driver/engine/mcp/recording-server.mjs +10 -6
  27. package/driver/findings-model.mjs +16 -4
  28. package/driver/floor-duty.mjs +9 -0
  29. package/driver/gate-metrics.mjs +7 -1
  30. package/driver/gateway.mjs +28 -14
  31. package/driver/goods-evidence.mjs +295 -0
  32. package/driver/knockout-assess-record.mjs +1 -1
  33. package/driver/narrative-refutation-record.mjs +40 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +1945 -314
  36. package/driver/placement-decisions.mjs +243 -0
  37. package/driver/placement-form.mjs +42 -8
  38. package/driver/placement-union.mjs +14 -6
  39. package/driver/provider-usage.mjs +10 -7
  40. package/driver/publish/audit-from-spine.mjs +5 -2
  41. package/driver/publish/index.mjs +19 -4
  42. package/driver/publish/xlsx.mjs +6 -6
  43. package/driver/recall-reconciliation.mjs +15 -5
  44. package/driver/record-carry.mjs +70 -6
  45. package/driver/record-discard.mjs +7 -1
  46. package/driver/record-fold.mjs +14 -0
  47. package/driver/record-goods.mjs +101 -5
  48. package/driver/register-plan.mjs +89 -0
  49. package/driver/registry-fidelity.mjs +34 -3
  50. package/driver/remedy-accounting.mjs +52 -14
  51. package/driver/repair-composers.mjs +265 -105
  52. package/driver/repair-scope.mjs +327 -0
  53. package/driver/repair-targets.mjs +92 -0
  54. package/driver/result-noun-fields.mjs +3 -3
  55. package/driver/screen-gate.mjs +96 -0
  56. package/driver/skills/clearance-common-law/SKILL.md +8 -0
  57. package/driver/skills/clearance-register/SKILL.md +5 -5
  58. package/driver/skills/clearance-register/digest.md +14 -14
  59. package/driver/skills/clearance-register/unit.md +3 -3
  60. package/driver/skills/clearance-search/delivery-contract.md +2 -58
  61. package/driver/skills/clearance-search/report-prose.md +12 -3
  62. package/driver/skills/clearance-search/risk-framework-triage.md +1 -1
  63. package/driver/skills/clearance-search/synthesis-rules.md +10 -13
  64. package/driver/skills/clearance-variants/SKILL.md +4 -4
  65. package/driver/skills/knockout-assess/SKILL.md +9 -14
  66. package/driver/skills/knockout-frame/SKILL.md +5 -5
  67. package/driver/skills/narrative-refutation/SKILL.md +1 -1
  68. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  69. package/driver/skills/whatif-memo/SKILL.md +1 -1
  70. package/driver/stage-context.mjs +2 -1
  71. package/driver/stages-knockout.mjs +1 -1
  72. package/driver/stages.mjs +95 -60
  73. package/driver/suite-census.json +367 -19
  74. package/driver/synthesis-record.mjs +310 -8
  75. package/driver/terminal-clamp.mjs +53 -2
  76. package/driver/verify-knockout.mjs +22 -4
  77. package/driver/verify.mjs +23 -3
  78. package/mcp-server/CHANGELOG.md +4 -0
  79. package/mcp-server/lib/driver.mjs +5 -0
  80. package/mcp-server/lib/events.mjs +25 -4
  81. package/mcp-server/lib/findings.mjs +4 -2
  82. package/mcp-server/package.json +1 -1
  83. package/package.json +1 -1
  84. package/portal-ui/package.json +1 -1
  85. package/providers/jx/src/core.js +3 -4
  86. package/providers/jx/src/judge.js +2 -2
  87. package/providers/jx/src/nativeread.js +1 -2
  88. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  89. package/providers/oauth-mcp-bridge/package.json +1 -1
  90. package/scripts/e2e-first-time.mjs +29 -12
@@ -702,6 +702,61 @@ export function mintRecordCarryDoubts(artifact, { max = 25 } = {}) {
702
702
  return { doubts, minted: doubts.length, omitted: Math.max(0, all.length - doubts.length) };
703
703
  }
704
704
 
705
+ /**
706
+ * THE RECORD-CARRY DOUBTS AGAIN, FROM A TRACE WRITTEN AGAIN after a repair. A doubt of this family names one record
707
+ * under a POSITIONAL id, and the closure step's verdicts are applied by id, so a closure recorded against an id must
708
+ * never move onto another record:
709
+ * - a record still unreasoned keeps its doubt exactly as it stands: id, status and ending;
710
+ * - a record newly unreasoned gets a new doubt, under an id past every one this family gave, stitched by `stitch` as
711
+ * a fresh doubt is (in code; nothing is dispatched);
712
+ * - a record the new trace POSITIVELY reasons, a row for it that is not unreasoned, loses its doubt;
713
+ * - a record the new trace does not list at all keeps its doubt as it stands: an absence is not a ground.
714
+ * A trace that is missing or could not be computed (no readable band, among other causes) says nothing about any
715
+ * record, so it removes nothing and adds nothing: every doubt stands as the pass raised it (`unavailable: true`).
716
+ * Every other family's doubts stay exactly where they stood. The cap (`max`) limits only the new doubts: a record that
717
+ * keeps its doubt keeps it wherever the new trace lists it, or where it does not. Returns { doubts, fresh, dropped,
718
+ * minted, omitted, unavailable }: the ids of the new and the dropped doubts, the family's size, the records without a
719
+ * ground the cap left out, and whether the trace could be read at all. PURE.
720
+ */
721
+ export function recordCarryDoubtsAgain(doubts, trace, { stitch = (ds) => ds, max = 25 } = {}) {
722
+ const FAMILY = /^doubt:record-carry:unreasoned:(\d+)$/;
723
+ const all = Array.isArray(doubts) ? doubts : [];
724
+ const inFamily = (d) => FAMILY.test(String(d?.id ?? ""));
725
+ const uriOf = (d) => String(d?.subject?.uris?.[0] ?? "").toLowerCase();
726
+ const old = all.filter(inFamily);
727
+ if (trace?.computable !== true) return { doubts: all, fresh: [], dropped: [], minted: old.length, omitted: 0, unavailable: true };
728
+ const oldByUri = new Map(old.filter((d) => uriOf(d)).map((d) => [uriOf(d), d]));
729
+ // Minted whole, then capped: a record that keeps its doubt keeps it wherever the new trace lists it, and the cap
730
+ // limits only the new doubts, filled in the trace's order up to what the kept ones leave.
731
+ const mint = mintRecordCarryDoubts(trace, { max: Infinity });
732
+ const unreasonedUris = new Set(mint.doubts.map(uriOf));
733
+ const listed = new Set((Array.isArray(trace?.rows) ? trace.rows : []).map((r) => String(r?.uri ?? "").toLowerCase()).filter(Boolean));
734
+ const reasoned = (uri) => listed.has(uri) && !unreasonedUris.has(uri);
735
+ const carried = old.filter((d) => !unreasonedUris.has(uriOf(d)) && !reasoned(uriOf(d))); // not listed: kept as it stands
736
+ const keptCount = mint.doubts.filter((d) => oldByUri.has(uriOf(d))).length + carried.length;
737
+ let room = Math.max(0, max - keptCount);
738
+ let next = old.reduce((n, d) => Math.max(n, Number(FAMILY.exec(d.id)[1])), 0);
739
+ const family = [], fresh = [];
740
+ for (const d of mint.doubts) {
741
+ const prior = oldByUri.get(uriOf(d));
742
+ if (prior) family.push(prior);
743
+ else if (room > 0) { room--; const f = { ...d, id: `doubt:record-carry:unreasoned:${++next}` }; fresh.push(f); family.push(f); }
744
+ }
745
+ family.push(...carried);
746
+ const stitched = fresh.length ? new Map(stitch(fresh).map((d) => [d.id, d])) : new Map();
747
+ const members = family.map((d) => stitched.get(d.id) ?? d);
748
+ const keptIds = new Set(members.map((d) => d.id));
749
+ const out = [];
750
+ let placed = false;
751
+ for (const d of all) {
752
+ if (!inFamily(d)) { out.push(d); continue; }
753
+ if (!placed) { out.push(...members); placed = true; }
754
+ }
755
+ if (!placed) out.push(...members);
756
+ return { doubts: out, fresh: fresh.map((d) => d.id), dropped: old.filter((d) => !keptIds.has(d.id)).map((d) => d.id),
757
+ minted: members.length, omitted: Math.max(0, mint.doubts.length - (members.length - carried.length)), unavailable: false };
758
+ }
759
+
705
760
  /**
706
761
  * The one-line reader answer to "where did this record stop, and why". Takes the artifact and a mark
707
762
  * text or uri; returns the matching rows. The acceptance question for — "does it say why 澜珀 did
@@ -915,12 +970,7 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
915
970
  // So the carry rows are the population — they are where a stated drop is RECORDED — and the
916
971
  // reconciliation is demoted to optional corroboration. Measured on that delivery, the correct
917
972
  // population is 70 rows over 34 distinct marks, all `digest:reasoned-negative`, and it contains both.
918
- const arrived = new Set(["finding", "findings-surface"]);
919
- const population = carryRows.filter((r) => r?.uri
920
- && r.reason_source === "step-stated" // the sibling owns step-silent; step-structural is mechanical
921
- && !arrived.has(r.reach)); // arrived, or arrived somewhere visible, is not a divergence
922
-
923
- // Kept for corroboration only. `ended` no longer gates anything; where the reconciliation DOES name a
973
+ // Kept for corroboration. `ended` gates nothing but one exclusion below; where the reconciliation DOES name a
924
974
  // position it agrees with, that is recorded on the row so a reader can weigh it.
925
975
  const ended = [];
926
976
  for (const bucket of ["top_slice", "residual"]) {
@@ -930,6 +980,20 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
930
980
  }
931
981
  }
932
982
  const endedUris = new Set(ended.map((e) => e.uri));
983
+ const arrived = new Set(["finding", "findings-surface"]);
984
+ // PLACEMENT'S OWN SET-ASIDE IS NOT A LOST FINDING. A record placement set aside, by name or by its group's row,
985
+ // leaves the placement seam with the step's ground before the digest ever sees it: the ruled method's decision,
986
+ // recorded where it was made. Read as a stated drop it was listed as a divergence pointing at another artifact,
987
+ // and on a crowded band those rows buried the few this check is for. It stays in the population where the digest
988
+ // ended its position as a finding, because then the record did vanish: in the reconciliation, or in the finding
989
+ // rows the digest's own typed calls recorded, which the reconciliation's silence cannot erase.
990
+ const digestFound = new Set((Array.isArray(digestFindingUris) ? digestFindingUris : []).map(lc));
991
+ const placementSetAside = (r) => r.stopped_at === "placement" && r.reason === "placement:set-aside"
992
+ && !endedUris.has(lc(r.uri)) && !digestFound.has(lc(r.uri));
993
+ const population = carryRows.filter((r) => r?.uri
994
+ && r.reason_source === "step-stated" // the sibling owns step-silent; step-structural is mechanical
995
+ && !arrived.has(r.reach) // arrived, or arrived somewhere visible, is not a divergence
996
+ && !placementSetAside(r));
933
997
  const byUri = new Map();
934
998
  for (const r of carryRows) if (r?.uri) byUri.set(lc(r.uri), r);
935
999
 
@@ -92,8 +92,13 @@ const clip = (s, n = 300) => {
92
92
  * not name cannot be distinguished between considered-and-not-selected and never reached at all. Those
93
93
  * rows carry `verdict: "discarded"` with `reason_source: "step-structural"` and a reason naming the
94
94
  * incompletion, so the fact survives; they are never silently folded into ordinary drops.
95
+ *
96
+ * A SKIPPED PASS DECIDED NOTHING. A resumed run skips a stage whose answer is on disk, and that answer is an
97
+ * earlier pass's. Where `prior` (the folded ledger) already holds that earlier pass's row for a record at this
98
+ * seam, the skipped pass writes none: under the winner rule an incompletion written over it erased every ground
99
+ * the step had given. A record no earlier pass spoke about is still recorded as not reached.
95
100
  */
96
- export function seamRows({ seam, stage, trigger = null, pass = 1, completed = true,
101
+ export function seamRows({ seam, stage, trigger = null, pass = 1, completed = true, skipped = false, prior = null,
97
102
  saw = [], carried = [], reasonFor = null, evidence = "" } = {}) {
98
103
  if (!DISCARD_SEAMS.includes(seam)) return [];
99
104
  const carriedSet = new Set();
@@ -112,6 +117,7 @@ export function seamRows({ seam, stage, trigger = null, pass = 1, completed = tr
112
117
  continue;
113
118
  }
114
119
  if (!completed) {
120
+ if (skipped && prior?.byUri?.get?.(uri)?.[seam]) continue;
115
121
  rows.push({
116
122
  seam, stage, trigger, pass, uri, verdict: "discarded",
117
123
  reason: `${seam}:stage-incomplete`, reason_source: "step-structural",
@@ -153,6 +153,20 @@ export function modelRecordPath(runDir, findingsPath) {
153
153
  return readFold(runDir)?.map && existsSync(at) ? at : findingsPath;
154
154
  }
155
155
 
156
+ /**
157
+ * The model's ordinal behind a finding of findings.json as it stands, given the fold state `readFold` returns.
158
+ * With no fold in force the file is the model's record, so the ordinal is its own. Under a fold a folded finding
159
+ * IS the object of the member the fold kept, so that member is the answer. Null when a fold is in force and its
160
+ * map does not name the finding: a caller then leaves it alone rather than guessing. PURE.
161
+ */
162
+ export function keptModelOrdinal(state, ordinal) {
163
+ if (!state?.map) return ordinal;
164
+ const members = state.map.foldedToModel?.[String(ordinal)];
165
+ if (!Array.isArray(members) || !members.length) return null;
166
+ if (members.length === 1) return members[0];
167
+ return (state.merges ?? []).find((m) => members.includes(m?.kept))?.kept ?? null;
168
+ }
169
+
156
170
  /** The fold as it stands, to put back if a pass that changed it is rolled back. Never throws. */
157
171
  export function snapshotFold(runDir) {
158
172
  const state = readFold(runDir);
@@ -77,12 +77,25 @@ export function recordGoods(body, { english = true } = {}) {
77
77
  }
78
78
  }
79
79
  if (out.length === 0 && Array.isArray(b?._raw?.classifications)) {
80
- for (const c of b._raw.classifications) push([c?.nice_class], c?.goods_services_text, null, isEnglish(c?.goods_services_language) || (!tagged(c?.goods_services_language) && english));
80
+ for (const c of b._raw.classifications) {
81
+ const at = out.length;
82
+ push([c?.nice_class], c?.goods_services_text, null, isEnglish(c?.goods_services_language) || (!tagged(c?.goods_services_language) && english));
83
+ // The register's own flag, on the full record it serves (Signa, Retrieve Trademark, the classification
84
+ // object's `goods_services_text_truncated`): true is a cut, false is whole, and no flag establishes nothing.
85
+ if (out.length > at) {
86
+ if (c?.goods_services_text_truncated === true) out[at].cut = true;
87
+ else if (c?.goods_services_text_truncated !== false) out[at].unknown = true;
88
+ }
89
+ }
81
90
  }
82
91
  if (out.length === 0 && b.goodsAndServices != null) {
83
92
  const gs = b.goodsAndServices;
93
+ // One string is the register's own statement as this install's US index holds it, whole (uspto-local). A list
94
+ // is a register whose full-record operation this tree documents nowhere (EUIPO and the free tier): its goods
95
+ // are read, and their completeness is not established.
84
96
  if (typeof gs === "string") push([], gs, null, english);
85
97
  else if (Array.isArray(gs)) {
98
+ const from = out.length;
86
99
  for (const g of gs) {
87
100
  if (typeof g === "string") { push([], g, null, english); continue; }
88
101
  const d = g?.description;
@@ -91,11 +104,35 @@ export function recordGoods(body, { english = true } = {}) {
91
104
  push([g?.classNumber ?? g?.class ?? g?.niceClass ?? g?.nice_class], textOf(chosen ?? g?.text ?? g?.goods), null,
92
105
  Boolean(en) || (Array.isArray(d) ? !tagged(d[0]?.language) && english : english));
93
106
  }
107
+ for (let i = from; i < out.length; i++) out[i].unknown = true;
94
108
  }
95
109
  }
96
110
  return out;
97
111
  }
98
112
 
113
+ /**
114
+ * WHAT A HELD RECORD'S GOODS REST ON: the operation its body came from, and whether that body supplies the goods
115
+ * field at all. PURE. `operation` is read off the body's shape, as `recordGoods` reads it:
116
+ * · `record-text` — Clarivate's record text, the full record (its statements are whole: the accepted provenance);
117
+ * · `retrieve` — Signa's Retrieve Trademark, the full record, each statement with the register's own flag;
118
+ * · `bulk-register` — the register's own statement as this install's US index holds it, whole;
119
+ * · `undocumented` — any other shape: its completeness is not established.
120
+ * `field` is `listed` (the field is there with goods), `empty` (there, listing none) or `none` (not there at
121
+ * all): a body whose goods field is missing, or that will not parse, is not one that lists no goods.
122
+ */
123
+ export function recordGoodsProvenance(body) {
124
+ const b = Array.isArray(body) ? body[0] : body;
125
+ if (!b || typeof b !== "object") return { operation: "undocumented", field: "none" };
126
+ const said = (v) => String(textOf(v) ?? "").trim() !== "";
127
+ if (Array.isArray(b.goodsServices))
128
+ return { operation: "record-text", field: b.goodsServices.some((g) => said(g?.description) || said(g?.translation)) ? "listed" : "empty" };
129
+ if (Array.isArray(b?._raw?.classifications))
130
+ return { operation: "retrieve", field: b._raw.classifications.some((c) => said(c?.goods_services_text)) ? "listed" : "empty" };
131
+ if (typeof b.goodsAndServices === "string") return { operation: "bulk-register", field: b.goodsAndServices.trim() ? "listed" : "empty" };
132
+ if (b.goodsAndServices != null) return { operation: "undocumented", field: recordGoods(b).length ? "listed" : "empty" };
133
+ return { operation: "undocumented", field: "none" };
134
+ }
135
+
99
136
  // THE RAW GOODS LIST SAYS NOTHING THE NORMALISED ONE DOES NOT. A Clarivate record holds its goods twice: as the
100
137
  // register wrote them (`_raw.goodsServices.intClassDescriptions`) and as the provider maps them, item for item,
101
138
  // into `goodsServices` beside it (providers/clarivate/src/core.js `normalizeGoodsServices`). On a crowded test
@@ -163,6 +200,59 @@ export function bandGoodsIndex(runDir) {
163
200
  /** The goods of one band row from an index, or null when the run holds none for it. PURE. */
164
201
  export const goodsFor = (index, record) => index?.get(normalizeRecordUri(record?.record_id)) ?? null;
165
202
 
203
+ /**
204
+ * The goods a band row holds itself, where the register's search answer carries them: Signa sends each record's
205
+ * class statements on the search row (`raw.classifications`) and flags a statement it cut. Each statement is read as
206
+ * a held body's is (`recordGoods`) and marked `cut: true` where the register cut it, so a statement that stops early
207
+ * is never read as the whole of its class. null when the row holds none. PURE.
208
+ *
209
+ * WHOLE ONLY WHERE THE ANSWER SAYS SO. The search answer our request asks for is the register's default, which it
210
+ * documents as "the first 280 characters" of each statement (docs.signa.so, Search Trademarks, "Response options",
211
+ * `include`: "`full_goods_services` returns the full goods and services text instead of the first 280
212
+ * characters"); the request never asks for the full text. So a statement shorter than 280 characters is whole, a
213
+ * flagged one is cut, and an unflagged one of 280 characters or more is marked `unknown: true`: its completeness is
214
+ * not established, and missing words in it establish nothing.
215
+ */
216
+ export const DEFAULT_ANSWER_CUT = 280;
217
+ export function rowGoods(row) {
218
+ const classes = Array.isArray(row?.raw?.classifications) ? row.raw.classifications : null;
219
+ if (!classes) return null;
220
+ const english = untaggedIsEnglish(normalizeRecordUri(row?.record_id));
221
+ const out = [];
222
+ for (const c of classes) {
223
+ const [g] = recordGoods({ _raw: { classifications: [c] } }, { english });
224
+ if (!g) continue;
225
+ const { cut: _cut, unknown: _unknown, ...whole } = g; // the row's own rule below, not the full record's
226
+ if (c?.goods_services_text_truncated === true) out.push({ ...whole, cut: true });
227
+ else if ([...String(c?.goods_services_text ?? "")].length >= DEFAULT_ANSWER_CUT) out.push({ ...whole, unknown: true });
228
+ else out.push(whole);
229
+ }
230
+ return out.length ? out : null;
231
+ }
232
+
233
+ /**
234
+ * Was a statement of these goods cut short by the register? A record whose statements match no word, while one of
235
+ * them stops where the register cut it, is unknown to the goods test, not a mismatch: the words cut off may be the
236
+ * client's. Only goods read off a band row carry the mark (`rowGoods`); a held body's statements are whole. PURE.
237
+ */
238
+ export const statementCut = (goods) => (goods ?? []).some((g) => g?.cut === true);
239
+
240
+ /** Is a statement of these goods one whose completeness is not established (`rowGoods`, `recordGoods`)? PURE. */
241
+ export const completenessUnknown = (goods) => (goods ?? []).some((g) => g?.unknown === true);
242
+ /** Could words cut off, or not shown, from these goods be the client's? Then a statement matching none is unknown. PURE. */
243
+ const notWhole = (goods) => statementCut(goods) || completenessUnknown(goods);
244
+
245
+ /** How many records rest on each kind of goods evidence: a held body, a row's whole statements, a row's statements cut short or not established whole, or none. PURE. */
246
+ export function goodsEvidence(records, goods) {
247
+ const out = { held: 0, complete: 0, cut: 0, unknown: 0, none: 0 };
248
+ for (const r of Array.isArray(records) ? records : []) {
249
+ if (goods?.held?.(r)) { out.held++; continue; }
250
+ const g = goods ? goods.of(r) : null;
251
+ if (!g) out.none++; else if (statementCut(g)) out.cut++; else if (completenessUnknown(g)) out.unknown++; else out.complete++;
252
+ }
253
+ return out;
254
+ }
255
+
166
256
  const fold = (s) => foldDiacritics(String(s ?? "")).toLowerCase();
167
257
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
168
258
 
@@ -206,7 +296,7 @@ export function goodsWordCounter(words, { stems = false } = {}) {
206
296
  if (!res.length || !readable.length) return null;
207
297
  const text = fold(readable.map((g) => (g.alt ? `${g.text}\n${g.alt}` : g.text)).join("\n"));
208
298
  const n = res.filter((re) => re.test(text)).length;
209
- return n === 0 && classUnread(goods) ? null : n;
299
+ return n === 0 && (classUnread(goods) || notWhole(goods)) ? null : n;
210
300
  };
211
301
  }
212
302
 
@@ -238,7 +328,7 @@ export function goodsWordsMatched(words, { stems = false } = {}) {
238
328
  return (goods) => {
239
329
  if (!list.length || !(goods ?? []).some((g) => g.en !== false)) return null;
240
330
  const hit = tests.filter(([, t]) => t(goods) === 1).map(([w]) => w);
241
- return hit.length === 0 && classUnread(goods) ? null : hit;
331
+ return hit.length === 0 && (classUnread(goods) || notWhole(goods)) ? null : hit;
242
332
  };
243
333
  }
244
334
 
@@ -269,8 +359,12 @@ export function instructedOrder(P, job = null) {
269
359
  * search asked for beside the one the variants stage named. The client's markets come from the caller:
270
360
  * this module is loaded by the band tool server, which reads no account settings. IMPURE (reads the run).
271
361
  * Any part that cannot be read is simply absent, and the shape sorts without it.
362
+ *
363
+ * A record's goods are its held body's, and where the run holds no body, the statements its band row carries
364
+ * (`rowGoods`). `held` says which: a line's goods are "on file" only where a body is held, because that is the
365
+ * record `band_record` can open. `records` are the band's, so the shape tests goods whenever either is held.
272
366
  */
273
- export function bandShapeGoods(P) {
367
+ export function bandShapeGoods(P, records = []) {
274
368
  let model = null;
275
369
  try { if (existsSync(P.variantManifestModel)) model = parseVariantManifestModel(readFileSync(P.variantManifestModel, "utf8")); } catch { model = null; }
276
370
  let searched = "";
@@ -285,7 +379,9 @@ export function bandShapeGoods(P) {
285
379
  const words = product ? model.product_words : Array.isArray(model?.goods_words) ? model.goods_words : [];
286
380
  const stems = true; // the word list the shape groups on is matched as stems, whichever source it came from
287
381
  const hits = goodsWordCounter(words, { stems });
288
- const goods = index.size ? { of: (r) => goodsFor(index, r), hits, matched: goodsWordsMatched(words, { stems }) } : null;
382
+ const held = (r) => goodsFor(index, r);
383
+ const rowsHold = Array.isArray(records) && records.some((r) => r && typeof r === "object" && rowGoods(r));
384
+ const goods = index.size || rowsHold ? { of: (r) => held(r) ?? rowGoods(r), held: (r) => held(r) != null, hits, matched: goodsWordsMatched(words, { stems }) } : null;
289
385
  const goodsTest = { source: product ? "product_words" : words.length ? "goods_words" : "none", words };
290
386
  return { goods, goodsTest, goodsWordCount: words.length, dominantTerm: searched || manifestDominant, manifestDominant };
291
387
  }
@@ -2155,6 +2155,95 @@ export function foldSupplementalEntries(plan, entries) {
2155
2155
  };
2156
2156
  }
2157
2157
 
2158
+ /**
2159
+ * THE WAITING FAMILIES THE READING TURN WITHHELD, keyed by the question each one asks.
2160
+ *
2161
+ * A wider family waits in the plan until the reading turn asks for it, and the turn records each family it
2162
+ * leaves unasked as withheld-by-judgment, with its reason (withheld-families.mjs). The fold above lets a
2163
+ * question through when it matches a waiting family, because the turn's own ask of that question is what the
2164
+ * family waits for. That rule cannot tell the turn's ask from anyone else's, and the blind frame comparison's
2165
+ * re-sweep turns its requests into questions by code: its contains question on the mark is such a family, so
2166
+ * it asked families the turn had withheld. This map is what lets the re-sweep tell them apart.
2167
+ *
2168
+ * A family is in it only when all of these hold, because each one missing is a way a question could inherit
2169
+ * a judgment nobody made:
2170
+ * · it waits for the reading turn in this plan: a required row has no withholding to inherit;
2171
+ * · the turn's record names it, on its own axis, with a reason: a family nobody judged is still open;
2172
+ * · it is not released: a release replaces the withholding;
2173
+ * · no plan row that does not wait asks the same question: that row is the one that answers it, and the
2174
+ * fold's duplicate rule names it as the twin.
2175
+ * `withheld` is the record as readWithheldFamilies returns it, `{ [qid]: { axis, reason } }`, and `released`
2176
+ * the set releasedFamilyQids returns. The caller reads both, so this stays PURE.
2177
+ * @returns {Map<string, {qid: string, reason: string}>} question key → the withheld family and its reason
2178
+ */
2179
+ export function withheldFamilyTwins(plan, withheld = {}, released = new Set()) {
2180
+ const entries = Array.isArray(plan?.entries) ? plan.entries : [];
2181
+ const asked = new Set(entries.filter((e) => e && e.unsupported !== true && !awaitsReadingTurn(e.when))
2182
+ .map((e) => entryQuestionKey(e, plan)).filter(Boolean));
2183
+ const twins = new Map();
2184
+ for (const e of entries) {
2185
+ if (!e?.qid || e.unsupported === true || !awaitsReadingTurn(e.when) || released?.has?.(e.qid)) continue;
2186
+ const w = withheld?.[e.qid];
2187
+ const reason = String(w?.reason ?? "").trim();
2188
+ if (!reason || (w?.axis != null && String(w.axis) !== String(e.axis ?? ""))) continue;
2189
+ const key = entryQuestionKey(e, plan);
2190
+ if (key && !asked.has(key) && !twins.has(key)) twins.set(key, { qid: e.qid, reason });
2191
+ }
2192
+ return twins;
2193
+ }
2194
+
2195
+ /**
2196
+ * WHAT A RE-SWEEP THAT ADDS NOTHING STILL OWES ITS AXIS: the axis's entries that have not landed. The executor reads
2197
+ * an empty question list as every entry on the axis, so a fold that added nothing used to repeat every landed
2198
+ * question and, with them, retry the ones still unanswered. These are that retry without the repeats: every entry
2199
+ * on the axis the provider can express (`unsupported` is never sent), a family waiting for the reading turn only once
2200
+ * the turn released it, less every entry the join of these bands counts as executed, a released family's block
2201
+ * included. So it sends a required search that did not land, and also what the old re-send retried among the
2202
+ * unlanded: an entry whose provider error spent its retry ladder (a real call, as before), a capability refusal not
2203
+ * yet accepted as a gap (refused again without a call), and a fringe entry whose guard the executor still holds
2204
+ * (no call). The dispatcher drops the capability gaps the run has already accepted. PURE.
2205
+ * @param {object} plan the frozen register plan
2206
+ * @param {string} axis the axis the re-sweep ran on
2207
+ * @param {object} bandsByAxis the run's register bands, as readRegisterBands returns them
2208
+ * @param {Set<string>} released the families the reading turn released (releasedFamilyQids)
2209
+ */
2210
+ export function pendingRequiredQids(plan, axis, bandsByAxis = {}, released = new Set()) {
2211
+ const landed = new Set(joinPlanToBands(plan, bandsByAxis, { released }).executed.map((x) => x.qid));
2212
+ return (Array.isArray(plan?.entries) ? plan.entries : [])
2213
+ .filter((e) => e?.qid && e.axis === axis && e.unsupported !== true && (!awaitsReadingTurn(e.when) || released?.has?.(e.qid))
2214
+ && !landed.has(e.qid))
2215
+ .map((e) => e.qid);
2216
+ }
2217
+
2218
+ /**
2219
+ * Refuse the minted entries that repeat a withheld family exactly, before they reach the fold. "Exactly" is
2220
+ * the fold's own question identity (entryQuestionKey: offices resolved, classes, goods, owner, term form), so
2221
+ * the same term asked in other classes, other offices or narrowed by goods is a different question and is
2222
+ * kept. Each refusal is `withheld-by-judgment`, its twin is the family, and it carries the reason on the
2223
+ * family's record, so the closure rule (close-verify.mjs, withheldAs) settles the slice by that judgment:
2224
+ * the slice is never frozen into the plan and never runs, and the family stays waiting, so the reading turn
2225
+ * can still release it.
2226
+ *
2227
+ * NOT A SCREEN IN THE FOLD ITSELF, deliberately. The fold is the funnel for the reading turn's own proposals
2228
+ * as well, and the turn's ask of a family it once withheld is that turn reconsidering, which the waiting rule
2229
+ * exists to let through. Only the re-sweep, which asks on nobody's judgment, calls this.
2230
+ *
2231
+ * Every other entry is KEPT for the fold, which screens it as before. An entry whose qid the plan already
2232
+ * holds is kept too: the fold reads it as a re-proposal of a row that is already there. PURE.
2233
+ * @returns {{ kept: object[], refused: {qid: string, term: string, kind: "withheld-by-judgment", twin: string, reason: string}[] }}
2234
+ */
2235
+ export function refuseWithheldFamilies(entries, twins, plan) {
2236
+ const inPlan = new Set((plan?.entries ?? []).map((e) => e?.qid));
2237
+ const kept = [], refused = [];
2238
+ for (const e of Array.isArray(entries) ? entries : []) {
2239
+ const key = e?.qid && !inPlan.has(e.qid) ? entryQuestionKey(e, plan) : null;
2240
+ const twin = key ? twins?.get?.(key) : undefined;
2241
+ if (!twin) { kept.push(e); continue; }
2242
+ refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "withheld-by-judgment", twin: twin.qid, reason: twin.reason });
2243
+ }
2244
+ return { kept, refused };
2245
+ }
2246
+
2158
2247
  /**
2159
2248
  * The store decision (pure — the attach step does only IO around this): given the slug store's
2160
2249
  * latest plan (or null) and a fresh compile, decide reuse / extend / mint.
@@ -105,6 +105,32 @@ let lastLedgerError = null;
105
105
  export const ledgerReadError = () => lastLedgerError;
106
106
  const noteLedger = (r) => { if (r.error) lastLedgerError = r.error; return r; };
107
107
 
108
+ // A RECORD BODY THAT CARRIES NO RECORD IS NOT A RECORD. A body counts as the record only where some field beside
109
+ // its identity and its plumbing holds a value: a mark, a status, an owner, a class list, goods, a date. An object
110
+ // holding nothing else is a placeholder: it satisfies no evidence duty, and it never replaces a body that carries
111
+ // the record. The normalising registers write their identity on every record, an empty one included: the provider,
112
+ // the office, the numbers the request asked for, a status class of "unknown", the link minted from them, and the
113
+ // underscored provenance and raw copy. None of those is the record. A value counts only where something in it holds
114
+ // one, so an object of nulls, or a list of empty objects, is no content either.
115
+ const RECORD_IDENTITY_KEYS = new Set(["uri", "record_id", "id", "guid", "serial", "serialNumber", "object", "provider", "office", "statusClass", "status_class",
116
+ "applicationNumber", "registrationNumber", "resolved_link", "officeUrl"]);
117
+ const holdsValue = (v, depth = 0) => {
118
+ if (v == null) return false;
119
+ if (typeof v === "string") return Boolean(v.trim());
120
+ if (depth > 8) return true;
121
+ if (Array.isArray(v)) return v.some((x) => holdsValue(x, depth + 1));
122
+ if (typeof v === "object") return Object.values(v).some((x) => holdsValue(x, depth + 1));
123
+ return true;
124
+ };
125
+ /** Does this body carry the record's own content, rather than only an identity? PURE. */
126
+ export function recordBodyHasContent(body) {
127
+ const b = Array.isArray(body) ? body[0] : body;
128
+ if (!b || typeof b !== "object" || Array.isArray(b)) return false;
129
+ return Object.entries(b).some(([k, v]) => !k.startsWith("_") && !RECORD_IDENTITY_KEYS.has(k) && holdsValue(v));
130
+ }
131
+ // A later copy wins, unless it is a placeholder and the record already holds one with content.
132
+ const keepRecord = (map, uri, body) => { if (recordBodyHasContent(body) || !recordBodyHasContent(map.get(uri))) map.set(uri, body); };
133
+
108
134
  // Latest fetched body per record URI for the run. Never throws (a missing ledger → empty map).
109
135
  //
110
136
  // — NO DEFAULT PATH. It used to fall back to `ledgerPath("record")`, the box-global file. Now that
@@ -118,7 +144,7 @@ export function collectRecordBodies(recordLogPath, runPrefix) {
118
144
  try { row = JSON.parse(line); } catch { return; }
119
145
  if (!runPrefix || !rowMatchesRun(row, runPrefix)) return;
120
146
  const body = Array.isArray(row.body) ? row.body[0] : row.body;
121
- if (row.target && body && typeof body === "object") map.set(String(row.target).toLowerCase(), body);
147
+ if (row.target && body && typeof body === "object") keepRecord(map, String(row.target).toLowerCase(), body);
122
148
  }));
123
149
  return map;
124
150
  }
@@ -128,12 +154,17 @@ export function collectRecordBodies(recordLogPath, runPrefix) {
128
154
  // provider calls); replaces the "was it on disk?" inference the report's `verified` label rested on.
129
155
  export function collectRecordReceipts(recordLogPath, runPrefix) { // — no default; see above
130
156
  const map = new Map();
157
+ const real = new Set(); // the records whose receipt is from a body carrying the record, which a placeholder's never replaces
131
158
  noteLedger(forEachLedgerLine(recordLogPath, (line) => {
132
159
  let row;
133
160
  try { row = JSON.parse(line); } catch { return; }
134
161
  if (!runPrefix || !rowMatchesRun(row, runPrefix)) return;
135
162
  if (!row.target || !row.ts) return;
136
- map.set(String(row.target).toLowerCase(), { fetched_at: String(row.ts), context: stripGatewayNs(row.sessionKey ?? "") || null });
163
+ const key = String(row.target).toLowerCase();
164
+ const full = recordBodyHasContent(row.body);
165
+ if (!full && real.has(key)) return;
166
+ if (full) real.add(key);
167
+ map.set(key, { fetched_at: String(row.ts), context: stripGatewayNs(row.sessionKey ?? "") || null });
137
168
  }));
138
169
  return map;
139
170
  }
@@ -229,7 +260,7 @@ export function assembleRunRecords(runDir, runPrefix,
229
260
  const fromRunDir = readRecordArtifacts(runDir);
230
261
  const fromLedger = collectRecordBodies(recordLogPath, runPrefix);
231
262
  const records = new Map(fromRunDir);
232
- for (const [uri, body] of fromLedger) records.set(uri, body);
263
+ for (const [uri, body] of fromLedger) keepRecord(records, uri, body);
233
264
  // WP-receipts: receipts assemble the same way the records do — artifact-embedded ones (inherited run
234
265
  // dir) ∪ this-session ledger rows (fresh fetch wins) — so a re-assembled run dir keeps its proof.
235
266
  const receipts = new Map();
@@ -42,19 +42,23 @@
42
42
  // re-stated: verifyRegisterDirectiveClose asks the identical question of the same blocks, and two
43
43
  // copies of a matcher is how two matchers drift apart.
44
44
 
45
- import { blockCollapsed, answeredAs } from "./close-verify.mjs";
45
+ import { blockCollapsed, answeredAs, withheldAs } from "./close-verify.mjs";
46
46
  import { reopenKey } from "./frame-diff-model.mjs";
47
47
 
48
48
  export const REMEDY_ACCOUNTING_SCHEMA_VERSION = 1;
49
49
 
50
50
  /**
51
- * The five term classes. EXACTLY ONE per remedy term, decided in the fixed order in classifyRemedyTerm.
51
+ * The six term classes. EXACTLY ONE per remedy term, decided in the fixed order in classifyRemedyTerm.
52
52
  *
53
- * found — every slice landed and the register returned material (records, or a counted crowd
54
- * too large to enumerate). The term is answered.
53
+ * found — every slice that ran landed and the register returned material (records, or a
54
+ * counted crowd too large to enumerate). The term is answered.
55
55
  * searched-empty — every slice landed and every one is a COUNTED zero: state `enumerated`, a finite
56
56
  * total_hits of 0, an empty records array. The executed query and its empty result
57
57
  * are both recorded on the row. This is the state that used to leave no trace.
58
+ * withheld-by-judgment — a slice repeated a waiting family the reading turn withheld, so it was never
59
+ * asked, and every other slice landed with nothing material. Nothing was found and
60
+ * nothing is claimed clean: the term rests on the reading turn's recorded judgment,
61
+ * named on the slice it settles (`withheld_as`).
58
62
  * not-dispatched — no slice was ever minted for this term, with the reason the mint or the remedy
59
63
  * derivation gave. The term was NOT searched, and now says so.
60
64
  * dispatch-failed— a slice was minted but did not land as a usable executed block: unlanded, error,
@@ -64,7 +68,7 @@ export const REMEDY_ACCOUNTING_SCHEMA_VERSION = 1;
64
68
  * counted zero (an uncountable total, an unrecognised state), or the receipt offers
65
69
  * no mapping at all. NEVER guessed in either direction.
66
70
  */
67
- export const REMEDY_TERM_CLASSES = ["found", "searched-empty", "not-dispatched", "dispatch-failed", "unaccounted"];
71
+ export const REMEDY_TERM_CLASSES = ["found", "searched-empty", "withheld-by-judgment", "not-dispatched", "dispatch-failed", "unaccounted"];
68
72
 
69
73
  /**
70
74
  * The two classes that mean "this term's search is ON THE RECORD". Deliberately NOT every class with a
@@ -73,6 +77,15 @@ export const REMEDY_TERM_CLASSES = ["found", "searched-empty", "not-dispatched",
73
77
  */
74
78
  export const ACCOUNTED_CLASSES = ["found", "searched-empty"];
75
79
 
80
+ /**
81
+ * The classes that leave nothing open: a search on the record, or the reading turn's recorded judgment not
82
+ * to ask the question at all. A withheld family is a wider question the ruled method lets that turn leave
83
+ * unasked, on the record with its reason, so a term resting on one is neither a hole nor a doubt. It is
84
+ * still not a search, which is why ACCOUNTED_CLASSES stays as it is. The not-accounted list, the doubts and
85
+ * the closure read below all read this one.
86
+ */
87
+ export const SETTLED_CLASSES = [...ACCOUNTED_CLASSES, "withheld-by-judgment"];
88
+
76
89
  const clip = (s, n = 240) => {
77
90
  const t = String(s ?? "").replace(/\s+/g, " ").trim();
78
91
  return t.length > n ? `${t.slice(0, n - 1)}…` : t;
@@ -138,10 +151,13 @@ function material(block) {
138
151
  * 2. ANY slice that did not land clean ⇒ `dispatch-failed`, naming the first such qid. Every slice
139
152
  * must land, exactly as verifyRegisterDirectiveClose requires of a directive — one convention. A
140
153
  * slice the FOLD refused as already-asked is read through the twin it duplicates first, because that
141
- * slice cannot land and the twin is where its question was answered;
154
+ * slice cannot land and the twin is where its question was answered. A slice refused as a family
155
+ * the reading turn withheld is settled by that judgment (`withheldAs`) and is left out of rules 2-5:
156
+ * it never ran, so it has no result to read either way;
142
157
  * 3. all landed, ANY slice material ⇒ `found`;
143
- * 4. all landed, EVERY slice a counted zero ⇒ `searched-empty`;
144
- * 5. otherwise ⇒ `unaccounted`, with the shape that defeated the join stated.
158
+ * 4. all landed with nothing material, and a slice withheld ⇒ `withheld-by-judgment`;
159
+ * 5. all landed, EVERY slice a counted zero ⇒ `searched-empty`;
160
+ * 6. otherwise ⇒ `unaccounted`, with the shape that defeated the join stated.
145
161
  * PURE.
146
162
  */
147
163
  export function classifyRemedyTerm(row, { blocksByQid = new Map(), executedQids = new Set(), foldRefusals = new Map() } = {}) {
@@ -159,20 +175,31 @@ export function classifyRemedyTerm(row, { blocksByQid = new Map(), executedQids
159
175
  // ONLY THE DUPLICATE KIND RESOLVES. An identity collision or a malformed term is a genuine fault and
160
176
  // stays `dispatch-failed`: nothing else asked those questions, so nothing else answers them.
161
177
  const landsAs = new Map(qids.map((q) => [q, answeredAs(q, foldRefusals)])); // one rule with the directive check
178
+ // — A SLICE REFUSED AS A FAMILY THE READING TURN WITHHELD IS SETTLED, NOT READ.
179
+ //
180
+ // Its twin never ran: the reading turn decided not to ask that family and recorded why, and the re-sweep
181
+ // refused to ask it on the turn's behalf. So there is no result to read, and reading the slice as one that
182
+ // failed to land would record a dispatch failure, and mint a doubt, over a decision the run had made on
183
+ // purpose. It is settled by the same rule the directive check uses (`withheldAs`), it keeps its own row
184
+ // saying which family it repeats, and it carries no count, no state and no executed flag of its own.
185
+ const settledBy = new Map(qids.map((q) => [q, withheldAs(q, foldRefusals)]).filter(([, w]) => w));
162
186
  const blockFor = (q) => blocksByQid.get(landsAs.get(q) ?? q);
163
187
  const ranAs = (q) => executedQids.has(landsAs.get(q) ?? q);
164
188
  const slices = qids.map((q) => {
189
+ const settled = settledBy.get(q);
190
+ if (settled) return { ...sliceRow(q, null, false), refused_at_fold: "withheld-by-judgment", withheld_as: settled.family };
165
191
  const via = landsAs.get(q);
166
192
  const slice = sliceRow(q, blockFor(q), ranAs(q));
167
193
  return via && via !== q ? { ...slice, answered_by: via, refused_at_fold: "duplicate-question" } : slice;
168
194
  });
195
+ const asked = qids.filter((q) => !settledBy.has(q));
169
196
  if (!qids.length) {
170
197
  const reason = String(row?.dispatch_reason ?? "").trim();
171
198
  return reason
172
199
  ? { class: "not-dispatched", basis: "no-slice-minted", reason: clip(reason, 300), slices }
173
200
  : { class: "unaccounted", basis: "no-mapping", reason: "the receipt records no slice and no reason for this term — the join cannot say whether it was searched", slices };
174
201
  }
175
- for (const q of qids) {
202
+ for (const q of asked) {
176
203
  const b = blockFor(q);
177
204
  if (sliceLanded(b, ranAs(q))) continue;
178
205
  const why = !ranAs(q) ? "slice-not-landed"
@@ -182,8 +209,11 @@ export function classifyRemedyTerm(row, { blocksByQid = new Map(), executedQids
182
209
  : "collapsed-slice";
183
210
  return { class: "dispatch-failed", basis: why, reason: `${why}:${q}${b?.reason ? ` — ${clip(b.reason, 200)}` : ""}`, slices };
184
211
  }
185
- const blocks = qids.map((q) => blockFor(q));
212
+ const blocks = asked.map((q) => blockFor(q));
186
213
  if (blocks.some(material)) return { class: "found", basis: "band-block", reason: null, slices };
214
+ // Nothing material came back, and part of the term was never asked by the turn's decision: a counted zero
215
+ // beside a question nobody asked is not a clean result for the term, so it is not `searched-empty`.
216
+ if (settledBy.size && blocks.every(countedZero)) return { class: "withheld-by-judgment", basis: "reading-turn-record", reason: null, slices };
187
217
  if (blocks.every(countedZero)) return { class: "searched-empty", basis: "counted-zero", reason: null, slices };
188
218
  const odd = blocks.find((b) => !countedZero(b));
189
219
  return {
@@ -240,9 +270,10 @@ export function accountRemedyTerms({ terms = [], blocksByQid = new Map(), execut
240
270
  totals,
241
271
  by_directive: byDirective,
242
272
  // the two lists a reader is owed, kept APART on purpose (see the zero-semantics note in the header):
243
- // a term that ran and found nothing is EVIDENCE; a term that never ran is a hole.
273
+ // a term that ran and found nothing is EVIDENCE; a term that never ran is a hole. A term the reading
274
+ // turn's judgment settled is neither, and is in neither list: its row says which family it rests on.
244
275
  searched_empty: rows.filter((r) => r.class === "searched-empty"),
245
- not_accounted: rows.filter((r) => !ACCOUNTED_CLASSES.includes(r.class)),
276
+ not_accounted: rows.filter((r) => !SETTLED_CLASSES.includes(r.class)),
246
277
  out_of_scope: (Array.isArray(outOfScope) ? outOfScope : []).map((o) => ({
247
278
  directive: String(o?.directive ?? ""), layer: String(o?.layer ?? ""),
248
279
  reason: clip(o?.reason ?? "source-channel directive — swept by a prose byte-diff on the common-law findings, so it has no term unit and no per-query record to account", 300),
@@ -269,6 +300,11 @@ export function accountRemedyTerms({ terms = [], blocksByQid = new Map(), execut
269
300
  * `accounting` may be null (the ledger did not build) — then this returns the caller's own prior answer
270
301
  * untouched, because a join that could not run is not evidence of a gap. PURE.
271
302
  *
303
+ * A term settled by the reading turn's recorded judgment does not hold the gap open (SETTLED_CLASSES): the
304
+ * re-sweep did not ask a question that turn had decided against, and holding a CLEAR on that would turn a
305
+ * recorded decision into an unfinished search. It closes nothing by itself either: the directive must still
306
+ * be verified-closed, and every slice that did run must have landed.
307
+ *
272
308
  * @returns {{domClosed: boolean, blockedBy: {term, directive, class, reason}[]}}
273
309
  */
274
310
  export function domClosedOverAccounted({ directives = [], swept = [], accounting = null, reopenKeyOf = reopenKey } = {}) {
@@ -283,7 +319,7 @@ export function domClosedOverAccounted({ directives = [], swept = [], accounting
283
319
  if (!closedOnADominant) return { domClosed: false, blockedBy: [] };
284
320
  if (!accounting || accounting.computable !== true) return { domClosed: true, blockedBy: [] };
285
321
  const blockedBy = (accounting.rows ?? [])
286
- .filter((r) => domKeys.has(r.directive) && !ACCOUNTED_CLASSES.includes(r.class))
322
+ .filter((r) => domKeys.has(r.directive) && !SETTLED_CLASSES.includes(r.class))
287
323
  .map((r) => ({ term: r.term, directive: r.directive, class: r.class, reason: r.reason }));
288
324
  return { domClosed: blockedBy.length === 0, blockedBy };
289
325
  }
@@ -292,7 +328,7 @@ export function domClosedOverAccounted({ directives = [], swept = [], accounting
292
328
  // AD-4 house rule: every field is written on EVERY row, so "no term went unaccounted"
293
329
  // (unaccounted:0) and "the ledger could not run" (unaccounted:null) differ by VALUE, never by field
294
330
  // presence.
295
- export const REMEDY_ACCOUNTING_EVENT_FIELDS = ["terms", "found", "searched_empty", "not_dispatched",
331
+ export const REMEDY_ACCOUNTING_EVENT_FIELDS = ["terms", "found", "searched_empty", "withheld_by_judgment", "not_dispatched",
296
332
  "dispatch_failed", "unaccounted"];
297
333
 
298
334
  export function remedyAccountingEvent({ trigger = null, artifact = null, reason = null } = {}) {
@@ -313,6 +349,8 @@ export function remedyAccountingEvent({ trigger = null, artifact = null, reason
313
349
  * doubt:remedy-accounting:unaccounted:N — the join cannot say either way
314
350
  * A `searched-empty` term mints NOTHING: it is answered, and its query and empty result ride the
315
351
  * artifact and the receipt. Minting a doubt for it would turn a completed search into an open question.
352
+ * A `withheld-by-judgment` term mints nothing either: it rests on the reading turn's recorded decision not
353
+ * to ask, and a doubt would re-open that decision as a question nobody had answered.
316
354
  *
317
355
  * The frozen doubt-record shape, status "open"; stitchDoubts and the doubt-closure stage decide endings
318
356
  * exactly as for every other doubt family, and an OPEN one shipping is the system working. Never gates,