clearotron 0.4.1 → 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) 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 +32 -0
  6. package/driver/ask-ledger.mjs +90 -16
  7. package/driver/band-shape.mjs +65 -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-io.mjs +2 -1
  19. package/driver/coverage-form.mjs +17 -2
  20. package/driver/coverage-union.mjs +11 -2
  21. package/driver/digest-queue.mjs +1 -1
  22. package/driver/disposition-call.mjs +1 -1
  23. package/driver/document-coverage.mjs +3 -2
  24. package/driver/effective-scope.mjs +12 -7
  25. package/driver/engine/mcp/band-server.mjs +77 -13
  26. package/driver/engine/mcp/dispositions-server.mjs +2 -2
  27. package/driver/engine/mcp/held-answers.mjs +128 -0
  28. package/driver/engine/mcp/recording-server.mjs +10 -6
  29. package/driver/engine/mcp/supplemental.mjs +74 -12
  30. package/driver/findings-model.mjs +16 -4
  31. package/driver/floor-duty.mjs +9 -0
  32. package/driver/gate-metrics.mjs +7 -1
  33. package/driver/gateway.mjs +28 -14
  34. package/driver/goods-evidence.mjs +295 -0
  35. package/driver/knockout-assess-record.mjs +1 -1
  36. package/driver/narrative-refutation-record.mjs +40 -2
  37. package/driver/package.json +1 -1
  38. package/driver/pipeline.mjs +1994 -323
  39. package/driver/placement-decisions.mjs +243 -0
  40. package/driver/placement-form.mjs +42 -8
  41. package/driver/placement-union.mjs +14 -6
  42. package/driver/provider-usage.mjs +10 -7
  43. package/driver/publish/audit-from-spine.mjs +5 -2
  44. package/driver/publish/index.mjs +19 -4
  45. package/driver/publish/xlsx.mjs +6 -6
  46. package/driver/recall-reconciliation.mjs +15 -5
  47. package/driver/record-carry.mjs +70 -6
  48. package/driver/record-discard.mjs +7 -1
  49. package/driver/record-fold.mjs +14 -0
  50. package/driver/record-goods.mjs +101 -5
  51. package/driver/register-plan.mjs +221 -6
  52. package/driver/registry-fidelity.mjs +34 -3
  53. package/driver/remedy-accounting.mjs +52 -14
  54. package/driver/repair-composers.mjs +265 -105
  55. package/driver/repair-scope.mjs +327 -0
  56. package/driver/repair-targets.mjs +92 -0
  57. package/driver/result-noun-fields.mjs +3 -3
  58. package/driver/scope-facts.mjs +6 -2
  59. package/driver/screen-gate.mjs +96 -0
  60. package/driver/skills/clearance-common-law/SKILL.md +8 -0
  61. package/driver/skills/clearance-register/SKILL.md +29 -21
  62. package/driver/skills/clearance-register/digest.md +22 -17
  63. package/driver/skills/clearance-register/providers/clarivate.md +4 -4
  64. package/driver/skills/clearance-register/providers/corsearch.md +1 -1
  65. package/driver/skills/clearance-register/providers/euipo.md +4 -4
  66. package/driver/skills/clearance-register/providers/free-tier.md +4 -4
  67. package/driver/skills/clearance-register/providers/uspto-local.md +3 -3
  68. package/driver/skills/clearance-register/register-recipes.md +6 -6
  69. package/driver/skills/clearance-register/unit.md +29 -22
  70. package/driver/skills/clearance-search/delivery-contract.md +2 -58
  71. package/driver/skills/clearance-search/report-prose.md +12 -3
  72. package/driver/skills/clearance-search/risk-framework-triage.md +1 -1
  73. package/driver/skills/clearance-search/synthesis-rules.md +10 -13
  74. package/driver/skills/clearance-variants/SKILL.md +4 -4
  75. package/driver/skills/knockout-assess/SKILL.md +9 -14
  76. package/driver/skills/knockout-frame/SKILL.md +5 -5
  77. package/driver/skills/narrative-refutation/SKILL.md +1 -1
  78. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  79. package/driver/skills/whatif-memo/SKILL.md +1 -1
  80. package/driver/stage-context.mjs +2 -1
  81. package/driver/stages-knockout.mjs +1 -1
  82. package/driver/stages.mjs +96 -61
  83. package/driver/suite-census.json +415 -25
  84. package/driver/synthesis-record.mjs +310 -8
  85. package/driver/terminal-clamp.mjs +53 -2
  86. package/driver/verify-knockout.mjs +22 -4
  87. package/driver/verify.mjs +23 -3
  88. package/mcp-server/CHANGELOG.md +8 -0
  89. package/mcp-server/lib/driver.mjs +5 -0
  90. package/mcp-server/lib/events.mjs +25 -4
  91. package/mcp-server/lib/findings.mjs +4 -2
  92. package/mcp-server/package.json +1 -1
  93. package/package.json +1 -1
  94. package/portal-ui/package.json +1 -1
  95. package/providers/_shared/enumerate.mjs +12 -6
  96. package/providers/_shared/execute-plan.mjs +25 -1
  97. package/providers/_shared/follow-up-searches.mjs +139 -0
  98. package/providers/jx/src/core.js +3 -4
  99. package/providers/jx/src/judge.js +2 -2
  100. package/providers/jx/src/nativeread.js +1 -2
  101. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  102. package/providers/oauth-mcp-bridge/package.json +1 -1
  103. package/scripts/e2e-first-time.mjs +30 -13
  104. package/scripts/release-await-cut.mjs +4 -2
@@ -9,17 +9,20 @@
9
9
  //
10
10
  // ── WHY THIS IS NOT SIMPLY "ALLOW THE WORD" ─────────────────────────────────────────────────────────
11
11
  //
12
- // The production corrective pass is a WARM followup: it resumes the synthesis session and sends only
13
- // `correctionsExtra`'s blocks. An experiment arm always builds a fresh key and dispatches into a sandbox,
14
- // so it cannot resume anything — and resuming the REAL session from a sandbox was rejected outright,
15
- // because "canonical run untouched" is the arm's whole contract and an arm that breaks its contract to
16
- // gain fidelity is measuring with a broken instrument.
12
+ // The production corrective pass is a FOLLOWUP dispatch of the synthesis stage, on the run's own key with
13
+ // the winning attempt's model and thinking, and its message is `correctionsExtra`'s blocks. It does not
14
+ // resume the synthesis session: no engine resumes by session key (stages-followup.test.mjs pins it), so
15
+ // the followup's first attempt is a fresh turn handed the corrections and the stage's inputs to read. An
16
+ // experiment arm always builds a fresh key and dispatches into a sandbox — and dispatching on the REAL run's
17
+ // key from a sandbox was rejected outright, because "canonical run untouched" is the arm's whole contract and
18
+ // an arm that breaks its contract to gain fidelity is measuring with a broken instrument.
17
19
  //
18
20
  // So an arm carries the corrective pass's INPUTS on a COLD turn, composed by the same function production
19
- // uses. That reaches the seam nothing else reaches without a two-hour run. It is NOT the warm pass, and
20
- // the receipt says so in a word rather than leaving it to be inferred:
21
+ // uses. That reaches the seam nothing else reaches without a two-hour run. It is NOT production's
22
+ // dispatch, and the receipt says so in a word rather than leaving it to be inferred. The production word
23
+ // is a recorded value and stays as it is, though the followup it names never resumes the synthesis session:
21
24
  //
22
- // dispatched-warm production only — the resumed followup
25
+ // dispatched-warm production only — the followup on the run's own key
23
26
  // dispatched-cold the arm — the same blocks, a fresh turn
24
27
  // refused-no-corrective-state neither — this run has no corrective pass to reproduce
25
28
  //
@@ -149,7 +149,8 @@ export function coverageFormInput(runDir) {
149
149
  const orderedTerritories = Array.isArray(plan.ordered_jurisdictions) ? plan.ordered_jurisdictions : [];
150
150
  return { input: { skeleton: exec.skeleton, plan, bandBlocksByAxis, deferredReasons, activeAxes,
151
151
  bandsUnreadable, orderedTerritories, capabilities, unknownAxisUnits,
152
- awaiting: Array.isArray(exec.awaiting) ? exec.awaiting : [], withheld: readWithheldFamilies(runDir) }, absent: null };
152
+ awaiting: Array.isArray(exec.awaiting) ? exec.awaiting : [], withheld: readWithheldFamilies(runDir),
153
+ mainMarkets: Array.isArray(exec.main_markets) ? exec.main_markets : [] }, absent: null };
153
154
  }
154
155
 
155
156
  /**
@@ -31,7 +31,7 @@
31
31
  import { REGISTER_AXES, COVERAGE_STATUSES, normalizeAxis, CROWD_RULING_TOKEN,
32
32
  CROWD_RULING_UNIT_GRAMMAR } from "./coverage-ledger.mjs";
33
33
  import { shortId } from "./connotation-search.mjs";
34
- import { openBlocksByAxis } from "./register-plan.mjs";
34
+ import { openBlocksByAxis, crowdNarrowing, blocksByQidOf } from "./register-plan.mjs";
35
35
  import { territoryLayerReport, unsearchedLayerReason } from "./binding-layers.mjs"; import { goodsTermsList } from "../providers/_shared/term-shape.mjs";
36
36
 
37
37
  const STATUS_SET = new Set(COVERAGE_STATUSES);
@@ -344,8 +344,17 @@ export function blockOpenBecause(b, narrowings = []) {
344
344
  */
345
345
  export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null,
346
346
  bandBlocksByAxis = {}, deferredReasons = {}, bandsUnreadable = [],
347
- orderedTerritories = [], capabilities = null, awaiting = [], withheld = {} } = {}) {
347
+ orderedTerritories = [], capabilities = null, awaiting = [], withheld = {}, mainMarkets = [] } = {}) {
348
348
  const skel = Array.isArray(skeleton) ? skeleton.filter((s) => s && typeof s === "object") : [];
349
+ // WHAT WAS RETRIEVED UNDER EACH CROWD (register-plan.mjs, crowdNarrowing), worked out from the same blocks these rows
350
+ // are built from and carried on its row as data: a narrowed list retrieved in full (and which), none, or for the
351
+ // identical mark whether retrieved lists cover every class in every main market (and the cells they do not). The
352
+ // row stays open either way: reading the lists and deciding the crowd are the reading step's.
353
+ const narrowingByQid = new Map(crowdNarrowing(plan, blocksByQidOf(bandBlocksByAxis), { mainMarkets }).rows.map((r) => [String(r.qid), r]));
354
+ const endingOf = (qid) => {
355
+ const r = narrowingByQid.get(String(qid));
356
+ return r ? { narrowing: r.narrowing, ...(r.lists?.length ? { lists: r.lists } : {}), ...(r.not_covered?.length ? { not_covered: r.not_covered } : {}) } : {};
357
+ };
349
358
  const entriesByQid = new Map((plan?.entries ?? []).map((e) => [e.qid, e]));
350
359
  const open = openBlocksByAxis(skel, bandBlocksByAxis, plan);
351
360
  // Axis order is the run's, not a set's iteration order: the plan-execution skeleton first (the order
@@ -393,6 +402,7 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
393
402
  ...(Number.isInteger(b.total_hits) ? { total_hits: b.total_hits } : {}),
394
403
  ...(b.unaccounted?.length ? { unaccounted_terms: b.unaccounted } : {}),
395
404
  ...(b.unaccounted_classes?.length ? { unaccounted_classes: b.unaccounted_classes } : {}),
405
+ ...endingOf(b.qid),
396
406
  // The block's OWN row is the naming the deleted join required. A clean claim HERE is the
397
407
  // silent swallow; a clean claim on a sibling slice never discharged this block and does not now.
398
408
  open: true,
@@ -420,6 +430,8 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
420
430
  // it was not asked. The row arrives settled when the turn recorded that, and open when nobody did,
421
431
  // so an unjudged family is an obligation the digest's gate refuses to pass. `family` rows stay out of
422
432
  // the ledger the report is built from; the reason is the run's record and the audit workbook's.
433
+ // A row that arrives settled names the record its judgment came from (`reason_from`), so the union can keep
434
+ // that judgment the row's own: a later digest may add its reading beside it, never in place of it.
423
435
  for (const f of (Array.isArray(awaiting) ? awaiting : [])) {
424
436
  const qid = String(f?.qid ?? "").trim();
425
437
  if (!qid || String(f?.axis ?? "").trim() !== axis) continue;
@@ -432,6 +444,7 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
432
444
  open: true,
433
445
  open_because: "a waiting family the reading turn did not ask — it was never searched, and its only judgment is withheld-by-judgment with the reason it was not asked",
434
446
  status: w?.reason ? "withheld-by-judgment" : null, reason: w?.reason ? String(w.reason) : null,
447
+ ...(w?.reason ? { reason_from: `_driver/withheld-families-${String(w.axis ?? axis)}.json` } : {}),
435
448
  });
436
449
  }
437
450
  // ── AN OFFICE THIS DEPLOYMENT COULD NOT REACH ────────────────────────────────────────
