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 中的 canonicalMessageRecord批量归档到 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(回填前禁止裁剪)