refactor: centralize runtime feature flag bindings (#2142)
This commit is contained in:
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user