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