package ctxmgr import ( "os" "strings" "sync" ) const ( EnvSemanticSummaryShadow = "SEMANTIC_SUMMARY_SHADOW" // 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. "*" widens // eligibility to every session (present and future), mirroring // INTENT_PLAN_PROJECT_ALLOWLIST=*; the master flag still gates globally. EnvSemanticSummaryReplaceSessions = "SEMANTIC_SUMMARY_REPLACE_SESSIONS" ) // 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 // ReplaceAllSessions is the "*" wildcard for ReplaceSessions: every // session (present and future) is eligible while the master Replace flag // stays on. An empty allowlist still replaces nothing. ReplaceAllSessions bool ReplaceSessions map[string]struct{} } var ( summaryFlagsMu sync.RWMutex activeSummaryFlags = loadSummaryFlagsFromEnv() ) func loadSummaryFlagsFromEnv() SummaryFeatureFlags { sessions, all := parseReplaceSessions(os.Getenv(EnvSemanticSummaryReplaceSessions)) return SummaryFeatureFlags{ Shadow: envBoolDefault(EnvSemanticSummaryShadow, false), Replace: envBoolDefault(EnvSemanticSummaryReplace, false), ReplaceAllSessions: all, ReplaceSessions: sessions, } } // parseReplaceSessions splits the comma-separated allowlist. Any "*" token // widens to all sessions; empty or ID-only values keep the explicit set. func parseReplaceSessions(raw string) (map[string]struct{}, bool) { out := make(map[string]struct{}) for _, part := range strings.Split(raw, ",") { id := strings.TrimSpace(part) if id == "*" { return nil, true } if id != "" { out[id] = struct{}{} } } return out, false } func envBoolDefault(key string, def bool) bool { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") } // CurrentSummaryFlags returns the active summary feature flags. func CurrentSummaryFlags() SummaryFeatureFlags { summaryFlagsMu.RLock() defer summaryFlagsMu.RUnlock() return activeSummaryFlags } // SemanticSummaryShadowEnabled reports whether shadow semantic summary is on. func SemanticSummaryShadowEnabled() bool { return CurrentSummaryFlags().Shadow } // 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 (or the allowlist // is the "*" wildcard). Empty allowlist replaces no session. func SemanticReplaceEnabledFor(sessionID string) bool { f := CurrentSummaryFlags() if !f.Replace || sessionID == "" { return false } if f.ReplaceAllSessions { return true } _, ok := f.ReplaceSessions[sessionID] return ok } // SetSummaryFlagsForTest overrides flags in tests. func SetSummaryFlagsForTest(f SummaryFeatureFlags) { summaryFlagsMu.Lock() activeSummaryFlags = f summaryFlagsMu.Unlock() } // ResetSummaryFlagsForTest restores flags from the process environment. func ResetSummaryFlagsForTest() { summaryFlagsMu.Lock() activeSummaryFlags = loadSummaryFlagsFromEnv() summaryFlagsMu.Unlock() }