@sema-agent/server 7.11.0 → 7.13.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 (78) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +86 -2
  3. package/dist/adoption/plan.d.ts +38 -4
  4. package/dist/adoption/plan.js +72 -0
  5. package/dist/adoption/quiesce.d.ts +70 -0
  6. package/dist/adoption/quiesce.js +148 -0
  7. package/dist/adoption/runner.js +63 -5
  8. package/dist/adoption/sql.d.ts +15 -0
  9. package/dist/adoption/sql.js +18 -0
  10. package/dist/adoption/wire.d.ts +7 -1
  11. package/dist/adoption/wire.js +6 -0
  12. package/dist/approval-card.d.ts +5 -0
  13. package/dist/approval-card.js +22 -0
  14. package/dist/auth-keys.d.ts +28 -4
  15. package/dist/auth-keys.js +60 -15
  16. package/dist/boot/parked-revive-gate.d.ts +18 -2
  17. package/dist/boot/parked-revive-gate.js +136 -14
  18. package/dist/boot/permission-rules-audit.d.ts +49 -0
  19. package/dist/boot/permission-rules-audit.js +85 -0
  20. package/dist/boot/reapers.d.ts +15 -0
  21. package/dist/boot/reapers.js +101 -44
  22. package/dist/boot/resolve-spec.js +43 -12
  23. package/dist/boot/runner-deps.d.ts +16 -2
  24. package/dist/boot/runner-deps.js +5 -4
  25. package/dist/budget.js +22 -0
  26. package/dist/config-types.d.ts +31 -11
  27. package/dist/config.d.ts +28 -2
  28. package/dist/config.js +348 -79
  29. package/dist/governance-ask-marks.js +8 -2
  30. package/dist/http/active-run-conflict.d.ts +33 -8
  31. package/dist/http/active-run-conflict.js +37 -2
  32. package/dist/http/route-ctx.d.ts +6 -3
  33. package/dist/http/routes/adoption.js +25 -2
  34. package/dist/http/routes/approvals-assistant.js +35 -4
  35. package/dist/http/routes/capabilities.js +69 -10
  36. package/dist/http/routes/images.js +18 -0
  37. package/dist/http/routes/rules.d.ts +19 -7
  38. package/dist/http/routes/rules.js +180 -4
  39. package/dist/http/routes/runs.js +21 -5
  40. package/dist/http/routes/tasks.js +18 -6
  41. package/dist/http/server.d.ts +30 -10
  42. package/dist/http/server.js +183 -19
  43. package/dist/http/wire-types.d.ts +48 -0
  44. package/dist/main.js +65 -7
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +2 -1
  48. package/dist/observability/tool-trace.d.ts +5 -1
  49. package/dist/observability/tool-trace.js +33 -6
  50. package/dist/parked-decide.d.ts +13 -3
  51. package/dist/parked-decide.js +10 -1
  52. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  53. package/dist/plugins/adoption-log-sql.js +69 -2
  54. package/dist/plugins/file-run-store.d.ts +85 -1
  55. package/dist/plugins/file-run-store.js +450 -17
  56. package/dist/plugins/permission-rule-store-file.d.ts +83 -0
  57. package/dist/plugins/permission-rule-store-file.js +371 -0
  58. package/dist/plugins/permission-rule-store-sql.d.ts +52 -0
  59. package/dist/plugins/permission-rule-store-sql.js +71 -2
  60. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  61. package/dist/plugins/shared-memory-store-sql.js +55 -18
  62. package/dist/plugins/sql-driver.d.ts +19 -0
  63. package/dist/plugins/sql-driver.js +12 -0
  64. package/dist/plugins/store-backend.d.ts +12 -6
  65. package/dist/plugins/store-backend.js +82 -10
  66. package/dist/rules-consent.d.ts +98 -1
  67. package/dist/rules-consent.js +84 -1
  68. package/dist/run-local.js +126 -15
  69. package/dist/runtime-governance.d.ts +33 -0
  70. package/dist/runtime-governance.js +41 -3
  71. package/dist/task-settings.d.ts +44 -0
  72. package/dist/task-settings.js +57 -1
  73. package/dist/tool-approval.d.ts +38 -1
  74. package/dist/tool-approval.js +125 -26
  75. package/dist/trace/core-keyset-guard.d.ts +14 -3
  76. package/dist/trace/project.d.ts +19 -2
  77. package/dist/trace/project.js +24 -4
  78. package/package.json +3 -3
