Skip to content

feat: add database-driven feature flag library (OHE-3101) - #217

Open
tofarr wants to merge 14 commits into
mainfrom
feat/db-driven-feature-flags
Open

feat: add database-driven feature flag library (OHE-3101)#217
tofarr wants to merge 14 commits into
mainfrom
feat/db-driven-feature-flags

Conversation

@tofarr

@tofarr tofarr commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

HUMAN:

  • A human has tested these changes.

I have tested this in my local environment.

AGENT:


Why

There is no first-class feature flag mechanism in the application today; gating is done with env-var toggles and ad-hoc config. OHE-3101 asks for a database-driven, no-external-service flag library with user/org/email targeting, rule-based includes/excludes, and REST administration.

Surveying the Python ecosystem, the mature flag libraries (Unleash, Flagsmith, GrowthBook) all assume a separate flag-delivery service, which conflicts with the "no external service" requirement. This PR implements a bespoke library instead, modeled directly on the existing user_authorizations whitelist/blacklist pattern — so it's a natural generalization of a pattern the team already maintains.

Summary

  • Add FeatureFlag + FeatureFlagRule storage models with targeting by user_id, org_id, email_pattern (SQL LIKE), and percentage rollout, plus an async store mirroring user_authorization_store's _internal(session)/public(session=None) overload pattern.
  • Add FeatureFlagService evaluator with exclude-before-include precedence (generalizes whitelist-beats-blacklist), deterministic percentage bucketing (sha256(flag+user)), and a short-TTL in-memory cache.
  • Add an admin REST API at /api/admin/feature-flags (CRUD for flags + rules, plus an evaluate endpoint) gated by a new MANAGE_FEATURE_FLAGS permission granted only to the superadmin super role.
  • Add Alembic migration 150 (chained off 149, the current origin head) and 46 unit tests across store/service/routes.

Issue Number

OHE-3101

How to Test

  1. cd enterprise && poetry install --with dev,test
  2. PYTHONPATH=".:$PYTHONPATH" poetry run pytest enterprise/tests/unit/storage/test_feature_flag_store.py enterprise/tests/unit/server/services/test_feature_flag_service.py enterprise/tests/unit/server/routes/test_feature_flags.py
  3. Apply migration 150 against a dev DB (alembic upgrade head), then exercise the REST endpoints (requires a superadmin caller):
    • POST /api/admin/feature-flags → create a flag
    • POST /api/admin/feature-flags/{key}/rules → add an include/exclude rule
    • POST /api/admin/feature-flags/{key}/evaluate → preview a context
  4. Verify evaluation precedence: exclude beats include; percentage rollout is stable across calls for the same user.

Video/Screenshots

N/A — backend-only change; behavior verified via the unit test suite (46 passing).

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Migration is numbered 150 (not 148) because 148 and 149 already landed on origin/main while this was in progress.
  • The MANAGE_FEATURE_FLAGS permission is granted only to the superadmin super role (parallel to MANAGE_SUPER_ADMINS); no org-scoped role can reach these routes.
  • Rule cascade on flag delete is done explicitly in the store so it works on SQLite (test DB) as well as Postgres, which doesn't enforce FK ondelete=CASCADE by default.
  • This PR was created by an AI agent (OpenHands) on behalf of the repository owner.

Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-39d9155

@linear

linear Bot commented Aug 20, 2026

Copy link
Copy Markdown
OHE-3101 [Hardening Week] Add a first-class feature flag library for safer releases

Proposed solutions: adopt before building

Prefer an established open-source library/control plane rather than implementing feature flags from scratch.

  1. OpenFeature — vendor-neutral standard with JavaScript and Python SDKs. Likely the best application-facing abstraction because OpenHands can switch providers without coupling product code to one vendor.
  2. Unleash — mature self-hosted control plane with gradual rollout, targeting, auditability, and SDKs.
  3. GrowthBook — self-hosted feature flags and experimentation with JavaScript/Python support.
  4. Flagsmith — self-hosted flags and remote configuration with targeting and SDKs.
  5. PostHog — already used by OpenHands and supports feature flags. It may minimize integration cost, but product code should still use a provider-neutral wrapper and self-hosted/offline deployments must have deterministic local defaults.

Recommended starting point: use OpenFeature as the typed application API, then evaluate PostHog, Unleash, GrowthBook, or Flagsmith as the runtime provider/control plane. The Hardening Week deliverable should include a short build-vs-adopt decision and a small proof of concept with the leading option.

Summary

OpenHands does not have a shared release-safety feature flag contract across Agent Canvas, backend services, SaaS, and self-hosted deployments.

Flags currently appear as hardcoded web-client booleans, VITE_* build variables, deployment environment variables, settings fields, direct Cloud-backend assumptions, and one-off PostHog calls. This makes features harder to stage, disable, test, observe, and retire. It also allows frontend and backend behavior to disagree.

This is a good Hardening Week candidate because an initial library integration, safe provider contract, tests, release runbook, and two representative migrations are bounded work that improves every later release.

Context

Audit performed against OpenHands/main at 2965aca5.

  • src/api/option-service/option.types.ts exposes only hide_llm_settings and hide_users_page; the local adapter hardcodes their values.
  • Build-time gates such as VITE_ENABLE_BROWSER_TOOLS require rebuild/redeploy and cannot provide an emergency runtime kill switch.
  • Some feature availability is inferred from backend.kind === "cloud", conflating backend capability, release state, entitlement, and deployment identity.
  • Backend experiment code has called PostHog directly. ALL-4224 showed that remote flag failure can return None and reach downstream storage without a consistent fallback contract.
  • Existing Linear issues create and remove individual flags, but none defines reusable release infrastructure.

Related but not duplicate:

  • OHE-3044 covers OHE web-client environment wiring.
  • OHE-2642 explores backend-declared Canvas capabilities. Capability discovery should integrate with release flags but is a separate concern.

Required properties

Whichever library/provider is selected must support:

  • typed, centrally registered boolean and variant flags;
  • deterministic safe defaults on timeout, outage, missing flag, or unknown variant;
  • stable percentage rollout and targeted organization/user deployment;
  • backend-authoritative evaluation when frontend and API behavior must agree;
  • self-hosted and offline operation without mandatory SaaS calls;
  • test/in-memory overrides that never contact a remote provider;
  • auditability for production changes;
  • flag owner, purpose, creation date, expiry/review date, and removal issue;
  • CI or reporting that identifies expired flags;
  • a clear separation between feature flags, backend capabilities, entitlements, and authorization.

A client-visible flag must never be treated as authorization. Server endpoints must continue enforcing authentication, permission, entitlement, and data access independently.

Potential implementation from scratch

The following is context and a fallback design if no existing library satisfies the requirements. It is not the preferred first choice.

Typed registry

const flags = defineFeatureFlags({
  hostedVscode: {
    type: "boolean",
    default: false,
    owner: "agent-canvas",
    failurePolicy: "use-default",
    expiresAt: "2026-10-01",
  },
  newConversationFlow: {
    type: "variant",
    variants: ["control", "candidate"],
    default: "control",
    owner: "conversation-platform",
    failurePolicy: "use-default",
    expiresAt: "2026-10-01",
  },
});

Product code would consume a provider-neutral API:

evaluate(flag, context) -> value + evaluation metadata
subscribe(flag, context) -> updates where supported

Provider adapters could include static defaults, test overrides, deployment configuration, backend-evaluated snapshots, and PostHog or another remote provider.

Evaluation rules

  • Each flag has a safe default.
  • Remote evaluation cannot block rendering indefinitely.
  • Unknown flags fail in development/test.
  • Missing flags and provider errors are observable but not fatal.
  • Percentage assignment is deterministic for a stable subject.
  • Secrets, prompts, and repository content are prohibited from evaluation context.
  • Cross-layer features are evaluated authoritatively by the backend; the frontend does not independently guess.

Feature availability should compose separate concerns:

available = backend capability
          AND operational release flag
          AND authorization/entitlement

Lifecycle

  1. Merge with the flag default off.
  2. Deploy dark.
  3. Enable for internal subjects.
  4. Increase targeted/percentage rollout while observing health metrics.
  5. Use a tested kill switch if regressions appear.
  6. Roll out fully.
  7. Remove the flag and dead branch by the review date.

Bounded Hardening Week scope

  1. Compare the open-source options above and record the decision.
  2. Add the selected typed application API and static/test providers.
  3. Add one runtime provider or backend-evaluated snapshot.
  4. Migrate hide_llm_settings as a simple frontend flag.
  5. Migrate one cross-layer flag requiring frontend/backend agreement.
  6. Add tests for enabled, disabled, variant, timeout, outage, missing flag, and unknown variant states.
  7. Document offline/self-hosted behavior and the release/rollback process.
  8. Generate follow-up issues for broader migration; do not convert every environment or backend.kind check in the first PR.

