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
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // placement-decisions.mjs — PLACEMENT'S DECISIONS AS THEY STAND ON A RUN'S FILES, for every reader that is told them.
5
+ //
6
+ // Placement sets lines aside by name and under its groups' rows, each with a ground, and keeps them out of
7
+ // placements.json. One check says which of them still stand (a group's row checked again on the shape now on disk; a
8
+ // set-aside by name while the band still holds its record and no placement places its line), and every reader takes
9
+ // them through it: the delivered Audit Trail, recall reconciliation, the review's list of floors, and the list the
10
+ // reading step and the writer are given. Here rather than in the pipeline so that the MCP server's rebuild of a
11
+ // missing audit, which loads no publishing code, delivers the same rows the driver's audit builder does. Reads the
12
+ // run's files; never writes them.
13
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+ import { createHash } from "node:crypto";
16
+ import { buildSelectionIndex, setAsideGrounds, isSetAsideGroupRow, lineKey, groupName } from "./placement-form.mjs";
17
+ import { unionPlacementForm } from "./placement-union.mjs";
18
+ import { readPlacementForm, readPlacementFormInput } from "./placement-form-io.mjs";
19
+ import { parsePlacementsJson } from "./placement-model.mjs";
20
+
21
+ const safeReadJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
22
+
23
+ /** Every record uri a placements.json names. */
24
+ export function placedUriRows(P) {
25
+ try {
26
+ const placements = parsePlacementsJson(readFileSync(P.placementModel, "utf8")).placements;
27
+ const out = [];
28
+ for (const p of Array.isArray(placements) ? placements : []) {
29
+ for (const r of Array.isArray(p?.records) ? p.records : []) out.push({ uri: r, tier: p?.tier ?? null });
30
+ }
31
+ return out;
32
+ } catch { return []; }
33
+ }
34
+
35
+
36
+ /**
37
+ * The form's set-aside entries as they stand on the shape now on disk, and on what placement placed. A ground by
38
+ * name stands while the band still holds its record and no placement places its line: a later pass that places
39
+ * the line reverses the set-aside, as the fate count already reads it, and a record the band no longer holds has
40
+ * no decision left to deliver. A group's row is checked again exactly as the union took it, on its count and on
41
+ * every line read, because the shape may have been derived again since: a resumed run derives it afresh, and a
42
+ * record body fetched after placement can move a line out of the list the goods test could not place and into a
43
+ * group. A row taken on the earlier shape then covers no line the later one added. `placedUris` are the records
44
+ * the placements carry, and `rows` the form's own rows, from which the union reads the lines placement placed.
45
+ * Nothing is written back. PURE.
46
+ */
47
+ export function standingSetAside(entries, input, { placedUris = [], rows = [] } = {}) {
48
+ const all = Array.isArray(entries) ? entries : [];
49
+ const index = buildSelectionIndex(input);
50
+ const placedLines = new Set([...placedUris].map((u) => lineKey(index.resolve(u))).filter(Boolean));
51
+ const named = all.filter((e) => !isSetAsideGroupRow(e)).filter((e) => {
52
+ const line = lineKey(index.resolve(e?.set_aside));
53
+ return Boolean(line) && !placedLines.has(line);
54
+ }).map((e) => ({ ...e, key: lineKey(index.resolve(e.set_aside)) }));
55
+ const grouped = all.filter(isSetAsideGroupRow);
56
+ if (!grouped.length) return named;
57
+ // A group's row is taken again beside the lines placement placed (`rows`, the form's own) and the ones it named that
58
+ // still stand, so the lines it covers and the floors it leaves are counted as the fate count reads them: neither a
59
+ // placed line nor a named one is the row's, and a placed floor is not one it leaves. Each row carries the keys of
60
+ // the lines it covers (`covered_lines`), which the form itself never says back.
61
+ const { form, covered_lines: covered } = unionPlacementForm({ rows: Array.isArray(rows) ? rows : [], set_aside: [...named, ...grouped] }, null, input,
62
+ { covered: true });
63
+ return [...named, ...form.set_aside_groups.map((e) => ({ ...e, covered_lines: covered?.[e.set_aside_group] ?? [] }))];
64
+ }
65
+
66
+ /** The set-aside grounds the placement form holds, by name and by group, as uri → entry, through the form's own selection index. */
67
+ export function placementSetAsideGrounds(P) { // @internal — driven by its test with a run's own files
68
+ try {
69
+ const form = readPlacementForm(P.runDir);
70
+ if (!form.set_aside?.length) return () => null;
71
+ const input = readPlacementFormInput(P.runDir);
72
+ const placedUris = placedUriRows(P).map((r) => r.uri);
73
+ return setAsideGrounds(standingSetAside(form.set_aside, input, { placedUris, rows: form.rows }), buildSelectionIndex(input), input.groups, input.floors,
74
+ { placedUris });
75
+ } catch { return () => null; }
76
+ }
77
+
78
+ /**
79
+ * THE FLOORS PLACEMENT SET ASIDE, AS THE REVIEW IS TOLD THEM: one line per floor of the shape now on disk whose
80
+ * set-aside stands, by name on its status or its territory, or under a crowd's row, with placement's ground. Only
81
+ * decisions that stand on the current shape are listed (`placementSetAsideGrounds`, which checks a group's row
82
+ * again); a floor with no standing decision is not listed, so the review flags it as before. [] when nothing stands
83
+ * or nothing can be read. IMPURE (reads the run).
84
+ */
85
+ export function placementFloorDecisionLines(P) { // @internal — driven by its test with a run's own files
86
+ try {
87
+ const floors = safeReadJson(P.bandShape)?.floors?.in_class_identical_or_near;
88
+ if (!Array.isArray(floors) || !floors.length) return [];
89
+ const groundOf = placementSetAsideGrounds(P);
90
+ const out = [];
91
+ for (const f of floors) {
92
+ const g = f?.record_id ? groundOf(f.record_id) : null;
93
+ if (!g?.ground) continue;
94
+ const who = `${f.mark_text ?? "(unnamed)"}${f.owner_name ? ` — ${f.owner_name}` : ""} (${f.record_id})`;
95
+ if (g.by === "group") out.push(`- ${who}: covered by group (${g.group})'s row: ${g.ground}`);
96
+ else if (g.on === "status" || g.on === "territory") out.push(`- ${who}: set aside on its ${g.on}: ${g.ground}`);
97
+ }
98
+ return out;
99
+ } catch { return []; }
100
+ }
101
+
102
+ /**
103
+ * PLACEMENT'S ACCEPTED SET-ASIDES THAT STAND ON THE RUN'S FILES, as data: one entry per decision (`standingSetAside`: a
104
+ * group's row checked again on the shape beside what placement placed and named, a set-aside by name only while the
105
+ * band holds its record and no placement places its line). A name's entry gives the record the record tools open, its
106
+ * mark and owner, its ground and, for a floor, what the ground is on; a group's row its name, the records in the group,
107
+ * the lines it covers (the group's lines placement neither placed nor named) and its ground, with every floor it leaves
108
+ * to be placed or named. A group's row also carries a hash of the keys of the lines it covers, so a row that comes to
109
+ * cover other lines at the same count is a changed decision to every stage that reads it, while the file stays small
110
+ * enough for a stage to open. [] when nothing stands or nothing can be read. IMPURE (reads the run).
111
+ */
112
+ export function standingPlacementDecisions(P) { // @internal — driven by its test with a run's own files
113
+ try {
114
+ const form = readPlacementForm(P.runDir);
115
+ if (!form.set_aside?.length) return [];
116
+ const input = readPlacementFormInput(P.runDir);
117
+ const index = buildSelectionIndex(input);
118
+ return standingSetAside(form.set_aside, input, { placedUris: placedUriRows(P).map((r) => r.uri), rows: form.rows }).flatMap((e) => {
119
+ if (!e?.ground) return [];
120
+ if (isSetAsideGroupRow(e)) return [{ kind: "group", group: groupName(e.set_aside_group), records: Number(e.count ?? 0),
121
+ covers_lines: Number(e.covers_lines ?? 0), ground: e.ground, floors_not_covered: Array.isArray(e.floors_not_covered) ? e.floors_not_covered : [],
122
+ covered_lines_hash: createHash("sha256").update((e.covered_lines ?? []).join("\n")).digest("hex").slice(0, 16) }];
123
+ const c = index.resolve(e.set_aside);
124
+ return [{ kind: "name", record: e.set_aside, mark: c?.mark ?? null, owner: c?.owner ?? null, ground: e.ground,
125
+ ...(e.on === "status" || e.on === "territory" ? { on: e.on } : {}) }];
126
+ });
127
+ } catch { return []; }
128
+ }
129
+
130
+ /**
131
+ * WHERE EACH RECORD STANDS IN PLACEMENT'S ACCOUNT: a placed record by its tier, a record set aside by name by its ground,
132
+ * and each group's row as one entry with the records it holds, which it does not name. Read before and after a repeated
133
+ * placement pass, it says how many records the pass re-ranked. IMPURE (reads the run).
134
+ */
135
+ export function rankingNow(P) { // @internal — driven by its test with a run's own files
136
+ const records = new Map();
137
+ for (const r of placedUriRows(P)) records.set(String(r.uri).toLowerCase(), `placed:${r.tier ?? ""}`);
138
+ const groups = new Map();
139
+ for (const d of standingPlacementDecisions(P)) {
140
+ if (d.kind === "group") groups.set(d.group, { key: `${d.ground}|${d.covers_lines}|${d.covered_lines_hash}`, records: d.records });
141
+ else records.set(String(d.record).toLowerCase(), `set aside${d.on ? ` on its ${d.on}` : ""}: ${d.ground}`);
142
+ }
143
+ return { records, groups };
144
+ }
145
+
146
+ /** A ranking as it is kept on disk: its two maps as plain objects. PURE. */
147
+ export function rankingToJson(ranking) {
148
+ return { records: Object.fromEntries(ranking?.records ?? []), groups: Object.fromEntries(ranking?.groups ?? []) };
149
+ }
150
+
151
+ /** A ranking kept on disk, read back into its two maps; null when there is none to read. PURE. */
152
+ export function rankingFromJson(json) {
153
+ if (!json || typeof json !== "object" || typeof json.records !== "object" || typeof json.groups !== "object") return null;
154
+ return { records: new Map(Object.entries(json.records ?? {})), groups: new Map(Object.entries(json.groups ?? {})) };
155
+ }
156
+
157
+ /** How many records a ranking accounts for: each named record once, and every record a group's row holds. PURE. */
158
+ export function rankedRecords(ranking) {
159
+ return (ranking?.records?.size ?? 0) + [...(ranking?.groups?.values() ?? [])].reduce((n, g) => n + (Number(g?.records) || 0), 0);
160
+ }
161
+
162
+ /**
163
+ * How many records stand differently in `after` than in `before`: a named record that moved, appeared or went counts
164
+ * once, and a group's row that changed counts every record it holds, as it names none of them. PURE.
165
+ */
166
+ export function recordsReRanked(before, after) {
167
+ let n = 0;
168
+ for (const id of new Set([...(before?.records?.keys() ?? []), ...(after?.records?.keys() ?? [])]))
169
+ if (before?.records?.get(id) !== after?.records?.get(id)) n++;
170
+ for (const g of new Set([...(before?.groups?.keys() ?? []), ...(after?.groups?.keys() ?? [])])) {
171
+ const a = before?.groups?.get(g), b = after?.groups?.get(g);
172
+ if (a?.key !== b?.key || a?.records !== b?.records) n += Math.max(Number(a?.records) || 0, Number(b?.records) || 0);
173
+ }
174
+ return n;
175
+ }
176
+
177
+ /**
178
+ * THE SAME DECISIONS, AS THE READING STEP AND THE WRITER ARE TOLD THEM: one line each, under the approved introduction.
179
+ * A group's floors are listed up to twenty; beyond that the line says how many more there are and names the file that
180
+ * holds every one of them (`writePlacementDecisions`), so a shortened list never stands in for the whole. IMPURE.
181
+ */
182
+ export function placementSetAsideLines(P, decisions = standingPlacementDecisions(P)) { // @internal — driven by its test with a run's own files
183
+ const n = (x) => Number(x ?? 0).toLocaleString("en-US");
184
+ const listed = (ids) => (ids.length > 20 ? `${ids.slice(0, 20).join(", ")}, and ${n(ids.length - 20)} more, all listed in ${P.placementDecisions}` : ids.join(", "));
185
+ return decisions.map((d) => (d.kind === "group"
186
+ ? `- group (${d.group})'s row, ${n(d.records)} records in the group, covering ${n(d.covers_lines)} lines: ${d.ground}`
187
+ + (d.floors_not_covered.length ? `; the floors it does not cover: ${listed(d.floors_not_covered)}` : "")
188
+ : `- ${d.mark ?? d.record}${d.owner ? ` — ${d.owner}` : ""} (${d.record}): set aside${d.on ? ` on its ${d.on}` : ""}: ${d.ground}`));
189
+ }
190
+
191
+ /**
192
+ * The standing decisions, written where the reading step and the writer declare them as an input, so a change to a
193
+ * decision alone (a reason, a group's membership) makes a reader that already ran stale, and the delivery loop repeats
194
+ * it, as it does when the placements change. No clock: the file changes only when a decision does. Returns the
195
+ * decisions written. IMPURE (writes `_driver/placement-decisions.json`).
196
+ */
197
+ export function writePlacementDecisions(P) { // @internal — driven by its test with a run's own files
198
+ const decisions = standingPlacementDecisions(P);
199
+ mkdirSync(dirname(P.placementDecisions), { recursive: true });
200
+ writeFileSync(P.placementDecisions, JSON.stringify({ schema_version: 1, decisions }, null, 2) + "\n");
201
+ return decisions;
202
+ }
203
+
204
+ /**
205
+ * PLACEMENT'S DECISIONS IN THE DELIVERED AUDIT TRAIL (ruled 2026-10-18): one row per set-aside that stands on the shape
206
+ * now on disk, in the Audit Trail's own fields, carrying the decision's reference, its ground and, for a group's row,
207
+ * its count. A set-aside the shape no longer supports is not delivered. [] when there is none or nothing can be read.
208
+ * IMPURE (reads the run).
209
+ */
210
+ export function placementAuditRows(P) { // @internal — driven by its test with a run's own files
211
+ try {
212
+ const form = readPlacementForm(P.runDir);
213
+ if (!form.set_aside?.length) return [];
214
+ const input = readPlacementFormInput(P.runDir);
215
+ const index = buildSelectionIndex(input);
216
+ return standingSetAside(form.set_aside, input, { placedUris: placedUriRows(P).map((r) => r.uri), rows: form.rows }).flatMap((e) => {
217
+ if (!e?.ground) return [];
218
+ if (isSetAsideGroupRow(e)) return [{ source_layer: "Register", step: "placement", query: `group (${groupName(e.set_aside_group)})`,
219
+ rationale: e.ground, result_summary: `${Number(e.count ?? 0).toLocaleString("en-US")} records` }];
220
+ const c = index.resolve(e.set_aside);
221
+ return [{ source_layer: "Register", step: "placement",
222
+ query: `${c?.mark ?? e.set_aside}${c?.owner ? ` — ${c.owner}` : ""} (${e.set_aside})`, rationale: e.ground }];
223
+ });
224
+ } catch { return []; }
225
+ }
226
+
227
+ /**
228
+ * The records placement set aside, as recall reconciliation accepts them now that the audit delivers them: by name,
229
+ * an individual ending for any position; under a group's row, the members that row validly covers, which end only a
230
+ * residual position, as a ruled crowd does. Lower-cased uris. Empty when nothing stands. IMPURE (reads the run).
231
+ */
232
+ export function placementRecallEndings(P, uris) { // @internal — driven by its test with a run's own files
233
+ const byName = new Set(), byGroup = new Set();
234
+ try {
235
+ const groundOf = placementSetAsideGrounds(P);
236
+ for (const u of uris ?? []) {
237
+ const g = u ? groundOf(u) : null;
238
+ if (!g?.ground) continue;
239
+ (g.by === "group" ? byGroup : byName).add(String(u).toLowerCase());
240
+ }
241
+ } catch { /* nothing stands */ }
242
+ return { byName, byGroup };
243
+ }
@@ -264,15 +264,31 @@ export function setAsideKeysOf(positions) {
264
264
  * {set_aside, owner, ground, by} or null. Resolved through the same selection index a `select` resolves
265
265
  * through, so the owner is read one way at both ends. A ground by name wins over the owner's group row;
266
266
  * `groups` is the band shape's, which says which group each owner sits in. PURE; never throws.
267
+ *
268
+ * A GROUP'S ROW GIVES A RECORD ONLY THE GROUND IT GIVES THE RECORD'S LINE. The row covers the lines of its group
269
+ * the step neither placed nor named, and a line holding a floor only in a crowd of a thousand or more in (b): the
270
+ * form says back exactly that (`covers_lines`, `floors_not_covered`) and the fate count counts it. Read record by
271
+ * record, the row also gave its ground to the other records of a floor's line, which had no fate, and to the other
272
+ * positions of a line the step placed through one of them, so the record trace showed grounds nobody gave. Every
273
+ * record of a line holding a floor the row cannot answer now leaves with none; so does every record of a placed
274
+ * line other than a floor, given `placedUris`, the records the form placed. A floor there keeps the answer it had,
275
+ * because the floor duty counts floors one by one and the delivery floor is read from it.
267
276
  */
268
- export function setAsideGrounds(entries, index, groups = null, floors = null) {
277
+ export function setAsideGrounds(entries, index, groups = null, floors = null, { placedUris = null } = {}) {
269
278
  const byKey = new Map(), byGroup = new Map();
270
279
  for (const e of Array.isArray(entries) ? entries : []) {
271
280
  if (isSetAsideGroupRow(e)) { const g = groupName(e.set_aside_group); if (g && e?.ground) byGroup.set(g, e); }
272
281
  else if (e?.key && e?.ground) byKey.set(e.key, e);
273
282
  }
274
283
  const groupOf = byGroup.size ? groupOfKeys(groups) : new Map();
275
- const floorUris = new Set((Array.isArray(floors) ? floors : []).map((f) => normalizeRecordUri(f?.record_id) || String(f?.record_id ?? "")).filter(Boolean));
284
+ const uriKey = (u) => normalizeRecordUri(u) || String(u ?? "");
285
+ const floorUris = new Set((Array.isArray(floors) ? floors : []).map((f) => uriKey(f?.record_id)).filter(Boolean));
286
+ // The lines holding a floor, as the union reads them: any floor record's line, or a shape line counting one.
287
+ const floorLines = new Set(byGroup.size ? [
288
+ ...[...floorUris].map((u) => lineKey(index?.resolve?.(u))).filter(Boolean),
289
+ ...["a", "b", "c", "d", "no_goods"].flatMap((g) => (Array.isArray(groups?.[g]?.list) ? groups[g].list : []).filter((l) => l?.floors > 0).map((l) => l.key)),
290
+ ] : []);
291
+ const placedLines = new Set(byGroup.size ? [...(placedUris ?? [])].map((u) => lineKey(index?.resolve?.(u))).filter(Boolean) : []);
276
292
  return (uri) => {
277
293
  if ((!byKey.size && !byGroup.size) || !index?.resolve) return null;
278
294
  const canonical = index.resolve(uri);
@@ -281,8 +297,11 @@ export function setAsideGrounds(entries, index, groups = null, floors = null) {
281
297
  const named = line ? byKey.get(line) : null;
282
298
  if (named) return { ...named, by: "name" };
283
299
  const group = line ? groupOf.get(line) : null, row = group ? byGroup.get(group) : null;
284
- if (row && floorUris.has(normalizeRecordUri(uri) || String(uri ?? "")) && !groupRowCoversFloors(groups, group)) return null;
285
- return row ? { set_aside: null, key: line, owner: null, ground: row.ground, by: "group", group } : null;
300
+ if (!row) return null;
301
+ const isFloor = floorUris.has(uriKey(uri));
302
+ if ((isFloor || floorLines.has(line)) && !groupRowCoversFloors(groups, group)) return null;
303
+ if (!isFloor && placedLines.has(line)) return null;
304
+ return { set_aside: null, key: line, owner: null, ground: row.ground, by: "group", group };
286
305
  };
287
306
  }
288
307
 
@@ -290,9 +309,11 @@ export function setAsideGrounds(entries, index, groups = null, floors = null) {
290
309
  * EVERY LINE'S FATE AT PLACEMENT, counted by the list the shape put it in: placed (a record of its mark is
291
310
  * in a placement), set aside by name, covered by its group's row, or no fate. A line is one mark of one
292
311
  * owner; an owner counts as fated when every one of its lines is. The lines with no goods on file are their
293
- * own list, which no group row covers. Counts, plus the keys left with no fate. PURE.
312
+ * own list, which no group row covers. Counts, plus the keys left with no fate. Given `served`, the line keys
313
+ * placement was served, a group row's lines never served are counted as `by_group_unread`; given `lineEvidence`,
314
+ * line → what its goods rest on (goods-evidence.mjs), a group row's lines are counted by it. PURE.
294
315
  */
295
- export function ownerFates({ groups, entries = [], index, placedUris = [] } = {}) {
316
+ export function ownerFates({ groups, entries = [], index, placedUris = [], served = null, lineEvidence = null } = {}) {
296
317
  if (!groups || typeof groups !== "object" || !index?.resolve) return null;
297
318
  const placed = new Set([...placedUris].map((u) => lineKey(index.resolve(u))).filter(Boolean));
298
319
  const named = new Set(), grouped = new Set();
@@ -306,6 +327,7 @@ export function ownerFates({ groups, entries = [], index, placedUris = [] } = {}
306
327
  lists.no_goods = Array.isArray(groups.no_goods?.list) ? groups.no_goods.list : [];
307
328
  const zero = () => ({ lines: 0, placed: 0, by_name: 0, by_group: 0, unfated: 0 });
308
329
  const totals = zero(), by_list = {}, unfated_lines = [], ownerFated = new Map();
330
+ let unreadTotal = 0;
309
331
  for (const [list, lines] of Object.entries(lists)) {
310
332
  const c = zero();
311
333
  for (const l of lines) {
@@ -315,14 +337,26 @@ export function ownerFates({ groups, entries = [], index, placedUris = [] } = {}
315
337
  const fate = placed.has(l.key) ? "placed" : byName ? "by_name" : rowMay ? "by_group" : "unfated";
316
338
  c.lines++; c[fate]++; totals.lines++; totals[fate]++;
317
339
  if (fate === "unfated") unfated_lines.push({ list, key: l.key });
340
+ if (fate === "by_group") {
341
+ // AN UNREAD CROWD IS NOT A CLEAN NEGATIVE (ruled 2026-10-18). A crowd's row stands on its first page read and the
342
+ // count, so it covers lines placement never read; given what placement was served, those are counted apart. They
343
+ // stay covered by the group: the ruled method accepts the row, and the count records what it rested on.
344
+ if (served && !served.has(l.key)) { c.by_group_unread = (c.by_group_unread ?? 0) + 1; unreadTotal++; }
345
+ const state = typeof lineEvidence === "function" ? lineEvidence(l) : null;
346
+ if (state) { c.by_group_goods ??= {}; c.by_group_goods[state] = (c.by_group_goods[state] ?? 0) + 1; }
347
+ }
318
348
  ownerFated.set(l.owner_key, (ownerFated.get(l.owner_key) ?? true) && fate !== "unfated");
319
349
  }
320
- by_list[list] = c;
350
+ by_list[list] = { ...c, ...(served ? { by_group_unread: c.by_group_unread ?? 0 } : {}), reconciles: sumsToLines(c) };
321
351
  }
322
- return { unit: "line", ...totals, owners: ownerFated.size, owners_fated: [...ownerFated.values()].filter(Boolean).length,
352
+ // THE SUM IS STATED, as the floor duty states its own: one fate per line, so the four add up to the lines, and a
353
+ // count that does not is a defect here that a reader should not have to find by adding.
354
+ return { unit: "line", ...totals, ...(served ? { by_group_unread: unreadTotal } : {}), reconciles: sumsToLines(totals), owners: ownerFated.size, owners_fated: [...ownerFated.values()].filter(Boolean).length,
323
355
  by_list, unfated_lines };
324
356
  }
325
357
 
358
+ const sumsToLines = (c) => c.placed + c.by_name + c.by_group + c.unfated === c.lines;
359
+
326
360
  const PROVENANCE = "driver-written form. You SELECT and you JUDGE; the driver COPIES. For each "
327
361
  + "register candidate you are placing, add a row naming one of its record ids in `select` plus your "
328
362
  + "`tier` and `reason` (and `borderline` if the call is close) — the driver resolves the id against the "
@@ -91,9 +91,11 @@ function indexRows(rows, { bySelection = true, uriLeg = true } = {}) {
91
91
  * @returns {{form, total, settled, outstanding, carried, seat_rows}}
92
92
  * carried — settled rows this attempt's own submission would NOT have carried on its own: exactly the
93
93
  * work a killed or cold re-dispatch used to destroy.
94
+ * covered_lines — with `{ covered: true }` only: each set-aside group's covered line keys, sorted. Kept off
95
+ * the form, so the step is never said back a list of the lines its row covers.
94
96
  * PURE; never throws.
95
97
  */
96
- export function unionPlacementForm(prior, submitted, input) {
98
+ export function unionPlacementForm(prior, submitted, input, { covered = false } = {}) {
97
99
  const index = buildSelectionIndex(input);
98
100
  const priorRows = Array.isArray(prior) ? prior : (Array.isArray(prior?.rows) ? prior.rows : []);
99
101
  const submittedRows = Array.isArray(submitted) ? submitted : (Array.isArray(submitted?.rows) ? submitted.rows : null);
@@ -314,12 +316,15 @@ export function unionPlacementForm(prior, submitted, input) {
314
316
  const placedLines = new Set(form.rows.filter((row) => row.kind === "register" && rowIsSettled(row, row)).map((row) => lineKey(row)).filter(Boolean));
315
317
  const groupOf = groupOfKeys(groups);
316
318
  const lineOf = (g, key) => (Array.isArray(groups?.[g]?.list) ? groups[g].list : []).find((l) => l.key === key) ?? null;
317
- const covers = new Map(), floorsLeft = new Map();
319
+ const covers = new Map(), floorsLeft = new Map(), coveredKeys = new Map();
318
320
  for (const [key, g] of groupOf) {
319
321
  if (!setAsideGroups.has(g) || placedLines.has(key) || setAside.has(key)) continue;
320
322
  const line = lineOf(g, key);
321
323
  if ((line?.floors ?? 0) > 0 && !groupRowCoversFloors(groups, g)) floorsLeft.set(g, [...(floorsLeft.get(g) ?? []), line.record_id ?? key]);
322
- else covers.set(g, (covers.get(g) ?? 0) + 1);
324
+ else {
325
+ covers.set(g, (covers.get(g) ?? 0) + 1);
326
+ if (covered) { if (!coveredKeys.has(g)) coveredKeys.set(g, []); coveredKeys.get(g).push(key); }
327
+ }
323
328
  }
324
329
  for (const [g, e] of setAsideGroups) {
325
330
  e.covers_lines = covers.get(g) ?? 0;
@@ -331,9 +336,11 @@ export function unionPlacementForm(prior, submitted, input) {
331
336
  // or more, stands on its row and its count. Said back once the step has answered, on this turn or an earlier one:
332
337
  // the form written before any answer would list every floor of the band, and a turn whose form will not parse
333
338
  // leaves the earlier answer standing, its floors still owed.
334
- const groundOf = setAsideGrounds([...setAside.values(), ...setAsideGroups.values()], index, groups, input?.floors);
339
+ const rendered = renderPlacementsJson(form.rows).placements;
340
+ const groundOf = setAsideGrounds([...setAside.values(), ...setAsideGroups.values()], index, groups, input?.floors,
341
+ { placedUris: rendered.flatMap((p) => (Array.isArray(p?.records) ? p.records : [])) });
335
342
  const duty = reconcileFloorDuty({ floors: Array.isArray(input?.floors) ? input.floors : [],
336
- placements: renderPlacementsJson(form.rows).placements, setAsideGroundOf: groundOf });
343
+ placements: rendered, setAsideGroundOf: groundOf });
337
344
  const unfated = new Map();
338
345
  for (const r of duty.rows) {
339
346
  if (r.disposition !== "unanswered" && r.disposition !== "named-without-ground") continue;
@@ -349,7 +356,8 @@ export function unionPlacementForm(prior, submitted, input) {
349
356
  if (answered && unfated.size) form.floors_without_fate = [...unfated.values()];
350
357
  return { form, total: form.rows.length, settled, outstanding: form.rows.length - settled,
351
358
  carried, seat_rows: carriedSeat.length, unresolved: unresolved.length, set_aside: setAside.size, set_aside_groups: setAsideGroups.size,
352
- floors_without_fate: [...unfated.values()].reduce((n, e) => n + e.floors.length, 0) };
359
+ floors_without_fate: [...unfated.values()].reduce((n, e) => n + e.floors.length, 0),
360
+ ...(covered ? { covered_lines: Object.fromEntries([...coveredKeys].map(([g, keys]) => [g, [...keys].sort()])) } : {}) };
353
361
  }
354
362
 
355
363
  /** How many rows of an already-unioned form the render would still omit. PURE. */
@@ -241,14 +241,17 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
241
241
  }
242
242
 
243
243
  /**
244
- * The SET of record_fetch target URIs for the run identified by `runPrefix`. Backs the Fix-1b acceptance
245
- * gate (the screen-gate predicate cross-checks each in-scope-live goods/field drop against this set). Reuses
246
- * the same gateway-namespace stripping + run-prefix matching as tallyRegisterCalls; read-only and defensive
247
- * (missing/unreadable ledger or a torn last line ⇒ skips that line, never throws). A cache_hit record_fetch
248
- * still counts — the URI WAS fetched this run, which is exactly what the gate asks.
244
+ * The SET of record_fetch target URIs this run fetched SUCCESSFULLY, for the run identified by `runPrefix`.
245
+ * Backs the Fix-1b acceptance gate (the screen-gate predicate cross-checks each in-scope-live goods/field drop
246
+ * against this set). Reuses the same gateway-namespace stripping + run-prefix matching as tallyRegisterCalls;
247
+ * read-only and defensive (missing/unreadable ledger or a torn last line ⇒ skips that line, never throws). A
248
+ * cache_hit record_fetch that succeeded still counts — the record WAS retrieved this run, which is what the
249
+ * gate asks. A fetch the register refused or that failed in transport (`ok: false`) does not: an attempt is
250
+ * not a retrieval, and the gate's own recovery fetch, then its disclosure, is what such a drop is owed (ruled
251
+ * 2026-10-18). One success among the target's rows is enough, whatever came before or after it.
249
252
  * @param {string} ledgerPath path to the JSONL ledger
250
253
  * @param {string} runPrefix e.g. `clearance-acme-bluejay-` (note the trailing hyphen)
251
- * @returns {Set<string>} the set of fetched record URIs
254
+ * @returns {Set<string>} the set of record URIs this run fetched successfully
252
255
  */
253
256
  export function fetchedRecordUris(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix) {
254
257
  const out = new Set();
@@ -262,7 +265,7 @@ export function fetchedRecordUris(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix) {
262
265
  let row;
263
266
  try { row = JSON.parse(line); } catch { continue; } // tolerate a torn concurrent-append line
264
267
  if (!rowMatchesRun(row, runPrefix)) continue;
265
- if (row.tool === "record_fetch" && typeof row.target === "string" && row.target) out.add(row.target);
268
+ if (row.tool === "record_fetch" && row.ok === true && typeof row.target === "string" && row.target) out.add(row.target);
266
269
  }
267
270
  return out;
268
271
  }
@@ -245,7 +245,7 @@ export function recordsNeverDelivered(readingLog) {
245
245
  .sort((a, b) => b.misses - a.misses || a.record_id.localeCompare(b.record_id));
246
246
  }
247
247
 
248
- export function buildAuditMd(registerMd, commonLawMd, { findings: runFindings = null, doubts = null, asks = null, readingLog = null, commonLawGrid = null, doubtTruncations = null, registerPresence = null } = {}) {
248
+ export function buildAuditMd(registerMd, commonLawMd, { findings: runFindings = null, doubts = null, asks = null, readingLog = null, commonLawGrid = null, doubtTruncations = null, registerPresence = null, placementRows = null } = {}) {
249
249
  const reg = parseTables(registerMd);
250
250
  const cl = parseTables(commonLawMd);
251
251
 
@@ -301,7 +301,10 @@ export function buildAuditMd(registerMd, commonLawMd, { findings: runFindings =
301
301
  result_summary: get(r, "Result Summary", "Result", "Summary"),
302
302
  tool_call: get(r, "Tool Call #", "Tool Call", "Call"),
303
303
  finding_ref: get(r, "Finding Reference", "Finding Ref", "Ref"),
304
- })));
304
+ })))
305
+ // Placement's decisions, in the same fields: the reference, the ground and a group row's count, as the run
306
+ // recorded them (pipeline.mjs placementAuditRows), so a set-aside is traceable in the delivered audit.
307
+ .concat(Array.isArray(placementRows) ? placementRows : []);
305
308
 
306
309
  // One resolution per claim, GENERALIZED (2026-07-22, doubt-stitch): a block that joins a findings.json
307
310
  // finding of ANY disposition gets the run's own recorded resolution stamped onto it. The withdrawn
@@ -14,7 +14,7 @@ import { parseReport, parseAudit, parseSections, parseBlocks, stripInternal, par
14
14
  import { renderHtml, parseActionBuckets, actYouConditions } from './render.mjs';
15
15
  import { buildAudit } from './xlsx.mjs'; import { readDegradedPartRows, degradedPartRows, degradedAtPublish, storeStates, readDeliveredStores, mergeDegradedRows } from '../degraded-parts.mjs'; import { readDeclinations } from '../declination-tool.mjs'; import { frameSetAsideRows } from '../web-grid.mjs'; // — what synthesis set aside, with its grounds; and the stores the matter frame set aside, with its reason
16
16
  import { parseFindingsJson, parseFindingsJsonLenient, deriveDisplayVerdict, joinFindingToBlock, CLIENT_TIER_BY_COMPOSITE, projectCoverageJudgment } from '../findings-model.mjs';
17
- import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; import { coverageFormStamp, readCoverageForm } from '../coverage-form-io.mjs'; import { readReleasedFamilies } from '../withheld-families.mjs'; import { unitLabel } from '../coverage-form.mjs'; import { coverageUnitLabel } from '../coverage-ledger.mjs'; import { recallReceiptForOwnCompany } from '../recall-receipt.mjs'; // — and why an absence did not close; whose recall checks an audit lists
17
+ import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; import { coverageFormStamp, readCoverageForm } from '../coverage-form-io.mjs'; import { readReleasedFamilies, readWithheldFamilies } from '../withheld-families.mjs'; import { unitLabel } from '../coverage-form.mjs'; import { coverageUnitLabel } from '../coverage-ledger.mjs'; import { recallReceiptForOwnCompany } from '../recall-receipt.mjs'; // — and why an absence did not close; whose recall checks an audit lists
18
18
  import { clearanceReportData } from './report-data.mjs';
19
19
  import { searchDepthRecord, planTerritoriesOf, caseLawPassRecord } from './search-depth.mjs'; import { bandRecords } from '../named-band.mjs'; import { CASELAW_BRIDGES } from '../engine/mcp/gather-config.mjs'; // how much was read to reach the answer, as counts and tokens; and the case-law sources whose calls the court state reads
20
20
  import { parseFrameworkManifest } from '../framework.mjs'; import { readFrozenMethod, FROZEN_METHOD_FILE } from '../framework-method.mjs';
@@ -1868,12 +1868,27 @@ function officeLinksFor(findings, recordsByUri, runOrigins) {
1868
1868
  return links;
1869
1869
  }
1870
1870
 
1871
- /** The coverage form's family rows judged withheld, as audit-workbook coverage rows. Never throws. */
1871
+ /**
1872
+ * The coverage form's family rows judged withheld, as audit-workbook coverage rows. Never throws.
1873
+ *
1874
+ * A family judged withheld is a recorded judgment and not open work: it takes the shipped `Note` presentation a
1875
+ * released family's row has. Its reason is the reading turn's, read from the turn's own record, never the words a
1876
+ * later digest gave the form's row; a family the turn left unrecorded, which the form itself settled, keeps the
1877
+ * form's reason. That is the same set the register coverage line and the reviewer's table count as withheld
1878
+ * (waitingFamilyStates), so the workbook never calls open work what the class line has already left out of it.
1879
+ * A family the turn released after the form settled it ran: its own row, from the release record, says so, and
1880
+ * the form's stale withheld row is not shown beside it.
1881
+ */
1872
1882
  export function withheldFamilyRows(runDir) {
1873
1883
  try {
1874
1884
  const { rows } = readCoverageForm(runDir, coverageFormStamp(runDir).formName);
1875
- return (rows ?? []).filter((r) => r?.kind === 'family' && r.status === 'withheld-by-judgment' && r.reason)
1876
- .map((r) => ({ area: coverageUnitLabel(r.unit), state: 'not-searched', note: String(r.reason), done: '', left: String(r.reason) }));
1885
+ const judged = readWithheldFamilies(runDir);
1886
+ const released = readReleasedFamilies(runDir);
1887
+ return (rows ?? []).filter((r) => r?.kind === 'family' && r.status === 'withheld-by-judgment' && r.reason && !Object.hasOwn(released, String(r.qid ?? '')))
1888
+ .map((r) => {
1889
+ const reason = String(judged[String(r.qid ?? '')]?.reason ?? r.reason).trim();
1890
+ return { area: coverageUnitLabel(r.unit), state: 'note', note: reason, done: reason, left: '' };
1891
+ });
1877
1892
  } catch { return []; }
1878
1893
  }
1879
1894
 
@@ -141,22 +141,22 @@ const treatmentCell = f => f?.disposition === 'withdrawn'
141
141
  // Record retrieved? — the plain-English state of the register-record fetch for a finding, from the per-URI
142
142
  // fetch-state map ({retrieved | failed | not-attempted}). A common-law finding (no registrations) is
143
143
  // "n/a — live listing"; a failed fetch is VISIBLY unverified, never presented as confirmed.
144
- function retrievedCell(f, fetchState) {
144
+ export function retrievedCell(f, fetchState) { // @internal — driven by its test
145
145
  const regs = f?.owner?.registrations || [];
146
146
  if (!regs.length) return 'n/a — live listing';
147
147
  const st = uri => (fetchState && fetchState[String(uri || '').toLowerCase()]) || 'not-attempted';
148
148
  const states = regs.map(r => st(r.uri));
149
- if (states.every(s => s === 'retrieved')) return 'Read from the register record';
149
+ if (states.every(s => s === 'retrieved')) return 'Retrieved from the register';
150
150
  const anyRead = states.some(s => s === 'retrieved');
151
151
  const anyFailed = states.some(s => s === 'failed');
152
152
  // no leg read: a fetch that FAILED (a 404) makes the registry values unverified — say so visibly; a leg
153
153
  // simply never pulled (off-field context) is the softer "not individually retrieved".
154
154
  if (!anyRead) return anyFailed ? 'Not retrieved — details unverified' : 'Not individually retrieved';
155
- // mixed — spell out which country legs read and which did not ("CA read; US/AU not retrieved").
155
+ // mixed — spell out which country legs were retrieved and which were not ("CA retrieved; US/AU not retrieved").
156
156
  const read = regs.filter(r => st(r.uri) === 'retrieved').map(r => uriCountry(r.uri)).filter(Boolean);
157
157
  const missing = regs.filter(r => st(r.uri) !== 'retrieved').map(r => uriCountry(r.uri)).filter(Boolean);
158
158
  const parts = [];
159
- if (read.length) parts.push(`${read.join('/')} read`);
159
+ if (read.length) parts.push(`${read.join('/')} retrieved`);
160
160
  if (missing.length) parts.push(`${missing.join('/')} not retrieved`);
161
161
  return parts.join('; ') || 'Not individually retrieved';
162
162
  }
@@ -442,7 +442,7 @@ function coverageOpen({ coverage = [], coverageJudgment = null, verdict = null,
442
442
  return openState || judgeUnsure || verdictCoverage;
443
443
  }
444
444
 
445
- function summaryRows(findings, coverage, fm, { mark, coverageJudgment, verdict, jurisdiction, registerOnly = false, commonLawUnlogged = false, clientGate = null, lintFailures = [], productName = null, degradedParts = [] } = {}) {
445
+ export function summaryRows(findings, coverage, fm, { mark, coverageJudgment, verdict, jurisdiction, registerOnly = false, commonLawUnlogged = false, clientGate = null, lintFailures = [], productName = null, degradedParts = [] } = {}) {
446
446
  const disp = d => findings.filter(f => f.disposition === d && !f.isContextNote).length;
447
447
  const rated = findings.filter(f => !f.isContextNote);
448
448
  // A part that failed and still shipped is open too, so the count and the caveat never read complete
@@ -540,7 +540,7 @@ function summaryRows(findings, coverage, fm, { mark, coverageJudgment, verdict,
540
540
  : 'every search we ran — register and common-law — so you can see we looked everywhere.' },
541
541
  { Field: ' Coverage & gaps', Value: 'what is fully cleared and what is still open — read this first if you have a doubt.' },
542
542
  { Field: ' Evidence tags', Value: '“from source” = checked against the actual register record or live listing · “our read” = our own assessment.' },
543
- { Field: ' Record retrieved?', Value: 'whether the register entry itself was pulled and read; where a record could not be retrieved, its details are shown unverified.' },
543
+ { Field: ' Record retrieved?', Value: 'whether the register entry itself was retrieved; where a record could not be retrieved, its details are shown unverified.' },
544
544
  ];
545
545
  return rows;
546
546
  }
@@ -209,24 +209,30 @@ export function groupByPosition(rankedCandidates, positions = []) {
209
209
  *
210
210
  * Deterministic; returns the machine artifact (no timestamps — the caller stamps `ts`).
211
211
  */
212
- export function reconcileRecall({ candidates = [], endings, crowdRulings = [], topSlice = RECALL_TOP_SLICE, positions = [], dominantElement = null, inScopeClasses = [] } = {}) {
212
+ export function reconcileRecall({ candidates = [], endings, crowdRulings = [], topSlice = RECALL_TOP_SLICE, positions = [], dominantElement = null, inScopeClasses = [], placement = null } = {}) {
213
213
  const carried = endings?.carried ?? new Set();
214
214
  const dropRows = endings?.dropRows ?? new Set();
215
+ // PLACEMENT'S DECISIONS END A POSITION TOO, now that the delivered audit carries them (ruled 2026-10-18): a
216
+ // set-aside by name is an individual ending, for any position; a group's row ends only a residual position,
217
+ // for the members it validly covers, as a ruled crowd does, never a top-slice one that must end individually.
218
+ const byName = placement?.byName ?? new Set(), byGroup = placement?.byGroup ?? new Set();
215
219
  const ranked = rankCandidates(candidates);
216
220
  const groups = groupByPosition(ranked, positions);
217
221
  // ANY constituent URI ends the position — drop row first so an explicit negative is reported as
218
222
  // the reason even when the same position is also mentioned elsewhere.
219
223
  // match on the lowercased key, REPORT the URI in the band's own casing (see groupByPosition)
220
- const endingOf = (g) => {
224
+ const endingOf = (g, tier) => {
221
225
  for (let i = 0; i < g.members.length; i++) if (dropRows.has(g.members[i])) return { ending: "drop-row", ended_by: g.member_display[i] };
222
226
  for (let i = 0; i < g.members.length; i++) if (carried.has(g.members[i])) return { ending: "finding", ended_by: g.member_display[i] };
227
+ for (let i = 0; i < g.members.length; i++) if (byName.has(g.members[i])) return { ending: "placement-set-aside", ended_by: g.member_display[i] };
228
+ if (tier === "residual") for (let i = 0; i < g.members.length; i++) if (byGroup.has(g.members[i])) return { ending: "placement-group", ended_by: g.member_display[i] };
223
229
  return { ending: null, ended_by: null };
224
230
  };
225
231
  const top = groups.slice(0, Math.max(0, topSlice));
226
232
  const rest = groups.slice(Math.max(0, topSlice));
227
233
  const row = (g, tier) => {
228
234
  const c = g.candidates[0]; // the position's strongest constituent candidate
229
- const { ending, ended_by } = endingOf(g);
235
+ const { ending, ended_by } = endingOf(g, tier);
230
236
  return {
231
237
  record_id: c.record_id, mark_text: c.mark_text, basis: c.basis, classes: c.classes,
232
238
  status: c.status, registry: c.registry, application_date: c.application_date ?? null,
@@ -261,6 +267,9 @@ export function reconcileRecall({ candidates = [], endings, crowdRulings = [], t
261
267
  in_scope_classes: inScopeClasses,
262
268
  top_slice: topRows,
263
269
  crowd: { rulings: crowdRulings, declared, residual: residualUnended.length, covers: crowdCovers },
270
+ // The records the gate weighed, by id: the population its count is out of, so a sentence stated with another
271
+ // gate's records can count each record once.
272
+ candidate_ids: [...new Set(ranked.map((c) => lc(normalizeRecordUri(c.record_id) || c.record_id || "")).filter(Boolean))],
264
273
  totals: {
265
274
  candidates: ranked.length,
266
275
  positions: groups.length,
@@ -268,6 +277,7 @@ export function reconcileRecall({ candidates = [], endings, crowdRulings = [], t
268
277
  ended_finding: [...topRows, ...restRows].filter((r) => r.ending === "finding").length,
269
278
  ended_drop_row: [...topRows, ...restRows].filter((r) => r.ending === "drop-row").length,
270
279
  ended_crowd: restRows.filter((r) => r.ending === "crowd").length,
280
+ ended_placement: [...topRows, ...restRows].filter((r) => r.ending === "placement-set-aside" || r.ending === "placement-group").length,
271
281
  unended: unended.length,
272
282
  },
273
283
  unended,
@@ -282,7 +292,7 @@ export function reconcileRecall({ candidates = [], endings, crowdRulings = [], t
282
292
  * Every field below is written on every row; absence is expressed as an explicit null. PURE.
283
293
  */
284
294
  export const RECALL_EVENT_FIELDS = ["candidates", "positions", "top_slice", "ended_finding",
285
- "ended_drop_row", "ended_crowd", "unended", "crowd_declared", "crowd_residual"];
295
+ "ended_drop_row", "ended_crowd", "ended_placement", "unended", "crowd_declared", "crowd_residual"];
286
296
 
287
297
  export function recallReconciliationEvent({ trigger = null, artifact = null, reason = null } = {}) {
288
298
  const computable = artifact?.computable === true;
@@ -367,7 +377,7 @@ export function buildReconcileFollowup(artifact, { registerFindingsPath, hasCove
367
377
  : "";
368
378
  };
369
379
  const lines = [
370
- `You are RESUMING your own register-digest session. The driver's retrieved-to-judgment RECONCILIATION found ${un.length} screened-live dominant-element POSITION(s) that end NOWHERE a reader can see — retrieved, screened in an instructed class, then silent. A report never ships losing screened candidates silently. The unit is the POSITION (the exact-identity collapse in the shape's "## Positions" section): one row ends the whole position, and citing ANY ONE constituent URI is enough. End each one now:`,
380
+ `This is a fresh session: your earlier register-digest work is not in your context. Your prior digest is ${registerFindingsPath}. The driver's retrieved-to-judgment RECONCILIATION found ${un.length} screened-live dominant-element POSITION(s) that end NOWHERE a reader can see — retrieved, screened in an instructed class, then silent. A report never ships losing screened candidates silently. The unit is the POSITION (the exact-identity collapse in the shape's "## Positions" section): one row ends the whole position, and citing ANY ONE constituent URI is enough. End each one now:`,
371
381
  ];
372
382
  if (topUn.length) {
373
383
  lines.push(