@@ -1031,6 +1044,8 @@ function briefRow(r) {
1031
1044
  r.unaccounted_classes?.length ? `classes unaccounted: ${r.unaccounted_classes.join(", ")}` : "",
1032
1045
  r.unaccounted_terms?.length ? `terms unaccounted: ${r.unaccounted_terms.join(", ")}` : "",
1033
1046
  r.receipt_reason ? `receipt: ${String(r.receipt_reason).slice(0, 160)}` : "",
1047
+ // what was retrieved under the crowd (register-plan.mjs, crowdNarrowing), as data beside the counts it rests on
1048
+ r.narrowing ? `narrowing: ${r.narrowing}${r.lists?.length ? ` (${r.lists.slice(0, 6).join(", ")})` : ""}${r.not_covered?.length ? `; not covered: ${r.not_covered.slice(0, 12).join(", ")}` : ""}` : "",
1034
1049
  ].filter(Boolean).join("; ");
1035
1050
  const settled = String(r.status ?? "").trim() ? ` [settled: ${String(r.status).trim()}]` : "";
1036
1051
  return ` ${r.row_id} (${r.kind}) ${r.unit}${facts ? ` — ${facts}` : ""}${r.open === true ? " — OPEN: never confirmed-clean" : ""}${settled}`;
@@ -104,8 +104,17 @@ export function unionCoverageForm(prior, submitted, input, { parkedIds = null }
104
104
  const p = findPrior(row), s = findSubmitted(row);
105
105
  const sOk = s && rowIsSettled(s, row), pOk = p && rowIsSettled(p, row), dOk = rowIsSettled(row, row);
106
106
  let fields;
107
- if (sOk) fields = seatFields(s);
108
- else if (pOk) fields = seatFields(p); else if (dOk) fields = seatFields(row); // a judgment the driver row arrived with (a family the reading turn withheld)
107
+ // A FAMILY THE READING TURN WITHHELD ARRIVES SETTLED WITH THE TURN'S OWN JUDGMENT, and that judgment stays the
108
+ // row's. Every later reader is told the row carries the turn's decision, so a digest that re-words it would
109
+ // make that untrue: its own reading is kept beside the turn's, as `digest_reason`, and never replaces it. A
110
+ // release takes the family out of the turn's record, so the next pass builds the row without that judgment
111
+ // and the digest settles it as any row.
112
+ if (row.kind === "family" && row.reason_from && dOk) {
113
+ fields = seatFields(row);
114
+ const theirs = sOk ? seatFields(s).reason : (p?.digest_reason ?? null);
115
+ if (theirs && theirs !== fields.reason) row.digest_reason = theirs;
116
+ } else if (sOk) fields = seatFields(s);
117
+ else if (pOk) fields = seatFields(p); else if (dOk) fields = seatFields(row); // a judgment the driver row arrived with
109
118
  else {
110
119
  const sf = seatFields(s ?? {}), pf = seatFields(p ?? {});
111
120
  fields = { status: sf.status || pf.status, reason: sf.reason || pf.reason };
@@ -156,7 +156,7 @@ export const DIGEST_OWN_PASS_EXEMPTIONS = Object.freeze([
156
156
  // The standard digest-resume preamble every warm re-digest opens with (verbatim from the legacy
157
157
  // escalation/envelope/frame-reopen followups — shared so the OFF path stays byte-identical).
158
158
  export const DIGEST_RESUME_PREAMBLE =
159
- "You are RESUMING your earlier register-digest session — your prior digest and all unit files are already in your context. Do NOT redo it from scratch.";
159
+ "This is a fresh session: your earlier register-digest work is not in your context, and the unit files are among the inputs listed below. Your prior digest is the findings file named below. Do NOT redo it from scratch.";
160
160
 
161
161
  /**
162
162
  * The standard settlement repair contract: every consolidated flush ends with THIS.
@@ -881,7 +881,7 @@ export function callAnswer({ accepted, refused, overflow }, outstanding) {
881
881
  const owed = !r.evidence_owed ? ""
882
882
  : r.evidence_unsatisfiable === true
883
883
  ? " — this row CANNOT be evidenced: no passage on its candidates is long enough to quote from, so no `fragment` will ever bind. Do not keep re-sending it. Rule it and send `obstacle` saying the snippet carries nothing quotable."
884
- : (r.ruled ? " — RULED, but this row now also needs `segment_index` (the number of the passage you relied on, from the ruled receipt's numbered passages) and `fragment` (a few characters copied exactly out of that passage)"
884
+ : (r.ruled ? " — RULED, but this row now also needs `segment_index` (the number of the passage you relied on, from the ruled receipt's numbered passages)"
885
885
  : " — needs `segment_index` (the number of the passage you rely on, from the ruled receipt's numbered passages) and `fragment` (a few characters copied exactly out of that passage)");
886
886
  lines.push(` ${name(r)}${owed}`);
887
887
  }
@@ -94,8 +94,9 @@ export function renderDocumentCoverageSection(summary) {
94
94
  : `**Registry documents were retrieved for this search:** ${s.recordsWithBody} record`
95
95
  + `${s.recordsWithBody === 1 ? "" : "s"}${s.recordsTotal != null && s.recordsTotal !== s.recordsWithBody
96
96
  ? ` of the ${s.recordsTotal} held for this matter` : ""} carr${s.recordsWithBody === 1 ? "ies" : "y"} `
97
- + "the document itself, so the goods and services behind those rights were read as written rather "
98
- + "than inferred from their class numbers.");
97
+ // Holding a record is not reading it: a judging step opens few of the records a register holds, so the
98
+ // sentence says what was retrieved and claims no reading (the owner's approved wording, 2026-10-18).
99
+ + "the document itself.");
99
100
  if (s.findingsOnRecord != null && s.recordsWithBody !== 0)
100
101
  parts.push(s.findingsOnRecord === 0
101
102
  ? "No finding in this report rests on wording read from a retrieved document; where a specification "
@@ -192,19 +192,24 @@ export function orderTerritories(job, profile) {
192
192
  /**
193
193
  * THE NARROWING ORDER, as the register reading step is told it (ruled 2026-09-26): goods and class
194
194
  * first, then the markets in turn, with the markets the driver knows filled in. One line. PURE.
195
+ *
196
+ * ONE MARKET LIST, the one that applies: the order's countries; else the customer's priority markets; else the
197
+ * markets the customer's field and brand point to, with the major markets as their fallback. The line used to
198
+ * print all three as a chain of "else", and the reading step was left to pick among them. Each step keeps the
199
+ * limits before it, and a crowd still a crowd after the markets is recorded with its count: the narrowing ends.
195
200
  */
196
201
  export function narrowingOrderLine(job, profile, inScopeClasses = null) {
197
202
  const { scope, priorities, major } = narrowingMarkets(job, profile);
198
203
  const markets = scope.length
199
204
  ? `one question per market of the order's scope (${scope.join(", ")}); nothing outside it was ordered`
200
- : `one question per market: ${[
201
- priorities.length ? `the customer's priority markets (${priorities.join(", ")})` : "",
202
- "the markets the customer's field and brand point to (judge them from the matter frame)",
203
- `the major markets (${major.join(", ")})`,
204
- ].filter(Boolean).join(", else ")}`;
205
+ : priorities.length
206
+ ? `one question per market: the customer's priority markets (${priorities.join(", ")})`
207
+ : `one question per market: the markets the customer's field and brand point to (judge them from the matter frame), else the major markets (${major.join(", ")})`;
205
208
  return `NARROWING A CROWD, in this order: first the goods and the classes, the client's goods words in `
206
- + `${inScopeClasses ? `the order's classes [${inScopeClasses}]` : "the order's classes"}, then the classes the crowding owners `
207
- + `file in; still a crowd, then the markets, ${markets}. From the narrowed lists, read what a lawyer would raise. `
209
+ + `${inScopeClasses ? `the order's classes [${inScopeClasses}]` : "the order's classes"}, then one of those classes at a time, `
210
+ + `each step keeping the limits before it and stopping at the first list you can read; `
211
+ + `still a crowd, then the markets, ${markets}. Still a crowd after the markets: record it with its count. `
212
+ + `From the narrowed lists, read what a lawyer would raise. `
208
213
  + `What was counted and not read stays on its coverage row with its count: set aside, never clean.`;
209
214
  }
210
215
 
@@ -21,10 +21,12 @@ import { join, dirname } from "node:path";
21
21
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
22
22
  import { serve } from "./stdio-server.mjs";
23
23
  import { answerBudget, quotedBytes } from "../../../shared/answer-parts.mjs";
24
- import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord, niceClassKey, PLACEMENT_GROUP_ORDERS } from "../../band-shape.mjs";
24
+ import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord, niceClassKey, withoutPlacementOrders } from "../../band-shape.mjs";
25
25
  import { recordQids } from "../../named-band.mjs";
