@furongjun1999/dsh-memory 0.7.0 → 0.7.1

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 (65) hide show
  1. package/README.md +7 -6
  2. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +223 -0
  3. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.1.md +340 -0
  4. package/docs/eval/issue50_/345/205/203/346/225/260/346/215/256/351/200/217/344/274/240/344/270/216/345/205/234/345/272/225_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +437 -0
  5. package/docs/eval/issue50_/345/215/212/351/207/215/345/244/215/345/276/205/345/256/232/345/244/215/346/240/270_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +488 -0
  6. package/docs/eval/issue50_/345/276/205/345/256/232/345/244/215/346/240/270/345/205/245/351/230/237_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +428 -0
  7. package/docs/eval/issue50_/350/257/273/351/235/242/344/277/235/346/212/244/345/217/252/350/256/244/346/230/276/345/274/217/346/235/245/346/272/220_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +271 -0
  8. package/docs/eval/issue50_/351/207/215/350/246/201/345/272/246/345/220/214/346/272/220/344/270/216/344/277/235/346/212/244/350/257/255/344/271/211_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +306 -0
  9. package/docs/eval/issue51_/344/270/200/351/224/256/345/256/211/350/243/205/345/244/261/350/264/245_/345/275/222/345/261/236/345/210/244/345/256/232_v1.0.md +58 -0
  10. package/docs/eval/issue52_/346/235/241/344/273/266/345/205/210/350/241/214/344/270/216/346/210/252/346/226/255/345/217/257/350/247/202/346/265/213_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +360 -0
  11. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/241/346/241/243/344/275/215/345/215/225/344/270/200/345/205/245/345/217/243_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +651 -0
  12. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/242/345/217/230/346/233/264/345/215/225/344/270/216/345/233/236/346/273/232/345/216/237/350/257/255_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +439 -0
  13. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/243/345/207/206/345/205/245/350/257/273/346/225/260_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1337 -0
  14. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/244/346/224/266/345/256/230/344/270/216/345/205/250/351/223/276/351/252/214/346/224/266_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1817 -0
  15. package/docs/eval//345/217/221/345/270/20309_/346/243/200/347/264/242/351/235/242/344/270/211/346/211/271/346/224/266/345/217/243_v1.0.md +66 -0
  16. package/docs/eval//347/254/2543/345/261/202stg/347/273/223/346/236/204/347/264/242/345/274/225_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +250 -0
  17. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +25 -25
  18. package/docs/plans/stg/346/235/241/344/273/266/345/214/226/344/270/216/347/273/223/346/236/204/347/264/242/345/274/225_/350/256/276/350/256/241_v0.1.md +124 -0
  19. package/docs/plans//350/256/260/345/277/206/350/207/252/345/244/204/347/220/206/344/270/211/346/241/243/350/207/252/346/262/273_/350/256/276/350/256/241_v0.2.md +207 -0
  20. package/md_cg/admission.py +718 -0
  21. package/md_cg/autonomy_modes.py +642 -0
  22. package/md_cg/consistency.py +133 -8
  23. package/md_cg/forgetting.py +38 -6
  24. package/md_cg/freshness.py +17 -1
  25. package/md_cg/lifecycle.py +30 -2
  26. package/md_cg/mcp_server.py +63 -7
  27. package/md_cg/mdcg.py +85 -17
  28. package/md_cg/mdcos.py +666 -16
  29. package/md_cg/protect.py +79 -4
  30. package/md_cg/provenance.py +1 -1
  31. package/md_cg/review_cli.py +31 -2
  32. package/md_cg/rollback.py +411 -0
  33. package/md_cg/rollback_cli.py +104 -0
  34. package/md_cg/stg.py +400 -50
  35. package/md_cg/stgidx.py +281 -0
  36. package/md_cg/test_autonomy_admission.py +1098 -0
  37. package/md_cg/test_autonomy_modes.py +1972 -0
  38. package/md_cg/test_b1_auto_id_multiproc.py +7 -0
  39. package/md_cg/test_b1b2_write_face.py +7 -0
  40. package/md_cg/test_b3_merge_keeps_content.py +7 -0
  41. package/md_cg/test_cons200_scan_selection.py +791 -0
  42. package/md_cg/test_i50a_half_dup_defer.py +408 -0
  43. package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
  44. package/md_cg/test_i50c_meta_passthrough.py +608 -0
  45. package/md_cg/test_i50d_importance_source.py +531 -0
  46. package/md_cg/test_i50e_readside_protection.py +656 -0
  47. package/md_cg/test_issue39_utf8_stdio.py +9 -1
  48. package/md_cg/test_issue52_scan_condition_first.py +677 -0
  49. package/md_cg/test_linkref.py +7 -0
  50. package/md_cg/test_mode_parity.py +1393 -0
  51. package/md_cg/test_mutation_rollback.py +805 -0
  52. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
  53. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
  54. package/md_cg/test_p2_mcp.py +8 -0
  55. package/md_cg/test_p9_forget_protect.py +7 -0
  56. package/md_cg/test_p9c_dedup_hints.py +7 -0
  57. package/md_cg/test_policy_required_ccg.py +5 -1
  58. package/md_cg/test_protocol.py +7 -0
  59. package/md_cg/test_stgidx_index_parity.py +1020 -0
  60. package/md_cg/test_writelimit.py +36 -9
  61. package/md_cg/test_writepipe.py +5 -2
  62. package/md_cg/weights.py +15 -1
  63. package/md_cg/writepipe.py +101 -2
  64. package/package.json +1 -1
  65. package/skills/plugin.json +1 -1
package/md_cg/protect.py CHANGED
@@ -82,7 +82,7 @@ def _entry(cg, node_id):
82
82
  return ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id)
83
83
 
84
84
 
85
- # 生效条件:cg 索引中无 node_id 条目时返回 None;有条目时先取 layer/importance(缺 importance 键回落 0.5)/protected/protection_reason/immutable/self_state,仅当条目缺 protected 或 immutable、或(缺 self_state 且条目 layer∈PROTECTED_LAYERS)、或(条目 layer∈PROTECTED_LAYERS 且 _GATE_UNKNOWN_KEYS 中任一键的值为 None)时再经 cg.get(node_id) 用 frontmatter 覆盖这四个键(cg.get 抛异常或返回假值时保留索引值;layer 取 frontmatter.layer or 索引 layer,importance 缺键时回落索引 importance)。
85
+ # 生效条件:cg 索引中无 node_id 条目时返回 None;有条目时先取 layer/importance(缺 importance 键回落 0.5)/protected/protection_reason/immutable/self_state/importance_source(issue50-e:缺键读 None),仅当条目缺 protected 或 immutable、或(缺 self_state 且条目 layer∈PROTECTED_LAYERS)、或(条目 layer∈PROTECTED_LAYERS 且 _GATE_UNKNOWN_KEYS 中任一键的值为 None)时再经 cg.get(node_id) 用 frontmatter 覆盖这些键(cg.get 抛异常或返回假值时保留索引值;layer 取 frontmatter.layer or 索引 layer,importance 缺键时回落索引 importance)。
86
86
  def _fm(cg, node_id):
87
87
  """取判定所需的 frontmatter 字段;索引快照门控字段**未知**时回退读文件。"""
88
88
  e = _entry(cg, node_id)
@@ -95,6 +95,9 @@ def _fm(cg, node_id):
95
95
  "protection_reason": e.get("protection_reason"),
96
96
  "immutable": e.get("immutable"),
97
97
  "self_state": e.get("self_state"),
98
+ # issue50-e:重要度来源进判定面(_node_entry/_stage 恒落该键,值可为
99
+ # None ⇒「缺省来源」;老索引条目缺键时 .get() 同样得 None,等价缺省)。
100
+ "importance_source": e.get("importance_source"),
98
101
  }
99
102
  # 回退读文件:索引快照缺字段时。self_state 只在受保护层(self/anchor)
100
103
  # 需要,回退代价被限制在少量节点上,不影响全量统计性能。
@@ -128,12 +131,30 @@ def _fm(cg, node_id):
128
131
  fm["self_state"] = f2.get("self_state")
129
132
  fm["layer"] = f2.get("layer") or fm["layer"]
130
133
  fm["importance"] = f2.get("importance", fm["importance"])
134
+ fm["importance_source"] = f2.get("importance_source")
131
135
  return fm
132
136
 
133
137
 
134
- # 生效条件:cg 索引无 node_id 条目(_fm 直接返回 None,不走回退读文件)时返回 (False, '');有条目时按 layer∈PROTECTED_LAYERS 返回 (True, 层保护);否则 protected is True 时返回 (True, protection_reason 或 '显式保护标记');否则 importance(缺失/假值/float 转换异常一律按 0.0)≥AUTO_PROTECT_IMPORTANCE 时返回 (True, 重要性保护);其余返回 (False, '')。
138
+ # 生效条件:cg 索引无 node_id 条目(_fm 直接返回 None,不走回退读文件)时返回 (False, '');有条目时按 layer∈PROTECTED_LAYERS 返回 (True, 层保护);否则 protected is True 时返回 (True, protection_reason 或 '显式保护标记');否则 importance(缺失/假值/float 转换异常一律按 0.0)≥AUTO_PROTECT_IMPORTANCE 且 importance_source 为 None(键缺省)或 'hint' 时返回 (True, 重要性保护)——issue50-e:'heuristic' 等显式非 hint 来源不得由分数触发;其余返回 (False, '')。
135
139
  def is_protected(cg, node_id):
136
- """**不可遗忘**判定 → (是否受保护, 原因)。节点不存在返回 (False, "")。"""
140
+ """**不可遗忘**判定 → (是否受保护, 原因)。节点不存在返回 (False, "")。
141
+
142
+ issue50-e(2026-10-02,使用者裁定「读面也只认显式来源」):按分自动保护
143
+ 只对**缺省来源或显式 hint** 的分数生效。issue50-d 落盘了启发式真分
144
+ (importance=0.7x + importance_source="heuristic"、不打保护位),若本
145
+ 判定不看来源,读面(遗忘/搬迁闸、protect.check、scrub 净化与 confidence
146
+ 校准的 skip 判定、stats 盘点)会把机器推断的分数重新认回受保护——写侧
147
+ 「只落分不打位」被读侧单方面推翻。边界:
148
+ ① 键缺省(None)= 存量节点(既有 cg.add(importance=0.9) 直写、维护路径
149
+ 调分等从未有过该键)⇒ 行为一字不变——按「缺省即不认」会大规模改变
150
+ 既有保护面,禁止;
151
+ ② "hint" = 显式声明(写侧对 hint 过线本就打位,按分分支只是其无位形态
152
+ 的兜底,语义不变);
153
+ ③ "heuristic" 及其它显式来源不得由分数触发——机器推断的重要度不构成
154
+ 不可遗忘的依据。层保护(PROTECTED_LAYERS)、fm.protected 位、
155
+ is_immutable 一律不动;MERGE 强化(forgetting.reinforce 跨 0.7 置
156
+ protected=True)是「重复确认」的显式动作,不经本分支,不受影响。
157
+ """
137
158
  fm = _fm(cg, node_id)
138
159
  if fm is None:
139
160
  return False, ""
@@ -146,7 +167,11 @@ def is_protected(cg, node_id):
146
167
  imp = float(fm.get("importance") or 0.0)
147
168
  except Exception:
148
169
  imp = 0.0
149
- if imp >= AUTO_PROTECT_IMPORTANCE:
170
+ # issue50-e(2026-10-02):按分保护**只认缺省或显式 hint 来源**——
171
+ # 语义与边界见 docstring ①②③;_fm 恒带 importance_source 键(缺省读
172
+ # None),老索引条目缺键同样落 None ⇒ 存量按分保护一字不变。
173
+ _src = fm.get("importance_source")
174
+ if imp >= AUTO_PROTECT_IMPORTANCE and (_src is None or _src == "hint"):
150
175
  return True, f"重要性保护:importance={imp:.2f}≥{AUTO_PROTECT_IMPORTANCE}"
151
176
  return False, ""
152
177
 
@@ -220,6 +245,56 @@ def snapshot(cg, node_id):
220
245
  return rel
221
246
 
222
247
 
248
+ # 生效条件:cg.get(node_id) 抛异常或返回假值时返回 None(与 snapshot 同口径——目标不存在=动作不会发生,由调用方按既有错误路径处理);否则在 cg.root/HISTORY_DIR/node_id 下以「秒级时间戳-微秒后缀」命名写入当前 frontmatter 与 content(写盘形态与 snapshot 逐位一致:同目录、同 nodefile 序列化、同 _write_node 封装钩子),_write_node 抛异常时返回 None,成功则追加一条 action="preimage" 的审计(reason 交代动作类与 pid)并返回相对 cg.root 且以 '/' 分隔的路径;
249
+ def snapshot_preimage(cg, node_id, action="", pid=None, reason="", actor=None):
250
+ """执行时点前像(设计 §四「回滚原语通用化」:快照面推广为一切 C/D/B)。
251
+
252
+ 与 `snapshot()` 的关系(**面复用、语义泛化**,既有行为一字不动):
253
+ `_protected_history/<id>/` + `_protected_audit.jsonl` 这一对从「仅受保护节点」
254
+ 推广为「一切 C/D/B 动作」的**前像面**——本函数是新入口,`snapshot()` /
255
+ `guard_*` 的既有形态(文件名 `%Y%m%d-%H%M%S.md`、审计 action="snapshot"、
256
+ reason「显式快照(rel)」、覆盖路径快照数)**不改一行**(守卫钉死)。
257
+ 两条差别都是为「执行时点前像」的场景补强:
258
+ · 文件名带微秒后缀——同一节点的多个变更单可能在**同一秒**内先后执行,
259
+ 秒级名会互相覆盖(探针实测:同秒二次 snapshot 返回同一路径)——前像
260
+ 被后来的前像踩掉,早先那张的回滚句柄就指向错误时点的内容;
261
+ · 审计 action="preimage" 且 reason 交代动作类/pid——回滚与 §七 R3 读数
262
+ (「存在实测回滚记录」)要能把「变更单前像」与既有显式快照区分开。
263
+
264
+ 为什么前像必须在**执行时点**拍(设计 §四 明文):变更单在提议时点构造,
265
+ 从提议到 accept 之间目标可能被第三方改动——回滚必须撤销**本变更本身**,
266
+ 而不是一并抹掉第三方改动。故由执行桥(`mdcos._mutation_execute`)在动作
267
+ 原语落盘**之前**拍摄,作为载荷 `before` 字段(执行时点快照引用)。
268
+
269
+ 边界(如实):加密库(覆写了 `_seal_content` 的实例)里本文件的密文层
270
+ 因每次封装熵不同**不保证逐字节可复现**——前像的语义是「明文内容 + 结构
271
+ 的可恢复」,逐字节比对在非加密库(含一切守卫合成库)上成立(探针实测)。
272
+ """
273
+ try:
274
+ node = cg.get(node_id)
275
+ except Exception:
276
+ node = None
277
+ if not node:
278
+ return None
279
+ d = os.path.join(cg.root, HISTORY_DIR, node_id)
280
+ os.makedirs(d, exist_ok=True)
281
+ ts = (time.strftime("%Y%m%d-%H%M%S", time.localtime())
282
+ + "-%06d" % (time.time_ns() // 1000 % 1000000))
283
+ p = os.path.join(d, f"{ts}.md")
284
+ try:
285
+ cg._write_node(node.get("id"), p, node.get("frontmatter") or {},
286
+ node.get("content") or "")
287
+ except Exception:
288
+ return None
289
+ rel = os.path.relpath(p, cg.root).replace("\\", "/")
290
+ _audit(cg, "preimage", node_id,
291
+ reason or ("执行时点前像(动作类 %s,pid=%s)"
292
+ % (action or "?", pid or "?")),
293
+ actor=actor if actor is not None else getattr(cg, "actor", None),
294
+ snapshot=rel)
295
+ return rel
296
+
297
+
223
298
  # 生效条件:cg.root/HISTORY_DIR/node_id 不是目录时返回 [];是目录时返回该目录下以 .md 结尾(不递归)的文件按名称排序后的 `HISTORY_DIR/node_id/文件名` 列表,无匹配文件则列表为空。
224
299
  def history(cg, node_id):
225
300
  """受保护节点的历史版本列表(按时间升序)。"""
@@ -431,7 +431,7 @@ def _node_digest(cg, nid) -> dict:
431
431
  N226(2026-09-28):此前直读 `cg.index` 无可见性判定 ⇒ 端点摘要(layer/
432
432
  tags/importance/writer/session/temporal)把读闸拒绝节点的元数据照返回
433
433
  (实测 guest 经 `cg(op="edges", expand_nodes=true)` 拿到私密节点摘要)。
434
- 同库同身份的 `stg.timeline`(stg.py:133/172)早已接线 `_readable`——本处
434
+ 同库同身份的 `stg.timeline`(stg.py:144/238)早已接线 `_readable`——本处
435
435
  是漏网的旁路出口,改为同一个跨层单点(`security.node_visible`)。
436
436
  """
437
437
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
@@ -26,6 +26,13 @@ noop 语义:**已评估、判定不改变任何现有记忆**——只留痕
26
26
  审计 md 节点)并关闭提案,不落业务节点、不进负记忆。它与 reject 的区别是
27
27
  「评估过了、无需改动」而非「否掉这条候选」,故不可借 noop 绕过 accept 门控。
28
28
 
29
+ 变更单(三档自治批次②,kind=mutation):队列里除「提案」外还有**变更单**
30
+ ——对既有记忆的 B 合并 / C 改写 / D 删除(设计 v0.2 §四)。`list` 会把它显式
31
+ 标为 `变更单(动作名→目标)`:accept = **执行**对应动作(B reinforce/converge、
32
+ C 覆写落盘、D 软删),reject = 原样留痕不执行;edit/merge/noop 对变更单未定义
33
+ (fail-closed 报错,不会静默当已处理)。存量条目无 `kind` 键,一律按提案走原
34
+ 路径(零迁移)。
35
+
29
36
  裁决留痕:decisions.jsonl + 审计 md 节点(由 review_decide 内部完成)。
30
37
 
31
38
  落盘归因(P1 修复,2026-09-26,DSH 端在役实测回告):
@@ -112,6 +119,27 @@ def _brief(rec, width=66):
112
119
  return text[:width] + ("…" if len(text) > width else "")
113
120
 
114
121
 
122
+ # 生效条件:rec 支持 .get 且其 kind 为 "mutation" 时返回「变更单(动作名→目标)」标签,其余(含缺键 = 存量提案)返回「提案」;本函数只读,不产生任何副作用;
123
+ def _kind_label(rec):
124
+ """队列条目类型标签(三档自治批次②:变更单与提案在同一队列里可分辨)。
125
+
126
+ 为什么要有:设计 §四「两类条目共用一个队列,靠类型字段区分」——显示面若
127
+ 不区分,裁决者面对一张 `[pid] pending · contextual 层` 的单子看不出它是
128
+ 「新写入候选」还是「对既有记忆的 B/C/D 变更单」,也无从知道确认后会发生
129
+ 什么。缺键(存量条目)一律按提案显示(零迁移)。
130
+ """
131
+ try:
132
+ kind = str(rec.get("kind") or "").strip()
133
+ slot = (rec.get("extra") or {}).get("mutation") or {}
134
+ except AttributeError:
135
+ return "提案"
136
+ if kind != "mutation":
137
+ return "提案"
138
+ name = slot.get("action_name") or slot.get("action") or "?"
139
+ tgt = slot.get("target") or rec.get("id")
140
+ return "变更单(%s%s)" % (name, ("→ " + str(tgt)) if tgt else "")
141
+
142
+
115
143
  # 生效条件:cg 与 args 就绪时按 args.cmd 分派——"list" 时 cg.review_list() 为空则打印空队列并返回 0、非空则逐条打印(tags 取真值拼接、layer/round 为假值显示 "?"/0)后返回 0;"rounds" 时打印 cg.review_rounds(args.pid) 并返回 0;"stats" 时打印 cg.review_stats() 的记录数/提案数/待审数/已关闭数与动作分布(含 noop 计数)并返回 0;"edit" 时以 args.content 加真值 args.tags(按逗号分割并剔除空项)/args.layer 组成 edits 调 cg.review_decide;其余 cmd(含 noop)以 getattr(args, "into", None) 与 args.reason 调 cg.review_decide;后两类再按 out.get("ok") 为真返回 0,否则打印 out 并返回 1。
116
144
  def _execute(cg, args):
117
145
  """按子命令执行裁决(cg 的生命周期由 main 统一收尾)。"""
@@ -123,8 +151,9 @@ def _execute(cg, args):
123
151
  print("待审 %d 条:" % len(pend))
124
152
  for r in pend:
125
153
  tags = (", tags=" + ",".join(r.get("tags") or [])) if r.get("tags") else ""
126
- print(" [%s] %s · %s 层%s · round=%s\n %s" % (
127
- r.get("pid"), r.get("status"), r.get("layer") or "?",
154
+ print(" [%s] %s · %s · %s 层%s · round=%s\n %s" % (
155
+ r.get("pid"), r.get("status"), _kind_label(r),
156
+ r.get("layer") or "?",
128
157
  tags, r.get("round") or 0, _brief(r)))
129
158
  print('\n裁决示例:python -m md_cg.review_cli accept <pid> --reason "实跑测试证据"')
130
159
  return 0
@@ -0,0 +1,411 @@
1
+ # -*- coding: utf-8 -*-
2
+ """三档自治 · 回滚原语通用化(设计 v0.2 §四/§十一 · 批次③)。
3
+
4
+ 设计 §四「回滚原语(已有,需通用化)」四条在本文档与实现中的落点:
5
+
6
+ 1. **受保护快照面推广为一切 C/D/B 动作**:`_protected_history/<id>/<时间戳>.md`
7
+ + `_protected_audit.jsonl`(protect.py 面)**面复用、语义泛化**——执行桥
8
+ 经 `preimage()` 在**执行时点**调 `protect.snapshot_preimage()` 拍前像;
9
+ `protect.py` 的既有 `snapshot()` / `guard_*` 行为**一字不改**(守卫钉死)。
10
+ 2. **软删(D)**:`trash/` + 删除清单的既有 `restore` 接进统一口径——
11
+ `rollback_mutation` 的 D 分支调 `cg.restore(force=True)`;restore 走 add
12
+ 全量重建,探针实测与原件有字节差(多 `sensitivity: null` 键),故其后按
13
+ 前像**校准**(`calibrated` 读数如实返回,不假装 restore 本身逐字节)。
14
+ 3. **合并(B)**:聚合行 `- 【聚合 …】`(`forgetting.AGG_MARK`)**定向剥离**
15
+ (`strip_aggregate_lines`)回退到合并前正文;fm 全量取前像——
16
+ merge_count/last_merge_at/importance/merge_sources/protected 等**不可逆**
17
+ (`-0.05` 会被 `min(1.0,…)` 截断、last_merge_at 旧值无从得知),前像快照
18
+ 是唯一诚实来源。
19
+ 4. **分数类(E)**:不属本批(设计 §四 明文「分数类:各模块既有 rollback」)。
20
+
21
+ 统一回滚入口 `rollback_mutation(cg, mutation, ...)`:输入=变更单的**执行时点
22
+ 载荷**(执行桥补全后的 `mutation_executed` 形态;CLI 从 decisions.jsonl 的
23
+ `rec["mutation"]` 读回),按动作类分派 C/B/D 三条原语,回滚后做**残留核对**
24
+ (边/索引条目/聚合行三面与前像时点影响面逐位对拍)与**逐字节比对前像**读数。
25
+
26
+ 边界(如实登记):
27
+ · 前像=执行时点快照——提议时点**不拍**(设计 §四:从提议到 accept 之间目标
28
+ 可能被第三方改动,回滚必须撤销本变更本身);执行记录里的 `before` 引用由
29
+ 执行桥补全,故对**未执行**(reject/fail-closed/pending)的变更单,本模块
30
+ fail-closed 拒绝回滚(`rollback_gap`,不允许凭空猜一个前像)。
31
+ · 加密库(覆写 `_seal_content` 的实例)密文层不保证逐字节可复现(每次封装
32
+ 熵不同)——`bytes_equal_preimage` 在非加密库上成立(探针实测);
33
+ 加密库语义为「明文内容 + 结构可恢复」,读数如实返回。
34
+ · `restore` 的 tombstone 检查保留语义:回滚是「确认该删除属于本变更」的
35
+ 处置,故显式 `force=True` 且 `reason` 写入返回体,不静默绕过。
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import os
40
+
41
+ from . import chain
42
+ from . import forgetting as _forgetting
43
+ from . import nodefile
44
+ from . import protect
45
+ from . import subgraph as _subgraph
46
+ from . import trust
47
+
48
+ __all__ = [
49
+ "RollbackError", "PreimageError",
50
+ "command_for", "preimage", "collect_impact", "read_preimage",
51
+ "strip_aggregate_lines", "rollback_mutation",
52
+ ]
53
+
54
+ #: 影响面「索引条目」对拍键(`collect_impact` 采集、残留核对比对,同一份口径)。
55
+ _INDEX_KEYS = ("present", "path", "layer", "importance", "merge_count",
56
+ "protected", "tags")
57
+ #: 需要前像/可回滚的动作类(设计 §一:B 合并 / C 改写 / D 删除)。
58
+ _ROLLBACK_ACTIONS = ("B", "C", "D")
59
+
60
+
61
+ class RollbackError(RuntimeError):
62
+ """回滚无法执行(前像缺失/不可读、动作类未知等)——fail-closed。"""
63
+
64
+ def __init__(self, code, message, **extra):
65
+ super().__init__(message)
66
+ self.code = code
67
+ self.extra = extra
68
+
69
+
70
+ class PreimageError(RuntimeError):
71
+ """执行时点前像拍摄失败——执行桥据此 fail-closed(一切破坏性动作先留前像)。"""
72
+
73
+
74
+ # 生效条件:pid 非空时返回 `python -X utf8 -m md_cg.rollback_cli --root "<root>" --pid <pid>` 形态的命令串(唯一构造点);pid 为空时抛 ValueError;
75
+ def command_for(root, pid) -> str:
76
+ """回滚命令串(**唯一构造点**,可执行——守卫实测演练同一条串)。
77
+
78
+ 形态自定为 `md_cg.rollback_cli`:CLI 从 decisions.jsonl 读回该 pid 的
79
+ 执行记录(`rec["mutation"]`,含执行时点前像/影响面/回滚命令),按动作类
80
+ 分派统一回滚入口。root 用双引号包裹(路径可含空格)。
81
+ """
82
+ if not pid:
83
+ raise ValueError("回滚命令缺 pid:无 pid 即无法定位执行记录——"
84
+ "fail-closed 不构造命令串。")
85
+ return ('python -X utf8 -m md_cg.rollback_cli --root "%s" --pid %s'
86
+ % (root, pid))
87
+
88
+
89
+ # 生效条件:action 归一后属 _ROLLBACK_ACTIONS 时——forgetting.prior_node 判目标不存在则返回 None(动作不会发生,由调用方走既有 target_missing/not_found 路径);目标在位于执行时点拍前像(protect.snapshot_preimage)并采集影响面与回滚命令,返回 {"before","impact","rollback"};拍摄失败(目标在位而快照未落)抛 PreimageError(fail-closed);action 不属 _ROLLBACK_ACTIONS 时返回 None;
90
+ def preimage(cg, action, target, pid=None, reason=""):
91
+ """执行时点前像(快照引用 + 影响面 + 回滚命令)——**唯一采集点**。
92
+
93
+ 由执行桥(`mdcos._mutation_execute`)在动作原语落盘**之前**调用:
94
+ 拍到的快照即「执行前一刻」的盘面(含提议之后、执行之前的第三方改动),
95
+ 回滚据此撤销本变更本身(设计 §四:前像=执行时点,不是提议时点)。
96
+
97
+ 返回 None 的两条路径(都=动作不会发生,无需留前像):
98
+ · 动作类不属 B/C/D(未知动作类由执行桥的既有分支 fail-closed);
99
+ · 目标节点不存在(既有原语会走 target_missing/not_found,执行桥的
100
+ C 分支另有显式 target_missing 检查——本函数不抢它的错误形态)。
101
+ """
102
+ act = str(action or "").strip().upper()
103
+ if act not in _ROLLBACK_ACTIONS:
104
+ return None
105
+ if _forgetting.prior_node(cg, target) is None:
106
+ return None
107
+ rel = protect.snapshot_preimage(cg, target, action=act, pid=pid,
108
+ reason=reason)
109
+ if rel is None:
110
+ raise PreimageError(
111
+ "执行时点前像拍摄失败(目标 %s 在位但快照未落盘)——"
112
+ "一切破坏性动作先留前像(设计 §四/§六),本次 fail-closed 未执行。"
113
+ % target)
114
+ return {"before": rel,
115
+ "impact": collect_impact(cg, target),
116
+ "rollback": command_for(cg.root, pid)}
117
+
118
+
119
+ # 生效条件:对任意 cg、node_id 均返回影响面读数 dict——{"edges": {"declared","children","parents"}, "index": {"present","path","layer","importance","merge_count","protected","tags"}, "agg_lines": [...]}(节点不可读时各面取空值/None,不抛);本函数只读,不产生副作用;
120
+ def collect_impact(cg, node_id):
121
+ """执行时点影响面读数(设计 §四:边 / 索引条目 / 聚合行)。
122
+
123
+ 三个面都是**执行时点**(动作落盘前)的读数——回滚后由
124
+ `rollback_mutation` 重采一次逐位对拍(残留核对:回滚后不得残留本变更
125
+ 在任一面的痕迹)。采集走既有单点:`subgraph.children/parents_index`
126
+ (声明式 ∪ 边式)、索引条目直读、聚合行按 `forgetting.AGG_MARK` 字面判。
127
+ """
128
+ try:
129
+ node = cg.get(node_id)
130
+ except Exception: # noqa: BLE001 —— 读面失败按空读数采
131
+ node = None
132
+ fm = (node or {}).get("frontmatter") or {}
133
+ content = (node or {}).get("content") or ""
134
+ entry = ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id) \
135
+ or {}
136
+ return {
137
+ "edges": {
138
+ "declared": list(fm.get("edges") or []),
139
+ "children": _subgraph.children(cg, node_id),
140
+ "parents": list(_subgraph.parents_index(cg).get(node_id) or []),
141
+ },
142
+ "index": {
143
+ "present": bool(entry),
144
+ "path": entry.get("path"),
145
+ "layer": entry.get("layer"),
146
+ "importance": entry.get("importance"),
147
+ "merge_count": entry.get("merge_count"),
148
+ "protected": bool(entry.get("protected")),
149
+ "tags": list(entry.get("tags") or []),
150
+ },
151
+ # 聚合行行号(1 起):正文里字面含 AGG_MARK 的行——forgetting.aggregate_line
152
+ # 生成的形态是 `- 【聚合 月-日 时:分】…`;宽判据(含标记即计)与回滚核对同源。
153
+ "agg_lines": [i + 1 for i, l in enumerate(content.splitlines())
154
+ if _forgetting.AGG_MARK in l],
155
+ }
156
+
157
+
158
+ # 生效条件:rel 指向的文件不存在时抛 RollbackError(code="preimage_missing");存在则读取并以 cg._open_content(node_id, fm, raw) 对称解封,返回 (fm, content)——content 为 None(无密钥/身份不符)时抛 RollbackError(code="preimage_unreadable");
159
+ def read_preimage(cg, node_id, rel):
160
+ """读执行时点前像文件(**与 snapshot_preimage 对称**:写走 _write_node 封装、
161
+ 读走 _open_content 解封)——返回 (fm, content)。
162
+
163
+ 注意:快照文件以节点文件同款 nodefile 序列化落盘;加密库中其内容为密封
164
+ 形态,必须经解封钩子读,直接 nodefile.loads 会把密文当明文再次封装
165
+ (双重封装)。非加密库两钩子恒等(探针实测逐字节一致)。
166
+ """
167
+ p = os.path.join(cg.root, str(rel).replace("/", os.sep))
168
+ if not os.path.isfile(p):
169
+ raise RollbackError("preimage_missing",
170
+ "前像文件不存在:%s——变更单可能未执行(无前像)"
171
+ "或快照被移走;fail-closed 不猜一个前像。" % rel)
172
+ with open(p, encoding="utf-8") as f:
173
+ fm, raw = nodefile.loads(f.read())
174
+ content = cg._open_content(node_id, fm, raw)
175
+ if content is None:
176
+ raise RollbackError("preimage_unreadable",
177
+ "前像不可解封(无密钥/身份不符):%s。" % rel)
178
+ return fm, content
179
+
180
+
181
+ # 生效条件:content 以 reference 为前缀且其后追加段逐段为「\n + 含 AGG_MARK 的非空行」时,逐段剥掉该追加段返回 (reference + 剩余段, 被剥行列表);不以 reference 为前缀、追加段形态不符、或 reference 为空时原样返回 (content, [])——不猜、不做字符串手术;
182
+ def strip_aggregate_lines(content, reference):
183
+ """定向剥离(设计 §四:合并回滚)——把**合并追加的聚合行**从正文里剥掉。
184
+
185
+ 为什么是「前缀 + 追加段」判据而不是全局扫行:合并原语(`forgetting.
186
+ reinforce` / `writelimit.converge_into`)对正文是**追加式**的
187
+ (`aggregate_line`:`new_body = body + "\\n" + line`),回滚的目标是回到
188
+ 前像正文——精确、可判、不误伤正文里历史遗留的聚合行(它们在前像里就已
189
+ 存在,属「合并前正文」的一部分,不该动)。非追加式形态(第三方改写等)
190
+ 一律不手术:返回原 content 与空剥离表,由调用方按读数决策。
191
+
192
+ 返回 `(new_content, removed)`;追加段完全剥净时 `new_content == reference`
193
+ (正常回滚场景,逐字节)。判据与 `forgetting.aggregate_line` 同款字面
194
+ (`AGG_MARK` 常量单点在 forgetting.py,不另写第二份)。
195
+ """
196
+ text = content or ""
197
+ ref = reference or ""
198
+ if not ref or not text.startswith(ref):
199
+ return text, []
200
+ tail = text[len(ref):]
201
+ removed = []
202
+ while tail.startswith("\n"):
203
+ nl = tail.find("\n", 1)
204
+ line = tail[1:] if nl < 0 else tail[1:nl]
205
+ if not line.strip() or _forgetting.AGG_MARK not in line:
206
+ break # 空行/非聚合行:停止(不越界手术)
207
+ removed.append(line)
208
+ tail = "" if nl < 0 else tail[nl:]
209
+ # 收尾:追加段的**纯换行尾**(`nodefile.dumps:313` 给不以换行结尾的正文
210
+ # 补过一个尾换行,读回时即多出——探针实测:合并后读回形态为
211
+ # `前像 + "\n" + 聚合行 + "\n"`)在已剥过行时一并剥净,使正常场景逐字节
212
+ # 回到前像;未剥过行(removed 为空)不动分毫。
213
+ if removed and not tail.strip("\n"):
214
+ tail = ""
215
+ return ref + tail, removed
216
+
217
+
218
+ # 生效条件:把 fm/content 以 _write_node 落盘到既有 path,随后按 _node_entry(与 _scan_nodes 同源)重算索引条目写入 index["nodes"] 与 _dirty(索引持久化靠 _dirty→flush→_index_log 重放),并失效 subgraph/chain/trust 三个反查缓存;本函数只被本模块的回滚分支调用;
219
+ def _land(cg, node_id, path, fm, content):
220
+ """回滚的**唯一落盘口**:写盘 + 索引重算 + 缓存失效(三步同口径)。
221
+
222
+ 与 `mdcos.review_decide` merge 分支的定向 upsert 同款(`_node_entry`
223
+ 与 `_scan_nodes` 同源,字段集零漂移);走 `_dirty`(**不走 `_stage`**
224
+ ——它会虚增 bucket 计数,目标已在索引)。
225
+ """
226
+ cg._write_node(node_id, path, fm, content)
227
+ layer = str(fm.get("layer") or "knowledge")
228
+ try:
229
+ e = cg._node_entry(path, layer, fm, content)
230
+ except Exception: # noqa: BLE001 —— 重算失败不阻断回滚落盘
231
+ e = None
232
+ if e is not None:
233
+ cg.index["nodes"][node_id] = e
234
+ cg._dirty[node_id] = e
235
+ _subgraph.invalidate_cache(cg)
236
+ chain.invalidate_cache(cg)
237
+ trust.invalidate_cache(cg)
238
+
239
+
240
+ # 生效条件:node_id 的索引条目存在时返回其盘面绝对路径;不存在或条目无 path 时返回 None;
241
+ def _disk_path(cg, node_id):
242
+ e = ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id) or {}
243
+ p = e.get("path")
244
+ return os.path.join(cg.root, p) if p else None
245
+
246
+
247
+ # 生效条件:对任意路径返回 bytes(不存在/不可读时返回 None——比对判据按 None 处理,不抛);
248
+ def _bytes_of(path):
249
+ if not path:
250
+ return None
251
+ try:
252
+ with open(path, "rb") as f:
253
+ return f.read()
254
+ except OSError:
255
+ return None
256
+
257
+
258
+ # 生效条件:path 与 rel 都取到字节时返回两者逐字节相等的布尔;任一侧字节取不到时返回 False(不可证即不认——比对判据 fail-closed);
259
+ def _bytes_equal(path, rel_path):
260
+ a, b = _bytes_of(path), _bytes_of(rel_path)
261
+ return a is not None and b is not None and a == b
262
+
263
+
264
+ # 生效条件:C 改写回滚——索引中节点在位(不在位返回 ok False error target_missing)时把前像 (fm0,c0) 落到节点盘面 path(_land)+ flush,返回读数 dict;
265
+ def _rollback_rewrite(cg, tgt, fm0, c0):
266
+ path = _disk_path(cg, tgt)
267
+ if path is None or _forgetting.prior_node(cg, tgt) is None:
268
+ return {"ok": False, "error": "target_missing",
269
+ "detail": "C 回滚:目标 %s 不在索引中——本变更之外还有变化,"
270
+ "fail-closed 不猜落点(如属误删可先 restore)。" % tgt}
271
+ _land(cg, tgt, path, fm0, c0)
272
+ cg.flush()
273
+ return {"ok": True, "restored_path": os.path.relpath(path, cg.root)
274
+ .replace("\\", "/"), "method": "preimage"}
275
+
276
+
277
+ # 生效条件:B 合并回滚——目标在位(不在位返回 ok False error target_missing)时先按 strip_aggregate_lines 剥离追加的聚合行,剥离结果与前像正文逐字节相等则正文取剥离结果、否则取前像正文(stripped_ok 如实记录),fm 全量取前像 (fm0),经 _land 落盘 + flush;返回读数 dict(removed_lines/stripped_ok/method);
278
+ def _rollback_merge(cg, tgt, fm0, c0):
279
+ cur = None
280
+ try:
281
+ cur = cg.get(tgt)
282
+ except Exception: # noqa: BLE001
283
+ cur = None
284
+ if not cur:
285
+ return {"ok": False, "error": "target_missing",
286
+ "detail": "B 回滚:目标 %s 不可读——本变更之外还有变化,"
287
+ "fail-closed 不猜落点。" % tgt}
288
+ path = _disk_path(cg, tgt)
289
+ if path is None:
290
+ return {"ok": False, "error": "target_missing",
291
+ "detail": "B 回滚:目标 %s 索引条目缺 path。" % tgt}
292
+ c1 = cur.get("content") or ""
293
+ stripped, removed = strip_aggregate_lines(c1, c0)
294
+ stripped_ok = stripped == c0
295
+ # 正文取剥离结果(正常场景逐字节==前像);剥离不精确(追加段含非聚合行等)
296
+ # 时以快照正文为准——回滚的判据是「回到前像」,stripped_ok 如实记录。
297
+ # fm 全量取前像:merge_count/last_merge_at/importance(+0.05 可被 1.0 截断)/
298
+ # merge_sources/protected/lifecycle_state 等均不可逆,前像是唯一诚实来源。
299
+ _land(cg, tgt, path, fm0, stripped if stripped_ok else c0)
300
+ cg.flush()
301
+ return {"ok": True, "restored_path": os.path.relpath(path, cg.root)
302
+ .replace("\\", "/"),
303
+ "method": "strip_aggregate_lines" if stripped_ok else "preimage",
304
+ "stripped_ok": stripped_ok, "removed_lines": removed}
305
+
306
+
307
+ # 生效条件:D 删除回滚——先调既有 cg.restore(tgt, force=True)(trash + 删除清单口径);restore 失败且(错误非 not_in_trash 或节点仍不在库)时返回 ok False(detail 带 restore 原返回体);restore 成功、或 not_in_trash 而节点已在库(此前已回滚过=幂等重放)时继续:按前像做逐字节校准(盘面与快照字节不等时经 _land 以 (fm0,c0) 重写,calibrated=True 如实记录——探针实测 restore 重建会多 sensitivity:null 键),返回读数 dict(replayed 标注重放);
308
+ def _rollback_delete(cg, tgt, fm0, c0, rel):
309
+ res = cg.restore(tgt, force=True)
310
+ replayed = (not res.get("ok")) and res.get("error") == "not_in_trash" \
311
+ and _forgetting.prior_node(cg, tgt) is not None
312
+ if not res.get("ok") and not replayed:
313
+ return {"ok": False, "error": res.get("error") or "restore_failed",
314
+ "detail": {"restore": res},
315
+ "hint": "D 回滚走既有 restore(trash + 删除清单)——失败即"
316
+ "未恢复;如 trash 缺失可核对前像目录。"}
317
+ path = _disk_path(cg, tgt)
318
+ if path is None:
319
+ return {"ok": False, "error": "target_missing",
320
+ "detail": "D 回滚:restore 返回 ok 但索引无条目。"}
321
+ pre_p = os.path.join(cg.root, str(rel).replace("/", os.sep))
322
+ restore_bytes_equal = _bytes_equal(path, pre_p)
323
+ calibrated = False
324
+ if not restore_bytes_equal:
325
+ # 校准(如实):restore 的 add 全量重建与快照存在字节差(实测 =
326
+ # 多 `sensitivity: null` 键)——回滚的判据是「回到前像」,故以快照
327
+ # 重写一次;`restore_bytes_equal=False` 读数保留,不假装 restore
328
+ # 本身逐字节。
329
+ _land(cg, tgt, path, fm0, c0)
330
+ calibrated = True
331
+ cg.flush()
332
+ return {"ok": True, "restored_path": os.path.relpath(path, cg.root)
333
+ .replace("\\", "/"),
334
+ "method": "restore+preimage_calibration" if calibrated
335
+ else "restore",
336
+ "replayed": replayed,
337
+ "calibrated": calibrated,
338
+ "restore_bytes_equal": restore_bytes_equal}
339
+
340
+
341
+ # 生效条件:before_impact 与 after 两个影响面 dict(缺面按空 dict)逐面返回对拍布尔——{"agg_lines_restored","edges_restored","index_restored"};
342
+ def _residual(before_impact, after):
343
+ bi = before_impact if isinstance(before_impact, dict) else {}
344
+ return {
345
+ "agg_lines_restored": (after.get("agg_lines") or [])
346
+ == (bi.get("agg_lines") or []),
347
+ "edges_restored": (after.get("edges") or {}) == (bi.get("edges") or {}),
348
+ "index_restored": {k: (after.get("index") or {}).get(k)
349
+ for k in _INDEX_KEYS}
350
+ == {k: (bi.get("index") or {}).get(k)
351
+ for k in _INDEX_KEYS},
352
+ }
353
+
354
+
355
+ # 生效条件:mutation 为 dict 且含非空 target 与 before(否则返回 ok False error mutation_missing/rollback_gap 带 hint);before 文件不可读按 RollbackError(code) 返回 ok False;动作类属 C/B/D 时按对应分支回滚,成功再重采影响面做残留对拍并做逐字节比对,返回 {"ok": True, "action","target","preimage","restored_path","bytes_equal_preimage","residual","method",...};动作类不属 C/B/D 时返回 ok False error rollback_action_unknown;本函数不抛(可预期错误一律返回体);
356
+ def rollback_mutation(cg, mutation, reason="", actor=None):
357
+ """统一回滚入口(设计 §四 四条:C/B/D 分派 + 残留核对 + 逐字节比对)。
358
+
359
+ 输入=**执行时点载荷**(`autonomy_modes.mutation_executed` 形态,含
360
+ action/target/before/impact/rollback);CLI 从 decisions.jsonl 的
361
+ `rec["mutation"]` 读回。`reason`/`actor` 进返回体(审计由各分支的
362
+ 落盘/restore 留痕承担,本模块不另写第二套台账)。
363
+ """
364
+ if not isinstance(mutation, dict):
365
+ return {"ok": False, "error": "mutation_missing",
366
+ "hint": "回滚入口需要变更单的执行时点载荷(dict)——"
367
+ "decisions.jsonl 的 rec[\"mutation\"]。"}
368
+ act = str(mutation.get("action") or "").strip().upper()
369
+ tgt = str(mutation.get("target") or "").strip()
370
+ rel = mutation.get("before")
371
+ if not tgt:
372
+ return {"ok": False, "error": "mutation_missing",
373
+ "hint": "载荷缺 target——无法定位回滚对象。"}
374
+ if not rel:
375
+ return {"ok": False, "error": "rollback_gap",
376
+ "hint": "载荷缺 before(执行时点前像引用):该变更单**未执行**"
377
+ "(reject / fail-closed / pending)或执行记录不完整——"
378
+ "fail-closed 不猜一个前像(前像必须来自执行时点)。"}
379
+ if act not in _ROLLBACK_ACTIONS:
380
+ return {"ok": False, "error": "rollback_action_unknown",
381
+ "action": act or None,
382
+ "hint": "回滚只覆盖 C 改写 / B 合并 / D 删除(设计 §四);"
383
+ "动作类 %r 未定义回滚——fail-closed。" % (act or None)}
384
+ try:
385
+ fm0, c0 = read_preimage(cg, tgt, rel)
386
+ except RollbackError as exc:
387
+ return {"ok": False, "error": exc.code, "detail": str(exc),
388
+ "action": act, "target": tgt, "preimage": rel}
389
+ if act == "C":
390
+ res = _rollback_rewrite(cg, tgt, fm0, c0)
391
+ elif act == "B":
392
+ res = _rollback_merge(cg, tgt, fm0, c0)
393
+ else: # "D"
394
+ res = _rollback_delete(cg, tgt, fm0, c0, rel)
395
+ if not res.get("ok"):
396
+ out = dict(res)
397
+ out.update({"action": act, "target": tgt, "preimage": rel})
398
+ return out
399
+ # 残留核对(设计 §十一 红项面):重采影响面,与前像时点记录逐面对拍。
400
+ after = collect_impact(cg, tgt)
401
+ residual = _residual(mutation.get("impact"), after)
402
+ # 逐字节比对前像(设计 §十一):盘面 vs 快照文件。
403
+ pre_p = os.path.join(cg.root, str(rel).replace("/", os.sep))
404
+ disk_p = _disk_path(cg, tgt)
405
+ out = {"ok": True, "action": act, "target": tgt, "preimage": rel,
406
+ "rollback_command": mutation.get("rollback") or "",
407
+ "reason": str(reason or ""), "actor": actor,
408
+ "bytes_equal_preimage": _bytes_equal(disk_p, pre_p),
409
+ "residual": residual, "impact_after": after}
410
+ out.update(res)
411
+ return out