Managing Feature Flags¶
This guide explains how to add, modify, and use Boolean feature flags in SyRF, including non-production runtime overrides shared by browser and API consumers.
Overview¶
The feature flags system uses env-mapping.yaml as the single source of truth for all environment configuration, including feature flags. The generator reads from this unified schema and produces:
- TypeScript interfaces (
FeatureFlags,FeatureFlagsExternalConfig) - Default values (
FEATURE_FLAG_DEFAULTS) - NgRx selectors for each flag
- Config parser (
parseFeatureFlags()) for runtime parsing - Updates to the web chart's Helm
values.yamlfor deployment configuration
The NGINX envsubst template (env.template.generated.json), appConfig.default.generated.json, the config TypeScript files and _env-blocks.tpl are written by a separate generator, pnpm run generate:env-blocks (from src/charts/syrf-common). generate:runtime-catalog writes RuntimeFeatureFlagCatalog.generated.cs. See Work with generated files.
Key benefits:
- Adding a new feature flag requires only editing
env-mapping.yamland running the generator - Uses unified
SYRF__prefix for all environment variables (not legacySPA__) - The API receives the same deployed defaults as the web app, so cross-stack gates such as
bulkPdfUploadstay consistent - Non-production administrators can temporarily override catalogued Boolean feature flags where runtime changes are supported, without replacing the deployed defaults
┌─────────────────────────────────────────────────────────────┐
│ src/charts/syrf-common/env-mapping.yaml │
│ (Single Source of Truth) │
│ │
│ featureFlags: │
│ services: [api] # Shared web/API deployed defaults │
│ envVars: │
│ - name: SYRF__FeatureFlags__MyNewFeature │
│ valuePath: featureFlags.myNewFeature │
│ default: "false" │
│ web: { tsName: myNewFeature, tsType: boolean } │
│ featureFlag: true │
│ displayName: "My new feature" │
│ category: uiFeatures │
│ description: "Enable my new feature" │
└─────────────────────────────────┬────────────────────────────┘
│
pnpm run generate:flags
│
┌───────────┴────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ TypeScript Files │ │ Helm values.yaml │
│ │ │ (web .chart) │
│ - types.ts │ │ featureFlags: │
│ - constants.ts │ │ myNewFeature: │
│ - selectors.ts │ │ false │
│ - parser.ts │ │ │
└─────────────────────┘ └─────────────────────┘
File Locations¶
| File | Purpose |
|---|---|
src/charts/syrf-common/env-mapping.yaml |
Schema definition (edit this!) |
src/services/web/scripts/generate-feature-flags.ts |
Generator script |
src/services/web/src/app/generated/feature-flags.types.ts |
Generated TypeScript interfaces (types only, no JS output) |
src/services/web/src/app/generated/feature-flags.constants.ts |
Generated runtime constants (as const) |
src/services/web/src/app/generated/feature-flags.selectors.ts |
Generated NgRx selectors |
src/services/web/src/app/generated/feature-flags.parser.ts |
Generated config parser |
src/services/web/src/assets/data/env.template.generated.json |
envsubst template, written by generate:env-blocks (not generate:flags) |
src/services/web/.chart/values.yaml |
Auto-updated Helm values |
Flag lifecycle requirements¶
A feature flag switches a feature on and off. It does not migrate data. Chris's rule (2026-10-17):
"Flags are used to turn features on and off, not necessarily to auto migrate databases back and forth. So whilst the flag is present and flippable both the database and app code should work well with either on or off state and should be happy going back and forth. That should be an implementation requirement when adding the feature flag in the first place. (The extra infrastructure for and to support a specific flag can be removed at a later stage when the flag is retired.)"
So while a flag exists, the code and the database must work with it on or off, and survive being switched back and forth any number of times, including with data created in the other state. The author of the flag builds and tests that when the flag is added; it is not left to be discovered during a rollback.
This section is the canonical text. The agent checklist in
.claude/rules/feature-flags.md
summarises it, and ADR-023
records the migration side. If they differ, this section wins.
Environments, deployments, migrations and flags¶
Four terms have been getting confused. Use them in this sense in pull requests, runbooks and ADRs.
- Environment: local, preview, staging or production. Each has its own database and its own flag values.
- Deployment: puts a build into an environment. A promotion deploys staging's build to production; a rollback deploys the previous build. Flag values are configuration: changing one is a separate decision from deploying a build, even when a deployment-managed flag needs a rollout to change.
- Migration: part of a build. It runs before that build is deployed (ADR-023). While the previous build may still be serving, a migration only adds things, and it must suit every flag state of both builds. Flags never run, skip or reverse a migration, and no migration reads a flag.
- Flag: a switch that a running build reads, per environment. The only allowed link with migrations is one-way: a feature may refuse to switch on, failing closed, until its migrations have run (its readiness check).
For example, deletionLifecycle needs the deletion indexes, which are migrations 0005 to 0008
(ADR-024). With the flag on but those migrations
missing, the search-delete routes answer a typed 503 instead of recording a deletion that nothing could
advance. That is the allowed direction: the missing migration keeps the feature closed. Turning the flag on or
off never applies or reverses those migrations, and applying them does not turn the flag on.
Requirements when a flag is added¶
- Both states work. On is the new behaviour. Off is the previous behaviour, or the feature shown as
unavailable; never broken. Off is not only a rollback state: for a flag that production has not turned
on, it is production's behaviour, which is what keeps
mainpromotable. - Round trips are safe, any number of times. Data created while the flag is on is handled correctly while it is off (kept, ignored, or finished by work that is not flag-gated), and the reverse. Work already accepted keeps converging after the flag goes off: a kill switch stops new exposure, it does not strand what was already started.
- Flags read by several services. The browser, the API and Project Management may read the same flag, and a rollout reaches them at different times. Every combination of per-service values that a rollout can produce is safe, or the feature refuses to start until the values agree. The dependency catalog (see Non-Production Runtime Overrides) is one way to enforce this.
- Tests. An on, then off, then on test, with data created in each state.
- Retirement. The dead path is deleted. Anything that existed only for the flag (code, collections,
indexes, data) is removed in a later clean-up, by a migration if indexes or data are involved, once no
deployable build and no flag state needs it. Until then it stays, so that switching the flag back is
always possible.
pnpm feature:settledoes the mechanical part (see Settling a feature).
The pull request template has a checkbox for requirements 1, 2 and 4.
Adding a New Feature Flag¶
Adding a flag includes meeting the flag lifecycle requirements: both states work, round trips are safe and the on, off, on test exists. The steps below cover only the wiring.
Step 1: Scaffold the feature and its flag¶
Two commands, from the repository root:
pnpm feature:new <feature-id> --type <Release|Operational|KillSwitch|Experiment|Permission> --owner "<team>" \
[--permission <SignedIn|Project...Policy|Application...Policy>] [--flag <key>] [--hosts api,project-management] [--title "Sentence case label"] [--description "..."] [--category <category>]
pnpm run validate:generated
--permission is required with --type Release (see Feature fields). For example, pnpm feature:new demo-x --type Release --owner "API maintainers" --permission ProjectEditMembershipsPolicy --flag demoX. The command:
- Adds one entry to the
featureFlagssection ofsrc/charts/syrf-common/env-mapping.yaml:SYRF__FeatureFlags__<Key>, Helm valuefeatureFlags.<key>(default"false"), the web name anddisplayName, andruntimePolicywith the next freeorderandactivationRequirement: immediate.--flagdefaults to the feature ID in camelCase,--titleto the ID in sentence case, and--categorytouiFeatures. - Adds one schema 2 feature to
src/charts/syrf-common/feature-definitions.yaml:lifecycle: Developing,grain: Global, the flag inflags, a single-flag capability and the type's defaults (see Feature fields). AReleasegetstopicIds: [<feature-id>]: its user guide is Required, sovalidate:registryreports "guidance missing" for that topic until content exists. Every other type is exempt with a reason and has no topics. - With
--hosts api,project-management, also adds the Project Management half inbackendFeatureFlagsand adeploymentManagedpin, because Project Management never reads runtime overrides (see Carrying a web flag to Project Management). - Runs the generators that read those two files (
pnpm generateruns all of them, in order) and the registry validator, then prints the remaining steps. If a generator throws, it restores every file it could have changed (both sources, the checksum manifest and those generators' outputs) and reports that nothing changed.
It refuses, and writes nothing, when the feature ID, the flag key, its environment variable, its web name or its Helm value already exists, or when the key is a removed flag (a removed key is never reused). It writes no consumer code, content, What's New entry, walkthrough or cluster-gitops value. Those are the remaining steps it prints:
- Consumer code: the web reads
select<Key>(Step 2 below); .NET readsRuntimeFeatureFlagKeys.<Key>, which is generated. A .NET consumer also needs its property inFeatureFlags.csand its entry in the consumer manifest. - Catalog digest: a new catalogued flag changes the catalog digest that every stored runtime policy identity
carries. Update
PinnedCatalogDigestinRuntimeCatalogDigestPinTeststo the value the failing test prints. It is the only pin on the catalog's membership. - Content and What's New (a
Release): a non-Draftdocs/content/release-content.yamlentry for the topic and its user-guide page, and aruntime:entry for the feature indocs/content/whats-new.yaml, before the feature leavesDeveloping(an announced release Apply refuses without it:WhatsNewEntryMissing). See Runtime What's New entries. - Walkthrough: optional, from 6G.4.
- cluster-gitops:
featureFlags.<key>per environment (Step 3 below).
This is what feature:new writes, for reference or for a flag that needs a different section:
featureFlags:
services: [api] # Shared web/API deployed defaults
displayName: "Feature Flags"
envVars:
# ... existing flags ...
- name: SYRF__FeatureFlags__MyNewFeature
runtimePolicy:
key: myNewFeature
order: 54
category: uiFeatures
description: "Enable my new feature"
activationRequirement: immediate
valuePath: featureFlags.myNewFeature
default: "false"
web:
tsName: myNewFeature
tsType: boolean
featureFlag: true
displayName: "My new feature"
category: uiFeatures
description: "Enable my new feature"
displayName is required for every featureFlag: true entry: write a short sentence-case
label. The generator emits it as FEATURE_FLAG_DISPLAY_NAMES in feature-flags.constants.ts,
and the admin runtime feature flags page (/admin/feature-flags) shows it as the row name.
Generation fails with the list of offending entries if any feature flag lacks a displayName.
runtimePolicy is the runtime catalog entry: stable key and order, category, description, activation requirement
and dependencies. The runtime key must match web.tsName when that browser alias is present.
Application-settings-only catalog entries belong in featureFlags.catalogOnlyFlags and require
configurationReason; they do not create new Helm values. Dependency entries must name known runtime or deployment
keys, and cycles are rejected. Catalog membership does not confer runtime eligibility; do not add arbitrary Boolean
operational or security settings.
After a hand edit of env-mapping.yaml or feature-definitions.yaml, run pnpm generate and then
pnpm run validate:generated. pnpm generate runs, in order: generate:env-blocks (env template, appConfig
defaults, config TS files, _env-blocks.tpl), generate:flags (feature-flag TypeScript and the web
.chart/values.yaml), generate:runtime-catalog (RuntimeFeatureFlagCatalog.generated.cs and
RuntimeFeatureFlagKeys.generated.cs), generate:feature-definitions, the Dockerfiles, and the NSwag client only
when swagger.json changed. Then it runs the registry validator. pnpm generate <name> runs only the named
generators. Do not hand-edit the generated outputs or add definitions or key constants to the handwritten
RuntimeFeatureFlagCatalog.cs facade.
Step 2: Use the Flag in Components¶
Import and use the generated selector:
import { selectMyNewFeature } from '@app/generated/feature-flags.selectors';
@Component({
selector: 'app-my-component',
template: `
@if (myNewFeatureEnabled()) {
<div>New feature content!</div>
}
`
})
export class MyComponent {
private store = inject(Store);
// Using signals (recommended)
myNewFeatureEnabled = this.store.selectSignal(selectMyNewFeature);
// Or using observables
myNewFeature$ = this.store.select(selectMyNewFeature);
}
Step 3: Configure for Deployment¶
The Helm values.yaml is auto-updated. To enable in a specific environment, update cluster-gitops:
# cluster-gitops/syrf/environments/staging/web/values.yaml
featureFlags:
myNewFeature: true # Enable in staging
Env var precedence and boolean defaults¶
The chart renders one env entry per name. Two rules keep that true:
- Explicit
env:overrides generated flag env vars. If an environment's rawenv:map (incluster-gitops) sets a name that the generated block also emits, for exampleSYRF__FeatureFlags__BulkStudyUpdateRequireCurrentExecutionVersion: "true", the generated entry is skipped and only the explicit value is rendered. This is done insyrf-common.deployment.dotnet(syrf-common.env.withoutExplicit), so it applies to api, project-management, quartz and identity. Prefer the flag's Helm value (featureFlags.<name>) over a rawenv:entry; the override exists so a duplicate key can never be rendered, not as the normal way to set a flag. - An explicit
falseis honoured when the default is"true". Helm'sdefaulttreatsfalseas empty, so a plain| default "true"can never renderfalse. For anyenvVarsentry whosedefault: "true", the generator emitsinclude "syrf-common.boolOrDefault", which keeps an explicit bool and falls back to the default only when the value is unset. Do not hand-write| default "true"on a boolean in a chart template.
A chart value that has no env-mapping.yaml entry renders nothing. Do not add one to a service chart's
values.yaml as documentation; an application setting that is read from appsettings (for example
FeatureFlags:MaxInProgressSessions) is not configured by the chart.
Carrying a web flag to Project Management¶
The featureFlags section renders only into the API. When Project Management also reads a web flag,
declare the same variable and valuePath in the backendFeatureFlags section too (and keep the
web: block only on the featureFlags entry), as bulkPdfUpload and reviewEligibilityPolicy do. One
featureFlags.<name> Helm value then drives the browser, the API and Project Management. A flag that is
read by Project Management but declared only in featureFlags is silently off there (#4292).
Project Management does not read the API's runtime override store, so a runtime override changes the API
and the browser but never Project Management. A flag read by both services must therefore be
deployment-managed: the API pins its effective value to the deployed value, rejects a runtime change
with 400 Bad Request and shows it as deployment-managed on the Feature flags admin page. An
override saved before a flag was pinned has no effect and can still be cleared. reviewEligibilityPolicy
is deployment-managed for this reason (it keeps its pageReload activation, which now only describes how
a changed deployed value reaches an open browser). Change it in GitOps for every consuming service. To pin
another flag, add it with its constraint and refusal text to deploymentManaged in
src/charts/syrf-common/feature-definitions.yaml and regenerate (see Feature registry).
Carrying a web flag to Identity¶
Do not add identity to the featureFlags section's services: that would render every
catalogued web flag into the Identity pod. When Identity needs one web flag, add a narrow
Identity-only section that maps the same Helm value, as identityThemeFlags does for
themeToggle (account portal slice 3,
shared theme preference):
identityThemeFlags:
services: [identity]
envVars:
- name: SYRF__FeatureFlags__ThemeToggle
valuePath: featureFlags.themeToggle
default: "false"
Identity binds it on IdentityFeatureFlags at start-up and never reads the API's runtime override
store, so the Identity half is deployment-only: a runtime override on the Feature flags admin page
changes the app, not Identity. Set the value in the Identity service's environment values in
cluster-gitops as well as the web and API values.
Reading a flag in .NET¶
API and Project Management code reads a runtime flag through one interface, IFeatureDecisions
(SyRF.Mongo.Common.RuntimeFeatureFlags, spec 6R.3 slice R3c). Inject it and use the decision's value:
public sealed class ExampleController(IFeatureDecisions decisions) : ControllerBase
{
[HttpPost]
public IActionResult Start() =>
decisions.Decide(RuntimeFeatureFlagKeys.ProportionalStudyAllocation).Value ? Ok() : NotFound();
}
- Never pass a default.
Valueis always a decision you may act on. When the policy cannot decide (not yet read, stale, unavailable, no reviewed contract), it is the safe value from the catalog's consumer contract, which is false for every reviewed contract today;ReasonandErrorCodesay why. The safe value belongs to the catalog, never to the caller. - Decisions are synchronous and never throw for any reader state, so they fit route selection, authorization handlers and admission checks.
- Several keys at once:
DecideAll(keys)answers every key from one capture of each policy source, so a capability that needs all (or any) of several flags never mixes two revisions.AllEnabledandAnyEnabledanswerAllOfandAnyOf. FeatureFlagsis deployed configuration, bound at start-up. Read it for start-up or domain settings, not for a decision that a runtime override or stop must change.
Which path decides a key depends only on the host's configuration, exactly as before R3c:
| Host configuration | Keys | Decided by |
|---|---|---|
RuntimeFeatureFlags:SharedReaderMode=Membership (API) |
disableMembership |
The legacy-format shared reader (60 s maximum age) |
SharedReaderMode=StatisticsServing (PM) |
statistics serving and its three consumer selectors | The legacy-format shared reader, with this host's FeatureFlags as fallbacks while it is current |
RuntimeFeatureFlags:Mode=Shared |
keys with a consumer contract | The bound authority (contract maximum age; stops force the key off; production confines runtime policy to eligible keys) |
| Anything else | every other key | The host's compatibility values: the API's runtime provider (legacy overrides, deployment pins, AuthorityPreserved), or Project Management's FeatureFlags |
Start-up refuses Mode=Shared together with an enrolled shared reader, so no key has two paths. Production
with ProductionPolicyMode=Legacy resolves to Legacy mode. The reasons are Evaluated, Stopped,
DeploymentControlled, Cached, Legacy, AuthorityPreserved, Stale, Unavailable and Unreviewed.
PolicyVersion is an opaque string, comparable only between decisions from the same source in one process.
Do not read IRuntimeFeatureFlagProvider, SharedRuntimeFeatureFlagReader or RuntimePolicyAuthorityEvaluator
directly: the consumer inventory guard fails CI on a new direct read. In tests,
mock IFeatureDecisions (Decide returns a FeatureDecision), or build RuntimeFeatureDecisions over a real
reader or evaluator when the test is about the path. The vocabulary follows OpenFeature, but SyRF does not use
the OpenFeature SDK: it would lose the catalog-owned safe value, synchronous decisions and single-capture
multi-key reads (spec 6R.3). A later adapter could expose IFeatureDecisions as an OpenFeature provider.
Flag decision (R3c): not flagged. Parity tests in SyRF.Mongo.Common.Tests, SyRF.API.Endpoint.Tests and
SyRF.ProjectManagement.Endpoint.Tests prove the same value as the replaced call sites for every catalog key and
every reader state; a flag would keep both paths alive.
Settling a feature¶
A Released feature settles when it has been on everywhere for longer than its settle period and nobody needs
"off" any more: its flag and the "off" branches go, and the feature becomes a permanent part of the product. The
clean-up PR is titled "remove flag <key> for <feature-id>".
pnpm feature:settle <feature-id>
pnpm run validate:generated # after deleting the references it prints
feature:settle does the mechanical part:
- Removes every
env-mapping.yamldeclaration of the feature's flags (each section's entry for the sameSYRF__FeatureFlags__<Key>, and a section left empty), and anycatalogOnlyFlagsentry. - Sets the feature's registry entry to
lifecycle: Settled. Itsflagsandcapabilitystay as history. - Adds the keys to the removed flags (
removedFlagsunderfeatureFlagsinenv-mapping.yaml, generated asRuntimeFeatureFlagKeys.RemovedFlags). Only catalogued keys are evaluated, so a stored runtime override for a removed key is ignored;RuntimeFeatureFlagCatalogTestschecks that for every removed flag. A removed key is never reused: the generator andfeature:newrefuse it. - Drops the flag's
deploymentManagedpin, if any. - Regenerates, then prints every reference still to delete: the consumer-manifest reads, and a search of tracked
files (generated files excluded) for the key, its configuration property, its
select<Key>selector and its environment variable. If a generator throws, it restores every file it could have changed and reports that nothing changed.
It refuses, and writes nothing, a feature that is not Released, and a flag that a flag group still names (group IDs
are versioned contracts, so remove the group in its own reviewed change first).
In the same PR, delete the "off" branches and their tests, the FeatureFlags.cs properties and the consumer-manifest
entries, and update the catalog digest pin as for a new flag. The registry validator refuses a
Settled feature whose key is still catalogued or is not a removed flag.
Ordering safety. Settling makes "on" unconditional. Before merging, confirm that every environment serves "on"
for each flag: the deployed value in cluster-gitops and any runtime override on that environment's Feature flags
admin page. Then deploying the clean-up changes nothing anywhere. After it deploys, remove the featureFlags.<key>
values from cluster-gitops; a removed key renders nothing, so this order is safe (production values need Chris).
Non-Production Runtime Overrides¶
The deployed GitOps value remains the default and recovery fallback. In staging and preview environments, a real SyRF administrator can set or clear an override through the API:
PUT /api/runtime-feature-flags/bulkPdfUpload
Content-Type: application/json
{
"value": true,
"expectedRevision": 12,
"reason": "Exercise bulk PDF upload in preview 2943"
}
Use "value": null to clear an override and return to the deployed value. GET
/api/runtime-feature-flags returns the full authoritative snapshot, revision, deployed and
override values, and activation metadata. Mutation is disabled in production.
The Feature groups section offers bulk PDF upload, reviewer workspace and supported statistics
application-use controls, with separate configured and effective On/Off/Mixed states.
Group membership comes from the API snapshot, which serves the flagGroups of the
feature registry. Enable/Disable group writes all members and
its audit entry atomically at the current revision; additional dependencies still require
confirmation. Individual flags remain available below. Statistics calculation/maintenance,
project readiness and explicit initial builds remain in the statistics operations workflow.
A failed or timed-out response may have committed: the UI refreshes without replaying it.
Use precomputed screening statistics (statistics-consumers-v2) includes serving, pages,
Project Overview, screening history and SignalR statistics refreshes. Enabling requires
deployed serving, maintenance and screening; live refreshes also need
SignalR connectivity. Existing per-project enrollment, freshness and fallback still apply.
Disabling stops these consumers while calculation/maintenance stays on. The exports flag
has no implemented export/report consumer, so it is excluded. Stage Overview and stage
history remain individual because staging has annotation maintenance disabled. Reviewer
history remains individual because it requires membership-screening maintenance, which
is also disabled on staging. Maintenance and the seven
metric-family gates remain deployment-managed across write hosts and are not group members.
The old statistics-screening-v1 and statistics-consumers-v1 requests remain accepted
with their original members for already-open clients; new snapshots advertise only v2.
The Bulk PDF upload and processing activity preset (bulk-pdf-upload-v1) changes
bulkPdfUpload and studyManagementProcessing together. The Processing activity page
also shows other study jobs; use the individual flags to control these separately.
Deletion handling in API and Project Management, notifier cleanup authority and a ready
PDF processing pipeline remain deployment prerequisites. Missing deletion handling blocks
the whole group change. Without notifier authority, a saved upload override remains
effectively off in the browser. Follow the
notifier authority activation runbook
before enabling uploads. Disabling this preset hides upload and Processing entry points;
it does not cancel existing jobs or disable cleanup authority. The notifier cleanup authority
(bulkPdfNotifierAuthority.enabled, deployment-managed) follows the same lifecycle rule: off
pauses clean-up and refuses deletes with nothing lost, and switching it back on is checked
against the recorded receipt's binding to its environment root and cleanup authority
(ADR-017 amendment 2026-10-17).
Default is the environment's deployed baseline, a saved change (override) replaces it, and the pill shows the effective value after dependency normalization and domain constraints. These overrides are environment-wide, not project enrollment or permission grants. Statistics maintenance and family gates are shown as deployment-managed instead of offering working-looking runtime buttons. An old override can still be cleared to the deployed default. Global statistics serving cannot be enabled unless deployed serving is already enabled. Use the statistics pilot administration workflow for project readiness, builds and consumer presets.
If a change needs other runtime-manageable flags, the first PUT returns 422
Unprocessable Entity with code: "featureFlagDependenciesRequired", the dependency set, and
canApplyAutomatically: true. After showing that set to the administrator, repeat the PUT with
the exact "confirmedDependencies" key/value set and the latest snapshot revision. Keep the legacy
"includeDependencies" field false: an older API replica will then reject the retry instead of
applying dependencies it cannot bind to that exact plan. The current server rejects the retry if
another API generation resolves a different dependency plan, rather than applying changes the
administrator did not review. It then applies the requested flag and every confirmed dependent
change atomically in one revision and audit entry.
The snapshot's effective values are authoritative even when a flag has no override. Loaded flags with unsatisfied prerequisites are normalized Off in both API and browser. If a later mutation would reactivate another dormant configured flag without confirming it, that flag is pinned Off by an audited override and must be enabled explicitly in a separate reviewed change.
Deployment-managed dependencies are different: their 422 response has
canApplyAutomatically: false and cannot be bypassed with includeDependencies. For example,
bulkPdfUpload requires DeletionLifecycle=true in both API and Project Management. Roll out and
verify that shared GitOps value across every consuming service before enabling the runtime override.
During a rolling deployment, wait until all relevant workloads are healthy on the new generation;
the API's local readiness check is deliberately only a fail-closed final guard, not proof that a
different service has completed its rollout.
Each change atomically updates the normal current-state override document and appends a separate audit entry. Authorization always checks the real signed-in user's administrator group, even while that administrator is impersonating someone else. The audit entry records both the real and impersonated identities plus the required reason.
After a successful mutation, the API broadcasts only the new revision over SignalR. Browsers then fetch a complete snapshot; they also poll every five minutes as a slow fallback. The API refreshes its cached snapshot periodically so backend gates converge across pods.
There is no SignalR backplane, so a broadcast reaches only the connections of the pod that sends it. In
Legacy mode only the writing pod broadcasts, after a legacy override change. In
RuntimeFeatureFlags:Mode=Shared (6B.4 G1) every API pod hints its own connections when its authority
reader observes a new policy identity (RuntimePolicyBrowserHint, called from
RuntimePolicyAuthorityRefreshService after each 8–12 s refresh). That is the only hint path. The writing pod
does not hint at commit time, because its browsers' refetch is served from this pod's reader, which would
still hold the previous revision. Instead a successful commit, emergency stop or ReleaseStop restore wakes
that pod's own refresh (IRuntimePolicyRefreshNudge), and the hint follows as soon as the reader has the new
identity. If that early read still sees the old identity, nothing is marked as hinted and the next regular pass
hints. Nudges coalesce (several during one pass cause exactly one more pass), the refresh never runs twice at
once, and after a nudged pass the next wait is a full 8–12 s delay. The message is the existing
RuntimeFeatureFlagsRevision invalidation; browsers refetch the snapshot and never use the hinted value. A pod
hints each store epoch and revision at most once and never for a successful start-up read (if the start-up
read fails, the first identity it reads later is hinted); a new store epoch after a re-initialisation or rebind
is hinted even when its revision is lower. A hint failure, or a hub that does not answer within 2 s, is
logged and changes nothing else: the refresh and acknowledgement carry on, and the commit receipt is never
affected (the nudge neither blocks nor throws). Tabs without a live hub connection still rely on focus,
online, reconnect and the five-minute poll.
Flag decision: not separately flagged. The hint runs only under the existing Mode=Shared switch, which no
shared environment runs, fires once per observed revision per pod and writes nothing, so switching Shared mode
off and on again is safe.
bulkPdfUpload is applied to both the Angular gate and the API endpoint gate from that same snapshot.
Runtime-manageable Boolean flags use the same endpoint. Most changes
apply immediately after the next snapshot refresh. The snapshot marks integratedPdfViewer,
signalRActive, zonelessChangeDetection, errorTracking and logRocketEnabled with
activationRequirement: "pageReload" because their viewer, transport, change-detection, diagnostic,
telemetry or session-recording lifecycles are bound during page initialization. Their overrides are
persisted immediately and become active when the browser is reloaded; the initial snapshot is applied
before those lifecycle services start. reviewEligibilityPolicy is also marked pageReload (review
configuration screens read the eligibility mode when they load) but it is not runtime-manageable: it is
deployment-managed because Project Management reads it too (see
Carrying a web flag to Project Management). It keeps its
ReloadRequired(...) catalog entry because a changed deployed value still reaches an open browser only on
reload. The authoritative list is ReloadRequiredFlags in
src/services/api/SyRF.API.Endpoint.Tests/RuntimeFeatureFlagCatalogTests.cs, which pins every
ReloadRequired(...) entry in the catalog. GitOps remains the deployed default and production source of truth.
See the administration delivery slices for current propagation boundaries, proposed grouped controls and retirement review criteria.
This MVP deliberately does not include percentage or audience targeting, typed non-Boolean configuration, event sourcing, projections, replay, or a richer audit-history UI.
Production runtime policy, release records, targeting and announcements are planned, not implemented: see the runtime feature releases plan and ADR-025 (Proposed). This guide describes current behaviour only.
Using the Feature flags admin page¶
The page at /admin/feature-flags (redesigned in #4385) is a summary card above one list of flags.
Summary card. One line says whether the servers have the current settings: All servers up to
date (only when the server reports the fleet Active), Updating: 2 of 4 servers have the latest
settings while hosts adopt it, Only 2 of 4 servers have the latest settings after the one-minute
confirmation window, or Server status unavailable. A saved policy is not adoption, so a save never
reads "up to date" until the fleet does. The line re-checks every 10 seconds while servers are updating,
for up to two minutes. Legacy mode says server status is not tracked; a read-only environment says so
here once instead of on every row. In Shared mode a second line gives the emergency-stop state of the
membership control (Membership disable: stopped, not stopped, updating… or stop state
unknown), read from authority.stoppedKeys. Emergency stop… or Restore… opens a dialog (both
appear when the state is unknown or updating). Details is collapsed by default. It holds the mode,
policy revision, the last saved revision while the page catches up, the stop-state explanation, the
fleet host table (each host state in words, such as Up to date or Catching up), flag delivery status
and the explanatory notes. Its diagnostics load only when it is opened. A revision the page has saved
stays the floor until a snapshot reaches it, even if its receipt is dismissed or replaced, so a lagging
snapshot never reads "not stopped" straight after a stop. Policy identities compare GUIDs and digests
case-insensitively, as the snapshot guard does.
List. Search matches the key, name, description and category. Show filters to All, Changed from default (a saved override) or Can't change here. Each row has the name, a one-line description, an On/Off pill (marked changed, stopped or updating only when it is; a row at its default shows just the value), and a Change menu (Turn on, Turn off, Use default (On/Off); the current choice is disabled). Selecting the name expands the row to show the full description, key, default, saved change, reload requirement and dependencies. A deployment-managed flag shows a lock and its reason instead of a menu. In Legacy mode its menu offers only Use default, to clear an old override. Feature groups is a collapsed section below the list; each group has Turn all on and Turn all off in Legacy mode and shows why it is locked otherwise.
Change dialog (Shared). The dialog opens straight away and asks the server for a preview. It then states the effect in one sentence ("Off → On for everyone in this environment"), lists any other flags the preview changes, and offers an optional comment. The preview digest covers the audit reason, so editing the comment requests a fresh preview half a second after typing stops; the primary button is enabled only while the preview on screen matches the comment. Confirming commits exactly that previewed request and digest. Cancel, Escape or closing the dialog commits nothing. A dependency refusal, at preview or at commit, shows the needed flags in the same dialog; Review combined change previews again with the confirmed set and still needs confirmation. A revision conflict closes the dialog and reloads current values. When saved, the dialog closes, a short message says "Servers update within about a minute", and the row shows the new value marked updating until the API's reader catches up (the page re-reads quietly every 3 seconds, up to five times).
Outcome unknown. If the connection drops before the server answers, the dialog says it is not sure the change was saved and offers Check outcome and Retry unchanged request (the same mutation ID, so it cannot apply twice). Nothing is committed automatically. If the dialog is closed, a banner keeps both actions and the mutation ID on the page, and other changes stay blocked until the outcome is known. The stop has the same banner with Retry unchanged stop.
Emergency stop and restore. The stop dialog says what the stop does and needs a reason. Stop re-reads the current policy and then sends the stop, so the dialog is the confirmation. Restore previews the release on the server, shows the value the control goes back to, and needs a reason before it can be confirmed; it is blocked while a stop request is unresolved.
Legacy mode uses the same list and a simple confirmation dialog with an optional comment, because the
legacy PUT has no preview. Dependencies the change needs are listed in that dialog. A revision conflict
reloads current values and requires a fresh action rather than silently overwriting another
administrator.
Schema Properties Reference¶
| Property | Required | Description |
|---|---|---|
name |
Yes | Environment variable name (SYRF__FeatureFlags__*) |
valuePath |
Yes | Path in Helm values.yaml (e.g., featureFlags.myNewFeature) |
default |
Yes | Default value as string ("true" or "false") |
web.tsName |
Yes | camelCase name used in TypeScript (e.g., myNewFeature) |
web.tsType |
Yes | TypeScript type (always boolean for feature flags) |
featureFlag |
Yes | Must be true to mark as feature flag |
displayName |
Yes | Sentence-case label shown on the admin runtime feature flags page (generation fails without it) |
description |
Yes | Human-readable description (used in JSDoc comments) |
category |
Yes | Grouping for organization (see categories below) |
adminOnly |
No | If true, flag requires admin UI to be visible |
Categories¶
Use existing categories for consistency:
| Category | Purpose |
|---|---|
projectSettings |
Project configuration features |
uiFeatures |
General UI features |
robFeatures |
Risk of Bias related features |
dataFeatures |
Data import/export features |
development |
Development/debugging features |
monitoring |
Observability features (Sentry, LogRocket) |
Admin-Only Flags¶
Flags marked adminOnly: true are automatically gated by the admin UI state:
- name: SYRF__FeatureFlags__ExampleAdminAction
valuePath: featureFlags.exampleAdminAction
default: "false"
web:
tsName: exampleAdminAction
tsType: boolean
featureFlag: true
category: robFeatures
description: "Example admin action (illustrative, not a deployed flag)"
adminOnly: true
Generated selector automatically combines with admin state:
export const selectExampleAdminAction = createSelector(
selectConfigState,
selectShowAdminUiOptions,
(configState: AppConfig, showAdminUi: boolean): boolean =>
configState.exampleAdminAction && showAdminUi,
);
Generator Commands¶
cd src/services/web
# Generate all files
pnpm run generate:flags
# Validate schema without writing (CI/pre-commit friendly)
pnpm run validate:flags
# Preview changes without writing files
pnpm run generate:flags -- --dry-run
# Show verbose output including Helm env vars
pnpm run generate:flags -- --verbose
Pre-commit Hooks¶
Two pre-commit hooks validate generated code when relevant files change:
1. validate-env-blocks (syrf-common)
Triggered by changes to:
src/charts/syrf-common/env-mapping.yamlsrc/charts/syrf-common/templates/_env-blocks.tpl
2. validate-feature-flags (web)
Triggered by changes to:
src/charts/syrf-common/env-mapping.yamlsrc/services/web/src/app/generated/feature-flags.*.tssrc/services/web/src/assets/data/env.template.generated.jsonsrc/services/web/.chart/values.yaml
If validation fails (files are stale), the commit is blocked until you run the appropriate generator.
CI Validation¶
The validate-generated-code job in pr-tests.yml runs both validators:
- Detects if schema or generated files changed
- Runs
pnpm run validate:env-blocksin syrf-common - Runs
pnpm run validate:flagsin web - Reports status in PR test summary
Why validate-only (not auto-generate)? Generated TypeScript could break the build. Developers should run the generator, review changes, test the build passes, then commit.
Note: The web chart's deployment.yaml env vars are generated by syrf-common/scripts/generate-env-blocks.ts, not the web generator. The web chart uses shared templates via {{ include "syrf-common.env.webConfig" . }} and {{ include "syrf-common.env.featureFlags" . }}.
The generators are idempotent - running them multiple times produces identical output. This ensures consistent validation in CI and pre-commit hooks.
How It Works at Runtime¶
- Build time: Helm templates reference
values.yamlpaths - Container startup: NGINX envsubst replaces
${SPA__*}placeholders inenv.template.json(the image build copiesenv.template.generated.jsonin under that name) - App bootstrap:
main.tsfetches config JSON files - Config parsing:
parseFeatureFlags()converts string values to booleans - State management: NgRx store holds parsed config
- Component access: Selectors provide typed access to flags
Helm values.yaml → deployment.yaml env vars
↓
Docker container
↓
NGINX envsubst → appConfig.env.json
↓
main.ts fetch()
↓
parseFeatureFlags() → AppConfig
↓
NgRx Store (config state)
↓
selectMyFeature selector
↓
Component signal/observable
Architecture: AppConfigService Integration¶
The AppConfigService cleanly separates generated code (feature flags) from manual code (derived values):
// app-config.service.ts
// AppConfig = FeatureFlags (generated) + AppSettings (manual)
export interface AppConfig extends FeatureFlags, AppSettings {}
// ExternalConfig = FeatureFlagsExternalConfig (generated) + ExternalConfigSettings (manual)
export interface ExternalConfig extends FeatureFlagsExternalConfig, ExternalConfigSettings {}
static loadConfig(externalConfig: ExternalConfig): void {
// Generated: parses all feature flags using schema defaults
const featureFlags = parseFeatureFlags(externalConfig);
// Manual: complex derived values (URLs, version strings, etc.)
const appSettings = buildAppSettings(externalConfig);
this.staticConfig = { ...featureFlags, ...appSettings };
}
Why Some Values Aren't Generated¶
Non-feature-flag configuration (AppSettings) requires manual logic that can't be generated:
| Property | Reason |
|---|---|
displayVersion |
Environment-specific formatting (30+ lines of logic) |
auth0Domain |
Computed from {tenant}.{region}.auth0.com |
apiUrl |
Derived from apiOrigin + '/api' |
protectedUrls |
String split + array append logic |
randomId |
Runtime-generated browser session ID |
File Structure¶
src/app/
├── generated/ # AUTO-GENERATED (don't edit!)
│ ├── feature-flags.types.ts # Interfaces only (no JS output)
│ ├── feature-flags.constants.ts # FEATURE_FLAG_DEFAULTS, env vars, helm paths
│ ├── feature-flags.selectors.ts # NgRx selectors
│ └── feature-flags.parser.ts # parseFeatureFlags()
│
└── core/services/config/
├── app-config.service.ts # Main service (uses generated code)
└── app-settings.interface.ts # Non-feature-flag types (manual)
Note: types.ts contains only TypeScript interfaces (erased at compile time). All runtime values (as const) are in constants.ts for clear separation.
Troubleshooting¶
Generated files out of sync¶
Validation fails in CI¶
The validate-generated-code job reports which files are stale:
This checks if values.yaml and the TypeScript files match the schema.
Pre-commit hook fails¶
# Run the generator to update files
pnpm run generate:flags
# Check what changed
git diff
# If TypeScript changed, test the build
pnpm run build
# Then stage the generated files
git add src/app/generated/ .chart/values.yaml
Flag not working in deployment¶
- Check Helm values in cluster-gitops
- Verify deployment has correct env var:
kubectl exec ... -- env | grep SYRF__FeatureFlags - Check browser dev tools → Network → fetch for config JSON
- Verify flag value in parsed config (browser console)
TypeScript build fails after generation¶
Generated code might reference types that don't exist yet. Check:
- Is
selectConfigStateexported from the config selectors? - Is
selectShowAdminUiOptionsavailable for admin-only flags? - Are NgRx imports correct in the generated selectors file?
Related Documentation¶
- Environment Variable Code Generation - The broader env-mapping system
- Environment Variables Reference - Complete env var listing
- How-To: Extend Env-Mapping Schema - For non-feature-flag config
- ADR-023: Pre-deploy database migrations - Migrations and flags are separate (amendment 2026-10-17)
- Operate runtime feature flags - Operator runbooks: binding, lineage import, enrolment, collector, coordinator, inventory, change, stop, restore, recovery and Legacy return (written, not executed)
- Staging rehearsal plan and production prerequisites - Not executed
- Isolated rehearsal checklist - How each runbook step is exercised in the local stack
Shared reader: first API/PM migration slice¶
The shared reader is an opt-in non-production migration seam. Its independent deployment setting is
RuntimeFeatureFlags:SharedReaderMode (SYRF__RuntimeFeatureFlags__SharedReaderMode in the host environment).
Configure it through the existing environment/GitOps configuration; it is deliberately not a runtime flag
that could disable its own provider. The default is Disabled, preserving existing behavior.
| Mode | API | Project Management |
|---|---|---|
Disabled |
Existing runtime provider and behavior | Existing startup/domain behavior |
Shadow |
Observe persisted global overrides without changing consumers | Observe the same persisted overrides without changing domain behavior |
Membership |
Use the shared snapshot for the existing disableMembership action-selector gate |
Continue shadow observation; no membership or reviewer domain control is changed |
StatisticsServing |
Invalid for this host | Use the shared materializedProjectStatisticsServing value as an additional stop for precomputed statistics serving |
StatisticsServing also uses shared overrides for the Pages, Stage Overview, SignalR and Exports
consumer selectors. These are reversible consumer requests, not evidence that any project is Fresh
or allowlisted. disableMembership gates the auditable disable endpoint; re-enabling an already-disabled member stays
available with the flag off. It does not remove membership authorization or rewrite persisted membership state. Each route decision captures one immutable
observation. Already admitted requests complete under the existing application protocol. All other API
consumers continue using their existing provider. In StatisticsServing mode PM keeps writes, family
gates, project allowlists and reviewer-capacity controls deployment-owned. Shared serving and consumer
values can stop precomputed reads, but cannot enable them when deployment serving or writes are off. The durable
statistics control and freshness checks still apply. An unavailable or stale observation stops new
materialized reads in PM; it does not stop maintenance or undo an admitted operation. This makes the
existing Use precomputed statistics admin switch meaningful in both API and PM on an enrolled
non-production deployment. Calculate and maintain statistics is still a separate, deployment-bound
control; its coordinated runtime transition remains follow-up work.
Both hosts reconcile the existing global Mongo override document every 15 seconds with majority reads and
a five-second read deadline. There are no change streams in this first slice. Complete frozen snapshots
replace previous snapshots, so resetting a flag removes the old override. Missing/malformed documents,
read failure, revision regression, conflicting content at an equal revision and observations older than
60 seconds produce an unknown/unavailable decision. The enrolled API route fails closed in those states;
the last observation remains available for diagnosis. A successful later reconciliation recovers. Cold start
with no persisted state does not synthesize an authoritative revision zero: first initialize the legacy
state through an ordinary reviewed flag edit while in Shadow mode, then compare before selecting Membership.
The admin flag page exposes GET /api/runtime-feature-flags/provider-status to real administrators only.
It distinguishes the independently fetched saved revision from the local observed revision, mode, state,
observation time and nullable evaluated value. Current means a recent successful local observation, not
fleet convergence. PM also exposes a limited read-only GET /api/runtime-feature-flags/reader-status
containing only local mode, observation state/revision/time and the effective statistics-serving decision;
the same Boolean policy is already public in the API snapshot. The admin page displays that result
separately from the API result. PM retains the runtime-feature-flag-reader diagnostic health entry.
Neither endpoint is a fleet acknowledgment. There is no fleet registry, and the UI explicitly reports
fleet state as unknown.
The legacy-global-overrides digest covers only sorted override content. It excludes deployed defaults,
catalog metadata and environment identity. Matching digests do not establish equal effective values
when hosts have different defaults. PM health includes its selected consumer's deployed default and
evaluated value. This is not the approved-baseline/epoch authority described by the broader architecture;
a document carrying the future storeEpoch field is rejected by this legacy reader until its migration
is implemented. Production and unknown/conflicting deployment identities reject opt-in modes at startup.
Before rollback to Disabled, compare the saved revision, the legacy provider's observed value and the
shared reader's value for disableMembership; wait for legacy convergence or write a reviewed forward
revision first. Do not use rollback to bypass an unavailable authority or restore a stopped capability.
For PM StatisticsServing, first make the deployment fallback agree with the reviewed runtime serving
decision. Otherwise reverting the reader to Disabled can reopen precomputed serving despite an Off
override. No deployment setting or runtime policy is changed merely by merging this implementation.
Consumer inventory guard¶
pnpm run validate:generated also checks the C# typed consumer inventory in
consumer-manifest.json. New or changed common
flag seams and reads through typed/inferred aliases require a reviewed classification, owner, scope and
activation boundary. Changes to _flags.SomeGate in an already classified file are detected. This guard
is conservative static evidence, not proof of every dynamic configuration lookup or external worker;
those remain explicit audit work before migration. Update the manifest after reviewing a change, never
regenerate it in CI to accept a new read automatically. In PR Tests, the Validate Generated Code lane runs for any change to
the manifest, the validate:generated scripts or a .cs file under src/libs/ or src/services/, so a
changed flag read cannot merge unclassified.
Since R3c the guard also enforces reading flags through IFeatureDecisions. A
production file that names IRuntimeFeatureFlagProvider, SharedRuntimeFeatureFlagReader or
RuntimePolicyAuthorityEvaluator in code, or an existing-runtime consumer that reads FeatureFlags, fails
unless the manifest's legacySeamAllowList names it with a kind and a reason. The kinds are implementation
(the decision paths and their registration), control-plane (diagnostics and reports),
deployed-configuration (a start-up value read beside runtime decisions) and pending-migration. An entry that
no longer needs to be there fails as stale, so the list only shrinks. The pending migrations are the five
domain callers of IRuntimeFeatureFlagProvider.IsEnabled (ReviewController, StageAllocationReadController,
StageWorkloadSharesController, AnnotationFoldTargets, SubmitAnnotationSessionService) and the four
statistics history controllers. The history controllers read FeatureFlags as the runtime provider mutates it:
moving them to IFeatureDecisions would decide materializedProjectStatisticsPages from the bounded authority
in Shared mode, which changes their staleness behaviour, so it needs its own review.
The legacy store never interprets or overwrites a document containing storeEpoch, including a malformed
epoch (owner decision D7=A, spec 6C.5). A host in Legacy mode (or production with
ProductionPolicyMode=Legacy) that finds one starts normally and serves the deployment-controlled values
for every key: the authority's saved overrides and stops are preserved but not applied. From that moment the
process is a read-only legacy reader for its lifetime: snapshots carry policyState:
DeploymentControlledAuthorityPreserved and mutationAllowed: false, legacy changes answer 409
runtime_policy_authority_preserved without writing, provider status says the runtime policy is preserved but
not applied, authority-status reports reason LegacyModeAuthorityPreserved, and the process marks its own
syrf-api writer-registry entry runtimePolicyReadOnly so it does not block a later lineage freeze. The
legacy-format shared reader (API membership, PM statistics) likewise serves deployed defaults in state
AuthorityPreserved. Returning the hosts to Shared resumes the unchanged authority at the same identity
(runbook 10 of Operate runtime feature flags). Older
images that predate this behaviour still refuse to start against an imported authority, and their registry
entries are always counted by the freeze. Stop/freeze older writers before lineage import.
Runtime-policy reader/writer boundary guard¶
Runtime-policy code has two sides. The reader side (snapshots, evaluation, catalog, host acknowledgements and the
host's own writer-registry entry) lives in SyRF.Mongo.Common/RuntimeFeatureFlags and RuntimeFeatureReleases;
every service ships it, and PM publishes its own heartbeat. The writer, administration and release side (the
mutation store, flag store, lineage import and its entry point, release intent, the environment-report and workload
stores, the writer freeze) lives in the API-only project src/services/api/SyRF.API.RuntimeAdministration, which
only SyRF.API.Endpoint references in production. Its types keep their SyRF.Mongo.Common.* namespaces. Logic both
sides need (receipt validation and digests, effective values, deployment-constraint and observation validation) is
reader-side, and the module reaches the reader's internals through InternalsVisibleTo.
RuntimePolicyBoundaryArchitectureTests in SyRF.ProjectManagement.Endpoint.Tests fails if:
- PM's assembly closure contains the module, or any type the module declares;
- a project other than those in its
ModuleReferencerslist references the module (the API, plus two test projects that use the writers for setup); - a PM production source, or a reader-side type, names a writer-side type (every type the module declares). The
ReaderReachInsexception list is empty; - a reader-side type writes MongoDB documents, indexes or collections (host acknowledgements excepted).
The source scans ignore comments but not string literals, and assume file-scoped namespaces (a test checks that).
To read a failure, look at the named consumer, project or assembly. Route the consumer through a reader-side type,
moving shared logic into a reader-side file, or move writer code into the module. A new test project that needs the
writers goes into ModuleReferencers with its reason. Design: spec 6R.3 (reader/writer module split).
Reviewed runtime-policy lineage import¶
The step-by-step operator procedure for this command, with preflight checks and refusals, is runbook 2 of Operate runtime feature flags.
PR #4347 provides an explicit operator command in the API image for the Increment B legacy-lineage transition. It is separate from schema migrations and normal startup. It does not activate runtime modes, enroll consumers, establish fleet readiness, or authorize a production operation. Coordinate its use with an authority-capable reader/writer rollout; the legacy store and shared legacy reader refuse epoch-bearing state. Do not use it as a standalone live-environment rollout.
The preview requires an existing legacy policy, a verified environment binding, and a reviewed baseline JSON file with these fields:
values: a Boolean value for every key in the generated shared runtime catalog; unknown keys and missing keys are rejected. Reconcile all consuming services' rendered/deployed inputs before approving this artifact. The command never chooses the API's current settings as the environment baseline.approval: the reviewed baseline decision/reference.provenance: the source configuration/report identities used to reconcile it. These are operator review evidence, not an assertion that a compatibility collector or fleet acknowledgement already exists.
Use the existing Mongo configuration and RuntimeEnvironment, plus the binding expectations used by the
migration tooling: SYRF_MIGRATION_BINDING_EXPECTED_ENVIRONMENT, ..._DATABASE, and ..._CLUSTER.
The expected cluster must come from a verified binding/status result. Never substitute a guessed target.
To produce a read-only preview, run the API image's normal entry point with:
SYRF_RUNTIME_POLICY_MODE=preview
SYRF_RUNTIME_POLICY_INPUT=/reviewed/baseline.json
SYRF_RUNTIME_POLICY_OUTPUT=/reviewed/lineage-preview.json
The output contains the source revision/digests and audit references, accepted baseline and target identity, new store epoch, mutation ID and preview digest. Review and retain this exact artifact. It uses Canonical Extended JSON to preserve BSON integer and identifier types. Editing or reformatting through a tool that changes BSON types invalidates it; generate and review a new preview instead.
Before apply, freeze deployments/autoscaling and stop all legacy policy writers. Hold that operational
freeze throughout the command. The command also requires an existing, well-formed syrf-api registry with
zero registered instances; missing registry data and stale/live registrations refuse. There is no bypass
switch. This registry check is evidence of stopped instances, not a Kubernetes deployment lock.
Apply the exact reviewed preview with:
SYRF_RUNTIME_POLICY_MODE=apply
SYRF_RUNTIME_POLICY_INPUT=/reviewed/lineage-preview.json
SYRF_RUNTIME_POLICY_OUTPUT=/reviewed/lineage-receipt.json
SYRF_RUNTIME_POLICY_ACTOR=<operator identity>
SYRF_RUNTIME_POLICY_WRITER_FREEZE_ACK=<reviewed maintenance/freeze reference>
Apply requires migration-owned audit indexes 0009 and 0010 with their expected definitions; it never
creates indexes. It verifies the binding and source again, preserves the numeric revision and exact
overrides, and commits the authority document and receipt together with snapshot reads and majority write
concern. The receipt references the old audit rows; those rows remain untouched. Missing state, a changed
source, an unknown legacy field or another lineage refuses rather than silently initializing/resetting.
Input and output paths must differ, and output must not already exist. If apply loses its response or cannot write its output artifact, retry the identical reviewed preview with a new output path. The persisted mutation receipt resolves the original result without another policy write. A different preview using the same mutation ID is refused. A receipt proves that import committed, not that any consumer has accepted the lineage or that the fleet is ready. Restore/rebind and later baseline changes require their own reviewed transitions; this command never rotates an existing lineage.
First-time initialisation (no legacy document)¶
Where no global flag document exists (production: legacy runtime mutation is disabled there, so there is
nothing to import), the same command takes SYRF_RUNTIME_POLICY_SOURCE=initialize (owner decision D8=A,
slice 6C.6). Unset or import, it is the import above, unchanged. Initialise creates revision 0 from the
deployed values: the FeatureFlags configuration the command itself renders, for every catalog key, with
no overrides and no stops, bound to the verified binding. Its input carries only approval and provenance.
The preview is deterministic (the store epoch and mutation ID are derived from the reviewed content), and apply
needs the reviewed digest, a new receipt path, the same deployed values and migration catalogue as the preview,
the writer freeze, and the receipt kind deployedValueInitialization. It refuses any existing global document
(legacy or authority), an unverified binding, registered writers, and migrations that are not Ready (the API
image's project-management catalogue, checked as SYRF_MIGRATION_MODE=verify does). An exact replay returns the
original receipt. Readers accept the new receipt kind only for revision 0 with no overrides and no stops. Steps,
expected output and every refusal: runbook 2, Initialise.
Read-only authority observation¶
PR #4348 adds an optional observer for the imported authority in API and Project Management. The default
RuntimeFeatureFlags:Mode=Legacy registers no refresh worker and makes no authority-source queries.
Shadow is read-only and requires matching non-production identities plus all three pinned application
configuration values: RuntimeFeatureFlags:Binding:Environment, ...:Database, and ...:Cluster.
Use the verified binding/status evidence for these values. These are deployment/application settings,
not live feature-flag controls; no Helm value is introduced by this slice. #4352 adds the explicit
non-production Shared consumer mode described below; production Shared mode remains refused.
Each process reconciles every 15 seconds with a five-second read deadline. It reads the current envelope, binding and current mutation receipt coherently, validates the receipt and all digests, then atomically publishes an immutable snapshot. Legacy or missing state is never Ready. After initialization, a missing/legacy document, changed environment/binding/store epoch, revision rollback or equal-revision/different identity quarantines that reader instance. There is no automatic rebind or quarantine reset; complete the reviewed recovery procedure before restarting. Ordinary read failure immediately removes decision availability. Snapshots expire after 60 seconds measured with a monotonic clock, so moving wall time backwards cannot extend trust. An operation can retain one immutable valid capture while later operations see the refreshed observation.
GET /api/runtime-feature-flags/authority-status is authenticated and requires the current
InspectRuntimeFeaturePolicy decision (legacy claims only in the existing Off/Shadow
authorization modes). It returns local-process state, identity, timestamps and reason, with revisions as
decimal strings. It returns no policy values, targeting lists or fleet-readiness claim, and does not query
authority on demand. A valid local snapshot does not mean every expected host has acknowledged it.
PR #4349 extends receipt validation to ordinary global authority mutations as well as lineage import. Existing feature consumers remain on their existing paths; this observer does not enroll them or make a live lineage import operationally safe by itself. #4352 adds local Shared admission and #4358 adds host acknowledgements; scoped compatibility reporting remains subsequent work.
Idempotent authority mutation primitive¶
PR #4349 adds RuntimePolicyMutationStore in SyRF.Mongo.Common. It is not registered as a replacement
for the legacy API store and exposes no additional public mutation endpoint. API wiring must preserve
current permission checks and introduce the reviewed Shared-mode admission protocol; constructing this
library service does not enroll consumers or enable production policy.
A request carries a mutation ID, complete expected policy identity, flag change and real/impersonated actor. The store requires a verified non-production binding, the existing migration-owned audit indexes, and a receipt-validated initialized authority. It reuses dependency planning and deployment compatibility checks, preserves the accepted baseline and unrelated overrides, and atomically commits the new envelope and immutable receipt with snapshot reads and majority writes. The deployment constraint guard is preserved and incremented. PR #4360 interprets receipt-verified schema-v1 deployment constraints: compatible changes proceed, while saved enabled keys outside the capability intersection refuse. Unknown or inconsistent constraints still refuse ordinary changes. Emergency stops remain independent of constraint evidence.
Concurrent editors at one identity produce one commit and one revision conflict. Retrying the identical
mutation ID returns its original receipt, even after subsequent changes; reusing that ID with different
payload or actor refuses. The receipt retains the complete original request and resulting envelope.
FindReceiptAsync resolves the outcome without another write. Unknown commit outcomes are not converted
into a success or a known-aborted conflict: retry the same request to resolve them. The read-only observer
accepts both import and ordinary mutation receipts, and rejects inconsistent receipt data. Receipt success
proves persistence only; fleet acceptance remains a separate coordinator responsibility.
Deployment constraint transactions¶
PR #4360 adds the bound ChangeDeploymentConstraintAsync shared-store primitive. A trusted coordinator
can record one active rollout, extend its expiry or release it with an exact reviewed constraint digest.
Every operation requires the complete current policy identity, a mutation ID, an unimpersonated actor and
reason. Record validates the serving catalogs, target catalog and global schema-v1 enabled-key capability
intersection against saved policy. A stopped feature retains its saved capability requirement.
Constraint operations increment the same constraintGuard as policy edits inside a snapshot/majority
transaction. They leave policy revision, baseline, values and policy digest unchanged. This invalidates old
previews without falsely indicating a policy change to hosts. An expired constraint keeps blocking
incompatible changes; expiry is never a release. Ordinary extend/release requires the original coordinator.
Break-glass is a distinct audited action whose caller must enforce its separate administrator permission.
Receipts make identical retries idempotent, including a retry of record after release, and detect corrupted
active constraint evidence. Emergency stop continues even when constraint evidence cannot be interpreted.
This is a transaction-layer increment, not a deployment endpoint. It inherits explicit non-production admission; Legacy remains default. No new collection, index, startup write, migration or environment setting is introduced. The next transport increment must authenticate environment-bound machine scopes, construct capability intersections from trusted reports and require collector evidence of rollout/rollback completion before normal release. The administrator break-glass capability, expired-constraint status in the fleet UI, and report/coordinator endpoints are still required. Never expose this primitive directly to policy editors.
Scoped compatibility reports¶
PR #4361 adds GET /api/environment/compatibility-reports/current, protected by the dedicated
environment:report:read machine scope. Reporting defaults off. An enabled API requires
RuntimeFeatureFlags:CompatibilityReport:Enabled=true, an explicit Provider (OpenIddict or Auth0),
ClientId and Audience in that same section, plus the existing exact RuntimeFeatureFlags:Binding
expectations. The report audience must match that provider's configured API validation audience, and the
runtime/active environment must agree with the binding. This does not select Shared policy mode: Legacy
can report that no accepted bound baseline exists without inventing one.
For staging Identity, the dedicated client is syrf-environment-report. Its only grant is client credentials
and its only scope is environment:report:read; configure EnvironmentReport:ClientSecret through the
normal secret owner to enable its registration. Missing, placeholder or weak secrets leave it disabled
and revoke managed registrations holding that scope, using the same lifecycle as the statistics evidence
client. No secret or environment value is supplied by this PR. Production Auth0 provisioning remains a
separate approved tenant change; the API accepts only the configured client-credentials subject, client ID,
audience and scope through the existing validated bearer handler. User/BFF principals, mixed scopes and
other clients cannot read a report. A report token cannot authenticate on ordinary API routes, even if it
also carries an end-user scope. Report permission grants no coordinator or policy-write permission.
Collection uses a snapshot transaction and verifies the current environment binding and policy receipt.
It projects only policy identity, required global enabled keys, stops, accepted Boolean baseline and host
self-observations. It excludes actor records, credentials, database names and targeting identities.
Host self-observations never establish trusted workload inventory or readiness. A stable report is
Complete only for a stated purpose whose required sources are all Current; see purposes and completeness.
PRs #4362–#4365 supply constraint, workload, desired-state and migration evidence; #4366
revalidates the sources and returns Unstable if they keep changing. Never treat an HTTP 200 or the presence of a policy as deployment clearance.
The GET retains an immutable sanitized report in runtimeEnvironmentReport, so it is marked as a side
effect for support impersonation. It requires existing migration-owned indexes 0019 and 0020; it never
creates them. Reports expire for checks 15 minutes after collection and become TTL-eligible after 30 days
(OD-20); top-level retention timestamps are BSON dates. Responses are private/no-store. Missing bindings,
indexes, inconsistent authority or unavailable evidence return a bounded 503 without internal details.
Turning reporting off denies collection and preserves previously retained reports until normal retention.
The endpoint does not mutate policy, enroll consumers, activate a feature or promote a deployment.
Report purposes and completeness¶
PR #4405 (slice 6A.0) lets a report earn Complete. The caller states what the report is for with the
optional query purpose:
purpose |
Use | Extra required sources |
|---|---|---|
policy-change |
Before a runtime policy edit | none |
promotion |
Before promoting a candidate build | gitops (desired provenance) |
legacy-return |
Before switching Runtime back to Legacy | none; legacyParity must be present |
| absent | Diagnosis only | the report is never Complete (purpose and requiredSources are null) |
Any other value, an empty value or a repeated purpose returns 400 ProblemDetails with code
environment_report_purpose_invalid before anything is collected. Values are case-sensitive.
Every purpose requires policy, baseline, deploymentConstraint, hostAcknowledgements, workloads
(the aggregate of workloads:api and workloads:project-management) and migrations. When the bound policy
touches a statistics key, domain:statistics and domain:statistics-control are required too. A key
touches statistics when its effective runtime value is on, or it has a saved override or an active stop.
Statistics keys are the reviewed consumer contracts that Project Management also consumes (today
materializedProjectStatisticsServing, Pages, StageOverview and SignalR). domain:statistics
is always Missing, so a report whose policy touches statistics can never be Complete; that is intended,
because statistics are not production-eligible. The report lists the set it applied in requiredSources.
The report is Complete only when all of these hold:
- a supported purpose is given;
RuntimeFeatureFlags:FleetInventoryModeisCollector(same setting andStaticdefault as fleet inspection). InStaticmode a purposed report addsfleetInventoryMode:StatictounknownOrUnsupportedand staysIncomplete, because configured counts are not observed inventory;- the sources were stable across captures (otherwise
Unstable, as before); - every required source appears exactly once with state
Current, and no required name is inunknownOrUnsupported; - the
migrationsprerequisite isReady(NotReadyorUnknownnever completes a report); - the report has not expired;
- for
legacy-return,legacyParityis present (it is null without a bound policy).
Sources that are not required, such as gitops for policy-change or domain:statistics when the policy
does not touch statistics, may be Missing or listed as unknown without blocking. A Current migrations
source only means verification ran, so completeness also requires every migration this build knows to be
applied with its checksum and declared indexes (Ready). Complete describes evidence, not permission:
the offline checker still decides.
Host heartbeats no longer make reports Unstable. The stability comparison ignores each host's
lastSeenAtUtc, and the hostAcknowledgements source digest is computed over the stored host rows
without lastSeenAtUtc and authorityCheckedAtUtc. Instance UID, process start, image, catalog digest,
acknowledged policy, readiness and state still count. The returned applied entries keep lastSeenAtUtc.
legacyParity (RT-15) is computed server-side for every baseline key that is production-eligible (today
only disableMembership). Each entry records legacyValue (the deployed FeatureFlags value, as the
evaluator computes it with stored policy ignored), runtimeValue (the evaluator's runtime decision: off
while a stop is active, otherwise override or baseline), stopActive and agrees. An entry agrees only
when no stop is active and the two values match; the section's agrees is true only when every entry agrees.
appliedMigrations lists the id, version and checksum of every migration the runner recorded as
applied, in version order, read in the same verification step (at most 10,000 rows; more refuses
collection). It never includes timestamps, actors or errors, and it is null unless the migrations
source is Current. It takes part in the stability comparison, so the offline checker can confirm that a
candidate's required migrations have been applied.
All four fields are additive and nullable; schemaVersion stays 1 and retained reports collected earlier
read back with them null. The capabilities digest now ends purpose-completeness-v1:legacy-parity-v1:applied-migrations-v1.
No flag is added: the route stays behind the default-off RuntimeFeatureFlags:CompatibilityReport:Enabled,
and collection still writes only the retained report.
Checking a candidate offline¶
scripts/runtime-compatibility-check.py judges a candidate build against a report saved from
GET /api/environment/compatibility-reports/current. It reads two local JSON files and prints one JSON object
(verdict, exitCode, reasons[] of {code, detail}, sorted by code then detail). It is offline only: no network,
credentials, database or cluster access, so it never collects or refreshes evidence and is not deployment clearance by itself.
python3 scripts/runtime-compatibility-check.py --report report.json --candidate candidate.json \
--environment staging --binding-epoch <guid> --purpose policy-change|promotion|legacy-return [--now ISO8601]
The candidate uses schema syrf.runtime-candidate/v1 (catalogDigest, optional compatibleCatalogDigests, catalogKeys,
policySchemaVersion, migrations[{id, version, checksum?, required}], images, targetMode Legacy|Runtime, and
optional catalogGrains, a map of catalog key to contract grain). A report's policy.targetedKeys[{key, grain}] (policy
schema 2, see Targeting) join the keys the candidate's catalog must
contain, and when the candidate gives catalogGrains, a targeted key whose grain differs (or is missing) refuses
targeted-key-grain-changed. A schema-1-only candidate (policySchemaVersion: 1) against a schema-2 report is already
outside N-1..N (policy-schema-unsupported), so rolling back below a schema-2 binary while targeting exists never passes:
remove every rule first (the document returns to schema 1).
--now defaults to the current UTC time. Fixtures live in scripts/testdata/runtime-compatibility/; run
python3 scripts/test-runtime-compatibility-check.py.
| Exit | Verdict | Meaning |
|---|---|---|
| 0 | pass |
No evidence gap and no refusal |
| 1 | refuse |
Binding mismatch (checked first, short-circuits), or a replacement-build, legacy-parity or constraint refusal |
| 2 | insufficient-evidence |
Incomplete, unstable, unsupported or expired evidence (takes precedence over refusals) |
| 3 | unusable-input |
Unreadable or invalid files/arguments (short-circuits) |
Reason codes: input-unreadable, input-invalid, candidate-schema-unsupported (3); binding-mismatch,
catalog-key-missing, catalog-digest-incompatible, targeted-key-grain-changed, migration-not-applied, image-change-hosts-not-current,
legacy-parity-stop-active, legacy-parity-differs, constraint-catalog-outside, constraint-key-outside,
constraint-schema-outside (1); report-schema-unsupported, report-expired, report-unstable, report-incomplete,
purpose-mismatch, required-source-unknown, required-sources-missing, policy-missing, migration-evidence-missing,
legacy-parity-missing, policy-schema-unsupported (policy schema must be N or N-1), constraint-action-required, constraint-state-unknown, constraint-expired, required-source-undeclared (2). Timestamps must carry a UTC offset, and a
missing argument is input-invalid (3), not argparse's usual exit 2.
The checker does not trust the report's own requiredSources alone: policy, baseline, deploymentConstraint,
hostAcknowledgements, workloads and migrations must always be declared and Current. --now exists for fixtures;
setting it earlier than the real time defeats the freshness and constraint-expiry checks, so never pass it for a real
decision. A host counts as current when it acknowledges the report's policy identity; its readiness and heartbeat age are
outside this checker (the server reports readiness as Unknown today, and fleet freshness belongs to the admission path).
Retrieve retained compatibility evidence¶
PR #4368 adds GET /api/environment/compatibility-reports/{reportId} for the same dedicated
machine reader and exact environment binding as collection. It uses the existing default-off
RuntimeFeatureFlags:CompatibilityReport:Enabled setting. Human Inspect access and a browsing UI remain
follow-up work; this route grants no additional token scope or policy authority.
The response preserves the original report ID, source observations, completeness and timestamps. A report
past its 15-minute validity may still be retrieved for diagnosis during its 30-day retention, but it is
historical evidence, never a fresh deployment check. Retrieval does not recollect sources, extend expiry,
write a replacement, create indexes or refresh TTL. Records past DeleteAfterUtc return 404 even before the
TTL sweep removes them. Missing IDs and records belonging to another binding also return 404; unreadable
binding or inconsistent stored metadata returns sanitized 503. Every response is private/no-store.
Switching reporting off denies retrieval while retaining saved records; switching it back on restores access under the same binding until retention expires. A different binding never gains access to the old reports.
Constraint evidence in compatibility reports¶
PR #4362 adds the optional deploymentConstraint projection and a deploymentConstraint source entry.
Both are captured in the report's policy snapshot. A bound authority with no active constraint returns a
null projection and a Current source with an explicit absence digest. Legacy/uninitialized policy keeps
constraint evidence Missing; null alone never proves that a deployment is unconstrained.
An active document must match its immutable constraint receipt and the current environment/binding/store lineage. The report exposes rollout ID, constraint digest, serving/target catalog digests, global schema-v1 capability intersection, policy revision at record and timestamps. Coordinator/actor identity, reason and receipt internals are excluded. Policy revision and the source's shared-guard revision are decimal strings. The source revision advances on record, extend and release even though policy identity does not change. Every policy edit also increments this shared guard; the source revision can advance with an unchanged constraint digest.
An expired but unreleased constraint remains present with state: ActionRequired and reason: Expired.
Collection never releases or extends it. Inconsistent receipts, unsupported schema/lineage, invalid guards
or malformed projection evidence refuse collection. A verified constraint is one required source among
several (see purposes and completeness); it is not permission to deploy.
This is additive evidence on the existing default-off, report-only machine route. It introduces no new
scope, index, migration, activation or deployment setting.
Workload collector publication¶
PR #4363 adds POST /api/environment/observations behind
RuntimeFeatureFlags:WorkloadCollector:Enabled (default false). Enabling it requires explicit Provider,
ClientId and Audience under that section plus the same verified runtime environment binding used by the
report service. environment:observations:write is a separate machine scope: it cannot read reports, read or
mutate policy, or use any other API route. Mixed-scope, session, user and impersonated credentials are refused.
The OpenIddict client is syrf-environment-collector; only an explicit strong EnvironmentCollector:ClientSecret
seeds it. No credential or collector is provisioned by this PR.
Each publication contains one complete API or project-management Deployment snapshot: environment/binding, observation ID, previous observation ID, Deployment UID/resource version, lossless generation strings, desired replica count, collection timestamp and up to 1,000 pods with UID, pinned image digest, readiness and termination status. The body is bounded to 512 KiB. The collector must retain its last accepted observation ID: new publications compare against it; exact retries reuse the same ID and payload. A timeout is unknown, so retry the same request. Conflicting replay or a superseded publication is refused, never silently rebased. Snapshots older than 30 seconds or in the future are refused; collector and API clocks must agree.
PR #4369 requires every pod to include explicit Boolean ready and terminating fields. Omission is
unknown evidence, not false: an authorized request missing either field returns 400 before publication.
Explicit false values remain valid. Existing observations written by the typed publisher already include
both fields and retain their exact replay digest; malformed stored payloads missing either field are refused
rather than silently interpreted. No stored observation is rewritten or deleted and no migration is needed.
The existing default-off collector setting continues to control publication.
Migration 0018 owns the age index. Publication verifies it and the environment binding, then updates only
runtimeWorkloadObservation. It neither reads policy nor removes host acknowledgements. Reports expose
collector evidence separately from host self-reports with per-service Current/Missing/Stale/Unknown source
states. A fresh observation does not mean every pod is ready; deployment generation, replica and readiness
checks still belong to the compatibility checker. Completeness additionally needs Collector inventory
mode and a purpose (see purposes and completeness);
4364–#4366 add desired GitOps, migration and cross-source evidence. Automatic host retirement and live Kubernetes collection are¶
follow-ups; no runtime mode, activation, migration execution or production access changes here.
Read-only workload capture¶
PR #4370 adds scripts/capture-runtime-workload.py, an explicitly invoked capture command that produces
one JSON request for the existing observations endpoint. It does not publish, provision credentials,
select a default cluster, change kube context, read Secret resources or activate a feature. The existing
collector endpoint remains default-off. Use a separately approved read-only Kubernetes identity with
get on the named Deployment and list on ReplicaSets and Pods in the selected namespace.
Example for an approved non-production context (values must match the reviewed environment binding):
python3 scripts/capture-runtime-workload.py \
--context "$REVIEWED_CONTEXT" --namespace "$REVIEWED_NAMESPACE" \
--service api --deployment syrf-api --container syrf-api \
--environment "$BOUND_ENVIRONMENT_ID" --binding-epoch "$BOUND_EPOCH" \
--expected-observation-id "$LAST_ACCEPTED_OBSERVATION_ID" \
--output api-observation.json
Omit --expected-observation-id only for a known first publication. The service/deployment/container mapping
is explicit operator input; the command never guesses it. The capture can be submitted immediately to
POST /api/environment/observations by the separately authenticated publisher. Retain the exact output
and observation ID for uncertain-outcome retries; do not change evidence under that ID. The command never
overwrites an existing output file or retries publication. Publication still enforces binding, expected
observation ID and 30-second freshness; a capture file is not a durable freshness guarantee.
Each capture reads Deployment, ReplicaSet and Pod JSON twice using only kubectl get. It follows controller
UIDs from Deployment to ReplicaSet to Pod, including old rollout ReplicaSets; labels alone cannot enroll a
foreign pod. It requires the exact named container, an explicit readiness condition and container Boolean,
and a repository-qualified @sha256: image ID (optionally prefixed docker-pullable://). Digest-only or
missing image IDs are refused rather than inferred from image tags. Missing observations, paginated lists,
duplicate UIDs, malformed ownership, changed selected resource versions and clock regression refuse capture.
A valid zero-replica observation is allowed but never establishes fleet readiness.
The observation time precedes the first read. All six reads and revalidation must finish within 20 seconds; each command has a five-second deadline. Responses larger than 8 MiB are refused, as is output above the endpoint's 512 KiB limit. Only the allowlisted observation fields are emitted; raw environment values, annotations, labels, container diagnostics and command stderr never enter the artifact. Matching reads establish observed agreement, not an atomic Kubernetes snapshot. The collector endpoint and later compatibility checks still must validate freshness, desired generation, replica count and readiness.
This slice supplies workload acquisition only. Scheduled publication/credential handling, desired Argo/GitOps acquisition, trusted retirement and rollout-completion checks remain ordered follow-ups. Tests use synthetic responses exclusively; no live cluster or environment is enrolled by this change.
Authenticated deployment coordinator¶
PR #4374 adds default-off machine-only PUT and DELETE /api/environment/deployment-constraints/{rolloutId}.
Enable only with reviewed RuntimeFeatureFlags:DeploymentCoordinator settings (Enabled, Provider,
ClientId, Audience) and the existing exact environment/database/cluster binding. The API authority must
also be Shared with the same binding. This does not relax the existing production Shared-mode refusal.
The separate scope is environment:deployments:coordinate; report, collector, human and session credentials
cannot use it, and coordinator tokens cannot use unrelated routes or the administrator break-glass path.
Native Identity seeds syrf-environment-coordinator only when EnvironmentCoordinator:ClientSecret is supplied
with at least 32 characters, using client credentials and this sole scope. An absent/short secret removes the
client's grant through the existing automation-client reconciliation. The API also accepts the explicitly
configured Auth0 machine provider; this code does not provision or change an Auth0 tenant.
PUT accepts Record or Extend; DELETE accepts Release. The body carries a mutation ID, complete expected policy
identity (revision as a canonical decimal string), action, reason and operation-specific constraint fields.
Coordinator clients and proxies must preserve the JSON body on DELETE as well as PUT. Always send action
explicitly: the current non-nullable wire enum defaults an omitted action to Record.
Record includes the immutable completion targets described below; Extend and Release use the reviewed
constraint digest. Release additionally selects Target or Rollback and always calls the verified store path.
The actor is derived from the configured authenticated provider/client, never from a request actor field.
Keep that client identity stable for an active rollout: only its owning coordinator may extend or release it.
Rollout IDs are one ASCII path segment, up to 128 letters/digits or -_.: characters.
Responses expose only mutation ID, rollout ID, action, resulting constraint digest (null after release) and original receipt time. Exact uncertain-outcome retries preserve the entire request. HTTP 409 refuses a changed policy/constraint or unproven completion; HTTP 503 or transport loss leaves the outcome unknown. Requests are bounded to 32 KiB and ten seconds; responses and authorization denials are no-store. No endpoint accepts BreakGlassRelease. Its separately authorized administrator adapter remains a follow-up.
The gate defaults off, preserves all saved constraint/audit data, and never runs a migration. Credential provisioning, workflow invocation and environment enrollment are separate approved operations; none occurs in this change. Tests use synthetic identities, a TestServer and isolated local test data only. The API chart does not expose a dedicated coordinator values block; reviewed chart/config wiring and environment enablement remain follow-ups. Adapter tests pin configuration and identity refusal, the server-derived owner and receipt mapping independently of controller fakes. Further transport improvements are explicit missing-action rejection and distinguishing corrupt stored-state FormatException from malformed request identity (the current controller maps either to 400); neither changes the store's fail-closed mutation behavior.
Verified deployment completion primitive¶
PR #4373 adds ChangeVerifiedDeploymentConstraintAsync for the later authenticated coordinator adapter.
Recording through this entry point requires immutable target and rollback expectations: one catalog digest
and one repository-qualified SHA-256 image per API/PM service. The target catalog must equal the constraint's
target; the rollback catalog must be in its serving set. Extension preserves these expectations unchanged.
Normal release explicitly selects Target or Rollback. Inside the existing binding/policy/guard transaction, the store reads current collector observations and host acknowledgements, requires collector-backed Active adoption under the configured positive counts, and matches every observed pod image and current catalog to the selected recorded expectations. Missing, stale, mixed-image, non-ready or unacknowledged evidence refuses release without changing the guard, constraint or audit. These expectations also enforce verification when the older internal primitive is called. Exact successful mutation replay uses the immutable receipt even if observations have since expired or disappeared; it never performs the operation a second time.
Old internal constraints/receipts without completion expectations remain readable/replayable, but cannot be released through the verified coordinator entry point. Its break-glass action is refused: that operation must remain on the separate administrator capability path. No HTTP coordinator or break-glass adapter is exposed by this slice. The original internal primitive retains its documented caller-authentication requirement.
No new flag is introduced for this internal safety boundary. Existing default-off runtime/collector modes are unchanged, no collections or indexes are migrated, and no live constraint is recorded or released. This proves observed target/rollback completion; candidate compatibility, domain readiness and live coordinator credentials/workflow integration remain separate requirements.
Collector-backed fleet inventory¶
PR #4372 adds deployment configuration RuntimeFeatureFlags:FleetInventoryMode=Static|Collector.
The default is Static, preserving the configured-count inspection path. Collector is an explicit opt-in
for environments with the trusted workload publisher; unknown values refuse inspection. No environment is
enrolled by this change. This configuration gates read-only fleet semantics, not policy decisions or migrations.
Collector mode reads authority, acknowledgements and workload observations in one bound database snapshot.
Both API and PM must have fresh observations (strictly younger than 30 seconds), matching binding and the
configured positive FleetExpectedReplicas counts. Deployment generation must be observed, every expected
pod must be present, ready and non-terminating, and UIDs must be unique across services. Missing evidence,
zero/surplus replicas, generation lag, stale/future timestamps and service disagreement never establish Active.
The existing host policy/catalog checks, autoscaler Unknown rule and statistics-domain Unknown rule still apply.
Retirement uses MongoDB's clock only (PR #4407). Every heartbeat write stores serverSeenAtUtc, stamped by
MongoDB itself ($$NOW), beside the host's own lastSeenAtUtc. The write keeps its filter, upsert, transaction
and late-write protection. Evaluation reads MongoDB's current time ($$NOW) in the same snapshot transaction.
A historical acknowledgement absent from the fresh service inventory is marked Retired in the returned view
only when MongoDB has not seen it rewritten for the quiet period, RuntimeCollectorFleet.RetirementQuietPeriod
(the 30-second freshness window plus 5 seconds, so 35 seconds). Hosts heartbeat every 8–12 seconds, so a
quiet row belongs to a stopped pod. A row written more recently may be a pod that started after the collector's
read: it leaves the fleet Unknown (CollectorHostObservationDisagrees). A host clock that runs behind or ahead
cannot change this, because both times come from MongoDB. A row whose start time follows its heartbeat is also
Unknown.
Rows written before serverSeenAtUtc existed, or by an image without it, carry only the host clock. Such a
legacy row retires only once its lastSeenAtUtc is at least LegacyRetirementAge (15 minutes) behind MongoDB's
time. The 15 minutes is an assumed bound on host clock skew, not a measured or verified one: a live legacy host
whose clock runs more than 15 minutes behind MongoDB could be retired. The bound applies only to legacy rows. It
stops mattering once every host runs an image that writes the server stamp, because every row then carries
MongoDB's own time. The first rollout away from images that do not stamp the field can therefore take 15 minutes
to reach Active. After that, a rollout reaches Active about 35 seconds after the old pods stop, once the new pods
have acknowledged.
MongoDB's time is read as the first statement of any transaction that may retire rows (fleet inspection, production admission and deployment completion). A snapshot transaction sees rows as of its first operation, so a slow transaction cannot make a live row look older than it was. Emergency stops never read it.
All rows remain stored and visible, including retired rows. No TTL, deletion or migration is introduced.
serverSeenAtUtc is an additive root field: older readers ignore it, and older rows parse without it. It is not
part of the stored payload or the API response. Switching back to Static uses every retained row again.
Static mode counts every retained acknowledgement, so it suits only hosts whose instance UIDs stay the same
(for example the local runtime-fleet harness). In Kubernetes every replaced pod has a new UID, so after any
rollout the old pods' rows keep a Static fleet out of Active (it reads Applying, then Degraded). Kubernetes
environments therefore need Collector mode before fleet Active can be relied on.
A pod present in the inventory but missing an acknowledgement still blocks Active.
The local runtime-fleet harness exercises this end to end against real API and project-management processes:
bash e2e/run-local.sh --runtime-fleet --scenario collector-scale-up. It restarts the APIs in Collector mode,
publishes a synthetic inventory through POST /api/environment/observations with a mock-OIDC machine token, and
asserts Active with every listed host current, Unknown (CollectorHostObservationDisagrees) while an unlisted
API is live, and Active again with that host Retired once it has been quiet for 35 seconds by MongoDB's clock.
What it covers and what it does not:
runtime fleet lane.
This confirms policy adoption against observed UID membership. It does not prove deployment target image compatibility, release a deployment constraint, or complete statistics domain readiness. Live collector setup, rollout target proof and coordinator integration remain required follow-ups.
Explicit workload publication¶
PR #4371 adds scripts/publish-runtime-workload.py to submit a saved capture once. Supply an already-issued
machine token in SYRF_RUNTIME_COLLECTOR_TOKEN; it must have the separate environment:observations:write
scope and match the API's configured client, provider, audience and environment binding. The command does not
obtain tokens, provision credentials, schedule collection or activate the default-off collector endpoint.
No new flag is needed for an explicitly invoked command; the endpoint's existing gate remains authoritative.
python3 scripts/publish-runtime-workload.py \
--api-origin "$REVIEWED_API_ORIGIN" --environment "$BOUND_ENVIRONMENT_ID" \
--binding-epoch "$BOUND_EPOCH" --service api --input api-observation.json
The API origin must be HTTPS (HTTP is accepted only for loopback test servers), with no user information, path, query or fragment. Redirects are refused. The command checks the saved capture's environment, binding epoch, service and observation ID before sending its exact bytes. The API still validates the complete contract, permissions, expected-observation CAS and initial freshness. No local age check prevents an exact retry of an already accepted observation after its initial admission window.
Exit 0 means a 200 receipt with the matching observation ID and a valid digest was received; stdout contains only those two receipt fields. Exit 1 means an HTTP 3xx/4xx refusal, except 408/425/429; exit 2 means local input refusal; exit 3 means the outcome is uncertain (408/425/429, transport loss, 5xx, or an invalid/missing receipt). Preserve the exact capture and replay it after investigating an uncertain outcome. The command never retries, rewrites the capture, prints credentials or copies remote error bodies into logs. A successful receipt confirms publication, not fleet readiness or rollout completion. Rotated/advanced observations can make an old retry conflict; do not replace evidence under an existing observation ID.
Environment proxy settings are explicitly ignored, so bearer tokens are sent directly to the reviewed origin. HTTP 408, 425 and 429 are uncertain outcomes (exit 3), as are transport failures and 5xx responses: retain and replay the exact capture. The ten-second urllib timeout is per socket operation, not a total elapsed deadline; an invocation under a scheduler needs its own process deadline. Terminating a publisher must also be treated as an unknown outcome.
Tests use mocks and a loopback HTTP stub only. Live publication, token provisioning, automatic scheduling, desired GitOps acquisition and trusted retirement/rollout checks remain separate follow-ups.
Desired deployment provenance¶
PR #4364 adds optional desired evidence to each collector observation: applicationUid, lossless
resourceVersion, a 40-character gitOpsRevision, chartTag and 64-character renderedSettingsDigest.
The collector must bind these to the same service and timestamp as its workload snapshot, using the earliest
observation time when collecting multiple sources. This is a collector obligation: desired has no independent
timestamp, so the API applies the workload timestamp and cannot attest the actual source-read time. It must derive rendered-settings digests from allowlisted
non-secret configuration; the API accepts digests, never raw settings or secret values.
Reports include per-service GitOps sources and project the aggregate desired revision/chart tags only when
both API and PM have Current provenance at the same GitOps revision. The aggregate settings digest is a
canonical hash of sorted service/digest pairs; each original digest stays available in the workload projection.
Different revisions yield Unknown, not a guessed winner. Missing or stale evidence leaves the desired snapshot
null. Optional evidence can be added and removed without invalidating stored observations; exact retries of
older requests without desired preserve their receipt digest.
This uses the existing default-off collector/report settings and scopes, with no new migration or rollout.
Reports can be Complete only in Collector mode (see purposes and completeness); migration verification
and cross-source stability checks are implemented in #4365–#4366.
PR #4365 supplies fresh migration evidence through the existing default-off compatibility-report route.
The API supplies its PmMigrations.All catalogue. Each request uses the migration runner's read-only
verification: every known migration must be applied with its expected checksum, and every declared index
must match. The migrations prerequisite is Ready or NotReady; a missing catalogue or unreadable
state produces Unknown. This does not use the five-minute schema health cache.
The migration source revision is the ordered catalogue digest; its evidence digest covers that catalogue and the verification result. Neither raw diagnostics nor migration actors/history are returned. Evidence is dated before verification starts and expires after 30 seconds, including time spent collecting the remaining report. Collection writes only the retained report, never migration state or indexes. A later request rechecks everything, so an index removed after a healthy result is detected immediately.
This proves readiness only against this binary's catalogue, not against a future candidate build. Reads
of migration records and index definitions are not atomic with the report's policy snapshot. Reports
are revalidated as described below; completeness follows purposes and completeness. The former crossSourceRevalidation missing-input reason is no longer emitted.
No migration, live collector, deployment or activation is performed by this slice.
PR #4366 revalidates report sources before retention. It collects an independent snapshot, recollects, and compares the policy/baseline, constraint, workload and desired evidence, host acknowledgement digest, migration verification result, prerequisite states and unknown reasons. Per-read timestamps and generated report IDs are excluded from comparison; stored observation timestamps (other than host heartbeats, since
4405) and freshness states are included.¶
A mismatch starts another comparison using the latest snapshot. Three comparisons (at most four captures)
are allowed under the existing request deadline. A stable result is then evaluated for its purpose
(purposes and completeness). Continuing changes return Unstable with crossSourceChanged; read failure or cancellation
cannot produce a stable result. Only the final report is retained with majority acknowledgement.
Each snapshot rechecks the original environment binding. Freshness is evaluated at the end of collection, so a source that ages out while other evidence is read cannot remain Current. This verifies agreement between observed source snapshots, not an atomic transaction spanning Mongo migration/index reads and external collectors. No report in this slice authorizes activation or deployment.
PR #4367 adds optional statisticsControl metadata from the durable global statistics singleton.
The domain adapter reads at most two rows in the report's own snapshot and rejects duplicate controls,
missing required fields, malformed data, unknown modes and negative epochs/versions. No repository
initialization, index creation or cache is involved. The output includes mode, active-reviewer-tracking
mode, whether a mode transition is pending, catalogue/storage/source/schema versions and lossless string
write/mode/client-invalidation epochs. It excludes document IDs, actor/audit fields, transition tokens and
project allowlists. Missing or invalid evidence is explicit in domain:statistics-control.
The control metadata participates in cross-source revalidation. domain:statistics remains unknown:
this record alone cannot establish deployed maintenance flags, fleet protocol compatibility, inventory
or per-project serving readiness, so a report whose policy touches statistics stays Incomplete. The existing default-off report setting and
machine permission boundary apply; collection does not initialize or transition statistics state.
Reviewed consumer evaluation¶
PR #4350 adds runtimePolicy.consumerContract metadata to the canonical YAML and includes it in the
catalog digest. The initial disableMembership (API admission) and materializedProjectStatisticsServing
(API/PM read admission) contracts are extended by #4352 with the existing pages, Stage Overview and SignalR
selectors. Other keys remain unreviewed; absence of metadata
never means safe or production-eligible. See the audited contract evidence.
GET /api/runtime-feature-flags/authority-evaluation/{key} requires the current InspectRuntimeFeaturePolicy
permission before catalog lookup or evaluation. In Shadow mode it evaluates one immutable, receipt-validated
local capture, with its accepted baseline and current deployment prerequisites. All five contracts expire at
30 seconds measured monotonically and tolerate no failed reads. Legacy mode reports Disabled. Unknown keys
return 404; known but unreviewed keys report UnreviewedConsumer; an unsupported host or unavailable/stale
policy returns no value. The declared safe value remains separate from that unknown decision.
Responses include local-process scope, catalog classification and a decimal-string revision when a fresh decision is available. ProductionEligible describes the reviewed catalog candidate, not permission to activate it, production readiness, or fleet acknowledgement. No database query happens on demand. This inspection route does not replace any domain gate or enable Shared mode by itself. Fleet coordinator admission remains required before a rollout can be declared Active.
The added catalog metadata changes CatalogDigest. Previously generated lineage previews must be regenerated and reviewed against this catalog; an old digest is rejected rather than silently reinterpreted. No live lineage import has run in this implementation programme. This change runs no data migration and changes no Helm/browser defaults.
Authority emergency-stop primitive¶
PR #4351 extends the bound shared writer with EmergencyStop and ReleaseStop actions for
the audited global controls with a false safe value (initially membership and serving; #4352 also audits
the three statistics selectors). These are library operations, not new public endpoints or permission grants. Legacy
remains the deployment default and production writes remain refused.
A stop records the key in killSwitches and advances policy identity without changing the accepted baseline
or saved overrides. The bounded evaluator applies the safe value before dependency normalization; ordinary
policy edits cannot clear the stop. Releasing it explicitly resumes the latest saved policy, subject to the
existing deployment and dependency checks. This does not remove saved memberships, statistics, or history,
and does not alter deployment-owned statistics maintenance gates.
Both actions require exact binding/lineage/revision, actor and reason, and atomically commit an immutable
receipt with policy state. Identical request replay resolves the original receipt, including after later
edits. Reusing its mutation ID for a different action refuses. Emergency stop bypasses deployment-constraint
and new-audit-index readiness gates (plan §6.4/Q1 and §14.5); the built-in unique audit _id and shared policy
transaction still serialize receipts. It does not bypass binding or authority integrity. ReleaseStop retains
the normal readiness and constraint gates. No index is created or migration run by either action.
Older authority readers that cannot understand non-empty stops refuse that policy. Before any live enrollment, deployment compatibility checks, the preview/commit UI and production-grade fleet admission (independently observed inventory, not only the host acknowledgements described below) must be complete. No emergency stop has been applied to a real environment.
Targeting (policy schema 2)¶
Project allow and exclude rules are writable behind a switch. 6F PR A (#4463) taught every API and Project Management binary to read and evaluate targeting rules; 6F PR B (#4472) adds the preview and commit routes behind
RuntimeFeatureTargeting:Enabled(default off in every environment) and the first targetable flag. Route gates and availability still decide without a project until 6F PR C, so a saved rule changes decisions made with a project context (IFeatureDecisions.DecideAll(keys, in target)) but not yet any route. Personal and pair rules (6F.6), rollouts (6F.8) and switched-off rules (6F.9, pending D28) are refused asnotYetSupported. Plan: delivery plan §7.2.
The targetable flag. screeningKeywordHighlighting has a reviewed grain: Project consumer contract (API only,
productionEligible: false, new-request-admission) and owns the registry feature keyword-highlighting
(grain: Project, permission: ProjectDesignPolicy, whatsNew: quiet while it is a Developing pilot). The pilot choice is pending owner decision D33, built to its
recommendation A; retargeting means moving that contract and feature to another flag and regenerating. With no rule
saved it decides exactly as before in Legacy and Shadow mode; in Shared mode it moves from the API's compatibility values
to the bound authority, which gives the same value while the policy is fresh and the contract's safe value (off) when it is
stale or unavailable. Production stays deployment-controlled (non-Global keys are never production-eligible). Every
other key is Global and refuses rules (globalKey). The catalog generator accepts grain: Project | Personal on a
contract but never with productionEligible: true; registry check 9 requires a feature's grain to equal its flags'
contract grain, a non-Global feature's flags to all have contracts, and no deployment-managed flag to be targeted.
Switch on an isolated stack (never staging or production without an owner decision): set
RuntimeFeatureTargeting:Enabled=true for API and Project Management alike (chart value runtimeFeatureTargeting.enabled,
env SYRF__RuntimeFeatureTargeting__Enabled), with RuntimeFeatureFlags:Mode=Shared and a bound environment. Every
fresh API and PM host must advertise policy schema 2 (policySchemaVersions: [1, 2] on its acknowledgement, which every
binary since PR A does) before the first rule is accepted.
Preview, then save (capability ChangeRuntimeFeaturePolicy; both answer 404 and read nothing while the switch is off):
POST /api/runtime-feature-flags/screeningKeywordHighlighting/targeting/preview
{ "expectedIdentity": { …current policy identity… },
"targeting": { "rules": [
{ "id": "<new guid>", "kind": "allow", "label": "Wave 1 pilot", "subjects": [ { "type": "project", "id": "<project guid>" } ] },
{ "id": "<new guid>", "kind": "exclude", "label": "Held for data review", "subjects": [ { "type": "project", "id": "<project guid>" } ] } ] } }
PUT /api/runtime-feature-flags/screeningKeywordHighlighting/targeting
{ "mutationId": "<new guid>", "expectedIdentity": { … }, "targeting": { "rules": [ …the previewed rules… ] },
"previewDigest": "<from the preview>", "reason": "Pilot for the keyword-highlighting study" }
The rules are the key's complete replacement; "targeting": null (or no rules) clears them, and removing the last
rule anywhere writes schema 1 again with the v1 digest. Clearing a key that has no stored rules is an idempotent no-op: the
preview proposes the current identity, and the commit writes no revision and no receipt and returns the unchanged identity. The preview returns the rules before and after by kind, the
projects that gain and lose the key (counts, and up to 50 with their names), dependency notes, schema admission
(required, hosts supporting and total, Ready | Blocked) and the usual identities; it writes nothing. The commit goes
through the policy writer exactly as a policy change does: binding re-check, receipt chain, replay by mutation ID (retry
the same ID and body after an unknown outcome; another body under that ID is refused), the preview digest (domain
syrf-runtime-targeting-preview-v1, over the key, expected identity and rules, the candidate document, the constraint
guard and the deployed values), the deployment constraint, then schema-2 admission. Its receipt is an ordinary
globalPolicyMutation with request.action = "Targeting" and request.targeting = { key, rules } (null to clear), so
GET mutations/{mutationId} resolves it. Saved settings, stops and other keys' rules are untouched, and a later policy
change or stop keeps every stored rule.
Refusals are HTTP 422 { code: "runtime_targeting_refused", reason, message } and write nothing:
| Reason | When |
|---|---|
globalKey |
A rule on a key with no Project or Personal contract (clearing is allowed on any key, so rules a catalog change left incompatible can be removed) |
deploymentManaged |
The key is pinned to its deployed value across API and PM |
personalRuleOnProjectGrain |
A person or pair subject on a Project key |
notYetSupported |
A rollout, a rule with enabled: false, or a Personal key |
invalidRule, duplicateSubject, limitExceeded |
Schema rules 2 to 6 (the message is the exact schema message), or a document over 512 KiB |
unknownSubject |
A named project does not exist or is deleted (one generic reason) |
parentOff |
An allow on a key whose required Global parent is off: turn the parent on or target it first; it is never widened |
schemaUnsupportedByFleet |
The first schema-2 write while any fresh host (or no host at all) does not advertise schema 2 |
A deployment constraint that allows only policy schema 1 refuses the write (runtime_policy_constraint), and a coordinator
may now record allowedCapabilities.policySchemaVersion: 2. Production refuses every targeting write, a clear included and
on any key, eligible or not: Legacy production has no policy administration, and Runtime production answers
runtime_policy_constraint ("This key is not eligible for production runtime policy.").
Switch off. Stored rules are kept. Allows (and rollouts) stop applying, while exclusions and stops still apply, so switching off never widens access; on again, the rules apply as saved. Decisions are a function of the stored policy and the switch alone, so on, off, on gives the same decisions with policy written in either state.
The policy document (runtimeFeatureFlagOverrides, _id: "global") has two schemas:
- Schema 1 is today's document, byte-for-byte, with the
syrf-runtime-global-policy-v1digest. It is still written whenever there is no targeting and no feature setting, so removing every rule returns the document to a shape every older binary reads. - Schema 2 adds
targeting(per catalog key, at most 20 rules:excludeandallowrules withenabled, a label and 1–200project,personorpairsubjects, and at most onerolloutwithsubjectType,group,basisPointsandstateActive|Paused|Off) andfeatureSettings(per registry feature,{ consent, reason }). It needs at least one of them non-empty, allows at most 2,000 subjects, and digests undersyrf-runtime-global-policy-v2in one canonical order (keys ordinal, rules by kind then ID, subjects by type then IDs), so array order never changes the digest. Subject IDs are lowercase GUIDs only.RuntimePolicyAuthority.Parserefuses anything else with an exact message (RuntimeTargeting.Messages); a schema-1 document keeps its old messages.
Decisions follow one order, RuntimeTargetingEvaluator (displayed order = evaluation order): requirements (authority
state, deployment configuration, a required parent off for the same subject), stops, exclusions, specific allows (pair,
then person, then project), gradual rollout, the saved setting, the baseline. Steps before the allows can only keep a
feature off; allows and rollouts can only turn it on. Grain is checked when deciding, not when reading: a Project key
reads project subjects only (every member of a project gets one decision), a Personal key reads pair, person and
project subjects (a project or pair allow means an active member), and a rule the grain forbids is ignored with reason
TargetingIncompatible, so a catalog change never takes the policy down. Global keys ignore targeting and decide
exactly as before (pinned against a frozen copy of main's results).
IFeatureDecisions.DecideAll(keys, in FeatureTargetContext) decides for one subject (ProjectId, the trusted
Investigator as PersonId, PersonIsActiveMember); the older Decide and DecideAll(keys) use the empty context.
Nothing builds a request context yet (6F PR C), allows apply only while the switch is on, and rollouts never match (the
cohort algorithm is 6F PR F). A key whose value the deployment fixes (a pin forcing it on or off) decides exactly as the
context-free values do on the targeted path too, so stored rules never override a deployment pin (#4469). The preview's
gains and losses use the same deployment-aware decision (RuntimeTargetingDeployment.Decide). Consent, rollout and per-rule switch fields are parsed and
validated only, pending owner decisions D28 to D30, and the single "person" level follows D31's recommended answer.
Compatibility: hosts advertise policySchemaVersions; a deployment constraint may allow policy schema 1 or 2 and refuses
a schema-2 policy it does not allow; keys with an enabled allow or an Active or Paused rollout join the required-enabled
set; the compatibility report gives the document's policy.schemaVersion and policy.targetedKeys. The anonymous browser
snapshot keeps schemaVersion: 1 and never carries targeting (a targeted key shows its context-free decision). An older
binary given a schema-2 document reports the authority Unavailable and serves safe values, which is why the writer
refuses a first schema-2 write until every fresh host advertises 2.
Non-production bound consumer mode¶
PR #4352 connects RuntimeFeatureFlags:Mode=Shared to API membership-disable admission and API/PM statistics
serving plus the existing pages, Stage Overview and SignalR selectors. It requires matching non-production
environment identities and the three pinned binding expectations above. SharedReaderMode must be Disabled;
legacy-format reader enrollment cannot be combined with the bound authority. No defaults, Helm deployment
values or real environments are changed by this PR.
Every audited decision reads the current immutable authority, expires at 30 seconds, and refuses admission on the first failed read. The API disable route becomes 404 while stopped/unavailable, while the enable route retains its permission checks and can restore an already-disabled member. API/PM statistics stop materialized serving/selectors; maintenance, family gates, pilot allowlists and durable lifecycle controls remain separate. Accepted baseline and overrides survive on/stop/restore. The PM selectors use the same normalized evaluator as the API instead of losing their existing runtime overrides during cutover.
The API refresh worker reads a bounded compatibility projection of initialized authority rather than the
legacy store. GET /api/runtime-feature-flags exposes that projection as read-only; no current authority
returns 503. Its legacy numeric revision contract is unchanged in this slice: lossless browser identity and
reconnect handling remain required follow-ons. Authenticated authority status/evaluation already expose
full identity with decimal-string revisions. The legacy PUT/group API refuses writes in Shared mode even
for administrators; the identity-aware permission/preview/receipt write path is still pending.
Only the audited keys bypass the API compatibility cache on each decision. Other API flag consumers keep their existing cached behavior and are not claimed as bounded runtime consumers. PM maintenance and non-enrolled selectors keep their deployment inputs. Legacy and Shadow modes keep their existing consumer paths. Shared mode is a per-host admission option, not a fleet readiness assertion: no Active status, production enrollment, coordinator clearance or rollout approval is implied. Host acknowledgements and the static expected inventory now exist (see the fleet section below); production-grade fleet admission, compatibility reporting and the production gate remain required.
Browser bootstrap and connection recovery¶
PR #4353 hands the exact successfully applied pre-bootstrap snapshot to RuntimeFeatureFlagsService.
The service seeds its revision before subscribing to background fetches, so an older response from another
API pod cannot overwrite the snapshot that selected the application's initial configuration. A successful
bootstrap also resolves the initial-snapshot wait without requiring a second request. If bootstrap failed,
the existing finite retry/fallback behavior remains.
The service refetches on successful first SignalR connection, explicit or automatic reconnection, browser online return and window focus return. The first-connection event is separate from the existing reconnect project-reload event; it does not force a project reload. Revision hints and the five-minute independent poll remain active, including when SignalR is disabled. A newer refresh trigger cancels the previous client fetch, and destruction removes the window listeners and subscriptions.
This repairs global snapshot recovery without changing permissions, saved review work, feature defaults or lifecycle teardown. It does not complete the browser contract: lossless revision transport, contextual identity/digest checks, authenticated/project-specific envelopes, and desired-versus-active lifecycle reporting remain required in later slices. No production or environment activation accompanies this change.
Browser safe fallback (browserRuntimeSafeFallback, 6B.4 G3)¶
If the bootstrap request for the runtime snapshot fails, the browser keeps the deployed values until a later
fetch succeeds. Under an emergency stop that is permissive: E2E deploys disableMembership On, so a tab whose
bootstrap failed shows "Disable membership" although the control is stopped. The server still refuses the action,
so this is about visibility, not security (RT-14, browser half).
The deployment setting browserRuntimeSafeFallback changes that. When it is true and the bootstrap request fails
(a network error, a non-OK status or an invalid snapshot), main.ts calls
applyRuntimeFeatureFlagBootstrapFailure(). That applies RUNTIME_SAFE_VALUES before the app starts, so the
store is seeded with them. The values hold until the first accepted snapshot, which then replaces them with the
server state. A key the snapshot omits falls back to the deployed value, because the deployed values are captured
before the safe values are applied (by design, but not covered by a test; the server always returns every catalogued
key). RuntimeFeatureFlagsService.safeFallbackActive() is true until then, including while a rejected snapshot leaves
the safe values in force. It is a diagnostic and test hook, not a UI contract: nothing renders it. A failed fetch after the first accepted
snapshot keeps that snapshot, as before. Nothing is stored, so each page load decides again. The console warning
says which fallback was used: using safe values … or using deployed defaults.
RUNTIME_SAFE_VALUES(src/app/generated/runtime-safe-values.generated.ts) is written bygenerate:runtime-catalog. It lists the safe value of every key that has a reviewedconsumerContractwithproductionEligible: true, in catalog order. Today that isdisableMembership: false. Statistics contracts are non-production and are not listed.generate-runtime-flag-catalog.test.tspins the list.- The setting is
SYRF__BrowserRuntimeSafeFallbackin thewebConfigsection ofenv-mapping.yaml. The Helm value isruntimeFeatureFlags.browserSafeFallbackin the web chart, defaultfalse, and the browser readsbrowserRuntimeSafeFallbackfromappConfig.env.json(AppSettings; only the string"true"turns it on). It is not a runtime catalog entry: it has no override, is not in the catalog digest and does not needcatalogOnlyFlags. It reaches only the browser.
Flag decision: flagged, by this deployment-static setting, false in every environment until a reviewed
rollout. It changes what every user sees when the bootstrap fails, so it needs a switch. A runtime flag cannot be
that switch, because it would be read before any snapshot exists. Off is exactly the old behaviour. Nothing is
persisted, so on, off and on again are all safe; runtime-feature-flags.service.spec.ts tests that sequence. The
E2E case is runtime-fleet-browser-recovery.spec.ts on the runtime-fleet lane, which sets the setting per tab
through an appConfig.env.json overlay.
Operation-specific administration capabilities¶
PR #4354 separates runtime administration at the existing application-authority gate. Provider status,
authority status and authority evaluation request InspectRuntimeFeaturePolicy; individual and group
policy mutations request ChangeRuntimeFeaturePolicy before reading policy or validating keys/bodies.
EmergencyStopRuntimeFeatures and ManageFeatureReleases are distinct registered capabilities for the
remaining stop and release routes. All four initially require the PM Administrator role (OD-2).
PR #4375 advances the policy version to application-authority.runtime-operations.2 and adds the
Administrator-only BreakGlassRuntimeDeploymentConstraint capability. Existing capability ordinals and the
legacy ManageRuntimeFeatureFlags name are preserved for older clients; the old aggregate is not a
fallback grant for these routes. Self-capabilities includes the new names without changing its string
DTO schema. Off and Shadow application-authority modes retain their existing claim behavior; Enforced
uses current PM authority, denies on unavailability, and authorizes the impersonated target rather than
the real actor. Successful mutations retain both identities in the audit record. Break-glass recovery is
the explicit exception: impersonation is refused before capability evaluation.
Flag decision: use the existing application-authority rollout modes; no new switch or role migration is needed for an operation split whose initial role mapping is unchanged. Production mutation refusal, Shared-mode legacy-write refusal, runtime defaults and saved review data are unchanged. Public bound preview/commit, stop routes, release management and browser presentation of separate capabilities remain follow-up integration work; registering a capability does not expose a new operation or activate a feature.
Bound emergency-stop API¶
PR #4355 adds POST /api/runtime-feature-flags/{key}/emergency-stop for explicit non-production Shared
API mode. It requires EmergencyStopRuntimeFeatures before policy access. Send mutationId, reason
and expectedIdentity copied from the authority observation, including environment, binding/store
epochs, catalog/baseline/policy digests and the decimal-string revision. The writer accepts only an
audited global safe-false consumer; it preserves saved baseline and override values and writes the
stop and immutable audit receipt atomically. Legacy and Shadow modes cannot write through this route.
A successful response contains mutationId and the committed identity; it means durably committed,
not Active or fleet-converged. Consumers observe the stop through their existing bounded refresh path.
A 409 runtime_policy_revision_conflict includes a decimal-string actualRevision: inspect the new
identity before deciding on a new mutation. A 503 runtime_policy_commit_unknown does not prove failure:
retry the same mutation ID and unchanged request, including reason and expected identity. An exact
replay returns its original receipt even after another administrator restores the policy, without
reapplying the old stop. Actor identity is part of idempotency; another actor cannot claim that receipt. A new mutation ID with the current expected identity records another revision even when the key is already stopped; idempotency is per mutation ID, not per key.
The request deadline is ten seconds; a caller that loses the response uses the same retry procedure.
Emergency stops bypass the deployment-constraint and new-audit-index gates as specified in the plan, but never bypass binding, lineage, receipt integrity or authorization. The API has no content or notification dependency and does not wait for a fleet acknowledgement. Restore remains the shared writer's tested ReleaseStop operation pending the reviewed public preview/commit workflow; this slice does not expose a restore route or enroll an environment. Production remains refused.
Reader corrections accompanying the route count time spent reading toward freshness, and treat malformed BSON/argument failures as unavailable so they cannot escape an observation refresh. The catalog digest includes descriptions and definitions: changing either invalidates an old imported authority. Until the catalog-evolution workflow exists, do not enroll or mutate against a changed catalog; regenerate/review an import preview before first enrollment. Never rewrite a stored digest to bypass validation.
Bound preview, commit and restore¶
PR #4356 adds POST /api/runtime-feature-flags/{key}/preview and
PUT /api/runtime-feature-flags/{key}/policy in explicit non-production Shared API mode. Both require
ChangeRuntimeFeaturePolicy before policy access. The additive /policy route has a typed, lossless
commit receipt; the legacy PUT keeps its existing response contract and remains refused in Shared mode.
Send mutationId, the complete decimal-revision expectedIdentity, nullable value, reason, optional
confirmedDependencies, and action (Change or ReleaseStop). Preview is read-only, including under
support impersonation: it writes no policy, receipt, index or lease. Dependency confirmation follows the
existing typed 422 response; submit the exact confirmed key/value set before accepting the final preview.
The response includes the proposed identity, saved override/baseline changes, before/proposed effective
values, requested/dependency/induced-pin causes, and previewDigest. FleetReadiness is explicitly Unknown
until expected hosts and acknowledgements are integrated. A preview is not a rollout approval.
Commit the same request plus previewDigest. The API requires that digest even locally. The store
recomputes it inside the same transaction that writes policy and receipt; it includes the request and
actor, exact authority identity, proposed policy, captured deployment flags and deployment-constraint
guard. Record, extend and release each increment that guard and invalidate outstanding previews,
even when the policy identity itself is unchanged. Changed requests or deployment inputs require a new preview; changed policy identity returns a
revision conflict. Staging also requires a preview at the shared-store boundary. Emergency stops retain
their independent capability/path and do not require preview, content, fleet or deployment-constraint
approval. A Change-policy request cannot smuggle an EmergencyStop action.
To restore, preview and commit ReleaseStop with null value and no dependency changes. Saved values
are preserved; effective values still obey deployed prerequisites. A no-op accepted under a new mutation
ID intentionally advances revision and audit; exact replay uses the original receipt. If a commit returns
503 or its response is lost, retry the same ID and unchanged request, including digest and actor context.
A missing receipt is not proof of abort while another host may still be committing. No environment is
enrolled by this change. Public UI integration, expected-host inventory, coordinator,
production admission, targeting and release/content integration remain required.
Resolving a lost mutation response¶
In explicit non-production Shared mode, an administrator with ChangeRuntimeFeaturePolicy can call
GET /api/runtime-feature-flags/mutations/{mutationId}. Committed returns the original
committedIdentity with its decimal-string revision, even after later changes or a stop restoration.
It confirms the durable receipt, not current policy or fleet adoption. Unknown has no committed identity
and does not prove an abort: retry the same mutation ID and unchanged original request. Do not mint a
replacement ID merely because lookup is Unknown. Storage failures return 503 instead of an outcome.
Outcome responses use Cache-Control: no-store so a previous Unknown result cannot be reused by a browser.
The lookup takes a read-only snapshot of the verified binding, current authority and receipts. It refuses foreign lineages, corrupt or future receipts, and missing current-authority evidence. It neither installs indexes nor runs migrations and remains available in the presence of a deployment constraint. Lineage-import IDs are not policy mutation IDs. Legacy and Shadow modes refuse this route. This adds no environment enrollment or production activation; the default mode remains Legacy.
Host acknowledgements and fleet inspection¶
In explicit non-production Shared mode, API and Project Management publish runtimePolicyHostAck after
startup reconciliation and each jittered 8–12 second poll. Reads retain the five-second deadline; an
acknowledgement write has a separate two-second deadline and cannot invalidate a valid local decision.
Each acknowledgement includes pod UID, process start identity/time, image version, catalog and full
policy identity, last successful authority check, state/error class, polling mode and last-seen time, and (since 6F PR A)
policySchemaVersions, the policy document schemas the binary reads ([1, 2]; a row without it means [1]).
Only checks younger than 30 seconds qualify as current. No user or project identifiers are included.
Publishing requires RuntimeFeatureFlags:InstanceUid (the pod UID, unique in the environment),
ImageVersion, positive ExpectedReplicas for that service, and explicit Autoscaled=false for a fixed
fleet. These are deployment metadata, not feature toggles. Missing UID/image/count produces no acknowledgement;
missing or invalid autoscaling metadata is treated as autoscaled. Migration 0011 must already have installed
the {service, lastSeenAtUtc} index. Publishing never creates indexes or runs migrations. Legacy and Shadow
never publish. A late write from an older process cannot replace a restarted process's record for the same UID.
The migration forward-rule guard explicitly enrolls this writer; its Mongo test pins the UID, service and
BSON date fields, idempotent replacement and absence of TTL. Retained reports have corresponding shape coverage for their environment, collection timestamp and TTL
expiry fields, and workload observations pin their service ID and BSON observation date with no TTL.
Collections without an enrolled writer remain catalogue-only.
GET /api/runtime-feature-flags/{key}/fleet requires InspectRuntimeFeaturePolicy, Shared mode and a
verified environment binding. Responses are not cached. The API needs explicit
RuntimeFeatureFlags:FleetExpectedReplicas:api and RuntimeFeatureFlags:FleetExpectedReplicas:project-management
counts matching each service's rendered ExpectedReplicas; no count is inferred from observed hosts.
For a synthetic two-API/two-PM exercise, both expected counts are 2 and each process has its own UID.
Missing or invalid expected inventory and any autoscaled host remain Unknown. A host's count disagreeing
with inventory requires action. Exact count, compatible full identity and fresh evidence from every host
are required for Active. Active means adoption of this policy, not that its value is On or that a user is authorized.
With incomplete acknowledgements the state is Saved/Applying during the 60-second confirmation window,
then Degraded. Per-host binding/catalog incompatibility is always reported; the aggregate prioritizes
Unknown when inventory is missing or autoscaled, otherwise incompatibility requires action immediately.
This slice can confirm the audited immediate membership control. Statistics controls remain Unknown until separate domain-readiness evidence is integrated. Rows have no TTL; missing or silent hosts never disappear from the evidence. Extra old pod UIDs also prevent Active. Trusted workload collection and explicit retirement of old UIDs, chart rendering of metadata/counts, production admission, the admin UI, metrics and the multi-replica 200-commit freshness exercise remain required before real enrollment. These trial timing bounds are not a measured production guarantee. The timestamp comparisons assume synchronized UTC clocks across the database and hosts; clock skew can make evidence Unknown. The 200-commit trial must measure skew and the worst-case poll/read/write margin before live enrollment. No live configuration is changed by this PR.
Rendering host metadata¶
The API and PM charts can set runtimePolicyHostMetadata.enabled: true to derive each host's four
acknowledgement metadata values from its own Deployment. The default is false and adds no variables.
UID comes from metadata.uid (not pod name), image version is the exact container image including a debug
suffix, expected replicas comes from replicaCount, and autoscaling comes from hpa.enabled. Raw env:
entries cannot override these observations when enabled, including case/colon aliases. The chart does not
select Shared mode or supply binding, migrations, or cross-service FleetExpectedReplicas values.
HPA remains explicitly autoscaled/Unknown. Scale-to-zero is allowed and renders zero, which does not publish a positive-count acknowledgement. Knative opt-in is refused pending trusted workload inventory. This is an opt-in deployment-metadata setting, not an additional runtime policy switch; the existing Legacy mode remains the default. No environment values or live deployments are changed. Trusted inventory must still reconcile cross-service desired counts and retire old pod UIDs before real enrollment.
Administrator deployment constraint recovery¶
PR #4375 exposes POST /api/environment/deployment-constraints/{rolloutId}/break-glass-release for a
stranded coordinator. This is a separate BreakGlassRuntimeDeploymentConstraint application capability,
assigned to Administrator; Change, Emergency stop and Manage releases are not fallback grants. Application
authority off/shadow uses the existing administrator claim decision; enforced mode uses current PM authority
and answers 503 when that authority is unavailable. A single authenticated human identity is required;
automation scopes, multiple authenticated identities and support impersonation are refused.
The request contains mutationId, the full expectedIdentity (decimal-string revision), a nonempty reason
(maximum 1,000 characters), and the reviewed expectedConstraintDigest. The bound store checks the exact
policy identity, active rollout and constraint digest, releases the constraint and writes the before-state,
real actor, reason and receipt in one majority-acknowledged transaction. Recovery does not assert rollout
completion, change feature policy, or enable a feature. It deliberately bypasses the owning coordinator and
completion-evidence requirements; it cannot cross an environment binding or authority lineage.
The flag decision is the existing deployment-only authority mode: this route is available only in explicit
non-production Shared API mode. Legacy and Shadow refuse it and retain all constraint/audit data.
It remains usable if the machine coordinator endpoint is disabled. No additional toggle or migration is
needed for this recovery path. Production Shared mode is still refused.
Requests are limited to 16 KiB and ten seconds; responses are private/no-store. A 409 requires inspection of current policy/constraint state. A 503 may mean a committed operation: retry the same mutation ID and unchanged request, including actor context. Exact replay returns the original timestamp without releasing a subsequent constraint. Do not substitute a new mutation ID merely because the response was lost.
The audit record is durable. Surfacing the last break-glass event in the fleet view and the administrator recovery UI remain ordered follow-ups; this slice provides the authenticated recovery operation only. Tests use synthetic replica-set data; no live constraint or production environment was changed.
Lossless browser snapshot ordering¶
PR #4376 adds revisionText, the invariant decimal representation of the snapshot's Int64 revision.
The numeric revision remains for existing clients. Bootstrap and the browser propagation service prefer
revisionText, validate a canonical nonnegative Int64, and compare using exact integers. A present malformed
string is refused even if the numeric field looks valid. Older servers remain readable only when their
numeric revision is a nonnegative JavaScript safe integer. Unsafe legacy numbers are never rounded into an
ordering decision.
An invalid bootstrap does not change deployed values. An invalid later response retains the last accepted snapshot, releases the initial-navigation wait and leaves future polls/reconnect fetches active. Adjacent revisions above 2^53 cannot overwrite each other in reverse order. SignalR remains an invalidation hint; its numeric payload is not used for ordering. No extra deployment flag is needed for this additive wire correction; runtime modes and feature decisions are unchanged, and no saved data is migrated. Full lineage/context identity, staleness UX and lossless administrator mutation requests remain follow-ups.
Production authority admission (default off)¶
PR #4377 introduces deployment-only RuntimeFeatureFlags:ProductionPolicyMode=Legacy|Runtime,
defaulting to Legacy. In production, Legacy resolves the authority provider to Legacy even when the
raw RuntimeFeatureFlags:Mode remains Shared. Returning to Runtime requires Shared mode, matching
production environment identities and explicit verified binding. The mode changes no saved policy,
baseline, receipt, audit or stop data. This supersedes the earlier blanket production Shared refusal;
it does not enroll a deployment or authorize activation.
Only reviewed production-eligible, global, false-safe contracts may use runtime decisions or mutations.
Currently this is disableMembership. Other flags retain normalized deployment values even if saved
runtime overrides exist or authority refresh fails. Statistics serving and maintenance remain deployment
controlled in production.
Ordinary enabling changes and stop releases that restore On require an exact preview digest and fresh
acknowledgements of the current policy from the configured fixed API/PM fleet, rechecked inside the
mutation transaction. Missing, stale, autoscaled or extra historical hosts refuse admission. Admission
follows RuntimeFeatureFlags:FleetInventoryMode, the same operator-supplied setting as fleet inspection,
which defaults to Static. Static admission counts every retained row, so it refuses after any Kubernetes
rollout until the old rows are removed. With Collector, admission reads the workload observations and
MongoDB's time in the same transaction and evaluates exactly as the inspect view does. Absent rows that
MongoDB has not seen for 35 seconds (15 minutes for legacy rows) are retired, so an enable or restore is
admitted about 35 seconds after a rollout's old pods stop. A live absent row, or missing or stale collector
evidence, still refuses. An unsupported mode value refuses. A passing preview does not establish adoption
of the candidate revision. Emergency stop remains independent of fleet readiness, in either mode, and
preserves saved overrides.
The code slice is tested with production-like binding identities in an isolated replica-set fixture. Production activation remains blocked on the B/C prerequisites: complete compatibility evidence, promotion preflight parity checks for returning to Legacy, recovery exercise, freshness trial and explicit operator approval. Pods start in Legacy without consulting authority parity; the promotion preflight must prevent an unsafe mode rollback. No chart, live binding, credentials or production state is changed here.
Browser lineage guard¶
PR #4378 adds an optional authority envelope to global Shared snapshots: schema version 1 and the
verified environment, binding/store epochs, exact decimal revision and catalog/baseline/policy digests.
Legacy snapshots carry no authority. No targeting rules or user/project identity are included.
The browser pins the first accepted provider mode and lineage for the page lifetime. A changed environment
or epoch, lost authority metadata, unsupported schema or conflicting equal revision stops further snapshot
application and exposes snapshotFault on the service. Current values and unsaved work are retained; no
forced reload occurs. Lower revisions are ignored; equal identities with identical values are idempotent.
Equal policy identities with different effective values also fault, covering deployment fallback drift.
A new page can bootstrap only from the server's verified reader; reconnect alone never acknowledges a new
lineage. Legacy-only revision behavior is preserved. SHA-256 digests accept the server's uppercase
hexadecimal representation and lowercase hex; case alone does not change a snapshot identity.
This is a global transport guard, not personalized authorization or a client security boundary. Server gates remain authoritative. Personalized request generations/context hashes and a user-facing recovery notice remain follow-ups; the service exposes the fault for that notice without destroying review state. No separate deployment flag is needed: authority metadata exists only in explicit Shared mode, and Legacy remains the default. No policy data, migrations or environment configuration change.
Feature registry (schema 2)¶
The generator also writes the feature inventory (docs/features/feature-inventory.md): counts by type and lifecycle, the complexity metrics (flags, evaluation seams, generators, commands and hand-edited files to add a flag, all computed from the sources), the features table, unclaimed flags and removed flags. It has no dates and no runtime state; "Ready to settle" is shown in each environment's admin page from its own history. Its diff in a PR is the reviewable complexity effect.
src/charts/syrf-common/feature-definitions.yaml is the one reviewed source for product features, the preset flag
groups and the deployment-managed pins. PR #4379 introduced it (schema 1); R1b of wave 6R (#4432) made it schema 2.
pnpm feature:new adds a feature (see Adding a New Feature Flag). After a hand edit,
run pnpm generate (or only generate:feature-definitions) and then pnpm run validate:generated. The generator validates the registry against the runtime flag catalog and writes:
| Output | What reads it |
|---|---|
SyRF.Mongo.Common/RuntimeFeatureReleases/FeatureDefinitions.generated.cs |
FeatureDefinitionCatalog.All (release preparation, 6D) |
src/services/web/src/app/generated/feature-definitions.generated.ts |
Read-only metadata (no web consumer yet) |
SyRF.Mongo.Common/RuntimeFeatureFlags/RuntimeFeatureFlagGroups.generated.cs |
RuntimeFeatureFlagGroups.All and Find |
SyRF.Mongo.Common/RuntimeFeatureFlags/RuntimeFeatureFlagPins.generated.cs |
ProjectStatisticsRuntimeActivation (pinned keys) |
src/services/web/src/assets/data/whats-new.json |
The home-page What's New feed (from docs/content/whats-new.yaml; see Update the What's New feed) |
CI checks source and output checksums and rejects omitted generators.
Feature fields¶
| Field | Required | Meaning |
|---|---|---|
featureId |
Yes | Stable, permanent ID (lower-case words joined by hyphens); never a flag key |
type |
Yes | Release, Operational, KillSwitch, Experiment or Permission. Sets the defaults below |
owner |
Yes | The team that owns the feature |
settleDays |
No | Overrides the type's settle period (whole days, 1 to 3650) |
grain |
Yes | Global, Project or Personal |
lifecycle |
Yes | Developing, Piloting, Released, Settled or Withdrawn (below) |
flags |
Yes | The flag keys this feature owns. A key belongs to at most one feature |
permission |
Release: yes | The one authorization policy using the feature requires: SignedIn, or an existing Project* or Application* policy name (below) |
compatibleWorkflowVersions |
Yes | Reviewed workflow versions; must be non-empty outside Developing |
topicIds |
Yes | Permanent guidance topic IDs (content lives in docs/content/release-content.yaml) |
capability |
Yes | Typed Flag/AllOf/AnyOf expression over the feature's own flags |
communication |
No | Overrides of the type's communication defaults; every field is optional |
permission says who may use the feature, so the availability endpoint (Feature availability,
6E.2) checks permission first and reports a feature only to callers allowed to use it. The value is SignedIn (any active, signed-in user) or the name of an
existing ASP.NET policy that starts Project or Application, for example ProjectEditMembershipsPolicy (the policy on
the route that disables a membership). A Stage* policy needs a stage, so it is refused. A Release must declare it;
other types may omit it, and a feature without it is never reported as available (it fails closed). The registry
validator checks the shape (pnpm run validate:registry); FeatureDefinitionPermissionTests in SyRF.API.Endpoint.Tests
proves the name is a registered policy, so a misspelt name fails the build. pnpm feature:new --type Release requires
--permission <value> and refuses, writing nothing, without it. permission is generated into FeatureDefinition.Permission
and the TypeScript definitions; it does not enter the runtime catalog digest. A release Prepared before this field
existed must be prepared again, because Apply refuses a changed definition.
communication fields: userGuide ({ kind: Required }, or { kind: Exempt, reason: "…" }), whatsNew
(announce or quiet), notify (releaseAudience, administrators or none), alertingOwner (defaults to
owner) and walkthroughId (a walkthroughs.yaml ID; optional and unchecked until walkthroughs ship in 6G.4).
The schema 1 guidance field is now communication.userGuide and is refused at the top level.
Defaults by type (owner decision, 2026-10-23). The generator resolves them, so the generated C# and TypeScript carry final values and no reader applies a default:
| Type | User guide | What's New | Notify | Settle period |
|---|---|---|---|---|
Release |
Required | announce |
releaseAudience |
30 days |
Operational, KillSwitch, Experiment, Permission |
Exempt ("<Type> control; no user-facing guidance") |
quiet |
none |
none |
A once-only quiet release of a Release feature is a release-time choice (6D PR A adds it to the Prepare and Apply
requests), not a registry edit.
Lifecycle. Developing, Piloting and Released describe a feature that still has a live flag. Settled
means the clean-up PR has merged: no flag and no branch remain, and the feature is a permanent part of the product.
Withdrawn means the feature was removed from the product. Both keep their former flags and capability keys as
history, and the validator refuses either if one of those keys is still in the runtime catalog. They replace the
schema 1 value Retired; in C#, FeatureLifecycle.Withdrawn keeps Retired's numeric value (3) and Settled is 4.
Release preparation refuses a Settled or Withdrawn feature. (Release content's own publication class Retired
is a different concept and is unchanged.)
Retirement is fact-based, with no dates in the registry. The registry holds the intent: the type and its settle
period. Each environment's own release history holds the fact: when the feature became available to everyone and
stayed unstopped. A Release feature is ready to settle once that has lasted longer than its settle period; the
admin page that lists it arrives later in 6R, and pnpm feature:settle does the clean-up. Nothing time-based ever fails a build. See §5A (6R.2, "Retirement is
fact-based") of the remaining delivery plan.
Flag groups and deployment-managed pins¶
flagGroups lists the preset groups served in the runtime snapshot (RuntimeFeatureFlagSnapshot.Groups) and
written by the legacy PUT api/runtime-feature-flags/groups/{key}. Each entry has key, name, description,
keys and an optional advertised (default true). The advertised order is the file order. A group with
advertised: false is a retained ID: an older browser snapshot may still submit it, so Find resolves it, but
All never lists it. Group IDs are versioned contracts: never change the keys of an existing ID. Add a new ID and
set the old one to advertised: false.
deploymentManaged lists the flags pinned to their deployed value because a service that never reads runtime
overrides consumes them (today reviewEligibilityPolicy). Each entry has key, constraint (shown on the admin
page) and refusal (the 400 message). The nine statistics maintenance gates stay in
ProjectStatisticsRuntimeActivation, where a parity test checks them against the domain family map.
None of these fields enter RuntimeFeatureFlagDefinition. Its fields feed the catalog digest that every stored
runtime policy identity carries, so RuntimeCatalogDigestPinTests pins the digest value; registry metadata goes on
FeatureDefinition only.
Complexity effect (R1b). Flags 55, caller evaluation seams 5 and generators 5 are unchanged. The hand-written
C# groups (RuntimeFeatureFlagGroups.cs data) and pins (PinnedKeys) are gone: a new group or pin is now one YAML
entry plus generate:feature-definitions, instead of a hand edit in C#. That removes 2 of the up-to-6 situational
hand edits for adding a flag. The cost is two more generated files, which validate:generated checks.
Feature registry validator¶
pnpm run validate:generated runs scripts/feature-registry.ts (alone: pnpm run validate:registry). It
fails on any broken cross-link and names the file and entry:
- Flag keys: every capability, owned
flagsanddeploymentManagedkey is in the runtime catalog; no key is owned by two features or pinned twice; a capability uses only its feature's ownflags. ASettledorWithdrawnfeature is the reverse: none of its keys may still be in the catalog. - Guidance: a feature whose user guide is Required (
communication.userGuide, or theReleasedefault) has a non-Draftdocs/content/release-content.yamlentry for each topic once it leavesDeveloping(a Developing feature only warns). Each contentfeatureId/topicIdresolves. - Links: each What's New
userGuidePathnames a publisheduser-guide/page by path (.htmlor.md) or front-matterpermalink:(a page with a permalink is reachable only there), and each optionalfeatureIdexists. The user-guide topic resolves: every public content entry of a Required feature names a Markdown file underuser-guide/.walkthroughIdis not checked until 6G.4. - Groups: members are catalogued and unique, and a group ID's keys never change compared with the base
commit:
HEADlocally (the pre-commit hook) andHEAD^1in GitHub Actions, which is the base branch tip of a pull request's merge ref (theValidate Generated Codejobs check out withfetch-depth: 2). Without a readable base it prints "group history unchecked". - Schemas:
env-mapping.yamlagainstenv-mapping.schema.json, and the generatedwhats-new.jsonagainstwhats-new.schema.json. - CI paths: every source it reads matches the PR Tests
Validate Generated Codechange regex and the pre-commitGENERATED_PATTERN, so a registry or guide edit cannot skip it. - Runtime What's New (6G.1): each
runtime:entry indocs/content/whats-new.yamlnames a feature whose resolvedcommunication.whatsNewisannounce, one of itstopicIdsand validstages, with at most one entry per feature and stage. Its body has no image, no raw HTML and onlyhttps://or user-guide links that resolve. A feature that announces needs an entry once it leavesDeveloping(a Developing feature only warns).
Feature IDs are independent of flag keys and permanent topic IDs. The initial definition is
project-membership-disable, owned by API maintainers, with the topic project-membership-status.
It is a Release that owns disableMembership and composes the disableMembership=true requirement.
Guidance is required (the Release default); the existing public
membership guide supplies background, but a content-manifest entry and reviewed workflow compatibility
are still needed before release. Its lifecycle is deliberately Developing with no workflow versions:
this catalog does not retroactively claim a release or invent a milestone date.
Capability expressions are typed Flag/AllOf/AnyOf, bounded to eight nested levels and 64 nodes. Unknown
flags, flags the feature does not own, scripts, empty expressions, duplicate IDs, unknown fields (including a
reviewBy date) and malformed guidance or communication fail generation. Missing flag
values never satisfy even a false requirement. Matching flags is only capability composition: it neither
authorizes a user nor confirms availability, publication or a release. Permission-first availability,
content publication classes and the release writer remain subsequent contracts. Do not place private
article text, audience lists or secrets in these compiled metadata files.
Flag decision: not flagged. These are build-time sources, generation and validation; a flag cannot switch them.
The groups, pins, REST golden and catalog digest are unchanged, which proves runtime behaviour is unchanged, and
the new FeatureDefinition fields have no consumer until 6D PR A, which sits behind RuntimeFeatureReleases:Enabled.
Existing feature decisions, policy, saved data and migrations are unchanged.
Release lifecycle (walkthrough)¶
A release moves one registry feature's flag requirement into effect in one environment and records when that
happened. Everything below sits behind RuntimeFeatureReleases__Enabled=true (default false in every environment) and
Shared authority mode, so production, which runs Legacy policy mode, cannot reach it. The steps, each detailed in the
subsection it links to:
- Prepare (Prepared release intents). Preview, then prepare with that digest. The
record is
Prepared(version 1) with aPreparedevent. No policy changes. - Apply (Applying a release). Preview the Apply against current policy, then apply with
that digest. One transaction commits the policy, moves the record to
Applying(version 2), and writesPolicyCommitted,AnnouncementQueuedand the outbox rows. Admission can refuse it (422). Apply is idempotent by release ID. - Confirm (Confirming activation). The reconciler on each Shared API host confirms the
stage milestone once the fleet is
Activefor the committed policy. The record becomesActive, getsconfirmedAtUtc(MongoDB time) and oneActivationConfirmed. If the fleet is not Active within 60 s the record isDegraded, and it is still confirmed once later. - Announce (Dispatching release announcements). Behind
RuntimeFeatureReleases__OutboxDispatch__Enabled=true(default false). The in-app notice waits for confirmation, then reaches the test sink in staging. Locally and in previews it endsCancelled/DeliveryDisabled, and in production the in-app notice handler completes it. The What's New row is completed by its handler in every environment once the release is confirmed, and availability then discloses the entry (6G.2). - Stop and restore (Stop and restore recording). An emergency stop or a pause uses
the ordinary policy path, which never reads release data. The reconciler then records
EmergencyStoppedorPaused, cancels the unsent notice once, and moves the record toPaused. A restore recordsRestored, then the record returns toActivewith the same milestone and no second confirmation. - Import history (Importing past releases). A reviewed operator command, run outside
startup, records a release that happened before release records existed:
imported: true, noappliedblock and no outbox rows. The reconciler ignores these records, and availability counts them as released. Imported records are outside RT-22.
Crash recovery (RT-22). A crash at any step resumes without duplicates. A lost Prepare or Apply response is
resolved by retrying the unchanged request with the same mutation ID, which returns the original receipt. A crash
inside Apply writes nothing, and its retry applies once. A crash inside a reconcile writes nothing, and the next pass
confirms once (ObservedAfterRecovery after a gap of more than three periods). A dead reconciler's lease expires after
30 s of MongoDB time, and a resumed holder whose lease has gone gets LeaseLost and writes nothing (the tested path; the version compare-and-set behind it is not separately driven). A dispatcher that dies holding
a row lets the lease expire after 60 s, and the row is leased again. A re-delivery after the handler ran hits the
handler's deduplication key, so the row is Done once. A stop that commits between confirmation and dispatch cancels
the notice once and sends nothing. RuntimeReleaseCrashRecoveryTests (SyRF.Mongo.Common.Tests, replica set) proves
each boundary. The release-lifecycle scenario of the runtime-fleet harness
(bash e2e/run-local.sh --runtime-fleet --scenario release-lifecycle, local stack only, run on Bramble) runs the whole
lifecycle through real API and Project Management processes, and SIGKILLs the API that holds the reconcile lease. It is
never part of all. It needs a feature the registry can release: on main, project-membership-disable declares no
compatibleWorkflowVersions, so its Apply preview refuses with WorkflowCompatibilityMissing and the scenario stops there. Its
release profile mounts the server help bundle compiled from the checkout's docs/content
(RuntimeFeatureReleases:GuidanceBundlePath), so an announced Apply finds the feature's runtime What's New entry (6G.1).
Before any production release (written prerequisites only; none is done by 6D): a releasable registry entry
(reviewed compatibleWorkflowVersions, and guidance Required with a public article or a reviewed exemption); the
compiled guidance bundle mounted on the API (RuntimeFeatureReleases:GuidanceBundlePath); ProductionPolicyMode and
RuntimeFeatureFlags:Mode=Shared in production, with production fleet admission; migrations 0011–0022 applied
and bound; and the release switches set per environment in cluster-gitops. The What's New and in-app notice handlers
exist (6G.2); the screens that show their result are 6G PRs C and D.
Prepared release intents¶
PR #4380 adds a preparation-only slice behind RuntimeFeatureReleases__Enabled=true (default false)
and Shared authority mode. It supports global features with one exact flag requirement. Preparation
records the reviewed intent; it does not activate policy, verify guidance, schedule execution, publish
content or create a confirmed milestone date. The initial Developing definition remains unreleased.
Preview and preparation require ManageFeatureReleases; retained reads require
InspectRuntimeFeaturePolicy or ManageFeatureReleases. Each permission check precedes mode, request or data inspection:
POST /api/feature-releases/{featureId}/previewreturns the exact release preview digest and expected and proposed policy identities. Required guidance is explicitlyUnverified.POST /api/feature-releases/{featureId}/preparerequires that preview digest and saves the intent plus itsPreparedevent atomically. The server binds the authenticated actor and environment.GET /api/feature-releases/{releaseId}reads the retained preparation in the current environment binding. This read remains available in Shared mode when preparation is disabled.
ReleaseKey identifies a logical release within one environment and feature: a lowercase kebab-case
identifier beginning with a letter, at most 100 characters. A new attempt for the same release retains its
key; a genuinely separate release uses a new key and release ID. Migration 0021 adds the unique
{environmentId, featureId, releaseKey} index alongside the unchanged 0011–0020 migrations.
Preparation refuses missing or incompatible release indexes; flags never provision them.
The preview covers release metadata, actor, policy candidate and the definition snapshot. Preparation serializes against policy, constraint and lineage changes through the existing authority constraint guard, without advancing the policy revision or changing values. Every new prepare advances that shared constraint guard, which the preview digest includes, so any other outstanding preview becomes stale and must be previewed again (an exact retry of an already prepared request returns its receipt and does not advance the guard). The record and receipt share one transaction. An exact mutation retry returns its original receipt; changed requests cannot reuse a mutation ID. An unknown commit response requires retrying the unchanged request, not assigning another mutation ID. Foreign binding or store epochs are refused.
requestedAtUtc is server time. An optional scheduledForUtc is intent only; confirmedAtUtc remains
null and milestones remain empty. Turning preparation off retains records and events; turning it on
again can resolve the original receipt. Preparation and Apply accept only a Developing or Piloting
feature; any other lifecycle is refused with 422 LifecycleNotReleasable (see below). No deployment or
production enablement is included.
Applying a release¶
6D PR A adds Apply behind the same RuntimeFeatureReleases__Enabled=true (default false everywhere) and
Shared authority mode. Production runs Legacy policy mode, so Apply is not reachable there. Both routes need
ManageFeatureReleases, checked before mode, request or data:
POST /api/feature-releases/{releaseId}/apply/previewis side-effect free. Body:{ mutationId, expectedIdentity, reason, announcement, quietReason }. It returns the Apply preview digest (domainsyrf-release-apply-v1, over the stored preparation result, the definition snapshot, the policy preview of the fixed flag change against current policy, the request, the announcements, the guidance result and the matched What's New entry), the expected and proposed policy identities with the policy changes, the guidance result (guidanceState:Verified,ExemptorUnverified;guidanceBundleDigest, the verified bundle's digest;guidanceTopics, the feature's topics found in it), the admission result (admitted,admissionRefusal) andqueuedAnnouncements. The digest also binds the guidance result and, for an Apply that queuesMaterialiseWhatsNew, the matched runtime What's New entry (itsentryIdandrevision) and the verified bundle's digest (#4473), so a changed bundle or entry needs a fresh preview. It is marked read-only safe for support impersonation.POST /api/feature-releases/{releaseId}/applytakes the same body plus thatpreviewDigestand returns the applied release:status: "Applying",version: "2", the committed policy identity,requestedAtUtc(from preparation),committedAtUtc, the announcement choice andconfirmedAtUtc: null. Apply is a change for impersonation.
One MongoDB transaction re-reads the binding, the authority and its receipt chain; resolves a replay by mutation
ID; requires a Prepared record whose definition snapshot equals the current definition; checks the expected
identity and the preview digest; runs admission; commits the policy through the same path as a policy commit
(policy preview digest, production fleet admission, deployment constraint, audit receipt with _id = the mutation
ID); moves the record from version 1 to 2 (status: "Applying", an applied block and an unconfirmed
milestones.{stage} entry with precision: "Instant"); inserts the "{mutationId}:PolicyCommitted" event; and
inserts the announcement rows with an "{mutationId}:AnnouncementQueued" event. A failure anywhere before commit
writes none of them. The mutation-outcome lookup resolves an Apply mutation ID like any policy mutation.
Idempotent by release ID. An exact retry returns the original receipt. The same mutation ID with a different body is refused ("Release mutation ID was reused with a different request."), and so is a second Apply of an applied release ("Only a Prepared release can be applied; this release is Applying."). After an unknown outcome, retry the unchanged request.
Policy drift. The flag change and the definition snapshot are fixed at preparation. If other policy changes have moved the revision since, preview the Apply again against current policy and apply with that digest. A stale identity returns 409; a stale or missing digest returns 400 ("The reviewed apply preview is missing or stale. Preview the apply against current policy and retry."). A changed definition is refused: "The feature definition changed after preparation; prepare a new release."
Announcements come from the feature's resolved registry communication settings: whatsNew: announce queues a
MaterialiseWhatsNew row and notify other than none queues a NotifyInApp row, so a Release queues both
and every other type queues none. announcement: "Quiet" (enum value 1) with a required quietReason queues none;
the preview shows the choice, the digest binds it and the record and PolicyCommitted event keep it. A quiet
reason without Quiet is refused. Rows are Pending, keyed
{environmentId}:{bindingEpoch}:{releaseId}:{stage}:{releaseId}:{stage}:1:{kind} (unique by migration 0016),
with requiresConfirmation: true and the committed identity as guardPolicy. The outbox dispatcher sends them
(see Dispatching release announcements), and the release stays Applying until
the reconciler confirms activation (below).
Admission refuses with 422 { code: "runtime_release_admission", reason, message }, checked in this order:
| Reason | Refused when |
|---|---|
LifecycleNotReleasable |
the feature is not Developing or Piloting |
WorkflowCompatibilityMissing |
the feature has no reviewed compatibleWorkflowVersions |
GuidanceBundleMissing |
the user guide is Required and no compiled server bundle is readable: RuntimeFeatureReleases:GuidanceBundlePath is unset (the default), or the file is absent or unreadable |
GuidanceBundleDigestMismatch |
the bundle does not verify: its file name is not server-<sha256>.json, the digest recomputed from its content differs from that name or from its embedded digest, or its format is not a compiled server bundle. Also, deliberately, when the release was prepared with a contentBundleDigest and the mounted bundle is any other bundle: such a release applies only with that exact bundle, because Apply must use the content that was reviewed. The pin applies to the guidance check and, for an Apply that queues MaterialiseWhatsNew, to the What's New check too, so a guidance-exempt feature that announces is pinned as well (#4473). After recompiling the content, prepare the release again |
GuidanceTopicMissing |
a topic in the feature's topicIds has no article for this feature in the bundle (or the feature names no topics) |
GuidancePublicationNotReleased |
a topic has no PublicPreview or PublicReleased article (Draft, PrivatePilot and Retired do not count) |
GuidanceWorkflowNotCovered |
a topic's public articles do not list every version in the feature's compatibleWorkflowVersions |
WhatsNewEntryMissing |
the Apply would queue MaterialiseWhatsNew (the feature announces and the Apply is not quiet) but the verified server bundle has no runtime What's New entry covering this feature and stage, or no bundle verifies. A quiet Apply with a reason (D13) needs no entry (6G.1) |
KeyStopped |
the release's flag is under an emergency stop at the current policy (the same stop test as confirmation and the dispatcher's guard; in production only an eligible key's stop counts). Restore the key with ReleaseStop first, then preview the Apply again: the restore moves the revision, so the earlier preview is stale anyway (#4456) |
IncompatibleHost |
any acknowledged host is incompatible (foreign catalog digest, binding or lineage) |
FleetActionRequired |
the fleet for the key reports Action required |
So project-membership-disable (Developing, no workflow versions, guidance Required) is refused today. The Apply
preview reports the same reason as admitted: false with admissionRefusal, and a refused Apply writes nothing. An
emergency stop never runs admission and never reads the bundle, so it commits while a release of its key is Prepared
or refused.
What's New admission (6G.1). An Apply that would queue MaterialiseWhatsNew reads the same verified server bundle,
before the session opens, and needs a runtime What's New entry (whatsNew in a schema 2 bundle, compiled from
docs/content/whats-new.yaml runtime:) whose featureId is the release's feature and whose stages include the
release's stage. Without one it is refused WhatsNewEntryMissing, at the Apply preview and at Apply, and nothing is
written. The same release applied with announcement: "Quiet" and a reason queues nothing and needs no entry, and a quiet
Apply of a guidance-exempt feature never reads the bundle. A schema 1 bundle (compiled before 6G.1) still verifies but has
no entries, so it admits only quiet or non-announcing releases. Flag decision: not separately flagged; this only tightens
the existing default-off RuntimeFeatureReleases:Enabled Apply.
Which entry admitted the Apply (#4473). The PolicyCommitted event has a whatsNew block beside guidance:
{ entryId, revision, bundleDigest, refusal: null } for an Apply that queued MaterialiseWhatsNew, and null for one
that queued none (quiet, or a feature that does not announce). It records the bundle digest even for a guidance-exempt
feature, whose guidance.bundleDigest is null. The same block, with its refusal, is part of the Apply preview digest.
The prepared-bundle pin (#4473 decision). A release prepared with a contentBundleDigest is admitted only against
that bundle for What's New as well as for guidance: one pin rule, so the event's whatsNew.bundleDigest is always the
bundle the release was prepared against when one was named. A mismatch refuses GuidanceBundleDigestMismatch; a quiet
Apply queues no What's New row, so it is not pinned by What's New. Leaving contentBundleDigest unset (the usual case)
pins nothing, and Apply records whichever verified bundle admitted it. The pin is about audit and consistency only:
availability always serves the current bundle's entry (see What's New and in-app notices),
so readers see edited text as soon as a new bundle is deployed, with its new whatsNew.revision.
Guidance admission (6D PR E, RT-26, decision D-Q6). When the feature's resolved communication.userGuide is
Required, Apply and its preview read the compiled server bundle that
pnpm run compile:release-content wrote (server-<sha256>.json, see
Audience-separated content artifacts) from the read-only path in
RuntimeFeatureReleases:GuidanceBundlePath (environment variable SYRF__RuntimeFeatureReleases__GuidanceBundlePath).
There is no separate expected-digest setting: the digest is the one in the content-addressed file name, re-verified by
recomputing the compiler's digest (SHA-256 of JSON.stringify of the payload without digest) from the parsed file, so
reformatting cannot hide an edit. The bundle is read and verified before Apply's MongoDB session opens, so file IO,
hashing and parsing never hold the transaction open; the transaction uses only that result. A release prepared with a contentBundleDigest verifies only against that bundle.
Every topic must have an article for the feature, published for the release's audience, and the articles must cover the
reviewed workflow versions. Admission checks topics and their publication class, not topic revisions: any article
revision in the verified bundle satisfies a topic. Pinning the reviewed revision belongs to 6G's protected revisions
(future work). A release reaches everyone (the Global audience is the only one until targeting, 6F), so
only PublicPreview and PublicReleased articles count. The file is read only for guidance-required features and
for Applies that would announce in What's New (6G.1). The last verified bundle is kept with the SHA-256 of its bytes
(each Apply still reads and hashes the file, but an unchanged file is not parsed again). It is one entry replaced by a
single reference write, so it is bounded without the racy clear it replaced (#4473); a second bundle loaded in the same
process only costs a re-parse. No failure throws: every
problem is one of the refusals above, so admission fails closed. Exempt features skip the check and record the
exemption. The PolicyCommitted event's guidance block records state, exemptionReason, bundleDigest and
topics. Flag decision: not separately flagged. It sits behind RuntimeFeatureReleases:Enabled and only makes
admission stricter; a switch that bypassed it would defeat OD-5.
Mounting the bundle (operator). Nothing changes until it is set. Compile the reviewed manifest into a private directory, put the server file in a Kubernetes Secret (it holds restricted content, so not a ConfigMap) keyed by its file name, and name the Secret in the API's values:
pnpm run compile:release-content docs/content/release-content.yaml user-guide /tmp/syrf-content-private /tmp/syrf-content-public
# One file, server-<sha256>.json; the Secret key must keep that name.
# cluster-gitops syrf/environments/<env>/api/values.yaml (not changed by this PR)
runtimeFeatureReleases:
guidanceBundle:
secretName: syrf-release-guidance # Secret holding the server bundle (for example from External Secrets)
fileName: server-<sha256>.json # the Secret key, exactly as the compiler named the file
The API chart then mounts the Secret read-only at /etc/syrf/release-guidance (defaultMode 0444) and sets
SYRF__RuntimeFeatureReleases__GuidanceBundlePath=/etc/syrf/release-guidance/<fileName>; fileName is required with
secretName. A Secret holds at most 1 MiB, which bounds the bundle this route can carry. A new bundle is a new file
name, so rotating it is a reviewed values change and a rollout. Apply refuses missing or incompatible release and outbox indexes (0012–0017, 0021)
before writing. Turning the switch off refuses new previews and Applies only: GET /api/feature-releases/{releaseId}
still reads the record, now with its current status and version, and turning it on again resolves the original
receipt. Flag decision: no new switch, because Prepare and Apply are one operator workflow behind
RuntimeFeatureReleases:Enabled.
Confirming activation¶
6D PR B adds the confirmation reconciler (RT-21). RuntimeReleaseReconcilerService runs in each API process only
where RuntimeFeatureFlags:Mode=Shared (the runtime-policy writer; decision D11). It is absent in Legacy mode,
in production while ProductionPolicyMode is Legacy, and in Project Management. Every 10 s ± 2 s it lists this
binding's applied releases (Applying, Degraded, ActionRequired, Active or Paused with an applied block,
never an imported one, through the {environmentId, status} index) and reconciles each one under its own lease:
- Lease.
reconcileLease: { token, owner, expiresAtUtc }on the record, taken withfindOneAndUpdateonly when absent or expired by MongoDB's own clock ($$NOW), for 30 s. The commit releases it. Leases avoid duplicate work; the version compare-and-set (with the lease token in the same filter) and the unique event index make confirmation exactly-once, so any number of API replicas is safe. - One transaction. It reads MongoDB's time first, then the binding, the authority and its receipt chain, then
the record. Every decision uses that MongoDB time and stored evidence only, never the API host's clock: the
fleet's freshness checks, the 60 s window and the recovery gap. So a skewed API clock, or which replica holds the
lease, changes neither a decision nor a confirmed instant (decision D12). Host acknowledgements still carry each
host's own clock, as before; a host whose clock is far from MongoDB's reads
Unknownand blocks confirmation. - Confirm when all of these hold: the status is
Applying,DegradedorActionRequired; the stage's milestone is unconfirmed; the feature's reviewed flag requirement (from the record's definition snapshot) holds for the current policy and its key is not stopped; the current store epoch equals the applied one with a revision at or above it; and the fleet for the key isActiveat the current policy (every expected hostCurrent; see Host acknowledgements and fleet inspection). It then setsmilestones.{stage}.confirmedAtUtcandrecordedAtUtcto the transaction's MongoDB time,confirmedPolicyto the current identity andsemanticstoObservedActivation, orObservedAfterRecoverywhen more than 30 s (three periods) passed since the last reconcile (or since the commit, if none ran): the instant is when MongoDB observed activation, never an earlier guess. The record becomesActiveand the event"{applyMutationId}/{releaseId}:ActivationConfirmed"recordsconfirmedBy(the pod'sRuntimeFeatureFlags:InstanceUid), the confirmed policy and theCurrenthosts. A confirmed milestone is never rewritten. - Degrade. An
Applyingrelease whose fleet is notActive60 s after its commit (or, after a restore, 60 s after the restore was recorded) becomesDegraded, orActionRequiredwhen the fleet reports Action required. No milestone or event is written, and reconciliation continues, so a laterActivefleet still confirms once. - Observation. For an unconfirmed release, every pass records
lastReconciledAtUtc(the heartbeat the recovery gap is measured from),lastFleetStateandfleetReason. A confirmedActiveorPausedrelease is evaluated without a lease and is written only when a new policy receipt exists or its fleet state or reason changes, so a stable release keeps its version and is not rewritten every pass. It needs no heartbeat: its milestone is never written again.
Only disableMembership can reach a fleet Active today; any other key reads Unknown
(DomainReadinessNotEstablished), so its release stays Applying and then Degraded, and never confirms.
GET /api/feature-releases/{releaseId} returns milestones (keyed by stage) and confirmedAtUtc from them.
A pass that cannot list releases (release indexes 0012–0015 or 0021, the audit indexes 0009–0010 or the
outbox index 0022 missing or incompatible, an unverified binding) logs one warning and writes nothing; a failed
release is logged and retried on the next pass. Flag decision: not separately flagged. It only converges releases
an operator already applied, which must continue when RuntimeFeatureReleases:Enabled goes off; it does nothing
without applied records and is absent in Legacy mode.
Stop and restore recording¶
6D PR D records stops and restores in the release's history (RT-24). The stop path does not change: an emergency
stop, a pause (Change to off) and a restore (ReleaseStop, or Change back on) still go through the ordinary
policy commit, which reads no release record, event, outbox row or document. An emergency stop therefore commits with
the release, event and outbox indexes dropped, the dispatcher failing and the reconciler stopped; the record catches
up on the next pass after they return (RuntimeReleaseStopRestoreTests, replica set).
In the same reconcile transaction, before confirming anything, the reconciler reads the policy receipts after the
record's applied.lastObservedRevision in {storeEpoch, revision} order (audit index 0010), at most 100 per pass,
and advances lastObservedRevision past them. For each receipt that changes whether the release's capability holds
(the same test as confirmation: the reviewed flag value and no stop on its key), it appends one event:
| Receipt | Event | Record status |
|---|---|---|
EmergencyStop that stops the capability |
EmergencyStopped |
Paused |
Any other change that stops it (a Change to off) |
Paused |
Paused |
ReleaseStop, or a Change, that makes it hold again |
Restored |
Applying, then Active once the fleet is Active |
A receipt that changes neither (an unrelated key, a second stop while stopped) appends nothing. Each event's ID is
"{receiptMutationId}/{releaseId}:{type}", so it is exactly-once by the 0013 index, and it records
sourceMutationId, sourceRevision, action, key, stage, occurredAtUtc (the receipt's own recordedAtUtc),
occurredPrecision: "Instant", provenance: "Observed", observedPolicy, observedBy and the status before and
after. A stop and its restore inside one reconcile period are both recorded, in revision order.
Milestones are never rewritten. A restored release whose milestone was already confirmed goes back to Active
once the fleet is Active for the restored policy, with no second ActivationConfirmed; its confirmedAtUtc,
requestedAtUtc, release ID and announcement ID stay byte-identical. A release stopped before it was confirmed goes
Paused rather than staying Applying, and is confirmed once, as usual, after its restore. Nothing is confirmed
while the history scan is behind (more than 100 receipts pending). A restore sets lastRestoredAtUtc, from which the
60 s confirmation window then runs.
History assumes the capability held at the Apply revision. That holds because Apply refuses a key under an emergency
stop (KeyStopped, #4456); before that refusal, a release applied
while its key was stopped was recorded as Paused at the next, unrelated policy receipt. If the dispatcher leases an unsent
notice between the reconciler's read and its cancellation, the reconciler's compare-and-set fails and the whole stop
recording rolls back; the next pass records it, so Paused can appear one reconcile period late.
Stop-time cancellation. In the transaction that records a stop, the reconciler cancels the release's unsent
in-app notices (Pending NotifyInApp rows, found through the {releaseId, state} index 0022) exactly as the
dispatcher's guard does: Cancelled, cancelReason: "CapabilityStopped", cancelledByMutationId and one
"{firstStopMutationId}/{releaseId}:AnnouncementCancelled" event, where the first stop is the first receipt since
Apply that stopped the capability. Both writers derive that same ID and insert the event only if it is absent, so a
notice is cancelled once with one event, whichever sees the stop first. A Leased row is left to the dispatcher
holding it, whose guard cancels it under the same ID. MaterialiseWhatsNew rows are not cancelled: the dispatcher
defers them while stopped. A restore never requeues a cancelled notice (D-Q5); a new notification needs a new
announcement revision. Flag decision: not flagged, because it is part of the reconciler (absent in Legacy mode) and
the stop path does not change.
Malformed notices (#4458). A Pending NotifyInApp row that
does not parse (no guardPolicy, a guardPolicy without a GUID storeEpoch and an integer revision, or anything the
dispatcher's row parse rejects) is poison. The stop-time cancellation skips it, so the stop event and Paused are still
recorded and the well-formed notices still cancelled. After that transaction commits, the reconciler marks the row
terminal in its own write: state: "Failed", lastErrorClass: "MalformedRow", failedAtUtc from $$NOW, with
attempts untouched. It logs the row ID at Error. Later passes do not see the row again. Rows are only written by
Apply, so this arises only from a bug or a manual edit. If the process dies between the commit and that write, or the
dispatcher leased the row meanwhile, the dispatcher's guard marks it the same way.
Importing past releases¶
6D PR F adds a reviewed operator command for releases that happened before release records existed (RT-27, plan
§8.5), RuntimeReleaseHistoryEntryPoint. Like the lineage command it runs
the API image's normal entry point before startup, never as part of startup, migrations or a flag. It is a written
operational prerequisite only: it has not been run in any shared environment, and the operator procedure is
runbook 11. Flag decision: not flagged,
because, like initialise, it does nothing unless an operator runs it.
The reviewed input is a JSON file with exactly these fields:
| Field | Value |
|---|---|
featureId |
a feature in the generated feature definition catalog (any lifecycle) |
releaseKey |
lowercase kebab-case, beginning with a letter, at most 100 characters |
stage |
LimitedRelease or GeneralRelease |
precision |
Date, Month or Unknown |
occurredOn |
yyyy-MM-dd for Date, yyyy-MM for Month, absent or null for Unknown; never in the future |
source |
where the date comes from, for example the whats-new.json entry (at most 1000 characters) |
reviewer |
who reviewed it (at most 200 characters) |
SYRF_RUNTIME_RELEASE_HISTORY_MODE=preview
SYRF_RUNTIME_RELEASE_HISTORY_INPUT=/reviewed/history-input.json
SYRF_RUNTIME_RELEASE_HISTORY_OUTPUT=/reviewed/history-preview.json
plus the Mongo configuration, RuntimeEnvironment and the SYRF_MIGRATION_BINDING_EXPECTED_* binding expectations,
as for the lineage command. The preview is read-only and deterministic: the release ID and the mutation ID are derived
from the binding, the feature and the release key, so the same input in the same binding always gives a
byte-identical plan. It prints the exact record and event documents and the previewDigest; apply fills only the
fields listed in its applyFills (recordedAtUtc, importedBy and previewDigest), which are null in the preview.
SYRF_RUNTIME_RELEASE_HISTORY_MODE=apply
SYRF_RUNTIME_RELEASE_HISTORY_INPUT=/reviewed/history-preview.json
SYRF_RUNTIME_RELEASE_HISTORY_OUTPUT=/reviewed/history-receipt.json
SYRF_RUNTIME_RELEASE_HISTORY_ACTOR=<operator identity>
Apply recomputes the plan from its input against the current binding and catalog and refuses unless the reviewed
digest still matches. It needs the migration-owned release indexes (0012–0015, 0021) and never creates one. In
one transaction it writes:
- the record:
status: "Active",version: 1,imported: true, noappliedblock, andmilestones.{stage}withsemantics: "Imported", theprecision, the reviewedoccurredOnliteral andconfirmedAtUtcset to the first instant (UTC) of that day or month, or null forUnknown; - one
"{mutationId}:HistoricalImported"event:provenance: "Imported",provenanceDetail: { source, reviewer },semantics: "Imported",occurredAtUtc,occurredPrecision,occurredOn,importedByand thepreviewDigest.
It writes no policy, no audit row and no outbox row: nobody is notified about a past release. The confirmation
reconciler lists only records with an applied block and without imported: true, and skips an imported record it
is asked to reconcile by ID, so it never leases or writes one. Apply and its preview
refuse an imported release ("An imported historical release is history only; it cannot be applied."), and
GET /api/feature-releases/{releaseId}, which reads a release's preparation, returns 404 for one. An imported key
also blocks a later preparation with the same key.
An exact replay of the same preview returns the original receipt and writes nothing, so after an unknown outcome retry the identical preview with a new output path. Refusals (exit 2, nothing written): an unknown feature ("Unknown feature: …"), a modified preview ("The reviewed import preview was modified …"), a stale one, for example after a rebind ("The reviewed import preview is stale: …"), a different import or a prepared release holding the same key ("A release with this feature and release key already exists …"), invalid input, a missing index or actor, an existing output path, and a combined migration or lineage mode.
Reading releases (admin Releases screen)¶
6G PR AD2 adds the read routes the admin Releases screen uses. They are read-only, need InspectRuntimeFeaturePolicy
or ManageFeatureReleases (either one is enough; a caller with neither gets 403, and the check precedes any read), and
sit behind RuntimeFeatureReleases:Enabled: while it is false the list and events routes answer 404
feature_availability_disabled before authorization and read nothing. Every query is filtered to the current
environment binding (environmentId plus bindingEpoch), so a release, event or announcement row written under another
epoch or environment never appears. Nothing here writes, and there is no migration.
GET /api/feature-releases?featureId=&status=&before=lists this binding's releases, newest first (requestedAtUtcdescending, an imported release last, then release ID), at most 50.statusis a record status (Prepared,Applying,Active,Degraded,ActionRequired,Paused); an unknown one is 400. The response is{ releases, nextBefore }: passnextBeforeasbeforefor the next page (absent on the last page); abeforethat is not a release of this binding is 400. Each item carries the feature, release key, stage, status, version,imported, milestones,confirmedAtUtcand itsannouncements. An imported release lists here (withimported: trueand norequestedAtUtc) although its detail GET is 404. The query uses migration0012(environmentId,status), or0021(environmentId,featureId,releaseKey) whenfeatureIdis given, by hint, and sorts the binding's releases in memory; an explain test pins both plans to an index scan.GET /api/feature-releases/{releaseId}/eventsreturns{ releaseId, events, truncated }: the release's events in recorded order through0014, at most 200 (truncatedsays more exist). An event shows its type, recorded and occurred times, stage, provenance and, forPolicyCommitted, the Apply reason; no actor, request body or digest. A release outside this binding, or unknown, is 404.GET /api/feature-releases/{releaseId}gainsannouncements: [{ kind, state, attempts, lastErrorClass, cancelReason, completedAtUtc, testSink }], read from the release outbox through0022.testSinkis whether a finished delivery went to a test sink (null until something is delivered). While the switch is off the block is omitted and the outbox is not read; the rest of the detail is unchanged.
Dispatching release announcements¶
6D PR C adds the release outbox dispatcher (RT-23, RT-25) behind its own switch,
RuntimeFeatureReleases__OutboxDispatch__Enabled=true (RuntimeFeatureReleases:OutboxDispatch:Enabled, default false
everywhere). It is a plain configuration switch, like RuntimeFeatureReleases:Enabled, not a catalogued runtime flag.
The hosted service RuntimeReleaseOutboxDispatcherService is registered only on an API host in Shared authority mode
(the one writer host); Legacy (production today) and Shadow hosts never run it. Every 10 s ± 2 s it dispatches up to
20 due rows:
- Lease. One
findOneAndUpdateagainst MongoDB's clock takes aPendingrow, or aLeasedrow whose lease has expired, withnextAttemptAtUtc ≤ $$NOW, and setsstate: "Leased",lease: { token, owner, leasedAtUtc }andnextAttemptAtUtc = $$NOW + 60 s, so a holder that dies releases the row through the same0017scan. Only rows this host can route are leased; a kind with no handler staysPendingand visible. - Guard, in one snapshot transaction before any side effect, in this order:
| Check | Outcome |
|---|---|
The row does not parse (wrong schemaVersion, a deduplication key that does not match, a missing field) |
terminal Failed, lastErrorClass: "MalformedRow", logged at Error |
| The row's environment or binding epoch differs from the current binding (a restored copy) | Cancelled, cancelReason: "BindingChanged" |
An outbound kind (NotifyInApp) under announcementDelivery: disabled (previews, local stacks, rebound copies) |
Cancelled, "DeliveryDisabled" |
No guardPolicy, or one without a GUID storeEpoch and an integer revision |
terminal Failed, "MalformedRow", logged at Error |
NotifyInApp, and some policy revision since Apply stopped the release's capability (emergency stop or a change that turns it off) |
Cancelled, "CapabilityStopped", plus one "{stopMutationId}/{releaseId}:AnnouncementCancelled" event; it stays cancelled after a restore (decision D14) |
MaterialiseWhatsNew while the capability does not hold |
deferred ("CapabilityNotHeld"); What's New returns with its original date once restored |
The release is not Active, or its milestone is unconfirmed |
deferred ("AwaitingConfirmation") |
Deferral sets Pending, deferrals + 1 and nextAttemptAtUtc = $$NOW + backoff; it does not count as an attempt.
A malformed row is poison (#4458): failedAtUtc from $$NOW,
attempts untouched, the pass outcome Quarantined, and it is never leased again. The binding checks run between
the two malformed-row checks, so a restored copy's row is still cancelled BindingChanged.
3. Handle. Routing follows the binding's announcementDelivery: an outbound kind under testSink (staging)
reaches only the test-sink handler, under production only a real handler, under disabled nothing. Handlers are
idempotent by the deduplication key.
4. Complete by compare-and-set on the lease token: Done, completedAtUtc, attempts + 1 and result. A handler
failure, including the handler's own timeout or cancellation, sets attempts + 1, lastErrorClass and Pending
after the backoff; the 20th makes the row terminal Failed (failedAtUtc). A guard that cannot run (authority not
initialised, binding changed mid-pass, the release record missing) sets guardErrors + 1 and lastGuardErrorClass
and backs off without touching attempts, so no outage can make a row Failed. A malformed row is not a guard
error: the guard marks it MalformedRow (step 2). Only a shutdown of the pass itself leaves the
row leased until its lease expires. An operator requeue is a follow-up.
Readiness (the indexes and the binding) is checked once per pass, and each row's guard still re-reads the binding in its own transaction.
The backoff is 15 s, doubling per failure or deferral, capped at 1 hour. A stop is never delayed by any of this: the stop path does not read the outbox.
Handlers. The API registers one handler per kind and audience (the dispatcher refuses two):
| Handler | Kind | Test sink | Effect |
|---|---|---|---|
RuntimeReleaseTestSinkHandler |
NotifyInApp |
yes | inserts { _id: dedupKey, kind, releaseId, environmentId, bindingEpoch, announcementId, announcementRevision, deliveredAtUtc } into runtimeFeatureReleaseTestSink if absent |
RuntimeReleaseWhatsNewHandler (6G.2) |
MaterialiseWhatsNew |
no | none: completing the row is the record availability reads |
RuntimeReleaseInAppNoticeHandler (6G.2) |
NotifyInApp |
no | none: completing the row (result.testSink: false) is what availability reports as delivered |
All three are idempotent, so a repeated delivery changes nothing. So in staging an in-app notice ends Done with one
sink row (and is never reported as delivered); in previews and local stacks it ends Cancelled/DeliveryDisabled; in
production it ends Done by the in-app notice handler. MaterialiseWhatsNew is not outbound, so it ends Done by its
handler in every environment once the release is confirmed (deferred while the capability does not hold). There is no
projection store: the outbox rows are the record of what was announced.
Retention. Done, Cancelled and Failed rows are never deleted: there is no TTL index, no deleteAfterUtc and no
clean-up job (ADR-023, forward only). Migration 0022 adds {releaseId, state} for the bounded per-release scan.
Dispatch refuses to run (logs and writes nothing) until 0009, 0010, 0013, 0016, 0017 and 0022 exist, and
refuses if the outbox has any TTL index.
Switching. Off, a pass reads and writes nothing, so rows stay Pending and kept. On, the rows are dispatched under
the same guards, so a notice for a release that was stopped meanwhile is cancelled rather than sent. Off, on, off, on
delivers nothing twice (RuntimeReleaseOutboxDispatchTests, replica set). Flag decision: flagged, because this is the
only 6D component with outbound side effects and it needs its own kill switch; the binding's announcementDelivery is a
separate hard gate, not a flag.
Feature availability¶
6E.2 answers "which released features can this person use here" for discovery surfaces (What's New and walkthroughs,
6G and 6G.4). It is a discovery fact, not a gate: UI and route gates keep reading the flag snapshot and
IFeatureDecisions, and availability reads the same decisions, so there is no second evaluation path.
| Route | Admission |
|---|---|
GET /api/features/availability |
Signed in. Features whose permission is a Project* policy are omitted without being evaluated (D25 option A) |
GET /api/projects/{projectId}/features/availability |
ProjectViewPolicy, before the action runs (switch on), so an absent or deleted project answers exactly as every other ProjectView route (404, or 410 project-deleted for an active member) |
Both answer Cache-Control: private, no-store. The server, per request:
- Takes the registry's features that are not
Withdrawnand declare apermission. - Permission first. Authorises each distinct policy once with
IAuthorizationService(the existing handlers and gates;SignedInpasses for a signed-in caller). A feature the caller may not use is omitted, never reported with a reason, so the body is byte-identical to one from a registry without it. If a check finds the response already started (a deletion racing the request), the handler's answer stands. - Makes one
IFeatureDecisions.DecideAllover the union of the remaining features' flag keys. - Reads the released records of exactly those features (
RuntimeReleaseStateReader), only when aGlobalfeature that is notSettledis among them (Developing/Pilotinganswers depend on the record, and aReleasedfeature'sreleaseandannouncementfeed What's New and "New to you"): per feature, the latest record in the captured authority binding (environment and binding epoch) with a confirmed stage milestone, or an importedActiverecord (Unknown precision included). GeneralRelease beats LimitedRelease, then the later confirmation. One bounded query (at most 1000 rows, projection only, majority read), cached per binding for 10 s, so a process issues at most one release query per 10 s. A record is announced only when it was applied with announcement modeDefaultand queued at least one kind (6G.2: a Default-mode release of a feature that is quiet with no notice announces nothing). In the same cached read, when an announced record is among them, one more query reads their outbox rows by_id $in(the deduplication keys, at most two per record; projectionstateandresult.testSink), so a process issues at most one outbox query per 10 s too. A failed outbox query keeps the released records, reports nothing as done or delivered, logs a Warning and makes the responseDegraded. Any failure of that query other than a cancelled caller (a Mongo error, a malformed record, an unexpected exception type) returns the reader'sFailure, and the failure is cached for the same 10 s, so a bad read never becomes a 500. The failure is logged once per period at Warning with the exception and its type. Legacy mode has no binding, so nothing counts as released there. A response whose permitted features are allSettledor notGlobaldoes not call the reader, so it isCurrentwhether or not the read would have failed. - Evaluates, first matching row wins:
| Condition | availability |
reason |
|---|---|---|
Grain not Global (until 6F) |
unknown |
policyUnavailable |
Settled |
available |
null |
A decision is Stale |
unknown |
staleAuthority |
| Any other decision error | unknown |
policyUnavailable |
Capability false and a key Stopped |
unavailable |
stopped |
| Capability false | unavailable |
notReleased |
Capability true, Released |
available |
null |
Capability true, Developing/Piloting, released record |
available |
null |
Capability true, Developing/Piloting, no released record |
unavailable |
notReleased |
Capability true, Developing/Piloting, release read failed |
unknown |
policyUnavailable |
The response carries contextId (sha256: over the environment and binding, user, project and every field except
checkedAtUtc, including rehearsal, delivered and each whatsNew entry; an equality token, never recomputed by the
browser), policyVersion, rehearsal (6G.2, below), freshness (checkedAtUtc, and state Current or Degraded
when a reported decision, the release read, the outbox read, the discovery state read or a needed bundle read failed)
and features ordered by featureId: featureId, availability, reason, release (stage, releasedAtUtc,
precision; null without a released record), announcement ({releaseId}:{stage}, revision 1, delivered, and
seen and dismissed from the caller's own discovery state below; null for a quiet, imported or unannounced release)
and whatsNew (below). It never carries a flag key, store epoch, digest or targeting (RT-60), pinned by the
FeatureAvailabilityResponse golden, FeatureAvailabilityContractTests and the endpoint catalogue's response-field
pin.
Switching. Behind RuntimeFeatureReleases:Enabled (default false; false in every shared environment): off, both
routes answer 404 { "code": "feature_availability_disabled" } and read nothing. The answer comes from
RuntimeFeatureReleasesSwitchMiddleware, which Program.cs runs after authentication and before UseAuthorization
(it reads the [RuntimeFeatureReleasesEnabled] endpoint metadata), so on the project route ProjectViewPolicy never
runs and the project is never loaded: a non-member, a member, an administrator and an anonymous caller get the same disabled body (a subject that subject admission refuses, with applicationSuspension on, still gets that refusal, because admission runs first; the 401 challenge comes from authorization, which has not run; with the switch on, anonymous gets 401). The
attribute's resource filter is the backstop where the middleware is absent. It is read-only and stateless, so
on, off, on gives the same answers with any data (FeatureAvailabilityHttpTests). Flag decision: it shares the release
switch rather than adding a flag, because availability exists only to serve release discovery. Enabling it in an
environment also needs RuntimeFeatureFlags:Mode=Shared and a bound environment; production stays Legacy and off.
Seen and dismissed announcements¶
6E.3 remembers, per person, that they have seen or dismissed one announcement (OD-14), so 6G's "New to you" and 6G.4's walkthroughs do not show it again:
PUT /api/account/announcements/{announcementId}/state
{ "revision": 1, "seen": true, "dismissed": true }
- Validation is by format only:
{releaseId}:{LimitedRelease|GeneralRelease}(theannouncement.idavailability reports),revisionat least 1, and at least one ofseenanddismissedtrue. Anything else is 400, the same 400 whether or not the announcement exists. - It writes only when the announcement is one availability reports now: the latest released, non-quiet,
non-imported record of a feature in this authority binding (read from the same cached
RuntimeReleaseStateReaderread, no second evaluation path). An unknown, quiet, imported, superseded or foreign-binding announcement, and Legacy mode, write nothing. Either way the answer is 204, so the route never says whether a release exists. - A
revisionahead of the announcement's current one also gets 204 and writes nothing, so a client cannot record a future revision to hide its own later re-notification. - No permission or project check. The row is the caller's own preference, read back only through availability, which omits every feature the caller may not use before it joins the rows. A dismissal of a feature the caller cannot use is stored and never returned, and their availability body does not change.
- Storage:
runtimeFeatureDiscoveryState, one document per person and announcement,_id = "{userId:D}:{announcementId}"(MongoDB reference), written by one upsert by_idwith MongoDB$$NOWtimestamps. The same revision keeps the firstseenAtUtcanddismissedAtUtc(retries and races leave one row); a higher revision replaces both (re-notification); a lower one is ignored. There is no un-dismiss. No secondary index, so no migration. - Join: availability reads the caller's rows by
_id($inover exactly the announcements in its response).seenanddismissedare true only for a row stored at a revision at least the announcement's. A failed read reports both false andfreshness.state: Degraded. - Impersonation: the PUT is a change, so read-only support impersonation refuses it; under an edit context it
writes the target's row, never the actor's (
SupportImpersonationContractTests). - Switching: the same
RuntimeFeatureReleases:Enabledswitch. Off, the PUT answers 404feature_availability_disabledbefore reading the body; rows are kept and ignored, and apply again when it is on. - Account deletion keeps these rows today, like every other per-user store (D27, Chris chose A on 2026-10-23); erasure belongs to the OD-6 subject-deletion work.
Tests: FeatureAvailabilityHttpTests (RT-41 API half, no leak and no junk with row counts, the switch round trip),
RuntimeFeatureDiscoveryStoreTests (replica set: shape, races, revisions), FeatureAvailabilityEvaluatorTests (the
join) and the impersonation suites.
What's New and in-app notices¶
6G.2 discloses runtime What's New text through availability, the one disclosure gate: there is no second personalised endpoint. Each feature entry may carry:
"announcement": { "id": "<releaseId>:LimitedRelease", "revision": 1, "delivered": true, "seen": false, "dismissed": false },
"whatsNew": { "id": "project-membership-disable", "revision": "sha256:…", "title": "…", "body": "…Markdown…",
"topicId": "project-membership-status", "guidePath": "projects/membership/members-groups.html#disabling-and-re-enabling-a-membership" }
whatsNewis non-null only when all of these hold: the feature isavailableto the caller (so permission came first, and a stopped feature isunavailable); its release is announced withMaterialiseWhatsNewamong its queued kinds; that outbox row isDone; and the current verified bundle has a runtime entry covering the release's stage. A confirmed release whose row is stillPending(or deferred) shows nothing. A stop hides the entry at once; a restore shows it again with the originalrelease.releasedAtUtc. A missing or unverifiable bundle giveswhatsNew: nullandDegraded; a verified bundle without an entry for the stage givesnulland staysCurrent.revisionissha256:and the entry's hash from the current bundle; no route takes a revision, and deploying a new bundle changesrevisionandcontextIdon the next read. The body is Markdown, which the browser renders only throughMarkdownPipeinuntrustedmode (6G PR D).announcement.delivered("New to you") is true only when the release'sNotifyInApprow isDoneby a handler that is not a test sink (result.testSink: false) and the feature'snotifyisreleaseAudience(anadministratorsnotice belongs to the admin Releases screen, 6G.11). A cancelled notice is neverDone, so after a stop and restore it stays false (D14). Staging's sink, previews and local stacks never deliver one.rehearsalis true when the captured binding's announcement delivery is notproduction(staging, previews, local stacks), so the screens label runtime entries "Rehearsal" (OD-19). The delivery is derived from the environment ID exactly as the binding record derives it, so it costs no read; Legacy mode has no binding and isfalse. A test fixture that rewrites a staging binding record's delivery toproductionstill reportsrehearsal: true.- Bundle cache. Availability reads the bundle through
RuntimeReleaseGuidanceBundleCache, keyed by path, length and last-write time and re-checked at most every 10 s; the file is read and hashed again only when one of them changed, so no request hashes the file. It is read only when some entry passes the tests before the bundle (so a broken bundle never degrades a response that could not show What's New). Bundle files are content-addressed, so a new bundle normally has a new path; an in-place rewrite that keeps the length and last-write time is not noticed. - Cost. One release query, one outbox query and at most one bundle stat per 10 s per process, however many
requests (
RuntimeReleaseStateReaderTestsandRuntimeReleaseGuidanceBundleTests, 50 concurrent callers).
Flag decision: not separately flagged. Reads sit behind RuntimeFeatureReleases:Enabled (404 and no reads when off)
and the handlers behind RuntimeFeatureReleases:OutboxDispatch:Enabled; both are default false everywhere. No
migration, no new collection (the reader only reads the outbox by _id), nothing at start-up. On, off, on gives the same
bodies with any data. Tests: FeatureAvailabilityHttpTests (a reviewer, a non-member and the no-project route get
bodies byte-identical to a registry without the feature, with the project administrator as the positive control; the
outbox, stop and restore sequence; the switch round trip), FeatureAvailabilityEvaluatorTests,
RuntimeReleaseStateReaderTests and RuntimeReleaseOutboxDispatchTests (production, testSink and disabled
delivery with the registered handlers).
Shared administrator preview and commit¶
Current page layout: the review panel, retained-request panel and saved receipt described here are now presented in the change dialog, the outcome banner and Details; see Using the Feature flags admin page. The preview, digest, commit and retry rules below are unchanged.
PR #4381 connects individual controls on the existing Feature Flags page to the Shared policy API when its verified snapshot carries authority metadata. Legacy snapshots retain the existing mutation flow. Shared group controls remain unavailable because there is no shared group transaction endpoint.
In Shared mode the snapshot's mutationAllowed is false: it describes only the legacy PUT, which the
server refuses there. Each flag's runtimeManageable and managementReason describe the Shared preview and
commit routes instead, using the same deployment constraints as the legacy store (for example the statistics
maintenance gates). In production only eligible keys are manageable; the others read "This flag is
deployment-controlled in production." The page offers On, Off and Default when the snapshot carries
schema-1 authority and the flag is manageable, and shows the reason otherwise. The authority envelope also
lists stoppedKeys: the ordinal-sorted keys whose emergency stop is in force at that policy (production lists
only eligible keys). The field is additive within schema version 1; the browser's lineage guard accepts
envelopes without it and refuses one whose stoppedKeys is not a list of key strings.
A change first requests a server preview. The page shows exact before/proposed revisions, effective values, the proposed override (including Deployed default), induced changes and fleet readiness. Confirm and save commits that reviewed request with its preview digest and one mutation ID. Cancel before commit writes nothing. A dependency refusal, at preview or at commit, returns to the existing explicit dependency-confirmation flow; it never retries or commits automatically, and a new preview and confirmation are required.
A successful receipt is shown as Policy saved, without claiming fleet adoption. After a lost response, keep the page open: controls remain locked to the original request, and Check outcome reads its receipt. An absent receipt means Unknown, not aborted. Retry unchanged request preserves every field and mutation ID. While the outcome is unknown, the page shows the retained request (flag, current and proposed values, override and revisions) so you can see what a retry would write; there is no Confirm and save button. A refusal of that retry does not prove that the earlier attempt failed. The mutation ID stays visible for diagnosis. This page-scoped recovery state is not retained across closing/reloading the page; do not start a new request to resolve an unknown outcome. Durable browser recovery is a follow-up.
The server still owns permissions, environment binding, eligibility, dependencies, full-identity checks and fleet admission. A known initial refusal requires a fresh snapshot and another reviewed preview; changes never fall back to legacy PUT in Shared mode. The existing Shared mode is the opt-in boundary; this slice adds no environment configuration, migration, production activation or automatic page reload. Emergency-stop controls, full fleet-status UI and the hermetic multi-replica freshness exercise remain follow-ups before the complete B/C operator workflow is claimed.
Audience-separated content artifacts¶
PR #4382 adds an offline content compiler and a content manifest, now the reviewed YAML source
docs/content/release-content.yaml (6R R1a; the old release-content.manifest.json is gone). The compiler
accepts .yaml or .json and hashes parsed objects, so the format does not change any digest. It validates
feature/topic references against the versioned feature definitions, explicit publication classes, workflow
references and asset audiences. It produces separate server and public JSON artifacts with SHA-256 content
identities. Source ordering does not affect those identities; article, asset or reader-class edits change
the article revision. Private-only edits leave public bytes and the public digest unchanged.
pnpm run compile:release-content docs/content/release-content.yaml user-guide \
/tmp/syrf-content-private /tmp/syrf-content-public
Output directories must be separate trees outside the source root. Server artifacts use owner-only file permissions and are refused in the repository's public guide/web asset trees, including symlink aliases. Keep server bundles private: they contain full restricted Markdown, audience IDs and assets. Only copy public artifacts into a future public renderer. The compiler does not publish, render or serve either artifact; API reader authorization and old-revision access checks are still required before protected content is delivered. Markdown rendering/sanitization and URL resolution belong to that renderer.
Every article declares featureId, stable topicId, variantId, title, relative Markdown source,
publication, workflowVersions and assetIds, plus an optional section: a heading anchor on the page (as
kramdown generates it), refused when the page has no such heading. Assets declare an ID, relative source and reader
class. Supported classes are Draft, PrivatePilot, PublicPreview, PublicReleased and Retired. Draft/private entries
require an explicit documentation audienceId. Retired entries require a reviewed historicalPublication
class and retain its audience; retirement never makes a private pilot public automatically.
Publication evidence (6G.1, #4382). A declaration alone is not proof, so the compiler checks the evidence it can
see. A public class (PublicPreview, PublicReleased) is accepted only for a source inside user-guide/, which the
public site publishes (Public content must live in the public user guide ...); a restricted class is accepted only
outside it. A restricted entry's audienceId must equal its featureId, meaning "the people the feature is available
to", until 6F defines audiences. A Retired entry or asset whose historicalPublication is public is refused, because
nothing records which revisions were published (D40); Retired with PrivatePilot is still accepted.
Server bundle schema 2 (6G.1). The server bundle is schemaVersion: 2: the schema 1 fields plus whatsNew, the
runtime What's New entries from docs/content/whats-new.yaml (runtime:), each
{ id, featureId, stages, title, body, topicId, guidePath, revision }. guidePath is the topic's public article page
and section (for example projects/membership/members-groups.html#disabling-and-re-enabling-a-membership), or null while
the topic has no public article; revision is the SHA-256 of the entry. The public bundle stays schemaVersion: 1 and
never carries whatsNew. The CLI reads docs/content/whats-new.yaml by default; a fifth argument names another file.
The API's reader accepts schema 1 and schema 2, each with its exact field list.
Public output contains only explicitly public articles, their public search metadata and the public assets
they reference. Restricted articles/assets, audience IDs and unreferenced assets cannot leak through this
projection. Public articles cannot reference restricted assets; restricted assets must match the article's
audience. Restricted sources themselves are refused under user-guide/ and web public assets: filtering
one bundle cannot protect files already exposed by another public build. Keep future private sources in a
separate non-public source tree. Relative paths, symlink containment, file/bundle sizes and UTF-8 are checked.
The initial asset formats are PNG/JPEG/WebP; active-content formats require a later reviewed renderer.
The checked-in entry compiles the already-public membership guide's "Disabling and Re-enabling a Membership" section
(projects/membership/members-groups.md, section: disabling-and-re-enabling-a-membership). PublicReleased
describes that existing article, not the Developing feature's lifecycle. Its empty workflow list asserts no reviewed
workflow compatibility. No release date, guidance readiness, activation, announcement or new publication
is inferred from compilation. The compiler consumes no feature-flag values. Flag decision: no runtime
switch is needed for this explicit offline tool, which is not registered in an application or publishing
pipeline. Required guidance remains Unverified in release preparation; release Apply verifies the compiled server
bundle (see Applying a release). Protected delivery to readers is still a later integration (6G).
Inspecting fleet adoption¶
Current page layout: there is no per-flag Inspect fleet button any more. The summary line reads the membership control's fleet (the only key with tracked adoption, which reports the whole policy), and the host table is in Details; see Using the Feature flags admin page.
PR #4383 adds Inspect fleet beside each flag when the administrator catalog carries Shared authority. It calls the existing inspect-capability endpoint and shows its exact policy revision, adoption state, expected replica counts, host states, reasons and last-seen times. A saved mutation is not evidence that hosts adopted it. Domain readiness remains unknown for controls the server cannot yet assess.
The response must match the selected flag and every field of the catalog policy identity. Refresh the catalog if a newer policy was committed. Selecting another flag or receiving a new catalog identity cancels the previous lookup. Checking again clears the previous observation immediately; failed or forbidden reads show Unknown. Observations expire after 15 seconds and require Check fleet to obtain fresh evidence. This is an on-demand inspection view, not a continuous convergence monitor. It changes no policy, enrollment, configuration or data. The existing Shared-mode opt-in is the flag boundary; Legacy has no fleet action. Emergency-stop controls, automatic convergence monitoring and the multi-replica exercise remain follow-ups.
Administrator emergency stop¶
Current page layout: Emergency stop… in the summary card opens a dialog that needs a reason; Stop prepares and sends the stop in one confirmed step. Check and unchanged retry are unchanged; see Using the Feature flags admin page.
PR #4384 exposes a separate Prepare emergency stop action for the audited global disableMembership
control in Shared mode. It fetches the current catalog identity within the same environment/binding/store
lineage, shows its exact revision and audit reason, and requires Confirm emergency stop before invoking
the dedicated stop endpoint. The audit reason uses the membership control's existing comment, or an explicit
administrator-page default. The server independently checks the emergency-stop capability and safe-state
contract. Stop forces the control's audited safe value (Off) without discarding saved overrides.
The stop flow is independent of ordinary preview/save state: an ordinary mutation with an unknown outcome does not disable it. An ambiguous stop keeps one immutable request and mutation ID. Check stop outcome reads that receipt; Retry unchanged stop sends the same request. Missing receipts and later refusals do not prove an earlier attempt aborted. Keep the page open until resolved; durable browser recovery after navigation/reload remains a follow-up. An initial known conflict/refusal can be prepared afresh.
Emergency stop saved confirms only the durable receipt and names the committed revision. Confirming reloads the catalog even if an ordinary mutation is unresolved, without replacing that original mutation. The API's policy reader can lag a commit by a few seconds; until the page shows the saved revision the receipt says so and offers Refresh. The receipt goes once dismissed or once a refresh reaches its revision. Inspect fleet adoption separately.
The section shows the current stop state (Stopped or Not stopped) after every catalog load,
including a page reload. It reads authority.stoppedKeys from the snapshot (added in #4381), so no extra
request or capability is needed. While a saved receipt is newer than the snapshot the section shows Stop
state updating instead of a stale answer. Prepare emergency stop is offered only when no stop is
known to be active. A server that omits stoppedKeys leaves the state unknown; the section says so and
keeps the stop available, and the server still refuses a stop that does not apply. The stop does not call release preparation, guidance or announcement services. Releasing a stop is a
separate action: see Restoring a stopped membership control.
Hermetic multi-replica timing evidence remains open.
Flag decision: the existing Shared authority opt-in and server capability are the gates. This adds no
production activation, deployment configuration, migration or audience enrollment.
Restoring a stopped membership control¶
Current page layout: Restore… in the summary card opens a dialog that shows the server preview's restored value and needs a reason; see Using the Feature flags admin page.
PR #4385 adds Preview restore (accessible name "Preview restore of membership control") to the
emergency-stop section of the Shared administrator page. It is offered while the section shows Stopped,
or when the server does not report the stop state; Prepare emergency stop is offered only when the control is not
known to be stopped. It uses the
existing ReleaseStop policy action with no override or dependency edits. The server preview must remove
the selected stop while preserving its saved override. Review the before/after effective values, saved
values and Emergency stop column (for example Stop released), then confirm. A control with no matching stop cannot be confirmed
through this restore flow.
Restoration uses the ordinary policy-change permission, preview digest, exact identity, deployment constraint and production-readiness checks. It does not use the emergency-stop bypass. Preparation and confirmation are blocked while the local emergency-stop request is preparing, confirming, committing, resolving or unknown. The original restore request is retained after ambiguous responses, with the same receipt lookup and unchanged retry as ordinary policy edits. A confirmed restore dismisses an older known stop receipt, refreshes the catalog and shows a Policy saved receipt with the committed revision rather than claiming adoption; inspect fleet adoption separately. The server preserves saved overrides and stop/restore data; the browser changes no migrations or release milestone dates. Existing Shared-mode opt-in remains the flag boundary. Production configuration remains unchanged. Durable browser recovery and hermetic convergence/stop/restore timing remain follow-ups.