clearotron 0.5.0-beta.2 → 0.5.0-beta.3

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 (57) hide show
  1. package/.env.example +7 -0
  2. package/build-info.json +2 -2
  3. package/docs/architecture/04-configuration-reference.md +1 -0
  4. package/docs/architecture/05-config-governance.md +1 -1
  5. package/driver/CHANGELOG.md +11 -0
  6. package/driver/band-shape.mjs +4 -4
  7. package/driver/contract-vocabulary.mjs +3 -3
  8. package/driver/coverage-form-io.mjs +3 -1
  9. package/driver/coverage-form.mjs +21 -2
  10. package/driver/coverage-ledger.mjs +7 -1
  11. package/driver/declination-call.mjs +33 -16
  12. package/driver/degraded-parts.mjs +6 -0
  13. package/driver/engine/mcp/clarivate-server.mjs +2 -0
  14. package/driver/engine/mcp/corsearch-server.mjs +2 -0
  15. package/driver/engine/mcp/declination-server.mjs +4 -5
  16. package/driver/engine/mcp/euipo-server.mjs +2 -0
  17. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  18. package/driver/engine/mcp/gather-config.mjs +15 -0
  19. package/driver/engine/mcp/held-answers.mjs +11 -3
  20. package/driver/engine/mcp/signa-server.mjs +2 -0
  21. package/driver/engine/mcp/supplemental.mjs +5 -2
  22. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  23. package/driver/envelope-settle.mjs +13 -3
  24. package/driver/gateway.mjs +31 -10
  25. package/driver/package.json +1 -1
  26. package/driver/parts-not-completed.mjs +52 -11
  27. package/driver/pipeline-knockout.mjs +5 -4
  28. package/driver/pipeline.mjs +436 -72
  29. package/driver/placement-decisions.mjs +4 -4
  30. package/driver/placement-form-io.mjs +21 -1
  31. package/driver/placement-form.mjs +25 -15
  32. package/driver/placement-union.mjs +53 -8
  33. package/driver/publish/index.mjs +7 -3
  34. package/driver/runner.mjs +4 -4
  35. package/driver/skills/clearance-register/digest.md +1 -1
  36. package/driver/skills/clearance-search/phase2-execution.md +3 -3
  37. package/driver/skills/clearance-search/synthesis-rules.md +3 -3
  38. package/driver/skills/placement-inquiry/SKILL.md +3 -3
  39. package/driver/stages.mjs +5 -5
  40. package/driver/suite-census.json +84 -12
  41. package/driver/verify.mjs +6 -6
  42. package/mcp-server/CHANGELOG.md +4 -0
  43. package/mcp-server/package.json +1 -1
  44. package/package.json +1 -1
  45. package/portal-ui/package.json +1 -1
  46. package/providers/_shared/README.md +1 -0
  47. package/providers/_shared/enumerate.mjs +4 -3
  48. package/providers/_shared/execute-plan.mjs +24 -7
  49. package/providers/_shared/send-until.mjs +25 -0
  50. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  51. package/providers/oauth-mcp-bridge/package.json +1 -1
  52. package/scripts/band-shape-probe.mjs +1 -1
  53. package/scripts/e2e-first-time.mjs +2 -1
  54. package/shared/names-in-force.mjs +1 -0
  55. package/shared/slices-taken-out.mjs +65 -0
  56. package/skills/clearotron-account/SKILL.md +1 -1
  57. package/skills/clearotron-ops/SKILL.md +1 -1
package/.env.example CHANGED
@@ -184,6 +184,13 @@ CLEAROTRON_DATABASE=clarivate
184
184
  # CLEAROTRON_GATHER_SESSION_KEY, CLEAROTRON_GATHER_SESSION_ID, CLEAROTRON_GATHER_AGENT,
185
185
  # CLEAROTRON_REGISTER_CALL_LOG, CLEAROTRON_REGISTER_RECORD_LOG
186
186
  #
187
+ # The time, in epoch milliseconds, at which a turn's register tools stop sending new questions, so the
188
+ # unit can write what it holds inside its own time. The driver writes it per turn into each register
189
+ # tool server's env, a quarter of the turn (at most five minutes) ahead of the turn's deadline. Leave it
190
+ # empty: a value set here would be read by the driver's own code-side executor as a fixed time.
191
+ # effect: tuning
192
+ CLEAROTRON_GATHER_SEND_UNTIL_MS=
193
+ #
187
194
  # THERE IS NO RETENTION SETTING AND NO CLEANUP JOB. CLEAROTRON_REGISTER_LEDGER_MAX_BYTES,
188
195
  # CLEAROTRON_REGISTER_LEDGER_KEEP and `npm run prune:ledger` are gone with the rotation tool, because the
189
196
  # thing they bounded no longer grows: RECORD BODIES ARE WRITTEN INTO THE RUN'S OWN DIRECTORY
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "bdb15f0c3974c46ccb0db2a2020779e65f0b4e21",
3
- "version": "0.5.0-beta.2"
2
+ "commit": "9a5ba90756f37ddefaee2a21ab79e9f7a374667a",
3
+ "version": "0.5.0-beta.3"
4
4
  }
@@ -373,6 +373,7 @@ cannot be read as one list.
373
373
  | `CLEAROTRON_DUMP_JSON` | unset | Dump each attempt's raw engine envelope to `_driver/<stage>.attempt<N>.rawjson.json`. Opt-in: any value except `0`/`off`/`false`/`no`/empty arms it. |
374
374
  | `CLEAROTRON_DISPATCH_RECORD` | **on** | Write the verbatim message of every stage dispatch to `_driver/<stage>.attempt<N>[.repair<M>].dispatch.txt`, with `{file, sha, bytes, chars, kind}` on the attempt row. **Default ON** — `0`/`off`/`false`/`no` disarms it. Unlike `CLEAROTRON_DUMP_JSON` beside it, this is opt-OUT: the question it answers ("was the model given this?") is asked *after* the run that raised it, so a flag someone had to remember would be off on exactly the run that needed it. The files carry the company's identity verbatim and are deliberately not in the artifact table. |
375
375
  | `CLEAROTRON_GATHER_SESSION_KEY` / `CLEAROTRON_GATHER_AGENT` / `CLEAROTRON_GATHER_SESSION_ID` | set per stage | Telemetry attribution into the provider-call ledger (set by the gather config; not operator-set). |
376
+ | `CLEAROTRON_GATHER_SEND_UNTIL_MS` | set per turn | When a turn's register tools stop sending new questions: a quarter of the turn, at most five minutes, ahead of its deadline (set by the gather config; not operator-set). |
376
377
  | `CLEAROTRON_RECORD_AXIS` | set per dispatch (unset ⇒ the stage is not fanned out) | Binds one fan-out turn of a recording stage to the single member it may write. `stageOnce` suffixes a fan-out stage's label with its axis, the gather config resolves `<stage>:<axis>` back to the base stage's tool group, and this carries the axis to the recording server. A call whose payload names a different member than the turn is bound to is REFUSED, so a seat cannot write into a sibling's file — without the binding every turn of the fan-out would record over member one. Set by the driver; not operator-set. |
377
378
  | `PORTAL_READ_MODEL` | `sonnet` | The model the portal's own compose-read turn uses. Distinct from the pipeline's tiers: this is a portal surface, not a stage. |
378
379
  | `CLEAROTRON_ORDER_PROBE_SEED` | unset | Seed for `scripts/band-shape-probe.mjs`, so an ordering probe can be replayed. A diagnostic script's knob, not a run's. |
@@ -282,7 +282,7 @@ edited — that box runs pre-rebuild code and genuinely still sets it.
282
282
  `CLEAROTRON_OUTBOX_BACKOFF_MAX_RETRIES` (5) / `CLEAROTRON_OUTBOX_GIVEUP_COOLDOWN_SEC` (3600 — note this one
283
283
  drops BACKOFF from the prefix, which the elided spelling this row used to carry actively misled about),
284
284
  `CLEAROTRON_OUTBOX_NOPROGRESS_MAX`, `TRADEMARK_MSGID_DOMAIN` (enqueue.local).
285
- Code-set per stage (not operator-set): `CLEAROTRON_GATHER_AGENT` / `CLEAROTRON_GATHER_SESSION_KEY` / `CLEAROTRON_GATHER_SESSION_ID`.
285
+ Code-set per stage (not operator-set): `CLEAROTRON_GATHER_AGENT` / `CLEAROTRON_GATHER_SESSION_KEY` / `CLEAROTRON_GATHER_SESSION_ID`; per turn: `CLEAROTRON_GATHER_SEND_UNTIL_MS`.
286
286
 
287
287
  **Who the completion notice reaches** — `CLEAROTRON_REQUESTER_WHATSAPP` (empty; effect: deployment) is a
288
288
  JSON map of requester email or handle → number, and `CLEAROTRON_WHATSAPP_OPERATOR_COPY` (on unless `0`,
@@ -1,5 +1,16 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.5.0-beta.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: The delivery email for a result the review could not sign off now lists the report's "Only you can close these" items.
8
+ - Fixed: When a clearance fails, its chat notice now goes to the person who asked for it, with a copy to the operator.
9
+ - Fixed: A clearance no longer repeats a finished step because a web lookup inside it timed out.
10
+ - Fixed: A clearance whose register search cannot finish, such as one that keeps timing out, now delivers its report instead of ending with none.
11
+ - Fixed: A search that cannot be shown to have run is now listed as not searched, and the report is still delivered.
12
+ - Fixed: Reports can set aside an identical mark registered for unrelated goods, recording the reason in the audit workbook instead of listing it as a finding.
13
+
3
14
  ## 0.5.0-beta.2
4
15
 
5
16
  ### Patch Changes
@@ -1129,8 +1129,8 @@ export const FLOORS_BY_NAME_HERE = "Every floor here is placed or set aside by n
1129
1129
  export const A_CROWDS_ONE_ROW = "Read the first page, carry forward any line a lawyer would raise, and set the rest aside in this group's one row with the count: the client is told the field is crowded.";
1130
1130
  export const A_CROWDS_ROW_COVERS_A_READ_FLOOR = "This row covers a floor only once you have read its line and its goods are not the client's goods.";
1131
1131
  export const AN_UNREAD_CROWDS_ONE_ROW = "Carry forward any line a lawyer would raise, and set the rest aside in this list's one row with the count, on the ground that their goods are unreadable and the field is crowded.";
1132
- export const AN_UNREAD_CROWDS_FLOOR = "A floor here is placed or set aside by name on its status or its territory; this row never covers it.";
1133
- export const AN_UNREAD_LISTS_FLOOR = "A floor here is placed, or set aside by name on its status or its territory; any other line may be set aside on the ground that its goods are unknown.";
1132
+ export const AN_UNREAD_CROWDS_FLOOR = "This row never covers a floor. Place each floor here, or set it aside by name; a goods ground needs the floor's goods read in full.";
1133
+ export const AN_UNREAD_LISTS_FLOOR = "Place each floor here, or set it aside by name; a goods ground needs the floor's goods read in full. Any other line here may be set aside because its goods are unknown.";
1134
1134
  // What placement does with a line of that list before its fate, by its marker (approved wording, 2026-10-19). Placement's
1135
1135
  // alone, as its other orders are: every other stage keeps the markers as evidence and is not told of a fate.
1136
1136
  export const AN_UNREAD_LINE_IS_CHECKED = 'A line marked "on file, not readable", "cut by the register, no goods word found", "goods held; completeness not established" or "no goods text held; completeness not established" must be checked with band_record before its fate. Read the evidence held for it, keeping its source and goods completeness clear. A line marked "no goods on file", "fetch failed, no goods on file" or "the register\'s record lists no goods" has no goods text on file. Missing goods do not establish that the goods are unrelated.';
@@ -1231,8 +1231,8 @@ function renderGroupsMd(md, groups, rows = []) {
1231
1231
  if (groups.no_goods.lines) {
1232
1232
  // The lines the goods test could not place, read by the same size rule (ruled 2026-10-12): under a thousand
1233
1233
  // records, a fate per line by name, opened where goods are on file; at a thousand or more, the first page,
1234
- // and one row on the rest whose goods are unreadable in a crowded field. A floor is never set aside on
1235
- // its goods, so a floor here is placed or set aside on its status or territory.
1234
+ // and one row on the rest whose goods are unreadable in a crowded field. A floor is set aside on its goods only
1235
+ // once they are read in full, which unknown goods never are, so a floor here is placed or set aside by name.
1236
1236
  const ng = groups.no_goods;
1237
1237
  const crowded = ng.records >= groups.crowded_at;
1238
1238
  const lead = tested.source === "none"
@@ -530,9 +530,9 @@ export const INNER_CODES = Object.freeze([
530
530
  // ONE CODE, TWO TOKENS, and the split is by `cause` rather than by code: verify.mjs peels the
531
531
  // `axis_invalid` cause into its own family before counting the rest. A ruling naming only
532
532
  // `coverage_no_status` would be true of most `no_status` records and false of the ones that matter most.
533
- { code: "no_status", mints: ["driver/coverage-form.mjs:920"], rollsUpTo: ["coverage_no_status", "coverage_form_axis_invalid"],
533
+ { code: "no_status", mints: ["driver/coverage-form.mjs:939"], rollsUpTo: ["coverage_no_status", "coverage_form_axis_invalid"],
534
534
  why: "Two composites, split on the record's `cause`: coverageFormFail() in verify.mjs for cause `axis_invalid`, and the same function for the rest. NOT the `@returns` annotation of findCoverageFormViolations() in coverage-form.mjs — that is the annotation, not the mint." },
535
- { code: "engine_vocabulary", mints: ["driver/coverage-form.mjs:910"], rollsUpTo: ["coverage_form_engine_vocabulary"],
535
+ { code: "engine_vocabulary", mints: ["driver/coverage-form.mjs:929"], rollsUpTo: ["coverage_form_engine_vocabulary"],
536
536
  why: "#669 — the seat wrote an engine token into the `reason` sentence that reaches the reader's page. Checked on settled rows too, because a row the seat considers finished is exactly the one whose sentence gets printed. Namespaced by coverageFormFail() in verify.mjs; the bare code names a row, never a stage." },
537
537
 
538
538
  // ── ONE CODE, TWO MODULES ────────────────────────────────────────────────────────────────────────
@@ -544,7 +544,7 @@ export const INNER_CODES = Object.freeze([
544
544
  // failure tokens" already states the rule this row records: `coverage_form_damaged`, never a bare
545
545
  // `form_damaged`.
546
546
  { code: "form_damaged",
547
- mints: ["driver/connotation-search.mjs:2002", "driver/connotation-search.mjs:2078", "driver/coverage-form.mjs:902"],
547
+ mints: ["driver/connotation-search.mjs:2002", "driver/connotation-search.mjs:2078", "driver/coverage-form.mjs:921"],
548
548
  rollsUpTo: ["connotation_form_damaged", "coverage_form_damaged"],
549
549
  why: "Minted in two lanes and namespaced per lane: connotationDispositionFail() in verify.mjs for the meaning sweep, coverageFormFail() in verify.mjs for the register digest. The namespacing is what keeps them apart — see verify.mjs's block headed \"the register coverage-form failure tokens\"." },
550
550
  ]);
@@ -32,6 +32,7 @@ import { COVERAGE_STATUSES, COVERAGE_FORM_NAME, REGISTER_AXES } from "./coverage
32
32
  import { coverageFormSidecarName, parseCoverageForm } from "./coverage-form.mjs";
33
33
  import { capabilitiesFor } from "./register-capabilities.mjs";
34
34
  import { readWithheldFamilies, splitWaitingFamilies } from "./withheld-families.mjs";
35
+ import { slicesTakenOutByAxis } from "../shared/slices-taken-out.mjs"; // the slices the searches record took out of the band
35
36
 
36
37
  export { COVERAGE_FORM_NAME };
37
38
 
@@ -150,7 +151,8 @@ export function coverageFormInput(runDir) {
150
151
  return { input: { skeleton: exec.skeleton, plan, bandBlocksByAxis, deferredReasons, activeAxes,
151
152
  bandsUnreadable, orderedTerritories, capabilities, unknownAxisUnits,
152
153
  awaiting: Array.isArray(exec.awaiting) ? exec.awaiting : [], withheld: readWithheldFamilies(runDir),
153
- mainMarkets: Array.isArray(exec.main_markets) ? exec.main_markets : [] }, absent: null };
154
+ mainMarkets: Array.isArray(exec.main_markets) ? exec.main_markets : [],
155
+ takenOut: Object.fromEntries(slicesTakenOutByAxis(runDir)) }, absent: null };
154
156
  }
155
157
 
156
158
  /**
@@ -353,10 +353,14 @@ export const DEFERRED_OPEN_BECAUSE = Object.freeze({
353
353
  });
354
354
  const deferredOpenBecause = (block, receiptReason) => block?.gateway_timeout === true ? DEFERRED_OPEN_BECAUSE.timeout
355
355
  : isProviderHardErrorReason(receiptReason) ? DEFERRED_OPEN_BECAUSE.hardError : DEFERRED_OPEN_BECAUSE.capability;
356
+ // A SLICE THE RUN TOOK OUT OF THE BAND (the searches not completed, parts-not-completed.mjs) is open for the reason its
357
+ // record gives, which is the shipped line the reader gets for it (ruled 2026-10-27): the provider could express it, and
358
+ // what is missing is the evidence that it ran. A taken-out slice with no qid has no plan row to defer it, so it gets a
359
+ // row of its own, keyed on its query.
356
360
 
357
361
  export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null,
358
362
  bandBlocksByAxis = {}, deferredReasons = {}, bandsUnreadable = [],
359
- orderedTerritories = [], capabilities = null, awaiting = [], withheld = {}, mainMarkets = [] } = {}) {
363
+ orderedTerritories = [], capabilities = null, awaiting = [], withheld = {}, mainMarkets = [], takenOut = {} } = {}) {
360
364
  const skel = Array.isArray(skeleton) ? skeleton.filter((s) => s && typeof s === "object") : [];
361
365
  // WHAT WAS RETRIEVED UNDER EACH CROWD (register-plan.mjs, crowdNarrowing), worked out from the same blocks these rows
362
366
  // are built from and carried on its row as data: a narrowed list retrieved in full (and which), none, or for the
@@ -423,6 +427,8 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
423
427
  status: null, reason: null,
424
428
  });
425
429
  }
430
+ const out = Array.isArray(takenOut?.[axis]) ? takenOut[axis] : [];
431
+ const takenReason = new Map(out.filter((x) => x?.qid && x?.reason).map((x) => [String(x.qid), String(x.reason)]));
426
432
  for (const qid of deferredQids) {
427
433
  rows.push({
428
434
  row_id: shortId("CD", `deferred:${qid}`),
@@ -433,7 +439,20 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
433
439
  // It was never searched, or it was asked and never answered, and nothing in this run asks it again, so a clean
434
440
  // claim over it is not a judgment the seat gets to make.
435
441
  open: true,
436
- open_because: deferredOpenBecause(blockByQid.get(qid), deferredReasons?.[qid]),
442
+ open_because: takenReason.get(qid) ?? deferredOpenBecause(blockByQid.get(qid), deferredReasons?.[qid]),
443
+ status: null, reason: null,
444
+ });
445
+ }
446
+ for (const x of out) {
447
+ if (x?.qid || !x?.query || !x?.reason) continue;
448
+ rows.push({
449
+ row_id: shortId("CD", `deferred:${axis}:query:${x.query}`),
450
+ axis, kind: "deferred",
451
+ unit: unitLabel(axis, { term: String(x.query) }),
452
+ qid: null, query: String(x.query),
453
+ receipt_reason: String(x.reason),
454
+ open: true,
455
+ open_because: String(x.reason),
437
456
  status: null, reason: null,
438
457
  });
439
458
  }
@@ -23,6 +23,7 @@
23
23
  // `validators` at module scope, so verify importing stages would TDZ-crash whenever verify loads
24
24
  // first; stages.mjs re-exports both names, keeping every existing import site working).
25
25
 
26
+ import { plainDeferralReason } from "./deferral-row.mjs";
26
27
  export const REGISTER_AXES = ["saturation-probe", "primary-sweep", "transliteration-numeric", "incumbent-class"];
27
28
 
28
29
  // ── — THE READER'S NAME FOR AN AXIS, EMITTED AT SOURCE ──────────────────────────────────────────
@@ -347,13 +348,18 @@ export function coverageLedgerTableRows(rows, { reasonMax = 180 } = {}) {
347
348
  // confirmed-clean rows on tainted material axes move; coverage-limited/deferred rows and non-material
348
349
  // axes (saturation-probe) are untouched; an empty taint set is a no-op. PURE; the caller pre-reads the
349
350
  // taint verdicts (pipeline loadCoverageLedger → readRegisterTaint).
351
+ // The reason a tainted axis's withdrawn clean claim carries: the shipped gap sentence (deferral-row.mjs).
352
+ export const TAINTED_CLEAN_REASON = plainDeferralReason("unfinished");
353
+
350
354
  export function applyTaintDeferred(rows, taintedAxes) {
351
355
  const tainted = new Set((taintedAxes ?? []).map((a) => String(a).toLowerCase()));
352
356
  if (!tainted.size) return rows ?? [];
353
357
  const material = (a) => !NON_MATERIAL_AXES.includes(String(a ?? "").toLowerCase());
358
+ // The pass that claimed clean was killed before it finished reading, so the claim is withdrawn whole: the row is open,
359
+ // with the shipped reason a reader gets for a part not completed, and none of the claim's words.
354
360
  return (rows ?? []).map((r) =>
355
361
  (r && r.status === "confirmed-clean" && tainted.has(String(r.axis ?? "").toLowerCase()) && material(r.axis))
356
- ? { ...r, status: "deferred", reason: `${String(r.reason ?? "").trim()}${r.reason ? " — " : ""}timeout-tainted pass: self-reported clean downgraded pending re-run`.trim() }
362
+ ? { ...r, status: "deferred", reason: TAINTED_CLEAN_REASON }
357
363
  : r);
358
364
  }
359
365
 
@@ -117,6 +117,13 @@ export const DECLINATION_REASONS = Object.freeze({
117
117
  gloss: "an off-field name that is not a major brand, an active dispute or proceeding, or a "
118
118
  + "well-known enforcer — omitted, never listed for completeness",
119
119
  },
120
+ // A COMPARISON OF MARKS (ruled 2026-10-27): the writer may set aside a near spelling on its sign, as the write-up
121
+ // may, with the write-up's own gloss. It is refused on the applied-for mark itself, which no comparison sets aside.
122
+ sign: {
123
+ discretionary: true,
124
+ rules: 'synthesis-rules.md, "The consumer confusion test — the governing gate"',
125
+ gloss: "a near spelling a buyer in this market could not take for the mark, by sound, by look or by meaning",
126
+ },
120
127
  "not-worth-the-line": {
121
128
  discretionary: true,
122
129
  rules: 'synthesis-rules.md, "Promote-or-omit"',
@@ -206,7 +213,7 @@ export function contradictionFor(reason, row, scope) {
206
213
  // class is closed to the entry that holds it. Each record is judged on its own mark, status and classes.
207
214
  if (Array.isArray(row?.members) && row.members.length) {
208
215
  for (const m of row.members) {
209
- const clash = contradictionFor(reason, { mark: m?.mark ?? row.mark, status: m?.status ?? null, classes: m?.classes ?? [] }, scope);
216
+ const clash = contradictionFor(reason, { mark: m?.mark ?? row.mark, status: m?.status ?? null, classes: m?.classes ?? [], goods: m?.goods ?? null }, scope);
210
217
  if (clash) return clash;
211
218
  }
212
219
  return "";
@@ -218,14 +225,26 @@ export function contradictionFor(reason, row, scope) {
218
225
  // it switched the identical-mark refusal below off on exactly those runs.
219
226
  const marks = Array.isArray(scope?.marks) ? scope.marks : (scope?.marks ? [scope.marks] : []);
220
227
 
228
+ // ── AN IDENTICAL MARK IN A FILED CLASS MAY BE DECLINED ON ITS GOODS, AND ON NOTHING ELSE DISCRETIONARY ──
229
+ //
230
+ // Ruled 2026-10-27: identical spelling and a shared class no longer bar a supported judgment that the registered
231
+ // goods are unrelated. This refused every discretionary token on such a record, so on a test run 29 goods
232
+ // decisions were refused and every one came back as a finding. A goods decision now stands where the run holds the
233
+ // record's goods complete (goods-evidence.mjs), because cut, unknown or missing goods establish nothing. The
234
+ // record's field, the report's length and a comparison with the applied-for mark itself are still refused.
221
235
  if (isIdenticalToAppliedMark(row?.mark, marks) && isLive(row?.status) && shared.length) {
222
- return `this record's mark is IDENTICAL to the applied-for mark under the house normalizer, its `
223
- + `status is ${String(row?.status ?? "").trim() || "live"}, and it sits in class ${shared.join(", ")} `
224
- + `— one of the matter's own filed classes. synthesis-rules.md:167 orders that an on-point `
225
- + `identical mark in the relevant class is never dropped, and says so "regardless of filer `
226
- + `profile". Deliver it and rate it; a Stage-2 mitigant is a rating input, never a reason to omit. `
227
- + `If it is the applicant's own right or already delivered under another record, say so with `
228
- + `own-right or duplicate-of-delivered.`;
236
+ const facts = `this record's mark is IDENTICAL to the applied-for mark, its status is `
237
+ + `${String(row?.status ?? "").trim() || "live"}, and it is in filed class ${shared.join(", ")}`;
238
+ if (reason === "unrelated-goods") {
239
+ if (row?.goods === "complete") return "";
240
+ return `${facts}. The run holds its goods as ${String(row?.goods ?? "").trim() || "not recorded"}, not in full, `
241
+ + `and incomplete goods cannot show the goods are unrelated. Deliver and rate it, or use own-right or `
242
+ + `duplicate-of-delivered if one is true.`;
243
+ }
244
+ return `${facts}. Such a mark is never declined for its field, for want of room, or on its sign. Deliver and `
245
+ + `rate it; a Stage-2 mitigant goes into the rating. If its registered goods, read in full, do not meet the `
246
+ + `matter's goods, decline it as unrelated-goods and say so in the ground. If it is the applicant's own right `
247
+ + `or already delivered, use own-right or duplicate-of-delivered.`;
229
248
  }
230
249
 
231
250
  // ── ECONOMY IS NOT A REASON ABOUT A LIVE IN-CLASS MARK (ruling 2026-09-07) ─────────────────
@@ -250,14 +269,12 @@ export function contradictionFor(reason, row, scope) {
250
269
  // It does effectively retire this token for live in-class records, because the sweep is already scoped
251
270
  // to the instructed classes. That was put to the owner as the consequence and ruled anyway.
252
271
  if (reason === "not-worth-the-line" && isLive(row?.status) && shared.length) {
253
- return `this record is ${String(row?.status ?? "").trim() || "live"} and sits in class `
254
- + `${shared.join(", ")} — one of the matter's own instructed classes. "not worth the line" is a `
255
- + `statement about the report's budget, not about this mark, and a live registration in an `
256
- + `instructed class is never omitted for want of room. Either carry it, or decline it on something `
257
- + `true of THIS MARK: unrelated-goods if its goods do not meet the matter's, off-field-not-major `
258
- + `if it is off-field and not a major brand, an active dispute or a well-known enforcer, `
259
- + `own-right if it belongs to the applicant, duplicate-of-delivered if the same right is already `
260
- + `reported. Say what makes this mark distant, or give it its line.`;
272
+ return `this record is ${String(row?.status ?? "").trim() || "live"} in instructed class ${shared.join(", ")}. `
273
+ + `A live mark in an instructed class is never declined for want of room. Deliver it, or decline it on a fact `
274
+ + `about this mark: unrelated-goods if its goods do not meet the matter's; off-field-not-major if it is `
275
+ + `off-field and not a major brand, an active dispute or a well-known enforcer; sign if a buyer in this market `
276
+ + `could not take its spelling for the mark; own-right if it belongs to the applicant; duplicate-of-delivered `
277
+ + `if the same right is already reported.`;
261
278
  }
262
279
 
263
280
  // THERE IS NO CLASS-OVERLAP REFUSAL, AND THE FIRST DRAFT HAD ONE. It refused `unrelated-goods` and
@@ -171,6 +171,12 @@ function register(runDir, log) {
171
171
  const open = readPartNotCompleted(runDir, "assessment");
172
172
  if (open && ["placement-inquiry", "register-digest"].includes(open.stage))
173
173
  failed.push([{ fail: `${open.stage} did not complete: ${open.reason ?? "no cause recorded"}` }, "the register write-up"]);
174
+ // The searches a check could not prove ran, taken out of the band and delivered as open rows (parts-not-completed.mjs).
175
+ const searches = readPartNotCompleted(runDir, "searches");
176
+ if (searches) failed.push([{ fail: [
177
+ (searches.slices ?? []).length ? `${searches.slices.length} slice(s) taken out of the band` : "",
178
+ (searches.disclosed_axes ?? []).length ? `the pass on ${searches.disclosed_axes.join(", ")} timed out, read as it stands with its clean claims open` : "",
179
+ ].filter(Boolean).join("; ") + `: ${searches.reason ?? "no cause recorded"}` }, "the searches"]);
174
180
  let reopen = null;
175
181
  try { reopen = JSON.parse(readFileSync(driverDir(runDir, "frame-reopen.json"), "utf8")); } catch { reopen = null; }
176
182
  if (reopen?.dominant_element_gap === true && reopen?.domClosed !== true)
@@ -29,6 +29,7 @@
29
29
  // `match_mode: "phonetic"` on register_search / register_enumerate remains fully available; it is the
30
30
  // PREVIEW of the expansion that this provider cannot give, not phonetic search itself.
31
31
  import { serve } from "./stdio-server.mjs";
32
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
32
33
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
33
34
  import {
34
35
  CAPABILITIES, doSearch, doRecordFetch, doImageFetch, doBatchScreen, doEnumerate, doExecutePlan, DEFAULT_BASE,
@@ -43,6 +44,7 @@ const tctx = (kind) => ({
43
44
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
44
45
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "", // run session key — telemetry attribution
45
46
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
47
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
46
48
  });
47
49
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
48
50
  const guard = (fn) => async (args) => {
@@ -7,6 +7,7 @@
7
7
  // session key (tctx.sessionKey) — we thread the run's session key via CLEAROTRON_GATHER_SESSION_KEY so the
8
8
  // $0 provider-usage diff still attributes calls to the run.
9
9
  import { serve } from "./stdio-server.mjs";
10
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
10
11
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
11
12
  import { CAPABILITIES, doSearch, doRecordFetch, doImageFetch, doExpandPhoneme, doBatchScreen, doEnumerate, doExecutePlan } from "../../../providers/corsearch/src/core.js";
12
13
  import { proposeSupplemental } from "./supplemental.mjs";
@@ -18,6 +19,7 @@ const tctx = (kind) => ({
18
19
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
19
20
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "", // run session key — telemetry attribution
20
21
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
22
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
21
23
  });
22
24
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
23
25
  const guard = (fn) => async (args) => {
@@ -83,12 +83,11 @@ serve({
83
83
  description:
84
84
  "Record, per position or page, your decision NOT to deliver something that reached your findings surface. " +
85
85
  "Every position the digest carried to you, and every page the web notes marked as a candidate or conflict, " +
86
- "either becomes a finding " +
87
- "or carries a stated reason it did not — there is no third way out, and one left silent is reported as " +
88
- "a defect of this run. One call carries a batch; the answer tells you what was accepted, " +
86
+ "ends as a finding or as a declination with its reason. One left with neither is reported as a defect " +
87
+ "of this run. One call carries a batch; the answer tells you what was accepted, " +
89
88
  "what was refused and why, and WHICH ROWS STILL CARRY NO DECISION, so you can finish inside this " +
90
- "turn. A refusal is about bookkeeping, never about your legal judgment: a reason is refused only " +
91
- "where it contradicts a fact the job recorded before this run started.",
89
+ "turn. A refusal is about the record's facts, never your legal judgment: a reason is refused only when it " +
90
+ "contradicts the matter's instructions or the record's mark, status, classes or goods as the run holds them.",
92
91
  inputSchema: { type: "object", required: ["declinations"], properties: {
93
92
  declinations: {
94
93
  type: "array",
@@ -28,6 +28,7 @@
28
28
  // `GET /trademarks/{n}/image` answers 200 with real image bytes. The tool returns metadata + the
29
29
  // Vienna figurative-element codes rather than the bytes; see the core for why.
30
30
  import { serve } from "./stdio-server.mjs";
31
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
31
32
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
32
33
  import {
33
34
  CAPABILITIES, CRED_ENV, doSearch, doRecordFetch, doBatchScreen, doImageFetch,
@@ -51,6 +52,7 @@ const tctx = (kind) => ({
51
52
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
52
53
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "",
53
54
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
55
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
54
56
  });
55
57
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
56
58
  const guard = (fn) => async (args) => {
@@ -30,6 +30,7 @@
30
30
  // Telemetry: the core writes CLEAROTRON_REGISTER_CALL_LOG / CLEAROTRON_REGISTER_RECORD_LOG (the SHARED register
31
31
  // ledger) under the provider id `free-tier`, keyed by the gateway session key.
32
32
  import { serve } from "./stdio-server.mjs";
33
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
33
34
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
34
35
  import {
35
36
  CAPABILITIES, doSearch, doRecordFetch, doBatchScreen, doImageFetch, doEnumerate, doExecutePlan,
@@ -47,6 +48,7 @@ const tctx = (kind) => ({
47
48
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
48
49
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "",
49
50
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
51
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
50
52
  });
51
53
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
52
54
  const CAPABILITY_OFFICES = CAPABILITIES.offices.covered.join("+");
@@ -900,6 +900,21 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis, bandGroups = false }
900
900
 
901
901
  // Build the mcp-config object for a set of groups. Returns { config: <object>, servers: [...] } or null if
902
902
  // the groups need no MCP server (e.g. caselaw-only EUR-Lex via WebFetch needs no server but the bridges do).
903
+ /**
904
+ * The stage's tool config for ONE turn, with that turn's time to stop sending handed to every server
905
+ * (providers/_shared/send-until.mjs). Built per turn, because each attempt has its own deadline. The config
906
+ * is the JSON string the engines take; anything that is not one is returned as it came. PURE.
907
+ */
908
+ export function withSendUntil(mcpConfigJson, sendUntilMs) {
909
+ if (typeof mcpConfigJson !== "string" || !Number.isFinite(Number(sendUntilMs))) return mcpConfigJson;
910
+ let cfg;
911
+ try { cfg = JSON.parse(mcpConfigJson); } catch { return mcpConfigJson; }
912
+ if (!cfg?.mcpServers || typeof cfg.mcpServers !== "object") return mcpConfigJson;
913
+ for (const server of Object.values(cfg.mcpServers))
914
+ if (server && typeof server === "object") server.env = { ...(server.env ?? {}), CLEAROTRON_GATHER_SEND_UNTIL_MS: String(Math.floor(Number(sendUntilMs))) };
915
+ return JSON.stringify(cfg);
916
+ }
917
+
903
918
  export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, recordAxis, bandGroups = false } = {}) {
904
919
  const localKeys = new Set(), bridgeKeys = new Set();
905
920
  for (const g of groups) { const r = resolveGroup(g); r.local.forEach((k) => localKeys.add(k)); r.bridges.forEach((k) => bridgeKeys.add(k)); }
@@ -15,7 +15,8 @@
15
15
  // is the same as one naming them. Owner names are compared as written, never merged. A different scope is a
16
16
  // different question, and an earlier answer that failed is no answer: both are asked again. A question that timed out
17
17
  // at the gateway is the one failure held, by the executor rather than here: its timeout stands until the envelope
18
- // close asks it once more (execute-plan.mjs, timedOutBefore).
18
+ // close asks it once more (execute-plan.mjs, timedOutBefore). An answer in a slice the run took out of its band
19
+ // because a check could not prove it ran is asked again too, whatever wrote the band since.
19
20
 
20
21
  import { readFileSync, readdirSync, statSync } from "node:fs";
21
22
  import { join } from "node:path";
@@ -23,6 +24,7 @@ import { driverDir } from "../../../shared/driver-dir.mjs";
23
24
  import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
24
25
  import { awaitsReadingTurn } from "../../../providers/_shared/plan-guards.mjs";
25
26
  import { releasedFamilyQids } from "../../withheld-families.mjs";
27
+ import { bandWithSlicesOut, slicesTakenOutByAxis } from "../../../shared/slices-taken-out.mjs";
26
28
 
27
29
  const QUESTION_FIELDS = ["predicate", "term", "terms", "romanizedTerms", "term_literal", "nice_classes", "regions", "goods_text", "owner", "expected_kind"];
28
30
  const sorted = (v) => [...v].map((x) => String(x)).sort();
@@ -88,7 +90,9 @@ export function refuseHeldQuestions(entries, held, planRegions = []) {
88
90
  }
89
91
 
90
92
  // The run's questions and answers as its files hold them: the frozen plan, each worker's supplemental plan, and every
91
- // band's blocks by qid. IMPURE.
93
+ // band's blocks by qid, with the slices the searches record took out read as the open slices they were delivered as
94
+ // (shared/slices-taken-out.mjs). They are read out after the cache, which knows a band's size and time and not the
95
+ // record's. IMPURE.
92
96
  function readRunQuestions(runDir) {
93
97
  const units = join(runDir, "register-units");
94
98
  const blocksByQid = new Map();
@@ -96,8 +100,12 @@ function readRunQuestions(runDir) {
96
100
  try { plans.push({ axis: null, plan: JSON.parse(readFileSync(driverDir(runDir, "register-plan.json"), "utf8")) }); } catch { /* no frozen plan */ }
97
101
  let files = [];
98
102
  try { files = readdirSync(units); } catch { files = []; }
103
+ const takenOut = slicesTakenOutByAxis(runDir);
99
104
  for (const f of files) {
100
- if (f.endsWith("-band.json")) for (const b of readBlocks(join(units, f))) if (b?.qid) blocksByQid.set(String(b.qid), b);
105
+ if (f.endsWith("-band.json")) {
106
+ const { blocks } = bandWithSlicesOut(readBlocks(join(units, f)), takenOut.get(f.slice(0, -"-band.json".length)));
107
+ for (const b of blocks) if (b?.qid) blocksByQid.set(String(b.qid), b);
108
+ }
101
109
  if (f.endsWith("-supplemental-plan.json")) {
102
110
  try { plans.push({ axis: f.slice(0, -"-supplemental-plan.json".length), plan: JSON.parse(readFileSync(join(units, f), "utf8")) }); } catch { /* torn: skipped */ }
103
111
  }
@@ -9,6 +9,7 @@
9
9
  // keyed by the GATEWAY session key (tctx.sessionKey) — we thread the run's session key via
10
10
  // CLEAROTRON_GATHER_SESSION_KEY so the $0 provider-usage diff still attributes calls to the run.
11
11
  import { serve } from "./stdio-server.mjs";
12
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
12
13
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
13
14
  import { doSearch, doRecordFetch, doEnumerate, doExecutePlan, DEFAULT_BASE } from "../../../providers/signa/src/core.js";
14
15
  import { proposeSupplemental } from "./supplemental.mjs";
@@ -22,6 +23,7 @@ const tctx = (kind) => ({
22
23
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
23
24
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "", // run session key — telemetry attribution
24
25
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
26
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
25
27
  });
26
28
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
27
29
  const guard = (fn) => async (args) => {
@@ -36,6 +36,7 @@ import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, planExactOrWi
36
36
  import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
37
37
  import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
38
38
  import { heldAnswers, questionKey } from "./held-answers.mjs";
39
+ import { bandWithSlicesOut, slicesTakenOutByAxis } from "../../../shared/slices-taken-out.mjs";
39
40
  import { readWithheldFamilies } from "../../withheld-families.mjs";
40
41
  import { addedClassBound, boundAddedClasses } from "../../frame-diff-model.mjs"; // an added class takes one question, the identical mark
41
42
  import { scriptQuestionsByMarket } from "../../registration-scripts.mjs"; // one script question per market on the plan's list
@@ -573,11 +574,13 @@ export async function proposeSupplemental(params, tctx, deps) {
573
574
  }
574
575
 
575
576
  // A question this worker re-proposes that already holds an answer is not run again: the answer stands. One with no
576
- // answer, a failed one, or one this call enriched or gave its markets runs as before.
577
+ // answer, a failed one, one whose slice the run took out of the band because a check could not prove it ran
578
+ // (shared/slices-taken-out.mjs), or one this call enriched or gave its markets runs as before.
577
579
  let reusedHeld = [];
578
580
  try {
579
581
  const own = new Map();
580
- for (const b of JSON.parse(readFileSync(outPath, "utf8"))) if (b?.qid) own.set(String(b.qid), b);
582
+ const { blocks } = bandWithSlicesOut(JSON.parse(readFileSync(outPath, "utf8")), slicesTakenOutByAxis(dirname(dirname(outPath))).get(axis));
583
+ for (const b of blocks) if (b?.qid) own.set(String(b.qid), b);
581
584
  reusedHeld = reused.filter((q) => { const b = own.get(String(q)); return b && b.error !== true && !enrichedQids.includes(q) && !staleOnDisk; });
582
585
  } catch { reusedHeld = []; }
583
586
  const qids = [...minted.map((e) => e.qid), ...reused.filter((q) => !reusedHeld.includes(q))];
@@ -32,6 +32,7 @@
32
32
  // visible where a spawn and a reviewer each actually look — the tool list here, the REGISTER_SERVERS
33
33
  // filter in gather-config.mjs, and the exact-six pin in driver/test/engine.gather.test.mjs.
34
34
  import { serve } from "./stdio-server.mjs";
35
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
35
36
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
36
37
  import {
37
38
  CAPABILITIES, DEFAULT_DB_ENV, doSearch, doRecordFetch, doBatchScreen, doEnumerate, doExecutePlan,
@@ -48,6 +49,7 @@ const tctx = (kind) => ({
48
49
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
49
50
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "", // run session key — telemetry attribution
50
51
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
52
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
51
53
  });
52
54
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
53
55
  const guard = (fn) => async (args) => {
@@ -37,6 +37,7 @@
37
37
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
38
38
  import { isCapabilityGapReason } from "./coverage-ledger.mjs";
39
39
  import { fullyDeferredAxes } from "./register-plan.mjs";
40
+ import { readPartNotCompleted } from "./parts-not-completed.mjs";
40
41
 
41
42
  export const SETTLE_SCHEMA_VERSION = 1;
42
43
 
@@ -194,9 +195,18 @@ export function settleTriedAfter(prior, dispatched, planVersion) {
194
195
  return [...out.values()];
195
196
  }
196
197
 
197
- /** The run's sticky capability gaps, by qid → row. Absent or unreadable decision ⇒ none. */
198
+ /**
199
+ * The run's sticky gaps, by qid → row: the capability gaps it accepted, and the slices a check could not prove ran,
200
+ * which the searches' record took out of the band once their recovery was spent (parts-not-completed.mjs). Neither is
201
+ * sent to the provider again, settled again or re-run to close. Absent or unreadable decision and record ⇒ none.
202
+ */
198
203
  export function readStickyGaps(P) {
199
- return new Map((readEnvelopeDecision(P)?.sticky_gaps ?? []).map((g) => [String(g?.qid ?? ""), g]).filter(([q]) => q));
204
+ const out = new Map((readEnvelopeDecision(P)?.sticky_gaps ?? []).map((g) => [String(g?.qid ?? ""), g]).filter(([q]) => q));
205
+ for (const s of (P?.runDir ? readPartNotCompleted(P.runDir, "searches")?.slices : null) ?? []) {
206
+ const qid = String(s?.qid ?? "");
207
+ if (qid && !out.has(qid)) out.set(qid, { qid, axis: String(s.axis ?? ""), reason: String(s.reason ?? "").slice(0, 400) });
208
+ }
209
+ return out;
200
210
  }
201
211
 
202
212
  /** One history entry per superseded decision. Returns the prior history with the old decision appended. */
@@ -225,7 +235,7 @@ export function supersede(prior, source) {
225
235
  export async function settleReceipt({ P, plan, receipt, dispatch = null, rejoin = null, now = null }) {
226
236
  const deferred = receipt?.deferred ?? [];
227
237
  const prior = readEnvelopeDecision(P);
228
- const sticky = new Set((prior?.sticky_gaps ?? []).map((g) => String(g?.qid ?? "")));
238
+ const sticky = new Set(readStickyGaps(P).keys());
229
239
  let { accepted, suspect } = partitionReceiptDeferrals(plan, receipt, { sticky });
230
240
  const closed = [], closeFailed = [];
231
241
  const tried = new Map((prior?.settle_tried ?? []).map((t) => [String(t?.qid ?? ""), t]));