@@ -0,0 +1,371 @@
1
+ /**
2
+ * #203 §1(design/203 v2 §6 F1)—— 持久化权限规则店的 **local(File)三面束**。
3
+ *
4
+ * 车二在 `store-backend.ts` 上写下的那句「local 车道诚实缺席」有一条**明写的解除条件**:「要在 local
5
+ * 真上,先落 File 形(core 现成 `FilePermissionRuleStoreProvider` 即可当模子)」。本文件就是那一件 ——
6
+ * 三面里**规则桶那一面直接接线 core 的现成件**(不是照抄:它自带符号链接拒收、写锁、整文件校验和、
7
+ * 损坏即整份拒读,那几样都不是能顺手复刻对的东西),另外两面(审批记录 / 导入票)core 只给了
8
+ * `InMemory` 参照物,所以在这里落 File 形。
9
+ *
10
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
11
+ * 🔴 为什么 File 形的「单进程内 CAS」是**够的**(而不是一次偷工)
12
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
13
+ * SQL 形把 CAS 交给引擎的行锁,是因为多副本共享一张表。local 车道不是那个形状:`LocalBackend` 的构造
14
+ * 函数在数据根上取 `root/LOCK` 这只 boot pidfile 锁,**同主机第二个实例起不来**(store-backend.ts 的
15
+ * `LocalBackend` 头注逐字);core 的 `FilePermissionRuleStoreProvider` 自己还在规则目录上另取一把写锁,
16
+ * 第二个持有者被响亮拒绝而不是交错写。⇒ 在这条车道上「同一时刻只有一个写者」是**被强制**的,不是被
17
+ * 假设的,于是进程内的 `rev` 比较就是一次真 compare-and-set。
18
+ * 这条推理有一个已登记的边界(与 core 的 File 规则店同源、不是本文件新增的):跨 PID 命名空间或 NFS
19
+ * 上 `process.kill(pid,0)` 的活体判据会失效(见 `docs/DEPLOY-PREREQS.md`)。那时该换 SQL 后端 ——
20
+ * 这正是 store seam 存在的理由。
21
+ *
22
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
23
+ * 🔴 落盘形:一票据/一记录一行 JSONL 追加日志 + boot 重放,**不是**整文件覆写
24
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
25
+ * 与 `FileApprovalExemptionStore` / `FileSendFileLedger` 同一个模子(core 的 `AppendLog` /
26
+ * `readJsonlRecords`:追加 + fsync,撕裂的尾行在重放时被丢掉而不是被猜出来)。选它而不是「整份 JSON
27
+ * 原子改名」的理由是**这两面的写都是逐条的**(一次审批一条、一张票一条),整份覆写会让一次写的代价随
28
+ * 历史长度线性涨,而且把「两条无关记录」放进同一次 CAS 窗口。
29
+ * 代价如实登记:日志只增不减(没有紧凑腿)。票有 TTL、记录是审计事实,单用户一台机器上的量级是每天
30
+ * 几条到几十条 —— 真长到要紧凑时,该做的是紧凑腿(后续件),不是现在为它牺牲崩溃安全。
31
+ *
32
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
33
+ * 🔴 状态在**内存索引**里,磁盘是它的重放源
34
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
35
+ * 判决(CAS 成不成、票能不能认领)一律读内存索引,写成功后**先追加日志再改索引** —— 反过来(先改索引
36
+ * 再写盘)会在写失败时留下一个「进程认为已经发生、盘上没有」的事实,重启即回滚,而中间那段时间里
37
+ * 一次已经被消费掉的票会被当成还能用。
38
+ */
39
+ import { join } from "node:path";
40
+ import { readdirSync } from "node:fs";
41
+ import { FilePermissionRuleStoreProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
42
+ import { z } from "zod";
43
+ import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
44
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
45
+ // 落盘记录的边界 schema(宪法 [2704]:边界必 schema,禁裸 as-cast)
46
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
47
+ //
48
+ // 🔴 这**是**一条边界:磁盘上的字节可能来自上一个版本、来自一次手改、来自一次撕裂写。读回来的东西
49
+ // 若被 as-cast 成域类型,一条缺字段的记录会带着 `undefined` 走进 CAS 与兑付判决。判据用 zod。
50
+ //
51
+ // 🔴 读不出形的**内部**行 ⇒ 整面 fail-closed(codex 对抗复审 R1-F4,验真后修)。
52
+ // 本文件的初版写的是「坏行跳过 —— 一行是一条独立事实」,那句话是**错的**,而且错在最贵的地方:
53
+ // 票日志上的 mint / consume / release 是**同一张票的状态迁移**。一条 `consume` 坏掉而它前面的 `mint`
54
+ // 还读得出 ⇒ 重启后那张票复活成**未认领**,「一次性消费」这条信任边界当场失效(SQL 形靠引擎行锁,
55
+ // 永远不会有这种形)。审批记录同族:一条坏掉的快照会把记录连同 `rev` 回滚到更早的状态。
56
+ // 处置与 core 对**规则文件**的姿势对齐 ——「refusing the whole bucket rather than reporting a partial
57
+ // rule set」:整面拒 + 响亮留痕。失败方向是收紧(兑不动 ⇒ 多问几次),这正是这条轴该倒的方向。
58
+ // **撕裂的尾行不算**:core 的 `readJsonlRecords` 按崩溃尾丢弃它并且**不**报 `onCorrupt` —— 那是每次
59
+ // 非优雅退出的正常落盘形,把它算成损坏会让一次 kill -9 永久锁死这条车道。
60
+ const RuleScopeSchema = z.union([
61
+ z.object({ kind: z.literal("global") }).strict(),
62
+ z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
63
+ ]);
64
+ const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
65
+ const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
66
+ const ApprovalRecordSchema = z
67
+ .object({
68
+ id: z.string().min(1),
69
+ principal: z.string().optional(),
70
+ owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
71
+ kind: z.enum(["card", "import", "starter"]),
72
+ state: z.enum(["pending", "approved", "redeemed"]),
73
+ candidates: z.array(RuleCandidateSchema),
74
+ createdAt: z.string(),
75
+ toolCallId: z.string().optional(),
76
+ boundInputHash: z.string().optional(),
77
+ rev: z.number().int().nonnegative().safe(),
78
+ selectedCandidate: z.number().int().nonnegative().safe().optional(),
79
+ redeemedDots: z.record(z.string(), RuleDotSchema).optional(),
80
+ })
81
+ .strict();
82
+ /**
83
+ * 校验产物 → 域类型的**显式**投影。
84
+ *
85
+ * 🔴 为什么是一个函数而不是 `.transform(...)` 上挂一个断言:`RuleApprovalRecord.redeemedDots` 的键是
86
+ * `number`,而 JSON 对象的键恒是字符串 —— zod 只表达得出 `Record<string, …>`,于是「用 zod 直接声明成
87
+ * 域类型」必然要一次 `as unknown as`,那正是宪法禁的裸断言。逐字段抄写换来的是:core 给
88
+ * `RuleApprovalRecord` 加一个必填字段时,**这里编译红**(而断言形会静默放行一条缺字段的记录)。
89
+ * 数字键的还原与 SQL 侧 `redeemed_dots_json` 的回读逐字同一处置。
90
+ */
91
+ function toApprovalRecord(row) {
92
+ return {
93
+ id: row.id,
94
+ ...(row.principal !== undefined ? { principal: row.principal } : {}),
95
+ ...(row.owner !== undefined ? { owner: row.owner } : {}),
96
+ kind: row.kind,
97
+ state: row.state,
98
+ candidates: row.candidates,
99
+ createdAt: row.createdAt,
100
+ ...(row.toolCallId !== undefined ? { toolCallId: row.toolCallId } : {}),
101
+ ...(row.boundInputHash !== undefined ? { boundInputHash: row.boundInputHash } : {}),
102
+ rev: row.rev,
103
+ ...(row.selectedCandidate !== undefined ? { selectedCandidate: row.selectedCandidate } : {}),
104
+ ...(row.redeemedDots !== undefined
105
+ ? { redeemedDots: Object.fromEntries(Object.entries(row.redeemedDots).map(([k, v]) => [Number(k), v])) }
106
+ : {}),
107
+ };
108
+ }
109
+ /** 审批记录日志的一行:整条记录的快照(last-wins 重放)或一条丢弃墓碑。 */
110
+ const ApprovalLineSchema = z.union([
111
+ z.object({ op: z.literal("put"), record: ApprovalRecordSchema }).strict(),
112
+ z.object({ op: z.literal("discard"), id: z.string().min(1) }).strict(),
113
+ ]);
114
+ /** 票日志的一行:铸票 / 认领 / 放回。三个动作各一行,重放顺序即真相。 */
115
+ const TicketLineSchema = z.union([
116
+ z
117
+ .object({
118
+ op: z.literal("mint"),
119
+ ticketId: z.string().min(1),
120
+ ownerKey: z.string().min(1),
121
+ approvalId: z.string().min(1),
122
+ payloadHash: z.string().min(1),
123
+ expiresAtMs: z.number().int().nonnegative().safe(),
124
+ })
125
+ .strict(),
126
+ z.object({ op: z.literal("consume"), ticketId: z.string().min(1), atMs: z.number().int().nonnegative().safe() }).strict(),
127
+ z.object({ op: z.literal("release"), ticketId: z.string().min(1) }).strict(),
128
+ ]);
129
+ /**
130
+ * 一面的**损坏闸**。置位之后该面的每一个方法都响亮拒绝 —— 读与写都拒:一份不完整的授权账既不能用来
131
+ * 判决,也不能在它上面继续追加(追加只会让下一次重启读到同样残缺的历史)。
132
+ */
133
+ class CorruptionGate {
134
+ face;
135
+ path;
136
+ onError;
137
+ reason;
138
+ constructor(face, path, onError) {
139
+ this.face = face;
140
+ this.path = path;
141
+ this.onError = onError;
142
+ }
143
+ /** 记一次内部损坏(幂等:第一条原因就是要报告的那条)。 */
144
+ trip(reason) {
145
+ this.reason ??= reason;
146
+ this.onError?.(`${this.face} at ${this.path} is corrupt: ${reason}`);
147
+ }
148
+ get tripped() {
149
+ return this.reason !== undefined;
150
+ }
151
+ /** 每个方法的第一行。文案里带上文件路径与处置 —— 一个运维读到它要知道去看哪个文件。 */
152
+ assertUsable() {
153
+ if (this.reason === undefined)
154
+ return;
155
+ throw new Error(`refusing to use a corrupt ${this.face} (${this.path}): ${this.reason} — ` +
156
+ "the permission-rule lane is fail-closed until a person inspects the file (moving it aside restarts the lane with an empty log)");
157
+ }
158
+ }
159
+ /**
160
+ * `RuleApprovalRecordStore` 的 File 形。
161
+ *
162
+ * CAS 按 `rev`(core 硬条款,理由逐字见 SQL 侧同名类的头注:只比 state 会让批记录的第二个候选上两次
163
+ * 并发重试都以为自己赢了)。
164
+ */
165
+ export class FileRuleApprovalRecordStore {
166
+ rows = new Map();
167
+ log;
168
+ gate;
169
+ constructor(dir, onError) {
170
+ ensureDir(dir);
171
+ const path = join(dir, "approvals.jsonl");
172
+ this.gate = new CorruptionGate("permission-rule approval log", path, onError);
173
+ // `onCorrupt` = core 对**内部**不可解析行的报告口(崩溃尾它自己丢弃且不报)。不接它就是把一次
174
+ // 「历史被削掉一块」变成静默 —— #191 门② 说的正是这个形。
175
+ for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
176
+ const parsed = ApprovalLineSchema.safeParse(raw);
177
+ if (!parsed.success) {
178
+ // 形不对 = 与「解析不出」同一类事实(版本漂/手改),同样是历史缺了一块 ⇒ 同样 fail-closed。
179
+ this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
180
+ continue;
181
+ }
182
+ if (parsed.data.op === "discard")
183
+ this.rows.delete(parsed.data.id);
184
+ else
185
+ this.rows.set(parsed.data.record.id, toApprovalRecord(parsed.data.record));
186
+ }
187
+ this.log = new AppendLog(path);
188
+ }
189
+ /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
190
+ get corrupt() {
191
+ return this.gate.tripped;
192
+ }
193
+ /** 归还本面 eager 持有的日志描述符(束的 `dispose()` 唯一调用点)。`AppendLog` 上没有 finalizer,
194
+ * 不显式关就是一只跟到进程末尾的 fd —— `LocalBackend` 反复开合(热重载/多根)时按次泄漏。
195
+ * 关后写面抛 `log_closed`(core 语义):**不重开**,因为「这只店已经交还」与「这条命还能写」
196
+ * 不能两立,静默重开会让一次 dispose 之后的写落进一份没人再读的日志。 */
197
+ close() {
198
+ this.log.close();
199
+ }
200
+ async get(id) {
201
+ this.gate.assertUsable();
202
+ const r = this.rows.get(id);
203
+ // 深拷贝出门:调用方(core 的兑付腿)会就地改它再交回 `cas`,共享同一个对象会让 CAS 比的是
204
+ // 「自己改过的那份」——那等于没有 CAS。
205
+ return r === undefined ? undefined : structuredClone(r);
206
+ }
207
+ async create(record) {
208
+ this.gate.assertUsable();
209
+ if (this.rows.has(record.id))
210
+ throw new Error(`rule approval record ${record.id} already exists`);
211
+ this.log.append({ op: "put", record }, true); // 先盘后索引(顶注)
212
+ this.rows.set(record.id, structuredClone(record));
213
+ }
214
+ async cas(id, expectRev, next) {
215
+ this.gate.assertUsable();
216
+ if (next.rev !== expectRev + 1) {
217
+ throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
218
+ }
219
+ const cur = this.rows.get(id);
220
+ if (cur === undefined || cur.rev !== expectRev)
221
+ return false;
222
+ this.log.append({ op: "put", record: next }, true);
223
+ this.rows.set(id, structuredClone(next));
224
+ return true;
225
+ }
226
+ /** 与 SQL 侧同名方法同义(超帽拒绝时收掉 `prepareCcImport` 已落盘的那条 pending 记录)。
227
+ * `state === "pending"` 是硬的:已确认/已兑付的记录是一次真人同意的审计事实。 */
228
+ async discardPendingRecord(recordId) {
229
+ this.gate.assertUsable();
230
+ const cur = this.rows.get(recordId);
231
+ if (cur === undefined || cur.state !== "pending")
232
+ return false;
233
+ this.log.append({ op: "discard", id: recordId }, true);
234
+ this.rows.delete(recordId);
235
+ return true;
236
+ }
237
+ }
238
+ /**
239
+ * CC 导入票的 File 形。四条信任边界(principal 绑定 / TTL / 一次性原子消费 / 载荷绑定)与 SQL 形
240
+ * **同语义**;唯一不同的是「原子」由谁保证 —— 那边是引擎行锁,这边是单进程 + 单写者(顶注)。
241
+ */
242
+ export class FileRuleImportTicketStore {
243
+ rows = new Map();
244
+ log;
245
+ gate;
246
+ constructor(dir, now, onError) {
247
+ this.now = now ?? Date.now;
248
+ ensureDir(dir);
249
+ const path = join(dir, "import-tickets.jsonl");
250
+ this.gate = new CorruptionGate("permission-rule import-ticket log", path, onError);
251
+ // 🔴 这一面是四条信任边界里「一次性原子消费」的**唯一**载体(SQL 形靠引擎行锁,File 形靠这份日志)。
252
+ // 内部坏行 ⇒ 整面 fail-closed:一张票复活成未认领,代价是一次人的同意被重复兑付。
253
+ for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
254
+ const parsed = TicketLineSchema.safeParse(raw);
255
+ if (!parsed.success) {
256
+ this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
257
+ continue;
258
+ }
259
+ const line = parsed.data;
260
+ if (line.op === "mint") {
261
+ this.rows.set(line.ticketId, { ownerKey: line.ownerKey, approvalId: line.approvalId, payloadHash: line.payloadHash, expiresAtMs: line.expiresAtMs });
262
+ continue;
263
+ }
264
+ const row = this.rows.get(line.ticketId);
265
+ if (row === undefined)
266
+ continue;
267
+ if (line.op === "consume")
268
+ row.consumedAtMs = line.atMs;
269
+ else
270
+ delete row.consumedAtMs;
271
+ }
272
+ this.log = new AppendLog(path);
273
+ }
274
+ now;
275
+ /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
276
+ get corrupt() {
277
+ return this.gate.tripped;
278
+ }
279
+ /** 归还本面 eager 持有的日志描述符(理由与姊妹面 {@link FileRuleApprovalRecordStore.close} 逐字同源)。 */
280
+ close() {
281
+ this.log.close();
282
+ }
283
+ async mint(input) {
284
+ this.gate.assertUsable();
285
+ const expiresAtMs = this.now() + input.ttlMs;
286
+ // 摘要函数与 SQL 形**同一个**(载荷绑定的等式两侧同源;两份实现必然各自漂)。
287
+ const payloadHash = buildRulePayloadHash(input.candidates);
288
+ // owner 键:本车道只铸 principal 形的票(local-owner 桶没有跨网络的导入口)。刻意存**明文
289
+ // principal** 而不是 SQL 侧那把 sha —— 那把 hash 的两条理由(列宽静默截断、local-owner 需要定长
290
+ // 同域表示)在一份进程内 Map 上都不成立,而明文让一次取证直接读得懂。
291
+ this.log.append({ op: "mint", ticketId: input.ticketId, ownerKey: input.principal, approvalId: input.approvalId, payloadHash, expiresAtMs }, true);
292
+ this.rows.set(input.ticketId, { ownerKey: input.principal, approvalId: input.approvalId, payloadHash, expiresAtMs });
293
+ return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
294
+ }
295
+ /** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
296
+ * 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
297
+ async consume(ticketId, principal) {
298
+ this.gate.assertUsable();
299
+ const row = this.rows.get(ticketId);
300
+ if (row === undefined)
301
+ return { ok: false, reason: "unknown" };
302
+ if (row.ownerKey !== principal)
303
+ return { ok: false, reason: "wrong-principal" };
304
+ if (row.consumedAtMs !== undefined)
305
+ return { ok: false, reason: "consumed" };
306
+ const nowMs = this.now();
307
+ if (row.expiresAtMs <= nowMs)
308
+ return { ok: false, reason: "expired" };
309
+ this.log.append({ op: "consume", ticketId, atMs: nowMs }, true);
310
+ row.consumedAtMs = nowMs;
311
+ return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
312
+ }
313
+ /** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
314
+ * (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
315
+ async release(ticketId, principal, mustRemainValidMs = 0) {
316
+ this.gate.assertUsable();
317
+ const row = this.rows.get(ticketId);
318
+ if (row === undefined || row.ownerKey !== principal || row.consumedAtMs === undefined)
319
+ return false;
320
+ if (row.expiresAtMs <= this.now() + mustRemainValidMs)
321
+ return false;
322
+ this.log.append({ op: "release", ticketId }, true);
323
+ delete row.consumedAtMs;
324
+ return true;
325
+ }
326
+ }
327
+ /**
328
+ * 装配 local 三面束。
329
+ *
330
+ * 🔴 `provider` 必须是**单例**(F1):core 的 `FilePermissionRuleStoreProvider` 在第一次取写面时对规则
331
+ * 目录取一把进程级写锁,每次 `new` 一个就是第二个持有者 —— 而它对第二个持有者是**响亮拒绝**,不是
332
+ * 排队。所以束在 `LocalBackend` 上按字段持有,`LocalBackend.close()` 调 `dispose()` 释放
333
+ * (不释放 ⇒ 一次优雅重启会被自己上一条命留下的锁挡在门外,与数据根 `root/LOCK` 同一个病)。
334
+ *
335
+ * `onError` 接的是 core 规则文件的**披露面**(读不出来 / 校验和不符 / 撞上符号链接时它答零规则并
336
+ * 说出来)。接住它打一条 warn 是**必须**的:那条路径上「零规则」与「真的没有规则」在读面上同形,
337
+ * 不留痕就变成一次静默的 fail-closed(用户会突然被反复询问,却没有任何线索)。
338
+ */
339
+ export function createFilePermissionRuleStores(root, opts) {
340
+ const dir = join(root, "permission-rules");
341
+ ensureDir(dir);
342
+ const provider = opts?.onError ? new FilePermissionRuleStoreProvider(dir, opts.onError) : new FilePermissionRuleStoreProvider(dir);
343
+ // 三面共用**同一个**告警口:core 规则文件的披露与本仓两份日志的损坏对运维是同一件事
344
+ // (「这台机器上的规则面出问题了,去看这个目录」),分成两个 sink 只会让其中一个没人接。
345
+ const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
346
+ const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError);
347
+ return {
348
+ provider,
349
+ approvals,
350
+ tickets,
351
+ /** 桶数 = 规则目录里的桶文件数(core 的命名:`<64 hex>.json` = 一只 principal 桶,
352
+ * `local-owner.json` = 身份缺席桶)。**刻意不数**审批/票的日志文件与收编标记文件 ——
353
+ * 审计问的是「有没有既有的规则状态」,不是「这个目录里有几个文件」。
354
+ *
355
+ * 🔴 读不出目录就**抛**(不 catch 成 0)。目录在装配时刚 `ensureDir` 过,读不动只可能是被外力
356
+ * 删掉/权限被改 —— 那是「不知道」,不是「零」。回 0 会被休眠行审计读成「确认没有休眠行」,把一次
357
+ * 读失败伪装成一个结论;审计那侧本来就有 fail-open 臂(warn + 不拒启),让它去处置才是对的分工。 */
358
+ countBuckets: async () => readdirSync(dir).filter((n) => n === "local-owner.json" || /^[0-9a-f]{64}\.json$/.test(n)).length,
359
+ /** 三面各自的释放,**两只日志先于 provider**:provider 那步释放的是规则目录的进程级写锁,
360
+ * 一旦它先松手,同一个根就可能被下一位持有者开起来 —— 而此刻本束的两只 fd 还开着。顺序写死
361
+ * 在这里,`LocalBackend.close()` 只需调这一只口(与它对其余 file 店逐行关闭的既有纪律同形)。
362
+ * 幂等:两只 `close()` 与 `provider.dispose()` 都可重入(重复调用是常态 —— 兜底路径与
363
+ * `LocalBackend.close()` 会各调一次)。 */
364
+ dispose: () => {
365
+ approvals.close();
366
+ tickets.close();
367
+ provider.dispose();
368
+ },
369
+ };
370
+ }
371
+ //# sourceMappingURL=permission-rule-store-file.js.map
@@ -232,11 +232,63 @@ export declare class SqlRuleImportTicketStore {
232
232
  release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
233
233
  private readRow;
234
234
  }
235
+ /**
236
+ * 孤儿 pending 审批记录的保留期(A-010.17)。
237
+ *
238
+ * 判据是「**可证已死**」,不是一个拍脑袋的时长:一条 pending 记录只能经 `redeemRuleTicket` 走活,
239
+ * 而兑付要么发生在铸它的**那一次请求内**(`persistCardRule` 的 prepare→confirm→redeem 三步同请求),
240
+ * 要么要拿一张导入票 —— 而票自铸起最多活 {@link RULE_IMPORT_TICKET_TTL_MS}(10 分钟,`consume` 的
241
+ * `expires_at_ms > now` 是硬条件)。所以创建时刻早于「now − 票 TTL」的 pending 记录**再也不可能**
242
+ * 被兑付。24 小时是在这条上界之上再压两个数量级的余量,留给运维「昨天那次导入怎么没成」的排查窗。
243
+ *
244
+ * 🔴 **只收 pending**。`approved` / `redeemed` 是一次真人同意的**审计事实**(与 `discardPendingRecord`
245
+ * 的 `WHERE state = 'pending'` 同一条硬约束,也与收编把这张表判成 D2「史实不改写」同源)——
246
+ * 保留期策略动不到它们,本腿一行都不碰。
247
+ */
248
+ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
249
+ /**
250
+ * 保留期腿**每轮**最多删多少行(codex R2 [high])。
251
+ *
252
+ * 为什么必须有界:`permission_rule_approval` 按设计**永久**留 approved/redeemed 的审计事实(收编把它
253
+ * 判成 D2「史实不改写」,`discardPendingRecord` 的 `WHERE state='pending'` 也是同一条硬约束)——
254
+ * 也就是说这张表**只增不减**。一条无界 DELETE 在首次开清扫、或一次长期没跑的部署上,会在一个事务里
255
+ * 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数
256
+ * (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。
257
+ *
258
+ * ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库
259
+ * 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一
260
+ * 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗
261
+ * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
262
+ */
263
+ export declare const RULE_REAP_BATCH = 500;
235
264
  /** 三个 SQL 店的一次性装配束(one driver, three faces)。 */
236
265
  export interface PermissionRuleStores {
237
266
  provider: SqlPermissionRuleStoreProvider;
238
267
  approvals: SqlRuleApprovalRecordStore;
239
268
  tickets: SqlRuleImportTicketStore;
269
+ /** #203 §3 —— boot 期休眠行审计的**窄读口**:库里已有几只桶(`permission_rule` 一行一桶)。
270
+ *
271
+ * 🔴 为什么数**桶**而不是数**规则**:数规则要把每一行的 `rules_json` 都读回来再解析(一只桶的规则集
272
+ * 没有硬上限,顶注 §列宽依据已说明它是 LONGTEXT),那是一次无界的 boot 期扫描 —— 为一条诊断行付这个
273
+ * 代价不划算。桶数回答的正是审计要问的那个问题(「这台部署上有没有既有的规则状态」),而且是一次
274
+ * 索引级 `COUNT(*)`。消费点(`boot/permission-rules-audit.ts`)的文案因此逐字说的是 bucket,不是 rule。 */
275
+ countBuckets(): Promise<number>;
276
+ /**
277
+ * 保留期腿(A-010.17)。返回删掉的**总行数**(两张表合计),给 reaper 的 `reapCount` 用。
278
+ *
279
+ * 病:这两张表此前**没有任何 retention 腿**,而它们都是只进不出的:
280
+ * · `permission_rule_ticket` —— 每一次 `POST /v1/rules/cc-import/prepare` 落一行,不管有没有人去
281
+ * 兑付。而普查门把它登记成「过期即死」—— 那句话描述的是**语义**(过期票 `consume` 必拒),
282
+ * 库里那一行从来没有人删。一个把 CC settings 导来导去的部署,这张表每次预览都长一行,永久。
283
+ * · `permission_rule_approval` 的 **pending** 行 —— core 在**返回预览之前**就落记录,于是「看了预览
284
+ * 没按确认」这条最常见的人类路径,每走一次留一条永远没人要的行(`discardPendingRecord` 只收
285
+ * 「超帽当场拒」那一条路,不收「人改主意了」)。
286
+ *
287
+ * 两条谓词都只删**可证已死**的行,所以本腿不需要旋钮(没有可调的语义):
288
+ * · 票:`expires_at_ms <= now` —— `consume` 的硬条件是 `expires_at_ms > now`,过期票已不可兑付;
289
+ * · 记录:`state = 'pending' AND created_at_ms < now − {@link RULE_PENDING_APPROVAL_RETENTION_MS}`。
290
+ */
291
+ reapExpired(nowMs: number): Promise<number>;
240
292
  }
