@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
package/md_cg/protect.py CHANGED
@@ -40,12 +40,18 @@ import time
40
40
 
41
41
  from . import nodefile
42
42
  from .fsutil import append_jsonl, atomic_write
43
+ from .security import DEFAULT_SENSITIVITY
43
44
 
44
45
  PROTECTED_LAYERS = ("self", "anchor")
45
46
  AUTO_PROTECT_IMPORTANCE = 0.70
46
47
  HISTORY_DIR = "_protected_history"
47
48
  AUDIT_FILE = "_protected_audit.jsonl"
48
49
 
50
+ # 索引条目里的**门控三键**(键集单点定义):`_node_entry`(mdcg.py:1294-1298)与
51
+ # `_stage`(mdcg.py:1848-1851)恒落这三键,值可为 None ⇒「键不存在」不是
52
+ # 「未标记」的可靠判据(N213 同根,2026-09-28)。None = **索引未记录该标记**。
53
+ _GATE_UNKNOWN_KEYS = ("protected", "immutable", "self_state")
54
+
49
55
 
50
56
  class ProtectionError(PermissionError):
51
57
  """受保护节点的写动作被拒绝。"""
@@ -53,14 +59,32 @@ class ProtectionError(PermissionError):
53
59
 
54
60
  # ---------------------------------------------------------------- 判定
55
61
 
56
- # 生效条件:对任意 cg、node_id 无条件返回 `(cg.index 或其假值时的 {})["nodes"]`(该键缺失或假值时为 `{}`)中以 node_id 为键的值,索引无此键时返回 None。
62
+ # 生效条件:cg 具备可调用的 _maybe_reload_index 时先调一次(索引代际探活,异常静默);随后对任意 cg、node_id 返回 `(cg.index 或其假值时的 {})["nodes"]`(该键缺失或假值时为 `{}`)中以 node_id 为键的值,索引无此键时返回 None。
57
63
  def _entry(cg, node_id):
64
+ """索引条目读取单点(判定前**代际探活**)。
65
+
66
+ N213 同根(2026-09-28):保护面的全部判据(_fm / is_protected / is_immutable
67
+ / guard_write / guard_forget / guard_move / stats)都经由本函数直读**本进程
68
+ 内存索引**,而写面只有 `MdCG.add`(mdcg.py:1508)接了探活——删除(forget)、
69
+ 降级搬迁(_move_layer)、统计与角色视图等出口全都没有。他进程(serve 常驻、
70
+ autoflush=1 只 flush 不 close)新盖的 immutable/protected 在陈旧条目上不存在
71
+ → 判定「未标记」→ 受保护节点被静默覆写/删除/搬迁,无快照无审计。
72
+ 探活放在这里 = 保护面一次接线、全部出口同闸(签名未变时只有一次 stat +
73
+ 一次 listdir;重载是稀疏事件)。同理 `_resolve_target` 的探活在本函数外层
74
+ 成了冗余的一次廉价检查,保留不动。
75
+ """
76
+ _reload = getattr(cg, "_maybe_reload_index", None)
77
+ if callable(_reload):
78
+ try:
79
+ _reload()
80
+ except Exception: # noqa: BLE001 —— 探活失败不得阻断判定本身
81
+ pass
58
82
  return ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id)
59
83
 
60
84
 
61
- # 生效条件:cg 索引中无 node_id 条目时返回 None;有条目时先取 layer/importance(缺 importance 键回落 0.5)/protected/protection_reason/immutable/self_state,仅当条目缺 protected 或 immutable、或(缺 self_state 且条目 layer∈PROTECTED_LAYERS)时再经 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,仅当条目缺 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)。
62
86
  def _fm(cg, node_id):
63
- """取判定所需的 frontmatter 字段;索引快照缺 protected 时回退读文件。"""
87
+ """取判定所需的 frontmatter 字段;索引快照门控字段**未知**时回退读文件。"""
64
88
  e = _entry(cg, node_id)
65
89
  if e is None:
66
90
  return None
@@ -74,9 +98,23 @@ def _fm(cg, node_id):
74
98
  }
75
99
  # 回退读文件:索引快照缺字段时。self_state 只在受保护层(self/anchor)
76
100
  # 需要,回退代价被限制在少量节点上,不影响全量统计性能。
101
+ _layer = str(e.get("layer") or "")
77
102
  need_fallback = ("protected" not in e or "immutable" not in e
78
103
  or ("self_state" not in e
79
- and str(e.get("layer") or "") in PROTECTED_LAYERS))
104
+ and _layer in PROTECTED_LAYERS))
105
+ # N213 同根(2026-09-28):`_node_entry`/`_stage` **恒落**这三键(值可为
106
+ # None)⇒ 上面的「缺键」判据在本仓所有构造点恒假、第四个析取子句不可达,
107
+ # `_fm` 100% 信任索引、**从不重读文件**;索引里的 None 被当「未标记」采信,
108
+ # 而「只改文件、不进索引写日志」的门控写点(`protect.mark` 一类)在陈旧
109
+ # 条目里正是 None → guard_write/guard_forget/guard_move 静默放行。
110
+ # 值为 None 即「索引未记录该标记」,唯一权威是文件——但读盘有代价,
111
+ # 只在**受保护层**(self/anchor,节点数极少;层保护本身已拦 不可覆盖/
112
+ # 不可遗忘,None 决定的是 self_state 豁免与层间搬迁的细粒度判定)无条件
113
+ # 回退;非受保护层由「门控写点必须进写日志」承担(`mark` 已随本批接线,
114
+ # `add(immutable=True)` 本就经 _stage),故不付全池读盘代价。
115
+ if not need_fallback and _layer in PROTECTED_LAYERS \
116
+ and any(e.get(_k) is None for _k in _GATE_UNKNOWN_KEYS):
117
+ need_fallback = True
80
118
  if need_fallback:
81
119
  try:
82
120
  node = cg.get(node_id)
@@ -148,9 +186,19 @@ def _audit(cg, action, node_id, reason, actor=None, snapshot=None):
148
186
  return rec
149
187
 
150
188
 
151
- # 生效条件:cg.get(node_id) 抛异常或返回假值时返回 None;否则在 cg.root/HISTORY_DIR/node_id 下以时间戳命名写入当前 frontmatter 与 content,_write_node 抛异常时返回 None,成功则返回相对 cg.root 且以 '/' 分隔的路径。
189
+ # 生效条件:cg.get(node_id) 抛异常或返回假值时返回 None;否则在 cg.root/HISTORY_DIR/node_id 下以时间戳命名写入当前 frontmatter 与 content,_write_node 抛异常时返回 None,成功则追加一条 action="snapshot" 的审计(_audit,actor 取 cg.actor)并返回相对 cg.root 且以 '/' 分隔的路径。
152
190
  def snapshot(cg, node_id):
153
- """把节点当前版本快照进 `_protected_history/<id>/<ts>.md`,返回相对路径。"""
191
+ """把节点当前版本快照进 `_protected_history/<id>/<ts>.md`,返回相对路径。
192
+
193
+ N228(2026-09-28):本函数此前**全文零审计**——而它是**落盘写入**(写
194
+ `_protected_history/`,且这些快照是后续 override 快照链的基线)。经工具面
195
+ `mdcg_protect(action="snapshot")`(此前只要求粗粒度 write)任何 can_write
196
+ 角色都能在自己可读的节点上制造未审计磁盘写入(实测 `_audit.jsonl` 条数
197
+ 8→8 不变)。本批两条收口:① 工具面 op 要求对齐规范出口(protect 面在
198
+ `cg(op="protect")` 上级为 designer 专属);② 快照成功即留痕(本函数内,
199
+ 两条入口——工具面与 `guard_*` 的 `_allow`——都覆盖;`_allow` 路径会有
200
+ 「snapshot + 具体守卫动作」两条审计,属如实记录而非重复计数)。
201
+ """
154
202
  try:
155
203
  node = cg.get(node_id)
156
204
  except Exception:
@@ -166,7 +214,10 @@ def snapshot(cg, node_id):
166
214
  node.get("content") or "")
167
215
  except Exception:
168
216
  return None
169
- return os.path.relpath(p, cg.root).replace("\\", "/")
217
+ rel = os.path.relpath(p, cg.root).replace("\\", "/")
218
+ _audit(cg, "snapshot", node_id, "显式快照(%s)" % rel,
219
+ actor=getattr(cg, "actor", None), snapshot=rel)
220
+ return rel
170
221
 
171
222
 
172
223
  # 生效条件:cg.root/HISTORY_DIR/node_id 不是目录时返回 [];是目录时返回该目录下以 .md 结尾(不递归)的文件按名称排序后的 `HISTORY_DIR/node_id/文件名` 列表,无匹配文件则列表为空。
@@ -231,15 +282,96 @@ def guard_move(cg, node_id, to_layer, override=False, actor=None):
231
282
  f"节点 {node_id} 受写保护({why});降级移出保护层需显式 override=True")
232
283
 
233
284
 
