clearotron 0.5.0-beta.2 → 0.5.0-beta.4

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 (73) hide show
  1. package/.env.example +7 -0
  2. package/build-info.json +2 -2
  3. package/docs/architecture/03-run-lifecycle.md +6 -0
  4. package/docs/architecture/04-configuration-reference.md +1 -0
  5. package/docs/architecture/05-config-governance.md +1 -1
  6. package/driver/CHANGELOG.md +21 -0
  7. package/driver/band-shape.mjs +19 -11
  8. package/driver/contract-vocabulary.mjs +3 -3
  9. package/driver/coverage-form-io.mjs +3 -1
  10. package/driver/coverage-form.mjs +21 -2
  11. package/driver/coverage-ledger.mjs +7 -1
  12. package/driver/declination-call.mjs +33 -16
  13. package/driver/degraded-parts.mjs +6 -0
  14. package/driver/engine/mcp/band-server.mjs +12 -12
  15. package/driver/engine/mcp/clarivate-server.mjs +2 -0
  16. package/driver/engine/mcp/codex-config.mjs +12 -7
  17. package/driver/engine/mcp/corsearch-server.mjs +2 -0
  18. package/driver/engine/mcp/declination-server.mjs +4 -5
  19. package/driver/engine/mcp/dispositions-server.mjs +7 -0
  20. package/driver/engine/mcp/euipo-server.mjs +2 -0
  21. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  22. package/driver/engine/mcp/gather-config.mjs +15 -0
  23. package/driver/engine/mcp/held-answers.mjs +11 -3
  24. package/driver/engine/mcp/perplexity-server.mjs +29 -1
  25. package/driver/engine/mcp/recording-server.mjs +16 -1
  26. package/driver/engine/mcp/signa-server.mjs +2 -0
  27. package/driver/engine/mcp/supplemental.mjs +11 -3
  28. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  29. package/driver/engine/openai-agent.mjs +48 -9
  30. package/driver/envelope-settle.mjs +13 -3
  31. package/driver/findings-model.mjs +17 -0
  32. package/driver/gateway.mjs +31 -10
  33. package/driver/package.json +1 -1
  34. package/driver/parts-not-completed.mjs +52 -11
  35. package/driver/pipeline-knockout.mjs +5 -4
  36. package/driver/pipeline.mjs +474 -81
  37. package/driver/placement-decisions.mjs +24 -9
  38. package/driver/placement-form-io.mjs +21 -1
  39. package/driver/placement-form.mjs +25 -15
  40. package/driver/placement-union.mjs +53 -8
  41. package/driver/predelivery-lint.mjs +8 -1
  42. package/driver/publish/index.mjs +7 -3
  43. package/driver/register-digest-record.mjs +6 -3
  44. package/driver/research-answers.mjs +54 -7
  45. package/driver/runner.mjs +4 -4
  46. package/driver/saved-findings-block.mjs +36 -0
  47. package/driver/skills/clearance-register/digest.md +1 -1
  48. package/driver/skills/clearance-search/phase2-execution.md +7 -5
  49. package/driver/skills/clearance-search/synthesis-rules.md +3 -3
  50. package/driver/skills/placement-inquiry/SKILL.md +3 -3
  51. package/driver/stage-context.mjs +18 -0
  52. package/driver/stages.mjs +41 -13
  53. package/driver/suite-census.json +140 -14
  54. package/driver/synthesis-record.mjs +167 -1
  55. package/driver/verify.mjs +6 -6
  56. package/mcp-server/CHANGELOG.md +8 -0
  57. package/mcp-server/package.json +1 -1
  58. package/package.json +1 -1
  59. package/portal-ui/package.json +1 -1
  60. package/providers/_shared/README.md +1 -0
  61. package/providers/_shared/enumerate.mjs +4 -3
  62. package/providers/_shared/execute-plan.mjs +33 -7
  63. package/providers/_shared/send-until.mjs +25 -0
  64. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  65. package/providers/oauth-mcp-bridge/package.json +1 -1
  66. package/scripts/band-shape-probe.mjs +1 -1
  67. package/scripts/e2e-first-time.mjs +2 -1
  68. package/scripts/release-notes-lint.mjs +1 -1
  69. package/shared/inside-run.mjs +54 -0
  70. package/shared/names-in-force.mjs +1 -0
  71. package/shared/slices-taken-out.mjs +65 -0
  72. package/skills/clearotron-account/SKILL.md +1 -1
  73. package/skills/clearotron-ops/SKILL.md +1 -1
@@ -13,9 +13,9 @@
13
13
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
14
  import { dirname } from "node:path";
15
15
  import { createHash } from "node:crypto";
16
- import { buildSelectionIndex, setAsideGrounds, isSetAsideGroupRow, lineKey, groupName, servedLines } from "./placement-form.mjs";
16
+ import { buildSelectionIndex, setAsideGrounds, isSetAsideGroupRow, lineKey, groupName, servedLines, FLOOR_GROUND_ON } from "./placement-form.mjs";
17
17
  import { unionPlacementForm } from "./placement-union.mjs";
18
- import { readPlacementForm, readPlacementFormInput } from "./placement-form-io.mjs";
18
+ import { readPlacementForm, readPlacementFormInput, placementFormStamp, placementFormPaths } from "./placement-form-io.mjs";
19
19
  import { parsePlacementsJson } from "./placement-model.mjs";
20
20
 
21
21
  const safeReadJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
