clearotron 0.4.0 → 0.4.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/build-info.json +2 -2
  3. package/docs/SECURITY-OWASP.md +21 -13
  4. package/docs/SECURITY.md +20 -14
  5. package/docs/architecture/04-configuration-reference.md +3 -2
  6. package/docs/architecture/05-config-governance.md +1 -1
  7. package/docs/architecture/06-operations-runbook.md +1 -1
  8. package/docs/architecture/09-security-and-data.md +16 -11
  9. package/driver/CHANGELOG.md +18 -0
  10. package/driver/binding-layers.mjs +28 -6
  11. package/driver/citation-census.json +3 -3
  12. package/driver/common-law-coverage-status.mjs +52 -0
  13. package/driver/common-law-receipts.mjs +1 -1
  14. package/driver/connotation-search.mjs +5 -5
  15. package/driver/contract-dictation-registry.mjs +1 -1
  16. package/driver/contract-e3-backlog.mjs +1 -1
  17. package/driver/contract-vocabulary.mjs +18 -18
  18. package/driver/degraded-parts.mjs +10 -3
  19. package/driver/dispatch-record.mjs +19 -0
  20. package/driver/doubt-closure-tool.mjs +2 -2
  21. package/driver/driver.config.mjs +39 -6
  22. package/driver/engagement-receipt.mjs +1 -1
  23. package/driver/engine/CONTRACT.md +41 -1
  24. package/driver/engine/anthropic-agent.mjs +56 -4
  25. package/driver/engine/mcp/codex-config.mjs +1 -1
  26. package/driver/engine/mcp/coverage-server.mjs +1 -1
  27. package/driver/engine/mcp/gather-config.mjs +1 -1
  28. package/driver/engine/mcp/recording-server.mjs +31 -4
  29. package/driver/engine/mcp/stdio-server.mjs +45 -7
  30. package/driver/engine/openai-agent.mjs +100 -17
  31. package/driver/engine/session-record.mjs +223 -0
  32. package/driver/enqueue-schema.mjs +2 -2
  33. package/driver/findings-model.mjs +3 -3
  34. package/driver/form-neighbourhood.mjs +7 -7
  35. package/driver/frame-diff-model.mjs +3 -3
  36. package/driver/framework.mjs +22 -0
  37. package/driver/gateway.mjs +94 -12
  38. package/driver/named-band.mjs +2 -0
  39. package/driver/package.json +1 -1
  40. package/driver/phase0.mjs +13 -2
  41. package/driver/pipeline-knockout.mjs +1 -1
  42. package/driver/pipeline.mjs +46 -33
  43. package/driver/portal-families.mjs +3 -3
  44. package/driver/portal-service.mjs +1 -1
  45. package/driver/predelivery-lint.mjs +19 -19
  46. package/driver/publish/index.mjs +1 -1
  47. package/driver/publish/knockout.mjs +5 -4
  48. package/driver/publish/office-record-links.mjs +17 -2
  49. package/driver/publish/publish-inputs.mjs +1 -1
  50. package/driver/publish/render-knockout.mjs +17 -5
  51. package/driver/publish/render.mjs +4 -2
  52. package/driver/record-fold.mjs +177 -0
  53. package/driver/register-count.mjs +18 -1
  54. package/driver/register-plan.mjs +71 -17
  55. package/driver/register-records.mjs +7 -0
  56. package/driver/registry-fidelity.mjs +2 -2
  57. package/driver/repairs.mjs +1 -1
  58. package/driver/report-card-record.mjs +4 -4
  59. package/driver/scope-facts.mjs +11 -3
  60. package/driver/search-policy.mjs +20 -1
  61. package/driver/skills/clearance-common-law/SKILL.md +22 -22
  62. package/driver/skills/clearance-register/digest.md +3 -3
  63. package/driver/skills/clearance-register/register-recipes.md +11 -11
  64. package/driver/skills/clearance-register/status-rules.md +1 -1
  65. package/driver/skills/clearance-register/unit.md +1 -1
  66. package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
  67. package/driver/skills/clearance-search/phase2-execution.md +6 -5
  68. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  69. package/driver/skills/clearance-search/worked-examples.md +39 -39
  70. package/driver/skills/clearance-variants/SKILL.md +16 -16
  71. package/driver/skills/frame-diff/SKILL.md +2 -2
  72. package/driver/stage-context.mjs +27 -1
  73. package/driver/stages.mjs +12 -12
  74. package/driver/suite-census.json +175 -31
  75. package/driver/synthesis-record.mjs +5 -0
  76. package/driver/unit-inventory.mjs +17 -28
  77. package/driver/verify.mjs +6 -6
  78. package/mcp-server/CHANGELOG.md +4 -0
  79. package/mcp-server/lib/evidence.mjs +8 -8
  80. package/mcp-server/lib/knockout.mjs +1 -1
  81. package/mcp-server/package.json +2 -2
  82. package/package.json +2 -2
  83. package/portal-ui/package.json +1 -1
  84. package/providers/_shared/count.mjs +4 -1
  85. package/providers/_shared/enumerate.mjs +61 -22
  86. package/providers/_shared/execute-plan.mjs +2 -0
  87. package/providers/corsearch/src/capabilities.js +1 -1
  88. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  89. package/providers/oauth-mcp-bridge/package.json +2 -2
  90. package/providers/signa/src/capabilities.js +17 -2
  91. package/providers/signa/src/core.js +234 -37
  92. package/scripts/band-shape-probe.mjs +2 -2
  93. package/scripts/deprecate-below.mjs +28 -4
  94. package/scripts/e2e-first-time.mjs +16 -1
  95. package/scripts/freeze-example-run.mjs +2 -2
  96. package/shared/scope.mjs +18 -2
@@ -70,7 +70,7 @@ export function parseFrameDiff(raw) {
70
70
  if (d.remedy != null) out.remedy = parseRemedy(d.remedy);
71
71
  // ── THE ASK CONTRACT AT ASK-RAISE (charter P2e) ───────────────────────────────────────────────
72
72
  // A firing directive is a REQUEST TO SEARCH SOMETHING. Until now the parser accepted one whose
73
- // item was a display label ("TAKIS (famous cpg snack, …)") with no remedy, and the refusal came
73
+ // item was a display label ("WAVA (famous cpg snack, …)") with no remedy, and the refusal came
74
74
  // hours later at reopen — by which time the session that could have restated it had exited. Four
75
75
  // of the five directives on the 2026-07-29 run died exactly there: loud (coverage rows, the clamp,
76
76
  // the audit trail) but terminal, with the head noun of a perfect one-query term sitting in the
@@ -143,7 +143,7 @@ export function undispatchableThrow(offenders) {
143
143
  * Every FIRING directive in a frame-diff document that cannot be dispatched — read LENIENTLY, from
144
144
  * raw text or an already-parsed object. This is the enumeration the gateway hint and the pipeline's
145
145
  * exhausted-ladder disclosure both run against the artifact ON DISK, because the fail string they
146
- * would otherwise scrape is cut at 160 characters (verify.mjs:381) and cannot carry more than the
146
+ * would otherwise scrape is cut at 160 characters (verify.mjs:381 commonLawMeaningSeat) and cannot carry more than the
147
147
  * first offender. Lenient on purpose: the artifact reaching these two callers has ALREADY failed
148
148
  * validation, so it may also carry an unknown key or a bad gap flag — refusing to enumerate then
149
149
  * would lose exactly the disclosures this exists to keep. Returns [{item, layer, severity, why}].
@@ -190,7 +190,7 @@ export function firingDirectivesLenient(rawOrDoc) {
190
190
  *
191
191
  * SCOPE IS DELIBERATELY THE VARIANT LAYER (plus any layer's own remedy):
192
192
  * * VARIANT — the evidenced class. The directive says "search this near-form", so its item stands
193
- * where a mark term stands, and a label there ("TAKIS (famous cpg snack, …)") is the whole defect:
193
+ * where a mark term stands, and a label there ("WAVA (famous cpg snack, …)") is the whole defect:
194
194
  * it dispatches as a nil search that reads CLEAN, or it dies unasked at reopen.
195
195
  * * FIELD — untouched. A class-gap directive whose item names no parseable class already has a
196
196
  * principled ending: deriveDirectiveRemedy returns null and the directive is DISCLOSED rather
@@ -139,6 +139,28 @@ export function aboveLowestBand(manifest, label) {
139
139
  return i !== -1 && i < manifest.bands.length - 1;
140
140
  }
141
141
 
142
+ /** The tones of the rungs whose findings are "material": medium risk or higher. A rung's tone is its own
143
+ * statement of its risk, so the line holds on any client's ladder. A line drawn by POSITION ("above the
144
+ * lowest band") did not: the bottom rung is "low" on a four-rung ladder and "minimal" on a five-rung one,
145
+ * so one client's low-risk rung was checked and another's was not (owner's ruling, 2026-10-09). */
146
+ export const MATERIAL_TONES = Object.freeze(["severe", "high", "medium"]);
147
+
148
+ /** True when the label is a band whose tone is medium risk or higher — the "material finding" predicate. */
149
+ export function materialBand(manifest, label) {
150
+ return MATERIAL_TONES.includes(bandTone(manifest, label));
151
+ }
152
+
153
+ /** The material bands' labels, highest first: what a dispatch names when it says which findings a check covers. */
154
+ export const materialBandLabels = (manifest) => (manifest?.bands ?? [])
155
+ .filter((b) => MATERIAL_TONES.includes(b?.tone)).map((b) => b.label);
156
+
157
+ /** The material bands as a reader's list — "Very High, High or Moderate" — or null when the ladder names none. */
158
+ export function materialBandPhrase(manifest) {
159
+ const xs = materialBandLabels(manifest);
160
+ if (!xs.length) return null;
161
+ return xs.length === 1 ? xs[0] : `${xs.slice(0, -1).join(", ")} or ${xs[xs.length - 1]}`;
162
+ }
163
+
142
164
  /** The most severe band label among the given labels (unknown labels ignored); null when none are bands. */
143
165
  export function worstBand(manifest, labels) {
144
166
  let best = -1;
@@ -246,7 +246,8 @@ import { REGISTER_ENUMERATE_TOOL, SUPPLEMENTAL_LANE_STEERING } from "./stages.mj
246
246
  // same thing to the exit-1 rescue below as it does to the register taint chain that reads the very
247
247
  // same rows off disk. Acyclic: register-taint.mjs imports node builtins only.
248
248
  import { isTaintRow } from "./register-taint.mjs";
249
- import { recordDispatch } from "./dispatch-record.mjs";
249
+ import { recordDispatch, streamFilePath } from "./dispatch-record.mjs";
250
+ import { anyResultErrored, endingFields } from "./engine/session-record.mjs"; // a session that reached an error result is not an ok attempt, whatever its last result said
250
251
  import { recordBestDraft } from "./best-draft.mjs";
251
252
  //: the allowlisted progress-quantity extractor. Acyclic — repairs.mjs imports node builtins only
252
253
  // (fs/crypto/path). Used only when the validator did not stamp its own count; never a digit hunt.
@@ -309,6 +310,37 @@ export function isTimeout({ killed, code, wall, stderr = "", timeoutSec }) {
309
310
  // on a parsed 0-usage object. A slow-but-working model that times out returns a status_timeout envelope WITH
310
311
  // usage (admitted) → NOT a wedge. Kept pure + exported so the classifier is unit-tested (it must not misfire on
311
312
  // admitted turns, and it MUST fire on the null-usage hard-kill that the production incident actually produced).
313
+ /**
314
+ * Did this attempt spend its whole derived budget, exit cleanly, and still fail? PURE.
315
+ *
316
+ * THE CASE THIS NAMES, measured on one round: an attempt ran 958.6 seconds against a derived limit of 900,
317
+ * exited 0, wrote nothing, and was recorded as `missing_file` naming its own output. It was working the whole
318
+ * time — 35,341 output tokens, 119 tool calls, 947.7 seconds of active time. The hard kill is the limit plus
319
+ * 60, so it finished 1.4 seconds under it.
320
+ *
321
+ * WHY THE CLASSIFICATION ABOVE IS RIGHT AND THE RECORD WAS STILL WRONG. `isTimeout`'s third signal needs a
322
+ * non-zero exit as well as a wall past the budget, and the program exited 0 — so it is not a timeout, and
323
+ * nothing here argues it should be. The defect is that the row then read `missing_file`, which names an
324
+ * artifact and sends the next reader to look for a path bug or a race. The two numbers that say otherwise
325
+ * were both on the row — the wall beside the derived limit — and nobody was pointed at them.
326
+ *
327
+ * SO THIS RECORDS A FACT AND MOVES NO POLICY. It does not feed `isTimeout`, it does not change which retry
328
+ * the attempt earns, and the fail class is untouched. Whether a clean exit at the budget should also earn the
329
+ * longer retry a timeout earns is a question for the owner, and the issue that asked for this says so
330
+ * explicitly: it may be right that a clean exit is not a timeout. This is the record it would be decided on.
331
+ *
332
+ * Three-valued, like the model fields on the same row: null when the budget is unknown, so "nobody derived a
333
+ * limit for this stage" stays visibly different from "it did not spend it".
334
+ */
335
+ export function spentBudgetCleanly({ fail, killed, code, wall, derivedLimitSec }) {
336
+ if (!fail) return false; // a passing attempt is not this shape
337
+ if (!Number.isFinite(derivedLimitSec) || derivedLimitSec <= 0) return null;
338
+ if (!Number.isFinite(wall)) return null;
339
+ if (killed) return false; // a kill is the timeout path, not this one
340
+ if (code !== 0) return false; // a dirty exit at the budget IS a timeout already
341
+ return wall >= derivedLimitSec;
342
+ }
343
+
312
344
  export function isLaneWedge(fail, usage) {
313
345
  if (fail !== "timeout") return false;
314
346
  const moved = (usage?.input || 0) + (usage?.output || 0) + (usage?.cacheRead || 0) + (usage?.cacheWrite || 0);
@@ -389,9 +421,35 @@ export function toolGauge(turn) {
389
421
  // engine cannot report", the same distinction toolWaitByTool draws one line up. RECORDING ONLY:
390
422
  // toolWaitMs and the per-tool split are exactly what they were, because the kill clock reads them.
391
423
  toolWaitUnmeasurable: Array.isArray(turn?.toolWaitUnmeasurable) ? [...turn.toolWaitUnmeasurable] : null, ...toolCallCounts(turn),
424
+ // The calls by tool name, beside the total, so a tool server's own log can be set against what the
425
+ // model asked for. An OBJECT is a measurement (`{}`: none); null is "this engine cannot report".
426
+ toolCallsByName: turn?.toolCallsByName && typeof turn.toolCallsByName === "object" && !Array.isArray(turn.toolCallsByName)
427
+ ? { ...turn.toolCallsByName } : null,
392
428
  };
393
429
  }
394
430
 
431
+ /**
432
+ * Whether the attempt is OK: the driver's verdict (`fail`) AND no result event of the session carried
433
+ * `is_error`. A session the vendor refused and a restart recovered ends in a successful LAST result, so
434
+ * the retry ladder (which reads that last result, unchanged) accepts it; the record says what happened.
435
+ * Readers that ask "did the stage fail" read `fail`, which this does not touch. PURE.
436
+ */
437
+ export function attemptOk(fail, turn) {
438
+ return !fail && !anyResultErrored(turn?.session);
439
+ }
440
+
441
+ /**
442
+ * The stream record as a row carries it: the file relative to the run, as the dispatch record names its
443
+ * file, and the size — or why it was not kept. null when the engine kept none or ran no session. PURE.
444
+ */
445
+ export function streamMeta(runDir, stream) {
446
+ if (!stream || typeof stream !== "object" || !stream.file) return null;
447
+ const dir = String(runDir ?? "");
448
+ const file = dir && String(stream.file).startsWith(dir) ? String(stream.file).slice(dir.length).replace(/^[\\/]+/, "") : String(stream.file);
449
+ return { file, present: stream.present === true, bytes: Number.isFinite(stream.bytes) ? stream.bytes : null,
450
+ ...(stream.reason ? { reason: stream.reason } : {}) };
451
+ }
452
+
395
453
  export function tokensPerSec(usage, wall) {
396
454
  const w = Number(wall);
397
455
  if (!usage || !Number.isFinite(w) || w <= 0) return null;
@@ -1073,7 +1131,10 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1073
1131
  // run-integrity.mjs for why it is not a manifest file and why the append-only journals are excluded.
1074
1132
  const integrityBefore = frozenSnapshot(runDir);
1075
1133
  const turn = await engine.runTurn({ agent, sessionKey: key, message: effMessage, grantDispatch: base, model, thinking, timeoutSec: effTimeout, resumeRef: warm ? lastSessionRef : undefined, codexHome: stageCodexHome, mcpConfig: gatherMcpConfig, allowedTools: gatherAllowedTools, seatWrites: gatherSeatWrites, skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
1076
- progressFiles: files }); // the no-progress watchdog's artifact-advance signal (anthropic-agent; other adapters ignore it)
1134
+ progressFiles: files, // the no-progress watchdog's artifact-advance signal (anthropic-agent; other adapters ignore it)
1135
+ // The raw stream of this session, beside its dispatch record (engine/session-record.mjs). The engines
1136
+ // keep it while the disk has room and say on the tuple's `stream` what landed.
1137
+ streamFile: streamFilePath(runDir, name, attempt) });
1077
1138
  const settledAt = Date.now(); //: zero point of the wall-rescue quiescence clock, read before anything else
1078
1139
  // LOG-ONLY. Nothing here can fail a turn: the claim that the frozen set does not change across a seat
1079
1140
  // turn is READ from the writers' call sites and not yet MEASURED on a running system, and arming an
@@ -1294,7 +1355,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1294
1355
  const rt = await engine.runTurn({ agent, sessionKey: key, message: repairMessage, model, thinking,
1295
1356
  timeoutSec: effTimeout, resumeRef: repairRef, codexHome: stageCodexHome, mcpConfig: gatherMcpConfig, allowedTools: gatherAllowedTools,
1296
1357
  skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
1297
- progressFiles: files });
1358
+ progressFiles: files, streamFile: streamFilePath(runDir, name, attempt, formRepairsUsed + 1) });
1298
1359
  repairRef = rt.sessionRef ?? repairRef;
1299
1360
  // A KILLED REPAIR TURN'S BYTES ARE NOT STAGE TRUTH. Same doctrine as the exit-1 rescue below: a
1300
1361
  // stage validator is a SHAPE check, not a completeness proof, and a torn draft can pass its own
@@ -1338,8 +1399,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1338
1399
  repair: formRepairsUsed, repairOf: MAX_FORM_REPAIRS, repairOutcome: outcome, fail: triggeredBy,
1339
1400
  repairTarget: rel(target), // the file the repair was AIMED at (often a sibling, not the expectFile)
1340
1401
  dispatch: repairDispatch, // — the verbatim message this repair turn was given
1341
- stopReason: rt.json?.stopReason ?? undefined,
1402
+ stopReason: rt.json?.stopReason ?? undefined, ...endingFields(rt.json), // — and what ended it, in the program's words on an error
1342
1403
  killed: rt.killed || undefined, signals: rt.signals ?? undefined,
1404
+ session: rt.session ?? null, stream: streamMeta(runDir, rt.stream), // — the repair session's own record, as on the attempt row
1343
1405
  warm: true, // a repair turn always RESUMES the session that wrote the defect
1344
1406
  runId: rt.json?.runId, engineSummary: rt.json?.summary, usage: rt.usage,
1345
1407
  selfReportContradicted: (outcome !== "repaired" && rt.json?.status === "ok") || undefined, //
@@ -1707,7 +1769,13 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1707
1769
  // attempt produce what the stage exists to produce". Sixty report-card attempts on round 892dd88e
1708
1770
  // journalled status:"ok", summary:"success", code:0, wrote:true and output.present:true beside a
1709
1771
  // populated `fail`, and a watcher keyed on the headline saw sixty successes.
1710
- ok: !fail,
1772
+ // — AND NOT WHEN THE SESSION REACHED AN ERROR RESULT, whatever its last result said: a refusal a
1773
+ // restart recovered is a session that was refused (engine/session-record.mjs). `fail` is unchanged,
1774
+ // so the ladder and every reader asking "did the stage fail" read exactly what they read before.
1775
+ ok: attemptOk(fail, turn),
1776
+ // What the session went through — every result, classifier cut, refusal and restart, with when —
1777
+ // and where its raw stream is, or why it was not kept. null where the engine cannot report.
1778
+ session: turn.session ?? null, stream: streamMeta(runDir, turn.stream),
1711
1779
  // The engine's claim about itself, KEPT and renamed to say whose claim it is. A self-report is
1712
1780
  // evidence — deleting it would destroy the only record of what the seat believed it had done — and
1713
1781
  // it is not a verdict. Still three-valued (ok | timeout | error). Nothing in the driver reads either
@@ -1743,7 +1811,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1743
1811
  laneWaitMs, // WS-C turn-cap queue time — the live overlap validation reads this
1744
1812
  // A6: the provider's stop reason, verbatim — the max_tokens fault line is countable from the
1745
1813
  // rows alone (fail carries the named fault; this field carries the raw discriminator).
1746
- stopReason: json?.stopReason ?? undefined,
1814
+ stopReason: json?.stopReason ?? undefined, ...endingFields(json), // — and beside it what ended the session: its kind, the vendor's status, and on an error the program's own words (session-record.mjs)
1747
1815
  // taint durability (copper-lattice): the kill discriminators survive to disk — register-taint.mjs
1748
1816
  // reads these rows across process restarts (--resume), where the in-memory turn object is gone.
1749
1817
  // `followup` matters too: a followup success PATCHES the prior session's output, it never
@@ -1785,6 +1853,11 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1785
1853
  // measure" stays visibly different from "this turn called no tools" — see toolGauge.
1786
1854
  ...toolGauge(turn), band: bandSize ?? undefined,
1787
1855
  inputBytes: derivedLimit?.inputBytes ?? undefined, derivedLimitSec: derivedLimit?.sec ?? undefined,
1856
+ // …and whether this attempt spent that limit and exited cleanly anyway. Written UNCONDITIONALLY and
1857
+ // three-valued, beside the two numbers it is about, so a failure that had used its whole budget is
1858
+ // not left looking like an artifact problem. It names a fact; it moves no retry policy. See
1859
+ // `spentBudgetCleanly`.
1860
+ budgetSpentCleanly: spentBudgetCleanly({ fail, killed, code, wall, derivedLimitSec: derivedLimit?.sec }),
1788
1861
  // AD-4 emitted-vs-landed, UNCONDITIONAL (was success-only, which made a failed attempt's mid-write
1789
1862
  // artifact invisible): `output` = what LANDED on disk after this attempt (null when the stage has no
1790
1863
  // expected file); `wrote` = whether THIS attempt emitted it (see the computation above the runDir
@@ -1815,7 +1888,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1815
1888
  // from "retries not recorded here"). Best-effort like every telemetry write.
1816
1889
  try {
1817
1890
  runLog(runDir, {
1818
- event: "attempt", stage: name, attempt, of: maxRetries + 1, ok: !fail, fail: fail ?? null,
1891
+ event: "attempt", stage: name, attempt, of: maxRetries + 1, ok: attemptOk(fail, turn), fail: fail ?? null,
1892
+ // The spine carries the one-word answer and the pointer; the per-stage row carries the record.
1893
+ interrupted: turn.session?.interrupted ?? null, stream: streamMeta(runDir, turn.stream)?.file ?? null,
1819
1894
  //: the spine carries the same pair as the per-stage log, or the two disagree about what
1820
1895
  // ran. `model` stays the requested resolution (its existing readers); `modelActual` is the wire.
1821
1896
  model: modelRequested, modelActual, modelBasis, modelSnapshot, modelMismatch, providerReported,
@@ -1850,6 +1925,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1850
1925
  // — the spine carries them too, or a round has to join two files to ask why a stage was slow.
1851
1926
  ...toolGauge(turn), band: bandSize ?? undefined,
1852
1927
  inputBytes: derivedLimit?.inputBytes ?? undefined, derivedLimitSec: derivedLimit?.sec ?? undefined,
1928
+ // …and the same fact on this row, because one of the two carrying it records half the runs and
1929
+ // reads as done. Three-valued and beside the numbers it is about. See `spentBudgetCleanly`.
1930
+ budgetSpentCleanly: spentBudgetCleanly({ fail, killed, code, wall, derivedLimitSec: derivedLimit?.sec }),
1853
1931
  formRepairs: formRepairsThisAttempt || undefined, //, see the stage row above
1854
1932
  });
1855
1933
  } catch { /* telemetry best-effort — never fail a turn over a journal line */ }
@@ -3037,7 +3115,7 @@ export function correctionHint(lastFail, { gridLedgerName = "common-law-grid.jso
3037
3115
  // run the driver stamped as form-required, and the stamp is conditional. On an unstamped run
3038
3116
  // validators.registerFindings demands the table exactly as it did before and emits this label,
3039
3117
  // so dropping the arm left the one lane that can still fire it with a generic hint.
3040
- hint = "the file has a findings heading plus a Coverage ledger with a status row (confirmed-clean / coverage-limited / deferred)" + (/common-law-findings/.test(lastFail) ? ", or each ledger row's status is recorded by calling `record_coverage_status` with `grid_spec_path`, the same spec path the grid tool was given" : "");
3118
+ hint = "the file has a findings heading plus a Coverage ledger with a status row (confirmed-clean / coverage-limited / deferred)"; // the common-law clause went with that lane's status gate (ruling 2026-10-01)
3041
3119
  } else if (/negative-results|coverage-ledger|audit-trail|findings-heading/.test(lastFail)) {
3042
3120
  hint = "the findings file carries ALL required sections: a findings heading, the Negative results matrix " +
3043
3121
  "(every variant × platform row), the Coverage ledger with a status row, and the Audit trail call log";
@@ -3114,7 +3192,7 @@ export function isFormClassFail(fail) {
3114
3192
  return FORM_CLASS_RE.test(fail ?? "");
3115
3193
  }
3116
3194
  // How many in-dispatch repairs one stage run may buy. TWO, and the number has a cause: parseFrameDiff
3117
- // is FAIL-FAST — it throws on the first bad severity (frame-diff-model.mjs:66) before it ever collects
3195
+ // is FAIL-FAST — it throws on the first bad severity (frame-diff-model.mjs:66 parseFrameDiff) before it ever collects
3118
3196
  // the undispatchable directives (:100) — so ONE artifact carrying both defects surfaces them
3119
3197
  // SEQUENTIALLY. That is exactly what the 08-02 frame-diff ladder did (severity on a1, undispatchable
3120
3198
  // on a2), and a cap of 1 would have handed the second one straight back to the ladder. The codebase
@@ -3201,7 +3279,7 @@ const MAX_FORM_REPAIRS = 2;
3201
3279
  // issue. The coverage-judgment family was COLD-ONLY — WARM_ELIGIBLE_RE carried seven coverage STRUCTURE
3202
3280
  // tokens and not one `coverage_clean_*` — so every retry re-dispatched a fresh session that re-read a
3203
3281
  // 1.9 MB band and re-derived a 160 KB document instead of editing it. The stage's own measured profile
3204
- // (repair-contract.mjs:10-18) is 105,747 out FAIL → 137,519 out FAIL → 36,362 out PASS, and the attempt
3282
+ // (repair-contract.mjs, 'WHY THIS EXISTS') is 105,747 out FAIL → 137,519 out FAIL → 36,362 out PASS, and the attempt
3205
3283
  // that passed is the one that PATCHED. A cold ladder never patches.
3206
3284
  // WHY THEY ARE SAFE TO WARM, on the same argument made one gate over: findCoverageFormViolations
3207
3285
  // emits both tokens only over rows the DRIVER wrote from the frozen plan and the plan-execution
@@ -3605,11 +3683,15 @@ export function warmPatchMessage(lastFail, expectFile, { supplementalLane = fals
3605
3683
  * calls the program refused (Claude's own denials; Codex's tool-server calls refused before reaching their
3606
3684
  * server). `commandToolCalls`: calls to a tool that runs a command, which no Claude stage is offered, so
3607
3685
  * zero is the expected reading; Codex keeps its shell and reports null. `mcpToolCalls`: the tool-server
3608
- * calls Codex completed, its only count of calls made, since its `toolCalls` stays null. Null, as in
3686
+ * calls Codex completed, its only count of calls made, since its `toolCalls` stays null.
3687
+ * `toolCallsErroredByName`: the tool-server calls that came back as an error, by tool name, so a stage that
3688
+ * had a call refused and made it again says so where its row otherwise reads ok on one attempt. Null, as in
3609
3689
  * `toolGauge`, is "this engine does not report", never zero. RECORDING ONLY. It sits at the end of this
3610
3690
  * file so that adding it moved no line another file cites.
3611
3691
  */
3612
3692
  function toolCallCounts(turn) {
3613
3693
  const n = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
3614
- return { toolCallsRefused: n(turn?.toolCallsRefused), commandToolCalls: n(turn?.commandToolCalls), mcpToolCalls: n(turn?.mcpToolCalls) };
3694
+ const errored = turn?.toolCallsErroredByName;
3695
+ return { toolCallsRefused: n(turn?.toolCallsRefused), commandToolCalls: n(turn?.commandToolCalls), mcpToolCalls: n(turn?.mcpToolCalls),
3696
+ toolCallsErroredByName: errored && typeof errored === "object" && !Array.isArray(errored) ? { ...errored } : null };
3615
3697
  }
@@ -129,6 +129,8 @@ export function parseNamedBand(raw) {
129
129
  // truncate the covered_by pointers with the reason's 400-char cap. Old bands carry neither key.
130
130
  ...(b.class_counts && typeof b.class_counts === "object" && !Array.isArray(b.class_counts) ? { class_counts: b.class_counts } : {}),
131
131
  ...(Array.isArray(b.covered_by) && b.covered_by.length ? { covered_by: b.covered_by } : {}),
132
+ // what the total counts, when the register said — carried like the counts it qualifies
133
+ ...(typeof b.total_counts === "string" ? { total_counts: b.total_counts } : {}),
132
134
  // — THE REFUSAL STAMP SURVIVES THE PROJECTION. `execute-plan.mjs` writes an errored slice
133
135
  // as `{state:"incomplete", total_hits:0, fetched:0, error:true}` and says why in its own header:
134
136
  // "the error:true stamp (a provider error is never confusable with a sanctioned crowd)". The
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.0",
5
+ "version": "0.4.1-beta.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
package/driver/phase0.mjs CHANGED
@@ -76,15 +76,26 @@ const boundName = (s) => String(s).slice(0, PLAN_MAX_NAME_LENGTH);
76
76
  // Refless jobs (ref/tmp absent — allowed since the intake relaxation) get `noref<6-hex>-<mark>`: the hash is
77
77
  // of job.id, the sanitized email message-id and the queue's dedup key, so the slug stays unique across
78
78
  // same-name marks AND stable across webhook re-delivery + resume. Refed jobs are byte-identical to before.
79
+ //
80
+ // THE SLUG IS THE FIRST PART OF THE RUN ID, AND THE RUN ID IS A URL PATH SEGMENT IN ASCII. Every route that
81
+ // serves a run, and every link a delivery carries, spells the run id as `[A-Za-z0-9._-]`
82
+ // (publish/index.mjs RUN_SEGMENT_RE, the portal's own gate), and refuses anything else: a link the portal
83
+ // would refuse is not built at all. `kebab` keeps every Unicode letter, so a mark written in Greek,
84
+ // Cyrillic, Han or with a letter such as Ł gave a run id no route accepts, and its run was delivered with
85
+ // no report link on any surface. So the mark's part of the slug keeps only what the routes accept. A name
86
+ // whose kebab is already ASCII is untouched, byte for byte, which is every run id minted before this; a
87
+ // name with nothing ASCII left reads `mark`, the knockout slug's own word for that case. The reference
88
+ // prefix and the codename keep the run id unique, as they always have: the mark only makes it readable.
89
+ const asciiMark = (slug) => slug.replace(/[^a-z0-9-]+/g, "").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "") || "mark";
79
90
  export function deriveSlug(job) {
80
91
  const raw = boundName(String(job.ref ?? job.tmp ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""));
81
92
  const mark = kebab(boundName(job.markName ?? job.name ?? job.marks?.[0]?.name ?? "mark"));
82
93
  if (!raw) {
83
94
  const h = createHash("sha256").update(String(job.id ?? mark)).digest("hex").slice(0, 6);
84
- return `noref${h}-${mark}`;
95
+ return `noref${h}-${asciiMark(mark)}`;
85
96
  }
86
97
  const tmp = raw.startsWith("tmp") ? raw : `tmp${raw}`;
87
- return `${tmp}-${mark}`;
98
+ return `${tmp}-${asciiMark(mark)}`;
88
99
  }
89
100
 
90
101
  // Calendar date in Europe/Zurich (the firm's timezone), YYYY-MM-DD.
@@ -1209,7 +1209,7 @@ export async function knockoutInner(ctx, job, opts = {}) {
1209
1209
  // ONE LINE, N LINKS. This said "Report: <one url>" and on a batch that url is now null, which would
1210
1210
  // have read "Report: null" — the fail-visible shape doing its job, and still not a line to send. A
1211
1211
  // batch names every report it produced, in the order the names were ordered.
1212
- whatsappText: `✅ Knockout screen${refTag} of ${nMarks} mark(s) is done — worst band ${overall}. `
1212
+ whatsappText: `✅ Knockout screen${refTag} of ${nMarks} mark(s) is done — ${nMarks === 1 ? "rating" : "highest rating"} ${overall}. `
1213
1213
  + (published.reports.length === 1
1214
1214
  ? `Report: ${published.reports[0].url}`
1215
1215
  : `Reports: ${published.reports.map((r) => `${r.mark} ${r.url}`).join(" · ")}`),
@@ -5,7 +5,7 @@
5
5
  // verdict, and never parks (there is no LLM continuation decision anywhere). CLI: `node pipeline.mjs --job <file.json>`.
6
6
 
7
7
  import "../shared/env-local.mjs"; // side effect: apply <repo>/.env when THIS file is the CLI entry (never on library import)
8
- import "./engine/mcp/http-dispatcher.mjs"; // side effect: raise undici headersTimeout (code-side fetches)
8
+ import "./engine/mcp/http-dispatcher.mjs"; import { recordStatusesFromFindings } from "./common-law-coverage-status.mjs"; // side effect: raise undici headersTimeout (code-side fetches); the second records a common-law ledger's statuses in code
9
9
  import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, copyFileSync, readdirSync, rmSync, statSync } from "node:fs";
10
10
  import { createHash } from "node:crypto";
11
11
  import { join, dirname, basename, resolve } from "node:path"; // resolve: the resume line must work from any cwd
@@ -148,6 +148,7 @@ import { beginAnswerMemory, endAnswerMemory } from "../providers/_shared/answer-
148
148
  import { findScreenGateViolations, findScreenGateParseGaps, screenGateZeroCause } from "./screen-gate.mjs";
149
149
  import { emptyQueue, coerceQueue, mintItem, pendingItems, markFlushed, receiptKeyFor,
150
150
  buildFlushFollowup, runPostFlushGateRepair } from "./digest-queue.mjs"; // (t1cd) — the digest-trigger funnel
151
+ import { foldFindingsFile, foldAfterSave, openPass, modelRecordPath, snapshotFold, restoreFold } from "./record-fold.mjs"; // the folded record from the delivery seam on
151
152
  import { writeStamp, stageStaleness, restamp, restampStage, staleOnPath, reconcileStamps, shaOf } from "./stage-freshness.mjs";
152
153
  import { parseManifestVariants, variantsParseFailure, findCoverageLimitedCells, partitionClosableCells, findSimilarListingSignals,
153
154
  GRID_HALVES, GRID_SEATS, MEANING_SEAT, splitGridSpec, halfOfTerm, balanceClosureCells, mergeGrids, mergeCommonLawFindings, openChannelRows,
@@ -2280,7 +2281,7 @@ export function deriveFormNeighbourhood(ctx) { // @internal — exported for i
2280
2281
  } catch { /* scope ledger optional — markets are informational; no ledger ⇒ nothing dropped (fail-open) */ }
2281
2282
  // NOT scoped here: the transliteration SCRIPT set. `markets` is passed through but never narrows
2282
2283
  // SUPPORTED_SCRIPTS, so latin-diacritic/german/nordic/cyrillic/greek run on every matter regardless of the
2283
- // jurisdictions judgment applied. That is what generated the five PARADISE diacritics — but the fold-dedupe
2284
+ // jurisdictions judgment applied. That is what generated the five Drivers Haven diacritics — but the fold-dedupe
2284
2285
  // in formNeighbourhood removes them at the source, and the surviving cross-script forms (ß→ss, Cyrillic and
2285
2286
  // Greek homoglyphs) are genuinely distinct strings that cost one term each. Deferred deliberately: narrowing
2286
2287
  // scripts by market is a coverage decision, and the harm it was meant to fix is already gone.
@@ -3933,7 +3934,7 @@ export function findFloorBreaches(ledger, floorAxes) { // @internal
3933
3934
  * `[]`: no manifest, unreadable, unparseable. That is the honest default here and not an absence read as
3934
3935
  * a pass, because the whole mechanism is opt-in — a run with no designation owes no floor, and a run whose
3935
3936
  * manifest cannot be read has no designation to honour. The refusal for an absent or unparseable manifest
3936
- * belongs to the stage that writes it and already exists there — verify.mjs:1335 runs the same parser
3937
+ * belongs to the stage that writes it and already exists there — verify.mjs:1335 CALL_REASON_SET runs the same parser
3937
3938
  * through `checkSiblingJson` and fails clearance-variants with `variantmodel_missing`. Checked, because
3938
3939
  * "something else refuses it" is exactly the assumption that turns a swallowed error into a silent pass.
3939
3940
  */
@@ -5922,6 +5923,14 @@ function snapshotFindingsForCorrections(P, runDir) {
5922
5923
  try { raw = readFileSync(P.findings, "utf8"); } catch { /* legacy / not-yet-populated */ }
5923
5924
  if (raw == null) return null;
5924
5925
  const sha = createHash("sha256").update(raw).digest("hex");
5926
+ // THE RECORD IN THE MODEL'S NUMBERING, beside the one on disk. The review's flags name findings in the
5927
+ // model's numbering, so the corrective body compares the record the model wrote; once the delivered
5928
+ // record is folded the two differ (record-fold.mjs). The fold is held too, for a rollback to put back.
5929
+ const modelAt = modelRecordPath(runDir, P.findings);
5930
+ let modelRaw = raw;
5931
+ if (modelAt !== P.findings) { try { modelRaw = readFileSync(modelAt, "utf8"); } catch { modelRaw = raw; } }
5932
+ const model = { raw: modelRaw, sha: modelRaw === raw ? sha : createHash("sha256").update(modelRaw).digest("hex") };
5933
+ const fold = snapshotFold(runDir);
5925
5934
  try {
5926
5935
  const tmp = driverDir(runDir, "findings-pre-corrective.json.tmp");
5927
5936
  // PR-4 (snapshot-before-overwrite): store the FULL pre-corrective text, not just its sha — a
@@ -5931,7 +5940,7 @@ function snapshotFindingsForCorrections(P, runDir) {
5931
5940
  writeFileSync(tmp, JSON.stringify({ ts: new Date().toISOString(), sha, text: raw }, null, 2));
5932
5941
  renameSync(tmp, driverDir(runDir, "findings-pre-corrective.json"));
5933
5942
  } catch { /* advisory sidecar */ }
5934
- return { raw, sha };
5943
+ return { raw, sha, model, fold };
5935
5944
  }
5936
5945
  /**
5937
5946
  *, T3b — THE CORRECTIVE PASS FAILED VALIDATION. RESTORE THE LAST GOOD STATE.
@@ -5979,6 +5988,7 @@ export function rollbackCorrectivePass(P, runDir, pre, fail) { // @internal
5979
5988
  // Atomic, like every other write to this file: a half-restored findings.json is a worse state than
5980
5989
  // either of the two this is choosing between.
5981
5990
  atomicWrite(P.findings, pre.raw);
5991
+ if (pre.fold) restoreFold(runDir, pre.fold); // the record the model wrote and the map, as they stood
5982
5992
  }
5983
5993
  const record = { ts: new Date().toISOString(), reason: String(fail?.fail ?? "failed").slice(0, 200),
5984
5994
  restored: changed, preSha: pre.sha, failedSha: nowSha };
@@ -6023,9 +6033,11 @@ export function rollbackCorrectivePass(P, runDir, pre, fail) { // @internal
6023
6033
  */
6024
6034
  export function repairUnnamedRemovals(P, runDir, pre, namedOrdinals, namedMarks, namedLines = []) { // @internal
6025
6035
  if (!pre?.raw) return null; // nothing held — nothing to compare against
6036
+ // In the model's numbering: the flags name its ordinals, and a folded record renumbers, so a finding
6037
+ // removed from the folded record would shift every ordinal after it (record-fold.mjs).
6026
6038
  let preDoc = null, postDoc = null, postRaw = null;
6027
- try { preDoc = JSON.parse(pre.raw); } catch { return null; }
6028
- try { postRaw = readFileSync(P.findings, "utf8"); postDoc = JSON.parse(postRaw); } catch { return null; }
6039
+ try { preDoc = JSON.parse(pre.model?.raw ?? pre.raw); } catch { return null; }
6040
+ try { postRaw = readFileSync(modelRecordPath(runDir, P.findings), "utf8"); postDoc = JSON.parse(postRaw); } catch { return null; }
6029
6041
  if (!preDoc || !postDoc || typeof postDoc !== "object") return null;
6030
6042
 
6031
6043
  const norm = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
@@ -6095,6 +6107,7 @@ export function repairUnnamedRemovals(P, runDir, pre, namedOrdinals, namedMarks,
6095
6107
  leftRemoved: leftRemoved.map(brief),
6096
6108
  };
6097
6109
  atomicWrite(P.findings, JSON.stringify(merged, null, 2) + "\n");
6110
+ foldAfterSave(runDir, P.findings); // after the seam the repaired record is folded again, as a save's is
6098
6111
  try { atomicWrite(driverDir(runDir, "corrective-repair.json"), JSON.stringify(record, null, 2) + "\n"); }
6099
6112
  catch { /* sidecar is best-effort; the runLog event is the record that decides */ }
6100
6113
  return record;
@@ -6218,10 +6231,11 @@ async function enforceCorrectionsReachFindings(ctx, P, pre, resume) {
6218
6231
  const named = correctionNamedSet(P);
6219
6232
  const readState = () => {
6220
6233
  let raw = null, corrections = null;
6221
- try { raw = readFileSync(P.findings, "utf8"); } catch { return { stale: false }; }
6234
+ // the record the model wrote: a change the fold hides is still a change it made (record-fold.mjs)
6235
+ try { raw = readFileSync(modelRecordPath(run.runDir, P.findings), "utf8"); } catch { return { stale: false }; }
6222
6236
  try { corrections = parseFindingsJsonLenient(raw).corrections; } catch { /* shape defects ride the normal ladder */ }
6223
6237
  const sha = createHash("sha256").update(raw).digest("hex");
6224
- return { stale: named.length > 0 && sha === pre.sha && !(corrections && corrections.applied), sha };
6238
+ return { stale: named.length > 0 && sha === (pre.model?.sha ?? pre.sha) && !(corrections && corrections.applied), sha };
6225
6239
  };
6226
6240
  if (!readState().stale) return;
6227
6241
  note(`[corrections] findings.json unchanged after the corrective pass (review names: ${named.join(", ")}) — demanding the re-emit`);
@@ -7477,26 +7491,15 @@ function enrichFindingDeadlines(P, runDir, note, { nowMs = Date.now(), withinDay
7477
7491
  }
7478
7492
  }
7479
7493
 
7494
+ // THE SEAM'S FOLD. From here on every accepted save folds the record again on acceptance, so the record
7495
+ // the reviewer, the cards and the delivery read stays folded (record-fold.mjs, design 2026-10-03). doc 50 —
7496
+ // on a v4 record the merge base is the WORST BAND by the frozen manifest's order. No duplicates leave the
7497
+ // file byte-identical; a folded shape that does not validate keeps the record as written.
7480
7498
  function consolidateFindingsFile(P, note, manifest = null) {
7481
- try {
7482
- if (!existsSync(P.findings)) return;
7483
- const doc = JSON.parse(readFileSync(P.findings, "utf8"));
7484
- if (!doc || !Array.isArray(doc.findings) || doc.findings.length < 2) return;
7485
- // doc 50 — on a v4 record the merge base is the WORST BAND by the frozen manifest's order
7486
- const { findings, merges, ordinalMap } = consolidateFindings(doc.findings, (doc.schema_version ?? 1) >= 4 ? manifest : null);
7487
- if (!merges.length) return; // no duplicates → leave the file byte-identical
7488
- const next = { ...doc, findings };
7489
- // spec 64 — actions follow the renumber: a merged-away ordinal remaps to its kept finding's new
7490
- // ordinal (the action still applies to the consolidated conflict — dropping the reference would
7491
- // silently un-condition a live demand).
7492
- if (Array.isArray(doc.actions)) next.actions = remapActionOrdinals(doc.actions, ordinalMap);
7493
- parseFindingsJson(JSON.stringify(next)); // re-validate the consolidated shape (throws → catch keeps original)
7494
- atomicWrite(P.findings, `${JSON.stringify(next, null, 2)}\n`); // B5 — a crash mid-write must never leave a truncated findings.json
7495
- for (const m of merges)
7496
- note(`[consolidate] ${m.owner} — ${m.mark}: folded ${m.dropped.length} duplicate filing(s) (ord ${m.dropped.join(", ")}) into one card`);
7497
- } catch (e) {
7498
- note(`[consolidate] skipped: ${String(e?.message || e).replace(/\s+/g, " ").slice(0, 100)}`);
7499
- }
7499
+ const { merges, error } = foldFindingsFile(P.runDir, P.findings, manifest, { seam: true });
7500
+ for (const m of merges)
7501
+ note(`[consolidate] ${m.owner} — ${m.mark}: folded ${m.dropped.length} duplicate filing(s) (ord ${m.dropped.join(", ")}) into one card`);
7502
+ if (error) note(`[consolidate] skipped: ${error.slice(0, 100)}`);
7500
7503
  }
7501
7504
  // minor (a): a finding earns a FULL prose card when composite ≥ 3 (on-field) OR level ∈ {A,B} (high legal
7502
7505
  // exposure even at a low practical composite — a mis-rated-low mark must not be reduced to a structured-only row).
@@ -8793,6 +8796,9 @@ async function pipelineInner(job, opts = {}) {
8793
8796
  // BEFORE doc 50 backfills its framework sidecar from the frozen frameworkPath (deploy-boundary case) so
8794
8797
  // the v4 gates always have vocabulary; a run that already has one reads it verbatim (never re-derived).
8795
8798
  attachFramework(ctx);
8799
+ // A pass opens before the delivery seam: until this pass's seam folds the record, a save writes the
8800
+ // model's record as it is (record-fold.mjs).
8801
+ openPass(ctx.paths.runDir);
8796
8802
 
8797
8803
  // ── (t1cd): the digest-trigger FUNNEL ──────────────────────────────────────────────────────
8798
8804
  // The digest-trigger funnel (; flag deleted post-E2E 2026-07-22): the queue
@@ -9217,7 +9223,7 @@ async function pipelineInner(job, opts = {}) {
9217
9223
  : stage(g.name, { ...ctx, axis: g.axis })),
9218
9224
  stage("blind-frame", ctx).catch((e) => ({ ok: false, fail: String(e?.message ?? e) })),
9219
9225
  ]);
9220
- if (!blindRes.ok) {
9226
+ if (!clSplit && clMember()?.ok) recordStatusesFromFindings(P.commonLaw); if (!blindRes.ok) {
9221
9227
  note(`blind-frame failed (non-fatal — no frame-diff/reopen this run): ${blindRes.fail}`);
9222
9228
  runLog(run.runDir, { event: "blind-frame-skipped", reason: blindRes.fail });
9223
9229
  }
@@ -9345,7 +9351,7 @@ async function pipelineInner(job, opts = {}) {
9345
9351
  // synthesis, replay) reads exactly the artifacts a single-member run would have written. Re-run after
9346
9352
  // every routed half followup (closure / frame-reopen) — the half files stay the source of truth and
9347
9353
  // the canonical pair is always derived, which also makes the merge resume-idempotent.
9348
- const mergeCommonLawArtifacts = !clSplit ? null : ({ discloseUnfinished = false } = {}) => {
9354
+ const mergeCommonLawArtifacts = !clSplit ? null : ({ discloseUnfinished = false } = {}) => { for (const h of GRID_HALVES) recordStatusesFromFindings(P.commonLawHalf(h), { half: h }); // each half's ledger statuses, recorded by code where the seat made no call (ruling 2026-10-01)
9349
9355
  // Each half's coverage truth = its plugin-written main ledger FOLDED with any plugin-written
9350
9356
  // supplementary sibling(s) the closure lane produced. Returns the batches as an ARRAY (mergeGrids'
9351
9357
  // batchesOf flattens it, cells union across all of them); a missing/corrupt main leaves its cells to
@@ -9424,7 +9430,7 @@ async function pipelineInner(job, opts = {}) {
9424
9430
  //
9425
9431
  // The prior channel is the TRUSTED one and already carries everything needed. Half forms are
9426
9432
  // driver-written, `seatFields(p, row)` carries their rulings by the same `pOk` path, and
9427
- // gateway.mjs:603 already unions this exact shape — prior-only, `{ rows: null }` submitted — on
9433
+ // gateway.mjs:630 selectEngine already unions this exact shape — prior-only, `{ rows: null }` submitted — on
9428
9434
  // every attempt. So this is that mode, not a new one.
9429
9435
  //
9430
9436
  // ONE METRIC MOVES, deliberately: with no submission, `carried` counts every ruled row, because
@@ -12644,9 +12650,9 @@ async function pipelineInner(job, opts = {}) {
12644
12650
  // always did, which is today's behaviour exactly.
12645
12651
  try {
12646
12652
  const rows = parseCorrections(readFileSync(P.seniorEyeReview, "utf8"));
12647
- const preDoc = preCorrective ? parseFindingsJsonLenient(preCorrective.raw) : null;
12653
+ const preDoc = preCorrective ? parseFindingsJsonLenient(preCorrective.model?.raw ?? preCorrective.raw) : null; // the flags name the model's ordinals, so both sides are the record the model wrote (record-fold.mjs)
12648
12654
  let postDoc = null;
12649
- try { postDoc = parseFindingsJsonLenient(readFileSync(P.findings, "utf8")); } catch { /* shape defects ride the normal ladder */ }
12655
+ try { postDoc = parseFindingsJsonLenient(readFileSync(modelRecordPath(run.runDir, P.findings), "utf8")); } catch { /* shape defects ride the normal ladder */ }
12650
12656
  correctionsApplied = buildCorrectionsApplied(rows, preDoc, postDoc);
12651
12657
  // — A CLAIM MAY NOT OUTLIVE ITS EVIDENCE, AND A DEMOTED STAMP MAY NOT COME BACK.
12652
12658
  //
@@ -14390,6 +14396,13 @@ async function pipelineInner(job, opts = {}) {
14390
14396
  lintActions = soft.actions ?? null; lintAskAnswers = soft.askAnswers ?? null; lintFourAnswers = soft.fourAnswers ?? null;
14391
14397
  } } catch { /* validators.findings owns the unparseable case */ }
14392
14398
  }
14399
+ // The narrative's finding headings are in the model's numbering: once the record is folded, its
14400
+ // write-ups join the record the model wrote (record-fold.mjs).
14401
+ let lintNarrativeFindings = null;
14402
+ const lintModelAt = modelRecordPath(run.runDir, P.findings);
14403
+ if (lintModelAt !== P.findings) {
14404
+ try { lintNarrativeFindings = parseFindingsJsonLenient(readFileSync(lintModelAt, "utf8")).findings; } catch { lintNarrativeFindings = null; }
14405
+ }
14393
14406
  // wp50/wi2 — the sidecar tier joins the lint so overall-level prose can't contradict it.
14394
14407
  let lintVerdict = null;
14395
14408
  try { lintVerdict = JSON.parse(readFileSync(driverDir(run.runDir, "verdict.json"), "utf8")); } catch { /* legacy */ }
@@ -14413,7 +14426,7 @@ async function pipelineInner(job, opts = {}) {
14413
14426
  // is presence-gated and simply emits nothing here; they stay alive for replay-archive.mjs,
14414
14427
  // which passes the archived summary.
14415
14428
  reportMd, narrativeMd, auditMd, recordsByUri: assembled.records, searchedNames,
14416
- fetchFailures: recordFetchFailures, findings: lintFindings, findingsRaw: lintFindingsRaw, actionsRegister: lintActions, verdictDoc: lintVerdict,
14429
+ fetchFailures: recordFetchFailures, findings: lintFindings, narrativeFindings: lintNarrativeFindings, findingsRaw: lintFindingsRaw, actionsRegister: lintActions, verdictDoc: lintVerdict,
14417
14430
  // WP-56 B2 — fresh v4 runs expect the standing mark-assessment (structural banner flag when
14418
14431
  // absent). The replay harness's runLint call never passes this, so archived runs never flip.
14419
14432
  markAssessment: lintMarkAssessment, markAssessmentExpected: lintSv >= 4,
@@ -18,8 +18,8 @@
18
18
  // ── the shape ────────────────────────────────────────────────────────────────────────────────────────
19
19
  //
20
20
  // { "schema": 1,
21
- // "families": { "hydra-range": { "name": "Hydra range", "account": "foxglade" } },
22
- // "of": { "<runId>": "hydra-range" } }
21
+ // "families": { "aqua-range": { "name": "Aqua range", "account": "foxglade" } },
22
+ // "of": { "<runId>": "aqua-range" } }
23
23
  //
24
24
  // Keyed by RUN rather than by mark. A mark is not a thing the pool stores — it is a grouping the browser
25
25
  // derives from run metadata — so a run id is the only stable handle the two sides already agree on. The
@@ -39,7 +39,7 @@ const FILE = "family-tags.json";
39
39
  // Caught by a test asserting a malformed file degrades to no families; it degraded to someone else's.
40
40
  const empty = () => ({ schema: 1, families: {}, of: {} });
41
41
 
42
- /** Ids are derived from the name so that grouping into "Hydra range" twice MERGES rather than duplicating. */
42
+ /** Ids are derived from the name so that grouping into "Aqua range" twice MERGES rather than duplicating. */
43
43
  export function familyId(name) {
44
44
  return String(name ?? "")
45
45
  .normalize("NFKD")
@@ -3147,7 +3147,7 @@ async function connectorDoorKind(url) {
3147
3147
  //
3148
3148
  // STAFF ONLY, and by construction rather than by filtering: it hangs off /portal/admin, which
3149
3149
  // asserted staffOnly above and 404s for everyone else. That is a decision, not an oversight. A
3150
- // family name is staff shorthand for a brand line — "Hydra range" can name a launch a client has
3150
+ // family name is staff shorthand for a brand line — "Aqua range" can name a launch a client has
3151
3151
  // not announced — and a grouping a client can see but not edit or explain raises more questions
3152
3152
  // than it answers. Revisit when clients have a reason to care; until then the client's list is
3153
3153
  // exactly the mark list, which is what the browser falls back to when this 404s.