Skip to content

Adding a Feature Flag

End-to-end recipe for shipping a new feature flag in CampusCore. Read this when you need to gate a new capability behind a per-deployment toggle.

For the architecture and why the system works the way it does, see RBAC & Feature Flags. For the institution-admin side, see Managing Features.

TL;DR - the 5-step recipe

  1. Register the flag in code - call register_feature(...) in a module that's imported at app-ready time, add the slug to its scope's alias, and run bash scripts/sync_openapi_types.sh
  2. Sync it to the DB - every web container, local or cloud, runs it through run_boot_sequence as it starts; a running local container needs docker compose restart web or bash scripts/stack.sh up
  3. Flip availability - as a CampusCore platform admin, set FeatureState.is_available=True in Django admin for this deployment
  4. Flip enablement - as an institution admin, toggle the feature on in Settings → Features (and optionally restrict it to specific roles)
  5. Gate the code - useFeatureEnabled(slug) in the SPA, an auth= mode on typed endpoints, is_feature_enabled(user, slug) in Python; @feature_required(...) only on the views Django still renders

One decision up front: the flag's scope. The default, scope="user", is a per-person gate (roles required, evaluated against the signed-in user). scope="deployment" is a single workspace-wide switch - on iff available AND enabled, for any caller including an anonymous one, with no role picker in Feature Management and no entry in the per-user bootstrap.features list. Use it only for surfaces that exist or don't exist for the whole deployment at once (e.g. cc_public_access); such a flag needs its own wire to the client. The two legal values are the FeatureScope Literal in apps/auth/features.py, so a misspelt scope is a type error in Python and, through the generated API types, in the SPA too. The slug itself works the same way: UserFeatureSlug and DeploymentFeatureSlug in the same module list the shipped flags of each scope, every Python gate takes that type, and the SPA's UserFeatureSlug union is generated from AuthenticatedBootstrap.features. A misspelt slug is an invalid-argument-type under ty and a tsc error in the SPA, rather than a gate that never opens. The scope is also the discriminator of the FeatureItem union the Settings page reads: the deployment variant has no roles or role_slugs field at all, and check_enablement refuses any writer that tries to give a deployment-scoped feature a role scope.

That's it. Each step is described in detail below.

Step 1 - Register the flag in code

