@sema-agent/server 7.90.0 → 7.91.2

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 (44) hide show
  1. package/USAGE.md +1 -1
  2. package/dist/adoption/plan.js +11 -0
  3. package/dist/boot/runner-deps.d.ts +6 -0
  4. package/dist/boot/runner-deps.js +11 -0
  5. package/dist/boot/session-faces.d.ts +6 -0
  6. package/dist/boot/session-faces.js +10 -2
  7. package/dist/boot/stage-03-store-backend.d.ts +1 -0
  8. package/dist/boot/stage-03-store-backend.js +2 -2
  9. package/dist/boot/stage-04-stores.d.ts +1 -0
  10. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  11. package/dist/boot/stage-06-runners.d.ts +1 -0
  12. package/dist/boot/stage-06-runners.js +2 -2
  13. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  14. package/dist/boot/stage-08-reapers.d.ts +1 -0
  15. package/dist/boot/stage-09-leader.d.ts +1 -0
  16. package/dist/boot/stage-09-leader.js +2 -2
  17. package/dist/boot/stage-10-http-server.d.ts +1 -0
  18. package/dist/boot/stage-10-http-server.js +1 -0
  19. package/dist/boot/stores.d.ts +2 -0
  20. package/dist/boot/stores.js +11 -3
  21. package/dist/cross-session-settings.d.ts +6 -2
  22. package/dist/http/routes/capabilities.js +4 -1
  23. package/dist/http/routes/rules.d.ts +11 -0
  24. package/dist/http/routes/rules.js +54 -5
  25. package/dist/http/routes/sessions-list.js +6 -1
  26. package/dist/http/server.d.ts +12 -0
  27. package/dist/peer-directory.d.ts +114 -0
  28. package/dist/peer-directory.js +136 -0
  29. package/dist/permission-rule-vocab.d.ts +27 -1
  30. package/dist/permission-rule-vocab.js +8 -0
  31. package/dist/plugins/mailbox-recipient-lifecycle.d.ts +86 -0
  32. package/dist/plugins/mailbox-recipient-lifecycle.js +10 -0
  33. package/dist/plugins/mailbox-store-sql.d.ts +18 -2
  34. package/dist/plugins/mailbox-store-sql.js +77 -1
  35. package/dist/plugins/store-contracts.d.ts +36 -1
  36. package/dist/plugins/store-contracts.js +10 -0
  37. package/dist/rules-consent.d.ts +74 -1
  38. package/dist/rules-consent.js +81 -1
  39. package/dist/trace/core-keyset-guard.d.ts +10 -4
  40. package/dist/trace/project.d.ts +16 -0
  41. package/dist/trace/project.js +13 -1
  42. package/dist/trace/redact.js +129 -18
  43. package/dist/trace/sema-provenance.d.ts +7 -6
  44. package/package.json +3 -3
@@ -0,0 +1,114 @@
1
+ /**
2
+ * [ref] —— core `RunnerDeps.peerDirectory` 的 server 部署席(跨会话互传消息的 server 半场,设计稿
3
+ * `sema-internal/server/designs/2026-09-19-s72-peer-directory-seat-v1.md` v1.1)。
4
+ *
5
+ * ## 一句话
6
+ *
7
+ * **目录不是一张要人维护的注册表,是会话列表面那一张派生视图的第二个读者。** 零新注册表、零新写家:
8
+ * `listPeerSessions({scope})` 每次经**会话枚举面那只同一个函数**(`GET /v1/sessions` 用的那只
9
+ * `listSessions`)读出来,再把 mailbox 侧的墓碑行(已删会话)并进去。
10
+ *
11
+ * ## 为什么复用那只 lister,而不是自己写一条 JOIN(设计稿 §2 给的是 SQL 形,这里收得更紧)
12
+ *
13
+ * 设计稿 §4-1 的承重条款是「**成员资格 = 会话读面的那一只属主可读谓词,一字不另写**」。自己写一条 JOIN
14
+ * 就必须自己带一份属主谓词 —— 那正是兔子洞 S-487「属主判定单一存取器」要消灭的第 10 处手抄,而且两份
15
+ * 谓词**一定**会漂(存量 `owner IS NULL` 的窄互认臂、`placement_kind IS NULL` 的 [ref] 义务、keyset 排序
16
+ * 口径三条都要各抄一遍)。改读同一只 `listSessions` 之后,**成员资格**不再是一条需要测试盯住的断言,
17
+ * 而是结构上的同一次调用;SQL 面因此也一个字节都不用改。
18
+ *
19
+ * ⚠️ **共享的是谓词,不是最终集合**(codex 对抗复审 r1 ② 采纳,如实收窄 —— 稿里与本注初版都写成了
20
+ * 「两面集合相等」,那是**过度承诺**):两面在下面这些地方按设计给出不同的集合,逐条都不是 bug ——
21
+ * · **fleet-wide 调用方**:`GET /v1/sessions` 对 service-token / dev-open 形不钉 owner、还认 `?owner=`
22
+ * (运维视图);目录**只**按 core 递进来的那一个 scope 分区,永不跨;
23
+ * · **分页与搜索**:那条路由有 `?limit`/`?cursor`/`?q=`,目录是一次定量读(见 {@link PEER_DIRECTORY_CAP});
24
+ * · **peer 可寻址性过滤**:语法外 / 非小写规范形 / 与别条会话折到同一只箱的 id 都不入目录(下面那道
25
+ * 单射门),那条路由照列;
26
+ * · **墓碑**:目录会多出「正在删除中」的 `deleted` 记录,那条路由不列;
27
+ * · **席位差**:那条路由还有 `runStore.listSessions` 的 legacy 兜底,本席刻意不并(见装配点的注)。
28
+ * 机器钉因此只钉「同一 principal、无 q、无分页、量在 cap 内」那一格的集合相等(它是那条承重条款的
29
+ * 可执行投影),别把它读成两面恒等。
30
+ *
31
+ * ⚠️ 与设计稿的第二处收紧(亲读实现推翻稿面):稿里把谓词写成 `principal-gate.ts runOwnerReadable`。
32
+ * 亲读两只函数后不成立 —— `runOwnerReadable(p, owner, cfg)` 对 `owner === null` **无条件**放行
33
+ * (`principal-gate.ts:136-137`,函数体第一行),而**列表面**的第三条件由 store 的
34
+ * `owner = ? OR owner IS NULL` 承载,开关是 `legacyUnownedAmnestyOpen(requirePrincipal, gateOwner)`
35
+ * (`security.ts:119`,它的头注逐字:
36
+ * 「单独存在的唯一理由是**列表面**……单实体读/写点一律用三条件齐的 `legacyUnownedSessionOk`」)。
37
+ * 拿 `runOwnerReadable` 当列表谓词会让目录比 `GET /v1/sessions` **多列出**别人的无主会话 —— 正好违反
38
+ * 稿子自己那条对拍。所以这里用的是列表面那一只:`legacyUnownedAmnestyOpen` + 同一个 lister。
39
+ *
40
+ * ## scope 轴
41
+ *
42
+ * core 递进来的 `access.scope` = `taskScope` = `internals?.registryScope ?? spec.principal ?? "default"`
43
+ * (core `prepare-task.js`)× server `resolve-spec.ts:701 principal: auth?.principal` ⇒ **裸 principal 串**,
44
+ * 无 principal 的部署上是字面量 `"default"`,而同一情形下 `session_meta.owner` 是 SQL NULL ——
45
+ * 这正是窄互认臂(`LEGACY_UNOWNED_AMNESTY_TENANT === "default"`)要接住的那一格,不必另铸编解码。
46
+ *
47
+ * ## 各字段的**唯一**来源(设计稿 §2 表;这里不许出现第二处算法)
48
+ *
49
+ * · `schemaVersion` = core 常量 `PEER_SESSION_RECORD_SCHEMA_VERSION`(不手抄数字);
50
+ * · `sessionId` = 会话行 id;过不了 core `isPeerSessionId` 的行、以及**箱句柄不是本 scope 独占**的行
51
+ * (非小写规范形 / 与别的会话折到同一只箱)**不入视图并计数披露**(不静默,见下方单射门);
52
+ * · `scope` = 查询参数原样(视图永不跨 scope —— 这就是设计稿 §4-3 那堵墙的全部实现);
53
+ * · `name` = `title`,空 ⇒ `session-<id 前 8>`,`nameSource: "auto"`;
54
+ * · `startedAt`/`updatedAt` = 会话行的 `firstActivityAt`/`lastActivityAt`;
55
+ * · `liveness` = **会话行在 ⇒ `"live"`**;`"deleted"` 来自 mailbox 墓碑(= 删除级联**在飞**那段窗口;
56
+ * 墓碑随箱亡,级联跑完之后那个 id 就是「不存在」—— 理由全文在 `plugins/mailbox-recipient-lifecycle.ts`),
57
+ * 同句柄上**墓碑压过活行**;**`"dead"` v1 不铸**
58
+ * (core `list-agents-tool.js:60` 跳过 deleted 行、`:62` 默认跳过 dead 行 —— 行号读的是**装树里的 7.24.2**
59
+ * dist,7.23.5 上是 `:49`/`:51`;⇒ 把闲置会话映成 dead
60
+ * 等于把用户自己的闲置会话从 `ListAgents` 里藏掉,正是本席要解的那个面。忙闲一律走 `tempo`);
61
+ * · `tempo` = `sessionTempoOfRunStatus(lastStatus)`(`plugins/store-contracts.ts` 的那**一张**表,
62
+ * `GET /v1/sessions` 的 `tempo` 列读的是同一只函数 —— 一处定义两处读);
63
+ * · `inboundPosture` = managed 层 `crossSessionInbound === "refuse"` ⇒ `"unavailable"`,否则 `"available"`;
64
+ * · `pid` / `procStartMs` / `sockPath` / `cwd` = **恒缺席**(server 会话是耐久可寻址实体,不是进程;
65
+ * 缺席即 core 的探活臂按 `isPeerSessionProcessAlive` 直接跳过);
66
+ * · `peerProtocol` / `peerFeatures` = 不铸(v1 不声明 CC 能力位;core 侧全 optional)。
67
+ */
68
+ import { type PeerDirectory } from "@sema-agent/core";
69
+ import type { CrossSessionInboundSettingLayers } from "@sema-agent/core";
70
+ import type { Logger } from "./observability/logger.js";
71
+ import type { MailboxRecipientLifecycle } from "./plugins/mailbox-recipient-lifecycle.js";
72
+ import { type SessionListItem } from "./security.js";
73
+ /** 会话枚举面(= `GET /v1/sessions` 用的那只 `OwnerAwareSessionStore.listSessions`)。 */
74
+ export type PeerSessionLister = (opts: {
75
+ owner?: string;
76
+ includeUnowned?: boolean;
77
+ cursor?: {
78
+ lastActivityAt: string;
79
+ sessionId: string;
80
+ };
81
+ limit: number;
82
+ q?: string;
83
+ }) => Promise<SessionListItem[]>;
84
+ /**
85
+ * 一次 `listPeerSessions` 最多铸多少行。
86
+ *
87
+ * 🔴 为什么要有上界、而且是**常量**:core 的 ref 铸造(`mintPeerSessionCandidates`)跑在**全集**上,而
88
+ * `ListAgents` 的结果还有 10 000 字符的硬上界(`LIST_AGENTS_MAX_RESULT_CHARS`)—— 没有上界的话一个有
89
+ * 几千条会话的 principal 会让每一次 turn 边界都拉一张巨表。做成部署旋钮会多一个「两台机器答案不同」的
90
+ * 轴(ref 的稳定性依赖集合本身),而这个数字不是策略、是防呆:超出即**响亮截断并计数**(见
91
+ * `peer_directory_truncated`),不静默。
92
+ */
93
+ export declare const PEER_DIRECTORY_CAP = 200;
94
+ export interface PeerDirectorySeatCtx {
95
+ /** 会话枚举面;缺席 ⇒ 不给席(装配点判,见 `boot/runner-deps.ts`)。 */
96
+ lister: PeerSessionLister;
97
+ /** mailbox 的收件人生命周期面(墓碑读 + 被活行压过的墓碑撤回)。 */
98
+ lifecycle: MailboxRecipientLifecycle;
99
+ /** `config.requirePrincipal` —— 窄互认臂的条件①(与列表面同一个开关)。 */
100
+ requirePrincipal: boolean;
101
+ /** managed 层的 `crossSessionInbound` 供层(`cross-session-settings.ts` 的既有解析,不新写)。 */
102
+ crossSessionInbound: CrossSessionInboundSettingLayers | undefined;
103
+ logger: Logger;
104
+ }
105
+ /**
106
+ * peer 目录席 —— `create*`(带行为的对象:每次调用都真读一次两个面)。
107
+ *
108
+ * 只实现 `listPeerSessions`。**`restorePeerSession` 刻意不实现**:core 的接口注逐字写着「a directory that
109
+ * cannot write (a server session table the engine only reads) omits it, and the residual window is disclosed
110
+ * in the receipt instead」—— 本席读的是会话账,会话行不是由 peer 车道创建的,写回去等于让 peer 车道
111
+ * 凭一次投递**造出**一条会话行。
112
+ */
113
+ export declare function createPeerDirectory(ctx: PeerDirectorySeatCtx): PeerDirectory;
114
+ //# sourceMappingURL=peer-directory.d.ts.map
@@ -0,0 +1,136 @@
1
+ import { PEER_SESSION_RECORD_SCHEMA_VERSION, isPeerSessionId, peerSessionBoxHandle, SESSION_BOX_PREFIX, } from "@sema-agent/core";
2
+ import { sessionTempoOfRunStatus } from "./plugins/store-contracts.js";
3
+ import { legacyUnownedAmnestyOpen } from "./security.js";
4
+ export const PEER_DIRECTORY_CAP = 200;
5
+ function autoName(sessionId) {
6
+ return `session-${sessionId.slice(0, 8)}`;
7
+ }
8
+ function sessionIdOfBoxHandle(handle) {
9
+ return handle.slice(SESSION_BOX_PREFIX.length);
10
+ }
11
+ export function createPeerDirectory(ctx) {
12
+ const { lister, lifecycle, requirePrincipal, crossSessionInbound, logger } = ctx;
13
+ return {
14
+ listPeerSessions: async (access) => {
15
+ const scope = access.scope;
16
+ const includeUnowned = legacyUnownedAmnestyOpen(requirePrincipal, scope);
17
+ const rows = await lister({ owner: scope, ...(includeUnowned ? { includeUnowned: true } : {}), limit: PEER_DIRECTORY_CAP + 1 });
18
+ const truncated = rows.length > PEER_DIRECTORY_CAP;
19
+ if (truncated) {
20
+ logger.warn("peer_directory_truncated", {
21
+ scope,
22
+ cap: PEER_DIRECTORY_CAP,
23
+ note: "this principal has more sessions than the peer directory lists in one read — the sessions beyond the cap are NOT addressable on the cross-session lane this turn (newest-first by last activity, so the oldest drop out)",
24
+ });
25
+ }
26
+ const posture = crossSessionInbound?.managed === "refuse" ? "unavailable" : "available";
27
+ let tombstones = [];
28
+ try {
29
+ tombstones = await lifecycle.listRetiredRecipients(scope, SESSION_BOX_PREFIX);
30
+ }
31
+ catch (err) {
32
+ logger.warn("peer_directory_tombstones_unreadable", { scope, err: String(err) });
33
+ }
34
+ const retired = new Map();
35
+ for (const t of tombstones) {
36
+ if (!t.handle.startsWith(SESSION_BOX_PREFIX))
37
+ continue;
38
+ retired.set(t.handle, t);
39
+ }
40
+ const out = [];
41
+ let ungrammatical = 0;
42
+ let notCanonical = 0;
43
+ const byBox = new Map();
44
+ for (const [i, r] of rows.entries()) {
45
+ if (!isPeerSessionId(r.sessionId)) {
46
+ if (i < PEER_DIRECTORY_CAP)
47
+ ungrammatical += 1;
48
+ continue;
49
+ }
50
+ const handle = peerSessionBoxHandle(r.sessionId);
51
+ const g = byBox.get(handle) ?? { handle, ids: [], rows: [] };
52
+ if (g.ids.includes(r.sessionId)) {
53
+ logger.warn("peer_directory_duplicate_session_row", { scope, box: handle, kept: r.sessionId, dropped: r.sessionId });
54
+ continue;
55
+ }
56
+ g.ids.push(r.sessionId);
57
+ g.rows.push({ row: r, inPage: i < PEER_DIRECTORY_CAP });
58
+ byBox.set(handle, g);
59
+ }
60
+ const foldCollisions = [];
61
+ for (const g of byBox.values()) {
62
+ if (g.ids.length > 1) {
63
+ foldCollisions.push({ box: g.handle, sessions: [...g.ids] });
64
+ continue;
65
+ }
66
+ const only = g.rows[0];
67
+ if (!only.inPage)
68
+ continue;
69
+ const r = only.row;
70
+ if (r.sessionId !== r.sessionId.toLowerCase()) {
71
+ notCanonical += 1;
72
+ continue;
73
+ }
74
+ if (retired.has(g.handle))
75
+ continue;
76
+ const started = Date.parse(r.firstActivityAt);
77
+ const updated = Date.parse(r.lastActivityAt);
78
+ const tempo = sessionTempoOfRunStatus(r.lastStatus);
79
+ out.push({
80
+ schemaVersion: PEER_SESSION_RECORD_SCHEMA_VERSION,
81
+ sessionId: r.sessionId,
82
+ scope,
83
+ name: r.title !== null && r.title.trim() !== "" ? r.title : autoName(r.sessionId),
84
+ nameSource: "auto",
85
+ startedAt: Number.isFinite(started) ? started : 0,
86
+ updatedAt: Number.isFinite(updated) ? updated : 0,
87
+ liveness: "live",
88
+ ...(tempo !== undefined ? { tempo } : {}),
89
+ inboundPosture: posture,
90
+ });
91
+ }
92
+ if (ungrammatical > 0) {
93
+ logger.warn("peer_directory_ungrammatical_sessions_skipped", {
94
+ scope,
95
+ skipped: ungrammatical,
96
+ grammar: "^[A-Za-z0-9_-]{1,80}$",
97
+ note: "these sessions exist and are listed by GET /v1/sessions, but their ids cannot be spelled as a `session.<id>` peer address — they are neither addressable nor drainable on the cross-session lane (the engine refuses the lane for such a session by name, `peer.session_id_ungrammatical`)",
98
+ });
99
+ }
100
+ if (notCanonical > 0) {
101
+ logger.warn("peer_directory_non_canonical_sessions_skipped", {
102
+ scope,
103
+ skipped: notCanonical,
104
+ note: "these sessions exist and are listed by GET /v1/sessions, but their ids are not lower-case canonical — the peer box handle folds to lower case, so publishing them could not keep the id→box mapping injective. They are NOT addressable on the cross-session lane; mint lower-case session ids to use it",
105
+ });
106
+ }
107
+ for (const c of foldCollisions) {
108
+ logger.warn("peer_directory_session_id_case_fold_collision", {
109
+ scope,
110
+ box: c.box,
111
+ sessions: c.sessions,
112
+ note: "these sessions of this principal fold to the SAME peer box handle, so NONE of them is published in the peer directory and none is addressable on the cross-session lane (fail-closed: the engine mounts its box drain on the folded handle for every one of them, so publishing any single one would let the others consume its messages). Root fix = a case-canonical session-id grammar on the submit face (a breaking change, tracked separately); until then do not mint session ids that differ only by case",
113
+ });
114
+ }
115
+ for (const t of retired.values()) {
116
+ const sessionId = sessionIdOfBoxHandle(t.handle);
117
+ if (!isPeerSessionId(sessionId))
118
+ continue;
119
+ out.push({
120
+ schemaVersion: PEER_SESSION_RECORD_SCHEMA_VERSION,
121
+ sessionId,
122
+ scope,
123
+ name: t.label !== undefined && t.label.trim() !== "" ? t.label : autoName(sessionId),
124
+ nameSource: "auto",
125
+ startedAt: t.retiredAt,
126
+ updatedAt: t.retiredAt,
127
+ liveness: "deleted",
128
+ diedAt: t.retiredAt,
129
+ inboundPosture: posture,
130
+ });
131
+ }
132
+ return out;
133
+ },
134
+ };
135
+ }
136
+ //# sourceMappingURL=peer-directory.js.map
@@ -1,4 +1,4 @@
1
- import type { RuleApprovalRecord } from "@sema-agent/core";
1
+ import type { RuleApprovalRecord, RuleBehavior } from "@sema-agent/core";
2
2
  /**
3
3
  * `PersistedRuleTool` **不再是闭词表**(core 7.9.0 / [ref]):工具名自 core 的**目录**派生
4
4
  * (`pathTarget` 声明 ⇒ 路径文法;`Bash` ⇒ 命令文法),型是 `string`。于是本文件对它的处置从
@@ -16,6 +16,32 @@ export declare function isPersistedRuleTool(name: string): boolean;
16
16
  * —— `//abs`、`~/`、`/root-relative`、cwd 相对,段内 `*`、跨段 `**`)。
17
17
  */
18
18
  export declare const PERSISTED_RULE_MATCHES: readonly ["exact", "prefix", "wildcard", "subpath", "path"];
19
+ /**
20
+ * S-511 —— `RuleBehavior` 每一员在**无同意仪式的直接写口**(`POST /v1/rules`)上的逐员表态。
21
+ *
22
+ * 为什么是一张**表**而不是一句 `behavior !== "allow"`:后者对 core 加员是**静默放行** —— 将来多一个
23
+ * 放宽方向的态,它会直接从这条收紧口写进店,而没有任何一行代码会红。`Record<RuleBehavior, boolean>`
24
+ * 少一员就是编译红([ref] 词表纪律:未知词必须是编译错),新词由人显式表态,而**方向由 `false` 兜底**
25
+ * —— 加一员而忘了表态是编译红,表态成 `false` 是恒拒,两条路都不会悄悄放宽。
26
+ *
27
+ * 判据本身是 core 成文的那条不对称(`removePersistedRule` 头注逐字):收紧不需要同意仪式
28
+ * (「a host may narrow on a user's behalf, it may not widen」),放宽需要 —— 所以 `allow` 的家是
29
+ * 「一次点头」那条路(ask 回决携规则 / 导入面的票仪式),不是这条口。
30
+ */
31
+ export declare const DIRECT_RULE_WRITE_ADMITS: {
32
+ /** 放宽方向:一次人的点头才铸得出它,本口恒拒(端点侧 400,文案在冻结门里)。 */
33
+ readonly allow: false;
34
+ /** 收紧:一条常驻拒绝。 */
35
+ readonly deny: true;
36
+ /** 收紧:一条常驻提问(它把自动放行降级成问人,方向同样是收紧)。 */
37
+ readonly ask: true;
38
+ };
39
+ /** 能经直接写口落店的那几员 —— 从上表**派生**,于是「允许哪几员」只有一个说话人。 */
40
+ export type DirectWritableRuleBehavior = {
41
+ [B in RuleBehavior]: (typeof DIRECT_RULE_WRITE_ADMITS)[B] extends true ? B : never;
42
+ }[RuleBehavior];
43
+ /** 判别式谓词(收窄到 {@link DirectWritableRuleBehavior}):端点判过之后,`allow` 在**类型上**到不了写家。 */
44
+ export declare function isDirectWritableRuleBehavior(behavior: RuleBehavior): behavior is DirectWritableRuleBehavior;
19
45
  /** [ref] §2.3 B3:batch offer 成员的判别位(`command` = 历史逐段 Bash 规则;`directoryRead` = cd 段铸的目录只读授权)。 */
20
46
  export declare const RULE_OFFER_BATCH_MEMBER_KINDS: readonly ["command", "directoryRead"];
21
47
  /** [ref] §3.5:一段「为什么仍未被覆盖」的闭三词集(`redirection` 永久逐次 / `no_rule_form` 无可覆盖形 / `cap_overflow` 去重+帽挤出)。 */
@@ -5,6 +5,14 @@ export function isPersistedRuleTool(name) {
5
5
  export const PERSISTED_RULE_MATCHES = ["exact", "prefix", "wildcard", "subpath", "path"];
6
6
  const MATCHES_PIN = true;
7
7
  void MATCHES_PIN;
8
+ export const DIRECT_RULE_WRITE_ADMITS = {
9
+ allow: false,
10
+ deny: true,
11
+ ask: true,
12
+ };
13
+ export function isDirectWritableRuleBehavior(behavior) {
14
+ return DIRECT_RULE_WRITE_ADMITS[behavior];
15
+ }
8
16
  export const RULE_OFFER_BATCH_MEMBER_KINDS = ["command", "directoryRead"];
9
17
  const MEMBER_KINDS_PIN = true;
10
18
  void MEMBER_KINDS_PIN;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 信箱**收件人生命周期面**([ref])—— core `MailboxStore` 契约里被点名、但机制**刻意不由 core 命名**的
3
+ * 那一半:「这个收件人正在被部署删掉」。
4
+ *
5
+ * core 的原话(`core/store-contracts/mailbox-store-contract.ts` 的 `MailboxTombstonedRecipientContractHooks`
6
+ * 逐字):「Put `(scope, handle)` into the deployment's PRE-DELETE state — whatever that is for this backend
7
+ * (a cascade marking the session row, a `deleting` column, a tombstone table). Core does not name the
8
+ * mechanism, only what `append` must then do.」 ⇒ 本文件就是 server 侧那个 "whatever"。
9
+ *
10
+ * ## 两个动词,各答一个问题
11
+ *
12
+ * · {@link MailboxRecipientLifecycle.retireRecipient} —— **只打标,不清仓**。打标之后 `append` 响亮拒
13
+ * (`MAILBOX_TOMBSTONED_RECIPIENT_CODE`),但**已入箱的消息、活着的租约一个都不动**。这不是口味问题:
14
+ * core 的 T2 试剂盒有两格逐字钉它 ——「拒的是入口,不是清仓:被拒的 append 零副作用,已入箱的消息按原
15
+ * seq/内容原封不动」与「副作用轴 · 活租约:被拒的 append 不得动别人手上的租约」。所以会话删除级联要
16
+ * 清箱时,是 `retireRecipient` **之后再** `drop`(两个动词两件事,见下)。
17
+ * · {@link MailboxRecipientLifecycle.listRetiredRecipients} —— 目录用它把「正在删除中」那条记录
18
+ * (`liveness: "deleted"`)补出来。
19
+ *
20
+ * ## 墓碑的寿命 = 箱的寿命(`drop` 即扫,**没有** revive 动词)
21
+ *
22
+ * `retireRecipient` 打的标随 `drop(scope, handle)` **一起消失**:盒的生命周期终结,收件人这个态也就没有
23
+ * 附着的对象了。这不是省事,是 core 自己的清扫规则 —— 它的参照目录 `createInMemoryPeerDirectory.sweep`
24
+ * 逐字写着「a non-live row is removable ONLY while its box is EMPTY」,而 `drop` 恰恰是把箱清空那一步;
25
+ * 契约对墓碑的要求只有「the host's deletion cascade writes it BEFORE dropping the box」(**写在丢箱之前**),
26
+ * 没有要求它活过丢箱。
27
+ *
28
+ * 🔴 **为什么这条设计比「墓碑永久 + 一个撤回动词」严格更好**(codex 对抗复审 r1 三条 [high] 之一,验真后重设计):
29
+ * 会话 id 由调用方自选(`http/admission.ts:153` 只有长度门,`security.ts:187-199` 明写刻意不加形状门),
30
+ * 删掉之后**同一个 id 可以被重新登记**(`run-local --session myproject`、留存腿删后用户再跑同名会话)。
31
+ * 永久墓碑于是需要一个「这个收件人回来了」的撤回口,而那个判据**在读面上判不出来** —— 会话删除级联是
32
+ * 「子腿先、`session_meta` 最后」(E21 次序契约),所以级联在飞的那一段里,活会话行与新墓碑是**同时**
33
+ * 存在的:任何「有活行就撤标」的规则都会在删除进行中把围栏拆掉(实序:retire → 目录读到旧活行 → 撤标 →
34
+ * 投递成功 → drop 把它删掉 = 一条有 `queued` 回执的消息被吞)。改成「墓碑随箱亡」之后:
35
+ * · 撤回动词**不存在** ⇒ 那条竞态结构上不可达,读面也不再写任何东西;
36
+ * · 重新登记的 id 天然可用(箱与墓碑在上一次 `drop` 里一起没了),不依赖任何时钟比较;
37
+ * · 代价如实:级联**跑完之后**向那个 id 投递拿到的是「查无此会话」而不是「已删除」——「已删除」这一句
38
+ * 只在级联在飞的那段窗口内说得出。两者都是拒、都不 park,差的只有措辞。
39
+ *
40
+ * ## `drop` 与 `retire` 仍是两个动词(别合并成一个)
41
+ *
42
+ * `retireRecipient` **只打标**:此后 append 响亮拒,但已入箱的消息与活租约一个都不动(core T2 试剂盒的
43
+ * 两格副作用判据)。`drop` 才清仓,并同时扫掉墓碑。会话删除级联两件都做,**先 retire 再 drop**。
44
+ *
45
+ * ## 缺席形是**能力差**,不是兜底
46
+ *
47
+ * 看不见收件人生命周期的后端(local 车道用的是 core 自带的 `FileMailboxStore`)由
48
+ * {@link createDropOnlyRecipientLifecycle} 服务:retire ⇒ 直接 `drop`(箱真清空、投递真拒 —— 只是拒的
49
+ * 措辞退化成「查无此会话」而不是「已删除」),墓碑清单恒空 ⇒ 目录不铸 `deleted` 行。这不是静默降级:
50
+ * 后端选择点(`boot/stores.ts` 的 `mailbox_store_enabled` 行)逐次落 `recipientTombstones` 读数,
51
+ * 披露在 `docs/DEPLOY-PREREQS.md`。方向上它也不放宽任何东西 —— 两种形都**不收**给已删会话的信。
52
+ */
53
+ import type { MailboxStore } from "@sema-agent/core";
54
+ /** 一条墓碑(= 一个已退役的收件人)。 */
55
+ export interface MailboxRecipientTombstone {
56
+ /** 盒句柄(会话盒 = `session.<小写 id>`)。 */
57
+ readonly handle: string;
58
+ /** 退役时刻(epoch ms)——目录据此铸 `diedAt`。 */
59
+ readonly retiredAt: number;
60
+ /** 退役前的显示名(会话的删前 `title`);缺席 = 当时没有可用的名字。 */
61
+ readonly label?: string;
62
+ }
63
+ export interface MailboxRecipientLifecycle {
64
+ /**
65
+ * 把 `(scope, handle)` 置入**预删除态**:此后 `append` 一律以 `MAILBOX_TOMBSTONED_RECIPIENT_CODE` 拒。
66
+ * **只打标**——不删消息、不动租约、不碰盒行的高水位(理由 = core T2 试剂盒的两格副作用判据,见模块头)。
67
+ * 幂等(重复 retire 只更新时刻/名字)。
68
+ *
69
+ * @returns 是否真的落了一张耐久墓碑。`false` = 本后端没有预删除态,箱已被直接 `drop`
70
+ * (拒的方向不变,退化的只有拒绝措辞与「正在删除中」那一行的可见性)。
71
+ */
72
+ retireRecipient(scope: string, handle: string, at: number, label?: string): Promise<boolean>;
73
+ /** 本 scope 下句柄以 `handlePrefix` 开头的墓碑。无预删除态的后端恒返回空。 */
74
+ listRetiredRecipients(scope: string, handlePrefix: string): Promise<readonly MailboxRecipientTombstone[]>;
75
+ }
76
+ /**
77
+ * 看不见收件人生命周期的后端的诚实形:retire = 直接把箱 `drop` 掉(**不**打标,因为没有可打标的地方),
78
+ * 墓碑清单恒空。
79
+ *
80
+ * 🔴 与 `retireRecipient` 的 "只打标不清仓" 契约**有意不同**,且这不是违约:契约的那两格判据是给
81
+ * **有**预删除态的后端写的(它们要能在拒收的同时保住已入箱的消息给运维/留存腿看);没有预删除态的后端
82
+ * 唯一能做到「此后不再收信」的动作就是把箱清掉,`false` 返回值就是这件事的机器可读声明。core 的 T2
83
+ * 试剂盒**不挂**在这种后端上(它自己的注逐字:只挂看得见收件人生命周期的后端)。
84
+ */
85
+ export declare function createDropOnlyRecipientLifecycle(store: MailboxStore): MailboxRecipientLifecycle;
86
+ //# sourceMappingURL=mailbox-recipient-lifecycle.d.ts.map
@@ -0,0 +1,10 @@
1
+ export function createDropOnlyRecipientLifecycle(store) {
2
+ return {
3
+ retireRecipient: async (scope, handle) => {
4
+ await store.drop(scope, handle);
5
+ return false;
6
+ },
7
+ listRetiredRecipients: async () => [],
8
+ };
9
+ }
10
+ //# sourceMappingURL=mailbox-recipient-lifecycle.js.map
@@ -46,8 +46,15 @@ import type { Pool as MySqlPool } from "mysql2/promise";
46
46
  import type { Pool as PgPool } from "pg";
47
47
  import type { MailboxAppendMessage, MailboxLease, MailboxStore } from "@sema-agent/core";
48
48
  import { type IndexSpec } from "./ensure-index.js";
49
+ import type { MailboxRecipientLifecycle, MailboxRecipientTombstone } from "./mailbox-recipient-lifecycle.js";
49
50
  export declare const MAILBOX_TABLE = "mailbox";
50
51
  export declare const MAILBOX_MSG_TABLE = "mailbox_message";
52
+ /** [ref] 收件人墓碑表(预删除态的**唯一**真源;形与理由见 `mailbox-recipient-lifecycle.ts` 模块头)。
53
+ * 🔴 为什么是**新表**而不是给 `mailbox` 加两列:加列走的是本仓「删库重建、不开 ALTER seam」那条政策
54
+ * (存量库首跑必红);而 `CREATE TABLE IF NOT EXISTS` 一张新表在存量库上零破坏、零运维动作。语义上也更干净
55
+ * —— `mailbox` 行是**盒**事实(next_seq / lease),墓碑是**收件人**事实,两者的生命周期本来就不同
56
+ * (`drop` 同时终结两者 —— 墓碑随箱亡,理由全文见 `mailbox-recipient-lifecycle.ts` 模块头)。 */
57
+ export declare const MAILBOX_TOMBSTONE_TABLE = "mailbox_tombstone";
51
58
  /** 店侧日志座(可选;boot 注入 `Logger`)。7.58.0 合并重扫 [medium]:坏 `peer_meta` 行的 fail-closed 抛必须**响亮**——core 唯一的
52
59
  * claimLease 调用方(peer-session-drain)吞掉该错,店不记这一笔就成了「静默永久卡箱」。抛之前记 `mailbox_peer_meta_corrupt`
53
60
  * (scope/handle/seq/code),运维按 seq 修行。 */
@@ -58,7 +65,7 @@ export declare function ensureTiDBMailboxSchema(pool: MySqlPool): Promise<void>;
58
65
  /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgMailboxSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
59
66
  export declare const MAILBOX_INDEXES: readonly IndexSpec[];
60
67
  export declare function ensurePgMailboxSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
61
- export declare class TiDBMailboxStore implements MailboxStore {
68
+ export declare class TiDBMailboxStore implements MailboxStore, MailboxRecipientLifecycle {
62
69
  private readonly pool;
63
70
  private readonly logger?;
64
71
  /** [ref]([ref] §1.2⑥):跨进程安全**显式**声明 —— core 的 `mailboxCrossProcessMountVerdict` 只对字面 `true` 放行
@@ -79,8 +86,15 @@ export declare class TiDBMailboxStore implements MailboxStore {
79
86
  reap(scope: string, now: number, opts?: {
80
87
  maxAgeMs?: number;
81
88
  }): Promise<number>;
89
+ /** 见 {@link MailboxRecipientLifecycle.retireRecipient}。**只打标**:不删消息、不动租约、不碰 next_seq。
90
+ * 锁序 = 盒行先(与 append / claim / ack / drop / reap 五面统一 —— ABBA 死锁形不许在这里重开),
91
+ * 于是 retire 与并发 append 在同一把行锁上串行:要么 append 先拿到 seq、要么它读到墓碑被拒,没有中间态。 */
92
+ retireRecipient(scope: string, handle: string, at: number, label?: string): Promise<boolean>;
93
+ /** 见 {@link MailboxRecipientLifecycle.listRetiredRecipients}。前缀查带 `ESCAPE`(句柄是开集串,
94
+ * `_`/`%` 若被当通配会把**别的**收件人一起列出来 —— 与本仓 `?q=` 面同一条 escapeLike 纪律)。 */
95
+ listRetiredRecipients(scope: string, handlePrefix: string): Promise<readonly MailboxRecipientTombstone[]>;
82
96
  }
83
- export declare class PgMailboxStore implements MailboxStore {
97
+ export declare class PgMailboxStore implements MailboxStore, MailboxRecipientLifecycle {
84
98
  private readonly pool;
85
99
  private readonly logger?;
86
100
  /** [ref] 跨进程安全显式声明(TiDB 孪生同注:事务 + 盒行 FOR UPDATE 锁序是真值前提;终验 = test/mailbox-cross-process-kit.test.ts pg 腿)。 */
@@ -98,5 +112,7 @@ export declare class PgMailboxStore implements MailboxStore {
98
112
  reap(scope: string, now: number, opts?: {
99
113
  maxAgeMs?: number;
100
114
  }): Promise<number>;
115
+ retireRecipient(scope: string, handle: string, at: number, label?: string): Promise<boolean>;
116
+ listRetiredRecipients(scope: string, handlePrefix: string): Promise<readonly MailboxRecipientTombstone[]>;
101
117
  }
102
118
  //# sourceMappingURL=mailbox-store-sql.d.ts.map
@@ -1,8 +1,23 @@
1
- import { MAILBOX_INVALID_PEER_META_CODE, MailboxStoreError, readMailboxPeerMeta } from "@sema-agent/core";
1
+ import { MAILBOX_INVALID_PEER_META_CODE, MAILBOX_TOMBSTONED_RECIPIENT_CODE, MailboxStoreError, readMailboxPeerMeta } from "@sema-agent/core";
2
2
  import { pgHasUnstorable, pgSanitizeText, PgUnstorableError } from "./pg-safe-json.js";
3
3
  import { ensureIndexes } from "./ensure-index.js";
4
+ import { escapeLike } from "./sql-escape.js";
4
5
  export const MAILBOX_TABLE = "mailbox";
5
6
  export const MAILBOX_MSG_TABLE = "mailbox_message";
7
+ export const MAILBOX_TOMBSTONE_TABLE = "mailbox_tombstone";
8
+ const TOMBSTONE_LABEL_MAX_CHARS = 120;
9
+ function boundedLabel(label) {
10
+ if (label === undefined || label === "")
11
+ return null;
12
+ return label.length > TOMBSTONE_LABEL_MAX_CHARS ? label.slice(0, TOMBSTONE_LABEL_MAX_CHARS) : label;
13
+ }
14
+ function tombstonedRecipientError(scope, handle) {
15
+ return new MailboxStoreError(MAILBOX_TOMBSTONED_RECIPIENT_CODE, `MailboxStore.append: recipient ${JSON.stringify(handle)} in scope ${JSON.stringify(scope)} is in this deployment's pre-delete state (refusing — nothing was parked; a receipt here would be one the deletion cascade is about to tear up)`);
16
+ }
17
+ function rowToTombstone(r) {
18
+ const label = r.label === null || r.label === undefined ? undefined : bufToStr(r.label);
19
+ return { handle: bufToStr(r.handle), retiredAt: Number(r.retired_at_ms), ...(label !== undefined ? { label } : {}) };
20
+ }
6
21
  function assertScope(scope) {
7
22
  if (scope === undefined || scope === "") {
8
23
  throw new Error("MailboxStore.append: refusing a message without a scope");
@@ -131,6 +146,14 @@ export async function ensureTiDBMailboxSchema(pool) {
131
146
  peer_meta TEXT NULL,
132
147
  PRIMARY KEY (scope_key, handle, seq),
133
148
  KEY idx_mbm_sent (scope_key, handle, sent_at_ms)
149
+ ) COLLATE utf8mb4_bin`);
150
+ await pool.query(`CREATE TABLE IF NOT EXISTS ${MAILBOX_TOMBSTONE_TABLE} (
151
+ scope_key VARBINARY(190) NOT NULL,
152
+ handle VARBINARY(190) NOT NULL,
153
+ retired_at_ms BIGINT NOT NULL,
154
+ -- 退役前的显示名(会话的删前 title)。纯显示面:目录据它铸 PeerSessionRecord.name;NULL = 当时无名可用。
155
+ label TEXT NULL,
156
+ PRIMARY KEY (scope_key, handle)
134
157
  ) COLLATE utf8mb4_bin`);
135
158
  await ensureIndexes((text, params) => pool.query(text, params), "mysql", MAILBOX_INDEXES);
136
159
  }
@@ -158,6 +181,13 @@ export async function ensurePgMailboxSchema(q) {
158
181
  hop_chain TEXT COLLATE "C", -- core 5.17.0 design/176(TiDB twin 同注:NULL = 缺席且有语义)
159
182
  peer_meta TEXT COLLATE "C", -- S-90 core fc52b4d4 design/385 片2b(TiDB twin 同注:typed peer 记录 JSON;NULL = 缺席且有语义)
160
183
  PRIMARY KEY (scope_key, handle, seq)
184
+ )`);
185
+ await q(`CREATE TABLE IF NOT EXISTS ${MAILBOX_TOMBSTONE_TABLE} (
186
+ scope_key VARCHAR(190) COLLATE "C" NOT NULL,
187
+ handle VARCHAR(190) COLLATE "C" NOT NULL,
188
+ retired_at_ms BIGINT NOT NULL,
189
+ label TEXT COLLATE "C", -- TiDB twin 同注:纯显示面,NULL = 无名可用
190
+ PRIMARY KEY (scope_key, handle)
161
191
  )`);
162
192
  await ensureIndexes(q, "pg", MAILBOX_INDEXES);
163
193
  }
@@ -200,6 +230,9 @@ export class TiDBMailboxStore {
200
230
  await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE scope_key = scope_key`, [scope, handle, scope]);
201
231
  [rows] = (await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
202
232
  }
233
+ const [tomb] = await c.query(`SELECT 1 FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]);
234
+ if (tomb.length > 0)
235
+ throw tombstonedRecipientError(scope, handle);
203
236
  const seq = Number(rows[0].next_seq);
204
237
  await c.query(`UPDATE ${MAILBOX_TABLE} SET next_seq = ? WHERE scope_key = ? AND handle = ?`, [seq + 1, scope, handle]);
205
238
  await c.query(`INSERT INTO ${MAILBOX_MSG_TABLE} (scope_key, handle, seq, from_id, content, sent_at_ms, hop_chain, peer_meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [scope, handle, seq, msg.from ?? null, msg.content, msg.sentAt, hopChain, peerMeta]);
@@ -247,6 +280,7 @@ export class TiDBMailboxStore {
247
280
  await this.tx(async (c) => {
248
281
  await c.query(`DELETE FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ?`, [scope, handle]);
249
282
  await c.query(`DELETE FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ?`, [scope, handle]);
283
+ await c.query(`DELETE FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = ? AND handle = ?`, [scope, handle]);
250
284
  });
251
285
  }
252
286
  async reap(scope, now, opts) {
@@ -273,6 +307,24 @@ export class TiDBMailboxStore {
273
307
  return dropped;
274
308
  });
275
309
  }
310
+ async retireRecipient(scope, handle, at, label) {
311
+ assertScope(scope);
312
+ assertIdentityChars("scope", scope);
313
+ assertIdentityChars("handle", handle);
314
+ assertKeyBytes("scope", scope);
315
+ assertKeyBytes("handle", handle);
316
+ await this.tx(async (c) => {
317
+ await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE scope_key = scope_key`, [scope, handle, scope]);
318
+ await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]);
319
+ await c.query(`INSERT INTO ${MAILBOX_TOMBSTONE_TABLE} (scope_key, handle, retired_at_ms, label) VALUES (?, ?, ?, ?)
320
+ ON DUPLICATE KEY UPDATE retired_at_ms = VALUES(retired_at_ms), label = VALUES(label)`, [scope, handle, at, boundedLabel(label)]);
321
+ });
322
+ return true;
323
+ }
324
+ async listRetiredRecipients(scope, handlePrefix) {
325
+ const [rows] = await this.pool.query(`SELECT handle, retired_at_ms, label FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = ? AND handle LIKE ? ESCAPE '\\\\'`, [scope, `${escapeLike(handlePrefix)}%`]);
326
+ return rows.map(rowToTombstone);
327
+ }
276
328
  }
277
329
  export class PgMailboxStore {
278
330
  pool;
@@ -327,6 +379,9 @@ export class PgMailboxStore {
327
379
  ]);
328
380
  ({ rows } = await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = $1 AND handle = $2 FOR UPDATE`, [scope, handle]));
329
381
  }
382
+ const { rows: tomb } = await c.query(`SELECT 1 FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = $1 AND handle = $2 FOR UPDATE`, [scope, handle]);
383
+ if (tomb.length > 0)
384
+ throw tombstonedRecipientError(scope, handle);
330
385
  const seq = Number(rows[0].next_seq);
331
386
  await c.query(`UPDATE ${MAILBOX_TABLE} SET next_seq = $1 WHERE scope_key = $2 AND handle = $3`, [seq + 1, scope, handle]);
332
387
  await c.query(`INSERT INTO ${MAILBOX_MSG_TABLE} (scope_key, handle, seq, from_id, content, sent_at_ms, hop_chain, peer_meta) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [scope, handle, seq, msg.from ?? null, content, msg.sentAt, hopChain, peerMeta]);
@@ -375,6 +430,7 @@ export class PgMailboxStore {
375
430
  await this.tx(async (c) => {
376
431
  await c.query(`DELETE FROM ${MAILBOX_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
377
432
  await c.query(`DELETE FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
433
+ await c.query(`DELETE FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
378
434
  });
379
435
  }
380
436
  async reap(scope, now, opts) {
@@ -401,5 +457,25 @@ export class PgMailboxStore {
401
457
  return dropped;
402
458
  });
403
459
  }
460
+ async retireRecipient(scope, handle, at, label) {
461
+ assertScope(scope);
462
+ this.assertIdentity("scope", scope);
463
+ this.assertIdentity("handle", handle);
464
+ assertKeyBytes("scope", scope);
465
+ assertKeyBytes("handle", handle);
466
+ const bounded = boundedLabel(label);
467
+ const stored = bounded === null ? null : pgSanitizeText(bounded);
468
+ await this.tx(async (c) => {
469
+ await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES ($1, $2, $3, 1) ON CONFLICT (scope_key, handle) DO NOTHING`, [scope, handle, scope]);
470
+ await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = $1 AND handle = $2 FOR UPDATE`, [scope, handle]);
471
+ await c.query(`INSERT INTO ${MAILBOX_TOMBSTONE_TABLE} (scope_key, handle, retired_at_ms, label) VALUES ($1, $2, $3, $4)
472
+ ON CONFLICT (scope_key, handle) DO UPDATE SET retired_at_ms = EXCLUDED.retired_at_ms, label = EXCLUDED.label`, [scope, handle, at, stored]);
473
+ });
474
+ return true;
475
+ }
476
+ async listRetiredRecipients(scope, handlePrefix) {
477
+ const { rows } = await this.pool.query(`SELECT handle, retired_at_ms, label FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = $1 AND handle LIKE $2 ESCAPE '\\'`, [scope, `${escapeLike(handlePrefix)}%`]);
478
+ return rows.map(rowToTombstone);
479
+ }
404
480
  }
405
481
  //# sourceMappingURL=mailbox-store-sql.js.map