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¶
- Register the flag in code — call
register_feature(...)in a module that's imported at app-ready time - Sync it to the DB —
python manage.py sync_feature_registry(or just deploy;entrypoint.shruns it) - Flip availability — as a CampusCore platform admin, set
FeatureState.is_available=Truein Django admin for this deployment - Flip enablement — as an institution admin, toggle the feature on in Settings → Features (and optionally restrict it to specific roles)
- Gate the code — drop
@feature_required(...),{% if_feature %}, oris_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¶
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:
- Log into Django admin as a superuser
- Open Feature States
- Find the row matching your feature slug (or create one)
- Set
is_available=Truefor this deployment - 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:
- Open Settings → Features
- Find the feature card
- Toggle Enabled on
- (Optional) Click roles in the role picker to restrict who can see the feature
- 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:
- The feature exists in the in-process registry (otherwise →
False, unknown flag) - The feature is not deprecated in the registry
- The user is authenticated
FeatureState.is_availableisTrue(CampusCore unlocked it)FeatureState.is_enabledisTrue(institution turned it on)enabled_for_rolesis 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:
- Register: add
register_feature(slug="cc_knowledge_folders", ...)toapps/auth/features.py - HTMX endpoints: stack
@feature_required("cc_knowledge_folders")on every view inapps/main_app/apis/knowledge_folder_apis.py(list,create,edit,delete,files,selector, etc.) - Page-level views: same decorator on
knowledge_folders_viewandknowledge_folder_detail_viewinapps/main_app/views.py - Settings nav: wrap the "Knowledge Folders" sidebar link in
templates/components/settings_nav_items.htmlwith{% if_feature "cc_knowledge_folders" %}. Same for the mobile pills version. Add the decorator tosettings_knowledge_admininapps/main_app/apis/settings_apis.py. - Folder selector in chat: gate the
folder_selectorendpoint and the include in the chat input template - Agent tool registration: where the agent tool list is built per-request, wrap
search_knowledge_folderanddiscover_knowledge_sourcesinif is_feature_enabled(user, "cc_knowledge_folders") - Django admin: override
KnowledgeFolderAdmin.has_module_permissionto returnis_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, butis_feature_enabledstill 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 bytest_feature_service.py::test_is_feature_enabled_never_writes_audit_log. - Decorator order —
@feature_requiredmust come AFTER@registered_user_requiredin source (so auth runs first, before the feature check). - Test fixtures — call
_reset_registry_for_tests()in bothsetUpandtearDown, and re-register the flag insetUp. 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-orphansin production. Removing aFeaturerow cascades toFeatureStateand silently destroys per-deployment state. Mark deprecated in the registry instead (is_deprecated=True), which makesis_feature_enabledreturn False.
See also¶
- RBAC & Feature Flags — architecture and the why behind the design
- Managing Features — institution-admin guide for the in-app Features settings
apps/auth/features.py— the registry implementationapps/auth/services/feature_service.py— theis_feature_enabledcheckapps/auth/management/commands/sync_feature_registry.py— the sync command source