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
  2. Sync it to the DBpython manage.py sync_feature_registry (or just deploy; entrypoint.sh runs it)
  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 — drop @feature_required(...), {% if_feature %}, or is_feature_enabled(user, slug) everywhere the feature surfaces

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,
)

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 exec campuscore_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.

You don't have to remember to run this manually for production deploys: entrypoint.sh runs it on every container start, right after migrate.

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.

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.

Gate type 1: View decorator

For HTMX endpoints and full-page Django views:

from campus_core.decorators import feature_required, registered_user_required

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

Decorator order matters. @registered_user_required (or @htmx_login_required) goes ABOVE @feature_required in source so authentication runs first. Otherwise an unauthenticated user gets a 403 about a feature they can't even know exists, instead of a redirect to login.

The decorator 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.

Gate type 2: Template tag

For showing/hiding UI elements in Django templates:

{% load auth_tags %}

{% if_feature "cc_advising_notes_export" %}
    <a href="{% url 'export_notes' %}" class="...">Export to PDF</a>
{% endif_feature %}

Useful when you want the page to render either way but a specific button or section to disappear when the feature is off. Uses the per-request RBAC cache so calling it many times in one render is cheap.

Gate type 3: Service / programmatic check

For business logic 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 inside services and helpers where decorator-style gating doesn't fit. The check is per-request cached (when you pass request=request), and fails closed for unknown slugs.

Gate type 4: Agent tool registration

The tricky one. The agent's tools are constructed dynamically per-request, not declared statically. So decorators don't fit — you need an explicit if at tool list build time:

def build_tools_for_user(user):
    tools = [reasoning_tool, ...]
    if is_feature_enabled(user, "cc_knowledge_folders"):
        tools.append(search_knowledge_folder_tool)
    return tools

If the tool isn't in the agent's hand, the agent can't call it. This is the right defense layer — the underlying vector store and storage layers stay passive and unaware of feature flags.

The two-axis truth table

A 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. enabled_for_roles is non-empty AND the user has at least one matching role

There is no "visible to all" fallback. The institution admin MUST explicitly pick at least one role to enable a feature. The Settings → Features UI refuses to save is_enabled=True with an empty role list, and is_feature_enabled returns False if it ever encounters that state — defense in depth against fixtures, shell hacks, or direct DB writes that bypass the UI validation.

If any condition fails, is_feature_enabled returns False. Fail-closed means you can't accidentally ship a gated feature in the open state.

Testing your gate

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

from apps.auth.features import _reset_registry_for_tests, register_feature
from apps.auth.models import Feature, FeatureState

class MyFeatureGateTestCase(TestCase):
    SLUG = "cc_my_feature"

    def setUp(self):
        # Wipe the in-process registry so tests don't interfere
        _reset_registry_for_tests()
        # Register the flag — equivalent of the production register_feature call
        register_feature(slug=self.SLUG, name="My Feature", default_enabled=False)
        # Create the Feature DB row by hand (sync_feature_registry doesn't run in tests)
        self.feature = Feature.objects.create(slug=self.SLUG, name="My Feature")

    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):
        FeatureState.objects.create(feature=self.feature, is_available=True, is_enabled=True)
        assert is_feature_enabled(self.user, self.SLUG) is True

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: gating knowledge folders

Suppose you want to gate the entire knowledge folders subsystem behind cc_knowledge_folders. The seven touch points:

  1. Register: add register_feature(slug="cc_knowledge_folders", ...) to apps/auth/features.py
  2. HTMX endpoints: stack @feature_required("cc_knowledge_folders") on every view in apps/main_app/apis/knowledge_folder_apis.py (list, create, edit, delete, files, selector, etc.)
  3. Page-level views: same decorator on knowledge_folders_view and knowledge_folder_detail_view in apps/main_app/views.py
  4. Settings nav: wrap the "Knowledge Folders" sidebar link in templates/components/settings_nav_items.html with {% if_feature "cc_knowledge_folders" %}. Same for the mobile pills version. Add the decorator to settings_knowledge_admin in apps/main_app/apis/settings_apis.py.
  5. Folder selector in chat: gate the folder_selector endpoint and the include in the chat input template
  6. Agent tool registration: where the agent tool list is built per-request, wrap search_knowledge_folder and discover_knowledge_sources in if is_feature_enabled(user, "cc_knowledge_folders")
  7. Django admin: override KnowledgeFolderAdmin.has_module_permission to return is_feature_enabled(request.user, "cc_knowledge_folders")

What you do not touch: document_chunk_store.py::_apply_access_control. The vector store stays a passive component. Once tools aren't registered (step 6), the store is never queried for folder content. 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. The deploy entrypoint handles this in production; only matters for local dev.
  • 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.
  • Decorator order@feature_required must come AFTER @registered_user_required in source (so auth runs first, before the feature check).
  • Test fixtures — call _reset_registry_for_tests() in both setUp and tearDown, and re-register the flag in setUp. The registry is module-level and leaks across tests otherwise.
  • Forgetting the agent tool gate — the most common miss. Decorators cover views but not agent tools. Always ask "where else is this surfaced?" The seven-touch-point exercise above is a useful checklist.
  • Trying to delete a flag → don't --prune-orphans in production. Removing a Feature row cascades to FeatureState and silently destroys per-deployment state. Mark deprecated in the registry instead (is_deprecated=True), which makes is_feature_enabled return False.

See also