@@ -78,7 +78,7 @@ export function placementSetAsideGrounds(P) { // @internal — driven by its t
78
78
 
79
79
  /**
80
80
  * THE FLOORS PLACEMENT SET ASIDE, AS THE REVIEW IS TOLD THEM: one line per standing decision on the shape now on disk,
81
- * with placement's ground once. A set-aside by name, on its status or its territory, covers one floor and names it. A
81
+ * with placement's ground once. A set-aside by name, on its status, territory, goods or mark, covers one floor and names it. A
82
82
  * crowd's row names its ground once and then every floor it covers, by record: the ground is the group's, so it was
83
83
  * never the floors' to repeat, and on a test run of a crowded matter repeating it on each of 1,177 floors made the
84
84
  * review's opening message 866 KB of its 899. Only decisions that stand on the current shape are listed
@@ -99,7 +99,7 @@ export function placementFloorDecisionLines(P) { // @internal — driven by it
99
99
  const key = `${g.group}\u0000${g.ground}`;
100
100
  if (!rows.has(key)) { rows.set(key, { group: g.group, ground: g.ground, ids: [] }); out.push(rows.get(key)); }
101
101
  rows.get(key).ids.push(f.record_id);
102
- } else if (g.on === "status" || g.on === "territory") {
102
+ } else if (FLOOR_GROUND_ON.includes(g.on)) {
103
103
  out.push(`- ${f.mark_text ?? "(unnamed)"}${f.owner_name ? ` — ${f.owner_name}` : ""} (${f.record_id}): set aside on its ${g.on}: ${g.ground}`);
104
104
  }
105
105
  }
@@ -118,9 +118,20 @@ export function placementFloorDecisionLines(P) { // @internal — driven by it
118
118
  * enough for a stage to open. [] when nothing stands or nothing can be read. IMPURE (reads the run).
119
119
  */
120
120
  export function standingPlacementDecisions(P) { // @internal — driven by its test with a run's own files
121
+ // A FORM THAT IS NOT THERE IS NOT "NOTHING SET ASIDE". Every failure here used to read as an empty list, so a writer in
122
+ // the experiment sandbox, which held no form, was told of no set-aside at all on a run whose placement had set 178
123
+ // aside. Where this run's placement records its decisions on a form (the era stamp), a missing or unreadable form is
124
+ // refused by name, and so is a failure reading what stands. A run from before the form had none, and its empty list
125
+ // is true.
126
+ const form = readPlacementForm(P.runDir);
127
+ if (form.error) throw new Error(`placement_form_unreadable: ${form.error} — placement's set-asides cannot be read, and an unread form is not "none set aside"`);
128
+ if (!form.present) {
129
+ if (placementFormStamp(P.runDir).required)
130
+ throw new Error(`placement_form_missing: this run's placement records its decisions on a form, and ${placementFormPaths(P.runDir).sidecar} is not there — refusing to read its absence as "none set aside"`);
131
+ return [];
132
+ }
133
+ if (!form.set_aside?.length) return [];
121
134
  try {
122
- const form = readPlacementForm(P.runDir);
123
- if (!form.set_aside?.length) return [];
124
135
  const input = readPlacementFormInput(P.runDir);
125
136
  const index = buildSelectionIndex(input);
126
137
  return standingSetAside(form.set_aside, input, { placedUris: placedUriRows(P).map((r) => r.uri), rows: form.rows }).flatMap((e) => {
@@ -130,9 +141,9 @@ export function standingPlacementDecisions(P) { // @internal — driven by its
130
141
  covered_lines_hash: createHash("sha256").update((e.covered_lines ?? []).join("\n")).digest("hex").slice(0, 16) }];
131
142
  const c = index.resolve(e.set_aside);
132
143
  return [{ kind: "name", record: e.set_aside, mark: c?.mark ?? null, owner: c?.owner ?? null, ground: e.ground,
133
- ...(e.on === "status" || e.on === "territory" ? { on: e.on } : {}) }];
144
+ ...(FLOOR_GROUND_ON.includes(e.on) ? { on: e.on } : {}) }];
134
145
  });
135
- } catch { return []; }
146
+ } catch (e) { throw new Error(`placement_decisions_unreadable: ${String(e?.message ?? e).slice(0, 200)} — what placement set aside could not be worked out from its form`); }
136
147
  }
137
148
 
138
149
  /**
@@ -144,7 +155,11 @@ export function rankingNow(P) { // @internal — driven by its test with a run
144
155
  const records = new Map();
145
156
  for (const r of placedUriRows(P)) records.set(String(r.uri).toLowerCase(), `placed:${r.tier ?? ""}`);
146
157
  const groups = new Map();
147
- for (const d of standingPlacementDecisions(P)) {
158
+ // The re-rank count runs inside the delivery loop, and a run always delivers: here a refusal counts no decision, and
159
+ // the writer's own message, which reads the same decisions, is where it is refused.
160
+ let decisions = [];
161
+ try { decisions = standingPlacementDecisions(P); } catch { decisions = []; }
162
+ for (const d of decisions) {
148
163
  if (d.kind === "group") groups.set(d.group, { key: `${d.ground}|${d.covers_lines}|${d.covered_lines_hash}`, records: d.records });
149
164
  else records.set(String(d.record).toLowerCase(), `set aside${d.on ? ` on its ${d.on}` : ""}: ${d.ground}`);
150
165
  }
@@ -25,6 +25,7 @@ import { join, dirname, basename } from "node:path";
25
25
  import { driverDir } from "../shared/driver-dir.mjs"; //
26
26
  import { PLACEMENT_FORM_NAME, placementFormSidecarName, parsePlacementForm, renderPlacementsJson } from "./placement-form.mjs";
27
27
  import { parsePlacementsJson } from "./placement-model.mjs";
28
+ import { normalizeRecordUri } from "./registry-fidelity.mjs";
28
29
  import { atomicWrite } from "./progress.mjs";
29
30
 
30
31
  export { PLACEMENT_FORM_NAME };
@@ -54,13 +55,32 @@ export function placementFormStamp(runDir) {
54
55
  export function readPlacementFormInput(runDir) {
55
56
  const shape = readJson(driverDir(runDir, "band-shape.json"));
56
57
  const pos = readJson(driverDir(runDir, "register-positions.json"));
57
- return {
58
+ const scope = readJson(driverDir(runDir, "instructed-scope.json"));
59
+ const input = {
58
60
  floors: Array.isArray(shape?.floors?.in_class_identical_or_near) ? shape.floors.in_class_identical_or_near : null,
59
61
  positions: Array.isArray(pos?.positions) ? pos.positions : null,
60
62
  // The shape's groups, which a group row names and is checked against; null on a shape that has none.
61
63
  groups: shape?.groups && typeof shape.groups === "object" ? shape.groups : null,
62
64
  served: readServed(runDir),
65
+ // The applied-for marks, written by the job before any model ran: a ground on a floor's mark is refused on the
66
+ // client's own mark. A scope that names one mark as a string names it all the same.
67
+ marks: Array.isArray(scope?.marks) ? scope.marks : (typeof scope?.marks === "string" && scope.marks ? [scope.marks] : []),
63
68
  };
69
+ // What each record's goods rest on (goods-evidence.mjs), which a ground on a floor's goods is checked against. Read
70
+ // only when a ground needs it: the record runs to megabytes and the form is judged on every pass. null where the
71
+ // run wrote none, which establishes no goods.
72
+ let evidence;
73
+ Object.defineProperty(input, "evidence", { enumerable: false, get: () => (evidence === undefined ? (evidence = readGoodsStates(runDir)) : evidence) });
74
+ return input;
75
+ }
76
+
77
+ /** canonical uri → the state goods-evidence.mjs recorded for that record's goods; null with no record. */
78
+ export function readGoodsStates(runDir) {
79
+ const doc = readJson(driverDir(runDir, "goods-evidence.json"));
80
+ if (!Array.isArray(doc?.records)) return null;
81
+ const out = new Map();
82
+ for (const r of doc.records) { const k = normalizeRecordUri(r?.uri); if (k && typeof r?.state === "string") out.set(k, r.state); }
83
+ return out;
64
84
  }
65
85
 