26
26
  import { normalizeRecordUri } from "../../registry-fidelity.mjs";
27
- import { bandGoodsIndex, goodsFor, goodsWordCounter, goodsWordsMatched, rawGoodsCarried, untaggedIsEnglish } from "../../record-goods.mjs";
27
+ import { bandGoodsIndex, goodsFor, goodsWordCounter, goodsWordsMatched, rawGoodsCarried, untaggedIsEnglish, rowGoods } from "../../record-goods.mjs";
28
+ import { recordGoodsEvidence, heldRecordProvenance, recordFetchOutcomes, readRunCallRows, goodsFromLabel } from "../../goods-evidence.mjs";
29
+ import { DEFAULT_LEDGER_PATH } from "../../provider-usage.mjs";
28
30
 
29
31
  const RUN_DIR = process.env.CLEAROTRON_BAND_RUN_DIR || "";
30
32
  const SESSION = process.env.CLEAROTRON_GATHER_SESSION_KEY || "";
@@ -299,14 +301,31 @@ const shapeGoodsTest = () => {
299
301
  testCache = { words, matched: goodsWordsMatched(words, { stems: true }) };
300
302
  return testCache;
301
303
  };
302
- // The record's body first; a row that carries goods of its own stands in where the run holds no body.
303
- const goodsOfRow = (r) => goodsFor(goodsIndex(), r)
304
+ // The record's body first; where the run holds none, the statements the row carries, as the shape reads them
305
+ // (`rowGoods`, a statement the register cut marked so); a row that carries its goods as one string stands in last.
306
+ const goodsOfRow = (r) => goodsFor(goodsIndex(), r) ?? rowGoods(r)
304
307
  ?? (typeof r?.goods === "string" && r.goods.trim() ? [{ classes: [], text: r.goods.trim(), ...(untaggedIsEnglish(r?.record_id) ? {} : { en: false }) }] : null);
308
+ // WHAT A RECORD'S GOODS REST ON, from the run's own files and its fetches, by the shared reader's one rule
309
+ // (goods-evidence.mjs `recordGoodsEvidence`): the supporting evidence the record holds, never the last call made
310
+ // for it. A fetch counts as failed only where the register's call ledger records it so; missing telemetry is not a
311
+ // recorded failure. Read once per server, as the band and the goods index are.
312
+ let evidenceCache = null;
313
+ const runEvidence = () => evidenceCache ??= (() => {
314
+ let prefix = null;
315
+ try { const st = loadJson(join(RUN_DIR, "status.json")); if (st?.slug && st?.codename) prefix = `clearance-${st.slug}-${st.codename}-`; } catch { prefix = null; }
316
+ const calls = prefix ? readRunCallRows(DEFAULT_LEDGER_PATH, prefix) : { rows: [], error: "no run prefix" };
317
+ return { held: heldRecordProvenance(RUN_DIR), fetches: calls.error ? null : recordFetchOutcomes(calls.rows, { runPrefix: prefix }) };
318
+ })();
319
+ const evidenceOfRow = (r) => {
320
+ const { held, fetches } = runEvidence();
321
+ return recordGoodsEvidence(r, { goods: { of: goodsOfRow, held: (x) => goodsFor(goodsIndex(), x) != null }, held, fetches });
322
+ };
305
323
  const withGoods = (r) => {
306
324
  const { words, matched } = shapeGoodsTest();
307
325
  const m = words.length ? matched(goodsOfRow(r)) : null;
308
326
  const { goods: _text, ...row } = project(r); // a row's own goods text stands in for the test, and is not served
309
- return { ...row, goods_words: m == null ? null : `${m.length} of ${words.length}${m.length ? `: ${m.join(", ")}` : ""}` };
327
+ return { ...row, goods_words: m == null ? null : `${m.length} of ${words.length}${m.length ? `: ${m.join(", ")}` : ""}`,
328
+ goods_from: goodsFromLabel(evidenceOfRow(r)) };
310
329
  };
311
330
 
312
331
  // A GROUP, PAGED AS THE SHAPE LISTS IT. The shape lists a group of a thousand records or more by its first
