refactor: centralize runtime feature flag bindings (#2142)

This commit is contained in:
Carlos Florêncio
2026-08-02 15:36:21 +01:00
committed by GitHub
parent 443e9c89cc
commit e077658a61
29 changed files with 1495 additions and 506 deletions
@@ -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.<camelCaseKey>`,
`KANDEV_FEATURES_<UPPER_SNAKE_CASE>`, 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_<NAME>` 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.
@@ -27,6 +27,7 @@ Task arrives
|-- Test strategy or coverage gaps? --------> /tdd or /e2e in the current conversation |-- Test strategy or coverage gaps? --------> /tdd or /e2e in the current conversation
|-- Add debug logs? ------------------------> /debug |-- Add debug logs? ------------------------> /debug
|-- Add Jira/Linear-style integration? -----> /add-integration |-- 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 |-- Validate implementation? ----------------> /tdd plus exact task-defined tests/E2E
|-- Need local QA/review/simplification? ----> only on explicit user request or PR finding |-- Need local QA/review/simplification? ----> only on explicit user request or PR finding
|-- Improve skills/agents/commands? --------> /harness-improvement |-- Improve skills/agents/commands? --------> /harness-improvement
+4 -4
View File
@@ -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. **`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
+4 -26
View File
@@ -28,11 +28,7 @@ apps/backend/
│ │ ├── settings/ # Agent settings │ │ ├── settings/ # Agent settings
│ │ ├── mcpconfig/ # MCP server configuration │ │ ├── mcpconfig/ # MCP server configuration
│ │ └── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI │ │ └── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI
│ ├── auth/ # Opt-in user authentication + per-user scoping │ ├── auth/ # Opt-in auth, per-user scoping, middleware, API, store
│ │ ├── 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
│ ├── agentctl/ │ ├── agentctl/
│ │ └── server/ # agentctl HTTP server │ │ └── server/ # agentctl HTTP server
│ │ ├── acp/ # ACP protocol implementation │ │ ├── acp/ # ACP protocol implementation
@@ -60,27 +56,9 @@ apps/backend/
│ │ ├── models/ # Task, Session, Executor, Message models │ │ ├── models/ # Task, Session, Executor, Message models
│ │ ├── repository/ # Database access (SQLite) │ │ ├── repository/ # Database access (SQLite)
│ │ └── service/ # Task business logic │ │ └── service/ # Task business logic
│ ├── office/ # Autonomous agent management (Office feature) │ ├── office/ # Autonomous agent management (agents, approvals, channels, config, costs,
│ │ ├── agents/ # Agent instance CRUD + auth guards │ │ # dashboard, infra, labels, onboarding, projects, repository, runtime,
│ │ ├── approvals/ # Approval requests and decisions │ │ # routines, routing, scheduler, service, shared, skills, workspaces)
│ │ ├── 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
│ ├── events/ # Event bus for internal pub/sub │ ├── events/ # Event bus for internal pub/sub
│ ├── gateway/ # WebSocket gateway │ ├── gateway/ # WebSocket gateway
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks) │ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
@@ -365,9 +365,11 @@ type VoiceConfig struct {
OpenAIAPIKey string `mapstructure:"openAIApiKey"` OpenAIAPIKey string `mapstructure:"openAIApiKey"`
} }
// FeaturesConfig is the central registry of runtime feature flags. Every flag // FeaturesConfig is the typed wire/config shape for runtime feature flags.
// defaults to false so production binaries ship with new work hidden until a // Every new release toggle defaults to false so production binaries ship with
// deployment explicitly opts in (env var, e.g. KANDEV_FEATURES_OFFICE=true). // 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 // 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 // keep the field names lowercase and the handler in helpers.go just calls
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
) )
@@ -167,14 +168,15 @@ func TestValidate_PostgresSSLMode(t *testing.T) {
}) })
} }
// TestFeatures_ProductionDefaults pins the production policy: in-progress // TestFeatures_ProductionDefaults pins the production policy: new and
// features remain off, while the shipped app status bar remains on unless a // in-progress features remain off until a deployment explicitly opts in.
// deployment or saved runtime override explicitly disables it.
func TestFeatures_ProductionDefaults(t *testing.T) { func TestFeatures_ProductionDefaults(t *testing.T) {
// Force a clean env so KANDEV_FEATURES_* and profile-selector vars from the // Force a clean env so KANDEV_FEATURES_* and profile-selector vars from the
// host shell cannot change the production-profile defaults under test. // host shell cannot change the production-profile defaults under test.
t.Setenv("KANDEV_FEATURES_OFFICE", "") t.Setenv("KANDEV_FEATURES_OFFICE", "")
unsetEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR") 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_DEV_MODE", "")
t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "") t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "")
t.Setenv("KANDEV_E2E_MOCK", "") t.Setenv("KANDEV_E2E_MOCK", "")
@@ -190,6 +192,12 @@ func TestFeatures_ProductionDefaults(t *testing.T) {
if cfg.Features.AppStatusBar { if cfg.Features.AppStatusBar {
t.Error("Features.AppStatusBar = true, want false (status surface must remain opt-in by default)") 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: // 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. // TestFeaturesConfig_JSONShape pins the wire format of GET /api/v1/features.
// The handler in helpers.go serializes FeaturesConfig directly so new // The handler in helpers.go serializes FeaturesConfig directly so new fields
// fields flow through without an extra edit; this test guarantees the // flow through without an extra edit. Every feature field must remain a bool
// `json` tag is present on every field. A regression (struct field added // with explicit JSON and profile/mapstructure names so the runtime flag
// without a tag) would surface as a capitalized JSON key and break the // registry and frontend contract can derive from the same declaration.
// frontend's case-sensitive read in apps/web/app/actions/features.ts.
func TestFeaturesConfig_JSONShape(t *testing.T) { func TestFeaturesConfig_JSONShape(t *testing.T) {
cfg := FeaturesConfig{ typeOfFeatures := reflect.TypeOf(FeaturesConfig{})
Office: true, cfgValue := reflect.New(typeOfFeatures).Elem()
AppStatusBar: true, seenJSONNames := make(map[string]struct{}, typeOfFeatures.NumField())
Auth: true, 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) raw, err := json.Marshal(cfg)
if err != nil { if err != nil {
t.Fatalf("json.Marshal: %v", err) t.Fatalf("json.Marshal: %v", err)
} }
got := string(raw) var decoded map[string]bool
want := `{"office":true,"appStatusBar":true,"auth":true,"claudeBackgroundPromptHandoff":false}` if err := json.Unmarshal(raw, &decoded); err != nil {
if got != want { t.Fatalf("json.Unmarshal: %v", err)
t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want) }
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)
}
} }
} }
+2 -1
View File
@@ -24,7 +24,8 @@
# #
# Set its `prod` value to `true`. Ship the next release. Once the # 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 # 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 ─────────────────────────────────────────────────────── # ── See also ───────────────────────────────────────────────────────
# #
@@ -13,6 +13,10 @@ import (
func TestApplyProfile_DefaultsToProd(t *testing.T) { func TestApplyProfile_DefaultsToProd(t *testing.T) {
clearProfileSelectors(t) clearProfileSelectors(t)
clearProfilesYAMLVars(t) clearProfilesYAMLVars(t)
defaults, err := FeatureFlagDefaults()
if err != nil {
t.Fatalf("FeatureFlagDefaults: %v", err)
}
count, env, err := ApplyProfile() count, env, err := ApplyProfile()
if err != nil { if err != nil {
@@ -21,9 +25,16 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) {
if env != EnvProd { if env != EnvProd {
t.Errorf("env = %q, want %q (no selector env vars set)", 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. // Prod writes every declared feature flag with its shipped default. Derive
if count != 4 { // the count so adding a flag cannot require another hard-coded update here.
t.Errorf("ApplyProfile wrote %d vars in prod; want 4", count) 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" { if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" {
t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", 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) { func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) {
clearAppliedEnvVars(t) clearAppliedEnvVars(t)
+31 -49
View File
@@ -9,35 +9,23 @@ import (
) )
const ( const (
featureOfficeKey = "features.office" envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED"
featureAppStatusBarKey = "features.appStatusBar" envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES"
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"
) )
func OptionsFromConfig(cfg *config.Config) Options { 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{ return Options{
DefaultValues: ValuesFromConfig(cfg), DefaultValues: ValuesFromConfig(cfg),
RuntimeValues: ValuesFromConfig(cfg), RuntimeValues: ValuesFromConfig(cfg),
EnvValues: map[string]bool{ EnvValues: envValues,
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)),
},
IsExplicitEnv: func(name string) bool { IsExplicitEnv: func(name string) bool {
_, ok := os.LookupEnv(name) _, ok := os.LookupEnv(name)
return ok && !profiles.WasApplied(name) return ok && !profiles.WasApplied(name)
@@ -46,41 +34,35 @@ func OptionsFromConfig(cfg *config.Config) Options {
} }
func ValuesFromConfig(cfg *config.Config) map[string]bool { func ValuesFromConfig(cfg *config.Config) map[string]bool {
debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled values := make(map[string]bool, len(registrations))
return map[string]bool{ for _, registration := range registrations {
featureOfficeKey: cfg.Features.Office, values[registration.definition.Key] = registration.read(cfg)
featureAppStatusBarKey: cfg.Features.AppStatusBar,
featureAuthKey: cfg.Features.Auth,
featureClaudeBackgroundPromptHandoffKey: cfg.Features.ClaudeBackgroundPromptHandoff,
debugDevModeKey: debugEnabled,
} }
return values
} }
func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) { func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) {
for _, state := range states { for _, state := range states {
switch state.Key { registration, ok := registrationByKey(state.Key)
case featureOfficeKey: if !ok {
cfg.Features.Office = state.EffectiveValue continue
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.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 { func RuntimeOptionsFromAppliedConfig(defaults map[string]bool, cfg *config.Config) Options {
opts := OptionsFromConfig(cfg) opts := OptionsFromConfig(cfg)
opts.DefaultValues = defaults opts.DefaultValues = defaults
+135 -76
View File
@@ -1,91 +1,150 @@
package runtimeflags 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, definition: RuntimeFlagDefinition{
EnvVar: envFeaturesOffice, Key: "features.office",
Kind: KindFeature, EnvVar: "KANDEV_FEATURES_OFFICE",
Label: "Office mode", Kind: KindFeature,
Description: "Enables autonomous agent office workflows and related settings.", Label: "Office mode",
Stability: StabilityExperimental, Description: "Enables autonomous agent office workflows and related settings.",
RiskLevel: RiskMedium, Stability: StabilityExperimental,
RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " + RiskLevel: RiskMedium,
"may change between releases and should be reviewed before relying on them.", RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " +
RestartRequired: true, "may change between releases and should be reviewed before relying on them.",
Mutable: true, 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,
}, },
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 { func Definitions() []RuntimeFlagDefinition {
out := make([]RuntimeFlagDefinition, len(definitions)) out := make([]RuntimeFlagDefinition, len(registrations))
copy(out, definitions) for i, registration := range registrations {
out[i] = publicDefinition(registration.definition)
}
return out return out
} }
func DefinitionByKey(key string) (RuntimeFlagDefinition, bool) { func DefinitionByKey(key string) (RuntimeFlagDefinition, bool) {
for _, def := range definitions { registration, ok := registrationByKey(key)
if def.Key == key { if !ok {
return def, true 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
} }
@@ -1,6 +1,21 @@
package runtimeflags 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) { func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.office") 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) { func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.appStatusBar") def, ok := DefinitionByKey("features.appStatusBar")
if !ok { if !ok {
@@ -90,3 +114,289 @@ func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) {
t.Fatal("debug.agentMessages must not be a top-level user-facing toggle") 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)
}
}
}
+32
View File
@@ -5,6 +5,9 @@ vi.mock("@/lib/config", () => ({
})); }));
import { getRuntimeDebugModeAction } from "./features"; 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<typeof fetch>[0]; type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1]; type FetchInit = Parameters<typeof fetch>[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", () => { describe("getRuntimeDebugModeAction", () => {
it("returns true when the effective debug runtime flag is enabled", async () => { it("returns true when the effective debug runtime flag is enabled", async () => {
fetchSpy.mockResolvedValueOnce( fetchSpy.mockResolvedValueOnce(
+6 -7
View File
@@ -1,8 +1,7 @@
"use server"; "use server";
import { getBackendConfig } from "@/lib/config"; import { getBackendConfig } from "@/lib/config";
import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice"; import { defaultFeatureFlags, type FeatureFlags } from "@/lib/state/slices/features/types";
import type { FeatureFlags } from "@/lib/state/slices/features/types";
import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags"; import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags";
const { apiBaseUrl } = getBackendConfig(); const { apiBaseUrl } = getBackendConfig();
@@ -12,15 +11,15 @@ const { apiBaseUrl } = getBackendConfig();
// store. Falls back to defaults (all-off) when the backend is unreachable, // store. Falls back to defaults (all-off) when the backend is unreachable,
// so a dev-server restart doesn't crash page rendering. // so a dev-server restart doesn't crash page rendering.
// //
// Parsing is type-driven via `defaultFeaturesState.features`: every key // Parsing is type-driven via `defaultFeatureFlags`: every declared key is read
// declared on FeatureFlags is read from the response and Boolean-coerced; // from the response and only actual booleans are accepted; missing or
// missing or non-bool values fall through to the default. Adding a flag // non-boolean values remain all-off. Adding a flag is therefore one entry in
// is therefore one entry in FeatureFlags + the default — no edit here. // the declaration — no edit here.
// //
// See docs/decisions/0007-runtime-feature-flags.md. // See docs/decisions/0007-runtime-feature-flags.md.
export async function getFeatureFlagsAction(): Promise<FeatureFlags> { export async function getFeatureFlagsAction(): Promise<FeatureFlags> {
const url = `${apiBaseUrl}/api/v1/features`; const url = `${apiBaseUrl}/api/v1/features`;
const defaults = defaultFeaturesState.features; const defaults: FeatureFlags = { ...defaultFeatureFlags };
try { try {
const response = await fetch(url, { cache: "no-store" }); const response = await fetch(url, { cache: "no-store" });
if (!response.ok) { if (!response.ok) {
@@ -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<void> {
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,
});
}
@@ -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));
});
});
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import { create } from "zustand"; import { create } from "zustand";
import { immer } from "zustand/middleware/immer"; import { immer } from "zustand/middleware/immer";
import { createFeaturesSlice, defaultFeaturesState } from "./features-slice"; import { createFeaturesSlice, defaultFeaturesState } from "./features-slice";
import type { FeaturesSlice } from "./types"; import { defaultFeatureFlags } from "./types";
import type { FeatureFlags, FeaturesSlice } from "./types";
function makeStore() { function makeStore() {
return create<FeaturesSlice>()( return create<FeaturesSlice>()(
@@ -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", () => { describe("features slice", () => {
// Production-safety invariant: every flag must be false out of the box. // 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 // 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)) { for (const [name, value] of Object.entries(defaultFeaturesState.features)) {
expect(value, `default of features.${name}`).toBe(false); expect(value, `default of features.${name}`).toBe(false);
} }
expect(store.getState().features.office).toBe(false); expect(store.getState().features).toEqual(flagsWith(false));
expect(defaultFeaturesState.features).toHaveProperty("appStatusBar", false);
expect(defaultFeaturesState.features).toHaveProperty("auth", false);
expect(defaultFeaturesState.features).toHaveProperty("claudeBackgroundPromptHandoff", false);
}); });
it("setFeatures replaces the whole flag map", () => { it("setFeatures replaces the whole flag map", () => {
const store = makeStore(); const store = makeStore();
store.getState().setFeatures({ store.getState().setFeatures(flagsWith(true));
office: true, expect(store.getState().features).toEqual(flagsWith(true));
appStatusBar: true, store.getState().setFeatures(flagsWith(false));
auth: true, expect(store.getState().features).toEqual(flagsWith(false));
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);
}); });
}); });
@@ -1,16 +1,8 @@
import type { StateCreator } from "zustand"; import type { StateCreator } from "zustand";
import type { FeaturesSlice, FeaturesSliceState } from "./types"; import { defaultFeatureFlags, type FeaturesSlice, type FeaturesSliceState } from "./types";
export const defaultFeaturesState: FeaturesSliceState = { export const defaultFeaturesState: FeaturesSliceState = {
// All flags default to false. Production releases ship every flag off features: { ...defaultFeatureFlags },
// 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,
},
}; };
type ImmerSet = Parameters< type ImmerSet = Parameters<
+15 -9
View File
@@ -1,17 +1,23 @@
// Runtime feature-flag state. The shape mirrors the backend's // Runtime feature-flag state. The shape mirrors the backend's
// /api/v1/features response (FeaturesConfig in // /api/v1/features response (FeaturesConfig in
// apps/backend/internal/common/config/config.go). Every flag is a boolean, // apps/backend/internal/common/config/config.go). New flags are additive:
// keyed by feature name. New flags are additive — keep this shape stable. // declare the all-off default once and derive the names and state from it.
// See docs/decisions/0007-runtime-feature-flags.md. // See docs/decisions/0007-runtime-feature-flags.md.
export type FeatureFlags = { export const defaultFeatureFlags = {
office: boolean; // New release toggles start disabled in every frontend state. The SSR layer
appStatusBar: boolean; // overwrites this with the backend's effective values after startup.
auth: boolean; office: false,
claudeBackgroundPromptHandoff: boolean; 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 = { export type FeaturesSliceState = {
features: FeatureFlags; features: FeatureFlags;
+42 -16
View File
@@ -16,7 +16,9 @@ We needed:
1. One file that's the answer to "what's on in dev / e2e / prod?" 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. 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. 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 ## 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.yaml` — the file (symlinked from `./profiles.yaml` at the repo root).
- `apps/backend/internal/profiles/profiles.go` — `DetectEnvironment`, `ApplyProfile`, `FeatureFlagDefaults`. - `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. - `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. - `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.
- 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). - `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) ### 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. - 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. - 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. - 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. - 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 ## Alternatives considered
@@ -112,21 +115,43 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats
```yaml ```yaml
KANDEV_FEATURES_<NAME>: KANDEV_FEATURES_<NAME>:
prod: "false" prod: "false"
dev: "true" dev: "false"
e2e: "true" 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.) 2. Add the matching `bool` field to `FeaturesConfig` in
3. Gate backend construction at the relevant init call site (`cmd/kandev/main.go` typically). `apps/backend/internal/common/config/config.go`, including explicit
4. Update `/api/v1/features` handler in `cmd/kandev/helpers.go` to include the new key. `mapstructure` and `json` tags. (No `v.SetDefault` is needed —
5. Add the field to `FeatureFlags` in `apps/web/lib/state/slices/features/types.ts` and the default in `features-slice.ts`. `profiles.FeatureFlagDefaults()` seeds it automatically.)
6. Update `apps/web/app/actions/features.ts` to normalize the new key. 3. Add one registration in `apps/backend/internal/runtimeflags/registry.go`
7. Gate frontend nav with `useFeature("<name>")` and page subtrees with `notFound()` in the relevant server-side layout. 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 ## 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 ## 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 - `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/profiles/profiles.go` — loader (`//go:embed`, `DetectEnvironment`, `ApplyProfile`, `FeatureFlagDefaults`)
- `apps/backend/internal/common/config/config.go` — `LoadWithPath` calls `ApplyProfile` first thing - `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/internal/backendapp/helpers.go` — `GET /api/v1/features` handler
- `apps/backend/cmd/kandev/main.go` — `initOfficeServices` early-return on `cfg.Features.Office` - `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/app/layout.tsx` — SSR-fetch + StateProvider seeding
- `apps/web/lib/state/slices/features/` — Zustand slice - `apps/web/lib/state/slices/features/` — Zustand slice
- `apps/web/hooks/domains/features/use-feature.ts` — client hook - `apps/web/hooks/domains/features/use-feature.ts` — client hook
@@ -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 requirements. The Feature Toggles UI renders that registry state rather than
hardcoding env-var behavior in React. 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 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: V1 toggle changes require restart. Restart is launcher/supervisor mediated:
@@ -55,8 +57,8 @@ process-tree cleanup.
states. states.
- Restart support needs CLI/service coordination rather than a backend-only - Restart support needs CLI/service coordination rather than a backend-only
handler. handler.
- Future multi-user support must restrict Feature Toggles, especially Debug - Feature Toggle mutations are restricted to admins, especially for Debug mode;
mode, to admins. members may read effective state so the frontend can render consistently.
## Alternatives Considered ## Alternatives Considered
@@ -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.
+1
View File
@@ -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-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-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-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-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 | | 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 |
+227
View File
@@ -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.<json-tag>` runtime registration;
- its `KANDEV_FEATURES_<MAPSTRUCTURE_TAG>` 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.
@@ -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.
@@ -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.
@@ -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.
+14 -10
View File
@@ -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. | | `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. | | `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.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.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. | | `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`. 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: features:
office: false office: false
plugins: false app_status_bar: false
app_status_bar: true auth: false
app_status_bar: true 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. 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 | | Key | Environment lock | Production default | Effect |
|---|---|---|---| |---|---|---|---|
| `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. | | `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.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. | | `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. | | `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. 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 ## 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. 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.
+30 -4
View File
@@ -91,9 +91,8 @@ instance and app store.
Plugins are distributed as a signed-or-unsigned tarball and installed by URL, Plugins are distributed as a signed-or-unsigned tarball and installed by URL,
manual upload, or filesystem sideload/sync — there is no manifest-paste manual upload, or filesystem sideload/sync — there is no manifest-paste
registration step and no credentials to issue. The whole system sits behind registration step and no credentials to issue. Plugins are part of the base
the `plugins` feature flag (Settings > System > Feature Toggles), off by product and are not a runtime feature toggle. See [Plugins](plugins.md) for the operator-facing
default in production. See [Plugins](plugins.md) for the operator-facing
install/operate flow, [Authoring a plugin](plugins-authoring.md) for the install/operate flow, [Authoring a plugin](plugins-authoring.md) for the
build tutorial and SDK reference, and the [Plugin manifest build tutorial and SDK reference, and the [Plugin manifest
reference](plugins-manifest.md) for the complete `manifest.yaml` schema. 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. 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. 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.
+160 -219
View File
@@ -1,128 +1,95 @@
--- ---
status: draft status: draft
created: 2026-06-14 created: 2026-06-14
updated: 2026-08-02
owner: tbd owner: tbd
--- ---
# Feature Toggles # 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 ## Why
Users can already opt into runtime features through environment variables and Kandev needs to merge medium and large changes into `main` without making an
`profiles.yaml`, but those controls are invisible from the app and require unfinished or risky behavior available in the next release. Operators also
terminal access. Users need a settings page that explains experimental need a simple way to enable a gated feature on one installation, test it with
features, lets them choose install-level overrides, and clearly handles the real work, disable it again if necessary, and understand when restart is
restart required for startup-gated features such as Office mode and Debug mode. required. Adding and later removing a temporary release toggle must not require
duplicating flag identity or config-binding code across many files.
## What ## 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`. `/settings/system/feature-toggles`.
- The page shows user-facing runtime toggles, not every profile/env knob. - Runtime toggles have two uses:
- V1 exposes three user-facing toggles: - **Release toggles** temporarily protect unfinished or risky behavior while
- **Office mode** — enables the autonomous-agent Office surface. it bakes on selected installations. They are removed after graduation.
- **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). - **Operator configuration toggles** are supported long-lived installation
- **Debug mode** — enables local diagnostic/debug behavior, including agent choices such as Authentication or Debug mode.
message debug logs. - A new release toggle defaults to `false` in the `prod`, `dev`, and `e2e`
- Office mode is marked **Experimental** and includes a concise description of profiles. A feature spec may deliberately choose a different non-production
what it does and the risks of enabling it. default, but `prod` remains off until the default-on rollout.
- Debug mode is a single user-facing toggle. Agent message debug logs are part - An administrator can save an installation override from Feature Toggles.
of Debug mode, not a duplicate top-level toggle. Explicit environment variables remain authoritative and lock the UI control.
- Every v1 toggle requires restart before the changed value takes effect. - Every toggle change requires restart before its effective runtime value
- The page distinguishes effective value, saved override, default value, and changes. Unsupported supervisors show manual restart guidance.
environment-locked value. - `/api/v1/features` exposes the startup-effective feature booleans used by SSR
- Explicit environment variables remain authoritative. If an env var controls a and `useFeature()` callers. Missing or unavailable frontend flag data fails
flag, the UI shows the flag as locked and does not allow overriding it. closed to `false`.
- Users can reset a saved override back to the profile/env default. - Feature UI is absent while its release toggle is off. A guessed route returns
- After saving any restart-required change, the UI shows a persistent restart `404` when the feature owns a route subtree.
banner and offers a restart action when Kandev can safely restart itself. - The backend is authoritative. A disabled feature cannot be invoked by direct
- If automatic restart is unsupported, the UI shows manual restart guidance. HTTP, WebSocket, MCP, background-job, or agent-tool entry paths even if a
- `/api/v1/features` continues to return effective feature booleans for SSR stale or modified client sends its enabled-only request shape.
gating and existing `useFeature()` callers. - Gates live at the narrowest composition or capability boundary that covers
each entry path. Internal helpers do not independently re-read the flag when
## Tech stack their caller has already enforced the same boundary.
- The disabled path preserves the behavior that existed before the gated PR.
- Backend: Go, Gin HTTP handlers, SQLite persistence. Shared refactors, startup initialization, schema migrations, and data-model
- Frontend: Next.js App Router, React, Zustand, `@kandev/ui` shadcn compatibility are not considered protected merely because a later behavior
components. branch is gated.
- E2E: Playwright under `apps/web/e2e`. - Data written while a feature is enabled remains readable and inert when the
- Runtime defaults: `profiles.yaml` embedded by `apps/backend/internal/profiles`. feature is disabled. Each gated feature documents any stronger rollback
behavior in its own spec.
## Commands - Runtime flag definitions bind their registry key, environment variable,
config read, and config apply behavior in one backend definition. Frontend
```bash feature names and default-false values have one declaration. Graduated flag
make fmt keys and environment variables move to an append-only retired-identity set.
make typecheck test lint - A release toggle follows this lifecycle:
make -C apps/backend test 1. merge with every shipped profile default off;
cd apps && pnpm --filter @kandev/web typecheck 2. enable selected installations with an override or explicit environment;
cd apps && pnpm --filter @kandev/web lint 3. ship one default-on release while retaining the kill switch;
cd apps/web && pnpm e2e -- settings/feature-toggles.spec.ts settings/mobile-feature-toggles.spec.ts 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
## Project structure persisted override rows are ignored so upgrades and downgrades do not
destructively rewrite operator state.
```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.
## Data model ## Data model
Feature Toggle overrides are install-scoped and stored in SQLite. Feature-toggle overrides are installation-scoped and stored in
`runtime_flag_overrides`.
`runtime_flag_overrides`
| Field | Type | Constraint | | 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` | | `value` | `INTEGER` | required, `0` or `1` |
| `created_at` | `DATETIME` | required | | `created_at` | `DATETIME` | required |
| `updated_at` | `DATETIME` | required | | `updated_at` | `DATETIME` | required |
Absence of a row means "use the effective default". A `false` row is a real Absence of a row means “use the effective default.” A `false` row is a real
override and must not be treated as missing. override. Rows whose keys are absent from the running binary's registry are
ignored and are not deleted automatically.
## API surface ## 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 ```json
{ {
@@ -134,7 +101,7 @@ Returns all user-facing runtime toggle states and metadata.
"description": "Enables autonomous agent office workflows and related settings.", "description": "Enables autonomous agent office workflows and related settings.",
"stability": "experimental", "stability": "experimental",
"risk_level": "medium", "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, "effective_value": false,
"default_value": false, "default_value": false,
"override_value": true, "override_value": true,
@@ -142,159 +109,133 @@ Returns all user-facing runtime toggle states and metadata.
"env_var": "KANDEV_FEATURES_OFFICE", "env_var": "KANDEV_FEATURES_OFFICE",
"env_locked": false, "env_locked": false,
"restart_required": true, "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 The request body is `{ "override": true | false | null }`. `null` clears the
{ "override": true } 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. ### `GET /api/v1/features`
Unknown keys return `404`. Env-locked keys reject writes with `409`.
`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 Restart capability and restart-request contracts remain defined by
{ [Restart supervisor ownership](../../decisions/0019-restart-supervisor.md).
"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.
## State machine ## State machine
Runtime flag state:
```text ```text
profile default -> override saved -> restart pending -> restarted/effective default off
\-> override cleared -> restart pending -> restarted/default -> selected-install override on
-> default on with kill switch retained
-> live flag removed, identity retired, new behavior permanent
``` ```
Restart request state: 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
```text `restart pending` until a new backend boot applies it.
idle -> requested -> backend unavailable -> backend healthy -> complete
\-> timeout -> manual recovery shown
```
## Permissions ## Permissions
Kandev is single-user in v1. Every user with settings access can view and change - Authenticated administrators and members can read runtime-toggle state and
Feature Toggles. Future multi-user support must restrict Feature Toggles, risk metadata; only administrators can mutate installation-wide overrides.
especially Debug mode, to admins. - `/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 ## Failure modes
- **Environment-locked flag** — UI disables the switch and shows the controlling - **Environment lock** — the UI disables mutation and identifies the controlling
env var. API rejects override writes with `409`. environment variable; the API returns `409`.
- **Override store unavailable** — runtime flags API fails with a recoverable - **Override store unavailable** — the runtime-flags API reports a recoverable
error; existing `/api/v1/features` continues to use startup-effective config. error; startup-effective behavior remains unchanged.
- **Restart unsupported** — restart banner shows manual restart instructions - **Frontend flag fetch fails** — client and SSR gates use their all-false
and no dead action button. defaults and do not expose the feature.
- **Restart requested while one is pending** — API rejects or idempotently - **Disabled feature request** — the authoritative backend boundary rejects the
reports the pending restart. request without mutation or side effects. Route-owned surfaces use `404`;
- **Backend does not return after restart** — frontend times out and shows extensions to an existing endpoint use a stable client error defined by that
manual recovery guidance. feature.
- **Debug mode enabled** — UI warns that local diagnostic endpoints and agent - **Restart unsupported or unsuccessful** — manual restart/recovery guidance is
message logs may include sensitive prompt, file, and tool content. 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 ## Persistence guarantees
- Saved overrides survive Kandev restarts. - Saved overrides survive restart and version upgrades.
- Effective runtime values are applied at backend startup. - Effective values are resolved at backend startup with precedence:
- 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.
## Testing strategy ```text
explicit environment > saved install override > active profile > Go zero value
```
- Backend unit tests cover registry metadata, override persistence, precedence, - Applying or clearing a saved override does not live-mutate already registered
env locking, and pending-restart computation. routes, constructed services, or agent processes.
- Backend handler tests cover runtime flag APIs and restart capability/request - Unknown override rows are non-authoritative and non-destructive.
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.
## Scenarios ## Scenarios
- **GIVEN** Office mode is using the production default, **WHEN** the user opens - **GIVEN** a new release toggle, **WHEN** Kandev boots in `prod`, `dev`, or
`/settings/system/feature-toggles`, **THEN** Office mode appears off with a `e2e` without an explicit override, **THEN** the feature is off.
`Default` source, an `Experimental` badge, and risk text. - **GIVEN** a release toggle is off, **WHEN** an administrator saves an on
- **GIVEN** Office mode is off, **WHEN** the user enables it, **THEN** the override and restarts Kandev, **THEN** the feature is enabled only on that
override is saved and the page shows that restart is required before Office installation and the page reports `Saved override` as its source.
mode takes effect. - **GIVEN** a release toggle is off, **WHEN** a direct client sends an
- **GIVEN** `KANDEV_FEATURES_OFFICE=true` is set in the environment, **WHEN** the enabled-only HTTP, WebSocket, MCP, or agent-tool request, **THEN** the backend
user opens Feature Toggles, **THEN** Office mode appears on, env-locked, and rejects it without persisting or dispatching enabled-only work.
cannot be changed from the UI. - **GIVEN** a release toggle is off, **WHEN** a user opens the application,
- **GIVEN** Debug mode is off, **WHEN** the user enables it, **THEN** the page **THEN** the new UI is absent and the pre-feature workflow remains usable on
explains that local diagnostic endpoints and agent message debug logs will be desktop and mobile.
enabled after restart. - **GIVEN** `KANDEV_FEATURES_<NAME>=true`, **WHEN** the Feature Toggles page
- **GIVEN** App status bar is using its default, **WHEN** the user opens Feature opens, **THEN** the toggle is on, environment-locked, and cannot be changed
Toggles, **THEN** it appears off and explains that enabling it adds both the from the UI.
desktop/tablet bar and ordinary phone Status entry after restart, while urgent - **GIVEN** the backend cannot provide feature data during SSR, **WHEN** the SPA
WebSocket connectivity warnings remain available when it is off. renders, **THEN** every unknown or unavailable feature is treated as off.
- **GIVEN** a toggle override exists, **WHEN** the user clicks `Reset to default`, - **GIVEN** a release toggle has baked on selected installations, **WHEN** its
**THEN** the override is removed and the page shows restart-required state if production default changes to on, **THEN** operators can still override it
the effective runtime value differs from the restored default. off for that release.
- **GIVEN** restart capability is supported, **WHEN** the user clicks - **GIVEN** one default-on release has succeeded, **WHEN** the toggle graduates,
`Restart Kandev`, **THEN** the UI enters `Restarting...`, polls `/health`, and **THEN** the legacy behavior and live flag declarations/gates are removed,
refreshes the page after the backend returns healthy. the identity is retired, and the new behavior remains enabled.
- **GIVEN** restart capability is unsupported, **WHEN** a restart-required change - **GIVEN** a removed key still has a persisted override, **WHEN** Kandev boots,
is saved, **THEN** the UI shows manual restart instructions instead of a **THEN** the row is ignored and no unrelated feature adopts its value.
clickable restart button. - **GIVEN** a phone viewport, **WHEN** an administrator opens Feature Toggles and
- **GIVEN** a mobile viewport, **WHEN** the user opens Feature Toggles, **THEN** changes an override, **THEN** the same save, reset, lock, risk, and restart
all descriptions, badges, switches, reset buttons, and restart actions are information is reachable without horizontal scrolling.
reachable without horizontal scrolling.
## Success criteria ## Success criteria
- A user can enable or disable Office mode from Feature Toggles and understand - A risky feature can merge to `main` without changing default production,
that restart is required. development, or E2E behavior.
- A user can enable or hide the App status bar from Feature Toggles and - An operator can enable that feature on one installation and turn it off again.
understand that it applies after restart on every breakpoint. - Direct and stale clients cannot bypass a disabled flag.
- Office mode clearly communicates experimental status and risk. - Adding or removing a frontend-visible toggle changes one backend definition,
- A user can enable or disable Debug mode without seeing duplicate agent-message one typed config field, one profile entry, and at most one frontend feature
debug toggles. declaration before its actual gates and tests.
- Env-controlled flags are accurately shown as locked. - CI detects incomplete or malformed registry/config/profile/frontend
- Restart-required changes have a clear supported or manual restart path. contracts in either direction.
- Existing SSR feature gating keeps working through `/api/v1/features`. - A graduated toggle leaves no live legacy branch or reusable stale key or
- Desktop and mobile tests cover the core workflow. environment variable.
## Out of scope ## Out of scope
- Live-applying Office mode or Debug mode without restart. - Per-user, workspace-scoped, percentage, or cohort rollouts.
- Per-user, workspace-scoped, or percentage rollout flags. - Live-applying runtime toggles without restart.
- Exposing mock providers, e2e tuning, or arbitrary env vars as user toggles. - A third-party feature-flag service.
- A third-party feature flag service. - Protecting incompatible migrations, unsafe shared initialization, or
- Multi-user admin enforcement in v1. unconditionally executed refactors with a late behavior check.
- Exposing mocks, E2E tuning, or arbitrary environment variables as user
settings.