@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.
- package/dist/approval-ask-audit-store.d.ts +129 -0
- package/dist/approval-ask-audit-store.js +284 -0
- package/dist/approval-card.d.ts +18 -6
- package/dist/approval-card.js +2 -2
- package/dist/approval-reconciler.d.ts +2 -1
- package/dist/approval-reconciler.js +1 -1
- package/dist/boot/coordinators.d.ts +2 -0
- package/dist/boot/coordinators.js +18 -1
- package/dist/boot/reapers.d.ts +2 -1
- package/dist/boot/stores.js +8 -1
- package/dist/device-store.d.ts +66 -2
- package/dist/device-store.js +35 -0
- package/dist/device-ws-hub.d.ts +8 -0
- package/dist/device-ws-hub.js +6 -0
- package/dist/http/routes/approvals-assistant.js +1 -0
- package/dist/http/routes/devices.d.ts +64 -0
- package/dist/http/routes/devices.js +173 -0
- package/dist/http/server.d.ts +14 -0
- package/dist/http/server.js +13 -2
- package/dist/main.js +3 -1
- package/dist/observability/fail-open.d.ts +7 -3
- package/dist/observability/fail-open.js +7 -3
- package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
- package/dist/plugins/approval-ask-store-memory.js +20 -3
- package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
- package/dist/plugins/approval-ask-store-sql.js +41 -10
- package/dist/plugins/device-store-sql.d.ts +38 -1
- package/dist/plugins/device-store-sql.js +82 -2
- package/dist/plugins/remote-env-device.js +8 -1
- package/dist/plugins/sql-errors.d.ts +12 -0
- package/dist/plugins/sql-errors.js +10 -0
- package/dist/runs.js +1 -0
- package/dist/tool-approval.d.ts +54 -23
- package/dist/tool-approval.js +218 -127
- package/dist/trace/ledger-events.d.ts +9 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
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
|