@furongjun1999/dsh-memory 0.6.0 → 0.6.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 (170) hide show
  1. package/README.md +588 -572
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/discipline/harnesses.yaml +31 -0
  5. package/docs/discipline/templates/full.md.tmpl +1 -1
  6. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  7. package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
  8. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
  9. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
  10. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
  11. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
  12. package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
  13. package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
  14. package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
  15. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
  16. package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -0
  17. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v20.md +283 -0
  18. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v21.md +224 -0
  19. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v22.md +223 -0
  20. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v23.md +293 -0
  21. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v24.md +303 -0
  22. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v25.md +230 -0
  23. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +20 -0
  24. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
  25. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  26. 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 +40 -40
  27. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  28. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
  29. package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +39 -3
  30. package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
  31. package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
  32. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
  33. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +460 -460
  34. package/lib/bridge.js +24 -2
  35. package/lib/hooks.d.ts +31 -2
  36. package/lib/hooks.js +219 -16
  37. package/lib/init.js +16 -1
  38. package/lib/lib/prompt_safety.d.ts +52 -1
  39. package/lib/lib/prompt_safety.js +76 -1
  40. package/md_cg/audit.py +652 -379
  41. package/md_cg/bench6_arms.py +1 -1
  42. package/md_cg/bench_p0.py +1 -1
  43. package/md_cg/branches.py +2 -2
  44. package/md_cg/ccgc.py +1071 -1006
  45. package/md_cg/chain.py +1 -1
  46. package/md_cg/consistency.py +97 -9
  47. package/md_cg/consolidate.py +34 -9
  48. package/md_cg/crypto.py +59 -26
  49. package/md_cg/docindex.py +16 -1
  50. package/md_cg/evolution.py +17 -1
  51. package/md_cg/export.py +1 -1
  52. package/md_cg/forgetting.py +309 -13
  53. package/md_cg/fsutil.py +454 -8
  54. package/md_cg/identity.py +3 -3
  55. package/md_cg/insight.py +24 -3
  56. package/md_cg/linkref.py +1 -1
  57. package/md_cg/logref.py +327 -0
  58. package/md_cg/mcp_server.py +4152 -3818
  59. package/md_cg/mdcg.py +4208 -3547
  60. package/md_cg/mdcos.py +4491 -4159
  61. package/md_cg/mreview/pipeline.py +15 -1
  62. package/md_cg/nodefile.py +639 -575
  63. package/md_cg/protect.py +158 -11
  64. package/md_cg/protocol.py +41 -2
  65. package/md_cg/provenance.py +22 -4
  66. package/md_cg/reach.py +4 -4
  67. package/md_cg/readcache.py +76 -15
  68. package/md_cg/reconcile.py +1 -1
  69. package/md_cg/refindex.py +246 -37
  70. package/md_cg/refine.py +1 -1
  71. package/md_cg/routing.py +32 -5
  72. package/md_cg/run_tests.py +17 -0
  73. package/md_cg/scrub.py +30 -3
  74. package/md_cg/security.py +47 -1
  75. package/md_cg/self_state.py +5 -5
  76. package/md_cg/sources.py +23 -6
  77. package/md_cg/srcindex.py +352 -0
  78. package/md_cg/stg.py +47 -3
  79. package/md_cg/subgraph.py +2 -2
  80. package/md_cg/sustain.py +1299 -1168
  81. package/md_cg/tasks.py +469 -470
  82. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  83. package/md_cg/test_b1b2_write_face.py +417 -0
  84. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  85. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  86. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  87. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  88. package/md_cg/test_c3_transient_read_negative.py +793 -0
  89. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  90. package/md_cg/test_ccg_form_parity.py +188 -0
  91. package/md_cg/test_ccgc.py +28 -3
  92. package/md_cg/test_govern_directread.py +17 -4
  93. package/md_cg/test_h2_session_view_norm.py +233 -0
  94. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  95. package/md_cg/test_hive_ingest.py +285 -285
  96. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  97. package/md_cg/test_issue43_default_policy.py +865 -0
  98. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  99. package/md_cg/test_linkref.py +10 -3
  100. package/md_cg/test_lock.py +2 -2
  101. package/md_cg/test_logref.py +1109 -0
  102. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  103. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  104. package/md_cg/test_mr_m2.py +15 -3
  105. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  106. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  107. package/md_cg/test_n176_link_trust.py +221 -0
  108. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  109. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  110. package/md_cg/test_n195_writepath_reload.py +283 -0
  111. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  112. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  113. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  114. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  115. package/md_cg/test_n201_proposal_visibility.py +382 -0
  116. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  117. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  118. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  119. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  120. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  121. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  122. package/md_cg/test_n225_nonobject_load.py +1110 -0
  123. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  124. package/md_cg/test_neg_condition_hits.py +333 -0
  125. package/md_cg/test_neg_tail_honesty.py +663 -0
  126. package/md_cg/test_none_id_write_guard.py +4 -3
  127. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  128. package/md_cg/test_p1.py +5 -5
  129. package/md_cg/test_p11_consistency.py +161 -12
  130. package/md_cg/test_p26_refindex.py +2 -2
  131. package/md_cg/test_p27_docindex.py +777 -774
  132. package/md_cg/test_p28_refcheck.py +740 -13
  133. package/md_cg/test_p29_session_ingest_export.py +2 -2
  134. package/md_cg/test_p2_mcp.py +12 -1
  135. package/md_cg/test_p30_maintain.py +33 -2
  136. package/md_cg/test_p31_insight.py +1 -0
  137. package/md_cg/test_p9c_dedup_hints.py +276 -0
  138. package/md_cg/test_policy_required_ccg.py +426 -0
  139. package/md_cg/test_protocol.py +22 -0
  140. package/md_cg/test_rank_parity_score_mode.py +516 -0
  141. package/md_cg/test_read_face_input_gates.py +363 -0
  142. package/md_cg/test_read_face_semantics.py +489 -0
  143. package/md_cg/test_recall_face_guards.py +812 -0
  144. package/md_cg/test_rejected_credential_forms.py +188 -0
  145. package/md_cg/test_rejected_redact.py +146 -0
  146. package/md_cg/test_retr_s1b.py +2 -2
  147. package/md_cg/test_retr_s5.py +20 -7
  148. package/md_cg/test_review_conformance.py +21 -3
  149. package/md_cg/test_security_audit_v21.py +2 -2
  150. package/md_cg/test_server_version.py +67 -0
  151. package/md_cg/test_srcindex.py +171 -0
  152. package/md_cg/test_tenant_env_override_warn.py +63 -50
  153. package/md_cg/test_token_lowercase_form.py +315 -0
  154. package/md_cg/test_v21r1_package_version.py +310 -0
  155. package/md_cg/test_writepipe.py +10 -4
  156. package/md_cg/tokens.py +225 -95
  157. package/md_cg/tool_face.py +4 -4
  158. package/md_cg/trust.py +49 -5
  159. package/md_cg/units.py +204 -6
  160. package/md_cg/weights.py +4 -4
  161. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  162. package/md_cg/writelimit.py +42 -8
  163. package/md_cg/writepipe.py +651 -554
  164. package/package.json +3 -2
  165. package/skills/plugin.json +1 -1
  166. package/src/bridge.ts +25 -2
  167. package/src/hooks.ts +229 -16
  168. package/src/init.ts +15 -1
  169. package/src/lib/prompt_safety.ts +88 -1
  170. package/zcode/AGENTS.md +10 -10
@@ -1,555 +1,652 @@
1
- # -*- coding: utf-8 -*-
2
- """写入路径拦截器链(Pi 钩子化机制移植,交接文档 §3⑥「闸门即扩展」)。
3
-
4
- pi 机制(packages/coding-agent/docs/extensions.md):全生命周期事件总线,
5
- 闸门/路径保护/审批全是以扩展形态叠加的,核心没有硬编码策略。灵枢对应:
6
- write 的六道闸(audit 校验 / consistency 冲突 / review 审核 / gated 主动遗忘
7
- / writelimit 限流 / 权限)的次序与启停原先硬编码在 mcp_server._cg_dispatch
8
- 的 if/else 流程里,加一道闸要改核心文件。本模块把它重构为显式拦截器链:
9
-
10
- - **before 链**:拦截器按注册序执行。返回 None = 放行(链继续);
11
- 返回 dict = 终态响应(短路——不落盘,或该拦截器已代为落盘/入队)。
12
- - **after 链**:落盘成功后依次执行(观测者;返回值忽略;异常不吞——
13
- 写入已成功,钩子故障必须暴露而非静默)。
14
- - **ctx 为可变 dict**:a=原始入参 / cg=实例 / nid=节点 id / verdict=校验闸
15
- 裁决 / cvd=冲突闸裁决。拦截器改写 ctx["a"]["content"] 等字段即实现
16
- REWRITE(改写后传给后续链与执行器)。
17
- - **链尾执行器(_executor)是常驻环节**,不在注册表中、不提供卸载 API;
18
- 它调 cg.add 落盘,而角色/层权限校验(require_layer_write)在
19
- MdCGSecure.add **库层内部**——是落盘必经之路,结构上不可被任何拦截器
20
- 绕过(反面清单:不学 pi 的全权信任,信任必须结构强制)。
21
-
22
- 默认链(install_default_gates,与重构前 _cg_dispatch write 分支行为逐字
23
- 节一致):audit → consistency → gated → _executor。
24
-
25
- 验收口径(交接文档 §3⑥):全部既有写入测试零改动通过;新增/移除一个
26
- 拦截器不改核心文件(register_before / unregister_before 即插即拔)。
27
-
28
- **③ 两段式提交(2026-09-16 叠加)**:链尾执行器与 gated 闸(两条**真实落盘**
29
- 路径)各自在执行落盘前调 `twophase.begin` 落 intent、落盘后调 `twophase.commit`
30
- 记 outcome;崩溃在两者之间时由 `twophase.reconcile` 据正文指纹补账/标记。
31
- 边界(如实):`_gate_audit` 的 REJECT(写负记忆)与各闸的 propose(**未落盘**,
32
- 仅入审核队列)不在两段式覆盖面内——前者是短小负记录、后者本就没有落盘动作。
33
-
34
- **④ 写提交边界(2026-09-16 叠加)**:`execute` 的两条出口(before 链短路 /
35
- 链尾执行器 + after 链之后)统一调 `_commit_visibility`——把内存脏索引
36
- `flush()` 到分片日志,使本次写入对**其他进程**立即可见。这是第16条「写入后
37
- 读回确认」的跨进程前置条件(server 级 `autoflush=1` 是同一问题的兜底,
38
- 覆盖不经本链的写入路径)。根因取证见 `_commit_visibility` 文档串。
39
- """
40
-
41
- import time
42
-
43
- from . import twophase, trust
44
-
45
- __all__ = ["WritePipeline", "default_pipeline"]
46
-
47
-
48
- # 生效条件:调用即对 cg 执行 flush()(无脏数据时为 no-op),且仅当该调用抛异常而 out 是 dict 时在 out 写入 "flush_error",异常本身不外抛;
49
- def _commit_visibility(cg, out):
50
- """写提交边界(2026-09-16):把内存脏索引落分片日志,使本次写入对其他进程立即可见。
51
-
52
- 根因(第4条取证):写入只经 `_stage` 入内存 + `_dirty`,须达 `autoflush`
53
- (默认 64)或 `close()` 才 `flush()` 落 `_index_log/`;MCP server 常驻、
54
- 不 close,故单条写入在阈值前**对其他进程不可见**——`_load_index` 读的是
55
- 「快照 `_index.json` + 分片日志重放」,而快照只在 compact/rebuild 时重写。
56
- 症状即第16条「写入后读回确认」在跨进程读面上系统性误报(写入返回
57
- committed=true,读回却检索不到)。
58
-
59
- 边界(如实):无脏数据时 `flush()` 是 no-op,成本只在「确有落盘」时产生;
60
- 失败**不抛异常**——写入内容已落盘,抛出去会让调用方误判「写入失败」而
61
- 重试(两段式账本已记 committed,重试即重复写入)。改为在响应里如实标记
62
- `flush_error`,不静默。
63
- """
64
- try:
65
- cg.flush()
66
- except Exception as exc: # noqa: BLE001 —— 索引可见性故障不得改写写入语义
67
- if isinstance(out, dict):
68
- out["flush_error"] = "%s: %s" % (type(exc).__name__, exc)
69
-
70
-
71
- class WritePipeline:
72
- """写入拦截器链(实例级;default_pipeline() 提供进程级默认单例)。"""
73
-
74
- # 生效条件:无前置;初始化 _before / _after 两条空链(元素为 (name, fn) 二元组),不做任何注册、不触盘;
75
- def __init__(self):
76
- self._before = [] # [(name, fn)]
77
- self._after = [] # [(name, fn)]
78
-
79
- # ---------- 注册表 ----------
80
-
81
- # 生效条件:fn 可调用时先按 name 摘除同名项,再在 position 为 None 时把 (str(name), fn) 追加到链尾、否则插入 max(0, int(position))(position=0 非 None,走插入分支);fn 不可调用则抛 TypeError;
82
- def register_before(self, name, fn, position=None):
83
- """注册 before 拦截器(同名幂等替换;position=None 追加到链尾)。
84
-
85
- fn(ctx) -> None | dict(终态响应,短路)。
86
- """
87
- if not callable(fn):
88
- raise TypeError(f"拦截器必须可调用:{name!r}")
89
- self.unregister_before(name)
90
- item = (str(name), fn)
91
- if position is None:
92
- self._before.append(item)
93
- else:
94
- self._before.insert(max(0, int(position)), item)
95
-
96
- # 生效条件:按 str(name) 过滤 _before,仅保留 x[0] != str(name) 的项(即删除全部同名项),并返回删除前后长度是否不等以表示是否确有移除;
97
- def unregister_before(self, name):
98
- n0 = len(self._before)
99
- self._before = [x for x in self._before if x[0] != str(name)]
100
- return len(self._before) != n0
101
-
102
- # 生效条件:fn 可调用时先按 name 摘除同名项,再把 (str(name), fn) 追加到 _after 链尾;fn 不可调用则抛 TypeError;
103
- def register_after(self, name, fn):
104
- """注册 after 观察者:fn(ctx, out),落盘成功后按序调用。"""
105
- if not callable(fn):
106
- raise TypeError(f"after 钩子必须可调用:{name!r}")
107
- self.unregister_after(name)
108
- self._after.append((str(name), fn))
109
-
110
- # 生效条件:按 str(name) 过滤 _after,仅保留 x[0] != str(name) 的项(即删除全部同名项),并返回删除前后长度是否不等以表示是否确有移除;
111
- def unregister_after(self, name):
112
- n0 = len(self._after)
113
- self._after = [x for x in self._after if x[0] != str(name)]
114
- return len(self._after) != n0
115
-
116
- # 生效条件:无前置;返回 {"before": [...注册名], "after": [...注册名]},只暴露名字不暴露函数对象,顺序即执行顺序;
117
- def names(self):
118
- return {"before": [n for n, _f in self._before],
119
- "after": [n for n, _f in self._after]}
120
-
121
- # ---------- 执行 ----------
122
-
123
- # 生效条件:传入 cg 与 a(a 为假值如 None 时按 {} 处理,nid 取 a.get("node_id") 或其假值回落 "mem_"+毫秒时间戳),任一 before 钩子返回非 None 即记 halted_by 并经 _commit_visibility 短路返回该响应,全部放行则记 twophase 意图后跑 _executor(其抛 BaseException 时记 STATUS_ERROR 并原样重抛)再顺序跑 after 链、_commit_visibility 并返回落盘 out;
124
- def execute(self, cg, a):
125
- """写入请求入口:跑 before 链 → 链尾执行器 → after 链。
126
-
127
- before 链任一非 None 返回值即终态响应(与重构前各分支的 return
128
- 形态逐字节一致);链尾执行器产生落盘响应,after 链只观测不改写。
129
- """
130
- a = a or {}
131
- ctx = {"cg": cg, "a": a,
132
- "nid": a.get("node_id")
133
- or ("mem_" + str(int(time.time() * 1000))),
134
- "verdict": None, "cvd": None}
135
- for name, fn in self._before:
136
- out = fn(ctx)
137
- if out is not None:
138
- ctx["halted_by"] = name
139
- _commit_visibility(cg, out)
140
- return out
141
- # ③ 两段式:闸门**全部放行**(确认要写)→ 先落意图,再执行落盘,
142
- # 最后记结果。崩溃若发生在两者之间,`reconcile` 能据正文指纹回答
143
- # 「那笔写入到底落盘了没有」,而不是留下一条无痕的静默记忆。
144
- tok = twophase.begin(cg, ctx["nid"], a.get("content", ""),
145
- layer=a.get("layer") or "knowledge",
146
- actor="writepipe:executor")
147
- try:
148
- out = _executor(ctx)
149
- except BaseException as exc:
150
- # 执行器抛异常(权限拒绝/校验失败)= 写入未完成 → 账本记 error,
151
- # 异常照抛不吞(两段式只记账,不改写既有错误语义)。
152
- twophase.commit(cg, tok, status=twophase.STATUS_ERROR,
153
- reason=type(exc).__name__)
154
- raise
155
- ctx["out"] = out
156
- twophase.commit(cg, tok, status=twophase.STATUS_COMMITTED,
157
- reason="executor_ok")
158
- for _name, fn in self._after:
159
- fn(ctx, out)
160
- _commit_visibility(cg, out)
161
- return out
162
-
163
-
164
- # --------------------------------------------------------------------------
165
- # 默认链(原 mcp_server._cg_dispatch op=="write" 分支,行为逐字节搬运)
166
- # --------------------------------------------------------------------------
167
-
168
- # 生效条件:ctx["a"] 经 audit.audit 得出的 state 为 ACCEPT 时返 None 放行,为 REJECT 时经 cg.add_rejected 返回 ok=False/moved_to="rejected",其余 state 经 cg.propose 返回 moved_to="review_queue"(pr 带 dedup 时再附 dedup/dup_of/dup_status 并改写 hint);
169
- def _gate_audit(ctx):
170
- """校验闸:audit.audit 四态。ACCEPT 放行;REJECT 负记忆;其余入审核队列。"""
171
- a = ctx["a"]
172
- cg = ctx["cg"]
173
- from . import audit
174
- payload = {"content": a.get("content", ""), "action": a.get("action"),
175
- "sensitivity": a.get("sensitivity"),
176
- "topic": a.get("query") or a.get("intent")}
177
- if (a.get("content_kind") or "").strip() == "hyperedge":
178
- # 超边验证器(回放比对)需要锚与结构键:fm 键平铺在 a 顶层,
179
- # 经 hyperedge.audit_payload 装配三键载荷(缺锚由验证器 fail-closed)。
180
- from . import hyperedge as _he
181
- payload = _he.audit_payload(a)
182
- verdict = audit.audit(
183
- (a.get("content_kind") or "").strip(),
184
- payload,
185
- {"cg": cg, "principal": getattr(cg, "principal", None)})
186
- ctx["verdict"] = verdict
187
- st = verdict["state"]
188
- if st == audit.ACCEPT:
189
- return None
190
- if st == audit.REJECT:
191
- rid = cg.add_rejected((a.get("content") or "")[:200], verdict["evidence"],
192
- verification_basis=verdict.get("basis") or "test",
193
- tags=a.get("tags"))
194
- return {"ok": False, "id": rid, "committed": False,
195
- "moved_to": "rejected", "verdict": verdict,
196
- "hint": "这是审核闸门的正常行为:内容未过内容政策审核(REJECT),"
197
- "已记入负记忆——不是工具故障,重试同样结果;"
198
- "拒绝依据见 verdict.evidence"}
199
- from .mcp_server import _proposal_extras
200
- pr = cg.propose(ctx["nid"], a.get("content", ""), info=True,
201
- layer=a.get("layer") or "knowledge",
202
- tags=a.get("tags"), condition_space=a.get("condition_space"),
203
- **_proposal_extras(a, verdict))
204
- out = {"ok": True, "id": ctx["nid"], "pid": pr["pid"], "committed": False,
205
- "moved_to": "review_queue", "verdict": verdict,
206
- "hint": "这是校验闸门的正常行为(verdict=%s):内容未达 ACCEPT,"
207
- "已入审核队列——不需要重试;落盘须由设计者权限(can_admin)"
208
- "对提案 pid 裁决(agent 端无裁决权是设计),转告使用者:"
209
- "python -m md_cg.review_cli list 后 accept/reject,"
210
- "或 cg(op=review, pid=<pid>, decision=accept|reject|"
211
- "edit|merge, reason=<理由>)" % verdict.get("state")}
212
- if pr.get("dedup"):
213
- out["dedup"] = True
214
- out["dup_of"] = pr["pid"]
215
- out["dup_status"] = pr.get("dup_status")
216
- out["hint"] = (
217
- "同内容提案已存在(pid=%s,状态=%s,幂等去重),"
218
- "本次未重复入队——无需重试;落盘须由设计者权限(can_admin)"
219
- "对该 pid 裁决:python -m md_cg.review_cli list 后 accept/reject,"
220
- "或 cg(op=review, pid=<pid>, decision=accept|reject|edit|merge, "
221
- "reason=<理由>)" % (pr["pid"], pr.get("dup_status") or "pending"))
222
- return out
223
-
224
-
225
- # 生效条件:ctx["a"]["consistency"] 为假值时返回 None;on_conflict 缺键或假值回落 "defer",仅当 verdict=REJECT 且 on_conflict=reject(返回 moved_to="conflict_rejected")或 verdict∈{REJECT,BLINDSPOT} 且 on_conflict=defer(转 review_queue,去重命中时改写 hint)才拦截,其余 on_conflict 取值返回 None;
226
- def _gate_consistency(ctx):
227
- """冲突闸:节点间自动冲突检测(三级决策)。
228
-
229
- 仅 REJECT(明确判为冲突)按 on_conflict 处置:reject=直接拒绝;
230
- defer=转入审核队列。BLINDSPOT(无可比对节点,检测前提不存在)恒放行,
231
- cvd 审计经链尾透出(issue #26:无法比对 ≠ 冲突,入队是死胡同)。
232
- """
233
- a = ctx["a"]
234
- cg = ctx["cg"]
235
- if not bool(a.get("consistency", True)):
236
- return None
237
- from .mcp_server import _proposal_extras
238
- oc = (a.get("on_conflict") or "defer").strip().lower()
239
- cvd = cg.check_consistency(
240
- a.get("content", ""),
241
- layer=a.get("layer") or ("contextual" if a.get("gated")
242
- else "knowledge"),
243
- condition_space=a.get("condition_space"),
244
- non_applicable_conditions=a.get("non_applicable_conditions"),
245
- tags=a.get("tags"), exclude=ctx["nid"], auto_flywheel=True)
246
- ctx["cvd"] = cvd
247
- v = cvd.get("verdict")
248
- # issue #26(2026-09-23):BLINDSPOT ≠ REJECT——冲突闸的 BLINDSPOT 唯一出口
249
- # 是 comparable==0(既有节点无一声明条件,含空库),语义是「检测前提不
250
- # 存在」而非「已判定冲突」;defer 入队后裁决者面对同样空白(无可操作
251
- # 下一步,死胡同)。故 BLINDSPOT 恒放行:cvd 经链尾 _executor 的
252
- # consistency 字段如实透出(放行原因可观测);REJECT(明确冲突)维持
253
- # 原拦截语义。原先两者等同拦截 → 空库首次写入恒不落盘(README 推荐的
254
- # content_kind=code 通路必失败——库越空越写不进)。
255
- blocked = (v == "REJECT" and oc in ("reject", "defer"))
256
- if not blocked:
257
- return None
258
- if oc == "reject":
259
- return {"ok": False, "id": ctx["nid"], "committed": False,
260
- "moved_to": "conflict_rejected",
261
- "consistency": cvd, "verdict": ctx["verdict"]}
262
- pr = cg.propose(ctx["nid"], a.get("content", ""), info=True,
263
- layer=a.get("layer") or "knowledge",
264
- tags=a.get("tags"),
265
- condition_space=a.get("condition_space"),
266
- **_proposal_extras(a, ctx["verdict"]))
267
- out = {"ok": False, "id": ctx["nid"], "pid": pr["pid"],
268
- "committed": False,
269
- "moved_to": "review_queue", "consistency": cvd,
270
- "verdict": ctx["verdict"],
271
- "hint": "这是冲突闸门的正常行为:本次写入与既有条件/纪律冲突"
272
- "(on_conflict=defer),已转入审核队列待裁决——"
273
- "不是工具故障,重试同样结果;"
274
- "落盘须由设计者权限(can_admin)裁决,转告使用者:"
275
- "python -m md_cg.review_cli list 后 accept/reject,"
276
- "或 cg(op=review, pid=<pid>, decision=accept|reject|"
277
- "edit|merge, reason=<理由>)"}
278
- if pr.get("dedup"):
279
- out["dedup"] = True
280
- out["dup_of"] = pr["pid"]
281
- out["hint"] = (
282
- "同内容提案已存在于审核队列(pid=%s,幂等去重),"
283
- "本次未重复入队——无需重试;"
284
- "落盘须由设计者权限(can_admin)对该 pid 裁决:"
285
- "python -m md_cg.review_cli list 后 accept/reject,"
286
- "或 cg(op=review, pid=<pid>, decision=accept|reject|"
287
- "edit|merge, reason=<理由>)" % pr["pid"])
288
- return out
289
-
290
-
291
- # 生效条件:ctx["a"] 的 gated 为假值时返 None 放行;为真值时按 cg.remember_gated 返回的 verdict 落两段式账,且仅 verdict 为 ACCEPT 时 ok/committed 为 True,verdict 为 MERGE 时记 committed 并置 moved_to="merged_into:"+merged_into,verdict 为 DROP/DEFER 时记 aborted 且 moved_to 为其小写值;
292
- def _gate_gated(ctx):
293
- """主动遗忘闸(gated=true 时启用):writelimit 限流 + forgetting 三问四态。
294
-
295
- 本闸是「替代执行路径」:命中即由 remember_gated 代为落盘/合并/丢弃并
296
- 返回终态;未启用(gated 假值)放行给链尾执行器。
297
- """
298
- a = ctx["a"]
299
- cg = ctx["cg"]
300
- if not a.get("gated"):
301
- return None
302
- hint = a.get("importance_hint")
303
- if hint is None and a.get("importance") is not None:
304
- hint = float(a["importance"])
305
- # ③ 两段式:本闸是**替代执行路径**(自己落盘),意图必须由它先记——
306
- # 若等 execute 在链后统一记,intent 会晚于本闸内部的写盘,「先行持久化」
307
- # 就不成立了。落盘前的窗口因此仍然被账本覆盖。
308
- tok = twophase.begin(cg, ctx["nid"], a.get("content", ""),
309
- layer=a.get("layer") or "contextual",
310
- actor="writepipe:gated")
311
- res = cg.remember_gated(
312
- ctx["nid"], a.get("content", ""), layer=a.get("layer") or "contextual",
313
- role=a.get("role"), tags=a.get("tags"),
314
- condition_space=a.get("condition_space"),
315
- verification_basis=(a.get("verification_basis")
316
- or (ctx.get("verdict") or {}).get("basis")),
317
- non_applicable_conditions=a.get("non_applicable_conditions"),
318
- importance_hint=hint, override=bool(a.get("override")),
319
- consistency=False,
320
- derived_from=_split_ids(a.get("derived_from")),
321
- relation=a.get("relation"))
322
- v = res.get("verdict")
323
- committed = v == "ACCEPT"
324
- # 结局如实记:ACCEPT=落盘完成;MERGE=内容并入既有节点(不再以本次内容成
325
- # 文,指纹对账不适用,故直接记 committed 并注明去向);DROP/DEFER=未落盘。
326
- if committed:
327
- twophase.commit(cg, tok, status=twophase.STATUS_COMMITTED,
328
- reason="gated_accept")
329
- elif v == "MERGE":
330
- twophase.commit(cg, tok, status=twophase.STATUS_COMMITTED,
331
- reason="merged_into:%s" % res.get("merged_into"))
332
- else:
333
- twophase.commit(cg, tok, status=twophase.STATUS_ABORTED,
334
- reason="gated_%s" % str(v).lower())
335
- out = {"ok": committed, "id": ctx["nid"], "committed": committed,
336
- "gate": res, "verdict": ctx["verdict"]}
337
- if ctx.get("cvd") is not None:
338
- out["consistency"] = ctx["cvd"]
339
- if v == "MERGE":
340
- out["moved_to"] = "merged_into:" + str(res.get("merged_into"))
341
- elif v in ("DROP", "DEFER"):
342
- out["moved_to"] = v.lower()
343
- # gated 是**替代落盘路径**(自行落盘/合并后直接返回终态、不跑 after 链),故
344
- # 一跳同步传播须在此单独触发——否则 MERGE 类覆写会漏传下游(非对称边界,
345
- # 与 `_after_trust` 注释互指)。
346
- if committed or v == "MERGE":
347
- prop = trust.mark_dependents(
348
- cg, ctx["nid"],
349
- reason="上游节点被 gated 写入/合并(内容或验证态可能已变)",
350
- actor="writepipe:gated", trigger="write_gated")
351
- if isinstance(prop, dict) and prop.get("changed"):
352
- out["propagation"] = {"changed": prop.get("changed"),
353
- "updated": (prop.get("updated") or [])[:10]}
354
- return out
355
-
356
-
357
- # 生效条件:ctx["a"]["content"] 的「# 子功能:」行含显式跨节点引用(`@<节点 id>`)且 depends_on 解析为空时返回 ok=False/error="E050" 的终态;depends_on 含库中不存在的 id 时返回 ok=False/error="E051" 的终态;其余(无该行 / 哨兵 / 自然语言自述 / 声明且目标齐备)返回 None 放行;
358
- def _gate_deps(ctx):
359
- """依赖声明闸(before 链:linkref 之后、audit 之前):**硬拒条件缺失**。
360
-
361
- 「声明」的界定(收窄裁定 b,2026-09-19):以 `@<节点 id>` 显式引用为界——
362
- 自然语言**自述子功能**(描述本单元**内部**构成)不构成依赖声明。原因:CCG
363
- 编译产物六要素必含「子功能」行,若沿用「非哨兵即声明」,每个 CCG 节点落库后
364
- 都会被自己的闸门永久要求 depends_on(E050 死锁,test_ccgc V16f 实证)。
365
- 依赖不是必填元数据;**只有显式声称依赖却不落字段**才是违规(声称与落盘不一致)。
366
-
367
- 为何是硬拒而非告警:依赖是失效传播的**唯一入口**。声明缺失时,「上游变了
368
- 下游要存疑」这条链从源头就不存在——它既不报错、也不留任何信号,缺陷以
369
- 「静默不传播」的形态长期存活(比报错更难发现)。故按契约缺失处理,与
370
- `ccgc` 的 E 码体系同构(E050 声明缺失 / E051 目标不存在)。
371
-
372
- 张力消解(与 provenance「写路径永不阻断」纪律的边界,二者不冲突):
373
- - **声明缺失 / 目标不可解析 = 契约违规** → 硬拒(本闸只做这件事);
374
- - **传播落盘失败 = 运维降级** → 告警不阻断(见 `_after_trust`)。
375
-
376
- 判据基于**入参**而非落盘后回读:首次写入时节点尚不存在,回读式校验会
377
- 永远放行(等于闸门失效)。
378
- """
379
- a = ctx["a"]
380
- cg = ctx["cg"]
381
- from . import nodefile
382
- if not nodefile.declares_dependency(a.get("content") or ""):
383
- return None
384
- deps = trust.as_deps(a.get("depends_on"))
385
- if not deps:
386
- return {"ok": False, "id": ctx["nid"], "committed": False,
387
- "gate": "deps", "error": "E050",
388
- "verdict": ctx.get("verdict"),
389
- "hint": ("依赖声明缺失(E050):正文以 " + nodefile.DEP_REF_MARK
390
- + "<节点 id> 显式声明了跨节点依赖(「# 子功能:」行),"
391
- "但 depends_on 未给出可解析目标。依赖必须是**可解析的字段**"
392
- "(形如 depends_on=[\"<被依赖节点 id>\"]),不能只是散文——"
393
- "否则被依赖单元变动时,下游无处可传。补齐后重试;"
394
- "若该行只是描述本单元内部构成(自述),去掉 "
395
- + nodefile.DEP_REF_MARK + " 引用或改填「无」即可。"
396
- "本闸是契约闸门的正常行为,不是工具故障。")}
397
- known = set((getattr(cg, "index", None) or {}).get("nodes") or {})
398
- missing = [d for d in deps if d not in known]
399
- if missing:
400
- return {"ok": False, "id": ctx["nid"], "committed": False,
401
- "gate": "deps", "error": "E051", "missing": missing[:10],
402
- "verdict": ctx.get("verdict"),
403
- "hint": "依赖目标不存在(E051):depends_on 指向 "
404
- + ", ".join(missing[:5])
405
- + ",但库中查无此节点——声称依赖一个并不存在的地基,"
406
- "失效传播会在此处断链。请先建立被依赖节点,或修正 id。"}
407
- return None
408
-
409
-
410
- # 生效条件:out 为 dict 且 out["committed"] 为真时,调 trust.mark_dependents 做一跳同步传播(异常吞掉并降级),并在节点时间轴非「时效内」时往 out 写 "validity" 提示;其余情况直接返回不做任何动作;
411
- def _after_trust(ctx, out):
412
- """after 观察者:落盘后触发**一跳同步传播** + 时效提示。
413
-
414
- 为何落在 after 而非 before:只有真正落盘(内容确实变了)才构成「地基动了」;
415
- before 链短路路径(REJECT / DEFER)本就不跑 after 链,语义天然正确。
416
- 例外:`gated` 是替代落盘路径(自行落盘并返回终态、不跑 after),故它的
417
- 传播在 `_gate_gated` 内部单独触发(见该处注释)。
418
-
419
- **永不抛**:传播失败只降级(`trust.mark_dependents` 内部已兜底并写台账),
420
- 绝不把「写入已成功」改写为失败——与 `_commit_visibility` 同款边界。
421
- """
422
- if not isinstance(out, dict) or not out.get("committed"):
423
- return
424
- cg = ctx["cg"]
425
- nid = ctx.get("nid")
426
- if not nid:
427
- return
428
- prop = trust.mark_dependents(
429
- cg, nid, reason="上游节点被写入/覆写(内容或验证态可能已变)",
430
- actor="writepipe:trust", trigger="write")
431
- if isinstance(prop, dict) and prop.get("changed"):
432
- out["propagation"] = {"changed": prop.get("changed"),
433
- "updated": (prop.get("updated") or [])[:10]}
434
- # 热路径失效(2026-09-19 热温冷分层):写入后清缓存
435
- try:
436
- from . import hotcache as _hc
437
- _hc.invalidate(cg, nid)
438
- except Exception: # noqa: BLE001
439
- pass # 缓存失效失败不阻断写入(永不抛)
440
-
441
- # 冷路径入队(2026-09-19 热温冷分层):异步深度验证
442
- try:
443
- from . import coldverify as _cv
444
- _cv.enqueue(cg, nid, action="reverify",
445
- reason="writepipe:after_trust")
446
- except Exception: # noqa: BLE001
447
- pass # 入队失败不阻断写入
448
-
449
-
450
- # 生效条件:a 的 content_kind 非 hyperedge 即返回 {},为 hyperedge 时延迟导入
451
- # hyperedge.EXTRA_FM_KEYS 收集 a 中非 None 的对应键返回(其余写入零键透传);
452
- def _hyperedge_extra(a):
453
- """hyperedge 写入的 fm 扩展键透传。计划决策 2:超边写入走 write 动词既有
454
- 审核链、不造新通道——链尾执行器是唯一落盘点,cg.add 经 **extra 落 fm,
455
- 故扩展键在此透传;落盘面丢字段 = 上游声明静默失效。延迟导入防循环依赖。"""
456
- if (a.get("content_kind") or "").strip() != "hyperedge":
457
- return {}
458
- from . import hyperedge as _he
459
- return {k: a[k] for k in _he.EXTRA_FM_KEYS if a.get(k) is not None}
460
-
461
-
462
- # 生效条件:由链尾以含 cg 与 a 的 ctx 调用即无条件执行 cg.add 落盘并返回 ok=True/committed=True,ctx["cvd"] 非 None 时附加 consistency 字段;
463
- def _executor(ctx):
464
- """链尾执行器(常驻不可卸载):cg.add 直写落盘。
465
-
466
- 角色/层权限校验(require_layer_write)在 MdCGSecure.add 库层内部,
467
- 结构上不可被拦截器绕过——拦截器只能裁决「写不写」,改不了「谁能写」。
468
- """
469
- a = ctx["a"]
470
- cg = ctx["cg"]
471
- # importance 显式 null(JSON null→None)时 get 的缺省值不生效,直接
472
- # float(None) 抛 TypeError 崩主写路径——回退默认 0.5(与 add 缺省同口径);
473
- # 0 / 0.0 等合法 falsy 数值照传(_gate_gated :304 已是同款 None 判定)。
474
- _imp = a.get("importance")
475
- cg.add(ctx["nid"], a.get("content", ""),
476
- layer=a.get("layer") or "knowledge",
477
- tags=a.get("tags"), condition_space=a.get("condition_space"),
478
- importance=0.5 if _imp is None else float(_imp),
479
- verification_basis=a.get("verification_basis")
480
- or (ctx.get("verdict") or {}).get("basis"),
481
- non_applicable_conditions=a.get("non_applicable_conditions"),
482
- override=bool(a.get("override")), consistency=False,
483
- derived_from=_split_ids(a.get("derived_from")),
484
- relation=a.get("relation"),
485
- # 可验证记忆单元(裁定 D):依赖/双时间轴/验证态随写入落 fm。
486
- # 透传而非丢弃——落盘面丢字段=上游声明静默失效(比报错难发现)。
487
- depends_on=trust.as_deps(a.get("depends_on")),
488
- valid_from=a.get("valid_from"), valid_until=a.get("valid_until"),
489
- verification_state=a.get("verification_state"),
490
- **_hyperedge_extra(a))
491
- out = {"ok": True, "id": ctx["nid"], "committed": True,
492
- "verdict": ctx["verdict"]}
493
- if ctx.get("cvd") is not None:
494
- out["consistency"] = ctx["cvd"]
495
- return out
496
-
497
-
498
- # 生效条件:value 传入即无条件延迟导入并转调 mcp_server._split_ids 后原样返回其结果(本符号无自身分支);
499
- def _split_ids(value):
500
- # 与 mcp_server._split_ids 同源(延迟导入,单一真源)
501
- from .mcp_server import _split_ids as _f
502
- return _f(value)
503
-
504
-
505
- # --------------------------------------------------------------------------
506
- # 进程级默认链(单例;mcp_server op=write 一行分发到此)
507
- # --------------------------------------------------------------------------
508
-
509
- _DEFAULT = None
510
-
511
-
512
- # 生效条件:pipe 传入即对其依次注册 before 的 linkref(position=0)/deps/audit/consistency/gated 与 after 的 linkref/trust(同名幂等替换),并返回同一 pipe;
513
- def install_default_gates(pipe):
514
- """把默认闸以拦截器形态注册(幂等:同名替换,可重复调用)。
515
-
516
- 链序(2026-09-19 起):
517
- before = linkref(解析) → deps(依赖声明) → audit → consistency → gated
518
- → 链尾执行器
519
- after = linkref(建边) → trust(一跳传播)
520
-
521
- linkref 置于链首的理由:正文引用解析是**纯读、无副作用**,且其结果必须
522
- 先于任何短路闸写入 ctx,供 after 链消费。短路闸(REJECT/DEFER/gated)
523
- 返回终态时 `execute` 不跑 after 链,故未落盘的写入不会建边——语义正确。
524
-
525
- deps 夹在 linkref 与 audit 之间的理由:两件事都与内容政策无关,故在 audit
526
- 之前;linkref 之后是因为它只解析正文引用、不动依赖声明——依赖是**字段域**
527
- 而非正文域,顺序倒置不会互相污染,但保持「解析在前、裁决在后」的一致读序。
528
-
529
- after 的 trust 置于 linkref 之后:建边先于传播——传播按 `depends_on`
530
- 反查(字段域)而非边域,故顺序不影响正确性;置于其后只为让 ctx 中的
531
- 边信息先落定,便于排障时读 ctx。
532
-
533
- linkref 的**落点是 after 而非注入 `a["edges"]`**:`cg.add` 是全量重建
534
- fm,注入 edges 会在覆写既有节点时清空其原有边(破坏性副作用);
535
- `append_edge` 是边域窄原语且幂等(见 linkref 模块 docstring)。
536
- 依赖声明同忌经 `a["edges"]` 注入——走 `depends_on` 字段域。
537
- """
538
- from . import linkref
539
- pipe.register_before("linkref", linkref.before_hook(), position=0)
540
- pipe.register_before("deps", _gate_deps)
541
- pipe.register_before("audit", _gate_audit)
542
- pipe.register_before("consistency", _gate_consistency)
543
- pipe.register_before("gated", _gate_gated)
544
- pipe.register_after("linkref", linkref.after_hook())
545
- pipe.register_after("trust", _after_trust)
546
- return pipe
547
-
548
-
549
- # 生效条件:模块级 _DEFAULT 为 None 时新建 WritePipeline 并经 install_default_gates 注册后缓存返回,否则直接返回已缓存的 _DEFAULT 单例;
550
- def default_pipeline():
551
- """进程级默认写入链(单例)。自定义闸门 register_before 即插即拔。"""
552
- global _DEFAULT
553
- if _DEFAULT is None:
554
- _DEFAULT = install_default_gates(WritePipeline())
1
+ # -*- coding: utf-8 -*-
2
+ """写入路径拦截器链(Pi 钩子化机制移植,交接文档 §3⑥「闸门即扩展」)。
3
+
4
+ pi 机制(packages/coding-agent/docs/extensions.md):全生命周期事件总线,
5
+ 闸门/路径保护/审批全是以扩展形态叠加的,核心没有硬编码策略。灵枢对应:
6
+ write 的六道闸(audit 校验 / consistency 冲突 / review 审核 / gated 主动遗忘
7
+ / writelimit 限流 / 权限)的次序与启停原先硬编码在 mcp_server._cg_dispatch
8
+ 的 if/else 流程里,加一道闸要改核心文件。本模块把它重构为显式拦截器链:
9
+
10
+ - **before 链**:拦截器按注册序执行。返回 None = 放行(链继续);
11
+ 返回 dict = 终态响应(短路——不落盘,或该拦截器已代为落盘/入队)。
12
+ - **after 链**:落盘成功后依次执行(观测者;返回值忽略;异常不吞——
13
+ 写入已成功,钩子故障必须暴露而非静默)。
14
+ - **ctx 为可变 dict**:a=原始入参 / cg=实例 / nid=节点 id / verdict=校验闸
15
+ 裁决 / cvd=冲突闸裁决。拦截器改写 ctx["a"]["content"] 等字段即实现
16
+ REWRITE(改写后传给后续链与执行器)。
17
+ - **链尾执行器(_executor)是常驻环节**,不在注册表中、不提供卸载 API;
18
+ 它调 cg.add 落盘,而角色/层权限校验(require_layer_write)在
19
+ MdCGSecure.add **库层内部**——是落盘必经之路,结构上不可被任何拦截器
20
+ 绕过(反面清单:不学 pi 的全权信任,信任必须结构强制)。
21
+
22
+ 默认链(install_default_gates,与重构前 _cg_dispatch write 分支行为逐字
23
+ 节一致):audit → consistency → gated → _executor。
24
+
25
+ 验收口径(交接文档 §3⑥):全部既有写入测试零改动通过;新增/移除一个
26
+ 拦截器不改核心文件(register_before / unregister_before 即插即拔)。
27
+
28
+ **③ 两段式提交(2026-09-16 叠加)**:链尾执行器与 gated 闸(两条**真实落盘**
29
+ 路径)各自在执行落盘前调 `twophase.begin` 落 intent、落盘后调 `twophase.commit`
30
+ 记 outcome;崩溃在两者之间时由 `twophase.reconcile` 据正文指纹补账/标记。
31
+ 边界(如实):`_gate_audit` 的 REJECT(写负记忆)与各闸的 propose(**未落盘**,
32
+ 仅入审核队列)不在两段式覆盖面内——前者是短小负记录、后者本就没有落盘动作。
33
+
34
+ **④ 写提交边界(2026-09-16 叠加)**:`execute` 的两条出口(before 链短路 /
35
+ 链尾执行器 + after 链之后)统一调 `_commit_visibility`——把内存脏索引
36
+ `flush()` 到分片日志,使本次写入对**其他进程**立即可见。这是第16条「写入后
37
+ 读回确认」的跨进程前置条件(server 级 `autoflush=1` 是同一问题的兜底,
38
+ 覆盖不经本链的写入路径)。根因取证见 `_commit_visibility` 文档串。
39
+ """
40
+
41
+ from . import twophase, trust
42
+
43
+ __all__ = ["WritePipeline", "default_pipeline"]
44
+
45
+
46
+ # 生效条件:调用即对 cg 执行 flush()(无脏数据时为 no-op),且仅当该调用抛异常而 out 是 dict 时在 out 写入 "flush_error",异常本身不外抛;
47
+ def _commit_visibility(cg, out):
48
+ """写提交边界(2026-09-16):把内存脏索引落分片日志,使本次写入对其他进程立即可见。
49
+
50
+ 根因(第4条取证):写入只经 `_stage` 入内存 + `_dirty`,须达 `autoflush`
51
+ (默认 64)或 `close()` 才 `flush()` 落 `_index_log/`;MCP server 常驻、
52
+ 不 close,故单条写入在阈值前**对其他进程不可见**——`_load_index` 读的是
53
+ 「快照 `_index.json` + 分片日志重放」,而快照只在 compact/rebuild 时重写。
54
+ 症状即第16条「写入后读回确认」在跨进程读面上系统性误报(写入返回
55
+ committed=true,读回却检索不到)。
56
+
57
+ 边界(如实):无脏数据时 `flush()` 是 no-op,成本只在「确有落盘」时产生;
58
+ 失败**不抛异常**——写入内容已落盘,抛出去会让调用方误判「写入失败」而
59
+ 重试(两段式账本已记 committed,重试即重复写入)。改为在响应里如实标记
60
+ `flush_error`,不静默。
61
+ """
62
+ try:
63
+ cg.flush()
64
+ except Exception as exc: # noqa: BLE001 —— 索引可见性故障不得改写写入语义
65
+ if isinstance(out, dict):
66
+ out["flush_error"] = "%s: %s" % (type(exc).__name__, exc)
67
+
68
+
69
+ class WritePipeline:
70
+ """写入拦截器链(实例级;default_pipeline() 提供进程级默认单例)。"""
71
+
72
+ # 生效条件:无前置;初始化 _before / _after 两条空链(元素为 (name, fn) 二元组),不做任何注册、不触盘;
73
+ def __init__(self):
74
+ self._before = [] # [(name, fn)]
75
+ self._after = [] # [(name, fn)]
76
+
77
+ # ---------- 注册表 ----------
78
+
79
+ # 生效条件:fn 可调用时先按 name 摘除同名项,再在 position 为 None 时把 (str(name), fn) 追加到链尾、否则插入 max(0, int(position))(position=0 非 None,走插入分支);fn 不可调用则抛 TypeError;
80
+ def register_before(self, name, fn, position=None):
81
+ """注册 before 拦截器(同名幂等替换;position=None 追加到链尾)。
82
+
83
+ fn(ctx) -> None | dict(终态响应,短路)。
84
+ """
85
+ if not callable(fn):
86
+ raise TypeError(f"拦截器必须可调用:{name!r}")
87
+ self.unregister_before(name)
88
+ item = (str(name), fn)
89
+ if position is None:
90
+ self._before.append(item)
91
+ else:
92
+ self._before.insert(max(0, int(position)), item)
93
+
94
+ # 生效条件:按 str(name) 过滤 _before,仅保留 x[0] != str(name) 的项(即删除全部同名项),并返回删除前后长度是否不等以表示是否确有移除;
95
+ def unregister_before(self, name):
96
+ n0 = len(self._before)
97
+ self._before = [x for x in self._before if x[0] != str(name)]
98
+ return len(self._before) != n0
99
+
100
+ # 生效条件:fn 可调用时先按 name 摘除同名项,再把 (str(name), fn) 追加到 _after 链尾;fn 不可调用则抛 TypeError;
101
+ def register_after(self, name, fn):
102
+ """注册 after 观察者:fn(ctx, out),落盘成功后按序调用。"""
103
+ if not callable(fn):
104
+ raise TypeError(f"after 钩子必须可调用:{name!r}")
105
+ self.unregister_after(name)
106
+ self._after.append((str(name), fn))
107
+
108
+ # 生效条件:按 str(name) 过滤 _after,仅保留 x[0] != str(name) 的项(即删除全部同名项),并返回删除前后长度是否不等以表示是否确有移除;
109
+ def unregister_after(self, name):
110
+ n0 = len(self._after)
111
+ self._after = [x for x in self._after if x[0] != str(name)]
112
+ return len(self._after) != n0
113
+
114
+ # 生效条件:无前置;返回 {"before": [...注册名], "after": [...注册名]},只暴露名字不暴露函数对象,顺序即执行顺序;
115
+ def names(self):
116
+ return {"before": [n for n, _f in self._before],
117
+ "after": [n for n, _f in self._after]}
118
+
119
+ # ---------- 执行 ----------
120
+
121
+ # 生效条件:传入 cg 与 a(a 为假值如 None 时按 {} 处理,nid 取 a.get("node_id") 或其假值回落 mdcg.mint_auto_id(cg)——自动 id 的**唯一铸造点**,含毫秒位+6 位 hex 随机段与「已存在则换随机段重生成」的有界存在性闸),任一 before 钩子返回非 None 即记 halted_by 并经 _commit_visibility 短路返回该响应,全部放行则记 twophase 意图后跑 _executor(其抛 BaseException 时记 STATUS_ERROR 并原样重抛)再顺序跑 after 链、_commit_visibility 并返回落盘 out;
122
+ def execute(self, cg, a):
123
+ """写入请求入口:跑 before 链 → 链尾执行器 → after 链。
124
+
125
+ before 链任一非 None 返回值即终态响应(与重构前各分支的 return
126
+ 形态逐字节一致);链尾执行器产生落盘响应,after 链只观测不改写。
127
+ """
128
+ a = a or {}
129
+ # B1(2026-09-30):自动 id 的铸造**只有一份实现**(mdcg.mint_auto_id)
130
+ # ——原先此处 `"mem_" + 毫秒` 与 mcp_server 的 mdcg_remember 分支各写一份,
131
+ # 同毫秒自动写入铸出同一 id,被 add 的 upsert 语义静默顶替(无失败信号)。
132
+ # 本处只委托,不复制判据。
133
+ from .mdcg import mint_auto_id
134
+ ctx = {"cg": cg, "a": a,
135
+ "nid": a.get("node_id") or mint_auto_id(cg),
136
+ "verdict": None, "cvd": None}
137
+ for name, fn in self._before:
138
+ out = fn(ctx)
139
+ if out is not None:
140
+ ctx["halted_by"] = name
141
+ _commit_visibility(cg, out)
142
+ return out
143
+ # ③ 两段式:闸门**全部放行**(确认要写)→ 先落意图,再执行落盘,
144
+ # 最后记结果。崩溃若发生在两者之间,`reconcile` 能据正文指纹回答
145
+ # 「那笔写入到底落盘了没有」,而不是留下一条无痕的静默记忆。
146
+ tok = twophase.begin(cg, ctx["nid"], a.get("content", ""),
147
+ layer=a.get("layer") or "knowledge",
148
+ actor="writepipe:executor")
149
+ try:
150
+ out = _executor(ctx)
151
+ except BaseException as exc:
152
+ # 执行器抛异常(权限拒绝/校验失败)= 写入未完成 → 账本记 error,
153
+ # 异常照抛不吞(两段式只记账,不改写既有错误语义)。
154
+ twophase.commit(cg, tok, status=twophase.STATUS_ERROR,
155
+ reason=type(exc).__name__)
156
+ raise
157
+ ctx["out"] = out
158
+ twophase.commit(cg, tok, status=twophase.STATUS_COMMITTED,
159
+ reason="executor_ok")
160
+ for _name, fn in self._after:
161
+ fn(ctx, out)
162
+ _commit_visibility(cg, out)
163
+ return out
164
+
165
+
166
+ # --------------------------------------------------------------------------
167
+ # 默认链(原 mcp_server._cg_dispatch op=="write" 分支,行为逐字节搬运)
168
+ # --------------------------------------------------------------------------
169
+
170
+ # 生效条件:verdict 的 detail.missing 非空(或 evidence 以「缺少必需要素」起首)时返回「可修正的缺要素」文案(含缺失清单与补齐指引,不含「重试同样结果」式劝退表述);其余 REJECT(禁止规则命中=政策违规)返回「重试同样结果」原文案。
171
+ def _reject_hint(verdict):
172
+ """审核 REJECT 的 hint 分型(**单点生成处**)。
173
+
174
+ 为什么收敛在此:write 的 REJECT 出口只有本文件的 `_gate_audit` 一处
175
+ (链尾 `_executor` 只管落盘成功;其余闸门的 hint 各有自己的语义),
176
+ 故分型逻辑集中在此函数,`_gate_audit` 只做调用——避免「同一语义两处
177
+ 文案」随改动各自漂移。
178
+
179
+ 两类 REJECT 对调用方的**可操作性**不同,文案必须分型(否则把可修正的
180
+ 缺要素误导成重试无用):
181
+ · 缺必需要素(成文格式不全)→ 内容可补,补完重写即落盘;
182
+ · 命中禁止规则(内容政策违规)→ 内容本身不该入库,原样再发无意义。
183
+ """
184
+ detail = verdict.get("detail") or {}
185
+ missing = [str(x) for x in (detail.get("missing") or [])]
186
+ ev = str(verdict.get("evidence") or "")
187
+ if missing or ev.startswith("缺少必需要素"):
188
+ listed = "、".join(missing) if missing else ev
189
+ return ("写入被拒(REJECT):缺少必需要素——%s。"
190
+ "这是**可修正**的拒收:按 CCG 六要素(功能名/生效条件/子功能/"
191
+ "执行/验证方式/不适用条件,各占一行、以「# 要素名:」起首)"
192
+ "补齐后重写即可,本条未入库;该次内容已记入负记忆(rejected),"
193
+ "补全后重写为新条目。" % listed)
194
+ return ("这是审核闸门的正常行为:内容未过内容政策审核(REJECT),"
195
+ "已记入负记忆——不是工具故障,重试同样结果;"
196
+ "拒绝依据见 verdict.evidence")
197
+
198
+
199
+ # 生效条件:先经 audit.resolve_rulebook() 判策略可用性——不可用(env 显式坏路径 / env 未设且包内默认也拿不到)即返回 ok=False/moved_to="policy_unavailable" 与结构化 error(含 code/reason/hint),**不进 audit、不 propose、不落任何节点**;可用则把规则经 ctx["rules"] 下传(含 forbidden=0 且 required=0 的空规则,空规则仍走 _rule_check 的 DEFER 分支),其后 ctx["a"] 经 audit.audit 得出的 state 为 ACCEPT 时返 None 放行,为 REJECT 时经 cg.add_rejected(正文先经 audit.redact_forbidden 把禁表命中替换为占位符、再截前 200 字)返回 ok=False/moved_to="rejected"(hint 由 _reject_hint 按「可修正的缺要素 / 政策违规」分型生成),其余 state 经 cg.propose 返回 moved_to="review_queue"(pr 带 dedup 时再附 dedup/dup_of/dup_status 并改写 hint);
200
+ def _gate_audit(ctx):
201
+ """校验闸:先判策略可用性(fail-closed),再按 audit.audit 四态分派。
202
+
203
+ 策略面(issue #43 问题 1 修复):修前 env 未设 → load_rulebook 返回空规则
204
+ → text 恒 DEFER → 落到本函数的**非 ACCEPT/REJECT 出口**(cg.propose),
205
+ 正文(含凭据)明文入 hippocampus/inbox.jsonl 且不经脱敏(脱敏只在 REJECT
206
+ 分支)。故策略不可用时在**提案入队之前**返回结构化错误:moved_to=
207
+ "policy_unavailable",响应体只带错误码/原因/hint,**不含正文**。
208
+ """
209
+ a = ctx["a"]
210
+ cg = ctx["cg"]
211
+ from . import audit
212
+ rules, source, perr = audit.resolve_rulebook()
213
+ ctx["policy"] = {"source": source}
214
+ if perr is not None:
215
+ return {"ok": False, "id": ctx["nid"], "committed": False,
216
+ "moved_to": "policy_unavailable",
217
+ "policy": {"source": source}, "error": perr,
218
+ "hint": "写入被拒(fail-closed):策略不可用——%s。%s"
219
+ % (perr["reason"], perr["hint"])}
220
+ payload = {"content": a.get("content", ""), "action": a.get("action"),
221
+ "sensitivity": a.get("sensitivity"),
222
+ "topic": a.get("query") or a.get("intent")}
223
+ if (a.get("content_kind") or "").strip() == "hyperedge":
224
+ # 超边验证器(回放比对)需要锚与结构键:fm 键平铺在 a 顶层,
225
+ # 经 hyperedge.audit_payload 装配三键载荷(缺锚由验证器 fail-closed)。
226
+ from . import hyperedge as _he
227
+ payload = _he.audit_payload(a)
228
+ verdict = audit.audit(
229
+ (a.get("content_kind") or "").strip(),
230
+ payload,
231
+ {"cg": cg, "principal": getattr(cg, "principal", None),
232
+ # 规则来源已在闸门单点解析(含包内默认回落),下传给验证器——
233
+ # 验证器仍保留 `ctx.get("rules") or load_rulebook()` 的兜底。
234
+ "rules": rules})
235
+ ctx["verdict"] = verdict
236
+ st = verdict["state"]
237
+ if st == audit.ACCEPT:
238
+ return None
239
+ if st == audit.REJECT:
240
+ # 先脱敏再截断:命中禁表的凭据不得随负记忆落盘(issue #43);
241
+ # 截断在后,避免凭据跨 200 字边界被截成不再匹配模式的残片而漏过。
242
+ # tags 与正文**同口径脱敏**(PR#44 复核补):正文命中而 tags 夹带凭据时,
243
+ # 原先 tags 原样进负记忆——凭据照样落盘,只是换了个字段。
244
+ tags = a.get("tags")
245
+ if isinstance(tags, (list, tuple)):
246
+ tags = [audit.redact_forbidden(t) if isinstance(t, str) else t for t in tags]
247
+ rid = cg.add_rejected(audit.redact_forbidden(a.get("content") or "")[:200],
248
+ verdict["evidence"],
249
+ verification_basis=verdict.get("basis") or "test",
250
+ tags=tags)
251
+ return {"ok": False, "id": rid, "committed": False,
252
+ "moved_to": "rejected", "verdict": verdict,
253
+ "hint": _reject_hint(verdict)}
254
+ from .mcp_server import _proposal_extras
255
+ pr = cg.propose(ctx["nid"], a.get("content", ""), info=True,
256
+ layer=a.get("layer") or "knowledge",
257
+ tags=a.get("tags"), condition_space=a.get("condition_space"),
258
+ **_proposal_extras(a, verdict))
259
+ out = {"ok": True, "id": ctx["nid"], "pid": pr["pid"], "committed": False,
260
+ "moved_to": "review_queue", "verdict": verdict,
261
+ "hint": "这是校验闸门的正常行为(verdict=%s):内容未达 ACCEPT,"
262
+ "已入审核队列——不需要重试;落盘须经裁决(can_admin 权限)"
263
+ "——agent 可在经蜂群或验证端复核后自行裁决,例外须转使用者"
264
+ "(智能论这类重要协议真源 / 对外发送信息数据 / 可能泄露·病毒·"
265
+ "恶意操纵):python -m md_cg.review_cli list 后 accept/reject,"
266
+ "或 cg(op=review, pid=<pid>, decision=accept|reject|"
267
+ "edit|merge, reason=<理由>)" % verdict.get("state")}
268
+ if pr.get("dedup"):
269
+ out["dedup"] = True
270
+ out["dup_of"] = pr["pid"]
271
+ out["dup_status"] = pr.get("dup_status")
272
+ out["hint"] = (
273
+ "同内容提案已存在(pid=%s,状态=%s,幂等去重),"
274
+ "本次未重复入队——无需重试;落盘须经裁决(can_admin 权限):"
275
+ "python -m md_cg.review_cli list 后 accept/reject,"
276
+ "或 cg(op=review, pid=<pid>, decision=accept|reject|edit|merge, "
277
+ "reason=<理由>)" % (pr["pid"], pr.get("dup_status") or "pending"))
278
+ return out
279
+
280
+
281
+ # 生效条件:ctx["a"]["consistency"] 为假值时返回 None;on_conflict 缺键或假值回落 "defer",仅当 verdict=REJECT 且 on_conflict=reject(返回 moved_to="conflict_rejected")或 verdict∈{REJECT,BLINDSPOT} 且 on_conflict=defer(转 review_queue,去重命中时改写 hint)才拦截,其余 on_conflict 取值返回 None;
282
+ def _gate_consistency(ctx):
283
+ """冲突闸:节点间自动冲突检测(三级决策)。
284
+
285
+ 仅 REJECT(明确判为冲突)按 on_conflict 处置:reject=直接拒绝;
286
+ defer=转入审核队列。BLINDSPOT(无可比对节点,检测前提不存在)恒放行,
287
+ cvd 审计经链尾透出(issue #26:无法比对 ≠ 冲突,入队是死胡同)。
288
+ """
289
+ a = ctx["a"]
290
+ cg = ctx["cg"]
291
+ if not bool(a.get("consistency", True)):
292
+ return None
293
+ from .mcp_server import _proposal_extras
294
+ oc = (a.get("on_conflict") or "defer").strip().lower()
295
+ cvd = cg.check_consistency(
296
+ a.get("content", ""),
297
+ layer=a.get("layer") or ("contextual" if a.get("gated")
298
+ else "knowledge"),
299
+ condition_space=a.get("condition_space"),
300
+ non_applicable_conditions=a.get("non_applicable_conditions"),
301
+ tags=a.get("tags"), exclude=ctx["nid"], auto_flywheel=True)
302
+ ctx["cvd"] = cvd
303
+ v = cvd.get("verdict")
304
+ # issue #26(2026-09-23):BLINDSPOT ≠ REJECT——冲突闸的 BLINDSPOT 唯一出口
305
+ # 是 comparable==0(既有节点无一声明条件,含空库),语义是「检测前提不
306
+ # 存在」而非「已判定冲突」;defer 入队后裁决者面对同样空白(无可操作
307
+ # 下一步,死胡同)。故 BLINDSPOT 恒放行:cvd 经链尾 _executor 的
308
+ # consistency 字段如实透出(放行原因可观测);REJECT(明确冲突)维持
309
+ # 原拦截语义。原先两者等同拦截 → 空库首次写入恒不落盘(README 推荐的
310
+ # content_kind=code 通路必失败——库越空越写不进)。
311
+ blocked = (v == "REJECT" and oc in ("reject", "defer"))
312
+ if not blocked:
313
+ return None
314
+ if oc == "reject":
315
+ return {"ok": False, "id": ctx["nid"], "committed": False,
316
+ "moved_to": "conflict_rejected",
317
+ "consistency": cvd, "verdict": ctx["verdict"]}
318
+ pr = cg.propose(ctx["nid"], a.get("content", ""), info=True,
319
+ layer=a.get("layer") or "knowledge",
320
+ tags=a.get("tags"),
321
+ condition_space=a.get("condition_space"),
322
+ **_proposal_extras(a, ctx["verdict"]))
323
+ out = {"ok": False, "id": ctx["nid"], "pid": pr["pid"],
324
+ "committed": False,
325
+ "moved_to": "review_queue", "consistency": cvd,
326
+ "verdict": ctx["verdict"],
327
+ "hint": "这是冲突闸门的正常行为:本次写入与既有条件/纪律冲突"
328
+ "(on_conflict=defer),已转入审核队列待裁决——"
329
+ "不是工具故障,重试同样结果;"
330
+ "落盘须经裁决(can_admin 权限)——agent 可在经蜂群或验证端"
331
+ "复核后自行裁决,例外须转使用者(智能论这类重要协议真源 / "
332
+ "对外发送信息数据 / 可能泄露·病毒·恶意操纵):"
333
+ "python -m md_cg.review_cli list 后 accept/reject,"
334
+ "或 cg(op=review, pid=<pid>, decision=accept|reject|"
335
+ "edit|merge, reason=<理由>)"}
336
+ if pr.get("dedup"):
337
+ out["dedup"] = True
338
+ out["dup_of"] = pr["pid"]
339
+ out["hint"] = (
340
+ "同内容提案已存在于审核队列(pid=%s,幂等去重),"
341
+ "本次未重复入队——无需重试;"
342
+ "落盘须经裁决(can_admin 权限):"
343
+ "python -m md_cg.review_cli list 后 accept/reject,"
344
+ "或 cg(op=review, pid=<pid>, decision=accept|reject|"
345
+ "edit|merge, reason=<理由>)" % pr["pid"])
346
+ return out
347
+
348
+
349
+ # 生效条件:ctx["a"] 的 gated 为假值时返 None 放行;为真值时按 cg.remember_gated(a.get("sensitivity") 一并透传——同一漏传族,B2)返回的 verdict 落两段式账,且仅 verdict 为 ACCEPT 时 ok/committed 为 True,verdict 为 MERGE 时记 committed 并置 moved_to="merged_into:"+merged_into,verdict 为 DROP/DEFER 时记 aborted 且 moved_to 为其小写值;
350
+ def _gate_gated(ctx):
351
+ """主动遗忘闸(gated=true 时启用):writelimit 限流 + forgetting 三问四态。
352
+
353
+ 本闸是「替代执行路径」:命中即由 remember_gated 代为落盘/合并/丢弃并
354
+ 返回终态;未启用(gated 假值)放行给链尾执行器。
355
+ """
356
+ a = ctx["a"]
357
+ cg = ctx["cg"]
358
+ if not a.get("gated"):
359
+ return None
360
+ hint = a.get("importance_hint")
361
+ if hint is None and a.get("importance") is not None:
362
+ hint = float(a["importance"])
363
+ # ③ 两段式:本闸是**替代执行路径**(自己落盘),意图必须由它先记——
364
+ # 若等 execute 在链后统一记,intent 会晚于本闸内部的写盘,「先行持久化」
365
+ # 就不成立了。落盘前的窗口因此仍然被账本覆盖。
366
+ tok = twophase.begin(cg, ctx["nid"], a.get("content", ""),
367
+ layer=a.get("layer") or "contextual",
368
+ actor="writepipe:gated")
369
+ res = cg.remember_gated(
370
+ ctx["nid"], a.get("content", ""), layer=a.get("layer") or "contextual",
371
+ # B2(2026-09-30):同一漏传族——gated 分支也是**落盘路径**
372
+ # (remember_gated → add),不透传则声明 private 在此静默降级 internal。
373
+ sensitivity=a.get("sensitivity"),
374
+ role=a.get("role"), tags=a.get("tags"),
375
+ condition_space=a.get("condition_space"),
376
+ verification_basis=(a.get("verification_basis")
377
+ or (ctx.get("verdict") or {}).get("basis")),
378
+ non_applicable_conditions=a.get("non_applicable_conditions"),
379
+ importance_hint=hint, override=bool(a.get("override")),
380
+ consistency=False,
381
+ derived_from=_split_ids(a.get("derived_from")),
382
+ relation=a.get("relation"))
383
+ v = res.get("verdict")
384
+ committed = v == "ACCEPT"
385
+ # 结局如实记:ACCEPT=落盘完成;MERGE=内容并入既有节点(不再以本次内容成
386
+ # 文,指纹对账不适用,故直接记 committed 并注明去向);DROP/DEFER=未落盘。
387
+ if committed:
388
+ twophase.commit(cg, tok, status=twophase.STATUS_COMMITTED,
389
+ reason="gated_accept")
390
+ elif v == "MERGE":
391
+ twophase.commit(cg, tok, status=twophase.STATUS_COMMITTED,
392
+ reason="merged_into:%s" % res.get("merged_into"))
393
+ else:
394
+ twophase.commit(cg, tok, status=twophase.STATUS_ABORTED,
395
+ reason="gated_%s" % str(v).lower())
396
+ out = {"ok": committed, "id": ctx["nid"], "committed": committed,
397
+ "gate": res, "verdict": ctx["verdict"]}
398
+ if ctx.get("cvd") is not None:
399
+ out["consistency"] = ctx["cvd"]
400
+ if v == "MERGE":
401
+ out["moved_to"] = "merged_into:" + str(res.get("merged_into"))
402
+ elif v in ("DROP", "DEFER"):
403
+ out["moved_to"] = v.lower()
404
+ # gated 是**替代落盘路径**(自行落盘/合并后直接返回终态、不跑 after 链),故
405
+ # 一跳同步传播须在此单独触发——否则 MERGE 类覆写会漏传下游(非对称边界,
406
+ # 与 `_after_trust` 注释互指)。
407
+ if committed or v == "MERGE":
408
+ prop = trust.mark_dependents(
409
+ cg, ctx["nid"],
410
+ reason="上游节点被 gated 写入/合并(内容或验证态可能已变)",
411
+ actor="writepipe:gated", trigger="write_gated")
412
+ if isinstance(prop, dict) and prop.get("changed"):
413
+ out["propagation"] = {"changed": prop.get("changed"),
414
+ "updated": (prop.get("updated") or [])[:10]}
415
+ return out
416
+
417
+
418
+ # 生效条件:ctx["a"]["content"] 的「# 子功能:」行含显式跨节点引用(`@<节点 id>`)且 depends_on 解析为空时返回 ok=False/error="E050" 的终态;depends_on 含库中不存在的 id 时返回 ok=False/error="E051" 的终态;其余(无该行 / 哨兵 / 自然语言自述 / 声明且目标齐备)返回 None 放行;
419
+ def _gate_deps(ctx):
420
+ """依赖声明闸(before 链:linkref 之后、audit 之前):**硬拒条件缺失**。
421
+
422
+ 「声明」的界定(收窄裁定 b,2026-09-19):以 `@<节点 id>` 显式引用为界——
423
+ 自然语言**自述子功能**(描述本单元**内部**构成)不构成依赖声明。原因:CCG
424
+ 编译产物六要素必含「子功能」行,若沿用「非哨兵即声明」,每个 CCG 节点落库后
425
+ 都会被自己的闸门永久要求 depends_on(E050 死锁,test_ccgc V16f 实证)。
426
+ 依赖不是必填元数据;**只有显式声称依赖却不落字段**才是违规(声称与落盘不一致)。
427
+
428
+ 为何是硬拒而非告警:依赖是失效传播的**唯一入口**。声明缺失时,「上游变了
429
+ 下游要存疑」这条链从源头就不存在——它既不报错、也不留任何信号,缺陷以
430
+ 「静默不传播」的形态长期存活(比报错更难发现)。故按契约缺失处理,与
431
+ `ccgc` 的 E 码体系同构(E050 声明缺失 / E051 目标不存在)。
432
+
433
+ 张力消解(与 provenance「写路径永不阻断」纪律的边界,二者不冲突):
434
+ - **声明缺失 / 目标不可解析 = 契约违规** → 硬拒(本闸只做这件事);
435
+ - **传播落盘失败 = 运维降级** → 告警不阻断(见 `_after_trust`)。
436
+
437
+ 判据基于**入参**而非落盘后回读:首次写入时节点尚不存在,回读式校验会
438
+ 永远放行(等于闸门失效)。
439
+ """
440
+ a = ctx["a"]
441
+ cg = ctx["cg"]
442
+ from . import nodefile
443
+ if not nodefile.declares_dependency(a.get("content") or ""):
444
+ return None
445
+ deps = trust.as_deps(a.get("depends_on"))
446
+ if not deps:
447
+ return {"ok": False, "id": ctx["nid"], "committed": False,
448
+ "gate": "deps", "error": "E050",
449
+ "verdict": ctx.get("verdict"),
450
+ "hint": ("依赖声明缺失(E050):正文以 " + nodefile.DEP_REF_MARK
451
+ + "<节点 id> 显式声明了跨节点依赖(「# 子功能:」行),"
452
+ "但 depends_on 未给出可解析目标。依赖必须是**可解析的字段**"
453
+ "(形如 depends_on=[\"<被依赖节点 id>\"]),不能只是散文——"
454
+ "否则被依赖单元变动时,下游无处可传。补齐后重试;"
455
+ "若该行只是描述本单元内部构成(自述),去掉 "
456
+ + nodefile.DEP_REF_MARK + " 引用或改填「无」即可。"
457
+ "本闸是契约闸门的正常行为,不是工具故障。")}
458
+ known = set((getattr(cg, "index", None) or {}).get("nodes") or {})
459
+ missing = [d for d in deps if d not in known]
460
+ if missing:
461
+ return {"ok": False, "id": ctx["nid"], "committed": False,
462
+ "gate": "deps", "error": "E051", "missing": missing[:10],
463
+ "verdict": ctx.get("verdict"),
464
+ "hint": "依赖目标不存在(E051):depends_on 指向 "
465
+ + ", ".join(missing[:5])
466
+ + ",但库中查无此节点——声称依赖一个并不存在的地基,"
467
+ "失效传播会在此处断链。请先建立被依赖节点,或修正 id。"}
468
+ return None
469
+
470
+
471
+ # 生效条件:out 为 dict 且 out["committed"] 为真时,调 trust.mark_dependents 做一跳同步传播(异常吞掉并降级),并在节点时间轴非「时效内」时往 out 写 "validity" 提示;其余情况直接返回不做任何动作;
472
+ def _after_trust(ctx, out):
473
+ """after 观察者:落盘后触发**一跳同步传播** + 时效提示。
474
+
475
+ 为何落在 after 而非 before:只有真正落盘(内容确实变了)才构成「地基动了」;
476
+ before 链短路路径(REJECT / DEFER)本就不跑 after 链,语义天然正确。
477
+ 例外:`gated` 是替代落盘路径(自行落盘并返回终态、不跑 after),故它的
478
+ 传播在 `_gate_gated` 内部单独触发(见该处注释)。
479
+
480
+ **永不抛**:传播失败只降级(`trust.mark_dependents` 内部已兜底并写台账),
481
+ 绝不把「写入已成功」改写为失败——与 `_commit_visibility` 同款边界。
482
+ """
483
+ if not isinstance(out, dict) or not out.get("committed"):
484
+ return
485
+ cg = ctx["cg"]
486
+ nid = ctx.get("nid")
487
+ if not nid:
488
+ return
489
+ prop = trust.mark_dependents(
490
+ cg, nid, reason="上游节点被写入/覆写(内容或验证态可能已变)",
491
+ actor="writepipe:trust", trigger="write")
492
+ if isinstance(prop, dict) and prop.get("changed"):
493
+ out["propagation"] = {"changed": prop.get("changed"),
494
+ "updated": (prop.get("updated") or [])[:10]}
495
+ # 热路径失效(2026-09-19 热温冷分层):写入后清缓存
496
+ try:
497
+ from . import hotcache as _hc
498
+ _hc.invalidate(cg, nid)
499
+ except Exception: # noqa: BLE001
500
+ pass # 缓存失效失败不阻断写入(永不抛)
501
+
502
+ # 冷路径入队(2026-09-19 热温冷分层):异步深度验证
503
+ try:
504
+ from . import coldverify as _cv
505
+ _cv.enqueue(cg, nid, action="reverify",
506
+ reason="writepipe:after_trust")
507
+ except Exception: # noqa: BLE001
508
+ pass # 入队失败不阻断写入
509
+
510
+
511
+ # 生效条件:a 的 content_kind 非 hyperedge 即返回 {},为 hyperedge 时延迟导入
512
+ # hyperedge.EXTRA_FM_KEYS 收集 a 中非 None 的对应键返回(其余写入零键透传);
513
+ def _hyperedge_extra(a):
514
+ """hyperedge 写入的 fm 扩展键透传。计划决策 2:超边写入走 write 动词既有
515
+ 审核链、不造新通道——链尾执行器是唯一落盘点,cg.add 经 **extra 落 fm,
516
+ 故扩展键在此透传;落盘面丢字段 = 上游声明静默失效。延迟导入防循环依赖。"""
517
+ if (a.get("content_kind") or "").strip() != "hyperedge":
518
+ return {}
519
+ from . import hyperedge as _he
520
+ return {k: a[k] for k in _he.EXTRA_FM_KEYS if a.get(k) is not None}
521
+
522
+
523
+ # 生效条件:由链尾以含 cg 与 a 的 ctx 调用即无条件执行 cg.add 落盘(a.get("sensitivity") 一并透传——落盘面丢字段=上游声明静默失效,B2)并返回 ok=True/committed=True,ctx["cvd"] 非 None 时附加 consistency 字段;落盘前另取「同内容已存在」与「覆写既有同 id 节点」两个读数(P-9b ⑥),命中即在返回体附 dup_of/dup_ratio/dup_compared/dup_hint 与 overwrite_of/overwrite_ratio——**只加提示,不改落盘行为、不改 verdict**;
524
+ def _executor(ctx):
525
+ """链尾执行器(常驻不可卸载):cg.add 直写落盘。
526
+
527
+ 角色/层权限校验(require_layer_write)在 MdCGSecure.add 库层内部,
528
+ 结构上不可被拦截器绕过——拦截器只能裁决「写不写」,改不了「谁能写」。
529
+ """
530
+ a = ctx["a"]
531
+ cg = ctx["cg"]
532
+ # importance 显式 null(JSON null→None)时 get 的缺省值不生效,直接
533
+ # float(None) 抛 TypeError 崩主写路径——回退默认 0.5(与 add 缺省同口径);
534
+ # 0 / 0.0 等合法 falsy 数值照传(_gate_gated :304 已是同款 None 判定)。
535
+ _imp = a.get("importance")
536
+ # ⑥(P-9b):本执行器是**直写落盘点**之一(另一处 = mcp_server 的
537
+ # mdcg_remember 非 gated 分支)。直写不去重是文档化现状(writelimit.py
538
+ # 模块头注 :9-12:限流/同构聚合只作用 contextual 层,knowledge 等手动纪律
539
+ # 写入不受限)——故**不改落盘行为、不改 verdict**,只在返回体补
540
+ # 「同内容已存在」(dup_of/dup_ratio)与「本次是覆写」(overwrite_of/
541
+ # overwrite_ratio)两个读数。判据复用 forgetting 的同一实现
542
+ # (redundancy/prior_node/self_coverage);两个读数都必须在 cg.add **之前**
543
+ # 取(add 后索引必有 nid:覆写判据恒真、覆盖度恒 1.0)。
544
+ from . import forgetting as _forgetting
545
+ _content = a.get("content", "")
546
+ _prior = _forgetting.prior_node(cg, ctx["nid"])
547
+ _prior_cov = (_forgetting.self_coverage(cg, _prior, _content)
548
+ if _prior is not None else None)
549
+ _dup = _forgetting.redundancy(cg, _content,
550
+ layer=a.get("layer") or "knowledge",
551
+ exclude=ctx["nid"])
552
+ cg.add(ctx["nid"], _content,
553
+ # B2(2026-09-30):密级透传。此前本实参表**缺 sensitivity**,而
554
+ # 同文件 _gate_audit 的 payload(:219)带着它交给审核闸——两面口径
555
+ # 分叉:审核闸按调用方声明的密级判,落盘闸按 DEFAULT_SENSITIVITY
556
+ # 回落 internal,声明 private 的正文以明文 + fm internal 落盘
557
+ # (纯漏传,非设计取舍)。透传即修好,**不得**在此自行 _seal_content
558
+ # (会绕过 fm 与 _write_node 的单一密封点)。
559
+ sensitivity=a.get("sensitivity"),
560
+ layer=a.get("layer") or "knowledge",
561
+ tags=a.get("tags"), condition_space=a.get("condition_space"),
562
+ importance=0.5 if _imp is None else float(_imp),
563
+ verification_basis=a.get("verification_basis")
564
+ or (ctx.get("verdict") or {}).get("basis"),
565
+ non_applicable_conditions=a.get("non_applicable_conditions"),
566
+ override=bool(a.get("override")), consistency=False,
567
+ derived_from=_split_ids(a.get("derived_from")),
568
+ relation=a.get("relation"),
569
+ # 可验证记忆单元(裁定 D):依赖/双时间轴/验证态随写入落 fm。
570
+ # 透传而非丢弃——落盘面丢字段=上游声明静默失效(比报错难发现)。
571
+ depends_on=trust.as_deps(a.get("depends_on")),
572
+ valid_from=a.get("valid_from"), valid_until=a.get("valid_until"),
573
+ verification_state=a.get("verification_state"),
574
+ **_hyperedge_extra(a))
575
+ out = {"ok": True, "id": ctx["nid"], "committed": True,
576
+ "verdict": ctx["verdict"]}
577
+ if ctx.get("cvd") is not None:
578
+ out["consistency"] = ctx["cvd"]
579
+ # ⑥:直写提示(不改落盘行为、不改 verdict;见本函数开头注释)
580
+ if _prior is not None:
581
+ out["overwrite_of"] = _prior
582
+ out["overwrite_ratio"] = _prior_cov
583
+ if _dup["with"] and _dup["max"] >= _forgetting.DUP_MERGE:
584
+ out["dup_of"] = _dup["with"]
585
+ out["dup_ratio"] = round(_dup["max"], 4)
586
+ out["dup_compared"] = _dup["compared"]
587
+ out["dup_hint"] = (
588
+ "同内容已存在于 %s(覆盖度 %.2f≥%.2f);本路径是直写(gated=false:"
589
+ "knowledge 层手动纪律写入不受限流/去重约束,见 writelimit.py 模块头注),"
590
+ "正文已按原样落盘——如需并入既有节点请显式处理"
591
+ % (_dup["with"], _dup["max"], _forgetting.DUP_MERGE))
592
+ return out
593
+
594
+
595
+ # 生效条件:value 传入即无条件延迟导入并转调 mcp_server._split_ids 后原样返回其结果(本符号无自身分支);
596
+ def _split_ids(value):
597
+ # 与 mcp_server._split_ids 同源(延迟导入,单一真源)
598
+ from .mcp_server import _split_ids as _f
599
+ return _f(value)
600
+
601
+
602
+ # --------------------------------------------------------------------------
603
+ # 进程级默认链(单例;mcp_server op=write 一行分发到此)
604
+ # --------------------------------------------------------------------------
605
+
606
+ _DEFAULT = None
607
+
608
+
609
+ # 生效条件:pipe 传入即对其依次注册 before 的 linkref(position=0)/deps/audit/consistency/gated 与 after 的 linkref/trust(同名幂等替换),并返回同一 pipe;
610
+ def install_default_gates(pipe):
611
+ """把默认闸以拦截器形态注册(幂等:同名替换,可重复调用)。
612
+
613
+ 链序(2026-09-19 起):
614
+ before = linkref(解析) → deps(依赖声明) → audit → consistency → gated
615
+ → 链尾执行器
616
+ after = linkref(建边) → trust(一跳传播)
617
+
618
+ linkref 置于链首的理由:正文引用解析是**纯读、无副作用**,且其结果必须
619
+ 先于任何短路闸写入 ctx,供 after 链消费。短路闸(REJECT/DEFER/gated)
620
+ 返回终态时 `execute` 不跑 after 链,故未落盘的写入不会建边——语义正确。
621
+
622
+ deps 夹在 linkref 与 audit 之间的理由:两件事都与内容政策无关,故在 audit
623
+ 之前;linkref 之后是因为它只解析正文引用、不动依赖声明——依赖是**字段域**
624
+ 而非正文域,顺序倒置不会互相污染,但保持「解析在前、裁决在后」的一致读序。
625
+
626
+ after 的 trust 置于 linkref 之后:建边先于传播——传播按 `depends_on`
627
+ 反查(字段域)而非边域,故顺序不影响正确性;置于其后只为让 ctx 中的
628
+ 边信息先落定,便于排障时读 ctx。
629
+
630
+ linkref 的**落点是 after 而非注入 `a["edges"]`**:`cg.add` 是全量重建
631
+ fm,注入 edges 会在覆写既有节点时清空其原有边(破坏性副作用);
632
+ `append_edge` 是边域窄原语且幂等(见 linkref 模块 docstring)。
633
+ 依赖声明同忌经 `a["edges"]` 注入——走 `depends_on` 字段域。
634
+ """
635
+ from . import linkref
636
+ pipe.register_before("linkref", linkref.before_hook(), position=0)
637
+ pipe.register_before("deps", _gate_deps)
638
+ pipe.register_before("audit", _gate_audit)
639
+ pipe.register_before("consistency", _gate_consistency)
640
+ pipe.register_before("gated", _gate_gated)
641
+ pipe.register_after("linkref", linkref.after_hook())
642
+ pipe.register_after("trust", _after_trust)
643
+ return pipe
644
+
645
+
646
+ # 生效条件:模块级 _DEFAULT 为 None 时新建 WritePipeline 并经 install_default_gates 注册后缓存返回,否则直接返回已缓存的 _DEFAULT 单例;
647
+ def default_pipeline():
648
+ """进程级默认写入链(单例)。自定义闸门 register_before 即插即拔。"""
649
+ global _DEFAULT
650
+ if _DEFAULT is None:
651
+ _DEFAULT = install_default_gates(WritePipeline())
555
652
  return _DEFAULT