66
86
  /**
@@ -112,11 +112,15 @@ export const SELECT_ROW_CONTRACT = Object.freeze({
112
112
  // (ruled 2026-10-12). It is not a candidate, it renders into no placement, and nothing bounds how many there are.
113
113
  export const SET_ASIDE_ROW_FIELDS = ["set_aside", "ground", "on"];
114
114
  /**
115
- * What a ground setting a floor aside by name may be about (ruled 2026-10-12): its status or its territory.
116
- * Never its goods, which decide whether it sits in (a) or (b), and in a crowd (b)'s row is the goods ground;
117
- * never its owner's customers or field, whatever `on` says, so the form reads the ground's words too.
115
+ * What a ground setting a floor aside by name may be about: its status, its territory, its registered goods or a
116
+ * reasoned comparison of its mark with the client's (ruled 2026-10-27, superseding the status-or-territory rule of
117
+ * 2026-10-12). Never its owner's customers or field. The union checks each against the run's own evidence: a goods
118
+ * ground needs the line read and every floor's goods held complete, and a mark ground is refused on the client's
119
+ * own mark. The words of a ground on status or territory are read too, for customers, field and goods.
118
120
  */
119
- export const FLOOR_GROUND_ON = Object.freeze(["status", "territory"]);
121
+ export const FLOOR_GROUND_ON = Object.freeze(["status", "territory", "goods", "mark"]);
122
+ /** The grounds whose words the form reads for customers, field and goods: the two that are about neither. */
123
+ export const FLOOR_GROUND_WORDS_READ = Object.freeze(["status", "territory"]);
120
124
  export const FLOOR_GROUND_REFUSED = /\b(?:customers?|consumers?|clients|clientele|users?|audiences?|buyers?|purchasers?|patients?|professionals?|channels?|markets?|marketplaces?|sectors?|fields?|industr(?:y|ies|ial)|business(?:es)?|activit(?:y|ies)|commerce|commercial|trad(?:e|es|ing)(?!\s*-?\s*marks?)|distribution|wholesale|retail(?:ers?)?|sells?|sold|selling|b2b|b2c|off[- ]field|goods|products?|services?(?!\s*-?\s*marks?)|merchandise)\b/i;
121
125
  /** The lists a group row may name: the shape's groups, the rest, and the lines the goods test could not place. */
122
126
  export const ROW_GROUPS = Object.freeze([...BAND_GROUPS, "no_goods"]);
@@ -124,24 +128,30 @@ export const SET_ASIDE_ROW_CONTRACT = Object.freeze({
124
128
  when: "a line the shape lists, one mark of one owner, whose records you do not carry. One row gives that "
125
129
  + "line its ground.",
126
130
  fields: SET_ASIDE_ROW_FIELDS,
127
- set_aside: "one record id of that line, from the band. The driver finds the line on the register's own "
128
- + "projection; the ground covers every record of that mark of that owner the form does not place, and "
129
- + "never another mark of the owner, which needs its own fate. A record that names no owner stands for its "
130
- + "own position only.",
131
+ set_aside: "One record id of the line, from the band, or a list of record ids, one per line, that share one ground. "
132
+ + "The ground covers every unplaced record of that mark of that owner. Another mark of the same owner needs its "
133
+ + "own fate. A record that names no owner stands for its own position only.",
131
134
  ground: "why this line's records are not carried, in a lawyer's words.",
132
- on: "where the line holds a floor, a live identical or near-identical mark in an ordered class: what the "
133
- + "ground is about, status or territory. A floor is never set aside on its goods, which decide its group, "
134
- + "nor on its owner, its customers or its field; a ground in those terms is refused.",
135
+ on: "Required when the line holds a floor (a live identical or near-identical mark in an ordered class): one of "
136
+ + "status, territory, goods, mark. Use goods when the registered goods, read in full, do not meet the client's "
137
+ + "particular goods. Use mark when a reasoned comparison with the client's mark shows the two do not conflict. "
138
+ + "The owner, its customers and its field are never a ground.",
135
139
  not_a_candidate: "It places nothing. A record of the same line you do place stays placed.",
136
140
  latest: "A later row for the same line replaces the earlier ground. To remove one, hand back "
137
141
  + "{\"retract\":\"<its set_aside id>\"}.",
138
- unresolved: "A `set_aside` the band does not hold, one with no ground, or a floor's row whose `on` is not "
139
- + "status or territory or whose ground speaks of customers, channels, markets or field, is reported back "
140
- + "to you by id.",
142
+ unresolved: "The driver says a row back by id when: its record is not in the band; it has no ground; a floor's "
143
+ + "`on` is missing or not one of the four; a status or territory ground speaks of goods, customers, channels, "
144
+ + "markets or field; a goods ground rests on a line not read, or on goods the run does not hold in full; a mark "
145
+ + "ground names the client's own mark, or the run holds no applied-for mark to compare with. In a list, each "
146
+ + "line is judged on its own.",
141
147
  });
142
148
 
149
+ /** The record ids a set-aside row names: one, or the list of lines one ground covers. PURE. */
150
+ export const setAsideMembers = (row) => (Array.isArray(row?.set_aside) ? row.set_aside : [row?.set_aside])
151
+ .filter((id) => typeof id === "string" && id.trim() !== "").map((id) => id.trim());
152
+
143
153
  /** Is this submitted row an owner's set-aside? PURE. */
144
- export const isSetAsideRow = (row) => typeof row?.set_aside === "string" && row.set_aside.trim() !== "";
154
+ export const isSetAsideRow = (row) => setAsideMembers(row).length > 0;
145
155
 
146
156
  // ONE ROW PER GROUP. The band shape lists the band in the lawyer's order, one line per mark of an owner,
147
157
  // the rest last. A listed line gets a fate, not a row: it is placed, or set aside by name, or covered
@@ -27,9 +27,11 @@ import { buildSelectionIndex, selectionOf, rowIsSettled, seatFields, formRowKey,
27
27
  SEAT_ROW_CONTRACT, SELECT_ROW_CONTRACT, PLACEMENT_FORM_PROVENANCE,
28
28
  SET_ASIDE_ROW_CONTRACT, isSetAsideRow,
29
29
  SET_ASIDE_GROUP_CONTRACT, isSetAsideGroupRow, groupName, groupOfKeys, namedKey, lineKey, servedLines,
30
- groupRowCoversFloors, groupRowCoversFloorLine, FLOOR_GROUND_ON, FLOOR_GROUND_REFUSED, ROW_GROUPS, renderPlacementsJson, setAsideGrounds } from "./placement-form.mjs";
30
+ groupRowCoversFloors, groupRowCoversFloorLine, FLOOR_GROUND_ON, FLOOR_GROUND_WORDS_READ, FLOOR_GROUND_REFUSED, ROW_GROUPS, renderPlacementsJson,
31
+ setAsideGrounds, setAsideMembers } from "./placement-form.mjs";
31
32
  import { groupPageCall } from "./band-shape.mjs";
32
33
  import { normalizeRecordUri } from "./registry-fidelity.mjs";
34
+ import { formKey } from "../providers/_shared/script-form.mjs"; // — the one fold for "is this the same form of the mark"
33
35
  import { reconcileFloorDuty } from "./floor-duty.mjs";
34
36
 
35
37
  /** Why a `select` the fold does not hold is refused, said back with it. */
@@ -162,8 +164,8 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
162
164
  // placements.json. Prior first, so a later row for the same owner replaces the earlier ground, and a
