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
+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 |