@sema-agent/core 7.26.3 → 7.27.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 (45) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/core/auto-mode-directive.d.ts +6 -4
  3. package/dist/core/auto-mode-directive.js +1 -5
  4. package/dist/core/engine-notice.d.ts +13 -6
  5. package/dist/core/runner/contracts.d.ts +31 -7
  6. package/dist/core/runner/denial-limit-arms.d.ts +17 -15
  7. package/dist/core/runner/denial-limit-arms.js +4 -1
  8. package/dist/core/runner/prepare-gate-stations.d.ts +1 -2
  9. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -2
  10. package/dist/core/runner/prepare-policy-chain.d.ts +1 -2
  11. package/dist/core/runner/prepare-task.js +1 -1
  12. package/dist/core/runner/run-leg.js +5 -1
  13. package/dist/core/tool-policy.d.ts +23 -2
  14. package/dist/core/tool-policy.js +106 -103
  15. package/dist/index.d.ts +6 -1
  16. package/dist/stores/file/background-agent-store.d.ts +4 -0
  17. package/dist/stores/file/background-agent-store.js +10 -0
  18. package/dist/stores/file/checkpoint-store.d.ts +3 -0
  19. package/dist/stores/file/checkpoint-store.js +18 -0
  20. package/dist/stores/file/corrupt-read-sink.d.ts +20 -0
  21. package/dist/stores/file/corrupt-read-sink.js +15 -0
  22. package/dist/stores/file/file-history-store.d.ts +1 -2
  23. package/dist/stores/file/file-history-store.js +3 -8
  24. package/dist/stores/file/fs-atomic.d.ts +8 -3
  25. package/dist/stores/file/fs-atomic.js +19 -2
  26. package/dist/stores/file/index.js +4 -4
  27. package/dist/stores/file/mailbox-store.d.ts +3 -0
  28. package/dist/stores/file/mailbox-store.js +24 -18
  29. package/dist/stores/file/permission-rule-store.js +18 -1
  30. package/dist/stores/file/session-policy-store.d.ts +17 -1
  31. package/dist/stores/file/session-policy-store.js +3 -12
  32. package/dist/stores/file/session-store.d.ts +1 -5
  33. package/dist/stores/file/session-store.js +9 -14
  34. package/dist/stores/file/shared-ledger.d.ts +18 -0
  35. package/dist/stores/file/shared-ledger.js +16 -2
  36. package/dist/stores/file/tool-result-store.d.ts +9 -1
  37. package/dist/stores/file/tool-result-store.js +17 -2
  38. package/dist/stores/file/workflow-journal-store.d.ts +10 -1
  39. package/dist/stores/file/workflow-journal-store.js +8 -12
  40. package/dist/stores/file/workflow-run-store.d.ts +3 -0
  41. package/dist/stores/file/workflow-run-store.js +10 -0
  42. package/dist/tools/artifact/local-stub.js +16 -1
  43. package/package.json +1 -1
  44. package/test/export-surface.snapshot.json +3 -1
  45. package/wire-changes/core@7.27.0.json +152 -0
@@ -414,11 +414,31 @@ export function createApprovalPolicy(opts) {
414
414
  if (signal?.aborted) {
415
415
  return attest({ action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)` }, settledByNobody("task_aborted"));
416
416
  }
417
+ if (typeof ok === "object" && ok !== null) {
418
+ const reading = readApproverOutcomeObject(ok, req.toolName, "none");
419
+ if (reading.kind === "refused")
420
+ return attest({ action: "deny", message: reading.message }, reading.settlement);
421
+ if (reading.kind === "deny") {
422
+ return attest({
423
+ action: "deny",
424
+ message: reading.word === "policy"
425
+ ? policyRefusalMessage(req.toolName, undefined, reading.note)
426
+ : reading.word === "timeout"
427
+ ? `no one answered the approval request for "${req.toolName}" — the approver reported its own window elapsed with no answer; denied fail-closed${reading.note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", reading.note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
428
+ : `approval denied for "${req.toolName}"${reading.note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", reading.note, REVIEWER_NOTE_MAX_BODY)}` : ""}`,
429
+ }, reading.settlement);
430
+ }
431
+ return attest({ action: "allow" }, reading.settlement);
432
+ }
417
433
  const okRaw = ok;
418
434
  if (okRaw === true)
419
435
  return attest({ action: "allow" }, settledByPerson("human_allowed", undefined));
420
436
  if (okRaw !== false) {
421
- return attest({ action: "deny", message: `approval callback for "${req.toolName}" returned an out-of-contract value (${typeof ok}) — denied fail-closed (this policy's \`approve\` returns a boolean: return true or false)` }, settledByNobody("approver_contract"));
437
+ return attest({
438
+ action: "deny",
439
+ message: `approval callback for "${req.toolName}" returned an out-of-contract value (${typeof ok}) — denied fail-closed ` +
440
+ `(this policy's \`approve\` returns true, false, or the {allow, settledBy?, reason?, approver?} outcome object)`,
441
+ }, settledByNobody("approver_contract"));
422
442
  }
423
443
  return attest({ action: "deny", message: `approval denied for "${req.toolName}"` }, settledByPerson("human_refused", undefined));
424
444
  }
@@ -1134,6 +1154,78 @@ function detachLateAskWait(wait, onLate) {
1134
1154
  })
1135
1155
  .catch(() => undefined);
1136
1156
  }
1157
+ function readApproverOutcomeObject(ok, toolName, editLane) {
1158
+ const refused = (message, settlement) => ({ kind: "refused", message, settlement });
1159
+ let supplied;
1160
+ let allowed;
1161
+ let suppliedEdit;
1162
+ let suppliedApprover;
1163
+ try {
1164
+ supplied = ok.settledBy;
1165
+ allowed = ok.allow;
1166
+ suppliedEdit = ok.updatedInput;
1167
+ suppliedApprover = ok.approver;
1168
+ }
1169
+ catch (err) {
1170
+ return refused(`the approver's outcome for "${toolName}" could not be read (${describeThrown(err)}) — denied fail-closed`, settledByNobody("approver_contract"));
1171
+ }
1172
+ if (typeof allowed !== "boolean") {
1173
+ return refused(`the approver for "${toolName}" returned an object whose allow is not a boolean ` +
1174
+ `(got ${allowed === null ? "null" : typeof allowed}) — a verdict is exactly true or false; denied fail-closed`, settledByNobody("approver_contract"));
1175
+ }
1176
+ if (supplied !== undefined && supplied !== "human" && supplied !== "timeout" && supplied !== "policy") {
1177
+ return refused(`the approver for "${toolName}" reported settledBy "${typeof supplied === "string" ? containThrownText(supplied) : supplied === null ? "null" : typeof supplied}", which is outside what a synchronous ` +
1178
+ `approver may self-report — it is exactly "human", "timeout" or "policy" (or omitted); denied fail-closed`, settledByNobody("approver_contract"));
1179
+ }
1180
+ const settledWord = supplied === "timeout" ? "timeout" : supplied === "policy" ? "policy" : "human";
1181
+ const attribution = screenApproverAttribution(suppliedApprover);
1182
+ if (attribution.defect !== undefined) {
1183
+ return refused(`the approver for "${toolName}" reported an attribution this seam refuses: ${attribution.defect}; denied fail-closed`, settledByNobody("approver_contract"));
1184
+ }
1185
+ if (settledWord !== "human" && allowed === true) {
1186
+ return refused(`the approver for "${toolName}" returned an allow settled by "${settledWord}" — only a person approves, and that word ` +
1187
+ `names a wait that ended with nobody judging: it cannot be what approved a call; denied fail-closed ` +
1188
+ `(report "${settledWord}" with allow:false, or allow with settledBy "human"/omitted)`, settledByNobody("approver_contract"));
1189
+ }
1190
+ if (allowed !== true) {
1191
+ let reasonText;
1192
+ try {
1193
+ const suppliedReason = ok.reason;
1194
+ if (suppliedReason !== undefined && typeof suppliedReason !== "string") {
1195
+ return refused(`the approver for "${toolName}" attached a reason that is not a plain string ` +
1196
+ `(got ${suppliedReason === null ? "null" : typeof suppliedReason}) — a deny note is the decider's plain text; denied fail-closed`, settledByNobody("approver_contract"));
1197
+ }
1198
+ reasonText = typeof suppliedReason === "string" ? suppliedReason : undefined;
1199
+ }
1200
+ catch (err) {
1201
+ return refused(`the approver for "${toolName}" attached a reason that could not be read (${describeThrown(err)}) — denied fail-closed`, settledByNobody("approver_contract"));
1202
+ }
1203
+ const note = decidersNote(reasonText);
1204
+ return {
1205
+ kind: "deny",
1206
+ word: settledWord,
1207
+ note,
1208
+ settlement: settledWord === "timeout"
1209
+ ? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
1210
+ : settledWord === "policy"
1211
+ ? settledByNobody("policy_refused")
1212
+ : settledByPerson("human_refused", attribution.approver, note),
1213
+ };
1214
+ }
1215
+ if (suppliedEdit === undefined)
1216
+ return { kind: "allow", edit: undefined, settlement: settledByPerson("human_allowed", attribution.approver) };
1217
+ if (editLane === "none") {
1218
+ return refused(`the approver for "${toolName}" attached an edit (updatedInput) on a seat that has no lane to adjudicate one — ` +
1219
+ `this seat's answer is the deployment POLICY's verdict, and an edit arriving through it would execute without the ` +
1220
+ `re-adjudication an approver's edit gets; denied fail-closed (approve or refuse what was shown, and route edits ` +
1221
+ `through the run's approver seat)`, settledByNobody("approver_contract"));
1222
+ }
1223
+ const edit = tryCloneArgs(suppliedEdit);
1224
+ if (!edit.ok) {
1225
+ return refused(`the approved edit for "${toolName}" is not safely clonable (${edit.reason}) — denied fail-closed`, settledByNobody("presentation_failed"));
1226
+ }
1227
+ return { kind: "allow", edit: { value: edit.value }, settlement: settledByPerson("human_allowed", attribution.approver) };
1228
+ }
1137
1229
  async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1138
1230
  if (onAsk === "allow") {
1139
1231
  if (req.requiresRealApproval === true) {
@@ -1229,113 +1321,24 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1229
1321
  };
1230
1322
  }
1231
1323
  if (typeof ok === "object" && ok !== null) {
1232
- let supplied;
1233
- let allowed;
1234
- let suppliedEdit;
1235
- let suppliedApprover;
1236
- try {
1237
- supplied = ok.settledBy;
1238
- allowed = ok.allow;
1239
- suppliedEdit = ok.updatedInput;
1240
- suppliedApprover = ok.approver;
1241
- }
1242
- catch (err) {
1243
- return {
1244
- action: "deny",
1245
- message: `the approver's outcome for "${req.toolName}" could not be read (${describeThrown(err)}) — denied fail-closed`,
1246
- decisionReason: "mode",
1247
- settlement: settledByNobody("approver_contract"),
1248
- };
1249
- }
1250
- if (typeof allowed !== "boolean") {
1324
+ const reading = readApproverOutcomeObject(ok, req.toolName, "readjudicated");
1325
+ if (reading.kind === "refused")
1326
+ return { action: "deny", message: reading.message, decisionReason: "mode", settlement: reading.settlement };
1327
+ if (reading.kind === "deny") {
1251
1328
  return {
1252
1329
  action: "deny",
1253
- message: `the approver for "${req.toolName}" returned an object whose allow is not a boolean ` +
1254
- `(got ${allowed === null ? "null" : typeof allowed}) — a verdict is exactly true or false; denied fail-closed`,
1330
+ message: reading.word === "timeout"
1331
+ ? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${reading.note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", reading.note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
1332
+ : reading.word === "policy"
1333
+ ? policyRefusalMessage(req.toolName, req.message, reading.note)
1334
+ : humanRefusalMessage(req, reading.note),
1255
1335
  decisionReason: "mode",
1256
- settlement: settledByNobody("approver_contract"),
1257
- };
1258
- }
1259
- if (supplied !== undefined && supplied !== "human" && supplied !== "timeout" && supplied !== "policy") {
1260
- return {
1261
- action: "deny",
1262
- message: `the approver for "${req.toolName}" reported settledBy "${typeof supplied === "string" ? containThrownText(supplied) : supplied === null ? "null" : typeof supplied}", which is outside what a synchronous ` +
1263
- `approver may self-report — it is exactly "human", "timeout" or "policy" (or omitted); denied fail-closed`,
1264
- decisionReason: "mode",
1265
- settlement: settledByNobody("approver_contract"),
1266
- };
1267
- }
1268
- const settledWord = supplied === "timeout" ? "timeout" : supplied === "policy" ? "policy" : "human";
1269
- const attribution = screenApproverAttribution(suppliedApprover);
1270
- if (attribution.defect !== undefined) {
1271
- return {
1272
- action: "deny",
1273
- message: `the approver for "${req.toolName}" reported an attribution this seam refuses: ${attribution.defect}; denied fail-closed`,
1274
- decisionReason: "mode",
1275
- settlement: settledByNobody("approver_contract"),
1276
- };
1277
- }
1278
- if (settledWord !== "human" && allowed === true) {
1279
- return {
1280
- action: "deny",
1281
- message: `the approver for "${req.toolName}" returned an allow settled by "${settledWord}" — only a person approves, and that word ` +
1282
- `names a wait that ended with nobody judging: it cannot be what approved a call; denied fail-closed ` +
1283
- `(report "${settledWord}" with allow:false, or allow with settledBy "human"/omitted)`,
1284
- decisionReason: "mode",
1285
- settlement: settledByNobody("approver_contract"),
1286
- };
1287
- }
1288
- if (allowed !== true) {
1289
- let reasonText;
1290
- try {
1291
- const suppliedReason = ok.reason;
1292
- if (suppliedReason !== undefined && typeof suppliedReason !== "string") {
1293
- return {
1294
- action: "deny",
1295
- message: `the approver for "${req.toolName}" attached a reason that is not a plain string ` +
1296
- `(got ${suppliedReason === null ? "null" : typeof suppliedReason}) — a deny note is the decider's plain text; denied fail-closed`,
1297
- decisionReason: "mode",
1298
- settlement: settledByNobody("approver_contract"),
1299
- };
1300
- }
1301
- reasonText = typeof suppliedReason === "string" ? suppliedReason : undefined;
1302
- }
1303
- catch (err) {
1304
- return {
1305
- action: "deny",
1306
- message: `the approver for "${req.toolName}" attached a reason that could not be read (${describeThrown(err)}) — denied fail-closed`,
1307
- decisionReason: "mode",
1308
- settlement: settledByNobody("approver_contract"),
1309
- };
1310
- }
1311
- const note = decidersNote(reasonText);
1312
- return {
1313
- action: "deny",
1314
- message: settledWord === "timeout"
1315
- ? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
1316
- : settledWord === "policy"
1317
- ? policyRefusalMessage(req.toolName, req.message, note)
1318
- : humanRefusalMessage(req, note),
1319
- decisionReason: "mode",
1320
- settlement: settledWord === "timeout"
1321
- ? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
1322
- : settledWord === "policy"
1323
- ? settledByNobody("policy_refused")
1324
- : settledByPerson("human_refused", attribution.approver, note),
1325
- };
1326
- }
1327
- if (suppliedEdit === undefined)
1328
- return { action: "allow", decisionReason: "mode", presentedInput: presented.value, settlement: settledByPerson("human_allowed", attribution.approver) };
1329
- const edit = tryCloneArgs(suppliedEdit);
1330
- if (!edit.ok) {
1331
- return {
1332
- action: "deny",
1333
- message: `the approved edit for "${req.toolName}" is not safely clonable (${edit.reason}) — denied fail-closed`,
1334
- decisionReason: "mode",
1335
- settlement: settledByNobody("presentation_failed"),
1336
+ settlement: reading.settlement,
1336
1337
  };
1337
1338
  }
1338
- return { action: "allow", updatedInput: edit.value, decisionReason: "mode", settlement: settledByPerson("human_allowed", attribution.approver) };
1339
+ if (reading.edit === undefined)
1340
+ return { action: "allow", decisionReason: "mode", presentedInput: presented.value, settlement: reading.settlement };
1341
+ return { action: "allow", updatedInput: reading.edit.value, decisionReason: "mode", settlement: reading.settlement };
1339
1342
  }
1340
1343
  const okRaw = ok;
1341
1344
  if (okRaw === true)
package/dist/index.d.ts CHANGED
@@ -177,7 +177,12 @@ export type { SchedulerCapability, SchedulerErrorCode, ScheduledIntent, Schedule
177
177
  export { createSchedulerTools, type SchedulerToolContext, SCHEDULE_WAKEUP_TOOL_NAME, AUTONOMOUS_LOOP_SENTINEL, AUTONOMOUS_LOOP_DYNAMIC_SENTINEL, } from "./tools/scheduler-tools.js";
178
178
  export { resolveAutonomousLoopPrompt, AUTONOMOUS_LOOP_PREAMBLE, AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT, type AutonomousLoopPromptOptions, } from "./tools/loop-tick.js";
179
179
  export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.js";
180
- export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicyProjection, type ToolPolicyProjectionComponent, type ConstraintChainEntry, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, type AskRuleEvidence, type AskEvidenceAbsence, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
180
+ export { createAllowDenyPolicy, createApprovalPolicy,
181
+ /** The return type of `createApprovalPolicy.approve` — a boolean, or the `AskOutcome` object minus the two
182
+ * members that seat has no lane for (`"unavailable"`, a routing answer; `updatedInput`, an operator edit a
183
+ * policy verdict cannot get re-adjudicated). Exported so a host can NAME it instead of deriving it through
184
+ * `Parameters<typeof createApprovalPolicy>[0]["approve"]`. */
185
+ type ApprovalSeatOutcome, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicyProjection, type ToolPolicyProjectionComponent, type ConstraintChainEntry, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, type AskRuleEvidence, type AskEvidenceAbsence, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
181
186
  export { type AskOrigin, ASK_ORIGINS, isAskOrigin, classifierMayAnswer, ORIGIN_IMPLIES_REAL_APPROVAL, RULE_STORE_UNREADABLE_KINDS, type RuleStoreUnreadable, isRuleStoreUnreadable } from "./core/ask-origin.js";
182
187
  export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource, type AutoModeDenySource } from "./core/runtime-caps.js";
183
188
  export { SETTLEMENT_KINDS, type SettlementKind, isSettlementKind, type Settlement, SETTLEMENT_IS_REFUSAL, DENIED_BY_VALUES, type DeniedBy, isDeniedBy, DENIED_BY_MAY_VETO, CLASSIFIER_DENY_CAUSES, type ClassifierDenyCause, isClassifierDenyCause, DENIED_BY_MAY_CARRY_CAUSE, type GateDisposition, type GateOutcome, screenGateOutcome } from "./core/gate-outcome.js";
@@ -1,5 +1,9 @@
1
1
  import { type BackgroundAgentReapOptions, type BackgroundAgentRecord, type BackgroundAgentRowSummary, type BackgroundAgentStore, type BackgroundAgentUpdateIfExpect } from "../../core/background-agent-store.js";
2
+ import { type CorruptReadInfo } from "./corrupt-read-sink.js";
2
3
  export interface FileBackgroundAgentStoreOptions {
4
+ /** This store's half of {@link import("./shared-ledger.js").LedgerPaths.onCorruptRead} — the same seat,
5
+ * over the agent row model. */
6
+ onCorruptRead?: (info: CorruptReadInfo) => void;
3
7
  /** When false, a ledger append is NOT fsync'd. Default true. */
4
8
  fsync?: boolean;
5
9
  /** Compact the ledger into a snapshot once it exceeds this many events (then truncate). Default 1000. */
@@ -1,7 +1,9 @@
1
1
  import { join } from "node:path";
2
2
  import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, assertBackgroundAgentReapOptions, queryBackgroundAgents, } from "../../core/background-agent-store.js";
3
3
  import { SharedLedgerTable } from "./shared-ledger.js";
4
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
4
5
  import { assertAdoptionBootGate } from "./adoption/marker.js";
6
+ const isAgentRow = (x) => x !== null && typeof x === "object" && typeof x.handle === "string" && typeof x.scope === "string";
5
7
  const agentLedgers = new SharedLedgerTable({
6
8
  label: "background-agent ledger",
7
9
  keyOf: (r) => FileBackgroundAgentStore.key(r.handle, r.scope),
@@ -12,6 +14,13 @@ const agentLedgers = new SharedLedgerTable({
12
14
  }
13
15
  rows.set(ev.k, ev.r);
14
16
  },
17
+ judgeRow: isAgentRow,
18
+ judgeEvent: (x) => {
19
+ const ev = x;
20
+ if (typeof ev.k !== "string")
21
+ return false;
22
+ return ev.t === "delete" ? true : isAgentRow(ev.r);
23
+ },
15
24
  });
16
25
  export class FileBackgroundAgentStore {
17
26
  fsyncEnabled;
@@ -29,6 +38,7 @@ export class FileBackgroundAgentStore {
29
38
  this.ledger = agentLedgers.acquire({
30
39
  dir,
31
40
  tmpDir: join(root, "tmp"),
41
+ ...(opts.onCorruptRead !== undefined ? { onCorruptRead: guardCorruptSink(opts.onCorruptRead) } : {}),
32
42
  ledgerPath: join(dir, "agents.jsonl"),
33
43
  snapshotPath: join(dir, "agents.snapshot.jsonl"),
34
44
  });
@@ -1,7 +1,10 @@
1
1
  import { type ExecutionOutcomeRecordWord } from "../../core/checkpoint-execution-record.js";
2
2
  import { type Checkpoint, type PendingSteerInput, type CheckpointFaultMode, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type ReopenReason, type ResolveExpectation, type ResumeOutcome } from "../../core/checkpoint-store.js";
3
3
  import type { GateOutcome } from "../../core/gate-outcome.js";
4
+ import { type CorruptReadInfo } from "./corrupt-read-sink.js";
4
5
  export interface FileCheckpointStoreOptions {
6
+ /** This store's half of {@link import("./shared-ledger.js").LedgerPaths.onCorruptRead}. */
7
+ onCorruptRead?: (info: CorruptReadInfo) => void;
5
8
  /** When false, an `appendLine` for a state transition is NOT fsync'd. The checkpoint COMMIT POINT always
6
9
  * fsyncs regardless (its crash-safety depends on it); this governs the `put` and `setPendingSteer`
7
10
  * (steer-append) commits only — `resolve`/`reopen`/`expire` fsync unconditionally. Default true. */
@@ -3,6 +3,7 @@ import { assertAdoptionBootGate } from "./adoption/marker.js";
3
3
  import { checkpointExecutionRecorded, executionRecordDisposition, ExecutionOutcomeConflictError, } from "../../core/checkpoint-execution-record.js";
4
4
  import { appendPendingSteer, CheckpointError, checkpointOccMatches, checkpointRowMatches, summarizeCheckpoint, validatePendingSteer, winnerFromOutcome, } from "../../core/checkpoint-store.js";
5
5
  import { SharedLedgerTable } from "./shared-ledger.js";
6
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
6
7
  const CHECKPOINT_LEDGER_EVENT_REGISTRY = {
7
8
  put: true,
8
9
  resolve: true,
@@ -57,10 +58,26 @@ function applyCheckpointEvent(cps, ev) {
57
58
  }
58
59
  }
59
60
  }
61
+ const isCheckpointRow = (x) => x !== null &&
62
+ typeof x === "object" &&
63
+ typeof x.token === "string" &&
64
+ typeof x.scope === "string" &&
65
+ typeof x.status === "string";
60
66
  const checkpointLedgers = new SharedLedgerTable({
61
67
  label: "checkpoint ledger",
62
68
  keyOf: (cp) => cp.token,
63
69
  apply: applyCheckpointEvent,
70
+ judgeRow: isCheckpointRow,
71
+ judgeEvent: (x) => {
72
+ const ev = x;
73
+ if (typeof ev.t !== "string")
74
+ return false;
75
+ if (!KNOWN_CHECKPOINT_LEDGER_EVENTS.has(ev.t))
76
+ return true;
77
+ if (typeof ev.token !== "string")
78
+ return false;
79
+ return ev.t === "put" ? isCheckpointRow(ev.cp) : true;
80
+ },
64
81
  });
65
82
  export class FileCheckpointStore {
66
83
  durability = "durable";
@@ -83,6 +100,7 @@ export class FileCheckpointStore {
83
100
  this.ledger = checkpointLedgers.acquire({
84
101
  dir,
85
102
  tmpDir: join(root, "tmp"),
103
+ ...(opts.onCorruptRead !== undefined ? { onCorruptRead: guardCorruptSink(opts.onCorruptRead) } : {}),
86
104
  ledgerPath: join(dir, "checkpoints.jsonl"),
87
105
  snapshotPath: join(dir, "checkpoints.snapshot.jsonl"),
88
106
  });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The file backend's ONE disclosure-seat guard: `@contract store.read_back_shape_judged`'s second legal
3
+ * posture — an absence that DISCLOSES — rides a host's `onCorruptRead`, and the guard around it was
4
+ * hand-written four times. Three copies contained an async sink's rejection; the mailbox's caught only a
5
+ * synchronous throw, so an `async` sink there became an unhandled rejection.
6
+ */
7
+ /** Coordinates of one durable read a store answered as absent because the bytes were corrupt. */
8
+ export interface CorruptReadInfo {
9
+ path: string;
10
+ reason: string;
11
+ }
12
+ /**
13
+ * Wrap a host's disclosure seat: neither a synchronous throw nor an async rejection can re-introduce the
14
+ * fault the fail-open avoids. `undefined` in ⇒ `undefined` out. Generic in the payload because the
15
+ * session-policy store discloses session coordinates rather than a path.
16
+ */
17
+ /** The sink is invoked UNBOUND (`this` is undefined): pass a bound function or an arrow, never a bare
18
+ * method reference — a method reading `this` throws, and the throw is contained here, so the disclosure
19
+ * is silently lost rather than surfaced. */
20
+ export declare function guardCorruptSink<I>(sink: ((info: I) => void) | undefined): ((info: I) => void) | undefined;
@@ -0,0 +1,15 @@
1
+ export function guardCorruptSink(sink) {
2
+ if (sink === undefined)
3
+ return undefined;
4
+ return (info) => {
5
+ try {
6
+ const returned = sink(info);
7
+ if (typeof returned?.then === "function") {
8
+ void Promise.resolve(returned).then(undefined, () => {
9
+ });
10
+ }
11
+ }
12
+ catch {
13
+ }
14
+ };
15
+ }
@@ -233,8 +233,7 @@ export declare class FileFileHistoryStore implements FileHistoryStore {
233
233
  * GC that could not run leaves a graph larger than its bound, the safer of the two directions).
234
234
  */
235
235
  private applyBoundaryRetention;
236
- /** The single delivery point for the disclosure sink; swallow-guarded here so no call site has to
237
- * remember, and so a host's throwing sink cannot break the never-throw seam. */
236
+ /** The single delivery point for the disclosure sink. */
238
237
  private disclose;
239
238
  private scopeDir;
240
239
  private trackedPath;
@@ -5,6 +5,7 @@ import { thrownText } from "../../core/thrown-value.js";
5
5
  import { applyFileRestoreViaEnv, boundaryPublishVerdict, worldStillMatchesMint, captureFileBackupViaEnv, modeProvenDifferent, previewFileDelta, describeTrackKey, fileHistoryExportDigest, resolveTrackKey, trackKeyOf, validateFileHistoryExport, } from "../../core/file-history-store.js";
6
6
  import { fileHistoryBoundariesToKeep, resolveFileHistoryRetention } from "../../core/file-history-retention.js";
7
7
  import { atomicWriteFile, ensureDir, sanitizePathComponent, sanitizeScope, writeThenLink } from "./fs-atomic.js";
8
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
8
9
  import { isAbsoluteForFamily } from "../../tools/fs/safety.js";
9
10
  import { assertAdoptionBootGate } from "./adoption/marker.js";
10
11
  function trackedDigest(rows) {
@@ -156,7 +157,7 @@ export class FileFileHistoryStore {
156
157
  this.scopesDir = join(this.base, "scopes");
157
158
  ensureDir(this.blobsDir);
158
159
  ensureDir(this.scopesDir);
159
- this.onCorruptRead = opts?.onCorruptRead;
160
+ this.onCorruptRead = guardCorruptSink(opts?.onCorruptRead);
160
161
  }
161
162
  applyBoundaryRetention(scope, justCommitted) {
162
163
  if (this.boundaryKeep === null)
@@ -194,13 +195,7 @@ export class FileFileHistoryStore {
194
195
  }
195
196
  }
196
197
  disclose(path, reason) {
197
- try {
198
- const r = this.onCorruptRead?.({ path, reason });
199
- if (typeof r?.then === "function")
200
- r.then(undefined, () => { });
201
- }
202
- catch {
203
- }
198
+ this.onCorruptRead?.({ path, reason });
204
199
  }
205
200
  scopeDir(scope) {
206
201
  return join(this.scopesDir, sanitizeScope(scope));
@@ -40,11 +40,16 @@ export declare function atomicWriteFile(tmpDir: string, target: string, bytes: s
40
40
  * parity), so a single corrupt interior line never poisons the whole replay.
41
41
  * A missing file → `[]`. This is why JSONL beats one big JSON blob: a torn tail can never corrupt the store.
42
42
  *
43
+ * SHAPE (`@contract store.read_back_shape_judged`): parseability is not the judgement. Every store on this
44
+ * format writes ONE JSON OBJECT per line, so a line parsing to a number, string, boolean, `null` or array
45
+ * takes the road a torn line takes — disclosed, skipped, the replay intact — because it is the same fact:
46
+ * these bytes are not what this store wrote. Without it `null` made a `line.kind` test throw.
47
+ *
43
48
  * `onCorrupt` (optional, review-510 disclosure-seat family): fired once per skipped INTERIOR record so a
44
49
  * caller-side seat can observe real corruption — a silently-skipped record is otherwise indistinguishable
45
- * from "less data". The torn TAIL stays out of it by design: dying mid-write is the documented crash
46
- * shape this format exists to absorb, not corruption. The callback is invoked as given — a seat that
47
- * must not throw swallow-guards on ITS side (the stores do).
50
+ * from "less data"; its `reason` opens with the {@link CorruptBodyForm} word, so a sink classifies without
51
+ * reading prose. The torn TAIL stays out by design: dying mid-write is the crash shape this format exists
52
+ * to absorb. The callback is invoked as given — a seat that must not throw swallow-guards on ITS side.
48
53
  */
49
54
  export declare function readJsonlRecords<T>(path: string, onCorrupt?: (info: {
50
55
  path: string;
@@ -126,15 +126,32 @@ export function readJsonlRecords(path, onCorrupt) {
126
126
  const line = parts[i];
127
127
  if (line === "")
128
128
  continue;
129
+ let record;
129
130
  try {
130
- out.push(JSON.parse(line));
131
+ record = JSON.parse(line);
131
132
  }
132
133
  catch {
133
- onCorrupt?.({ path, reason: `unparseable jsonl record at line ${i + 1} (skipped; the rest of the replay is intact)` });
134
+ onCorrupt?.({ path, reason: corruptRecordReason("torn-json", i + 1, "unparseable jsonl record") });
135
+ continue;
136
+ }
137
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
138
+ onCorrupt?.({ path, reason: corruptRecordReason("not-an-object", i + 1, `jsonl record is ${jsonTypeName(record)}, not an object`) });
139
+ continue;
134
140
  }
141
+ out.push(record);
135
142
  }
136
143
  return out;
137
144
  }
145
+ function corruptRecordReason(form, lineNo, what) {
146
+ return `${form}: ${what} at line ${lineNo} (skipped; the rest of the replay is intact)`;
147
+ }
148
+ function jsonTypeName(value) {
149
+ if (value === null)
150
+ return "null";
151
+ if (Array.isArray(value))
152
+ return "an array";
153
+ return `a ${typeof value}`;
154
+ }
138
155
  export function canonicalStoreKey(p) {
139
156
  return physicalPathOfExisting(p).toLowerCase();
140
157
  }
@@ -65,19 +65,19 @@ export class FileStorageBackend {
65
65
  this.fileSessions = repo;
66
66
  this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget", durability: "durable", placements: { subagent: { durability: "durable" } } });
67
67
  this.sessionStore = this.ttl;
68
- this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
68
+ this.fileCheckpoints = new FileCheckpointStore(this.root, { ...opts.checkpoint, ...corruptRead });
69
69
  built.push(() => this.fileCheckpoints.close());
70
70
  this.checkpointStore = this.fileCheckpoints;
71
71
  this.fileMemory = new FileMemoryStore(this.root, opts.embedder ? { embedder: opts.embedder } : {});
72
72
  built.push(() => this.fileMemory.close());
73
73
  this.memoryStore = guardedMemoryStore(this.fileMemory, opts.utilityGate);
74
- this.toolResultStore = new FileToolResultStore(this.root);
74
+ this.toolResultStore = new FileToolResultStore(this.root, corruptRead);
75
75
  this.sessionPolicyStore = new FileSessionPolicyStore(this.root, corruptRead);
76
- this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root);
76
+ this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root, true, corruptRead);
77
77
  built.push(() => this.fileWorkflowJournal.dispose());
78
78
  this.workflowJournalStore = this.fileWorkflowJournal;
79
79
  this.usageWindowStore = new FileUsageWindowStore(this.root);
80
- this.fileAgentRows = new FileBackgroundAgentStore(this.root);
80
+ this.fileAgentRows = new FileBackgroundAgentStore(this.root, corruptRead ?? {});
81
81
  built.push(() => this.fileAgentRows.close());
82
82
  this.backgroundAgentStore = this.fileAgentRows;
83
83
  this.fileMailbox = new FileMailboxStore(this.root, corruptRead ?? {});
@@ -105,6 +105,9 @@ export declare class FileMailboxStore implements MailboxStore {
105
105
  * and the cache is replayed from the file. A moved INODE also swap-closes the descriptor (ring 3):
106
106
  * the next write reopens the current file instead of landing on the unlinked one.
107
107
  */
108
+ /** Fold ONE replayed event, disclosing a record that is not one of this box's ({@link applyEvent}
109
+ * answers `false`): dropped silently it reads exactly like a shorter mailbox. */
110
+ private applyReplayed;
108
111
  private reconcile;
109
112
  private load;
110
113
  private commit;