Acceptance criteria

  • An open-source library/provider decision is documented, with adoption preferred over custom code.
  • Product code uses a typed, provider-neutral feature flag API.
  • Static/default, test, and one runtime provider are available.
  • Provider failures have deterministic, tested behavior.
  • Self-hosted/offline deployments do not require a SaaS flag service.
  • Backend-gated features cannot disagree with the frontend.
  • Flags cannot replace authorization checks.
  • Every flag has an owner, safe default, failure policy, and expiry/review date.
  • Expired flags are surfaced automatically.
  • Two representative existing gates are migrated.
  • A runbook covers dark launch, targeted rollout, health checks, kill switch, full rollout, and removal.

This issue was updated by an AI agent (OpenHands) on behalf of the user.

Review in Linear

@github-actions github-actions Bot added the type: feat A new feature label Aug 20, 2026
@tofarr
tofarr marked this pull request as draft August 20, 2026 17:17
Add a first-class feature flag system modeled on the existing
user_authorizations whitelist/blacklist pattern:

- FeatureFlag + FeatureFlagRule storage models with targeting by
  user_id, org_id, email_pattern (SQL LIKE), and percentage rollout
- FeatureFlagStore async store mirroring user_authorization_store's
  _internal(session)/public(session=None) overload pattern
- FeatureFlagService evaluator with exclude-before-include precedence
  (generalizes whitelist-beats-blacklist), deterministic percentage
  bucketing, and a short-TTL in-memory cache
- Admin REST API at /api/admin/feature-flags gated by a new
  MANAGE_FEATURE_FLAGS permission granted only to the superadmin role
- Alembic migration 150 (chained off 149, the current origin head)
- 46 unit tests (store, service, routes)

Co-authored-by: openhands <openhands@all-hands.dev>
… conv limit)

origin/main landed migration 150_add_daily_conversation_limit after this
branch was cut, so the feature-flags migration collided on revision 150.
Renumber to 151 and chain off the new 150 head.

Co-authored-by: openhands <openhands@all-hands.dev>
@tofarr
tofarr force-pushed the feat/db-driven-feature-flags branch from b74084e to 4a335f8 Compare August 24, 2026 16:22
@github-actions

Copy link
Copy Markdown

⚠️ This PR contains migrations. Please synchronize before merging to prevent conflicts.

Resolve merge conflict in enterprise/server/auth/authorization.py by
keeping both the new MANAGE_ORG_QUOTA permission (from main) and the
MANAGE_FEATURE_FLAGS permission (from this branch), and adding both to
the super-role permissions set.

Renumber the feature-flags migration 151 -> 154 to chain after the new
main migrations (151 org daily conv limit, 152 quota increase request,
153 kimi->deepseek settings migration). Single alembic head is now 154.

Co-authored-by: openhands <openhands@all-hands.dev>
@tofarr
tofarr force-pushed the feat/db-driven-feature-flags branch from d6eae8b to 2334d35 Compare August 26, 2026 15:54
tofarr and others added 8 commits August 27, 2026 16:57
…pose global flags in web-client config

Targeted rules (per-user/per-org/per-email exclude or include) previously
matched an anonymous context because the dimension check was skipped when
the context value was None. That meant a per-user exclude rule silently
excluded unauthenticated callers and a per-user include silently granted
them -- leaking targeting state on anonymous paths such as
/api/web-client/config.

A populated rule dimension now requires a populated context value to match,
in both the service's Python mirror (_rule_matches_context) and the store's
SQL pre-filter (_get_matching_rules). Fully-blank rules (and percentage-only
rules with no targeting) still match anonymous callers, so only genuinely
global flags reach unauthenticated paths.

Add FeatureFlagService.get_global_flags(), which returns the subset of flags
with NO rules at all as {key: enabled}, cached with the same TTL as
is_enabled and invalidated on any flag mutation. This is the safe set for
anonymous contexts.

Wire the global flags into the unauthenticated /api/web-client/config
response via a new db_feature_flags: dict[str, bool] field on WebClientConfig.
The injector loads them through a lazy, best-effort import of the enterprise
FeatureFlagService, so OSS installs and SaaS installs that have not applied
the feature-flag migration get an empty map and the config endpoint never
breaks.

Co-authored-by: openhands <openhands@all-hands.dev>
get_global_flags() previously reused the 5s per-flag is_enabled TTL, so the
unauthenticated /api/web-client/config endpoint hit the DB ~12x/min per
worker process. The global set is only the rule-less flags, which change
rarely, and the endpoint is called on every page load -- so a longer TTL is
safe and desirable.

Add a separate _DEFAULT_GLOBAL_CACHE_TTL_SECONDS = 60 (configurable via the
new global_cache_ttl_seconds constructor arg, independent of the per-flag
cache_ttl_seconds). The per-flag is_enabled path stays at 5s so the admin
evaluate endpoint still reflects mutations quickly. invalidate() continues
to drop the global snapshot immediately on admin mutation.

This caps the DB load at roughly one refresh per minute per worker process.
True once-per-node deduplication across multiple workers would need a shared
cache and is left as a follow-up.

Co-authored-by: openhands <openhands@all-hands.dev>
Fixes the "Lint python" and "Lint enterprise python" pre-commit failures:
- OSS test: add missing `import pytest` (F821 on @pytest.mark.asyncio),
  add trailing newline (end-of-file-fixer), wrap long patch.dict lines
- Enterprise: convert double-quoted strings to single quotes to match the
  enterprise ruff config (flake8-quotes inline-quotes = single), and apply
  ruff format reformatting

All affected files now pass `ruff check` and `ruff format --check` under
their respective configs (pyproject.toml for OSS, enterprise ruff.toml for
enterprise).

Co-authored-by: openhands <openhands@all-hands.dev>
The "Lint enterprise python" pre-commit runs ruff --all-files, so the
pre-existing double-quoted strings in test_feature_flags.py also needed
converting to single quotes to satisfy the enterprise ruff config.

Co-authored-by: openhands <openhands@all-hands.dev>
When a known flag key (e.g. ENABLE_BILLING) has no FeatureFlag database row,
resolve it from its environment variable instead of returning False. This lets
an operator toggle an env-backed flag before/without promoting it to a
DB-managed flag, and lets the web-client config endpoint surface it.

Behavior:
- A registered flag with no DB row -> env var truthiness (allow-all when
  "true", deny-all otherwise), falling back to the registered default when
  the env var is unset.
- An unknown/unregistered flag with no DB row -> False (unchanged; absence
  of a DB row never implicitly grants anything).
- Once a DB row exists, the database is authoritative and the env fallback is
  ignored -- so promoting an env-backed flag to DB management is a clean
  takeover with no precedence fight.

Adds:
- _ENV_FLAG_DEFAULTS registry + _EnvFlagDefault, seeded with ENABLE_BILLING.
- FeatureFlagService.register_env_default() classmethod to register more
  env-backed flags at startup.
- _env_fallback(key) helper resolving the env value.
- is_enabled(): missing flag -> _env_fallback(key) instead of False.
- get_global_flags(): include env-fallback flags absent from the DB, since
  they are inherently global (no per-user targeting).
- Tests for: env true/false/unset, unknown flag, DB-row-overrides-env,
  default-true registration, and get_global_flags inclusion / non-duplication.

Co-authored-by: openhands <openhands@all-hands.dev>
Fixes the 6 failing tests reported on PR #217:

1. Five TestGetGlobalFlags tests broke because the env-var fallback now
   injects the seeded ENABLE_BILLING into get_global_flags() whenever it is
   absent from the (mocked) DB, so exact-equality assertions gained an extra
   key. Add a module-level autouse fixture that snapshots and clears the
   process-global _ENV_FLAG_DEFAULTS registry (and the relevant env vars) for
   each test, so DB-only tests run against a clean registry. The env-fallback
   feature tests re-seed ENABLE_BILLING via a class autouse fixture that
   depends on the isolation fixture (so it runs after the clear).

2. test_super_role_permissions_are_explicit asserted the ADMIN super-role
   permission set did not include MANAGE_FEATURE_FLAGS, but the PR added
   that permission to SUPER_ROLE_PERMISSIONS[RoleName.ADMIN] in
   authorization.py (it is correctly super-role-only -- not in the
   org-scoped ROLE_PERMISSIONS). Update the expected frozenset to include
   Permission.MANAGE_FEATURE_FLAGS.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  enterprise
  saas_server.py 40-48, 162-168
  enterprise/server/auth
  authorization.py
  enterprise/server/routes
  feature_flags.py 82, 94-100, 113-125, 134, 145-154, 164-172, 181-186, 198-213, 223-229, 239-245
  enterprise/server/services
  feature_flag_service.py 96, 108-115, 137-174, 177-191, 217-238, 249-255, 275-288, 293-305, 314-316
  enterprise/storage
  feature_flag.py
  feature_flag_store.py 28-31, 39-42, 46-47, 54-57, 66-70, 80-95, 104-110, 124-139, 145-149, 157-169, 176-181, 189-198, 211-223, 251-280, 303-343, 350-358, 366-372
  openhands/app_server/web_client
  default_web_client_config_injector.py 243-254
  web_client_models.py
Project Total  

This report was generated by python-coverage-comment-action

@tofarr
tofarr marked this pull request as ready for review August 28, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants