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
|-- Add debug logs? ------------------------> /debug
|-- Add Jira/Linear-style integration? -----> /add-integration
|-- Add/roll out/promote/graduate/remove a runtime feature flag or release toggle? -> /runtime-feature-flags
|-- Validate implementation? ----------------> /tdd plus exact task-defined tests/E2E
|-- Need local QA/review/simplification? ----> only on explicit user request or PR finding
|-- Improve skills/agents/commands? --------> /harness-improvement
+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.
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
│ │ ├── mcpconfig/ # MCP server configuration
│ │ └── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI
│ ├── auth/ # Opt-in user authentication + per-user scoping
│ │ ├── authn/ # Request-identity plumbing (context + gin helpers, RequireAdmin)
│ │ ├── httpmw/ # Global enforcement middleware (allowlist, synthetic identity)
│ │ ├── httpapi/ # /api/v1/auth/* + /api/v1/users endpoints
│ │ └── store/ # auth_identities, auth_sessions, auth_api_tokens, auth_invites
│ ├── auth/ # Opt-in auth, per-user scoping, middleware, API, store
│ ├── agentctl/
│ │ └── server/ # agentctl HTTP server
│ │ ├── acp/ # ACP protocol implementation
@@ -60,27 +56,9 @@ apps/backend/
│ │ ├── models/ # Task, Session, Executor, Message models
│ │ ├── repository/ # Database access (SQLite)
│ │ └── service/ # Task business logic
│ ├── office/ # Autonomous agent management (Office feature)
│ │ ├── agents/ # Agent instance CRUD + auth guards
│ │ ├── approvals/ # Approval requests and decisions
│ │ ├── channels/ # External integration channels (webhooks)
│ │ ├── config/ # Config sync (DB ↔ filesystem)
│ │ ├── configloader/ # Filesystem config reader/writer
│ │ ├── costs/ # Cost tracking and budget policies
│ │ ├── dashboard/ # Dashboard API, issues, activity, live runs
│ │ ├── infra/ # GC, reconciliation
│ │ ├── labels/ # Task labels
│ │ ├── onboarding/ # Workspace onboarding wizard API
│ │ ├── projects/ # Project management
│ │ ├── repository/ # Office SQLite persistence
│ │ ├── runtime/ # Agent run context, capabilities, and runtime action surface
│ │ ├── routines/ # Scheduled recurring tasks
│ │ ├── routing/ # Provider routing: resolver, validators, catalogue, backoff, agent-overrides
│ │ ├── scheduler/ # Wakeup scheduler (duplicate of service scheduler features)
│ │ ├── service/ # Core office service (wakeups, event subscribers, execution policy)
│ │ ├── shared/ # Shared interfaces and activity logging
│ │ ├── skills/ # Skill injection and materialization
│ │ └── workspaces/ # Workspace deletion handler
│ ├── office/ # Autonomous agent management (agents, approvals, channels, config, costs,
│ │ # dashboard, infra, labels, onboarding, projects, repository, runtime,
│ │ # routines, routing, scheduler, service, shared, skills, workspaces)
│ ├── events/ # Event bus for internal pub/sub
│ ├── gateway/ # WebSocket gateway
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
@@ -365,9 +365,11 @@ type VoiceConfig struct {
OpenAIAPIKey string `mapstructure:"openAIApiKey"`
}
// FeaturesConfig is the central registry of runtime feature flags. Every flag
// defaults to false so production binaries ship with new work hidden until a
// deployment explicitly opts in (env var, e.g. KANDEV_FEATURES_OFFICE=true).
// FeaturesConfig is the typed wire/config shape for runtime feature flags.
// Every new release toggle defaults to false so production binaries ship with
// new work hidden until a deployment explicitly opts in (env var, e.g.
// KANDEV_FEATURES_OFFICE=true). The runtimeflags package owns metadata and
// typed config bindings.
//
// The struct doubles as the wire shape for GET /api/v1/features — `json` tags
// keep the field names lowercase and the handler in helpers.go just calls
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
@@ -167,14 +168,15 @@ func TestValidate_PostgresSSLMode(t *testing.T) {
})
}
// TestFeatures_ProductionDefaults pins the production policy: in-progress
// features remain off, while the shipped app status bar remains on unless a
// deployment or saved runtime override explicitly disables it.
// TestFeatures_ProductionDefaults pins the production policy: new and
// in-progress features remain off until a deployment explicitly opts in.
func TestFeatures_ProductionDefaults(t *testing.T) {
// Force a clean env so KANDEV_FEATURES_* and profile-selector vars from the
// host shell cannot change the production-profile defaults under test.
t.Setenv("KANDEV_FEATURES_OFFICE", "")
unsetEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR")
unsetEnv(t, "KANDEV_FEATURES_AUTH")
unsetEnv(t, "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF")
t.Setenv("KANDEV_DEBUG_DEV_MODE", "")
t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "")
t.Setenv("KANDEV_E2E_MOCK", "")
@@ -190,6 +192,12 @@ func TestFeatures_ProductionDefaults(t *testing.T) {
if cfg.Features.AppStatusBar {
t.Error("Features.AppStatusBar = true, want false (status surface must remain opt-in by default)")
}
if cfg.Features.Auth {
t.Error("Features.Auth = true, want false (authentication must remain opt-in by default)")
}
if cfg.Features.ClaudeBackgroundPromptHandoff {
t.Error("Features.ClaudeBackgroundPromptHandoff = true, want false (experiment must remain opt-in by default)")
}
}
// TestFeatures_OfficeEnabledByEnv proves the documented opt-in path:
@@ -409,24 +417,54 @@ func TestServerHostsFromConfigWhenHostUnset(t *testing.T) {
}
// TestFeaturesConfig_JSONShape pins the wire format of GET /api/v1/features.
// The handler in helpers.go serializes FeaturesConfig directly so new
// fields flow through without an extra edit; this test guarantees the
// `json` tag is present on every field. A regression (struct field added
// without a tag) would surface as a capitalized JSON key and break the
// frontend's case-sensitive read in apps/web/app/actions/features.ts.
// The handler in helpers.go serializes FeaturesConfig directly so new fields
// flow through without an extra edit. Every feature field must remain a bool
// with explicit JSON and profile/mapstructure names so the runtime flag
// registry and frontend contract can derive from the same declaration.
func TestFeaturesConfig_JSONShape(t *testing.T) {
cfg := FeaturesConfig{
Office: true,
AppStatusBar: true,
Auth: true,
typeOfFeatures := reflect.TypeOf(FeaturesConfig{})
cfgValue := reflect.New(typeOfFeatures).Elem()
seenJSONNames := make(map[string]struct{}, typeOfFeatures.NumField())
seenMapstructureNames := make(map[string]struct{}, typeOfFeatures.NumField())
for i := 0; i < typeOfFeatures.NumField(); i++ {
field := typeOfFeatures.Field(i)
if field.Type.Kind() != reflect.Bool {
t.Fatalf("FeaturesConfig.%s has type %s; want bool", field.Name, field.Type)
}
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
if jsonName == "" || jsonName == "-" || jsonName == field.Name {
t.Fatalf("FeaturesConfig.%s has invalid json tag %q", field.Name, field.Tag.Get("json"))
}
if _, exists := seenJSONNames[jsonName]; exists {
t.Fatalf("FeaturesConfig has duplicate json tag %q", jsonName)
}
seenJSONNames[jsonName] = struct{}{}
mapstructureName := strings.Split(field.Tag.Get("mapstructure"), ",")[0]
if mapstructureName == "" || mapstructureName == "-" || mapstructureName == field.Name {
t.Fatalf("FeaturesConfig.%s has invalid mapstructure tag %q", field.Name, field.Tag.Get("mapstructure"))
}
if _, exists := seenMapstructureNames[mapstructureName]; exists {
t.Fatalf("FeaturesConfig has duplicate mapstructure tag %q", mapstructureName)
}
seenMapstructureNames[mapstructureName] = struct{}{}
cfgValue.Field(i).SetBool(true)
}
cfg := cfgValue.Interface().(FeaturesConfig)
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
got := string(raw)
want := `{"office":true,"appStatusBar":true,"auth":true,"claudeBackgroundPromptHandoff":false}`
if got != want {
t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want)
var decoded map[string]bool
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if len(decoded) != typeOfFeatures.NumField() {
t.Fatalf("FeaturesConfig JSON has %d fields; want %d", len(decoded), typeOfFeatures.NumField())
}
for i := 0; i < typeOfFeatures.NumField(); i++ {
jsonName := strings.Split(typeOfFeatures.Field(i).Tag.Get("json"), ",")[0]
if got, ok := decoded[jsonName]; !ok || !got {
t.Errorf("FeaturesConfig JSON missing true field %q: %#v", jsonName, decoded)
}
}
}
+2 -1
View File
@@ -24,7 +24,8 @@
#
# Set its `prod` value to `true`. Ship the next release. Once the
# feature has been on by default for a release or two and is
# permanent, rip the flag out of the codebase entirely.
# permanent, remove its live wiring and move its key/environment pair
# to runtimeflags' append-only retired-identity set.
#
# ── See also ───────────────────────────────────────────────────────
#
@@ -13,6 +13,10 @@ import (
func TestApplyProfile_DefaultsToProd(t *testing.T) {
clearProfileSelectors(t)
clearProfilesYAMLVars(t)
defaults, err := FeatureFlagDefaults()
if err != nil {
t.Fatalf("FeatureFlagDefaults: %v", err)
}
count, env, err := ApplyProfile()
if err != nil {
@@ -21,9 +25,16 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) {
if env != EnvProd {
t.Errorf("env = %q, want %q (no selector env vars set)", env, EnvProd)
}
// Prod writes each registered feature flag with its safe shipped default.
if count != 4 {
t.Errorf("ApplyProfile wrote %d vars in prod; want 4", count)
// Prod writes every declared feature flag with its shipped default. Derive
// the count so adding a flag cannot require another hard-coded update here.
if count != len(defaults) {
t.Errorf("ApplyProfile wrote %d vars in prod; want %d profile feature defaults", count, len(defaults))
}
for shortName, want := range defaults {
envVar := featurePrefix + strings.ToUpper(shortName)
if got := os.Getenv(envVar); got != want {
t.Errorf("%s = %q after prod ApplyProfile; want %q", envVar, got, want)
}
}
if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" {
t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false")
@@ -175,6 +186,18 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) {
}
}
func TestProfilesYAMLFeatureKeysUseRequiredPrefix(t *testing.T) {
file, err := parse(profilesYAML)
if err != nil {
t.Fatalf("parse profiles YAML: %v", err)
}
for envVar := range file["features"] {
if _, ok := stripFeaturePrefix(envVar); !ok {
t.Errorf("profiles.yaml feature key %q must start with %q and include a name", envVar, featurePrefix)
}
}
}
func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) {
clearAppliedEnvVars(t)
+31 -49
View File
@@ -9,35 +9,23 @@ import (
)
const (
featureOfficeKey = "features.office"
featureAppStatusBarKey = "features.appStatusBar"
featureAuthKey = "features.auth"
featureClaudeBackgroundPromptHandoffKey = "features.claudeBackgroundPromptHandoff"
debugDevModeKey = "debug.devMode"
envFeaturesOffice = "KANDEV_FEATURES_OFFICE"
envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR"
envFeaturesAuth = "KANDEV_FEATURES_AUTH"
envFeaturesClaudeBackgroundPromptHandoff = "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"
envDebugDevMode = "KANDEV_DEBUG_DEV_MODE"
envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED"
envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES"
envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED"
envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES"
)
func OptionsFromConfig(cfg *config.Config) Options {
envValues := make(map[string]bool, len(registrations))
for _, registration := range registrations {
envValues[registration.definition.EnvVar] = isTruthy(os.Getenv(registration.definition.EnvVar))
for _, impliedEnvVar := range registration.definition.ImpliedEnvVars {
envValues[impliedEnvVar] = isTruthy(os.Getenv(impliedEnvVar))
}
}
return Options{
DefaultValues: ValuesFromConfig(cfg),
RuntimeValues: ValuesFromConfig(cfg),
EnvValues: map[string]bool{
envFeaturesOffice: isTruthy(os.Getenv(envFeaturesOffice)),
envFeaturesAppStatusBar: isTruthy(os.Getenv(envFeaturesAppStatusBar)),
envFeaturesAuth: isTruthy(os.Getenv(envFeaturesAuth)),
envFeaturesClaudeBackgroundPromptHandoff: isTruthy(
os.Getenv(envFeaturesClaudeBackgroundPromptHandoff),
),
envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)),
envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)),
envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)),
},
EnvValues: envValues,
IsExplicitEnv: func(name string) bool {
_, ok := os.LookupEnv(name)
return ok && !profiles.WasApplied(name)
@@ -46,41 +34,35 @@ func OptionsFromConfig(cfg *config.Config) Options {
}
func ValuesFromConfig(cfg *config.Config) map[string]bool {
debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled
return map[string]bool{
featureOfficeKey: cfg.Features.Office,
featureAppStatusBarKey: cfg.Features.AppStatusBar,
featureAuthKey: cfg.Features.Auth,
featureClaudeBackgroundPromptHandoffKey: cfg.Features.ClaudeBackgroundPromptHandoff,
debugDevModeKey: debugEnabled,
values := make(map[string]bool, len(registrations))
for _, registration := range registrations {
values[registration.definition.Key] = registration.read(cfg)
}
return values
}
func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) {
for _, state := range states {
switch state.Key {
case featureOfficeKey:
cfg.Features.Office = state.EffectiveValue
case featureAppStatusBarKey:
cfg.Features.AppStatusBar = state.EffectiveValue
case featureAuthKey:
cfg.Features.Auth = state.EffectiveValue
case featureClaudeBackgroundPromptHandoffKey:
cfg.Features.ClaudeBackgroundPromptHandoff = state.EffectiveValue
case debugDevModeKey:
cfg.Debug.DevMode = state.EffectiveValue
cfg.Debug.PprofEnabled = state.EffectiveValue
if state.EffectiveValue {
setIfNotExplicit(envDebugAgentMessages, "true")
setIfNotExplicit(envDebugPprofEnabled, "true")
} else {
unsetIfNotExplicit(envDebugAgentMessages)
unsetIfNotExplicit(envDebugPprofEnabled)
}
registration, ok := registrationByKey(state.Key)
if !ok {
continue
}
registration.apply(cfg, state.EffectiveValue)
}
}
func applyDebugMode(cfg *config.Config, enabled bool) {
cfg.Debug.DevMode = enabled
cfg.Debug.PprofEnabled = enabled
if enabled {
setIfNotExplicit(envDebugAgentMessages, "true")
setIfNotExplicit(envDebugPprofEnabled, "true")
return
}
unsetIfNotExplicit(envDebugAgentMessages)
unsetIfNotExplicit(envDebugPprofEnabled)
}
func RuntimeOptionsFromAppliedConfig(defaults map[string]bool, cfg *config.Config) Options {
opts := OptionsFromConfig(cfg)
opts.DefaultValues = defaults
+135 -76
View File
@@ -1,91 +1,150 @@
package runtimeflags
var definitions = []RuntimeFlagDefinition{
import "github.com/kandev/kandev/internal/common/config"
// runtimeFlagRegistration keeps the public metadata and the typed config
// binding for a flag together. The function fields stay internal so the HTTP
// registry response remains metadata-only.
type runtimeFlagRegistration struct {
definition RuntimeFlagDefinition
read func(*config.Config) bool
apply func(*config.Config, bool)
}
type runtimeFlagIdentity struct {
key string
envVar string
}
// retiredRuntimeFlagIdentities is append-only. When a flag graduates, remove
// its active registration and move its identity here. Persisted overrides for
// unknown keys are intentionally retained, so neither the key nor environment
// variable may ever be reused for a different flag.
var retiredRuntimeFlagIdentities = []runtimeFlagIdentity{
{key: "features.plugins", envVar: "KANDEV_FEATURES_PLUGINS"},
}
var registrations = []runtimeFlagRegistration{
{
Key: featureOfficeKey,
EnvVar: envFeaturesOffice,
Kind: KindFeature,
Label: "Office mode",
Description: "Enables autonomous agent office workflows and related settings.",
Stability: StabilityExperimental,
RiskLevel: RiskMedium,
RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " +
"may change between releases and should be reviewed before relying on them.",
RestartRequired: true,
Mutable: true,
},
{
Key: featureAppStatusBarKey,
EnvVar: envFeaturesAppStatusBar,
Kind: KindFeature,
Label: "App status bar",
Description: "Adds the global connection, optional host metrics, and plugin status surface.",
Stability: StabilityStable,
RiskLevel: RiskLow,
RiskDescription: "Changing this adds or removes the desktop and tablet status bar and the phone Status drawer entry " +
"after restart. It does not stop connections, metrics collection requested by other clients, or plugins.",
RestartRequired: true,
Mutable: true,
},
{
Key: featureAuthKey,
EnvVar: envFeaturesAuth,
Kind: KindFeature,
Label: "Authentication & users",
Description: "Requires every visitor to sign in and gives each user their own private workspaces. The first person to sign in after enabling becomes the admin.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Turning this ON locks the instance behind a login after restart — the first visitor " +
"completes a setup wizard and becomes the admin, and existing workspaces/secrets are assigned to " +
"them. Turning it OFF after restart makes the instance open to anyone who can reach it again. " +
"Enable it before exposing kandev on a shared or public network.",
RestartRequired: true,
Mutable: true,
},
{
Key: featureClaudeBackgroundPromptHandoffKey,
EnvVar: envFeaturesClaudeBackgroundPromptHandoff,
Kind: KindFeature,
Label: "Claude background prompt handoff",
Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " +
"Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " +
"Use it only for controlled testing and disable it if a session behaves unexpectedly.",
RestartRequired: true,
Mutable: true,
},
{
Key: debugDevModeKey,
EnvVar: envDebugDevMode,
Kind: KindDebug,
Label: "Debug mode",
Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.",
Stability: StabilityStable,
RiskLevel: RiskHigh,
RiskDescription: "Debug mode can expose local diagnostic endpoints and write prompt, file, " +
"and tool-call content to local debug logs. Enable it only on trusted machines.",
RestartRequired: true,
Mutable: true,
ImpliedEnvVars: []string{
envDebugPprofEnabled,
envDebugAgentMessages,
definition: RuntimeFlagDefinition{
Key: "features.office",
EnvVar: "KANDEV_FEATURES_OFFICE",
Kind: KindFeature,
Label: "Office mode",
Description: "Enables autonomous agent office workflows and related settings.",
Stability: StabilityExperimental,
RiskLevel: RiskMedium,
RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " +
"may change between releases and should be reviewed before relying on them.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.Office },
apply: func(cfg *config.Config, value bool) { cfg.Features.Office = value },
},
{
definition: RuntimeFlagDefinition{
Key: "features.appStatusBar",
EnvVar: "KANDEV_FEATURES_APP_STATUS_BAR",
Kind: KindFeature,
Label: "App status bar",
Description: "Adds the global connection, optional host metrics, and plugin status surface.",
Stability: StabilityStable,
RiskLevel: RiskLow,
RiskDescription: "Changing this adds or removes the desktop and tablet status bar and the phone Status drawer entry " +
"after restart. It does not stop connections, metrics collection requested by other clients, or plugins.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.AppStatusBar },
apply: func(cfg *config.Config, value bool) { cfg.Features.AppStatusBar = value },
},
{
definition: RuntimeFlagDefinition{
Key: "features.auth",
EnvVar: "KANDEV_FEATURES_AUTH",
Kind: KindFeature,
Label: "Authentication & users",
Description: "Requires every visitor to sign in and gives each user their own private workspaces. The first person to sign in after enabling becomes the admin.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Turning this ON locks the instance behind a login after restart — the first visitor " +
"completes a setup wizard and becomes the admin, and existing workspaces/secrets are assigned to " +
"them. Turning it OFF after restart makes the instance open to anyone who can reach it again. " +
"Enable it before exposing kandev on a shared or public network.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.Auth },
apply: func(cfg *config.Config, value bool) { cfg.Features.Auth = value },
},
{
definition: RuntimeFlagDefinition{
Key: "features.claudeBackgroundPromptHandoff",
EnvVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF",
Kind: KindFeature,
Label: "Claude background prompt handoff",
Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " +
"Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " +
"Use it only for controlled testing and disable it if a session behaves unexpectedly.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.ClaudeBackgroundPromptHandoff },
apply: func(cfg *config.Config, value bool) { cfg.Features.ClaudeBackgroundPromptHandoff = value },
},
{
definition: RuntimeFlagDefinition{
Key: "debug.devMode",
EnvVar: "KANDEV_DEBUG_DEV_MODE",
Kind: KindDebug,
Label: "Debug mode",
Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.",
Stability: StabilityStable,
RiskLevel: RiskHigh,
RiskDescription: "Debug mode can expose local diagnostic endpoints and write prompt, file, " +
"and tool-call content to local debug logs. Enable it only on trusted machines.",
RestartRequired: true,
Mutable: true,
ImpliedEnvVars: []string{
envDebugPprofEnabled,
envDebugAgentMessages,
},
},
read: func(cfg *config.Config) bool { return cfg.Debug.DevMode || cfg.Debug.PprofEnabled },
apply: applyDebugMode,
},
}
func Definitions() []RuntimeFlagDefinition {
out := make([]RuntimeFlagDefinition, len(definitions))
copy(out, definitions)
out := make([]RuntimeFlagDefinition, len(registrations))
for i, registration := range registrations {
out[i] = publicDefinition(registration.definition)
}
return out
}
func DefinitionByKey(key string) (RuntimeFlagDefinition, bool) {
for _, def := range definitions {
if def.Key == key {
return def, true
registration, ok := registrationByKey(key)
if !ok {
return RuntimeFlagDefinition{}, false
}
return publicDefinition(registration.definition), true
}
func registrationByKey(key string) (runtimeFlagRegistration, bool) {
for _, registration := range registrations {
if registration.definition.Key == key {
return registration, true
}
}
return RuntimeFlagDefinition{}, false
return runtimeFlagRegistration{}, false
}
func publicDefinition(definition RuntimeFlagDefinition) RuntimeFlagDefinition {
definition.ImpliedEnvVars = append([]string(nil), definition.ImpliedEnvVars...)
return definition
}
@@ -1,6 +1,21 @@
package runtimeflags
import "testing"
import (
"reflect"
"strings"
"testing"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/profiles"
)
type featureFieldBinding struct {
fieldIndex int
fieldName string
key string
profileKey string
envVar string
}
func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.office")
@@ -35,6 +50,15 @@ func TestDefinitionsExcludePlugins(t *testing.T) {
}
}
func TestRetiredRuntimeFlagIdentitiesIncludePlugins(t *testing.T) {
for _, identity := range retiredRuntimeFlagIdentities {
if identity.key == "features.plugins" && identity.envVar == "KANDEV_FEATURES_PLUGINS" {
return
}
}
t.Fatal("graduated plugins flag identity is missing from the retired identity set")
}
func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.appStatusBar")
if !ok {
@@ -90,3 +114,289 @@ func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) {
t.Fatal("debug.agentMessages must not be a top-level user-facing toggle")
}
}
func TestRuntimeFlagRegistrationsHaveCompleteMetadata(t *testing.T) {
retiredKeys, retiredEnvVars := retiredIdentitySets(t)
activeKeys := make(map[string]struct{}, len(registrations))
activeEnvVars := make(map[string]string, len(registrations))
for _, registration := range registrations {
definition := registration.definition
validateRuntimeFlagDefinition(t, definition)
if registration.read == nil || registration.apply == nil {
t.Fatalf("runtime flag registration %q is missing a config binding", definition.Key)
}
if _, exists := activeKeys[definition.Key]; exists {
t.Fatalf("duplicate runtime flag registration for %q", definition.Key)
}
activeKeys[definition.Key] = struct{}{}
if _, retired := retiredKeys[definition.Key]; retired {
t.Fatalf("active runtime flag %q reuses a retired key", definition.Key)
}
registerActiveEnvVar(t, activeEnvVars, retiredEnvVars, definition.Key, definition.EnvVar)
for _, envVar := range definition.ImpliedEnvVars {
registerActiveEnvVar(t, activeEnvVars, retiredEnvVars, definition.Key, envVar)
}
}
}
func TestRuntimeFlagConfigBindingsAreIsolated(t *testing.T) {
// Keep debug-mode's intentional environment side effects inert while this
// test exercises only the typed config bindings.
t.Setenv(envDebugAgentMessages, "explicit")
t.Setenv(envDebugPprofEnabled, "explicit")
for _, target := range registrations {
cfg := &config.Config{}
target.apply(cfg, true)
for _, registration := range registrations {
want := registration.definition.Key == target.definition.Key
if got := registration.read(cfg); got != want {
t.Fatalf(
"applying %q changed read value for %q to %t; want %t",
target.definition.Key,
registration.definition.Key,
got,
want,
)
}
}
target.apply(cfg, false)
for _, registration := range registrations {
if registration.read(cfg) {
t.Fatalf("disabling %q left %q enabled", target.definition.Key, registration.definition.Key)
}
}
}
}
// TestFeatureBindingsExactlyMatchConfigAndProfiles keeps the typed config,
// profile defaults, and feature registry in lockstep in both directions.
func TestFeatureBindingsExactlyMatchConfigAndProfiles(t *testing.T) {
defaults, err := profiles.FeatureFlagDefaults()
if err != nil {
t.Fatalf("FeatureFlagDefaults: %v", err)
}
fields := featureFieldBindings(t)
registered := registeredFeatureBindings(t)
for key, field := range fields {
registration, ok := registered[key]
if !ok {
t.Fatalf("FeaturesConfig.%s has no runtime flag registration for %q", field.fieldName, key)
}
if registration.definition.EnvVar != field.envVar {
t.Fatalf("FeaturesConfig.%s EnvVar = %q, want %q", field.fieldName, registration.definition.EnvVar, field.envVar)
}
if _, ok := defaults[field.profileKey]; !ok {
t.Fatalf("FeaturesConfig.%s has no profile default for %q", field.fieldName, field.profileKey)
}
}
for key := range registered {
if _, ok := fields[key]; !ok {
t.Fatalf("feature registration %q has no FeaturesConfig field", key)
}
}
for profileKey := range defaults {
if !hasProfileKey(fields, profileKey) {
t.Fatalf("profile feature %q has no FeaturesConfig field", profileKey)
}
}
if len(fields) != len(registered) || len(fields) != len(defaults) {
t.Fatalf(
"feature contract size mismatch: config=%d registry=%d profiles=%d",
len(fields),
len(registered),
len(defaults),
)
}
assertFeatureConfigRoundTrips(t, fields)
}
func retiredIdentitySets(t *testing.T) (map[string]struct{}, map[string]struct{}) {
t.Helper()
keys := make(map[string]struct{}, len(retiredRuntimeFlagIdentities))
envVars := make(map[string]struct{}, len(retiredRuntimeFlagIdentities))
for _, identity := range retiredRuntimeFlagIdentities {
if strings.TrimSpace(identity.key) == "" || strings.TrimSpace(identity.envVar) == "" {
t.Fatal("retired runtime flag identities must include both key and environment variable")
}
if _, exists := keys[identity.key]; exists {
t.Fatalf("duplicate retired runtime flag key %q", identity.key)
}
keys[identity.key] = struct{}{}
if _, exists := envVars[identity.envVar]; exists {
t.Fatalf("duplicate retired runtime flag environment variable %q", identity.envVar)
}
envVars[identity.envVar] = struct{}{}
}
return keys, envVars
}
func registerActiveEnvVar(
t *testing.T,
active map[string]string,
retired map[string]struct{},
key string,
envVar string,
) {
t.Helper()
if strings.TrimSpace(envVar) == "" {
t.Fatalf("runtime flag %q has an empty environment variable", key)
}
if owner, exists := active[envVar]; exists {
t.Fatalf("runtime flags %q and %q both use environment variable %q", owner, key, envVar)
}
if _, exists := retired[envVar]; exists {
t.Fatalf("active runtime flag %q reuses retired environment variable %q", key, envVar)
}
active[envVar] = key
}
func validateRuntimeFlagDefinition(t *testing.T, definition RuntimeFlagDefinition) {
t.Helper()
wantKeyPrefix, wantEnvPrefix := definitionPrefixes(t, definition.Kind)
if !strings.HasPrefix(definition.Key, wantKeyPrefix) || len(definition.Key) == len(wantKeyPrefix) {
t.Fatalf("runtime flag key %q must start with %q and include a name", definition.Key, wantKeyPrefix)
}
if !strings.HasPrefix(definition.EnvVar, wantEnvPrefix) || len(definition.EnvVar) == len(wantEnvPrefix) {
t.Fatalf("runtime flag %q environment variable %q must start with %q", definition.Key, definition.EnvVar, wantEnvPrefix)
}
for name, value := range map[string]string{
"label": definition.Label, "description": definition.Description, "risk description": definition.RiskDescription,
} {
if strings.TrimSpace(value) == "" {
t.Fatalf("runtime flag %q has an empty %s", definition.Key, name)
}
}
if !validStability(definition.Stability) {
t.Fatalf("runtime flag %q has invalid stability %q", definition.Key, definition.Stability)
}
if !validRiskLevel(definition.RiskLevel) {
t.Fatalf("runtime flag %q has invalid risk level %q", definition.Key, definition.RiskLevel)
}
if !definition.RestartRequired {
t.Fatalf("runtime flag %q must declare restart_required", definition.Key)
}
if !definition.Mutable {
t.Fatalf("runtime flag %q must declare mutable", definition.Key)
}
}
func definitionPrefixes(t *testing.T, kind RuntimeFlagKind) (string, string) {
t.Helper()
switch kind {
case KindFeature:
return "features.", "KANDEV_FEATURES_"
case KindDebug:
return "debug.", "KANDEV_DEBUG_"
default:
t.Fatalf("invalid runtime flag kind %q", kind)
return "", ""
}
}
func validStability(stability RuntimeFlagStability) bool {
switch stability {
case StabilityStable, StabilityBeta, StabilityExperimental:
return true
default:
return false
}
}
func validRiskLevel(risk RuntimeFlagRiskLevel) bool {
switch risk {
case RiskLow, RiskMedium, RiskHigh:
return true
default:
return false
}
}
func featureFieldBindings(t *testing.T) map[string]featureFieldBinding {
t.Helper()
typeOfFeatures := reflect.TypeOf(config.FeaturesConfig{})
fields := make(map[string]featureFieldBinding, typeOfFeatures.NumField())
for i := 0; i < typeOfFeatures.NumField(); i++ {
field := typeOfFeatures.Field(i)
if field.Type.Kind() != reflect.Bool {
t.Fatalf("FeaturesConfig.%s has type %s; feature flags must be bool", field.Name, field.Type)
}
jsonName := requiredTagName(t, field, "json")
profileKey := requiredTagName(t, field, "mapstructure")
key := "features." + jsonName
if _, exists := fields[key]; exists {
t.Fatalf("duplicate FeaturesConfig JSON key %q", jsonName)
}
fields[key] = featureFieldBinding{
fieldIndex: i,
fieldName: field.Name,
key: key,
profileKey: profileKey,
envVar: "KANDEV_FEATURES_" + strings.ToUpper(profileKey),
}
}
return fields
}
func requiredTagName(t *testing.T, field reflect.StructField, tag string) string {
t.Helper()
name := strings.Split(field.Tag.Get(tag), ",")[0]
if name == "" || name == "-" {
t.Fatalf("FeaturesConfig.%s is missing a %s name", field.Name, tag)
}
return name
}
func registeredFeatureBindings(t *testing.T) map[string]runtimeFlagRegistration {
t.Helper()
registered := make(map[string]runtimeFlagRegistration)
for _, registration := range registrations {
definition := registration.definition
if definition.Kind != KindFeature && !strings.HasPrefix(definition.Key, "features.") {
continue
}
if _, exists := registered[definition.Key]; exists {
t.Fatalf("duplicate feature registration %q", definition.Key)
}
registered[definition.Key] = registration
}
return registered
}
func hasProfileKey(fields map[string]featureFieldBinding, profileKey string) bool {
for _, field := range fields {
if field.profileKey == profileKey {
return true
}
}
return false
}
func assertFeatureConfigRoundTrips(t *testing.T, fields map[string]featureFieldBinding) {
t.Helper()
typeOfFeatures := reflect.TypeOf(config.FeaturesConfig{})
for key, field := range fields {
cfg := &config.Config{}
features := reflect.ValueOf(&cfg.Features).Elem()
ApplyStatesToConfig(cfg, []RuntimeFlagState{{Key: key, EffectiveValue: true}})
if !features.Field(field.fieldIndex).Bool() {
t.Fatalf("ApplyStatesToConfig(%q) did not enable target field %s", key, field.fieldName)
}
for i := 0; i < features.NumField(); i++ {
if i != field.fieldIndex && features.Field(i).Bool() {
t.Fatalf("ApplyStatesToConfig(%q) changed unrelated field %s", key, typeOfFeatures.Field(i).Name)
}
}
if got := ValuesFromConfig(cfg)[key]; !got {
t.Fatalf("ValuesFromConfig(%q) = false after enabling %s", key, field.fieldName)
}
ApplyStatesToConfig(cfg, []RuntimeFlagState{{Key: key, EffectiveValue: false}})
if features.Field(field.fieldIndex).Bool() || ValuesFromConfig(cfg)[key] {
t.Fatalf("ApplyStatesToConfig(%q) did not disable target field %s", key, field.fieldName)
}
}
}
+32
View File
@@ -5,6 +5,9 @@ vi.mock("@/lib/config", () => ({
}));
import { getRuntimeDebugModeAction } from "./features";
import { getFeatureFlagsAction } from "./features";
import { defaultFeatureFlags } from "@/lib/state/slices/features/types";
import type { FeatureFlags } from "@/lib/state/slices/features/types";
type FetchInput = Parameters<typeof fetch>[0];
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", () => {
it("returns true when the effective debug runtime flag is enabled", async () => {
fetchSpy.mockResolvedValueOnce(
+6 -7
View File
@@ -1,8 +1,7 @@
"use server";
import { getBackendConfig } from "@/lib/config";
import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice";
import type { FeatureFlags } from "@/lib/state/slices/features/types";
import { defaultFeatureFlags, type FeatureFlags } from "@/lib/state/slices/features/types";
import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags";
const { apiBaseUrl } = getBackendConfig();
@@ -12,15 +11,15 @@ const { apiBaseUrl } = getBackendConfig();
// store. Falls back to defaults (all-off) when the backend is unreachable,
// so a dev-server restart doesn't crash page rendering.
//
// Parsing is type-driven via `defaultFeaturesState.features`: every key
// declared on FeatureFlags is read from the response and Boolean-coerced;
// missing or non-bool values fall through to the default. Adding a flag
// is therefore one entry in FeatureFlags + the default — no edit here.
// Parsing is type-driven via `defaultFeatureFlags`: every declared key is read
// from the response and only actual booleans are accepted; missing or
// non-boolean values remain all-off. Adding a flag is therefore one entry in
// the declaration — no edit here.
//
// See docs/decisions/0007-runtime-feature-flags.md.
export async function getFeatureFlagsAction(): Promise<FeatureFlags> {
const url = `${apiBaseUrl}/api/v1/features`;
const defaults = defaultFeaturesState.features;
const defaults: FeatureFlags = { ...defaultFeatureFlags };
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
@@ -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 { immer } from "zustand/middleware/immer";
import { createFeaturesSlice, defaultFeaturesState } from "./features-slice";
import type { FeaturesSlice } from "./types";
import { defaultFeatureFlags } from "./types";
import type { FeatureFlags, FeaturesSlice } from "./types";
function makeStore() {
return create<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", () => {
// Production-safety invariant: every flag must be false out of the box.
// If this test starts failing because a new flag was added defaulting to
@@ -20,33 +27,14 @@ describe("features slice", () => {
for (const [name, value] of Object.entries(defaultFeaturesState.features)) {
expect(value, `default of features.${name}`).toBe(false);
}
expect(store.getState().features.office).toBe(false);
expect(defaultFeaturesState.features).toHaveProperty("appStatusBar", false);
expect(defaultFeaturesState.features).toHaveProperty("auth", false);
expect(defaultFeaturesState.features).toHaveProperty("claudeBackgroundPromptHandoff", false);
expect(store.getState().features).toEqual(flagsWith(false));
});
it("setFeatures replaces the whole flag map", () => {
const store = makeStore();
store.getState().setFeatures({
office: true,
appStatusBar: true,
auth: true,
claudeBackgroundPromptHandoff: true,
});
expect(store.getState().features.office).toBe(true);
expect(store.getState().features.appStatusBar).toBe(true);
expect(store.getState().features.auth).toBe(true);
expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(true);
store.getState().setFeatures({
office: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
});
expect(store.getState().features.office).toBe(false);
expect(store.getState().features.appStatusBar).toBe(false);
expect(store.getState().features.auth).toBe(false);
expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(false);
store.getState().setFeatures(flagsWith(true));
expect(store.getState().features).toEqual(flagsWith(true));
store.getState().setFeatures(flagsWith(false));
expect(store.getState().features).toEqual(flagsWith(false));
});
});
@@ -1,16 +1,8 @@
import type { StateCreator } from "zustand";
import type { FeaturesSlice, FeaturesSliceState } from "./types";
import { defaultFeatureFlags, type FeaturesSlice, type FeaturesSliceState } from "./types";
export const defaultFeaturesState: FeaturesSliceState = {
// All flags default to false. Production releases ship every flag off
// until the deployment opts in via env var (e.g. KANDEV_FEATURES_OFFICE).
// The SSR layer overwrites this with whatever the backend reports.
features: {
office: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
},
features: { ...defaultFeatureFlags },
};
type ImmerSet = Parameters<
+15 -9
View File
@@ -1,17 +1,23 @@
// Runtime feature-flag state. The shape mirrors the backend's
// /api/v1/features response (FeaturesConfig in
// apps/backend/internal/common/config/config.go). Every flag is a boolean,
// keyed by feature name. New flags are additive — keep this shape stable.
// apps/backend/internal/common/config/config.go). New flags are additive:
// declare the all-off default once and derive the names and state from it.
// See docs/decisions/0007-runtime-feature-flags.md.
export type FeatureFlags = {
office: boolean;
appStatusBar: boolean;
auth: boolean;
claudeBackgroundPromptHandoff: boolean;
};
export const defaultFeatureFlags = {
// New release toggles start disabled in every frontend state. The SSR layer
// overwrites this with the backend's effective values after startup.
office: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
} as const;
export type FeatureName = keyof FeatureFlags;
export type FeatureName = keyof typeof defaultFeatureFlags;
export type FeatureFlags = {
[K in FeatureName]: boolean;
};
export type FeaturesSliceState = {
features: FeatureFlags;
+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?"
2. Production releases that *cannot* accidentally ship with dev/e2e knobs on, even if env vars leak.
3. Per-spec and per-host overrides that still win without special-casing.
4. Reusable plumbing — adding the next feature flag should be ~1 line.
4. Reusable plumbing — adding the next feature flag should be one registry
binding plus the deliberately gated call sites, not a new map or switch in
every startup path.
## Decision
@@ -73,8 +75,9 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats
- `apps/backend/internal/profiles/profiles.yaml` — the file (symlinked from `./profiles.yaml` at the repo root).
- `apps/backend/internal/profiles/profiles.go` — `DetectEnvironment`, `ApplyProfile`, `FeatureFlagDefaults`.
- `apps/backend/internal/common/config/config.go` — calls `profiles.ApplyProfile()` at the top of `LoadWithPath` (before Viper's `AutomaticEnv`) and then seeds Viper's `features.*` keyspace from `profiles.FeatureFlagDefaults()` so the typed `FeaturesConfig` populates correctly even in tests.
- `FeaturesConfig` struct holds a typed `bool` per feature.
- Feature initialization (service construction, route registration) is gated at the call site — typically `cmd/kandev/main.go` early-return in `initOfficeServices`. When off, the relevant `services.X` stays nil and downstream consumers nil-check. HTTP routes are simply never registered, so a guessed URL returns 404 (not 401).
- `apps/backend/internal/runtimeflags/registry.go` — owns each flag's public metadata, key, environment variable, typed config reader, and typed config applier in one internal registration. It also owns the append-only identities of retired flags. `OptionsFromConfig`, `ValuesFromConfig`, and persisted-state application iterate active registrations.
- `FeaturesConfig` holds a typed `bool` per feature. Reflective tests require exact config/registry/profile key equality, complete metadata, isolated typed bindings, and no active collision with a retired identity. A frontend Vitest contract compares the backend JSON tags with `defaultFeatureFlags`.
- `apps/backend/internal/backendapp/main.go` and other backend entry paths gate construction, route registration, handlers, background jobs, and agent/MCP behavior at their narrow composition boundaries. When off, the relevant service stays nil or the operation is rejected before side effects; HTTP routes are simply never registered, so a guessed URL returns 404 (not 401).
### Frontend wiring (feature flags only)
@@ -95,7 +98,7 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats
- The "what's on in dev / e2e?" question is grep-friendly at the repo root.
- Per-spec / per-host overrides still work because `ApplyProfile` is idempotent and leaves already-set vars alone.
- Gated code stays compiled into the production binary and JS bundle. Acceptable trade-off vs build tags: the Office surface adds ≤ ~5% to the binary, and the operational simplicity (one artifact, runtime flips) is worth more than tree-shaking would be at our scale.
- Adding a new flag is ~1 line in `profiles.yaml`, ~1 line in `FeaturesConfig`, plus the gate at the call site and (for UI flags) the frontend additions listed below.
- Adding a new flag has one profile entry, one typed `FeaturesConfig` field, one registry registration, and the necessary backend/frontend gates. Completeness tests fail when a layer is omitted, added only on one side, or malformed.
- The 404-on-disabled-feature failure mode is deliberate: a flagged-off feature should look like it doesn't exist, not like a permission denial.
## Alternatives considered
@@ -112,21 +115,43 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats
```yaml
KANDEV_FEATURES_<NAME>:
prod: "false"
dev: "true"
e2e: "true"
dev: "false"
e2e: "false"
```
2. Add the matching `bool` field to `FeaturesConfig` in `apps/backend/internal/common/config/config.go`. (No `v.SetDefault` needed — `profiles.FeatureFlagDefaults()` seeds it automatically.)
3. Gate backend construction at the relevant init call site (`cmd/kandev/main.go` typically).
4. Update `/api/v1/features` handler in `cmd/kandev/helpers.go` to include the new key.
5. Add the field to `FeatureFlags` in `apps/web/lib/state/slices/features/types.ts` and the default in `features-slice.ts`.
6. Update `apps/web/app/actions/features.ts` to normalize the new key.
7. Gate frontend nav with `useFeature("<name>")` and page subtrees with `notFound()` in the relevant server-side layout.
2. Add the matching `bool` field to `FeaturesConfig` in
`apps/backend/internal/common/config/config.go`, including explicit
`mapstructure` and `json` tags. (No `v.SetDefault` is needed —
`profiles.FeatureFlagDefaults()` seeds it automatically.)
3. Add one registration in `apps/backend/internal/runtimeflags/registry.go`
with the key, env var, metadata, typed reader, and typed applier. Do not add
a parallel per-flag map or switch, and do not reuse an identity in
`retiredRuntimeFlagIdentities`. The registry completeness test must pass.
4. Gate every backend entry path at its narrow composition boundary. The
disabled path must preserve the legacy behavior and must fail closed before
deriving data, writing state, dispatching work, or exposing a capability.
5. Add the field to `defaultFeatureFlags` in
`apps/web/lib/state/slices/features/types.ts`; the frontend normalizer is
derived from that declaration. Gate client surfaces with `useFeature` and
page subtrees with `notFound()` in the relevant server-side layout.
6. Add on/off tests for backend behavior, frontend normalization, and each
meaningful user entry path. Shared refactors, migrations, and data reads
must not be hidden behind a flag unless their disabled behavior is explicit.
## How to enable a feature for all users
Flip its `prod:` value in `profiles.yaml` from `"false"` to `"true"`. Ship the next release.
For a temporary release toggle, merge the code with the flag off in every
profile. Enable it on a selected install through the admin Feature Toggles UI
or an explicit environment variable, restart, and observe it. Once the
feature is ready for everyone, change its `prod:` value in `profiles.yaml` to
`"true"` for the next release while retaining the registry metadata as a
kill-switch during the rollout. After the feature has been on by default for a
release or two and is permanent, remove the live flag end-to-end.
Once the feature has been on by default for a release or two and is permanent, rip the flag out entirely (the `profiles.yaml` entry, the `FeaturesConfig` field, the gate at the call site, the `useFeature` checks, the `notFound()`, the frontend slice field).
Removal includes the `profiles.yaml` entry, the `FeaturesConfig` field, the
active registry registration, backend gates, frontend declaration and checks,
tests, and docs. Move the removed key and environment variable to the
append-only `retiredRuntimeFlagIdentities` set. Unknown persisted overrides
remain inert, and neither retired identity may be reused.
## How to add a non-feature-flag knob
@@ -137,8 +162,9 @@ Same pattern — pick the section that matches the knob's purpose (`mocks`, `deb
- `profiles.yaml` (repo root, symlink) → `apps/backend/internal/profiles/profiles.yaml` — source of truth
- `apps/backend/internal/profiles/profiles.go` — loader (`//go:embed`, `DetectEnvironment`, `ApplyProfile`, `FeatureFlagDefaults`)
- `apps/backend/internal/common/config/config.go` — `LoadWithPath` calls `ApplyProfile` first thing
- `apps/backend/cmd/kandev/helpers.go` — `GET /api/v1/features` handler
- `apps/backend/cmd/kandev/main.go` — `initOfficeServices` early-return on `cfg.Features.Office`
- `apps/backend/internal/backendapp/helpers.go` — `GET /api/v1/features` handler
- `apps/backend/internal/backendapp/main.go` — composition gates such as `initOfficeServices`
- `apps/backend/internal/runtimeflags/registry.go` — metadata and typed config bindings
- `apps/web/app/layout.tsx` — SSR-fetch + StateProvider seeding
- `apps/web/lib/state/slices/features/` — Zustand slice
- `apps/web/hooks/domains/features/use-feature.ts` — client hook
@@ -28,9 +28,11 @@ descriptions, stability metadata, risk descriptions, env-var names, and restart
requirements. The Feature Toggles UI renders that registry state rather than
hardcoding env-var behavior in React.
V1 exposes Office mode and Debug mode. Office mode is marked experimental.
V1 exposed Office mode and Debug mode. The current registry also includes the
App Status Bar, opt-in Authentication, and Claude background prompt handoff
experiment. Office mode and the handoff experiment are marked experimental;
Debug mode is the user-facing toggle for debug behavior, including agent
message debug logs; agent message logs are not a separate top-level toggle.
message debug logs. Agent message logs are not a separate top-level toggle.
V1 toggle changes require restart. Restart is launcher/supervisor mediated:
@@ -55,8 +57,8 @@ process-tree cleanup.
states.
- Restart support needs CLI/service coordination rather than a backend-only
handler.
- Future multi-user support must restrict Feature Toggles, especially Debug
mode, to admins.
- Feature Toggle mutations are restricted to admins, especially for Debug mode;
members may read effective state so the frontend can render consistently.
## Alternatives Considered
@@ -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-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 |
| 2026-08-01-global-run-scheduler-ownership | [Separate Global Run Dispatch from Office Maintenance](2026-08-01-global-run-scheduler-ownership.md) | accepted | backend, workflow | 2026-08-01 |
| 2026-08-01-release-toggle-gating-contract | [Release Toggles Are Install-Wide Fail-Closed Gates](2026-08-01-release-toggle-gating-contract.md) | proposed | backend, frontend, workflow | 2026-08-01 |
| 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
| 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 |
+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. |
| `voice.openAIApiKey` | `KANDEV_VOICE_OPENAI_API_KEY` | empty | Server-side transcription fallback when browser speech recognition is unavailable. Empty disables the fallback and its endpoint returns unavailable. |
| `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. |
| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. |
| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. |
| `features.auth` | `KANDEV_FEATURES_AUTH` | `false` in production | Opt-in authentication and per-user workspaces. The first visitor after enabling completes setup and becomes the admin. |
| `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. |
Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`.
@@ -291,9 +289,9 @@ voice:
features:
office: false
plugins: false
app_status_bar: true
app_status_bar: true
app_status_bar: false
auth: false
claude_background_prompt_handoff: false
```
Copying this entire file is unnecessary and can freeze old defaults in a deployment. Keep only deliberate overrides. On Windows, do not copy the Unix Docker host/path literals from this example.
@@ -307,16 +305,22 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym
| Key | Environment lock | Production default | Effect |
|---|---|---|---|
| `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. |
| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. |
| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. |
| `features.auth` | `KANDEV_FEATURES_AUTH` | off | Authentication and per-user workspaces for the whole install. |
| `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. |
UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override.
The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment.
For a risky release feature, keep the flag off in the shipped profiles, enable it
only on a selected install through an admin override or explicit environment,
restart, and test it there. Promote the `prod` profile default only after the
feature is ready for everyone; keep the registry entry as a kill-switch until
the rollout is complete, then remove the live flag and move its key and
environment variable to the runtime registry's append-only retired identities.
Plugins are part of the base product and are not a runtime toggle.
The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar, authentication, and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment.
## Credentials and product settings
The **Unread Messages** preference in **Settings > General > Task Actions** controls the Slack-style **New** divider in session transcripts. It defaults off for each user, persists with user settings, and takes effect immediately. Enabling it also allows that user's active transcript view to advance the session read cursor.
+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,
manual upload, or filesystem sideload/sync — there is no manifest-paste
registration step and no credentials to issue. The whole system sits behind
the `plugins` feature flag (Settings > System > Feature Toggles), off by
default in production. See [Plugins](plugins.md) for the operator-facing
registration step and no credentials to issue. Plugins are part of the base
product and are not a runtime feature toggle. See [Plugins](plugins.md) for the operator-facing
install/operate flow, [Authoring a plugin](plugins-authoring.md) for the
build tutorial and SDK reference, and the [Plugin manifest
reference](plugins-manifest.md) for the complete `manifest.yaml` schema.
@@ -102,7 +101,34 @@ reference](plugins-manifest.md) for the complete `manifest.yaml` schema.
Create backend ownership and validation before a web form. Web settings require the appropriate domain client/hook, `src/settings-routes.tsx`, page/components, and sidebar or general navigation. Add a global state slice only when multiple surfaces or WebSocket hydration require it.
Wire runtime feature flags through every enforcement layer in one change. Add the default to `profiles.yaml` and `FeaturesConfig`, register its environment variable and runtime behavior in `internal/runtimeflags/registry.go`, and gate backend construction, handlers, or other call sites. Mirror the field in the frontend feature types, use `useFeature` for client surfaces, and call `notFound()` from the server layout or page when a guessed URL must remain unavailable. Add backend and frontend tests for both states. A flag is incomplete if any layer can expose or execute the feature while it is disabled. Do not turn an internal package or environment variable into a public setting without first defining its compatibility contract and support status.
Wire a release toggle through every enforcement layer in one change:
1. Add a `KANDEV_FEATURES_*` entry to `profiles.yaml` with `prod`, `dev`, and
`e2e` set to `false` until the rollout is ready.
2. Add the typed `FeaturesConfig` field with explicit `mapstructure` and `json`
tags, then add one metadata/read/apply registration in
`internal/runtimeflags/registry.go`. The registry owns the binding; do not
add a parallel per-flag map or switch.
3. Gate backend construction, handlers, HTTP/WS/MCP actions, background jobs,
and agent prompt paths at their narrow composition boundary. The disabled
path must fail closed before deriving data, writing state, or dispatching
work, while existing readable data remains inert.
4. Add the field to the frontend `defaultFeatureFlags` declaration, use
`useFeature` for client surfaces, and call `notFound()` from the server
layout or page when a guessed URL must remain unavailable.
5. Add backend and frontend tests for enabled and disabled behavior, including
legacy behavior while the flag is off. Shared refactors, migrations, and
data reads are not automatically protected by a feature flag.
For rollout, merge with the flag off, enable it on one install through an
admin override or explicit environment and restart, then promote the `prod`
default after observation. Remove the live flag only after it is permanent,
and move its key and environment variable to the append-only retired identities
in `internal/runtimeflags/registry.go`. Never reuse either identity for
different behavior. A flag is incomplete if any layer can expose or execute
the feature while it is disabled. Do not turn an internal package or
environment variable into a public setting without first defining its
compatibility contract and support status.
Workbench changes must preserve task/session/repository selection, dock-layout restoration, reconnect behavior, and the separate mobile layout. Test unavailable dependencies, invalid credentials, save/test failures, keyboard access, and narrow viewports.
+160 -219
View File
@@ -1,128 +1,95 @@
---
status: draft
created: 2026-06-14
updated: 2026-08-02
owner: tbd
---
# Feature Toggles
Decision: [ADR-2026-08-01-release-toggle-gating-contract](../../decisions/2026-08-01-release-toggle-gating-contract.md)
Implementation plan: [Release Feature Toggles](../../plans/release-feature-toggles/plan.md)
## Why
Users can already opt into runtime features through environment variables and
`profiles.yaml`, but those controls are invisible from the app and require
terminal access. Users need a settings page that explains experimental
features, lets them choose install-level overrides, and clearly handles the
restart required for startup-gated features such as Office mode and Debug mode.
Kandev needs to merge medium and large changes into `main` without making an
unfinished or risky behavior available in the next release. Operators also
need a simple way to enable a gated feature on one installation, test it with
real work, disable it again if necessary, and understand when restart is
required. Adding and later removing a temporary release toggle must not require
duplicating flag identity or config-binding code across many files.
## What
- Kandev adds a **Feature Toggles** settings page at
- Kandev provides install-wide runtime toggles under
**Settings > System > Feature Toggles** at
`/settings/system/feature-toggles`.
- The page shows user-facing runtime toggles, not every profile/env knob.
- V1 exposes three user-facing toggles:
- **Office mode** — enables the autonomous-agent Office surface.
- **App status bar** — shows the general-purpose desktop/tablet status bar and phone Status drawer entry. It is stable and off by default in production; enabling it changes visibility only. Disabling it does not suppress the problem-only [WebSocket connectivity warning](../ui/ws-connectivity-warning.md).
- **Debug mode** — enables local diagnostic/debug behavior, including agent
message debug logs.
- Office mode is marked **Experimental** and includes a concise description of
what it does and the risks of enabling it.
- Debug mode is a single user-facing toggle. Agent message debug logs are part
of Debug mode, not a duplicate top-level toggle.
- Every v1 toggle requires restart before the changed value takes effect.
- The page distinguishes effective value, saved override, default value, and
environment-locked value.
- Explicit environment variables remain authoritative. If an env var controls a
flag, the UI shows the flag as locked and does not allow overriding it.
- Users can reset a saved override back to the profile/env default.
- After saving any restart-required change, the UI shows a persistent restart
banner and offers a restart action when Kandev can safely restart itself.
- If automatic restart is unsupported, the UI shows manual restart guidance.
- `/api/v1/features` continues to return effective feature booleans for SSR
gating and existing `useFeature()` callers.
## Tech stack
- Backend: Go, Gin HTTP handlers, SQLite persistence.
- Frontend: Next.js App Router, React, Zustand, `@kandev/ui` shadcn
components.
- E2E: Playwright under `apps/web/e2e`.
- Runtime defaults: `profiles.yaml` embedded by `apps/backend/internal/profiles`.
## Commands
```bash
make fmt
make typecheck test lint
make -C apps/backend test
cd apps && pnpm --filter @kandev/web typecheck
cd apps && pnpm --filter @kandev/web lint
cd apps/web && pnpm e2e -- settings/feature-toggles.spec.ts settings/mobile-feature-toggles.spec.ts
```
## Project structure
```text
apps/backend/internal/runtimeflags/ Runtime flag registry, store, service, handlers
apps/backend/internal/system/restart/ Restart capability/request support
apps/backend/cmd/kandev/ Startup override application and route wiring
apps/web/app/settings/system/feature-toggles/page.tsx
apps/web/components/settings/system/feature-toggles-settings.tsx
apps/web/lib/api/domains/runtime-flags-api.ts
docs/decisions/0018-runtime-settings-overrides.md
docs/decisions/0019-restart-supervisor.md
```
Planned E2E coverage:
```text
apps/web/e2e/settings/feature-toggles.spec.ts TODO
apps/web/e2e/settings/mobile-feature-toggles.spec.ts TODO
```
## Code style
Use typed contracts at the backend boundary and keep env-var knowledge in the
backend registry rather than duplicating it in React components.
```go
type RuntimeFlagDefinition struct {
Key string
EnvVar string
Kind RuntimeFlagKind
Label string
Description string
Stability RuntimeFlagStability
RiskLevel RuntimeFlagRiskLevel
RiskDescription string
RestartRequired bool
Mutable bool
}
```
Frontend components render the API shape and avoid hardcoding flag-specific
logic except for layout/grouping.
- Runtime toggles have two uses:
- **Release toggles** temporarily protect unfinished or risky behavior while
it bakes on selected installations. They are removed after graduation.
- **Operator configuration toggles** are supported long-lived installation
choices such as Authentication or Debug mode.
- A new release toggle defaults to `false` in the `prod`, `dev`, and `e2e`
profiles. A feature spec may deliberately choose a different non-production
default, but `prod` remains off until the default-on rollout.
- An administrator can save an installation override from Feature Toggles.
Explicit environment variables remain authoritative and lock the UI control.
- Every toggle change requires restart before its effective runtime value
changes. Unsupported supervisors show manual restart guidance.
- `/api/v1/features` exposes the startup-effective feature booleans used by SSR
and `useFeature()` callers. Missing or unavailable frontend flag data fails
closed to `false`.
- Feature UI is absent while its release toggle is off. A guessed route returns
`404` when the feature owns a route subtree.
- The backend is authoritative. A disabled feature cannot be invoked by direct
HTTP, WebSocket, MCP, background-job, or agent-tool entry paths even if a
stale or modified client sends its enabled-only request shape.
- Gates live at the narrowest composition or capability boundary that covers
each entry path. Internal helpers do not independently re-read the flag when
their caller has already enforced the same boundary.
- The disabled path preserves the behavior that existed before the gated PR.
Shared refactors, startup initialization, schema migrations, and data-model
compatibility are not considered protected merely because a later behavior
branch is gated.
- Data written while a feature is enabled remains readable and inert when the
feature is disabled. Each gated feature documents any stronger rollback
behavior in its own spec.
- Runtime flag definitions bind their registry key, environment variable,
config read, and config apply behavior in one backend definition. Frontend
feature names and default-false values have one declaration. Graduated flag
keys and environment variables move to an append-only retired-identity set.
- A release toggle follows this lifecycle:
1. merge with every shipped profile default off;
2. enable selected installations with an override or explicit environment;
3. ship one default-on release while retaining the kill switch;
4. remove the live flag and legacy behavior after the default-on release is
proven, while retaining its retired key and environment identity.
- Removed flag keys and environment variables are never reused. Unknown
persisted override rows are ignored so upgrades and downgrades do not
destructively rewrite operator state.
## Data model
Feature Toggle overrides are install-scoped and stored in SQLite.
`runtime_flag_overrides`
Feature-toggle overrides are installation-scoped and stored in
`runtime_flag_overrides`.
| Field | Type | Constraint |
|---|---|---|
| `key` | `TEXT` | primary key, e.g. `features.office` |
| `key` | `TEXT` | primary key, for example `features.office` |
| `value` | `INTEGER` | required, `0` or `1` |
| `created_at` | `DATETIME` | required |
| `updated_at` | `DATETIME` | required |
Absence of a row means "use the effective default". A `false` row is a real
override and must not be treated as missing.
Absence of a row means “use the effective default.” A `false` row is a real
override. Rows whose keys are absent from the running binary's registry are
ignored and are not deleted automatically.
## API surface
`GET /api/v1/runtime-flags`
### `GET /api/v1/runtime-flags`
Returns all user-facing runtime toggle states and metadata.
Returns the registered operator-facing toggle states and metadata.
```json
{
@@ -134,7 +101,7 @@ Returns all user-facing runtime toggle states and metadata.
"description": "Enables autonomous agent office workflows and related settings.",
"stability": "experimental",
"risk_level": "medium",
"risk_description": "Office mode is still evolving. Workflows, routes, and background automation may change between releases and should be reviewed before relying on them.",
"risk_description": "Office mode is still evolving.",
"effective_value": false,
"default_value": false,
"override_value": true,
@@ -142,159 +109,133 @@ Returns all user-facing runtime toggle states and metadata.
"env_var": "KANDEV_FEATURES_OFFICE",
"env_locked": false,
"restart_required": true,
"requires_restart_to_apply": true
"requires_restart_to_apply": true,
"mutable": true
}
]
}
```
`PATCH /api/v1/runtime-flags/:key`
### `PATCH /api/v1/runtime-flags/:key`
```json
{ "override": true }
```
The request body is `{ "override": true | false | null }`. `null` clears the
saved override. Unknown keys return `404`; environment-locked keys return
`409`. Mutation requires an administrator identity. When authentication is
disabled, the synthetic install identity is an administrator.
`override` can be `true`, `false`, or `null`. `null` clears the saved override.
Unknown keys return `404`. Env-locked keys reject writes with `409`.
### `GET /api/v1/features`
`GET /api/v1/system/restart-capability`
Returns startup-effective feature booleans keyed by their public JSON names.
It remains a public bootstrapping endpoint and contains no risk metadata or
saved override details.
```json
{
"supported": true,
"mode": "cli",
"reason": ""
}
```
`mode` is `cli`, `systemd`, `launchd`, or `unsupported`.
`POST /api/v1/system/restart`
Requests a restart. The response is flushed before shutdown starts so the UI
can enter a waiting state and poll `/health`.
`GET /api/v1/features`
Existing contract remains unchanged: effective feature booleans only.
Restart capability and restart-request contracts remain defined by
[Restart supervisor ownership](../../decisions/0019-restart-supervisor.md).
## State machine
Runtime flag state:
```text
profile default -> override saved -> restart pending -> restarted/effective
\-> override cleared -> restart pending -> restarted/default
default off
-> selected-install override on
-> default on with kill switch retained
-> live flag removed, identity retired, new behavior permanent
```
Restart request state:
```text
idle -> requested -> backend unavailable -> backend healthy -> complete
\-> timeout -> manual recovery shown
```
At any retained-flag stage, an explicit environment value has precedence over
a saved override. A saved value that differs from the current process value is
`restart pending` until a new backend boot applies it.
## Permissions
Kandev is single-user in v1. Every user with settings access can view and change
Feature Toggles. Future multi-user support must restrict Feature Toggles,
especially Debug mode, to admins.
- Authenticated administrators and members can read runtime-toggle state and
risk metadata; only administrators can mutate installation-wide overrides.
- `/api/v1/features` remains public because the SPA needs it before authenticated
route composition; it exposes booleans only.
- MCP and agent callers do not receive a generic flag-mutation capability.
## Failure modes
- **Environment-locked flag** — UI disables the switch and shows the controlling
env var. API rejects override writes with `409`.
- **Override store unavailable** — runtime flags API fails with a recoverable
error; existing `/api/v1/features` continues to use startup-effective config.
- **Restart unsupported** — restart banner shows manual restart instructions
and no dead action button.
- **Restart requested while one is pending** — API rejects or idempotently
reports the pending restart.
- **Backend does not return after restart** — frontend times out and shows
manual recovery guidance.
- **Debug mode enabled** — UI warns that local diagnostic endpoints and agent
message logs may include sensitive prompt, file, and tool content.
- **Environment lock** — the UI disables mutation and identifies the controlling
environment variable; the API returns `409`.
- **Override store unavailable** — the runtime-flags API reports a recoverable
error; startup-effective behavior remains unchanged.
- **Frontend flag fetch fails** — client and SSR gates use their all-false
defaults and do not expose the feature.
- **Disabled feature request** — the authoritative backend boundary rejects the
request without mutation or side effects. Route-owned surfaces use `404`;
extensions to an existing endpoint use a stable client error defined by that
feature.
- **Restart unsupported or unsuccessful** — manual restart/recovery guidance is
shown and the running process retains its old effective values.
- **Unknown persisted override** — the row is ignored and hidden, but retained
for downgrade compatibility. Its key cannot be assigned to a future feature.
- **Feature disabled after enabled data exists** — the data remains readable but
cannot trigger enabled-only execution. The feature's own spec defines any
feature-specific resume or cleanup behavior.
## Persistence guarantees
- Saved overrides survive Kandev restarts.
- Effective runtime values are applied at backend startup.
- V1 toggles are startup-gated; changing a value does not affect already
registered routes, constructed services, or running agentctl processes until
restart.
- Environment variables always win over saved overrides.
- Saved overrides survive restart and version upgrades.
- Effective values are resolved at backend startup with precedence:
## Testing strategy
```text
explicit environment > saved install override > active profile > Go zero value
```
- Backend unit tests cover registry metadata, override persistence, precedence,
env locking, and pending-restart computation.
- Backend handler tests cover runtime flag APIs and restart capability/request
behavior.
- Frontend component tests cover Office experimental copy, Debug mode copy,
env-locked rows, save/reset actions, and restart banner states.
- Desktop and mobile Playwright tests cover the user workflow from settings
navigation through saving a restart-required toggle.
## Boundaries
- Always: keep `profiles.yaml` as immutable shipped defaults.
- Always: keep env vars authoritative over DB overrides.
- Always: show restart-required state for v1 toggles.
- Always: include mobile coverage for the settings page.
- Ask first: adding a new external flag service or changing the auth model.
- Ask first: exposing mocks/e2e tuning as user-facing toggles.
- Never: make the Go backend fork an unmanaged replacement process for restart.
- Never: expose agent message debug logs as a separate top-level toggle in v1.
- Applying or clearing a saved override does not live-mutate already registered
routes, constructed services, or agent processes.
- Unknown override rows are non-authoritative and non-destructive.
## Scenarios
- **GIVEN** Office mode is using the production default, **WHEN** the user opens
`/settings/system/feature-toggles`, **THEN** Office mode appears off with a
`Default` source, an `Experimental` badge, and risk text.
- **GIVEN** Office mode is off, **WHEN** the user enables it, **THEN** the
override is saved and the page shows that restart is required before Office
mode takes effect.
- **GIVEN** `KANDEV_FEATURES_OFFICE=true` is set in the environment, **WHEN** the
user opens Feature Toggles, **THEN** Office mode appears on, env-locked, and
cannot be changed from the UI.
- **GIVEN** Debug mode is off, **WHEN** the user enables it, **THEN** the page
explains that local diagnostic endpoints and agent message debug logs will be
enabled after restart.
- **GIVEN** App status bar is using its default, **WHEN** the user opens Feature
Toggles, **THEN** it appears off and explains that enabling it adds both the
desktop/tablet bar and ordinary phone Status entry after restart, while urgent
WebSocket connectivity warnings remain available when it is off.
- **GIVEN** a toggle override exists, **WHEN** the user clicks `Reset to default`,
**THEN** the override is removed and the page shows restart-required state if
the effective runtime value differs from the restored default.
- **GIVEN** restart capability is supported, **WHEN** the user clicks
`Restart Kandev`, **THEN** the UI enters `Restarting...`, polls `/health`, and
refreshes the page after the backend returns healthy.
- **GIVEN** restart capability is unsupported, **WHEN** a restart-required change
is saved, **THEN** the UI shows manual restart instructions instead of a
clickable restart button.
- **GIVEN** a mobile viewport, **WHEN** the user opens Feature Toggles, **THEN**
all descriptions, badges, switches, reset buttons, and restart actions are
reachable without horizontal scrolling.
- **GIVEN** a new release toggle, **WHEN** Kandev boots in `prod`, `dev`, or
`e2e` without an explicit override, **THEN** the feature is off.
- **GIVEN** a release toggle is off, **WHEN** an administrator saves an on
override and restarts Kandev, **THEN** the feature is enabled only on that
installation and the page reports `Saved override` as its source.
- **GIVEN** a release toggle is off, **WHEN** a direct client sends an
enabled-only HTTP, WebSocket, MCP, or agent-tool request, **THEN** the backend
rejects it without persisting or dispatching enabled-only work.
- **GIVEN** a release toggle is off, **WHEN** a user opens the application,
**THEN** the new UI is absent and the pre-feature workflow remains usable on
desktop and mobile.
- **GIVEN** `KANDEV_FEATURES_<NAME>=true`, **WHEN** the Feature Toggles page
opens, **THEN** the toggle is on, environment-locked, and cannot be changed
from the UI.
- **GIVEN** the backend cannot provide feature data during SSR, **WHEN** the SPA
renders, **THEN** every unknown or unavailable feature is treated as off.
- **GIVEN** a release toggle has baked on selected installations, **WHEN** its
production default changes to on, **THEN** operators can still override it
off for that release.
- **GIVEN** one default-on release has succeeded, **WHEN** the toggle graduates,
**THEN** the legacy behavior and live flag declarations/gates are removed,
the identity is retired, and the new behavior remains enabled.
- **GIVEN** a removed key still has a persisted override, **WHEN** Kandev boots,
**THEN** the row is ignored and no unrelated feature adopts its value.
- **GIVEN** a phone viewport, **WHEN** an administrator opens Feature Toggles and
changes an override, **THEN** the same save, reset, lock, risk, and restart
information is reachable without horizontal scrolling.
## Success criteria
- A user can enable or disable Office mode from Feature Toggles and understand
that restart is required.
- A user can enable or hide the App status bar from Feature Toggles and
understand that it applies after restart on every breakpoint.
- Office mode clearly communicates experimental status and risk.
- A user can enable or disable Debug mode without seeing duplicate agent-message
debug toggles.
- Env-controlled flags are accurately shown as locked.
- Restart-required changes have a clear supported or manual restart path.
- Existing SSR feature gating keeps working through `/api/v1/features`.
- Desktop and mobile tests cover the core workflow.
- A risky feature can merge to `main` without changing default production,
development, or E2E behavior.
- An operator can enable that feature on one installation and turn it off again.
- Direct and stale clients cannot bypass a disabled flag.
- Adding or removing a frontend-visible toggle changes one backend definition,
one typed config field, one profile entry, and at most one frontend feature
declaration before its actual gates and tests.
- CI detects incomplete or malformed registry/config/profile/frontend
contracts in either direction.
- A graduated toggle leaves no live legacy branch or reusable stale key or
environment variable.
## Out of scope
- Live-applying Office mode or Debug mode without restart.
- Per-user, workspace-scoped, or percentage rollout flags.
- Exposing mock providers, e2e tuning, or arbitrary env vars as user toggles.
- A third-party feature flag service.
- Multi-user admin enforcement in v1.
- Per-user, workspace-scoped, percentage, or cohort rollouts.
- Live-applying runtime toggles without restart.
- A third-party feature-flag service.
- Protecting incompatible migrations, unsafe shared initialization, or
unconditionally executed refactors with a late behavior check.
- Exposing mocks, E2E tuning, or arbitrary environment variables as user
settings.