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 viarun_boot_sequence) - 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
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 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,
)
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 run_boot_sequence on every container start, which syncs the registry right after migrate under a cross-container lock.
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 picks at least one active role to enable a feature, and apps/auth/services/feature_state_service.py is the one place that rule is enforced.
Three writers go through it: the Settings → Features API, the FeatureState Django admin form, and the boot-time sync_feature_registry seeder.
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.
Testing your gate¶
The test pattern is in apps/auth/tests/test_feature_service.py. Key points:
from apps.auth.features import register_feature, reset_registry_for_tests
from apps.auth.models import Feature, FeatureState, Role
class MyFeatureGateTestCase(TestCase):
SLUG = "cc_my_feature"
def setUp(self):
# Reset 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):
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 feature_state_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."
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'srun_boot_sequencehandles 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