@@ -525,14 +544,59 @@ function bandRecord(params = {}) {
525
544
  // It stays an error, it stays in the reading audit with the reason that made it one, and it still says
526
545
  // what IS on file: the deferral this tool reports must stay visible for the real failures, or a dead
527
546
  // document reads back as a record that never had one.
528
- logRead("band_record", { record_id: uri }, { ok: false, reason: hit.miss });
529
547
  // The uri grammar is the same one screen-gate reads, and it does not cover every segment shape (a
530
548
  // 7+ character office segment parses nowhere). So this message never tells the model its uri is
531
549
  // wrong: it says what the resolver could not do, and what to do about it either way.
532
- const why = hit.miss === "unparsable-uri"
550
+ //
551
+ // WHAT IS ON FILE INSTEAD. Where the register's search answer listed goods for the record, they are served here
552
+ // with the miss, each statement with its source and completeness, in parts as an official record's text is: a
553
+ // whole statement on the search answer can decide a line's group, and no other tool serves its text. A fetch
554
+ // that the register's call ledger records as failed is said to have failed; nothing else is.
555
+ // A cite without its leading slash resolves on a hit; the miss reads it the same way, or it would lose the failed
556
+ // fetch and the search answer's goods that the cite's record has.
557
+ const parsed = hit.miss !== "unparsable-uri";
558
+ const key = normalizeRecordUri(uri) || normalizeRecordUri(`/${String(uri ?? "").trim().replace(/^\/+/, "")}`);
559
+ const bandRow = parsed && key ? bandRowFor(key) : null;
560
+ const statements = bandRow ? rowGoods(bandRow) : null;
561
+ const fetched = parsed ? runEvidence().fetches : null;
562
+ const fetch = fetched && key ? (fetched.get(key) ?? fetched.get(key.split("/").pop())) : null;
563
+ const failed = Boolean(fetch?.failed && !fetch.ok);
564
+ const why = !parsed
533
565
  ? `"${uri}" did not parse as a record reference (expected /mark/<cc>/<id> — a full provider URL carrying one is fine) and no archived record matches it exactly. If the uri IS right, its shape is outside this tool's grammar: state the gap, never read it as a clean.`
534
- : `No official record fetched for "${uri}" this run.`;
535
- return { isError: true, text: `${why} ${available.length ? `Records on file (${available.length}): ${available.slice(0, 40).join(", ")}${available.length > 40 ? ", …" : ""}.` : "No records fetched yet."} This tool is read-only — it never fetches; a record you need but do not have is an honest gap to state, not a reason to guess.` };
566
+ : statements
567
+ ? `${failed ? `No official record is on file for "${uri}": a fetch of it failed this run.` : `No official record is on file for "${uri}".`} The register's search answer listed these goods. Their source and completeness are shown with each statement.`
568
+ : failed ? `No official record is on file for "${uri}": a fetch of it failed this run.` : `No official record fetched for "${uri}" this run.`;
569
+ const rest = `${available.length ? `Records on file (${available.length}): ${available.slice(0, 40).join(", ")}${available.length > 40 ? ", …" : ""}.` : "No records fetched yet."} This tool is read-only — it never fetches; a record you need but do not have is an honest gap to state, not a reason to guess.`;
570
+ if (!statements) {
571
+ logRead("band_record", { record_id: uri }, { ok: false, reason: hit.miss, ...(failed ? { fetch_failed: true } : {}) });
572
+ return { isError: true, text: `${why} ${rest}` };
573
+ }
574
+ const goodsLines = statements.map((g) => JSON.stringify({ class: g.classes.join(",") || null, text: g.text,
575
+ goods_from: g.cut ? "search answer, cut by the register" : g.unknown ? "search answer, completeness not established" : "search answer" }));
576
+ const text = [why, ...goodsLines, rest].join("\n");
577
+ const parts = splitAtLines(text, RESPONSE_CHARS);
578
+ const wanted = Number(params.part ?? 1);
579
+ const part = Number.isInteger(wanted) && wanted >= 1 && wanted <= parts.length ? wanted : 1;
580
+ logRead("band_record", { record_id: uri }, { ok: false, reason: hit.miss, served: "search-answer-goods", bytes: text.length,
581
+ ...(parts.length > 1 ? { part, parts: parts.length } : {}), ...(failed ? { fetch_failed: true } : {}) });
582
+ if (parts.length === 1) return { isError: true, text };
583
+ return { isError: true, text: `[record ${uri} — part ${part}/${parts.length}]\n` + parts[part - 1]
584
+ + (part < parts.length ? `\n[end of part ${part}/${parts.length} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
585
+ }
586
+
587
+ // The band row a record id names, from the band file, read once per server. null where the band holds none.
588
+ let bandRowsCache = null;
589
+ function bandRowFor(uri) {
590
+ if (!bandRowsCache) {
591
+ bandRowsCache = new Map();
592
+ try {
593
+ for (const r of loadJson(bandPath())?.enumerated ?? []) {
594
+ const k = normalizeRecordUri(r?.record_id);
595
+ if (k && !bandRowsCache.has(k)) bandRowsCache.set(k, r);
596
+ }
597
+ } catch { /* no band on file: nothing to serve beside the miss */ }
598
+ }
599
+ return bandRowsCache.get(normalizeRecordUri(uri)) ?? null;
536
600
  }
537
601
 
538
602
  // THE SHAPE'S GROUP PAGES ARE PLACEMENT'S. Placement owes every line a group lists a fate, so it is offered the
@@ -543,10 +607,10 @@ function bandRecord(params = {}) {
543
607
  // arrives anyway is served as before: the offer changes, not what the tool answers.
544
608
  const GROUP_PAGES = process.env.CLEAROTRON_BAND_GROUPS === "1";
545
609
  // The same holds for the shape's own orders under the groups: placement reads them, and every other stage is
546
- // served the shape without them (Krzys's ruling, 2026-10-14). Each is removed with the space before it, inside
610
+ // served the shape without them (the owner's ruling, 2026-10-14). Each is removed with the space before it, inside
547
611
  // its line, so no line moves and the line each listed mark is printed on, which `linesByPart` reads, still holds.
548
- const withoutPlacementOrders = (text) => PLACEMENT_GROUP_ORDERS.reduce((t, order) => t.split(` ${order}`).join("").split(order).join(""), text);
549
- const LOOKUP_DESCRIPTION = "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, goods (words: records whose goods contain any of them, as whole words, singular or plural; a record with no goods on file matches too), group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), limit (default 20, max 100), offset (default 0: where in the matching records or lines to start — a capped answer names the next offset, so a large band or group is read page by page). On each record served, `goods_words` gives the client's goods words its goods hold, as the shape tests them (k of N: the words), or null where its goods cannot be read; the goods text is in band_record. Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.";
612
+ // (band-shape.mjs `withoutPlacementOrders` removes them, with the shape's other placement-only orders and its group-page commands.)
613
+ const LOOKUP_DESCRIPTION = "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, goods (words: records whose goods contain any of them, as whole words, singular or plural; a record with no goods on file matches too), group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), limit (default 20, max 100), offset (default 0: where in the matching records or lines to start — a capped answer names the next offset, so a large band or group is read page by page). On each record served, `goods_words` gives the client's goods words its goods hold, as the shape tests them (k of N: the words), or null where its goods cannot be read; `goods_from` says what those goods rest on: official record; official record, cut by the register; official record, completeness not established; official record, no goods listed; search answer; search answer, cut by the register; search answer, completeness not established; fetch failed; or nothing held. The goods text is in band_record. Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.";
550
614
  const LOOKUP_GROUP_CLAUSE = "group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), ";
551
615
  const LOOKUP_OFFSET_GROUPS = "offset (default 0: where in the matching records or lines to start — a capped answer names the next offset, so a large band or group is read page by page)";
552
616
  const LOOKUP_OFFSET_BAND = "offset (default 0: where in the matching records to start — a capped answer names the next offset, so a large band is read page by page)";
@@ -579,7 +643,7 @@ serve({
579
643
  },
580
644
  {
581
645
  name: "band_record",
582
- description: "The OFFICIAL registry record the driver fetched into this run's _records/ for a given record_id (/mark/... uri) — the fetched document itself, for detail the band row does not carry. Cite the uri in whatever form the band, the findings or the provider gave you: case, a wrapping provider URL and the registration-instance suffix are all resolved to the same document. Read-only: it never fetches; an unfetched record returns an honest miss listing what is on file, and a record that IS on file but cannot be opened says so — both are gaps to state, never records without documents. Every call is logged to the reading audit.",
646
+ description: "The OFFICIAL registry record the driver fetched into this run's _records/ for a given record_id (/mark/... uri) — the fetched document itself, for detail the band row does not carry. Cite the uri in whatever form the band, the findings or the provider gave you: case, a wrapping provider URL and the registration-instance suffix are all resolved to the same document. Read-only: it never fetches. An unfetched record returns an honest miss listing what is on file, with the goods the register's search answer listed for it where it listed any; a record that IS on file but cannot be opened says so — both are gaps to state, never records without documents. Every call is logged to the reading audit.",
583
647
  inputSchema: { type: "object", required: ["record_id"], properties: { record_id: { type: "string", description: "the /mark/<cc>/<id> uri" }, part: { type: "number", description: "1-based part number when an oversize record is served in parts (default 1)" } } },
584
648
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
585
649
  handler: bandRecord,
@@ -128,7 +128,7 @@ serve({
128
128
  name: "dispositions", version: "0.1.0",
129
129
  tools: [{
130
130
  name: "record_dispositions",
131
- description: "Record your meaning rulings. Send VALUES, not a file: one entry per row, and the driver writes the document. You type NO identifier of any kind: give `row_index`, the NUMBER printed beside that obligation in the obligations block, and `receipt_index`, the POSITION of the candidate in that row's own list — the driver resolves both. Rows are validated against THAT row as they arrive, so you learn what to fix inside this turn; rows that validate are kept even when others in the same call are refused. The answer names every obligation still outstanding and which of them still owe proof of reading — a `segment_index` and a `fragment`.",
131
+ description: "Record your meaning rulings. Send VALUES, not a file: one entry per row, and the driver writes the document. You type NO identifier of any kind: give `row_index`, the NUMBER printed beside that obligation in the obligations block, and `receipt_index`, the POSITION of the candidate in that row's own list — the driver resolves both. Rows are validated against THAT row as they arrive, so you learn what to fix inside this turn; rows that validate are kept even when others in the same call are refused. The answer names every obligation still outstanding and which of them still owe proof of reading — a `segment_index`.",
132
132
  inputSchema: { type: "object", required: ["grid_spec_path", "rows"], properties: {
133
133
  grid_spec_path: { type: "string", description: "Absolute path to the driver-written grid spec — the same one the grid tool was given." },
134
134
  rows: {
@@ -156,7 +156,7 @@ serve({
156
156
  // — THE HONEST EXIT. Declared here because a seat can only take an exit it can see: the
157
157
  // one row that killed a production run was refused 217 times by a seat that had no way to say
158
158
  // it could not rule, and the schema is the half its tool-calling binds to.
159
- obstacle: { type: "string", description: "ONLY when this receipt supports no ruling at all: one line naming what in it blocks a ruling. Send this INSTEAD of `ruling` — never both. The row is then recorded as undecided, with your sentence shown to the reviewing lawyer in place of a ruling, and the stage completes rather than stalling on it. Prefer a ruling whenever you can give one; this is not a way past a hard row." },
159
+ obstacle: { type: "string", description: "ONLY when this receipt is genuinely impossible to evidence — every passage an elision marker, nothing quotable in any of them: one line saying what stops you. Send this ALONGSIDE your `ruling` and `note`, never instead of them. The row is then recorded as undecided, with your sentence shown to the reviewing lawyer in place of proof, and the stage completes rather than stalling on it. This is not a way past a hard row: `obstacle` is for the receipt that offers nothing, not the awkward one." },
160
160
  } },
161
161
  },
162
162
  } },
@@ -0,0 +1,128 @@
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
+ // held-answers.mjs — ONE ANSWER PER QUESTION PER RUN, ACROSS EVERY WORKER AND PASS.
5
+ //
6
+ // The register workers run in parallel and do not see each other's questions, so the same question was put to the
7
+ // register again by another worker: on a test run of a crowded matter, 178 follow-up proposals held 167 distinct
8
+ // questions, and the 11 copies cost 49 calls. A question the run has already answered now gets the held answer, and
9
+ // one the run has already decided not to ask (a family withheld by judgment, or a follow-up the run's safety net
10
+ // refused) gets that decision. Nothing is asked again and no call is made.
11
+ //
12
+ // WHAT MAKES TWO QUESTIONS THE SAME: every field that changes what the register is asked — the predicate, the term
13
+ // or terms, their romanised forms, the classes, the markets, the goods words, the owner and the kind of answer —
14
+ // and none that only records where a question came from. A question with no markets is asked to the plan's, so it
15
+ // is the same as one naming them. Owner names are compared as written, never merged. A different scope is a
16
+ // different question, and an earlier answer that failed is no answer: both are asked again.
17
+
18
+ import { readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { driverDir } from "../../../shared/driver-dir.mjs";
21
+ import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
22
+ import { awaitsReadingTurn } from "../../../providers/_shared/plan-guards.mjs";
23
+ import { releasedFamilyQids } from "../../withheld-families.mjs";
24
+
25
+ const QUESTION_FIELDS = ["predicate", "term", "terms", "romanizedTerms", "term_literal", "nice_classes", "regions", "goods_text", "owner", "expected_kind"];
26
+ const sorted = (v) => [...v].map((x) => String(x)).sort();
27
+
28
+ /** The question an entry asks, as a key. `planRegions` fills an entry that names no markets. PURE. */
29
+ export function questionKey(e, planRegions = []) {
30
+ const o = {};
31
+ for (const k of QUESTION_FIELDS) {
32
+ let v = e?.[k];
33
+ if (k === "predicate") v = String(v ?? "default");
34
+ if (k === "expected_kind") v = String(v ?? "enumerate");
35
+ if (k === "regions" && !(Array.isArray(v) && v.length)) v = planRegions;
36
+ if (v === undefined || v === null || v === false || (Array.isArray(v) && !v.length) || v === "") continue;
37
+ o[k] = Array.isArray(v) ? (k === "terms" || k === "romanizedTerms" ? v.map(String) : sorted(v)) : v;
38
+ }
39
+ return JSON.stringify(o);
40
+ }
41
+
42
+ // A band file is read once per change: the propose tool is called many times in a turn, and a primary sweep's band
43
+ // runs to tens of megabytes. Keyed on the path, its size and its modification time. Only what a held answer hands
44
+ // back is kept, the first records among it, so a worker's process does not hold every band whole.
45
+ const BAND_CACHE = new Map();
46
+ const HELD_RECORDS = 25;
47
+ function readBlocks(path) {
48
+ let st;
49
+ try { st = statSync(path); } catch { return []; }
50
+ const stamp = `${st.size}:${st.mtimeMs}`;
51
+ const hit = BAND_CACHE.get(path);
52
+ if (hit && hit.stamp === stamp) return hit.blocks;
53
+ let blocks = [];
54
+ try {
55
+ const j = JSON.parse(readFileSync(path, "utf8"));
56
+ blocks = (Array.isArray(j) ? j : []).filter((b) => b?.qid).map((b) => ({
57
+ qid: b.qid, state: b.state ?? null, error: b.error === true, total_hits: b.total_hits ?? null,
58
+ count: b.count ?? (Array.isArray(b.records) ? b.records.length : null),
59
+ records: Array.isArray(b.records) ? b.records.slice(0, HELD_RECORDS) : [],
60
+ }));
61
+ } catch { blocks = []; }
62
+ BAND_CACHE.set(path, { stamp, blocks });
63
+ return blocks;
64
+ }
65
+
66
+ /**
67
+ * Refuse the minted entries whose question the run has already answered on another row, the way the fold refuses a
68
+ * question the plan already holds: `duplicate-question`, with the row that answered it as the twin. Second framing's
69
+ * re-sweep calls this before its fold, because the fold sees only the plan's rows on the re-sweep's own axis, and a
70
+ * question another worker answered is not asked again. Only an answer settles: a withheld family has its own refusal,
71
+ * and a follow-up the safety net did not send would not be sent again. PURE.
72
+ * @returns {{ kept: object[], refused: {qid: string, term: string, kind: "duplicate-question", twin: string}[] }}
73
+ */
74
+ export function refuseHeldQuestions(entries, held, planRegions = []) {
75
+ const kept = [], refused = [];
76
+ for (const e of Array.isArray(entries) ? entries : []) {
77
+ const h = held?.get?.(questionKey(e, planRegions));
78
+ if (e?.qid && h?.decision === "answered" && h.qid && String(h.qid) !== String(e.qid))
79
+ refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "duplicate-question", twin: String(h.qid) });
80
+ else kept.push(e);
81
+ }
82
+ return { kept, refused };
83
+ }
84
+
85
+ /**
86
+ * The run's held answers and decisions, by question key. IMPURE (reads the run's register files).
87
+ * @returns {Map<string, { qid: string, axis: string, decision: "answered"|"withheld"|"outstanding", block?: object }>}
88
+ */
89
+ export function heldAnswers(runDir, { planRegions = [], withheld = {}, released = null } = {}) {
90
+ // A FAMILY WAITING FOR THE READING TURN IS ANSWERED ONLY ONCE IT IS RELEASED AND RUN, the plan join's own rule: until
91
+ // then it was not asked, whatever block its qid may carry, and a question repeating it is asked as the join expects.
92
+ let releasedQids = released;
93
+ if (!(releasedQids instanceof Set)) { try { releasedQids = releasedFamilyQids(runDir); } catch { releasedQids = new Set(); } }
94
+ const held = new Map();
95
+ const units = join(runDir, "register-units");
96
+ const blocksByQid = new Map();
97
+ const plans = [];
98
+ try { plans.push({ axis: null, plan: JSON.parse(readFileSync(driverDir(runDir, "register-plan.json"), "utf8")) }); } catch { /* no frozen plan */ }
99
+ let files = [];
100
+ try { files = readdirSync(units); } catch { files = []; }
101
+ for (const f of files) {
102
+ if (f.endsWith("-band.json")) for (const b of readBlocks(join(units, f))) if (b?.qid) blocksByQid.set(String(b.qid), b);
103
+ if (f.endsWith("-supplemental-plan.json")) {
104
+ try { plans.push({ axis: f.slice(0, -"-supplemental-plan.json".length), plan: JSON.parse(readFileSync(join(units, f), "utf8")) }); } catch { /* torn: skipped */ }
105
+ }
106
+ }
107
+ for (const { plan } of plans) {
108
+ for (const e of Array.isArray(plan?.entries) ? plan.entries : []) {
109
+ if (!e?.qid) continue;
110
+ const key = questionKey(e, planRegions);
111
+ if (held.has(key)) continue;
112
+ if (awaitsReadingTurn(e.when) && !releasedQids.has(String(e.qid))) {
113
+ if (withheld?.[e.qid]) held.set(key, { qid: e.qid, axis: e.axis, decision: "withheld" });
114
+ continue;
115
+ }
116
+ const b = blocksByQid.get(String(e.qid));
117
+ if (b && b.error !== true) held.set(key, { qid: e.qid, axis: e.axis, decision: "answered", block: b });
118
+ else if (withheld?.[e.qid]) held.set(key, { qid: e.qid, axis: e.axis, decision: "withheld" });
119
+ }
120
+ // A follow-up the run's safety net did not send is a decision too: proposed again, it would not be sent again.
121
+ for (const r of Array.isArray(plan?.rejected) ? plan.rejected : []) {
122
+ if (r?.issue !== OUTSTANDING_REASON || !r?.proposal) continue;
123
+ const key = questionKey(r.proposal, planRegions);
124
+ if (!held.has(key)) held.set(key, { qid: null, axis: null, decision: "outstanding" });
125
+ }
126
+ }
127
+ return held;
128
+ }
@@ -1082,7 +1082,7 @@ serve({
1082
1082
  findings_patch: {
1083
1083
  type: "array",
1084
1084
  description:
1085
- "A TARGETED CORRECTION, and the shape the repair prompts ask for. Send the complete corrected finding object(s), each carrying the `ordinal` of the finding it replaces, and send NOTHING else — no `findings`, no `narrative` unless you also corrected a section. The driver is holding every value you already sent and re-renders both files from them, so what you do not name comes back byte-identical. It cannot remove a finding: an ordinal names one to replace, and there is no way to express a deletion. Sending the whole `findings` document instead REPLACES the record, which is how fifteen findings were lost once — if you mean to correct four findings, send four rows here, not a document containing four.",
1085
+ "A TARGETED CORRECTION, and the shape the repair prompts ask for. Send the complete corrected finding object(s), each carrying the `ordinal` of the finding it replaces, and send NOTHING else — no `findings[]` list, no `narrative` unless you also corrected a section, and a `findings` object only to carry the registers you changed or the \"corrections\" marker. The driver merges this patch into the last accepted record, keeps the values the patch does not replace, and renders both files. It cannot remove a finding: an ordinal names one to replace, and there is no way to express a deletion. Sending a `findings` document that carries its `findings[]` list instead REPLACES the record, which is how fifteen findings were lost once — if you mean to correct four findings, send four rows here, not a document containing four.",
1086
1086
  items: {
1087
1087
  type: "object",
1088
1088
  required: ["ordinal"],
@@ -1109,7 +1109,11 @@ serve({
1109
1109
  narrative: {
1110
1110
  type: "object",
1111
1111
  description: "The cross-finding read, as sections. This is the material no other artifact on the run holds — NOT a second write-up of each finding, which lives on the report card. On a first call, send it complete. On a correction, send only the sections you changed — the rest are held.",
1112
- required: ["spine", "verdict"],
1112
+ // NO `required` HERE EITHER. It refused a correction that sent only the sections it changed — the save
1113
+ // this description and every repair prompt ask for — before the save code saw the call, and such a
1114
+ // refusal left no call file, only a line in the server's log. The save code still refuses a FIRST
1115
+ // call without the spine or the verdict, by name (`synthesis_spine_missing`, `synthesis_verdict_missing`),
1116
+ // and a patch keeps the held ones.
1113
1117
  properties: {
1114
1118
  spine: { type: "string", description: "The dominant-element spine: what carries these marks and what a register would weigh. The core of the product." },
1115
1119
  verdict: { type: "string", description: "The verdict prose — what the findings together mean for this client." },
@@ -1133,7 +1137,7 @@ serve({
1133
1137
  findings: {
1134
1138
  type: "object",
1135
1139
  description:
1136
- "The findings document — schema_version, rated_under_framework, findings[], coverage[], ask_answers[] and the other top-level registers. Already typed values; it arrives here instead of being written as a file so the joins below can run, and it is validated through the shipped parser, which names its own defect tokens. " +
1140
+ "The findings document — schema_version, rated_under_framework, findings[], coverage[], ask_answers[] and the other top-level registers. On a first call, send it whole. To correct part of the record, send no `findings[]` list (a finding is corrected through `findings_patch`) and only the registers you changed, each complete, with the \"corrections\" marker when the repair asks for it: every register you do not send is held. A `findings` object that carries a `findings[]` list replaces the whole record; so does one that changes `schema_version` or `rated_under_framework`, which must then carry its list. Already typed values; it arrives here instead of being written as a file so the joins below can run, and it is validated through the shipped parser, which names its own defect tokens. " +
1137
1141
  "TWO OF ITS FIELDS ALSO RENDER INTO THE NARRATIVE, which is why they are here and not on `narrative`: `coverage[]` becomes the readable coverage list (rows are {area, state, note}; `confirmed-clean` ASSERTS a search ran and is joined to this run's plan-execution receipt, so a clean claim over a slice that did not run is refused — name the area as the receipt names the slice, and the honest alternatives are coverage-limited and not-searched), and `ask_answers[]` becomes the labelled answer lines ({ask, answer}: the ask VERBATIM because the driver joins on it, and the ANSWER ALONE starting at its first word — the driver writes the \"- You asked: <ask> → \" label, so an answer that repeats the ask ships the question to the client twice).",
1138
1142
  },
1139
1143
  },
@@ -1165,7 +1169,7 @@ serve({
1165
1169
  chunkSummary: {
1166
1170
  type: "string",
1167
1171
  description:
1168
- "2–5 sentences covering THIS chunk's marks, as markdown narrative. EVERY chunk emits one: " +
1172
+ "The cross-mark read covering THIS chunk's marks: a short opening line for what the batch as a whole shows, then one \"## <MARK NAME>\" sub-header per mark with its band and the one or two things that drive it, bullets where they help. EVERY chunk emits one: " +
1169
1173
  "the whole-batch executive summary is composed in code by concatenating them, so a chunk " +
1170
1174
  "without one vanishes from the summary a client reads.",
1171
1175
  },
@@ -1207,7 +1211,7 @@ serve({
1207
1211
  registerEstimate: { type: "string" },
1208
1212
  parodyNote: { type: ["string", "null"] },
1209
1213
  crowdedField: { type: "boolean" },
1210
- purpleNotes: { type: "array", items: { type: "string" }, description: "Internal notes — removed on export, never client-facing." },
1214
+ purpleNotes: { type: "array", items: { type: "string" }, description: "Reviewer notes appear on the report page and are removed on export." },
1211
1215
  // — DECLARED HERE OR IT IS FOLKLORE.
1212
1216
  //
1213
1217
  // Both fields were built end to end for — instructed in the dispatch,
@@ -1409,7 +1413,7 @@ serve({
1409
1413
  inUseAs: { type: "string", description: "The kinds of use off the register that could conflict in this client's field, as one phrase completing \"Is this name already in use as …?\". The places the batch is searched on include the sites where these uses are listed." },
1410
1414
  places: {
1411
1415
  type: "array", items: { type: "string" },
1412
- description: "The 2 to 4 places every spelling is searched on: \"web\" for the whole web, and the client's stores and the sites where the kinds of use are listed, each a bare host. A host covers its subdomains.",
1416
+ description: "THE PLACES ARE PER NAME (task 2d) and go on each mark, not here.",
1413
1417
  },
1414
1418
  umbrellaBrandNote: { type: "string", description: "A note where an umbrella brand is in play, or omit it." },
1415
1419
  executionOrder: {