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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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