234
- # 生效条件:cg.get(node_id) 抛异常或返回假值时返回 None;否则把 protected=True 与 protection_reason=reason 写入 cg.root 下 node["path"](该键缺失即抛 KeyError)对应的 frontmatter 并保持原 content,随后门条目存在时同步其 protected/protection_reason,返回 {'node_id': node_id, 'protected': True, 'reason': reason}。
285
+ # 生效条件:cg 具备可调用的 _maybe_reload_index 时先调一次;层取形参 layer、索引条目 layer、节点 frontmatter layer 中首个真值(皆假值回落 "knowledge",与 require_layer_write 缺省同口径);敏感度取形参 sensitivity、索引条目 sensitivity、节点 frontmatter sensitivity 中首个真值(皆假值回落 security.DEFAULT_SENSITIVITY);返回 (层, 敏感度) 二元组。
286
+ def _resolve_target(cg, node_id, layer=None, sensitivity=None):
287
+ """既有节点写面的「层 / 敏感度」解析**单点**(不设第二份口径)。
288
+
289
+ 索引代际探活(N195 同族):写面直读本进程内存索引,他进程刚写入/搬迁的节点
290
+ 在本进程索引中不存在、或层位陈旧 → 解析出的层不是真层,层闸会**静默失效**
291
+ (「越权改 knowledge」被当成「同层正当写」放行)。故进入判定前先探活一次
292
+ (签名未变时只有一次 stat)。
293
+ """
294
+ _reload = getattr(cg, "_maybe_reload_index", None)
295
+ if callable(_reload):
296
+ _reload()
297
+ e = _entry(cg, node_id) or {}
298
+ _layer = layer or e.get("layer")
299
+ _sens = sensitivity or e.get("sensitivity")
300
+ if not _layer or not _sens:
301
+ try:
302
+ node = cg.get(node_id)
303
+ except Exception: # noqa: BLE001 —— 读面失败不阻断判据本身
304
+ node = None
305
+ if node:
306
+ f = node.get("frontmatter") or {}
307
+ _layer = _layer or f.get("layer")
308
+ _sens = _sens or f.get("sensitivity")
309
+ return _layer or "knowledge", _sens or DEFAULT_SENSITIVITY
310
+
311
+
312
+ # 生效条件:经 _resolve_target 解析出节点真层与敏感度后,cg.principal 非 None 且具备 require_layer_write 时调 principal.require_layer_write(layer, sensitivity)(越权抛 AccessDenied),返回解析出的层;cg 无 principal(裸 MdCG)时不做任何判定。
313
+ def require_layer(cg, node_id, layer=None, sensitivity=None, actor=None):
314
+ """既有节点写面的 **principal 层闸**单点(不含引擎级保护闸)。
315
+
316
+ N209(2026-09-28,同族未接线的相邻写面入口):「只有 `falsified` 一态接了
317
+ 层闸」之外的三条写面全程只认管理位/保护位、**不认层白名单**——
318
+ `MdCGSecure.verify` 的 confirmed/weakened 分支直写被验证节点本体
319
+ (`md_cg/mdcg.py:3502`)、`trust.set_state`(验证态唯一推进入口 ⇒ 依赖者
320
+ `mark_dependents` 与 `set_verification` 两条写路,`md_cg/trust.py:697-698`)、
321
+ `MdCG._move_layer`(降级搬迁 = 源层一次删除写 + 目标层一次新增写,
322
+ `md_cg/mdcg.py:3405`)。后果:持 verify 令牌(`layers_allow` 仅
323
+ rejected/contextual、forbidden 明列「knowledge/self/anchor 层」)即可改写
324
+ knowledge 层节点本体、把 self 层依赖者置 doubted、把 knowledge 节点搬出层。
325
+ 层闸口径与 `MdCGSecure.add`/`add_rejected` 一致(`md_cg/mdcos.py:3889`/`:3898`)。
326
+ """
327
+ _layer, _sens = _resolve_target(cg, node_id, layer=layer,
328
+ sensitivity=sensitivity)
329
+ p = getattr(cg, "principal", None)
330
+ if p is not None and hasattr(p, "require_layer_write"):
331
+ p.require_layer_write(_layer, _sens)
332
+ return _layer
333
+
334
+
335
+ # 生效条件:先经 require_layer(cg, node_id, layer, sensitivity) 做 principal 层闸(越权抛 AccessDenied),再委托 guard_write(cg, node_id, layer=解析层, override=override, actor=actor) 并返回其结果。
336
+ def guard_overwrite(cg, node_id, layer=None, sensitivity=None,
337
+ override=False, actor=None):
338
+ """既有节点**覆写**前的统一双闸:principal 层写权限 + 引擎级写保护。
339
+
340
+ N197/N208(2026-09-28):「同一身份对**同层**的 `add` 已被
341
+ `require_layer_write` 拒绝,但直调 `cg._write_node` 的写面照样落盘」——
342
+ 层闸被同一库的两条出口口径不一致地绕开。与 N131(review 队列 merge 面)
343
+ 同序同错型:principal 层写闸在先(对照 `MdCGSecure.add` :3889),引擎级
344
+ `guard_write` 在后(对照 `MdCG.add` :1509)。任何覆写**既有节点**的写面都
345
+ 必须先过这里,否则 self/anchor 层与 immutable 节点被无痕覆写:不抛错、不落
346
+ `_protected_history` 快照、不写 `_protected_audit.jsonl`。
347
+
348
+ 索引代际探活(N195 同族):写面直读本进程内存索引,他进程刚置的保护位在本
349
+ 进程索引中不存在 → `is_immutable` 的 `_entry` 得 None → 判 False,两道闸
350
+ 会**同时静默失效**。故进入判定前先探活一次(签名未变时只有一次 stat);
351
+ 解析与层闸由 `require_layer` 同一单点承担。
352
+ """
353
+ _layer = require_layer(cg, node_id, layer=layer, sensitivity=sensitivity)
354
+ return guard_write(cg, node_id, layer=_layer, override=override, actor=actor)
355
+
356
+
357
+ # 生效条件:cg.get(node_id) 抛异常或返回假值时返回 {'ok': False, 'error': 'node_not_found', 'node_id': node_id}(负路由,形态对齐 trust.set_state:692);否则把 protected=True 与 protection_reason=reason 写入 cg.root 下 node["path"](该键缺失即抛 KeyError)对应的 frontmatter 并保持原 content,随后门条目存在时同步其 protected/protection_reason 并把该条目并入索引写日志(_dirty 标脏 + flush,失败静默),返回 {'node_id': node_id, 'protected': True, 'reason': reason}。
235
358
  def mark(cg, node_id, reason):
