Skip to main content

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_enabled is the upstream kill-switch — when it is OFF there is no auto-delivery at all, regardless of threshold or mode. See auto-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).

KeyUI labelBuilt-in defaultWhat OFF does
agentic_mode_enabledSpecialist draftingtrueAgentic task creation throws 403 ForbiddenException (agentic.service.ts); the conversation pipeline skips agentic drafting (conversations.service.ts).
kb_retrieval_enabledKB retrievaltrueKB 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_enabledAuto-replyfalseNo AI draft is ever auto-delivered; every draft queues for Expert review (conversations.service.ts).
tool_calling_enabledTool callingtrueTool 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_enabledfalseClient 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):

Scopescope_idMeaning
globalNULLPlatform-wide.
orgorganizations.idOne client org.
org_specialistorg_specialist_assignments.idOne (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_audit row (previousEnabled → newEnabled, reason, changedBy, changedAt) are saved together, and the controller additionally writes a platform feature_flag.toggle entry 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 ID or Org-specialist assignment ID plus 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 + PlatformRolesGuardsuperadmin (feature-flags.controller.ts).

Method & pathPurpose
GET /admin/feature-flags?include=auditList all flag rows (optionally with last-5 audit preview per flag).
PATCH /admin/feature-flags/:keyUpsert 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-flagsOrg-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 index uq_feature_flags_key_scope on (key, scope_type, COALESCE(scope_id, ZERO_UUID)) enforces one row per (key × scope). CHECK constraints pin scope_type to the three allowed values and require scope_id IS NULL iff global.
  • feature_flag_audit — append-only history, flag_id FK ON DELETE CASCADE, indexed on (flag_id, changed_at DESC) and (key, changed_at DESC).

Multi-tenancy note (per ADR-020): feature_flags is 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 value seedDefaults() writes on first boot (called from the service's onModuleInit() lifecycle hook).
  • The original migration (1760000000001) seeds global rows for agentic_mode_enabled, kb_retrieval_enabled, auto_reply_enabled, and tool_calling_enabled, all with enabled = true. The anomaly unique to auto_reply_enabled is that its seeded value (true) differs from its built-in default (false); the other three flags agree. (telegram_self_serve_enabled is not seeded by the migration — only seedDefaults() creates its global row, at the built-in false.)

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.