@sema-agent/server 7.99.0 → 7.100.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 (115) hide show
  1. package/MIGRATION.md +13 -0
  2. package/USAGE.md +75 -52
  3. package/dist/approval-ask-machine.d.ts +10 -0
  4. package/dist/approval-ask-machine.js +3 -0
  5. package/dist/approval-card.d.ts +74 -1
  6. package/dist/approval-card.js +41 -8
  7. package/dist/approval.d.ts +44 -20
  8. package/dist/approval.js +14 -6
  9. package/dist/boot/config-center.d.ts +16 -8
  10. package/dist/boot/config-center.js +39 -81
  11. package/dist/boot/execution-env.d.ts +1 -2
  12. package/dist/boot/execution-env.js +2 -2
  13. package/dist/boot/leader.js +12 -9
  14. package/dist/boot/memory-consolidation.d.ts +38 -0
  15. package/dist/boot/memory-consolidation.js +35 -0
  16. package/dist/boot/resolve-spec.d.ts +0 -1
  17. package/dist/boot/resolve-spec.js +4 -3
  18. package/dist/boot/runner-deps.d.ts +2 -2
  19. package/dist/boot/stage-05-execution-env.d.ts +1 -1
  20. package/dist/boot/stage-06-runners.d.ts +2 -2
  21. package/dist/boot/stage-06-runners.js +3 -3
  22. package/dist/boot/stage-07-capability-layer.d.ts +2 -3
  23. package/dist/boot/stage-07-capability-layer.js +6 -6
  24. package/dist/boot/stage-08-reapers.d.ts +3 -4
  25. package/dist/boot/stage-08-reapers.js +2 -2
  26. package/dist/boot/stage-09-leader.d.ts +2 -3
  27. package/dist/boot/stage-10-http-server.d.ts +2 -3
  28. package/dist/boot/stage-10-http-server.js +2 -2
  29. package/dist/capabilities/center-plugins.d.ts +14 -2
  30. package/dist/capabilities/center-plugins.js +13 -5
  31. package/dist/capabilities/hands-lane.js +2 -1
  32. package/dist/capabilities/scenarios.js +18 -5
  33. package/dist/capabilities/tool-defer.d.ts +2 -2
  34. package/dist/config-catalog.d.ts +2 -2
  35. package/dist/config-catalog.js +12 -13
  36. package/dist/config-center/apply-effective.d.ts +34 -39
  37. package/dist/config-center/apply-effective.js +100 -64
  38. package/dist/config-center/apply-ledger.d.ts +35 -16
  39. package/dist/config-center/apply-ledger.js +25 -18
  40. package/dist/config-center/facade.d.ts +11 -5
  41. package/dist/config-center/facade.js +1 -1
  42. package/dist/config-center/hot-keys-registry.d.ts +11 -4
  43. package/dist/config-center/hot-keys-registry.js +11 -7
  44. package/dist/config-center/restart-signal.d.ts +21 -14
  45. package/dist/config-center/restart-signal.js +16 -22
  46. package/dist/config-center/types.d.ts +3 -3
  47. package/dist/config-types.d.ts +22 -7
  48. package/dist/cross-session-settings.js +1 -1
  49. package/dist/host-lsp-manager.d.ts +29 -0
  50. package/dist/host-lsp-manager.js +14 -0
  51. package/dist/http/active-run-conflict.d.ts +2 -12
  52. package/dist/http/active-run-conflict.js +1 -6
  53. package/dist/http/admission.js +8 -0
  54. package/dist/http/resume-legs.d.ts +1 -1
  55. package/dist/http/resume-legs.js +14 -10
  56. package/dist/http/route-ctx.d.ts +2 -2
  57. package/dist/http/routes/approvals-assistant.d.ts +1 -1
  58. package/dist/http/routes/approvals-assistant.js +2 -2
  59. package/dist/http/routes/memory-compliance.js +5 -0
  60. package/dist/http/routes/memory-origin.js +5 -0
  61. package/dist/http/routes/sessions.js +6 -0
  62. package/dist/http/routes/tasks.js +7 -6
  63. package/dist/http/server.d.ts +6 -5
  64. package/dist/http/wire-types.d.ts +6 -3
  65. package/dist/leader/wire.d.ts +20 -12
  66. package/dist/leader/wire.js +7 -6
  67. package/dist/memory-layer-preflight.d.ts +46 -0
  68. package/dist/memory-layer-preflight.js +121 -0
  69. package/dist/memory-operator-faces.js +17 -1
  70. package/dist/observability/corrupt-read-seat.d.ts +21 -4
  71. package/dist/observability/corrupt-read-seat.js +31 -0
  72. package/dist/observability/fail-open.d.ts +8 -4
  73. package/dist/observability/fail-open.js +8 -4
  74. package/dist/observability/run-terminal.js +1 -0
  75. package/dist/observability/secret-env-scrub.d.ts +1 -0
  76. package/dist/observability/secret-env-scrub.js +1 -0
  77. package/dist/orchestration/workflow-completion-inbox.d.ts +31 -23
  78. package/dist/orchestration/workflow-completion-inbox.js +8 -3
  79. package/dist/plugins/approval-ask-store-file.d.ts +20 -2
  80. package/dist/plugins/approval-ask-store-file.js +21 -3
  81. package/dist/plugins/approval-ask-store-memory.d.ts +2 -0
  82. package/dist/plugins/approval-ask-store-memory.js +39 -13
  83. package/dist/plugins/approval-ask-store-sql.d.ts +24 -5
  84. package/dist/plugins/approval-ask-store-sql.js +27 -3
  85. package/dist/plugins/checkpoint-store-sql.d.ts +9 -3
  86. package/dist/plugins/checkpoint-store-sql.js +19 -2
  87. package/dist/plugins/local-checkpoint-store.js +2 -0
  88. package/dist/plugins/mailbox-store-sql.d.ts +7 -5
  89. package/dist/plugins/mailbox-store-sql.js +6 -4
  90. package/dist/plugins/permission-rule-store-file.js +3 -2
  91. package/dist/plugins/sql-json-column.d.ts +16 -3
  92. package/dist/plugins/sql-json-column.js +15 -4
  93. package/dist/plugins/web-search.d.ts +42 -22
  94. package/dist/plugins/web-search.js +141 -49
  95. package/dist/run-local.js +6 -5
  96. package/dist/runs.js +6 -5
  97. package/dist/runtime-governance.d.ts +50 -3
  98. package/dist/runtime-governance.js +5 -0
  99. package/dist/server-secret-env.d.ts +4 -2
  100. package/dist/task-settings.d.ts +1 -1
  101. package/dist/tool-approval.d.ts +107 -19
  102. package/dist/tool-approval.js +64 -15
  103. package/dist/trace/core-keyset-guard.d.ts +5 -5
  104. package/dist/trace/ledger-sink.js +2 -2
  105. package/dist/trace/project.d.ts +13 -0
  106. package/dist/trace/project.js +22 -5
  107. package/dist/trace/projection-drop.d.ts +2 -0
  108. package/dist/trace/projection-drop.js +6 -0
  109. package/dist/trace/sema-provenance.d.ts +4 -1
  110. package/dist/trace/sema-provenance.js +1 -1
  111. package/dist/trace/task-notification-facets.d.ts +65 -0
  112. package/dist/trace/task-notification-facets.js +31 -0
  113. package/dist/trace/wire-projection-faces.d.ts +3 -3
  114. package/dist/trace/wire-projection-faces.js +3 -3
  115. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { type SemaProvenanceInput } from "../trace/sema-provenance.js";
