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:
@@ -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. [ ] 测试报告 + 矩阵行更新 + 收口清理
|
||||
|
||||
## 四、结果登记
|
||||
|
||||
(完成后回填)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 叙事增强;默认仅确定性回退"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user