10 KiB
0007: profiles.yaml — runtime defaults for prod / dev / e2e
Status: accepted Date: 2026-05-16 Area: backend, frontend
Context
Kandev ships a single binary (npm/Homebrew/GitHub release) plus its Next.js web app from one repo. Two related problems were driving scope every time we cut a release:
- In-progress features leak into production. Large multi-PR work (Office, ADR-0004, ADR-0005) stays in
mainfor weeks before it is user-ready. Releasing in the middle exposes half-finished surfaces to users. - Dev / e2e configuration is scattered.
KANDEV_MOCK_AGENT,KANDEV_DEBUG_DEV_MODE,KANDEV_FEATURES_OFFICE,AGENTCTL_AUTO_APPROVE_PERMISSIONS,KANDEV_PLAN_COALESCE_WINDOW_MS, etc. live as hardcoded literals inapps/cli/src/dev.tsandapps/web/e2e/fixtures/backend.ts. To answer "what does e2e mode actually set?" a contributor has to grep both files. Knobs drift between dev and e2e for no reason other than "nobody updated both places."
We needed:
- One file that's the answer to "what's on in dev / e2e / prod?"
- Production releases that cannot accidentally ship with dev/e2e knobs on, even if env vars leak.
- Per-spec and per-host overrides that still win without special-casing.
- Reusable plumbing — adding the next feature flag should be ~1 line.
Decision
A single profiles.yaml at the repo root, owned by the backend, declares the env-var values for three named profiles: prod (default), dev (make dev), and e2e (playwright fixtures). The backend embeds the file (//go:embed from apps/backend/internal/profiles/) and applies the active profile to its own process env at startup.
profiles.yaml shape
features:
KANDEV_FEATURES_OFFICE:
prod: "false"
dev: "true"
e2e: "true"
mocks:
KANDEV_MOCK_AGENT: { prod: "", dev: "true", e2e: "only" }
KANDEV_E2E_MOCK: { prod: "", dev: "", e2e: "true" }
KANDEV_MOCK_GITHUB: { prod: "", dev: "", e2e: "true" }
# …
debug:
KANDEV_DEBUG_DEV_MODE: { prod: "", dev: "true", e2e: "" }
KANDEV_DEBUG_PPROF_ENABLED: { prod: "", dev: "true", e2e: "" }
e2eTuning:
AGENTCTL_AUTO_APPROVE_PERMISSIONS: { prod: "", dev: "", e2e: "true" }
KANDEV_PLAN_COALESCE_WINDOW_MS: { prod: "", dev: "", e2e: "2000" }
Top-level sections are purely for human readability (the loader walks every leaf the same way). Each leaf is keyed by the canonical env-var name, with a value per profile. An empty string means "leave this var unset."
How profile selection works
profiles.DetectEnvironment() reads process env, in order:
| signal | profile | who sets it |
|---|---|---|
KANDEV_E2E_MOCK=true |
e2e |
apps/web/e2e/fixtures/backend.ts |
KANDEV_DEBUG_DEV_MODE=true (or …_PPROF_ENABLED=true) |
dev |
apps/cli/src/dev.ts |
| (neither) | prod |
the default for any other launch |
profiles.ApplyProfile() then walks every leaf and calls os.Setenv only when the var is not already set and only when the resolved value is non-empty. Empty means "leave unset."
This produces the precedence chain:
shell env / launcher env / per-spec override > profiles.yaml > Go zero values
A self-hoster setting KANDEV_FEATURES_OFFICE=true in their k8s manifest beats the YAML's prod: "false". A playwright spec setting AGENTCTL_AUTO_APPROVE_PERMISSIONS=false (the opposite of e2e's default) likewise wins — because the spec sets it before spawning, ApplyProfile sees it as already-set and skips.
Backend wiring
apps/backend/internal/profiles/profiles.yaml— the file (symlinked from./profiles.yamlat the repo root).apps/backend/internal/profiles/profiles.go—DetectEnvironment,ApplyProfile,FeatureFlagDefaults.apps/backend/internal/common/config/config.go— callsprofiles.ApplyProfile()at the top ofLoadWithPath(before Viper'sAutomaticEnv) and then seeds Viper'sfeatures.*keyspace fromprofiles.FeatureFlagDefaults()so the typedFeaturesConfigpopulates correctly even in tests.FeaturesConfigstruct holds a typedboolper feature.- Feature initialization (service construction, route registration) is gated at the call site — typically
cmd/kandev/main.goearly-return ininitOfficeServices. When off, the relevantservices.Xstays nil and downstream consumers nil-check. HTTP routes are simply never registered, so a guessed URL returns 404 (not 401).
Frontend wiring (feature flags only)
GET /api/v1/featuresreturns the feature-flag map as JSON. Public, unauthenticated.- A
featuresslice in the Zustand store mirrors the response. - The root layout (
apps/web/app/layout.tsx) SSR-fetches the flags once per request and seedsStateProviderinitialState, so the first paint reflects the deployment's flags. No flash of feature UI. useFeature(name)reads a single flag.- Page-level gating uses Next.js
notFound()from a server-side layout (e.g.apps/web/app/office/layout.tsx); nav entries useuseFeatureand rendernullwhen off.
Launchers
apps/cli/src/dev.tssets onlyKANDEV_DEBUG_DEV_MODE=true— the selector. profiles.yaml'sdev:column supplies the rest.apps/web/e2e/fixtures/backend.tssets onlyKANDEV_E2E_MOCK=true— the selector for e2e. profiles.yaml'se2e:column supplies the rest. Per-host paths (DB, worktree base, Docker binaries) stay in the fixture because they're runtime-computed, not environmental.
Consequences
- One artifact ships to everyone. Flag flips are a one-line YAML change.
- The "what's on in dev / e2e?" question is grep-friendly at the repo root.
- Per-spec / per-host overrides still work because
ApplyProfileis 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 inFeaturesConfig, plus the gate at the call site and (for UI flags) the frontend additions listed below. - 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
- Go build tags (
//go:build office). Rejected. Build tags require a build-matrix split (release.yml would need "with office" and "without office" jobs), do not extend to the Next.js bundle, and make staging into "we built a special artifact for staging" rather than "we flipped a switch." Size savings don't justify the operational tax. - Per-user permissions / RBAC. Rejected for this purpose. The feature being hidden is about whether the deployment opted in, not about user authorization. Conflating them creates confusing "you have the permission but the feature isn't deployed" UX.
- Env vars sprinkled across launchers. This was the starting state — what we replaced. The catalyst was discovering
dev.tsande2e/fixtures/backend.tshad drifted onKANDEV_MOCK_LINEARsettings. - External flag service (LaunchDarkly, GrowthBook, etc.). Rejected for v1. We don't yet need percentage rollouts, audience targeting, or remote kill-switches. When we do,
profiles.FeatureFlagDefaults()is the right seam to plug a provider into. - Two files (
features.yaml+dev-profile.yaml). Rejected during iteration. The user explicitly asked for one file; the mental model of "this section is feature flags, that section is mocks" is clearer when they're co-located.
How to add a feature flag
- Add an entry under
features:inprofiles.yaml:KANDEV_FEATURES_<NAME>: prod: "false" dev: "true" e2e: "true" - Add the matching
boolfield toFeaturesConfiginapps/backend/internal/common/config/config.go. (Nov.SetDefaultneeded —profiles.FeatureFlagDefaults()seeds it automatically.) - Gate backend construction at the relevant init call site (
cmd/kandev/main.gotypically). - Update
/api/v1/featureshandler incmd/kandev/helpers.goto include the new key. - Add the field to
FeatureFlagsinapps/web/lib/state/slices/features/types.tsand the default infeatures-slice.ts. - Update
apps/web/app/actions/features.tsto normalize the new key. - Gate frontend nav with
useFeature("<name>")and page subtrees withnotFound()in the relevant server-side layout.
How to enable a feature for all users
Flip its prod: value in profiles.yaml from "false" to "true". Ship the next release.
Once the feature has been on by default for a release or two and is permanent, rip the flag out entirely (the profiles.yaml entry, the FeaturesConfig field, the gate at the call site, the useFeature checks, the notFound(), the frontend slice field).
How to add a non-feature-flag knob
Same pattern — pick the section that matches the knob's purpose (mocks, debug, e2eTuning), add the entry, and the backend's ApplyProfile will apply it via os.Setenv at startup. Existing os.Getenv reads scattered across the backend keep working unchanged.
References
profiles.yaml(repo root, symlink) →apps/backend/internal/profiles/profiles.yaml— source of truthapps/backend/internal/profiles/profiles.go— loader (//go:embed,DetectEnvironment,ApplyProfile,FeatureFlagDefaults)apps/backend/internal/common/config/config.go—LoadWithPathcallsApplyProfilefirst thingapps/backend/cmd/kandev/helpers.go—GET /api/v1/featureshandlerapps/backend/cmd/kandev/main.go—initOfficeServicesearly-return oncfg.Features.Officeapps/web/app/layout.tsx— SSR-fetch + StateProvider seedingapps/web/lib/state/slices/features/— Zustand sliceapps/web/hooks/domains/features/use-feature.ts— client hookapps/web/app/office/layout.tsx— page-levelnotFound()gatingapps/cli/src/dev.ts— sets only theKANDEV_DEBUG_DEV_MODEselectorapps/web/e2e/fixtures/backend.ts— sets only theKANDEV_E2E_MOCKselector + per-host paths