163
165
  // retraction names the record id the row set aside. A row the band cannot resolve, or one with no
164
166
  // ground, is reported back by id, never dropped in silence.
165
- // A ground by name covers the named line only. On a line holding a floor it is about the mark, its goods,
166
- // its status or its territory, and says which in `on`; a ground about the owner never sets a floor aside.
167
+ // A ground by name covers the named line only. On a line holding a floor it is about its status, its territory,
168
+ // its registered goods or its mark, and says which in `on`; a ground about the owner never sets a floor aside.
167
169
  const groups = input?.groups ?? null;
168
170
  const floorUris = new Set((Array.isArray(input?.floors) ? input.floors : []).map((f) => normalizeRecordUri(f?.record_id) || String(f?.record_id ?? "")).filter(Boolean));
169
171
  const holdsFloor = (canonical) => (canonical?.records ?? []).some((u) => floorUris.has(normalizeRecordUri(u) || String(u ?? "")));
@@ -174,10 +176,52 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
174
176
  ...["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)),
175
177
  ]);
176
178
  const lineHoldsFloor = (canonical) => holdsFloor(canonical) || floorLines.has(namedKey(canonical));
179
+ // THE FLOORS OF EACH LINE, which a ground on a floor's goods or mark is checked against record by record.
180
+ const floorsOfLine = new Map();
181
+ for (const f of (Array.isArray(input?.floors) ? input.floors : [])) {
182
+ const u = normalizeRecordUri(f?.record_id) || String(f?.record_id ?? "");
183
+ const k = u ? namedKey(index.resolve(u)) : null;
184
+ if (k) floorsOfLine.set(k, [...(floorsOfLine.get(k) ?? []), { uri: u, mark: f?.mark_text ?? null }]);
185
+ }
186
+ // The records a ground on a line's goods or mark must hold for: the line's floors and the records of the position
187
+ // the row names, so a line the projection keys apart from its floors is still checked on the records it names.
188
+ const checkedRecords = (canonical) => {
189
+ const byUri = new Map((floorsOfLine.get(namedKey(canonical)) ?? []).map((f) => [f.uri, f]));
190
+ for (const u of canonical?.records ?? []) { const k = normalizeRecordUri(u) || String(u ?? ""); if (k && !byUri.has(k)) byUri.set(k, { uri: k, mark: canonical?.mark ?? null }); }
191
+ return [...byUri.values()];
192
+ };
193
+ const servedSet = servedLines(groups, input?.served, index);
194
+ const clientMarks = new Set((Array.isArray(input?.marks) ? input.marks : []).map((m) => formKey(String(m ?? ""))).filter(Boolean));
195
+ // WHY A GROUND ON A FLOOR'S GOODS OR MARK DOES NOT HOLD, or "" where it does (ruled 2026-10-27). Identical spelling
196
+ // and a shared class no longer bar the judgment; what bars it is the evidence. A goods ground stands on a line the
197
+ // step read and on goods the run holds complete for every floor of it: cut, unknown or missing goods establish
198
+ // nothing. A mark ground is a comparison with the client's mark, so it cannot set aside the client's mark itself.
199
+ const evidenceProblem = (on, canonical) => {
200
+ const records = checkedRecords(canonical);
201
+ if (on === "goods") {
202
+ if (!servedSet.has(namedKey(canonical)))
203
+ return "this line has not been read. Read it in band_shape or its group's page, or open its record with band_record, before you set it aside on its goods";
204
+ const states = input?.evidence instanceof Map ? input.evidence : null;
205
+ if (!states) return "the run has no record of this line's goods, so a goods ground cannot be checked. Place the line, or set it aside on its status, territory or mark";
206
+ const short = records.filter((r) => states.get(r.uri) !== "complete");
207
+ if (short.length)
208
+ return `the run does not hold the goods of ${short.slice(0, 3).map((r) => `${r.uri} (${states.get(r.uri) ?? "not recorded"})`).join(", ")}${short.length > 3 ? `, and ${short.length - 3} more` : ""} in full, so a goods ground cannot stand. Place the line, or set it aside on its status, territory or mark`;
209
+ }
210
+ if (on === "mark") {
211
+ if (!clientMarks.size) return "the run has no applied-for mark to compare with. Place the line, or set it aside on its status, territory or goods";
212
+ const own = records.find((r) => clientMarks.has(formKey(String(r.mark ?? ""))));
213
+ if (own) return `${own.uri} carries the client's own mark, so a mark ground cannot set it aside. Place the line, or set it aside on its status, territory or goods`;
214
+ }
215
+ return "";
216
+ };
177
217
  const setAside = new Map();
178
218
  const setAsideRefused = [];
179
219
  const takeSetAside = (row) => {
180
- const id = String(row.set_aside).trim();
220
+ // A LIST UNDER ONE GROUND is taken line by line: each member stands or is reported back on its own evidence, so a
221
+ // shared ground covers only the lines it holds for, and the exceptions are said back by id.
222
+ const members = setAsideMembers(row);
223
+ if (members.length > 1) { for (const m of members) takeSetAside({ ...row, set_aside: m }); return; }
224
+ const id = members[0];
181
225
  const ground = typeof row.ground === "string" ? row.ground.trim() : "";
182
226
  const canonical = index.resolve(id);
183
227
  const refuse = (why) => { if (!setAsideRefused.some((x) => x.set_aside === id && x.why === why)) setAsideRefused.push({ set_aside: id, why }); };
@@ -185,8 +229,10 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
185
229
  if (!ground) { refuse("no ground"); return; }
186
230
  const on = typeof row.on === "string" ? row.on.trim().toLowerCase() : "";
187
231
  if (lineHoldsFloor(canonical)) {
188
- if (!FLOOR_GROUND_ON.includes(on)) { refuse(`a floor is set aside by name only on its status or its territory: give "on" as one of ${FLOOR_GROUND_ON.join(", ")}`); return; }
189
- if (FLOOR_GROUND_REFUSED.test(ground)) { refuse("a floor is never set aside on its owner's customers, channels, markets or field: write a ground about its status or its territory"); return; }
232
+ if (!FLOOR_GROUND_ON.includes(on)) { refuse(`a floor's set-aside needs "on": one of ${FLOOR_GROUND_ON.join(", ")}`); return; }
233
+ if (FLOOR_GROUND_WORDS_READ.includes(on) && FLOOR_GROUND_REFUSED.test(ground)) { refuse(`a ${on} ground is about the ${on} only, and this one speaks of goods, customers, channels, markets or field. Rewrite it about the ${on}, or send a ground about the registered goods with "on": "goods"`); return; }
234
+ const problem = evidenceProblem(on, canonical);
235
+ if (problem) { refuse(problem); return; }
190
236
  }
191
237
  const key = namedKey(canonical);
192
238
  setAside.delete(key);
@@ -217,7 +263,6 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
217
263
  // does not list, a count that is not the shape's, or a row with no ground is reported back, never dropped.
218
264
  const setAsideGroups = new Map();
219
265
  const groupRefused = [];
220
- const servedSet = servedLines(groups, input?.served, index);
221
266
  const takeGroup = (row) => {
222
267
  const written = String(row.set_aside_group).trim();
223
268
  const refuse = (why) => { if (!groupRefused.some((x) => x.set_aside_group === written && x.why === why)) groupRefused.push({ set_aside_group: written, why }); };
@@ -289,7 +334,7 @@ export function unionPlacementForm(prior, submitted, input, { covered = false }
289
334
  // AN IDENTICAL MARK LIVE IN AN ORDERED OR FRAME-ADDED CLASS IS NEVER OUT-OF-SCOPE-FILTERED. The tier is
290
335
  // refused, said back, and the row left open, so the floor duty still owes it a fate.
291
336
  if (row.tier === "out-of-scope-filtered" && holdsFloor(row)) {
292
- tierRefused.push({ select: row.select ?? null, why: "an identical mark live in an ordered or frame-added class is never out-of-scope-filtered: place it, sheet-2 at the least, or set it aside by name on its status or its territory" });
337
+ tierRefused.push({ select: row.select ?? null, why: "a floor never takes out-of-scope-filtered. Place it at headline-candidate or sheet-2, or set its line aside by name" });
293
338
  row.tier = null;
294
339
  }
295
340
  // JUDGED ON THE MERGED ROW, not on either input — a tier written on one pass and its reason on the
@@ -1256,11 +1256,18 @@ export function correctionConsistencyChecks({ reportMd, clientSummaryMd, auditMd
1256
1256
  const report = String(reportMd ?? "");
1257
1257
  const cs = String(clientSummaryMd ?? "");
1258
1258
  const audit = String(auditMd ?? "");
1259
+ // A CARD THAT CARRIES ITS OWN `- ord:` LINE IS THE FINDING THAT LINE NAMES. One owner can hold the same mark in
1260
+ // several records, each its own finding, and a live one keeps its card under the same head when another is
1261
+ // withdrawn: read by its head, it was the withdrawn finding back, and that one false failure closed the client
1262
+ // export of a crowded test run (2026-10-28). The ord line already answers for such a card, so the head match is
1263
+ // for a card with none, the older card shape.
1264
+ const unnumberedHeads = [...report.matchAll(/^##\s+([^\n]+)\n?((?:- [^\n]*\n?)*)/gm)]
1265
+ .filter(([, , meta]) => !/^-\s*ord:/m.test(meta)).map(([, h]) => h);
1259
1266
  for (const f of withdrawn) {
1260
1267
  const ord = f.ordinal;
1261
1268
  const markN = norm(f.mark), ownerN = norm(f.owner?.name);
1262
1269
  const ordHit = ord != null && new RegExp(`^-\\s*ord:\\s*${ord}\\s*$`, "m").test(report);
1263
- const headHit = [...report.matchAll(/^##\s+([^\n]+)$/gm)].some(([, h]) => {
1270
+ const headHit = unnumberedHeads.some((h) => {
1264
1271
  const hN = norm(h);
1265
1272
  return markN && ownerN && hN.includes(markN) && hN.includes(ownerN);
1266
1273
  });
@@ -1823,15 +1823,19 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1823
1823
  // doc-52 — the cover banner speaks the SAME plain "Only you can close these" wording as the report, never
1824
1824
  // the engine clamp reasons (opts.conditions = verdict.json reasons). Machinery-only clamps (no client
1825
1825
  // item authored) degrade to one generic plain line — never raw engine jargon, never truncated mid-sentence.
1826
- const emailConditions = opts?.verdict === 'CONDITIONAL' ? actYouConditions(parseActionBuckets(secs['Actions']).you) : [];
1826
+ // A BLOCKING RESULT KEEPS THE REPORT'S ITEMS TOO (ruled 2026-10-27). The box showed only on a CONDITIONAL result, so
1827
+ // a blocking one lost the client's own actions the report lists. It shows them under the report's heading in the
1828
+ // report's words, and only where the report names any: no empty box, no fallback line.
1829
+ const emailConditions = opts?.verdict === 'CONDITIONAL' || opts?.verdict === 'BLOCKING' ? actYouConditions(parseActionBuckets(secs['Actions']).you) : [];
1827
1830
  // THE REPORT IS THE MASTER (ruled 2026-09-22). The banner used to print a second summary composed
1828
1831
  // beside the report — "On hold — …" on a run the report called conditional, although nothing is ever
1829
1832
  // held — and before that the delivery gate's word. It now quotes the report: the rating, then the
1830
1833
  // report's own conclusion (below), and where the report names what only the client can close, those
1831
1834
  // items under the report's own heading. Nothing here composes a sentence of its own.
1835
+ const closeBox = (items) => `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>Only you can close these</b><br>${items.map((r) => `• ${cell(String(r))}`).join('<br>')}</p>`;
1832
1836
  const verdictBound = opts?.verdict === 'CONDITIONAL'
1833
- ? `<p style="margin:0 0 8px;padding:8px 10px;background:#fdeeee;border:1px solid #c98a86;color:#6e1512"><b>Only you can close these</b><br>${(emailConditions.length ? emailConditions : ['the open items set out in the report, before relying on a clean result']).map((r) => `• ${cell(String(r))}`).join('<br>')}</p>`
1834
- : '';
1837
+ ? closeBox(emailConditions.length ? emailConditions : ['the open items set out in the report, before relying on a clean result'])
1838
+ : opts?.verdict === 'BLOCKING' && emailConditions.length ? closeBox(emailConditions) : '';
1835
1839
 
1836
1840
  const reviewHeadline = `<div style="${FONT};font-size:11pt;color:#1a1a2e;margin:0 0 14px">`
1837
1841
  + flagBanner
@@ -146,13 +146,16 @@ export function accountedUris(model, owed = []) {
146
146
  * THE SHEET-2 ROWS THE DRIVER WRITES. A sheet-2 entry is adopted as placement placed it: one row, on the entry's first
147
147
  * live record (else its first), carrying placement's reason word for word, with verify "yes" because nothing has checked
148
148
  * it against the live register. An entry the write-up overrode gets none: any row of its own naming any of the entry's
149
- * records, or a disagreement resolution naming one, is its ending instead. An entry whose record the band does not
150
- * hold gets none. PURE.
149
+ * records, or an OVERRODE resolution naming one, is its ending instead. An ADOPTED resolution keeps placement's
150
+ * placement, so the entry keeps its row: counting it as an ending rendered an adoption exactly as an override, and
151
+ * kept entries vanished from the table the writer is owed from. An entry whose record the band does not hold gets
152
+ * none. PURE.
151
153
  */
152
154
  export function adoptedRows(model, facts = emptyFacts()) {
153
155
  const adopt = Array.isArray(facts?.adopt) ? facts.adopt : [];
154
156
  if (!adopt.length) return [];
155
- const named = accountedUris(model, adopt.flatMap((e) => e.uris));
157
+ const overrides = { ...model, disagreement_resolutions: (model?.disagreement_resolutions ?? []).filter((d) => d?.decision !== "ADOPTED") };
158
+ const named = accountedUris(overrides, adopt.flatMap((e) => e.uris));
156
159
  const out = [];
157
160
  for (const e of adopt) {
158
161
  if (e.uris.some((u) => named.has(u))) continue;
@@ -25,7 +25,7 @@
25
25
  // A first attempt has none, and its message is unchanged.
26
26
  //
27
27
  // A pure leaf: node:fs, node:path and shared/driver-dir.mjs only, so the research server can import it.
28
- import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
28
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
29
29
  import { dirname, join } from "node:path";
30
30
  import { driverDir } from "../shared/driver-dir.mjs";
31
31
 
@@ -51,9 +51,13 @@ export function keepResearchAnswer(runDir, { session = null, agent = null, task,
51
51
  /**
52
52
  * The research answers the session `sessionKey` and its retries got, oldest first, one per question: the latest answer
53
53
  * to each exact question. [] when there are none, or no record. A torn line is skipped. IMPURE (reads the record).
54
+ *
55
+ * `ofRun` widens it to every stage of this run: the writer is told to use an answer this run already holds, from an
56
+ * earlier stage as from an earlier attempt, and it cannot open `_driver/` itself. The record is the run's own file, so
57
+ * nothing another run asked can be in it.
54
58
  * @returns {{ task: string, text: string, ts: string }[]}
55
59
  */
56
- export function researchAnswersFor(runDir, sessionKey) {
60
+ export function researchAnswersFor(runDir, sessionKey, { ofRun = false } = {}) {
57
61
  if (!runDir || !sessionKey) return [];
58
62
  let raw = "";
59
63
  try { raw = readFileSync(driverDir(runDir, RESEARCH_ANSWERS_FILE), "utf8"); } catch { return []; }
@@ -63,7 +67,7 @@ export function researchAnswersFor(runDir, sessionKey) {
63
67
  if (!line.trim()) continue;
64
68
  let row;
65
69
  try { row = JSON.parse(line); } catch { continue; }
66
- if (!own(row?.session) || typeof row.task !== "string" || !row.task.trim() || !isResearchAnswer(row.text)) continue;
70
+ if ((!ofRun && !own(row?.session)) || typeof row.task !== "string" || !row.task.trim() || !isResearchAnswer(row.text)) continue;
67
71
  byTask.delete(row.task); // the latest answer to a question takes the place of an earlier one
68
72
  byTask.set(row.task, { task: row.task, text: row.text, ts: String(row.ts ?? "") });
69
73
  }
@@ -83,8 +87,8 @@ export const researchAnswerLine = (n, { task, text }) => `Question ${n}: ${oneLi
83
87
  * folder, one question and its answer per line, and return how many. Nothing is written when there are none, and 0 is
84
88
  * returned when the file could not be written: a message must never name a file that is not there. IMPURE.
85
89
  */
86
- export function handResearchAnswers(runDir, sessionKey) {
87
- const answers = researchAnswersFor(runDir, sessionKey);
90
+ export function handResearchAnswers(runDir, sessionKey, opts = {}) {
91
+ const answers = researchAnswersFor(runDir, sessionKey, opts);
88
92
  if (!answers.length) return 0;
89
93
  try {
90
94
  const p = join(runDir, RESEARCH_ANSWERS_MD);
@@ -101,8 +105,51 @@ export function handResearchAnswers(runDir, sessionKey) {
101
105
  export function researchAnsweredBlock(n) {
102
106
  if (!(n > 0)) return "";
103
107
  const asked = n === 1 ? "asked 1 question and got its answer" : `asked ${n} questions and got their answers`;
104
- return `RESEARCH THIS RUN HAS ALREADY ANSWERED: an earlier attempt of this stage ${asked}. They are in ${RESEARCH_ANSWERS_MD}, `
108
+ return `RESEARCH THIS RUN HAS ALREADY ANSWERED: earlier stages or attempts of this run ${asked}. They are in ${RESEARCH_ANSWERS_MD}, `
105
109
  + "one question and its answer per line. Before you ask about a finding's owner and mark, search that file for the owner's name "
106
- + "with search_run_artifacts. Where one of them asked about that owner and mark for the same field and country, use its answer as "
110
+ + "with search_run_artifacts. Where one of them answers the same owner, mark, goods, territory and material time question, use its answer as "
107
111
  + "that finding's check, cite its URL and record it as you would a fresh one; ask only what none of them covers.";
108
112
  }
113
+
114
+ // ── A REPLAY ANSWERS FROM THE RUN'S OWN RESEARCH, AND ASKS NOTHING NEW ────────────────────────────────────────────────
115
+ //
116
+ // A stage replayed offline in the experiment sandbox would send its research questions out again: a cost, and an
117
+ // answer that is not the one the run got. With `--research replay` the sandbox holds the run's own answers and says so
118
+ // in this marker, and the research server answers a question the run already asked from what the run got, and refuses
119
+ // a question it did not ask, sending nothing out. Only the sandbox writes the marker; a run never does.
120
+ export const RESEARCH_REPLAY_FILE = "research-replay.json";
121
+
122
+ /** True when this run folder is a replay of a run's research. IMPURE (reads the marker). */
123
+ export const researchReplayOn = (runDir) => Boolean(runDir) && existsSync(driverDir(runDir, RESEARCH_REPLAY_FILE));
124
+
125
+ /**
126
+ * The run's latest answer to exactly this question, whichever of its stages asked it, or null. The question is
127
+ * matched as written, trimmed at its ends: a question worded differently is a new question. IMPURE (reads the record).
128
+ */
129
+ export function savedResearchAnswer(runDir, task) {
130
+ const want = typeof task === "string" ? task.trim() : "";
131
+ if (!runDir || !want) return null;
132
+ let raw = "";
133
+ try { raw = readFileSync(driverDir(runDir, RESEARCH_ANSWERS_FILE), "utf8"); } catch { return null; }
134
+ let found = null;
135
+ for (const line of raw.split("\n")) {
136
+ if (!line.trim()) continue;
137
+ let row;
138
+ try { row = JSON.parse(line); } catch { continue; }
139
+ if (typeof row?.task !== "string" || row.task.trim() !== want || !isResearchAnswer(row.text)) continue;
140
+ if (!found || String(row.ts ?? "") >= String(found.ts ?? "")) found = { task: row.task, text: row.text, ts: String(row.ts ?? ""), session: row.session ?? null };
141
+ }
142
+ return found;
143
+ }
144
+
145
+ /** How many questions the run's record answers: the experiment refuses a replay with nothing to replay. IMPURE. */
146
+ export function savedResearchCount(runDir) {
147
+ let raw = "";
148
+ try { raw = readFileSync(driverDir(runDir, RESEARCH_ANSWERS_FILE), "utf8"); } catch { return 0; }
149
+ const tasks = new Set();
150
+ for (const line of raw.split("\n")) {
151
+ try { const row = JSON.parse(line); if (typeof row?.task === "string" && row.task.trim() && isResearchAnswer(row.text)) tasks.add(row.task.trim()); }
152
+ catch { /* a torn line answers nothing */ }
153
+ }
154
+ return tasks.size;
155
+ }
package/driver/runner.mjs CHANGED
@@ -43,7 +43,7 @@ import { checkResolvedProduct } from "./scope-rules.mjs"; // every resolved-pr
43
43
  import { freeSlots } from "./slot-lock.mjs"; // admission arithmetic — see drainQueue
44
44
  import { orderedQueueFiles } from "./queue-order.mjs"; // the order jobs are admitted in (shared with the portal)
45
45
  import { isCancelled } from "./cancel.mjs"; // a stopped run must not come back through any resume path
46
- import { AGENT_WHATSAPP } from "./stages.mjs";
46
+ import { whatsappRouting } from "./stages.mjs";
47
47
  import { writeOutboxPacket } from "./outbox.mjs";
48
48
  import { writeRunStatus, terminalRunState } from "./progress.mjs"; // A5 — grace-exit park + reclaim-bound terminal go through the guarded status writer; terminalRunState is the run record's own terminal vocabulary (2155)
49
49
  import { identitySeed } from "./progress.mjs"; // — the same stamp a run seeds, for the process that runs them
@@ -702,7 +702,7 @@ function preRunFailNotify(agentId, base, job, reason) {
702
702
  jobId: job?.id ?? null, msgId: job?.msgId ?? null,
703
703
  forwarder: job?.forwarder ?? null, forwarderEmail: job?.forwarderEmail ?? null,
704
704
  markName: (job?.markName ?? job?.name ?? job?.marks?.[0]?.name) || null,
705
- whatsappTo: AGENT_WHATSAPP[agentId] ?? null,
705
+ ...whatsappRouting(job, agentId), // to whoever asked, as every other notice (the-notice-goes-to-whoever-asked)
706
706
  reason: terminalReasonFields(reason).reason, text,
707
707
  });
708
708
  note(`[runner] pre-run failure ${p ? `packet \u2192 ${p}` : "packet write FAILED"} for ${base}`);
@@ -739,7 +739,7 @@ async function backstopFailureNotice({ res, job, agentId, base, codename, studio
739
739
  // canonical runId form (charter P1 §3): `<slug>-<date>-<codename>` — owned.dir's leaf IS
740
740
  // "<date>-<codename>", so the dated id is derivable even here (matches status.runId + rescan).
741
741
  runId: `${slug}-${basename(owned.dir)}`, agent: agentId, job, failedStage: "pre-run",
742
- shortReason, reasonVerbatim: reason.slice(0, 1000), whatsappTo: AGENT_WHATSAPP[agentId] ?? null,
742
+ shortReason, reasonVerbatim: reason.slice(0, 1000),
743
743
  });
744
744
  // same per-send invariant as the pipeline's failure path: a fresh notice supersedes an older .sent
745
745
  // and its per-channel receipts (B4 — stale receipts would make clearotron-deliver skip this notice)
@@ -2089,7 +2089,7 @@ function reclaimOrphanedClaims(qdir, agentId) {
2089
2089
  // comment above calls out for NAMING THE ARTIFACT DIRECTORY at its end: the status field
2090
2090
  // was repaired by and the notice a human actually reads was left cutting it blind.
2091
2091
  shortReason: rt.reason, reasonVerbatim: reasonText, terminalKind: "reclaim-exhausted",
2092
- priorAttempts: priorReclaims, whatsappTo: AGENT_WHATSAPP[agentId] ?? null,
2092
+ priorAttempts: priorReclaims,
2093
2093
  });
2094
2094
  try { rmSync(join(owned.dir, ".sent"), { force: true }); } catch { /* none */ }
2095
2095
  try { rmSync(driverDir(owned.dir, "send-receipts.json"), { force: true }); } catch { /* none */ }
@@ -0,0 +1,36 @@
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
+ // saved-findings-block.mjs — what a later attempt of the writer is told about the findings this run already saved.
5
+ //
6
+ // The writer saves each finding as it completes it (synthesis-record.mjs, `saveFindings`), and an attempt that is
7
+ // stopped keeps them. A later attempt is handed them as a file, never in its message: a saved finding carries its
8
+ // evidence, and a few hundred of them would swamp the message. The message says how many there are, where they are,
9
+ // that they stand, and which positions are still to settle. A first attempt has none, and its message is unchanged.
10
+ // The model reads this block, so its words change only as model-facing wording does.
11
+ //
12
+ // A pure leaf, so the stage table can import it.
13
+
14
+ /** The file in the run folder a later attempt is handed its run's saved findings in. */
15
+ export const SAVED_FINDINGS_FILE = "synthesis-saved.json";
16
+
17
+ /**
18
+ * The block a later attempt's message ends with, or "" when nothing is saved. `h` is what
19
+ * synthesis-record.mjs's `handSavedFindings` returned: `{ findings, declined, outstanding }`, where `outstanding` is
20
+ * null when the run cannot say which positions are still owed. PURE.
21
+ */
22
+ export function savedFindingsBlock(h) {
23
+ if (!h || !(h.findings > 0)) return "";
24
+ const saved = h.findings === 1 ? "saved 1 finding" : `saved ${h.findings} findings`;
25
+ const declined = h.declined > 0 ? ` and declined ${h.declined === 1 ? "1 record" : `${h.declined} records`} through record_declination` : "";
26
+ const shown = Array.isArray(h.outstanding) ? h.outstanding.slice(0, 40) : [];
27
+ const more = Array.isArray(h.outstanding) && h.outstanding.length > shown.length ? `, and ${h.outstanding.length - shown.length} more` : "";
28
+ const owed = !Array.isArray(h.outstanding) ? ""
29
+ : h.outstanding.length ? ` Still to settle: ${h.outstanding.length === 1 ? "1 position" : `${h.outstanding.length} positions`}: ${shown.join(", ")}${more}.`
30
+ : " Every position you were handed is already settled by a saved finding or a declination.";
31
+ return `SAVED IN THIS RUN: an earlier attempt of this stage ${saved} through record_synthesis${declined}. The saved findings are in ${SAVED_FINDINGS_FILE}, `
32
+ + "each as it was accepted, with its evidence. They stand: save one again only for identified contrary evidence, a reasoning error "
33
+ + `or an applicable change in the instructions.${owed} Save each remaining finding as you complete it. Then send the whole record, `
34
+ + "`narrative` with `findings`: it takes every saved finding for a position its `findings[]` does not carry, so that list need hold "
35
+ + "only the findings not saved yet, and the overall conclusions answer for the complete set.";
36
+ }
@@ -396,7 +396,7 @@ Every judgment below spans the band and/or needs data a single unit cannot have,
396
396
  **Consume `placement-recommendations.md` (from Touchpoint 2 `placement-inquiry`).** The per-candidate tiers ALSO arrive structured in `placements.json` beside the md — `{mark, owner, jurisdiction, records[], tier, reason}` per candidate (plus an optional `borderline: true` — see below), `reason` carrying placement's stated reasoning. When present, the JSON is the authoritative tier record: adopt or override each tier BY ENGAGING ITS `reason` (an override quotes the reason it contradicts — never a silent re-tier; a kept tier may tighten a label while reusing the reason). The md remains the home of the rulings tail (Disagreements, Coverage rulings, Open questions), which you carry forward exactly as below. On a corrective/repair pass do NOT re-read the whole md — the tiers are in the JSON, and the driver hands you the md's rulings tail verbatim in the dispatch (a `PLACEMENT RULINGS TAIL` block): adjudicate against it there, adopting or counter-reasoning each ruling. For each candidate `placement-inquiry` placed:
397
397
  - Find the matching entry in `placements.json` (mark text + owner + jurisdiction match; fall back to the md's tier sections on runs that predate the JSON)
398
398
  - Adopt the graduated outcome (`headline-candidate` / `sheet-2` / `watchlist-annex` / `out-of-scope-filtered`) as the placement
399
- - A sheet-2 placement is adopted as it stands — the driver writes its Sheet-2 row with placement's reason — unless you override it; you send rows for the headline positions and for the placements you override.
399
+ - Keep each placement decision that its evidence supports, with its reason. Before an entry goes to the writer, decide its relevance from the evidence you hold. Carry plausible conflicts and the questions the evidence leaves open. Drop every other entry once, with a substantive reason. The length of the report, thin use, a small owner and possible revocation are never substantive reasons. On its own, a sheet-2 tier, a class match or the words “completes the record” is no reason to carry an entry. Change a placement decision only by naming the evidence or the error that changes it.
400
400
  - **Between `headline-candidate` and `sheet-2`, what you are adopting is an ANSWER, not a label.** Those two tiers are placement-inquiry's written answer to one question — *does this conflict change the advice, or only complete the record?* (a headline entry names what it changes; a sheet-2 entry states why the advice reads the same either way). Adopting means adopting that answer. **Overriding means answering the SAME question the other way, in writing**, in your `### Disagreement resolutions` row — never re-labelling the tier and never substituting a risk-tier argument for the question. An override that does not say what the conflict does (or does not do) to the advice has not engaged the placement
401
401
  - **A `"borderline": true` entry in `placements.json` is placement declaring that its own answer could be argued either way on this record.** That is the expected place for your judgment to differ, and it is a correct declaration rather than a defect — but it must be **resolved either way, explicitly**, in a `### Disagreement resolutions` row (`ADOPTED placement-inquiry — <the reading you took, and why>` / `OVERRODE — <the other reading, and why>`), never silently carried and never left as a shrug. **The declaration is internal adjudication only**: it belongs in your audit trail beside the other open verification flags, and no borderline mark, no "arguably", no "either way" derived from it goes into the findings prose the client reads — you resolve it here so the report does not have to hedge it there
402
402
  - The "Disagreements / flags surfaced to downstream" section names cases where placement-inquiry deviated from class-match — read those reasonings carefully
@@ -155,9 +155,9 @@ is not a reason to skip it, and "the rule said so" is not an answer.
155
155
  headline.)
156
156
  - **Dominant-element spine / calibration:** is each finding ranked by the *dominant element* + whole-mark confusion
157
157
  test? Is a distinguished mark (house-mark-prefixed) **over-rated**, or a
158
- bare-dominant-element-in-field conflict **under-rated or dropped**? Did any **on-point identical /
159
- near-identical-in-class** hit fall out of the deliverable (it must not — even if revocable, and regardless
160
- of filer size)? Is any escalation of a distinguished mark resting on optics/PR/partner rather than a
158
+ bare-dominant-element-in-field conflict **under-rated or dropped**? Did an **on-point identical or
159
+ near-identical mark in the class** fall out of the report? It must not, even if revocable and whatever its
160
+ owner's size. Is any escalation of a distinguished mark resting on optics/PR/partner rather than a
161
161
  named confusion theory? Is the headline risk driven by the top on-point conflicts, not a distinguished
162
162
  mark or unrelated-field noise?
163
163
  - **Coverage ledger:** is any `deferred` / `coverage-limited` row being narrated (or about to be narrated) as a
@@ -218,7 +218,9 @@ Every cross-check (including "checked, found nothing") logged in the unified aud
218
218
 
219
219
  ## Step 3.5 — Actual-use and enforcement check (mandatory for register hits rated medium risk or higher)
220
220
 
221
- Before synthesis, for **every register finding** advisory-rated on a rung **the framework rates medium risk or higher** (the stage message names those rungs; a rung below that is not checked, on any client's ladder), run a marketplace check via `perplexity_research` to determine whether the registered mark is **actually being used in the marketplace within the scope of the current search** (i.e. for the goods/services and field this clearance targets). **The same query also asks for the owner's enforcement record:** whether the owner has opposed, disputed or sued over this mark or a similar one.
221
+ For **every register finding** advisory-rated on a rung **the framework rates medium risk or higher** (the stage message names those rungs; a rung below that is not checked, on any client's ladder), run a marketplace check via `perplexity_research` to determine whether the registered mark is **actually being used in the marketplace within the scope of the current search** (i.e. for the goods/services and field this clearance targets). **The same query also asks for the owner's enforcement record:** whether the owner has opposed, disputed or sued over this mark or a similar one.
222
+
223
+ Apply the existing triggers for actual-use, enforcement, and owner checks after the relevant legal assessment. First use a suitable answer already held by this run, including an earlier stage or attempt, where it answers the same owner, mark, goods, territory, and material time question. A source requirement is not a requirement for a new call. One answer may support several findings only where it answers each finding's question. Ask only what remains unanswered and record the result against every finding it supports.
222
224
 
223
225
  Example: a JELLY register hit owned by Jellycat (a toy company) for Class 9 video game software — check whether Jellycat actually sells or markets video games under JELLY. If they don't (their commercial use is toys), note the filed-vs-used distinction in Key Factors and weigh it as a Stage-2 factor together with revocation-vulnerability — no automatic downgrade (see `synthesis-rules.md` → *Actual-use assessment results*).
224
226
 
@@ -245,7 +247,7 @@ If the marketplace query genuinely returned nothing, write the honest value verb
245
247
 
246
248
  The driver's `narrative` validator (`verify.mjs` → `findUseCheckViolations`) **rejects the whole narrative** if a
247
249
  Composite 3+ use-negative finding lacks this line, so the synthesis stage cannot ship an *inferred* "no use" —
248
- it must actually run the scoped query (owner + mark + goods/field) and cite the result, or state the honest
250
+ it must actually run the scoped query (owner + mark + goods/field), unless an answer this run already holds answers that question, and cite the result, or state the honest
249
251
  "no result". Never dress an un-run check as a clean negative. Also mirror the result into the matching `# Actions`
250
252
  item: "confirm the owner's actual use" becomes the stated result, not a task handed onward (this is what fills
251
253
  spec 09's "Checks we ran — what we found" bucket). On a perplexity outage the "no result" value is the accepted