From ffa41d78dfff6426c96f86a3083dd072ec3a99eb Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 25 Sep 2026 22:56:36 +0000 Subject: [PATCH] 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. --- .doc/ops/CURRENT.md | 1 + .../2026-09-26-chat-semantic-replace.md | 63 ++++++ internal/ctxmgr/semantic_replace_test.go | 207 ++++++++++++++++++ internal/ctxmgr/semantic_summary.go | 6 +- internal/ctxmgr/shadow_task.go | 37 +++- internal/ctxmgr/summary_flags.go | 59 +++-- internal/sysadmin/features.go | 7 +- internal/sysadmin/service_test.go | 14 +- 8 files changed, 368 insertions(+), 26 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-26-chat-semantic-replace.md create mode 100644 internal/ctxmgr/semantic_replace_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 6e090d77..230b01c9 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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 | diff --git a/.doc/ops/short-term/2026-09-26-chat-semantic-replace.md b/.doc/ops/short-term/2026-09-26-chat-semantic-replace.md new file mode 100644 index 00000000..42bbebd7 --- /dev/null +++ b/.doc/ops/short-term/2026-09-26-chat-semantic-replace.md @@ -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. [ ] 测试报告 + 矩阵行更新 + 收口清理 + +## 四、结果登记 + +(完成后回填) diff --git a/internal/ctxmgr/semantic_replace_test.go b/internal/ctxmgr/semantic_replace_test.go new file mode 100644 index 00000000..40eb7c6d --- /dev/null +++ b/internal/ctxmgr/semantic_replace_test.go @@ -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") + } +} diff --git a/internal/ctxmgr/semantic_summary.go b/internal/ctxmgr/semantic_summary.go index 5540eafa..dae85f4f 100644 --- a/internal/ctxmgr/semantic_summary.go +++ b/internal/ctxmgr/semantic_summary.go @@ -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 } diff --git a/internal/ctxmgr/shadow_task.go b/internal/ctxmgr/shadow_task.go index 193b39a3..9ae7b92e 100644 --- a/internal/ctxmgr/shadow_task.go +++ b/internal/ctxmgr/shadow_task.go @@ -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 diff --git a/internal/ctxmgr/summary_flags.go b/internal/ctxmgr/summary_flags.go index c6d43a26..8153bbd4 100644 --- a/internal/ctxmgr/summary_flags.go +++ b/internal/ctxmgr/summary_flags.go @@ -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() diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index 664535dc..273139ab 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -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 叙事增强;默认仅确定性回退"}, } diff --git a/internal/sysadmin/service_test.go b/internal/sysadmin/service_test.go index 5dcb0ea5..68c1a9ff 100644 --- a/internal/sysadmin/service_test.go +++ b/internal/sysadmin/service_test.go @@ -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" {