@sema-agent/server 7.54.0 → 7.55.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 (36) hide show
  1. package/dist/approval-ask-audit-store.d.ts +129 -0
  2. package/dist/approval-ask-audit-store.js +284 -0
  3. package/dist/approval-card.d.ts +18 -6
  4. package/dist/approval-card.js +2 -2
  5. package/dist/approval-reconciler.d.ts +2 -1
  6. package/dist/approval-reconciler.js +1 -1
  7. package/dist/boot/coordinators.d.ts +2 -0
  8. package/dist/boot/coordinators.js +18 -1
  9. package/dist/boot/reapers.d.ts +2 -1
  10. package/dist/boot/stores.js +8 -1
  11. package/dist/device-store.d.ts +66 -2
  12. package/dist/device-store.js +35 -0
  13. package/dist/device-ws-hub.d.ts +8 -0
  14. package/dist/device-ws-hub.js +6 -0
  15. package/dist/http/routes/approvals-assistant.js +1 -0
  16. package/dist/http/routes/devices.d.ts +64 -0
  17. package/dist/http/routes/devices.js +173 -0
  18. package/dist/http/server.d.ts +14 -0
  19. package/dist/http/server.js +13 -2
  20. package/dist/main.js +3 -1
  21. package/dist/observability/fail-open.d.ts +7 -3
  22. package/dist/observability/fail-open.js +7 -3
  23. package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
  24. package/dist/plugins/approval-ask-store-memory.js +20 -3
  25. package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
  26. package/dist/plugins/approval-ask-store-sql.js +41 -10
  27. package/dist/plugins/device-store-sql.d.ts +38 -1
  28. package/dist/plugins/device-store-sql.js +82 -2
  29. package/dist/plugins/remote-env-device.js +8 -1
  30. package/dist/plugins/sql-errors.d.ts +12 -0
  31. package/dist/plugins/sql-errors.js +10 -0
  32. package/dist/runs.js +1 -0
  33. package/dist/tool-approval.d.ts +54 -23
  34. package/dist/tool-approval.js +218 -127
  35. package/dist/trace/ledger-events.d.ts +9 -0
  36. package/package.json +2 -2
@@ -151,15 +151,19 @@ export declare const FAIL_OPEN_TAGS: {
151
151
  };
152
152
  readonly "server.approval.ambiguous-cas.expire": {
153
153
  readonly cls: "P-DEBT";
154
- readonly note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290):流内 ask 的**窗到期臂**打 `expireAsk` CAS 抛错或超墙钟(不知道做没做成)之后,恢复读口 6 格退避 7 次读(≈3.1s;末格睡后仍再读一次,末格不是盲窗 —— codex R1-F1)都读不出行 ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(它落地后本 tag 降 F)。**不计**的形:行读得出、只是仍 STREAM_PENDING / 行不在 —— 那是本臂的期望态(K1),零退避直接投影(codex-R2-2 / R3-1 钉;throw K2 pending 投影前重打一把本臂幂等 CAS 换持久背书,R3-F2);编辑在飞标记在场 ⇒ 有界让路、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);行读出 DECIDED 按人的决议结算,也不计。detail 带 askId 与读次数。";
154
+ readonly note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290)→ S-69 收窄(S-25-R1 终局,core design/384 §4.3 七律 / §4.4 键形对表,[5934]):流内 ask 的**窗到期臂**打原子终局 claim(`claimTerminal(expire)`,S-69 前 `expireAsk` CAS)被 reject(抛错 / 超墙钟,什么也证明不了 —— 律 3 K2)之后,有界退避**重发同一 claim**(6 ⇒ 至多 7 把,在飞帽 2 按持久 askId 记、撞帽拍不计 attempt、让路探针读同帽同界、帽满或同 askId 尚有在飞操作时探针不计 rung、真相广播同 askId 全部登记(本登记已 done 亦广播)—— codex S-69 R2-1/R3-1/R3-2/R4-1/R5-1/R6-1/R6-2/R7-1;末格睡后仍再发 —— codex R1-F1 同律;总墙钟硬上界 TERMINAL_LADDER_WALL_MS ≈10.15s —— R2-2 修正算术 + R3-3 硬封顶)**全部 reject** ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(A-075.116②:其兜底覆盖本形经确认后本 tag 降 F)。**不计**的形(S-69 起命中条件收窄到「店对整条阶梯持续不可用」):claim 输 ⇒ 败方答案**携真相**(DECIDED ⇒ 按人的决议结算,零补读;PARKING/PARKED park 路由;absent = 行不在/错 scope ⇒ K1 零退避投影),迟到回来的 claim 同样自足(迟到赢 副作用补做,迟到输 按真决议结算 —— 此前迟到回调只认 won 的那条残余窗即 S-25-R1,本批闭合);编辑在飞标记在场 ⇒ 有界让路(不重打 claim)、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);阶梯里曾拿到真相而标记已放开按那份真相结算。detail 带 askId 与 claim 把数。";
155
155
  };
156
156
  readonly "server.approval.ambiguous-cas.cancel": {
157
157
  readonly cls: "P-DEBT";
158
- readonly note: "S-25 同族第二员:**取消臂**(task signal abort / 连接全灭同路)打 `transitionAsk(STREAM_PENDING→VOID)` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);现在只有店持续读不出才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。";
158
+ readonly note: "S-25 同族第二员 → S-69 收窄:**取消臂**(task signal abort / 连接全灭同路)打 `claimTerminal(cancel)`(STREAM_PENDING→VOID;S-69 前 `transitionAsk` CAS)被 reject 之后,有界重发至多 7 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);S-25 起先读行;S-69 起干净输的答案本身携真相、迟到答案同样自足,只有店对整条阶梯持续 reject 才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。";
159
159
  };
160
160
  readonly "server.approval.ambiguous-cas.unreachable": {
161
161
  readonly cls: "P-DEBT";
162
- readonly note: "S-25 同族第三员:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `expireAsk` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;现在行读出 DECIDED ⇒ 按人的决议结算,只有店持续读不出才计数。";
162
+ readonly note: "S-25 同族第三员 → S-69 收窄:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `claimTerminal(expire)`(S-69 前 `expireAsk` CAS)被 reject 之后,有界重发至多 7 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;S-25 起行读出 DECIDED ⇒ 按人的决议结算;S-69 起真相随败方答案回来、迟到答案自足,只有店对整条阶梯持续 reject 才计数。";
163
+ };
164
+ readonly "server.approval-ask-audit.append-failed": {
165
+ readonly cls: "F";
166
+ readonly note: "S-38 裁 (c) 审计半场:local 车道 File ask 审计店(`approval-ask-audit-store.ts`)的**运行期** append(铸造/决议/腿闭/收敛四类行)失败(盘满/只读/fd 失效)⇒ 丢**这一条**审计行,审批路径照常。方向刻意 fail-open:审批可用性不押在审计盘上 —— 盘满时人还得能批,把一次审计写失败升级成审批失败是把「痕迹缺一条」换成「门整个不可用」,方向更坏。boot 期(mkdir/重放)失败则**拒启**(File 店族同律,store 头注成文),不经本 tag。放行的最坏后果 = 该 ask 在崩溃收敛读面上缺席或分臂失真(与 A-075.55 的零痕迹病同向)—— 所以必须留痕:「审计一直在漏写」与「没有审批发生」在读面上同形,不计数则病灶永不显形。detail 带行类与异常文案。";
163
167
  };
164
168
  readonly "server.pg-pool.idle-client-error": {
165
169
  readonly cls: "F";
@@ -142,15 +142,19 @@ export const FAIL_OPEN_TAGS = {
142
142
  },
143
143
  "server.approval.ambiguous-cas.expire": {
144
144
  cls: "P-DEBT",
145
- note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290):流内 ask 的**窗到期臂**打 `expireAsk` CAS 抛错或超墙钟(不知道做没做成)之后,恢复读口 6 格退避 7 次读(≈3.1s;末格睡后仍再读一次,末格不是盲窗 —— codex R1-F1)都读不出行 ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(它落地后本 tag 降 F)。**不计**的形:行读得出、只是仍 STREAM_PENDING / 行不在 —— 那是本臂的期望态(K1),零退避直接投影(codex-R2-2 / R3-1 钉;throw K2 pending 投影前重打一把本臂幂等 CAS 换持久背书,R3-F2);编辑在飞标记在场 ⇒ 有界让路、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);行读出 DECIDED 按人的决议结算,也不计。detail 带 askId 与读次数。",
145
+ note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290)→ S-69 收窄(S-25-R1 终局,core design/384 §4.3 七律 / §4.4 键形对表,[5934]):流内 ask 的**窗到期臂**打原子终局 claim(`claimTerminal(expire)`,S-69 前 `expireAsk` CAS)被 reject(抛错 / 超墙钟,什么也证明不了 —— 律 3 K2)之后,有界退避**重发同一 claim**(6 ⇒ 至多 7 把,在飞帽 2 按持久 askId 记、撞帽拍不计 attempt、让路探针读同帽同界、帽满或同 askId 尚有在飞操作时探针不计 rung、真相广播同 askId 全部登记(本登记已 done 亦广播)—— codex S-69 R2-1/R3-1/R3-2/R4-1/R5-1/R6-1/R6-2/R7-1;末格睡后仍再发 —— codex R1-F1 同律;总墙钟硬上界 TERMINAL_LADDER_WALL_MS ≈10.15s —— R2-2 修正算术 + R3-3 硬封顶)**全部 reject** ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(A-075.116②:其兜底覆盖本形经确认后本 tag 降 F)。**不计**的形(S-69 起命中条件收窄到「店对整条阶梯持续不可用」):claim 输 ⇒ 败方答案**携真相**(DECIDED ⇒ 按人的决议结算,零补读;PARKING/PARKED park 路由;absent = 行不在/错 scope ⇒ K1 零退避投影),迟到回来的 claim 同样自足(迟到赢 副作用补做,迟到输 按真决议结算 —— 此前迟到回调只认 won 的那条残余窗即 S-25-R1,本批闭合);编辑在飞标记在场 ⇒ 有界让路(不重打 claim)、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);阶梯里曾拿到真相而标记已放开按那份真相结算。detail 带 askId 与 claim 把数。",
146
146
  },
147
147
  "server.approval.ambiguous-cas.cancel": {
148
148
  cls: "P-DEBT",
149
- note: "S-25 同族第二员:**取消臂**(task signal abort / 连接全灭同路)打 `transitionAsk(STREAM_PENDING→VOID)` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);现在只有店持续读不出才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。",
149
+ note: "S-25 同族第二员 → S-69 收窄:**取消臂**(task signal abort / 连接全灭同路)打 `claimTerminal(cancel)`(STREAM_PENDING→VOID;S-69 前 `transitionAsk` CAS)被 reject 之后,有界重发至多 7 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);S-25 起先读行;S-69 起干净输的答案本身携真相、迟到答案同样自足,只有店对整条阶梯持续 reject 才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。",
150
150
  },
151
151
  "server.approval.ambiguous-cas.unreachable": {
152
152
  cls: "P-DEBT",
153
- note: "S-25 同族第三员:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `expireAsk` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;现在行读出 DECIDED ⇒ 按人的决议结算,只有店持续读不出才计数。",
153
+ note: "S-25 同族第三员 → S-69 收窄:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `claimTerminal(expire)`(S-69 前 `expireAsk` CAS)被 reject 之后,有界重发至多 7 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;S-25 起行读出 DECIDED ⇒ 按人的决议结算;S-69 起真相随败方答案回来、迟到答案自足,只有店对整条阶梯持续 reject 才计数。",
154
+ },
155
+ "server.approval-ask-audit.append-failed": {
156
+ cls: "F",
157
+ note: "S-38 裁 (c) 审计半场:local 车道 File ask 审计店(`approval-ask-audit-store.ts`)的**运行期** append(铸造/决议/腿闭/收敛四类行)失败(盘满/只读/fd 失效)⇒ 丢**这一条**审计行,审批路径照常。方向刻意 fail-open:审批可用性不押在审计盘上 —— 盘满时人还得能批,把一次审计写失败升级成审批失败是把「痕迹缺一条」换成「门整个不可用」,方向更坏。boot 期(mkdir/重放)失败则**拒启**(File 店族同律,store 头注成文),不经本 tag。放行的最坏后果 = 该 ask 在崩溃收敛读面上缺席或分臂失真(与 A-075.55 的零痕迹病同向)—— 所以必须留痕:「审计一直在漏写」与「没有审批发生」在读面上同形,不计数则病灶永不显形。detail 带行类与异常文案。",
154
158
  },
155
159
  "server.pg-pool.idle-client-error": {
156
160
  cls: "F",
@@ -12,13 +12,23 @@
12
12
  * - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
13
13
  */
14
14
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
15
- import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
15
+ import type { AskDecision, AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
16
16
  export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
17
17
  private readonly asks;
18
18
  private readonly batches;
19
19
  ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
20
20
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
21
21
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
22
+ /**
23
+ * [ref] 原子终局 claim(接口契约见 SQL 侧 {@link ApprovalAskStore.claimTerminal})。**委托形**:CAS 走
24
+ * 既有 verb(expire ⇒ `this.expireAsk`;cancel ⇒ `this.transitionAsk(STREAM_PENDING→VOID)`)——律 2
25
+ * 「一台机器」在本 twin 的落笔处是**字面同一个函数做写**(零第二套谓词);委托也让测试对 verb 的
26
+ * 实例级故障注入(`store.expireAsk = throwing` 等)对 claim 恒有效,注入面不因原语换名而漂移。
27
+ * 原子性:方法体的判定与读回之间唯一的 await 是对**同步体** verb 的调用(本店头注的单线程天然原子
28
+ * 论证);await 后读到的行 ≥ CAS 线性化点,而非 pending 态无回边 ⇒ 败方读数自足(与 SQL 侧同一条
29
+ * 单调性论证)。
30
+ */
31
+ claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise<AskTerminalClaimOutcome>;
22
32
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
23
33
  bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
24
34
  deferReconcile(askId: string, expectedState: AskState, expectedRev: number, nowMs: number): Promise<boolean>;
@@ -1,5 +1,5 @@
1
1
  import { canAskTransition } from "../approval-ask-machine.js";
2
- import { assertIdempotencyKeyShape, ApprovalAskReadAbortedError } from "./approval-ask-store-sql.js";
2
+ import { assertIdempotencyKeyShape, ApprovalAskReadAbortedError, 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;
@@ -95,7 +95,7 @@ export class InMemoryApprovalAskStore {
95
95
  return { ok: false, reason: "batch_closed", row: row ? { ...row } : null };
96
96
  }
97
97
  const ask = this.asks.get(askId);
98
- if (!ask || ask.state !== "STREAM_PENDING") {
98
+ if (!ask || ask.state !== "STREAM_PENDING" || ask.batchId !== batchId) {
99
99
  return { ok: false, reason: "ask_not_pending", row: ask ? { ...ask } : null };
100
100
  }
101
101
  if (decision.idempotencyKey != null) {
@@ -129,13 +129,30 @@ export class InMemoryApprovalAskStore {
129
129
  ask.updatedAtMs = now;
130
130
  return { ok: true, row: { ...ask } };
131
131
  }
132
+ async claimTerminal(askId, batchId, intent) {
133
+ if (intent === "expire") {
134
+ const res = await this.expireAsk(askId, batchId);
135
+ if (res.won)
136
+ return { claimed: true, voidedSiblings: res.voidedSiblings };
137
+ }
138
+ else {
139
+ const scoped = this.asks.get(askId);
140
+ if (scoped !== undefined && scoped.batchId !== batchId)
141
+ return { claimed: false, current: { state: "absent" } };
142
+ const won = await this.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() });
143
+ if (won)
144
+ return { claimed: true, voidedSiblings: [] };
145
+ }
146
+ const cur = this.asks.get(askId);
147
+ return { claimed: false, current: projectAskTerminalClaimTruth(cur !== undefined && cur.batchId === batchId ? { ...cur } : null) };
148
+ }
132
149
  async expireAsk(askId, batchId) {
133
150
  const now = Date.now();
134
151
  const batch = this.batches.get(batchId);
135
152
  if (!batch)
136
153
  return { won: false, voidedSiblings: [] };
137
154
  const ask = this.asks.get(askId);
138
- if (!ask || ask.state !== "STREAM_PENDING")
155
+ if (!ask || ask.state !== "STREAM_PENDING" || ask.batchId !== batchId)
139
156
  return { won: false, voidedSiblings: [] };
140
157
  ask.state = "PARKING";
141
158
  ask.rev += 1;
@@ -241,6 +241,58 @@ export interface ExpireResult {
241
241
  won: boolean;
242
242
  voidedSiblings: string[];
243
243
  }
244
+ /**
245
+ * [ref](DEBTS [ref]-R1 终局;core [ref] §4.4「@server 对表键形」,黑板 [ref] 认领):终局 claim 的
246
+ * 意图。ask 行的 TERMINAL 对儿只有两员 —— `expire`(窗到期 / emit 全灭共用:STREAM_PENDING→PARKING +
247
+ * 批转投递面 + 兄弟撤卡,与 `expireAsk` 同一台 CAS 机器)与 `cancel`(取消臂:STREAM_PENDING→VOID)。
248
+ * **人答臂(decide)不入本枚举**:`decideAsk` 带决议载荷与回决幂等键,其失败臂本就携行读数(384 §4.4
249
+ * 表「decideAsk(人答臂)」格的「视现有 CAS 形」裁定)——三 verb 的「一台机器义务」(384 §4.3-2)由共享
250
+ * WHERE 谓词 {@link pendingClaimWhere} 兑现,不是把三个不同 verb 塞进一个函数。
251
+ */
252
+ export type AskTerminalClaimIntent = "expire" | "cancel";
253
+ /**
254
+ * 败方读数:行上终局相关列的一致投影(status 词表 = 行自己的 `AskState`,零新值域 —— 384 §4.2 的
255
+ * 「reuse the row's own vocabulary」)。
256
+ * · `state:"absent"` = 行不在,**或 scope(batchId)不匹配** —— 错 scope 的 claim 必须输且不得读出
257
+ * 他 scope 行的真相(读谓词与 CAS 谓词是同一份 scope WHERE;多租户隔离,384 §4.2 absent 臂原话)。
258
+ * · **绝无 `STREAM_PENDING` 臂**:server 的 ask 店 CAS 谓词是 status 门(STREAM_PENDING→终态),无
259
+ * rev/OCC 期望语义 ⇒ core 表的 `pending`(rev 失配)臂在本形结构不可达,如实缺席不硬造([ref]
260
+ * 补充确认①)。输 = 行在 claim 线性化点上已非 pending,而非 pending 态在状态机上**无回边**
261
+ * (`approval-ask-machine.ts`,本域无 reopen)⇒ 败方读数恒 ≥ 那一刻;「CAS 时行不在、读时刚被
262
+ * ensureAsk 插入」的编排错序形按线性化点真相投影为 `absent`。
263
+ * · 本域无 reopen ⇒ DECIDED 恒不回 pending,败方读数在本域退化为永恒真相;消费仍按快照律
264
+ * (384 §4.3-5)写,不依赖此强化([ref] 补充确认②)。
265
+ */
266
+ export type AskTerminalClaimCurrent = {
267
+ state: "absent";
268
+ } | {
269
+ state: Exclude<AskState, "STREAM_PENDING">;
270
+ rev: number;
271
+ decision: AskDecision | null;
272
+ /** [ref] 三态读法照 {@link AskRow.updatedInput}:键缺席 = 没带编辑;`null` 是编辑成 null 的值。 */
273
+ updatedInput?: unknown;
274
+ };
275
+ /**
276
+ * 单次原子往返的答案(语义律的唯一规范陈述点 = core `checkpoint-store.ts` `claimTerminal` 契约 JSDoc,
277
+ * [ref] §4.3;本类型是其 ask 行同位物,不复述七律,只记 server 侧特有形):
278
+ * · 赢 ⇒ `{claimed:true}`(expire 意图随赢携兄弟撤卡名单,与 `expireAsk` 同形);
279
+ * · 输 ⇒ **同一原子单元**携行真相(律 1)—— 消费方拿到 `claimed:false` 后永不需要第二次读来行动;
280
+ * · reject(throw / 超墙钟)⇒ 什么也不证明(律 3 K2)——调用方不得按本地意图宣告终局;**迟到的
281
+ * settle 同样自足**:deadline 过后才回来的 `claimed:false` 直接携真决议,臂按真决议 settle 零补读。
282
+ * server 三臂全为**幂等消费**(按真决议 settle,谁写的无关,律 4)——本店无职责性消费面。
283
+ */
284
+ export type AskTerminalClaimOutcome = {
285
+ claimed: true;
286
+ voidedSiblings: string[];
287
+ } | {
288
+ claimed: false;
289
+ current: AskTerminalClaimCurrent;
290
+ };
291
+ /**
292
+ * 败方读数的投影(三 twin 共用一份,别各自挑列):`null` / 错 scope 由调用方先折成 `null` 传入;
293
+ * `STREAM_PENDING` 折 `absent` 的理由见 {@link AskTerminalClaimCurrent} 顶注(线性化点真相)。
294
+ */
295
+ export declare function projectAskTerminalClaimTruth(row: AskRow | null): AskTerminalClaimCurrent;
244
296
  export interface BindGateInput {
245
297
  gateToken: string;
246
298
  gateBoundCallId?: string | null;
@@ -278,6 +330,15 @@ export interface ApprovalAskStore {
278
330
  ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
279
331
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
280
332
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
333
+ /**
334
+ * [ref]:原子终局 claim —— 把「争终局」与「读真相」并成**一次**店往返(胜负判定与败方 `current` 读数
335
+ * 在同一原子单元内,语义见 {@link AskTerminalClaimOutcome})。[ref]-R1 的病根「CAS 与读真相是两次店
336
+ * 往返」由本原语从机制上消灭:settle 的答案自足(赢 ⇒ 赢;输 ⇒ 携真相),迟到的答案同样自足。
337
+ * 律 2「一台机器义务」:`expireAsk` 在本方法之上实现(SQL 双方言)/ 本方法委托给
338
+ * `expireAsk`/`transitionAsk`(InMemory,单线程天然原子)——两向都成立的判据是**没有第二套谓词**。
339
+ * 方法**必备**(本仓自有三形全实现,无 core 那侧的 presence 探测问题)。
340
+ */
341
+ claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise<AskTerminalClaimOutcome>;
281
342
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
282
343
  bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
283
344
  abortBatch(batchId: string): Promise<string[]>;
@@ -435,6 +496,27 @@ export declare class SqlApprovalAskStore implements ApprovalAskStore {
435
496
  ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
436
497
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
437
498
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
499
+ /**
500
+ * [ref] 原子终局 claim(接口契约见 {@link ApprovalAskStore.claimTerminal};类型语义见
501
+ * {@link AskTerminalClaimOutcome})。expire 意图 = 既有 `expireAsk` 事务**原体**(步⓪-③ 逐字保留,
502
+ * `expireAsk` 现在是本方法的薄投影 —— 律 2 的「在 claimTerminal 之上实现」半场);cancel 意图 =
503
+ * 取消臂的 STREAM_PENDING→VOID(此前走通用 `transitionAsk`,无败方读数)。
504
+ *
505
+ * **原子性证明(两方言各一句,红先钉在 db-integration 的 [ref] 组)**:
506
+ * · 败方读数是**同一事务内**的 `SELECT … FOR UPDATE` —— TiDB 侧本事务显式 `BEGIN PESSIMISTIC`
507
+ * (`beginPessimistic()`,不赌部署的 `tidb_txn_mode` 缺省):悲观锁定读是 CURRENT read,读到的是
508
+ * 并发赢家**已提交**的最新版本(乐观模式下 FOR UPDATE 读 start_ts 快照,会把赢家终局读旧 ——
509
+ * 正是 bindBatch 失败臂注记的那类方言分歧,这里用显式事务模式把它结构性关死);PG 侧
510
+ * `beginPessimistic()` = 普通 `BEGIN`,READ COMMITTED 下 FOR UPDATE 本就等待并返回最新已提交版本。
511
+ * · CAS 输(affected=0)⇒ 行在 UPDATE 那一刻已非 STREAM_PENDING(或不在/错 scope),而非 pending 态
512
+ * 在状态机上无回边 ⇒ 锁定读读到的恒 ≥ 线性化点,且 DECIDED 决议不可变 —— 答案自足。
513
+ * · 本事务在输臂上除批行 rev 自增外零写,rollback 无痕。
514
+ */
515
+ claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise<AskTerminalClaimOutcome>;
516
+ /** [ref] 起 = {@link claimTerminal}(expire 意图)的薄投影 —— 律 2「在 claimTerminal 之上实现」的
517
+ * SQL 半场落笔处:谓词/事务/兄弟撤卡全在 claim 里,这里零第二套写路径。败方读数按既有返回形丢弃
518
+ * (`ExpireResult` 不携真相 —— 要真相的调用方直接用 claimTerminal;对账收敛器的 orphan 腿输了本就
519
+ * 只需「不赢」这一位)。 */
438
520
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
439
521
  bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
440
522
  abortBatch(batchId: string): Promise<string[]>;
@@ -33,6 +33,16 @@ async function withReadSignal(op, signal, where) {
33
33
  signal.removeEventListener("abort", onAbort);
34
34
  }
35
35
  }
36
+ export function projectAskTerminalClaimTruth(row) {
37
+ if (row === null || row.state === "STREAM_PENDING")
38
+ return { state: "absent" };
39
+ return {
40
+ state: row.state,
41
+ rev: row.rev,
42
+ decision: row.decision,
43
+ ...(row.updatedInput !== undefined ? { updatedInput: row.updatedInput } : {}),
44
+ };
45
+ }
36
46
  export const APPROVAL_ASK_TABLE = "approval_ask";
37
47
  export const APPROVAL_BATCH_TABLE = "approval_batch";
38
48
  export const TIDB_APPROVAL_ASK_STATEMENTS = [
@@ -278,6 +288,9 @@ function patchColumns(patch, json) {
278
288
  function ph(dialect, n) {
279
289
  return dialect === "tidb" ? "?" : `$${n}`;
280
290
  }
291
+ function pendingClaimWhere(dialect, firstParam) {
292
+ return `ask_id = ${ph(dialect, firstParam)} AND batch_id = ${ph(dialect, firstParam + 1)} AND state = 'STREAM_PENDING'`;
293
+ }
281
294
  export class SqlApprovalAskStore {
282
295
  db;
283
296
  constructor(db) {
@@ -366,6 +379,7 @@ export class SqlApprovalAskStore {
366
379
  if (decision.idempotencyKey != null)
367
380
  assertIdempotencyKeyShape(decision.idempotencyKey);
368
381
  const now = Date.now();
382
+ const dialect = this.db.dialect;
369
383
  const conn = await this.db.connect();
370
384
  try {
371
385
  await conn.begin();
@@ -376,7 +390,7 @@ export class SqlApprovalAskStore {
376
390
  }
377
391
  let askRes;
378
392
  try {
379
- askRes = await conn.query(this.q(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=?, decision_actor=?, decision_note=?, updated_input=?, decided_at_ms=?, idempotency_key=?, rev=rev+1, updated_at_ms=? WHERE ask_id=? AND state='STREAM_PENDING'`, `UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=$1, decision_actor=$2, decision_note=$3, updated_input=$4, decided_at_ms=$5, idempotency_key=$6, rev=rev+1, updated_at_ms=$7 WHERE ask_id=$8 AND state='STREAM_PENDING'`), [
393
+ askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=${ph(dialect, 1)}, decision_actor=${ph(dialect, 2)}, decision_note=${ph(dialect, 3)}, updated_input=${ph(dialect, 4)}, decided_at_ms=${ph(dialect, 5)}, idempotency_key=${ph(dialect, 6)}, rev=rev+1, updated_at_ms=${ph(dialect, 7)} WHERE ${pendingClaimWhere(dialect, 8)}`, [
380
394
  decision.decision,
381
395
  decision.decisionActor == null ? null : this.json(decision.decisionActor),
382
396
  decision.decisionNote ?? null,
@@ -385,6 +399,7 @@ export class SqlApprovalAskStore {
385
399
  decision.idempotencyKey ?? null,
386
400
  now,
387
401
  askId,
402
+ batchId,
388
403
  ]);
389
404
  }
390
405
  catch (err) {
@@ -416,20 +431,32 @@ export class SqlApprovalAskStore {
416
431
  throw new Error(`decideAsk: row vanished after commit (askId=${askId})`);
417
432
  return { ok: true, row };
418
433
  }
419
- async expireAsk(askId, batchId) {
434
+ async claimTerminal(askId, batchId, intent) {
435
+ const to = intent === "expire" ? "PARKING" : "VOID";
436
+ if (!canAskTransition("STREAM_PENDING", to)) {
437
+ throw new Error(`approval-ask-store.claimTerminal: illegal terminal claim STREAM_PENDING -> ${to} (askId=${askId})`);
438
+ }
420
439
  const now = Date.now();
440
+ const dialect = this.db.dialect;
421
441
  const conn = await this.db.connect();
422
442
  try {
423
- await conn.begin();
424
- const lockRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=?`, `UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=$1`), [batchId]);
425
- if (lockRes.affected !== 1) {
426
- await conn.rollback();
427
- return { won: false, voidedSiblings: [] };
443
+ await conn.beginPessimistic();
444
+ if (intent === "expire") {
445
+ const lockRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=?`, `UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=$1`), [batchId]);
446
+ if (lockRes.affected !== 1) {
447
+ await conn.rollback();
448
+ return { claimed: false, current: { state: "absent" } };
449
+ }
428
450
  }
429
- const askRes = await conn.query(this.q(`UPDATE ${APPROVAL_ASK_TABLE} SET state='PARKING', rev=rev+1, updated_at_ms=? WHERE ask_id=? AND state='STREAM_PENDING'`, `UPDATE ${APPROVAL_ASK_TABLE} SET state='PARKING', rev=rev+1, updated_at_ms=$1 WHERE ask_id=$2 AND state='STREAM_PENDING'`), [now, askId]);
451
+ 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]);
430
452
  if (askRes.affected !== 1) {
453
+ const sel = await conn.query(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
431
454
  await conn.rollback();
432
- return { won: false, voidedSiblings: [] };
455
+ return { claimed: false, current: projectAskTerminalClaimTruth(sel.rows[0] ? mapAskRow(sel.rows[0]) : null) };
456
+ }
457
+ if (intent === "cancel") {
458
+ await conn.commit();
459
+ return { claimed: true, voidedSiblings: [] };
433
460
  }
434
461
  await conn.query(this.q(`UPDATE ${APPROVAL_BATCH_TABLE} SET state='ROUTING_UNBOUND', rev=rev+1, updated_at_ms=? WHERE batch_id=? AND state='OPEN'`, `UPDATE ${APPROVAL_BATCH_TABLE} SET state='ROUTING_UNBOUND', rev=rev+1, updated_at_ms=$1 WHERE batch_id=$2 AND state='OPEN'`), [now, batchId]);
435
462
  let voidedSiblings;
@@ -449,7 +476,7 @@ export class SqlApprovalAskStore {
449
476
  }
450
477
  }
451
478
  await conn.commit();
452
- return { won: true, voidedSiblings };
479
+ return { claimed: true, voidedSiblings };
453
480
  }
454
481
  catch (err) {
455
482
  try {
@@ -463,6 +490,10 @@ export class SqlApprovalAskStore {
463
490
  conn.release();
464
491
  }
465
492
  }
493
+ async expireAsk(askId, batchId) {
494
+ const res = await this.claimTerminal(askId, batchId, "expire");
495
+ return res.claimed ? { won: true, voidedSiblings: res.voidedSiblings } : { won: false, voidedSiblings: [] };
496
+ }
466
497
  async bindBatch(batchId, askId, gate) {
467
498
  const now = Date.now();
468
499
  const conn = await this.db.connect();
@@ -44,7 +44,7 @@
44
44
  import type { Pool as MySqlPool } from "mysql2/promise";
45
45
  import type { Pool as PgPool } from "pg";
46
46
  import { type SqlDriver } from "./sql-driver.js";
47
- import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type BindSessionInput, type BindSessionResult, type ConsumeEnrollTokenInput, type ConsumeEnrollTokenResult, type DeviceAdmissionView, type DeviceAuditRow, type DeviceEnrollTokenRow, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type HeartbeatConnectionInput, type IssueEnrollTokenInput, type ListAuditFilter, type RevokeDeviceInput, type RevokeDeviceResult } from "../device-store.js";
47
+ import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type BindSessionInput, type BindSessionResult, type ConsumeEnrollTokenInput, type ConsumeEnrollTokenResult, type DeviceAdmissionView, type DeviceAuditRow, type DeviceEnrollTokenRow, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type HeartbeatConnectionInput, type IssueEnrollTokenInput, type ListAuditFilter, type RebindSessionInput, type RebindSessionResult, type RevokeDeviceInput, type RevokeDeviceResult } from "../device-store.js";
48
48
  /**
49
49
  * ── 命名(**落库一律单数**,与设计稿的复数形不同)──────────────────────────────────────────────────
50
50
  * design v2 §6 的 DDL 写的是 `devices` / `device_sessions` / `device_enrollment_tokens`(复数),但本仓有一条
@@ -62,6 +62,29 @@ export declare const TIDB_DEVICE_STATEMENTS: readonly string[];
62
62
  export declare function ensureTiDBDeviceSchema(pool: MySqlPool): Promise<void>;
63
63
  type PgQuery = (sql: string, params?: unknown[]) => Promise<unknown>;
64
64
  export declare function ensurePgDeviceSchema(q: PgQuery): Promise<void>;
65
+ /**
66
+ * O4 升级前置探针 —— **拒启,不是 warn**([ref]/[ref]/[ref] `assert*Schema` 族先例):`device_audit` 的
67
+ * 事件闭集加了第 9 员 `session_rebound`,而 `CREATE TABLE IF NOT EXISTS` 对存量表是空操作 ⇒ 一台没
68
+ * 重建表就升上来的 **PG** 部署(PG 强制 CHECK;TiDB 默认 `tidb_enable_check_constraint=OFF` 不执行,
69
+ * 见 AUDIT_EVENT_CHECK 头注的真库实测),每一次换绑赢 CAS 都在**审计 INSERT** 上撞
70
+ * `ck_device_audit_event` ⇒ 整事务回滚,换绑动词在运行期恒 500 —— 而启动与健康检查全绿。
71
+ *
72
+ * 判据 = **能力探测**(begin → 插一行 `session_rebound` → rollback,探针行永不落地),不读
73
+ * information_schema 元数据(memory `capability-detection-not-impl-names`:判「装没装」用能力面 ——
74
+ * TiDB 上 CHECK 元数据在而不执行,读元数据会把能写的表误判成不能写)。只有**可证**的 CHECK 拒
75
+ * ({@link isCheckViolationError})才换成指路;其余错误原样上抛([ref] 两轮收窄的同一条:一句指向
76
+ * 手工 DDL 的指路比它要挡的缺陷更贵)。
77
+ *
78
+ * 🔴 指路刻意**不是** `DROP TABLE`(与兄弟探针不同):`device_audit` 是安全轴审计史,为扩一个词表
79
+ * 销毁全部审计行不成比例。本仓不发 `ALTER TABLE`(SCHEMA POLICY),但闭集执法的权威本就在应用层
80
+ * (读侧 `parseDeviceAuditEvent`),CHECK 只是纵深 —— 所以给运维的是**手工**约束级重建语句
81
+ * (drop + 按当前闭集 add),历史一行不丢,纵深回到当前词表。
82
+ *
83
+ * 调用点:`boot/stores.ts`,**只在 device 车道真会装配时探**(`config.remoteExec?.provider ===
84
+ * "device"`,与 main.ts 的 `createDeviceLane` 谓词同源)——非 device 部署零审计写点,为它拒启是纯误伤
85
+ * ([ref] round2 的同一条收窄)。
86
+ */
87
+ export declare function assertDeviceAuditRebindEventSchema(db: SqlDriver): Promise<void>;
65
88
  export declare class SqlDeviceStore implements DeviceStore {
66
89
  private readonly db;
67
90
  constructor(db: SqlDriver);
@@ -82,6 +105,8 @@ export declare class SqlDeviceStore implements DeviceStore {
82
105
  readDeviceForAdmission(deviceId: string, owner: DeviceOwner): Promise<DeviceAdmissionView | null>;
83
106
  getDeviceUnscoped(deviceId: string): Promise<DeviceRow | null>;
84
107
  listDevicesByOwner(owner: DeviceOwner): Promise<DeviceRow[]>;
108
+ /** O4/S-4 operator 全量(消费点必须先过 explicit operator 门 —— 契约注见 `DeviceStore`)。 */
109
+ listDevicesUnscoped(): Promise<DeviceRow[]>;
85
110
  /**
86
111
  * 吊销 = 终态 CAS(`status='active'` 前态)+ **租约作废 + 世代推进**(§8-R4 在途语义):
87
112
  * 世代一推,在途连接的每一次带世代谓词的写(心跳/结果帧入账)当场影响 0 行 ⇒ 设备侧立即失权。
@@ -113,6 +138,18 @@ export declare class SqlDeviceStore implements DeviceStore {
113
138
  * 输家回读既有行 —— 同 deviceId = 幂等成功,异 deviceId = `conflict`(无未定义态)。
114
139
  */
115
140
  bindSession(input: BindSessionInput): Promise<BindSessionResult>;
141
+ /**
142
+ * O4 显式换绑(2026-08-30 v2 稿 §2)—— **单事务**:目标设备门(当前读)+ 幂等短路 + 前态 CAS +
143
+ * `session_rebound` 审计行一起提交。三形语义 oracle = `InMemoryDeviceStore.rebindSession`。
144
+ *
145
+ * 🔴 `beginPessimistic` + 两次 `FOR UPDATE`(bindSession 的同一条真库红的射程):
146
+ * · 设备行必须当前读 —— 快照读会把「与吊销并发的换绑」绑到一台刚被吊销的设备上(double-admit 形);
147
+ * · 绑定行必须当前读且**持锁到提交** —— 两个并发换绑(同 expectedRev)在锁上串行:赢家 rev+1 提交,
148
+ * 输家的锁读看到新 rev ⇒ 走 `rev_conflict` 携当前行(单赢家,无未定义态)。锁读判等之后,CAS
149
+ * UPDATE(`rev = ?` 谓词 + `rev+1` 恒变列)在持锁下不可能 0 行 —— 0 行即驱动/引擎异常,响亮抛,
150
+ * 不折成业务分支([ref]:未知失败不伪装成正常拒绝)。
151
+ */
152
+ rebindSession(input: RebindSessionInput): Promise<RebindSessionResult>;
116
153
  getSessionBinding(rootSessionId: string): Promise<DeviceSessionRow | null>;
117
154
  deleteByRootSession(rootSessionId: string): Promise<number>;
118
155
  appendAudit(input: AppendAuditInput): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
- import { isDupKeyError } from "./sql-errors.js";
3
- import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
2
+ import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
3
+ import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
4
4
  export const DEVICES_TABLE = "device";
5
5
  export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
6
6
  export const DEVICE_SESSIONS_TABLE = "device_session";
@@ -149,6 +149,39 @@ export async function ensurePgDeviceSchema(q) {
149
149
  await q(`CREATE INDEX IF NOT EXISTS idx_device_audit_device ON ${DEVICE_AUDIT_TABLE} (device_id, at)`);
150
150
  await q(`CREATE INDEX IF NOT EXISTS idx_device_audit_event ON ${DEVICE_AUDIT_TABLE} (event, at)`);
151
151
  }
152
+ export async function assertDeviceAuditRebindEventSchema(db) {
153
+ const conn = await db.connect();
154
+ try {
155
+ await conn.begin();
156
+ try {
157
+ await conn.query(db.dialect === "tidb"
158
+ ? `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, NULL, NULL, NULL, NULL)`
159
+ : `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, NULL, NULL, NULL, NULL)`, ["session_rebound"]);
160
+ }
161
+ catch (err) {
162
+ if (!isCheckViolationError(err, db.dialect))
163
+ throw err;
164
+ const remedy = db.dialect === "pg"
165
+ ? `ALTER TABLE ${DEVICE_AUDIT_TABLE} DROP CONSTRAINT ck_device_audit_event; ALTER TABLE ${DEVICE_AUDIT_TABLE} ADD CONSTRAINT ck_device_audit_event CHECK (event IN (${AUDIT_EVENT_CHECK}));`
166
+ : `ALTER TABLE ${DEVICE_AUDIT_TABLE} DROP CHECK ck_device_audit_event; ALTER TABLE ${DEVICE_AUDIT_TABLE} ADD CONSTRAINT ck_device_audit_event CHECK (event IN (${AUDIT_EVENT_CHECK}));`;
167
+ throw new Error(`${DEVICE_AUDIT_TABLE} still carries a pre-O4 event CHECK that rejects 'session_rebound' — refusing to start. ` +
168
+ "The rebind verb (POST /v1/devices/sessions/:rootSessionId/rebind) writes this audit event in the same transaction " +
169
+ "as its CAS, so on this table every rebind would fail at runtime after a healthy-looking boot. This repository ships " +
170
+ "no ALTER TABLE migrations, and dropping an AUDIT table would destroy security-axis history — run the manual " +
171
+ `constraint rebuild instead (history preserved, defense-in-depth restored to the current closed set): \`${remedy}\` ` +
172
+ "The application layer remains the enforcing authority for this closed set (parseDeviceAuditEvent). " +
173
+ `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
174
+ }
175
+ await conn.rollback();
176
+ }
177
+ catch (err) {
178
+ await conn.rollback().catch(() => undefined);
179
+ throw err;
180
+ }
181
+ finally {
182
+ conn.release();
183
+ }
184
+ }
152
185
  const ms = (dialect, col, alias) => dialect === "tidb" ? `ROUND(UNIX_TIMESTAMP(${col}) * 1000) AS ${alias}` : `(EXTRACT(EPOCH FROM ${col}) * 1000)::bigint AS ${alias}`;
153
186
  const NOW = (dialect) => (dialect === "tidb" ? "NOW(3)" : "now()");
154
187
  const nowPlusSeconds = (dialect, placeholder) => dialect === "tidb" ? `NOW(3) + INTERVAL ${placeholder} SECOND` : `now() + (${placeholder}::double precision * INTERVAL '1 second')`;
@@ -413,6 +446,10 @@ export class SqlDeviceStore {
413
446
  const { rows } = await this.db.query(this.q(`SELECT ${deviceCols("tidb")} FROM ${DEVICES_TABLE} WHERE owner_tenant = ? AND owner_subject = ? ORDER BY device_id ASC`, `SELECT ${deviceCols("pg")} FROM ${DEVICES_TABLE} WHERE owner_tenant = $1 AND owner_subject = $2 ORDER BY device_id ASC`), [owner.tenant, owner.subject]);
414
447
  return rows.map(mapDeviceRow);
415
448
  }
449
+ async listDevicesUnscoped() {
450
+ const { rows } = await this.db.query(this.q(`SELECT ${deviceCols("tidb")} FROM ${DEVICES_TABLE} ORDER BY device_id ASC`, `SELECT ${deviceCols("pg")} FROM ${DEVICES_TABLE} ORDER BY device_id ASC`));
451
+ return rows.map(mapDeviceRow);
452
+ }
416
453
  async revokeDevice(input) {
417
454
  assertRevokeShape(input);
418
455
  const conn = await this.db.connect();
@@ -515,6 +552,49 @@ export class SqlDeviceStore {
515
552
  conn.release();
516
553
  }
517
554
  }
555
+ async rebindSession(input) {
556
+ assertRebindShape(input);
557
+ const conn = await this.db.connect();
558
+ try {
559
+ await conn.beginPessimistic();
560
+ const dev = await conn.query(this.q(`SELECT status FROM ${DEVICES_TABLE} WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT status FROM ${DEVICES_TABLE} WHERE device_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [input.toDeviceId, input.owner.tenant, input.owner.subject]);
561
+ if (!dev.rows[0] || parseDeviceStatus(bufToStr(dev.rows[0].status)) !== "active") {
562
+ await conn.rollback();
563
+ return { outcome: "device_not_found" };
564
+ }
565
+ const cur = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [input.rootSessionId, input.owner.tenant, input.owner.subject]);
566
+ if (!cur.rows[0]) {
567
+ await conn.rollback();
568
+ return { outcome: "binding_not_found" };
569
+ }
570
+ const row = mapSessionRow(cur.rows[0]);
571
+ if (row.deviceId === input.toDeviceId && row.rev === input.expectedRev) {
572
+ await conn.rollback();
573
+ return { outcome: "idempotent", row };
574
+ }
575
+ if (row.rev !== input.expectedRev) {
576
+ await conn.rollback();
577
+ return { outcome: "rev_conflict", row };
578
+ }
579
+ const cas = await conn.query(this.q(`UPDATE ${DEVICE_SESSIONS_TABLE} SET device_id = ?, bound_at = NOW(3), rev = rev + 1 WHERE root_session_id = ? AND owner_tenant = ? AND owner_subject = ? AND rev = ?`, `UPDATE ${DEVICE_SESSIONS_TABLE} SET device_id = $1, bound_at = now(), rev = rev + 1 WHERE root_session_id = $2 AND owner_tenant = $3 AND owner_subject = $4 AND rev = $5`), [input.toDeviceId, input.rootSessionId, input.owner.tenant, input.owner.subject, input.expectedRev]);
580
+ if (cas.affected !== 1) {
581
+ throw new Error(`device-store: rebind CAS affected ${cas.affected} rows under a held row lock — engine anomaly, refusing to guess`);
582
+ }
583
+ await this.insertAuditOn(conn, { event: "session_rebound", deviceId: input.toDeviceId, owner: row.owner, detail: { from: row.deviceId, to: input.toDeviceId, rev: row.rev + 1 } });
584
+ const { rows } = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ?`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1`), [input.rootSessionId]);
585
+ await conn.commit();
586
+ if (!rows[0])
587
+ throw new Error("device-store: rebound session binding vanished inside its own transaction");
588
+ return { outcome: "rebound", row: mapSessionRow(rows[0]) };
589
+ }
590
+ catch (err) {
591
+ await conn.rollback().catch(() => undefined);
592
+ throw err;
593
+ }
594
+ finally {
595
+ conn.release();
596
+ }
597
+ }
518
598
  async getSessionBinding(rootSessionId) {
519
599
  assertRootSessionIdShape(rootSessionId);
520
600
  const { rows } = await this.db.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ?`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1`), [rootSessionId]);
@@ -59,6 +59,8 @@ export const DEVICE_REJECT_TO_EXEC_ERROR = {
59
59
  "device.revoked": "auth_failed",
60
60
  "device.enrollment_invalid": "auth_failed",
61
61
  "not_found.device": "auth_failed",
62
+ "device.rebind_rev_conflict": "target_unavailable",
63
+ "device.rebind_active_run": "target_unavailable",
62
64
  "limit.rate_exceeded": "target_unavailable",
63
65
  "draining": "target_unavailable",
64
66
  };
@@ -73,6 +75,8 @@ export const DEVICE_REJECT_TO_FILE_ERROR = {
73
75
  "device.revoked": "permission_denied",
74
76
  "device.enrollment_invalid": "permission_denied",
75
77
  "not_found.device": "permission_denied",
78
+ "device.rebind_rev_conflict": "transport_lost",
79
+ "device.rebind_active_run": "transport_lost",
76
80
  "limit.rate_exceeded": "transport_lost",
77
81
  "draining": "transport_lost",
78
82
  };
@@ -87,6 +91,8 @@ const DEVICE_REJECT_TO_REMOTE_ERROR = {
87
91
  "device.revoked": "auth_failed",
88
92
  "device.enrollment_invalid": "auth_failed",
89
93
  "not_found.device": "auth_failed",
94
+ "device.rebind_rev_conflict": "connect_failed",
95
+ "device.rebind_active_run": "connect_failed",
90
96
  "limit.rate_exceeded": "connect_failed",
91
97
  "draining": "connect_failed",
92
98
  };
@@ -175,7 +181,7 @@ export class RemoteDeviceExecutionEnv {
175
181
  if (admitted.value.deviceId !== this.cfg.deviceId) {
176
182
  return {
177
183
  ok: false,
178
- error: new RemoteExecutionError("auth_failed", `[${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] this session is now bound to a different device than the one this environment was minted for — refusing to silently re-target (device lane identity, v2 §4.5)`),
184
+ error: new RemoteExecutionError("auth_failed", `[${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] this session is now bound to a different device (${admitted.value.deviceId}) than the one this environment was minted for — refusing to silently re-target; re-initiate the work on the currently bound device (device lane identity, v2 §4.5/§3)`),
179
185
  };
180
186
  }
181
187
  {
@@ -239,6 +245,7 @@ export class RemoteDeviceExecutionEnv {
239
245
  const input = {
240
246
  rootSessionId: this.cfg.rootSessionId,
241
247
  requester: this.cfg.owner,
248
+ expectedDeviceId: this.cfg.deviceId,
242
249
  kind,
243
250
  args,
244
251
  timeoutMs: opts.timeoutMs,
@@ -56,4 +56,16 @@ export declare const MYSQL_ER_BAD_FIELD_CODE = "ER_BAD_FIELD_ERROR";
56
56
  * MySQL 侧两个归因键都认(mysql2 的 `code`/`errno` 是同一件事的两种写法),与 dup-key 那条同姿势。
57
57
  */
58
58
  export declare function isMissingColumnError(err: unknown, dialect: SqlDialect): boolean;
59
+ /** PostgreSQL `check_violation` 的 SQLSTATE。 */
60
+ export declare const PG_CHECK_VIOLATION_SQLSTATE = "23514";
61
+ /** MySQL/TiDB `ER_CHECK_CONSTRAINT_VIOLATED` 的数字 errno(TiDB 仅在 `tidb_enable_check_constraint=ON` 时可达)。 */
62
+ export declare const MYSQL_ER_CHECK_VIOLATED_ERRNO = 3819;
63
+ /** MySQL/TiDB `ER_CHECK_CONSTRAINT_VIOLATED` 的字符串错误码。 */
64
+ export declare const MYSQL_ER_CHECK_VIOLATED_CODE = "ER_CHECK_CONSTRAINT_VIOLATED";
65
+ /**
66
+ * 「这个错误**是**『CHECK 约束被拒』吗」—— O4 `device_audit` 事件闭集升级探针
67
+ * (`assertDeviceAuditRebindEventSchema`)的唯一判据。收窄纪律与 {@link isMissingColumnError} 逐字同源:
68
+ * 它守的是一句**指向手工 DDL 的**错误指路,认不出的一律不是 CHECK 拒(原始错误原样上抛)。
69
+ */
70
+ export declare function isCheckViolationError(err: unknown, dialect: SqlDialect): boolean;
59
71
  //# sourceMappingURL=sql-errors.d.ts.map
@@ -25,4 +25,14 @@ export function isMissingColumnError(err, dialect) {
25
25
  return keyOf(err, "code") === PG_UNDEFINED_COLUMN_SQLSTATE;
26
26
  return keyOf(err, "errno") === MYSQL_ER_BAD_FIELD_ERRNO || keyOf(err, "code") === MYSQL_ER_BAD_FIELD_CODE;
27
27
  }
28
+ export const PG_CHECK_VIOLATION_SQLSTATE = "23514";
29
+ export const MYSQL_ER_CHECK_VIOLATED_ERRNO = 3819;
30
+ export const MYSQL_ER_CHECK_VIOLATED_CODE = "ER_CHECK_CONSTRAINT_VIOLATED";
31
+ export function isCheckViolationError(err, dialect) {
32
+ if (err === null || typeof err !== "object")
33
+ return false;
34
+ if (dialect === "pg")
35
+ return keyOf(err, "code") === PG_CHECK_VIOLATION_SQLSTATE;
36
+ return keyOf(err, "errno") === MYSQL_ER_CHECK_VIOLATED_ERRNO || keyOf(err, "code") === MYSQL_ER_CHECK_VIOLATED_CODE;
37
+ }
28
38
  //# sourceMappingURL=sql-errors.js.map