241
293
  export declare function createSqlPermissionRuleStores(db: SqlDriver, now?: () => number): PermissionRuleStores;
242
294
  //# sourceMappingURL=permission-rule-store-sql.d.ts.map
@@ -190,7 +190,12 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
190
190
  created_at_ms BIGINT NOT NULL,
191
191
  updated_at_ms BIGINT NOT NULL,
192
192
  PRIMARY KEY (record_id),
193
- KEY idx_permission_rule_approval_owner (owner_key)
193
+ KEY idx_permission_rule_approval_owner (owner_key),
194
+ -- 保留期腿的**支撑索引**(A-010.17 / codex R2 [high])。这张表按设计**永久**留着 approved/redeemed
195
+ -- 的审计事实,所以它只增不减 ⇒ 一条没有索引的 WHERE state='pending' AND created_at_ms < ? 谓词
196
+ -- 是一次随审计史无限增长的全表扫,而且每 tick 一次。(state, created_at_ms) 复合序把清扫收敛成
197
+ -- 一次窄区间扫:pending 段本来就短命,超期的那几行紧挨着。
198
+ KEY idx_permission_rule_approval_sweep (state, created_at_ms)
194
199
  ) COLLATE utf8mb4_bin`,
195
200
  `CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
196
201
  ticket_id VARCHAR(190) NOT NULL,
@@ -209,7 +214,9 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
209
214
  consumed_at_ms BIGINT NULL,
210
215
  created_at_ms BIGINT NOT NULL,
211
216
  PRIMARY KEY (ticket_id),
212
- KEY idx_permission_rule_ticket_owner (owner_key)
217
+ KEY idx_permission_rule_ticket_owner (owner_key),
218
+ -- 保留期腿的支撑索引(同上):WHERE expires_at_ms <= ? 是每 tick 一次的区间扫。
219
+ KEY idx_permission_rule_ticket_expires (expires_at_ms)
213
220
  ) COLLATE utf8mb4_bin`,
214
221
  ];
215
222
  /** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
@@ -255,6 +262,10 @@ export async function ensurePgPermissionRuleSchema(q) {
255
262
  PRIMARY KEY (record_id)
256
263
  )`);
257
264
  await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_owner ON ${PERMISSION_RULE_APPROVAL_TABLE} (owner_key)`);
265
+ // 保留期腿的支撑索引(MySQL 孪生的行内注写了理由:这张表按设计永久留审计事实,无索引的清扫谓词
266
+ // 是一次随史增长的全表扫)。PG 侧是独立 CREATE INDEX —— 存量库上 `IF NOT EXISTS` 会**真的补建**,
267
+ // 与 MySQL 侧「索引写在 CREATE TABLE 里、存量表拿不到」的不对称如实登记在 reapExpired 的注里。
268
+ await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_sweep ON ${PERMISSION_RULE_APPROVAL_TABLE} (state, created_at_ms)`);
258
269
  await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
259
270
  ticket_id VARCHAR(190) COLLATE "C" NOT NULL,
260
271
  owner_key VARCHAR(190) COLLATE "C" NOT NULL,
@@ -268,6 +279,7 @@ export async function ensurePgPermissionRuleSchema(q) {
268
279
  PRIMARY KEY (ticket_id)
269
280
  )`);
270
281
  await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_owner ON ${PERMISSION_RULE_TICKET_TABLE} (owner_key)`);
282
+ await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_expires ON ${PERMISSION_RULE_TICKET_TABLE} (expires_at_ms)`);
271
283
  }
272
284
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
273
285
  // 纯函数(桶键 / 摘要 / delta 折叠)
@@ -807,11 +819,68 @@ export class SqlRuleImportTicketStore {
807
819
  };
808
820
  }
809
821
  }
822
+ /**
823
+ * 孤儿 pending 审批记录的保留期(A-010.17)。
824
+ *
825
+ * 判据是「**可证已死**」,不是一个拍脑袋的时长:一条 pending 记录只能经 `redeemRuleTicket` 走活,
826
+ * 而兑付要么发生在铸它的**那一次请求内**(`persistCardRule` 的 prepare→confirm→redeem 三步同请求),
827
+ * 要么要拿一张导入票 —— 而票自铸起最多活 {@link RULE_IMPORT_TICKET_TTL_MS}(10 分钟,`consume` 的
828
+ * `expires_at_ms > now` 是硬条件)。所以创建时刻早于「now − 票 TTL」的 pending 记录**再也不可能**
829
+ * 被兑付。24 小时是在这条上界之上再压两个数量级的余量,留给运维「昨天那次导入怎么没成」的排查窗。
830
+ *
831
+ * 🔴 **只收 pending**。`approved` / `redeemed` 是一次真人同意的**审计事实**(与 `discardPendingRecord`
832
+ * 的 `WHERE state = 'pending'` 同一条硬约束,也与收编把这张表判成 D2「史实不改写」同源)——
833
+ * 保留期策略动不到它们,本腿一行都不碰。
834
+ */
835
+ export const RULE_PENDING_APPROVAL_RETENTION_MS = 24 * 60 * 60_000;
836
+ /**
837
+ * 保留期腿**每轮**最多删多少行(codex R2 [high])。
838
+ *
839
+ * 为什么必须有界:`permission_rule_approval` 按设计**永久**留 approved/redeemed 的审计事实(收编把它
840
+ * 判成 D2「史实不改写」,`discardPendingRecord` 的 `WHERE state='pending'` 也是同一条硬约束)——
841
+ * 也就是说这张表**只增不减**。一条无界 DELETE 在首次开清扫、或一次长期没跑的部署上,会在一个事务里
842
+ * 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数
843
+ * (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。
844
+ *
845
+ * ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库
846
+ * 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一
847
+ * 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗
848
+ * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
849
+ */
850
+ export const RULE_REAP_BATCH = 500;
810
851
  export function createSqlPermissionRuleStores(db, now) {
852
+ const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
811
853
  return {
812
854
  provider: new SqlPermissionRuleStoreProvider(db, now),
813
855
  approvals: new SqlRuleApprovalRecordStore(db, now),
814
856
  tickets: new SqlRuleImportTicketStore(db, now),
857
+ reapExpired: async (nowMs) => {
858
+ // 两条 DELETE **不包事务**:它们互相独立、各自幂等,一条失败不该把另一条已删的行回滚回来
859
+ // (维护腿的既有姿势 —— 邻居 reaper 腿全是独立语句)。
860
+ //
861
+ // 🔴 **每 tick 有界**(codex 对抗复审 R2 [high],验真后修):第一版是两条**无界** DELETE。
862
+ // 首次开清扫(或一次长时间没跑的部署)会在**一个事务**里删掉整个积压 —— 一条能跑很久、锁很多行、
863
+ // 把 binlog/WAL 顶起来的语句;而调用点当时又没有重入守卫,一旦耗时越过 tick 间隔,下一轮就叠上来。
864
+ // 收成每轮 {@link RULE_REAP_BATCH} 行:积压按轮**渐进**清空(每轮都是完整语义,不留半干净状态),
865
+ // 单条语句的最坏时长与库大小脱钩。调用侧另配了 in-flight 守卫(`boot/reapers.ts`)。
866
+ const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`,
867
+ // PG 的 DELETE 没有 LIMIT ⇒ 用 ctid 子查询限行(PG 侧的标准写法;`ctid` 是物理行号,
868
+ // 子查询里带 LIMIT 才是被支持的那一形)。两方言的**语义**相同:本轮最多删这么多行。
869
+ `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs]);
870
+ const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
871
+ return deadTickets.affected + orphanPending.affected;
872
+ },
873
+ // 两个方言逐字同形(`COUNT(*)` 无方言差),所以刻意**不**走 `q(tidb, pg)` 的双串姿势 —— 那会造出
874
+ // 两份可以各自漂的同一句 SQL。参数空数组:本语句没有绑定位。
875
+ countBuckets: async () => {
876
+ const { rows } = await db.query(`SELECT COUNT(*) AS n FROM ${PERMISSION_RULE_TABLE}`, []);
877
+ // PG 的 `COUNT(*)` 走 bigint ⇒ 驱动交回**字符串**;mysql2 交回 number。`Number()` 是两侧共同的收口,
878
+ // 读不出数(列缺席/NaN)⇒ 响亮抛,绝不静默当 0(0 会被审计读成「确认没有休眠行」= 假结论)。
879
+ const n = Number(rows[0]?.["n"] ?? Number.NaN);
880
+ if (!Number.isFinite(n))
881
+ throw new Error(`${PERMISSION_RULE_TABLE} bucket count came back unreadable (${String(rows[0]?.["n"])})`);
882
+ return n;
883
+ },
815
884
  };
816
885
  }
817
886
  //# sourceMappingURL=permission-rule-store-sql.js.map
@@ -154,10 +154,15 @@ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider {
154
154
  signal?: AbortSignal;
155
155
  }): Promise<SharedMemorySnapshot>;
156
156
  /**
157
- * 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)。方言差异显式:
158
- * · MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 一个事务内的两条 SELECT 天然同快照;
159
- * · PG 默认 READ COMMITTED,**每条语句各自取快照** ⇒ 必须显式抬到 REPEATABLE READ,否则这个事务
160
- * 对本条竞态毫无作用(那正是"包了事务就以为一致"最容易踩空的地方)。
157
+ * 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)
158
+ *
159
+ * 🔴 A-010.11(两臂不对称,验真后修):此前 PG 臂显式抬隔离级别、**MySQL 臂只靠服务端默认值**,
160
+ * 注里写的是「MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 天然同快照」。那句话对**默认配置**
161
+ * 为真,但 `transaction_isolation` 是可设的服务端/会话变量:一台跑 READ COMMITTED 的实例(托管
162
+ * MySQL 的厂商默认、中间层代理、运维手改)会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,
163
+ * 而本方法**不会报任何错**——它只是读到一对撕裂的 scope/store 行。「靠默认值成立」不是结构保证。
164
+ * 两臂现在都走 {@link SqlTxConn.beginRepeatableRead}(语句文本与**摆放位置**的方言分歧写在那里:
165
+ * MySQL 必须在 BEGIN **之前**发、PG 必须在 BEGIN **之后**发)。
161
166
  */
162
167
  private readBinding;
163
168
  private readerFor;
@@ -194,12 +199,21 @@ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider {
194
199
  * 与插入之间穿过去,留下一行孤儿文档 —— 被删的内容在 id 复用时复活。
195
200
  */
196
201
  putDocument(record: SharedMemoryDocumentRecord): Promise<void>;
197
- /** Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
198
- * successor tenant's library). Returns whether a row was actually there. */
202
+ /**
203
+ * Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
204
+ * successor tenant's library). Returns whether a row was actually there.
205
+ *
206
+ * 🔴 A-010.14(验真后修):此前围栏是**两条独立语句** —— `assertOwnedBy()` 先查一次登记表,然后另
207
+ * 起一条 DELETE。它自称与 `putDocument` 对齐,但 `putDocument` 的检查与写是**同事务同行锁**
208
+ * (`FOR UPDATE`),而这里的两条语句之间有一道真窗:登记检查通过之后、DELETE 发出之前,另一个 org
209
+ * 完成 `deleteStore` + `putStore` 的 id 复用,这条 DELETE 就落进**继任租户**的库里删掉他们的文档。
210
+ * 那正是 `putDocument` 的注里写明要挡住的那一形。现在逐字照它:同一事务、对登记行 `FOR UPDATE`、
211
+ * 拿到锁之后再删。
212
+ *
213
+ * 「未登记 ⇒ 响亮抛」与「登记了但没这条文档 ⇒ 回 false」两件事仍然分家(调用方据此分支),所以
214
+ * 不能收成一条 `DELETE … WHERE EXISTS(…)`:那样 `affected === 0` 会把两种结局压成同一个读数。
215
+ */
199
216
  deleteDocument(storeId: string, scopeKey: string, path: string): Promise<boolean>;
200
- /** 属主围栏的共用断言(轮3 F1)。**不**覆盖同一个 org 内的"删了又建"代际重用——那不是跨租户面,
201
- * 真要挡住需要一枚不可变的 generation token;这里如实说明边界,不假装它被覆盖了。 */
202
- private assertOwnedBy;
203
217
  /**
204
218
  * De-register a store AND its documents, ATOMICALLY.
205
219
  *