2
- import type { SystemInjectionPriority } from "@sema-agent/core";
2
+ import { type SystemInjectionPriority, type TaskNotificationPayload } from "@sema-agent/core";
3
3
  import type { LedgerEventType } from "../trace/ledger-events.js";
4
4
  /** One pending completion, scoped to the session that must be told about it. `summary` is ALREADY
5
5
  * redacted + length-bounded by core's notifier seam (see {@link WorkflowCompletionPayload}). */
@@ -76,7 +76,7 @@ export declare const SERVED_FENCE_MS: number;
76
76
  * 三路复审修1(MED):status 保留 RAW,不再归一 completed/failed —— 归一让同 id 无 seq 的两条 external
77
77
  * 通知(先 `event` 后 `cancelled`)折叠成同一把 `…:failed` 键,inbox map 里第二条 REPLACE 第一条,drain
78
78
  * 只吐一条(entry 的存储 status 列仍归一,那是账本的双终态列,不动;RAW status 本就随 payload extras
79
- * 透传)。这与 {@link taskNotificationStreamKey} 的 RAW 语义对齐(stream 侧早已保 RAW)。
79
+ * 透传)。这与流侧去重键(core `taskNotificationDedupKey`,见 {@link taskNotificationStreamKeyOf})的 RAW 语义对齐(stream 侧早已保 RAW)。
80
80
  *
81
81
  * 编码=防碰撞元组:域/taskId/status 走 JSON 数组(调用方自由串自造不了分隔符);`#seq` 尾折在 JSON 之外
82
82
  * ——JSON 段恒以 `"]` 结尾,`#digits` 尾无歧义,于是 {@link foldKeyFamily} 的剥尾语义 AND SQL 双生子
@@ -92,33 +92,30 @@ export declare function taskNotificationFoldKey(n: {
92
92
  status: string;
93
93
  seq?: number;
94
94
  }): string;
95
- /** [ref] stream 侧去重键(core [ref]④a + codex 1.189 前双审 F2)= `域:taskId:status[#seq]`。
96
- * 与 {@link taskNotificationFoldKey} 同族,域二分也一致(codex F2 HIGH:external 通知的 task_id 是
97
- * POST /v1/sessions/:id/notify 调用方自由串——同一条腿上,drain 出的 external 帧可与内部任务帧同形
98
- * `task_id:status:seq` 撞键互吞,per-leg 键集并不豁免这个面,旧注释「没有跨调用方预占面」判错);
99
- * 仍与 fold key 有意不同的一点:status 保留 RAW(killed/cancelled/event 不归一 failed)——归一会让同
100
- * task 的 Monitor event 批与 failed 终态在一条腿上互吞;wire 去重要的是逐帧身份,不是 inbox 的双终态列。
101
- * `seq` 折入 = 停→唤→停的第二周期同 status 帧各自成键(core 1.283 起 background_agent 四腿 notify 铸
102
- * cycleSeq;缺 seq 时旧键曾把二周期 completed 帧在 observer 臂反向吞掉——core [ref]④a);无 seq 的帧
103
- * (bash/monitor terminal/retain 降级)保持单周期旧键=同键第二帧吞,诚实语义。[ref]B-10:core 4.0.0 起
104
- * task_type 是必填闭合枚举——`task_type?` 可选性**不再对应旧 core 帧**,它活着只为家族前缀构造
105
- * (server.ts fleet 腿 `taskNotificationFoldKey({task_id,status})` 同款用法);缺席归 "task" 域的语义
106
- * 只在前缀用途下生效。external 帧三来源齐备(notify 端点 core 铸 / inbox entry extras 存 / drain 帧
107
- * spread 复原),不会漏域。observer 臂 / stream 臂 / turn-open drain 登记三处必须用本函数
108
- * 构键且都传 task_type,键形漂移=去重永 miss。 */
109
- export declare function taskNotificationStreamKey(n: {
110
- task_type?: string;
111
- task_id: string;
112
- status: string;
113
- seq?: number;
114
- }): string;
95
+ /** 一帧里组成流侧去重键的那几个字段 —— 投影后的 wire 帧(drain 出帧 / stream 事件的 `notification`)与 core 载荷同形读。
96
+ * 型取 core 载荷的那五位(`Partial`:帧上任一位都可能缺,缺了就不成键,见 {@link taskNotificationStreamKeyOf})。 */
97
+ export type TaskNotificationKeyFields = Partial<Pick<TaskNotificationPayload, "task_type" | "task_id" | "status" | "seq" | "attempt">>;
98
+ /**
99
+ * **唯一**从一帧上读键字段的地方(S-616 按类修):此前七个调用点各自手抄 `{ task_type, task_id, status, seq }` 四字段投影,
100
+ * core 给帧身份加一个分量(本版的 `attempt`)时,每一处手抄都得有人记得补 —— 漏一处就是那一腿的重供帧被吞。现在调用点
101
+ * 把整只帧交过来,键字段只在这里读。
102
+ *
103
+ * 🔴 **键 = core `taskNotificationDedupKey` 的输出**(NP-6,7.100.0 合并复审 [ref] 交叉表 #8,opus A3;[ref]):本仓流侧键此前
104
+ * 是 core 那只注入去重键的手抄孪生(`域:id:status[#seq]`),S-616 又逐字抄了它的 `:a<attempt>` 尾 —— core 改一次键规则,
105
+ * 这里就得有人记得跟。键只活在进程内(每腿 {@link NotifiedKeys},不落盘、不上 wire),换成 core 的输出零迁移面。
106
+ * core 那只键同样按车道分域(external 车道 `external:<id>`,其余裸 id —— codex F2 的「external 帧不许与内部帧撞键」照旧成立)、
107
+ * status 保 RAW、`seq` 折入、`attempt` 缺席 / 1 时字节不变。调用点手里已有 core 载荷的(observer 臂)直接调 core 函数。
108
+ * 成不了键 ⇒ `undefined`(该帧不进去重账,照写 —— at-least-once,壳按键去重兜底):`task_id` 缺席 / 空串(与旧形
109
+ * `f.task_id &&` 同判)、`task_type` / `status` 缺席(core 4.0.0 起两者在载荷上必填,缺了只能是坏帧 / 前 4.0 的存量 inbox 行)。
110
+ */
111
+ export declare function taskNotificationStreamKeyOf(n: TaskNotificationKeyFields | undefined): string | undefined;
115
112
  /** Per-leg notified-key ledger cap (codex 1.189 前双审 F3):一条超长腿(海量互异 bg 通知)不许把键集
116
113
  * 撑到无界——超出删最老插入(Map 迭代序=插入序)。远高于任何真实 per-leg 通知量;被逐键的老通知
117
114
  * 最坏情形=同键晚到帧多写一次(at-least-once,shell 按键 dedup 兜底),绝不吞新帧。 */
