@sema-agent/server 7.118.0 → 7.120.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 (139) hide show
  1. package/MIGRATION.md +139 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +26 -13
  5. package/deploy/sema-up/README.md +2 -0
  6. package/deploy/sema-up/chart/templates/minio.yaml +2 -1
  7. package/deploy/sema-up/chart/templates/server-v4pro.yaml +1 -0
  8. package/deploy/sema-up/chart/templates/server.yaml +1 -0
  9. package/deploy/sema-up/chart/values.yaml +7 -1
  10. package/deploy/sema-up/cluster-up.sh +20 -8
  11. package/deploy/sema-up/kube-up.sh +29 -16
  12. package/deploy/sema-up/lib/buckets.sh +21 -0
  13. package/deploy/sema-up/lib/health.sh +33 -0
  14. package/deploy/sema-up/sema-up.sh +14 -9
  15. package/deploy/sema-up/smoke.sh +16 -6
  16. package/deploy/sema-up/stack/compose.yaml +1 -1
  17. package/deploy/sema-up/stack/sema-stack-cluster.yaml +4 -1
  18. package/dist/approval-ask-audit-store.js +2 -7
  19. package/dist/approval-card.d.ts +5 -5
  20. package/dist/approval.d.ts +51 -5
  21. package/dist/approval.js +76 -13
  22. package/dist/audit.d.ts +12 -1
  23. package/dist/audit.js +62 -2
  24. package/dist/auto-mode-face.d.ts +2 -3
  25. package/dist/bake-runner/main.js +1 -6
  26. package/dist/bake-runner/protocol.d.ts +18 -0
  27. package/dist/bake-runner/protocol.js +22 -2
  28. package/dist/bake-runner/runner.d.ts +12 -8
  29. package/dist/bake-runner/runner.js +5 -9
  30. package/dist/boot/config-center.js +6 -6
  31. package/dist/boot/coordinators.js +4 -1
  32. package/dist/boot/device-lane.d.ts +3 -3
  33. package/dist/boot/device-lane.js +1 -2
  34. package/dist/boot/execution-env.js +3 -2
  35. package/dist/boot/fatal-exit.d.ts +7 -1
  36. package/dist/boot/fatal-exit.js +2 -1
  37. package/dist/boot/leader.d.ts +4 -4
  38. package/dist/boot/leader.js +1 -2
  39. package/dist/boot/parked-revive-gate.d.ts +5 -1
  40. package/dist/boot/parked-revive-gate.js +8 -2
  41. package/dist/boot/resolve-spec.js +7 -4
  42. package/dist/boot/runner-deps.d.ts +4 -3
  43. package/dist/boot/runner-deps.js +7 -3
  44. package/dist/boot/stage-01-config.js +2 -0
  45. package/dist/boot/stage-07-capability-layer.js +5 -1
  46. package/dist/boot/stage-10-http-server.js +3 -2
  47. package/dist/boot/stores.js +12 -7
  48. package/dist/capabilities/builtin-tools.d.ts +31 -8
  49. package/dist/capabilities/builtin-tools.js +22 -1
  50. package/dist/capabilities/center-plugins.d.ts +2 -0
  51. package/dist/capabilities/center-plugins.js +24 -6
  52. package/dist/capabilities/code-review-council.js +8 -11
  53. package/dist/capabilities/scenarios.d.ts +42 -13
  54. package/dist/capabilities/scenarios.js +36 -9
  55. package/dist/capabilities/send-user-file-tool.d.ts +6 -3
  56. package/dist/capabilities/send-user-file-tool.js +1 -1
  57. package/dist/config-catalog.js +5 -5
  58. package/dist/config-lkg.d.ts +33 -5
  59. package/dist/config-lkg.js +55 -11
  60. package/dist/config-types.d.ts +8 -1
  61. package/dist/config.js +11 -11
  62. package/dist/deployment-governance.d.ts +8 -3
  63. package/dist/deployment-governance.js +7 -4
  64. package/dist/device-store.d.ts +7 -0
  65. package/dist/device-store.js +4 -0
  66. package/dist/device-ws-hub.js +17 -9
  67. package/dist/device-ws-protocol.d.ts +15 -0
  68. package/dist/host-decision.d.ts +52 -27
  69. package/dist/host-decision.js +14 -7
  70. package/dist/http/admission.js +6 -6
  71. package/dist/http/resume-legs.js +6 -1
  72. package/dist/http/routes/approvals-assistant.d.ts +25 -3
  73. package/dist/http/routes/approvals-assistant.js +9 -5
  74. package/dist/http/routes/capabilities.js +5 -3
  75. package/dist/http/routes/devices.js +3 -3
  76. package/dist/http/routes/memory-origin.d.ts +1 -1
  77. package/dist/http/routes/rules.js +13 -6
  78. package/dist/http/routes/runs.js +11 -5
  79. package/dist/http/routes/sessions.js +20 -16
  80. package/dist/http/wire-types.d.ts +12 -13
  81. package/dist/images/manifest.d.ts +7 -1
  82. package/dist/images/manifest.js +18 -16
  83. package/dist/list-entry-display.d.ts +14 -0
  84. package/dist/list-entry-display.js +14 -0
  85. package/dist/memory-store-refusal.d.ts +7 -12
  86. package/dist/memory-store-refusal.js +4 -5
  87. package/dist/namespaced-rule-name.d.ts +4 -3
  88. package/dist/namespaced-rule-name.js +2 -6
  89. package/dist/observability/fail-open.d.ts +9 -1
  90. package/dist/observability/fail-open.js +9 -1
  91. package/dist/operator-ask.d.ts +7 -6
  92. package/dist/plugins/approval-exemption-store.d.ts +4 -0
  93. package/dist/plugins/approval-exemption-store.js +1 -0
  94. package/dist/plugins/blob-backend.d.ts +13 -0
  95. package/dist/plugins/blob-backend.js +26 -0
  96. package/dist/plugins/local-session-store.d.ts +78 -47
  97. package/dist/plugins/local-session-store.js +152 -122
  98. package/dist/plugins/one-time-migrations.d.ts +14 -0
  99. package/dist/plugins/one-time-migrations.js +90 -3
  100. package/dist/plugins/permission-rule-store-file.js +2 -6
  101. package/dist/plugins/permission-rule-store-sql.d.ts +64 -6
  102. package/dist/plugins/permission-rule-store-sql.js +166 -59
  103. package/dist/plugins/remote-env-device.d.ts +12 -1
  104. package/dist/plugins/remote-env-device.js +13 -5
  105. package/dist/plugins/scheduler-support.d.ts +8 -4
  106. package/dist/plugins/scheduler-support.js +5 -5
  107. package/dist/plugins/snapshot-bucket-presence.d.ts +36 -0
  108. package/dist/plugins/snapshot-bucket-presence.js +40 -0
  109. package/dist/plugins/store-backend.d.ts +17 -6
  110. package/dist/plugins/store-backend.js +46 -16
  111. package/dist/plugins/store-open-wait.d.ts +33 -0
  112. package/dist/plugins/store-open-wait.js +73 -0
  113. package/dist/rule-entry-list.d.ts +0 -1
  114. package/dist/rule-entry-list.js +5 -10
  115. package/dist/rules-consent.d.ts +12 -3
  116. package/dist/rules-consent.js +5 -4
  117. package/dist/run-local.js +6 -3
  118. package/dist/security.d.ts +5 -3
  119. package/dist/security.js +4 -0
  120. package/dist/sema-config-dir.d.ts +19 -0
  121. package/dist/sema-config-dir.js +33 -0
  122. package/dist/session-policy-wire.js +10 -14
  123. package/dist/spec-fields.d.ts +7 -2
  124. package/dist/store-posture.d.ts +14 -6
  125. package/dist/store-posture.js +7 -4
  126. package/dist/task-settings.d.ts +30 -58
  127. package/dist/task-settings.js +6 -18
  128. package/dist/tool-approval.d.ts +97 -43
  129. package/dist/tool-approval.js +120 -31
  130. package/dist/trace/engine-notice-wire.d.ts +6 -4
  131. package/dist/trace/engine-notice-wire.js +16 -9
  132. package/dist/trace/redact.d.ts +6 -0
  133. package/dist/trace/redact.js +9 -9
  134. package/dist/trace/self-minted-wire.d.ts +15 -1
  135. package/dist/trace/self-minted-wire.js +2 -0
  136. package/dist/user-input-ledger.d.ts +19 -15
  137. package/dist/user-input-ledger.js +12 -20
  138. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +56 -14
  139. package/package.json +2 -2
@@ -1,13 +1,17 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
- import { SessionError, validateEntriesForImport } from "@sema-agent/core";
3
+ import { FileSessionRepo, SessionError, validateEntriesForImport } from "@sema-agent/core";
4
+ import { z } from "zod";
4
5
  import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
5
6
  import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
6
- import { decodeJsonColumn, readStringRecordColumn, SqlJsonColumnError } from "./sql-json-column.js";
7
+ import { createZodColumnReader, decodeJsonColumn, readStringRecordColumn, SqlJsonColumnError } from "./sql-json-column.js";
8
+ import { ONE_TIME_MIGRATIONS, migrationWindowOpen } from "./one-time-migrations.js";
9
+ import { createLogger } from "../observability/logger.js";
7
10
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
8
11
  import { carriedIncarnation, mintSessionIncarnation, sessionFaceFromRow } from "../session-incarnation.js";
9
12
  import { decodeInstructionSource, encodeInstructionSource, sameInstructionSourceBaseline } from "../instruction-source-notice.js";
10
- import { openSessionForItself } from "../session-records-skipped-notice.js";
13
+ import { noteReplayInSessionOpenScope, openSessionForItself } from "../session-records-skipped-notice.js";
14
+ import { createCorruptReadFailOpenSeat } from "../observability/corrupt-read-seat.js";
11
15
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
12
16
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
13
17
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
@@ -30,18 +34,32 @@ function userText(entry) {
30
34
  }
31
35
  return null;
32
36
  }
33
- const SIDECAR_DEGRADES = { lastActivity: "recency", title: "auto-title", "instruction-source": "instruction-source baseline", incarnation: "session-incarnation (the write that needed it failed loudly)" };
37
+ const SIDECAR_DEGRADES = { lastActivity: "recency", title: "auto-title", "instruction-source": "instruction-source baseline", "session-meta": "session owner + incarnation (the write that needed it failed loudly)" };
38
+ function readSessionMetaCell(cell) {
39
+ return typeof cell === "string" ? { incarnation: cell, owner: undefined } : cell;
40
+ }
41
+ const ownerRecorded = (cell) => cell !== undefined && readSessionMetaCell(cell).owner !== undefined;
42
+ const SESSION_META_SIDECAR = z.record(z.string(), z.union([z.string(), z.object({ incarnation: z.string(), owner: z.string().nullable() }).strict()]));
43
+ const readSessionMetaSidecar = createZodColumnReader(SESSION_META_SIDECAR);
44
+ function ownerClaimWindowOpen() {
45
+ const window = ONE_TIME_MIGRATIONS.find((w) => w.id === "M10");
46
+ if (window === undefined)
47
+ throw new Error("LocalSessionStore: the one-time migration registry has no M10 row (the local session-owner claim window) — the claim arm outlived its registry row; delete it with that row");
48
+ return migrationWindowOpen(window, new Date().toISOString().slice(0, 10));
49
+ }
34
50
  const SIDECAR_MAP_STATE_BY_PATH = new Map();
35
- class SidecarStringMap {
51
+ class SidecarMap {
36
52
  path;
37
53
  label;
54
+ read;
38
55
  onUnreadable;
39
56
  onWriteFailed;
40
57
  strict;
41
58
  own = { entries: new Map(), writeChain: Promise.resolve() };
42
- constructor(path, label, onUnreadable, onWriteFailed, strict = false) {
59
+ constructor(path, label, read, onUnreadable, onWriteFailed, strict = false) {
43
60
  this.path = path;
44
61
  this.label = label;
62
+ this.read = read;
45
63
  this.onUnreadable = onUnreadable;
46
64
  this.onWriteFailed = onWriteFailed;
47
65
  this.strict = strict;
@@ -59,7 +77,7 @@ class SidecarStringMap {
59
77
  if (!st.loaded) {
60
78
  st.loaded = (async () => {
61
79
  try {
62
- const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, this.label);
80
+ const parsed = decodeJsonColumn(await readFile(path, "utf8"), this.read, this.label);
63
81
  for (const [id, v] of Object.entries(parsed)) {
64
82
  if (!st.entries.has(id))
65
83
  st.entries.set(id, v);
@@ -73,45 +91,60 @@ class SidecarStringMap {
73
91
  return;
74
92
  }
75
93
  const kind = e instanceof SqlJsonColumnError ? e.problem : (e?.code ?? "unreadable");
76
- throw new Error(`${this.label} is unreadable (${kind}) — refusing to read or mint session incarnations until it is repaired: a replacement ` +
77
- `token for an existing session would make it a different occupant (its memory opt-out would stop binding it), and a write ` +
78
- `would overwrite the unreadable bytes. The file is left in place at ${path}.`);
94
+ throw new Error(`${this.label} is unreadable (${kind}) — refusing to read or write session owners and incarnations until it is repaired: ` +
95
+ `reading it as empty would turn every session ownerless, and a replacement incarnation token for an existing session would ` +
96
+ `make it a different occupant (its memory opt-out would stop binding it); a write would overwrite the unreadable bytes. ` +
97
+ `The file is left in place at ${path}.`);
79
98
  }
80
99
  })();
81
100
  }
82
101
  return st.loaded;
83
102
  }
84
103
  persist() {
85
- return this.commit(() => () => undefined);
104
+ return this.commit(() => true);
86
105
  }
87
106
  commit(change) {
88
107
  const path = this.path;
89
108
  const st = this.state;
90
109
  const link = st.writeChain.then(async () => {
91
- const undo = change(st.entries);
92
- if (undo === undefined || !path)
93
- return true;
94
- try {
95
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
96
- await writeFile(tmp, JSON.stringify(Object.fromEntries(st.entries)), "utf8");
97
- await rename(tmp, path);
110
+ const base = new Map(st.entries);
111
+ const draft = new Map(base);
112
+ if (!change(draft))
98
113
  return true;
114
+ if (path) {
115
+ try {
116
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
117
+ await writeFile(tmp, JSON.stringify(Object.fromEntries(draft)), "utf8");
118
+ await rename(tmp, path);
119
+ }
120
+ catch (e) {
121
+ this.onWriteFailed(e);
122
+ return false;
123
+ }
99
124
  }
100
- catch (e) {
101
- undo();
102
- this.onWriteFailed(e);
103
- return false;
125
+ for (const key of new Set([...base.keys(), ...draft.keys()])) {
126
+ if (draft.has(key) === base.has(key) && draft.get(key) === base.get(key))
127
+ continue;
128
+ if (draft.has(key))
129
+ st.entries.set(key, draft.get(key));
130
+ else
131
+ st.entries.delete(key);
104
132
  }
133
+ return true;
105
134
  });
106
135
  st.writeChain = link.then(() => undefined);
107
136
  return link;
108
137
  }
109
138
  }
139
+ export function createLocalSessionRepo(root) {
140
+ return new FileSessionRepo(root, {
141
+ onCorruptRead: createCorruptReadFailOpenSeat("server.session-repo.corrupt-record-skipped", noteReplayInSessionOpenScope),
142
+ });
143
+ }
110
144
  export class LocalSessionStore {
111
145
  repo;
112
146
  retention = UNMANAGED_RETENTION;
113
147
  pending = new Map();
114
- owners = new Map();
115
148
  lastActivity = new Map();
116
149
  lastTaskRun = new Map();
117
150
  stagings = new Map();
@@ -124,48 +157,63 @@ export class LocalSessionStore {
124
157
  sidecarPath;
125
158
  titles;
126
159
  instructionSources;
127
- incarnations;
128
- constructor(repo, dataDir) {
160
+ meta;
161
+ log;
162
+ constructor(repo, dataDir, opts) {
129
163
  this.repo = repo;
130
164
  this.sidecarPath = dataDir ? join(dataDir, "session-last-activity.json") : undefined;
131
- const sidecar = (file, which, strict = false) => new SidecarStringMap(dataDir ? resolve(join(dataDir, file)) : undefined, `${file} (the local ${which} sidecar)`, (path, e) => this.refuseSidecar(path, `LocalSessionStore ${which} sidecar`, e, which), (e) => this.warnSidecar("write", e, which), strict);
132
- this.titles = sidecar("session-titles.json", "title");
133
- this.instructionSources = sidecar("session-instruction-sources.json", "instruction-source");
134
- this.incarnations = sidecar("session-incarnations.json", "incarnation", true);
135
- }
136
- async writeIncarnation(sessionId, next) {
137
- await this.incarnations.ensureLoaded();
138
- const committed = await this.incarnations.commit((entries) => {
139
- const before = entries.get(sessionId);
140
- const value = next(before);
141
- if (value === undefined || value === before)
142
- return undefined;
143
- entries.set(sessionId, value);
144
- return () => {
145
- if (before === undefined)
146
- entries.delete(sessionId);
147
- else
148
- entries.set(sessionId, before);
149
- };
165
+ this.log = opts?.log ?? createLogger();
166
+ const sidecar = (file, which, read, strict = false) => new SidecarMap(dataDir ? resolve(join(dataDir, file)) : undefined, `${file} (the local ${which} sidecar)`, read, (path, e) => this.refuseSidecar(path, `LocalSessionStore ${which} sidecar`, e, which), (e) => this.warnSidecar("write", e, which), strict);
167
+ this.titles = sidecar("session-titles.json", "title", readStringRecordColumn);
168
+ this.instructionSources = sidecar("session-instruction-sources.json", "instruction-source", readStringRecordColumn);
169
+ this.meta = sidecar("session-incarnations.json", "session-meta", readSessionMetaSidecar, true);
170
+ }
171
+ async writeMeta(sessionId, next) {
172
+ await this.meta.ensureLoaded();
173
+ let changed = false;
174
+ const committed = await this.meta.commit((draft) => {
175
+ const value = next(draft.get(sessionId));
176
+ if (value === undefined)
177
+ return false;
178
+ draft.set(sessionId, value);
179
+ changed = true;
180
+ return true;
150
181
  });
151
182
  if (!committed)
152
- throw new SessionError("storage", `LocalSessionStore: session-incarnation sidecar write failed for session ${sessionId} — nothing was recorded`);
183
+ throw new SessionError("storage", `LocalSessionStore: session-meta sidecar write failed for session ${sessionId} — nothing was recorded`);
184
+ return changed;
153
185
  }
154
- mintIncarnation(sessionId) {
155
- return this.writeIncarnation(sessionId, () => mintSessionIncarnation());
186
+ async dropMeta(sessionId) {
187
+ await this.meta.ensureLoaded();
188
+ const committed = await this.meta.commit((draft) => draft.delete(sessionId));
189
+ if (!committed)
190
+ throw new SessionError("storage", `LocalSessionStore: session-meta sidecar write failed while deleting session ${sessionId} — its owner/incarnation cell is still on disk`);
156
191
  }
157
- ensureIncarnation(sessionId) {
158
- return this.writeIncarnation(sessionId, (before) => (before === undefined ? mintSessionIncarnation() : undefined));
192
+ async existsInRepo(sessionId) {
193
+ try {
194
+ await this.repo.open({ id: sessionId, createdAt: "" });
195
+ return true;
196
+ }
197
+ catch (e) {
198
+ if (isNotFound(e))
199
+ return false;
200
+ throw e;
201
+ }
159
202
  }
160
- async sessionFaceOf(sessionId) {
161
- const owner = await this.ownerOf(sessionId);
162
- if (owner === undefined)
163
- return undefined;
164
- await this.ensureIncarnation(sessionId);
165
- const incarnation = this.incarnations.state.entries.get(sessionId);
166
- if (!this.owners.has(sessionId) || (this.owners.get(sessionId) ?? null) !== owner)
203
+ async metaOf(sessionId) {
204
+ await this.meta.ensureLoaded();
205
+ const cell = this.meta.state.entries.get(sessionId);
206
+ if (cell !== undefined)
207
+ return readSessionMetaCell(cell);
208
+ if (!(await this.existsInRepo(sessionId)))
167
209
  return undefined;
168
- return sessionFaceFromRow(sessionId, owner, incarnation);
210
+ await this.writeMeta(sessionId, (before) => (before === undefined ? mintSessionIncarnation() : undefined));
211
+ const minted = this.meta.state.entries.get(sessionId);
212
+ return minted === undefined ? undefined : readSessionMetaCell(minted);
213
+ }
214
+ async sessionFaceOf(sessionId) {
215
+ const meta = await this.metaOf(sessionId);
216
+ return meta === undefined ? undefined : sessionFaceFromRow(sessionId, meta.owner ?? null, meta.incarnation);
169
217
  }
170
218
  async probeTitle(sessionId) {
171
219
  await this.titles.ensureLoaded();
@@ -186,17 +234,11 @@ export class LocalSessionStore {
186
234
  }
187
235
  async recordInstructionSource(sessionId, expected, source) {
188
236
  await this.instructionSources.ensureLoaded();
189
- const committed = await this.instructionSources.commit((entries) => {
190
- const before = entries.get(sessionId);
191
- if (!sameInstructionSourceBaseline(decodeInstructionSource(before), expected))
192
- return undefined;
193
- entries.set(sessionId, encodeInstructionSource(source));
194
- return () => {
195
- if (before === undefined)
196
- entries.delete(sessionId);
197
- else
198
- entries.set(sessionId, before);
199
- };
237
+ const committed = await this.instructionSources.commit((draft) => {
238
+ if (!sameInstructionSourceBaseline(decodeInstructionSource(draft.get(sessionId)), expected))
239
+ return false;
240
+ draft.set(sessionId, encodeInstructionSource(source));
241
+ return true;
200
242
  });
201
243
  if (!committed)
202
244
  throw new Error("LocalSessionStore: instruction-source sidecar write failed — baseline not recorded");
@@ -271,11 +313,10 @@ export class LocalSessionStore {
271
313
  }
272
314
  async acquire(sessionId, opts) {
273
315
  if (!sessionId) {
274
- await this.incarnations.ensureLoaded();
316
+ await this.meta.ensureLoaded();
275
317
  const session = await this.repo.create({});
276
318
  const id = (await session.getMetadata()).id;
277
- this.owners.set(id, null);
278
- await this.mintIncarnation(id);
319
+ await this.writeMeta(id, () => ({ incarnation: mintSessionIncarnation(), owner: null }));
279
320
  return { session: session, sessionId: id };
280
321
  }
281
322
  if (opts?.requireExisting)
@@ -290,8 +331,6 @@ export class LocalSessionStore {
290
331
  async load(sessionId, requireExisting = false) {
291
332
  try {
292
333
  const session = await openSessionForItself(sessionId, () => this.repo.open({ id: sessionId, createdAt: "" }));
293
- if (!this.owners.has(sessionId))
294
- this.owners.set(sessionId, null);
295
334
  return { session: session, sessionId };
296
335
  }
297
336
  catch (e) {
@@ -299,56 +338,47 @@ export class LocalSessionStore {
299
338
  throw e;
300
339
  if (requireExisting)
301
340
  throw e;
302
- await this.incarnations.ensureLoaded();
341
+ await this.meta.ensureLoaded();
303
342
  const session = await this.repo.create({ id: sessionId });
304
- await this.mintIncarnation(sessionId);
305
- if (!this.owners.has(sessionId))
306
- this.owners.set(sessionId, null);
343
+ await this.writeMeta(sessionId, (before) => ({ incarnation: mintSessionIncarnation(), owner: before !== undefined && ownerRecorded(before) ? (readSessionMetaCell(before).owner ?? null) : null }));
307
344
  return { session: session, sessionId };
308
345
  }
309
346
  }
310
347
  async register(sessionId, owner) {
311
- if (this.owners.has(sessionId))
348
+ await this.meta.ensureLoaded();
349
+ if (ownerRecorded(this.meta.state.entries.get(sessionId)))
312
350
  return;
313
- await this.incarnations.ensureLoaded();
314
- this.owners.set(sessionId, owner);
315
- try {
316
- await this.repo.open({ id: sessionId, createdAt: "" });
317
- await this.ensureIncarnation(sessionId);
318
- }
319
- catch (e) {
320
- if (!isNotFound(e))
321
- throw e;
351
+ const existed = await this.existsInRepo(sessionId);
352
+ const claim = existed && owner !== null && ownerClaimWindowOpen();
353
+ const wrote = await this.writeMeta(sessionId, (before) => {
354
+ if (ownerRecorded(before))
355
+ return undefined;
356
+ if (!existed || claim)
357
+ return { incarnation: existed && before !== undefined ? readSessionMetaCell(before).incarnation : mintSessionIncarnation(), owner };
358
+ return before === undefined ? mintSessionIncarnation() : undefined;
359
+ });
360
+ if (!existed && wrote)
322
361
  await this.repo.create({ id: sessionId });
323
- await this.mintIncarnation(sessionId);
324
- }
362
+ if (claim && wrote)
363
+ this.log.info("session_owner_claimed", { sessionId });
325
364
  }
326
365
  async ownerOf(sessionId) {
327
- if (this.owners.has(sessionId))
328
- return this.owners.get(sessionId) ?? null;
329
- try {
330
- await this.repo.open({ id: sessionId, createdAt: "" });
331
- this.owners.set(sessionId, null);
332
- return null;
333
- }
334
- catch (e) {
335
- if (isNotFound(e))
336
- return undefined;
337
- throw e;
338
- }
366
+ const meta = await this.metaOf(sessionId);
367
+ return meta === undefined ? undefined : (meta.owner ?? null);
339
368
  }
340
369
  async getHead(sessionId) {
341
- if (!this.owners.has(sessionId) && (await this.ownerOf(sessionId)) === undefined)
370
+ const before = await this.metaOf(sessionId);
371
+ if (before === undefined)
342
372
  return undefined;
343
- const owner = this.owners.get(sessionId) ?? null;
344
373
  const leafId = await this.getLeafId(sessionId);
345
- if (!this.owners.has(sessionId) || (this.owners.get(sessionId) ?? null) !== owner)
374
+ const cell = this.meta.state.entries.get(sessionId);
375
+ const after = cell === undefined ? undefined : readSessionMetaCell(cell);
376
+ if (after === undefined || after.incarnation !== before.incarnation || (after.owner ?? null) !== (before.owner ?? null))
346
377
  return undefined;
347
- return { owner, leafId };
378
+ return { owner: before.owner ?? null, leafId };
348
379
  }
349
- restampOwner(sessionId, owner) {
350
- if (this.owners.has(sessionId))
351
- this.owners.set(sessionId, owner);
380
+ async restampOwner(sessionId, owner) {
381
+ await this.writeMeta(sessionId, (before) => (before === undefined ? undefined : { incarnation: readSessionMetaCell(before).incarnation, owner }));
352
382
  }
353
383
  async touch(sessionId) {
354
384
  this.lastActivity.set(sessionId, new Date().toISOString());
@@ -371,10 +401,12 @@ export class LocalSessionStore {
371
401
  async listSessions(opts) {
372
402
  await this.ensureLoaded();
373
403
  await this.titles.ensureLoaded();
404
+ await this.meta.ensureLoaded();
374
405
  const metas = await this.repo.list();
375
406
  const all = [];
376
407
  for (const meta of metas) {
377
- const owner = this.owners.has(meta.id) ? (this.owners.get(meta.id) ?? null) : null;
408
+ const cell = this.meta.state.entries.get(meta.id);
409
+ const owner = cell === undefined ? null : (readSessionMetaCell(cell).owner ?? null);
378
410
  if (opts.owner !== undefined && owner !== opts.owner && !(opts.includeUnowned === true && owner === null))
379
411
  continue;
380
412
  const firstActivityAt = meta.createdAt || new Date(0).toISOString();
@@ -423,12 +455,11 @@ export class LocalSessionStore {
423
455
  }
424
456
  }
425
457
  async fork(sourceId, owner) {
426
- await this.incarnations.ensureLoaded();
458
+ await this.meta.ensureLoaded();
427
459
  try {
428
460
  const session = await this.repo.fork({ id: sourceId, createdAt: "" }, {});
429
461
  const id = (await session.getMetadata()).id;
430
- this.owners.set(id, owner);
431
- await this.mintIncarnation(id);
462
+ await this.writeMeta(id, () => ({ incarnation: mintSessionIncarnation(), owner }));
432
463
  return id;
433
464
  }
434
465
  catch (e) {
@@ -471,10 +502,9 @@ export class LocalSessionStore {
471
502
  async importEntriesAs(sessionId, owner, entries, incarnation) {
472
503
  if (!this.repo.importEntries)
473
504
  throw new SessionError("storage", "session import is not supported by this local backend");
474
- await this.incarnations.ensureLoaded();
505
+ await this.meta.ensureLoaded();
475
506
  await this.repo.importEntries(sessionId, owner ?? undefined, entries);
476
- this.owners.set(sessionId, owner);
477
- await this.writeIncarnation(sessionId, () => incarnation);
507
+ await this.writeMeta(sessionId, () => ({ incarnation, owner }));
478
508
  }
479
509
  async replaceEntries(sessionId, owner, entries) {
480
510
  if (!this.repo.importEntries)
@@ -482,8 +512,8 @@ export class LocalSessionStore {
482
512
  if (!this.repo.delete)
483
513
  throw new SessionError("storage", "session replace is not supported by this local backend (no delete seam)");
484
514
  validateEntriesForImport(entries);
485
- await this.incarnations.ensureLoaded();
486
- const prior = this.owners.has(sessionId) ? { owner: this.owners.get(sessionId) ?? null, incarnation: this.incarnations.state.entries.get(sessionId) } : undefined;
515
+ const current = await this.metaOf(sessionId);
516
+ const prior = current === undefined ? undefined : { owner: current.owner ?? null, incarnation: current.incarnation };
487
517
  await this.deleteSession(sessionId, owner);
488
518
  await this.importEntriesAs(sessionId, owner, entries, carriedIncarnation(prior, owner));
489
519
  }
@@ -499,8 +529,8 @@ export class LocalSessionStore {
499
529
  this.stagings.delete(stagingId);
500
530
  }
501
531
  async deleteSession(sessionId, owner) {
502
- const known = this.owners.has(sessionId);
503
- if (!known || !ownerEq(this.owners.get(sessionId) ?? null, owner))
532
+ const current = await this.ownerOf(sessionId);
533
+ if (current === undefined || !ownerEq(current, owner))
504
534
  return false;
505
535
  try {
506
536
  await this.repo.delete({ id: sessionId, createdAt: "" });
@@ -509,10 +539,10 @@ export class LocalSessionStore {
509
539
  if (!isNotFound(e))
510
540
  throw e;
511
541
  }
512
- this.owners.delete(sessionId);
542
+ await this.dropMeta(sessionId);
513
543
  this.lastActivity.delete(sessionId);
514
544
  this.lastTaskRun.delete(sessionId);
515
- for (const map of [this.titles, this.instructionSources, this.incarnations]) {
545
+ for (const map of [this.titles, this.instructionSources]) {
516
546
  await map.ensureLoaded();
517
547
  if (map.state.entries.delete(sessionId))
518
548
  await map.persist();
@@ -572,7 +602,7 @@ class LocalStagingHandle {
572
602
  await this.store.replaceEntries(this.realSessionId, owner, this.staged);
573
603
  }
574
604
  else {
575
- this.store.restampOwner(this.realSessionId, owner);
605
+ await this.store.restampOwner(this.realSessionId, owner);
576
606
  }
577
607
  this.store.forgetStaging(this.stagingId);
578
608
  return { relation: rel.relation };
@@ -1,5 +1,6 @@
1
1
  import type { Pool as MysqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
+ import { type UndurableScopeRuleRow, type UnreadableRuleBucket } from "./permission-rule-store-sql.js";
3
4
  /** 迁移读数(启动日志一行写全;测试按它断言,不靠日志文本)。 */
4
5
  export interface OneTimeMigrationReport {
5
6
  /** M1:退役快照索引表 —— `dropped` = 本次真 drop 了,`absent` = 早已不在(第二跑/全新库)。 */
@@ -33,6 +34,13 @@ export interface OneTimeMigrationReport {
33
34
  readonly workflowParkIndexKey: "reshaped" | "current" | "file-ledger";
34
35
  /** M9:改形时原样搬进新表的指针行数(`current` / `file-ledger` 时恒 0)。非零 = 这次迁移真重建了一张表,运维该看见一次。 */
35
36
  readonly workflowParkIndexRowsCarried: number;
37
+ /** M11:本次从持久规则桶里删掉的、scope 不被引擎 durable 谓词收的行(一行一条,启动日志逐行 warn;local 车道恒空,见头注 M11 段)。 */
38
+ readonly undurableScopeRuleRows: readonly UndurableScopeRuleRow[];
39
+ /** M11:连续争不过在线写者、留到下一次起服的桶数(那期间坏行照旧由引擎读侧丢并告知;非零 ⇒ warn)。 */
40
+ readonly undurableScopeRuleBucketsDeferred: number;
41
+ /** M11:读腿整桶拒的桶(唯一判官判不过:形 / 指纹 / behavior 闭集 / 墓碑规范文本 / 单一验证器)—— 一个字节没动,一桶一条拒因
42
+ * (不带行内容),启动日志逐桶 warn;local 车道恒空(见头注 M11 段)。 */
43
+ readonly undurableScopeRuleBucketsUnreadable: readonly UnreadableRuleBucket[];
36
44
  }
37
45
  /**
38
46
  * S-590 —— 一条一次性迁移的窗(登记表的一行;纯数据)。字段注写着每个值的取证方式,改一行之前照着重核。
@@ -65,6 +73,12 @@ export interface OneTimeMigrationWindow {
65
73
  * 而留着 M3 会让 M3 永远判「跑过了」。三条同版引入、同一天到期,本来就同一次删。
66
74
  */
67
75
  export declare const ONE_TIME_MIGRATIONS: readonly OneTimeMigrationWindow[];
76
+ /**
77
+ * 一条登记行的窗**此刻**开着没有(`today` = UTC 日历日 YYYY-MM-DD):未发布形(`removableAfter: null`)⇒ 开;否则今天不晚于 `removableAfter` ⇒ 开
78
+ * (窗关当天仍开,与到期门「今天严格晚于才红」同一条边界)。
79
+ * 只给**窗内才生效的行为臂**用(M10 的认领):那种臂过了窗就停,不等删码车 —— 删码因此是零行为变化,而窗只写在本表这一处。
80
+ */
81
+ export declare function migrationWindowOpen(window: Pick<OneTimeMigrationWindow, "removableAfter">, today: string): boolean;
68
82
  /** local 车道上 SQL 独有的四条(M1 / M2 / M4 / M5)的读数(M3 / M6 两车道都有,local 腿的读数由 `LocalBackend.runOneTimeMigrations` 现算):那几张表 / 列在文件底座上从来不存在 ⇒ 如实答 absent / 0。 */
69
83
  export declare const NO_SQL_MIGRATIONS: Omit<OneTimeMigrationReport, "askOriginRowsMigrated" | "holderDeathCodeRowsBackfilled">;
70
84
  /** 启动期的日志口(logger 的子集)。两级各有其用,见 {@link logOneTimeMigrations}。 */
@@ -5,6 +5,7 @@ import { ensureIndexes } from "./ensure-index.js";
5
5
  import { decodeJsonColumn, isJsonColumnBodyProblem, jsonColumnText, jsonColumnTextExpr, readNonNullJsonValueColumn } from "./sql-json-column.js";
6
6
  import { SESSION_META_INCARNATION_COLUMN, mintSessionIncarnation } from "../session-incarnation.js";
7
7
  import { RUN_HOLDER_DEATH_CODES, RUN_ORPHANED_ERROR, RUN_STALLED_ERROR } from "./store-contracts.js";
8
+ import { PERMISSION_RULE_TABLE, purgeUndurableScopeRows } from "./permission-rule-store-sql.js";
8
9
  export const ONE_TIME_MIGRATIONS = [
9
10
  {
10
11
  id: "M1",
@@ -119,7 +120,39 @@ export const ONE_TIME_MIGRATIONS = [
119
120
  "`decide.workflow_parks_unreadable`);出路不变(续跑发起它的那条 workflow run);随包排空判据没了,换钉前没有机器读数可数这些卡",
120
121
  reportKeys: ["workflowParkIndexKey", "workflowParkIndexRowsCarried"],
121
122
  },
123
+ {
124
+ id: "M10",
125
+ introducedIn: "7.119.0",
126
+ releasedOn: "2026-10-08",
127
+ removableAfter: "2026-11-07",
128
+ removal: "删 local 车道的认领臂:`LocalSessionStore.register` 里「属主从没被记下的存量会话 ⇒ 带 principal 的登记认领它」那一支、认领日志行 `session_owner_claimed`" +
129
+ "(连同店构造参数里只为它存在的 `log` 位)与读本表 M10 行的 `ownerClaimWindowOpen`;`security.ts` `createAuthorizer` 里对无主会话的那次认领 `register` 调用;" +
130
+ "`http/routes/sessions.ts` 409 `conflict.session_ownerless` 句里「Ways out: on the local backend, during the upgrade window …」那半句(连同冻结表 " +
131
+ "`test/api-error-text-freeze.test.ts` 那一行、契约码目录 `docs/ASSISTANT-WIRE-CONTRACT.md` 那一行);本文件的 `migrationWindowOpen`(若再无别的读者)",
132
+ afterRemoval: "没有响亮臂(删码本身零行为变化:窗关那一刻起认领臂已经不生效):属主从没被记下的存量会话(边档那一格只有化身 —— 跳过了带本条的版本直接升," +
133
+ "或窗内从没被带 principal 提交过)照旧答无主(`ownerOf` = null):它的会话规则这条路由读不了也改不了(409 `conflict.session_ownerless`)," +
134
+ "已存的规则对写下它们的 principal 的 run 照旧生效;出路 = 删会话(`DELETE /v1/sessions/:id`,规则随之删)",
135
+ reportKeys: [],
136
+ },
137
+ {
138
+ id: "M11",
139
+ introducedIn: "7.120.0",
140
+ releasedOn: "2026-10-08",
141
+ removableAfter: "2026-11-07",
142
+ removal: "删 migrateOn 的 M11 步(`permission_rule` 在场探针 + `purgeUndurableScopeRows` 调用)与 `permission-rule-store-sql.ts` 的 `purgeUndurableScopeRows` / " +
143
+ "`purgeAllBuckets` / `purgeOneBucket` / `contentFreeRefusal` / `PurgeAccumulator` / `UndurableScopeRuleRow` / `UnreadableRuleBucket` / `UndurableScopePurge` / " +
144
+ "`UndurableScopePurgeDb` / `UNDURABLE_PURGE_PAGE` / `UNDURABLE_PURGE_OCC_ATTEMPTS`、本文件的 `removedRuleRowNote`,以及 `BucketRefusedError` 的 `verdict` 位(若届时再无别的读者;" +
145
+ "判官 `decodeBucketRow` 与写法 `writeBucket` 是读腿 / 写臂本身,留);报告、日志行(含逐行的 `permission_rule_row_removed` 与逐桶的 " +
146
+ "`permission_rule_bucket_unreadable`)与 `NO_SQL_MIGRATIONS` 的 `undurableScopeRuleRows` / `undurableScopeRuleBucketsDeferred` / `undurableScopeRuleBucketsUnreadable`",
147
+ afterRemoval: "引擎读侧的既有响亮臂:scope root 不是完整绝对路径的存量行(跳过了带本条的版本直接升的库,或窗关之后别的写者写进来的)在有效视图里被丢、" +
148
+ "每次 run 一条 `permission.rule_row_dropped`(点名规则与 root)—— 那一行不参与任何检查,但撤销口删不动它(引擎的写臂对这种 scope 同样拒);" +
149
+ "出路 = 运维在库里把那只桶的那一行删掉(整桶 OCC 回写,指纹按 `buildRuleBucketChecksum` 重算)",
150
+ reportKeys: ["undurableScopeRuleRows", "undurableScopeRuleBucketsDeferred", "undurableScopeRuleBucketsUnreadable"],
151
+ },
122
152
  ];
153
+ export function migrationWindowOpen(window, today) {
154
+ return window.removableAfter === null || today <= window.removableAfter;
155
+ }
123
156
  export const NO_SQL_MIGRATIONS = {
124
157
  retiredSnapshotTable: "absent",
125
158
  retiredSnapshotRows: 0,
@@ -132,6 +165,9 @@ export const NO_SQL_MIGRATIONS = {
132
165
  allowRuleShadowRowsUnreadable: 0,
133
166
  workflowParkIndexKey: "file-ledger",
134
167
  workflowParkIndexRowsCarried: 0,
168
+ undurableScopeRuleRows: [],
169
+ undurableScopeRuleBucketsDeferred: 0,
170
+ undurableScopeRuleBucketsUnreadable: [],
135
171
  };
136
172
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
137
173
  const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
@@ -276,8 +312,16 @@ async function migrateOn(db, dialect) {
276
312
  let retiredSnapshotTable = "absent";
277
313
  let retiredSnapshotRows = 0;
278
314
  let retiredSteerSeatColumn = "absent";
315
+ let undurableScopeRuleRows = [];
316
+ let undurableScopeRuleBucketsDeferred = 0;
317
+ let undurableScopeRuleBucketsUnreadable = [];
279
318
  const destroyed = [];
280
319
  try {
320
+ if (await tableExists(db, dialect, PERMISSION_RULE_TABLE)) {
321
+ ({ removed: undurableScopeRuleRows, bucketsDeferred: undurableScopeRuleBucketsDeferred, bucketsUnreadable: undurableScopeRuleBucketsUnreadable } = await purgeUndurableScopeRows(db, dialect));
322
+ for (const r of undurableScopeRuleRows)
323
+ destroyed.push(`removed permission_rule ${r.kind} (bucket ${r.ownerKey}, ${r.behavior} ${r.tool}, scope root ${JSON.stringify(r.scopeRoot)})`);
324
+ }
281
325
  const counted = !snapshotTablePresent
282
326
  ? undefined
283
327
  : await db.rows(`SELECT COUNT(*) AS n FROM ${RETIRED_SNAPSHOT_TABLE}`).then((rows) => Number(rows[0]?.n ?? 0), async (e) => {
@@ -306,7 +350,7 @@ async function migrateOn(db, dialect) {
306
350
  return {
307
351
  retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows,
308
352
  sessionIncarnationColumn, sessionIncarnationsBackfilled, holderDeathCodeRowsBackfilled, allowRuleShadowRowsMigrated, allowRuleShadowRowsUnreadable,
309
- workflowParkIndexKey, workflowParkIndexRowsCarried,
353
+ workflowParkIndexKey, workflowParkIndexRowsCarried, undurableScopeRuleRows, undurableScopeRuleBucketsDeferred, undurableScopeRuleBucketsUnreadable,
310
354
  };
311
355
  }
312
356
  const WORKFLOW_PARK_INDEX_TABLE = "workflow_agent_session";
@@ -508,7 +552,8 @@ export function migrateRetiredAskOrigin(type, data) {
508
552
  export function logOneTimeMigrations(log, backend, report) {
509
553
  const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0 ||
510
554
  report.sessionIncarnationColumn === "added" || report.sessionIncarnationsBackfilled > 0 || report.holderDeathCodeRowsBackfilled > 0 || report.allowRuleShadowRowsMigrated > 0 || report.allowRuleShadowRowsUnreadable > 0 ||
511
- report.workflowParkIndexKey === "reshaped";
555
+ report.workflowParkIndexKey === "reshaped" || report.undurableScopeRuleRows.length > 0 || report.undurableScopeRuleBucketsDeferred > 0 ||
556
+ report.undurableScopeRuleBucketsUnreadable.length > 0;
512
557
  (changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
513
558
  backend,
514
559
  retiredSnapshotTable: report.retiredSnapshotTable,
@@ -524,6 +569,9 @@ export function logOneTimeMigrations(log, backend, report) {
524
569
  allowRuleShadowRowsUnreadable: report.allowRuleShadowRowsUnreadable,
525
570
  workflowParkIndexKey: report.workflowParkIndexKey,
526
571
  workflowParkIndexRowsCarried: report.workflowParkIndexRowsCarried,
572
+ undurableScopeRuleRowsRemoved: report.undurableScopeRuleRows.length,
573
+ undurableScopeRuleBucketsDeferred: report.undurableScopeRuleBucketsDeferred,
574
+ undurableScopeRuleBucketsUnreadable: report.undurableScopeRuleBucketsUnreadable.length,
527
575
  removableAfter: Object.fromEntries(ONE_TIME_MIGRATIONS.map((w) => [w.id, w.removableAfter])),
528
576
  note: "one-time persisted-data migrations (M1: retired whole-tree snapshot index table dropped; M2: retired single-seat " +
529
577
  "steering column dropped; M3: retired ask-origin spelling rewritten in place; M4: audit details written as the JSON " +
@@ -533,10 +581,49 @@ export function logOneTimeMigrations(log, backend, report) {
533
581
  "run.orphaned; M7 writes nothing — it is the window of the refusal that keeps a suspended task whose stored allow list was written " +
534
582
  "under an earlier reading from resuming under the current one; M8: parked rows' allow-layer disclosure written by an earlier engine " +
535
583
  "under its old key shadowedRule rewritten to allowRuleShadowed; M9: the workflow park pointer index rebuilt with the key (session, run), " +
536
- "its rows carried over). Each step " +
584
+ "its rows carried over; M10 writes nothing at boot — on the local backend, while its window is open, the first task submitted with a principal " +
585
+ "on a session written by an earlier version (which recorded no owner) claims that session for the principal, one session_owner_claimed line per claim; " +
586
+ "M11: persisted permission rules whose project scope root is not a complete absolute path — accepted by earlier versions, dropped on every read " +
587
+ "by the current engine and not removable through DELETE /v1/rules — removed, one permission_rule_row_removed line per row; a bucket the read path " +
588
+ "refuses in whole is left untouched, one permission_rule_bucket_unreadable line per bucket). Each step " +
537
589
  "runs on every boot until its window closes (the release that introduced it + 30 days; the per-step date is in " +
538
590
  "removableAfter, null while that release is not published yet) and is deleted in the first minor after that — start a " +
539
591
  "version that still carries a step at least once before upgrading past its deletion.",
540
592
  });
593
+ for (const row of report.undurableScopeRuleRows) {
594
+ log.warn("permission_rule_row_removed", {
595
+ backend,
596
+ ownerKey: row.ownerKey,
597
+ kind: row.kind,
598
+ behavior: row.behavior,
599
+ tool: row.tool,
600
+ scopeRoot: row.scopeRoot,
601
+ note: removedRuleRowNote(row.kind),
602
+ });
603
+ }
604
+ for (const bucket of report.undurableScopeRuleBucketsUnreadable) {
605
+ log.warn("permission_rule_bucket_unreadable", {
606
+ backend,
607
+ ownerKey: bucket.ownerKey,
608
+ why: bucket.why,
609
+ note: "a persisted permission-rule bucket that the read path refuses in whole was left untouched by the one-time removal of rows whose project scope root " +
610
+ "is not a complete absolute path (removing rows from a bucket the read path refuses would turn that refusal into a readable bucket): every read of it " +
611
+ "keeps being refused, as before this release, until someone repairs or deletes that row by hand",
612
+ });
613
+ }
614
+ }
615
+ function removedRuleRowNote(kind) {
616
+ switch (kind) {
617
+ case "rule":
618
+ return "a persisted permission rule whose project scope root is not a complete absolute path was removed by a one-time migration: the current engine drops such a row on every read (it protected nothing) and refuses any write naming that scope, so it could not be revoked through DELETE /v1/rules; re-add it with the project's full path if it is still wanted";
619
+ case "revoked-rule":
620
+ return "a persisted permission rule whose project scope root is not a complete absolute path, and which had already been revoked (every approval behind it is removed by a tombstone in the same bucket), was removed by a one-time migration: it granted nothing before and grants nothing now — the revocation stands, no action needed";
621
+ case "tombstone":
622
+ return "a revocation record (tombstone) whose project scope root is not a complete absolute path was removed by a one-time migration: it can only cover rules with that same scope, which were removed with it or were never there, so nothing it revoked comes back — no action needed";
623
+ default: {
624
+ const unknown = kind;
625
+ throw new Error(`M11: no log sentence for removed-row kind ${JSON.stringify(unknown)}`);
626
+ }
627
+ }
541
628
  }
542
629
  //# sourceMappingURL=one-time-migrations.js.map