@sema-agent/core 5.47.0 → 5.49.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 (73) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +4 -0
  3. package/dist/agents/agent-transcript-tool.js +10 -3
  4. package/dist/agents/send-message-tool.d.ts +43 -1
  5. package/dist/agents/send-message-tool.js +50 -11
  6. package/dist/agents/subagent.d.ts +18 -0
  7. package/dist/agents/subagent.js +102 -2
  8. package/dist/agents/teacher.d.ts +25 -1
  9. package/dist/agents/teacher.js +85 -12
  10. package/dist/config/defaults.d.ts +20 -0
  11. package/dist/config/defaults.js +5 -0
  12. package/dist/core/background-agent-store.d.ts +1 -0
  13. package/dist/core/background-agent-store.js +14 -0
  14. package/dist/core/mcp.d.ts +6 -1
  15. package/dist/core/mcp.js +34 -7
  16. package/dist/core/memory-engine/delegation-settlement.d.ts +27 -0
  17. package/dist/core/memory-engine/delegation-settlement.js +31 -4
  18. package/dist/core/memory-engine/dual-root.js +11 -0
  19. package/dist/core/memory-engine/engine.d.ts +6 -1
  20. package/dist/core/memory-engine/engine.js +136 -21
  21. package/dist/core/memory-engine/memory-backend-contract.js +33 -0
  22. package/dist/core/memory-engine/origin-clearance.d.ts +19 -0
  23. package/dist/core/memory-engine/origin-clearance.js +10 -0
  24. package/dist/core/memory-engine/provenance-wording.d.ts +15 -1
  25. package/dist/core/memory-engine/provenance-wording.js +1 -0
  26. package/dist/core/memory-engine/tools.js +6 -4
  27. package/dist/core/reminder-disclosure.d.ts +90 -0
  28. package/dist/core/reminder-disclosure.js +64 -0
  29. package/dist/core/runner/prepare-acquire-reconcile.d.ts +6 -0
  30. package/dist/core/runner/prepare-acquire-reconcile.js +1 -1
  31. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  32. package/dist/core/runner/prepare-hands-readface.js +1 -0
  33. package/dist/core/runner/prepare-task.d.ts +15 -0
  34. package/dist/core/runner/prepare-task.js +51 -33
  35. package/dist/core/runner/runtask.d.ts +26 -1
  36. package/dist/core/runner/runtask.js +21 -3
  37. package/dist/core/session-store.d.ts +59 -1
  38. package/dist/core/session-store.js +82 -14
  39. package/dist/core/session.d.ts +83 -1
  40. package/dist/core/strategy-store.d.ts +180 -3
  41. package/dist/core/strategy-store.js +172 -23
  42. package/dist/core/task-registry-agent.d.ts +28 -0
  43. package/dist/core/task-registry-agent.js +63 -2
  44. package/dist/core/task-registry.d.ts +21 -0
  45. package/dist/core/task-registry.js +4 -1
  46. package/dist/core/types.d.ts +66 -0
  47. package/dist/core/untrusted-text.d.ts +63 -0
  48. package/dist/core/untrusted-text.js +48 -0
  49. package/dist/core/wiring-manifest.d.ts +35 -0
  50. package/dist/core/wiring-manifest.js +21 -1
  51. package/dist/engine/harness/types.d.ts +36 -1
  52. package/dist/index.d.ts +7 -6
  53. package/dist/index.js +6 -5
  54. package/dist/internal/harness-types.d.ts +1 -0
  55. package/dist/stores/file/file-snapshot-store.js +7 -1
  56. package/dist/stores/file/index.d.ts +27 -3
  57. package/dist/stores/file/index.js +36 -1
  58. package/dist/stores/file/session-policy-store.d.ts +0 -13
  59. package/dist/stores/file/session-policy-store.js +7 -1
  60. package/dist/stores/file/session-store.d.ts +22 -5
  61. package/dist/stores/file/session-store.js +80 -13
  62. package/dist/stores/file/strategy-store.d.ts +97 -0
  63. package/dist/stores/file/strategy-store.js +340 -0
  64. package/dist/tools/fs/fs-pdf.d.ts +12 -1
  65. package/dist/tools/fs/fs-pdf.js +17 -3
  66. package/dist/tools/fs/fs-read.d.ts +2 -1
  67. package/dist/tools/fs/fs-read.js +33 -5
  68. package/dist/tools/fs/fs-shared.d.ts +6 -2
  69. package/dist/tools/fs/index.d.ts +7 -0
  70. package/dist/tools/fs/index.js +1 -1
  71. package/dist/tools/web.js +21 -2
  72. package/package.json +3 -2
  73. package/test/export-surface.snapshot.json +22 -1
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { MAX_STRATEGY_INJECTION_TOTAL_BYTES, MAX_STRATEGY_PROBLEM_BYTES, MAX_STRATEGY_TEXT_BYTES } from "../core/strategy-store.js";
3
+ import { createSafeNotifier, observeThenableRejection } from "../core/safe-notify.js";
2
4
  import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
3
5
  import { isDefineToolProduct, stampDefineToolBrand } from "../core/tools.js";
4
6
  import { delimitUntrusted, inlineUntrusted } from "../core/untrusted-text.js";
@@ -46,31 +48,102 @@ export function parseTeacherAdvice(text) {
46
48
  export async function runWithTeacher(runner, studentSpec, teacher = {}) {
47
49
  const store = teacher.strategyStore;
48
50
  const scope = teacher.scope;
51
+ const incidentNotifier = createSafeNotifier();
52
+ const warnedIncidentOps = new Set();
53
+ const discloseStoreIncident = (i) => {
54
+ const seat = teacher.onStrategyStoreIncident;
55
+ if (seat) {
56
+ const site = `teacher.onStrategyStoreIncident.${i.op}`;
57
+ incidentNotifier.notify(() => observeThenableRejection(seat(i), incidentNotifier, site), site);
58
+ }
59
+ else if (!warnedIncidentOps.has(i.op)) {
60
+ warnedIncidentOps.add(i.op);
61
+ console.warn(`runWithTeacher: strategy store ${i.op} failed (contained — the run continues): ${i.error}`);
62
+ }
63
+ };
64
+ const isConfigCoded = (e) => typeof e?.code === "string" && (e.code).startsWith("config.");
49
65
  let spec = studentSpec;
66
+ let injected;
50
67
  if (store && scope && teacher.injectStrategies !== false) {
51
- const found = await store.find(scope, studentSpec.objective, teacher.retrieveK ?? 3);
68
+ let found = [];
69
+ try {
70
+ const raw = await store.find(scope, studentSpec.objective, teacher.retrieveK ?? 3);
71
+ if (!Array.isArray(raw)) {
72
+ discloseStoreIncident({ op: "find", error: "store.find returned a non-array result — injecting nothing" });
73
+ }
74
+ else {
75
+ let dropped = 0;
76
+ for (const e of raw) {
77
+ const c = e;
78
+ const id = c?.id;
79
+ const ts = c?.ts;
80
+ const strategy = c?.strategy;
81
+ if (typeof id !== "string" || typeof ts !== "string" || typeof strategy !== "string") {
82
+ dropped++;
83
+ continue;
84
+ }
85
+ const origin = c?.origin;
86
+ found.push({ id, ts, strategy, ...(origin === "earned" || origin === "seeded" ? { origin } : {}) });
87
+ }
88
+ if (dropped > 0) {
89
+ discloseStoreIncident({ op: "find", error: `${dropped} malformed retrieval entries dropped (id/ts/strategy must be strings)` });
90
+ }
91
+ }
92
+ }
93
+ catch (e) {
94
+ if (isConfigCoded(e))
95
+ throw e;
96
+ discloseStoreIncident({ op: "find", error: String(e?.message ?? e) });
97
+ found = [];
98
+ }
99
+ let budget = 0;
100
+ for (let i = 0; i < found.length; i++) {
101
+ budget += Buffer.byteLength(found[i].strategy, "utf8");
102
+ if (budget > MAX_STRATEGY_INJECTION_TOTAL_BYTES) {
103
+ discloseStoreIncident({
104
+ op: "find",
105
+ error: `injection truncated to ${i} of ${found.length} retrieved strategies — combined text exceeded ${MAX_STRATEGY_INJECTION_TOTAL_BYTES} bytes`,
106
+ });
107
+ found = found.slice(0, i);
108
+ break;
109
+ }
110
+ }
52
111
  if (found.length > 0) {
53
112
  const block = `[STRATEGIES from PAST, DIFFERENT tasks — they may NOT apply. If unsure, IGNORE them and proceed normally.]\n` +
54
- found.map((s) => `- ${s.strategy}`).join("\n");
113
+ delimitUntrusted("PAST STRATEGIES (from DIFFERENT tasks; may NOT apply — verify before use)", found.map((s) => `- ${s.strategy}`).join("\n"));
55
114
  spec = { ...studentSpec, objective: `${block}\n\n${studentSpec.objective}` };
115
+ injected = found.map((s) => ({ id: s.id, ts: s.ts, ...(s.origin !== undefined ? { origin: s.origin } : {}) }));
56
116
  }
57
117
  }
58
118
  const result = await runTeacherCore(runner, spec, teacher);
59
119
  if (store && scope && result.status === "completed" && result.escalations.length > 0) {
60
120
  const advice = result.escalations[result.escalations.length - 1].teacher;
61
121
  if (advice.strategy && advice.confidence >= (teacher.minConfidenceToStore ?? 2)) {
62
- await store.save({
63
- id: randomUUID(),
64
- problem: studentSpec.objective,
65
- strategy: advice.strategy,
66
- confidence: advice.confidence,
67
- scope,
68
- ts: new Date().toISOString(),
69
- teacherModel: typeof teacher.model === "string" ? teacher.model : teacher.model?.id,
70
- });
122
+ try {
123
+ const clip = (text, maxBytes) => {
124
+ let out = text;
125
+ while (Buffer.byteLength(out) > maxBytes)
126
+ out = out.slice(0, -1);
127
+ return out;
128
+ };
129
+ await store.save({
130
+ id: randomUUID(),
131
+ problem: clip(studentSpec.objective, MAX_STRATEGY_PROBLEM_BYTES),
132
+ strategy: clip(advice.strategy, MAX_STRATEGY_TEXT_BYTES),
133
+ confidence: Math.min(3, Math.max(0, advice.confidence)),
134
+ scope,
135
+ ts: new Date().toISOString(),
136
+ teacherModel: typeof teacher.model === "string" ? teacher.model : teacher.model?.id,
137
+ });
138
+ }
139
+ catch (e) {
140
+ if (isConfigCoded(e))
141
+ throw e;
142
+ discloseStoreIncident({ op: "save", error: String(e?.message ?? e) });
143
+ }
71
144
  }
72
145
  }
73
- return result;
146
+ return injected !== undefined && injected.length > 0 ? { ...result, strategiesInjected: injected } : result;
74
147
  }
75
148
  async function runTeacherCore(runner, studentSpec, teacher) {
76
149
  const maxEscalations = teacher.maxEscalations ?? 3;
@@ -21,6 +21,26 @@
21
21
  */
22
22
  export declare const RETAIN_DEFAULT_TTL_MS: number;
23
23
  export declare const RETAIN_DEFAULT_MAX = 16;
24
+ /** Subagent transcript persistence — the delegation ENTRY caps (CC values: 20 concurrent /
25
+ * 200 per session tree). The INLET bound family (`RunnerDeps.delegationEntryCaps`), as opposed to
26
+ * the retain pair above, which is a pure in-memory fast-lane handle bound (outlet — armed
27
+ * deployments lose nothing when it evicts). Concurrent = running/pending a* handles under one
28
+ * (scope, rootSessionId) key in THIS process's registry; cumulative = retained-window count under
29
+ * the same key (see the enforcement site for the exact retained-window semantics). */
30
+ export declare const DELEGATION_MAX_CONCURRENT_DEFAULT = 20;
31
+ export declare const DELEGATION_MAX_PER_SESSION_DEFAULT = 200;
32
+ /** Subagent transcript persistence — orphan adoption (CC parity: after a restart a deployment
33
+ * auto-adopts at most this many stale rows whose transcript mtime is inside the window; older ones
34
+ * stay MANUALLY continuable for the whole retention period — never deleted by the window). The
35
+ * TRIGGER is deployment-owned (core has no daemon and never revives runs nobody asked for); these
36
+ * constants are the shared vocabulary so every deployment adopts by the same numbers. */
37
+ export declare const ORPHAN_ADOPT_WINDOW_MS_DEFAULT: number;
38
+ export declare const ORPHAN_ADOPT_MAX_DEFAULT = 20;
39
+ /** Subagent transcript persistence — the default transcript retention period (CC
40
+ * cleanupPeriodDays parity). Core ships NO sweeper (design/151 ruling 2): the blessed path is the
41
+ * deployment calling `TaskRegistry.reapDurableAgents` with `maxAgeMs` derived from this (boot +
42
+ * every 24h is the reference cadence). */
43
+ export declare const SUBAGENT_TRANSCRIPT_RETENTION_DAYS_DEFAULT = 30;
24
44
  /** Default idle TTL (days) before a cached session is evicted (config-catalog `session.idleTtlDays`). */
25
45
  export declare const SESSION_DEFAULT_TTL_DAYS = 7;
26
46
  /**
@@ -1,4 +1,9 @@
1
1
  export const RETAIN_DEFAULT_TTL_MS = 30 * 60 * 1000;
2
2
  export const RETAIN_DEFAULT_MAX = 16;
3
+ export const DELEGATION_MAX_CONCURRENT_DEFAULT = 20;
4
+ export const DELEGATION_MAX_PER_SESSION_DEFAULT = 200;
5
+ export const ORPHAN_ADOPT_WINDOW_MS_DEFAULT = 48 * 60 * 60 * 1000;
6
+ export const ORPHAN_ADOPT_MAX_DEFAULT = 20;
7
+ export const SUBAGENT_TRANSCRIPT_RETENTION_DAYS_DEFAULT = 30;
3
8
  export const SESSION_DEFAULT_TTL_DAYS = 7;
4
9
  export const RUNNING_AGENT_OBSERVE_EVERY_BEATS = 4;
@@ -170,6 +170,7 @@ export interface BackgroundAgentRecord {
170
170
  export declare const REVIVED_ROW_CLEARED_FIELDS: readonly ["settledAt", "stoppedBy", "completionId", "finalOutput", "finalOutputFull", "error", "errorCode", "errorRetryable", "errorKind", "errorRetryAfterMs", "resultIsPartial", "summary", "recentSteps", "editedFiles", "usage"];
171
171
  /** Erase {@link REVIVED_ROW_CLEARED_FIELDS} from a record a revival is about to write back. */
172
172
  export declare function clearRevivedRowTerminalPayload(record: BackgroundAgentRecord): void;
173
+ export declare function announceTranscriptIntegrityGapOnce(handle: string, scope: string | undefined, sink: ((handle: string, scope: string | undefined) => void) | undefined): void;
173
174
  /** Content-free projection for list reads (design/151 HIGH-1: `summary`/`finalOutput`/`recentSteps`
174
175
  * and friends NEVER ride a list — content is get-by-handle only, behind the full predicate). */
175
176
  export interface BackgroundAgentRowSummary {
@@ -21,6 +21,20 @@ export function clearRevivedRowTerminalPayload(record) {
21
21
  for (const field of REVIVED_ROW_CLEARED_FIELDS)
22
22
  delete record[field];
23
23
  }
24
+ const transcriptIntegrityAnnounced = new Set();
25
+ export function announceTranscriptIntegrityGapOnce(handle, scope, sink) {
26
+ if (sink === undefined)
27
+ return;
28
+ const key = `${scope ?? ""}\u0000${handle}`;
29
+ if (transcriptIntegrityAnnounced.has(key))
30
+ return;
31
+ transcriptIntegrityAnnounced.add(key);
32
+ try {
33
+ sink(handle, scope);
34
+ }
35
+ catch {
36
+ }
37
+ }
24
38
  export class BackgroundAgentStoreError extends Error {
25
39
  code;
26
40
  constructor(code, message) {
@@ -36,6 +36,7 @@ import type { AgentTool } from "../internal/harness-types.js";
36
36
  import type { ImageContent, TextContent } from "../internal/llm.js";
37
37
  import { type McpImageResizer } from "./image-downsample.js";
38
38
  import type { McpServerSpec, OnElicit, ToolEffect } from "./types.js";
39
+ import { type ReminderDisclosureCounts } from "./reminder-disclosure.js";
39
40
  /**
40
41
  * The safety axes (design/77 §4 irreversibility, design/70 egress) derived from one materialized MCP
41
42
  * tool's server-advertised `annotations`. These ride alongside the {@link AgentTool} (which is vendored
@@ -462,7 +463,11 @@ export declare function normalizeMcpToolSchema(schema: unknown): McpSchemaNormal
462
463
  * combinator's own structure is legal JSON Schema ⇒ passes validateJsonSchemaShape too).
463
464
  */
464
465
  export declare function mcpToolSchemaProblem(schema: unknown): string | undefined;
465
- export declare function materializeMcpTools(specs: McpServerSpec[], principal?: string, onElicit?: OnElicit, imageResizer?: McpImageResizer): Promise<MaterializedMcp>;
466
+ export declare function materializeMcpTools(specs: McpServerSpec[], principal?: string, onElicit?: OnElicit, imageResizer?: McpImageResizer, // design/116 CONFIRM-1 seam: deployment-injected; default = auto-detected sharp
467
+ reminderDisclosure?: {
468
+ reminderMark?: string;
469
+ counts?: ReminderDisclosureCounts;
470
+ }): Promise<MaterializedMcp>;
466
471
  /**
467
472
  * Fold the caller's AUTHORITATIVE per-tool override (design F: caller = trust root) over the server-hint axis.
468
473
  * Unlike server hints, the caller may RAISE or LOWER any axis: `effect` sets the repeat-safety class (lower to
package/dist/core/mcp.js CHANGED
@@ -11,6 +11,7 @@ import { MCP_IMAGE_MAX_BASE64, sharpImageResizer } from "./image-downsample.js";
11
11
  import { sliceHeadSafe, sliceTailSafe } from "./surrogate-safe-slice.js";
12
12
  import { truncateError } from "./tool-errors.js";
13
13
  import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
14
+ import { discloseReminderShaped } from "./reminder-disclosure.js";
14
15
  import { withContentOrigin } from "./memory-engine/content-origin.js";
15
16
  import { validateJsonSchemaShape } from "./runner/strict-output-schema.js";
16
17
  export const MCP_PREFIX = MCP_NAMESPACE.prefix;
@@ -649,7 +650,10 @@ export function mcpToolSchemaProblem(schema) {
649
650
  }
650
651
  return undefined;
651
652
  }
652
- export async function materializeMcpTools(specs, principal, onElicit, imageResizer) {
653
+ export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure) {
654
+ const mcpDisclosure = reminderDisclosure?.reminderMark !== undefined
655
+ ? { mark: reminderDisclosure.reminderMark, windows: new Map(), ...(reminderDisclosure.counts !== undefined ? { counts: reminderDisclosure.counts } : {}) }
656
+ : undefined;
653
657
  const clients = [];
654
658
  const tools = [];
655
659
  const toolAxes = [];
@@ -661,7 +665,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
661
665
  const droppedTools = [];
662
666
  let disposing = false;
663
667
  const serverHandles = [];
664
- const settled = await Promise.allSettled(specs.map((spec) => connectServer(spec, principal, onElicit, imageResizer)));
668
+ const settled = await Promise.allSettled(specs.map((spec) => connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure)));
665
669
  try {
666
670
  for (let i = 0; i < specs.length; i++) {
667
671
  const r = settled[i];
@@ -728,7 +732,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
728
732
  try {
729
733
  const listed = await listToolsLenient(h.client);
730
734
  cacheMcpToolMetadata(h.client, listed.tools);
731
- const { serverTools, serverAxes, dropped } = intakeListedTools(listed, h.spec, h.client, h.health, imageResizer);
735
+ const { serverTools, serverAxes, dropped } = intakeListedTools(listed, h.spec, h.client, h.health, imageResizer, mcpDisclosure);
732
736
  const newNames = serverTools.map((t) => t.name);
733
737
  const added = newNames.filter((n) => !h.toolNames.includes(n));
734
738
  const removed = h.toolNames.filter((n) => !newNames.includes(n));
@@ -1119,7 +1123,7 @@ const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient
1119
1123
  async function listToolsLenient(client, options) {
1120
1124
  return client.request({ method: "tools/list", params: {} }, LenientListToolsResultSchema, options);
1121
1125
  }
1122
- async function connectServer(spec, principal, onElicit, imageResizer) {
1126
+ async function connectServer(spec, principal, onElicit, imageResizer, reminderDisclosure) {
1123
1127
  const elicitOn = spec.elicitation === true && onElicit !== undefined;
1124
1128
  const health = { dead: false, pendingElicitations: 0, lastElicitationClosedAt: 0 };
1125
1129
  const client = new Client({ name: `sema-core/${spec.name}`, version: "0.1.0" }, { capabilities: elicitOn ? { elicitation: { form: {} } } : {} });
@@ -1155,7 +1159,7 @@ async function connectServer(spec, principal, onElicit, imageResizer) {
1155
1159
  };
1156
1160
  const listed = await listToolsLenient(client, startupOpts);
1157
1161
  cacheMcpToolMetadata(client, listed.tools);
1158
- const { serverTools, serverAxes, dropped } = intakeListedTools(listed, spec, client, health, imageResizer);
1162
+ const { serverTools, serverAxes, dropped } = intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure);
1159
1163
  const caps = client.getServerCapabilities();
1160
1164
  const resourceInfo = caps?.resources
1161
1165
  ? {
@@ -1188,7 +1192,7 @@ async function connectServer(spec, principal, onElicit, imageResizer) {
1188
1192
  throw err;
1189
1193
  }
1190
1194
  }
1191
- function intakeListedTools(listed, spec, client, health, imageResizer) {
1195
+ function intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure) {
1192
1196
  const serverTools = [];
1193
1197
  const serverAxes = [];
1194
1198
  const dropped = [];
@@ -1276,8 +1280,31 @@ function intakeListedTools(listed, spec, client, health, imageResizer) {
1276
1280
  type: "text",
1277
1281
  text: `[structuredContent] JSON with schema: ${inferCompactSchema(sc)}\n${truncateError(JSON.stringify(sc))}`,
1278
1282
  });
1279
- return { content, details: { type: "mcp", structuredContent: sc }, terminate: false };
1280
1283
  }
1284
+ if (reminderDisclosure !== undefined) {
1285
+ const textIdx = [];
1286
+ const segments = [];
1287
+ content.forEach((b, i) => {
1288
+ if (b.type === "text" && typeof b.text === "string") {
1289
+ textIdx.push(i);
1290
+ segments.push(b.text);
1291
+ }
1292
+ });
1293
+ const d = discloseReminderShaped({
1294
+ segments,
1295
+ mark: reminderDisclosure.mark,
1296
+ outlet: "mcp",
1297
+ defuseExactMark: true,
1298
+ throttle: { key: `${spec.name}:${remoteName}`, windows: reminderDisclosure.windows },
1299
+ ...(reminderDisclosure.counts !== undefined ? { counts: reminderDisclosure.counts } : {}),
1300
+ });
1301
+ if (d.defused)
1302
+ textIdx.forEach((ci, si) => (content[ci] = { type: "text", text: d.segments[si] }));
1303
+ if (d.trailer !== undefined)
1304
+ content.push({ type: "text", text: d.trailer });
1305
+ }
1306
+ if (sc !== undefined)
1307
+ return { content, details: { type: "mcp", structuredContent: sc }, terminate: false };
1281
1308
  return { content, details: res, terminate: false };
1282
1309
  },
1283
1310
  });
@@ -221,6 +221,11 @@ export interface HoldRow {
221
221
  };
222
222
  /** Host valve verdict awaiting the next harvest ("release" commits with cause "static"). */
223
223
  resolved?: "release" | "discard";
224
+ /** The verdict's GENERATION (strictly increases on every re-resolution): the settle leg's
225
+ * consumed-verdict exit clears only the exact generation it read, so a host re-resolution
226
+ * racing a failing release attempt is never erased by the stale attempt's cleanup. Absent on
227
+ * rows resolved before the member existed (a generation-less clear matches those). */
228
+ resolvedAt?: number;
224
229
  }
225
230
  export declare function readHolds(controlDir: string): HoldRow[];
226
231
  /** ATOMIC no-replace restore of a staged file onto a plane path (adversarial round 2: an
@@ -308,6 +313,28 @@ export declare function resolveHoldRecord(controlDir: string, input: {
308
313
  requestId: string;
309
314
  now: () => number;
310
315
  }): void;
316
+ /**
317
+ * Consume a standing valve verdict WITHOUT any other transition — the settle leg's exit for a
318
+ * valve release that FAILED on an already-disposed row (an expired hold whose release hit a
319
+ * judgment refusal: lost custody, re-scan refusal, CAS conflict). `disposeHold` is a no-op there
320
+ * (the row is already terminal), so leaving `resolved` standing would re-run the identical failing
321
+ * release — and re-announce a disposal that never happened — on every subsequent harvest, forever.
322
+ * Clearing it returns the row to its plain disposed/expired state: the failure is disclosed by the
323
+ * caller, custody/quarantine bytes stay addressable, and `resolveHold(holdId, "release")` remains
324
+ * admissible after the host reconciles. GENERATION-KEYED: only the exact verdict generation the
325
+ * caller read is cleared — a host re-resolution that landed while the failing attempt was in
326
+ * flight is a NEWER generation and stands (the stale attempt's cleanup must not erase a live host
327
+ * action). A generation-less verdict (a row written before the member existed) is consumable
328
+ * against a generation-less read — every CURRENT writer mints a generation, so the only verdict
329
+ * such a clear can erase is one written by a PRE-generation writer racing on the same control
330
+ * plane: the standing mixed-version-writer class the upgrade-order duty prices, kept consumable
331
+ * on purpose (refusing it would revive the forever-re-firing valve loop for every verdict minted
332
+ * before the upgrade). Idempotent; unknown/verdict-less/other-generation rows are a no-op.
333
+ */
334
+ export declare function clearHoldResolution(controlDir: string, input: {
335
+ holdId: string;
336
+ ifResolvedAt: number | undefined;
337
+ }): void;
311
338
  /** Read one hold's custody bytes (release leg + expired-release valve). Returns undefined when
312
339
  * the custody file is gone or fails its digest — the caller records capture_lost, never commits
313
340
  * unverified bytes. For an expired-released row the bytes may already sit in quarantine
@@ -346,6 +346,7 @@ function coerceHolds(raw) {
346
346
  !HOLD_STATUSES.has(row.status) ||
347
347
  (row.disposition !== undefined && (typeof row.disposition !== "object" || row.disposition === null || !HOLD_TERMINALS.has(row.disposition.terminal) || (row.disposition.quarantineName !== undefined && !safeBasename(row.disposition.quarantineName)))) ||
348
348
  (row.resolved !== undefined && row.resolved !== "release" && row.resolved !== "discard") ||
349
+ (row.resolvedAt !== undefined && (typeof row.resolvedAt !== "number" || !Number.isSafeInteger(row.resolvedAt) || row.resolvedAt < 0 || row.resolvedAt >= 2 ** 50 || row.resolved === undefined)) ||
349
350
  (row.entryId !== undefined && typeof row.entryId !== "string") ||
350
351
  (row.baseRev !== undefined && typeof row.baseRev !== "string") ||
351
352
  (row.op === "update" && (!reqStr(row.entryId) || !reqStr(row.baseRev))) ||
@@ -401,6 +402,14 @@ export function openInstructionHold(controlDir, input) {
401
402
  const digest = sha256(buf);
402
403
  const captureName = `${holdId}.md`;
403
404
  const holdDir = join(controlDir, HOLD_DIR);
405
+ const refuse = (reason, terminal) => {
406
+ try {
407
+ disposeHold(controlDir, { holdId, terminal, now: input.now });
408
+ }
409
+ catch {
410
+ }
411
+ return { ok: false, holdId, reason };
412
+ };
404
413
  try {
405
414
  lockedStrictUpdate(controlDir, HOLDS_FILE, "memory instruction-hold ledger", coerceHolds, (rec) => {
406
415
  if (rec.rows.some((r) => r.holdId === holdId))
@@ -431,10 +440,10 @@ export function openInstructionHold(controlDir, input) {
431
440
  writeFileSync(join(holdDir, captureName), buf, { flag: "wx" });
432
441
  const back = readFileSync(join(holdDir, captureName));
433
442
  if (sha256(back) !== digest)
434
- return { ok: false, holdId, reason: "hold capture verification failed (written bytes do not hash to the captured digest)" };
443
+ return refuse("hold capture verification failed (written bytes do not hash to the captured digest)", "capture_lost");
435
444
  }
436
445
  catch (err) {
437
- return { ok: false, holdId, reason: `hold capture failed: ${err instanceof Error ? err.message : String(err)}` };
446
+ return refuse(`hold capture failed: ${err instanceof Error ? err.message : String(err)}`, "capture_lost");
438
447
  }
439
448
  const stagingName = uniqueStagingName(holdId, input.now);
440
449
  const stagingPath = join(holdDir, stagingName);
@@ -447,7 +456,7 @@ export function openInstructionHold(controlDir, input) {
447
456
  if (code === "ENOENT") {
448
457
  }
449
458
  else {
450
- return { ok: false, holdId, reason: `hold removal failed (${code ?? "rename error"}): the plane file was not moved` };
459
+ return refuse(`hold removal failed (${code ?? "rename error"}): the plane file was not moved`, "discarded");
451
460
  }
452
461
  }
453
462
  if (existsSync(stagingPath)) {
@@ -475,7 +484,7 @@ export function openInstructionHold(controlDir, input) {
475
484
  });
476
485
  }
477
486
  catch (err) {
478
- return { ok: false, holdId, reason: `hold ledger flip refused: ${err instanceof Error ? err.message : String(err)}` };
487
+ return refuse(`hold ledger flip refused: ${err instanceof Error ? err.message : String(err)}`, "discarded");
479
488
  }
480
489
  return { ok: true, holdId, ...(thirdWriterStranded !== undefined ? { thirdWriterStranded } : {}) };
481
490
  }
@@ -604,6 +613,7 @@ export function markHoldReleased(controlDir, input) {
604
613
  return { result: undefined };
605
614
  r.status = "released";
606
615
  delete r.resolved;
616
+ delete r.resolvedAt;
607
617
  return { next: rec, result: r.captureName };
608
618
  });
609
619
  if (captureName === undefined)
@@ -638,6 +648,23 @@ export function resolveHoldRecord(controlDir, input) {
638
648
  throw e;
639
649
  }
640
650
  r.resolved = input.action;
651
+ const nextGeneration = Math.max(input.now(), 0, (r.resolvedAt ?? 0) + 1);
652
+ if (!Number.isSafeInteger(nextGeneration) || nextGeneration < 0 || nextGeneration >= 2 ** 50) {
653
+ const e = new Error(`resolveHold: hold ${JSON.stringify(input.holdId)} cannot mint a fresh verdict generation (${String(nextGeneration)} — an exhausted window, a deranged clock, or a corrupt ledger row) — refused, never written.`);
654
+ e.code = "memory.hold_resolve_invalid_state";
655
+ throw e;
656
+ }
657
+ r.resolvedAt = nextGeneration;
658
+ return { next: rec, result: undefined };
659
+ });
660
+ }
661
+ export function clearHoldResolution(controlDir, input) {
662
+ lockedStrictUpdate(controlDir, HOLDS_FILE, "memory instruction-hold ledger", coerceHolds, (rec) => {
663
+ const r = rec.rows.find((x) => x.holdId === input.holdId);
664
+ if (r === undefined || r.resolved === undefined || r.resolvedAt !== input.ifResolvedAt)
665
+ return { result: undefined };
666
+ delete r.resolved;
667
+ delete r.resolvedAt;
641
668
  return { next: rec, result: undefined };
642
669
  });
643
670
  }
@@ -75,5 +75,16 @@ export function mergeHarvestReports(a, b) {
75
75
  const qf = [...(a.quarantineFailures ?? []), ...(b.quarantineFailures ?? [])];
76
76
  if (qf.length > 0)
77
77
  merged.quarantineFailures = qf;
78
+ const ac = a.containment;
79
+ const bc = b.containment;
80
+ if (ac !== undefined || bc !== undefined) {
81
+ merged.containment = {
82
+ indexRolledBack: ac?.indexRolledBack === true || bc?.indexRolledBack === true,
83
+ quarantinedInstruction: [...(ac?.quarantinedInstruction ?? []), ...(bc?.quarantinedInstruction ?? [])],
84
+ heldInstruction: [...(ac?.heldInstruction ?? []), ...(bc?.heldInstruction ?? [])],
85
+ releasedHolds: [...(ac?.releasedHolds ?? []), ...(bc?.releasedHolds ?? [])],
86
+ disposedHolds: [...(ac?.disposedHolds ?? []), ...(bc?.disposedHolds ?? [])],
87
+ };
88
+ }
78
89
  return merged;
79
90
  }
@@ -803,7 +803,12 @@ export declare class MemoryEngine {
803
803
  * and a re-call RESUMES it idempotently. Refusals (#123 coded, caller-shape):
804
804
  * unattributed/invalid input, unknown/unmarked entry, and a CHALLENGED entry — the clear valve
805
805
  * is not a challenge exit (adjudicate the challenge first; clearing a challenged entry would
806
- * launder the exclusion through the weaker credential).
806
+ * launder the exclusion through the weaker credential); the challenged refusal re-judges on the
807
+ * RESUME path too while the marked entry stands. Two effect-half refusals (terminal, both
808
+ * states untouched): an on-disk projection diverging from the committed state (the clear never
809
+ * overwrites unadopted plane bytes — adopt/reconcile first), and a resumed row whose entry is
810
+ * ABSENT without a recorded committed tombstone (that absence is an independent deletion the
811
+ * clear never resurrects; the custody bytes stay disclosed on the terminal row).
807
812
  */
808
813
  clearEntryOrigin(entryId: string, input: {
809
814
  requestId: string;