From e077658a61dc5fbb6cb5182bc104f6e03cc21996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 2 Aug 2026 15:36:21 +0100 Subject: [PATCH] refactor: centralize runtime feature flag bindings (#2142) --- .agents/skills/runtime-feature-flags/SKILL.md | 104 +++++ .agents/skills/using-agent-skills/SKILL.md | 1 + AGENTS.md | 8 +- apps/backend/AGENTS.md | 30 +- apps/backend/internal/common/config/config.go | 8 +- .../internal/common/config/config_test.go | 70 +++- apps/backend/internal/profiles/profiles.yaml | 3 +- .../internal/profiles/profiles_test.go | 29 +- apps/backend/internal/runtimeflags/config.go | 80 ++-- .../backend/internal/runtimeflags/registry.go | 211 ++++++---- .../internal/runtimeflags/registry_test.go | 312 +++++++++++++- apps/web/app/actions/features.test.ts | 32 ++ apps/web/app/actions/features.ts | 13 +- .../tests/settings/feature-toggles-helpers.ts | 23 -- .../slices/features/features-contract.test.ts | 24 ++ .../slices/features/features-slice.test.ts | 38 +- .../state/slices/features/features-slice.ts | 12 +- apps/web/lib/state/slices/features/types.ts | 24 +- docs/decisions/0007-runtime-feature-flags.md | 58 ++- .../0018-runtime-settings-overrides.md | 10 +- ...26-08-01-release-toggle-gating-contract.md | 78 ++++ docs/decisions/INDEX.md | 1 + docs/plans/release-feature-toggles/plan.md | 227 +++++++++++ .../task-01-centralize-backend-bindings.md | 59 +++ .../task-02-consolidate-frontend-contract.md | 54 +++ ...ask-03-document-release-toggle-workflow.md | 55 +++ docs/public/configuration.md | 24 +- docs/public/extending-kandev.md | 34 +- docs/specs/feature-toggles/spec.md | 379 ++++++++---------- 29 files changed, 1495 insertions(+), 506 deletions(-) create mode 100644 .agents/skills/runtime-feature-flags/SKILL.md delete mode 100644 apps/web/e2e/tests/settings/feature-toggles-helpers.ts create mode 100644 apps/web/lib/state/slices/features/features-contract.test.ts create mode 100644 docs/decisions/2026-08-01-release-toggle-gating-contract.md create mode 100644 docs/plans/release-feature-toggles/plan.md create mode 100644 docs/plans/release-feature-toggles/task-01-centralize-backend-bindings.md create mode 100644 docs/plans/release-feature-toggles/task-02-consolidate-frontend-contract.md create mode 100644 docs/plans/release-feature-toggles/task-03-document-release-toggle-workflow.md diff --git a/.agents/skills/runtime-feature-flags/SKILL.md b/.agents/skills/runtime-feature-flags/SKILL.md new file mode 100644 index 0000000000..6138c352cd --- /dev/null +++ b/.agents/skills/runtime-feature-flags/SKILL.md @@ -0,0 +1,104 @@ +--- +name: runtime-feature-flags +description: Add, roll out, promote, graduate, or remove Kandev runtime feature flags and release toggles across the backend and frontend. Use whenever a task mentions a feature flag, release toggle, staged rollout, kill switch, or graduating a flag. +--- + +# Runtime Feature Flags + +Use this checklist for temporary release toggles and risky features. It is +self-contained: do not depend on the agent having read an ADR or public docs. +Read `apps/backend/AGENTS.md` and `apps/web/AGENTS.md` when the change touches +those subtrees. + +## Invariants + +- The backend is authoritative. A disabled feature must not be reachable through + HTTP, WebSocket, MCP, agent-tool, or background-job entry points. +- A new release toggle is off in every shipped profile when it is merged. The + disabled path preserves the existing behavior and fails closed before deriving + data, writing state, dispatching work, or exposing a capability. +- Effective values are ordered: explicit environment variable, SQLite override, + then profile default. An explicit environment variable locks the admin UI. +- Use one identity across layers: `features.`, + `KANDEV_FEATURES_`, a Go `FeaturesConfig` field, its JSON tag, + and the matching frontend key. Never add a parallel flag map or switch. +- Never reuse an identity listed in `retiredRuntimeFlagIdentities`. + +## Add a flag + +Update these layers in the same change: + +1. **Profile default:** add `KANDEV_FEATURES_` under `features:` in the + root `profiles.yaml`; use `prod: "false"`, `dev: "false"`, and `e2e: "false"` + for a release toggle unless the task explicitly documents a test-only + exception. +2. **Backend config:** add a `bool` field with explicit `mapstructure` and + `json` tags to `apps/backend/internal/common/config/config.go`. +3. **Registry binding:** add exactly one complete registration to + `apps/backend/internal/runtimeflags/registry.go`: key, env var, kind, label, + description, stability, risk metadata, restart/mutability metadata, typed + `read`, and typed `apply` functions. +4. **Backend gates:** gate construction and every enabled-only entry path at + the narrow composition boundary. Do not only hide the frontend; direct + callers must receive the legacy behavior or a safe rejection. +5. **Frontend contract:** add the all-off key to + `apps/web/lib/state/slices/features/types.ts`. Use `useFeature()` for client + surfaces and `notFound()` from the relevant server layout/page when a route + subtree must be unavailable. SSR data must remain fail-closed. +6. **Tests:** add enabled/disabled behavior tests for each changed backend path + and frontend surface. Run the existing registry/profile/frontend contract + tests; add focused tests for normalization, route visibility, and disabled + side-effect prevention where applicable. + +The completeness checks require exact equality between profile keys, typed +backend fields/registrations, and frontend default keys. They do not discover +semantic call sites, so trace the feature's HTTP, WebSocket, MCP, agent, worker, +and startup paths manually. + +## Roll out and promote + +1. Merge with all shipped profile defaults off. +2. Enable one installation through **Settings > System > Feature Toggles** or an + explicit environment variable, restart when metadata requires it, and test + real workflows. +3. When ready for everyone, change only the `prod` profile value to `"true"` for + the next release. Retain the registry entry and backend/frontend gates as a + kill switch so operators can still disable the feature. + +## Graduate and remove the flag + +After the feature has proven itself as the default-on behavior, make the new +behavior unconditional and remove the live flag end-to-end: + +- remove the profile entry and `FeaturesConfig` field; +- remove the active registry registration; +- remove backend conditionals and legacy branches; +- remove the frontend default, `useFeature()` checks, and route gates; +- remove flag-specific tests and documentation while keeping permanent behavior + coverage. + +Before removing the registration, append its exact key and environment variable +to `retiredRuntimeFlagIdentities` in `registry.go`: + +```go +{key: "features.example", envVar: "KANDEV_FEATURES_EXAMPLE"}, +``` + +Do not delete old `runtime_flag_overrides` rows as part of graduation. Unknown +rows are intentionally inert, and the retired identity prevents stale operator +state from reactivating a future feature. Never reuse either the key or env var. + +## Verification and handoff + +Run the focused checks appropriate to the change: + +- from `apps/backend`: `rtk go test ./internal/runtimeflags ./internal/common/config ./internal/profiles`; +- from the repository root: `rtk make -C apps/backend lint`; +- from `apps`: `rtk pnpm --filter @kandev/web test -- lib/state/slices/features/features-contract.test.ts`; +- from `apps/web`: `rtk pnpm run typecheck` and `rtk pnpm run lint`; +- run affected E2E coverage when the gated surface is user-visible; +- run `rtk git diff --check` before handoff. + +Report the flag key/env identity, profile defaults, every gated entry path, +disabled/enabled test evidence, restart requirements, and whether the change is +still a kill switch or has been fully graduated. diff --git a/.agents/skills/using-agent-skills/SKILL.md b/.agents/skills/using-agent-skills/SKILL.md index f1067f50bb..6b2ba926cb 100644 --- a/.agents/skills/using-agent-skills/SKILL.md +++ b/.agents/skills/using-agent-skills/SKILL.md @@ -27,6 +27,7 @@ Task arrives |-- Test strategy or coverage gaps? --------> /tdd or /e2e in the current conversation |-- Add debug logs? ------------------------> /debug |-- Add Jira/Linear-style integration? -----> /add-integration +|-- Add/roll out/promote/graduate/remove a runtime feature flag or release toggle? -> /runtime-feature-flags |-- Validate implementation? ----------------> /tdd plus exact task-defined tests/E2E |-- Need local QA/review/simplification? ----> only on explicit user request or PR finding |-- Improve skills/agents/commands? --------> /harness-improvement diff --git a/AGENTS.md b/AGENTS.md index 195b8cb2a0..7951cc9842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,11 +185,11 @@ Production Kandev plugins live in dedicated repositories, not in this monorepo. **`profiles.yaml` at the repo root** is the single source of truth for env-driven runtime defaults — feature flags, mock providers (agent / GitHub / Jira / Linear), debug switches, and e2e tuning knobs. The backend embeds it (`//go:embed` via `apps/backend/internal/profiles/`) and at startup calls `profiles.ApplyProfile()` to write the matching profile's env vars onto its own process, *only when each var is not already set* — so launchers, shells, and per-spec overrides still win. -Runtime feature toggles add a SQLite-backed override tier managed through `Settings > System > Feature Toggles`. Effective values use this precedence: explicit environment variable > SQLite override > profile default. The typed runtime flag registry lives in `apps/backend/internal/runtimeflags/registry.go`; add or update registry metadata when exposing a flag in the UI. +Runtime feature toggles add a SQLite-backed override tier managed through `Settings > System > Feature Toggles`. Effective values use this precedence: explicit environment variable > SQLite override > profile default. The typed runtime flag registry lives in `apps/backend/internal/runtimeflags/registry.go`; each registration owns the public metadata, environment variable, config reader, and config applier. Do not add parallel per-flag maps or switches. -Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` → `dev`, otherwise `prod`. `apps/cli/src/dev.ts` and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values. +Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` or `KANDEV_DEBUG_PPROF_ENABLED=true` → `dev`, otherwise `prod`. `apps/cli/src/dev.ts` and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values. -To flip a feature on for every user: change its `prod:` to `"true"` in `profiles.yaml`. To add a new feature flag: 1 line in `profiles.yaml` + 1 `FeaturesConfig` field + the runtime flag registry entry when it should be user-toggleable + the gate at the call site + the frontend additions (`FeatureFlags` type, `useFeature` checks, `notFound()` from a server-side layout). Full pattern in `docs/decisions/0007-runtime-feature-flags.md`; runtime overrides and restart support are documented in `docs/decisions/0018-runtime-settings-overrides.md` and `docs/decisions/0019-restart-supervisor.md`. +For any task that adds, rolls out, promotes, graduates, or removes a release toggle, use `/runtime-feature-flags`. That skill contains the file-by-file checklist, disabled-path requirements, test commands, promotion procedure, and retired-identity removal steps; do not rely on an agent discovering an ADR or public docs. In brief: merge risky features off in every shipped profile, enable a selected install with an admin override or explicit environment, restart and test, then change `prod:` to `"true"` for the all-user release while retaining the registry entry as a kill-switch. Remove the live flag after the feature is permanent, move its key and environment variable to the append-only retired identities in `runtimeflags/registry.go`, and never reuse either identity. Completeness tests cover the registry/profile/frontend contracts. Runtime overrides and restart support are documented in `docs/decisions/0018-runtime-settings-overrides.md` and `docs/decisions/0019-restart-supervisor.md`. --- @@ -207,4 +207,4 @@ For developing in ephemeral cloud VMs (Cursor Cloud, Codex, GitHub Codespaces, e --- -**Last Updated**: 2026-07-27 +**Last Updated**: 2026-08-01 diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 16a6135430..9f17e5e465 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -28,11 +28,7 @@ apps/backend/ │ │ ├── settings/ # Agent settings │ │ ├── mcpconfig/ # MCP server configuration │ │ └── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI -│ ├── auth/ # Opt-in user authentication + per-user scoping -│ │ ├── authn/ # Request-identity plumbing (context + gin helpers, RequireAdmin) -│ │ ├── httpmw/ # Global enforcement middleware (allowlist, synthetic identity) -│ │ ├── httpapi/ # /api/v1/auth/* + /api/v1/users endpoints -│ │ └── store/ # auth_identities, auth_sessions, auth_api_tokens, auth_invites +│ ├── auth/ # Opt-in auth, per-user scoping, middleware, API, store │ ├── agentctl/ │ │ └── server/ # agentctl HTTP server │ │ ├── acp/ # ACP protocol implementation @@ -60,27 +56,9 @@ apps/backend/ │ │ ├── models/ # Task, Session, Executor, Message models │ │ ├── repository/ # Database access (SQLite) │ │ └── service/ # Task business logic -│ ├── office/ # Autonomous agent management (Office feature) -│ │ ├── agents/ # Agent instance CRUD + auth guards -│ │ ├── approvals/ # Approval requests and decisions -│ │ ├── channels/ # External integration channels (webhooks) -│ │ ├── config/ # Config sync (DB ↔ filesystem) -│ │ ├── configloader/ # Filesystem config reader/writer -│ │ ├── costs/ # Cost tracking and budget policies -│ │ ├── dashboard/ # Dashboard API, issues, activity, live runs -│ │ ├── infra/ # GC, reconciliation -│ │ ├── labels/ # Task labels -│ │ ├── onboarding/ # Workspace onboarding wizard API -│ │ ├── projects/ # Project management -│ │ ├── repository/ # Office SQLite persistence -│ │ ├── runtime/ # Agent run context, capabilities, and runtime action surface -│ │ ├── routines/ # Scheduled recurring tasks -│ │ ├── routing/ # Provider routing: resolver, validators, catalogue, backoff, agent-overrides -│ │ ├── scheduler/ # Wakeup scheduler (duplicate of service scheduler features) -│ │ ├── service/ # Core office service (wakeups, event subscribers, execution policy) -│ │ ├── shared/ # Shared interfaces and activity logging -│ │ ├── skills/ # Skill injection and materialization -│ │ └── workspaces/ # Workspace deletion handler +│ ├── office/ # Autonomous agent management (agents, approvals, channels, config, costs, +│ │ # dashboard, infra, labels, onboarding, projects, repository, runtime, +│ │ # routines, routing, scheduler, service, shared, skills, workspaces) │ ├── events/ # Event bus for internal pub/sub │ ├── gateway/ # WebSocket gateway │ ├── github/ # GitHub API integration (PRs, reviews, webhooks) diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 31bf1da0aa..48cb26c7cc 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -365,9 +365,11 @@ type VoiceConfig struct { OpenAIAPIKey string `mapstructure:"openAIApiKey"` } -// FeaturesConfig is the central registry of runtime feature flags. Every flag -// defaults to false so production binaries ship with new work hidden until a -// deployment explicitly opts in (env var, e.g. KANDEV_FEATURES_OFFICE=true). +// FeaturesConfig is the typed wire/config shape for runtime feature flags. +// Every new release toggle defaults to false so production binaries ship with +// new work hidden until a deployment explicitly opts in (env var, e.g. +// KANDEV_FEATURES_OFFICE=true). The runtimeflags package owns metadata and +// typed config bindings. // // The struct doubles as the wire shape for GET /api/v1/features — `json` tags // keep the field names lowercase and the handler in helpers.go just calls diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index ed906f6a7d..fe6a3be363 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" ) @@ -167,14 +168,15 @@ func TestValidate_PostgresSSLMode(t *testing.T) { }) } -// TestFeatures_ProductionDefaults pins the production policy: in-progress -// features remain off, while the shipped app status bar remains on unless a -// deployment or saved runtime override explicitly disables it. +// TestFeatures_ProductionDefaults pins the production policy: new and +// in-progress features remain off until a deployment explicitly opts in. func TestFeatures_ProductionDefaults(t *testing.T) { // Force a clean env so KANDEV_FEATURES_* and profile-selector vars from the // host shell cannot change the production-profile defaults under test. t.Setenv("KANDEV_FEATURES_OFFICE", "") unsetEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR") + unsetEnv(t, "KANDEV_FEATURES_AUTH") + unsetEnv(t, "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF") t.Setenv("KANDEV_DEBUG_DEV_MODE", "") t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "") t.Setenv("KANDEV_E2E_MOCK", "") @@ -190,6 +192,12 @@ func TestFeatures_ProductionDefaults(t *testing.T) { if cfg.Features.AppStatusBar { t.Error("Features.AppStatusBar = true, want false (status surface must remain opt-in by default)") } + if cfg.Features.Auth { + t.Error("Features.Auth = true, want false (authentication must remain opt-in by default)") + } + if cfg.Features.ClaudeBackgroundPromptHandoff { + t.Error("Features.ClaudeBackgroundPromptHandoff = true, want false (experiment must remain opt-in by default)") + } } // TestFeatures_OfficeEnabledByEnv proves the documented opt-in path: @@ -409,24 +417,54 @@ func TestServerHostsFromConfigWhenHostUnset(t *testing.T) { } // TestFeaturesConfig_JSONShape pins the wire format of GET /api/v1/features. -// The handler in helpers.go serializes FeaturesConfig directly so new -// fields flow through without an extra edit; this test guarantees the -// `json` tag is present on every field. A regression (struct field added -// without a tag) would surface as a capitalized JSON key and break the -// frontend's case-sensitive read in apps/web/app/actions/features.ts. +// The handler in helpers.go serializes FeaturesConfig directly so new fields +// flow through without an extra edit. Every feature field must remain a bool +// with explicit JSON and profile/mapstructure names so the runtime flag +// registry and frontend contract can derive from the same declaration. func TestFeaturesConfig_JSONShape(t *testing.T) { - cfg := FeaturesConfig{ - Office: true, - AppStatusBar: true, - Auth: true, + typeOfFeatures := reflect.TypeOf(FeaturesConfig{}) + cfgValue := reflect.New(typeOfFeatures).Elem() + seenJSONNames := make(map[string]struct{}, typeOfFeatures.NumField()) + seenMapstructureNames := make(map[string]struct{}, typeOfFeatures.NumField()) + for i := 0; i < typeOfFeatures.NumField(); i++ { + field := typeOfFeatures.Field(i) + if field.Type.Kind() != reflect.Bool { + t.Fatalf("FeaturesConfig.%s has type %s; want bool", field.Name, field.Type) + } + jsonName := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonName == "" || jsonName == "-" || jsonName == field.Name { + t.Fatalf("FeaturesConfig.%s has invalid json tag %q", field.Name, field.Tag.Get("json")) + } + if _, exists := seenJSONNames[jsonName]; exists { + t.Fatalf("FeaturesConfig has duplicate json tag %q", jsonName) + } + seenJSONNames[jsonName] = struct{}{} + mapstructureName := strings.Split(field.Tag.Get("mapstructure"), ",")[0] + if mapstructureName == "" || mapstructureName == "-" || mapstructureName == field.Name { + t.Fatalf("FeaturesConfig.%s has invalid mapstructure tag %q", field.Name, field.Tag.Get("mapstructure")) + } + if _, exists := seenMapstructureNames[mapstructureName]; exists { + t.Fatalf("FeaturesConfig has duplicate mapstructure tag %q", mapstructureName) + } + seenMapstructureNames[mapstructureName] = struct{}{} + cfgValue.Field(i).SetBool(true) } + cfg := cfgValue.Interface().(FeaturesConfig) raw, err := json.Marshal(cfg) if err != nil { t.Fatalf("json.Marshal: %v", err) } - got := string(raw) - want := `{"office":true,"appStatusBar":true,"auth":true,"claudeBackgroundPromptHandoff":false}` - if got != want { - t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want) + var decoded map[string]bool + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if len(decoded) != typeOfFeatures.NumField() { + t.Fatalf("FeaturesConfig JSON has %d fields; want %d", len(decoded), typeOfFeatures.NumField()) + } + for i := 0; i < typeOfFeatures.NumField(); i++ { + jsonName := strings.Split(typeOfFeatures.Field(i).Tag.Get("json"), ",")[0] + if got, ok := decoded[jsonName]; !ok || !got { + t.Errorf("FeaturesConfig JSON missing true field %q: %#v", jsonName, decoded) + } } } diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index a7a8b3996d..39f42d58fc 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -24,7 +24,8 @@ # # Set its `prod` value to `true`. Ship the next release. Once the # feature has been on by default for a release or two and is -# permanent, rip the flag out of the codebase entirely. +# permanent, remove its live wiring and move its key/environment pair +# to runtimeflags' append-only retired-identity set. # # ── See also ─────────────────────────────────────────────────────── # diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index e4bf967922..aca687493c 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -13,6 +13,10 @@ import ( func TestApplyProfile_DefaultsToProd(t *testing.T) { clearProfileSelectors(t) clearProfilesYAMLVars(t) + defaults, err := FeatureFlagDefaults() + if err != nil { + t.Fatalf("FeatureFlagDefaults: %v", err) + } count, env, err := ApplyProfile() if err != nil { @@ -21,9 +25,16 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { if env != EnvProd { t.Errorf("env = %q, want %q (no selector env vars set)", env, EnvProd) } - // Prod writes each registered feature flag with its safe shipped default. - if count != 4 { - t.Errorf("ApplyProfile wrote %d vars in prod; want 4", count) + // Prod writes every declared feature flag with its shipped default. Derive + // the count so adding a flag cannot require another hard-coded update here. + if count != len(defaults) { + t.Errorf("ApplyProfile wrote %d vars in prod; want %d profile feature defaults", count, len(defaults)) + } + for shortName, want := range defaults { + envVar := featurePrefix + strings.ToUpper(shortName) + if got := os.Getenv(envVar); got != want { + t.Errorf("%s = %q after prod ApplyProfile; want %q", envVar, got, want) + } } if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" { t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false") @@ -175,6 +186,18 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) { } } +func TestProfilesYAMLFeatureKeysUseRequiredPrefix(t *testing.T) { + file, err := parse(profilesYAML) + if err != nil { + t.Fatalf("parse profiles YAML: %v", err) + } + for envVar := range file["features"] { + if _, ok := stripFeaturePrefix(envVar); !ok { + t.Errorf("profiles.yaml feature key %q must start with %q and include a name", envVar, featurePrefix) + } + } +} + func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) { clearAppliedEnvVars(t) diff --git a/apps/backend/internal/runtimeflags/config.go b/apps/backend/internal/runtimeflags/config.go index 861dbaab1a..23b147ea87 100644 --- a/apps/backend/internal/runtimeflags/config.go +++ b/apps/backend/internal/runtimeflags/config.go @@ -9,35 +9,23 @@ import ( ) const ( - featureOfficeKey = "features.office" - featureAppStatusBarKey = "features.appStatusBar" - featureAuthKey = "features.auth" - featureClaudeBackgroundPromptHandoffKey = "features.claudeBackgroundPromptHandoff" - debugDevModeKey = "debug.devMode" - envFeaturesOffice = "KANDEV_FEATURES_OFFICE" - envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR" - envFeaturesAuth = "KANDEV_FEATURES_AUTH" - envFeaturesClaudeBackgroundPromptHandoff = "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF" - envDebugDevMode = "KANDEV_DEBUG_DEV_MODE" - envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED" - envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES" + envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED" + envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES" ) func OptionsFromConfig(cfg *config.Config) Options { + envValues := make(map[string]bool, len(registrations)) + for _, registration := range registrations { + envValues[registration.definition.EnvVar] = isTruthy(os.Getenv(registration.definition.EnvVar)) + for _, impliedEnvVar := range registration.definition.ImpliedEnvVars { + envValues[impliedEnvVar] = isTruthy(os.Getenv(impliedEnvVar)) + } + } + return Options{ DefaultValues: ValuesFromConfig(cfg), RuntimeValues: ValuesFromConfig(cfg), - EnvValues: map[string]bool{ - envFeaturesOffice: isTruthy(os.Getenv(envFeaturesOffice)), - envFeaturesAppStatusBar: isTruthy(os.Getenv(envFeaturesAppStatusBar)), - envFeaturesAuth: isTruthy(os.Getenv(envFeaturesAuth)), - envFeaturesClaudeBackgroundPromptHandoff: isTruthy( - os.Getenv(envFeaturesClaudeBackgroundPromptHandoff), - ), - envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)), - envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)), - envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)), - }, + EnvValues: envValues, IsExplicitEnv: func(name string) bool { _, ok := os.LookupEnv(name) return ok && !profiles.WasApplied(name) @@ -46,41 +34,35 @@ func OptionsFromConfig(cfg *config.Config) Options { } func ValuesFromConfig(cfg *config.Config) map[string]bool { - debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled - return map[string]bool{ - featureOfficeKey: cfg.Features.Office, - featureAppStatusBarKey: cfg.Features.AppStatusBar, - featureAuthKey: cfg.Features.Auth, - featureClaudeBackgroundPromptHandoffKey: cfg.Features.ClaudeBackgroundPromptHandoff, - debugDevModeKey: debugEnabled, + values := make(map[string]bool, len(registrations)) + for _, registration := range registrations { + values[registration.definition.Key] = registration.read(cfg) } + return values } func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) { for _, state := range states { - switch state.Key { - case featureOfficeKey: - cfg.Features.Office = state.EffectiveValue - case featureAppStatusBarKey: - cfg.Features.AppStatusBar = state.EffectiveValue - case featureAuthKey: - cfg.Features.Auth = state.EffectiveValue - case featureClaudeBackgroundPromptHandoffKey: - cfg.Features.ClaudeBackgroundPromptHandoff = state.EffectiveValue - case debugDevModeKey: - cfg.Debug.DevMode = state.EffectiveValue - cfg.Debug.PprofEnabled = state.EffectiveValue - if state.EffectiveValue { - setIfNotExplicit(envDebugAgentMessages, "true") - setIfNotExplicit(envDebugPprofEnabled, "true") - } else { - unsetIfNotExplicit(envDebugAgentMessages) - unsetIfNotExplicit(envDebugPprofEnabled) - } + registration, ok := registrationByKey(state.Key) + if !ok { + continue } + registration.apply(cfg, state.EffectiveValue) } } +func applyDebugMode(cfg *config.Config, enabled bool) { + cfg.Debug.DevMode = enabled + cfg.Debug.PprofEnabled = enabled + if enabled { + setIfNotExplicit(envDebugAgentMessages, "true") + setIfNotExplicit(envDebugPprofEnabled, "true") + return + } + unsetIfNotExplicit(envDebugAgentMessages) + unsetIfNotExplicit(envDebugPprofEnabled) +} + func RuntimeOptionsFromAppliedConfig(defaults map[string]bool, cfg *config.Config) Options { opts := OptionsFromConfig(cfg) opts.DefaultValues = defaults diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index b93747e6e8..ff48aea9bd 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -1,91 +1,150 @@ package runtimeflags -var definitions = []RuntimeFlagDefinition{ +import "github.com/kandev/kandev/internal/common/config" + +// runtimeFlagRegistration keeps the public metadata and the typed config +// binding for a flag together. The function fields stay internal so the HTTP +// registry response remains metadata-only. +type runtimeFlagRegistration struct { + definition RuntimeFlagDefinition + read func(*config.Config) bool + apply func(*config.Config, bool) +} + +type runtimeFlagIdentity struct { + key string + envVar string +} + +// retiredRuntimeFlagIdentities is append-only. When a flag graduates, remove +// its active registration and move its identity here. Persisted overrides for +// unknown keys are intentionally retained, so neither the key nor environment +// variable may ever be reused for a different flag. +var retiredRuntimeFlagIdentities = []runtimeFlagIdentity{ + {key: "features.plugins", envVar: "KANDEV_FEATURES_PLUGINS"}, +} + +var registrations = []runtimeFlagRegistration{ { - Key: featureOfficeKey, - EnvVar: envFeaturesOffice, - Kind: KindFeature, - Label: "Office mode", - Description: "Enables autonomous agent office workflows and related settings.", - Stability: StabilityExperimental, - RiskLevel: RiskMedium, - RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " + - "may change between releases and should be reviewed before relying on them.", - RestartRequired: true, - Mutable: true, - }, - { - Key: featureAppStatusBarKey, - EnvVar: envFeaturesAppStatusBar, - Kind: KindFeature, - Label: "App status bar", - Description: "Adds the global connection, optional host metrics, and plugin status surface.", - Stability: StabilityStable, - RiskLevel: RiskLow, - RiskDescription: "Changing this adds or removes the desktop and tablet status bar and the phone Status drawer entry " + - "after restart. It does not stop connections, metrics collection requested by other clients, or plugins.", - RestartRequired: true, - Mutable: true, - }, - { - Key: featureAuthKey, - EnvVar: envFeaturesAuth, - Kind: KindFeature, - Label: "Authentication & users", - Description: "Requires every visitor to sign in and gives each user their own private workspaces. The first person to sign in after enabling becomes the admin.", - Stability: StabilityExperimental, - RiskLevel: RiskHigh, - RiskDescription: "Turning this ON locks the instance behind a login after restart — the first visitor " + - "completes a setup wizard and becomes the admin, and existing workspaces/secrets are assigned to " + - "them. Turning it OFF after restart makes the instance open to anyone who can reach it again. " + - "Enable it before exposing kandev on a shared or public network.", - RestartRequired: true, - Mutable: true, - }, - { - Key: featureClaudeBackgroundPromptHandoffKey, - EnvVar: envFeaturesClaudeBackgroundPromptHandoff, - Kind: KindFeature, - Label: "Claude background prompt handoff", - Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.", - Stability: StabilityExperimental, - RiskLevel: RiskHigh, - RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " + - "Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " + - "Use it only for controlled testing and disable it if a session behaves unexpectedly.", - RestartRequired: true, - Mutable: true, - }, - { - Key: debugDevModeKey, - EnvVar: envDebugDevMode, - Kind: KindDebug, - Label: "Debug mode", - Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.", - Stability: StabilityStable, - RiskLevel: RiskHigh, - RiskDescription: "Debug mode can expose local diagnostic endpoints and write prompt, file, " + - "and tool-call content to local debug logs. Enable it only on trusted machines.", - RestartRequired: true, - Mutable: true, - ImpliedEnvVars: []string{ - envDebugPprofEnabled, - envDebugAgentMessages, + definition: RuntimeFlagDefinition{ + Key: "features.office", + EnvVar: "KANDEV_FEATURES_OFFICE", + Kind: KindFeature, + Label: "Office mode", + Description: "Enables autonomous agent office workflows and related settings.", + Stability: StabilityExperimental, + RiskLevel: RiskMedium, + RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " + + "may change between releases and should be reviewed before relying on them.", + RestartRequired: true, + Mutable: true, }, + read: func(cfg *config.Config) bool { return cfg.Features.Office }, + apply: func(cfg *config.Config, value bool) { cfg.Features.Office = value }, + }, + { + definition: RuntimeFlagDefinition{ + Key: "features.appStatusBar", + EnvVar: "KANDEV_FEATURES_APP_STATUS_BAR", + Kind: KindFeature, + Label: "App status bar", + Description: "Adds the global connection, optional host metrics, and plugin status surface.", + Stability: StabilityStable, + RiskLevel: RiskLow, + RiskDescription: "Changing this adds or removes the desktop and tablet status bar and the phone Status drawer entry " + + "after restart. It does not stop connections, metrics collection requested by other clients, or plugins.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.AppStatusBar }, + apply: func(cfg *config.Config, value bool) { cfg.Features.AppStatusBar = value }, + }, + { + definition: RuntimeFlagDefinition{ + Key: "features.auth", + EnvVar: "KANDEV_FEATURES_AUTH", + Kind: KindFeature, + Label: "Authentication & users", + Description: "Requires every visitor to sign in and gives each user their own private workspaces. The first person to sign in after enabling becomes the admin.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Turning this ON locks the instance behind a login after restart — the first visitor " + + "completes a setup wizard and becomes the admin, and existing workspaces/secrets are assigned to " + + "them. Turning it OFF after restart makes the instance open to anyone who can reach it again. " + + "Enable it before exposing kandev on a shared or public network.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.Auth }, + apply: func(cfg *config.Config, value bool) { cfg.Features.Auth = value }, + }, + { + definition: RuntimeFlagDefinition{ + Key: "features.claudeBackgroundPromptHandoff", + EnvVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", + Kind: KindFeature, + Label: "Claude background prompt handoff", + Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " + + "Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " + + "Use it only for controlled testing and disable it if a session behaves unexpectedly.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.ClaudeBackgroundPromptHandoff }, + apply: func(cfg *config.Config, value bool) { cfg.Features.ClaudeBackgroundPromptHandoff = value }, + }, + { + definition: RuntimeFlagDefinition{ + Key: "debug.devMode", + EnvVar: "KANDEV_DEBUG_DEV_MODE", + Kind: KindDebug, + Label: "Debug mode", + Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.", + Stability: StabilityStable, + RiskLevel: RiskHigh, + RiskDescription: "Debug mode can expose local diagnostic endpoints and write prompt, file, " + + "and tool-call content to local debug logs. Enable it only on trusted machines.", + RestartRequired: true, + Mutable: true, + ImpliedEnvVars: []string{ + envDebugPprofEnabled, + envDebugAgentMessages, + }, + }, + read: func(cfg *config.Config) bool { return cfg.Debug.DevMode || cfg.Debug.PprofEnabled }, + apply: applyDebugMode, }, } func Definitions() []RuntimeFlagDefinition { - out := make([]RuntimeFlagDefinition, len(definitions)) - copy(out, definitions) + out := make([]RuntimeFlagDefinition, len(registrations)) + for i, registration := range registrations { + out[i] = publicDefinition(registration.definition) + } return out } func DefinitionByKey(key string) (RuntimeFlagDefinition, bool) { - for _, def := range definitions { - if def.Key == key { - return def, true + registration, ok := registrationByKey(key) + if !ok { + return RuntimeFlagDefinition{}, false + } + return publicDefinition(registration.definition), true +} + +func registrationByKey(key string) (runtimeFlagRegistration, bool) { + for _, registration := range registrations { + if registration.definition.Key == key { + return registration, true } } - return RuntimeFlagDefinition{}, false + return runtimeFlagRegistration{}, false +} + +func publicDefinition(definition RuntimeFlagDefinition) RuntimeFlagDefinition { + definition.ImpliedEnvVars = append([]string(nil), definition.ImpliedEnvVars...) + return definition } diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 8da93a48db..05799e3af9 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -1,6 +1,21 @@ package runtimeflags -import "testing" +import ( + "reflect" + "strings" + "testing" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/profiles" +) + +type featureFieldBinding struct { + fieldIndex int + fieldName string + key string + profileKey string + envVar string +} func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) { def, ok := DefinitionByKey("features.office") @@ -35,6 +50,15 @@ func TestDefinitionsExcludePlugins(t *testing.T) { } } +func TestRetiredRuntimeFlagIdentitiesIncludePlugins(t *testing.T) { + for _, identity := range retiredRuntimeFlagIdentities { + if identity.key == "features.plugins" && identity.envVar == "KANDEV_FEATURES_PLUGINS" { + return + } + } + t.Fatal("graduated plugins flag identity is missing from the retired identity set") +} + func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) { def, ok := DefinitionByKey("features.appStatusBar") if !ok { @@ -90,3 +114,289 @@ func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) { t.Fatal("debug.agentMessages must not be a top-level user-facing toggle") } } + +func TestRuntimeFlagRegistrationsHaveCompleteMetadata(t *testing.T) { + retiredKeys, retiredEnvVars := retiredIdentitySets(t) + activeKeys := make(map[string]struct{}, len(registrations)) + activeEnvVars := make(map[string]string, len(registrations)) + + for _, registration := range registrations { + definition := registration.definition + validateRuntimeFlagDefinition(t, definition) + if registration.read == nil || registration.apply == nil { + t.Fatalf("runtime flag registration %q is missing a config binding", definition.Key) + } + if _, exists := activeKeys[definition.Key]; exists { + t.Fatalf("duplicate runtime flag registration for %q", definition.Key) + } + activeKeys[definition.Key] = struct{}{} + if _, retired := retiredKeys[definition.Key]; retired { + t.Fatalf("active runtime flag %q reuses a retired key", definition.Key) + } + registerActiveEnvVar(t, activeEnvVars, retiredEnvVars, definition.Key, definition.EnvVar) + for _, envVar := range definition.ImpliedEnvVars { + registerActiveEnvVar(t, activeEnvVars, retiredEnvVars, definition.Key, envVar) + } + } +} + +func TestRuntimeFlagConfigBindingsAreIsolated(t *testing.T) { + // Keep debug-mode's intentional environment side effects inert while this + // test exercises only the typed config bindings. + t.Setenv(envDebugAgentMessages, "explicit") + t.Setenv(envDebugPprofEnabled, "explicit") + + for _, target := range registrations { + cfg := &config.Config{} + target.apply(cfg, true) + for _, registration := range registrations { + want := registration.definition.Key == target.definition.Key + if got := registration.read(cfg); got != want { + t.Fatalf( + "applying %q changed read value for %q to %t; want %t", + target.definition.Key, + registration.definition.Key, + got, + want, + ) + } + } + + target.apply(cfg, false) + for _, registration := range registrations { + if registration.read(cfg) { + t.Fatalf("disabling %q left %q enabled", target.definition.Key, registration.definition.Key) + } + } + } +} + +// TestFeatureBindingsExactlyMatchConfigAndProfiles keeps the typed config, +// profile defaults, and feature registry in lockstep in both directions. +func TestFeatureBindingsExactlyMatchConfigAndProfiles(t *testing.T) { + defaults, err := profiles.FeatureFlagDefaults() + if err != nil { + t.Fatalf("FeatureFlagDefaults: %v", err) + } + fields := featureFieldBindings(t) + registered := registeredFeatureBindings(t) + + for key, field := range fields { + registration, ok := registered[key] + if !ok { + t.Fatalf("FeaturesConfig.%s has no runtime flag registration for %q", field.fieldName, key) + } + if registration.definition.EnvVar != field.envVar { + t.Fatalf("FeaturesConfig.%s EnvVar = %q, want %q", field.fieldName, registration.definition.EnvVar, field.envVar) + } + if _, ok := defaults[field.profileKey]; !ok { + t.Fatalf("FeaturesConfig.%s has no profile default for %q", field.fieldName, field.profileKey) + } + } + for key := range registered { + if _, ok := fields[key]; !ok { + t.Fatalf("feature registration %q has no FeaturesConfig field", key) + } + } + for profileKey := range defaults { + if !hasProfileKey(fields, profileKey) { + t.Fatalf("profile feature %q has no FeaturesConfig field", profileKey) + } + } + if len(fields) != len(registered) || len(fields) != len(defaults) { + t.Fatalf( + "feature contract size mismatch: config=%d registry=%d profiles=%d", + len(fields), + len(registered), + len(defaults), + ) + } + + assertFeatureConfigRoundTrips(t, fields) +} + +func retiredIdentitySets(t *testing.T) (map[string]struct{}, map[string]struct{}) { + t.Helper() + keys := make(map[string]struct{}, len(retiredRuntimeFlagIdentities)) + envVars := make(map[string]struct{}, len(retiredRuntimeFlagIdentities)) + for _, identity := range retiredRuntimeFlagIdentities { + if strings.TrimSpace(identity.key) == "" || strings.TrimSpace(identity.envVar) == "" { + t.Fatal("retired runtime flag identities must include both key and environment variable") + } + if _, exists := keys[identity.key]; exists { + t.Fatalf("duplicate retired runtime flag key %q", identity.key) + } + keys[identity.key] = struct{}{} + if _, exists := envVars[identity.envVar]; exists { + t.Fatalf("duplicate retired runtime flag environment variable %q", identity.envVar) + } + envVars[identity.envVar] = struct{}{} + } + return keys, envVars +} + +func registerActiveEnvVar( + t *testing.T, + active map[string]string, + retired map[string]struct{}, + key string, + envVar string, +) { + t.Helper() + if strings.TrimSpace(envVar) == "" { + t.Fatalf("runtime flag %q has an empty environment variable", key) + } + if owner, exists := active[envVar]; exists { + t.Fatalf("runtime flags %q and %q both use environment variable %q", owner, key, envVar) + } + if _, exists := retired[envVar]; exists { + t.Fatalf("active runtime flag %q reuses retired environment variable %q", key, envVar) + } + active[envVar] = key +} + +func validateRuntimeFlagDefinition(t *testing.T, definition RuntimeFlagDefinition) { + t.Helper() + wantKeyPrefix, wantEnvPrefix := definitionPrefixes(t, definition.Kind) + if !strings.HasPrefix(definition.Key, wantKeyPrefix) || len(definition.Key) == len(wantKeyPrefix) { + t.Fatalf("runtime flag key %q must start with %q and include a name", definition.Key, wantKeyPrefix) + } + if !strings.HasPrefix(definition.EnvVar, wantEnvPrefix) || len(definition.EnvVar) == len(wantEnvPrefix) { + t.Fatalf("runtime flag %q environment variable %q must start with %q", definition.Key, definition.EnvVar, wantEnvPrefix) + } + for name, value := range map[string]string{ + "label": definition.Label, "description": definition.Description, "risk description": definition.RiskDescription, + } { + if strings.TrimSpace(value) == "" { + t.Fatalf("runtime flag %q has an empty %s", definition.Key, name) + } + } + if !validStability(definition.Stability) { + t.Fatalf("runtime flag %q has invalid stability %q", definition.Key, definition.Stability) + } + if !validRiskLevel(definition.RiskLevel) { + t.Fatalf("runtime flag %q has invalid risk level %q", definition.Key, definition.RiskLevel) + } + if !definition.RestartRequired { + t.Fatalf("runtime flag %q must declare restart_required", definition.Key) + } + if !definition.Mutable { + t.Fatalf("runtime flag %q must declare mutable", definition.Key) + } +} + +func definitionPrefixes(t *testing.T, kind RuntimeFlagKind) (string, string) { + t.Helper() + switch kind { + case KindFeature: + return "features.", "KANDEV_FEATURES_" + case KindDebug: + return "debug.", "KANDEV_DEBUG_" + default: + t.Fatalf("invalid runtime flag kind %q", kind) + return "", "" + } +} + +func validStability(stability RuntimeFlagStability) bool { + switch stability { + case StabilityStable, StabilityBeta, StabilityExperimental: + return true + default: + return false + } +} + +func validRiskLevel(risk RuntimeFlagRiskLevel) bool { + switch risk { + case RiskLow, RiskMedium, RiskHigh: + return true + default: + return false + } +} + +func featureFieldBindings(t *testing.T) map[string]featureFieldBinding { + t.Helper() + typeOfFeatures := reflect.TypeOf(config.FeaturesConfig{}) + fields := make(map[string]featureFieldBinding, typeOfFeatures.NumField()) + for i := 0; i < typeOfFeatures.NumField(); i++ { + field := typeOfFeatures.Field(i) + if field.Type.Kind() != reflect.Bool { + t.Fatalf("FeaturesConfig.%s has type %s; feature flags must be bool", field.Name, field.Type) + } + jsonName := requiredTagName(t, field, "json") + profileKey := requiredTagName(t, field, "mapstructure") + key := "features." + jsonName + if _, exists := fields[key]; exists { + t.Fatalf("duplicate FeaturesConfig JSON key %q", jsonName) + } + fields[key] = featureFieldBinding{ + fieldIndex: i, + fieldName: field.Name, + key: key, + profileKey: profileKey, + envVar: "KANDEV_FEATURES_" + strings.ToUpper(profileKey), + } + } + return fields +} + +func requiredTagName(t *testing.T, field reflect.StructField, tag string) string { + t.Helper() + name := strings.Split(field.Tag.Get(tag), ",")[0] + if name == "" || name == "-" { + t.Fatalf("FeaturesConfig.%s is missing a %s name", field.Name, tag) + } + return name +} + +func registeredFeatureBindings(t *testing.T) map[string]runtimeFlagRegistration { + t.Helper() + registered := make(map[string]runtimeFlagRegistration) + for _, registration := range registrations { + definition := registration.definition + if definition.Kind != KindFeature && !strings.HasPrefix(definition.Key, "features.") { + continue + } + if _, exists := registered[definition.Key]; exists { + t.Fatalf("duplicate feature registration %q", definition.Key) + } + registered[definition.Key] = registration + } + return registered +} + +func hasProfileKey(fields map[string]featureFieldBinding, profileKey string) bool { + for _, field := range fields { + if field.profileKey == profileKey { + return true + } + } + return false +} + +func assertFeatureConfigRoundTrips(t *testing.T, fields map[string]featureFieldBinding) { + t.Helper() + typeOfFeatures := reflect.TypeOf(config.FeaturesConfig{}) + for key, field := range fields { + cfg := &config.Config{} + features := reflect.ValueOf(&cfg.Features).Elem() + ApplyStatesToConfig(cfg, []RuntimeFlagState{{Key: key, EffectiveValue: true}}) + if !features.Field(field.fieldIndex).Bool() { + t.Fatalf("ApplyStatesToConfig(%q) did not enable target field %s", key, field.fieldName) + } + for i := 0; i < features.NumField(); i++ { + if i != field.fieldIndex && features.Field(i).Bool() { + t.Fatalf("ApplyStatesToConfig(%q) changed unrelated field %s", key, typeOfFeatures.Field(i).Name) + } + } + if got := ValuesFromConfig(cfg)[key]; !got { + t.Fatalf("ValuesFromConfig(%q) = false after enabling %s", key, field.fieldName) + } + ApplyStatesToConfig(cfg, []RuntimeFlagState{{Key: key, EffectiveValue: false}}) + if features.Field(field.fieldIndex).Bool() || ValuesFromConfig(cfg)[key] { + t.Fatalf("ApplyStatesToConfig(%q) did not disable target field %s", key, field.fieldName) + } + } +} diff --git a/apps/web/app/actions/features.test.ts b/apps/web/app/actions/features.test.ts index 9b4e353b22..cf39f5ff84 100644 --- a/apps/web/app/actions/features.test.ts +++ b/apps/web/app/actions/features.test.ts @@ -5,6 +5,9 @@ vi.mock("@/lib/config", () => ({ })); import { getRuntimeDebugModeAction } from "./features"; +import { getFeatureFlagsAction } from "./features"; +import { defaultFeatureFlags } from "@/lib/state/slices/features/types"; +import type { FeatureFlags } from "@/lib/state/slices/features/types"; type FetchInput = Parameters[0]; type FetchInit = Parameters[1]; @@ -27,6 +30,35 @@ function jsonResponse(body: unknown): Response { }); } +function flagsWith(value: boolean): FeatureFlags { + return Object.fromEntries( + Object.keys(defaultFeatureFlags).map((name) => [name, value]), + ) as FeatureFlags; +} + +describe("getFeatureFlagsAction", () => { + it("normalizes declared backend flags and ignores unknown keys", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ ...flagsWith(true), unknown: true })); + + await expect(getFeatureFlagsAction()).resolves.toEqual(flagsWith(true)); + }); + + it("fails closed for missing and non-boolean backend values", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ office: "true", appStatusBar: true })); + + await expect(getFeatureFlagsAction()).resolves.toEqual({ + ...flagsWith(false), + appStatusBar: true, + }); + }); + + it("falls back to all-off defaults when the backend is unavailable", async () => { + fetchSpy.mockResolvedValueOnce(new Response("nope", { status: 503 })); + + await expect(getFeatureFlagsAction()).resolves.toEqual(flagsWith(false)); + }); +}); + describe("getRuntimeDebugModeAction", () => { it("returns true when the effective debug runtime flag is enabled", async () => { fetchSpy.mockResolvedValueOnce( diff --git a/apps/web/app/actions/features.ts b/apps/web/app/actions/features.ts index 1f39e8381c..a328af0706 100644 --- a/apps/web/app/actions/features.ts +++ b/apps/web/app/actions/features.ts @@ -1,8 +1,7 @@ "use server"; import { getBackendConfig } from "@/lib/config"; -import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice"; -import type { FeatureFlags } from "@/lib/state/slices/features/types"; +import { defaultFeatureFlags, type FeatureFlags } from "@/lib/state/slices/features/types"; import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags"; const { apiBaseUrl } = getBackendConfig(); @@ -12,15 +11,15 @@ const { apiBaseUrl } = getBackendConfig(); // store. Falls back to defaults (all-off) when the backend is unreachable, // so a dev-server restart doesn't crash page rendering. // -// Parsing is type-driven via `defaultFeaturesState.features`: every key -// declared on FeatureFlags is read from the response and Boolean-coerced; -// missing or non-bool values fall through to the default. Adding a flag -// is therefore one entry in FeatureFlags + the default — no edit here. +// Parsing is type-driven via `defaultFeatureFlags`: every declared key is read +// from the response and only actual booleans are accepted; missing or +// non-boolean values remain all-off. Adding a flag is therefore one entry in +// the declaration — no edit here. // // See docs/decisions/0007-runtime-feature-flags.md. export async function getFeatureFlagsAction(): Promise { const url = `${apiBaseUrl}/api/v1/features`; - const defaults = defaultFeaturesState.features; + const defaults: FeatureFlags = { ...defaultFeatureFlags }; try { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) { diff --git a/apps/web/e2e/tests/settings/feature-toggles-helpers.ts b/apps/web/e2e/tests/settings/feature-toggles-helpers.ts deleted file mode 100644 index 0cfc5e9785..0000000000 --- a/apps/web/e2e/tests/settings/feature-toggles-helpers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, type Page } from "@playwright/test"; -import type { PrAssetCapture } from "../../helpers/pr-asset-capture"; - -export async function assertUnreadDividerSetting( - page: Page, - prCapture: PrAssetCapture, - projectName: string, -): Promise { - await page.goto("/settings/system/feature-toggles"); - - const unreadDivider = page.getByTestId("feature-toggle-features.unreadDivider"); - await expect(unreadDivider).toBeVisible(); - await expect(unreadDivider.getByText("Unread divider", { exact: true })).toBeVisible(); - await expect(unreadDivider.getByRole("switch", { name: "Toggle Unread divider" })).toBeChecked(); - await expect(unreadDivider).toContainText("Source: Default"); - await expect(unreadDivider).toContainText("Requires restart"); - - await unreadDivider.scrollIntoViewIfNeeded(); - await prCapture.screenshot(`unread-divider-setting-${projectName}`, { - caption: `Unread divider feature toggle (${projectName})`, - fullPage: true, - }); -} diff --git a/apps/web/lib/state/slices/features/features-contract.test.ts b/apps/web/lib/state/slices/features/features-contract.test.ts new file mode 100644 index 0000000000..a6ac2caa3a --- /dev/null +++ b/apps/web/lib/state/slices/features/features-contract.test.ts @@ -0,0 +1,24 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { defaultFeatureFlags } from "./types"; + +const backendConfigPath = resolve(process.cwd(), "../backend/internal/common/config/config.go"); + +function backendFeatureKeys(source: string): string[] { + const structBody = source.match(/type FeaturesConfig struct \{([\s\S]*?)\n\}/)?.[1]; + if (!structBody) { + throw new Error("FeaturesConfig declaration not found in backend config"); + } + + return Array.from(structBody.matchAll(/json:"([^",]+)(?:,[^"]*)?"/g), (match) => match[1]).sort(); +} + +describe("feature flag repository contract", () => { + it("keeps frontend defaults equal to backend FeaturesConfig JSON keys", async () => { + const backendConfig = await readFile(backendConfigPath, "utf8"); + + expect(Object.keys(defaultFeatureFlags).sort()).toEqual(backendFeatureKeys(backendConfig)); + }); +}); diff --git a/apps/web/lib/state/slices/features/features-slice.test.ts b/apps/web/lib/state/slices/features/features-slice.test.ts index 77a71127a4..2cbef8855a 100644 --- a/apps/web/lib/state/slices/features/features-slice.test.ts +++ b/apps/web/lib/state/slices/features/features-slice.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createFeaturesSlice, defaultFeaturesState } from "./features-slice"; -import type { FeaturesSlice } from "./types"; +import { defaultFeatureFlags } from "./types"; +import type { FeatureFlags, FeaturesSlice } from "./types"; function makeStore() { return create()( @@ -11,6 +12,12 @@ function makeStore() { ); } +function flagsWith(value: boolean): FeatureFlags { + return Object.fromEntries( + Object.keys(defaultFeatureFlags).map((name) => [name, value]), + ) as FeatureFlags; +} + describe("features slice", () => { // Production-safety invariant: every flag must be false out of the box. // If this test starts failing because a new flag was added defaulting to @@ -20,33 +27,14 @@ describe("features slice", () => { for (const [name, value] of Object.entries(defaultFeaturesState.features)) { expect(value, `default of features.${name}`).toBe(false); } - expect(store.getState().features.office).toBe(false); - expect(defaultFeaturesState.features).toHaveProperty("appStatusBar", false); - expect(defaultFeaturesState.features).toHaveProperty("auth", false); - expect(defaultFeaturesState.features).toHaveProperty("claudeBackgroundPromptHandoff", false); + expect(store.getState().features).toEqual(flagsWith(false)); }); it("setFeatures replaces the whole flag map", () => { const store = makeStore(); - store.getState().setFeatures({ - office: true, - appStatusBar: true, - auth: true, - claudeBackgroundPromptHandoff: true, - }); - expect(store.getState().features.office).toBe(true); - expect(store.getState().features.appStatusBar).toBe(true); - expect(store.getState().features.auth).toBe(true); - expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(true); - store.getState().setFeatures({ - office: false, - appStatusBar: false, - auth: false, - claudeBackgroundPromptHandoff: false, - }); - expect(store.getState().features.office).toBe(false); - expect(store.getState().features.appStatusBar).toBe(false); - expect(store.getState().features.auth).toBe(false); - expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(false); + store.getState().setFeatures(flagsWith(true)); + expect(store.getState().features).toEqual(flagsWith(true)); + store.getState().setFeatures(flagsWith(false)); + expect(store.getState().features).toEqual(flagsWith(false)); }); }); diff --git a/apps/web/lib/state/slices/features/features-slice.ts b/apps/web/lib/state/slices/features/features-slice.ts index 66090dc1b3..16a2e3fefd 100644 --- a/apps/web/lib/state/slices/features/features-slice.ts +++ b/apps/web/lib/state/slices/features/features-slice.ts @@ -1,16 +1,8 @@ import type { StateCreator } from "zustand"; -import type { FeaturesSlice, FeaturesSliceState } from "./types"; +import { defaultFeatureFlags, type FeaturesSlice, type FeaturesSliceState } from "./types"; export const defaultFeaturesState: FeaturesSliceState = { - // All flags default to false. Production releases ship every flag off - // until the deployment opts in via env var (e.g. KANDEV_FEATURES_OFFICE). - // The SSR layer overwrites this with whatever the backend reports. - features: { - office: false, - appStatusBar: false, - auth: false, - claudeBackgroundPromptHandoff: false, - }, + features: { ...defaultFeatureFlags }, }; type ImmerSet = Parameters< diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 27f4bcbdfc..4c63b6d5f5 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -1,17 +1,23 @@ // Runtime feature-flag state. The shape mirrors the backend's // /api/v1/features response (FeaturesConfig in -// apps/backend/internal/common/config/config.go). Every flag is a boolean, -// keyed by feature name. New flags are additive — keep this shape stable. +// apps/backend/internal/common/config/config.go). New flags are additive: +// declare the all-off default once and derive the names and state from it. // See docs/decisions/0007-runtime-feature-flags.md. -export type FeatureFlags = { - office: boolean; - appStatusBar: boolean; - auth: boolean; - claudeBackgroundPromptHandoff: boolean; -}; +export const defaultFeatureFlags = { + // New release toggles start disabled in every frontend state. The SSR layer + // overwrites this with the backend's effective values after startup. + office: false, + appStatusBar: false, + auth: false, + claudeBackgroundPromptHandoff: false, +} as const; -export type FeatureName = keyof FeatureFlags; +export type FeatureName = keyof typeof defaultFeatureFlags; + +export type FeatureFlags = { + [K in FeatureName]: boolean; +}; export type FeaturesSliceState = { features: FeatureFlags; diff --git a/docs/decisions/0007-runtime-feature-flags.md b/docs/decisions/0007-runtime-feature-flags.md index 5c32a9e2ff..15683de668 100644 --- a/docs/decisions/0007-runtime-feature-flags.md +++ b/docs/decisions/0007-runtime-feature-flags.md @@ -16,7 +16,9 @@ We needed: 1. One file that's the answer to "what's on in dev / e2e / prod?" 2. Production releases that *cannot* accidentally ship with dev/e2e knobs on, even if env vars leak. 3. Per-spec and per-host overrides that still win without special-casing. -4. Reusable plumbing — adding the next feature flag should be ~1 line. +4. Reusable plumbing — adding the next feature flag should be one registry + binding plus the deliberately gated call sites, not a new map or switch in + every startup path. ## Decision @@ -73,8 +75,9 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats - `apps/backend/internal/profiles/profiles.yaml` — the file (symlinked from `./profiles.yaml` at the repo root). - `apps/backend/internal/profiles/profiles.go` — `DetectEnvironment`, `ApplyProfile`, `FeatureFlagDefaults`. - `apps/backend/internal/common/config/config.go` — calls `profiles.ApplyProfile()` at the top of `LoadWithPath` (before Viper's `AutomaticEnv`) and then seeds Viper's `features.*` keyspace from `profiles.FeatureFlagDefaults()` so the typed `FeaturesConfig` populates correctly even in tests. -- `FeaturesConfig` struct holds a typed `bool` per feature. -- Feature initialization (service construction, route registration) is gated at the call site — typically `cmd/kandev/main.go` early-return in `initOfficeServices`. When off, the relevant `services.X` stays nil and downstream consumers nil-check. HTTP routes are simply never registered, so a guessed URL returns 404 (not 401). +- `apps/backend/internal/runtimeflags/registry.go` — owns each flag's public metadata, key, environment variable, typed config reader, and typed config applier in one internal registration. It also owns the append-only identities of retired flags. `OptionsFromConfig`, `ValuesFromConfig`, and persisted-state application iterate active registrations. +- `FeaturesConfig` holds a typed `bool` per feature. Reflective tests require exact config/registry/profile key equality, complete metadata, isolated typed bindings, and no active collision with a retired identity. A frontend Vitest contract compares the backend JSON tags with `defaultFeatureFlags`. +- `apps/backend/internal/backendapp/main.go` and other backend entry paths gate construction, route registration, handlers, background jobs, and agent/MCP behavior at their narrow composition boundaries. When off, the relevant service stays nil or the operation is rejected before side effects; HTTP routes are simply never registered, so a guessed URL returns 404 (not 401). ### Frontend wiring (feature flags only) @@ -95,7 +98,7 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats - The "what's on in dev / e2e?" question is grep-friendly at the repo root. - Per-spec / per-host overrides still work because `ApplyProfile` is idempotent and leaves already-set vars alone. - Gated code stays compiled into the production binary and JS bundle. Acceptable trade-off vs build tags: the Office surface adds ≤ ~5% to the binary, and the operational simplicity (one artifact, runtime flips) is worth more than tree-shaking would be at our scale. -- Adding a new flag is ~1 line in `profiles.yaml`, ~1 line in `FeaturesConfig`, plus the gate at the call site and (for UI flags) the frontend additions listed below. +- Adding a new flag has one profile entry, one typed `FeaturesConfig` field, one registry registration, and the necessary backend/frontend gates. Completeness tests fail when a layer is omitted, added only on one side, or malformed. - The 404-on-disabled-feature failure mode is deliberate: a flagged-off feature should look like it doesn't exist, not like a permission denial. ## Alternatives considered @@ -112,21 +115,43 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats ```yaml KANDEV_FEATURES_: prod: "false" - dev: "true" - e2e: "true" + dev: "false" + e2e: "false" ``` -2. Add the matching `bool` field to `FeaturesConfig` in `apps/backend/internal/common/config/config.go`. (No `v.SetDefault` needed — `profiles.FeatureFlagDefaults()` seeds it automatically.) -3. Gate backend construction at the relevant init call site (`cmd/kandev/main.go` typically). -4. Update `/api/v1/features` handler in `cmd/kandev/helpers.go` to include the new key. -5. Add the field to `FeatureFlags` in `apps/web/lib/state/slices/features/types.ts` and the default in `features-slice.ts`. -6. Update `apps/web/app/actions/features.ts` to normalize the new key. -7. Gate frontend nav with `useFeature("")` and page subtrees with `notFound()` in the relevant server-side layout. +2. Add the matching `bool` field to `FeaturesConfig` in + `apps/backend/internal/common/config/config.go`, including explicit + `mapstructure` and `json` tags. (No `v.SetDefault` is needed — + `profiles.FeatureFlagDefaults()` seeds it automatically.) +3. Add one registration in `apps/backend/internal/runtimeflags/registry.go` + with the key, env var, metadata, typed reader, and typed applier. Do not add + a parallel per-flag map or switch, and do not reuse an identity in + `retiredRuntimeFlagIdentities`. The registry completeness test must pass. +4. Gate every backend entry path at its narrow composition boundary. The + disabled path must preserve the legacy behavior and must fail closed before + deriving data, writing state, dispatching work, or exposing a capability. +5. Add the field to `defaultFeatureFlags` in + `apps/web/lib/state/slices/features/types.ts`; the frontend normalizer is + derived from that declaration. Gate client surfaces with `useFeature` and + page subtrees with `notFound()` in the relevant server-side layout. +6. Add on/off tests for backend behavior, frontend normalization, and each + meaningful user entry path. Shared refactors, migrations, and data reads + must not be hidden behind a flag unless their disabled behavior is explicit. ## How to enable a feature for all users -Flip its `prod:` value in `profiles.yaml` from `"false"` to `"true"`. Ship the next release. +For a temporary release toggle, merge the code with the flag off in every +profile. Enable it on a selected install through the admin Feature Toggles UI +or an explicit environment variable, restart, and observe it. Once the +feature is ready for everyone, change its `prod:` value in `profiles.yaml` to +`"true"` for the next release while retaining the registry metadata as a +kill-switch during the rollout. After the feature has been on by default for a +release or two and is permanent, remove the live flag end-to-end. -Once the feature has been on by default for a release or two and is permanent, rip the flag out entirely (the `profiles.yaml` entry, the `FeaturesConfig` field, the gate at the call site, the `useFeature` checks, the `notFound()`, the frontend slice field). +Removal includes the `profiles.yaml` entry, the `FeaturesConfig` field, the +active registry registration, backend gates, frontend declaration and checks, +tests, and docs. Move the removed key and environment variable to the +append-only `retiredRuntimeFlagIdentities` set. Unknown persisted overrides +remain inert, and neither retired identity may be reused. ## How to add a non-feature-flag knob @@ -137,8 +162,9 @@ Same pattern — pick the section that matches the knob's purpose (`mocks`, `deb - `profiles.yaml` (repo root, symlink) → `apps/backend/internal/profiles/profiles.yaml` — source of truth - `apps/backend/internal/profiles/profiles.go` — loader (`//go:embed`, `DetectEnvironment`, `ApplyProfile`, `FeatureFlagDefaults`) - `apps/backend/internal/common/config/config.go` — `LoadWithPath` calls `ApplyProfile` first thing -- `apps/backend/cmd/kandev/helpers.go` — `GET /api/v1/features` handler -- `apps/backend/cmd/kandev/main.go` — `initOfficeServices` early-return on `cfg.Features.Office` +- `apps/backend/internal/backendapp/helpers.go` — `GET /api/v1/features` handler +- `apps/backend/internal/backendapp/main.go` — composition gates such as `initOfficeServices` +- `apps/backend/internal/runtimeflags/registry.go` — metadata and typed config bindings - `apps/web/app/layout.tsx` — SSR-fetch + StateProvider seeding - `apps/web/lib/state/slices/features/` — Zustand slice - `apps/web/hooks/domains/features/use-feature.ts` — client hook diff --git a/docs/decisions/0018-runtime-settings-overrides.md b/docs/decisions/0018-runtime-settings-overrides.md index bf57b3c74c..8372a72346 100644 --- a/docs/decisions/0018-runtime-settings-overrides.md +++ b/docs/decisions/0018-runtime-settings-overrides.md @@ -28,9 +28,11 @@ descriptions, stability metadata, risk descriptions, env-var names, and restart requirements. The Feature Toggles UI renders that registry state rather than hardcoding env-var behavior in React. -V1 exposes Office mode and Debug mode. Office mode is marked experimental. +V1 exposed Office mode and Debug mode. The current registry also includes the +App Status Bar, opt-in Authentication, and Claude background prompt handoff +experiment. Office mode and the handoff experiment are marked experimental; Debug mode is the user-facing toggle for debug behavior, including agent -message debug logs; agent message logs are not a separate top-level toggle. +message debug logs. Agent message logs are not a separate top-level toggle. V1 toggle changes require restart. Restart is launcher/supervisor mediated: @@ -55,8 +57,8 @@ process-tree cleanup. states. - Restart support needs CLI/service coordination rather than a backend-only handler. -- Future multi-user support must restrict Feature Toggles, especially Debug - mode, to admins. +- Feature Toggle mutations are restricted to admins, especially for Debug mode; + members may read effective state so the frontend can render consistently. ## Alternatives Considered diff --git a/docs/decisions/2026-08-01-release-toggle-gating-contract.md b/docs/decisions/2026-08-01-release-toggle-gating-contract.md new file mode 100644 index 0000000000..bdb939f269 --- /dev/null +++ b/docs/decisions/2026-08-01-release-toggle-gating-contract.md @@ -0,0 +1,78 @@ +# ADR-2026-08-01-release-toggle-gating-contract: Release Toggles Are Install-Wide Fail-Closed Gates + +**Status:** proposed +**Date:** 2026-08-01 +**Area:** backend, frontend, workflow + +## Context + +Kandev already resolves startup feature values from explicit environment, +persisted installation overrides, and `profiles.yaml`. Adding a frontend-visible +flag still duplicates its identity and config behavior across backend maps, +switches, frontend types, and defaults. More importantly, a UI preference or a +single late behavior check does not make a risky PR safe when direct callers, +agent tools, background work, shared initialization, or migrations can bypass +the gate. + +## Decision + +Temporary release toggles use the existing install-wide runtime-flag system; +they are not represented as portable per-user preferences. New release toggles +ship off in `prod`, `dev`, and `e2e`, and dedicated tests or selected +installations opt in explicitly. + +The backend is authoritative. Every externally reachable entry path is gated at +its narrowest composition or capability boundary, and disabled requests fail +before mutation or dispatch. Frontend checks control discovery and presentation +but never substitute for backend enforcement. Schema changes, startup work, and +shared refactors remain independently safe because code executed outside the +gate is not protected by it. + +The internal registration in `internal/runtimeflags/registry.go` becomes the +single backend binding for registry key, environment variable, config read, and +config apply behavior; the public `RuntimeFlagDefinition` remains metadata-only. +Generic resolution iterates registrations instead of adding each flag to +separate maps and switches. The frontend derives its feature-name type and +all-false defaults from one declaration. CI requires exact registry, +`FeaturesConfig`, profile, and frontend-default key equality and validates +registration metadata and typed binding isolation. + +Rollout is staged: merge default-off, enable selected installations, ship one +default-on release with the kill switch retained, then remove the flag and +legacy behavior. Graduation moves the key and environment variable to the +append-only retired-identity set in `internal/runtimeflags/registry.go`; +retired identities are never reused. Unknown persisted overrides are ignored +rather than deleted so downgrade compatibility does not mutate operator state. + +## Consequences + +- Medium and large changes can merge without changing default behavior, while a + real installation can opt in through the existing admin UI or environment. +- Direct clients and asynchronous entry paths cannot bypass an off flag. +- Flag addition and removal have fewer mechanical edit sites and generic tests + catch incomplete plumbing. +- Each gated feature still needs deliberate rollback-safe data and migration + design; a flag is not a substitute for compatibility. +- Retaining the kill switch for one default-on release delays complete code + removal, but preserves rollback during the highest-exposure stage. +- Unknown override rows can accumulate, but remain inert and preserve downgrade + behavior. The source-controlled retired-identity set prevents stale database + or environment values from acquiring new meaning. + +## Alternatives Considered + +1. **Use per-user preferences as release controls.** Rejected because rollout + ownership is installation-wide, direct callers can bypass UI preferences, + and portable settings add persistence plumbing that must later be removed. +2. **Keep the current per-flag maps and switches.** Rejected because each flag + multiplies addition/removal sites and makes drift likely as gated PR volume + grows. +3. **Generate flag code from YAML.** Rejected for now because code generation + adds build and review machinery when a definition-owned binding removes the + duplication without generated artifacts. +4. **Use reflection in production to bind `FeaturesConfig`.** Rejected because + explicit typed bindings are easier to navigate and fail at compile time; + reflection remains acceptable in completeness tests. +5. **Adopt an external rollout service.** Rejected because Kandev needs + installation opt-in and a local kill switch, not cohorts, percentage rollout, + or a hosted control plane. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 3a0b0eef91..0a96e447c5 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -97,5 +97,6 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-01-repository-task-executor-defaults | [Resolve Task Executor Policy Before Last-Used Profile](2026-08-01-repository-task-executor-defaults.md) | accepted | frontend | 2026-08-01 | | 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 | | 2026-08-01-global-run-scheduler-ownership | [Separate Global Run Dispatch from Office Maintenance](2026-08-01-global-run-scheduler-ownership.md) | accepted | backend, workflow | 2026-08-01 | +| 2026-08-01-release-toggle-gating-contract | [Release Toggles Are Install-Wide Fail-Closed Gates](2026-08-01-release-toggle-gating-contract.md) | proposed | backend, frontend, workflow | 2026-08-01 | | 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 | | 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 | diff --git a/docs/plans/release-feature-toggles/plan.md b/docs/plans/release-feature-toggles/plan.md new file mode 100644 index 0000000000..8866e2838a --- /dev/null +++ b/docs/plans/release-feature-toggles/plan.md @@ -0,0 +1,227 @@ +--- +spec: docs/specs/feature-toggles/spec.md +created: 2026-08-01 +status: completed +--- + +# Implementation Plan: Release Feature Toggles + +## Overview + +This plan simplifies the existing startup runtime-flag plumbing without +changing its API, persistence, precedence, restart, or settings-page behavior. +Backend config bindings move into the runtime-flag registry, frontend names and +all-false defaults gain one declaration, and generic tests prevent incomplete +flag wiring. Public and contributor documentation then defines the fail-closed +release-toggle lifecycle that follow-on feature PRs, beginning with #2104, use. + +--- + +## Backend + +### Definition-owned config bindings + +- In `apps/backend/internal/runtimeflags/registry.go`, replace the metadata-only + `definitions` slice with an internal registration type containing: + - the public `RuntimeFlagDefinition` metadata; + - a typed `read(*config.Config) bool` function; + - a typed `apply(*config.Config, bool)` function. +- Keep `RuntimeFlagDefinition`, `RuntimeFlagState`, and the HTTP response shape + unchanged. `Definitions()` and `DefinitionByKey()` continue to expose only + metadata. +- Inline each feature's registry key, environment variable, config getter, and + config setter in one registration. Keep Debug mode's subordinate environment + behavior in a named helper because it intentionally updates + `KANDEV_DEBUG_PPROF_ENABLED` and `KANDEV_DEBUG_AGENT_MESSAGES` together. +- In `apps/backend/internal/runtimeflags/config.go`, make + `OptionsFromConfig`, `ValuesFromConfig`, and `ApplyStatesToConfig` iterate the + registrations. Delete the per-feature key/env constants, environment map, + value map, and apply switch. Retain only constants needed for Debug mode's + implied environment variables. + +### Completeness invariants + +- Add a table/reflective test in + `apps/backend/internal/runtimeflags/registry_test.go` that compares every + boolean field in `config.FeaturesConfig` with: + - one `features.` runtime registration; + - its `KANDEV_FEATURES_` profile entry; + - a getter/setter round trip that changes only that field. +- Require exact equality in both directions, validate every registration's + metadata and binding isolation, and reject active key/environment collisions + with the append-only retired-identity set. +- Replace the hardcoded serialized object equality in + `apps/backend/internal/common/config/config_test.go` with a generic assertion + that every `FeaturesConfig` field is boolean and has non-empty, non-Go-name + `json` and `mapstructure` tags. Feature-specific behavior tests remain where + their semantics matter. +- Preserve unknown stored overrides: `Service.ListStates` continues to iterate + registrations rather than store rows, so removed keys remain inert and + hidden. + +No database schema, HTTP route, JSON response, restart protocol, or precedence +change is required. + +--- + +## Frontend + +### Single feature declaration + +- In `apps/web/lib/state/slices/features/types.ts`, declare the all-false + feature object once and derive `FeatureName` and `FeatureFlags` from its keys. +- In `apps/web/lib/state/slices/features/features-slice.ts`, initialize the + Zustand slice from that declaration instead of repeating every key. +- Keep `getFeatureFlagsAction()` type-driven normalization and `useFeature()` + behavior unchanged: malformed, missing, or unreachable values remain false. +- Update `features-slice.test.ts` and `app/actions/features.test.ts` so tests + prove all declared defaults are false and missing/non-boolean backend values + fail closed without repeating the complete feature list in test fixtures. +- Add a repository contract test that extracts the backend `FeaturesConfig` + JSON tags and requires exact equality with `defaultFeatureFlags` keys. + +This changes state/type normalization only. It does not change layout, +navigation, touch behavior, or the existing Feature Toggles cards. The closest +mobile surface remains the shipped responsive System settings page; no new +mobile composition or Playwright scenario is required for this task. + +--- + +## Tests + +- **What:** every typed backend feature has exactly one registry/profile + binding and its getter/setter round-trips. + - **File:** `apps/backend/internal/runtimeflags/registry_test.go` + - **How:** reflect over `config.FeaturesConfig`, compare tags/default keys, + apply true/false on a fresh config, and assert unrelated fields do not + change. +- **What:** generic runtime resolution preserves environment lock, persisted + override, profile default, pending-restart, and Debug implied-env behavior. + - **Files:** `apps/backend/internal/runtimeflags/config_test.go`, + `service_test.go`, and existing handler/store tests. + - **How:** run the existing focused package tests after making them consume + registrations. +- **What:** the public feature JSON contract remains tagged and boolean. + - **File:** `apps/backend/internal/common/config/config_test.go` + - **How:** reflective tag validation plus JSON marshal/unmarshal coverage. +- **What:** frontend feature names have one all-false declaration and bad or + absent backend values fail closed, while backend and frontend key sets remain + exactly equal. + - **Files:** `apps/web/lib/state/slices/features/features-slice.test.ts`, + `apps/web/app/actions/features.test.ts`, and + `apps/web/lib/state/slices/features/features-contract.test.ts` + - **How:** focused Vitest tests derive fixtures from the declaration and read + the backend `FeaturesConfig` contract directly. + +## E2E Tests + +No production UI interaction changes in this foundation refactor. Existing +component tests cover registry-rendered cards and state transitions; #2104's +follow-on flag integration retains its dedicated desktop and mobile task-title +Playwright coverage with the flag explicitly enabled. Mobile parity is +satisfied here by preserving the existing responsive Feature Toggles page and +changing only state/type normalization. + +--- + +## Risks + +- Registry config functions must remain internal so the public JSON metadata + contract does not acquire implementation fields. +- Debug mode has intentional environment side effects beyond its typed config + value; generic registration must preserve them exactly. +- A reflective completeness test must compare `json` names, `mapstructure` + names, and `KANDEV_FEATURES_*` names without making production config loading + reflective. +- PR #2104 cannot claim full flag isolation for code that still refactors the + shared task-creation path unconditionally. + +--- + +## Documentation + +- Amend `docs/decisions/0007-runtime-feature-flags.md` to reference the new + release-toggle ADR, current `backendapp` paths, definition-owned bindings, + default-off lifecycle, and actual add/remove steps. +- Update `docs/public/extending-kandev.md` with the authoritative-gate checklist + and staged rollout/removal procedure. +- Repair the runtime-toggle inventory in `docs/public/configuration.md`, which + currently contains duplicated and graduated plugin entries. +- Update root `AGENTS.md` so future agents use the streamlined declaration and + fail-closed entry-path rules. +- Remove the orphaned + `apps/web/e2e/tests/settings/feature-toggles-helpers.ts`, which still refers to + the graduated `features.unreadDivider` flag and has no callers. + +--- + +## Follow-on Pull Request #2104 + +After this foundation lands on `main`, rebase PR #2104 and update its existing +agent-generated-title spec, ADR, plan, and tasks on that branch: + +1. Add `features.agentGeneratedTaskTitles` with + `KANDEV_FEATURES_AGENT_GENERATED_TASK_TITLES=false` in all profiles and a + high-risk experimental registry definition. +2. Remove the temporary rollout concern from portable user settings: delete the + `agent_generated_task_titles` user-setting DTO/store/boot/state plumbing and + its General settings card unless product requirements separately retain a + permanent user preference. +3. Gate task/subtask creation UI with + `useFeature("agentGeneratedTaskTitles")`, keeping legacy title-required + behavior while off. +4. Reject `auto_title` in the task service while the install-wide feature is + off, before deriving a title or writing pending metadata. +5. Require the flag as well as pending-title state before injecting the system + prompt or exposing `set_task_title_kandev`, including every launch, resume, + queued-message, and workflow entry path. Disabling the flag leaves existing + provisional titles readable and inert. +6. Run backend on/off tests plus the existing desktop and mobile Playwright + specs with the flag explicitly enabled. Add a legacy/off regression proving + ordinary title creation remains unchanged. +7. Keep the dialog extraction separate from the rollout claim: either split the + unconditional refactor or retain explicit regression evidence for the + feature-off path. + +PR #2104 remains a separate dependent PR so the flag-system change can merge +and be released independently. + +--- + +## Verification Results + +- Backend-focused tests: passed (91 tests across runtimeflags, common/config, + and profiles). +- Backend lint: passed with 0 issues. +- Frontend-focused Vitest: passed (2 files, 8 tests). +- Frontend typecheck and lint: passed. +- Public docs validation: passed (58 tests; 41 published pages). +- `git diff --check`: passed. + +### Review remediation (2026-08-02) + +- Red phase: focused runtimeflags tests failed because the retired-identity set + did not exist. +- `go test ./internal/runtimeflags ./internal/profiles` from `apps/backend` + passed (37 tests across 2 packages); the full backend suite passed, and + backend lint reported 0 issues. +- The new backend/frontend contract test passed; the full web Vitest suite, + web typecheck, and web lint passed. +- Public docs validation passed (58 tests; 41 published pages), and + `git diff --check` passed. + +--- + +## Implementation Waves And Parallel Candidates + +Wave 1 (parallel candidates — user authorization required): + +- [x] [task-01-centralize-backend-bindings](task-01-centralize-backend-bindings.md) +- [x] [task-02-consolidate-frontend-contract](task-02-consolidate-frontend-contract.md) + +Wave 2: + +- [x] [task-03-document-release-toggle-workflow](task-03-document-release-toggle-workflow.md) + +The default execution order is sequential in the primary conversation. These +wave labels do not authorize subagents. diff --git a/docs/plans/release-feature-toggles/task-01-centralize-backend-bindings.md b/docs/plans/release-feature-toggles/task-01-centralize-backend-bindings.md new file mode 100644 index 0000000000..3f6297d08e --- /dev/null +++ b/docs/plans/release-feature-toggles/task-01-centralize-backend-bindings.md @@ -0,0 +1,59 @@ +--- +id: "01-centralize-backend-bindings" +title: "Centralize backend flag bindings" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/feature-toggles/spec.md" +--- + +# Task 01: Centralize backend flag bindings + +- **Acceptance:** Each runtime flag's key, environment variable, config reader, + and config applier are owned by one internal registry registration while the + public metadata/API shape remains unchanged. +- **Acceptance:** `OptionsFromConfig`, `ValuesFromConfig`, and + `ApplyStatesToConfig` contain no per-feature map or switch, and Debug mode's + implied environment behavior remains intact. +- **Acceptance:** Generic tests fail when a `FeaturesConfig` field lacks a + registry/profile binding or when its binding mutates the wrong field. +- **Verification:** Red first with the completeness/round-trip test, then run + `make -C apps/backend test`; run `make -C apps/backend lint` from the repo + root. +- **Files likely touched:** + - `apps/backend/internal/runtimeflags/registry.go` + - `apps/backend/internal/runtimeflags/config.go` + - `apps/backend/internal/runtimeflags/registry_test.go` + - `apps/backend/internal/runtimeflags/config_test.go` + - `apps/backend/internal/common/config/config_test.go` +- **Dependencies:** None. +- **Parallelism:** `parallel-safe` with Task 02 only; files are disjoint and + there is no shared generated contract, schema, lockfile, or package config. +- **Inputs:** Feature Toggles spec `What`, `Persistence guarantees`, and + `Scenarios`; ADR-2026-08-01-release-toggle-gating-contract; existing + `runtimeflags` service/handler/store tests. +- **Output contract:** Report changed files, exact test command and counts, + blockers/risks, external side effects (`None` expected), then update this task + and `plan.md` statuses/results in the same conversation. + +## Results + +- Changed `apps/backend/internal/runtimeflags/registry.go` to keep metadata, + typed config readers, and typed config appliers in one internal registration. +- Changed `apps/backend/internal/runtimeflags/config.go` to iterate those + registrations for environment values, runtime values, and persisted state. + Debug implied-environment behavior remains in its named applier. +- Added reflective registry/profile/config round-trip coverage and generic + `FeaturesConfig` JSON/tag coverage. +- Review remediation made config, registry, and profile key comparison exact in + both directions; validates registration metadata and binding isolation; and + reserves graduated key/environment identities in an append-only tombstone + set. The graduated plugins identity is the first tombstone. +- Review verification: `go test ./internal/runtimeflags ./internal/profiles` + from `apps/backend` passed (37 tests across 2 packages); the full backend + suite passed, and backend lint reported 0 issues. +- Verification: `go test ./internal/runtimeflags ./internal/common/config ./internal/profiles` + from `apps/backend` passed (91 tests across 3 packages), `make -C apps/backend + test` passed, and `make -C apps/backend lint` passed with 0 issues. +- External side effects: none. diff --git a/docs/plans/release-feature-toggles/task-02-consolidate-frontend-contract.md b/docs/plans/release-feature-toggles/task-02-consolidate-frontend-contract.md new file mode 100644 index 0000000000..29d4fa0021 --- /dev/null +++ b/docs/plans/release-feature-toggles/task-02-consolidate-frontend-contract.md @@ -0,0 +1,54 @@ +--- +id: "02-consolidate-frontend-contract" +title: "Consolidate frontend feature contract" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/feature-toggles/spec.md" +--- + +# Task 02: Consolidate frontend feature contract + +- **Acceptance:** Frontend feature names, `FeatureName`, `FeatureFlags`, and + all-false defaults derive from one declaration; the Zustand slice does not + repeat each key. +- **Acceptance:** SSR normalization and `useFeature()` continue to treat missing, + malformed, or unreachable backend values as false. +- **Acceptance:** Focused tests derive their complete feature fixtures instead + of maintaining a second hardcoded list. +- **Verification:** Red first with the single-declaration/fail-closed tests, then + run `cd apps && pnpm install --frozen-lockfile`; run `cd apps && pnpm --filter + @kandev/web exec vitest run lib/state/slices/features/features-slice.test.ts + app/actions/features.test.ts`; run `cd apps/web && pnpm run typecheck`. Run + `cd apps && pnpm --filter @kandev/web lint`. +- **Files likely touched:** + - `apps/web/lib/state/slices/features/types.ts` + - `apps/web/lib/state/slices/features/features-slice.ts` + - `apps/web/lib/state/slices/features/features-slice.test.ts` + - `apps/web/app/actions/features.test.ts` +- **Dependencies:** None. +- **Parallelism:** `parallel-safe` with Task 01 only; files are disjoint and + there is no shared generated contract, schema, lockfile, or package config. +- **Inputs:** Feature Toggles spec `What`, `Failure modes`, and `Scenarios`; + `apps/web/AGENTS.md`; existing `getFeatureFlagsAction` and `useFeature`. +- **Output contract:** Report changed files, exact commands and counts, + blockers/risks, rendered/mobile impact (`None; normalization only` expected), + external side effects (`None` expected), then update this task and `plan.md` + statuses/results in the same conversation. + +## Results + +- Added `defaultFeatureFlags` as the single all-off declaration and derived + `FeatureName`/`FeatureFlags` from its keys. +- Updated the Zustand slice and server action to consume that declaration; + malformed, missing, and unreachable values remain fail-closed. +- Updated slice/action tests to derive complete fixtures from the declaration. +- Review remediation added a repository contract test that extracts backend + `FeaturesConfig` JSON tags and compares them exactly with frontend defaults. +- Review verification: the contract test and full web Vitest suite passed; + web typecheck and lint passed. +- Verification: `pnpm install --frozen-lockfile` passed; focused Vitest passed + (2 files, 8 tests); `pnpm run typecheck` passed; `pnpm run lint` passed. +- Rendered/mobile impact: none; state/type normalization only. +- External side effects: none. diff --git a/docs/plans/release-feature-toggles/task-03-document-release-toggle-workflow.md b/docs/plans/release-feature-toggles/task-03-document-release-toggle-workflow.md new file mode 100644 index 0000000000..ecac09a1eb --- /dev/null +++ b/docs/plans/release-feature-toggles/task-03-document-release-toggle-workflow.md @@ -0,0 +1,55 @@ +--- +id: "03-document-release-toggle-workflow" +title: "Document release-toggle workflow" +status: completed +wave: 2 +depends_on: ["01-centralize-backend-bindings", "02-consolidate-frontend-contract"] +plan: "plan.md" +spec: "../../specs/feature-toggles/spec.md" +--- + +# Task 03: Document release-toggle workflow + +- **Acceptance:** Architecture, contributor, operator, and agent guidance agree + on install-wide ownership, all-profile default-off rollout, authoritative + entry-path gates, one default-on kill-switch release, removal, and key + non-reuse. +- **Acceptance:** Documentation names the streamlined declaration sites and no + longer lists graduated/duplicated flags or obsolete backend paths. +- **Acceptance:** The unused E2E helper for the graduated unread-divider flag is + removed, and public-doc validation passes. +- **Verification:** Run `node --test scripts/validate-public-docs.test.mjs`, + `node scripts/validate-public-docs.mjs`, and `git diff --check` from the repo + root. +- **Files likely touched:** + - `docs/decisions/0007-runtime-feature-flags.md` + - `docs/decisions/0018-runtime-settings-overrides.md` + - `docs/public/configuration.md` + - `docs/public/extending-kandev.md` + - `AGENTS.md` + - `apps/web/e2e/tests/settings/feature-toggles-helpers.ts` (remove) +- **Dependencies:** Tasks 01 and 02, so the documented add/remove steps match + the landed implementation. +- **Parallelism:** `sequential`; it reconciles both prior task outputs. +- **Inputs:** Feature Toggles spec; ADR-0007, ADR-0018, and + ADR-2026-08-01-release-toggle-gating-contract; Docs Maintainer guidance. +- **Output contract:** Report changed/removed files, exact validation commands + and counts, security/trust impact (`None` expected), external side effects + (`None` expected), then update this task and `plan.md` statuses/results in the + same conversation. + +## Results + +- Updated ADR-0007, public configuration/extending docs, and root agent + guidance with install-wide ownership, fail-closed entry paths, staged + rollout, kill-switch retention, removal, and retired key/environment + non-reuse. +- Added the release-toggle contract ADR and indexes, and removed the orphaned + unread-divider feature-toggle E2E helper. +- Verification: `node --test scripts/validate-public-docs.test.mjs` passed + (58 tests); `node scripts/validate-public-docs.mjs` validated 41 pages; + `git diff --check` passed. +- Security/trust impact: none; docs clarify existing boundaries. +- External side effects: none. +- Review verification: public-doc tests passed (58 tests), validation covered + 41 published pages, and `git diff --check` passed. diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 707b65998a..3dcce2f977 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -126,10 +126,8 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie | `office.jwtSigningKey` | `KANDEV_OFFICE_JWTSIGNINGKEY` | random per start | HMAC key for Office agent-runtime JWTs. Set a stable secret when Office tasks must survive restarts. | | `voice.openAIApiKey` | `KANDEV_VOICE_OPENAI_API_KEY` | empty | Server-side transcription fallback when browser speech recognition is unavailable. Empty disables the fallback and its endpoint returns unavailable. | | `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. | -| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. | -| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. | -| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. | | `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. | +| `features.auth` | `KANDEV_FEATURES_AUTH` | `false` in production | Opt-in authentication and per-user workspaces. The first visitor after enabling completes setup and becomes the admin. | | `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. | Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`. @@ -291,9 +289,9 @@ voice: features: office: false - plugins: false - app_status_bar: true - app_status_bar: true + app_status_bar: false + auth: false + claude_background_prompt_handoff: false ``` Copying this entire file is unnecessary and can freeze old defaults in a deployment. Keep only deliberate overrides. On Windows, do not copy the Unix Docker host/path literals from this example. @@ -307,16 +305,22 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym | Key | Environment lock | Production default | Effect | |---|---|---|---| | `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. | -| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. | -| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. | -| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. | | `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. | +| `features.auth` | `KANDEV_FEATURES_AUTH` | off | Authentication and per-user workspaces for the whole install. | | `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. | | `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. | UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override. -The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment. +For a risky release feature, keep the flag off in the shipped profiles, enable it +only on a selected install through an admin override or explicit environment, +restart, and test it there. Promote the `prod` profile default only after the +feature is ready for everyone; keep the registry entry as a kill-switch until +the rollout is complete, then remove the live flag and move its key and +environment variable to the runtime registry's append-only retired identities. +Plugins are part of the base product and are not a runtime toggle. + +The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar, authentication, and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment. ## Credentials and product settings The **Unread Messages** preference in **Settings > General > Task Actions** controls the Slack-style **New** divider in session transcripts. It defaults off for each user, persists with user settings, and takes effect immediately. Enabling it also allows that user's active transcript view to advance the session read cursor. diff --git a/docs/public/extending-kandev.md b/docs/public/extending-kandev.md index c3d95dfa97..d5841df14e 100644 --- a/docs/public/extending-kandev.md +++ b/docs/public/extending-kandev.md @@ -91,9 +91,8 @@ instance and app store. Plugins are distributed as a signed-or-unsigned tarball and installed by URL, manual upload, or filesystem sideload/sync — there is no manifest-paste -registration step and no credentials to issue. The whole system sits behind -the `plugins` feature flag (Settings > System > Feature Toggles), off by -default in production. See [Plugins](plugins.md) for the operator-facing +registration step and no credentials to issue. Plugins are part of the base +product and are not a runtime feature toggle. See [Plugins](plugins.md) for the operator-facing install/operate flow, [Authoring a plugin](plugins-authoring.md) for the build tutorial and SDK reference, and the [Plugin manifest reference](plugins-manifest.md) for the complete `manifest.yaml` schema. @@ -102,7 +101,34 @@ reference](plugins-manifest.md) for the complete `manifest.yaml` schema. Create backend ownership and validation before a web form. Web settings require the appropriate domain client/hook, `src/settings-routes.tsx`, page/components, and sidebar or general navigation. Add a global state slice only when multiple surfaces or WebSocket hydration require it. -Wire runtime feature flags through every enforcement layer in one change. Add the default to `profiles.yaml` and `FeaturesConfig`, register its environment variable and runtime behavior in `internal/runtimeflags/registry.go`, and gate backend construction, handlers, or other call sites. Mirror the field in the frontend feature types, use `useFeature` for client surfaces, and call `notFound()` from the server layout or page when a guessed URL must remain unavailable. Add backend and frontend tests for both states. A flag is incomplete if any layer can expose or execute the feature while it is disabled. Do not turn an internal package or environment variable into a public setting without first defining its compatibility contract and support status. +Wire a release toggle through every enforcement layer in one change: + +1. Add a `KANDEV_FEATURES_*` entry to `profiles.yaml` with `prod`, `dev`, and + `e2e` set to `false` until the rollout is ready. +2. Add the typed `FeaturesConfig` field with explicit `mapstructure` and `json` + tags, then add one metadata/read/apply registration in + `internal/runtimeflags/registry.go`. The registry owns the binding; do not + add a parallel per-flag map or switch. +3. Gate backend construction, handlers, HTTP/WS/MCP actions, background jobs, + and agent prompt paths at their narrow composition boundary. The disabled + path must fail closed before deriving data, writing state, or dispatching + work, while existing readable data remains inert. +4. Add the field to the frontend `defaultFeatureFlags` declaration, use + `useFeature` for client surfaces, and call `notFound()` from the server + layout or page when a guessed URL must remain unavailable. +5. Add backend and frontend tests for enabled and disabled behavior, including + legacy behavior while the flag is off. Shared refactors, migrations, and + data reads are not automatically protected by a feature flag. + +For rollout, merge with the flag off, enable it on one install through an +admin override or explicit environment and restart, then promote the `prod` +default after observation. Remove the live flag only after it is permanent, +and move its key and environment variable to the append-only retired identities +in `internal/runtimeflags/registry.go`. Never reuse either identity for +different behavior. A flag is incomplete if any layer can expose or execute +the feature while it is disabled. Do not turn an internal package or +environment variable into a public setting without first defining its +compatibility contract and support status. Workbench changes must preserve task/session/repository selection, dock-layout restoration, reconnect behavior, and the separate mobile layout. Test unavailable dependencies, invalid credentials, save/test failures, keyboard access, and narrow viewports. diff --git a/docs/specs/feature-toggles/spec.md b/docs/specs/feature-toggles/spec.md index 0a262579ba..54a106c4f5 100644 --- a/docs/specs/feature-toggles/spec.md +++ b/docs/specs/feature-toggles/spec.md @@ -1,128 +1,95 @@ --- status: draft created: 2026-06-14 +updated: 2026-08-02 owner: tbd --- # Feature Toggles +Decision: [ADR-2026-08-01-release-toggle-gating-contract](../../decisions/2026-08-01-release-toggle-gating-contract.md) + +Implementation plan: [Release Feature Toggles](../../plans/release-feature-toggles/plan.md) + ## Why -Users can already opt into runtime features through environment variables and -`profiles.yaml`, but those controls are invisible from the app and require -terminal access. Users need a settings page that explains experimental -features, lets them choose install-level overrides, and clearly handles the -restart required for startup-gated features such as Office mode and Debug mode. +Kandev needs to merge medium and large changes into `main` without making an +unfinished or risky behavior available in the next release. Operators also +need a simple way to enable a gated feature on one installation, test it with +real work, disable it again if necessary, and understand when restart is +required. Adding and later removing a temporary release toggle must not require +duplicating flag identity or config-binding code across many files. ## What -- Kandev adds a **Feature Toggles** settings page at +- Kandev provides install-wide runtime toggles under + **Settings > System > Feature Toggles** at `/settings/system/feature-toggles`. -- The page shows user-facing runtime toggles, not every profile/env knob. -- V1 exposes three user-facing toggles: - - **Office mode** — enables the autonomous-agent Office surface. - - **App status bar** — shows the general-purpose desktop/tablet status bar and phone Status drawer entry. It is stable and off by default in production; enabling it changes visibility only. Disabling it does not suppress the problem-only [WebSocket connectivity warning](../ui/ws-connectivity-warning.md). - - **Debug mode** — enables local diagnostic/debug behavior, including agent - message debug logs. -- Office mode is marked **Experimental** and includes a concise description of - what it does and the risks of enabling it. -- Debug mode is a single user-facing toggle. Agent message debug logs are part - of Debug mode, not a duplicate top-level toggle. -- Every v1 toggle requires restart before the changed value takes effect. -- The page distinguishes effective value, saved override, default value, and - environment-locked value. -- Explicit environment variables remain authoritative. If an env var controls a - flag, the UI shows the flag as locked and does not allow overriding it. -- Users can reset a saved override back to the profile/env default. -- After saving any restart-required change, the UI shows a persistent restart - banner and offers a restart action when Kandev can safely restart itself. -- If automatic restart is unsupported, the UI shows manual restart guidance. -- `/api/v1/features` continues to return effective feature booleans for SSR - gating and existing `useFeature()` callers. - -## Tech stack - -- Backend: Go, Gin HTTP handlers, SQLite persistence. -- Frontend: Next.js App Router, React, Zustand, `@kandev/ui` shadcn - components. -- E2E: Playwright under `apps/web/e2e`. -- Runtime defaults: `profiles.yaml` embedded by `apps/backend/internal/profiles`. - -## Commands - -```bash -make fmt -make typecheck test lint -make -C apps/backend test -cd apps && pnpm --filter @kandev/web typecheck -cd apps && pnpm --filter @kandev/web lint -cd apps/web && pnpm e2e -- settings/feature-toggles.spec.ts settings/mobile-feature-toggles.spec.ts -``` - -## Project structure - -```text -apps/backend/internal/runtimeflags/ Runtime flag registry, store, service, handlers -apps/backend/internal/system/restart/ Restart capability/request support -apps/backend/cmd/kandev/ Startup override application and route wiring -apps/web/app/settings/system/feature-toggles/page.tsx -apps/web/components/settings/system/feature-toggles-settings.tsx -apps/web/lib/api/domains/runtime-flags-api.ts -docs/decisions/0018-runtime-settings-overrides.md -docs/decisions/0019-restart-supervisor.md -``` - -Planned E2E coverage: - -```text -apps/web/e2e/settings/feature-toggles.spec.ts TODO -apps/web/e2e/settings/mobile-feature-toggles.spec.ts TODO -``` - -## Code style - -Use typed contracts at the backend boundary and keep env-var knowledge in the -backend registry rather than duplicating it in React components. - -```go -type RuntimeFlagDefinition struct { - Key string - EnvVar string - Kind RuntimeFlagKind - Label string - Description string - Stability RuntimeFlagStability - RiskLevel RuntimeFlagRiskLevel - RiskDescription string - RestartRequired bool - Mutable bool -} -``` - -Frontend components render the API shape and avoid hardcoding flag-specific -logic except for layout/grouping. +- Runtime toggles have two uses: + - **Release toggles** temporarily protect unfinished or risky behavior while + it bakes on selected installations. They are removed after graduation. + - **Operator configuration toggles** are supported long-lived installation + choices such as Authentication or Debug mode. +- A new release toggle defaults to `false` in the `prod`, `dev`, and `e2e` + profiles. A feature spec may deliberately choose a different non-production + default, but `prod` remains off until the default-on rollout. +- An administrator can save an installation override from Feature Toggles. + Explicit environment variables remain authoritative and lock the UI control. +- Every toggle change requires restart before its effective runtime value + changes. Unsupported supervisors show manual restart guidance. +- `/api/v1/features` exposes the startup-effective feature booleans used by SSR + and `useFeature()` callers. Missing or unavailable frontend flag data fails + closed to `false`. +- Feature UI is absent while its release toggle is off. A guessed route returns + `404` when the feature owns a route subtree. +- The backend is authoritative. A disabled feature cannot be invoked by direct + HTTP, WebSocket, MCP, background-job, or agent-tool entry paths even if a + stale or modified client sends its enabled-only request shape. +- Gates live at the narrowest composition or capability boundary that covers + each entry path. Internal helpers do not independently re-read the flag when + their caller has already enforced the same boundary. +- The disabled path preserves the behavior that existed before the gated PR. + Shared refactors, startup initialization, schema migrations, and data-model + compatibility are not considered protected merely because a later behavior + branch is gated. +- Data written while a feature is enabled remains readable and inert when the + feature is disabled. Each gated feature documents any stronger rollback + behavior in its own spec. +- Runtime flag definitions bind their registry key, environment variable, + config read, and config apply behavior in one backend definition. Frontend + feature names and default-false values have one declaration. Graduated flag + keys and environment variables move to an append-only retired-identity set. +- A release toggle follows this lifecycle: + 1. merge with every shipped profile default off; + 2. enable selected installations with an override or explicit environment; + 3. ship one default-on release while retaining the kill switch; + 4. remove the live flag and legacy behavior after the default-on release is + proven, while retaining its retired key and environment identity. +- Removed flag keys and environment variables are never reused. Unknown + persisted override rows are ignored so upgrades and downgrades do not + destructively rewrite operator state. ## Data model -Feature Toggle overrides are install-scoped and stored in SQLite. - -`runtime_flag_overrides` +Feature-toggle overrides are installation-scoped and stored in +`runtime_flag_overrides`. | Field | Type | Constraint | |---|---|---| -| `key` | `TEXT` | primary key, e.g. `features.office` | +| `key` | `TEXT` | primary key, for example `features.office` | | `value` | `INTEGER` | required, `0` or `1` | | `created_at` | `DATETIME` | required | | `updated_at` | `DATETIME` | required | -Absence of a row means "use the effective default". A `false` row is a real -override and must not be treated as missing. +Absence of a row means “use the effective default.” A `false` row is a real +override. Rows whose keys are absent from the running binary's registry are +ignored and are not deleted automatically. ## API surface -`GET /api/v1/runtime-flags` +### `GET /api/v1/runtime-flags` -Returns all user-facing runtime toggle states and metadata. +Returns the registered operator-facing toggle states and metadata. ```json { @@ -134,7 +101,7 @@ Returns all user-facing runtime toggle states and metadata. "description": "Enables autonomous agent office workflows and related settings.", "stability": "experimental", "risk_level": "medium", - "risk_description": "Office mode is still evolving. Workflows, routes, and background automation may change between releases and should be reviewed before relying on them.", + "risk_description": "Office mode is still evolving.", "effective_value": false, "default_value": false, "override_value": true, @@ -142,159 +109,133 @@ Returns all user-facing runtime toggle states and metadata. "env_var": "KANDEV_FEATURES_OFFICE", "env_locked": false, "restart_required": true, - "requires_restart_to_apply": true + "requires_restart_to_apply": true, + "mutable": true } ] } ``` -`PATCH /api/v1/runtime-flags/:key` +### `PATCH /api/v1/runtime-flags/:key` -```json -{ "override": true } -``` +The request body is `{ "override": true | false | null }`. `null` clears the +saved override. Unknown keys return `404`; environment-locked keys return +`409`. Mutation requires an administrator identity. When authentication is +disabled, the synthetic install identity is an administrator. -`override` can be `true`, `false`, or `null`. `null` clears the saved override. -Unknown keys return `404`. Env-locked keys reject writes with `409`. +### `GET /api/v1/features` -`GET /api/v1/system/restart-capability` +Returns startup-effective feature booleans keyed by their public JSON names. +It remains a public bootstrapping endpoint and contains no risk metadata or +saved override details. -```json -{ - "supported": true, - "mode": "cli", - "reason": "" -} -``` - -`mode` is `cli`, `systemd`, `launchd`, or `unsupported`. - -`POST /api/v1/system/restart` - -Requests a restart. The response is flushed before shutdown starts so the UI -can enter a waiting state and poll `/health`. - -`GET /api/v1/features` - -Existing contract remains unchanged: effective feature booleans only. +Restart capability and restart-request contracts remain defined by +[Restart supervisor ownership](../../decisions/0019-restart-supervisor.md). ## State machine -Runtime flag state: - ```text -profile default -> override saved -> restart pending -> restarted/effective - \-> override cleared -> restart pending -> restarted/default +default off + -> selected-install override on + -> default on with kill switch retained + -> live flag removed, identity retired, new behavior permanent ``` -Restart request state: - -```text -idle -> requested -> backend unavailable -> backend healthy -> complete - \-> timeout -> manual recovery shown -``` +At any retained-flag stage, an explicit environment value has precedence over +a saved override. A saved value that differs from the current process value is +`restart pending` until a new backend boot applies it. ## Permissions -Kandev is single-user in v1. Every user with settings access can view and change -Feature Toggles. Future multi-user support must restrict Feature Toggles, -especially Debug mode, to admins. +- Authenticated administrators and members can read runtime-toggle state and + risk metadata; only administrators can mutate installation-wide overrides. +- `/api/v1/features` remains public because the SPA needs it before authenticated + route composition; it exposes booleans only. +- MCP and agent callers do not receive a generic flag-mutation capability. ## Failure modes -- **Environment-locked flag** — UI disables the switch and shows the controlling - env var. API rejects override writes with `409`. -- **Override store unavailable** — runtime flags API fails with a recoverable - error; existing `/api/v1/features` continues to use startup-effective config. -- **Restart unsupported** — restart banner shows manual restart instructions - and no dead action button. -- **Restart requested while one is pending** — API rejects or idempotently - reports the pending restart. -- **Backend does not return after restart** — frontend times out and shows - manual recovery guidance. -- **Debug mode enabled** — UI warns that local diagnostic endpoints and agent - message logs may include sensitive prompt, file, and tool content. +- **Environment lock** — the UI disables mutation and identifies the controlling + environment variable; the API returns `409`. +- **Override store unavailable** — the runtime-flags API reports a recoverable + error; startup-effective behavior remains unchanged. +- **Frontend flag fetch fails** — client and SSR gates use their all-false + defaults and do not expose the feature. +- **Disabled feature request** — the authoritative backend boundary rejects the + request without mutation or side effects. Route-owned surfaces use `404`; + extensions to an existing endpoint use a stable client error defined by that + feature. +- **Restart unsupported or unsuccessful** — manual restart/recovery guidance is + shown and the running process retains its old effective values. +- **Unknown persisted override** — the row is ignored and hidden, but retained + for downgrade compatibility. Its key cannot be assigned to a future feature. +- **Feature disabled after enabled data exists** — the data remains readable but + cannot trigger enabled-only execution. The feature's own spec defines any + feature-specific resume or cleanup behavior. ## Persistence guarantees -- Saved overrides survive Kandev restarts. -- Effective runtime values are applied at backend startup. -- V1 toggles are startup-gated; changing a value does not affect already - registered routes, constructed services, or running agentctl processes until - restart. -- Environment variables always win over saved overrides. +- Saved overrides survive restart and version upgrades. +- Effective values are resolved at backend startup with precedence: -## Testing strategy + ```text + explicit environment > saved install override > active profile > Go zero value + ``` -- Backend unit tests cover registry metadata, override persistence, precedence, - env locking, and pending-restart computation. -- Backend handler tests cover runtime flag APIs and restart capability/request - behavior. -- Frontend component tests cover Office experimental copy, Debug mode copy, - env-locked rows, save/reset actions, and restart banner states. -- Desktop and mobile Playwright tests cover the user workflow from settings - navigation through saving a restart-required toggle. - -## Boundaries - -- Always: keep `profiles.yaml` as immutable shipped defaults. -- Always: keep env vars authoritative over DB overrides. -- Always: show restart-required state for v1 toggles. -- Always: include mobile coverage for the settings page. -- Ask first: adding a new external flag service or changing the auth model. -- Ask first: exposing mocks/e2e tuning as user-facing toggles. -- Never: make the Go backend fork an unmanaged replacement process for restart. -- Never: expose agent message debug logs as a separate top-level toggle in v1. +- Applying or clearing a saved override does not live-mutate already registered + routes, constructed services, or agent processes. +- Unknown override rows are non-authoritative and non-destructive. ## Scenarios -- **GIVEN** Office mode is using the production default, **WHEN** the user opens - `/settings/system/feature-toggles`, **THEN** Office mode appears off with a - `Default` source, an `Experimental` badge, and risk text. -- **GIVEN** Office mode is off, **WHEN** the user enables it, **THEN** the - override is saved and the page shows that restart is required before Office - mode takes effect. -- **GIVEN** `KANDEV_FEATURES_OFFICE=true` is set in the environment, **WHEN** the - user opens Feature Toggles, **THEN** Office mode appears on, env-locked, and - cannot be changed from the UI. -- **GIVEN** Debug mode is off, **WHEN** the user enables it, **THEN** the page - explains that local diagnostic endpoints and agent message debug logs will be - enabled after restart. -- **GIVEN** App status bar is using its default, **WHEN** the user opens Feature - Toggles, **THEN** it appears off and explains that enabling it adds both the - desktop/tablet bar and ordinary phone Status entry after restart, while urgent - WebSocket connectivity warnings remain available when it is off. -- **GIVEN** a toggle override exists, **WHEN** the user clicks `Reset to default`, - **THEN** the override is removed and the page shows restart-required state if - the effective runtime value differs from the restored default. -- **GIVEN** restart capability is supported, **WHEN** the user clicks - `Restart Kandev`, **THEN** the UI enters `Restarting...`, polls `/health`, and - refreshes the page after the backend returns healthy. -- **GIVEN** restart capability is unsupported, **WHEN** a restart-required change - is saved, **THEN** the UI shows manual restart instructions instead of a - clickable restart button. -- **GIVEN** a mobile viewport, **WHEN** the user opens Feature Toggles, **THEN** - all descriptions, badges, switches, reset buttons, and restart actions are - reachable without horizontal scrolling. +- **GIVEN** a new release toggle, **WHEN** Kandev boots in `prod`, `dev`, or + `e2e` without an explicit override, **THEN** the feature is off. +- **GIVEN** a release toggle is off, **WHEN** an administrator saves an on + override and restarts Kandev, **THEN** the feature is enabled only on that + installation and the page reports `Saved override` as its source. +- **GIVEN** a release toggle is off, **WHEN** a direct client sends an + enabled-only HTTP, WebSocket, MCP, or agent-tool request, **THEN** the backend + rejects it without persisting or dispatching enabled-only work. +- **GIVEN** a release toggle is off, **WHEN** a user opens the application, + **THEN** the new UI is absent and the pre-feature workflow remains usable on + desktop and mobile. +- **GIVEN** `KANDEV_FEATURES_=true`, **WHEN** the Feature Toggles page + opens, **THEN** the toggle is on, environment-locked, and cannot be changed + from the UI. +- **GIVEN** the backend cannot provide feature data during SSR, **WHEN** the SPA + renders, **THEN** every unknown or unavailable feature is treated as off. +- **GIVEN** a release toggle has baked on selected installations, **WHEN** its + production default changes to on, **THEN** operators can still override it + off for that release. +- **GIVEN** one default-on release has succeeded, **WHEN** the toggle graduates, + **THEN** the legacy behavior and live flag declarations/gates are removed, + the identity is retired, and the new behavior remains enabled. +- **GIVEN** a removed key still has a persisted override, **WHEN** Kandev boots, + **THEN** the row is ignored and no unrelated feature adopts its value. +- **GIVEN** a phone viewport, **WHEN** an administrator opens Feature Toggles and + changes an override, **THEN** the same save, reset, lock, risk, and restart + information is reachable without horizontal scrolling. ## Success criteria -- A user can enable or disable Office mode from Feature Toggles and understand - that restart is required. -- A user can enable or hide the App status bar from Feature Toggles and - understand that it applies after restart on every breakpoint. -- Office mode clearly communicates experimental status and risk. -- A user can enable or disable Debug mode without seeing duplicate agent-message - debug toggles. -- Env-controlled flags are accurately shown as locked. -- Restart-required changes have a clear supported or manual restart path. -- Existing SSR feature gating keeps working through `/api/v1/features`. -- Desktop and mobile tests cover the core workflow. +- A risky feature can merge to `main` without changing default production, + development, or E2E behavior. +- An operator can enable that feature on one installation and turn it off again. +- Direct and stale clients cannot bypass a disabled flag. +- Adding or removing a frontend-visible toggle changes one backend definition, + one typed config field, one profile entry, and at most one frontend feature + declaration before its actual gates and tests. +- CI detects incomplete or malformed registry/config/profile/frontend + contracts in either direction. +- A graduated toggle leaves no live legacy branch or reusable stale key or + environment variable. ## Out of scope -- Live-applying Office mode or Debug mode without restart. -- Per-user, workspace-scoped, or percentage rollout flags. -- Exposing mock providers, e2e tuning, or arbitrary env vars as user toggles. -- A third-party feature flag service. -- Multi-user admin enforcement in v1. +- Per-user, workspace-scoped, percentage, or cohort rollouts. +- Live-applying runtime toggles without restart. +- A third-party feature-flag service. +- Protecting incompatible migrations, unsafe shared initialization, or + unconditionally executed refactors with a late behavior check. +- Exposing mocks, E2E tuning, or arbitrary environment variables as user + settings.