236
- """给节点打上 `protected=True` 标记(写回 frontmatter,不动 content)。"""
359
+ """给节点打上 `protected=True` 标记(写回 frontmatter,不动 content)。
360
+
361
+ 节点不存在时返回**负路由** `{"ok": False, "error": "node_not_found",
362
+ "node_id": node_id}`(与 `trust.set_state` 的不存在分支逐键同形),
363
+ 不再裸返回 None(H9④ 前):None 与「成功」在调用方眼里都非 dict,
364
+ `mcp_server._protect_call` 直接把它序列化成 `null` 回给 MCP 客户端
365
+ ——不存在的 node_id 被读成「打标成功」,而 `_write_node` 从未发生。
366
+ 调用方分流:失败看 `r.get("ok") is False` / `"error" in r`,
367
+ 成功路径的返回键**不变**(node_id / protected / reason,无 ok 键)。
368
+ """
237
369
  try:
238
370
  node = cg.get(node_id)
239
371
  except Exception:
240
372
  node = None
241
373
  if not node:
242
- return None
374
+ return {"ok": False, "error": "node_not_found", "node_id": node_id}
243
375
  fm = node.get("frontmatter") or {}
244
376
  fm["protected"] = True
245
377
  fm["protection_reason"] = reason
@@ -249,6 +381,21 @@ def mark(cg, node_id, reason):
249
381
  if e is not None:
250
382
  e["protected"] = True
251
383
  e["protection_reason"] = reason
384
+ # N213 同根(2026-09-28):保护位是**门控字段**,只改内存条目 + 文件而
385
+ # 不进索引写日志 ⇒ 他进程(以及本进程 compact 前的重载)把该节点当
386
+ # 「未标记」→ guard_forget / guard_move 静默放行。与 add 同口径:
387
+ # `_dirty[nid] = e` 标脏(**不走 _stage**——它会重复累加该桶计数)
388
+ # 并 flush,使日志成为跨进程可见的代际载体。日志化失败不阻断打标本身
389
+ # (文件已改,下一次 compact/rebuild 的 _scan_nodes 会带上该标记)。
390
+ _dirty = getattr(cg, "_dirty", None)
391
+ if isinstance(_dirty, dict):
392
+ _dirty[node_id] = e
393
+ _flush = getattr(cg, "flush", None)
394
+ if callable(_flush):
395
+ try:
396
+ _flush()
397
+ except Exception: # noqa: BLE001 —— 落账失败不阻断打标
398
+ pass
252
399
  return {"node_id": node_id, "protected": True, "reason": reason}
253
400
 
254
401
 
@@ -257,7 +404,7 @@ def stats(cg):
257
404
  """保护面盘点:不可遗忘数 / 不可覆盖数 / 分层分布 / 自动保护命中数。"""
258
405
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
259
406
  by_layer, ids, auto, immutable = {}, [], 0, []
260
- for nid in nodes:
407
+ for nid in list(nodes):
261
408
  prot, why = is_protected(cg, nid)
262
409
  if prot:
263
410
  ids.append(nid)
package/md_cg/protocol.py CHANGED
@@ -11,6 +11,13 @@
11
11
 
12
12
  范围(先行收窄):route / read / write / supersede / forget 五动词。
13
13
 
14
+ **扩展能力面的契约登记**(不并入五动词形状表,但同为本文件真源,2026-09-30):
15
+ ① `ACTION_SOURCE_KEY / ACTION_SOURCE_VALUES`——wrapper 级恒定键 `action_source`
16
+ (M4,四态 explicit/sig/default/none,任何 op 的 dict 返回恒在场);
17
+ ② `PROTECT_MARK_NEG_ROUTE`——`protect.mark` 的负路由 error 形态(H9)。
18
+ 两者由 `audit()` 的 `action_source` / `protect_neg_route` 段透出,并由 `test_protocol`
19
+ 的**同源源码守卫**钉住 mcp_server / protect 的实现(防「声明了没做」)。
20
+
14
21
  **协议面 ≠ MCP 面全量**(2026-09-19 实测):`_cg_dispatch` 现有 35 个 op 分支,
15
22
  其中 30 个(ccg / review / protect / verify / whitebox …)属**扩展能力面**——它们是能力,
16
23
  不是协议违例。把「协议覆盖范围」当成「MCP 面全量」会让协议每加一个 op 就永久红灯,
@@ -70,6 +77,31 @@ DERIVE_EXTRA_KEYS = ("op", "op_derived", "hint")
70
77
  #: state 取值 = ACCEPT / REJECT / DEFER / BLINDSPOT(四态资格裁决)。
71
78
  VERDICT_FIELDS = ("state", "kind", "basis", "evidence", "detail")
72
79
 
80
+ #: M4(2026-09-30)· **wrapper 级恒定键**:**任何 op** 的 dict 返回都带此键
81
+ #: (`_cg_call` 追加,实测 mcp_server.py:1849-1857 的 act_source 三元表达式 +
82
+ #: 其后 `out.setdefault("action_source", act_source)`)。
83
+ #: 取值四态(顺序即优先级):explicit(调用方显式传了 action)> sig(漏传 action
84
+ #: 但按参数签名推导出)> default(该 op 在默认动作表里有条目,走了默认)>
85
+ #: none(该 op 无默认动作,不编造)。协议语义:客户端据此可判断本次 action 是
86
+ #: **自己传的**还是**系统挑的**——旧行为只在「推导过」时透出 action_derived,
87
+ #: 显式传 action 时返回里没有任何痕迹。
88
+ #: 与 DERIVE_EXTRA_KEYS 的边界:那是「推导发生过才追加」(显式态下不出现),
89
+ #: 本键**恒在场**;两套标记并存互补,见 test_read_face_input_gates 的 M4-C 组。
90
+ ACTION_SOURCE_KEY = "action_source"
91
+ ACTION_SOURCE_VALUES = ("explicit", "sig", "default", "none")
92
+
93
+ #: H9(2026-09-30)· protect.mark 的**负路由形态**(实测 protect.py:374)。
94
+ #: 目标不存在时该分支返回此 dict——不是裸 None:旧行为裸返 None 被
95
+ #: `_protect_call` 序列化成 `null` 回客户端,「id 不存在」被读成「打标成功」,
96
+ #: 而 `_write_node` 从未发生。与 read 的 `node_missing` 同为「缺失 ≠ 成功」,
97
+ #: 但**形态不同、须分别处理**:read 返 `null`(returns_null=True),
98
+ #: protect 返结构化 error(ok=False + error + node_id)。
99
+ #: 调用方分流:失败看 `r.get("ok") is False` 或 `"error" in r`;成功路径的返回键
100
+ #: **不变**(node_id / protected / reason,**无 ok 键**——不可用 `ok` 判成功)。
101
+ PROTECT_MARK_NEG_ROUTE = {"ok": False, "error": "node_not_found",
102
+ "node_id": "<目标 id>"}
103
+ PROTECT_MARK_NEG_ROUTE_ERROR = "node_not_found"
104
+
73
105
  VERB_SPECS = {
74
106
  "route": {
75
107
  "status": "live",
@@ -157,7 +189,8 @@ VERB_SPECS = {
157
189
  },
158
190
  },
159
191
  "semantics": "写动词多形态:committed=已落盘;未 committed 时 moved_to/gate 说明去向,"
160
- "hint 说明「闸门正常行为、不是工具故障、重试同样结果」",
192
+ "hint 说明下一步(闸门正常行为、不是工具故障;政策违规类 REJECT「重试同样结果」,"
193
+ "缺必需要素类 REJECT 给出完整缺失清单与「补齐后重写」指引)",
161
194
  },
162
195
  "supersede": {
163
196
  "status": "reserved",
@@ -318,7 +351,13 @@ def audit(module: str = None, func: str = None) -> dict:
318
351
  "reserved": reserved, "actual": actual, "has_impl": has_impl,
319
352
  "missing_impl": missing_impl, "extension_ops": extension_ops,
320
353
  "reserved_leaked": reserved_leaked, "shape_errors": shape_errors,
321
- "derive": list(OP_DERIVE), "errors": errors, "ok": not errors}
354
+ "derive": list(OP_DERIVE), "errors": errors, "ok": not errors,
355
+ # 扩展能力面的两条契约登记(2026-09-30):wrapper 级恒定键 +
356
+ # protect.mark 负路由形态——都不属五动词形状表,故不并入 VERB_SPECS。
357
+ "action_source": {"key": ACTION_SOURCE_KEY,
358
+ "values": list(ACTION_SOURCE_VALUES)},
359
+ "protect_neg_route": {"error": PROTECT_MARK_NEG_ROUTE_ERROR,
360
+ "shape": dict(PROTECT_MARK_NEG_ROUTE)}}
322
361
 
323
362
 
324
363
  # 生效条件:verb 为 VERBS 成员时返回其 VERB_SPECS 规格 dict,非成员(含 None/空串)抛 KeyError;
@@ -28,6 +28,7 @@ import os
28
28
  import time
29
29
 
30
30
  from . import trust as _trust
31
+ from . import security as _security
31
32
  from .fsutil import FileLock, append_jsonl, atomic_write, read_jsonl
32
33
 
33
34
  LEDGER_NAME = "_link.jsonl"
@@ -242,7 +243,7 @@ def index_edges(cg, *, prefix: str = None) -> list:
242
243
  """从**索引快照**恢复派生边(零读文件)——台账丢失/未重建时的只读兜底。"""
243
244
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
244
245
  out = []
245
- for nid, e in nodes.items():
246
+ for nid, e in list(nodes.items()):
246
247
  if prefix and not str(nid).startswith(prefix):
247
248
  continue
248
249
  rel = coerce_relation((e or {}).get(FM_REL_FIELD))
@@ -423,12 +424,19 @@ def _mode_of(start_operator, end_operator) -> str:
423
424
  else "overlap"
424
425
 
425
426
 
426
- # 生效条件:nid 不在 (cg.index or {}).get("nodes") or {} 的 dict 条目中(含 cg.index 缺失、条目非 dict)时返回 {'id': nid, 'present': False};否则返回 {'id','present':True} 并附 EXPAND_FIELDS 中值非 None 的字段。
427
+ # 生效条件:nid 不在 (cg.index or {}).get("nodes") or {} 的 dict 条目中(含 cg.index 缺失、条目非 dict)时返回 {'id': nid, 'present': False};条目在但对本身份不可见(security.node_visible 为假)时同样返回 {'id': nid, 'present': False}(不附任何字段);否则返回 {'id','present':True} 并附 EXPAND_FIELDS 中值非 None 的字段。
427
428
  def _node_digest(cg, nid) -> dict:
428
- """端点摘要(只读索引快照,**零读节点文件**);端点缺失 → `present=False`。"""
429
+ """端点摘要(只读索引快照,**零读节点文件**);端点缺失/不可见 → `present=False`。
430
+
431
+ N226(2026-09-28):此前直读 `cg.index` 无可见性判定 ⇒ 端点摘要(layer/
432
+ tags/importance/writer/session/temporal)把读闸拒绝节点的元数据照返回
433
+ (实测 guest 经 `cg(op="edges", expand_nodes=true)` 拿到私密节点摘要)。
434
+ 同库同身份的 `stg.timeline`(stg.py:133/172)早已接线 `_readable`——本处
435
+ 是漏网的旁路出口,改为同一个跨层单点(`security.node_visible`)。
436
+ """
429
437
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
430
438
  e = nodes.get(nid)
431
- if not isinstance(e, dict):
439
+ if not isinstance(e, dict) or not _security.node_visible(cg, nid):
432
440
  return {"id": nid, "present": False}
433
441
  d = {"id": nid, "present": True}
434
442
  for k in EXPAND_FIELDS:
@@ -494,6 +502,16 @@ def find_edges(cg, *, child=None, parent=None, relation=None, batch=None,
494
502
  q_e = _trust.parse_time(end_time) if enabled else None
495
503
 
496
504
  rows = all_edges(cg, path=path)
505
+ # N226(2026-09-28):可见性前置过滤(在谓词过滤**之前**,故 `total` 与
506
+ # 审计不变式 `dropped + len(kept) == total` 的口径随之收敛为「本身份可见
507
+ # 候选数」,仍可复算)。判定用**两端都可见**才保留:边拓扑(child->parent
508
+ # + relation + t)本身就会泄露隐藏端点的 id 与关系(实测
509
+ # `aggregates.sample=['child_mark->priv_mark(derived_from)']` 即此形态),
510
+ # 「至少一端可见」不足以闭口;口径与 `linkref` 的目标白名单、
511
+ # `stg._scan` 同策略——可见集之外一律当不存在(不含半条边)。
512
+ rows = [e for e in rows
513
+ if _security.node_visible(cg, e.get("child"))
514
+ and _security.node_visible(cg, e.get("parent"))]
497
515
  cand = []
498
516
  for e in rows:
499
517
  if c_f and e.get("child") != c_f:
package/md_cg/reach.py CHANGED
@@ -207,7 +207,7 @@ class ReachIndex:
207
207
  # 生效条件:当 cg 的 index 提供 nodes 时,对每个 path 非空、cg._read 返回内容非 None 且 cg._open_content 亦返回非 None 的节点写入 hashes/post(fm.semantic 为真时并入 sem),并只把两端均已入 hashes 的 edges 建成 adj,随后按原始 content_hash 是否齐全设置 hash_complete 并返回 self。
208
208
  def build(self, cg):
209
209
  nodes = cg.index.get("nodes") or {}
210
- id2path = {k: (v.get("path") or "") for k, v in nodes.items()}
210
+ id2path = {k: (v.get("path") or "") for k, v in list(nodes.items())}
211
211
  hashes, post, adj, sem = {}, {}, {}, []
212
212
  for k in sorted(nodes):
213
213
  e = nodes[k]
@@ -229,7 +229,7 @@ class ReachIndex:
229
229
  sem.append(p) # MDCG_SEMANTIC=1 时该节点无条件入池 → 必须进收敛集
230
230
  for b in _doc_tokens(c, (fm or {}).get("tags")):
231
231
  post.setdefault(b, []).append(p)
232
- for k, e in nodes.items():
232
+ for k, e in list(nodes.items()):
233
233
  p = e.get("path")
234
234
  if not p or p not in hashes:
235
235
  continue
@@ -247,7 +247,7 @@ class ReachIndex:
247
247
  # 覆盖完整性必须看**原始 content_hash**(hashes 里存的是 _node_key,
248
248
  # 含分隔符恒为真 → 曾使 hash_complete 恒判 True,r10 复核取证)
249
249
  self.hash_complete = all(bool(v.get("content_hash"))
250
- for v in nodes.values() if v.get("path"))
250
+ for v in list(nodes.values()) if v.get("path"))
251
251
  self.built_at = time.time()
252
252
  return self
253
253
 
@@ -357,7 +357,7 @@ def _hash_complete(cg) -> bool:
357
357
  # 口径与 build() 的覆盖集合一致:只按有 path 的条目判定(无 path 的条目 build 不收录,
358
358
  # 不应因此禁用缓存复用——r12 复核指出旧写法会让稳态每次全库重建)
359
359
  nodes = cg.index.get("nodes") or {}
360
- return all(bool(v.get("content_hash")) for v in nodes.values() if v.get("path"))
360
+ return all(bool(v.get("content_hash")) for v in list(nodes.values()) if v.get("path"))
361
361
 
362
362
 
363
363
  # 生效条件:磁盘缓存 hashes 与库内节点 path/content_hash 一一对应(数量相同且逐条相等)时返回 True,否则返回 False(触发重建)。
@@ -28,6 +28,15 @@
28
28
  Rust `load_docs` 预计算 stripped/db_len 同款理论);
29
29
  3. 进程内一致性边界(与 MdStore 相同的诚实边界):跨进程/外部直接改写
30
30
  md 文件不保证可见——认知图的多进程形态(每智能体一进程)各持快照。
31
+ 4. **读失败不固化(C-3 / FI-M02 / N134,2026-09-29)**:只接纳「成功」与
32
+ 「终态真缺(FileNotFoundError)」两种读结果,**瞬时读失败(独占句柄/
33
+ 资源剥夺…)一律不入缓存**——修前任何返回值含 `(None, None)` 都被当正常
34
+ 值固化且 `_fresh` 恒真,一次瞬态失败即「节点从检索面永久消失」(cache
35
+ 条目字面 `(gen,(None,None))`),而 `cg.get` 直读照常可读 ⇒「get 能读、
36
+ search 搜不到」撕裂。判别是读路径的**单点**(`MdCG._read_status` 第三
37
+ 元素,判据函数 `fsutil.classify_read_failure`),本层不看异常类型、不猜
38
+ 一遍;失败侧另有模块级计数+有界样本记账(`fsutil.transient_read_stats`),
39
+ 不再静默。**为什么不是「带失败标记 + TTL 入缓存」见 `install` 文档串。**
31
40
 
