Feature: Feature Flags & Kill-Switches
The feature-flag / kill-switch system lets a SuperAdmin turn major Specialist capabilities on or off at runtime — globally, per-org, or per (Org × Specialist) — without a deploy. It is the platform's blast-radius control: when a capability (KB retrieval, tool calling, agentic drafting, auto-reply, Telegram self-serve) misbehaves, an operator flips a flag and the change takes effect within seconds.
Shipped in #1273
(closes #1242). Admin surface lives at /ops/feature-flags
(frontend/src/app/ops/feature-flags/page.tsx), restricted to superadmin.
Not the same as
autoRespondThreshold/auto_reply_mode. Those govern how confident a draft must be and how a reply is delivered once auto-reply is permitted.auto_reply_enabledis the upstream kill-switch — when it is OFF there is no auto-delivery at all, regardless of threshold or mode. Seeauto-reply.md.
Flag keys
The known keys are the single source of truth in
api/src/feature-flags/feature-flags.constants.ts
(FLAG_KEYS). Adding a key anywhere else is rejected with a 400 (Unknown feature flag key).
| Key | UI label | Built-in default | What OFF does |
|---|---|---|---|
agentic_mode_enabled | Specialist drafting | true | Agentic task creation throws 403 ForbiddenException (agentic.service.ts); the conversation pipeline skips agentic drafting (conversations.service.ts). |
kb_retrieval_enabled | KB retrieval | true | KB retrieval returns zero documents with a disabled marker — the Specialist drafts without knowledge-base context (kb-retrieval.service.ts, agent-api.service.ts). |
auto_reply_enabled | Auto-reply | false | No AI draft is ever auto-delivered; every draft queues for Expert review (conversations.service.ts). |
tool_calling_enabled | Tool calling | true | Tool invocations are blocked at the gateway — the tool_calls row is persisted as status='blocked', errorCode='FEATURE_FLAG_DISABLED', and a tool_call.blocked ledger event is appended (runtime-control-plane/tool-gateway.service.ts). |
telegram_self_serve_enabled | — | false | Client self-serve Telegram setup throws 403 (credentials.service.ts). The only flag exposed to non-admins (read-only) via GET /orgs/:orgId/feature-flags. |
"Built-in default" is the value returned by DEFAULT_FLAG_VALUES when no flag
row matches at any scope. It is the fail-safe fallback, not the live value —
see Default vs. seeded global row below.
Scopes & evaluation order
A flag can be set at three scopes (FeatureFlagScopeType):
| Scope | scope_id | Meaning |
|---|---|---|
global | NULL | Platform-wide. |
org | organizations.id | One client org. |
org_specialist | org_specialist_assignments.id | One (Org × Specialist) instance (the OSA, not the bare specialist id). |
FeatureFlagService.resolve(key, ctx) evaluates most-specific-wins and
returns on the first matching row:
org_specialist (ctx.specialistAssignmentId)
↓ no row
org (ctx.orgId)
↓ no row
global (scope_id = NULL)
↓ no row
built-in default (DEFAULT_FLAG_VALUES[key])
A row at a narrower scope fully overrides broader scopes — there is no
AND/merge. So a global kill (global = OFF) can be punched through for one org
by adding an org = ON override, and vice-versa.
The resolution object carries enabled, the reason string, and the
scopeType/scopeId that won — enforcement points log all three so the audit
trail records which override decided the outcome.
Kill-switch semantics
- Flipping is instant-ish. Resolved flags are cached in-process for
15 s (
FEATURE_FLAG_CACHE_TTL_MS). A write invalidates the cache key on the writing pod; other pods in the active-active fleet pick up the change on their next cache expiry. Worst-case propagation is ~15 s — design flips as "stop the bleeding," not as a hard synchronous barrier. - Fail-safe direction differs per flag. Capability flags
(
agentic_mode_enabled,kb_retrieval_enabled,tool_calling_enabled) default ON and degrade gracefully when flipped OFF (block / empty result). Delivery flags (auto_reply_enabled,telegram_self_serve_enabled) default OFF — the platform's always-HITL posture means auto-delivery is opt-in. - Reason is mandatory. Every write requires a non-empty
reason(the API rejects blank; the UI enforces ≥ 5 chars). It is stored on the flag row and copied into every audit entry. - Every change is audited. Writes are transactional: the flag row and a
feature_flag_auditrow (previousEnabled → newEnabled,reason,changedBy,changedAt) are saved together, and the controller additionally writes a platformfeature_flag.toggleentry to the global audit log.
Admin surface — /ops/feature-flags
SuperAdmin-only (RequireRole allow={["superadmin"]}). Three sections —
Global flags, Per-org overrides, Per-org-specialist overrides — each
a table of name / scope / state / reason / last change.
- Toggle (power icon) opens a confirm dialog requiring a reason before the state flips. The toggle is optimistic and rolls back on API error.
- Add override (org / org-specialist sections) creates a new scoped row;
you supply the
Org IDorOrg-specialist assignment IDplus a reason. - History (clock icon) opens the per-flag audit trail (last 50 changes for that key + scope).
API
All write/admin endpoints are under admin/feature-flags, guarded by
JwtAuthGuard + PlatformRolesGuard → superadmin
(feature-flags.controller.ts).
| Method & path | Purpose |
|---|---|
GET /admin/feature-flags?include=audit | List all flag rows (optionally with last-5 audit preview per flag). |
PATCH /admin/feature-flags/:key | Upsert a flag. Body: { scope_type, scope_id, enabled, reason }. scope_id must be null for global and non-null otherwise. |
GET /admin/feature-flags/:key/audit?limit=&scope_type=&scope_id= | Audit trail for a key (default 50, max 100), optionally filtered to one scope. |
GET /orgs/:orgId/feature-flags | Org-scoped, read-only. Returns { key: boolean } only for keys in CLIENT_VISIBLE_FLAGS (currently just telegram_self_serve_enabled). Guarded by OrgRolesGuard. |
Programmatic checks call FeatureFlagService.isEnabled(key, ctx) /
resolve(key, ctx) with { orgId, specialistAssignmentId }. Consumers inject
the service @Optional() so unit tests and stripped-down contexts treat a
missing service as "flag not set" and fall through to default behavior.
Schema
Two tables, created in
api/migrations/1760000000001-CreateFeatureFlags.ts
(entities FeatureFlag / FeatureFlagAudit in api/src/common/entities.ts):
feature_flags—(key, scope_type, scope_id, enabled, reason, updated_by, updated_at, created_at). A partial-unique indexuq_feature_flags_key_scopeon(key, scope_type, COALESCE(scope_id, ZERO_UUID))enforces one row per (key × scope). CHECK constraints pinscope_typeto the three allowed values and requirescope_id IS NULLiffglobal.feature_flag_audit— append-only history,flag_idFKON DELETE CASCADE, indexed on(flag_id, changed_at DESC)and(key, changed_at DESC).
Multi-tenancy note (per ADR-020):
feature_flagsis an operator-scoped control-plane table, not tenant data. It is written only by SuperAdmins and read by server-side enforcement keyed on the resolved(orgId, specialistAssignmentId)context, so it does not carry the Org × Specialist RLS contract that conversation/queue tables do.
Default vs. seeded global row
There is a subtlety worth knowing for auto_reply_enabled:
DEFAULT_FLAG_VALUES.auto_reply_enabled = false— the fallback used when no row matches at any scope, and the valueseedDefaults()writes on first boot (called from the service'sonModuleInit()lifecycle hook).- The original migration (
1760000000001) seeds global rows foragentic_mode_enabled,kb_retrieval_enabled,auto_reply_enabled, andtool_calling_enabled, all withenabled = true. The anomaly unique toauto_reply_enabledis that its seeded value (true) differs from its built-in default (false); the other three flags agree. (telegram_self_serve_enabledis not seeded by the migration — onlyseedDefaults()creates its global row, at the built-infalse.)
On a migration-provisioned database the seeded global row (true) is what
resolve() returns, because a global row exists and short-circuits the
built-in default. Always read the live global row from /ops/feature-flags
rather than assuming the code default — and note that the org-level
auto-reply work gate (auto_reply_mode, work-start date) still applies on top,
so a true global flag does not by itself auto-deliver drafts.
Operational use
For "when to flip a flag during an incident," see the Feature flags /
kill-switches section of
docs/runbooks/incident-response.md.