M1-47: Feature flags with registration and cleanup gate¶
Status: ready | Depends on: M1-44, M1-46 | Milestone: M1
Goal¶
After this ticket every feature flag is registered at one place in the server code with key,
kind (release or option), description, default, owning module and, for release flags, the
version remove_by. The server evaluates flags per principal with the rollout lists of people
and groups, unknown keys are off, owners change flags over the API with step-up, reason and
audit, and clients get their evaluated flags from getInstance. A gate make flag-check fails
as soon as the server version reaches the remove_by of a registered release flag, so no
release flag outlives its date (16 "Feature flags", ADR 0064). The existing gate on personal API
tokens (tokens.personal_enabled, M1-44) runs through this evaluation.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 7 rows M1-27, M1-30, M1-44, M1-46, M1-47; exit criterion 9 (ADR 0064, accepted:docs/adr/0064-feature-flag-registration-with-a-cleanup-gate.md).docs/spec/16-operations.md: "Environments and feature flags" (6), especially "Feature flags" (6.2), and decision R-OP-14.docs/operations/environments-and-flags.md: section 2 "Feature flags" (2.1 to 2.5).docs/operations/configuration.md: 1 "Sources" (row Feature flags), 7 "Feature flags".docs/spec/03-data-model.md: 4.1 tablefeature_flags(rollout schemafeature_flag_rollout.v1); 4.3groups,group_memberships(validity of memberships).docs/spec/07-permissions.md: "Validity of a grant" (2.5: a membership withexpires_at = tis already invalid), "Principal context" (3, personal tokens andtokens.personal_enabled).docs/spec/05-actions.md: 4.2 rowsinstance.read,feature_flag.read,feature_flag.update; 4.3 rowtoken.create; 6 rowinstance.updated.docs/spec/06-api.md: 5 item 5 (403 feature_disabled, public endpoints answer404), 8 item 3 (flags take an optionalIf-Match).docs/spec/18-release.md: 2 "Components", rules 1 and 4 (fileVERSION, SemVer withoutv, equal to the tag).docs/spec/02-architecture.md: 4.1 item 4 (source ports), 4.3, 4.4, 5, 7.1 to 7.3.docs/adr/0000-template-madr-4.md,docs/adr/0001-adrs-in-madr-format-decisions-as-ruling-log.md(form of the new ADR),docs/adr/0011-12-factor-configuration-and-secret-provider.md.docs/api/openapi.yaml: operationslistFeatureFlags(GET /feature-flags),putFeatureFlag(PUT /feature-flags/{key}),getInstance; schemasFeatureFlag,FlagRollout,FeatureFlagPut,FeatureFlagList,EvaluatedFlag,InstanceInfo.- Code of earlier tickets:
internal/instance(M1-15, M1-46: settings registry,getInstance),internal/authz(M1-27 validity functions of memberships),internal/identity(M1-26 directory, M1-44 API tokens and the current gate ontokens.personal_enabled),internal/wire,services/server/VERSION,Makefile(targetgates), M1-05tools/archgen(location convention for Go tools). - Tables:
feature_flags. Action ids:feature_flag.read,feature_flag.update,instance.read,token.create(only the flag check).
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behaviour gets a test that is red before the code exists (plan section 3).
- No en dash or em dash in any text you write; white label (people Alex, Sam, Robin, groups
Support, Marketing, Engineering, Research); configuration only through named keys from
docs/operations/configuration.md. - 16 6.2 "Registration": every flag is registered in code with key, kind
releaseoroption, description, default, and forreleasethe versionremove_by; environments doc 2.2 adds the owning module and says unknown keys in the table are off and shown as orphaned. - 16 6.2 "Evaluation" and environments doc 2.3: value = global value, inverted when the
principal or one of its valid groups is in
rollout; unknown keys are off; the server evaluates, never the client; agents and apps get the global value (rollout holds only people and groups). - 16 6.2 "Cleanup rule": a gate fails when the server's
VERSIONreachesremove_byof a registeredreleaseflag. - 05 4.2:
feature_flag.readandfeature_flag.updateare owner only; update needs step-up and is audited on allow and deny; environments doc 2.4 requires a reason. - 05 6 and 16 6.2:
instance.updatednames only the changed flag keys. - 16 6.2 "Limit": flags switch functions only, never security limits, thresholds or secrets.
- configuration.md 7: the option flags
dm.enabled,voice.enabled,tokens.personal_enabled, all off by default. - 06 5 item 5 and 05
token.create: withtokens.personal_enabledoff for the caller, creating a personal token answers403 feature_disabled.
Scope¶
Paths are relative to services/server/ unless they start with docs/ or Makefile.
- Specification (in place from planning, spec first; change it only where the
implementation proves it wrong):
docs/api/openapi.yamlFeatureFlagwithkind,description,default,remove_by,module,orphaned,version,updated_by,updated_atandFeatureFlagPutwith requiredenabledandreasonand withoutdescription; ADR 0064; 03feature_flags(nodescriptioncolumn, version0for a registered key without a row); 02 4.3 (instancedeclaresFlagGroupSourceandFlagUserSource, implemented byauthzandidentity); 02 "6. Code layout" (tools/flagcheck). To do here:docs/quality/gates.mdrowflag-check;docs/DECISIONS.mdthe rulings under Design notes, in the file's existing format. -
Port
internal/instance/flags.go(port package, imports onlykernel):02 5 allows only the port package andtype FlagKind string // FlagKindRelease = "release", FlagKindOption = "option" type FlagDefinition struct { Key, Description, Module, RemoveBy string // RemoveBy only for release Kind FlagKind Default bool } const ( FlagDMEnabled = "dm.enabled" FlagVoiceEnabled = "voice.enabled" FlagTokensPersonalEnabled = "tokens.personal_enabled" ) func RegisteredFlags() []FlagDefinition // the one registry, sorted by key type FlagSubject struct { PrincipalID ids.ID; Kind string } // user, agent, app type EvaluatedFlag struct { Key string; Enabled bool } type Flags interface { Enabled(ctx context.Context, s FlagSubject, key string) (bool, error) EvaluateAll(ctx context.Context, s FlagSubject) ([]EvaluatedFlag, error) } type FlagGroupSource interface { // implemented by authz ValidGroupIDs(ctx context.Context, userID ids.ID, at time.Time) ([]ids.ID, error) ExistingGroupIDs(ctx context.Context, groupIDs []ids.ID) ([]ids.ID, error) } type FlagUserSource interface { // implemented by identity ExistingUserIDs(ctx context.Context, principalIDs []ids.ID) ([]ids.ID, error) }domain,app,adaptersin a module (PLAN row M1-47), so the parts live in those layers. -
Domain
internal/instance/domain/flags.go(pure):FlagSpec(same fields as the port definition;appconverts),CheckRegistry(specs []FlagSpec, modules []string) []string,Evaluate(global bool, inRollout bool) bool,InRollout(r Rollout, kind string, principalID ids.ID, validGroups []ids.ID) bool,NormalizeRollout(r Rollout) Rollout(sorted, unique),CompareSemVer(a, b string) (int, error)(precedence after semver.org 2.0.0 section 11, build metadata ignored),ReachedRemoveBy(version, removeBy string) (bool, error). - Application
internal/instance/app/flags.go: implementsinstance.Flags; use casesListFlags(ctx, actor)andPutFlag(ctx, cmd PutFlagCmd);GetInstanceInfoof M1-46 fillsfeatureswithEvaluateAllfor the caller. - Adapter postgres: migration
migrations/<next>_feature_flags.sql(table exactly as 03 4.1),queries/flags.sql, sqlc output. - Adapter httpapi: strict-server methods
ListFeatureFlags,PutFeatureFlag. - Source port implementations:
internal/authz/adapters/flagsource(valid groups through the M1-27 validity functions and the authz store; existing groups = not deleted),internal/identity/adapters/flagsource(principals of kinduserwith status other thandeleted); wired ininternal/wire. - Personal tokens. M1-44 decides
tokens.personal_enabledthrough its consumer portPersonalTokenSwitch, bound to the default-offapp.PersonalTokensDefault{}. Implement the switch ininternal/identity/adapters/instanceflagsoverinstance.Flags.Enabled(ctx, caller, instance.FlagTokensPersonalEnabled), rebind it ininternal/wire, and delete the default binding. - Gate tool
tools/flagcheck(Gomainin the server module, besidetools/archgen; follow its location convention): flags-version-file,-config-doc,-modules; checks in Design notes.Makefile: targetflag-check, part ofgates; it printsSKIP flag-check no server modulewhenservices/server/go.modis not tracked (gates.md "Skipping"). - Enable list
api/operations.yaml:listFeatureFlags,putFeatureFlag. - Documentation:
internal/instance/README.md(registry, evaluation, source ports, how to add and remove a flag),docs/operations/environments-and-flags.md2.2 (where the registry lives, theflag-checkcommand),services/server/CHANGELOG.mdunderUnreleased.
Out of scope¶
- Using
dm.enabled(V3, DMs) andvoice.enabled(M3 transcription): their features check the flag when they are built. - Console screens for flags: M6-09 and M4.
- Instance settings, branding, discovery: M1-46.
- Deleting a flag's row when its code is removed: done by the change that removes the flag (environments doc 2.5 item 2); no release flag exists in M1.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-47-feature-flags. - Conventional Commits with module scope (for example
feat(instance): feature flag registry) and DCO sign-off (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above except
services/server/CHANGELOG.md,docs/DECISIONS.mdanddocs/adr/README.md(bymake adr-index). - Follow the host rules supplied by the launcher.
- Stop only processes you started, by PID. Temporary directories only via
mktemp -d; remove them by their literal path at the end. PostgreSQL runs only as the throwaway container ofpgteston a random port bound to127.0.0.1. - Never read or print secrets. No network calls except package downloads for pinned tools.
Design notes¶
Registry content in M1 (configuration.md 7):
| Key | Kind | Default | Module | Description (English, shown in the console) |
|---|---|---|---|---|
dm.enabled |
option | false | chat |
Direct messages between people |
tokens.personal_enabled |
option | false | identity |
Personal API tokens |
voice.enabled |
option | false | files |
Transcription of voice messages |
Registry rules (checked by CheckRegistry, used by flag-check and a unit test): key matches
^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$ (fits the KeyPath pattern), keys unique, kind is
release or option, description not blank, module is a key under modules: in
architecture/modules.yaml, release has a valid remove_by (SemVer without v), option
has none. A flag never encodes a number, limit or secret (16 6.2 "Limit"); the README says so
next to the registry, because a check cannot see intent.
Evaluation.
Enabled(s, key):
def := registry[key]; if missing: return false # unknown keys are off
row := feature_flags[key] (one primary-key read)
global := row exists ? row.enabled : def.Default
in := s.Kind == "user" and (s.PrincipalID in row.rollout.user_ids
or any(ValidGroupIDs(s.PrincipalID, t)) in row.rollout.group_ids)
return global XOR in
t is read once per call from kernel/clock. ValidGroupIDs returns groups that are not
deleted and in which the person has a membership in effect at t (expires_at = t is already
invalid, 07 2.5). EvaluateAll reads all rows in one query, calls ValidGroupIDs at most once,
returns registered flags only, sorted by key. Orphaned rows evaluate to off and never appear in
EvaluateAll. No cache: one indexed read per call keeps changes in other processes effective at
once; a cache would need the event log and buys nothing at this size. Errors are returned, not
turned into "off", so a database fault shows as 5xx instead of silently disabling a feature.
Rollout (feature_flag_rollout.v1): {user_ids?: [uuid], group_ids?: [uuid]}, stored
normalized (sorted, unique, empty arrays omitted, {} when both empty). The API returns the same
shape (rollout: {} when empty).
listFeatureFlags (feature_flag.read, owner): every registered flag (row values, or the
default with version = 0, no updated_at, no updated_by), then every orphaned row
(orphaned: true, description from the row, no kind, default, module, remove_by), all
sorted by key.
putFeatureFlag (feature_flag.update, owner, step-up, audit, IfMatchOptional):
- key not registered (also orphaned):
404 not_found; If-Matchpresent and not the current version (0without row):412 version_conflictwithcurrent;- body
{enabled, rollout?, reason}; an absentrolloutmeans empty (PUT replaces); - unknown or non-person IDs in
rollout.user_ids(viaFlagUserSource):400 validation_failed, pointer/rollout/user_ids/<index>, codeunknown_user; unknown or deleted groups (viaFlagGroupSource): pointer/rollout/group_ids/<index>, codeunknown_group; - same
enabledand same normalized rollout as stored: no write, no event,200; - otherwise upsert:
version1 on insert,version + 1on update,descriptioncopied from the registry,updated_by,updated_at; appendinstance.updatedwithchanged_keys = [key]; audit allow withdetails = {key, enabled, rollout, reason}(IDs only, no names); - response
200FeatureFlagwithETag: "<version>".
getInstance now returns features = EvaluateAll(caller) (agents and apps get global
values). Its ETag (hash of the body, M1-46) changes with the evaluation, as intended.
flag-check (tools/flagcheck): reads VERSION, RegisteredFlags(), the module keys of
architecture/modules.yaml and the table of docs/operations/configuration.md section 7.
Fails with one line per finding, prefixed flag-check:, exit code 1:
- any
CheckRegistryfinding; - a
releaseflag withCompareSemVer(VERSION, remove_by) >= 0(a pre-release such as1.4.0-beta.1has not reached1.4.0), messageremove flag <key>: VERSION <v> reached remove_by <r>; - the configuration.md table differs from the registry in key, kind or default (
on= true,off= false). Printsflag-check: ok (<n> flags)otherwise. The tool takes the registry as a function argument internally, so tests run it on fixture registries without touching the real one.
ADR 0064 exists (accepted). Keep it current: if the implementation changes a rule or the
confirmation (make flag-check, TestCheckRegistry*, TestFlagCheck*, TestEvaluate*), update
the ADR in the same change and run make adr-index.
Rulings for docs/DECISIONS.md:
- The registry owns key, kind, default, module and description;
FeatureFlagPuthas nodescriptionand requiresreason. - A flag without a row has its default and
version = 0. - Flag evaluation reaches groups and people through source ports declared by
instance(FlagGroupSource,FlagUserSource), becauseinstancemay not depend onauthzoridentity(02 4.4). - No flag cache; errors are not mapped to "off".
Tests (write them first)¶
internal/instance/domain:TestCheckRegistry(bad key, duplicate, unknown kind, blank description, unknown module, release without or with invalidremove_by, option withremove_by),TestCompareSemVer(table from semver.org 11 including pre-release order and build metadata),TestReachedRemoveBy,TestEvaluate,TestInRollout(person direct, person via group, agent with its ID inuser_idsstays global),TestNormalizeRollout.internal/instance:TestRegisteredFlagsValid(the real registry passesCheckRegistry).tools/flagcheck:TestFlagCheck_ReleaseReachedRemoveByFails,TestFlagCheck_PreReleaseBeforeRemoveByPasses,TestFlagCheck_ConfigDocMismatchFails,TestFlagCheck_RealRepositoryPasses.- Integration against PostgreSQL 18 with real authz, identity, audit and event log and a
controllable clock:
TestFlags_GlobalOnOff,TestFlags_PersonInRolloutInverts,TestFlags_GroupInRolloutInverts,TestFlags_ExpiredMembershipDoesNotInvert(membership of Sam in group Research expires: att = expires_atSam gets the global value),TestFlags_RemovedMembershipDoesNotInvert,TestFlags_DeletedGroupDoesNotInvert,TestFlags_UnknownKeyOff,TestFlags_OrphanedRowOffAndListed,TestFlags_AgentGetsGlobalValue.TestListFeatureFlags_OwnerOnly(Sam403 forbiddenwithreasonowner_only), defaults withversion = 0.TestPutFeatureFlag_StepUpReasonAudit,TestPutFeatureFlag_UnknownKey404,TestPutFeatureFlag_OrphanedKey404,TestPutFeatureFlag_RolloutValidation(agent ID inuser_ids, unknown group),TestPutFeatureFlag_IfMatch412,TestPutFeatureFlag_EventNamesOnlyKey,TestPutFeatureFlag_NoChangeNoEvent.TestGetInstance_FeaturesEvaluatedForCaller(Alex and Sam see different values with a rollout for Sam).TestPersonalToken_FlagOffForbidden,TestPersonalToken_FlagOnAllowed,TestPersonalToken_RolloutForSamOnly(throughcreateTokenwith fresh step-up).
- Every response passes the response validation of the test build.
Commands that must pass:
cd services/server && go test -race ./internal/instance/... ./internal/authz/... ./internal/identity/... ./tools/...
make flag-check
make codegen-check
make api-lint
make docs
make check
Acceptance criteria¶
- Evaluation is correct globally, per person and per group, including an expired membership at the exact second; unknown and orphaned keys are off.
-
make flag-checkis part ofmake gatesand fails whenVERSIONreachesremove_byof a registeredreleaseflag (fixture test), and when configuration.md 7 drifts. - Changes need an owner with fresh step-up and a reason, are audited on allow and deny, and
write
instance.updatedwith only the changed key. -
getInstancereturns the flags evaluated for the caller. -
tokens.personal_enableddecidescreateTokenfor personal tokens throughinstance.Flags; no stand-in remains. - ADR 0064 matches the implementation and names its confirmation.
-
make checkis green.
Definition of done¶
Code, tests and documentation are complete: module README updated, doc comments on every public
symbol, ADR 0064 current, CHANGELOG entry under Unreleased in services/server/CHANGELOG.md.
make check is green. An independent review and the red team (docs/quality/red-team-gate.md)
have run over the diff with their verdicts recorded in the report. Merge locally into the base
branch only if both are green; then remove the worktree with git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red run of each
new test before the code), what is not proven, and follow-ups that could not be done in scope
with the reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.