@furongjun1999/dsh-memory 0.5.0 → 0.6.0

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 (156) hide show
  1. package/README.md +572 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  4. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  5. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  6. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  7. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  8. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  9. package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
  10. package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
  11. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  12. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
  13. package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
  14. package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
  15. 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_v1.md +156 -0
  16. 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_v10.md +210 -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_v11.md +227 -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_v12.md +203 -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_v13.md +233 -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_v14.md +191 -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_v15.md +213 -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_v16.md +214 -0
  23. 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_v17.md +199 -0
  24. 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_v18.md +183 -0
  25. 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_v19.md +207 -0
  26. 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_v2.md +156 -0
  27. 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_v3.md +152 -0
  28. 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_v4.md +128 -0
  29. 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_v5.md +114 -0
  30. 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_v6.md +192 -0
  31. 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_v7.md +187 -0
  32. 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_v8.md +207 -0
  33. 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_v9.md +203 -0
  34. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
  35. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
  36. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  37. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  38. 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
  39. package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
  40. package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.d.ts +9 -0
  43. package/lib/bridge.js +35 -0
  44. package/lib/cli.d.ts +3 -0
  45. package/lib/cli.js +66 -0
  46. package/lib/index.js +7 -1
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +287 -0
  49. package/lib/lib/roleplay_web.js +530 -443
  50. package/lib/lib/token_store.d.ts +7 -1
  51. package/lib/lib/token_store.js +12 -3
  52. package/md_cg/audit.py +12 -1
  53. package/md_cg/backfill.py +16 -15
  54. package/md_cg/backfill_bucket_zh.py +35 -0
  55. package/md_cg/bench_e2e_judge.py +532 -0
  56. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  57. package/md_cg/bench_e2e_qa.py +256 -0
  58. package/md_cg/branches.py +18 -2
  59. package/md_cg/ccgc.py +3 -2
  60. package/md_cg/chain.py +19 -4
  61. package/md_cg/consolidate.py +7 -6
  62. package/md_cg/crosscheck.py +4 -3
  63. package/md_cg/crypto.py +439 -437
  64. package/md_cg/datapath.py +395 -335
  65. package/md_cg/evidence.py +8 -3
  66. package/md_cg/export.py +3 -1
  67. package/md_cg/forgetting.py +2 -2
  68. package/md_cg/fsutil.py +48 -0
  69. package/md_cg/hotcache.py +255 -238
  70. package/md_cg/interop.py +161 -22
  71. package/md_cg/judgment_manifest.py +177 -0
  72. package/md_cg/links.py +655 -622
  73. package/md_cg/mcp_server.py +282 -31
  74. package/md_cg/mdcg.py +702 -126
  75. package/md_cg/mdcos.py +454 -68
  76. package/md_cg/mreview/govern.py +5 -4
  77. package/md_cg/postings.py +4 -2
  78. package/md_cg/readcache.py +76 -18
  79. package/md_cg/reconcile.py +228 -0
  80. package/md_cg/review_cli.py +215 -0
  81. package/md_cg/routing.py +28 -0
  82. package/md_cg/run_tests.py +211 -0
  83. package/md_cg/scrub.py +862 -852
  84. package/md_cg/security.py +385 -275
  85. package/md_cg/selfreport.py +3 -2
  86. package/md_cg/signer.py +565 -562
  87. package/md_cg/sources.py +3 -2
  88. package/md_cg/stg.py +6 -0
  89. package/md_cg/sustain.py +1168 -1138
  90. package/md_cg/test_access_hints.py +147 -0
  91. package/md_cg/test_branch_discard_tombstone.py +136 -0
  92. package/md_cg/test_branches.py +259 -249
  93. package/md_cg/test_chain_read_isolate.py +168 -0
  94. package/md_cg/test_datapath_device_name.py +203 -0
  95. package/md_cg/test_emit_negtail_cache.py +156 -0
  96. package/md_cg/test_en_pipeline.py +186 -166
  97. package/md_cg/test_govern_directread.py +421 -0
  98. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  99. package/md_cg/test_identity_attribution.py +228 -147
  100. package/md_cg/test_index_crossprocess_reload.py +301 -0
  101. package/md_cg/test_index_durability.py +238 -224
  102. package/md_cg/test_interop.py +4 -2
  103. package/md_cg/test_interop_judgment.py +228 -0
  104. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  105. package/md_cg/test_links_concurrent_write.py +188 -0
  106. package/md_cg/test_merge_upsert.py +168 -0
  107. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  108. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  109. package/md_cg/test_n131_merge_gate.py +205 -0
  110. package/md_cg/test_none_id_write_guard.py +165 -0
  111. package/md_cg/test_p1x_ref_root.py +160 -0
  112. package/md_cg/test_p27_docindex.py +774 -765
  113. package/md_cg/test_p2_mcp.py +3 -0
  114. package/md_cg/test_p32_backfill.py +304 -298
  115. package/md_cg/test_p39_verify_flow.py +90 -50
  116. package/md_cg/test_p47_session_view.py +60 -25
  117. package/md_cg/test_propose_tail_index.py +157 -0
  118. package/md_cg/test_read_scope_b27.py +277 -0
  119. package/md_cg/test_readcache_default_on.py +168 -0
  120. package/md_cg/test_readcache_precise_inval.py +270 -0
  121. package/md_cg/test_readcache_prodpath.py +55 -7
  122. package/md_cg/test_reconcile_v0.py +294 -0
  123. package/md_cg/test_retr_s1.py +6 -2
  124. package/md_cg/test_retr_s1b.py +67 -0
  125. package/md_cg/test_retr_s7.py +8 -0
  126. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  127. package/md_cg/test_retr_score_once.py +208 -0
  128. package/md_cg/test_review_cli_attribution.py +177 -0
  129. package/md_cg/test_review_cli_visibility.py +235 -0
  130. package/md_cg/test_review_onepass.py +170 -0
  131. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  132. package/md_cg/test_security_audit.py +155 -0
  133. package/md_cg/test_security_audit_b26.py +161 -0
  134. package/md_cg/test_security_audit_v21.py +250 -0
  135. package/md_cg/test_semantic_canonical.py +255 -241
  136. package/md_cg/test_session_isolation.py +168 -0
  137. package/md_cg/test_snapshot_autoclose.py +187 -0
  138. package/md_cg/test_tail_watermark_race.py +208 -0
  139. package/md_cg/test_tenant_env_override_warn.py +139 -0
  140. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  141. package/md_cg/test_v14_fixes.py +415 -397
  142. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  143. package/md_cg/theory.py +276 -273
  144. package/md_cg/tokens.py +734 -677
  145. package/md_cg/units.py +3 -2
  146. package/md_cg/vision_evidence.py +4 -3
  147. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  148. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  149. package/md_cg/writepipe.py +554 -550
  150. package/package.json +11 -2
  151. package/src/bridge.ts +434 -401
  152. package/src/cli.ts +65 -0
  153. package/src/index.ts +526 -518
  154. package/src/init.ts +347 -0
  155. package/src/lib/roleplay_web.ts +1019 -932
  156. package/src/lib/token_store.ts +202 -192
@@ -40,6 +40,9 @@ class McpClient:
40
40
  env["MDCG_ROOT"] = root
41
41
  env["MDCG_ACTOR"] = actor
42
42
  env["MDCG_CAN_ADMIN"] = "1" # 测试默认带管理权限
43
+ # P1-5(批次 26):legacy env 身份的 admin 需显式二次开关——本套
44
+ # 测 forget/restore/review_decide 等管理操作,属合法 admin 消费方
45
+ env["MDCG_LEGACY_ENV_ADMIN"] = "1"
43
46
  env["MDCG_LEGACY_ENV_AUTH"] = "1" # 旧 env 直连身份(令牌方案前兼容)
44
47
  env["PYTHONIOENCODING"] = "utf-8"
45
48
  if extra_env:
@@ -1,298 +1,304 @@
1
- # -*- coding: utf-8 -*-
2
- """真实库对齐(P32 · 设计态验收)。
3
-
4
- 对照 docs §五 / 交付计划:P32 交付 **CCG 回填 + 能力标签注入 + 时间线预览脱敏**。
5
- 验收覆盖:
6
- ① op 契约链:maintain 新增 6 个 action,工具 schema 已同步声明
7
- ② backfill 预演:只出报表不写盘;按「有来源才补」四态分流(可补/不全/无源/密文/脚手架)
8
- ③ backfill 落盘:补完即 CCG 要素齐全(6 行) + 生效条件由条件空间四槽合成
9
- + frontmatter 同步 + 留痕 + 幂等;单槽 observation_position **不得**冒充生效条件
10
- ④ backfill 回滚:按留痕反向应用;人工改写后不覆盖(守卫)
11
- ⑤ 密文节点 fail-closed:回填/cap 一律跳过,绝不解密回写
12
- ⑥ cap 注入:关键词启发式 → cap 标签 + 命中依据 + 置信度;route 返回建议能力
13
- ⑦ cap 回滚 / 幂等
14
- ⑧ 预览脱敏:时间线不回显密文碎片;有权限给明文 / 无权限给占位符
15
- ⑨ 权限分档:写层可预演、不可 apply/rollback
16
-
17
- 批量改写均可预演 / 可留痕 / 可回滚;独立临时根,重跑 ≡ 首跑。
18
-
19
- 运行:python -m md_cg.test_p32_backfill
20
- """
21
- from __future__ import annotations
22
-
23
- import json
24
- import os
25
- import shutil
26
- import tempfile
27
-
28
- from . import backfill, crypto, nodefile, stg, tokens
29
- from .mdcos import MdCGOS, MdCGSecure
30
- from .mcp_server import KERNEL_TOOLS, call_tool
31
- from .security import AccessDenied, Principal
32
-
33
- PASS = FAIL = 0
34
- FAILS = []
35
-
36
-
37
- def ok(cond, label):
38
- global PASS, FAIL
39
- if cond:
40
- PASS += 1
41
- else:
42
- FAIL += 1
43
- FAILS.append(label)
44
- print(f" FAIL {label}")
45
-
46
-
47
- def denied(fn, label):
48
- try:
49
- fn()
50
- except AccessDenied:
51
- ok(True, label)
52
- return
53
- ok(False, label)
54
-
55
-
56
- BF_OK = "# 功能名:文件摄取分派\n\n按扩展名把文件路由到对应摄取器。\n"
57
- BF_NONE = "# 功能名:无来源历史记忆\n\n迁移入库,未留下任何条件声明。\n"
58
- BF_PARTIAL = "# 功能名:半来源历史记忆\n\n只有验证基底,没有子功能与执行。\n"
59
- BF_HINT = "# 功能名:盲区线索\n\n非既有事实,待补条件。\n"
60
- CAP_DOC = ("# 功能名:全库导出\n# 生效条件:本地离线环境\n"
61
- "# 子功能:把整库导出为可搬运 JSONL\n# 执行:流式写 JSONL\n"
62
- "# 验证方式:实测数据(benchmark / 采样)\n# 不适用条件:无持久化介质\n")
63
-
64
- KEK = b"0123456789abcdef0123456789abcdef" # 32 字节(crypto 硬要求)
65
-
66
-
67
- def _seed(root):
68
- """造 6 类节点 + 1 条密文节点,覆盖回填分流的所有分支。"""
69
- cg = MdCGOS(root)
70
- # 四槽齐备 → 生效条件可由条件空间合成(唯一合法结构化来源)
71
- cg.add("bf_ok", BF_OK, layer="knowledge",
72
- condition_space={
73
- "observation_position": "本地文件系统(摄取器内部观测位)",
74
- "observation_tool": "扩展名映射表 + Ingestor",
75
- "time_window": [0.0, 9999999999.0],
76
- "existence_constraint": "仅对本地可读文件生效"},
77
- verification_basis="test",
78
- non_applicable_conditions=["二进制文件", "超过 50MB 的文件"],
79
- state_attributes={"comment": {
80
- "子功能": "按扩展名路由到对应摄取器",
81
- "执行": "读后缀映射表→调用对应 Ingestor"}})
82
- # 只有单槽 → **不得**冒充生效条件(观测位置 ≠ 生效条件)
83
- cg.add("bf_posonly",
84
- "# 功能名:只有观测位置的历史记忆\n\n条件空间只声明了一维。\n",
85
- layer="knowledge",
86
- condition_space={"observation_position": "某个外部观测位"},
87
- state_attributes={"comment": {
88
- "子功能": "待补:条件空间其余三维",
89
- "执行": "待补"}})
90
- cg.add("bf_none", BF_NONE, layer="knowledge")
91
- cg.add("bf_partial", BF_PARTIAL, layer="knowledge", verification_basis="data")
92
- cg.add("bf_hint", BF_HINT, layer="contextual", tags=["gap_hint"])
93
- cg.add("cap_export", CAP_DOC, layer="knowledge", verification_basis="test")
94
- cg.flush()
95
-
96
- sec = MdCGSecure(root, principal=Principal(
97
- actor="p32", clearance="private", can_write=True, can_admin=True),
98
- master_key=KEK)
99
- sec.add("bf_lock", "# 功能名:私有密文记忆\n\n敏感正文,不进回填。\n",
100
- layer="knowledge", sensitivity="private")
101
- sec.flush()
102
- cg.rebuild_index() # 让明文实例也看见密文节点(否则计数缺失)
103
- return cg
104
-
105
-
106
- def main():
107
- global PASS, FAIL
108
- tmp = tempfile.mkdtemp(prefix="mdcg_p32_")
109
- try:
110
- root = os.path.join(tmp, "root")
111
- os.makedirs(root, exist_ok=True)
112
- cg = _seed(root)
113
-
114
- # ---------- ① op 契约链 ----------
115
- blob = json.dumps(KERNEL_TOOLS, ensure_ascii=False)
116
- ok("backfill" in blob and "cap_history" in blob,
117
- "①工具 schema 声明 maintain 的 backfill/cap action")
118
- ok(all(a in MdCGOS.MAINTAIN_ACTIONS for a in backfill.ACTIONS),
119
- "①ALL_OPS 之外的 action 真源 == MAINTAIN_ACTIONS")
120
- ok(set(backfill.CAP_RULES) <= set(tokens.ALL_OPS),
121
- "①cap 规则只含真实 op(不注入无效能力名)")
122
-
123
- # ---------- ② backfill 预演(不改盘) ----------
124
- p1 = backfill.plan(cg)
125
- ok(p1["dry_run"] is True, "②预演标记 dry_run")
126
- ok("bf_ok" in p1["planned_ids"], "②有来源节点进入可回填清单")
127
- ok(p1["targeted"] >= 1, "②targeted 计数非零")
128
- ok(p1["skipped_locked"] >= 1, "②密文节点计入 skipped_locked")
129
- ok(p1["unfillable"] >= 1, "②无来源节点计入 unfillable(不编造)")
130
- ok(p1["skipped_derived"] >= 1, "②gap_hint 脚手架计入 skipped_derived")
131
- ok(p1["skipped_partial"] >= 1, "②补完仍不全者默认跳过(partial)")
132
- item = next(i for i in p1["items"] if i["id"] == "bf_ok")
133
- ok(item["class"] == "backfillable", "②bf_ok 判定为 backfillable")
134
- ok(item["fill"]["验证方式"]["basis"].startswith(
135
- "frontmatter.verification_basis"), "②验证方式来源可追溯(basis)")
136
- fm0, c0 = cg._read(cg.index["nodes"]["bf_ok"])
137
- ok("# 子功能:" not in c0, "②预演不改盘(正文无新增 CCG 行)")
138
-
139
- # 口径修正防回归:单槽 observation_position **不得**冒充生效条件
140
- fm_po, c_po = cg._read(cg.index["nodes"]["bf_posonly"])
141
- ok("生效条件" not in backfill.derive_fields(fm_po, c_po),
142
- "②单槽 observation_position 不生成生效条件(观测位置 ≠ 生效条件)")
143
- ok(nodefile.condition_space_text(
144
- fm_po.get("condition_space")) == "",
145
- "②四槽不全时合成函数返回空串(部分槽不构成声明)")
146
-
147
- # 待补台账:缺声明且推不出的节点必须可见,不得静默 ACCEPT
148
- pend = backfill.conditions_pending(cg)
149
- ok(pend["dry_run"] is True, "②待补台账标记 dry_run")
150
- po = next((i for i in pend["items"] if i["id"] == "bf_posonly"), None)
151
- # add 在未给 time_window 时以写入时刻锚定 1 小时观测窗,故缺方法 + 约束
152
- ok(po is not None and po["missing_slots"] == [
153
- "observation_tool", "existence_constraint"],
154
- "②单槽节点进待补台账并逐条列出缺失槽")
155
- ok("bf_ok" not in [i["id"] for i in pend["items"]],
156
- "②四槽齐备节点不进待补台账")
157
-
158
- # ---------- ③ backfill 落盘 ----------
159
- fm_pre = json.loads(json.dumps(cg._read(
160
- cg.index["nodes"]["bf_ok"])[0].get("state_attributes")))
161
- a1 = backfill.apply(cg, batch="p32a", actor="tester")
162
- ok(a1["written"] >= 1, "③apply 写入非零")
163
- fm1, c1 = cg._read(cg.index["nodes"]["bf_ok"])
164
- ok(nodefile.ccg_completeness(c1)["complete"] is True,
165
- "③bf_ok 回填后 CCG 要素齐全(6 行缺一不可)")
166
- synth = nodefile.condition_space_text(fm1.get("condition_space"))
167
- ok(f"# 生效条件:{synth}" in c1,
168
- "③生效条件 == 条件空间四槽合成(唯一口径)")
169
- ok("观测位置:" not in c1, "③正文不再出现单槽冒充形态")
170
- ok(nodefile.is_full_time_window(
171
- (fm1.get("condition_space") or {}).get("time_window")),
172
- "③前置:全时窗哨兵([0, 1e10] 表示任意时刻成立)")
173
- ok("全时窗(任意时刻成立)" in c1,
174
- "③全时窗渲染为可读声明(不落裸数组进召回键)")
175
- ok(fm1.get("state_attributes", {}).get("comment", {}).get("子功能")
176
- == "按扩展名路由到对应摄取器", "③frontmatter.comment 同步")
177
- ok(fm1.get("non_applicable_conditions") == ["二进制文件", "超过 50MB 的文件"],
178
- "③不适用条件同步为列表")
179
- h = backfill.history(cg, action="backfill")
180
- ok(h["total"] >= 1, "③留痕 _backfill.jsonl 非空")
181
- it = next(r for r in h["records"] if r["node"] == "bf_ok")
182
- ok("basis" in it["fields"]["执行"] and it["fields"]["执行"]["after"],
183
- "③留痕含 before/after/依据")
184
-
185
- # 幂等:再跑一次不重复写
186
- a2 = backfill.apply(cg, batch="p32a", actor="tester")
187
- ok(a2["written"] == 0, "③幂等:已完成节点不重复写")
188
-
189
- # ---------- ④ 回滚(真逆操作:还原而非暴力删除) ----------
190
- rb = backfill.rollback(cg, batch="p32a", actor="tester")
191
- ok(rb["reverted"] >= 1, "④回滚撤销非零")
192
- fm2, c2 = cg._read(cg.index["nodes"]["bf_ok"])
193
- ok("# 子功能:" not in c2, "④回滚移除 CCG 行")
194
- ok(fm2.get("state_attributes") == fm_pre,
195
- "④frontmatter 还原到改写前(comment 不被误删)")
196
- ok(backfill.apply(cg, batch="p32a2", actor="tester")["written"] >= 1,
197
- "④还原后可再次回填(回滚为真逆操作)")
198
- backfill.rollback(cg, batch="p32a2", actor="tester")
199
-
200
- # 守卫:回填后再人工改写 → 不覆盖
201
- backfill.apply(cg, batch="p32b", actor="tester")
202
- e = cg.index["nodes"]["bf_ok"]
203
- fm3, c3 = cg._read(e)
204
- c3 = c3.replace("# 执行:读后缀映射表→调用对应 Ingestor",
205
- "# 执行:人工改写过的执行方式")
206
- cg._write_node("bf_ok", os.path.join(root, e["path"]), fm3, c3,
207
- durable=True)
208
- cg.rebuild_index()
209
- rb2 = backfill.rollback(cg, batch="p32b", actor="tester")
210
- fm4, c4 = cg._read(cg.index["nodes"]["bf_ok"])
211
- ok("人工改写过的执行方式" in c4, "④回滚守卫:人工改写字段不被撤销")
212
- ok(rb2["conflict"] >= 1, "④回滚守卫计入 conflict")
213
-
214
- # ---------- ⑤ 密文 fail-closed ----------
215
- e5 = cg.index["nodes"]["bf_lock"]
216
- _, c5 = cg._read(e5)
217
- ok(crypto.is_encrypted(c5), "⑤前置:bf_lock 正文确为密文")
218
- ok("bf_lock" not in backfill.plan(cg)["planned_ids"], "⑤回填跳过密文节点")
219
- ok("bf_lock" not in backfill.cap_plan(cg)["planned_ids"], "⑤cap 跳过密文节点")
220
-
221
- # ---------- ⑥ cap 注入 ----------
222
- cp = backfill.cap_plan(cg)
223
- ok(cp["dry_run"] is True, "⑥cap 预演标记 dry_run")
224
- hit = next((i for i in cp["items"] if i["id"] == "cap_export"), None)
225
- ok(hit is not None, "⑥关键词命中节点进入 cap 清单")
226
- exp = next(h for h in hit["caps"] if h["cap"] == "export")
227
- ok("导出" in exp["matched_by"], "⑥cap 命中依据(matched_by)可核查")
228
- ok(0.0 < exp["confidence"] <= 1.0, "⑥cap 置信度落在 (0,1]")
229
- ca = backfill.cap_apply(cg, batch="p32cap", actor="tester")
230
- ok(ca["written"] >= 1, "⑥cap_apply 写入非零")
231
- fm6, _ = cg._read(cg.index["nodes"]["cap_export"])
232
- ok("cap:export" in (fm6.get("tags") or []), "⑥cap:export 注入 frontmatter.tags")
233
- ca2 = backfill.cap_apply(cg, batch="p32cap", actor="tester")
234
- ok(ca2["written"] == 0, "⑥cap 幂等:重复注入不重复写")
235
- rt = call_tool(cg, "cg", {"op": "route", "query": "全库导出 JSONL"})
236
- caps = rt.get("suggested_capabilities") or []
237
- ok("export" in caps, "⑥route 返回建议能力名(cap: 标签生效)")
238
- crb = backfill.cap_rollback(cg, batch="p32cap", actor="tester")
239
- fm7, _ = cg._read(cg.index["nodes"]["cap_export"])
240
- ok("cap:export" not in (fm7.get("tags") or []), "⑦cap_rollback 移除标签")
241
- ok(crb["reverted"] >= 1, "⑦cap_rollback 计数非零")
242
-
243
- # ---------- ⑧ 预览脱敏 ----------
244
- plain = MdCGOS(root)
245
- tl = stg.timeline(plain, limit=100)
246
- pv = next((i["preview"] for i in tl["items"] if i["id"] == "bf_lock"), None)
247
- ok(pv == stg.PLACEHOLDER_LOCKED, "⑧无密钥:密文节点预览为占位符")
248
- ok(pv is not None and "ENC1" not in pv and "封" not in pv,
249
- "⑧预览不回显密文碎片")
250
- sec_pub = MdCGSecure(root, principal=Principal(
251
- actor="p32pub", clearance="internal", can_write=False),
252
- master_key=KEK)
253
- tl2 = stg.timeline(sec_pub, limit=100)
254
- pv2 = next((i["preview"] for i in tl2["items"] if i["id"] == "bf_lock"), None)
255
- ok(pv2 == stg.PLACEHOLDER_DENIED, "⑧密级不足:预览为无权限占位符")
256
- sec_ok = MdCGSecure(root, principal=Principal(
257
- actor="p32", clearance="private", can_write=True, can_admin=True),
258
- master_key=KEK)
259
- tl3 = stg.timeline(sec_ok, limit=100)
260
- pv3 = next((i["preview"] for i in tl3["items"] if i["id"] == "bf_lock"), None)
261
- ok(pv3 is not None and "敏感正文" in pv3, "⑧有密钥且有权限:预览为明文")
262
-
263
- # ---------- ⑨ 权限分档 ----------
264
- wp = Principal(role="reflection", actor="p32w", clearance="internal",
265
- can_write=True, can_admin=False, ops_allow=("maintain",))
266
- wcg = MdCGSecure(root, principal=wp)
267
- ok(call_tool(wcg, "cg", {"op": "maintain", "action": "backfill"})
268
- .get("dry_run") is True, "⑨写层可预演 backfill")
269
- ok(call_tool(wcg, "cg", {"op": "maintain", "action": "cap"})
270
- .get("dry_run") is True, "⑨写层可预演 cap")
271
- denied(lambda: call_tool(wcg, "cg", {"op": "maintain",
272
- "action": "backfill", "apply": True}),
273
- "⑨写层不可 apply backfill(需管理权)")
274
- denied(lambda: call_tool(wcg, "cg", {"op": "maintain",
275
- "action": "cap", "apply": True}),
276
- "⑨写层不可 apply cap(需管理权)")
277
- denied(lambda: call_tool(wcg, "cg", {"op": "maintain",
278
- "action": "cap_rollback"}),
279
- "⑨写层不可 cap_rollback(需管理权)")
280
- try:
281
- call_tool(cg, "cg", {"op": "maintain", "action": "backfill_nope"})
282
- ok(False, "⑨未知 action 应报错而非静默成功")
283
- except Exception: # noqa: BLE001
284
- ok(True, "⑨未知 action 报错")
285
-
286
- # ---------- 收尾 ----------
287
- print(f"\nP32 结果:{PASS} 通过 / {FAIL} 失败")
288
- if FAILS:
289
- print("失败项:")
290
- for f in FAILS:
291
- print(f" - {f}")
292
- finally:
293
- shutil.rmtree(tmp, ignore_errors=True)
294
- return 1 if FAIL else 0
295
-
296
-
297
- if __name__ == "__main__":
298
- raise SystemExit(main())
1
+ # -*- coding: utf-8 -*-
2
+ """真实库对齐(P32 · 设计态验收)。
3
+
4
+ 对照 docs §五 / 交付计划:P32 交付 **CCG 回填 + 能力标签注入 + 时间线预览脱敏**。
5
+ 验收覆盖:
6
+ ① op 契约链:maintain 新增 6 个 action,工具 schema 已同步声明
7
+ ② backfill 预演:只出报表不写盘;按「有来源才补」四态分流(可补/不全/无源/密文/脚手架)
8
+ ③ backfill 落盘:补完即 CCG 要素齐全(6 行) + 生效条件由条件空间四槽合成
9
+ + frontmatter 同步 + 留痕 + 幂等;单槽 observation_position **不得**冒充生效条件
10
+ ④ backfill 回滚:按留痕反向应用;人工改写后不覆盖(守卫)
11
+ ⑤ 密文节点 fail-closed:回填/cap 一律跳过,绝不解密回写
12
+ ⑥ cap 注入:关键词启发式 → cap 标签 + 命中依据 + 置信度;route 返回建议能力
13
+ ⑦ cap 回滚 / 幂等
14
+ ⑧ 预览脱敏:时间线不回显密文碎片;有权限给明文 / 无权限给占位符
15
+ ⑨ 权限分档:写层可预演、不可 apply/rollback
16
+
17
+ 批量改写均可预演 / 可留痕 / 可回滚;独立临时根,重跑 ≡ 首跑。
18
+
19
+ 运行:python -m md_cg.test_p32_backfill
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import shutil
26
+ import tempfile
27
+
28
+ from . import backfill, crypto, nodefile, stg, tokens
29
+ from .mdcos import MdCGOS, MdCGSecure
30
+ from .mcp_server import KERNEL_TOOLS, call_tool
31
+ from .security import AccessDenied, Principal
32
+
33
+ PASS = FAIL = 0
34
+ FAILS = []
35
+
36
+
37
+ def ok(cond, label):
38
+ global PASS, FAIL
39
+ if cond:
40
+ PASS += 1
41
+ else:
42
+ FAIL += 1
43
+ FAILS.append(label)
44
+ print(f" FAIL {label}")
45
+
46
+
47
+ def denied(fn, label):
48
+ try:
49
+ fn()
50
+ except AccessDenied:
51
+ ok(True, label)
52
+ return
53
+ ok(False, label)
54
+
55
+
56
+ BF_OK = "# 功能名:文件摄取分派\n\n按扩展名把文件路由到对应摄取器。\n"
57
+ BF_NONE = "# 功能名:无来源历史记忆\n\n迁移入库,未留下任何条件声明。\n"
58
+ BF_PARTIAL = "# 功能名:半来源历史记忆\n\n只有验证基底,没有子功能与执行。\n"
59
+ BF_HINT = "# 功能名:盲区线索\n\n非既有事实,待补条件。\n"
60
+ CAP_DOC = ("# 功能名:全库导出\n# 生效条件:本地离线环境\n"
61
+ "# 子功能:把整库导出为可搬运 JSONL\n# 执行:流式写 JSONL\n"
62
+ "# 验证方式:实测数据(benchmark / 采样)\n# 不适用条件:无持久化介质\n")
63
+
64
+ KEK = b"0123456789abcdef0123456789abcdef" # 32 字节(crypto 硬要求)
65
+
66
+
67
+ def _seed(root):
68
+ """造 6 类节点 + 1 条密文节点,覆盖回填分流的所有分支。"""
69
+ cg = MdCGOS(root)
70
+ # 四槽齐备 → 生效条件可由条件空间合成(唯一合法结构化来源)
71
+ cg.add("bf_ok", BF_OK, layer="knowledge",
72
+ condition_space={
73
+ "observation_position": "本地文件系统(摄取器内部观测位)",
74
+ "observation_tool": "扩展名映射表 + Ingestor",
75
+ "time_window": [0.0, 9999999999.0],
76
+ "existence_constraint": "仅对本地可读文件生效"},
77
+ verification_basis="test",
78
+ non_applicable_conditions=["二进制文件", "超过 50MB 的文件"],
79
+ state_attributes={"comment": {
80
+ "子功能": "按扩展名路由到对应摄取器",
81
+ "执行": "读后缀映射表→调用对应 Ingestor"}})
82
+ # 只有单槽 → **不得**冒充生效条件(观测位置 ≠ 生效条件)
83
+ cg.add("bf_posonly",
84
+ "# 功能名:只有观测位置的历史记忆\n\n条件空间只声明了一维。\n",
85
+ layer="knowledge",
86
+ condition_space={"observation_position": "某个外部观测位"},
87
+ state_attributes={"comment": {
88
+ "子功能": "待补:条件空间其余三维",
89
+ "执行": "待补"}})
90
+ cg.add("bf_none", BF_NONE, layer="knowledge")
91
+ cg.add("bf_partial", BF_PARTIAL, layer="knowledge", verification_basis="data")
92
+ cg.add("bf_hint", BF_HINT, layer="contextual", tags=["gap_hint"])
93
+ cg.add("cap_export", CAP_DOC, layer="knowledge", verification_basis="test")
94
+ cg.flush()
95
+
96
+ sec = MdCGSecure(root, principal=Principal(
97
+ actor="p32", clearance="private", can_write=True, can_admin=True),
98
+ master_key=KEK)
99
+ sec.add("bf_lock", "# 功能名:私有密文记忆\n\n敏感正文,不进回填。\n",
100
+ layer="knowledge", sensitivity="private")
101
+ sec.flush()
102
+ cg.rebuild_index() # 让明文实例也看见密文节点(否则计数缺失)
103
+ return cg
104
+
105
+
106
+ def main():
107
+ global PASS, FAIL
108
+ tmp = tempfile.mkdtemp(prefix="mdcg_p32_")
109
+ try:
110
+ root = os.path.join(tmp, "root")
111
+ os.makedirs(root, exist_ok=True)
112
+ cg = _seed(root)
113
+
114
+ # ---------- ① op 契约链 ----------
115
+ blob = json.dumps(KERNEL_TOOLS, ensure_ascii=False)
116
+ ok("backfill" in blob and "cap_history" in blob,
117
+ "①工具 schema 声明 maintain 的 backfill/cap action")
118
+ ok(all(a in MdCGOS.MAINTAIN_ACTIONS for a in backfill.ACTIONS),
119
+ "①ALL_OPS 之外的 action 真源 == MAINTAIN_ACTIONS")
120
+ ok(set(backfill.CAP_RULES) <= set(tokens.ALL_OPS),
121
+ "①cap 规则只含真实 op(不注入无效能力名)")
122
+
123
+ # ---------- ② backfill 预演(不改盘) ----------
124
+ p1 = backfill.plan(cg)
125
+ ok(p1["dry_run"] is True, "②预演标记 dry_run")
126
+ ok("bf_ok" in p1["planned_ids"], "②有来源节点进入可回填清单")
127
+ ok(p1["targeted"] >= 1, "②targeted 计数非零")
128
+ ok(p1["skipped_locked"] >= 1, "②密文节点计入 skipped_locked")
129
+ ok(p1["unfillable"] >= 1, "②无来源节点计入 unfillable(不编造)")
130
+ ok(p1["skipped_derived"] >= 1, "②gap_hint 脚手架计入 skipped_derived")
131
+ ok(p1["skipped_partial"] >= 1, "②补完仍不全者默认跳过(partial)")
132
+ item = next(i for i in p1["items"] if i["id"] == "bf_ok")
133
+ ok(item["class"] == "backfillable", "②bf_ok 判定为 backfillable")
134
+ ok(item["fill"]["验证方式"]["basis"].startswith(
135
+ "frontmatter.verification_basis"), "②验证方式来源可追溯(basis)")
136
+ fm0, c0 = cg._read(cg.index["nodes"]["bf_ok"])
137
+ ok("# 子功能:" not in c0, "②预演不改盘(正文无新增 CCG 行)")
138
+
139
+ # 口径修正防回归:单槽 observation_position **不得**冒充生效条件
140
+ fm_po, c_po = cg._read(cg.index["nodes"]["bf_posonly"])
141
+ ok("生效条件" not in backfill.derive_fields(fm_po, c_po),
142
+ "②单槽 observation_position 不生成生效条件(观测位置 ≠ 生效条件)")
143
+ ok(nodefile.condition_space_text(
144
+ fm_po.get("condition_space")) == "",
145
+ "②四槽不全时合成函数返回空串(部分槽不构成声明)")
146
+
147
+ # 待补台账:缺声明且推不出的节点必须可见,不得静默 ACCEPT
148
+ pend = backfill.conditions_pending(cg)
149
+ ok(pend["dry_run"] is True, "②待补台账标记 dry_run")
150
+ po = next((i for i in pend["items"] if i["id"] == "bf_posonly"), None)
151
+ # add 在未给 time_window 时以写入时刻锚定 1 小时观测窗,故缺方法 + 约束
152
+ ok(po is not None and po["missing_slots"] == [
153
+ "observation_tool", "existence_constraint"],
154
+ "②单槽节点进待补台账并逐条列出缺失槽")
155
+ ok("bf_ok" not in [i["id"] for i in pend["items"]],
156
+ "②四槽齐备节点不进待补台账")
157
+
158
+ # ---------- ③ backfill 落盘 ----------
159
+ fm_pre = json.loads(json.dumps(cg._read(
160
+ cg.index["nodes"]["bf_ok"])[0].get("state_attributes")))
161
+ a1 = backfill.apply(cg, batch="p32a", actor="tester")
162
+ ok(a1["written"] >= 1, "③apply 写入非零")
163
+ fm1, c1 = cg._read(cg.index["nodes"]["bf_ok"])
164
+ ok(nodefile.ccg_completeness(c1)["complete"] is True,
165
+ "③bf_ok 回填后 CCG 要素齐全(6 行缺一不可)")
166
+ synth = nodefile.condition_space_text(fm1.get("condition_space"))
167
+ ok(f"# 生效条件:{synth}" in c1,
168
+ "③生效条件 == 条件空间四槽合成(唯一口径)")
169
+ ok("观测位置:" not in c1, "③正文不再出现单槽冒充形态")
170
+ ok(nodefile.is_full_time_window(
171
+ (fm1.get("condition_space") or {}).get("time_window")),
172
+ "③前置:全时窗哨兵([0, 1e10] 表示任意时刻成立)")
173
+ ok("全时窗(任意时刻成立)" in c1,
174
+ "③全时窗渲染为可读声明(不落裸数组进召回键)")
175
+ ok(fm1.get("state_attributes", {}).get("comment", {}).get("子功能")
176
+ == "按扩展名路由到对应摄取器", "③frontmatter.comment 同步")
177
+ ok(fm1.get("non_applicable_conditions") == ["二进制文件", "超过 50MB 的文件"],
178
+ "③不适用条件同步为列表")
179
+ h = backfill.history(cg, action="backfill")
180
+ ok(h["total"] >= 1, "③留痕 _backfill.jsonl 非空")
181
+ it = next(r for r in h["records"] if r["node"] == "bf_ok")
182
+ ok("basis" in it["fields"]["执行"] and it["fields"]["执行"]["after"],
183
+ "③留痕含 before/after/依据")
184
+
185
+ # 幂等:再跑一次不重复写
186
+ a2 = backfill.apply(cg, batch="p32a", actor="tester")
187
+ ok(a2["written"] == 0, "③幂等:已完成节点不重复写")
188
+
189
+ # ---------- ④ 回滚(真逆操作:还原而非暴力删除) ----------
190
+ rb = backfill.rollback(cg, batch="p32a", actor="tester")
191
+ ok(rb["reverted"] >= 1, "④回滚撤销非零")
192
+ fm2, c2 = cg._read(cg.index["nodes"]["bf_ok"])
193
+ ok("# 子功能:" not in c2, "④回滚移除 CCG 行")
194
+ ok(fm2.get("state_attributes") == fm_pre,
195
+ "④frontmatter 还原到改写前(comment 不被误删)")
196
+ ok(backfill.apply(cg, batch="p32a2", actor="tester")["written"] >= 1,
197
+ "④还原后可再次回填(回滚为真逆操作)")
198
+ backfill.rollback(cg, batch="p32a2", actor="tester")
199
+
200
+ # 守卫:回填后再人工改写 → 不覆盖
201
+ backfill.apply(cg, batch="p32b", actor="tester")
202
+ e = cg.index["nodes"]["bf_ok"]
203
+ fm3, c3 = cg._read(e)
204
+ c3 = c3.replace("# 执行:读后缀映射表→调用对应 Ingestor",
205
+ "# 执行:人工改写过的执行方式")
206
+ cg._write_node("bf_ok", os.path.join(root, e["path"]), fm3, c3,
207
+ durable=True)
208
+ cg.rebuild_index()
209
+ rb2 = backfill.rollback(cg, batch="p32b", actor="tester")
210
+ fm4, c4 = cg._read(cg.index["nodes"]["bf_ok"])
211
+ ok("人工改写过的执行方式" in c4, "④回滚守卫:人工改写字段不被撤销")
212
+ ok(rb2["conflict"] >= 1, "④回滚守卫计入 conflict")
213
+
214
+ # ---------- ⑤ 密文 fail-closed ----------
215
+ e5 = cg.index["nodes"]["bf_lock"]
216
+ _, c5 = cg._read(e5)
217
+ ok(crypto.is_encrypted(c5), "⑤前置:bf_lock 正文确为密文")
218
+ ok("bf_lock" not in backfill.plan(cg)["planned_ids"], "⑤回填跳过密文节点")
219
+ ok("bf_lock" not in backfill.cap_plan(cg)["planned_ids"], "⑤cap 跳过密文节点")
220
+
221
+ # ---------- ⑥ cap 注入 ----------
222
+ cp = backfill.cap_plan(cg)
223
+ ok(cp["dry_run"] is True, "⑥cap 预演标记 dry_run")
224
+ hit = next((i for i in cp["items"] if i["id"] == "cap_export"), None)
225
+ ok(hit is not None, "⑥关键词命中节点进入 cap 清单")
226
+ exp = next(h for h in hit["caps"] if h["cap"] == "export")
227
+ ok("导出" in exp["matched_by"], "⑥cap 命中依据(matched_by)可核查")
228
+ ok(0.0 < exp["confidence"] <= 1.0, "⑥cap 置信度落在 (0,1]")
229
+ ca = backfill.cap_apply(cg, batch="p32cap", actor="tester")
230
+ ok(ca["written"] >= 1, "⑥cap_apply 写入非零")
231
+ fm6, _ = cg._read(cg.index["nodes"]["cap_export"])
232
+ ok("cap:export" in (fm6.get("tags") or []), "⑥cap:export 注入 frontmatter.tags")
233
+ ca2 = backfill.cap_apply(cg, batch="p32cap", actor="tester")
234
+ ok(ca2["written"] == 0, "⑥cap 幂等:重复注入不重复写")
235
+ rt = call_tool(cg, "cg", {"op": "route", "query": "全库导出 JSONL"})
236
+ caps = rt.get("suggested_capabilities") or []
237
+ ok("export" in caps, "⑥route 返回建议能力名(cap: 标签生效)")
238
+ crb = backfill.cap_rollback(cg, batch="p32cap", actor="tester")
239
+ fm7, _ = cg._read(cg.index["nodes"]["cap_export"])
240
+ ok("cap:export" not in (fm7.get("tags") or []), "⑦cap_rollback 移除标签")
241
+ ok(crb["reverted"] >= 1, "⑦cap_rollback 计数非零")
242
+
243
+ # ---------- ⑧ 预览脱敏 ----------
244
+ plain = MdCGOS(root)
245
+ tl = stg.timeline(plain, limit=100)
246
+ pv = next((i["preview"] for i in tl["items"] if i["id"] == "bf_lock"), None)
247
+ ok(pv == stg.PLACEHOLDER_LOCKED, "⑧无密钥:密文节点预览为占位符")
248
+ ok(pv is not None and "ENC1" not in pv and "封" not in pv,
249
+ "⑧预览不回显密文碎片")
250
+ sec_pub = MdCGSecure(root, principal=Principal(
251
+ actor="p32pub", clearance="internal", can_write=False),
252
+ master_key=KEK)
253
+ tl2 = stg.timeline(sec_pub, limit=100)
254
+ pv2 = next((i["preview"] for i in tl2["items"] if i["id"] == "bf_lock"), None)
255
+ # issue #35 起:stg 各 op 与 _candidates 同口径——**不可读节点不进时间线**,
256
+ # 比「进列表再给占位符」更严:连 id 与存在性都不泄露。
257
+ ok(pv2 is None and all(i["id"] != "bf_lock" for i in tl2["items"]),
258
+ "⑧密级不足:节点不进时间线(不泄露存在性)")
259
+ # 纵深防御:候选层之外的调用面(直接取预览)仍给无权限占位符,不泄露正文
260
+ ok(stg._preview(sec_pub, "bf_lock") == stg.PLACEHOLDER_DENIED,
261
+ "⑧密级不足:_preview 给无权限占位符")
262
+ sec_ok = MdCGSecure(root, principal=Principal(
263
+ actor="p32", clearance="private", can_write=True, can_admin=True),
264
+ master_key=KEK)
265
+ tl3 = stg.timeline(sec_ok, limit=100)
266
+ pv3 = next((i["preview"] for i in tl3["items"] if i["id"] == "bf_lock"), None)
267
+ ok(pv3 is not None and "敏感正文" in pv3, "⑧有密钥且有权限:预览为明文")
268
+
269
+ # ---------- ⑨ 权限分档 ----------
270
+ wp = Principal(role="reflection", actor="p32w", clearance="internal",
271
+ can_write=True, can_admin=False, ops_allow=("maintain",))
272
+ wcg = MdCGSecure(root, principal=wp)
273
+ ok(call_tool(wcg, "cg", {"op": "maintain", "action": "backfill"})
274
+ .get("dry_run") is True, "⑨写层可预演 backfill")
275
+ ok(call_tool(wcg, "cg", {"op": "maintain", "action": "cap"})
276
+ .get("dry_run") is True, "⑨写层可预演 cap")
277
+ denied(lambda: call_tool(wcg, "cg", {"op": "maintain",
278
+ "action": "backfill", "apply": True}),
279
+ "⑨写层不可 apply backfill(需管理权)")
280
+ denied(lambda: call_tool(wcg, "cg", {"op": "maintain",
281
+ "action": "cap", "apply": True}),
282
+ "⑨写层不可 apply cap(需管理权)")
283
+ denied(lambda: call_tool(wcg, "cg", {"op": "maintain",
284
+ "action": "cap_rollback"}),
285
+ "⑨写层不可 cap_rollback(需管理权)")
286
+ try:
287
+ call_tool(cg, "cg", {"op": "maintain", "action": "backfill_nope"})
288
+ ok(False, "⑨未知 action 应报错而非静默成功")
289
+ except Exception: # noqa: BLE001
290
+ ok(True, "⑨未知 action 报错")
291
+
292
+ # ---------- 收尾 ----------
293
+ print(f"\nP32 结果:{PASS} 通过 / {FAIL} 失败")
294
+ if FAILS:
295
+ print("失败项:")
296
+ for f in FAILS:
297
+ print(f" - {f}")
298
+ finally:
299
+ shutil.rmtree(tmp, ignore_errors=True)
300
+ return 1 if FAIL else 0
301
+
302
+
303
+ if __name__ == "__main__":
304
+ raise SystemExit(main())