跳转至

10 保留、删除与合规

状态:可实施 更新日期:2026-09-01 上游契约:docs/PLAN.md §7.1(retention_class、dek_id)、§18.3、§21 实施决策:ADR-0007(一期 Redis MailboxStore)

0. 文档边界

本文定义:加密擦除实现、删除清单执行器、导出与 WORM、审计与数据驻留。

本文不得重新定义:retention_class 取值集合(§7.1)、§21.4 级联删除清单的行、 两类裁剪的性质区分(§18.3.3)。


1. 为什么必须是加密擦除

至少一次投递 + 多副本 + 不可变对象存储检查点 + WORM 备份, 使逐行物理删除在本架构下不可完成:

S3 检查点不可变        无法从已封存的检查点里剜掉一行
WORM 备份锁定期内不可删  合规要求本身禁止删除
多副本 + 修复           删掉的行可能被反熵修复重新带回
TWCS 禁止显式 DELETE    墓碑不释放空间且击穿 tombstones_scanned == 0 断言

因此删除 = 销毁密钥,行本体留待 TTL 自然回收。这条约束反向决定了存储格式 (每行必须携带 dek_id),所以必须在基线阶段确定,不能后补。


2. 密钥层级

TMK   Tenant Master Key      KMS 托管,租户级根密钥
 └─ CDEK  Conversation DEK   每会话一个,用 TMK 封装
     └─ EphemeralDEK         = KDF(CDEK, date_bucket),用于 ephemeral_24h(§3.3)

dek_id = (key_scope, key_id, key_version)
    key_scope   TENANT | CONVERSATION | EPHEMERAL
    key_version 轮换代次,轮换不解密旧数据(旧版本保留至该数据到期)

AAD 绑定必须包含定位坐标,防止密文被搬到别处仍可解密:

AAD = tenant_id || conversation_id || conversation_seq || dek_id.key_version

2.1 与 E2EE 的关系(不要混淆)

静态加密 DEK     服务端持有,用于合规擦除与静态数据保护,服务端**可**解密
E2EE 会话密钥    服务端不持有,§22.2 的 sender-key 模型,服务端只搬运

两者可叠加:E2EE 密文再经 DEK 加密后落盘
E2EE 会话的合规删除同样靠销毁 DEK —— 密文本身服务端本就读不懂

3. 保留分层

3.1 retention_class 的执行

default          一期全局 30 天(default_history_retention_days,可配,ADR-0019)逐行 USING TTL
ephemeral_24h    USING TTL 86400 + EphemeralDEK(见 §3.3)
compliance_hold  **不设 TTL**,只能由合规流程解除后转为 default
tenant_custom    按租户配置逐行设 TTL

当前实现状态(2026-09-26):历史已按 ADR-0018 分层——Redis 热层保留 history_hot_window(7 天)内的记录,HistoryArchiver 异步把 Outbox 中的 canonical MessageRecord 批量归档到 ScyllaDB,retention_class 的逐行 USING TTL 在归档层执行 (ephemeral_24h 按剩余期限写入,迟到归档仍按原时刻到期)。仅 ephemeral_24h 的自动到期 已落地;default 按 ADR-0019 保留 30 天(QIM_DEFAULT_HISTORY_RETENTION_DAYS 可配),Redis 热层 到期 GC 与 ScyllaDB 逐行 TTL 同一策略;tenant_custom 的租户策略来源未实现,失败闭合为不到期; 一期不做对象存储冷归档与归档回读(30 天保留远短于冷归档收益区间); compliance_hold 继续 fail closed 为无 TTL。§3.4 的热窗口裁剪不是租户保留策略, 只决定一条记录由哪一层提供。下文归档流程是目标契约,不是当前可用能力。

message_record 的表级 TTL 必须为 0(docs/02 §2.1)—— 否则 compliance_hold 的行会被表级 TTL 静默删除。

3.2 邮箱的两类裁剪(§18.3.3)

时间窗裁剪   mailbox_retention_days 到期后物理删除,不等待任何设备游标;
             落后设备显式收到 CURSOR_EXPIRED -> REBUILD,属正常保留策略
条目上限裁剪 条目数超 max_mailbox_entries_per_user(5 万)时提前裁剪;
             同样触发 CURSOR_EXPIRED -> REBUILD,但属容量兜底,触发即 P2 告警

expired_gap_boundary / effective_trim 必须持久证明时间窗已经越过的最大序号; 读路径若发现 after_seq 更早,必须失败闭合为 CURSOR_EXPIRED,不得返回空批次并推进游标。

mailbox_retention_days >= 投影压缩器最坏滞后 P99.9 × 3 只约束 §12.3 的阶段二 mailbox-tail 目标形态。当前 qsession 的 checkpoint 越过日志保留窗口时,递增投影纪元并按用户从 UserConversationState(ScyllaDB user_conversation_state)惰性重建会话集合(ADR-0021); 越窗期间首次出现且之后无消息的会话无法恢复。

3.3 ephemeral_24h 的实现

物理删除做不到(§1),所以用时间桶子密钥:

EphemeralDEK(date_bucket) = KDF(CDEK, date_bucket)
24 小时后销毁该子密钥 -> 该桶内全部 ephemeral 消息立即不可解密

对外口径:24 小时后不可读,物理字节随 TTL 自然回收
计量:key_destroy_lag(子密钥应销毁时刻 -> 实际销毁时刻)

3.4 历史热窗口裁剪(ADR-0018,与邮箱裁剪无关)

对象      MessageStore 的 Redis 热层记录(msgrecord/msghistory),不是邮箱条目
条件      committed_at <= min( now - history_hot_window,
                              归档水位 - outbox_recovery_window - 60 s 时钟余量 )
          即「到期」与「已归档」同时成立;归档水位缺失或停滞时上界为 0,什么都不删
动作      writer 每 5 s 批量执行(每批 1024 条、每轮 2 s 预算,取尽为止):
          删热层记录与索引,并抬高该会话的热层下界 F(只增不减)
读路径    seq <= F 以 ScyllaDB 归档层为准,seq > F 以 Redis 为准;两层拼接后返回,
          任一层所需记录缺失或损坏整页失败,不得以空页或部分未读伪装成功
不产生    CURSOR_EXPIRED / REBUILD——它不动任何设备游标,也不影响邮箱条目

归档水位由 HistoryArchiver 按「批前取 T0 与 Outbox 高水位 H0,位点 ≥ H0 后推进到 T0」 发布,只进不退;恢复窗口项保证同会话中序号更低、因恢复而更晚提交的消息也已归档。


4. 级联删除执行器

4.1 主体与清单

§21.4 的清单共 19 行,每行标注适用主体:用户删除 / 会话删除 / 租户删除。 执行器按主体过滤后逐行执行,漏掉任何一行都会造成残留,因此清单本身是验收项。

用户删除的特殊规则:
    不销毁会话 DEK(会话里还有其他成员的可读消息)
    只删除该用户的个人数据:UserMailboxEntry / UserConversationState /
    UserSessionProjection / UserBadgeState / PresenceEntry / device_token /
    MessageReaction 明细中该用户的行
    该用户发送的消息正文由**会话删除或租户删除**时随 CDEK 销毁

会话删除 / 租户删除:销毁 CDEK / TMK,一次覆盖全部密文

4.2 两个最容易漏的位置

ConversationHead.preview_or_placeholder     含正文片段,用会话 DEK 加密
                                            销毁 CDEK + **改写为通用占位**
                                            (不能只删消息不改头)
UserMailboxEntry 的内联正文(ADR-0005)      内联条目持有 dek_id,
                                            必须与 MessageRecord 同批加密擦除

UserSessionProjection.preview_or_placeholder 不持久化(§21.3.4)—— SESSION_DELTA / SESSION_LIST_BATCH 的 preview 由 MailboxNode 读时从 ConversationHead 填充,因此该行无独立密文与删除动作。

4.3 反向索引

对象存储媒体无法按 user_id 枚举 -> 必须维护 MediaOwnerIndex
    (tenant, owner_user) -> object_id 列表
否则用户删除时无法定位其上传的媒体,只能等对象生命周期规则

4.4 时限

即时(<= 1 h)   离线推送 payload 缓存、device_token 解绑
<= 24 h          UserMailboxEntry、ConversationHead.preview
<= 2 h           ClientDedup(TTL,ADR-0008)
<= 7 d           MessageRecord 加密擦除、投影、状态表、媒体、搜索索引
随保留期          检查点、冷归档、备份(靠加密擦除,不可逐行删)
<= 1 min         PresenceEntry(发 tombstone,compacted topic 收敛)

5. 数据主体权利

访问   导出该用户可见的全部数据(见 §6)
更正   仅限用户主动状态(昵称、设置),消息内容不可更正(会破坏 conversation_seq 语义)
删除   走 §4 的用户删除主体
限制处理  账号冻结:保留数据但停止投递与推送
可携带   §6 的导出格式

法务保留(compliance_hold)覆盖删除请求:收到删除请求但数据处于 compliance_hold 时,标记为"待删除",保留解除后自动执行并审计留痕。


6. 导出与 WORM

导出   按会话分区导出为 Parquet(利用 docs/02 的 seq_bucket 分桶天然对齐)
       E2EE 会话只能导出密文,明文导出需客户端参与(§22.3 的降级矩阵)
       导出任务本身受限流,且导出产物加密并设短期有效期

WORM   审计日志与合规备份写入锁定期 bucket
       backup_retention_days 后随锁定期到期消失
       锁定期内**不可删除** —— 这正是必须依赖加密擦除的原因

7. 审计

必须留痕的操作
    删除请求的受理、执行、完成
    DEK 销毁(谁、何时、哪个 key_scope)
    compliance_hold 的设置与解除
    qimctl 的全部危险操作(shard split / drain / membership recompact)
    跨境数据传输

审计日志本身进 WORM,保留期独立于业务数据
禁止在审计日志中出现明文正文或密钥

8. 数据驻留

用户 home_region 决定其邮箱、投影、Presence 的物理位置(§5.3.3)
会话 Home Region 决定 conversation_seq 分配点,可与前者不同

跨境传输:跨国群的消息正文会出现在会话 Home Region
          租户可配置"禁止跨境",此时该租户的群成员必须同 region,
          违反时建群失败而不是静默跨境

9. 搜索与 V2 边界

服务端全文检索、数据导出的完整形态列为 **V2**
但它们反向约束存储模型,因此现在就要预留:
    seq_bucket 分桶对批量导出友好
    加密擦除对检索索引的约束:索引词条必须随 DEK 失效,
    否则销毁密钥后索引仍可反推内容 —— V2 设计前必须确认这一点

10. 验收

X-1【发布阻断】删除清单无残留
  删除一个用户后,对 §21.4 清单的 19 行逐项断言无残留
  漏检任何一行即失败(清单本身是验收项)

X-2【发布阻断】compliance_hold 不被 TTL 删除
  写入 compliance_hold 消息,等待超过租户默认 TTL:行仍可读
  schema 断言:message_record 表级 default_time_to_live == 0

X-3 加密擦除有效性
  销毁 CDEK 后:该会话全部密文不可解密
  ConversationHead.preview 已改写为通用占位(不只是删消息)
  内联条目(ADR-0005)同样不可解密

X-4 ephemeral_24h
  24 小时后子密钥销毁:该桶消息不可解密
  key_destroy_lag <= 1 h

X-5 用户删除不误伤他人
  删除用户 A 后:A 参与的会话中其他成员仍能正常读取历史
  (断言 CDEK 未被销毁)

X-6 媒体反向索引
  删除用户后其上传的全部 object_id 被删除
  MediaOwnerIndex 覆盖率 == 100%(无孤儿对象)

X-7 条目上限裁剪与时间窗裁剪可区分
  两条路径越过落后游标时都返回 CURSOR_EXPIRED,禁止静默空批次
  超条目上限:mailbox_entry_cap_evicted_total 增长并触发 P2
  正常时间窗到期:该指标不增长,不触发条目上限 P2

X-8【发布阻断】历史热窗口裁剪不越过归档水位(ADR-0018)
  归档水位未推进时,任何已到期的热层记录都不得被删除
  推进水位后裁剪:跨热层下界的 OLDER/NEWER 翻页、整段裁剪会话与定义式未读
  均与单层读取一致(真实 Redis + 三节点 Scylla:qim-store/tests/tiered_message_store.rs)
  归档批内同一 conversation_seq 出现两条不同消息必须拒绝,不得静默覆盖

11. 待办

[ ] KMS 选型与 TMK 轮换流程
[ ] MediaOwnerIndex 的建表与回填(存量对象的归属重建)
[ ] 删除执行器的幂等与断点续做(19 行清单跨多个存储)
[ ] V2 搜索索引与加密擦除的兼容性验证
[x] 提交事实所在 Redis 强制 appendfsync always(writer 启动探测),消除 AOF 回退导致的
    conversation_seq 复用与归档静默覆盖(ADR-0018;生产需带掉电保护的 SSD)
[ ] 升级前已在 Redis、不在 Outbox 保留期内的历史一次性回填 ScyllaDB(回填前禁止裁剪)