feat(ctxmgr): wire fail-closed gray REPLACE for chat semantic narrative

Unseal SEMANTIC_SUMMARY_REPLACE under explicit user authorization with a
defense-in-depth session allowlist (SEMANTIC_SUMMARY_REPLACE_SESSIONS):
swap happens only for allowlisted sessions after result digests and served
watermark validation, bounded narrative size; facts/counts stay deterministic
and the daily digest stays advisory. Master flag alone replaces nothing.
This commit is contained in:
weichen
2026-09-25 22:56:36 +00:00
parent 4d5d695c75
commit ffa41d78df
8 changed files with 368 additions and 26 deletions
+1
View File
@@ -18,6 +18,7 @@
| owner | task | branch | worktree | mutex | status |
|---|---|---|---|---|---|
| pi | chat-semantic-replace-2026-09-26 | feature/chat-semantic-replace-2026-09-26 | .worktrees/chat-semantic-replace-2026-09-26 | chat | in_progress |
| pi | jevdecide-soak-2026-09-26 | feature/jevdecide-soak-2026-09-26 | .worktrees/jevdecide-soak-2026-09-26 | jevdecide | done |
| pi | dialogue-ctx-closeout-2026-09-26-r4 | feature/dialogue-ctx-closeout-2026-09-26 | .worktrees/dialogue-ctx-closeout-2026-09-26 | dialogue-ctx | done |
| pi | dialogue-ctx-closeout-2026-09-26-r2 | feature/dialogue-ctx-closeout-2026-09-26 | .worktrees/dialogue-ctx-closeout-2026-09-26 | dialogue-ctx | in_progress |
@@ -0,0 +1,63 @@
# chat-semantic-replace fail-closed 灰度接线(short-term)
- **task_id**: chat-semantic-replace-2026-09-26
- **owner**: pi
- **status**: in_progress
- **授权来源**: 用户会话指令「把 FEATURE-MATRIX 中的 …chat-semantic-replace 完成;严格按规范」——构成该行关闭条件中要求的「用户独立授权」(Wave5 X-2 密封条件:启用 REPLACE 需独立显式授权 + fail-closed 灰度设计)
- **write_scope**: internal/ctxmgr, internal/sysadmin, .doc
- **mutex**: chat
- **base_dev_sha**: 4d5d695c75d8a59452fcb2946b0b1217553e0c4e
- **capabilities**: docs:publish, docs:approve, env:dev
- **policy_revision**: AGENTS 2026-09 / agent-worktree-workflow r1 / validation-contract 2026-09-21
- **review_state**: pending
- **integration_state**: pending
- **deployment**: pending
- **cleanup**: pending
- **remote_sync**: not_requested
## 一、当前进度分析
现状(dev):`SEMANTIC_SUMMARY_REPLACE` 可读但密封不接线——`shadow_task.go:274` 与 `semantic_summary.go:367` 显式 `_ = SemanticSummaryReplaceEnabled()` 不换叙事;sysadmin 目录登记为 placeholder。shadow 链路(durable jobs/租约/watermark/可见集 digest/provenance)已生产化且 2026-09-23 收口重验 PASS。
关闭条件:用户独立授权(本任务授权来源满足)+ **fail-closed 灰度设计**。本行声明范围仅「chat 会话 served Narrative」;daily digest headline 不在替换范围(保持 advisory)。
## 二、方案(fail-closed 灰度)
### 2.1 双开关(默认全关)
- `SEMANTIC_SUMMARY_REPLACE`(master,默认 false,语义不变)
- 新增 `SEMANTIC_SUMMARY_REPLACE_SESSIONS`:会话 ID 逗号分隔 allowlist。**allowlist 为空时不替换任何会话**(即使 master 开——纵深防御,误开 master 无全局影响)
### 2.2 替换点与条件(全部满足才换,任一失败保留 deterministic)
接线点:`shadow_task.go persistShadowProvenance`(后台 durable job 完成处,serve 前持久化):
1. master on 且 `session ∈ allowlist`
2. shadow report `Status == ok`(模型成功、结果 digest/watermark 校验通过)
3. `ValidateShadowWatermark` 通过(revision/visibility/watermark 与当前 served 摘要一致——不换旧摘要)
4. 叙事非空且 ≤ `semanticReplaceMaxRunes`(4096)——超限视为模型异常,fail-closed
替换动作:`current.Narrative = report.Narrative`;`current.Source = "semantic_replace"`;FreshnessNote 记录替换时间戳;Facts/计数保持 deterministic(声明范围仅 Narrative)。回滚:关 master(或摘除会话)+ 下一轮摘要自然回 deterministic(确定性摘要为消息纯函数)。
### 2.3 不变式
- 摘要请求仍 no-tool、R13 最小化授权可见集;替换不改变可见集 digest 校验
- daily digest(`applyDigestSemanticShadow`)继续 advisory-only
- 单测覆盖:off 保留 / 非空 allowlist 未命中保留 / 命中+OK 替换 / 命中+fallback 保留 / 命中+stale 保留 / 超长保留
- sysadmin 目录 REPLACE 行从 placeholder 改 live+allowlist 说明;features envs 列表补 allowlist 键
### 2.4 验证
- Go 单测(上述矩阵)+ `arch-check --mode block` + `go vet` + `go test -short ./...`
- dev 运行时验收:dev 会话开启 shadow+replace(allowlist) → 真实对话后 served Narrative 换为语义叙事(chat digest API 可见 Source=semantic_replace);同会话关 master → 下一轮回 deterministic;非 allowlist 会话不受影响
## 三、计划
1. [x] 分析(§一)
2. [ ] 实现 §2(flags/allowlist/替换点/单测/sysadmin 目录)
3. [ ] 工程门禁 + 候选集成 dev
4. [ ] dev 运行时验收(真实链路 swap + 回滚 + allowlist 隔离)
5. [ ] 测试报告 + 矩阵行更新 + 收口清理
## 四、结果登记
(完成后回填)
+207
View File
@@ -0,0 +1,207 @@
package ctxmgr
import (
"context"
"strings"
"testing"
"time"
)
func replaceBatch() []ChatMessageRecord {
return []ChatMessageRecord{
{ID: 1, Role: "user", Content: "我们准备研发高频超声手术刀系统,请教立项建议", CreatedAt: time.Now()},
{ID: 2, Role: "assistant", Content: "超声手术设备属于二类或三类器械。", CreatedAt: time.Now()},
{ID: 3, Role: "user", Content: "目标市场是国内及欧洲CE,项目预算大约在200万左右", CreatedAt: time.Now()},
}
}
func drainShadow(t *testing.T, m *ContextManager, sessionID string) *ChatSummary {
t.Helper()
ctx := context.Background()
_ = m.DrainSemanticShadowTasks(ctx)
if !m.WaitShadowIdle(2 * time.Second) {
t.Fatal("shadow worker did not become idle")
}
sum, err := m.GetChatSummary(ctx, sessionID)
if err != nil || sum == nil {
t.Fatalf("get summary: %v %+v", err, sum)
}
return sum
}
// Master flag on but empty allowlist: nothing is replaced (defense in depth).
func TestSemanticReplace_EmptyAllowlistKeepsDeterministic(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true, Replace: true})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_empty", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_empty")
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") {
t.Fatalf("empty allowlist must keep deterministic, got source=%s narrative=%.60s", sum.Source, sum.Narrative)
}
}
// Allowlisted session + OK report: served narrative swaps with provenance.
func TestSemanticReplace_AllowlistedSessionSwapsNarrative(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_hit": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_hit", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_hit")
if sum.Source != "semantic_replace" {
t.Fatalf("expected semantic_replace source, got %s", sum.Source)
}
if sum.Narrative != "语义影子叙事:超声刀立项" {
t.Fatalf("narrative not replaced: %.80s", sum.Narrative)
}
if !strings.Contains(sum.FreshnessNote, "semantic_replace") {
t.Fatalf("freshness note missing replace provenance: %s", sum.FreshnessNote)
}
// Facts/counts stay deterministic under the declared scope.
if sum.Facts["device_type"] != "超声手术设备" {
t.Fatalf("deterministic facts lost: %+v", sum.Facts)
}
}
// Non-allowlisted session on the same config: deterministic retained.
func TestSemanticReplace_NonAllowlistedKeepsDeterministic(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_other": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_miss", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_miss")
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") {
t.Fatalf("non-allowlisted session must keep deterministic, got %s", sum.Source)
}
}
// Failing summarizer on an allowlisted session: fail-closed keeps deterministic.
func TestSemanticReplace_ModelErrorFailsClosed(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_err": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Err: context.DeadlineExceeded})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_err", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_err")
if sum.Source != "deterministic" || sum.Shadow == nil || sum.Shadow.Status != ShadowStatusFallback {
t.Fatalf("expected fallback with deterministic retained, got source=%s shadow=%+v", sum.Source, sum.Shadow)
}
}
// Oversized narrative: treated as anomaly, deterministic retained.
func TestSemanticReplace_OversizedNarrativeFailsClosed(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_big": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: strings.Repeat("超", semanticReplaceMaxRunes+10)})
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_big", nil, replaceBatch()); err != nil {
t.Fatal(err)
}
sum := drainShadow(t, m, "sess_r_big")
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "超超超") {
t.Fatalf("oversized narrative must fail closed, got source=%s", sum.Source)
}
if !strings.Contains(sum.FreshnessNote, "narrative_out_of_bounds") {
t.Fatalf("missing out-of-bounds note: %s", sum.FreshnessNote)
}
}
// Stale job (summary advanced past the job's revision): watermark guard
// rejects persistence; no swap even if the session is eligible.
func TestSemanticReplace_StaleWatermarkKeepsDeterministic(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: true, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_stale": {}},
})
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
sum1, err := m.GenerateRollingSummary(ctx, "sess_r_stale", nil, replaceBatch())
if err != nil {
t.Fatal(err)
}
// Turn shadow scheduling off so the advanced summary enqueues no new job;
// replacement flags stay armed to prove only the watermark guard rejects.
SetSummaryFlagsForTest(SummaryFeatureFlags{
Shadow: false, Replace: true,
ReplaceSessions: map[string]struct{}{"sess_r_stale": {}},
})
// Advance the served summary to a later revision.
advanced := append(replaceBatch(),
ChatMessageRecord{ID: 4, Role: "user", Content: "补充:预算调整为300万,型检周期多久", CreatedAt: time.Now()})
if _, err := m.GenerateRollingSummary(ctx, "sess_r_stale", nil, advanced); err != nil {
t.Fatal(err)
}
// Replay the stale (rev1) OK report directly: watermark must reject it.
staleJob := SemanticShadowJob{
SessionID: "sess_r_stale", SummaryRevision: sum1.Revision,
InputDigest: "digest-rev1", VisibilityDigest: sum1.VisibilityDigest,
WatermarkToMessageID: sum1.ToMessageID,
}
okReport := &SemanticShadowReport{
Status: ShadowStatusOK, Narrative: "旧版语义叙事", InputDigest: "digest-rev1",
Model: "stub-semantic-v1", Protocol: semanticProtocolV1, GeneratedAt: time.Now(),
}
if err := m.persistShadowProvenance(ctx, staleJob, okReport, nil); err == nil {
t.Fatal("stale job must be rejected by watermark guard")
}
sum := drainShadow(t, m, "sess_r_stale")
if sum.Source == "semantic_replace" || strings.Contains(sum.Narrative, "旧版语义叙事") {
t.Fatalf("stale report must not replace, got source=%s narrative=%.60s", sum.Source, sum.Narrative)
}
}
// Flag parse: allowlist parsing and fail-closed accessor.
func TestSemanticReplace_FlagParsing(t *testing.T) {
t.Cleanup(ResetSummaryFlagsForTest)
got := parseReplaceSessions(" a , b ,, ")
if len(got) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(got))
}
if _, ok := got["a"]; !ok {
t.Fatal("session a missing")
}
if SemanticReplaceEnabledFor("") {
t.Fatal("empty session must never be eligible")
}
SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true, ReplaceSessions: map[string]struct{}{"s1": {}}})
if !SemanticReplaceEnabledFor("s1") {
t.Fatal("s1 should be eligible")
}
if SemanticReplaceEnabledFor("s2") {
t.Fatal("s2 must not be eligible")
}
SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true})
if SemanticReplaceEnabledFor("s1") {
t.Fatal("master on + empty allowlist must replace nothing")
}
}
+3 -3
View File
@@ -362,9 +362,9 @@ func applySemanticShadow(ctx context.Context, sum *ChatSummary, msgs []ChatMessa
if report.Protocol == "" {
report.Protocol = semanticProtocolV1
}
// Explicitly do NOT swap sum.Narrative even if REPLACE env is true
// (Wave 5 X-2 sealed; see wave5-deadcode-decisions.md#x-2).
_ = SemanticSummaryReplaceEnabled()
// Explicitly do NOT swap sum.Narrative here: the rolling-chat replacement
// path runs in persistShadowProvenance under the fail-closed gray rollout;
// this advisory path (daily digest sync) stays deterministic.
sum.Shadow = report
}
+34 -3
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"strings"
"unicode/utf8"
"time"
)
@@ -16,7 +17,9 @@ const (
)
// SemanticShadowJob is a durable unit of work for advisory semantic generation.
// It never replaces the served deterministic Narrative (REPLACE stays unwired).
// Under the fail-closed gray rollout (2026-09-26) a validated OK report may
// replace the served Narrative for allowlisted sessions only; every failure
// path keeps the deterministic narrative.
type SemanticShadowJob struct {
ID int64 `json:"id,omitempty"`
SessionID string `json:"session_id"`
@@ -270,8 +273,8 @@ func (m *ContextManager) runShadowJob(ctx context.Context, job SemanticShadowJob
if report.Protocol == "" {
report.Protocol = semanticProtocolV1
}
// Explicitly do NOT swap Narrative even if REPLACE env is true.
_ = SemanticSummaryReplaceEnabled()
// Narrative replacement (if enabled) happens in persistShadowProvenance
// only after the watermark check against the current served summary.
_ = m.persistShadowProvenance(ctx, job, report, nil)
}
@@ -350,6 +353,7 @@ func (m *ContextManager) persistShadowProvenance(ctx context.Context, job Semant
current.ProtocolVersion = report.Protocol
}
current.Source = "deterministic"
applySemanticReplace(current, report)
saveErr := m.SaveChatSummary(ctx, current)
finishErr := runErr
if saveErr != nil && finishErr == nil {
@@ -359,6 +363,33 @@ func (m *ContextManager) persistShadowProvenance(ctx context.Context, job Semant
return saveErr
}
// semanticReplaceMaxRunes bounds the model narrative eligible to serve;
// oversized output is treated as a model anomaly and fails closed.
const semanticReplaceMaxRunes = 4096
// applySemanticReplace swaps the served Narrative with the validated shadow
// narrative under the fail-closed gray rollout: master flag on, session in
// the allowlist, report OK, and narrative within bounds. Any miss keeps the
// deterministic narrative. Facts/counts stay deterministic (declared scope:
// narrative only).
func applySemanticReplace(sum *ChatSummary, report *SemanticShadowReport) {
if sum == nil || report == nil || report.Status != ShadowStatusOK {
return
}
if !SemanticReplaceEnabledFor(sum.SessionID) {
return
}
n := strings.TrimSpace(report.Narrative)
if n == "" || utf8.RuneCountInString(n) > semanticReplaceMaxRunes {
report.FreshnessNote = fmt.Sprintf("semantic_replace_fallback: narrative_out_of_bounds; deterministic retained at %s", time.Now().UTC().Format(time.RFC3339))
sum.FreshnessNote = report.FreshnessNote
return
}
sum.Narrative = n
sum.Source = "semantic_replace"
sum.FreshnessNote = fmt.Sprintf("semantic_replace: narrative replaced from %s at %s; deterministic regenerates on next turn after rollback", report.Model, time.Now().UTC().Format(time.RFC3339))
}
func (m *ContextManager) finishShadowJob(ctx context.Context, job SemanticShadowJob, runErr error) {
if m.pool == nil || job.ID == 0 {
return
+44 -15
View File
@@ -8,20 +8,25 @@ import (
const (
EnvSemanticSummaryShadow = "SEMANTIC_SUMMARY_SHADOW"
// SEALED (Wave 5 X-2, 2026-09-18): REPLACE must stay unwired indefinitely per
// system-health-remediation-plan.md Wave5 + red-line "SEMANTIC_SUMMARY_REPLACE
// 不接线". Enabling REPLACE to swap served narrative requires a separate
// explicit user authorization + fail-closed rollout design; do not wire it as
// a side effect of an unrelated change. See .doc/ops/short-term/2026-09-18-
// wave5-deadcode-decisions.md#x-2.
// REPLACE was sealed by Wave 5 X-2 (2026-09-18). Unsealed 2026-09-26 under an
// explicit user authorization for the chat-semantic-replace closeout task:
// wiring is fail-closed and gray-released via a session allowlist. Enabling
// the master flag alone replaces nothing while the allowlist is empty.
// See .doc/ops/short-term/2026-09-26-chat-semantic-replace.md and
// .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-2 (history).
EnvSemanticSummaryReplace = "SEMANTIC_SUMMARY_REPLACE"
// EnvSemanticSummaryReplaceSessions is the fail-closed gray-release
// allowlist: comma-separated chat session IDs eligible for narrative
// replacement. Empty allowlist (default) replaces no session.
EnvSemanticSummaryReplaceSessions = "SEMANTIC_SUMMARY_REPLACE_SESSIONS"
)
// SummaryFeatureFlags controls semantic summary rollout.
// REPLACE is reserved and must remain unwired in PU6 shadow delivery (Wave 5 X-2 sealed).
// SummaryFeatureFlags controls semantic summary rollout. REPLACE swaps the
// served chat Narrative only for allowlisted sessions (fail-closed gray).
type SummaryFeatureFlags struct {
Shadow bool
Replace bool
Shadow bool
Replace bool
ReplaceSessions map[string]struct{}
}
var (
@@ -31,11 +36,23 @@ var (
func loadSummaryFlagsFromEnv() SummaryFeatureFlags {
return SummaryFeatureFlags{
Shadow: envBoolDefault(EnvSemanticSummaryShadow, false),
Replace: envBoolDefault(EnvSemanticSummaryReplace, false),
Shadow: envBoolDefault(EnvSemanticSummaryShadow, false),
Replace: envBoolDefault(EnvSemanticSummaryReplace, false),
ReplaceSessions: parseReplaceSessions(os.Getenv(EnvSemanticSummaryReplaceSessions)),
}
}
func parseReplaceSessions(raw string) map[string]struct{} {
out := make(map[string]struct{})
for _, part := range strings.Split(raw, ",") {
id := strings.TrimSpace(part)
if id != "" {
out[id] = struct{}{}
}
}
return out
}
func envBoolDefault(key string, def bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
@@ -56,13 +73,25 @@ func SemanticSummaryShadowEnabled() bool {
return CurrentSummaryFlags().Shadow
}
// SemanticSummaryReplaceEnabled reports the REPLACE env bit.
// SEALED (Wave 5 X-2): PU6 must not use this to swap the served deterministic narrative.
// See .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-2.
// SemanticSummaryReplaceEnabled reports the REPLACE master env bit.
// Narrative replacement additionally requires the session allowlist
// (SemanticReplaceEnabledFor); the master bit alone replaces nothing.
func SemanticSummaryReplaceEnabled() bool {
return CurrentSummaryFlags().Replace
}
// SemanticReplaceEnabledFor reports fail-closed gray eligibility: the master
// flag must be on AND the session must be in the allowlist (empty allowlist
// replaces no session).
func SemanticReplaceEnabledFor(sessionID string) bool {
f := CurrentSummaryFlags()
if !f.Replace || sessionID == "" {
return false
}
_, ok := f.ReplaceSessions[sessionID]
return ok
}
// SetSummaryFlagsForTest overrides flags in tests.
func SetSummaryFlagsForTest(f SummaryFeatureFlags) {
summaryFlagsMu.Lock()
+4 -3
View File
@@ -79,8 +79,8 @@ var featureMaturityCatalog = map[string]featureMaturity{
reason: "影子组件默认关闭;dev/test schema 61 已含 chat_summaries。摘要请求已用 reasoning profile,pi-agent 经 bind-mount 的 pi-args.mjs 带 --no-tools。授权可见集 digest 仍空",
},
"SEMANTIC_SUMMARY_REPLACE": {
state: implPlaceholder, adapter: adapterUnavailable,
reason: "替换路径明确未接线,打开开关不会替换叙事",
state: implShadow, adapter: adapterDegraded,
reason: "fail-closed 灰度已接线(2026-09-26):验证 OK 后仅替换白名单会话 served Narrative;Facts/计数保持 deterministic;digest 仍 advisory;默认关",
},
"PI_CHANGE_REPORT_NARRATIVE": {
state: implShadow, adapter: adapterDegraded,
@@ -121,7 +121,8 @@ var featureCatalog = []featureSpec{
{key: "MULTI_INTENT_U12_RUN_CONTROL", label: "U12 运行控制适配器", group: "intentflow", fallback: "false", description: "workflow/run-control 动作适配器"},
{key: "WORKFLOW_SCOPE_MODE", label: "工作流作用域裁决", group: "intent", fallback: "strict", description: "文档命中裁决:strict 命中他流即拦截;weighted 按工作流关系权重打分"},
{key: "SEMANTIC_SUMMARY_SHADOW", label: "语义摘要影子", group: "ctxmgr", fallback: "false", description: "上下文语义摘要影子生成"},
{key: "SEMANTIC_SUMMARY_REPLACE", label: "语义摘要替换", group: "ctxmgr", fallback: "false", description: "语义摘要替换确定性叙事(保留位,未接线)"},
{key: "SEMANTIC_SUMMARY_REPLACE", label: "语义摘要替换", group: "ctxmgr", fallback: "false", description: "验证 OK 后以语义叙事替换会话 served Narrative;fail-closed 灰度,需同时配置会话白名单"},
{key: "SEMANTIC_SUMMARY_REPLACE_SESSIONS", label: "语义摘要替换白名单", group: "ctxmgr", fallback: "", description: "逗号分隔会话 ID;空则不替换任何会话(纵深防御)"},
{key: "PI_CHANGE_REPORT_NARRATIVE", label: "PI 变更报告叙事", group: "docrevision", fallback: "false", description: "变更报告 PI 叙事增强;默认仅确定性回退"},
}
+12 -2
View File
@@ -188,8 +188,18 @@ func TestFeatures_ConfiguredValueDoesNotImplyReadiness(t *testing.T) {
}
}
replace := byKey["SEMANTIC_SUMMARY_REPLACE"]
if replace.Value != "true" || replace.ImplementationState != "placeholder" || replace.AdapterReadiness != "unavailable" {
t.Fatalf("replace=true must stay unwired: %+v", replace)
// 2026-09-26 fail-closed gray wiring: the master bit alone replaces
// nothing (session allowlist required); configured=true must not read as
// rollout-ready.
if replace.Value != "true" || replace.ImplementationState != "shadow" || replace.AdapterReadiness != "degraded" {
t.Fatalf("replace must report shadow/degraded under fail-closed gray: %+v", replace)
}
if !strings.Contains(replace.Description, "白名单") {
t.Fatalf("replace description must surface the allowlist requirement: %+v", replace)
}
allow := byKey["SEMANTIC_SUMMARY_REPLACE_SESSIONS"]
if allow.Value != "" {
t.Fatalf("replace sessions allowlist must default to empty (replaces nothing): %+v", allow)
}
tier := byKey["MULTI_INTENT_PLAN_TIER"]
if tier.Value != "confirm" || tier.ImplementationState != "live" || tier.AdapterReadiness != "degraded" {