Skip to content

2b: 退订交接(继任者全量 ack 门禁 + 本地彻底清理,无 tombstone) - #51

Merged
Windsander merged 6 commits into
mainfrom
feat/namespace-handoff
Sep 18, 2026
Merged

Windsander merged 6 commits into
mainfrom
feat/namespace-handoff

Conversation

@Windsander

Copy link
Copy Markdown
Owner

2b · 退订交接(继任者全量 ack 门禁 + 本地彻底清理)

退订 = 成员资格退出namespace_membership(active:false))+ 本机彻底清理该分区数据;绝不产生 tombstone;默认继任者全量 ack 后才清理,门禁不过则保持原状force 仅本地 CLI如实记录;新增审计产物 namespace_handoff(退订方签名写入 __policy__)。core 语义变更,走封板纪律。

关键实现(H1–H6)

  • H1 全量 ack = 既有 per-event ackplanNamespaceHandoffsync.getPendingEvents(successor) 判断——退订方校验继任者已 ack 其在该分区的他人署名事件(继任者自证事件其本就拥有,排除以免误判)。不新增同步协议、不放宽快照门禁(SEALING §4 快照门禁不动)。
  • H2 继任者:必须显式 --successor <deviceId>;且必须是该分区生效成员,否则拒绝。
  • H3 清理范围/原子性/崩溃安全:物理删除该分区事件/节点/边 + 本地水位(forgetNamespace:清 per-(peer,ns) 水位与已删事件 ack);__policy__ 永不清理;意图记录落图外 <storagePath>.handoff.json(0600 原子写)→ 可幂等续跑
  • H4 验前不删:校验 → 成员注销 + 交接记录(含 forced/缺失明细)→ 写意图 → 删除 → 清意图。
  • H5 可观测fleet leave --dry-run 给验证明细;doctor交接状态(有意图 → FAIL 提示续跑)。
  • H6:core 公共 API planNamespaceHandoff / leaveNamespace + fleet leave;fleet 只调公共 API。

主张 → 证据

# 主张 证据
1 覆盖足→清理成功、数据消失、B/C 不丢 2b ①(3 端 jest)+ verify:fleet:handoff CLI
2 覆盖不足→中止且原封不动 + 缺失明细 2b ② + CLI「A2 未同步→中止,数据不变」
3 force→成功且 forced:true 如实记录 2b ③ + CLI force
4 中断→续跑完成且无 tombstone 2b ④(注入 deleteEvent 抛错)
5 清理后成员闸门仍在(legacy 不退化) 2b ⑤ + CLI「members.active」
6 oracle-free:清理不改策略(签发者一致) 2b ⑤
7 __policy__ 保护 2b 保留分区保护 + CLI「含 namespace_handoff」
8 不回归 715 ×6、覆盖阈值全过、lint/build 无输出、CI 三 job 绿、verify:fleet:all 104/104

红 → 绿(断言原文)

不变量 破坏点 锚点 结果
物理清理 purgeNamespace 不删事件 2b ① 数据消失 ✕ Expected 0 Received 3 → 还原 ✓
覆盖门禁 if (!force)if (false) 2b ② 覆盖不足→中止 (不该清理却清理)→ 还原 ✓
脚本类(S9,先 build) plan 不排除继任者自证事件 verify:fleet:handoff dry-run FAIL dry-run {"ok":false,"pendingTotal":3}(15→10)→ 还原 15/15

交接记录样例(__policy__

{"type":"namespace_handoff","namespace":"__policy__","author":"device-A","data":{"handoff":{
  "handoffId":"<uuid>","namespace":"tasks","successor":"device-B","forced":true,
  "pendingCount":1,"missingAuthors":["device-A"],"issuedAt":1789736…,"note":"test"}}}

跨端 E2E 原始输出(verify:fleet:handoff,15/15)

PASS  前置:B 已全量接收并执行 N 次
PASS  dry-run:覆盖足 → ok=true, pendingTotal=0
PASS  ① leave 成功(deleted={"events":12,"nodes":12,"edges":0})
PASS  ① A 本分区数据彻底消失  {"before":12,"after":0}
PASS  ① __policy__ 保留(含 handoff)
PASS  ① 无 tombstone(A 仅剩 __policy__ 事件)
PASS  ① B 数据不丢(exec 仍 N)
PASS  ① 清理后成员闸门仍在(self 已注销)
PASS  ② 覆盖不足 → aborted, reason=successor-incomplete
PASS  ② 报告缺失作者明细  {"missing":[{"author":"device-A2","count":2}]}
PASS  ② 数据原封不动  {"before":2,"after":2}
PASS  ③ force → 成功且 forced=true
FLEET_SUMMARY {"total":15,"passed":15,"failed":[],"skipped":[]}

三端(A/B/C)B、C 数据不丢tests/sync/handoff.test.ts ① 断言(真实 InMemoryHub 同步)。

SEALING / 矩阵 diff 要点

  • POLICY-INVARIANTS.md:新增 namespace_handoff 审计行(不参与推导)+ 横切不变量 10(无 tombstone、__policy__ 保留、全量 ack 门禁复用既有 ack、验前不删+幂等续跑、成员闸门不退化为 legacy、force 仅本地如实记录)。
  • SEALING.md §3:事件类型加 namespace_handoff + 2b 语义 + 破坏性说明(退订方本地彻底删数据;旧节点无交接概念 → 不清理,安全);§4 退订交接标记已做,仅剩「重订阅恢复」属 2c。

给 2c 的接口/语义清单(重订阅恢复需要什么)

  • 已具备namespace_membership(active:true) 可重新在册;getNamespaceMembers(生效成员);forgetNamespace 已清该分区本地水位(恢复时从零水位重建)。
  • 2c 需定义:① 重订阅后从哪里取回该分区历史(退订方已物理删除 → 需向现任成员全量拉取,可能复用快照/offer,但快照门禁那条要求先做完整冲突合并 = G);② 本地 peerWatermarks 清空后的首次拉取语义(避免把“无水位”当成“已全有”);③ 重入时的授权/成员再校验;④ tombstone-free 前提下如何区分“从未有”与“已清理”(本机可用图外标记,但不得同步)。
  • 不建议:引入删除事件或对端可撤销的“已删”标记(违反无 tombstone 与安全方向)。

未做项 / 风险

  • 重入/重订阅恢复属 2c(本轮明说未支持)。
  • 覆盖判定基于退订方自己记录的 ack(保守):若继任者经第三方获得事件而退订方无 ack,会拒绝清理(保持原状,安全方向);可先与继任者直连同步再退订。
  • 事件日志 in-memory 时钟不随删除回退(进程内);持久存储已删除,重启恢复即为正确水位——不影响 2b,2c 重入需注意。
  • 兜底:无 CI 覆盖 Windows/Linux 真实清理(清理是存储层通用能力,已由跨平台 jest 覆盖)。

测试方式

npm ci && npm run build
npm run test:coverage
npm run verify:fleet:all
node scripts/verify-fleet-handoff.mjs

POLICY-INVARIANTS:namespace_handoff 审计行 + 横切不变量 10(无 tombstone、__policy__ 保留、全量 ack 门禁复用既有 ack、验前不删+幂等续跑、成员闸门不退化为 legacy、force 仅本地如实记录)。
SEALING §3 事件类型 + 2b 语义;§4 退订交接标记已做(重订阅恢复留 2c)。
grantPolicy:NAMESPACE_HANDOFF_EVENT + NamespaceHandoffRecord(不参与推导)。
SyncManager.forgetNamespace:清理后丢弃该分区本地水位/ack(不产生事件)。
Mebular.planNamespaceHandoff(复用 getPendingEvents,排除继任者自证事件)/ leaveNamespace(验前不删 → 成员注销+交接记录 → 图外意图 → 物理删除 → 清水位 → 去意图;force 如实记录;幂等续跑;__policy__ 保护)。
BREAKING CHANGE: 退订方本地彻底删除该分区数据(无 tombstone)。
handoff.test:覆盖足清理+policy保留+B/C不丢、覆盖不足中止、force、中断续跑无 tombstone、成员闸门仍在、__policy__ 保护。
grant.test:fleet planHandoff/leaveNamespace/doctor 交接状态。
…态 + ONBOARDING §12

onboard:planHandoff/leaveNamespace。cli:leave 子命令(force 仅本地)。
doctor:交接状态(存在图外意图 → FAIL 提示续跑)。
CLI 级:A/B 派活→B 全量 ack→leave 成功(A 数据消失、__policy__ 含 handoff、B exec 不变、无 tombstone);A2 未同步→leave 中止+原封不动;force 成功。verify:fleet:all 104/104。
B 执行 20/20 但 A 偶发只见到 19 条 done;本机 3× 7/7。
@Windsander
Windsander merged commit 3656ce6 into main Sep 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant