# Feature Flags

Chamber feature flags are app-owned runtime decisions. They are not stored in
`~/.chamber/config.json`, and they are not user-configurable unless a future
feature explicitly needs that behavior.

## Current model

The shared flag contract lives in `packages/shared/src/feature-flags.ts`.
Desktop exposes the resolved flags through `window.electronAPI.app.getFeatureFlags()`,
and the renderer copies them into app state during startup.

Flags default to the safest behavior: all disabled. Browser mode returns the
default flags unless it grows its own deployment policy signal. Desktop has
three explicit modes:

| Mode | Flag source | Behavior |
| ---- | ----------- | -------- |
| Stable packaged build | Remote policy `channels.stable` | Remote controls each flag |
| Insiders packaged build | Remote policy `channels.insiders` | Remote controls each flag |
| Local dev, `npm start` | `apps/desktop/src/main/devFeatureFlags.ts` | Independently toggleable |

The dev file is committed on purpose. It makes local preview behavior visible in
code review and avoids hidden per-developer environment setup.

## GitHub Pages config source

Packaged builds do not derive feature values from source code or user config.
They fetch one static JSON policy from GitHub Pages:

```text
https://chmbr.dev/flags/v1/flags.json
```

The file is committed at `docs/flags/v1/flags.json`. It is not generated by CI.
GitHub Pages publishes it from the `docs/` tree.

That JSON file is the remote config source for stable and insiders. Managing it
through a pull request gives us remote control without adding an admin portal:
merge a policy change, GitHub Pages publishes it, and the next Chamber startup
uses the new policy if it validates.

Expected shape:

```json
{
  "version": 1,
  "updatedAt": "2026-05-17T21:00:00Z",
  "channels": {
    "stable": {
      "switchboardRelay": false,
      "byoLlm": false,
      "chamberCopilot": false
    },
    "insiders": {
      "switchboardRelay": true,
      "byoLlm": true,
      "chamberCopilot": true
    }
  }
}
```

The embedded app version only selects the channel block:

| Channel | Version shape | Switchboard Relay |
| ------- | ------------- | ----------------- |
| Stable | `X.Y.Z` | controlled by `channels.stable.switchboardRelay` |
| Insiders | `X.Y.Z-insiders.N` | controlled by `channels.insiders.switchboardRelay` |

Remote policy is authoritative for packaged builds. Channel no longer defines a
local baseline like "stable off, insiders on"; it only chooses which remote JSON
object to read.

When changing the GitHub Pages policy, update both channel objects explicitly.
Do not rely on missing fields to mean "off" for the remote policy; packaged
policy validation requires every known flag to be present for both channels.

### Failure behavior

`FeatureFlagService` validates the policy schema before using it. On fetch,
HTTP, timeout, or schema failure, packaged builds fall back in this order:

1. Last cached valid policy for the same channel under app `userData`.
2. `DEFAULT_APP_FEATURE_FLAGS` with every flag disabled.

The cache is per-channel so an insiders policy is never used for stable, or vice
versa. The remote fetch is startup-only in v1; there is no live polling or
renderer update stream yet.

This means channel is still needed for failure isolation, but not for feature
values:

1. Online packaged stable reads `channels.stable`.
2. Online packaged insiders reads `channels.insiders`.
3. Offline packaged stable reads the last cached stable flags, then all-off.
4. Offline packaged insiders reads the last cached insiders flags, then all-off.

E2E specs that validate preview-only surfaces may opt in with
`CHAMBER_E2E=1 CHAMBER_E2E_PREVIEW_FEATURES=1`. That harness-only override
short-circuits remote policy and returns all preview flags on. Do not use it for
normal app runs or release builds.

## Current flags

| Flag | Stable | Insiders | Notes |
| ---- | ------ | -------- | ----- |
| `switchboardRelay` | remote | remote | Hides the activity-bar relay entry point and route. |
| `byoLlm` | remote | remote | Hides BYO model settings and disables desktop BYO runtime/IPC usage. |
| `chamberCopilot` | remote | remote | Wires the chamber-copilot ACP provider and `cli_*` tools. |

## Local development flags

For unpackaged Electron runs, Chamber uses `DEV_FEATURE_FLAGS` from
`apps/desktop/src/main/devFeatureFlags.ts`. Packaged builds ignore the file
entirely.

Change individual booleans there when local development needs a different
combination, for example testing stable-like behavior for only BYO LLM while
keeping Switchboard Relay enabled.

Default dev values currently keep all preview surfaces on:

```ts
export const DEV_FEATURE_FLAGS = {
  switchboardRelay: true,
  byoLlm: true,
  chamberCopilot: true,
};
```

## Switchboard Relay

`switchboardRelay` gates the user-visible relay surface. When disabled, the
left activity bar hides the A2A Relay icon and direct navigation to the relay
view falls back to the default chat view. The lower-level A2A IPC/service
surface remains wired so internal agent-to-agent behavior is not coupled to the
preview UI toggle.

## BYO LLM

`byoLlm` gates the local/custom model surface. When disabled, Settings hides
the "Local & Custom LLM" section, the desktop main process ignores any saved
BYO provider config, the BYO model-list side-channel returns no custom models,
and BYO IPC mutation/probe/restart handlers report that the feature is
unavailable in the current release channel.

Existing saved BYO credentials are not deleted when a stable build runs. They
are simply ignored until the user runs an insiders build again.

## chamber-copilot ACP

`chamberCopilot` gates the chamber-copilot ACP extension. When disabled,
`ChamberCopilotService` is not constructed and the `cli_*` tools are not added
to mind tool providers. Stable builds also ignore the legacy
`chamberCopilotEnabled` key in `~/.chamber/config.json`; users cannot turn this
surface on locally.

## Adding a new feature flag

Use this checklist when introducing a flag for a feature still under
development.

1. Add the flag to `AppFeatureFlags`.
2. Add the safe default to `DEFAULT_APP_FEATURE_FLAGS`. New flags should usually
   default to `false`.
3. Add a dev default in `apps/desktop/src/main/devFeatureFlags.ts`.
4. Add the flag to complete-policy validation in
   `packages/shared/src/feature-flags.ts`.
5. Add the flag to the GitHub Pages policy JSON for both `stable` and
   `insiders`.
6. Expose it only through `app:getFeatureFlags`; do not add it to user config.
7. Gate both the entry point and the target route/component when hiding a UI
   surface.
8. If disabling the UI is not enough, gate the runtime/service path too.
9. Add shared resolver tests and renderer tests for enabled and disabled states.
10. Update this document's current-flags table if the flag is expected to live
    longer than a one-off experiment.

### File map

| Purpose | File |
| ------- | ---- |
| Shared flag type and policy validation | `packages/shared/src/feature-flags.ts` |
| Shared resolver tests | `packages/shared/src/feature-flags.test.ts` |
| Dev-mode defaults | `apps/desktop/src/main/devFeatureFlags.ts` |
| Desktop remote/cache resolver | `apps/desktop/src/main/services/featureFlags/FeatureFlagService.ts` |
| Desktop IPC exposure | `apps/desktop/src/main.ts` via `app:getFeatureFlags` |
| Renderer state | `apps/web/src/renderer/lib/store/state.ts` |
| Renderer startup load | `apps/web/src/renderer/hooks/useAppSubscriptions.ts` |
| Browser-mode defaults | `apps/web/src/browserApi.ts` |

Most new flags only touch the shared type/resolver, the dev defaults, and the
feature-specific gates. The IPC and renderer-state plumbing should already
carry new fields because the flag object is passed as a whole.

### Rollout behavior decision

Before wiring the feature, choose one of these rollout shapes:

| Rollout shape | Stable | Insiders | Dev |
| ------------- | ------ | -------- | --- |
| Preview feature | remote `false` | remote `true` | controlled by `DEV_FEATURE_FLAGS` |
| Dev-only experiment | remote `false` | remote `false` | controlled by `DEV_FEATURE_FLAGS` |
| Stable feature | remote `true` | remote `true` | controlled by `DEV_FEATURE_FLAGS` until cleanup |

Most new work should start as a **dev-only experiment** until it is ready for
insiders. Flip the remote insiders value when the feature is safe enough for testers.
When a feature graduates to stable, remove the flag in a follow-up cleanup
instead of leaving dead conditionals around indefinitely.

### Gate depth

Pick the shallowest gate that is still honest:

| Feature kind | Required gates |
| ------------ | -------------- |
| Pure navigation or view preview | Hide the entry point and guard direct route rendering. |
| Settings surface | Hide the settings section and ignore saved settings if they should not apply. |
| Runtime capability | Do not construct/register the service or provider when disabled. |
| IPC/API mutation surface | Return an explicit unavailable error when disabled. |
| Data migration or background job | Gate the scheduler/runner, not just the UI that starts it. |

Do not rely on "the button is hidden" when background code, persisted config,
agent tools, IPC handlers, or scheduled work could still execute.

### Tests to add

Add tests at the same depth as the gate:

1. `packages/shared/src/feature-flags.test.ts` for policy parsing and schema
   rejection behavior.
2. `apps/desktop/src/main/services/featureFlags/FeatureFlagService.test.ts` for
   dev, remote success, cache fallback, and all-false fallback behavior.
3. Renderer component tests for hidden and visible states when the flag controls
   UI.
4. IPC/service tests for explicit disabled behavior when runtime calls must be
   unavailable.
5. E2E smoke updates only when an existing smoke needs preview features; use
   `CHAMBER_E2E=1 CHAMBER_E2E_PREVIEW_FEATURES=1` for that harness-only path.

### Example guard test: Switchboard Relay

Use guard tests to prove the feature flag controls the guarded surface, not to
prove the GitHub Pages config file itself. For example, `switchboardRelay` hides
the Relay activity-bar entry point when disabled.

Manual smoke experiment:

1. Temporarily set `switchboardRelay: false` in
   `apps/desktop/src/main/devFeatureFlags.ts`.
2. Temporarily remove `CHAMBER_E2E_PREVIEW_FEATURES: '1'` from
   `tests/e2e/electron/a2a-relay-roundtrip.spec.ts`; that override forces
   preview flags on and bypasses dev defaults.
3. Run `npm run smoke:a2a-relay`.
4. Expect the smoke to fail while waiting for the `A2A Relay` button, because
   the activity-bar icon is hidden by the flag.
5. Restore both temporary edits.

This test demonstrates the UI guard. It does not validate remote policy fetch or
cache behavior; those belong in `FeatureFlagService.test.ts` because they are
config-source behavior rather than UI behavior.

### Do not

- Do not add normal feature flags to `~/.chamber/config.json`.
- Do not make preview flags user-configurable in Settings.
- Do not require developers to remember environment variables for `npm start`.
- Do not leave stable builds able to execute disabled runtime paths.
- Do not use a broad "preview" check directly in renderer components; consume
  named fields from `AppFeatureFlags` so every gate is searchable.
- Do not use `CHAMBER_E2E_PREVIEW_FEATURES=1` when the test is meant to prove a
  dev flag can hide a surface; that override intentionally turns preview flags
  on.
