import { z } from "zod"; import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleCandidate, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core"; import type { Pool as MySqlPool } from "mysql2/promise"; import type { PgQueryFn } from "./pg-query.js"; import { type SqlDriver } from "./sql-driver.js"; import { type IndexSpec } from "./ensure-index.js"; /** * 一只 durable 分区的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。 * * 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是 * 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法 * 就该当场判 `undefined`,而不是在调用 `readRaw()` 时炸一个 `not a function`。 * * 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言, * 也是三份会各自漂的形状假设。 */ export declare function writerOfSqlRuleStore(store: DurableRulePartition): PermissionRuleWriter | undefined; /** * 382P1(core 7.0.0 [ref] §4.3)—— 这是 **DURABLE 二员面**的 at-rest 形:`{global, project-with-root}`, * 与 core `isValidDurableScope` 逐格等价(`test/permission-rule-durable-face-pin.test.ts` 探针表钉住)。 * `RuleScope` 联合自 v7 起有第三员 `session`,但它**结构上**不许进持久店(家在会话 overlay,随会话死): * at-rest 解码不认它 ⇒ 一条被外部写进来的 session 行整桶拒读(fail-closed),写臂另由 core 的 * `assertWriteDeltaScopeDurable` 在任何 DB 往返之前以典型码拒(见 `apply`)。consent 面(三员)归 * engine/cli 半场;本仓 wire 入口 `parseRuleScope` 只拼二员,所以审批记录的候选也用这张表(下方 * `RuleCandidateSchema`)——那是本仓自己的窄型收口,不是把两张表合成一张。 */ export declare const DurableRuleScopeSchema: z.ZodType; export declare const PERMISSION_RULE_TABLE = "permission_rule"; export declare const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval"; export declare const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket"; /** * MySQL-protocol(TiDB)侧的三条建表语句。真源在本文件(与 PG twin 并排,方言差异一眼可对); * `tidb-pool.ts` 的 `SCHEMA_STATEMENTS` 用 `...TIDB_PERMISSION_RULE_STATEMENTS` 展开,于是三张表跟着 * 中央 `ensureSchema` 在 **named-lock 的那条 conn** 上建(同 `TIDB_APPROVAL_ASK_STATEMENTS` 先例)。 * * 列宽依据([ref] A10 的「每列的值由谁铸、有没有入口上限」口径): * · `owner_key` / `record_id` / `ticket_id` / `approval_id` / `payload_hash` / `checksum` → 190: * 全是本仓自铸的键类值(sha256 hex 64 / uuid 36 / `sha256:` 前缀 71),190 是全仓键轴的统一宽度。 * · `principal` → 512:principal 入口上限是 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts`),这里给 * 名类宽度 512 是**上界宽于入口**的方向(绝不制造静默截断面)。 * · `actor` → 190:本 backend 自铸的副本身份(`sql-<12 hex>`),按构造 ≤ 20 字符。 * · 内容列一律 LONGTEXT / TEXT:一只桶的规则集没有硬上限(导入一份大 settings 就能过 64K TEXT 墙)。 * · 毫秒列一律 `_ms` 后缀;OCC 列一律叫 `rev`(`version` 是形/schema 版本的保留词)。 */ export declare const TIDB_PERMISSION_RULE_STATEMENTS: readonly string[]; /** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`; * 本函数留给只需要这三张表的集成测试)。 */ export declare function ensureTiDBPermissionRuleSchema(pool: MySqlPool): Promise; /** * [ref] 升级前置断言 —— **拒启**,不是 warn(codex 交叉复审 [high],验真后修;形照 [ref] 的 * `assertToolResultProvenanceSchema` 先例)。 * * 病:本仓不发 `ALTER TABLE` 迁移(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升 * 上来的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `permission_rule_approval` 是空操作 —— 本车新加的 * `command` / `edited_json` 两列不在,而 `get` / `create` / `cas` 三条语句**无条件**引用它们。 * 后果不是「少一个新功能」,是**整条审批记录面**在那台机器上是坏的: * · 卡道「不再询问」(**修前就有**的候选臂也一样)每次撞 unknown column,被回决腿的 catch 吞成 * `rule_store_error` —— 裁决照常 200,服务看起来完全健康; * · CC 导入的 prepare 连 pending 记录都落不下; * · 而 `/v1/capabilities` 的 `permissionRules` 仍然报 true ⇒ 本仓「says yes ⟺ route works」整句为假。 * 没有这道断言,运维得不到**任何**「这次升级必须重建表」的信号 —— 那正是 [ref]「禁静默降级」要挡的形态。 * * 判据用**能力探测**而不是版本号/information_schema:发一条恒空的 `WHERE 1=0` 读,列不在就报错。 * 只判「五列在不在」(command / edited_json / offers_json 三列 TEXT 族 + version / selected_offer 两列 * INT),不判宽度(TEXT 族无截断轴,INT 无宽度轴)。 * * 🔴 **只有可证的缺列才给破坏性指路**(codex 交叉复审 round2/round3 [high],两轮收窄后的终形): * 「探针失败」**不等于**「列不在」。超时、连接被重置、取消、资源不足、列级权限被拒、表整个不存在 —— * 每一种都会让这条读抛错,而把它们诊断成「去 DROP TABLE」是一条**会真的毁掉审计记录**的建议(比它要 * 挡的缺陷更贵)。round2 的两步探(先读一列老列)也不够:两次读之间同样可以插进一次瞬时故障。 * 终形判据 = **方言的缺列错误码**,一行一方言的闭集(见 {@link isMissingColumnError});其余错误 * **原样 rethrow**,一个字都不加工。真库两条腿各自跑过这条路径(db-integration 的存量表格),所以 * 这张码表不是抄手册抄来的,是实测钉住的。 * 🔴 位置与 [ref] 同款:**不在** `ensureSchema` 里(那条通道的契约是「只发 CREATE」,有运行时门看着), * 放在 DDL 之后、任何路由装配之前 —— 还没开始服务,拒启的意义仍在;且**只在规则车道真会被装配时**跑 * (`PERMISSION_RULES_ENABLED=false` 的部署根本不碰这张表,为它拒启是纯误伤,见调用点)。 */ /** * 「这个错误**是**『列不存在』吗」——判据属主自 [ref] 起收在 `sql-errors.ts`(与 `isDupKeyError` 同族、 * 同理由:识别集复制一次就会漂,而这条谓词守的是一句**破坏性**指路)。本文件此前那份逐字实现已下车, * 语义一个字不变(闭集与 fail-safe 方向逐字见那边的头注)。 */ export declare function assertPermissionRuleApprovalSchema(query: (sql: string) => Promise<{ rows: Record[]; }>, dialect: "tidb" | "pg"): Promise; /** * [ref] A7 升级前置断言 —— **拒启**(形与理由逐字照 {@link assertPermissionRuleApprovalSchema} 的配方, * 那条头注写了这道门为什么必须是拒启而不是 warn:本仓不发 `ALTER TABLE`,`CREATE TABLE IF NOT EXISTS` * 对存量表是空操作,而新列被**无条件**引用)。 * * 病:一台没删表就升上来的部署,`permission_rule_ticket` 上没有 `redeemed_outcome` 列,而 A7 的三态 * 协议(`settle` / `peek` / `reclaim`)三条语句全都引用它。后果不是"少一个新功能": * · `POST /v1/rules/local-import/redeem` 的落定那一跳撞 unknown column ⇒ 票停在 CLAIMED 永不结算, * 属主的重放只能拿到 409,一次人的确认就此卡死到票过期; * · 更坏的是它**只在兑付走完之后**才炸 —— 规则已经落进店了,而客户端拿到的是失败:一次**真实生效 * 的同意**被报成没发生。 * 所以判据放在启动期、拒启,而不是等第一位用户去踩。 * * 判据同款:发一条恒空的 `WHERE 1=0` 读;**只有可证的缺列**才给破坏性指路(其余错误原样 rethrow —— * 超时/连接重置/权限/表不在都会让这条读抛,把它们诊断成"去 DROP TABLE"是一条比缺陷更贵的建议)。 * * 🔴 破坏性指路的代价在本表上**比审批表小得多**,文案照实说:票是分钟级 TTL 的治理瞬态(过期即死), * 删表最多让**正在预览、还没按确认**的那几个人重走一次 prepare;持久规则在 `permission_rule` 里, * 一行都不动。 */ export declare function assertPermissionRuleTicketSchema(query: (sql: string) => Promise<{ rows: Record[]; }>, dialect: "tidb" | "pg"): Promise; /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgPermissionRuleSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */ export declare const PERMISSION_RULE_INDEXES: readonly IndexSpec[]; export declare function ensurePgPermissionRuleSchema(q: PgQueryFn): Promise; /** * 一只桶的**存储键**(纯数据 ⇒ `build*`)。 * * 🔴 为什么 hash 而不是把 principal 直接当主键:①principal 是自由文本身份串,做主键会让**长度**与 * **排序规则**变成安全面(一个 191 字符的 principal 在 190 宽的列上被静默截断 ⇒ 两个身份共用一只桶, * 那是一次跨租户放行);②`local-owner` 那一员**没有** principal,需要一个同域的定长表示。 * 种别前缀让两族键不可能相撞(`principal:` vs `local-owner`)。 */ export declare function buildRuleOwnerKey(owner: RuleOwner): string; /** 内容三列 + 观测向量的完整性指纹(core `ruleStoreChecksum` 同精神;**不含** rev/counter —— 那两样 * 是元数据轴,`nextDot` 只动 counter 就不该迫使重算内容指纹)。 * * 导出(纯数据 ⇒ `build*`)是为了让**取证测试**能造一条「指纹自洽但语义矛盾」的行 —— 那正是 * codex round1 [high] 二的形:一条只靠指纹是拦不住的坏行,必须由语义筛拦。 */ export declare function buildRuleBucketChecksum(body: { rules: readonly unknown[]; tombstones: readonly unknown[]; quarantined: readonly unknown[]; observedVector?: RuleSyncFrontier; }): string; /** * SQL 双方言的 {@link DurableRulePartitionProvider} —— [ref] 里的 **durable 分区后端** * (`user` + `project` 两源的 OR-Set CRDT),**不是**引擎接缝本身: * `RunnerDeps.permissionRuleStore` 是 `createPermissionRuleStoreProvider({ durable })` 合成出来的 * 统一店,合成点在 `main.ts` 一处(分区构成是**部署**决定,不是 backend 决定)。 * * `forPrincipal(undefined)` 恒解析成**零规则**分区(core 硬条款:未鉴权任务解析到零规则而不是一只 * 共享桶 —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。 */ export declare class SqlDurableRulePartitionProvider implements DurableRulePartitionProvider { private readonly db; private readonly now; constructor(db: SqlDriver, now?: () => number); forPrincipal(principal: string | undefined): DurableRulePartition; forLocalOwner(): DurableRulePartition; } /** * durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed, * 只比 state 的两次并发重试会**都**认为自己看到了预期状态,各铸一个 dot、各写一次,后者静默覆盖前者的 * `redeemedDots`。那正是「一次同意变成两个 add、一条删掉的规则复活」的机制。 */ export declare class SqlRuleApprovalRecordStore implements RuleApprovalRecordStore { private readonly db; private readonly now; constructor(db: SqlDriver, now?: () => number); private q; private enc; get(id: string): Promise; create(record: RuleApprovalRecord): Promise; /** * 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。 * * 用处只有一个:`prepareCcImport` 会在**返回预览之前**就把 pending 记录落盘,所以「预览超帽、这次导入 * 不做了」这条路会留下一条永远没人要的行。`WHERE state = 'pending'` 是硬的 —— 已确认/已兑付的记录是 * 一次真人同意的**审计事实**,任何路径都不许把它删掉(那条轴上的清理属于保留期策略,不是本方法)。 * 不是 core `RuleApprovalRecordStore` 的成员(那个接口只有 get/cas/create),故命名上与它区分。 */ discardPendingRecord(recordId: string): Promise; /** compare-and-set on `rev`(core 硬条款)。`next.rev` 必须是 `expectRev + 1`;不是 ⇒ 响亮拒绝 * (一个不推进的 CAS 会让两次写互相覆盖而两边都以为自己赢了)。 */ cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise; } /** 一张已铸的票(呈给调用方的形;`payload` 是 prepare 时刻的候选快照)。 */ export interface RuleImportTicket { ticketId: string; approvalId: string; payloadHash: string; expiresAtMs: number; } /** * [ref] A7 —— 一张票是**哪条导入车道**铸的(codex 交叉轮 R1-F2,验真后修)。 * * 🔴 闭集且**不可变**:两条车道共用这张表,而它们的票语义刻意不同(cc-import 一次性 / local-import * 可恢复三态)。票不带这一位时,两条车道的兑付口互相**可以吃对方的票** —— 那不是"多一条路",那是让 * 一条车道的语义在**另一条车道的代码里**被改掉。铸票时定,此后每一次读写都拿它当匹配项。 */ export type RuleTicketPurpose = "cc-import" | "local-import"; /** 消费结果。**五种拒绝合并成一个 `refused` 形**是刻意的:调用点把它们全部翻成同一个 404, * 于是「这张票不存在」「它是别人的」「它是另一条车道的」「它过期了」「它已经用过了」在 wire 上 * 不可区分(禁存在性 oracle)。 * * `claimId` = 本次认领的**代次**(A7 围栏,R1-F1):`release`/`settle` 必须原样递回来,于是一个被 * 顶替的旧持有者动不了现持有者的认领。 */ export type RuleTicketRedeemResult = { ok: true; approvalId: string; payloadHash: string; claimId: string; } | { ok: false; reason: "unknown" | "wrong-principal" | "wrong-purpose" | "expired" | "consumed"; }; /** * [ref] A7 —— 一张票的**三态快照**(MINTED → CLAIMED → SETTLED)。 * * 🔴 判别式联合而不是"一个态字符串 + 两个可选字段":三个态各自带的东西**互不相干**,而消费方对三者 * 要做的事完全不同(首兑 / 判租约 / 逐字回放)。塌成一个可选包会让"已落定却读不出终局"这种矛盾态 * 变得可表达 —— 而那正是本协议最不能出错的地方(回放不出终局 = 要么重跑一次已经跑过的兑付,要么 * 对属主说一次谎)。 * * `claimAgeMs` 由**店自己**用它的记账钟算,不交给调用方去减:认领戳(`consumed_at_ms`)是真墙钟, * 而车道手里那只是单调投影 —— 两只钟不在同一个域,跨域相减出来的"年龄"没有意义(本仓 [ref]③ 的 * 教训:两只钟必须分家,谁的问题谁的钟答)。 */ export type RuleTicketSnapshot = { state: "minted"; } | { state: "claimed"; claimAgeMs: number; } | { state: "settled"; outcome: unknown; }; /** * [ref] A7 —— 抢认领的结果。赢家拿回**票上那两样不可变的东西**(`approval_id` / `payload_hash`), * 与 {@link RuleTicketRedeemResult} 的成功臂同形:抢到认领之后要做的事与首次认领之后**一模一样** * (读记录 → 载荷比对 → 走批),所以两条路必须交出同一份素材,否则恢复臂就得自己再发一条读 —— * 而"认领之后还有一次可能失败的 I/O"正是本仓 round4 修掉的那个形。 * * 拒绝臂**不分类**:没抢到只有一个处置(继续等/让客户端稍后再来),分类只会诱导调用方去猜。 */ export type RuleTicketReclaimResult = { ok: true; approvalId: string; payloadHash: string; claimId: string; } | { ok: false; }; /** * [ref]③ 同族的 **GC 宽限**:`reapExpired` 只收「按**数据库的钟**至少已死一个完整 TTL」的票行。 * * 🔴 立论已换([ref] / [ref]):它原本是给单调锚兜底的 —— 一张「按墙钟已过期、按单调钟还在窗内」的 * 票当年是可兑付的,GC 不能抢在判定前面把行收走。TTL 轴整条搬到数据库的钟之后,那一族**从根上没有了** * (判定与清扫从此同一口钟、同一个不等号,不可能一个说活一个说死)。留着它的现由是另外两条,都成立: * · **归因质量**:行还在 ⇒ 拒绝是 `expired`(服务端日志说得清「票过期了」);行被收走 ⇒ 塌成 `unknown`。 * 死票在库里多躺一个宽限,换来运维排查时一个准确的归因,而票表按设计是短命小表,代价可忽略。 * · **数据库自己的钟也会被拨**(DB 主机的 NTP 纠正、或一次主从切换换了台机器)。宽限一个 TTL 覆盖住 * 「DB 钟被往前拨了不到一个 TTL」这一族:那种时刻 `consume` 会暂时拒,但行还在,钟走回来票就活。 * * 残余(如实登记,不假称已解):DB 钟被往前拨**超过**一个 TTL 时,行仍会被收。补偿是现成的:prepare * 廉价,重铸一张即可(core [ref] 建议的姿态)。 */ export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number; /** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 * * 🔴 入参型收到 core 的 `RuleCandidate`(core 7.9.0 [ref] 起含 `behavior`)—— 规则的身份是 * **(behavior, 文本, 作用域) 三元组**,而这道摘要正是「prepare 那一刻的候选集」与「redeem 那一刻的 * 候选集」之间的等式。此前的形参写成 `{rule, scope}` 二元组:序列化器吃的是整只对象所以运行期**恰好** * 覆盖了 behavior,但那是巧合不是保证 —— 哪天有人从一个只有二元组的地方喂进来,一次把候选从 `deny` * 改写成 `allow`(文本一个字节不动)在这道等式下就认不出来了。收到域型让机器替人守这一条。 */ export declare function buildRulePayloadHash(candidates: ReadonlyArray): string; /** * 票面三态协议的 SQL 实现。 * * 🔴 **这只店没有本地钟 seam**([ref]):它此前收一个 `now: () => number`,而票上的每一个时刻量现在都由 * 数据库自己出({@link SQL_DB_NOW_MS})。留着那只形参会让人以为「注一只假钟就能改票的判决」—— * 那正是本批要消灭的那条依赖。测试要制造过期,改行(把 `expires_at_ms` 相对 `` 挪)即可, * 而那恰恰也是生产上唯一能让票过期的方式。 */ export declare class SqlRuleImportTicketStore { private readonly db; constructor(db: SqlDriver); private q; /** 问一次**数据库自己的钟**(毫秒,{@link sqlDbNowMs})。铸票用它当基准,于是到期时刻与后续每一次判定同域。 */ private dbNowMs; /** * 铸一张票。 * * 🔴 到期时刻 = **数据库的钟** + TTL([ref] / [ref]),不是这台副本的墙钟 + TTL。理由逐字见 * {@link SQL_DB_NOW_MS} 的头注:铸造点与判定点必须同一口钟,否则 TTL 在集群上不单调。 * * 为什么是「先问一次钟、再把绝对值写进去」而不是「INSERT 里直接写 ` + ?`」:`expiresAtMs` * 要**原样上 wire**(prepare 的响应体里有它,客户端拿它显示倒计时),而 MySQL 的 INSERT 交不回 * 计算列 —— 那一形要么再补一条 SELECT 读回来(失败时留一行孤儿),要么两方言各写一套(PG 有 * `RETURNING`、MySQL 没有,两份会各自漂)。先问钟的形只有一个失败方向:钟没问到 ⇒ 一行都没落, * 调用方原样收错。两跳之间流逝的那点时间只会让票**略短命**,方向保守。 */ mint(input: { ticketId: string; principal: string; approvalId: string; purpose: RuleTicketPurpose; candidates: ReadonlyArray; ttlMs: number; }): Promise; /** * **一次性原子认领**(F1 ③)。五个条件全部写在 `WHERE` 里 —— ticket 身份、principal 绑定、车道绑定 * (purpose,A7 R1-F2)、未认领、未过期 —— 于是并发双 redeem 由引擎的行锁裁决,恰一条 `affected === 1`。 * * 🔴 **认领的 UPDATE 是这条方法的最后一次 I/O**(codex 交叉复审 round4 [high],验真后修)。 * 旧形是「先 UPDATE 认领、再 SELECT 取 approvalId/payloadHash」——那条 SELECT 失败时本方法**抛错**, * 而调用方(`redeemImport`)此时还没进它的恢复 `try`,于是**没有任何人去放回认领**:票被永久烧掉, * 重试收到不可重试的 404,人的确认凭空消失。改法不是加一层 catch,而是**把那次读挪到认领之前**: * `approval_id` / `payload_hash` 自铸票起就是**不可变**列,先读与后读读到的是同一份字节。 * * 🔴 **这仍然不是 read-then-write**(禁令的语义没有松动):**裁决**依旧只由那条条件 `UPDATE` 的 * `WHERE` 做出 —— 预读的五条否定项只用来**短路 + 归因**(把拒绝分成 unknown/wrong-principal/ * wrong-purpose/expired/consumed 五类给服务端日志;wire 面把五类折成同一个 404)。预读说「可以」而 UPDATE 命中零行, * 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。 */ consume(ticketId: string, principal: string, purpose: RuleTicketPurpose): Promise; /** * **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。 * * `consume` 是一次**认领**(claim),不是「这件事已经做完了」。认领之后还有三步会失败:读记录、 * 载荷比对、confirm+批量兑付。旧形把认领当终局 ⇒ 一次数据库抖动就让票**永久**烧掉,而调用方拿到的 * 是与「票不存在」同形的 404:重试无门、最终状态对客户端不可判。 * * ⇒ 认领之后**只有裁定性拒绝**才留着消费位(载荷被改写、记录不存在 —— 重试不改判);**不确定/瞬时** * 的失败(store 抛错、CAS 冲突)把认领**放回去**,让属主重试。放回去不会打开双兑付的门:同一时刻 * 只有一个持有者(认领本身仍是单条条件 UPDATE),而兑付腿(`redeemRuleBatch`)按 core 的设计是 * **幂等**的(记录里存着每个候选已铸的 dot,重放只会重放同一个 dot)。 * * 残留(如实登记,不假称已解):认领与释放之间**硬崩**会把票留在已认领态 —— 它随 TTL 自然消失, * 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。 * 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。 */ release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number): Promise; /** * [ref] A7 —— CLAIMED → **SETTLED**:把这一次兑付的终局写进行里。 * * 🔴 判据全写在 `WHERE` 里(与 `consume` 同一条纪律,**禁 read-then-write**):必须**持有认领** * (`consumed_at_ms IS NOT NULL`)且**尚未落定**(`redeemed_outcome IS NULL`)。两个写者同时到达时 * 恰一条 `affected === 1` —— 输者据此知道"别人已经答过了",去读那份终局回放,而不是把自己的那份 * 覆盖上去(覆盖 = 同一张票对两个客户端说了两句不同的话)。 * * 🔴 **过期刻意不进 WHERE**(与 `consume`/`release` 相反,理由方向也相反):走已经发生了,规则可能 * 已经落进店里 —— 因为票在记录的这一刻恰好过期就拒绝记录它,只会把一份**已知的真相**丢掉,把 * 属主推回"不知道到底成没成"。过期管的是"还能不能**开始**兑付",不是"能不能**记下**已经发生的事"。 */ settle(ticketId: string, principal: string, claimId: string, outcome: unknown): Promise; /** * [ref] A7 —— 读一张票的三态。**principal 绑定**与四类拒同源:别人的票 / 不存在的票一律 `undefined` * (调用方把它折成同一个 404,零存在性 oracle)。 * * `claimAgeMs` 在**这里**算(店的记账钟),不交给调用方:见 {@link RuleTicketSnapshot} 的头注。 */ peek(ticketId: string, principal: string, purpose: RuleTicketPurpose): Promise; /** * [ref] A7 —— **崩溃窗恢复**:把一次**可证陈旧**的认领抢过来。 * * 病:`consume` 之后、`settle` 之前硬崩,票停在 CLAIMED 且永远不会自己走完。旧形(cc-import)对这 * 一格没有答案 —— 票随 TTL 消失,人的确认丢掉;而 `release` 帮不上忙,它要求那个死掉的进程**还活着 * 去调它**。 * * 判据同样全在 `WHERE` 里(恰一赢者):**这一代**认领仍在场 ∧ 未落定 ∧ 认领戳早于 now − minClaimAgeMs。 * `minClaimAgeMs` 由调用方给(车道的租约常数),而"多久算陈旧"这件事本身**只能**用记账钟判 —— * 所以减法在这里做,与 {@link peek} 同源。 * * 🔴 抢过来之后**换一代认领**(新戳 + 新 `claim_id`):租约从此刻起算,于是"抢的人自己也死了"这一形 * 同样有下一个恢复者;而新代次让被顶替的那一位从此 `release`/`settle` 都动不了这张票(R1-F1)。 * 重驱本身安全的理由在车道那侧(core 的批走是成员级幂等的)。 * * 🔴 **过期刻意不进 WHERE**(codex 交叉轮 R1-F3,验真后改判)。第一版照 `consume` 抄了 * `expires_at_ms > now`,而那让"临期崩溃"这一整族**永远救不回来**:租约(分钟级)比 TTL(10 分钟)短, * 持有者若在距过期不足一个租约时崩掉,认领**成熟之时票已过期** ⇒ 谁都抢不动,而车道对这一态答的是 * 「稍后拿同一张票再来」—— 一句必然兑现不了的承诺(本仓 `release` 的保守门正是为了不说这种话)。 * 更实的伤:那次崩溃可能已经把一半规则写进店了,属主却永远拿不到回执。 * 改判的语义是**过期只挡「开始」一次兑付,不挡「做完」一次已经开始的**:`consume` 的过期门一个字节 * 不动(一张从未被认领的过期票照旧是死的,外泄的票不会因此复活),而一张**已被属主认领过**的票代表 * 一次已经开始的兑付 —— 它的载荷仍被摘要锚定,做完它不多给任何东西。窗仍然有界:行随 * {@link RULE_IMPORT_TICKET_GC_GRACE_MS} 之外的保留期腿被收走,那就是成文的"回放窗 = 票行存续期"。 */ reclaim(ticketId: string, principal: string, purpose: RuleTicketPurpose, minClaimAgeMs: number): Promise; private readRow; } /** * 孤儿 pending 审批记录的保留期([ref])。 * * 判据是「**可证已死**」,不是一个拍脑袋的时长:一条 pending 记录只能经 `redeemRuleTicket` 走活, * 而兑付要么发生在铸它的**那一次请求内**(`persistCardRule` 的 prepare→confirm→redeem 三步同请求), * 要么要拿一张导入票 —— 而票自铸起最多活 {@link RULE_IMPORT_TICKET_TTL_MS}(10 分钟,`consume` 的 * `expires_at_ms > now` 是硬条件)。所以创建时刻早于「now − 票 TTL」的 pending 记录**再也不可能** * 被兑付。24 小时是在这条上界之上再压两个数量级的余量,留给运维「昨天那次导入怎么没成」的排查窗。 * * 🔴 **只收 pending**。`approved` / `redeemed` 是一次真人同意的**审计事实**(与 `discardPendingRecord` * 的 `WHERE state = 'pending'` 同一条硬约束,也与收编把这张表判成 D2「史实不改写」同源)—— * 保留期策略动不到它们,本腿一行都不碰。 */ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number; /** * 保留期腿**每轮**最多删多少行(codex R2 [high])。 * * 为什么必须有界:`permission_rule_approval` 按设计**永久**留 approved/redeemed 的审计事实(收编把它 * 判成 D2「史实不改写」,`discardPendingRecord` 的 `WHERE state='pending'` 也是同一条硬约束)—— * 也就是说这张表**只增不减**。一条无界 DELETE 在首次开清扫、或一次长期没跑的部署上,会在一个事务里 * 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数 * (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。 * * ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库 * 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一 * 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗 * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。 */ export declare const RULE_REAP_BATCH = 500; /** 三个 SQL 面的一次性装配束(one driver, three faces)。`durable` = [ref] 的 durable 分区后端; * 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次。 */ export interface PermissionRuleStores { durable: SqlDurableRulePartitionProvider; approvals: SqlRuleApprovalRecordStore; tickets: SqlRuleImportTicketStore; /** [ref] §3 —— boot 期休眠行审计的**窄读口**:库里已有几只桶(`permission_rule` 一行一桶)。 * * 🔴 为什么数**桶**而不是数**规则**:数规则要把每一行的 `rules_json` 都读回来再解析(一只桶的规则集 * 没有硬上限,顶注 §列宽依据已说明它是 LONGTEXT),那是一次无界的 boot 期扫描 —— 为一条诊断行付这个 * 代价不划算。桶数回答的正是审计要问的那个问题(「这台部署上有没有既有的规则状态」),而且是一次 * 索引级 `COUNT(*)`。消费点(`boot/permission-rules-audit.ts`)的文案因此逐字说的是 bucket,不是 rule。 */ countBuckets(): Promise; /** * 保留期腿([ref])。返回删掉的**总行数**(两张表合计),给 reaper 的 `reapCount` 用。 * * 病:这两张表此前**没有任何 retention 腿**,而它们都是只进不出的: * · `permission_rule_ticket` —— 每一次 `POST /v1/rules/cc-import/prepare` 落一行,不管有没有人去 * 兑付。而普查门把它登记成「过期即死」—— 那句话描述的是**语义**(过期票 `consume` 必拒), * 库里那一行从来没有人删。一个把 CC settings 导来导去的部署,这张表每次预览都长一行,永久。 * · `permission_rule_approval` 的 **pending** 行 —— core 在**返回预览之前**就落记录,于是「看了预览 * 没按确认」这条最常见的人类路径,每走一次留一条永远没人要的行(`discardPendingRecord` 只收 * 「超帽当场拒」那一条路,不收「人改主意了」)。 * * 两张表的谓词都只删**可证已死**的行,所以本腿不需要旋钮(没有可调的语义): * · 票(三支 OR,A7 交叉复审定形,逐支论证见 reapExpired 实现内注)**三支全走 DB 钟**([ref]): * 甲=从未认领且过期已满 GC 宽限;乙=已落定且落定后过 GC 宽限(按 `settled_at_ms` 判); * 丙=CLAIMED-未结且认领戳老过废弃期(按 `consumed_at_ms` 判); * · 记录:`state = 'pending' AND created_at_ms < nowMs − {@link RULE_PENDING_APPROVAL_RETENTION_MS}`, * 再加 `NOT EXISTS`(载荷必须陪着把手活,R6-F1:仍有票引用的 pending 记录不删)。 * * 🔴 `nowMs` 形参**只剩记录那一支在用**,如实登记:`permission_rule_approval.created_at_ms` 是这台 * 副本自己写下的(见 {@link SqlRuleApprovalRecordStore}),按同一台副本的墙钟判"落盘满 24 小时了吗" * 是**同域比较** —— 把它单独搬到 DB 钟反而会造出一次新的跨钟比较。那张表的钟域是另一条轴上的事, * 不在本批射程(登记为后续件)。票那一支已经不看它了。 */ reapExpired(nowMs: number): Promise; } export declare function createSqlPermissionRuleStores(db: SqlDriver, now?: () => number): PermissionRuleStores; //# sourceMappingURL=permission-rule-store-sql.d.ts.map