118
115
  export declare const NOTIFIED_KEYS_MAX = 4096;
119
116
  /**
120
117
  * Per-leg delivered-notification ledger(codex 1.189 前双审 F1 HIGH 的修位)。
121
- * 键 = {@link taskNotificationStreamKey};值 = 「认领这把键的那次写是否真落盘」的 promise(true=成功)。
118
+ * 键 = {@link taskNotificationStreamKeyOf} / core `taskNotificationDedupKey`(同一只);值 = 「认领这把键的那次写是否真落盘」的 promise(true=成功)。
122
119
  * 为什么是 Map<string, Promise<boolean>> 而不是 Set:bg/resume 腿的 observer 臂是 fire-and-forget append
123
120
  * (`void append(...)`)——旧 Set 下 append 失败(SQL blip)时键已被占而盘上无帧,core pend-drain 把同
124
121
  * payload 重放到 stream 臂时被键吞 = 通知永丢。现在 stream 臂对已见键 `await` 认领方的真实结果,false 则
@@ -268,6 +265,17 @@ export declare function taskNotificationInboxEntry(sessionId: string, owner: str
268
265
  body: string;
269
266
  };
270
267
  _sema_provenance?: SemaProvenanceInput;
268
+ /** S-616(core 7.30.0 [ref] S1 / #1034 / #1036):委派终态帧三枚 facet + 重供两键 —— 判据与 live 腿同一只
269
+ * (`trace/task-notification-facets.ts`,入参按 `unknown` 收、判过形才成 typed)。 */
270
+ spawnName?: string;
271
+ handback?: string;
272
+ compaction?: {
273
+ passes: number;
274
+ breakerOpen: boolean;
275
+ kind?: string;
276
+ };
277
+ resupplied?: boolean;
278
+ attempt?: number;
271
279
  }, enqueuedAt: number,
272
280
  /** lead-b (dual-ID audit): the PARENT run's durable taskId (the uuid the shell's footer rows
273
281
  * key on) — rides the drained frame so a consumer can bind the a*-keyed child notification to its uuid-keyed
@@ -3,6 +3,8 @@ import { join } from "node:path";
3
3
  import { redactSecrets } from "../trace/redact.js";
4
4
  import { injectionTierOrAbsent } from "../trace/injection-tier.js";
5
5
  import { semaProvenanceOrAbsent } from "../trace/sema-provenance.js";
6
+ import { resupplyFields, terminalFrameFacetFields } from "../trace/task-notification-facets.js";
7
+ import { taskNotificationDedupKey } from "@sema-agent/core";
6
8
  export const MAX_PENDING_PER_SESSION = 100;
7
9
  export const COMPACT_EVERY = 512;
8
10
  export const PURGE_FENCE_MS = 10 * 60 * 1000;
@@ -11,9 +13,10 @@ export function taskNotificationFoldKey(n) {
11
13
  const domain = n.task_type === "external" ? "external" : "task";
12
14
  return `${JSON.stringify([domain, n.task_id, n.status])}${n.seq !== undefined ? `#${n.seq}` : ""}`;
13
15
  }
14
- export function taskNotificationStreamKey(n) {
15
- const domain = n.task_type === "external" ? "external" : "task";
16
- return `${domain}:${n.task_id}:${n.status}${n.seq !== undefined ? `#${n.seq}` : ""}`;
16
+ export function taskNotificationStreamKeyOf(n) {
17
+ if (n === undefined || typeof n.task_id !== "string" || n.task_id.length === 0 || n.task_type === undefined || n.status === undefined)
18
+ return undefined;
19
+ return taskNotificationDedupKey({ task_type: n.task_type, task_id: n.task_id, status: n.status, seq: n.seq, attempt: n.attempt });
17
20
  }
18
21
  export const NOTIFIED_KEYS_MAX = 4096;
19
22
  export class NotifiedKeys {
@@ -322,6 +325,8 @@ export function taskNotificationInboxEntry(sessionId, owner, n, enqueuedAt, pare
322
325
  const p = semaProvenanceOrAbsent(n._sema_provenance);
323
326
  return p !== undefined ? { _sema_provenance: p } : {};
324
327
  })(),
328
+ ...terminalFrameFacetFields(n),
329
+ ...resupplyFields(n),
325
330
  };
326
331
  return {
327
332
  sessionId,
@@ -21,7 +21,7 @@ export declare const ASK_JOURNAL_COMPACT_GROWTH = 2;
21
21
  * 账本的**动词闭集**([ref] 形)。运行期的词表判定与编译期的穷尽 `switch` 读同一份:
22
22
  * 往这里加一个词,{@link FileApprovalAskStore.replayOp} 的 switch 立刻编译红(漏一口不可能发布)。
23
23
  */
24
- declare const JOURNAL_OPS: readonly ["ensureAsk", "transitionAsk", "decideAsk", "claimTerminal", "expireAsk", "bindBatch", "abortBatch", "deferReconcile", "resolveProvisional", "deleteByTask"];
24
+ declare const JOURNAL_OPS: readonly ["ensureAsk", "transitionAsk", "decideAsk", "claimTerminal", "claimTerminalRefuse", "expireAsk", "bindBatch", "abortBatch", "deferReconcile", "resolveProvisional", "deleteByTask"];
25
25
  type JournalOpWord = (typeof JOURNAL_OPS)[number];
26
26
  /** 运行期词表(上面的数组)与类型面动词维({@link JournalCall})的**双向**对表 —— 任一边加词而另一边
27
27
  * 没跟上,这个别名解析成 `never`,{@link JOURNAL_OP_TABLE_GATE} 的赋值当场编译红。 */