32
41
  开关:默认**开**(issue #31 后续——生产检索入口 MdCGOS.search 默认态每查询
33
42
  全池 open+realpath+parse,3300 池实测中位 ~602ms/查询、O(n) 线性;读缓存
@@ -58,7 +67,7 @@ def direct_read(cg, entry):
58
67
  return fn(entry)
59
68
 
60
69
 
61
- # 生效条件:cg._read 可调用时以 (path → (缓存时 write_gen, 解析产物)) 常驻字典包装之——_dirty 带 path_gen 簿记(_DirtyDict 形态)时命中条件为「该 path 最近标脏代际(path_gen)与 broad_gen 均 ≤ 缓存时 write_gen」(脏集精确失效:单节点写只失效该节点),否则回落整代际相等校验(旧口径,非 _DirtyDict 防御);cg._doc_norm_bigrams 亦存在时同款包装其派生物(_score 热点:文档侧归一化 bigram 只依赖 content,随读缓存一并常驻);包装后 cg._read/_doc_norm_bigrams 为包装函数、cg._read_cache/_norm_bigrams_cache 为缓存字典、cg._read_uncached 为未被包装的原始 _read(direct_read 的真源);返回缓存字典。
70
+ # 生效条件:cg 提供 _read_status(MdCG/MdCGSecure 恒有)时按其**三态标签**包装:第三元素为 None(成功 / 终态真缺)者照脏集精确失效口径入缓存,第三元素非 None(瞬时读失败,fsutil.READ_FAIL_TRANSIENT)者**一律不入缓存**(C-3:不得把可重试的读失败以「新鲜」身份固化);cg 无 _read_status(非 MdCG 载体)时回落包装 cg._read,且空结果一律不接纳(无判别面时的保守侧:宁可重读,不可固化可能是瞬时的缺失);cg._doc_norm_bigrams 存在时同款包装其派生物(_score 热点:文档侧归一化 bigram 只依赖 content,随读缓存一并常驻);包装后 cg._read 与 cg._doc_norm_bigrams 走缓存、cg._read_cache/_norm_bigrams_cache 为缓存字典、cg._read_uncached 为**穿透缓存的原始二态 _read**(direct_read 的真源,返回形状与 install 前逐位一致);返回缓存字典。
62
71
  def install(cg):
63
72
  """把 `cg._read`(与派生物钩子)包成**脏集精确失效**的常驻缓存。
64
73
 
@@ -71,11 +80,28 @@ def install(cg):
71
80
  据此按 path 判新鲜:**写谁失效谁**,其余条目原对象复用。写路径不必
72
81
  再改一处(标脏即 `_dirty[nid]=entry` 带 path,簿记在 _DirtyDict 钩子
73
82
  内自动完成)。
83
+
84
+ 接纳口径(C-3 / FI-M02 / N134,2026-09-29):缓存只接纳**成功**与
85
+ **终态真缺**两种读结果;**瞬时读失败不入缓存**。修前任何返回值(含
86
+ `(None, None)`)一律入缓存,且该 path 无写事件时 `_fresh` 判定恒真 ⇒
87
+ 一次独占句柄/资源剥夺就被固化成「节点从检索面永久消失直到重启或再写盘」
88
+ (cache 条目字面 `(gen, (None, None))`),而 `cg.get` 直读照常可读 ⇒
89
+ 「get 能读、search 搜不到」撕裂。判据是读路径给的**标签**(`_read_status`
90
+ 第三元素),本层**不重新判别异常类型**(单点,见 fsutil.classify_read_failure)。
91
+
92
+ 为什么是「不入缓存」而不是「带失败标记入缓存 + 失效条件」:失败标记要
93
+ 生效必须引入时间窗(TTL / 单调钟代际),而本层的失效判据只有 `_fresh`
94
+ 一维(write_gen 脏集);给失败另开一维就是**第二份失效判据**,且 TTL 窗内
95
+ 该节点仍处「get 能读、search 搜不到」的撕裂态(只是有界)——与本缺陷要
96
+ 消灭的现象同型,仅缩窗。不入缓存则窗口为零:**释放即命中**(FI-M02 实测)。
97
+ 代价侧不存在「每次查询都重试」的性能悬崖:
98
+ · 只有**失败的那几个 path** 每查询多一次 `open`(O(1)/path),成功节点
99
+ 仍在缓存里(O(1)),不是整池重读——量级退化的最坏情形是「全池都读
100
+ 不出来」,那正是修前(缓存恒关面)的既有开销,也不比它更差;
101
+ · 真缺(FileNotFoundError)仍照旧入缓存 ⇒ 缺文件的节点**不会**每查询重试;
102
+ · 失败可观测(fsutil.transient_read_stats 计数+样本),不再静默。
74
103
  """
75
104
  cache = {}
76
- # 重复 install(如显式再调)不叠加包装层,_read_uncached 恒指真原始。
77
- orig = getattr(cg, "_read_uncached", None) or cg._read
78
- cg._read_uncached = orig
79
105
 
80
106
  def _fresh(p, hit):
81
107
  """hit=(缓存时 write_gen, val) 对 path p 是否仍新鲜(不陈旧)。"""
@@ -99,17 +125,52 @@ def install(cg):
99
125
  return (pg.get(p, 0) <= hit[0]
100
126
  and dirty.broad_gen <= hit[0])
101
127
 
102
- def _cached(entry):
103
- p = entry["path"]
104
- gen = getattr(cg._dirty, "write_gen", len(cg._dirty))
105
- hit = cache.get(p)
106
- if hit is not None and _fresh(p, hit):
107
- return hit[1]
108
- val = orig(entry)
109
- cache[p] = (gen, val)
110
- return val
111
-
112
- cg._read = _cached
128
+ status_fn = getattr(cg, "_read_status", None)
129
+ if status_fn is not None:
130
+ # 三态面(C-3):_read_status 是唯一判别点,本层只按标签决定接纳与否。
131
+ # 重复 install(如显式再调)不叠加:_read_status_uncached 恒指真原始。
132
+ orig_status = (getattr(cg, "_read_status_uncached", None)
133
+ or status_fn)
134
+ cg._read_status_uncached = orig_status
135
+ if getattr(cg, "_read_uncached", None) is None:
136
+ # 穿透缓存的原始二态读(direct_read 真源):形状同 install 前。
137
+ cg._read_uncached = (lambda entry: orig_status(entry)[:2])
138
+
139
+ def _cached_status(entry):
140
+ p = entry["path"]
141
+ gen = getattr(cg._dirty, "write_gen", len(cg._dirty))
142
+ hit = cache.get(p)
143
+ if hit is not None and _fresh(p, hit):
144
+ # 存储面恒为二态(成功/终态);命中时补回「非失败」标签。
145
+ return hit[1] + (None,)
146
+ val = orig_status(entry)
147
+ if val[2] is not None:
148
+ # C-3:瞬时读失败**不入缓存**(本次即返回,下次查询重试该 path)。
149
+ return val
150
+ cache[p] = (gen, (val[0], val[1]))
151
+ return val
152
+
153
+ cg._read_status = _cached_status
154
+ else:
155
+ # 无判别面的载体(非 MdCG):保守侧——空结果一律不接纳。
156
+ orig_read = getattr(cg, "_read_uncached", None) or cg._read
157
+ cg._read_uncached = orig_read
158
+
159
+ def _cached(entry):
160
+ p = entry["path"]
161
+ gen = getattr(cg._dirty, "write_gen", len(cg._dirty))
162
+ hit = cache.get(p)
163
+ if hit is not None and _fresh(p, hit):
164
+ return hit[1]
165
+ val = orig_read(entry)
166
+ if not val or val[0] is None or val[1] is None:
167
+ # 无三态面可判时不得固化空结果(可能是瞬时的缺失)。
168
+ return val
169
+ cache[p] = (gen, val)
170
+ return val
171
+
172
+ cg._read = _cached
173
+
113
174
  cg._read_cache = cache
114
175
 
115
176
  nb_cache = {}
@@ -156,7 +156,7 @@ def reconcile_state(cg, apply: bool = True) -> dict:
156
156
  kept_unreadable = [] # 条目在、真源暂不可读——保留只告警
157
157
 
158
158
  # ①③ 以索引为基准走一遍:多索引条目 + 内容漂移
159
- for nid, entry in index_nodes.items():
159
+ for nid, entry in list(index_nodes.items()):
160
160
  if nid not in by_nid:
161
161
  ep = entry.get("path") or ""
162
162
  if ep in problem_paths: