@sema-agent/server 7.10.0 → 7.11.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 (59) hide show
  1. package/dist/adoption/plan.d.ts +152 -0
  2. package/dist/adoption/plan.js +513 -0
  3. package/dist/adoption/runner.d.ts +54 -0
  4. package/dist/adoption/runner.js +505 -0
  5. package/dist/adoption/sql.d.ts +76 -0
  6. package/dist/adoption/sql.js +106 -0
  7. package/dist/adoption/wire.d.ts +250 -0
  8. package/dist/adoption/wire.js +153 -0
  9. package/dist/approval-card.d.ts +24 -0
  10. package/dist/approval-card.js +32 -0
  11. package/dist/boot/adoption.d.ts +30 -0
  12. package/dist/boot/adoption.js +57 -0
  13. package/dist/boot/coordinators.d.ts +4 -0
  14. package/dist/boot/coordinators.js +3 -1
  15. package/dist/boot/parked-revive-gate.d.ts +38 -5
  16. package/dist/boot/parked-revive-gate.js +53 -6
  17. package/dist/boot/runner-deps.d.ts +10 -2
  18. package/dist/boot/runner-deps.js +12 -1
  19. package/dist/config-types.d.ts +16 -1
  20. package/dist/config.js +5 -1
  21. package/dist/http/routes/adoption.d.ts +26 -0
  22. package/dist/http/routes/adoption.js +120 -0
  23. package/dist/http/routes/capabilities.js +12 -0
  24. package/dist/http/routes/rules.d.ts +23 -0
  25. package/dist/http/routes/rules.js +117 -0
  26. package/dist/http/routes/shared-memory.d.ts +31 -0
  27. package/dist/http/routes/shared-memory.js +181 -0
  28. package/dist/http/routes/trace-usage.js +139 -3
  29. package/dist/http/server.d.ts +19 -1
  30. package/dist/http/server.js +42 -0
  31. package/dist/main.js +52 -3
  32. package/dist/observability/fail-open.d.ts +8 -0
  33. package/dist/observability/fail-open.js +8 -0
  34. package/dist/plugins/adoption-log-sql.d.ts +191 -0
  35. package/dist/plugins/adoption-log-sql.js +273 -0
  36. package/dist/plugins/checkpoint-store-sql.d.ts +13 -0
  37. package/dist/plugins/checkpoint-store-sql.js +11 -0
  38. package/dist/plugins/local-checkpoint-store.d.ts +10 -0
  39. package/dist/plugins/local-checkpoint-store.js +8 -0
  40. package/dist/plugins/permission-rule-store-sql.d.ts +242 -0
  41. package/dist/plugins/permission-rule-store-sql.js +817 -0
  42. package/dist/plugins/pg-pool.js +37 -0
  43. package/dist/plugins/session-policy-store-sql.d.ts +6 -0
  44. package/dist/plugins/session-policy-store-sql.js +7 -1
  45. package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
  46. package/dist/plugins/shared-memory-store-sql.js +516 -0
  47. package/dist/plugins/store-backend.d.ts +30 -0
  48. package/dist/plugins/store-backend.js +14 -0
  49. package/dist/plugins/tidb-pool.js +47 -0
  50. package/dist/rules-consent.d.ts +126 -0
  51. package/dist/rules-consent.js +198 -0
  52. package/dist/shared-memory-scope-authorizer.d.ts +29 -0
  53. package/dist/shared-memory-scope-authorizer.js +17 -0
  54. package/dist/tool-approval.d.ts +55 -0
  55. package/dist/tool-approval.js +124 -5
  56. package/dist/trace/core-keyset-guard.d.ts +2 -2
  57. package/dist/trace/project.d.ts +1 -0
  58. package/dist/trace/project.js +1 -0
  59. package/package.json +3 -3
@@ -0,0 +1,273 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mysqlDriver, pgDriver } from "./sql-driver.js";
3
+ export const ADOPTION_LOG_TABLE = "adoption_log";
4
+ /** 弧的阶段(183 §3.2 根级状态机的 form b 投影)。**单调**,只增不减。 */
5
+ export const ADOPTION_PHASE = {
6
+ /** ② 意图已落库(行在,尚未动任何数据行)。 */
7
+ INTENT: 2,
8
+ /** ③ 身份轴重绑腿全部完成(与腿的 UPDATE **同一个事务**)。 */
9
+ REBOUND: 3,
10
+ /** ④ 解析切换 / 配置清单产出。form b 的 server 半场:配置账已物化进 `configs`。 */
11
+ CONFIGS: 4,
12
+ /** ⑤ 承运腿。**form b 恒零承运**(数据本来就在 SQL)—— 阶段位仍显式推进,好让 form a 上车时挂得上。 */
13
+ CARRIED: 5,
14
+ /** ⑥ 永久终态。 */
15
+ TERMINAL: 6,
16
+ };
17
+ /** MySQL 协议方言的建表语句(展开进 tidb-pool 的中央 `SCHEMA_STATEMENTS`,与 approval-ask 同姿势:
18
+ * 跟着中央 ensureSchema 在 named-lock 的那条 conn 上建,不绕开 DDL 串行化)。 */
19
+ export const TIDB_ADOPTION_LOG_STATEMENTS = [
20
+ `CREATE TABLE IF NOT EXISTS ${ADOPTION_LOG_TABLE} (
21
+ adoption_id VARCHAR(64) NOT NULL,
22
+ -- from_principal 上的 UNIQUE = 183 D7「同一个源不许被收编两次」的**数据库强形**。
23
+ -- 它同时是发起腿的幂等身份:并发同参 POST 由这条约束仲裁,不靠应用层先查后插。
24
+ from_principal VARCHAR(190) NOT NULL,
25
+ to_principal VARCHAR(190) NOT NULL,
26
+ phase INT NOT NULL,
27
+ state VARCHAR(16) NOT NULL,
28
+ -- legs/configs/report 是 TEXT 不是 JSON:report 的契约是「逐字节恒同」,JSON 列会重排键序(顶注)。
29
+ legs MEDIUMTEXT NOT NULL,
30
+ configs MEDIUMTEXT NOT NULL,
31
+ report MEDIUMTEXT NULL,
32
+ reject_code VARCHAR(64) NULL,
33
+ reject_detail TEXT NULL,
34
+ created_at_ms BIGINT NOT NULL,
35
+ updated_at_ms BIGINT NOT NULL,
36
+ PRIMARY KEY (adoption_id),
37
+ UNIQUE KEY uq_adoption_log_from (from_principal),
38
+ KEY idx_adoption_log_state (state)
39
+ ) COLLATE utf8mb4_bin`,
40
+ ];
41
+ /** PostgreSQL 孪生(由 `ensurePgSchema` 在 advisory lock 的那条 client 上调用)。 */
42
+ export async function ensurePgAdoptionLogSchema(q) {
43
+ await q(`CREATE TABLE IF NOT EXISTS ${ADOPTION_LOG_TABLE} (
44
+ adoption_id VARCHAR(64) COLLATE "C" NOT NULL,
45
+ -- MySQL 孪生的行内注解释了这条 UNIQUE 为什么是幂等身份的真源(183 D7 的 DB 强形)。
46
+ from_principal VARCHAR(190) COLLATE "C" NOT NULL,
47
+ to_principal VARCHAR(190) COLLATE "C" NOT NULL,
48
+ phase INT NOT NULL,
49
+ state VARCHAR(16) COLLATE "C" NOT NULL,
50
+ legs TEXT COLLATE "C" NOT NULL,
51
+ configs TEXT COLLATE "C" NOT NULL,
52
+ report TEXT COLLATE "C",
53
+ reject_code VARCHAR(64) COLLATE "C",
54
+ reject_detail TEXT COLLATE "C",
55
+ created_at_ms BIGINT NOT NULL,
56
+ updated_at_ms BIGINT NOT NULL,
57
+ PRIMARY KEY (adoption_id),
58
+ CONSTRAINT uq_adoption_log_from UNIQUE (from_principal)
59
+ )`);
60
+ await q(`CREATE INDEX IF NOT EXISTS idx_adoption_log_state ON ${ADOPTION_LOG_TABLE} (state)`);
61
+ }
62
+ const COLS = "adoption_id, from_principal, to_principal, phase, state, legs, configs, report, reject_code, reject_detail, created_at_ms, updated_at_ms";
63
+ function toRow(r) {
64
+ return {
65
+ adoptionId: String(r.adoption_id),
66
+ fromPrincipal: String(r.from_principal),
67
+ toPrincipal: String(r.to_principal),
68
+ phase: Number(r.phase),
69
+ state: String(r.state),
70
+ legsJson: String(r.legs),
71
+ configsJson: String(r.configs),
72
+ reportJson: r.report == null ? null : String(r.report),
73
+ rejectCode: r.reject_code == null ? null : String(r.reject_code),
74
+ rejectDetail: r.reject_detail == null ? null : String(r.reject_detail),
75
+ createdAtMs: Number(r.created_at_ms),
76
+ updatedAtMs: Number(r.updated_at_ms),
77
+ };
78
+ }
79
+ /** PG advisory lock 的 32 位对象键(类键固定,对象键 = 名字的 sha256 前 4 字节转有符号 int4)。 */
80
+ const PG_ADOPTION_LOCK_CLASS = 4907;
81
+ function pgLockObjectKey(name) {
82
+ return createHash("sha256").update(name).digest().readInt32BE(0);
83
+ }
84
+ /**
85
+ * 弧锁的名字。**定长**(前缀 14 + 32 位十六进制 = 46 字符)。
86
+ *
87
+ * 🔴 为什么必须 hash 而不是把 principal 直接拼进去(codex R3-F3,亲核属实):TiDB/MySQL 的 `GET_LOCK`
88
+ * 锁名上限是 **64 字符**,而请求面收的 principal 到 190 —— 超过 50 字符的源身份会让取锁**在弧动起来
89
+ * 之前**就失败,而那时意图行已经落库,于是每一次重发、每一次副本 boot 续跑都确定性地再撞一次同一堵墙。
90
+ * (PG 那条腿本来就走 hash 出来的 int4 对象键,这里只是把 MySQL 腿补齐到同一姿势。)
91
+ */
92
+ export function adoptionLockName(fromPrincipal) {
93
+ return `sema_adoption:${createHash("sha256").update(fromPrincipal).digest("hex").slice(0, 32)}`;
94
+ }
95
+ /**
96
+ * 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
97
+ *
98
+ * 每条语句的两方言文本**并排写在调用点**(A12 判据:方言差异必须显式,不许用占位符编号循环拼)。
99
+ */
100
+ export class SqlAdoptionLogStore {
101
+ db;
102
+ constructor(db) {
103
+ this.db = db;
104
+ }
105
+ q(tidb, pg) {
106
+ return this.db.dialect === "tidb" ? tidb : pg;
107
+ }
108
+ /** 池级只读执行面(phase 0 的 preflight 扫描用——它**零写**,不需要也不该占一条事务连接)。
109
+ * 写路径一律走 {@link rebindTransaction};把驱动整个暴露出去会让调用方能绕开 phase CAS。 */
110
+ get reader() {
111
+ return this.db;
112
+ }
113
+ /**
114
+ * 发起腿的 **insert-or-fetch 原子形**。返回读回的行(可能是别人插的)+ 是否本次插入。
115
+ * 调用方据 `row.toPrincipal !== toPrincipal` 判「同 from 异 to」⇒ typed 409。
116
+ */
117
+ async ensureAdoption(row) {
118
+ const emptyLegs = "[]";
119
+ const res = await this.db.query(this.q(`INSERT IGNORE INTO ${ADOPTION_LOG_TABLE} (${COLS}) VALUES (?, ?, ?, ?, 'in_flight', ?, ?, NULL, NULL, NULL, ?, ?)`, `INSERT INTO ${ADOPTION_LOG_TABLE} (${COLS}) VALUES ($1, $2, $3, $4, 'in_flight', $5, $6, NULL, NULL, NULL, $7, $8) ON CONFLICT (from_principal) DO NOTHING`), [row.adoptionId, row.fromPrincipal, row.toPrincipal, ADOPTION_PHASE.INTENT, emptyLegs, emptyLegs, row.nowMs, row.nowMs]);
120
+ const inserted = res.affected > 0;
121
+ const stored = await this.getByFrom(row.fromPrincipal);
122
+ if (!stored)
123
+ throw new Error(`ensureAdoption: row vanished right after insert (from=${row.fromPrincipal})`);
124
+ return { row: stored, inserted };
125
+ }
126
+ async getById(adoptionId, exec = this.db) {
127
+ const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE adoption_id = ?`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE adoption_id = $1`), [adoptionId]);
128
+ return res.rows[0] ? toRow(res.rows[0]) : null;
129
+ }
130
+ async getByFrom(fromPrincipal, exec = this.db) {
131
+ const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE from_principal = ?`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE from_principal = $1`), [fromPrincipal]);
132
+ return res.rows[0] ? toRow(res.rows[0]) : null;
133
+ }
134
+ /** I6 server 同族:每副本 boot 必查的在飞名单(**响亮**,不静默跳过)。 */
135
+ async listInFlight(exec = this.db) {
136
+ const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE state = 'in_flight' ORDER BY created_at_ms ASC`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE state = 'in_flight' ORDER BY created_at_ms ASC`), []);
137
+ return res.rows.map(toRow);
138
+ }
139
+ /**
140
+ * 迁移事务:腿的全部 UPDATE **与** phase CAS 落在**同一个事务**里。
141
+ *
142
+ * 🔴 为什么必须同事务(而不是「先迁完再推 phase」):两句分开就留下一个窗 —— 腿提交了、phase 没推,
143
+ * 崩溃后接棒副本重跑腿(幂等,零行变化)却把**首次的行数读数**丢了,回执里的 `legs` 会变成一串 0。
144
+ * 同事务之后这个窗根本不存在:要么「行迁了且 phase=3 且 legs 读数在」,要么什么都没发生。
145
+ * 任何异常(含唯一键违例 = 跨轴组合冲突)⇒ ROLLBACK ⇒ **源/目的地两侧字节零变更**。
146
+ */
147
+ async rebindOn(conn, adoptionId, expectPhase, nextPhase, nowMs, work, encodeLegs) {
148
+ try {
149
+ await conn.begin();
150
+ const legs = await work(conn);
151
+ const cas = await conn.query(
152
+ // 🔴 谓词里必须带 `state = 'in_flight'`(codex R2-F1..F3 的 F3):被拒的行**故意**停在 phase INTENT
153
+ // (「两侧字节零变更」要求它一步都别走),所以只判 phase 的 CAS 对一条已 rejected 的行是**满足**的
154
+ // —— 一个在锁上排队的过期调用方于是能把一条永久拒了的弧推去迁数据。终态永久性是库级不变量,
155
+ // 这道守卫就该在库里,不在调用方的 if 里。
156
+ this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, legs = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, legs = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5 AND state = 'in_flight'`), [nextPhase, encodeLegs(legs), nowMs, adoptionId, expectPhase]);
157
+ if (cas.affected !== 1) {
158
+ await conn.rollback();
159
+ return { ok: false, reason: "phase_moved" };
160
+ }
161
+ await conn.commit();
162
+ return { ok: true, legs };
163
+ }
164
+ catch (err) {
165
+ try {
166
+ await conn.rollback();
167
+ }
168
+ catch (rollbackErr) {
169
+ // 🔴 不空吞(#191 静默降级门):回滚本身失败通常意味着连接已经废了,原始异常才是要上抛的那个,
170
+ // 但「回滚没成功」是运维必须看见的事 —— 它决定了库里可能留着一段未提交/已废弃的事务状态。
171
+ console.warn("[adoption-log] rollback after a failed rebind transaction also failed:", rollbackErr);
172
+ }
173
+ throw err;
174
+ }
175
+ }
176
+ /**
177
+ * 终态**之后**的迟到行清扫事务(无 phase 变更 —— phase 已经是终态,而终态是永久的)。
178
+ *
179
+ * 🔴 它不是「再收编一次」:腿本身幂等,清扫只是把收编**结束后**又落到旧身份下的行(配置未随迁的
180
+ * 部署会持续制造它们)一并搬过去,并让 `current.residualSourceRows` 有个能归零的动作。
181
+ * `immutableReport` 一个字节都不动(史实与现势分家,183 §6)。
182
+ */
183
+ async sweepOn(conn, work) {
184
+ try {
185
+ await conn.begin();
186
+ const out = await work(conn);
187
+ await conn.commit();
188
+ return out;
189
+ }
190
+ catch (err) {
191
+ try {
192
+ await conn.rollback();
193
+ }
194
+ catch (rollbackErr) {
195
+ console.warn("[adoption-log] rollback after a failed sweep transaction also failed:", rollbackErr); // 同上:不空吞
196
+ }
197
+ throw err;
198
+ }
199
+ }
200
+ /** 单调 CAS 推进(无数据写的阶段;`false` = 别人已经推过了/相位不符 ⇒ 调用方重读行)。 */
201
+ async advancePhase(adoptionId, expectPhase, nextPhase, nowMs, exec = this.db) {
202
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ?`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, updated_at_ms = $2 WHERE adoption_id = $3 AND phase = $4`), [nextPhase, nowMs, adoptionId, expectPhase]);
203
+ return res.affected === 1;
204
+ }
205
+ /** phase 4:配置账物化(与 phase 推进同一条语句,免得账落了相位没推)。 */
206
+ async putConfigs(adoptionId, expectPhase, nextPhase, configsJson, nowMs, exec = this.db) {
207
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, configs = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ?`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, configs = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5`), [nextPhase, configsJson, nowMs, adoptionId, expectPhase]);
208
+ return res.affected === 1;
209
+ }
210
+ /**
211
+ * ⑥ 终态:`report` 一次写入,`state='adopted'`。
212
+ * 🔴 `AND report IS NULL` 是 I4(永久终态 + 不可变史实)的**数据库强形**:即使调用序漂了,快照也
213
+ * 不可能被第二次写盖掉 —— 「逐字节恒同」于是不依赖调用方自觉。
214
+ */
215
+ async finalizeAdopted(adoptionId, expectPhase, reportJson, nowMs, exec = this.db) {
216
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, state = 'adopted', report = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ? AND report IS NULL`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, state = 'adopted', report = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5 AND report IS NULL`), [ADOPTION_PHASE.TERMINAL, reportJson, nowMs, adoptionId, expectPhase]);
217
+ return res.affected === 1;
218
+ }
219
+ /** 目的地冲突 ⇒ rejected 终态(源/目的地两侧字节零变更;phase 停在 INTENT)。 */
220
+ async finalizeRejected(adoptionId, code, detail, nowMs, exec = this.db) {
221
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = ?, reject_detail = ?, updated_at_ms = ? WHERE adoption_id = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = $1, reject_detail = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND state = 'in_flight'`), [code, detail, nowMs, adoptionId]);
222
+ return res.affected === 1;
223
+ }
224
+ /**
225
+ * 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
226
+ *
227
+ * **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
228
+ * 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
229
+ * 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
230
+ */
231
+ async withLock(name, fn, attempts = 20, sleepMs = 100) {
232
+ const conn = await this.db.connect();
233
+ let held = false;
234
+ try {
235
+ for (let i = 0; i < attempts && !held; i += 1) {
236
+ const res = await conn.query(this.q("SELECT GET_LOCK(?, 0) AS got", "SELECT pg_try_advisory_lock($1, $2) AS got"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
237
+ const got = res.rows[0]?.got;
238
+ held = got === 1 || got === true || got === "1";
239
+ if (!held)
240
+ await new Promise((r) => setTimeout(r, sleepMs));
241
+ }
242
+ if (!held)
243
+ return undefined;
244
+ // 🔴 把**这一条**连接交出去:整条弧(读 / 事务 / CAS)都跑在它上面,弧内绝不再向池要第二条
245
+ // ——`connectionLimit=1` 的部署上那第二条永远等不到(codex R3-F2)。
246
+ return await fn(conn);
247
+ }
248
+ finally {
249
+ if (held) {
250
+ try {
251
+ await conn.query(this.q("SELECT RELEASE_LOCK(?) AS released", "SELECT pg_advisory_unlock($1, $2) AS released"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
252
+ }
253
+ catch (releaseErr) {
254
+ // 锁在连接关闭时会自动释放,所以释放失败不该盖掉真正的结果 —— 但同样不空吞:
255
+ // 释放失败意味着这把弧锁会一直持到连接回收,下一个副本要多等一会儿,那是要能看见的。
256
+ console.warn("[adoption-log] releasing the adoption arc lock failed (it auto-frees when the connection closes):", releaseErr);
257
+ }
258
+ }
259
+ conn.release();
260
+ }
261
+ }
262
+ }
263
+ export class TiDBAdoptionLogStore extends SqlAdoptionLogStore {
264
+ constructor(pool) {
265
+ super(mysqlDriver(pool));
266
+ }
267
+ }
268
+ export class PgAdoptionLogStore extends SqlAdoptionLogStore {
269
+ constructor(pool) {
270
+ super(pgDriver(pool));
271
+ }
272
+ }
273
+ //# sourceMappingURL=adoption-log-sql.js.map
@@ -152,6 +152,19 @@ export declare class SqlCheckpointStore implements CheckpointStore {
152
152
  * `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
153
153
  */
154
154
  readonly fidelity: StoreFidelity;
155
+ /**
156
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
157
+ * 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
158
+ * 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
159
+ * 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
160
+ * 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
161
+ * 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
162
+ * `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
163
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
164
+ */
165
+ readonly redecision: {
166
+ readonly reopen: true;
167
+ };
155
168
  constructor(db: SqlDriver, logger?: {
156
169
  info?(msg: string, meta?: unknown): void;
157
170
  } | undefined);
@@ -269,6 +269,17 @@ export class SqlCheckpointStore {
269
269
  * `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
270
270
  */
271
271
  fidelity = "json";
272
+ /**
273
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
274
+ * 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
275
+ * 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
276
+ * 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
277
+ * 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
278
+ * 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
279
+ * `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
280
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
281
+ */
282
+ redecision = { reopen: true };
272
283
  constructor(db, logger) {
273
284
  this.db = db;
274
285
  this.logger = logger;
@@ -27,6 +27,16 @@ export declare class LocalCheckpointStore {
27
27
  * 内核哪天改宽度,包装层的声明会被那条钉当场揪出来,而不是靠人记得同步。
28
28
  */
29
29
  readonly fidelity: StoreFidelity;
30
+ /**
31
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按内核判**,
32
+ * 与 durability/fidelity 同一条判据:内核 core `FileCheckpointStore` **自己**声明 `{ reopen: true }`,
33
+ * 本类的 {@link LocalCheckpointStore.reopen} 纯委派给它 ⇒ 抄内核的读数是诚实的。包装类**不继承**被
34
+ * 包装者的声明(委派不是子类),所以这一格必须自己写。`validatingLease` 不声明(内核也没声明)。
35
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
36
+ */
37
+ readonly redecision: {
38
+ readonly reopen: true;
39
+ };
30
40
  private readonly inner;
31
41
  private readonly scopesPath;
32
42
  private readonly sessionTokensPath;
@@ -54,6 +54,14 @@ export class LocalCheckpointStore {
54
54
  * 内核哪天改宽度,包装层的声明会被那条钉当场揪出来,而不是靠人记得同步。
55
55
  */
56
56
  fidelity = "json";
57
+ /**
58
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按内核判**,
59
+ * 与 durability/fidelity 同一条判据:内核 core `FileCheckpointStore` **自己**声明 `{ reopen: true }`,
60
+ * 本类的 {@link LocalCheckpointStore.reopen} 纯委派给它 ⇒ 抄内核的读数是诚实的。包装类**不继承**被
61
+ * 包装者的声明(委派不是子类),所以这一格必须自己写。`validatingLease` 不声明(内核也没声明)。
62
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
63
+ */
64
+ redecision = { reopen: true };
57
65
  inner;
58
66
  scopesPath;
59
67
  sessionTokensPath;
@@ -0,0 +1,242 @@
1
+ import { type PersistedAllowRule, type PermissionRuleStore, type PermissionRuleStoreProvider, type PutResult, type QuarantinedRuleAdd, type RuleAdd, type RuleDot, type RuleScope, type RuleSyncFrontier, type RuleTombstone, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
2
+ import type { Pool as MySqlPool } from "mysql2/promise";
3
+ import type { PgQueryFn } from "./pg-query.js";
4
+ import { type SqlDriver } from "./sql-driver.js";
5
+ import type { Pool as PgPool } from "pg";
6
+ /** core `permission-rule-store.ts` 的 `PERMISSION_RULE_WRITER` 字面值(逐字)。`writerOf(store)` 读的
7
+ * 就是这把键——它是 core 与 backend 之间**事实上的**协议名,只是没被导出。 */
8
+ export declare const PERMISSION_RULE_WRITER_KEY = "__semaPermissionRuleWriter";
9
+ /** core `RedemptionAuthorization` 的镜像。 */
10
+ export interface RedemptionAuthorizationMirror {
11
+ recordId: string;
12
+ principal?: string;
13
+ owner?: RuleOwner;
14
+ }
15
+ /** core `RuleAddDelta` 的镜像。 */
16
+ export interface RuleAddDeltaMirror {
17
+ kind: "redemption-add";
18
+ rule: string;
19
+ scope: RuleScope;
20
+ tool: PersistedAllowRule["tool"];
21
+ match: PersistedAllowRule["match"];
22
+ command: string;
23
+ add: RuleAdd;
24
+ redemption: RedemptionAuthorizationMirror;
25
+ }
26
+ /** core `RuleDeleteDelta` 的镜像。 */
27
+ export interface RuleDeleteDeltaMirror {
28
+ kind: "tighten-delete";
29
+ tombstone: RuleTombstone;
30
+ }
31
+ /** core `RuleSyncJoinDelta` 的镜像(**本 backend 不实现**,只为判别式完整——见顶注 ②)。 */
32
+ export interface RuleSyncJoinDeltaMirror {
33
+ kind: "sync-join";
34
+ inbound: {
35
+ rules: PersistedAllowRule[];
36
+ tombstones: RuleTombstone[];
37
+ };
38
+ gcFrontier?: RuleSyncFrontier;
39
+ observedVector?: RuleSyncFrontier;
40
+ quarantine?: Array<{
41
+ rule: string;
42
+ scope: RuleScope;
43
+ dots: RuleDot[];
44
+ reason: QuarantinedRuleAdd["reason"];
45
+ }>;
46
+ }
47
+ export type RuleWriteDeltaMirror = RuleAddDeltaMirror | RuleDeleteDeltaMirror | RuleSyncJoinDeltaMirror;
48
+ /** core `RawRuleSyncState` 的镜像。 */
49
+ export interface RawRuleSyncStateMirror {
50
+ actor: string;
51
+ counter: number;
52
+ rev: number;
53
+ rules: PersistedAllowRule[];
54
+ tombstones: RuleTombstone[];
55
+ observedVector?: RuleSyncFrontier;
56
+ quarantined?: QuarantinedRuleAdd[];
57
+ }
58
+ /** core `PermissionRuleWriter` 的镜像。 */
59
+ export interface PermissionRuleWriterMirror {
60
+ nextDot(): Promise<RuleDot>;
61
+ apply(delta: RuleWriteDeltaMirror, opts: {
62
+ expectedRev: number;
63
+ }): Promise<PutResult | {
64
+ conflict: true;
65
+ rev: number;
66
+ }>;
67
+ readRaw(): Promise<RawRuleSyncStateMirror>;
68
+ }
69
+ /** core `WritablePermissionRuleStore` 的镜像(属性名 = {@link PERMISSION_RULE_WRITER_KEY})。 */
70
+ export interface WritablePermissionRuleStoreMirror extends PermissionRuleStore {
71
+ readonly __semaPermissionRuleWriter: PermissionRuleWriterMirror;
72
+ }
73
+ /**
74
+ * core 私有 `writerOf` 的**本地对偶**:一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
75
+ *
76
+ * 结构检查而不是断言 —— 它同时是本仓消费点(测试/诊断)读写面的**唯一**入口:没有这个函数,每个调用点
77
+ * 都会各写一次 `as unknown as {…}`,那既是三处宽松断言,也是三份会各自漂的形状假设。
78
+ */
79
+ export declare function writerOfSqlRuleStore(store: PermissionRuleStore): PermissionRuleWriterMirror | undefined;
80
+ export declare const PERMISSION_RULE_TABLE = "permission_rule";
81
+ export declare const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval";
82
+ export declare const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket";
83
+ /**
84
+ * MySQL-protocol(TiDB)侧的三条建表语句。真源在本文件(与 PG twin 并排,方言差异一眼可对);
85
+ * `tidb-pool.ts` 的 `SCHEMA_STATEMENTS` 用 `...TIDB_PERMISSION_RULE_STATEMENTS` 展开,于是三张表跟着
86
+ * 中央 `ensureSchema` 在 **named-lock 的那条 conn** 上建(同 `TIDB_APPROVAL_ASK_STATEMENTS` 先例)。
87
+ *
88
+ * 列宽依据(#192 A10 的「每列的值由谁铸、有没有入口上限」口径):
89
+ * · `owner_key` / `record_id` / `ticket_id` / `approval_id` / `payload_hash` / `checksum` → 190:
90
+ * 全是本仓自铸的键类值(sha256 hex 64 / uuid 36 / `sha256:` 前缀 71),190 是全仓键轴的统一宽度。
91
+ * · `principal` → 512:principal 入口上限是 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts`),这里给
92
+ * 名类宽度 512 是**上界宽于入口**的方向(绝不制造静默截断面)。
93
+ * · `actor` → 190:本 backend 自铸的副本身份(`sql-<12 hex>`),按构造 ≤ 20 字符。
94
+ * · 内容列一律 LONGTEXT / TEXT:一只桶的规则集没有硬上限(导入一份大 settings 就能过 64K TEXT 墙)。
95
+ * · 毫秒列一律 `_ms` 后缀;OCC 列一律叫 `rev`(`version` 是形/schema 版本的保留词)。
96
+ */
97
+ export declare const TIDB_PERMISSION_RULE_STATEMENTS: readonly string[];
98
+ /** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
99
+ * 本函数留给只需要这三张表的集成测试)。 */
100
+ export declare function ensureTiDBPermissionRuleSchema(pool: MySqlPool): Promise<void>;
101
+ export declare function ensurePgPermissionRuleSchema(q: PgQueryFn): Promise<void>;
102
+ /**
103
+ * 一只桶的**存储键**(纯数据 ⇒ `build*`)。
104
+ *
105
+ * 🔴 为什么 hash 而不是把 principal 直接当主键:①principal 是自由文本身份串,做主键会让**长度**与
106
+ * **排序规则**变成安全面(一个 191 字符的 principal 在 190 宽的列上被静默截断 ⇒ 两个身份共用一只桶,
107
+ * 那是一次跨租户放行);②`local-owner` 那一员**没有** principal,需要一个同域的定长表示。
108
+ * 种别前缀让两族键不可能相撞(`principal:` vs `local-owner`)。
109
+ */
110
+ export declare function buildRuleOwnerKey(owner: RuleOwner): string;
111
+ /** 内容三列 + 观测向量的完整性指纹(core `ruleStoreChecksum` 同精神;**不含** rev/counter —— 那两样
112
+ * 是元数据轴,`nextDot` 只动 counter 就不该迫使重算内容指纹)。
113
+ *
114
+ * 导出(纯数据 ⇒ `build*`)是为了让**取证测试**能造一条「指纹自洽但语义矛盾」的行 —— 那正是
115
+ * codex round1 [high] 二的形:一条只靠指纹是拦不住的坏行,必须由语义筛拦。 */
116
+ export declare function buildRuleBucketChecksum(body: {
117
+ rules: readonly unknown[];
118
+ tombstones: readonly unknown[];
119
+ quarantined: readonly unknown[];
120
+ observedVector?: RuleSyncFrontier;
121
+ }): string;
122
+ /**
123
+ * SQL 双方言的 {@link PermissionRuleStoreProvider}。
124
+ *
125
+ * `forPrincipal(undefined)` 恒解析成**零规则**店(core 硬条款:未鉴权任务解析到零规则而不是一只共享桶
126
+ * —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
127
+ */
128
+ export declare class SqlPermissionRuleStoreProvider implements PermissionRuleStoreProvider {
129
+ private readonly db;
130
+ private readonly now;
131
+ constructor(db: SqlDriver, now?: () => number);
132
+ forPrincipal(principal: string | undefined): PermissionRuleStore;
133
+ forLocalOwner(): PermissionRuleStore;
134
+ }
135
+ export declare function createTiDBPermissionRuleStoreProvider(pool: MySqlPool, now?: () => number): SqlPermissionRuleStoreProvider;
136
+ export declare function createPgPermissionRuleStoreProvider(pool: PgPool, now?: () => number): SqlPermissionRuleStoreProvider;
137
+ /**
138
+ * durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed,
139
+ * 只比 state 的两次并发重试会**都**认为自己看到了预期状态,各铸一个 dot、各写一次,后者静默覆盖前者的
140
+ * `redeemedDots`。那正是「一次同意变成两个 add、一条删掉的规则复活」的机制。
141
+ */
142
+ export declare class SqlRuleApprovalRecordStore implements RuleApprovalRecordStore {
143
+ private readonly db;
144
+ private readonly now;
145
+ constructor(db: SqlDriver, now?: () => number);
146
+ private q;
147
+ private enc;
148
+ get(id: string): Promise<RuleApprovalRecord | undefined>;
149
+ create(record: RuleApprovalRecord): Promise<void>;
150
+ /**
151
+ * 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
152
+ *
153
+ * 用处只有一个:`prepareCcImport` 会在**返回预览之前**就把 pending 记录落盘,所以「预览超帽、这次导入
154
+ * 不做了」这条路会留下一条永远没人要的行。`WHERE state = 'pending'` 是硬的 —— 已确认/已兑付的记录是
155
+ * 一次真人同意的**审计事实**,任何路径都不许把它删掉(那条轴上的清理属于保留期策略,不是本方法)。
156
+ * 不是 core `RuleApprovalRecordStore` 的成员(那个接口只有 get/cas/create),故命名上与它区分。
157
+ */
158
+ discardPendingRecord(recordId: string): Promise<boolean>;
159
+ /** compare-and-set on `rev`(core 硬条款)。`next.rev` 必须是 `expectRev + 1`;不是 ⇒ 响亮拒绝
160
+ * (一个不推进的 CAS 会让两次写互相覆盖而两边都以为自己赢了)。 */
161
+ cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
162
+ }
163
+ /** 一张已铸的票(呈给调用方的形;`payload` 是 prepare 时刻的候选快照)。 */
164
+ export interface RuleImportTicket {
165
+ ticketId: string;
166
+ approvalId: string;
167
+ payloadHash: string;
168
+ expiresAtMs: number;
169
+ }
170
+ /** 消费结果。**四种拒绝合并成一个 `refused` 形**是刻意的:调用点把它们全部翻成同一个 404,
171
+ * 于是「这张票不存在」「它是别人的」「它过期了」「它已经用过了」在 wire 上不可区分(禁存在性 oracle)。 */
172
+ export type RuleTicketRedeemResult = {
173
+ ok: true;
174
+ approvalId: string;
175
+ payloadHash: string;
176
+ } | {
177
+ ok: false;
178
+ reason: "unknown" | "wrong-principal" | "expired" | "consumed";
179
+ };
180
+ /** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 */
181
+ export declare function buildRulePayloadHash(candidates: ReadonlyArray<{
182
+ rule: string;
183
+ scope: RuleScope;
184
+ }>): string;
185
+ export declare class SqlRuleImportTicketStore {
186
+ private readonly db;
187
+ private readonly now;
188
+ constructor(db: SqlDriver, now?: () => number);
189
+ private q;
190
+ mint(input: {
191
+ ticketId: string;
192
+ principal: string;
193
+ approvalId: string;
194
+ candidates: ReadonlyArray<{
195
+ rule: string;
196
+ scope: RuleScope;
197
+ }>;
198
+ ttlMs: number;
199
+ }): Promise<RuleImportTicket>;
200
+ /**
201
+ * **一次性原子认领**(F1 ③)。四个条件全部写在 `WHERE` 里 —— ticket 身份、principal 绑定、未过期、
202
+ * 未认领 —— 于是并发双 redeem 由引擎的行锁裁决,恰一条 `affected === 1`。
203
+ *
204
+ * 🔴 **认领的 UPDATE 是这条方法的最后一次 I/O**(codex 交叉复审 round4 [high],验真后修)。
205
+ * 旧形是「先 UPDATE 认领、再 SELECT 取 approvalId/payloadHash」——那条 SELECT 失败时本方法**抛错**,
206
+ * 而调用方(`redeemImport`)此时还没进它的恢复 `try`,于是**没有任何人去放回认领**:票被永久烧掉,
207
+ * 重试收到不可重试的 404,人的确认凭空消失。改法不是加一层 catch,而是**把那次读挪到认领之前**:
208
+ * `approval_id` / `payload_hash` 自铸票起就是**不可变**列,先读与后读读到的是同一份字节。
209
+ *
210
+ * 🔴 **这仍然不是 read-then-write**(禁令的语义没有松动):**裁决**依旧只由那条条件 `UPDATE` 的
211
+ * `WHERE` 做出 —— 预读的三条否定项只用来**短路 + 归因**(把拒绝分成 unknown/wrong-principal/
212
+ * expired/consumed 四类给服务端日志;wire 面把四类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
213
+ * 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
214
+ */
215
+ consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
216
+ /**
217
+ * **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。
218
+ *
219
+ * `consume` 是一次**认领**(claim),不是「这件事已经做完了」。认领之后还有三步会失败:读记录、
220
+ * 载荷比对、confirm+批量兑付。旧形把认领当终局 ⇒ 一次数据库抖动就让票**永久**烧掉,而调用方拿到的
221
+ * 是与「票不存在」同形的 404:重试无门、最终状态对客户端不可判。
222
+ *
223
+ * ⇒ 认领之后**只有裁定性拒绝**才留着消费位(载荷被篡改、记录不存在 —— 重试不改判);**不确定/瞬时**
224
+ * 的失败(store 抛错、CAS 冲突)把认领**放回去**,让属主重试。放回去不会打开双兑付的门:同一时刻
225
+ * 只有一个持有者(认领本身仍是单条条件 UPDATE),而兑付腿(`redeemRuleBatch`)按 core 的设计是
226
+ * **幂等**的(记录里存着每个候选已铸的 dot,重放只会重放同一个 dot)。
227
+ *
228
+ * 残留(如实登记,不假称已解):认领与释放之间**硬崩**会把票留在已认领态 —— 它随 TTL 自然消失,
229
+ * 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
230
+ * 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
231
+ */
232
+ release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
233
+ private readRow;
234
+ }
235
+ /** 三个 SQL 店的一次性装配束(one driver, three faces)。 */
236
+ export interface PermissionRuleStores {
237
+ provider: SqlPermissionRuleStoreProvider;
238
+ approvals: SqlRuleApprovalRecordStore;
239
+ tickets: SqlRuleImportTicketStore;
240
+ }
241
+ export declare function createSqlPermissionRuleStores(db: SqlDriver, now?: () => number): PermissionRuleStores;
242
+ //# sourceMappingURL=permission-rule-store-sql.d.ts.map