/** * 流内审批协议([ref] v3.1 §3.0/§3.1/§3.2)持久层 —— [ref] 车1,`ApprovalAskStore` 双方言实现 * (`TiDBApprovalAskStore` / `PgApprovalAskStore`,SINGLE-FILE DUAL-DIALECT,[ref] A12 形——照 * checkpoint-store-sql.ts:一个 `SqlApprovalAskStore` 类接 `SqlDriver`,两个薄 ctor 子类落方言绑定)。 * * 两张表:`approval_ask`(每个流内工具调用一行,ask 级 6 态状态机)+ `approval_batch`(同一决策点的 * 兄弟 ask 共享一行,批级 4 态状态机,承载「N 个候选里最终只有一个中选走 gate」的 bind-once 语义)。 * 状态机真源 = `../approval-ask-machine.js`(纯函数,零 IO)。 * * ── CAS 铁则(design 定稿 §4,全店统一,写在这里防遗忘)──────────────────────────────────────────── * 1. 一切转移带 `WHERE state = `,赢 = affected 恰为 1。禁读-改-写两步(TOCTOU 窗口)。 * 2. 🔴 MySQL/TiDB `affectedRows` 坑:同值 UPDATE(SET 的新值与当前列值逐字节相同)affectedRows=0—— * 这不是「行不存在」,是「驱动认为没有变化」。**所有** CAS UPDATE 因此必须带一个恒变列 * (`rev = rev + 1`),这样只要「行在且谓词命中」就必然 affected=1,不会被这个驱动怪癖 * 误判成「CAS 输了」。本文件每一条 CAS UPDATE 都带 `rev = rev + 1`。 * 3. 事务:走 `SqlDriver.connect()` → `conn.begin()` → 一串 `conn.query()` → `conn.commit()`,失败 * `conn.rollback()`(形照 image-bake-store-sql.ts 的 try/catch/finally 骨架)。事务动词只有一个; * 读语义(隔离级 / TiDB 悲观模式 / 事务内集合读必须锁读)的判据只有一处:{@link SqlTxConn.begin} * 的 `@contract txn.read-semantics`。本店每条 CAS 都是对已存在主键行的 `UPDATE … WHERE state=?` * (DML 对被更新行恒是 current read,与快照无关),败方读数则一律 `SELECT … FOR UPDATE`。 * * ── 方言差异(显式写在每个调用点,[ref] A12 doctrine)────────────────────────────────────────── * - `?` 占位符(位置序)vs `$n`(显式编号)——动态列清单(`transitionAsk`/`resolveProvisional`,列集合 * 依 patch 内容变化)靠 `ph(dialect, n)` 生成两种占位符文本,与 background-agent-store-sql.ts 的 * `updateIf` guard 占位符构造同精神(那里也是「列集合动态、占位符文本仍显式按方言生成」)。 * - `INSERT IGNORE` vs `ON CONFLICT … DO NOTHING`——ensureAsk 的幂等 upsert。 * - 撤卡名单回读:PG 用 `UPDATE … RETURNING ask_id` 单语句拿到名单;TiDB 无 RETURNING,同事务内 * 先 `SELECT` 后 `UPDATE`(设计定稿 §4 原话)。 * - JSON 列(`card_json`/`decision_actor`)走 `dialectJsonEncoder`(TiDB verbatim `JSON.stringify`; * PG 走 `pgSafeJsonStringify` 深度 sanitize——这两列是「已帽已洗后的投影」内容面 blob,不是像 * checkpoint 的 `tool_input` 那样跨信任边界做 hash 绑定的执行面字节,故用有损而非无损信封)。 * * ── 施工偏离记录(设计定稿字面矛盾,未擅自改设计,如实记在这里 + 车1 最终汇报)────────────────────── * - deriveAskId/deriveBatchId 的分隔符:设计定稿 §5 代码片段字面写 `.join(' ')`(单空格),但同一段 * prose 明确说「分隔用 NUL 防注入拼接歧义」,且 DDL 注释(§3 ask_id 行)又写「用 ':' join」——三处 * 互相矛盾。本实现(approval-ask-machine.ts)取 prose 的安全论证(NUL,唯一在这些不透明 id 部件里 * 不会自然出现的分隔字节)为准,未取字面的空格/冒号。 */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import type { PgQueryFn } from "./pg-query.js"; import { type SqlDriver, type SqlDialect } from "./sql-driver.js"; import { type AskState, type BatchState } from "../approval-ask-machine.js"; import { type IndexSpec } from "./ensure-index.js"; export type AskDecision = "approve" | "deny"; export interface AskRow { askId: string; taskId: string; sourceTaskId: string; sessionId: string; /** 租户 scope——哨兵归一在消费层(车2+),店里存原值(设计定稿 §3)。 */ owner: string | null; batchId: string; toolCallId: string; /** 车3 §3.2′ 换轴:数值 `leg` → `legKey`(= `sha256(resume token)` 的 hex,首腿空串)。语义与派生 * 论证的唯一真源 = `approval-ask-machine.ts` 的 `deriveAskId` 头注,这里不复述。 */ legKey: string; parentToolCallId: string | null; /** * 🔴 车5 §9 C2:铸卡时呈给人看的那份 input 的**服务端摘要**(`AskRequest.boundInputHash`,core 侧已在场)。 * 与下面 PARKED 坐标里的 `gateBoundInputHash` 是**两件不同的东西**,别混: * - `boundInputHash`(本列)= ask **铸行时**的入参摘要,一次写定永不改;对账收敛器判据 1 用它跟 * checkpoint 行的同名列做**硬相等**——它是**身份三元组之外的第二道等式**,身份本身是 * (`sourceTaskId`, `toolCallId`, 因果下界)三维([ref] 件1 换轴,判据属主 = `approval-reconciler.ts` * 的 `classifyGateMatch`;本注上一版写的「identity 四元组」是换轴前的旧口径,`sessionId` 从来不是 * 内存判据维)。任一侧缺席 ⇒ 判据 1 **不命中**;归因看走到哪一层:身份先判(候选集非空却没有同身份 * 的一条 ⇒ `identity_miss`),身份这层还够得着时缺席才记 `single_mint`(禁「能取到时才比」的可选谓词 * ——同 session 内 toolCallId 会被网关重用,只靠身份会把旧 ask PARK 到别人的 resume 坐标上,而 * PARKED 是不可回滚的终态)。 * - `gateBoundInputHash`(下面)= 真 **PARK 成功那一刻**从 checkpoint 抄回来的坐标之一,bindBatch 才写。 * 本车只落店面承载(列 + 行形 + 读写),铸行调用点的供值归车2/3b。 */ boundInputHash: string | null; state: AskState; /** §3.0 对账三约束②:超时不得发布终态 denial;provisional 终态可被 `resolveProvisional` 版本化收敛。 */ provisional: boolean; rev: number; decision: AskDecision | null; /** 171 ActorAssertion JSON(车4 才消费,列先在——JSON.parse 后的裸值,类型未知)。 */ decisionActor: unknown | null; decisionNote: string | null; /** * [ref]([ref] / [ref] 收口):人批准时带的 **ctrl+g 编辑实参**(wire `updatedInput`),与决议**同一条 * UPDATE** 原子落列 `updated_input`。语义三态,读法**严格**: * · `undefined`(列 NULL)= 这次决议**没带**编辑 —— 回放腿交裸 `true`,core 按原始实参执行; * · 任何其它值(**含 `null`**,列存 JSON 文本 `"null"`)= 带了编辑的**字面值**,回放腿交 * `{ allow: true, updatedInput }`,core 按它执行。「编辑成 null」与「没编辑」在 wire 上是两回事 * (`hasUpdatedInput` 读的是键在场),店面照此分家;消费端**禁**用 `?? ` / `== null` 折叠。 * · deny 行恒 `undefined`:deny 带 `updatedInput` 的成文语义是「宽收后忽略」,写口不落。 * * 为什么必须是行上的一列(census 第 31 行 P-DEBT 的终局件):行派生的 approve 回放(幂等重入 / 收敛臂 / * 店报错臂 / 迟到受理 / 悬挂轮询 / HTTP 观察者,六站点)此前只能交裸 `true` ⇒ core 的 * `d.updatedInput === undefined` 分支落回**原始未改写**实参执行 —— 人批的是改写后的命令、真跑的是原 * 命令 = 执行面宽于人所批准。列在,行即真源,六站点从行上读同一份编辑。 * * 🔴 LONGTEXT/TEXT 存 JSON 文本(与同表 `decision_actor` / `card_json` 同款,`dialectJsonEncoder` 编码、 * `parseJsonColumn` 读回):编辑实参是整只工具入参,与 `card_json` 同量级,VARCHAR 会造静默截断面。 */ updatedInput?: unknown; decidedAtMs: number | null; deniedReason: string | null; /** PARKED 坐标三件。 */ gateToken: string | null; gateBoundCallId: string | null; gateBoundInputHash: string | null; /** * 🔴 车4 §12-E:**回决专用**幂等键(响应四则①),`decideAsk` 赢 CAS 时落列 —— **不是** ask 创建键。 * (车1 原注把它写成 ensureAsk 时铸的创建键,那是错的:现网 `ensureAsk` 调用点恒不传,列恒 NULL, * 而端点的重试回放要认的是「同一次回决的重试」。双写者收口 ⇒ `NewAskRow` 的同名字段已摘除。) * UNIQUE `(task_id, idempotency_key)`(NULL 可重复,双库皆然)——**带 task 维**是租户/存在性门: * 全局唯一会让 A 租户用别人的 key 撞库探测到「这个 key 在别处存在」,也会让两 task 的正常键互撞。 */ idempotencyKey: string | null; /** 重放腿的帧载荷(args 已帽已洗后的投影)。 */ cardJson: unknown; /** * [ref] —— **规则车道素材**两列之一:这只 ask 被裁决的**命令**(已过基础脱敏,见 `tool-approval.ts` * 的落盘点)。`null` = 这只 ask 从没进过规则车道(治理档 / 无属主 / 引擎没铸候选 / args 读不出命令), * 或它由**加这两列之前**的构建落的行。 * * 🔴 **为什么必须是行上的一列,而不是从 `card_json` 里挖**:卡上有的是 `ruleOffers[].command` * ——那是**每条候选**的去规范化命令(batch 臂上是**分段**),不是被裁决的那一条整命令;而 * `persistCardRule` 的覆盖门与候选重铸吃的正是后者。拿分段当整命令喂进去 = 让一条**我们自己切过** * 的命令去决定一条常驻放行规则的形状。 * * 🔴 **LONGTEXT/TEXT,不是 VARCHAR**(与 `permission_rule_approval.command` 同款同理由):命令行没有 * 入口上限,VARCHAR 会造一个静默截断面,而截断过的命令与原命令是两条不同的命令。 */ ruleCommand: string | null; /** * [ref] —— 规则车道素材两列之二:**授权发生地**的 project root(`ruleScopeRootFor` 在**铸卡时**咨询 * 的那一份,[ref] F-1 的快照语义)。`null` = 解析不出 root ⇒ 兑付时**不铸 scope**,core 落 global * (= live 腿逐字同形;绝不在坐标系不明时编一个 root)。 */ ruleScopeRoot: string | null; schemaVersion: number; /** 一次铸定永不重算。 */ expiresAtMs: number; createdAtMs: number; updatedAtMs: number; } /** `ensureAsk` 的入参——初始态恒 STREAM_PENDING/provisional=false/rev=0,故不在此列。 */ export interface NewAskRow { askId: string; taskId: string; sourceTaskId: string; sessionId: string; owner?: string | null; batchId: string; toolCallId: string; legKey: string; parentToolCallId?: string | null; /** 车5 §9 C2 的对账 join 键(语义见 `AskRow.boundInputHash`)。缺席 ⇒ 该行结构上永不满足判据 1。 */ boundInputHash?: string | null; cardJson: unknown; /** [ref] 规则车道素材(语义与「为什么是行上的列」见 {@link AskRow.ruleCommand})。缺席 ⇒ 列 NULL ⇒ * 这条行上的迟到决议对 `persistRule` 响亮拒(`rule_material_absent`),绝不猜命令。 */ ruleCommand?: string | null; /** [ref] 授权发生地(语义见 {@link AskRow.ruleScopeRoot})。 */ ruleScopeRoot?: string | null; schemaVersion: number; expiresAtMs: number; createdAtMs: number; } /** * `ensureAsk` 的产出 —— 行 **+ 这一次调用到底插没插**([ref] 件2)。 * * 🔴 为什么判别位必须由**店**给:`ensureAsk` 是幂等 upsert(`askId` 是确定性派生,重试 / failover / * 闭包再入天然指向同一行),所以「拿到一条 STREAM_PENDING 行」有两种成因 —— 本次插的,或幂等命中了 * **别人正持有**的那条活行。两者的收尾权限完全相反:前者本次可以收(放弃时把孤儿行 VOID 掉),后者 * 一个字都不许动(动了就是把真属主正在等的那张卡作废)。调用侧此前只能拿 `createdAtMs === 本次传入值` * 去**猜**归属,而那把尺在同一毫秒的两次并发插入上会给出假阳性(旧注里如实记着的残余)。 * `INSERT IGNORE` / `ON CONFLICT DO NOTHING` 的 affected 行数是引擎对同一个问题的**权威**回答, * 两方言都有;把它如实带出来,猜就退休了。 * * `inserted: true` ⇒ 这条行是本次调用写下的(可收尾);`false` ⇒ 幂等命中既有行(只读,不许收尾)。 */ export interface EnsureAskResult { row: AskRow; inserted: boolean; } /** `transitionAsk`/`resolveProvisional` 的可选补丁——只有出现的字段才落 SQL(未出现 = 该列不变), * `updatedAtMs` 恒必填(调用方是未来的协调器,时间戳由它按事件时钟决定,店不偷偷用 `Date.now()`)。 */ export interface AskTransitionPatch { decision?: AskDecision | null; decisionActor?: unknown | null; decisionNote?: string | null; decidedAtMs?: number | null; deniedReason?: string | null; gateToken?: string | null; gateBoundCallId?: string | null; gateBoundInputHash?: string | null; provisional?: boolean; updatedAtMs: number; } export interface DecideAskInput { decision: AskDecision; decisionActor?: unknown; decisionNote?: string | null; /** 车4 §12-E:回决幂等键,赢 CAS 时随决议一起落列(见 `AskRow.idempotencyKey`)。缺席 ⇒ 列保持 NULL。 */ idempotencyKey?: string | null; /** [ref]:ctrl+g 编辑实参,与决议同一条 UPDATE 落列(语义三态见 {@link AskRow.updatedInput})。 * 缺席 / `undefined` ⇒ 列 NULL(没带编辑);**`null` 是值**(编辑成 null),照存 JSON `"null"`。 * 调用方纪律:deny 不传(店不替调用方判决议方向,写什么存什么)。 */ updatedInput?: unknown; } /** * `idempotency_conflict`(车4 §12-E):同一 (task_id, idempotency_key) 已被**另一条** ask 的回决占用。 * 🔴 方言错误的判别**收在店内**(TiDB `ER_DUP_ENTRY`/errno 1062 与 PG SQLSTATE 23505),端点只认这个 * typed 臂 —— 否则每个消费层都得自己认两套驱动的错误对象形状,方言就漏出了持久层。 * `row` = 判定时刻读到的本 ask 行(可能为 null:并发删)。 */ export type DecideResult = { ok: true; row: AskRow; } | { ok: false; reason: "batch_closed" | "ask_not_pending" | "idempotency_conflict"; row: AskRow | null; }; /** * 幂等键的入店卫生门(属主兜底轮 F2,三 twin 同判——SQL 双方言与 InMemory 都从这里过)。 * * 🔴 为什么必须在店门口拒而不是「存什么比什么」:TiDB/MySQL 的 VARCHAR 等值与 UNIQUE 检查带 * PAD SPACE 语义(utf8mb4_bin 也一样)——`'retry'` 与 `'retry '` 在那侧是**同一个键**,而 PG 与 * InMemory 的严格字符串比较把它们当两个键 ⇒ 同一份调用方代码在不同后端拿到不同的 * `idempotency_conflict`/回放结果。键是调用方铸的 opaque 值,首尾空白没有任何合法语义, * 在边界上 fail-loud 拒掉,方言分歧面整个消失(比把列改 VARBINARY 更窄、且对三形同时成立)。 */ /** BIGINT 毫秒列的整数守卫(两 twin 同一只):`expires_at_ms` / `created_at_ms` / `updated_at_ms` 落库前必须是 * 安全整数。带小数的墙钟(`Date.now() + performance.now()` 派生窗)在 PG 上被 `bigint` 拒收、在 MySQL 协议上被 * 隐式截断 —— 两方言不同答;这里改成两方言同一句 TypeError(响亮,点名字段与值),铸点侧(`tool-approval.ts` * 的 deadline 铸点)负责给整数,本守卫只拦漏网。 */ export declare function assertIntegralEpochMs(field: string, value: number): void; export declare function assertIdempotencyKeyShape(key: string): void; /** * [ref] 车3 刀 3b(§14.2 遗留记账):两条**重放读口**被调用方的 deadline 掐断时抛的具名错误。 * * 具名类而不是「按 message 文本判别」:错误文案是给人看的,拿它当控制流 = 上游改一句人话下游静默失效 * (#90 工程规范 v3 门②)。调用方(开流 preamble)对它的处置与其余读错误一样 —— fail-open、零卡、一次 * warn —— 所以它今天没有专门的分支;类在这里是为了让**将来**想分「超时 vs 店真报错」的消费点有得分。 */ export declare class ApprovalAskReadAbortedError extends Error { constructor(where: string); } export interface ExpireResult { won: boolean; voidedSiblings: string[]; } /** * [ref](DEBTS [ref]-R1 终局;core [ref] §4.4「@server 对表键形」,黑板 [ref] 认领):终局 claim 的 * 意图。ask 行的 TERMINAL 对儿只有两员 —— `expire`(窗到期 / emit 全灭共用:STREAM_PENDING→PARKING + * 批转投递面 + 兄弟撤卡,与 `expireAsk` 同一台 CAS 机器)与 `cancel`(取消臂:STREAM_PENDING→VOID)。 * **人答臂(decide)不入本枚举**:`decideAsk` 带决议载荷与回决幂等键,其失败臂本就携行读数(384 §4.4 * 表「decideAsk(人答臂)」格的「视现有 CAS 形」裁定)——三 verb 的「一台机器义务」(384 §4.3-2)由共享 * WHERE 谓词 {@link pendingClaimWhere} 兑现,不是把三个不同 verb 塞进一个函数。 */ export type AskTerminalClaimIntent = "expire" | "cancel"; /** * 败方读数:行上终局相关列的一致投影(status 词表 = 行自己的 `AskState`,零新值域 —— 384 §4.2 的 * 「reuse the row's own vocabulary」)。 * · `state:"absent"` = 行不在,**或 scope(batchId)不匹配** —— 错 scope 的 claim 必须输且不得读出 * 他 scope 行的真相(读谓词与 CAS 谓词是同一份 scope WHERE;多租户隔离,384 §4.2 absent 臂原话)。 * · **绝无 `STREAM_PENDING` 臂**:server 的 ask 店 CAS 谓词是 status 门(STREAM_PENDING→终态),无 * rev/OCC 期望语义 ⇒ core 表的 `pending`(rev 失配)臂在本形结构不可达,如实缺席不硬造([ref] * 补充确认①)。输 = 行在 claim 线性化点上已非 pending,而非 pending 态在状态机上**无回边** * (`approval-ask-machine.ts`,本域无 reopen)⇒ 败方读数恒 ≥ 那一刻;「CAS 时行不在、读时刚被 * ensureAsk 插入」的编排错序形按线性化点真相投影为 `absent`。 * · 本域无 reopen ⇒ DECIDED 恒不回 pending,败方读数在本域退化为永恒真相;消费仍按快照律 * (384 §4.3-5)写,不依赖此强化([ref] 补充确认②)。 */ export type AskTerminalClaimCurrent = { state: "absent"; } | { state: Exclude; rev: number; decision: AskDecision | null; /** [ref] 三态读法照 {@link AskRow.updatedInput}:键缺席 = 没带编辑;`null` 是编辑成 null 的值。 */ updatedInput?: unknown; }; /** * 单次原子往返的答案(语义律的唯一规范陈述点 = core `checkpoint-store.ts` `claimTerminal` 契约 JSDoc, * [ref] §4.3;本类型是其 ask 行同位物,不复述七律,只记 server 侧特有形): * · 赢 ⇒ `{claimed:true}`(expire 意图随赢携兄弟撤卡名单,与 `expireAsk` 同形); * · 输 ⇒ **同一原子单元**携行真相(律 1)—— 消费方拿到 `claimed:false` 后永不需要第二次读来行动; * · reject(throw / 超墙钟)⇒ 什么也不证明(律 3 K2)——调用方不得按本地意图宣告终局;**迟到的 * settle 同样自足**:deadline 过后才回来的 `claimed:false` 直接携真决议,臂按真决议 settle 零补读。 * server 三臂全为**幂等消费**(按真决议 settle,谁写的无关,律 4)——本店无职责性消费面。 */ export type AskTerminalClaimOutcome = { claimed: true; voidedSiblings: string[]; } | { claimed: false; current: AskTerminalClaimCurrent; }; /** * 败方读数的投影(三 twin 共用一份,别各自挑列):`null` / 错 scope 由调用方先折成 `null` 传入; * `STREAM_PENDING` 折 `absent` 的理由见 {@link AskTerminalClaimCurrent} 顶注(线性化点真相)。 */ export declare function projectAskTerminalClaimTruth(row: AskRow | null): AskTerminalClaimCurrent; export interface BindGateInput { gateToken: string; gateBoundCallId?: string | null; gateBoundInputHash?: string | null; } /** * `bindBatch` 的判别式返回形(车5 §8 A-4 + §9 C3)。原先的 `Promise` 把两件**处置完全相反**的 * 失败压成同一个 `false`:批 `ABORTED`(run 被取消 ⇒ 收敛器该走 VOID 臂)与批已 `ROUTING_BOUND` * (兄弟先绑 ⇒ 本行该收 VOID/续判,绝不是失败)。调用方拿不到批态就只能猜,于是: * - `ok: true` 带 `voidedSiblings` —— 被本次绑定连坐 VOID 的 PARKING 兄弟名单(撤卡帧的 `askIds`); * 读法与 `expireAsk` 同形(PG `RETURNING` / TiDB 同事务先 `SELECT` 后 `UPDATE`)。 * - `ok: false` 带 `batchState` —— 批行的**真实**当前态;`"MISSING"` = 批行不存在(错配 batchId 的 * 编程错误形)。`boundAskId` 只在批已绑定时在场(中选者是谁)。 */ export type BindResult = { ok: true; voidedSiblings: string[]; } | { ok: false; batchState: BatchState | "MISSING"; boundAskId?: string; }; export interface BatchRow { batchId: string; taskId: string; state: BatchState; boundAskId: string | null; rev: number; createdAtMs: number; updatedAtMs: number; } /** design 定稿 §4 的持久层接口。协调器接线(车2)、恢复扫描消费(车5)不在本车范围——本车只落这些方法。 */ export interface ApprovalAskStore { /** 幂等 upsert。返回**行 + 本次是否真插入**(判别位语义见 {@link EnsureAskResult})。 */ ensureAsk(row: NewAskRow): Promise; transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise; decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise; /** * [ref]:原子终局 claim —— 把「争终局」与「读真相」并成**一次**店往返(胜负判定与败方 `current` 读数 * 在同一原子单元内,语义见 {@link AskTerminalClaimOutcome})。[ref]-R1 的病根「CAS 与读真相是两次店 * 往返」由本原语从机制上消灭:settle 的答案自足(赢 ⇒ 赢;输 ⇒ 携真相),迟到的答案同样自足。 * 律 2「一台机器义务」:`expireAsk` 在本方法之上实现(SQL 双方言)/ 本方法委托给 * `expireAsk`/`transitionAsk`(InMemory,单线程天然原子)——两向都成立的判据是**没有第二套谓词**。 * 方法**必备**(本仓自有三形全实现,无 core 那侧的 presence 探测问题)。 */ claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise; expireAsk(askId: string, batchId: string): Promise; bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise; abortBatch(batchId: string): Promise; /** * 🔴 车5 §9 C5:**纯 touch** 的 CAS——推 `updated_at_ms` + `rev`,**不改 state、不走状态机转移表**。 * 收敛器每轮扫过但未收敛的 PARKING 行靠它排到队尾(读口 `ORDER BY updated_at_ms ASC`),否则 200 条 * 长驻行会把批量上限占满、新行永远轮不到(队头堵塞)。 * * 为什么不能复用 `transitionAsk`:那条路先过 `canAskTransition`,而 `PARKING → PARKING` 是同态转移、 * 表里没有这条边(表把「同值自环」定义为非法,§6-11 钉住的正是这条)——所以队列轮转在状态机上无合法写口, * 只能另开一扇不碰 state 的门。 * * `expectedRev` 必带:两副本同扫时只允许**一个**推进,输者原样返回 false(自己那轮的判定已过期)。 * 🔴 配套纪律(同 §9 C5):orphan/adhoc 的 TTL 一律量 immutable 的 `createdAtMs`/`expiresAtMs`, * **绝不量 `updatedAtMs`**——它会被本方法每轮刷新,量它的 TTL 永不到期。 */ deferReconcile(askId: string, expectedState: AskState, expectedRev: number, nowMs: number): Promise; listByState(state: AskState, limit: number): Promise; /** `signal` 语义见 {@link ApprovalAskStore.listPendingBySession}(两条重放读口同款)。 */ listPendingByTask(taskId: string, signal?: AbortSignal): Promise; /** 车3 §5.1 的第二个重放读口:sync 腿每次开流都是**新 taskId**,它要接的未决卡来自「同一 * (owner, session) 的上一条腿 / 长命 bg 子代」(正是 `boundAsk` broker 的 `["session", owner, * sessionId]` 键域)——按 taskId 读永远读不到它们。 * * `owner` **必填**(不是 optional):重放面是**跨腿投递**,必须带租户门,不能靠调用方记得过滤 * (与 `respond` 的 owner gate 同姿势)。`null` = 无租户身份的部署形,匹配 `owner IS NULL` 的行 —— * SQL 的 `owner = NULL` 恒 UNKNOWN,所以两方言都按 owner 是否为 null 走两段不同的谓词文本, * 而不是塞一个会静默匹配零行的绑定参数。 * * 🔴 `signal`(车3 刀 3b,§14.2 遗留记账):**只有两条重放读口**带它 —— 它们是唯一一类「调用方有硬 * deadline、超时后结果彻底无用」的读(开流 preamble 的 2s 有界窗)。给写路径加 signal 是另一回事 * (中止一半的事务语义),不在此列。 * * ⚠️ **可达到的语义与残留(如实)**:本仓的 `SqlDriver`/`PgQueryFn` 都**没有取消 seam**(mysql2 要 * `connection.destroy()`、pg 要另开连接发 cancel request),所以 abort 做到的是:①已 abort 的调用 * **一条 SQL 都不发**;②在飞的调用**立刻**以 abort 拒绝、调用方不再被吊住。**做不到**的是让已经发出去 * 的那条查询在库上停下来 —— 它仍会跑完并占着它那个池位。因此 §14.2 记的「DB 挂死时重连风暴累积连接池 * 等待」只被**部分**缓解(不再叠加调用方侧的等待,池侧仍会堆)。真正的取消要 driver 级 seam,登记为 * 后续件。 */ listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise; getAsk(askId: string): Promise; /** 回决幂等回放的读口。🔴 `taskId` **必填**且是键的第一维——UNIQUE 已改 `(task_id, idempotency_key)` * (车4 §12-E),裸按 key 全局查在多租户库上既可能回**别人 task** 的行(存在性外泄),也可能在同 key * 合法共存于两 task 时任选一行返回(不确定)。租户门是读口自己的责任,同 `listPendingBySession`。 */ getByIdempotencyKey(taskId: string, key: string): Promise; resolveProvisional(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise; deleteByTask(taskId: string): Promise; /** 🔴 车1 追加(不在设计定稿 §4 的接口字面清单里):设计的 `ApprovalAskStore` 接口本身没有批读方法, * 但 §6 测试钉(3/6/7 条)要求断言批行的 state/boundAskId 变化——没有读口就无法验证 store 真做对了 * 它自己承诺的事。加一个纯观测方法(不改变任何写路径行为),SQL 双方言 + InMemory 三形都实现, * 供测试与未来的协调器/对账消费——同精神先例:background-agent-store-sql.ts 的 `listScopes` 也是 * 「核心接口之外、为了让分区可枚举而加的」扩展读口。 */ getBatch(batchId: string): Promise; } export declare const APPROVAL_ASK_TABLE = "approval_ask"; export declare const APPROVAL_BATCH_TABLE = "approval_batch"; /** * TiDB/MySQL-protocol 侧的两条建表语句,**抽成导出数组**(车3 刀 3a):`tidb-pool.ts` 的 * `SCHEMA_STATEMENTS` 用 `...TIDB_APPROVAL_ASK_STATEMENTS` 展开它们,于是这两张表随中央 * `ensureSchema` 一起在 **named-lock 的那条 `conn`** 上建 —— 把一个 pool 级 ensure 函数塞进 * `SCHEMA_STATEMENTS` 的执行循环里做不到这点(会绕开 DDL 串行化),所以真源必须是**语句文本**。 * 下面的 `ensureTiDBApprovalAskSchema` 保留为遍历这个数组的薄壳(集成测试直接调它建表, * 不必为了建两张表去拉起整套中央 schema)。 * * SCHEMA POLICY 同 `tidb-pool.ts` 头注:纯 CREATE,禁 ALTER 增量 seam,改列直接改这里 + 重建库 * (`docs/schema/baseline-mysql.sql` 是由这些文本机器录制出的**产物**,永不手改)。 * * ── 列宽的**测量依据**([ref] A10;U2 逐列取证在档)────────────────────────────────────────────────── * 本表家族原先一律 VARCHAR(255),是"先建表后想"的产物。按"每列的值到底由谁铸、有没有入口上限"重新裁: * * 收窄(有硬上限背书,证据即上限本身): * · `ask_id` / `batch_id` / `bound_ask_id` → 64:`deriveAskId`/`deriveBatchId` 恒是 sha256 hex 取前 * 64 字符(`approval-ask-machine.ts` deterministicId),按构造不可能更长。 * · `task_id` → 64:入口 `UUIDV7_RE`(`security.ts:134`)只放行 36 字符的规范 uuidv7;与兄弟列 * `task_run.task_id VARCHAR(64)` 同宽(此前 255 是本家族独有的偏离,无依据)。 * · `session_id` → 64:提交入口硬拒 >64(`http/server.ts` "sessionId must be at most 64 characters"), * 与 `session_meta`/`task_run` 同宽。 * · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts` 的同名导出常量, * 刻意不钉行号 —— 旧注写的 `security.ts:350` 已随该文件编辑漂走)硬拒更长者; * 全仓每一根 owner/scope 轴都是 190,此前 255 同样是无依据偏离。 * · `gate_token` → 120:它抄的是 checkpoint 的 token(core `mintCheckpointToken` = 16 字节 hex = 32 字符), * 取与**被抄那一列** `checkpoint.token VARCHAR(120)` 同宽 —— 同一个值在两张表上宽度必须一致, * 否则抄的那一步就是一道静默截断。512 是原先的 16 倍冗余。 * * 🔑 上面四条收窄之所以**不引入任何新暴露面**,靠的不是入口断言而是一条更硬的事实:同一个 session_id / * task_id / owner **早就**同时躺在 `session_meta.session_id VARCHAR(64)` / `task_run.task_id VARCHAR(64)` / * `task_run.owner VARCHAR(190)` 里。任何长到能撑爆本表新宽度的值,在写到那几张**更中心**的表时就已经 * 先炸了 —— 本表此前的 255 从来不是一道额外的安全余量,只是一处与全仓不一致的偏离。 * ⇒ 收窄的风险上界 = 0;真正要担心的是反过来:留着 255 会让人误以为这里可以存更长的 id。 * * 🔴 **刻意不收窄**(测量结果不支持,记在这里免得下一个人以为是漏了): * · `source_task_id` / `tool_call_id` / `parent_tool_call_id` / `gate_bound_call_id` —— 这四列的值是 * **模型/引擎铸的原始 id**,服务端在写行这一步**没有任何长度断言**(`approval-card.ts` 的 * `MAX_IDENT` 只裁了给人看的那份 card 投影,不是本列)。没有入口上限就收窄 = 把"存不下"这件事 * 推迟到 INSERT 才炸(MySQL 更糟:静默截断),换来的只是几十字节。要收窄,先补入口断言 * (照 `assertPrincipalShape`/`assertIdempotencyKeyShape` 的姿势),那是另一件事。 * · `bound_input_hash` / `gate_bound_input_hash` —— 按约定是 sha256 hex(64),但 `readBoundInputHash` * 只校验"非空字符串",没校长度/字形 ⇒ 同上,先有断言再谈收窄。 * · `idempotency_key` —— 255 **正是**入口断言的上限本身(zod `.max(255)` + 店内 `assertIdempotencyKeyShape` * 双执法),宽度与上限同源,恰好正确,动它反而制造截断面。 */ export declare const TIDB_APPROVAL_ASK_STATEMENTS: readonly string[]; /** {@link TIDB_APPROVAL_ASK_STATEMENTS} 的遍历壳——生产路径走 `tidb-pool.ts` 的中央 `ensureSchema` * (那里展开了同一份数组),本函数留给只需要这两张表的集成测试。 */ export declare function ensureTiDBApprovalAskSchema(pool: MySqlPool): Promise; /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgApprovalAskSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */ export declare const APPROVAL_ASK_INDEXES: readonly IndexSpec[]; export declare function ensurePgApprovalAskSchema(q: PgQueryFn): Promise; /** * [ref] 升级前置断言 —— **拒启**,不是 warn(形照 [ref] 的 `assertPermissionRuleApprovalSchema` / * [ref] 的 `assertToolResultProvenanceSchema` 两条先例)。 * * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来 * 的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `approval_ask` 是空操作 —— 本车新加的 * `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `ASK_COLS` 的每一条读 * **无条件**引用它们。后果不是「少一个新功能」,是**整条流内审批协议**在那台机器上是坏的: * · 每一只 ask 的 `ensureAsk` 撞 unknown column ⇒ 被 `noteStoreError` 吞掉 ⇒ 整只 ask 退回 park 路由 * (卡不落库、`GET /v1/approvals` 的重放面恒空、迟到决议腿恒 404); * · 而 `/v1/capabilities` 的 `streamApproval` 仍然报 active ⇒「says yes ⟺ route works」整句为假。 * 没有这道断言,运维得不到**任何**「这次升级必须重建表」的信号 —— 正是 [ref]「禁静默降级」要挡的形。 * * 判据 = **能力探测**(恒空的 `WHERE 1=0` 读)+ **方言的缺列错误码**(判据属主 `sql-errors.ts` 的 * {@link isMissingColumnError});其余错误**原样 rethrow**,一个字都不加工 —— 一句破坏性的 * 「去 DROP TABLE」指路比它要挡的缺陷更贵([ref] 那两轮收窄的结论逐字继承)。 * * 🔴 位置与两条先例同款:**不在** `ensureSchema` 里(那条通道的契约是「只发 CREATE」,有运行时门看着), * 放在 DDL 之后、任何路由装配之前;且**只在流内审批协议真会上场时**跑(判据 = 与协调器同一个符号 * `resolveStreamApprovalGate`,见调用点)——协议不上场的部署根本不碰这张表,为它拒启是纯误伤。 */ export declare function assertApprovalAskRuleMaterialSchema(query: (sql: string) => Promise<{ rows: Record[]; }>, dialect: SqlDialect): Promise; /** 双方言 `ApprovalAskStore`。见文件头注的 CAS 铁则 + 方言差异清单。 */ export declare class SqlApprovalAskStore implements ApprovalAskStore { private readonly db; constructor(db: SqlDriver); private q; private json; /** 唯一键冲突判别 —— 判据属主 = `sql-errors.ts`([ref] P1-①)。`decideAsk` 用它把 `(task_id, * idempotency_key)` 撞键翻成 typed 的 `idempotency_conflict` 臂,不让驱动错误对象漏给消费层; * 旧形 tidb 臂只认 errno,只带 `code` 的驱动错误会漏判成"真障碍"。 */ private isDupKey; /** * 🔴 失败臂的回读**必须走当前这条 `conn`**,不许调 `this.getAsk`/`this.getBatch`(codex 复审 F1, * 2026-08-06 真缺陷,池大小=1 的钉上实测挂死 25s)。原因:那两个读口走**池级** `this.db.query`,而 * 此刻本方法还攥着自己的事务连接(要到 `finally` 才 release)——于是「回读在等一条新连接,而唯一 * 那条连接要等回读返回才释放」= 确定性死锁。池大于 1 时它退化成「同时走到失败臂的并发数 ≥ 池大小 * 就整池饿死」,是同一个缺陷的更难复现形,不是另一个问题。 * * 调用点纪律:**先 `conn.rollback()` 再调这两个**。回滚之后连接回到 autocommit,这条 SELECT 自成 * 一个事务 ⇒ 读到的是最新已提交视图(正是失败臂要如实上报的东西),不受本事务快照的影响——两方言 * 在这一点上同义,不必再纠结 InnoDB 读视图与 TiDB start_ts 的差别。 */ private getAskOn; /** {@link getAskOn} 的批侧同形(同一条纪律:失败臂回读走本连接)。 */ private getBatchOn; ensureAsk(row: NewAskRow): Promise; transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise; decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise; /** * [ref] 原子终局 claim(接口契约见 {@link ApprovalAskStore.claimTerminal};类型语义见 * {@link AskTerminalClaimOutcome})。expire 意图 = 既有 `expireAsk` 事务**原体**(步⓪-③ 逐字保留, * `expireAsk` 现在是本方法的薄投影 —— 律 2 的「在 claimTerminal 之上实现」半场);cancel 意图 = * 取消臂的 STREAM_PENDING→VOID(此前走通用 `transitionAsk`,无败方读数)。 * * **原子性证明(两方言各一句,红先钉在 db-integration 的 [ref] 组)**: * · 败方读数是**同一事务内**的 `SELECT … FOR UPDATE` —— TiDB 侧的悲观锁定读是 CURRENT read,读到的是 * 并发赢家**已提交**的最新版本(乐观模式下 FOR UPDATE 读 start_ts 快照,会把赢家终局读旧 —— 正是 * bindBatch 失败臂注记的那类方言分歧)。悲观模式**不赌部署的 `tidb_txn_mode` 缺省**:它是连接初始化 * 的结构保证,设不上就拒启({@link SqlTxConn.begin} 的 @contract txn.read-semantics)。PG 侧 * `BEGIN` = READ COMMITTED,FOR UPDATE 本就等待并返回最新已提交版本。 * · CAS 输(affected=0)⇒ 行在 UPDATE 那一刻已非 STREAM_PENDING(或不在/错 scope),而非 pending 态 * 在状态机上无回边 ⇒ 锁定读读到的恒 ≥ 线性化点,且 DECIDED 决议不可变 —— 答案自足。 * · 本事务在输臂上除批行 rev 自增外零写,rollback 无痕。 */ claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise; /** [ref] 起 = {@link claimTerminal}(expire 意图)的薄投影 —— 律 2「在 claimTerminal 之上实现」的 * SQL 半场落笔处:谓词/事务/兄弟撤卡全在 claim 里,这里零第二套写路径。败方读数按既有返回形丢弃 * (`ExpireResult` 不携真相 —— 要真相的调用方直接用 claimTerminal;对账收敛器的 orphan 腿输了本就 * 只需「不赢」这一位)。 */ expireAsk(askId: string, batchId: string): Promise; bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise; abortBatch(batchId: string): Promise; /** * 🔴 排序键是 `updated_at_ms`(车5 §8 D-4;codex 三轮补抓:原先按 `created_at_ms` 排,`deferReconcile` * 就是**空转**的)。这条读口与 `deferReconcile` 是一对:收敛器每轮取前 `limit` 条,判不出终局的行用 * `deferReconcile` 推 `updated_at_ms` 把自己排到队尾,下一轮新行才轮得到。若这里仍按建行时间排, * 推 `updated_at_ms` 对顺序毫无影响 ⇒ 一旦 `limit` 被若干长驻 PARKING 行占满,每轮扫到的永远是同一批, * 后来的审批被无限饿死(§5 旋钮那句「超出下轮接着扫」也就不成立)。 * 次级键 `created_at_ms` 只为**确定性**:同毫秒的行不至于在两次扫描间乱序(分页/限流下的稳定序)。 * * 🔴 配套纪律(§9 C5):任何 TTL 判据一律量 immutable 的 `createdAtMs`/`expiresAtMs`,**绝不量 * `updatedAtMs`**——它被队列轮转每轮刷新,量它的 TTL 永不到期。 */ listByState(state: AskState, limit: number): Promise; listPendingByTask(taskId: string, signal?: AbortSignal): Promise; listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise; getAsk(askId: string): Promise; getByIdempotencyKey(taskId: string, key: string): Promise; deferReconcile(askId: string, expectedState: AskState, expectedRev: number, nowMs: number): Promise; resolveProvisional(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise; deleteByTask(taskId: string): Promise; getBatch(batchId: string): Promise; } /** MySQL-protocol(TiDB)绑定。 */ export declare class TiDBApprovalAskStore extends SqlApprovalAskStore { constructor(pool: MySqlPool); } /** PostgreSQL 绑定。 */ export declare class PgApprovalAskStore extends SqlApprovalAskStore { constructor(pool: PgPool); } //# sourceMappingURL=approval-ask-store-sql.d.ts.map