@@ -29,6 +29,18 @@ type JournalOpTableIsExhaustive = (JournalCall["op"] extends JournalOpWord ? (Jo
29
29
  /** 上面那条对表的**执法位**(占位常量形,`core-keyset-guard.ts` 的占位元组同精神):运行期无用,
30
30
  * 存在的唯一理由是让「词表与类型面分家」在编译期就红,而不是等到某条记录重放不出来。 */
31
31
  export declare const JOURNAL_OP_TABLE_GATE: JournalOpTableIsExhaustive;
32
+ /**
33
+ * 老动词 `claimTerminal` 下**只许**出现的两个意图(NP-9,7.100.0 合并复审 gpt-6 验修腿 [medium])。
34
+ *
35
+ * 🔴 **为什么终局 claim 的新意图必须记成新动词**:已发布的读者(7.99.0 及以前)的内核按 `intent === "expire" ? … : <cancel 臂>`
36
+ * 分叉 —— 它不认识的意图**不会被拒,而是被当成 cancel 重放**(行 VOID)。所以一个新意图若记在老动词下,回滚到老版本时
37
+ * 那条结算会**静默**改判(真读者复现:`refuse` ⇒ `VOID/null/null`,零拒绝记录)。老读者对**闭集外动词**是拒启并点名出路
38
+ * (「a DOWNGRADE: a newer server wrote it」)⇒ 新意图记成新动词 = 降级时响亮拒启,而不是静默误读。
39
+ * 规则:`AskTerminalClaimIntent` 每加一个意图,就在 {@link JournalCall} 里加一个动词、在 {@link FileApprovalAskStore.claimTerminal}
40
+ * 的穷尽 switch 里给它一臂(漏了 ⇒ 编译红);本型永远只有这两个词。本版读者对老动词下的其它意图同样**拒启**(不按某一臂重放)。
41
+ */
42
+ declare const JOURNAL_V1_CLAIM_INTENTS: readonly ["expire", "cancel"];
43
+ type JournalV1ClaimIntent = (typeof JOURNAL_V1_CLAIM_INTENTS)[number];
32
44
  /** 一次写调用的实参(动词维判别式)——盘上记的就是它,重放时原样喂回内核。 */
33
45
  type JournalCall = {
34
46
  op: "ensureAsk";
@@ -48,7 +60,13 @@ type JournalCall = {
48
60
  op: "claimTerminal";
49
61
  askId: string;
50
62
  batchId: string;
51
- intent: AskTerminalClaimIntent;
63
+ intent: JournalV1ClaimIntent;
64
+ }
65
+ /** NP-2 的部署政策拒(`claimTerminal(…, "refuse")`)—— 新动词而不是老动词带新意图,理由见 {@link JOURNAL_V1_CLAIM_INTENTS}。 */
66
+ | {
67
+ op: "claimTerminalRefuse";
68
+ askId: string;
69
+ batchId: string;
52
70
  } | {
53
71
  op: "expireAsk";
54
72
  askId: string;
@@ -1,6 +1,7 @@
1
1
  import { closeSync, copyFileSync, openSync, readSync, statSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { AppendLog, atomicWriteFile, ensureDir, readJsonlRecords } from "@sema-agent/core";
4
+ import { createSkippedRecordSink } from "../observability/corrupt-read-seat.js";
4
5
  import { recordFailOpen } from "../observability/fail-open.js";
5
6
  import { StoreBootRefusalError } from "./store-contracts.js";
6
7
  import { InMemoryApprovalAskStore, encodeDecideUpdatedInput, encodeStoreJsonPayload } from "./approval-ask-store-memory.js";
@@ -13,6 +14,7 @@ const JOURNAL_OPS = [
13
14
  "transitionAsk",
14
15
  "decideAsk",
15
16
  "claimTerminal",
17
+ "claimTerminalRefuse",
16
18
  "expireAsk",
17
19
  "bindBatch",
18
20
  "abortBatch",
@@ -22,6 +24,8 @@ const JOURNAL_OPS = [
22
24
  ];
23
25
  const JOURNAL_OP_WORDS = new Set(JOURNAL_OPS);
24
26
  export const JOURNAL_OP_TABLE_GATE = true;
27
+ const JOURNAL_V1_CLAIM_INTENTS = ["expire", "cancel"];
28
+ const JOURNAL_V1_CLAIM_INTENT_WORDS = new Set(JOURNAL_V1_CLAIM_INTENTS);
25
29
  function endsAtRecordBoundary(path) {
26
30
  const size = statSync(path).size;
27
31
  if (size === 0)
@@ -54,10 +58,10 @@ export class FileApprovalAskStore {
54
58
  this.path = join(dir, APPROVAL_ASK_JOURNAL_FILE);
55
59
  this.core = new InMemoryApprovalAskStore({ now: () => this.clockRead() });
56
60
  const tornTail = existsSync(this.path) && !endsAtRecordBoundary(this.path);
57
- const records = readJsonlRecords(this.path, (info) => {
61
+ const records = readJsonlRecords(this.path, createSkippedRecordSink((info) => {
58
62
  this.corruptLineCount += 1;
59
63
  recordFailOpen("server.approval-ask-journal.corrupt-record-skipped", `path=${info.path} reason=${info.reason}`);
60
- });
64
+ }));
61
65
  for (const rec of records)
62
66
  this.replayRecord(rec);
63
67
  this.records = records.length;
@@ -156,6 +160,8 @@ export class FileApprovalAskStore {
156
160
  refuse("verb outside this build's closed set (a DOWNGRADE: a newer server wrote it)");
157
161
  if (typeof probe.t !== "number" || !Number.isFinite(probe.t))
158
162
  refuse("call record without a clock reading");
163
+ if (probe.op === "claimTerminal" && !JOURNAL_V1_CLAIM_INTENT_WORDS.has(String(raw.intent)))
164
+ refuse("claimTerminal intent outside this build's journal set (a newer server wrote it)");
159
165
  const rec = raw;
160
166
  this.pinnedNow = rec.t;
161
167
  try {
@@ -180,6 +186,8 @@ export class FileApprovalAskStore {
180
186
  return this.core.decideAsk(rec.askId, rec.batchId, rec.decision);
181
187
  case "claimTerminal":
182
188
  return this.core.claimTerminal(rec.askId, rec.batchId, rec.intent);
189
+ case "claimTerminalRefuse":
190
+ return this.core.claimTerminal(rec.askId, rec.batchId, "refuse");
183
191
  case "expireAsk":
184
192
  return this.core.expireAsk(rec.askId, rec.batchId);
185
193
  case "bindBatch":
@@ -209,7 +217,17 @@ export class FileApprovalAskStore {
209
217
  return this.write({ op: "decideAsk", askId, batchId, decision: journalled }, (c) => this.core.decideAsk(c.askId, c.batchId, c.decision));
210
218
  }
211
219
  async claimTerminal(askId, batchId, intent) {
212
- return this.write({ op: "claimTerminal", askId, batchId, intent }, (c) => this.core.claimTerminal(c.askId, c.batchId, c.intent));
220
+ switch (intent) {
221
+ case "expire":
222
+ case "cancel":
223
+ return this.write({ op: "claimTerminal", askId, batchId, intent }, (c) => this.core.claimTerminal(c.askId, c.batchId, c.intent));
224
+ case "refuse":
225
+ return this.write({ op: "claimTerminalRefuse", askId, batchId }, (c) => this.core.claimTerminal(c.askId, c.batchId, "refuse"));
226
+ default: {
227
+ const unreachable = intent;
228
+ throw new Error(`FileApprovalAskStore.claimTerminal: unknown intent ${String(unreachable)}`);
229
+ }
230
+ }
213
231
  }
214
232
  async expireAsk(askId, batchId) {
215
233
  return this.write({ op: "expireAsk", askId, batchId }, (c) => this.core.expireAsk(c.askId, c.batchId));
@@ -82,6 +82,8 @@ export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
82
82
  * 既有 verb(expire ⇒ `this.expireAsk`;cancel ⇒ `this.transitionAsk(STREAM_PENDING→VOID)`)——律 2
83
83
  * 「一台机器」在本 twin 的落笔处是**字面同一个函数做写**(零第二套谓词);委托也让测试对 verb 的
84
84
  * 实例级故障注入(`store.expireAsk = throwing` 等)对 claim 恒有效,注入面不因原语换名而漂移。
85
+ * `refuse`(NP-2)没有可委托的既有 verb(`decideAsk` 要批 OPEN —— 人答臂的语义),就地写;对它的故障注入走
86
+ * `store.claimTerminal` 本身。
85
87
  * 原子性:方法体的判定与读回之间唯一的 await 是对**同步体** verb 的调用(本店头注的单线程天然原子
86
88
  * 论证);await 后读到的行 ≥ CAS 线性化点,而非 pending 态无回边 ⇒ 败方读数自足(与 SQL 侧同一条
87
89
  * 单调性论证)。
@@ -1,5 +1,5 @@
1
1
  import { canAskTransition } from "../approval-ask-machine.js";
2
- import { assertDecideSettledBy, assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
2
+ import { assertDecideSettledBy, assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, claimTargetState, POLICY_REFUSAL_DECISION, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
3
3
  function applyPatch(row, patch) {
4
4
  if (patch.decision !== undefined)
5
5
  row.decision = patch.decision;
@@ -153,18 +153,44 @@ export class InMemoryApprovalAskStore {
153
153
  return { ok: true, row: { ...ask } };
154
154
  }
155
155
  async claimTerminal(askId, batchId, intent) {
156
- if (intent === "expire") {
157
- const res = await this.expireAsk(askId, batchId);
158
- if (res.won)
159
- return { claimed: true, voidedSiblings: res.voidedSiblings };
160
- }
161
- else {
162
- const scoped = this.asks.get(askId);
163
- if (scoped !== undefined && scoped.batchId !== batchId)
164
- return { claimed: false, current: { state: "absent" } };
165
- const won = await this.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: this.now() });
166
- if (won)
167
- return { claimed: true, voidedSiblings: [] };
156
+ switch (intent) {
157
+ case "expire": {
158
+ const res = await this.expireAsk(askId, batchId);
159
+ if (res.won)
160
+ return { claimed: true, voidedSiblings: res.voidedSiblings };
161
+ break;
162
+ }
163
+ case "cancel": {
164
+ const scoped = this.asks.get(askId);
165
+ if (scoped !== undefined && scoped.batchId !== batchId)
166
+ return { claimed: false, current: { state: "absent" } };
167
+ const won = await this.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: this.now() });
168
+ if (won)
169
+ return { claimed: true, voidedSiblings: [] };
170
+ break;
171
+ }
172
+ case "refuse": {
173
+ assertDecideSettledBy(POLICY_REFUSAL_DECISION);
174
+ const to = claimTargetState(intent);
175
+ if (!canAskTransition("STREAM_PENDING", to))
176
+ throw new Error(`InMemoryApprovalAskStore.claimTerminal: illegal terminal claim STREAM_PENDING -> ${to} (askId=${askId})`);
177
+ const ask = this.asks.get(askId);
178
+ if (ask !== undefined && ask.state === "STREAM_PENDING" && ask.batchId === batchId) {
179
+ const now = this.now();
180
+ ask.state = to;
181
+ ask.decision = POLICY_REFUSAL_DECISION.decision;
182
+ ask.settledBy = POLICY_REFUSAL_DECISION.settledBy;
183
+ ask.decidedAtMs = now;
184
+ ask.rev += 1;
185
+ ask.updatedAtMs = now;
186
+ return { claimed: true, voidedSiblings: [] };
187
+ }
188
+ break;
189
+ }
190
+ default: {
191
+ const unreachable = intent;
192
+ throw new Error(`InMemoryApprovalAskStore.claimTerminal: unknown intent ${String(unreachable)}`);
193
+ }
168
194
  }
169
195
  const cur = this.asks.get(askId);
170
196
  return { claimed: false, current: projectAskTerminalClaimTruth(cur !== undefined && cur.batchId === batchId ? { ...cur } : null) };
@@ -302,13 +302,32 @@ export interface ExpireResult {
302
302
  }
303
303
  /**
304
304
  * [ref](DEBTS [ref]-R1 终局;core [ref] §4.4「@server 对表键形」,黑板 [ref] 认领):终局 claim 的
305
- * 意图。ask 行的 TERMINAL 对儿只有两员 —— `expire`(窗到期 / emit 全灭共用:STREAM_PENDING→PARKING +
306
- * 批转投递面 + 兄弟撤卡,与 `expireAsk` 同一台 CAS 机器)与 `cancel`(取消臂:STREAM_PENDING→VOID)。
305
+ * 意图。ask 行的本地终局 claim 有三员:
306
+ * · `expire` —— 窗到期 / emit 全灭在 **park 政策**下:STREAM_PENDING→PARKING + 批转投递面 + 兄弟撤卡,与
307
+ * `expireAsk` 同一台 CAS 机器(批是 park 路由的单位);
308
+ * · `cancel` —— 取消臂:STREAM_PENDING→VOID;
309
+ * · `refuse` —— 同两臂在 **deny 政策**下(NP-2,7.100.0 合并复审 [ref] 交叉表 #4):STREAM_PENDING→DECIDED,
310
+ * 决议 = {@link POLICY_REFUSAL_DECISION}(deny + 结算来源 `policy`),**按 ask 作用域** —— 不碰批、不连坐兄弟
311
+ * (deny 政策下没有 park 路由,批不是任何东西的单位;存量整腿批行因此同判)。存在理由:部署政策拒交给 core 的
312
+ * 是 `{allow:false, settledBy:"policy"}`,行若停 PARKING,收敛后成 DENIED 且无来源 ⇒ 同 ask 重入回放裸 `false`
313
+ * (core 契约里那是「一个人拒了」)。行落的来源与交出去的来源是同一个词,回放由 `outcomeOfDecidedRow` 读回同一个对象。
307
314
  * **人答臂(decide)不入本枚举**:`decideAsk` 带决议载荷与回决幂等键,其失败臂本就携行读数(384 §4.4
308
- * 表「decideAsk(人答臂)」格的「视现有 CAS 形」裁定)——三 verb 的「一台机器义务」(384 §4.3-2)由共享
309
- * WHERE 谓词 {@link pendingClaimWhere} 兑现,不是把三个不同 verb 塞进一个函数。
315
+ * 表「decideAsk(人答臂)」格的「视现有 CAS 形」裁定)——终局 verb 的「一台机器义务」(384 §4.3-2)由共享
316
+ * WHERE 谓词 {@link pendingClaimWhere} 兑现,不是把不同 verb 塞进一个函数。
310
317
  */
311
- export type AskTerminalClaimIntent = "expire" | "cancel";
318
+ export type AskTerminalClaimIntent = "expire" | "cancel" | "refuse";
319
+ /**
320
+ * `refuse` 意图落行的决议(NP-2)—— **部署政策拒**:deny + 结算来源 `policy`。来源词的型取 core 的 `HostSettledBy`
321
+ * (core 改词 / 退词 ⇒ 这里 tsc 红);协调器交给 core 的那一只(`tool-approval.ts` 的 `UnattendedPolicyRefusal`)读的是
322
+ * **同一个**常量,于是「行上记的来源」与「首判交出去的来源」在构造上是一个词。三 twin 落行前都过 {@link assertDecideSettledBy}
323
+ * (settled_by 的唯一形门:长度 ≤ 列宽、只与 deny 同行)。
324
+ */
325
+ export declare const POLICY_REFUSAL_DECISION: {
326
+ readonly decision: "deny";
327
+ readonly settledBy: Extract<HostSettledBy, "policy">;
328
+ };
329
+ /** 终局 claim 意图 → 本次 CAS 的目标态(穷举 switch:意图闭集加一个词 ⇒ 这里编译期先红)。 */
330
+ export declare function claimTargetState(intent: AskTerminalClaimIntent): AskState;
312
331
  /**
313
332
  * 败方读数:行上终局相关列的一致投影(status 词表 = 行自己的 `AskState`,零新值域 —— 384 §4.2 的
314
333
  * 「reuse the row's own vocabulary」)。
@@ -52,6 +52,21 @@ async function withReadSignal(op, signal, where) {
52
52
  signal.removeEventListener("abort", onAbort);
53
53
  }
54
54
  }
55
+ export const POLICY_REFUSAL_DECISION = { decision: "deny", settledBy: "policy" };
56
+ export function claimTargetState(intent) {
57
+ switch (intent) {
58
+ case "expire":
59
+ return "PARKING";
60
+ case "cancel":
61
+ return "VOID";
62
+ case "refuse":
63
+ return "DECIDED";
64
+ default: {
65
+ const unreachable = intent;
66
+ throw new Error(`approval-ask-store: unknown terminal claim intent ${String(unreachable)}`);
67
+ }
68
+ }
69
+ }
55
70
  export function projectAskTerminalClaimTruth(row) {
56
71
  if (row === null || row.state === "STREAM_PENDING")
57
72
  return { state: "absent" };
@@ -484,10 +499,12 @@ export class SqlApprovalAskStore {
484
499
  return { ok: true, row };
485
500
  }
486
501
  async claimTerminal(askId, batchId, intent) {
487
- const to = intent === "expire" ? "PARKING" : "VOID";
502
+ const to = claimTargetState(intent);
488
503
  if (!canAskTransition("STREAM_PENDING", to)) {
489
504
  throw new Error(`approval-ask-store.claimTerminal: illegal terminal claim STREAM_PENDING -> ${to} (askId=${askId})`);
490
505
  }
506
+ if (intent === "refuse")
507
+ assertDecideSettledBy(POLICY_REFUSAL_DECISION);
491
508
  const now = Date.now();
492
509
  const dialect = this.db.dialect;
493
510
  const conn = await this.db.connect();
@@ -500,13 +517,20 @@ export class SqlApprovalAskStore {
500
517
  return { claimed: false, current: { state: "absent" } };
501
518
  }
502
519
  }
503
- const askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state = ${ph(dialect, 1)}, rev = rev + 1, updated_at_ms = ${ph(dialect, 2)} WHERE ${pendingClaimWhere(dialect, 3)}`, [to, now, askId, batchId]);
520
+ const askRes = intent === "refuse"
521
+ ? await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state = ${ph(dialect, 1)}, decision = ${ph(dialect, 2)}, settled_by = ${ph(dialect, 3)}, decided_at_ms = ${ph(dialect, 4)}, rev = rev + 1, updated_at_ms = ${ph(dialect, 5)} WHERE ${pendingClaimWhere(dialect, 6)}`, [to, POLICY_REFUSAL_DECISION.decision, POLICY_REFUSAL_DECISION.settledBy, now, now, askId, batchId])
522
+ : await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state = ${ph(dialect, 1)}, rev = rev + 1, updated_at_ms = ${ph(dialect, 2)} WHERE ${pendingClaimWhere(dialect, 3)}`, [
523
+ to,
524
+ now,
525
+ askId,
526
+ batchId,
527
+ ]);
504
528
  if (askRes.affected !== 1) {
505
529
  const sel = await conn.query(`SELECT ${askCols(dialect)} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
506
530
  await conn.rollback();
507
531
  return { claimed: false, current: projectAskTerminalClaimTruth(sel.rows[0] ? mapAskRow(sel.rows[0]) : null) };
508
532
  }
509
- if (intent === "cancel") {
533
+ if (intent !== "expire") {
510
534
  await conn.commit();
511
535
  return { claimed: true, voidedSiblings: [] };
512
536
  }
@@ -1,7 +1,7 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
3
  import { type ExecutionOutcomeRecordWord, type GateOutcome, type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
4
- import { type RuleOfferProjection } from "../approval-card.js";
4
+ import { type ParkRowFacts, type RuleOfferProjection } from "../approval-card.js";
5
5
  import { type SqlDialect, type SqlDriver } from "./sql-driver.js";
6
6
  export { approvalPayloadFingerprint } from "./approval-payload-fingerprint.js";
7
7
  /**
@@ -30,8 +30,12 @@ export declare const TERMINAL_GRACE_MS = 3600000;
30
30
  * resume-read-path log we emit a stable, non-reversible fingerprint instead, enough to correlate get↔resolve.
31
31
  */
32
32
  export declare function tokenFingerprint(token: string): string;
33
- /** Non-secret view of a pending checkpoint for the operator approval queue — deliberately NO token. */
34
- export interface PendingCheckpoint {
33
+ /** Non-secret view of a pending checkpoint for the operator approval queue — deliberately NO token.
34
+ *
35
+ * 🔴 S-700:行**顶层**另带 {@link ParkRowFacts}(`requiresRealApproval` / `ruleOffersAbsence`,真才带、缺席 ≠ false)——
36
+ * 型与值都只有一个来源:`approval-card.ts` 的 {@link parkRowFacts}(两只店各读自己手里的那只寄存行 `pendingAction`,
37
+ * 交给它、原样展开)。继承而不是在这里逐键再写一遍:core 给这一臂再加一位同类事实时,改的只有那张 Pick 表。 */
38
+ export interface PendingCheckpoint extends ParkRowFacts {
35
39
  sessionId: string;
36
40
  scope: string;
37
41
  toolName: string | null;
@@ -109,6 +113,8 @@ export interface PendingCheckpoint {
109
113
  * (`PendingAction.hasBidiControls`,core [ref]),`put()` 在铸行
110
114
  * 这一刻反范式成 `checkpoint.has_bidi_controls` 列(同行 `tool_input` / `bound_input_hash` / `gate_kind` /
111
115
  * `risk_descriptor` / `rule_suggestions` 五列同一条「铸行时一格、读口零 blob」先例),server 不重算。
116
+ * (S-700 起本读口**确实**逐行读 blob —— 只为 {@link ParkRowFacts} 那几位,且是按「本仓不发 ALTER TABLE、
117
+ * 加列 = 删库丢在飞 park」裁出来的代价,见 `listPending` 那一段;本位与五列的「铸行时一格」形不变。)
112
118
  *
113
119
  * 🔴 `true` **或缺席,恒不写 `false`/`null`**(core d.ts 的 OMIT 契约逐字点名 BFF:「never null, never
114
120
  * present-but-undefined, never false」):缺席 = **没检出**(干净 / core 的有界扫描没够着 / 本列诞生前
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
4
4
  import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
5
5
  import { redactDeep, redactSecrets } from "../trace/redact.js";
6
- import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
6
+ import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, parkRowFacts, screenUncoveredDetailSeat } from "../approval-card.js";
7
7
  import { assertJsonColumnWritable, createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonArrayColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
8
8
  import { recordFailOpen } from "../observability/fail-open.js";
9
9
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
@@ -168,6 +168,21 @@ function readRiskDescriptorCell(cell) {
168
168
  return null;
169
169
  }
170
170
  }
171
+ function readParkRowFactsCell(cell) {
172
+ let pendingAction;
173
+ try {
174
+ pendingAction = decodeNullableJsonColumn(cell, readPendingActionMemberBody, "checkpoint.checkpoint $.pendingAction (operator queue row)");
175
+ }
176
+ catch (e) {
177
+ if (!isJsonColumnBodyProblem(e))
178
+ throw e;
179
+ recordFailOpen("server.approvals.park-row-blob-unreadable");
180
+ return {};
181
+ }
182
+ return parkRowFacts(pendingAction);
183
+ }
184
+ const PARK_ROW_PENDING_ACTION = { key: "pendingAction", as: "pending_action" };
185
+ const readPendingActionMemberBody = createObjectColumnReader();
171
186
  export function boundedToolInput(args) {
172
187
  if (args === undefined)
173
188
  return null;
@@ -516,7 +531,8 @@ export class SqlCheckpointStore {
516
531
  async listPending(scope) {
517
532
  const d = this.db.dialect;
518
533
  const base = `SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, ${jsonColumnText(d, "tool_input")}, c.bound_input_hash, ` +
519
- `${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id ` +
534
+ `${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id, ` +
535
+ `${jsonColumnText(d, "checkpoint", PARK_ROW_PENDING_ACTION)} ` +
520
536
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
521
537
  const { rows } = scope
522
538
  ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
@@ -543,6 +559,7 @@ export class SqlCheckpointStore {
543
559
  ...(ruleOffers !== undefined ? { ruleOffers } : {}),
544
560
  ...(Number(r.has_bidi_controls) === 1 ? { hasBidiControls: true } : {}),
545
561
  ...(contentKindOf(toolName) !== undefined ? { contentKind: "content_ask" } : {}),
562
+ ...readParkRowFactsCell(r[PARK_ROW_PENDING_ACTION.as]),
546
563
  };
547
564
  });
548
565
  return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ASK_USER_QUESTION_TOOL_NAME, contentKindOf } from "../approval-content-kind.js";
3
+ import { parkRowFacts } from "../approval-card.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync } from "node:fs";
5
6
  import { join } from "node:path";
@@ -184,6 +185,7 @@ export class LocalCheckpointStore {
184
185
  })(),
185
186
  ...(pa?.hasBidiControls === true ? { hasBidiControls: true } : {}),
186
187
  ...(contentKindOf(pa?.toolName ?? null) !== undefined ? { contentKind: "content_ask" } : {}),
188
+ ...parkRowFacts(cp.pendingAction),
187
189
  });
188
190
  }
189
191
  return rows.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
@@ -12,8 +12,10 @@
12
12
  * 2026-07-25)起不再与 drop 同句**:reap 是收件人活着时的龄扫,**清箱保高水位**(删消息、清租约、盒行与
13
13
  * next_seq 留着)——本文件 2026-09-03(车BF,codex r1 [high])才跟上;此前 reap 删盒行 ⇒ 下一条 append 从 1
14
14
  * 重铸,陈旧 ack 可删掉一条没人见过的新消息。跨进程试剂盒明钉此条。
15
- * 3. **ack 无 owner 守卫**(信任 claim 赢者链):`seq <= upToSeq` 删行;lease 的 `maxSeq <= upToSeq`
16
- * 时顺带清 lease(全量 ack = 释放)。
15
+ * 3. **ack 带 owner 围栏**(MB1,core 1.452 起;原「无守卫、信任 claim 赢者链」已被推翻):当前持租者才删
16
+ * `seq <= upToSeq` 的行;lease 的 `maxSeq <= upToSeq` 时顺带清 lease(全量 ack = 释放)。**围栏的判决报出来**
17
+ * (core 7.30.0 #1036,S-616):放行 ⇒ `{ acked: true }`(删 0 行也是 true —— 契约是「围栏放行」不是「删了几行」),
18
+ * 盒不在 / 无人持租 / 非持租者 ⇒ `{ acked: false }`;逐格与 core `InMemoryMailboxStore.ack` 同答。
17
19
  * 4. **lease 过期判 = 严格 `expiresAt > now` 才挡**;`releaseLease` 只在 owner 匹配时清。
18
20
  * 5. `peekCount` 含已 lease 消息(可见性 ≠ lease 态);空盒 claim = null(box 行在而消息 0 也 null)。
19
21
  * 6. **reap = SQL 全量真扫**(core File 实现只扫已加载盒 = 进程内 backstop,[ref] 确认 SQL 是全量
@@ -44,7 +46,7 @@
44
46
  */
45
47
  import type { Pool as MySqlPool } from "mysql2/promise";
46
48
  import type { Pool as PgPool } from "pg";
47
- import type { MailboxAppendMessage, MailboxLease, MailboxStore } from "@sema-agent/core";
49
+ import type { MailboxAckResult, MailboxAppendMessage, MailboxLease, MailboxStore } from "@sema-agent/core";
48
50
  import { type IndexSpec } from "./ensure-index.js";
49
51
  import type { MailboxRecipientLifecycle, MailboxRecipientTombstone } from "./mailbox-recipient-lifecycle.js";
50
52
  export declare const MAILBOX_TABLE = "mailbox";
@@ -82,7 +84,7 @@ export declare class TiDBMailboxStore implements MailboxStore, MailboxRecipientL
82
84
  private tx;
83
85
  append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
84
86
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
85
- ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
87
+ ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>;
86
88
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
87
89
  peekCount(scope: string, handle: string): Promise<number>;
88
90
  drop(scope: string, handle: string): Promise<void>;
@@ -108,7 +110,7 @@ export declare class PgMailboxStore implements MailboxStore, MailboxRecipientLif
108
110
  private assertIdentity;
109
111
  append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
110
112
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
111
- ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
113
+ ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>;
112
114
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
113
115
  peekCount(scope: string, handle: string): Promise<number>;
114
116
  drop(scope: string, handle: string): Promise<void>;
@@ -263,13 +263,14 @@ export class TiDBMailboxStore {
263
263
  });
264
264
  }
265
265
  async ack(scope, handle, owner, upToSeq) {
266
- await this.tx(async (c) => {
266
+ return this.tx(async (c) => {
267
267
  const [rows] = (await c.query(`SELECT lease_owner FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
268
268
  if (rows.length === 0 || rows[0].lease_owner == null || bufToStr(rows[0].lease_owner) !== owner)
269
- return;
269
+ return { acked: false };
270
270
  await c.query(`DELETE FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? AND seq <= ?`, [scope, handle, upToSeq]);
271
271
  await c.query(`UPDATE ${MAILBOX_TABLE} SET lease_owner = NULL, lease_expires_at_ms = NULL, lease_max_seq = NULL
272
272
  WHERE scope_key = ? AND handle = ? AND lease_max_seq IS NOT NULL AND lease_max_seq <= ?`, [scope, handle, upToSeq]);
273
+ return { acked: true };
273
274
  });
274
275
  }
275
276
  async releaseLease(scope, handle, owner) {
@@ -410,13 +411,14 @@ export class PgMailboxStore {
410
411
  });
411
412
  }
412
413
  async ack(scope, handle, owner, upToSeq) {
413
- await this.tx(async (c) => {
414
+ return this.tx(async (c) => {
414
415
  const r = (await c.query(`SELECT lease_owner FROM ${MAILBOX_TABLE} WHERE scope_key = $1 AND handle = $2 FOR UPDATE`, [scope, handle]));
415
416
  if (r.rows.length === 0 || r.rows[0].lease_owner == null || bufToStr(r.rows[0].lease_owner) !== owner)
416
- return;
417
+ return { acked: false };
417
418
  await c.query(`DELETE FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 AND seq <= $3`, [scope, handle, upToSeq]);
418
419
  await c.query(`UPDATE ${MAILBOX_TABLE} SET lease_owner = NULL, lease_expires_at_ms = NULL, lease_max_seq = NULL
419
420
  WHERE scope_key = $1 AND handle = $2 AND lease_max_seq IS NOT NULL AND lease_max_seq <= $3`, [scope, handle, upToSeq]);
421
+ return { acked: true };
420
422
  });
421
423
  }
422
424
  async releaseLease(scope, handle, owner) {
@@ -5,6 +5,7 @@ import { performance } from "node:perf_hooks";
5
5
  import { FileDurableRulePartitionProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
6
6
  import { z } from "zod";
7
7
  import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
8
+ import { createSkippedRecordSink } from "../observability/corrupt-read-seat.js";
8
9
  import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
9
10
  import { RULE_BEHAVIORS } from "@sema-agent/core";
10
11
  const RuleScopeSchema = z.union([
@@ -140,7 +141,7 @@ export class FileRuleApprovalRecordStore {
140
141
  ensureDir(dir);
141
142
  const path = join(dir, "approvals.jsonl");
142
143
  this.gate = new CorruptionGate("permission-rule approval log", path, onError);
143
- for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
144
+ for (const raw of readJsonlRecords(path, createSkippedRecordSink((info) => this.gate.trip(info.reason)))) {
144
145
  const parsed = ApprovalLineSchema.safeParse(raw);
145
146
  if (!parsed.success) {
146
147
  const stale = StaleApprovalLineSchema.safeParse(raw);
@@ -209,7 +210,7 @@ export class FileRuleImportTicketStore {
209
210
  ensureDir(dir);
210
211
  const path = join(dir, "import-tickets.jsonl");
211
212
  this.gate = new CorruptionGate("permission-rule import-ticket log", path, onError);
212
- for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
213
+ for (const raw of readJsonlRecords(path, createSkippedRecordSink((info) => this.gate.trip(info.reason)))) {
213
214
  const parsed = TicketLineSchema.safeParse(raw);
214
215
  if (!parsed.success) {
215
216
  this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);