Open apps/auth/features.py (or another module that's imported at Django app-ready time) and add:

from apps.auth.features import register_feature

register_feature(
    slug="cc_advising_notes_export",
    name="Advising Notes Export",
    description="Allow advisors to export advising notes as PDF.",
    default_enabled=False,
)

Add the slug to the alias for its scope in the same edit, so the gates you write next type-check against it:

UserFeatureSlug = Literal["cc_knowledge_folders", "cc_advising_notes_export"]

A deployment-scoped flag goes in DeploymentFeatureSlug instead. test_feature_registry_slugs.py compares each alias against the flags the fresh registry holds of that scope, so a registration without its alias edit fails there.

Editing UserFeatureSlug changes the exported schema, because AuthenticatedBootstrap.features is a list of it. Run bash scripts/sync_openapi_types.sh and commit campuscore_app/openapi.json and web/src/types/generated/api.ts with the rest; scripts/gate.sh fails on the drift otherwise.

The register_feature registry is the source of truth for which feature flags exist in this version of the code. Calls to is_feature_enabled look up the registry first - unknown slugs return False (fail closed). This means a flag can never be silently introduced via DB tampering, and tests can register a flag without writing a migration.

Where to put register_feature calls

The simplest answer: apps/auth/features.py. That module is imported at Django app-ready time, so any registration there happens before the first request hits.

If your feature has its own app or module, you can also call register_feature from anywhere that's guaranteed to be imported at startup - typically the app's apps.py ready() method, or a module that other code already imports.

What you cannot do is register a flag from inside a view function - by the time the view runs, the registry is already in use, and the flag won't be findable until the next process restart.

Naming convention

Use the cc_ prefix for all CampusCore-defined feature flags. This makes them:

  • Greppable in code (grep -r 'cc_' campuscore_app/)
  • Distinguishable from any future institution-defined flags
  • Recognizable in admin and audit logs at a glance

Examples: cc_knowledge_folders, cc_advising_notes_export, cc_advanced_analytics.

Step 2 - Sync the registry to the DB

docker compose exec -T web python manage.py sync_feature_registry

This creates a Feature row mirroring your registration so the admin UI knows about it. The command is idempotent - re-running it adds new rows, updates changed metadata, and is a no-op when the registry matches the DB.

Every web container, local or cloud, runs run_boot_sequence as it starts, which syncs the registry right after migrate under a cross-container lock: a cloud container through entrypoint.sh, a local one through its docker-compose.yaml command. A local container that is already running does not re-run it when uvicorn --reload picks up your change, so run the command above, docker compose restart web, or bash scripts/stack.sh up in a worktree.

For tests, you do need to create the Feature row by hand because sync_feature_registry doesn't run in unit tests. See Testing your gate below.

Renaming a flag

A slug is the only link between the code registry and each deployment's Feature row, so a rename has to say what it was renamed from. Change slug= and add the old slug to renamed_from= in the same register_feature call:

register_feature(
    slug="cc_advising_notes_pdf",
    renamed_from=("cc_advising_notes_export",),
    name="Advising Notes PDF",
    ...
)

On its next boot, every deployment re-keys the old row under the new slug. The institution's FeatureState (on/off switch, role scope, timestamps) is untouched, because it points at the row by primary key; the sync report prints one old → new line. A deployment that had the Feature row but never a FeatureState gets one seeded under the new slug. During a rolling deploy, containers still running the previous release evaluate the flag as off until they are replaced, because they look its state up by the old slug and no row answers to that any more.

A rename is one-way. Once a deployment has booted it, that deployment's database holds the new slug, so redeploying the previous release hits the undeclared-rename refusal: the old release's registry knows only the old slug, which is now an orphan beside the flag the release creates, and the boot stops. Ship a rename in a release of its own, with nothing else in it that might force a rollback.

The old slug stays in renamed_from for the life of the flag, because each university boots a release at its own time and there is no moment at which every deployment is known to have migrated. On a second rename, append to the tuple; never replace it. The registry refuses a renamed_from that names the flag itself, a slug that is still a live flag, or a slug another flag already retired.

Old slugs are not resolved at read time: is_feature_enabled(user, "cc_advising_notes_export") returns False after the rename. Rename the alias member in the same edit and the compilers find the gates: ty reports every call to is_feature_enabled or feature_required still naming the old slug, and tsc reports every SPA one once scripts/sync_openapi_types.sh has regenerated the types. That covers the gates and nothing else, so still sweep with grep -rn "cc_advising_notes_export" campuscore_app/ for the places no checker reads: the register_feature call itself, the Feature.objects.create(slug=...) rows test fixtures build by hand, and Feature.slug's help_text. Those fail when the tests run rather than when they compile, which is loud enough, but only if you remember to look.

A rename without renamed_from does not pass silently. The sync sees one slug created and one orphaned in the same run, which is what a rename looks like, and stops the boot with a message naming both slugs and the fix: add renamed_from and redeploy. The same refusal backstops a developer who replaced the tuple instead of appending to it, on any deployment that skipped a release in between.

Step 3 - Flip availability (CampusCore staff)

Once the Feature row exists, a CampusCore platform admin (is_superuser=True) needs to create or edit the corresponding FeatureState row:

  1. Log into Django admin as a superuser
  2. Open Feature States
  3. Find the row matching your feature slug (or create one)
  4. Set is_available=True for this deployment
  5. Save

Until is_available=True, the feature stays invisible to the institution admin's Settings → Features page and is_feature_enabled returns False. This is the licensing axis - CampusCore decides which features each deployment is entitled to.

Step 4 - Flip enablement (institution admin)

Once availability is flipped, the institution admin sees the feature in the in-app UI:

  1. Open Settings → Features
  2. Find the feature card
  3. Toggle Enabled on
  4. (Optional) Click roles in the role picker to restrict who can see the feature
  5. Save

See Managing Features for the institution-admin walkthrough.

Step 5 - Gate the code

This is where most of the actual work happens. CampusCore offers four gate types, and which one you use depends on what you're protecting. Each one below names the shipped flag that uses it, if any, so you can open a real example.

Gate type 1: SPA surface

The client gate. The server decides and the SPA asks:

import { useFeatureEnabled } from "../../lib/api/bootstrap";

const advisingExportEnabled = useFeatureEnabled("cc_advising_notes_export");
if (!advisingExportEnabled) return null;

The slug is UserFeatureSlug, generated from AuthenticatedBootstrap.features, so tsc refuses a misspelling and refuses a deployment-scoped slug. A settings tab gates itself by declaring requiresFeature in web/src/routes/settingsTabs.tsx rather than calling the hook. Return null rather than a disabled control: a feature that is off is not offered at all.

This is the only gate cc_knowledge_folders has, at three sites: the hook in web/src/components/chat/FolderScopeMenu.tsx and web/src/components/chat/ConversationSidebar.tsx, and requiresFeature on the knowledge-folders tab in settingsTabs.tsx.

Gate type 2: Typed API auth mode

A typed endpoint declares who may call it through the auth= argument of @api_endpoint or @api_streaming_endpoint, and a flag can be one of those modes:

@api_streaming_endpoint(
    methods=["POST"],
    input=PublicChatStreamRequest,
    events=AGENT_EVENT_MODELS,
    auth="public_access",
    rate_limit=_PUBLIC_STREAM_RATE_LIMIT,
)
async def public_chat_stream(request, data: PublicChatStreamRequest) -> AsyncIterator[SseEvent | str]: ...

_AUTH_GUARDS in campus_core/api/decorators.py maps each mode to a guard; public_access maps to require_public_access in campus_core/api/auth.py, which calls is_feature_enabled and answers 404 when the flag is off, so the endpoint does not exist for a deployment that has not turned it on. A new flag that gates whole endpoints adds a mode rather than an if in every view: a member of the AuthMode Literal in campus_core/api/auth.py, its guard in _AUTH_GUARDS, and the mode in test_the_guard_mode_set_is_pinned in campus_core/api/tests/test_anonymous_endpoint_set.py, which asserts the exact set. The same file pins the public_access endpoints by name in test_public_access_is_exactly_the_guest_chat_surface, so a new endpoint on that mode fails there until it is added.

cc_public_access uses this on the four endpoints in apps/main_app/apis/public_chat_api.py.

Gate type 3: Service / programmatic check

For Python that needs to branch on the flag:

from apps.auth.services.feature_service import is_feature_enabled

def build_export_payload(user, ...):
    if not is_feature_enabled(user, "cc_advising_notes_export"):
        raise PermissionError("Export feature is not enabled")
    ...

Use this where a declared gate doesn't fit. The check is per-request cached (when you pass request=request), and fails closed for unknown slugs.

cc_public_access reaches this call twice: inside require_public_access above, and in apps/main_app/apis/bootstrap_api.py, which tells the SPA whether the public surface exists.

On the request path, the agent's tools are the surface with no declared gate. They are assembled per request by create_default_registry and create_guest_registry in apps/main_app/services/agent/tools/base.py, so a flag that should keep a tool out of the agent's hand would branch there with this check, and the vector store and storage layers below would stay flag-unaware. Both registries register their tools unconditionally; the one flag that reaches the agent, cc_agent_skills, is read by load_skill_catalog in apps/main_app/services/skills/catalog.py at the top of each run, and an off flag empties the catalog, which in turn drops the use_skill tool - the flag has no client wire and no declared gate, and that loader is its only reader (agent-skills.md).

Gate type 4: View decorator

For the views Django still renders itself - /cc_admin/, the setup wizard, allauth:

from campus_core.decorators import feature_required, registered_user_required

@registered_user_required
@feature_required("cc_advising_notes_export")
def export_advising_notes(request): ...

The decorator sends an unauthenticated user to login itself, the same redirect @registered_user_required performs, so the order of the two is a matter of reading, not behaviour. For an authenticated user without the feature it returns 403 for non-HTMX requests and HX-Redirect for HTMX. It also writes a permission_denied audit entry on every blocked request - free observability.

No shipped flag uses it, because every gated surface today is the SPA or a typed endpoint; apps/auth/tests/test_rbac_decorators.py pins its behaviour for the day one does.

The two-axis truth table

A user-scoped feature is visible to a user iff all of:

  1. The feature exists in the in-process registry (otherwise → False, unknown flag)
  2. The feature is not deprecated in the registry
  3. The user is authenticated
  4. FeatureState.is_available is True (CampusCore unlocked it)
  5. FeatureState.is_enabled is True (institution turned it on)
  6. The user holds at least one active role in enabled_for_roles, OR is an active account named in enabled_for_users

The two scopes are a union: a named person sees the feature with no role, and a role-holder sees it without being named. There is no "visible to all" fallback. The institution admin picks at least one active role or one active person to enable a feature, and apps/auth/services/enablement_service.py is the one place that rule is enforced. Three writers go through it on the feature side: the Settings → Features API, the FeatureState Django admin form, and the boot-time sync_feature_registry seeder. The service is shared with ConnectorActionPolicy, which has writers of its own; rbac-and-feature-flags.md lists them all. A write that bypasses the service - a fixture, a shell session, a hand-written migration - is not blocked, and is_feature_enabled returns False for an enabled feature whose active scope is empty, which is the defense in depth for those.

If any condition fails, is_feature_enabled returns False. Fail-closed means you can't accidentally ship a gated feature in the open state. A database fault while loading the FeatureState row is not a failed condition: it raises like any other database failure in the request, because an answer of False would be cached for the request and read exactly like a feature the institution switched off.

A deployment-scoped feature skips conditions 3 and 6, so it is on for any caller including an anonymous one; _evaluate in feature_service.py returns its FeatureState answer before it looks at the user.

Testing your gate

The test pattern is in apps/auth/tests/test_feature_service.py. Key points:

from apps.auth.features import UserFeatureSlug, reset_registry_for_tests
from apps.auth.models import Feature, FeatureState, Role

class MyFeatureGateTestCase(TestCase):
    # A member of the alias, added in step 1 alongside the registration.
    SLUG: UserFeatureSlug = "cc_advising_notes_export"

    def setUp(self):
        # Rebuild the in-process registry so tests don't interfere. This
        # re-registers the shipped flags, which is where SLUG comes from.
        reset_registry_for_tests()
        # Create the Feature DB row by hand (sync_feature_registry doesn't run in tests)
        self.feature = Feature.objects.create(slug=self.SLUG, name="Advising Notes Export")

    def tearDown(self):
        reset_registry_for_tests()

    def test_disabled_by_default(self):
        # No FeatureState row → fails closed
        assert is_feature_enabled(self.user, self.SLUG) is False

    def test_enabled_when_both_axes_true(self):
        state = FeatureState.objects.create(
            feature=self.feature, is_available=True, is_enabled=True
        )
        state.enabled_for_roles.set([Role.objects.get(slug="advisor")])
        assert is_feature_enabled(self.advisor_user, self.SLUG) is True

A fixture may create an enabled row with no roles, because the ORM does not go through enablement_service - but that is the read-side fail-close case, not a state the application supports. Scope the row to a role whenever the test means "this feature is on."

A test that needs a flag of its own - one exercising deprecation, a rename, or the Settings list - registers it through register_feature, whose slug is deliberately str. It cannot then reach it through is_feature_enabled, which takes the typed set; the tests that pass a slug illegal on purpose carry # ty: ignore[invalid-argument-type] # illegal by design on the line.

Test all six truth-table cases for any non-trivial flag: - No FeatureState row → False - is_available=False, is_enabled=True, with roles → False - is_available=True, is_enabled=False, with roles → False - is_available=True, is_enabled=True, empty role list → False (the new rule - no "visible to all") - is_available=True, is_enabled=True, with roles, user has a matching role → True - is_available=True, is_enabled=True, with roles, user has no matching role → False

Worked example: knowledge folders

cc_knowledge_folders is the shipped user-scoped flag, and it hides the folder surfaces in the UI. It does not confine folder content: the folder endpoints in apps/main_app/apis/workspace_folders_api.py gate on auth="workspace_admin", and the agent's ListFolderFilesTool and SearchTool are registered whether the flag is on or off. Who may read a folder's content is KnowledgeFolder.visibility and the access filter in the vector store, which rbac-and-feature-flags.md describes. The five touch points:

  1. Register: register_feature(slug="cc_knowledge_folders", ...) in apps/auth/features.py
  2. Alias: the slug is a member of UserFeatureSlug in the same module
  3. Types: bash scripts/sync_openapi_types.sh, so the SPA's UserFeatureSlug union carries it
  4. Chat surfaces: useFeatureEnabled("cc_knowledge_folders") in web/src/components/chat/FolderScopeMenu.tsx and web/src/components/chat/ConversationSidebar.tsx, each returning null when it is off
  5. Settings nav: requiresFeature: "cc_knowledge_folders" on the tab in web/src/routes/settingsTabs.tsx, so it is offered only when the server lists the slug in bootstrap.features

What you do not touch: document_chunk_store.py::_apply_access_control. The vector store stays a passive component that answers access questions, not feature questions. Adding a feature check there couples storage to authorization - wrong layer.

Common gotchas

  • Forgot to run sync_feature_registry → admin UI doesn't show the flag, but is_feature_enabled still works because the registry is the source of truth. Every web container, local or cloud, runs it through run_boot_sequence as it starts, but uvicorn --reload does not re-run that, so a running local container needs docker compose restart web or bash scripts/stack.sh up.
  • Logging inside is_feature_enabled → don't. Checks happen on every page render and every gated UI element. Logging would swamp the audit log. The invariant is enforced by test_feature_service.py::test_is_feature_enabled_never_writes_audit_log.
  • Test fixtures - call reset_registry_for_tests() in both setUp and tearDown. The registry is module-level and leaks across tests otherwise; the reset rebuilds it from the shipped registrations.
  • Forgetting a surface - always ask "where else is this offered?" The worked example above lists every site the shipped flag gates; if a flag ever needs to keep a tool out of the agent's hand, the place is create_default_registry (gate type 3), which no flag reaches today.
  • Trying to delete a flag → a flag is marked deprecated, never removed from the registry. Set is_deprecated=True, which makes is_feature_enabled return False and hides the card. Removing the register_feature call leaves an orphan row in every deployment, which the sync reads as an undeclared rename: the next release that adds any flag refuses to boot until someone with shell access runs sync_feature_registry --prune-orphans, and pruning cascades to FeatureState.
  • Renaming a slug without renamed_from → the boot fails on every deployment, naming the new slug and the orphaned one. See Renaming a flag above.

See also