@sema-agent/core 5.58.0 → 5.59.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.59.0 — 2026-08-24
4
+
5
+ ### BREAKING (narrow, named)
6
+ - **`ConsolidationDriverEngine` requires `readConsolidationPlanFoldEvidence`** (the member-level
7
+ conservation wall's read face). `MemoryEngine` satisfies it as before; a custom driver-engine stub
8
+ must add the member (compile error otherwise). Default-off feature shipped one version ago —
9
+ blast radius is stub authors only.
10
+
11
+ ### Changed — consolidation safety narrowings (design/376 slice ③; default-off face, all fail-closed)
12
+ - **Member-level conservation wall**: a run whose planned members were deleted/excluded/folded by
13
+ another writer mid-run now settles `driver_failed` naming the missing ids, where a numeric offset
14
+ previously produced a false `converged`. Attribution uses the exact plan-frozen cycle-id grammar
15
+ (prefix-shadow ids cannot re-satisfy a member). Count witnesses remain the fast pass.
16
+ - **C-2 scan-refusal reach**: scan findings refuse per PRODUCT (disclosed; whole-plan refusal past
17
+ `min(floor(0.25×submitted), 8)` — knobs `scanRefusalRatioCap`/`scanRefusalMaxPerPlan`, bad values
18
+ refuse loudly); an empty or frozen-empty plan refuses instead of settling a vacuous completed;
19
+ refused input ids are excluded from the completion fingerprint and ride the plan file under a
20
+ bidirectional v2⇔member version gate (a pre-field worker parks loudly on a v2 plan instead of
21
+ settling wrong). WIDENING, named: refused products' edges no longer trip the supersession fuse.
22
+ - **C-3 instruction-form hard gate is unconditional**: a clean visible set refuses instruction-form
23
+ products by default (previously committed), naming the host escape hatch
24
+ `consolidation.allowInstructionProducts: true` (screened exactly-true; opens the clean arm only —
25
+ a marked set refuses with no opening knob).
26
+ - Registry reads are journal-aware (a committed journal wins; an unreadable journal throws
27
+ fail-closed instead of serving a clean-looking stale registry).
28
+
29
+ ### Added
30
+ - **`NOTICE_AUDIENCE` registry (closed)**: `ENGINE_NOTICE_CODES` is the 27-code catalog,
31
+ `NOTICE_AUDIENCE` answers for every member (8 user / 19 operator) in compile-time lockstep both
32
+ directions; `task.user_steer_undrained`/`task.user_followup_undrained` now carry `sessionId`
33
+ filled from the real run; catalog/code/audience types root-exported for downstream table diffing.
34
+ - **`AskRequest.hasBidiControls`** presence bit (additive): minted at the ask throat over
35
+ execution-snapshot args + tool preview for the directional-control class (U+061C/200E/200F/
36
+ 202A-202E/2066-2069); presence-only (never `false`; absence covers budget exhaustion), zero
37
+ rewriting; the scan charges every character — values, object keys, Map keys, inherited names —
38
+ through ONE bounded reader (node budget + 1M code-unit allowance). Plus
39
+ **`renderUntrustedCommandText`** root export: the lossy DISPLAY baseline for raw command bytes
40
+ (strips Cf, folds controls to spaces, code-point cap with ellipsis; identity stays on raw).
41
+ - **Read faces**: `readConsolidationGateState` (present|absent|corrupt-with-detail; legacy face
42
+ byte-unchanged), `readConsolidationPlanFoldEvidence` (ok/absent/corrupt; applied-only edges),
43
+ `MemoryEngine.listMemoryScopes()`/File-backend `listScopes()` with the `supported` discriminator
44
+ (a backend that cannot enumerate answers `supported:false` with a reason, never `[]`).
45
+ - **Export tiers v1**: the export-surface snapshot carries a machine-readable `tiers` column
46
+ (stable/advanced/internal; self-declared heuristic) with a three-leg gate (every live export
47
+ tiered, no stale rows, live derivation ≡ frozen column).
48
+ - `OriginClearanceRow`/`OriginClearanceEvent` root type exports (server design/316 car-B compile
49
+ prerequisite). Export surface 1749 → 1758, all additive.
50
+
3
51
  ## 5.58.0 — 2026-08-24
4
52
 
5
53
  ### BREAKING (design/375 case A — per-segment batch consent; ships as one window, no compatibility arms)
@@ -174,7 +174,13 @@ export declare function adjudicateDerivedRoute(args: {
174
174
  */
175
175
  export declare function sameRouteIdentity(a: Pick<Model, "id" | "baseUrl">, b: Pick<Model, "id" | "baseUrl">): boolean;
176
176
  /** The `route.fallback_to_primary` announcement — one shared shape for every derived-leg seat, so
177
- * a consumer can rely on `detail.{seat, from, to, cause}` regardless of which seat fell back. */
177
+ * a consumer can rely on `detail.{seat, from, to, cause}` regardless of which seat fell back.
178
+ *
179
+ * `sessionId` (#433, additive): the swap happened while resolving ONE run's seats, so the run is
180
+ * what an operator correlates the line against ("which task ran on the fallback model"). The
181
+ * AUDIENCE stays `"operator"` — the fix is a credential/catalog fix, and no end user can act on it;
182
+ * attribution is a routing key, not an entitlement. Omitted when a caller has no session in hand,
183
+ * never fabricated. */
178
184
  export declare function fallbackToPrimaryNotice(args: {
179
185
  seat: string;
180
186
  from: string;
@@ -182,6 +188,7 @@ export declare function fallbackToPrimaryNotice(args: {
182
188
  verdict: Extract<RouteAdjudication, {
183
189
  ok: false;
184
190
  }>;
191
+ sessionId?: string;
185
192
  }): EngineNotice;
186
193
  /** The auth carriers of a header bag, canonicalized (case-fold name → value, sorted) — the
187
194
  * Model-visible credential fingerprint used by catalog-change disclosure (a same-name entry whose
@@ -173,7 +173,14 @@ export function fallbackToPrimaryNotice(args) {
173
173
  code: "route.fallback_to_primary",
174
174
  message: `derived model "${args.from}" (${args.seat}) is not routable (${args.verdict.code}) — ` +
175
175
  `falling back to the primary model "${args.to}". ${args.verdict.detail.fixHint}`,
176
- detail: { seat: args.seat, from: args.from, to: args.to, cause: args.verdict.code, fixHint: args.verdict.detail.fixHint },
176
+ detail: {
177
+ seat: args.seat,
178
+ from: args.from,
179
+ to: args.to,
180
+ cause: args.verdict.code,
181
+ fixHint: args.verdict.detail.fixHint,
182
+ ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
183
+ },
177
184
  };
178
185
  }
179
186
  export function authCarrierFingerprint(headers) {
@@ -68,15 +68,42 @@ export type RuleSyncDropReason = keyof typeof RULE_SYNC_DROP_CODES;
68
68
  * fence arm / local screening). `own_actor_forged` is inbound-only by construction. */
69
69
  export type RuleQuarantineReason = Exclude<RuleSyncDropReason, "own_actor_forged">;
70
70
  /**
71
- * The presentation-tier registry (the server's wire whitelist retires into this
72
- * table once shipped): WHO a notice code is for. `"user"` = a session-scoped disclosure the end
73
- * user of that session should see (safe to project onto the session's event stream); `"operator"`
74
- * = a deployment/config/ops fact for whoever runs the process. Presentation tier is a property of
75
- * the CODE (closed set, one code one tier) never of an individual emission, which is why this is
76
- * a registry and not an EngineNotice field. Codes absent from the table read as `"operator"`
77
- * (the conservative default: never push an unclassified code at an end user).
78
- * Orthogonal to {@link NON_GOVERNANCE_MEMORY_CODES} (a retry-semantics table, not presentation).
71
+ * WHO a notice code is for. `"user"` = a session-scoped disclosure the end user of that session is
72
+ * entitled to see (safe to project onto that session's event stream); `"operator"` = a
73
+ * deployment/config/ops fact for whoever runs the process.
74
+ *
75
+ * Two values, deliberately. "Can this notice be routed to a session" is NOT an audience value it
76
+ * is the presence of `EngineNotice.sessionId`, a per-EMISSION fact. A third value would fold a
77
+ * routing property into an entitlement vocabulary, and the two answer different questions: several
78
+ * codes below carry a session attribution while the person entitled to the fact is still the
79
+ * operator (a failed offload write, a routing fallback, a consolidation recommendation).
80
+ */
81
+ export type NoticeAudience = "user" | "operator";
82
+ /**
83
+ * The CLOSED catalog of EngineNotice codes this engine mints — the enumeration half of the
84
+ * presentation registry below, and the thing a consumer's own audience table can be diffed against
85
+ * (that is the point of exporting it: a downstream table with a row missing, or a row for a code
86
+ * this engine no longer mints, is a mechanically detectable disagreement rather than an argument).
87
+ *
88
+ * Kept in LOCKSTEP with {@link NOTICE_AUDIENCE} by the compiler (`satisfies Record<EngineNoticeCode,
89
+ * …>` below: a catalog entry with no audience row, or an audience row for a non-member, is a tsc
90
+ * error — a mirrored enumeration that is only checked at runtime is a mirror that drifts). The
91
+ * other half — "is the catalog itself still complete?" — cannot be a type: `EngineNotice.code` is
92
+ * declared `string` (a host may forward its own), so the gate test (governance-codes.test.ts) scans
93
+ * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
94
+ * no longer minted.
95
+ */
96
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "task.injection_priority_unimplemented", "tool_result.offload_put_failed"];
97
+ /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
98
+ * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
99
+ * sink is a supported shape, and narrowing that field would break it. */
100
+ export type EngineNoticeCode = (typeof ENGINE_NOTICE_CODES)[number];
101
+ /**
102
+ * The presentation-tier registry — see {@link NOTICE_AUDIENCE_TABLE} for the per-row judgment.
103
+ * The exported TYPE stays the open `Record<string, …>` on purpose: a consumer indexing it with a
104
+ * runtime string (a code off a wire, a host's own) must keep compiling. The lockstep with
105
+ * {@link ENGINE_NOTICE_CODES} is enforced on the table above, where narrowing costs nobody anything.
79
106
  */
80
- export declare const NOTICE_AUDIENCE: Readonly<Record<string, "user" | "operator">>;
107
+ export declare const NOTICE_AUDIENCE: Readonly<Record<string, NoticeAudience>>;
81
108
  /** The audience for `code` — table lookup with the conservative `"operator"` default. */
82
- export declare function noticeAudienceOf(code: string): "user" | "operator";
109
+ export declare function noticeAudienceOf(code: string): NoticeAudience;
@@ -84,14 +84,65 @@ export const RULE_SYNC_DROP_CODES = {
84
84
  below_gc_frontier: "local-quarantined",
85
85
  server_rejected: "local-quarantined",
86
86
  };
87
- export const NOTICE_AUDIENCE = {
87
+ export const ENGINE_NOTICE_CODES = [
88
+ "config.env_timeout_discarded",
89
+ "config.materialize_env_discarded",
90
+ "config.models_swapped",
91
+ "config.read_face_deployment_clamped",
92
+ "config.tool_model_gate_removed",
93
+ "config.tool_model_gate_unknown_class",
94
+ "config.tool_model_gate_env_invalid",
95
+ "delegation.transcript_integrity",
96
+ "mcp.revocation_probe_failed",
97
+ "workflow.governance_key_stripped",
98
+ "memory.session_polluted",
99
+ "memory.harvest_quarantined",
100
+ "memory.delegation_static_mark_waived",
101
+ "memory.hold_opened",
102
+ "memory.hold_released",
103
+ "memory.hold_disposed",
104
+ "memory.consolidation_recommended",
105
+ "memory.consolidation_committed",
106
+ "memory.consolidation_conflict",
107
+ "memory.consolidation_incomplete",
108
+ "memory.consolidation_refused",
109
+ "route.fallback_to_primary",
110
+ "route.base_url_changed_key_unchanged",
111
+ "task.user_steer_undrained",
112
+ "task.user_followup_undrained",
113
+ "task.injection_priority_unimplemented",
114
+ "tool_result.offload_put_failed",
115
+ ];
116
+ const NOTICE_AUDIENCE_TABLE = {
88
117
  "memory.session_polluted": "user",
89
118
  "memory.harvest_quarantined": "user",
90
119
  "memory.delegation_static_mark_waived": "user",
91
120
  "memory.hold_opened": "user",
92
121
  "memory.hold_released": "user",
93
122
  "memory.hold_disposed": "user",
123
+ "task.user_steer_undrained": "user",
124
+ "task.user_followup_undrained": "user",
125
+ "config.env_timeout_discarded": "operator",
126
+ "config.materialize_env_discarded": "operator",
127
+ "config.models_swapped": "operator",
128
+ "config.read_face_deployment_clamped": "operator",
129
+ "config.tool_model_gate_removed": "operator",
130
+ "config.tool_model_gate_unknown_class": "operator",
131
+ "config.tool_model_gate_env_invalid": "operator",
132
+ "delegation.transcript_integrity": "operator",
133
+ "mcp.revocation_probe_failed": "operator",
134
+ "workflow.governance_key_stripped": "operator",
135
+ "memory.consolidation_recommended": "operator",
136
+ "memory.consolidation_committed": "operator",
137
+ "memory.consolidation_conflict": "operator",
138
+ "memory.consolidation_incomplete": "operator",
139
+ "memory.consolidation_refused": "operator",
140
+ "route.fallback_to_primary": "operator",
141
+ "route.base_url_changed_key_unchanged": "operator",
142
+ "task.injection_priority_unimplemented": "operator",
143
+ "tool_result.offload_put_failed": "operator",
94
144
  };
145
+ export const NOTICE_AUDIENCE = NOTICE_AUDIENCE_TABLE;
95
146
  export function noticeAudienceOf(code) {
96
147
  return NOTICE_AUDIENCE[code] ?? "operator";
97
148
  }
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import type { EngineNotice } from "../types.js";
24
24
  import { type ConsolidationGateRow, type ConsolidationRunStopReason, type MemoryConsolidationOptions } from "./consolidation.js";
25
- import { type ConsolidationPlanSummary } from "./engine.js";
25
+ import { type ConsolidationPlanFoldEvidence, type ConsolidationPlanSummary } from "./engine.js";
26
26
  import { mintLlmConsolidationPlan, type ConsolidationDriveCycleRow, type ConsolidationDriveEngine, type ConsolidationFoldState, type DistillerChatFn, type LlmConsolidationPlan, type LlmConsolidationPlanProduct, type LlmDistillerContract } from "./distiller.js";
27
27
  export declare const CONSOLIDATION_DRIVER_RUNS_FILE = "distiller-runs.json";
28
28
  /** §1.6-4 — the mint archive: what was shown to the model and what it answered, verbatim, per run
@@ -114,10 +114,14 @@ export declare function readConsolidationDriverRun(controlDir: string, scope: st
114
114
  * run row's `planArchive` (written under the attempt guard) names the one that counts. */
115
115
  export declare function distillerPlanArchiveName(runId: string, attempt: number): string;
116
116
  export declare function archiveDistillerPlan(controlDir: string, runId: string, plan: LlmConsolidationPlan, attempt?: number): string;
117
- /** The engine face this verb consumes — `MemoryEngine` satisfies it structurally. */
117
+ /** The engine face this verb consumes — `MemoryEngine` satisfies it structurally.
118
+ * (`readConsolidationPlanFoldEvidence` joined in design/376 slice ③, #436: the conservation
119
+ * wall's terminal witness is member-level, so the face is REQUIRED — an engine without it could
120
+ * only offer the store-delta counts an interleaved fold can numerically offset.) */
118
121
  export interface ConsolidationDriverEngine extends ConsolidationDriveEngine {
119
122
  readConsolidationGate(scope: string): ConsolidationGateRow | undefined;
120
123
  listConsolidationPlans(): ConsolidationPlanSummary[];
124
+ readConsolidationPlanFoldEvidence(planId: string): ConsolidationPlanFoldEvidence;
121
125
  readConsolidationFoldState(scope: string): Promise<ConsolidationFoldState>;
122
126
  readonly controlPlaneRoot: string;
123
127
  }
@@ -182,6 +182,7 @@ export async function runMemoryConsolidationDriver(engine, scope, opts) {
182
182
  return settle("parked", undefined, parkedSeat);
183
183
  }
184
184
  let staleResidue = [];
185
+ let staleResidueMemberIds = new Set();
185
186
  const distill = async (candidates, snapshot, s) => {
186
187
  if (run.planCache === undefined) {
187
188
  let minted;
@@ -233,11 +234,12 @@ export async function runMemoryConsolidationDriver(engine, scope, opts) {
233
234
  writeDriverRun(controlDir, run);
234
235
  archiveDistillerPlan(controlDir, run.runId, plan, run.attempt);
235
236
  }
237
+ const offered = new Map();
238
+ for (const c of candidates)
239
+ if (typeof c?.entry?.id === "string")
240
+ offered.set(c.entry.id, c.entry.rev);
241
+ staleResidueMemberIds = new Set();
236
242
  staleResidue = (run.planCache?.products ?? []).flatMap((p) => {
237
- const offered = new Map();
238
- for (const c of candidates)
239
- if (typeof c?.entry?.id === "string")
240
- offered.set(c.entry.id, c.entry.rev);
241
243
  const ids = p.inputIds ?? [];
242
244
  const live = ids.filter((id) => offered.has(id));
243
245
  if (live.length === 0)
@@ -245,7 +247,11 @@ export async function runMemoryConsolidationDriver(engine, scope, opts) {
245
247
  const stale = p.inputRevs !== undefined && live.some((id) => p.inputRevs[id] !== undefined && offered.get(id) !== p.inputRevs[id]);
246
248
  const vanished = live.length < ids.length;
247
249
  const unanchored = p.inputRevs === undefined;
248
- return stale || vanished || unanchored ? [{ name: p.name, targets: live.length }] : [];
250
+ if (!(stale || vanished || unanchored))
251
+ return [];
252
+ for (const id of live)
253
+ staleResidueMemberIds.add(id);
254
+ return [{ name: p.name, targets: live.length }];
249
255
  });
250
256
  const replayView = run.planCache !== undefined && run.planCache.products.some((p) => p.inputRevs === undefined)
251
257
  ? { ...run.planCache, products: run.planCache.products.filter((p) => p.inputRevs !== undefined) }
@@ -329,6 +335,7 @@ export async function runMemoryConsolidationDriver(engine, scope, opts) {
329
335
  const writeResidue = run.writeFailures.map((w) => ({ name: w.key, targets: w.members }));
330
336
  const driverResidue = [...staleResidue, ...writeResidue];
331
337
  if (drive.converged) {
338
+ const plannedIds = [...new Set((run.planCache?.products ?? []).flatMap((p) => p.inputIds ?? []))];
332
339
  const plannedMembers = (run.planCache?.products ?? []).reduce((a, p) => a + (p.inputIds?.length ?? 0), 0);
333
340
  const rowAccount = run.entriesSuperseded ?? 0;
334
341
  const storeAccount = run.foldBaseline !== undefined ? Math.max(0, drive.foldState.after.superseded - run.foldBaseline.superseded) : rowAccount;
@@ -339,6 +346,24 @@ export async function runMemoryConsolidationDriver(engine, scope, opts) {
339
346
  run.residue = driverResidue;
340
347
  return settle("driver_failed", drive, `the drive reached a fixpoint but the account does not balance: ${plannedMembers} planned member(s), ${supersededAttested} superseded attested for this run (row account ${rowAccount}, store delta ${run.foldBaseline !== undefined ? storeAccount : "unavailable — the row predates the store anchor"}), ${residueTargets} in named residue — ${plannedMembers - accounted} member(s) left the candidate set without a fold the store attests (deleted or excluded mid-run${run.foldBaseline !== undefined ? "" : ", or folded in a window whose account write did not survive"}); their entries stand (or revive with their exclusion), and a fresh run re-mints over the current library`);
341
348
  }
349
+ if (plannedIds.length > 0) {
350
+ const evidence = collectRunFoldEvidence(engine, scope, run.requestId);
351
+ if (evidence.skipped.length > 0) {
352
+ const named = evidence.skipped.slice(0, 5).map((s) => `${s.planId} (${s.state})`).join(", ");
353
+ announce([
354
+ `memory consolidation conservation wall for scope ${inlineUntrusted(scope, 80)}: ${evidence.skipped.length} same-scope plan file(s) answered unreadable and were SKIPPED as foreign to run ${run.runId} — they attest nothing for this run and need out-of-band repair: ${named}${evidence.skipped.length > 5 ? ", …" : ""}`,
355
+ ]);
356
+ }
357
+ if (evidence.unreadable !== undefined) {
358
+ run.residue = driverResidue;
359
+ return settle("driver_failed", drive, `the drive reached a fixpoint but the member-level fold evidence is incomplete: plan ${evidence.unreadable} — one of THIS run's own cycles — answered ${evidence.unreadableState} (control-plane damage on run-owned evidence, not a transient race); a converged label needs attestable members; the library stands as committed and a fresh run re-mints over the current bytes`);
360
+ }
361
+ const missing = plannedIds.filter((id) => !evidence.targets.has(id) && !staleResidueMemberIds.has(id));
362
+ if (missing.length > 0) {
363
+ run.residue = driverResidue;
364
+ return settle("driver_failed", drive, `the drive reached a fixpoint but ${missing.length} of ${plannedIds.length} planned member(s) left the candidate set without a fold this run's plans attest (deleted, excluded, or superseded by another writer mid-run; e.g. ${missing.slice(0, 5).join(", ")}) — a store-delta count can be numerically offset by an unrelated interleaved fold, so member identity is the final judge; the entries stand (or revive with their exclusion) and a fresh run re-mints over the current library`);
365
+ }
366
+ }
342
367
  if (driverResidue.length > 0) {
343
368
  run.residue = driverResidue;
344
369
  return settle("driver_failed", drive, `the drive reached the surviving plan's fixpoint, but the driver's own half left ${driverResidue.length} group(s) unfolded (${staleResidue.length} stale against edited inputs, ${writeResidue.length} writing-pass failure(s)) — their members stay as they were; a fresh run re-mints over the current bytes`);
@@ -356,6 +381,30 @@ export async function runMemoryConsolidationDriver(engine, scope, opts) {
356
381
  return settle("parked", drive, stop.reason);
357
382
  return settle("refused", drive, stop?.reason ?? "the drive stopped without a stated reason");
358
383
  }
384
+ function collectRunFoldEvidence(engine, scope, requestId) {
385
+ const cycleId = new RegExp(`^${requestId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-c\\d+$`);
386
+ const targets = new Set();
387
+ const skipped = [];
388
+ for (const summary of engine.listConsolidationPlans()) {
389
+ if (summary.corrupt === true)
390
+ continue;
391
+ if (summary.scope !== scope)
392
+ continue;
393
+ const ev = engine.readConsolidationPlanFoldEvidence(summary.planId);
394
+ if (ev.state !== "ok") {
395
+ if (ev.state === "corrupt" && ev.requestId !== undefined && cycleId.test(ev.requestId)) {
396
+ return { targets, unreadable: summary.planId, unreadableState: ev.state, skipped };
397
+ }
398
+ skipped.push({ planId: summary.planId, state: ev.state });
399
+ continue;
400
+ }
401
+ if (ev.scope !== scope || !cycleId.test(ev.requestId))
402
+ continue;
403
+ for (const t of ev.supersededTargets)
404
+ targets.add(t);
405
+ }
406
+ return { targets, skipped };
407
+ }
359
408
  async function recoverSeat(engine, scope, { sleep, windowMs }) {
360
409
  await engine.reconcileConsolidation(scope);
361
410
  let seat = engine.readConsolidationGate(scope)?.openPlanId;
@@ -91,6 +91,25 @@ export interface MemoryConsolidationOptions {
91
91
  maxDirectedPatchesPerPlan?: number;
92
92
  minRunIntervalMs?: number;
93
93
  minSessionsBetweenRuns?: number;
94
+ /** design/376 C-2② — the scan-refusal fuse's ratio leg, in (0, 1]: scan findings refuse the
95
+ * affected PRODUCT (disclosed beside eligibility drift), and the whole plan refuses only when
96
+ * more than max(1, ⌊ratio × submitted products⌋) of them fail the scan (the floor keeps the
97
+ * per-product tolerance alive on 1-4 product plans — the drive lane's common size) — see
98
+ * {@link scanRefusalCeiling}. Default 0.25. */
99
+ scanRefusalRatioCap?: number;
100
+ /** design/376 C-2② — the fuse's absolute leg (positive integer): the large-batch upper bound on
101
+ * tolerated scan refusals per plan, so the ratio cannot scale the tolerance without limit.
102
+ * Default 8 (the point where the 0.25 ratio hands over at 32 submitted products — the family's
103
+ * "小 N" quantification; below that the ratio leg alone governs). */
104
+ scanRefusalMaxPerPlan?: number;
105
+ /** design/376 C-3 — the EXPLICIT escape hatch for the unconditional instruction-form gate:
106
+ * instruction-form consolidation products (`type: "feedback"` / v2 hint carriers) are refused
107
+ * by default REGARDLESS of the visible set's markers — a clean-set instruction product is the
108
+ * model-reachable privilege route (N ordinary notes in, one instruction entry out). `true` is
109
+ * the only accepted value (a posture declaration, like {@link multiNode}); it opens the
110
+ * CLEAN-set arm only — a marked visible set refuses instruction forms under the laundering
111
+ * hard gate no matter what (privilege and hard gate enter and exit through one door). */
112
+ allowInstructionProducts?: true;
94
113
  /** D-13a — the multi-node POSTURE declaration: present ⇒ `lease` must be injected (constructor
95
114
  * refusal otherwise). Multi-node without declaring is OUT OF CONTRACT (structurally
96
115
  * undetectable — the declaration turns a silent omission into an explicit choice). */
@@ -113,6 +132,8 @@ export declare const CONSOLIDATION_DEFAULTS: {
113
132
  readonly maxDirectedPatchesPerPlan: 32;
114
133
  readonly minRunIntervalMs: number;
115
134
  readonly minSessionsBetweenRuns: 5;
135
+ readonly scanRefusalRatioCap: 0.25;
136
+ readonly scanRefusalMaxPerPlan: 8;
116
137
  };
117
138
  /**
118
139
  * §2.2 — the HARD throttle floor: even a `force` run cannot start a second same-scope run within
@@ -142,6 +163,9 @@ export interface ScreenedConsolidationOptions {
142
163
  maxDirectedPatchesPerPlan: number;
143
164
  minRunIntervalMs: number;
144
165
  minSessionsBetweenRuns: number;
166
+ scanRefusalRatioCap: number;
167
+ scanRefusalMaxPerPlan: number;
168
+ allowInstructionProducts: boolean;
145
169
  multiNode: boolean;
146
170
  lease?: ConsolidationLeaseSeat;
147
171
  }
@@ -159,6 +183,21 @@ export declare function screenConsolidationOptions(raw: MemoryConsolidationOptio
159
183
  /** §1.2-2 — the supersession fuse ceiling: min(N-1, max(floor, ⌊ratio×N⌋)); N ≤ 1 ⇒ 0 (one plan
160
184
  * may NEVER empty the active set — the N-1 ceiling is unconditional, G21). */
161
185
  export declare function supersessionFuseCeiling(activeSetSize: number, opts: Pick<ScreenedConsolidationOptions, "supersedeRatioCap" | "supersedeAbsoluteFloor">): number;
186
+ /**
187
+ * design/376 C-2② — the scan-refusal fuse ceiling: how many scan-refused products ONE plan may
188
+ * carry before the whole plan refuses instead of committing around them.
189
+ * `min(max(1, ⌊ratio × submitted⌋), absolute max)`; the ratio leg governs small plans, the
190
+ * absolute leg is the large-batch upper bound, and the FLOOR of one keeps C-2①'s per-product
191
+ * tolerance alive at the drive lane's common plan sizes — under the default 0.25 ratio a bare
192
+ * floor answers 0 for every ≤3-product plan, so one stray refused product re-killed the whole
193
+ * plan there, exactly the shape C-2① was adopted to remove (an adopted review finding). The
194
+ * floor is NOT a hole for the fully-refused tiny plan: a plan whose every product is refused
195
+ * (and which carries no directed work) still dies at the freeze-emptied C-2③ backstop, never a
196
+ * quiet mostly-no-op — what the floor tolerates is exactly one refused product beside at least
197
+ * one survivor. Submitted count = the proposal's product count (per THIS plan, never a
198
+ * cross-run tally).
199
+ */
200
+ export declare function scanRefusalCeiling(submittedProducts: number, opts: Pick<ScreenedConsolidationOptions, "scanRefusalRatioCap" | "scanRefusalMaxPerPlan">): number;
162
201
  /** §3.1 — the type axis of the eligibility predicate: episodic entries are read-only evidence
163
202
  * (never candidates, never supersession/directed targets, A-7); procedural promotion is a v3
164
203
  * follow-on ticket. Unknown/absent types are ordinary semantic notes — eligible. */
@@ -231,6 +270,24 @@ export declare function updateConsolidationGate<T>(controlDir: string, fn: (file
231
270
  export declare function readConsolidationGateFile(controlDir: string): ConsolidationGateFile;
232
271
  /** True ⇔ the gate store file exists at all (G1's zero-file pin reads this negatively). */
233
272
  export declare function consolidationGateFileExists(controlDir: string): boolean;
273
+ /**
274
+ * design/376 slice ③ (#437②) — the gate read face's DISCRIMINATED answer
275
+ * ({@link import("./engine.js").MemoryEngine.readConsolidationGateState}): `present` carries the
276
+ * row; `absent` means the store is readable and simply has no row for the scope (a never-run
277
+ * scope, or no gate file at all); `corrupt` means the store cannot be trusted and carries the
278
+ * refusal detail. The undiscriminated `readConsolidationGate` face folds corrupt into `undefined`
279
+ * — an operator (or a driver's scheduling read) could not tell a healthy never-run scope from a
280
+ * store every verb will refuse fail-closed.
281
+ */
282
+ export type ConsolidationGateRead = {
283
+ state: "present";
284
+ row: ConsolidationGateRow;
285
+ } | {
286
+ state: "absent";
287
+ } | {
288
+ state: "corrupt";
289
+ detail: string;
290
+ };
234
291
  /**
235
292
  * §2.2 — the engine-minted session count (harvest-tail bookkeeping; ≤1 row write per terminal
236
293
  * harvest; the OFF mode never calls this — D-9a zero-write). Returns the recommendation edge:
@@ -304,7 +361,15 @@ export interface ConsolidationDirectedPatch {
304
361
  }
305
362
  export type ConsolidationPlanState = "open" | "applying" | "completed" | "conflict" | "discarded";
306
363
  export interface ConsolidationPlanFile {
307
- v: 1;
364
+ /** Plan schema version. `2` ⇔ the plan carries {@link freezeRefusedInputIds} — that member is a
365
+ * LOAD-BEARING settle input (C-2④ keeps the named ids out of the completion fingerprint), and
366
+ * a reader that predates it would accept the file as v1, ignore the member, and settle a
367
+ * fingerprint that stamps the refused members (silently blinding the incremental face — the
368
+ * exact hole the field closes). Version-gating the member makes such a reader answer its
369
+ * "unrecognized version" corrupt arm instead: the plan parks loudly for the valve, never
370
+ * settles wrong (mixed-fleet crash window between the durable plan write and gate settlement).
371
+ * Plans without the member stay v1 — zero noise on the common path. */
372
+ v: 1 | 2;
308
373
  planId: string;
309
374
  scope: string;
310
375
  requestId: string;
@@ -318,6 +383,13 @@ export interface ConsolidationPlanFile {
318
383
  /** Frozen products — FULL entry bytes (id/frontmatter incl. distilled + origin/body/rev). */
319
384
  products: MemoryEntry[];
320
385
  productStates: Record<string, "pending" | "applied" | "conflict">;
386
+ /** design/376 C-2④ — the declared input ids of every product the FREEZE refused (scan findings
387
+ * and eligibility drift alike). Settlement keeps these ids OUT of the completion fingerprint:
388
+ * a scan-refused input's rev never moved, so stamping it would make every later incremental
389
+ * snapshot read it as unchanged and never offer it again — the refused member must stay a
390
+ * candidate. Absent on plans frozen before the field existed, and on plans with nothing
391
+ * refused (either way the settle filter simply has nothing extra to exclude). */
392
+ freezeRefusedInputIds?: string[];
321
393
  directed: ConsolidationDirectedPatch[];
322
394
  intents: Array<{
323
395
  requestId: string;
@@ -40,6 +40,8 @@ export const CONSOLIDATION_DEFAULTS = {
40
40
  maxDirectedPatchesPerPlan: 32,
41
41
  minRunIntervalMs: 24 * 60 * 60 * 1000,
42
42
  minSessionsBetweenRuns: 5,
43
+ scanRefusalRatioCap: 0.25,
44
+ scanRefusalMaxPerPlan: 8,
43
45
  };
44
46
  export const CONSOLIDATION_FORCE_THROTTLE_FLOOR_MS = 60_000;
45
47
  export const CONSOLIDATION_SESSION_RING_MAX = 64;
@@ -70,12 +72,18 @@ export function screenConsolidationOptions(raw, provenance) {
70
72
  maxDirectedPatchesPerPlan: num("maxDirectedPatchesPerPlan", raw.maxDirectedPatchesPerPlan, (n) => Number.isInteger(n) && n >= 1, "a positive integer"),
71
73
  minRunIntervalMs: num("minRunIntervalMs", raw.minRunIntervalMs, (n) => n > 0, "a positive number of milliseconds"),
72
74
  minSessionsBetweenRuns: num("minSessionsBetweenRuns", raw.minSessionsBetweenRuns, (n) => Number.isInteger(n) && n >= 0, "a non-negative integer"),
75
+ scanRefusalRatioCap: num("scanRefusalRatioCap", raw.scanRefusalRatioCap, (n) => n > 0 && n <= 1, "a number in (0, 1]"),
76
+ scanRefusalMaxPerPlan: num("scanRefusalMaxPerPlan", raw.scanRefusalMaxPerPlan, (n) => Number.isInteger(n) && n >= 1, "a positive integer"),
77
+ allowInstructionProducts: raw.allowInstructionProducts === true,
73
78
  multiNode: raw.multiNode === true,
74
79
  ...(raw.lease !== undefined ? { lease: raw.lease } : {}),
75
80
  };
76
81
  if (raw.multiNode !== undefined && raw.multiNode !== true) {
77
82
  throw coded(`MemoryEngineOptions.consolidation.multiNode must be exactly true when present (a posture declaration, not a boolean knob).`, "config.memory_consolidation");
78
83
  }
84
+ if (raw.allowInstructionProducts !== undefined && raw.allowInstructionProducts !== true) {
85
+ throw coded(`MemoryEngineOptions.consolidation.allowInstructionProducts must be exactly true when present (a posture declaration, not a boolean knob — absent means the instruction-form gate stays closed).`, "config.memory_consolidation");
86
+ }
79
87
  if (screened.multiNode && screened.lease === undefined) {
80
88
  throw coded(`MemoryEngineOptions.consolidation.multiNode declared without a lease seat — a multi-node deployment MUST inject the host's global lease (the local plan-seat CAS does not reach across nodes). Inject consolidation.lease or remove the multiNode declaration.`, "config.memory_consolidation_lease_required");
81
89
  }
@@ -89,6 +97,11 @@ export function supersessionFuseCeiling(activeSetSize, opts) {
89
97
  return 0;
90
98
  return Math.min(activeSetSize - 1, Math.max(opts.supersedeAbsoluteFloor, Math.floor(opts.supersedeRatioCap * activeSetSize)));
91
99
  }
100
+ export function scanRefusalCeiling(submittedProducts, opts) {
101
+ if (submittedProducts <= 0)
102
+ return 0;
103
+ return Math.min(Math.max(1, Math.floor(opts.scanRefusalRatioCap * submittedProducts)), opts.scanRefusalMaxPerPlan);
104
+ }
92
105
  export function consolidationTypeEligible(type) {
93
106
  return type !== "episodic" && type !== "procedural";
94
107
  }
@@ -186,7 +199,7 @@ function planShapeInvalid(raw, planId) {
186
199
  if (typeof raw !== "object" || raw === null)
187
200
  return "not an object";
188
201
  const p = raw;
189
- if (p["v"] !== 1)
202
+ if (p["v"] !== 1 && p["v"] !== 2)
190
203
  return "unrecognized version";
191
204
  if (p["planId"] !== planId)
192
205
  return "planId mismatch";
@@ -248,6 +261,13 @@ function planShapeInvalid(raw, planId) {
248
261
  }
249
262
  if (!p["directed"].every((d) => intentIds.has(d.intentRequestId)))
250
263
  return "directed patch references an undeclared intent";
264
+ const freezeRefused = p["freezeRefusedInputIds"];
265
+ if (freezeRefused !== undefined && (!Array.isArray(freezeRefused) || !freezeRefused.every((x) => typeof x === "string"))) {
266
+ return "malformed freezeRefusedInputIds";
267
+ }
268
+ if ((p["v"] === 2) !== (freezeRefused !== undefined)) {
269
+ return p["v"] === 2 ? "v2 plan without its freezeRefusedInputIds member" : "freezeRefusedInputIds on a v1 plan";
270
+ }
251
271
  if (!Array.isArray(p["audit"]))
252
272
  return "malformed audit trail";
253
273
  return undefined;