Skip to content

Runtime feature releases: remaining delivery plan

This plan orders the rest of the runtime feature-release programme. It turns the backlog that the 20 October PR audit left under "follow-up acceptance" ("heading six") into deliverables. Each deliverable gets dependencies, numbered acceptance criteria and a way to verify each criterion. It does not change any decision in the accepted plan. Requirement and test IDs (RT-xx, OD-xx) refer to that plan, mainly §2 (MVP), §5 (increments) and §15 (acceptance tests).

Boundary. This applies to every slice, including the Bramble-only local slices. Nothing here publishes, promotes, deploys or activates anything in production. Live binding, import, enrolment and runtime-policy activation in any shared environment are outside this plan's execution; it only prepares and rehearses them in isolated environments and writes their prerequisites down. Staging execution needs a separate owner decision (§8). Deployments only move forward (ADR-023): a rollback is a planned replacement release.

1. Where the programme stands

Wave PRs Delivers State on 23 October 2026
1 Foundation #4344–#4347 Shared catalog, evaluator, writer; legacy downgrade guard; explicit lineage import Merged
2 Global control API #4348–#4360 Authority observer; exact mutations and receipts; consumer contracts; stop primitive; selected API and PM consumers; browser recovery; split capabilities; stop API; preview/commit/restore; outcome lookup; host acknowledgements; chart metadata; deployment-constraint transactions Merged
3 Evidence and constraints #4361–#4375 Scoped compatibility reports; constraint evidence; collector publication; desired provenance; migration and statistics evidence; source revalidation; retained reports; explicit readiness; read-only capture; publisher; collector inventory; verified constraint completion; coordinator adapters; break-glass Merged
4 Browser and admission #4376–#4378 Lossless revisions; production policy mode Legacy|Runtime (default Legacy); browser lineage pin Merged
5 Operator UI #4379–#4385 Feature definitions; Prepared release intents; admin preview/commit UI; offline content compiler; fleet UI; stop UI; restore UI Merged (kept as one stack, decision D1)
6 Remaining acceptance This plan, §3–§7 Compatibility checker; multi-process convergence and recovery; operational prerequisites; registry and developer experience (6R, §5A); release lifecycle; availability; targeting; help and What's New, including in-app walkthroughs (6G.4); reader migration In progress: 6A and 6C merged (#4405–#4409, #4413); 6B merged apart from 6B.4 PR 1 (#4419), which is open; 6R merged apart from the Q2 registration (#4438) (§5A); 6D merged (PRs A–G; G, #4461, at 21c73ed99) (§6.1); 6E.1–6E.3 merged (#4450, #4457, #4460) and 6E.4 in #4478 (§7.1); 6F PRs A–D, F and I merged (#4463, #4472, #4482, #4483, #4488, #4489), E (#4495) and H (#4496) in progress (§7.2); 6G PRs A, B and E1 merged (#4466, #4474, #4484), C+D in #4478 awaiting owner screen acceptance, W1 in #4497, W2 in #4517 awaiting owner screen acceptance (§7.3); 6H H0 merged (#4536), H1 in #4537 and H2 in #4539 (§7.4, D53–D56 open); owner decisions D28–D37 and D39–D42 answered on 2026-10-24 (§2; D40 B adds 6G.P, P1 #4531 and P2 #4532); the rest planned

Two corrections from the audit must survive every reconciliation. They are checked by content after each merge, not by patch ID:

  • #4371 0663336c: scripts/publish-runtime-workload.py builds its opener with build_opener(ProxyHandler({}), NoRedirects), and classifies HTTP 408, 425 and 429 as Unknown (exit 3), together with their tests.
  • #4374 f7fed343: RuntimeDeploymentCoordinatorTests.cs covers configuration, scope, refusal, server actor and receipt mapping for both providers, plus the HTTP deadline, revision and body-limit tests and the injectable coordinator seam.

2. Decisions

  • D1. Keep wave 5 as one stack. The release and content slices (#4379, #4380, #4382) are needed by 6D and 6G. Each is default-off. Splitting them into new bases would discard reviewed history without reducing total work.
  • D2. Reconcile by merging main. Never rebase or replay whole stacks. A simulated predecessor merge (21 October) is clean everywhere except one hunk in #4380 (EndpointAuthorizationExceptions.cs, plus how-to prose).
  • D3. MVP first. 6A, 6B and 6C complete the accepted MVP evidence (§2 of the accepted plan: change, stop and restore one eligible global control with safe adoption, recovery and compatibility checks). 6D–6H follow as separate usable slices. Each slice ships default-off with an explicit flag decision.
  • D4. Synthetic tests never stand in for operational readiness. Every evidence record names its layer: unit, integration, hermetic multi-process or rehearsal.
  • Later owner decisions are numbered on the programme page and in the programme ledger, which lives in the maintainer's handover folder outside this repository; each is summarised here. They were answered 2026-10-23 unless marked open. These are programme decisions: "programme D16" is not the CD plan's D16 in runtime-feature-releases.md.
  • D11: the API hosts the release jobs as its own release-management module.
  • D12: the database clock sets ConfirmedAtUtc ($$NOW), and the confirming host is recorded as confirmedBy (6D question Q2, §6.1).
  • D13: the registry's communication defaults decide announcements, with a per-release quiet override that needs a reason.
  • D14: a notice cancelled by a stop is not resent on restore.
  • Programme D15: the 6B.4 safe-value fallback. Answered A on 2026-10-23: build it, default off (6B.4 PR 4, #4449).
  • Programme D16: the browser fallback poll interval. Answered A on 2026-10-23: keep the 5-minute poll and state that the 60 s claim covers tabs with a live hub connection.
  • D17: the 6R wave (§5A), with YAML for every human-written source.
  • D18: one Features and releases admin area, built in steps.
  • D19: stay built. OpenFeature only without loss of functionality (assessed in §5A: not adopted as the caller interface). Targeting is built in-house, with no paid or closed-source third-party tools.
  • D20: in-app walkthroughs as 6G.4, with our own M3 CDK component.
  • D24 (Chris, 2026-10-23): cross-environment administration. Now: each environment's own admin page, linked to the others. Later (planned option): one admin web app that calls each environment's existing API with the person's own sign-in for that environment (a CORS allow-list for that one origin); each environment's preview, confirm and audit still apply. Ruled out: a central service holding write credentials. Proposed, owner-gated: a per-environment flag-state metrics export (the existing /metrics port 9464 plus GMP) with a drift dashboard.
  • D25, D26, D27 (Chris, 2026-10-23: all A): project-permission features are omitted when no project is open (as built in #4457); the anonymous availability view is dropped; seen and dismissed rows are kept on account deletion until the OD-6 subject-deletion work (as built in #4460). Details in §7.1.
  • D36 to D43 (Chris, 2026-10-24): the eight 6G questions (help and What's New, walkthroughs, admin screens), in §7.3. Answered: D36 A, D37 A, D38 A, D39 A, D40 B, D41 A, D42 C (restarted 2026-10-24 as 6G.N) and D43 B (Ready to settle reads release records and the flag history; the later date wins and a disagreement is noted). D36 to D43 are all answered. D40 B ("record now which public revisions each environment has served") is planned as 6G.P (§7.3).
  • Principle: every change must reduce complexity, not add to it.
  • D28 to D35 (Chris, 2026-10-24): the eight 6F questions, answered in §7.2. D28 A, D29 A, D30 A, D31 A, D32 C (add a reliable creation time first, planned as 6F.F2), D33 A, D34 A (meets OD-6 for 6F E) and D35 A (with an explanation on the Targeting page).
  • D44 (Chris, 2026-10-24): answered B (§7.2): a Personal-grain key whose request cannot confirm its person is Off (decision reason PersonUnconfirmed), never on by default; the route answers the unrouted 404 and availability hides the feature. Global and Project keys, and background work, are unchanged.
  • Owner request (2026-10-24): a registry audience field, built as 6R.A (#4506, merged) (§5A).
  • D60 (Chris, 2026-10-26): A, widened. "Staging should behave exactly like production for everything; the one exception is outgoing email, which goes to Mailpit when the separate Mailpit setting is on", and local and dev likewise. Release announcements are delivered the same way in every environment; OD-19's staging test sink, the preview and local refusal and the "Rehearsal" label are retired (#4560). No data change: delivery is decided from the environment ID, so existing bindings work as stored.
  • D53 to D56 (open, 2026-10-25): the four 6H questions (§7.4): Starlight go or no-go after the H3 proof (D53), the public guide's look (D54), when to build the retired-page archive (D55) and whether to promote a current Jekyll guide before the renderer switch (D56). Recommendation A for each.
  • D57 = B (26 Oct 2026): the public What's New feed keeps announcing any fully released feature with a public guide page, and an entry for a project-administrator feature says who it is for: the public entry carries the registry audience and the browser shows a "For project administrators" label (#4559).

3. 6A — trustworthy compatibility checker and safe transition

Goal. A candidate build, a policy change or a mode switch is admitted only by a checker that reads a fresh, complete, environment-bound report. Runtime-to-Legacy is refused while stored policy and legacy values disagree.

Depends on: waves 3 and 4 merged.

Slice Content
6A.1 Offline checker CLI (scripts/runtime-compatibility-check), read-only. It takes a report and a candidate catalog/build manifest and gives exit codes 0 (pass), 1 (refuse), 2 (incomplete/unstable/unsupported) and 3 (unknown transport). Uses report schema v1 and the N-1 window.
6A.2 Runtime-to-Legacy parity preflight. It computes, per enrolled key, the runtime decision against the legacy value. Any difference or active stop refuses (RT-15). Pods never refuse to start over this.
6A.3 Replacement-build gate. A candidate whose catalog cannot honour the accepted policy, or whose migrations are not applied, is refused (RT-50, RT-67 logic).
6A.4 CI lane wiring for synthetic reports only. Trusted collection is never run on candidate code (RT-65).

Acceptance:

  1. The checker passes a complete synthetic staging-like report and then refuses that same report for a production-like environment (foreign environmentId/bindingEpoch) (RT-62).
  2. Expired, N-2-schema, Unstable, Incomplete and UnknownOrUnsupported-field reports all fail with exit 2. N-1 passes (RT-61, RT-63).
  3. The parity preflight refuses with the exact differing keys when a stop is active or a value differs, and passes once they agree (RT-15).
  4. A candidate with an older catalog digest, or missing a required migration, is refused (RT-50).
  5. Validator fixtures prove that no service credential reaches a job that runs candidate code (RT-65).

Verification: checker unit tests with fixture reports; validate-workflows.sh fixtures; API tests for the parity computation.

4. 6B — multi-process convergence, stop and restore, recovery and freshness

Goal. Prove the propagation criteria with separate processes, not just in-process instances. Wave 2 already contributes an in-process 2 API + 2 PM harness (#4358).

Depends on: waves 2–5; 6A for the gating checks.

Slice Content
6B.1 Hermetic multi-process stack mode: e2e/run-local.sh --replicas 2 starts two API and two PM processes with distinct InstanceUids and fixed expected inventory, behind a round-robin proxy, with Shared mode on a locally bound synthetic environment. Bramble only.
6B.2 Convergence and timing harness. It drives off → on → off → on through the admin API, samples each process's decision and acknowledgement directly (per-process ports, so one healthy replica cannot hide another), and records p50, p95 and maximum latency against the bounds in §4.1.
6B.3 Disruption scenarios: silent replica (Degraded at 60 s), older-catalog new replica (Action required, no gated work), Mongo pause and resume, process restart, scale-up and scale-down, mixed image versions, late or out-of-order acknowledgements, stop with the outbox, docs and notification paths unavailable.
6B.4 Browser: Playwright with two tabs, bootstrap, reconnect, focus and online recovery. A stop is visible within the bound. Reload-safe recovery of an unresolved mutation by its original mutation ID (persisted only in session storage, without values).
6B.5 Retired-host clock-skew proof. Retirement uses server-side observation time and monotonic expiry. A host with a skewed clock cannot keep a fleet Active or make it stale early. Delivered by #4407: MongoDB-stamped serverSeenAtUtc, a 35-second quiet period against MongoDB's time, a 15-minute legacy-row bound, and Collector-mode production admission.

4.1 Bounds under test

The trial objectives come from accepted-plan §10:

  • A decision uses authority checked within 30 s (contract maximumAgeSeconds).
  • The fleet is confirmed Active within 60 s (ConfirmationWindow).
  • The refresh period is 8–12 s (shipped reader: Random.Shared.Next(8, 13) seconds, matching the accepted 10 s ± 2 s), the read deadline is 5 s and acknowledgement freshness is 30 s.

The harness measures against these and records any violation. It never assumes instantaneous switching.

Acceptance:

  1. Two API, two PM and a browser converge to Active for each flip. Per-process decisions match the committed revision, and the measured maximum is within 60 s (RT-11).
  2. A silent replica is Unknown and the fleet is Degraded by 60 s. An older-catalog replica is Action required and receives no gated work (RT-11, RT-67).
  3. An emergency stop reaches every process within the stale limit with the outbox, docs and notification paths down (RT-12, RT-40).
  4. With Mongo paused, decisions fail closed to the contract's safe value. After resume they converge without operator action (RT-03). A revision regression or equal-revision digest change after resume leaves a stopped flag stopped (RT-04). An epoch change without an acknowledged rebind quarantines the reader (RT-05).
  5. A new or restarted process makes no gated decision before its first successful read (startup refresh before serving). A restarted process supersedes its predecessor's acknowledgement in the same pod identity, and a late write from the old process cannot replace it.
  6. Scale-up adds a replica that converges before receiving gated work. Scale-down retires a replica without leaving the fleet Active on a missing host's last acknowledgement. Expected inventory that disagrees with observed replicas is Action required.
  7. A mixed-version rollout (old and new images at once) keeps every replica on the accepted policy or refuses the replica that cannot honour it (RT-67). Delayed and out-of-order acknowledgements never move a host backwards or report a superseded identity as Current.
  8. A browser reload during an unresolved commit resolves it by the original mutation ID. It never issues a new mutation (RT-02 browser half).
  9. Clock-skew fixtures (±5 min) cannot hold a fleet Active or retire a fresh host.
  10. The evidence matrix (layer, revision, processes, assertions, timings, failures, gaps) is stored with the run.

Verification: Playwright plus a harness script on Bramble's local E2E stack; captured fleet output and timing CSV.

4.2 6B.4 detail

Verified against main at 4ee7c0d00. Browsers are never part of Active and make no admission decision, so the browser bound is the 60 s confirmation window, measured from the commit response to the visible state in a tab with a live hub connection. Expected worst case with G1 is about 27 s (pod refresh 12 s, read 5 s, hint, fetch about 9.5 s). Record p50, p95 and max, and report against 30 s as information only.

Gaps that need product code

Gap Problem Fix Flag decision
G1 In Shared mode nothing sends a revision hint, and there is no backplane, so a tab sees a flip only on focus, online, reconnect, reload or the 5-minute poll (matrix P3.11) New IRuntimePolicyRevisionListener in SyRF.Mongo.Common/RuntimeFeatureFlags/, called by RuntimePolicyAuthorityRefreshService only when the observed identity changes (never at start-up; 2 s bound; failures swallowed). API RuntimePolicyBrowserHint.cs broadcasts RuntimeFeatureFlagsRevision(revision) on the pod's own connections. Shared commit and stop also broadcast, best-effort Not flagged. It runs only in Mode=Shared, which no shared environment uses. Hints carry a revision only and write nothing, so on → off → on is safe
G2 A reload loses the unresolved mutation ID (RuntimePolicyChangeService, RuntimeEmergencyStopService keep it in memory only) New runtime-policy-unresolved-store.ts: one sessionStorage key syrf.runtimePolicy.unresolved.v1 holding { v, kind, key, mutationId, expectedIdentity, proposedIdentity, savedAtUtc } and no values, reason or digest. Written before the commit, cleared on saved, known refusal, cancel and Discard, kept on unknown. A stored record puts the service in a new recovering phase: read the authority first, then getMutationOutcome(mutationId). Committed and matching: saved. Unknown with a moved authority: "Not committed; superseded". Unknown with the authority unchanged: stay unresolved with Check again and Discard. Never sends preview, policy or emergency-stop after a reload Not flagged. Administrator-only, Shared mode, read-only. With no record or blocked storage the page is today's page; unknown versions are dropped
G3 A failed first fetch keeps deployed values, which is permissive under a stop (RT-14). The server still refuses, so this is visibility only Generator emits RUNTIME_SAFE_VALUES (keys with a consumerContract and productionEligible: true); a failed bootstrap applies them until the first accepted snapshot Flagged: deployment-static browserRuntimeSafeFallback (catalog-only, default false). A runtime flag cannot decide this because it is read before any snapshot exists

Acceptance criteria

# Criterion Verification
1 Only a change of the observed identity notifies the listener (once per change, never at start-up, never for an equal identity). A throwing or hanging listener never delays refresh or acknowledgement New cases in RuntimePolicyAuthorityRegistrationTests (SyRF.Mongo.Common.Tests)
2 The listener broadcasts in Shared mode and is not registered in Legacy or Shadow. Shared commit and stop broadcast after success; a hub failure leaves the 200 receipt intact RuntimeFeatureFlagsControllerTests plus a registration test (SyRF.API.Endpoint.Tests)
3 The record is written before the commit is sent and holds exactly the allowlisted fields (a test fails on any other). Cleared on saved, refusal, cancel, Discard; kept on unknown. Throwing storage keeps today's behaviour pnpm exec ng test --no-watch --include="src/app/admin/runtime-feature-flags/**/*.spec.ts", new store spec and cases in both service specs
4 Recovery covers: Committed match, Committed mismatch, Unknown superseded, Unknown unchanged, lineage refusal, and authority read before outcome. The HTTP mock records zero previewPolicy, commitPolicy and emergencyStop calls, with a positive control in an ordinary commit Same specs plus runtime-feature-flags.component.spec.ts (banner; stop stays available)
5 run-local.sh --runtime-fleet --spec runtime-fleet-browser-<name> applies the config overlay, skips the harness driver and runs only the new Playwright project @runtime-fleet. Every other project ignores those files; every other --spec is still refused scripts/test-runtime-fleet-harness.sh, actionlint, shellcheck
6 Tab A is the admin page on api-1, tab B the membership page on api-2 (own login, apiOrigin override). Four flips committed on the other pod show in tab B within 60 s with no focus, online or reload event. A tab opened after each flip renders the committed revision. Timings go to browser-timings.csv runtime-fleet-browser-convergence.spec.ts on Bramble
7 An emergency stop from tab A hides "Disable membership" in tab B within 60 s and the admin page shows stopped. Restore brings back the prior override within 60 s Same spec
8 Negative control: a routeWebSocket passthrough that drops RuntimeFeatureFlagsRevision frames leaves tab B stale for 75 s with focus suppressed. Criterion 6 is the positive control Same spec
9 With hint frames dropped: a focus event converges within 15 s; setOffline(false) after a flip converges within 15 s; a restored hub socket converges within 60 s runtime-fleet-browser-recovery.spec.ts
10 Reload during an unresolved commit: a route forwards PUT …/policy then aborts the response; after reload the page resolves to Saved by the original mutation ID (read from storage beforehand), with no preview, policy or emergency-stop request afterwards and exactly one new audit receipt. Variants: abort before forwarding gives "Not committed; superseded" after a flip from tab B; the same for an emergency stop; tab B's storage is empty Same spec
11 Each run writes the timing CSV, evidence.json (pass, per-criterion results, git SHA, stack) and the Playwright report. The rehearsal checklist gains browser rows beside R7.6, R8.3/R8.5 and R9.1 A Bramble run in the handover evidence directory with SHA256SUMS
12 Only if G3 is built: with browserRuntimeSafeFallback=true and a stop active, an aborted bootstrap hides the control until the first good fetch; with it off, today's behaviour. Tested on → off → on runtime-feature-flags.service.spec.ts, pnpm run validate:generated, one E2E case

PR split (E2E on Bramble only, never the shared dev host)

  1. PR 1, web reload recovery (G2), #4419. Store, both services, banner, criteria 3 and 4. MVP; reviewable alone.
  2. PR 2, Shared hint (G1), #4420. Listener seam, API implementation, broadcasts, criteria 1 and 2. Parallel with PR 1. Moves the browser row in §10.3 of the accepted plan (runtime-feature-releases.md) and matrix P3.11 to "per pod, Shared".
  3. PR 3, E2E lane and specs. After PRs 1 and 2: the @runtime-fleet project, e2e/helpers/runtime-policy.ts (preview, commit, stop, outcome), both specs, checklist rows, criteria 5 to 11. If PR 2 slips, the recovery spec can land first.
  4. PR 4, safe-default fallback (G3), #4449 (open). Approved (D15). Default off; criterion 12 and the flag decision in manage-feature-flags.md.

Open questions (answered A by Chris on 2026-10-23, programme D15 and D16). G3: build the safe-value fallback now, default off (recommended, because it makes a stop visible when the bootstrap fails without changing default behaviour). The 5-minute poll: keep it, and state that the 60 s claim covers tabs with a live hub connection (recommended; a 60 s poll would multiply anonymous snapshot reads).

Not in scope: a browser age limit on stale snapshots (follow-up), a SignalR backplane, change streams, a round-robin proxy, and any staging or production run.

5. 6C — operational prerequisites and rehearsal planning

Goal. Everything an operator needs to bind, import, enrol and run the collector, written down and rehearsed in isolation, with no live execution.

Depends on: 6A, 6B.

Slice Content
6C.1 Runbooks: environment binding, lineage preview/apply with writer freeze, consumer enrolment, collector identity and scope, coordinator identity, expected-inventory values, recovery and rebind, stop and restore, Legacy return. Each runbook lists preflight checks and their expected outputs. Written in Operate runtime feature flags (#4408).
6C.2 An isolated rehearsal in the local stack that follows the runbooks end to end, with the transcript recorded. Step map and recorded outcomes: isolated rehearsal checklist (run 2026-10-22 on 45091ad7: 47 PASS, 0 FAIL, 13 not exercised; #4413).
6C.3 A staging rehearsal plan (RT-69 checklist, OD-26), including entry and exit criteria for Chris's later gate. Not executed. Written in the staging rehearsal plan.
6C.4 A production prerequisites list: catch-up promotion, ADR-023 Job, OD-21 automation identity, OD-23 collector (camarades-infrastructure), chart values, and an approval record template. Not executed. Written in the production prerequisites.
6C.5 Legacy reader for initialized authority (owner decision D7=A). A host in Legacy mode against an initialized authority starts, serves deployment-controlled values, never applies runtime overrides or stops, never writes the authority, and does not register as a writer. Returning to Legacy therefore stays a configuration change after an import. Flag decision: not separately flagged, because the existing mode setting is the switch, and a Legacy host with initialized authority previously refused to start.
6C.6 First-time initialisation without a legacy document (owner decision D8=A). A reviewed initialize lineage mode creates the authority from deployed values (no overrides, no stops), with the same preview/apply digest discipline, writer freeze and refusals as import. Production uses it instead of import. Flag decision: not flagged, because it is an operator command that runs only when explicitly selected before host start.

Acceptance:

  1. Every runbook step is exercised in the isolated rehearsal, and the transcript shows the expected output.
  2. The staging and production lists name the owner, approver, rollback-as-forward-release, and evidence to capture for each step.
  3. No runbook step requires a production credential during rehearsal.
  4. (6C.5) Legacy-mode API and PM hosts start against initialized authority and serve deployed values, with no write to the authority. A Shared → Legacy → Shared round trip leaves the authority byte-identical. Legacy behaviour against a legacy-shaped document is unchanged. An isolated legacy-return scenario proves the round trip, and runbook 10's STOP box is replaced with executable steps.
  5. (6C.6) initialize preview is deterministic, and apply requires the reviewed digest. It refuses an existing document, an unverified binding, registered writers and migrations that aren't Ready. An exact replay is idempotent. With an isolated production-like configuration, the initialised authority is accepted by Shared readers, the evaluator and fleet admission. An isolated rehearsal reaches Active, and the checker's policy-change passes.

5A. 6R — registry and developer experience

Source: owner decisions of 2026-10-23, verified against main at f92b496fe. 6R runs before 6D Apply and replaces the old "6D.0 registry communication fields" slice.

Principle. Every change must reduce complexity, not add to it. Each PR description carries a Complexity effect block with four before and after numbers: flags, concepts a caller must handle, generators, and files touched to add one flag. The inventory page (6R.2) computes them. Every human-written source is YAML.

Key constraint. No new registry field enters RuntimeFeatureFlagDefinition. Its fields feed RuntimePolicyAuthority.ComputeCatalogDigest, and the digest is part of every stored RuntimePolicyIdentity, so any change would make every stored identity foreign. A digest-pin test (value pinned at f92b496fe) enforces this. New fields go on FeatureDefinition only. That changes the Prepared release snapshot, which is acceptable: no shared environment has RuntimeFeatureReleases:Enabled, and 6D Apply refuses a changed snapshot ("prepare a new release").

6R.1 Registry v2 and one validator

  • Registry v2. src/charts/syrf-common/feature-definitions.yaml becomes schemaVersion: 2 (path kept). Each feature gains type (Release | Operational | KillSwitch | Experiment | Permission), owner, lifecycle (Developing | Piloting | Released | Settled | Withdrawn; "Ready to settle" is computed, never stored), an optional settleDays override (default per type: Release 30), flags, and an optional communication block (userGuide, walkthroughId, whatsNew announce/quiet, notify releaseAudience/administrators/none, alertingOwner). Defaults come from type: Release needs a user guide, announces and notifies the release audience; every other type is exempt, quiet and notifies no one. The user-guide requirement lives in communication.userGuide; schema 2 refuses a top-level guidance.
  • Groups and pins move into the registry. flagGroups replaces RuntimeFeatureFlagGroups.cs (order kept, two retained unadvertised IDs resolve through Find only). deploymentManaged replaces ProjectStatisticsRuntimeActivation.PinnedKeys. Group IDs are versioned contracts: the validator refuses a change to the keys of an existing group ID.
  • Other YAML sources (new, under docs/content/): release-content.yaml (the manifest's shape; the JSON manifest is deleted; the compiler accepts both; itself retired by 6G.5 E1, where guide pages declare their topics in front matter), whats-new.yaml (generates src/services/web/src/assets/data/whats-new.json byte for byte), and walkthroughs.yaml (format only; first file arrives with 6G.4: W1, #4497).
  • Generator. src/charts/syrf-common/scripts/generate-feature-definitions.ts stays the single registry generator and also writes RuntimeFeatureFlagGroups.generated.cs, RuntimeFeatureFlagPins.generated.cs and whats-new.json. FeatureDefinition gains Type and Communication, resolved at generation time so 6D reads final values. RuntimeFeatureFlagCatalog.generated.cs is untouched.
  • One validator. New scripts/feature-registry.ts plus scripts/feature-registry.test.ts, run by scripts/validate-generated.ts and as pnpm run validate:registry [--gitops <dir>]. Checks: (1) flag keys exist and no key has two owners; (2) required guidance has a non-Draft content entry once a feature leaves Developing; (3) What's New and user-guide links resolve (by path or Jekyll permalink:), and walkthrough anchors exist as data-tour-id; (4) group members are catalogued and group IDs never change; (5) env-mapping.yaml and the generated feed pass their schemas (the 14 current secretKeySource errors are fixed by making helmSource.source optional); (6) every registry source path is covered by the pr-tests.yml change regex and the .husky/pre-commit pattern; (7) no cluster-gitops check here: environment values are cluster-gitops' concern (Chris, 2026-10-23). SyRF publishes the valid key list instead (R1c), and cluster-gitops checks its own values against it. Nine dead keys exist there today.
  • Flag decision. Not flagged: build-time sources, generation and validation. Byte-identical outputs and unchanged goldens prove runtime behaviour is unchanged, and the new fields have no consumer until 6D PR A, which sits behind RuntimeFeatureReleases:Enabled.

6R.2 Developer experience

  • pnpm generate (scripts/generate-all.ts) runs the generators in dependency order (env blocks, web flags, runtime catalog, feature definitions, Dockerfiles, and nswag only when the spec hash changed), then validate:registry. A test fails if a generator is added to GENERATOR_COMMANDS without an order slot.
  • pnpm feature:new <id> --type <T> --owner "<team>" [--permission <P>] [--flag <key>] [--hosts api,project-management] (--permission required for a Release, 6E.1) writes exactly one env-mapping.yaml entry and one v2 registry entry (Release: default settle period, Required user guide with a topic the validator reports as "guidance missing" until content exists; others: Exempt with a reason), then regenerates. With --hosts api,project-management it also writes the documented Project Management half (backendFeatureFlags) and a deploymentManaged pin, because Project Management never reads runtime overrides (#4292). It refuses an existing ID or key (and a removed key), writes nothing on refusal, and prints the remaining steps (consumer code, catalog digest pin, content, What's New, walkthrough, cluster-gitops). The slow end-to-end tests of these commands run as pnpm run test:tooling-e2e (CI's Validate Generated Code jobs, and pre-commit only when the tooling changes), so validate:generated stays fast.
  • pnpm feature:settle <id> does the mechanical part of settling a Released feature: it removes the feature's env-mapping.yaml flag entries, sets lifecycle: Settled, adds the keys to the removed flags (removedFlags, generated as RuntimeFeatureFlagKeys.RemovedFlags; stored overrides for them are ignored and the keys are never reused), drops any pin, regenerates, and prints every code reference from the consumer manifest and a tracked-file search, so the developer deletes the "off" branches in the same clean-up PR ("remove flag <key> for <feature>"). It refuses a feature that is not Released, and writes nothing on refusal. Ordering safety: before merging, confirm every environment serves "on" (the cluster-gitops value and any runtime override), so making "on" unconditional changes nothing.
  • Generated key constants. RuntimeFeatureFlagKeys.generated.cs replaces the 12 hand-written constants (same names and values) with one constant for each of the 54 catalogued keys, plus RemovedFlags.
  • Inventory page. Generated docs/features/feature-inventory.md, in the MkDocs nav and deterministic. It shows flags, features by type, unclaimed flags, evaluation seams, and generators and files per flag. It holds no dates and no runtime state.
  • Settling is fact-based (Chris, 2026-10-23), with no manual dates. Git holds the intent: the type, plus an optional settleDays override. Each environment's own release history holds the fact: the release milestone where the feature became available to everyone (6D), or, before 6D, the flag's audit history. The environment's admin page lists "Ready to settle" (a Release type, available to everyone and unstopped for longer than its settle period), with a "Create clean-up issue" link that opens a prefilled GitHub issue in the viewer's browser, so the environment holds no GitHub credential. Nothing time-based ever fails a build, and runtime facts never feed compile-time checks. This removes the reviewBy field, the expiry test and the scheduled expiry job.
  • Flag decision. Not flagged: developer tooling and a generated document; only key constants change, with identical names and values.
Adding one flagged feature Before After
Commands 5–6, from 3 directories 2: pnpm feature:new …, then pnpm run validate:generated
Hand-edited registry files 1–3, plus up to 6 situational 0 (the scaffold writes 2)
Hand-edited test pins 3 (catalog fixture, flag count, catalog digest) 1 (catalog digest; the fixture now checks only existing flags and the count assertion is gone, since the digest pins the set)
Generated files touched 13–16 15–18 (adds key constants and the inventory page)
Links that can break unchecked What's New, guidance, groups, cluster-gitops keys none in SyRF (cluster-gitops checks its own values against the published key list)

The generated-file count rises by two. That is accepted under the principle, because what a developer edits and reviews falls: hand-edited sources go to 0, commands to 2, and the two new outputs replace hand-maintained key strings and an inventory nobody keeps today. Generated files are machine-checked by validate:generated. The generator count stays at 5 in R2a and becomes 4 after the backlog merge of the two web flag generators (R2a left it: merging a 1,374-line and a 683-line generator is not low-risk).

R2a outcome (complexity effect). Flags 55 and caller seams 5 unchanged; generators 5 unchanged; commands to add a flag 5–6 → 2; hand-edited key constants 12 → 0; hand-edited test pins per flag 3 → 1; settling a feature becomes one command plus the printed deletions.

6R.3 Module split and one evaluation interface

  • Reader package stays SyRF.Mongo.Common, so startup-bound consumers and PM wiring are untouched. First, extract the four reader-to-writer reaches into reader-side files (RuntimePolicyReceiptValidation.cs, RuntimeFeatureFlagEffectiveValues.cs, and a RuntimePolicyFleet.Freshness constant). Host acknowledgements stay, because every Shared host writes its own heartbeat.
  • API-only module src/services/api/SyRF.API.RuntimeAdministration/ (referenced only by SyRF.API.Endpoint; added to api.slnf, syrf.sln, docs/architecture/dependency-map.yaml, regenerated Dockerfiles). It receives the policy mutation store partials, RuntimeFeatureFlagStore, lineage import, environment report and workload observation stores, RuntimeReleaseIntent.cs, and every 6D store, reconciler and dispatcher (6D-Q1).
  • Architecture test (R3a, in SyRF.ProjectManagement.Endpoint.Tests, modelled on DeletionArchitectureScan): fails if PM production code names any writer type. The writer list is derived, with a positive control that finds RuntimePolicyMutationStore. After the move, an assembly-closure test and a csproj scan keep PM free of the module.
  • IFeatureDecisions (R3c, reader package): Decide(key) and DecideAll(keys) (one immutable capture). Synchronous, never throws, Value always a bool (the catalog safeValue applies when a contract key is unavailable, stale or stopped), Reason mapped 1:1 from the eight evaluator states, PolicyVersion a string?. It replaces IRuntimeFeatureFlagProvider.IsEnabled, SharedRuntimeFeatureFlagReader.Evaluate and IProjectStatisticsFlagSource for the 13 existing-runtime and shared-reader consumers. Caller seams go from 5 to 2 (FeatureFlags stays as deployed configuration). A parity test covers every catalog key and evaluator state.
  • Flag decision. R3a is a test only. R3b moves code without behaviour change. R3c is on hot paths but not flagged: the parity test proves identical values, and a flag would keep both paths alive (more complexity). A micro-benchmark shows no added allocation.

OpenFeature assessment. Not adopted as the caller interface (both SDKs are open source, so cost is not the reason). It would lose three things: the catalog-owned fail-closed safe value (OpenFeature returns the caller's default on error, so a permissive default fails open); synchronous decisions (the .NET client is async only, so authorisation and admission callers would become async); and multi-key single capture (it evaluates one flag per call, so keys could read different revisions). IFeatureDecisions uses OpenFeature vocabulary (value, reason, metadata), so an OpenFeature provider adapter can be added later if an external tool needs the standard. Callers would not migrate.

Acceptance criteria

# Criterion Verification
1 The generator over the migrated YAML reproduces whats-new.json, FeatureDefinitions.generated.cs and feature-definitions.generated.ts byte for byte; compiler bundle digests are unchanged pnpm run validate:generated, plus before and after sha256sum and digest file names in the R1a description
2 Each validator check (1–6) has a failing fixture, a passing positive control and an exact message (a permalink-only What's New path passes; a changed group membership fails; the 14 schema errors are gone) tsx --test scripts/feature-registry.test.ts, inside validate:generated
3 RuntimePolicyAuthority.CatalogDigest equals its value pinned at f92b496fe, and RuntimeFeatureFlagCatalog.generated.cs is unchanged by R1a and R1b New digest-pin test in SyRF.Mongo.Common.Tests, plus checksums
4 Groups and pins from YAML leave RuntimeFeatureFlagSnapshot.golden.json, RuntimeFeatureFlagGroupTests and the pin refusal texts unchanged; both retained group IDs resolve through Find and are absent from All SyRF.API.Endpoint.Tests
5 Release resolves to announce and releaseAudience; each other type to quiet and none; an override wins; generated C# and TypeScript carry resolved values Generator unit tests and the C# catalog test
6 Each chart release publishes the valid config-key list generated from env-mapping.yaml (deterministic, checksummed). A cluster-gitops check against it reports the nine dead keys Generator test in SyRF; the cluster-gitops PR's own check run (Chris merges)
7 pnpm generate from a clean tree is a no-op and runs in the documented order; a generator without an order slot fails Order slots: scripts/generate-all.test.ts (in validate:generated); no-op against a temp copy: scripts/tooling-e2e.test.ts (pnpm run test:tooling-e2e, a CI step after validate:generated)
8 feature:new writes exactly the two source entries, regenerates and passes validate:generated; a re-run refuses and writes nothing; each type gets its defaults. feature:settle refuses a feature that is not Released, writes nothing on refusal, and leaves validate:generated passing scripts/feature-commands.test.ts (planning, refusals, type defaults) and, against a temp copy, scripts/tooling-e2e.test.ts
9 The inventory page is deterministic, in the nav, and shows the four complexity numbers Checksums, docs/scripts/check-mkdocs-nav.sh
10 "Ready to settle" lists a Release feature only when it has been available to everyone and unstopped for longer than its settle period, from that environment's own history; the issue link is a prefilled URL with no credentials API unit and integration tests with an injected clock; a component spec (built with the admin area)
11 The R3a scan fails on a fixture PM file naming RuntimePolicyMutationStore, and its positive control finds every derived writer; after R3b the assembly closure test passes for PM SyRF.ProjectManagement.Endpoint.Tests
12 R3b changes only using lines and project references; API and PM images build CI Test Summary, Dockerfile checksums
13 R3c parity for every key and state; DecideAll uses one capture; no added allocation SyRF.Mongo.Common.Tests, SyRF.API.Endpoint.Tests, PM tests
14 Each PR description has the Complexity effect block, matching the inventory page diff Review

Complexity before and after

Before After
Flags (env-mapping flag keys: 54 catalogued plus the web-only activeReviewerTrackingEnabled) 55 55 (settling stale flags is backlog)
Caller evaluation seams 5 3 after R3c (IFeatureDecisions, FeatureFlags, and IRuntimeFeatureFlagProvider for five pinned callers); 2 once they migrate
Registry generators 5 5 (4 after the web flag generator merge)
Hand-edited sources per flagged feature 1–3, plus up to 6 situational 0 (scaffold writes 2)
Hand-written key constants 12 0
Hand-written JSON sources 2 0 (YAML)
Writer code shipped in PM yes no (R3b)

PR split

PR Content Depends on Lane
R1a (MVP), #4428 Validator checks 1–6, schema drift fix, whats-new.yaml and release-content.yaml, CI regex and pre-commit updates, how-to updates main A
R1b (MVP), #4432 (merged) Registry v2 fields, type defaults, flagGroups, deploymentManaged, generated groups and pins, digest-pin test R1a A
R1c, #4436 (merged) Publish the valid config-key list with each chart release; a separate cluster-gitops PR adds that repo's own scheduled and PR check, which Chris merges R1a C
R2a, #4437 (merged) pnpm generate, feature:new, feature:settle, generated key constants and removed flags R1b B
R2b, #4441 (merged) Inventory page R1b B
R3a, #4429 Architecture source scan main D
R3b, #4433 (merged) Reader extraction and API-only module move R3a D
R3c, #4439 IFeatureDecisions and migration of the consumer seams; nine remaining reads pinned in legacySeamAllowList for follow-up R3b D
6R.A, #4506 (merged) Optional audience per feature (signedIn, projectMembers, projectAdministrators, applicationAdministrators), defaulted from permission by one mapping table in the generator and only ever narrower; validator rules; generated into FeatureDefinition.Audience and the TypeScript registry; Audience column on the inventory page; architecture test that no decision code reads it. Consumers (What's New label, walkthroughs, help chips, release preview) come with those screens R1b, 6E.1 A
Admin "Ready to settle" (acceptance 10) The list and the prefilled "Create clean-up issue" link, in the Features and releases admin area (wave 7, D18). It reads 6D's release milestones and each flag's audit history, and uses the later date (D43 = B). Built as 6G AD3, #4521 6D PR A (milestones); R2b Wave 7

Lanes A (R1a, R1b) and D (R3a, R3b) run in parallel from day one. 6D PR A starts after R1b, and ideally after R3b, so its new files are created in the API module (if A is ready first it lands in SyRF.Mongo.Common and R3b moves it). B and C follow R1b and do not block 6D. The MVP boundary is R1a + R1b.

Decisions on the open questions (Chris, 2026-10-23)

  • Q1, expiry: replaced. There are no review-by dates; settling is fact-based (6R.2), shown in each environment's admin page and never blocking a build.
  • Q2, existing flags: the 54 existing catalogued flags (the 55 env-mapping flag keys minus the web-only activeReviewerTrackingEnabled) are registered in one agent-drafted PR after R1b, reviewed by Chris: #4438 (open, conflicting with main).
  • Q3, cluster-gitops: it is environment maintenance, so it belongs to cluster-gitops. SyRF publishes the valid key list (R1c), and cluster-gitops checks its own values in its own CI and opens clean-up issues. Clean-up PRs that touch production values stay owner-approved.

Simplification backlog (each a later PR; Shadow and legacy routes are owner-gated)

Item Precondition Proof nothing is lost
Settle stale flags with pnpm feature:settle (candidates: contactUsForm, newProgressIndicators, newScreeningOverview, newStageOverview, showUpdateInclusionInfo, plus the 9 dead cluster-gitops keys) R2b; flag true in production and staging for a promotion cycle; the cluster-gitops production PR needs Chris On-path tests pass with the flag deleted; validator shows no reference; rendered env differs only by the removed variable
One opaque policy version 6B.4 PR 1 merged Round-trip test for every field including Int64 revisions beyond 2^53; all 409 conflict tests pass; the 7-field body stays until no N-1 web build is served
Remove Shadow mode (Mode=Shadow and SharedReaderMode=Shadow) Owner confirmation Nothing configures either, production cannot run Shadow, writes refuse it, and Shared mode plus the offline checker give the same evidence. Start-up then fails closed on the invalid mode. ApplicationAuthorityMode.Shadow is unrelated and stays
Remove legacy write routes Production on Mode=Shared with ProductionPolicyMode=Runtime; Legacy return kept as a read-only mode Shared-mode suites pass unchanged; groups then lose their writer
Merge the two web flag generators R2a All 11 outputs byte-identical; generators 5 to 4

6. 6D — release Apply, events, outbox and confirmation

Depends on: wave 5 (#4380 Prepared) and 6A (fleet confirmation input).

Slice Content
6D.1 An Apply transaction joining policy, release intent, PolicyCommitted event and outbox rows (RT-20). Idempotent by intent ID.
6D.2 A confirmation reconciler. It confirms only when the fleet reaches Active for that identity, using ConfirmedAtUtc from the server clock (RT-21).
6D.3 An outbox dispatcher with durable retry, staging and preview refusal (RT-25; the refusal is superseded by D60 A, 26 Oct 2026: every environment delivers as production does), a guard against acting after a stop (RT-23), and finished entries kept permanently with no TTL (ADR-023 / #4319).
6D.4 Stop and restore keep the release ID, original milestone dates and announcement identity (RT-24). Historical import queues nothing (RT-27).
6D.5 Normal enable is refused without required guidance or with an incompatible host, while a stop still works (RT-26).

Acceptance: RT-20 to RT-27 pass as specified, including RT-22: a crash at each boundary resumes to one event per milestone and one announcement.

6.1 Detail

Verified against main at 4cbfe924a (after #4382). The merges since, up to 4ee7c0d00 (#4383–#4385, #4414), change the admin UI and the legacy reader, not the release store, routes or migrations named here. Today Prepare (#4380) writes a runtimeFeatureRelease record (status: "Prepared") and a runtimeFeatureReleaseEvent receipt in one transaction, and nothing consumes it. The release indexes 0012 to 0017 and 0021 already exist; the next free migration is 0022. Everything below runs only in isolation.

Cross-cutting design

  • Event IDs. _id = "{mutationId}:{type}". Apply events use the Apply mutation ID. Events the reconciler derives use "{policyMutationId}/{releaseId}" with sourceMutationId stored. The unique {mutationId, type} index (0013) is the exactly-once key.
  • Outbox dedup key (unique by 0016): "{environmentId}:{bindingEpoch}:{releaseId}:{stage}:{announcementId}:{announcementRevision}:{kind}", with announcementId = "{releaseId}:{stage}" and revision 1. It never changes on stop or restore (RT-24).
  • Record CAS. Every release-record write filters on {_id, version: V} and sets V+1; any other modified count aborts. Leases avoid duplicate work; the CAS and unique indexes are the correctness guarantee.
  • Transactions. Snapshot read concern, primary, majority write, binding re-read inside the transaction.
  • Fault hook. RuntimeReleaseFaultPoint plus an internal Action<…>? FaultHook on the store and dispatcher, a no-op unless set by SyRF.Mongo.Common.Tests. Points: ApplyAfterPolicyWrite, ApplyAfterEventInsert, ApplyBeforeCommit, ReconcileBeforeCommit, DispatchAfterLease, DispatchAfterHandler.

Status (2026-10-23). Merged: PR A (6D.1 Apply with the 6D.5 admission stub, #4443), PR B (reconciler, #4444), PR C (dispatcher, #4445), PR D (stop and restore recording, #4453), PR E (guidance admission, #4446) and PR F (historical import, #4447). PR G (#4461, merged at 21c73ed99) added the RT-22 crash matrix, the release-lifecycle harness scenario and the close-out docs. 6D is complete for isolated environments: RT-20 to RT-27 all pass on main. Nothing in 6D runs in a shared environment.

What remains before any production release (written prerequisites only; 6D executes none of them, and each is a separate reviewed change with owner approval):

  1. A releasable registry entry. project-membership-disable has no compatibleWorkflowVersions, so Apply refuses it with WorkflowCompatibilityMissing. Its guidance is Required, so it also needs a public article for every topic (or a reviewed exemption).
  2. The compiled server guidance bundle on the API. Since 6G PR G (D38 A, #4513) every API image carries it at /app/release-guidance/, read when RuntimeFeatureReleases:GuidanceBundlePath is unset; production prerequisite P12a reduces to deploying an API image built with it (no Secret or value).
  3. Migrations 0011–0022 applied and bound in the target database through the pre-deploy Job (ADR-023), which is enabled in no environment today (§5).
  4. RuntimeFeatureFlags:Mode=Shared and RuntimeFeatureFlags:ProductionPolicyMode=Runtime in production, with production fleet admission, Collector inventory and the 6C rehearsal evidence. Production runs Legacy today.
  5. RuntimeFeatureReleases:Enabled and RuntimeFeatureReleases:OutboxDispatch:Enabled set per environment in cluster-gitops. Both are false everywhere.
  6. Real What's New and in-app handlers (6G). Until they exist, a production binding has no outbound handler, so its notices stay Pending and What's New stays Pending everywhere.
  7. An operator requeue for Failed outbox rows, and the refusal of an Apply under an active stop (#4456).

6D.1 Apply (RT-20). New routes POST /api/feature-releases/{releaseId:guid}/apply/preview (side-effect-free; digest domain syrf-release-apply-v1; marked [ImpersonationReadOnlySafe]) and .../apply (body RuntimeReleaseApplyRequest( MutationId, ExpectedIdentity, PreviewDigest, Reason); response RuntimeAppliedRelease, Status = "Applying", ConfirmedAtUtc = null). They need ManageFeatureReleases, Shared mode and RuntimeFeatureReleases:Enabled. Refused when the record is not Prepared, the definition snapshot differs from the catalog, the identity, preview or binding is stale, or admission fails (422 runtime_release_admission). An exact retry returns the original; a changed body or a second different Apply is refused. One transaction: re-check binding, validate receipt chain, replay check, require Prepared, admission, CommitPolicyInTransactionAsync (extracted from MutateAsync by a pure refactor), record CAS 1→2 (status: "Applying", applied block, unconfirmed milestone), PolicyCommitted event, outbox rows (Pending, requiresConfirmation, guardPolicy) and an AnnouncementQueued event.

  • Files (writer and release code lives in the API-only src/services/api/SyRF.API.RuntimeAdministration/ since 6R R3b): RuntimePolicyMutationStore.cs; new RuntimeReleaseApplyStore.cs (partial) and RuntimeFeatureReleases/RuntimeReleaseLifecycle.cs (request, preview, result, event type, outbox kind, state, RuntimeReleaseDedupKey); RuntimeReleaseIntentStore.cs (injectable definitions); RuntimeReleaseIntentAdministration.cs, RuntimePolicyAdministration.cs, RuntimeFeatureFlagsController.cs (422 mapping); authorization classification files; goldens; regenerated swagger.json and api-client.generated.ts.
  • Acceptance: nothing is written at each of the three Apply fault points; an exact retry gives one audit row, one event and one row per dedup key; two concurrent Applies commit once; stale preview, identity, binding, definition and non-Prepared each refuse with a stable message; switch off refuses Apply but GET still reads Applying records (on → off → on); permission is checked first and Apply is a change for impersonation; MutateAsync suites pass unedited. Tests: new RuntimeReleaseApplyTests (replica set), RuntimeReleaseIntentAdministrationIntegrationTests.
  • Flag: behind existing RuntimeFeatureReleases:Enabled (default false) and Shared mode. Off refuses new Applies only; applied work keeps converging.

6D.2 Confirmation reconciler (RT-21). A leased worker reconciles releases that are Applying, Degraded, Active or Paused and have an applied block. In one transaction it reads server time first ($$NOW, Q2), then confirms only if the status is Applying/Degraded, the capability holds, the store epoch matches with Revision ≥ applied, and the fleet state is Active. Confirmation sets confirmedAtUtc, status: Active, semantics ObservedActivation (or ObservedAfterRecovery after a gap over 3 periods) and inserts ActivationConfirmed. An Applying release past 60 s with no Active fleet becomes Degraded (or ActionRequired) with no milestone; a later Active still confirms once. The lease (reconcileLease, 30 s) is acquired against $$NOW. Host: RuntimeReleaseReconcilerService (10 s ± 2 s), Shared mode and writer only; fails closed if indexes are missing. New RuntimeReleaseReconciliation.cs and RuntimeReleaseReconcilerService.cs; RuntimePreparedRelease gains a nullable Milestones projection (golden update). Acceptance: confirms only when every expected host is Current (never with Behind, Unknown, Incompatible, missing inventory, reversed capability or foreign epoch); ConfirmedAtUtc is MongoDB time and unchanged by a ±5 min host skew; Degraded then Active confirms once; two racing reconcilers give one event; a non-membership key never confirms; the service is absent in Legacy and non-writer hosts. Flag: not separately flagged (convergence of accepted work, a no-op without Applying records, absent in Legacy).

6D.3 Outbox dispatcher (RT-23, RT-25). Lease one due item (Pending or expired Leased, via 0017), guard in a transaction, handle through IRuntimeReleaseSideEffectHandler (idempotent by dedup key; a kind with no handler stays Pending), then complete by CAS on the lease token. Guards: binding or epoch differs from the key gives Cancelled/BindingChanged; outbound kinds with delivery disabled give Cancelled/DeliveryDisabled; testSink reaches only RuntimeReleaseTestSinkHandler; an unconfirmed milestone defers with backoff (cap 1 h); a stopped capability cancels NotifyInApp (AnnouncementCancelled) and defers MaterialiseWhatsNew. (D60 A, 26 Oct 2026, #4560: the disabled refusal and the test sink are superseded; every valid environment routes each kind to its one handler, and only an invalid environment ID refuses.) Handler errors back off and become Failed after 20 attempts. Done, Cancelled and Failed rows are never deleted: no TTL, no clean-up job. New migration 0022_runtimeFeatureReleaseOutbox_releaseId_state on {releaseId, state} (added to PmMigrations.cs, migrations.lock.json and both migration test files; like 0021, it stays outside the fixed 0011–0020 RuntimeReleaseIndexes group). New RuntimeReleaseOutboxDispatcher.cs, handler interface, test-sink handler and hosted service. Acceptance: a stop between lease and guard cancels once with an empty sink (fault hook DispatchAfterLease); staging reaches only the sink, preview:pr-N and local: end DeliveryDisabled, a restored copy ends BindingChanged; failing handlers back off and survive as Failed; no TTL index; dispatch off, on, off, on delivers nothing twice. Flag: new RuntimeFeatureReleases:OutboxDispatch:Enabled (default false), the only component with outbound side effects. Off keeps rows Pending.

6D.4 Stop, restore and import (RT-24, RT-27). The stop and restore write path is unchanged (no release, outbox or docs dependency). The reconciler reads audit receipts after applied.lastObservedRevision (index 0010, batch ≤ 100) and appends EmergencyStopped, Paused or Restored events (ID "{receiptMutationId}/{releaseId}:{type}", occurredAtUtc = receipt time). Status goes Paused then Applying then Active; milestones are never rewritten and a restore adds no second ActivationConfirmed. When the reconciler records a stop, it cancels unsent NotifyInApp rows in that same reconciler transaction; the stop's own write path is unchanged. Historical import is a new reviewed command, RuntimeReleaseHistoryEntryPoint (SYRF_RUNTIME_RELEASE_HISTORY_MODE=preview|apply, input, output, actor, wired next to the lineage entry point). Preview prints exact documents and a digest; apply needs the digest, writes an Active imported: true record with no applied block plus a HistoricalImported event, and writes no outbox rows. Acceptance: after confirm, stop, restore the release ID, milestone dates and announcement ID are byte-identical with one of each event; a stop and restore inside one period still yields both events; the stop succeeds with the release indexes dropped, the dispatcher throwing or the reconciler stopped; import keeps Date/Month/Unknown precision, queues nothing, and refuses a stale digest, unknown feature or duplicate key. Flag: neither is flagged (part of the reconciler; an explicit operator command).

6D.5 Release admission (RT-26). RuntimeReleaseAdmission runs inside Apply and its preview. Guidance (as built in PR E, #4446): when required, RuntimeReleaseGuidanceBundle reads the compiled server bundle (server-<digest>.json from compile-release-content.ts) from RuntimeFeatureReleases:GuidanceBundlePath, re-verifies the file-name digest from the parsed content, and requires every topic present for the feature, published PublicPreview or PublicReleased (the Global audience), with its articles covering the reviewed compatibleWorkflowVersions; a record's optional ContentBundleDigest must match. Reasons GuidanceBundleMissing, GuidanceBundleDigestMismatch, GuidanceTopicMissing, GuidancePublicationNotReleased and GuidanceWorkflowNotCovered (unset or unreadable path fails closed; GuidanceUnverifiable is removed); exempt features pass with the exemption recorded. Hosts: an Action required fleet refuses with IncompatibleHost or FleetActionRequired in every environment. Emergency stop never runs admission. Acceptance: missing bundle, missing topic, Draft-only topic and unset path give 422 with the exact reason and no write while the stop succeeds; a foreign catalog digest gives IncompatibleHost; C# and TypeScript digests agree on a golden fixture and a tampered byte fails; an exempt feature passes with the exemption in the event. Flag: not flagged (it only tightens Apply; a bypass would defeat OD-5).

Workflow compatibility (Chris, 2026-10-23). Admission (PR A's stub, then PR E) also requires the feature's reviewed compatibleWorkflowVersions; the registry no longer requires them outside Developing, so pre-registry features carry their real lifecycle.

RT-22 crash table. A table-driven RuntimeReleaseCrashRecoveryTests (replica set) resumes to one PolicyCommitted, one ActivationConfirmed, one outbox row per dedup key, one sink row and a final Active. Built in PR G (#4461): 11 cases (each Apply fault point, and the gap of boundary 4, have their own row), each with a positive control that the fault fired.

# Boundary Fault Resumption
1 After Prepare, before Apply process lost Apply with the same mutation ID applies once
2 Inside Apply hook at each of the three Apply points nothing written; retry applies once
3 Apply committed, response lost drop the result retry returns the original; no duplicates
4 Inside confirmation ReconcileBeforeCommit next tick confirms once (ObservedAfterRecovery if gap > 3 periods)
5 After confirmation kill the reconciler next tick is a no-op (CAS)
6 Mid-outbox, after lease DispatchAfterLease lease expires, item re-leased, delivered once
7 Mid-outbox, after handler DispatchAfterHandler re-delivery hits the sink's dedup key; Done once
8 Stop between confirmation and dispatch stop committed AnnouncementCancelled once; sink empty

A release-lifecycle scenario (e2e/harness/runtime-fleet/release-lifecycle.mjs, run through run.mjs; Bramble, 2 API + 2 PM, local binding) repeats this end to end, SIGKILLing the lease-holding API mid-run. Built in PR G as a named-only scenario. It needs a releasable registry entry, so on main it stops at the Apply preview with WorkflowCompatibilityMissing (prerequisite 1 above); the full run used a rehearsal-only registry entry in the scratch clone (evidence 6D-release-lifecycle/ in the handover directory).

PR split (each default-off and independently mergeable)

PR Content Depends on
A (MVP) 6D.1 Apply with a fail-closed admission stub (guidance-required features refuse with GuidanceUnverifiable; IncompatibleHost checked), MutateAsync refactor, injectable definitions, routes, outbox insertion, forward-rule test main
B 6D.2 reconciler and hosted service A
C 6D.3 dispatcher, test sink (removed by D60 A, #4560), 0022, dispatch switch A
E 6D.5 guidance verification (content index, golden digest) A
F 6D.4 historical import (RT-27) A
D 6D.4 stop/restore recording and cancellation (RT-24) B, C
G RT-22 crash matrix, release-lifecycle scenario, docs (lifecycle how-to, matrix R.2/R.3, mongodb-reference.md, this plan's §6 status) B, C, D

B, C, E and F run in parallel after A. The MVP slice is A + B. Each PR updates RuntimeReleaseCollectionsForwardRuleTests for the files it enrols.

Decisions

# Question Status Decision or recommendation
Q1 Where do the reconciler and dispatcher run? Answered 2026-10-23 (Chris) The API hosts the release jobs as its own release-management module (one writer host, one fleet configuration). The writer code split is 6R.3
Q2 Whose clock sets ConfirmedAtUtc? Answered 2026-10-23 by Chris The database clock ($$NOW); the host is recorded as confirmedBy
Q3 Can a release be applied after unrelated policy changes? Answered 2026-10-23 (Chris) Yes, with a fresh reviewed Apply preview against the current policy
Q4 Which announcements does an Apply queue before 6G has handlers? Answered 2026-10-23 (Chris) The registry's resolved communication defaults decide, with a per-release quiet override that needs a reason. It becomes a request field in 6D PR A (Announcement: Quiet plus a required QuietReason), replacing the guidance-based proposal
Q5 Is a notice cancelled by a stop re-sent on restore? Answered 2026-10-23 (Chris) A notice cancelled by a stop stays cancelled; What's New returns with its original date
Q6 How does Apply prove required guidance before 6G? Answered 2026-10-23 (Chris) The API reads the compiled server bundle from a configured read-only path and fails closed

The "6D.0 registry communication fields" slice is replaced by 6R (R1b). Implementation of PR A waits on R1b, and ideally on R3b (§5A).

Not in scope: any shared-environment execution or RuntimeFeatureReleases:* change, 6G delivery, the availability endpoint (6E), targeting (6F), the release UI, an operator requeue for Failed rows, change-stream wake-ups, and feature definitions for historical features.

7. 6E–6H — availability, targeting, help and reader migration

ID Slices Acceptance Depends on
6E availability Availability endpoint (accepted plan §7.5): permission check first, then release and policy evaluation; discovery preference store (OD-14); discovery surfaces read availability only A feature without the caller's permission is never reported available. Discovery hides it. RT-41 (API half: per-user dismissal; the rendering half is 6G) 6D PRs A and B (merged), R1b (merged); detail in §7.1
6F targeting PRs A–I and W1–W4 in §7.2: schema 2 reader and evaluator (A), project rules (B), project-context gates and availability (C), simulator (D), personal and combined rules (E), gradual rollout (F), consent (G), per-rule switches (H), targeted releases (I); web W1–W4 RT-30 to RT-37 (§7.2) A: 6E.2 (merged); see §7.2. E also needs the OD-6 subject-deletion answer (D34)
6G help and What's New Authoritative publication proof checked before disclosure; protected revisions, assets and search; rendering and sanitization; editorial dates (Europe/London); preferences and dismissal; RT-68 behaviour; RT-69 staging guide planned RT-28, RT-29, RT-41 (rendering half; 6E owns the API half), RT-68. Forged historical-public claims, foreign audiences, old revisions and assets refuse before any disclosure or milestone #4382, 6D, 6E (6F for audiences)
6G.4 walkthroughs In-app walkthroughs: our own M3 step-tour on the Angular CDK overlay. Steps live in walkthroughs.yaml (6R.1), anchored to data-tour-id attributes. Shown once when a feature becomes available; dismissal is remembered server-side; quiet releases skip it Keyboard and screen-reader accessible: Esc closes, focus is trapped and returned, reduced motion is respected 6G, 6E, R1b
6H reader migration Public renderer, redirects, accessibility, link and content parity, retirement of the old reader; a public archive of retired pages, if any, rests on 6G.P's publication record Link-parity report, automated accessibility checks, redirect map tests 6G, 6G.P

7.1 6E detail

Verified against main at 15bab4ba9. The full specification (contracts, files, line-level evidence) is the maintainer's handover note spec-6E.md; this section holds what a reviewer needs. 6E is four PRs, A to D, each default-off or build-time.

What "available" means. A feature is available to a person when it has been released to them in this environment and they can use it now: they have the permission it needs, its capability is on, it is not stopped, and (while it is still Developing or Piloting) a release of it has been confirmed in this environment. It is a discovery and communication fact, not a gate: a tester switching a flag on does not create a release, and screens and routes keep reading the flag snapshot. Availability reads the same decisions as everything else, so there is no second evaluation path.

Permission first. Each feature declares the one existing authorization policy its use requires. Before any flag decision or release lookup, the server drops every feature the caller may not use, using the existing policy handlers (never a new check). A dropped feature is left out entirely, so the response is identical to one from a registry that never had the feature, and counts, field shapes and reasons reveal nothing. The accepted plan's permission reason is therefore never sent.

The four slices

  • 6E.1 Registry permission (PR A). feature-definitions.yaml gains an optional permission: SignedIn, or the name of an existing Project* or Application* policy (a Stage* policy needs a stage and is refused). A Release must declare it; other types may omit it, and a feature without it is never reported. project-membership-disable gets ProjectEditMembershipsPolicy. The validator, pnpm feature:new --permission, the generated C# and TypeScript, and a test that every declared policy resolves in the API all carry it. The runtime catalog and its digest do not change.
  • 6E.2 Availability endpoint (PR B, MVP). GET /api/features/availability (no project) and GET /api/projects/{projectId}/features/availability (admitted by ProjectViewPolicy, so an absent or deleted project answers like every other project route). One evaluator applies permission, one DecideAll, then the confirmed-release records for this environment and binding, and answers available, unavailable or unknown with a reason (notReleased, stopped, staleAuthority, policyUnavailable). The response carries no flag keys, store epoch or digest (RT-60). Behind the existing RuntimeFeatureReleases:Enabled switch (404 when off); production stays Legacy, where nothing Developing is available.
  • 6E.3 Discovery preference store (PR C). PUT /api/account/announcements/{announcementId}/state records that one person has seen or dismissed one announcement (OD-14), one document per person and announcement. It always answers 204, so it never says whether a release exists. Availability returns seen and dismissed from it. Same switch as 6E.2.
  • 6E.4 Browser availability client (PR D). A service with one availability signal per signed-in person and project that refetches on the existing triggers, drops late responses for a previous context, and renders nothing. Merged just before 6G's first consumer so it never sits unused.

Status (2026-10-23). PR A (#4450) is merged. PR B (#4457) is merged: it implements 6E.2 with D25 option A (no project, no Project* feature) and meets 6E.2-1 to 6E.2-9 in SyRF.API.Endpoint.Tests (FeatureAvailabilityHttpTests, FeatureAvailabilityEvaluatorTests, FeatureAvailabilityContractTests, the endpoint catalogue) and RuntimeReleaseStateReaderTests (replica set). Imported (#4447) and paused (#4453) records are document fixtures in those PRs' shapes until they merge; its review follow-ups are #4459. PR C (#4460) implements 6E.3 and meets 6E.3-1 to 6E.3-5 in FeatureAvailabilityHttpTests, SupportImpersonationContractTests, SupportImpersonationHubAndClassificationTests and RuntimeFeatureDiscoveryStoreTests (replica set). It follows D27 option A (account deletion keeps the rows; the OD-6 item carries the erasure follow-up); Chris chose A on 2026-10-23. A PUT writes only for an announcement availability reports now (the latest released, announced record of a feature), so a superseded stage's announcement writes nothing. D is #4478, built together with 6G PR D (its first consumer), so it never sits unused on main.

Acceptance criteria

# Criterion Verification
6E.1-1 A Release without permission, a Stage* policy and a misspelt policy each fail with an exact message; a valid entry passes tsx --test scripts/feature-registry.test.ts; FeatureDefinitionPermissionTests
6E.1-2 Generated C# and TypeScript carry the value; RuntimeFeatureFlagCatalog.generated.cs and the catalog digest pin are unchanged pnpm run validate:generated; RuntimeCatalogDigestPinTests
6E.1-3 feature:new --type Release without --permission refuses and writes nothing scripts/feature-commands.test.ts
6E.2-1 A project administrator gets available on the project route; a reviewer in that project, a non-member, and the administrator on the no-project route get no entry SyRF.API.Endpoint.Tests HTTP test (administrator is the positive control)
6E.2-2 The body for an unpermitted feature is byte-identical to the body from a registry without it; an absent and a deleted project answer like another ProjectViewPolicy route Same suite, fixed TimeProvider
6E.2-3 A dropped feature is never passed to DecideAll or the release reader Evaluator unit test with recording fakes
6E.2-4 Every row of the truth table gives its value and reason, including a stopped key with the record still Active, and an imported record of Unknown precision FeatureAvailabilityEvaluatorTests (table-driven)
6E.2-5 A confirmed record from a foreign binding or environment leaves the feature notReleased; Legacy mode reports a Developing feature notReleased Replica-set test of the release-state reader, plus an HTTP test
6E.2-6 One DecideAll per request; each policy authorised once; at most one release query per 10 s per process under 50 concurrent requests Unit test with counters; reader test with a fake clock
6E.2-7 The golden pins exactly the response fields; no flag key, store epoch, digest or targeting appears Golden test
6E.2-8 Switch off: 404 and zero store reads. On, off, on gives the same answers HTTP test
6E.2-9 The routes are in the endpoint catalogue and pass the impersonation classification; an inactive user is refused like every authenticated route EndpointAuthorizationCatalogTests; SupportImpersonationHubAndClassificationTests
6E.3-1 RT-41 API half: user A dismisses; A sees dismissed: true, user B false; a later joiner sees the same release date and seen: false HTTP test with two e2e users
6E.3-2 An unknown announcement, a quiet release and a foreign binding each return 204 and write nothing; a real one writes one row HTTP test plus a collection count
6E.3-3 Concurrent identical PUTs leave one row with the first timestamps; a higher revision replaces, a lower one is ignored Replica-set store test
6E.3-4 Read-only impersonation refuses the PUT; full impersonation writes the target's row SupportImpersonationHubAndClassificationTests plus an HTTP case
6E.3-5 Rows written while on survive off and on HTTP test
6E.4-1 A late response for the previous project is discarded; 404 gives an empty set; an error keeps same-context data and a context change clears it pnpm exec ng test --no-watch --include="src/app/core/services/feature-availability.service.spec.ts"
6E.4-2 No extra request when no trigger fires; one fetch per accepted revision hint Same spec, fake timers

PR split

PR Content Depends on Parallel with
A (MVP), #4450 (merged) 6E.1, and this section main 6D PRs C, E, F
B (MVP), #4457 (merged) 6E.2 endpoint, evaluator, release-state reader, golden, swagger A 6D PRs C, E, F
C, #4460 6E.3 store and PUT; joins seen and dismissed into 6E.2 B D
D, #4478 (with 6G PR D) 6E.4 browser client; merges with 6G's first consumer B (C for the PUT) C

The MVP is A + B: on an isolated stack with RuntimeFeatureReleases:Enabled=true, a confirmed release of project-membership-disable shows as available to a project administrator in that project and is absent for a reviewer, a non-member and the no-project route. 6D PR D (stop recording) is not needed, because stops come from decisions.

Owner questions (programme decisions; Chris answered all three A on 2026-10-23)

# Question Recommendation
D25 (Q1) With no project open (the home page), should features that need a project permission count as available to someone who has it in at least one of their projects? Options: A no (hidden without a project; a page inside the project can show them); B yes (the server checks the person's active memberships, bounded and cached); C show them to every signed-in user A for 6E. It is correct, cheap and fails closed. B can be added later without changing the contract if 6G wants project features on the home page. C breaks the permission rule
D26 (Q2) The accepted plan also asks for a signed-out availability view. Do we still need it? Options: A drop it (signed-out readers see 6G's public What's New entries and the static feed); B build it for features marked public A. Nothing signed-out needs a per-feature answer, and dropping it removes a second, anonymous contract
D27 (Q3) When someone deletes their account, should their seen and dismissed records go too? Options: A keep today's behaviour (the records hold only a user ID and times) and erase them in the subject-deletion work OD-6 asks for before 6F; B delete them in the account-deletion path now (needs a {userId} index migration) A. It matches every other per-user store and needs no migration. Add a written follow-up to the OD-6 item. Awaiting Chris; #4460 follows A meanwhile and adds the follow-up to OD-6 in the accepted plan

Operational prerequisites (written only). Enabling availability in an environment needs RuntimeFeatureReleases:Enabled=true with RuntimeFeatureFlags:Mode=Shared and a bound environment; staging needs an owner decision (§8) and production stays Legacy and switched off. There is no migration and no pre-deploy Job change.

Not in scope. Any shared-environment execution; UI of any kind (6G, 6G.4); targeting, audiences and cohorts (6F); home-page any-project evaluation (D25 B); the anonymous variant (D26 B); protected content and workflow versions (6G); account-deletion erasure (D27 B).

Each 6E–6H slice gets its own detailed plan section (contracts, files and tests) before implementation, added to this document in the PR that starts it.

7.2 6F detail

Verified against main at d63db13c1. The full specification (schema, validation rules, contracts and line-level evidence) is the maintainer's handover note spec-6F.md; this section holds what a reviewer needs. Owner review of the admin design canvas (2026-10-23) is binding except where a decision below is open.

What 6F adds. Rules that turn a feature on or off for one project, one person or one person in one project, a stable gradual rollout, and (D29 A) consent. They live in the existing policy document as schema 2, written only when a rule or feature setting exists; with none, the document is schema 1, byte-identical to today, so every older binary still reads it. One evaluator decides, in the displayed order: requirements, stops, exclusions, specific allows (pair, then person, then project), gradual rollout, the saved setting, the baseline. Steps before the allows only keep a feature off; allows and rollouts only turn it on. Grains: Global keys take no rules; Project keys read project subjects only (every member gets one decision); Personal keys read pair, person and project subjects. Production stays Legacy, and every non-Global key is production-ineligible.

PR split (each default-off or build-time; main stays promotable)

PR Content Depends on Parallel with
A (MVP), #4463 6F.1 schema 2 reader, evaluator core, target context, reasons, host schema advert, report and checker additions. No writer main 6E C/D, 6D G
B (MVP), #4472 6F.2 project allow and exclude preview/commit, generator and registry grain checks, pilot contract (D33), constraint schema 1|2, RuntimeFeatureTargeting:Enabled switch A none
C (MVP), #4482 6F.3 two-phase route gate, context factory, operation context; 6F.4 project-aware availability B D
D (MVP), #4483 6F.5 "Preview and check" simulator API B C
E, #4495 (merged) 6F.6 personal and combined rules (one person level, D31 A; D34 A meets OD-6) C F, G, H
F, #4488 6F.8 gradual rollout and cross-language cohort vectors (project subjects; person subjects once E merges) B (C for rolloutPending) E, G, H
G, #4510 (in review) 6F.7 consent and environment settings (D29 A: consent: none \| offered \| required, a project's choice made by its administrator; D30 A: only the consent half) C (E for personal consent) E, F
H, #4496 (merged) 6F.9 per-rule switches (D28 A: targeting rules only; requirements, stops and the default never get one) B E, F, G
I, #4489 (merged) 6F.10 targeted release intents B, 6D A E–H
F2, #4509 (in progress) 6F.F2 Reliable creation time on projects and people (database clock; existing records count as created before any pause; no backfill), then an exact Pause (D32 C). Additive: a new field written on new records, not a data migration F (#4488, merged) E, G, H
W1, #4516 (merged) Browser gate: FeatureGate reads the runtime snapshot for Global features and project availability enabled for the rest (null falls back to the snapshot); the keyword-highlighting pilot's project settings, review toggle and PDF viewer move to it; RT-32 web half in feature-gate.service.spec.ts; Playwright scenario runtime-fleet-browser-feature-gate.spec.ts, passed on the Bramble runtime-fleet stack at d877a1dcf (P shown, Q hidden, revocation without reload; evidence handover/runtime-foundations-20261020-01a1519c-evidence/6F-W1-feature-gate/, outside the repo) C and 6E.4 W2
W2, #4519 (merged) Targeting page in Features and releases (M3, owner screen acceptance): the rules in the order SyRF checks them, a rule editor (project rules; person and pair rules on Personal features), per-rule switches (D28 A), draft then preview (gains and losses, people by name, inactive people listed for removal) then save with a reason, a conflict keeps the draft, refusals in plain words with the server's text under Details; "Choices projects have made" as counts (D29/D30); the D35 A line where a per-project stop would sit; the emergency stop stays on the Flags page; the rollout shown read-only with its pause time (D32 C). Adds the read-only GET api/runtime-feature-flags/{key}/targeting (Inspect or Change; names only under Change) the page needs to show saved rules, and the rule editor's project lookup GET api/runtime-feature-flags/targeting/projects (Change; active names or an exact ID, at most 20, each with owner, active members, stages, studies and creation date so similar names are told apart; owner feedback); people are found by name or e-mail from the Admin Console user list the User Impersonation lookup uses (GET api/investigators, ListUsers: Administrator, like Change; no new route; owner feedback) B (G for counts) W1, W3
W3, #4541 (merged) Rollout controls (…/targeting/rollout: amount 0.01%–100% as basis points, raise/lower with the lowering preview's names, Pause, Turn off, draft → preview → save with a reason) and "Preview and check" (…/targeting/check and beside the rollout preview: policy in force and draft side by side). Uses #4519's targeting read route D and F W2, W4
W4, #4523 (merged) Beta features pages (M3, owner screen acceptance): My Account → Beta features (Using now: in, turned on for you, stopped, off; Open to join, including those who left, with Rejoin; Coming soon: eligible only), search, filters, Join and Leave with a re-read of availability after each 204; a project settings section for Project betas, project administrators only. Lists only entries with consent; a stop shows Stopped, never "Open to join"; under full support impersonation the account page joins and leaves on the person's behalf ("(on behalf of …)"; recorded with realActorId and recordedUnderImpersonation), read-only impersonation keeps it disabled, and the project section stays disabled under any impersonation (403 consent_requires_own_session); every project Join, Rejoin and Leave is confirmed in a whole-project dialog; "New to you" and the What's New row reuse 6E/6G. Adds FeatureAvailabilityService.reread() G and W1 W2, W3

D44 (answered B, 2026-10-24): RT-34 fails closed. The question was what a Personal key decides when SyRF cannot confirm who is asking (two identities or user_id claims, a machine token, an unknown, deactivated or suspended Investigator, a failed read). B: the feature is treated as not available to them (Off), never on by default. Built in #4514: a new decision reason PersonUnconfirmed (appended, behind RuntimeFeatureTargeting:Enabled, no migration); a stop still reports Stopped; the route answers its unrouted 404 and availability reports unavailable / notReleased (no new wire value); Global and Project keys and ForOperation work are unchanged.

Status (2026-10-24, later). The owner answered D28 to D35 on 2026-10-24 (table below). E is #4495 and H is #4496; F (#4488) is merged. D32 C adds the planned piece F2.

Status (2026-10-24). PRs A (#4463), B (#4472) and D (#4483) are merged. PR B implements 6F.2: POST …/{key}/targeting/preview and PUT …/{key}/targeting through RuntimePolicyMutationStore (action Targeting, the existing receipt, replay and preview patterns), save-time refusals as 422 runtime_targeting_refused, schema-2 admission against host acknowledgements, a deployment constraint of policy schema 1 or 2, generator and registry check 9 grain rules, the pilot contract on screeningKeywordHighlighting with the keyword-highlighting feature (D33 A), and the RuntimeFeatureTargeting:Enabled switch (chart default false; bound on API and PM). It meets 6F.2-1 to 6F.2-10 in RuntimePolicyTargetingMutationTests (replica set), RuntimeTargetingAdministrationIntegrationTests, the endpoint catalogue and impersonation classification tests, and scripts/feature-registry.test.ts. It also closes the #4463 review follow-up #4469: a deployment pin that fixes a value now decides on the targeted path exactly as the context-free values do (RuntimeTargetingDeploymentPinParityTests), and stored rules survive later policy changes and stops. With no rule saved, the pilot decides byte-identically to main in Legacy and Shadow mode; Shared mode is the reviewed compatibility event recorded in the current-code matrix. PR D (#4483) adds the 6F.5 simulator, POST …/{key}/simulate. It is read-only and gated by InspectRuntimeFeaturePolicy or ChangeRuntimeFeaturePolicy. It returns the current and draft step traces from the runtime's own DecideTargeted path, plus the first changed step. Unknown subjects first got the same shape of answer as known ones; owner decision D59 = B (#4541) changed that: a project or person that does not exist, or is deleted, now refuses 422 unknownProject / unknownPerson ("No project with that ID", "No person with that ID"), because only application administrators, who can already list everyone, may simulate.

PR C (#4482, merged) implements 6F.3 and 6F.4. FeatureGated on a Global key is unchanged; on a Project or Personal key, phase 1 (action selection, before authorization) asks only IFeatureDecisions.MayBeOnForSomeTarget and phase 2 (FeatureTargetGateFilter, an action filter after authorization) decides the key for the routed project the policy admitted, answering an unrouted 404 when off. FeatureTargetContextFactory builds request contexts from the admitted route value only (no target for Global-only requests, no person read for a Project key); FeatureTargetContext.ForOperation names a background operation's project and never a person. Project availability decides targeted features for the routed project, adds enabled and the reasons excluded and prerequisiteMissing, omits Project-grain features without a project and moves contextId to domain v2. Phase 2 and project-aware availability act whenever the policy stores targeting for the key (IFeatureDecisions.HasStoredTargeting, Mode=Shared), with the switch on or off: off keeps exclusions and stops applied and drops allows, so switching off never widens access, and on, off, on gives the same gate answers with policy written in each state. With no stored targeting (the only state before the switch has been on) every gate and availability answer is byte-identical to main, pinned by a golden recorded against main's code. Criteria: 6F.3-1 and 6F.4-1 to 6F.4-3 in FeatureAvailabilityHttpTests.Targeting (one data set, the real keyword route and authorization handler, real decisions; 6F.2-9 at the gate with an exclusion negative control); 6F.3-2 in RuntimeTargetingRouteGateTests and FeatureDecisionsOperationContextTests (PM); 6F.3-3 and 6F.3-4 in FeatureTargetGateTests; 6F.4-4 in the availability golden and FeatureAvailabilityContractTests.

PR I (#4489, merged) implements 6F.10 behind RuntimeFeatureReleases:Enabled and RuntimeFeatureTargeting:Enabled (both default off). Preparation accepts a Project or Personal feature's Targeting intent; its enabled allow subjects are the audience (TargetingRequired for a Set, AudienceMissing with no allow subject, both 422 runtime_release_admission). Apply commits the prepared rules through the shared targeting commit path inside the Apply transaction, with every existing admission check, and refuses TargetingChanged once the key's rules moved after preparation (Apply replaces them). The reconciler, the stop and restore history and the outbox guard judge a targeted release per project of its audience with the deployment-aware evaluator; confirmation also needs the switch on. The fleet now reaches Active for every new-request-admission contract (membership and the pilot), so a pilot release can confirm. GeneralRelease needs its own intent. Criteria: the 6F.10 acceptance in RuntimeReleaseTargetedIntentTests (replica set) and FeatureAvailabilityHttpTests.TargetedRelease (Prepare, Apply and confirm an allow for P; available in P, notReleased in Q).

Amendment to criterion 6F.3-1 (accepted 2026-10-24). "No pilot leak" is met by identical non-member answers: a non-member gets the project policy's own refusal (403) in a targeted and an untargeted project alike. The unrouted 404 applies to members of untargeted (or excluded) projects only, because a non-member's 404 would need a decision before authorization, which is the leak the spec forbids. The person, membership and consent reads move to PRs E and G with the decisions they wait on (D31, D34, D29, D30).

PR F (#4488, merged) implements 6F.8 for project subjects. RuntimeCohort is algorithm syrf-cohort-v1 (length-prefixed UTF-8 fields, SHA-256, first 8 bytes big-endian modulo 10,000), with environmentId from the binding and salt version 1; it references no random source, new GUID or clock (IL architecture test). The evaluator's step 5 turns a project on (Split, bucket in the trace) inside an Active or Paused share and reports RolloutPending outside an Active one; availability maps that to rolloutPending. The writer accepts one project rollout per Project key (a rollout over people is personalRuleOnProjectGrain until E), refuses an amount, group or subject-type change while Paused (rolloutPaused), and, when a change alters membership, reads the population in the preview's transaction (a covered _id index scan of pmProject, at most 100,000 IDs, then _id $in reads keeping non-deleted projects) so gaining and losing list exactly the projects whose decision changes; the preview digest covers that list and the commit recomputes it. rolloutNotes words Pause (D32 C: until 6F.F2 ships, Pause holds the percentage and the screen says so), Turn off and lowering. The committed vectors (src/charts/syrf-common/cohort-vectors.json, from the Python reference scripts/cohort-vectors.py) are reproduced in C#, TypeScript (in validate:generated) and Python (in the script-tests workflow), and are append-only against the git base. Flag decision: behind RuntimeFeatureTargeting:Enabled (default off everywhere); nothing writes a rollout while it is off, no migration, no start-up work.

PR F2 (#4509, in review) implements 6F.F2 (D32 = C). New Project and Investigator documents carry createdAtUtc (set once by the constructor that makes the aggregate, from the host's UTC clock because the aggregates are saved by a whole-document replace; existing documents are never backfilled or rewritten; CreationDate is unchanged; both class maps carry the element through Entity.ExtraElements on an older binary, proven in BsonClassMapTests before the field was written). Moving a rollout to Paused stamps pausedAtUtc with MongoDB's time in the commit transaction, kept while paused and cleared by Active or Off; it is written only while every fresh host advertises the policy feature rolloutPausedAt. While paused, a subject inside the held share is in only if its creation time is absent or earlier than the pause (RolloutPaused and availability rolloutPaused otherwise). The target context takes creation times from the reads it already makes; the simulator shows both times; the preview decides projects with their stored creation times. Flag decision: the creation-time write is additive, unflagged data; its only reader is the rollout step behind RuntimeFeatureTargeting:Enabled.

PR F acceptance (6F.8)

# Criterion Verification
6F.8-1 C#, TypeScript and Python reproduce every committed vector (digest and bucket), membership row and refusal RuntimeCohortVectorTests; pnpm exec tsx --test scripts/cohort.test.ts; python3 scripts/test-cohort-vectors.py
6F.8-2 Over 10,000 subjects cohort(500) ⊂ cohort(2,500) ⊂ cohort(10,000), shares 5% ± 0.6 and 25% ± 1.2; one group nests, two groups overlap at 1% ± 0.4 RuntimeCohortPropertyTests
6F.8-3 No project, an empty GUID and a missing person never match and decide the context-free value; the cohort type references no Random, Guid.NewGuid or clock RuntimeTargetingRolloutTests; RuntimeCohortArchitectureTests (with a positive-control probe)
6F.8-4 Lowering 2,500 → 500 previews exactly the projects with buckets 500–2,499 (count and names) and commits only with that preview's digest RuntimeTargetingRolloutIntegrationTests (HTTP), RuntimePolicyRolloutMutationTests (a project created in range makes the preview stale; deleted projects never listed)
6F.8-5 Off then Active restores the identical cohort; Paused refuses an amount change Same suites and RuntimeTargetingRolloutTests
6F.8-6 One subject has different buckets in staging and preview:pr-1; a new binding epoch keeps the environment's cohort Vector file (environment-isolation), RuntimeCohortVectorTests, RuntimeTargetingRolloutTests

PR G (#4510, in review) implements 6F.7 with owner decisions D29 = A and D30 = A (consent half only; quieter announcements stay with D13 and 6G). The registry gains consent: none | offered | required (default none; always none for KillSwitch, Operational, Permission and Global features; a non-none feature needs a non-Draft release-content page whose title names the beta, generated into the C# FeatureDefinition.Title only); validator check 10 refuses each breach. Effective consent is the stricter of code and the policy document's featureSettings, which POST/PUT api/runtime-feature-flags/features/{featureId}/settings[/preview] writes through the policy writer (RuntimePolicyAction.FeatureSettings, receipt, replay, preview digest, schema-2 admission) and refuses consentNotStricter below or equal to code. The evaluator decides required and not joined as ConsentRequired (step 1) and a stored Left as OptedOut (step 3, after exclusion rules), whether the switch is on or off; a Joined never turns a feature on. Since #4572 (fixed in #4523) a subject with no choice gets ConsentRequired only where targeting would turn the feature on once joined; otherwise the decision (still Off) reports the targeting reason (RolloutPending, RolloutPaused, Excluded, or Evaluated, availability notReleased), so "Open to join" is never offered where a Join writes nothing. Choices live in runtimeFeatureConsent (_id = "{featureId}:{subjectType}:{subjectId}", _id reads and ranges only, rows of another binding epoch ignored); a request reads them once and only for features whose effective consent is not none (IFeatureDecisions.ConsentPlan). PUT api/account/features/{featureId}/consent (Personal) and PUT api/projects/{projectId}/features/{featureId}/consent (Project, ProjectEditPolicy inside a ProjectViewPolicy route) answer 204 unless malformed (400), write only when the spec's conditions hold, refuse 403 under any impersonation (since #4523 the account route records full impersonation on the person's behalf) and answer 404 while the switch is off. Availability entries gain consent, title and the reasons consentRequired and optedOut; GET …/features/{featureId}/choices returns counts only. Flag decision: behind RuntimeFeatureTargeting:Enabled (default off everywhere); no migration, no index, no start-up work; with no consent configured every answer is unchanged.

PR G acceptance (6F.7)

# Criterion Verification
6F.7-1 consent: offered on a KillSwitch, on a Global feature and on a feature without content each fail with the exact message; a valid Release passes pnpm exec tsx --test scripts/feature-registry.test.ts (check 10) and generate-feature-definitions.test.ts
6F.7-2 offered: reached on, Leave off optedOut, Rejoin on; required: reached and not joined off consentRequired, Join on, unreached Join writes nothing FeatureAvailabilityHttpTests.Consent (e2e users, real store), RuntimeTargetingConsentTests
6F.7-3 A reviewer's project PUT writes nothing (204), the administrator's writes one row, every member then gets the project's decision FeatureAvailabilityHttpTests.Consent with row counts
6F.7-4 An environment setting looser than (or equal to) code refuses; tightening offered → required turns the feature off for reached subjects who never joined RuntimeTargetingAdministrationIntegrationTests.Consent (replica set), FeatureAvailabilityHttpTests.Consent
6F.7-5 Impersonation, full or read-only, refuses both PUTs (403) and writes nothing SupportImpersonationHubAndClassificationTests, SupportImpersonationContractTests (real pipeline), FeatureAvailabilityHttpTests.Consent
6F.7-6 choices returns counts only, never IDs or names RuntimeTargetingAdministrationIntegrationTests.Consent golden (FeatureAvailability/Goldens/consent-choices.golden.json) and the wire golden (RuntimeFeatureConsentCounts.golden.json)
6F.7-7 Switch off: PUTs 404; stored Left and required consent still keep the feature off; on → off → on gives the same answers FeatureAvailabilityHttpTests.Consent, RuntimeTargetingConsentTests
RT-33 U6 U6, who left an offered feature, is off (OptedOut, availability optedOut) RuntimeTargetingPersonalDecisionTests, FeatureAvailabilityHttpTests.Consent

PR E (#4495, merged) implements 6F.6 with owner decisions D31 = A (one "person" level: the API's user_id claim is the Investigator ID) and D34 = A (opaque person IDs only; inactive people never match and are listed for removal; rules are never rewritten automatically). Personal keys take person, pair and project subjects, a project meaning its active members. FeatureTargetContextFactory.ForRequestAsync reads the person only when a requested key is Personal grain, and trusts it only for one authenticated identity, one non-empty user_id claim, no machine credential (gty=client-credentials, an environment:* scope) and an active Investigator from the authority snapshot reader; membership is one authority read of the admitted project for a trusted person. FeatureTargetSubjects holds those reads for the route gate's phase 2, availability and the simulator. Full and read-only impersonation decide as the target. The writer lifts notYetSupported for those subjects; a newly named inactive person refuses unknownSubject; a stored person who became inactive stays and appears in the preview's inactivePeople; the preview adds gainingPeople and losingPeople (IDs only) and decides a Personal key's project rules for an active member. Rollouts over people stay notYetSupported (their population preview over Investigators is not built), and a Personal feature's targeted release refuses PersonalAudienceNotSupported (confirmation decides projects without a person). The catalog has no Personal key yet, so tests use a synthetic Personal contract on the pilot key. Flag decision: behind RuntimeFeatureTargeting:Enabled (default off everywhere); no migration, no start-up work; with no stored personal rules nothing new is read.

PR E acceptance (6F.6)

# Criterion Verification
6F.6-1 RT-33 truth table on a Personal key, default Off: (P1, U1) pair On; (P2, U2) Off Excluded; (P3, U3) On; (P2, U4 member) On; (P2, U5 non-member) Off Evaluated; a pair rule on the Project key refused at save. The "U6 left an offered feature" row is decided by consent in PR G (OptedOut) FeatureAvailabilityHttpTests.Personal (availability and the gated route), RuntimeTargetingPersonalDecisionTests and RuntimeTargetingEvaluatorTests (core), RuntimePolicyTargetingMutationTests (pair on a Project key)
6F.6-2 RT-34: two user_id claims, no or malformed claim, an unknown, deactivated, suspended or pending-deactivation Investigator, a failed, malformed or throwing read, a client-credentials token, an environment:* scope and two identities each decide Off for a Personal key under default Off and default On (D44 = B, fails closed: availability enabled: false, the route's unrouted 404); the trusted U1 is the positive control, and Global and Project keys under the same defects decide as before FeatureAvailabilityHttpTests.Personal, FeatureTargetPersonTests
6F.6-3 RT-35: removing U4 from P2 without a policy change disables the feature on the next availability request and closes the gated route on the next request (the policy's own 403, as for a non-member, because the policy no longer admits U4; a member no rule reaches gets the unrouted 404) FeatureAvailabilityHttpTests.Personal (browser half: 6E.4)
6F.6-4 Full and read-only impersonation of U1 decide as U1; an inactive target or a machine caller decides the context-free value FeatureAvailabilityHttpTests.Personal, FeatureTargetPersonTests
D34 A newly named inactive person refuses unknownSubject; a stored person who became inactive stays, is listed in inactivePeople and blocks no unrelated change RuntimePolicyPersonalTargetingMutationTests (replica set)

MVP boundary: A + B + C + D. On an isolated stack (Bramble, two API and two PM replicas, Mode=Shared, the switch on, the pilot at default Off): an administrator previews and saves an allow for project P; P's gated route works for its members and availability shows enabled: true; project Q answers 404 like an unrouted path; an exclusion with default On turns it off for P only; the simulator explains both; removing every rule returns the document to schema 1; switching off keeps exclusions and drops allows. Shortest critical path: A → B → C, with D beside C.

MVP rehearsal (#4487). The runtime-fleet harness's named-only targeting-mvp scenario runs every step above on an isolated Bramble stack (two API and two PM hosts, Mode=Shared, all four restarted with the switch on, off and on again), with real HTTP and the e2e users: P and Q are created through the PM e2e setup API, the reviewer is a project Administrator of both and the standard user a member of neither. It passed with no failed assertion on main's product code (6F A to D unmodified). Fleet adoption is read from the disableMembership fleet view, because readiness is established only for that key (RuntimePolicyFleet), so the pilot's own fleet view and the targeting preview's fleetReadiness read Unknown (DomainReadinessNotEstablished).

PR A acceptance (6F.1)

# Criterion Verification
6F.1-1 A schema-1 document parses, digests and serializes byte-identically to main (golden taken from main before any change, plus a frozen copy of main's code over 200 generated policies); a schema-2 document round-trips; each validation rule refuses with its exact message, valid documents as controls RuntimePolicyAuthoritySchemaTests
6F.1-2 The same rules in any array order give one canonical document and one digest; empty targeting is refused under schema 2 and absent under schema 1 Same suite
6F.1-3 Every precedence row decides with its reason on synthetic Project and Personal contracts, including exclusion over allow, a switched-off rule skipped, rollout Off contributing nothing, default On turned off only by an exclusion, PrerequisiteMissing, and TargetingIncompatible RuntimeTargetingEvaluatorTests
6F.1-4 For 72 host, mode, environment and reader-state worlds, every catalog key decides as in a frozen copy of main's results, through DecideAll(keys), DecideAll(keys, None), DecideAll(keys, target) and Decide; the targeted Global path allocates nothing extra (warmed, minimum of long runs, 1 B epsilon, with an allocating positive control) Same suite
6F.1-5 A frozen schema-1-only reader reports a schema-2 document Unavailable and reads the schema-1 document written after the rules are removed as Ready; an acknowledgement row without the field parses as [1]; the checker does not pass a schema-1 candidate against a schema-2 report, refuses targeted-key-grain-changed and passes the schema-2 candidate SyRF.Mongo.Common.Tests; python3 scripts/test-runtime-compatibility-check.py
6F.1-6 The anonymous browser snapshot keeps schemaVersion: 1 and is identical with and without targeting (apart from the policy digest, which names the document) RuntimeTargetingBrowserEnvelopeTests golden

Flag decision for A: not flagged. It reads a shape nothing writes yet, Global keys decide exactly as on main, and allows and rollouts are never applied before PR B binds the switch (exclusions and stops can only keep a feature off).

"Global only" is enforced in six places (inaccuracy 3 below). PR A changes none of the refusals; it only reads and decides targeting. They move in later PRs: the catalog generator's grain: Global check (done in B, which also keeps production eligibility Global-only: non-Global stays production-ineligible, now refused by the generator too); stored and requested stops (RuntimePolicyAuthority.ValidateStops, RuntimePolicyMutationStore.ValidateRequest) when a non-Global key first needs a stop; release preparation (RuntimeReleaseIntentStore) in I; availability's non-Global row (FeatureAvailabilityEvaluator) in C, where it now applies only while targeting is not in force.

Owner decisions (programme decisions, answered by Chris on 2026-10-24)

# Question Answer
D28 (Q1) Should each exclusion, allow and rollout rule have its own on/off switch? A: yes, switching is a reviewed change; B: no, delete and re-add A. Targeting rules (exclusions, specific project and person rules, the rollout) get their own switch. Requirements, stops and the default never do. A rule keeps its settings while off; a rollout's switch is its "Turn off". Built as 6F H (#4496)
D29 (Q2) Should a feature declare whether people must agree to it (none, offered, required)? A: yes, a project's choice made by its administrators; B: no consent A. One registry setting per feature, consent: none \| offered \| required. For project features the choice belongs to the project administrator. Leaving becomes an exclusion; only the person or project chooses, never support staff acting as them. Built in 6F G
D30 (Q3) Who owns which setting? A: code owns what a feature is, each environment owns rules, rollout and stops and may only make settings stricter, with a reason; B: environments may loosen A. One owner per setting. Code (the registry, a reviewed PR) holds what a feature is and promises. Each environment holds who gets it (rules, rollout, stops) and may only make code settings stricter, with a reason. In 6F only the consent half is built; quieter announcements stay with D13's per-release quiet option and 6G
D31 (Q4) The API's user ID is the Investigator ID. Merge "user" and "investigator" into one level? A: one "person" level (pair, then person, then project); B: two levels A. One "person" level, in the order person-in-project, person, project. Built in 6F E (#4495)
D32 (Q5) What should Pause do, with no trusted creation time? A: hold the amount, and say new subjects inside the share still join; B: save the members as a list; C: add a creation time first C. Add a reliable creation time to projects and people first, then make Pause exact (Pause keeps out anyone created after it). Planned as 6F.F2 (PR table above). Until then Pause holds the percentage (behaviour A) and the screen says so. F2 is additive (a new field written on new records), not a data migration. "Turn off" remains the tool for stopping
D33 (Q6) Which feature pilots project targeting? A: keyword highlighting (screeningKeywordHighlighting); B: membership disabling; C: a test-only flag A. Already built
D34 (Q7, OD-6) Is subject deletion settled enough for personal targeting (opaque IDs only, inactive people never match and are listed, choices erased with D27's follow-up)? A: yes; B: also rewrite rules automatically on deletion A. Current data protections are enough before personal targeting. This satisfies the accepted plan's OD-6 gate for 6F E
D35 (Q8, changes OD-13 P-4) Build a per-project emergency stop now? A: no, use a project exclusion or the environment stop, and add one with the first flag whose safe value is not Off; B: yes A. No separate per-project emergency stop now. Where that control would be, the Targeting page (W2) shows a short explanation with links to the alternatives: add an exclusion for that project (reviewed), or use Stop for everyone in an emergency

Corrections to the plan documents (applied in PR A, from the spec): README §6 and accepted §11.1 now use one person level (inaccuracy 1, D31) and say stops apply to every grain from the environment, with project stops not built (2, D35 A); accepted §11.3 names the six enforcement points (3); README §6 rollout gains the algorithm ID as the first hash field, a fixed salt version 1, and says Pause cannot freeze a cohort (4, D32); RT-31 is proven with the shared evaluator on the PM host (5); the 6F rows above depend on 6E.2, not 6D (6); accepted §16 OD-6 is recorded against PR E (8); README §9 puts exclusions with project and personal rules (9); accepted §6.2, §7.1 and §7.5 note the schema-2 document, the compatibility additions and the availability fields (B3, B4). Inaccuracy 7 (§1, §2 and §7.1 status of #4457 and 6E.3) is corrected by #4460.

Operational prerequisites (written only). Using targeting needs Mode=Shared, a bound environment, the switch set to the same value for API and PM, and every replica on a binary that advertises policy schema 2. Staging needs an owner decision (§8); production stays Legacy and off. A rollback below a schema-2 binary while rules exist is refused by the checker; the remedy is a forward revision that removes every rule, then the rollback. No migration and no pre-deploy Job change.

Not in scope. Any shared-environment execution or RuntimeFeatureTargeting:* value; making a non-Global key production-eligible; expressions or attribute rules; re-shuffling a cohort; project-level stops (D35 A); account-deletion erasure (D27 follow-up).

7.3 6G detail

Verified against main at 21c73ed99 (6D PRs A–G and 6E.1–6E.3 merged). The full specification (contracts, files and line-level evidence) is the maintainer's handover note spec-6G.md; this section holds what a reviewer needs. 6G covers help and What's New for runtime releases, in-app walkthroughs (6G.4) and the release admin screens, which moved here from wave 7 (2026-10-23). The admin screens follow the agreed design canvas.

What 6G adds. Feature-linked What's New text that the server discloses only to the people a feature is available to; help links that open the guide at the right section and show whether the feature is available; a walkthrough shown once when a feature becomes available; and admin screens to release, watch and settle features. It follows five rules:

  • Content links to features through page and section metadata. What's New text names its featureId in docs/content/whats-new.yaml; guide pages name their topics in front matter (E1). Every human-written source stays YAML or Markdown.
  • One disclosure gate: availability. Personalised What's New entries travel inside the 6E.2 availability response, per feature, only when that feature is available to the caller. There is no second evaluator or personalised endpoint.
  • The outbox decides what was announced. An entry shows once its MaterialiseWhatsNew row is Done, and "New to you" once its NotifyInApp row is Done by a non-test handler. Both production handlers are idempotent no-ops; there is no projection store.
  • Publication evidence (#4382). A public class counts only for a source in user-guide/; a restricted entry's audience is its own feature until 6F; a Retired entry cannot claim it was public (D40). D40 B adds the evidence such a claim would need, 6G.P's publication record; the refusal stays until 6H consumes it.
  • Rendering. The server returns Markdown and the browser renders it only through MarkdownPipe in untrusted mode; the validator also refuses images, raw HTML and non-https, non-guide links. Date-only headings use Europe/London.
  • Release notices (6G.N, restarted 2026-10-24). RuntimeReleaseWhatsNewHandler also writes one notification fan-out intent for non-quiet releases; it remains idempotent by dedup key. Its XML comment ("writes nothing and reaches nobody") changes in 6G.N.

PR split (each default-off or build-time; main stays promotable)

PR Content Depends on Parallel with
A (MVP), merged #4466 6G.1 runtime What's New source, server bundle schema 2, compiler publication-evidence rules, WhatsNewEntryMissing, isoDate main W1, AD1
B (MVP), merged #4474 6G.2 What's New and in-app notice handlers, the announced-record fix, outbox join, bundle cache, availability fields (rehearsal, always false since D60 A; announcement.delivered, whatsNew); also #4473 (entry revision and bundle digest bound into the Apply digest and PolicyCommitted, one prepared-bundle pin rule, bounded bundle cache) A W1, AD1, AD2
C (MVP), with D in #4478 6E.4 browser availability client (6E PR D), merged immediately before D B —
D (MVP), #4478 6G.3 home-page What's New and the project "New in this project" card (D36 A; merged) C E1
E1, #4484 6G.5 guide front-matter topics (syrf.topics in user-guide/ and the new docs/content/protected/); release-content.yaml retired, the compiler takes no manifest; help-topics.generated.ts; validator checks 2 and 3 read the pages and the generated file A B, C, D
E2, #4524 6G.5 help-topic link with an availability chip (RT-69 in-app half): syrf-help-topic, used on the membership page; merged C, E1 F
F, #4529 6G.6 public What's New projection (GET /api/whats-new/public, anonymous, behind RuntimeFeatureReleases:Enabled), signed-out merge and signed-in deduplication by announcement, RT-69 rehearsal rows B, D E2
G, #4513 6G.7 server bundle built into the API image (D38 A): CI compiles it in the API image build, the Dockerfile copies it to /app/release-guidance/, GuidanceBundlePath defaults to it, a pre-push image check (G-1), the Secret mount and its env mapping removed, content paths rebuild the API A all
P1, #4531 6G.P publication manifest (D40 B): the compiler writes the public-only publication.json; the user-guide image serves it at /syrf-publication.json, with a pre-push image check (P-2); the compiler and definitions rebuild the guide E1, G P2
P2, #4532 6G.P publication recorder (D40 B): the API records, per verified environment binding, which public revisions its guide origin served (runtimeHelpPublication), behind RuntimeFeatureReleases:Enabled P1's manifest format (fixtures); P-9 needs P1's image P1
H 6G.8 protected help reader (D39 A: planned, built with the first private-pilot article) E1, C —
W1, #4497 (merged) 6G.4 walkthroughs.yaml, generator, validator, data-tour-id anchors (D41 A: text ships in the web bundle; the validator refuses a walkthrough for a feature whose help topics are all restricted) main A, AD1
W2, #4517 6G.4 tour component (TourService, TourStepComponent: one CDK overlay, M3 card, focus trap, Esc, focus return, live announcements, reduced motion, missing anchors skipped) and WalkthroughLauncherService (reads only 6E.4; starts once for an available, announced, unseen feature; close or finish records seen), plus What's New "Show me how" (replay, no state change) on the project card; awaiting owner screen acceptance (W2-6) C, W1 E2, F
AD1 6G.10 admin shell /admin/features (side menu Overview, Features, Flags, Releases, Environment, Content; feature tabs Overview, Targeting, Settings, History) and the split capabilities main A, B
AD2: AD2a merged #4476; AD2b merged #4518 6G.11 Releases screen and its read routes (list, events, announcement rows). AD2a: the read routes. AD2b: the read-only Releases list and release page (status, milestones, announcement state, timeline, Apply-preview admission check) and the feature History tab; merged. Prepare, Apply, pause and stop from the screen (canvas ReleaseApply) and release items on Overview follow AD1 B
AD3, merged #4521 6G.12 Ready to settle: an Overview attention item, a Features filter and status, and the lifecycle state on the feature page (not a menu item). GET /api/feature-releases/ready-to-settle reads release records and the flag history (D43 = B: the later date wins, a disagreement is noted), behind RuntimeFeatureReleases:Enabled, no migration; owner accepted the screens; merged AD1 AD2
N (restarted 2026-10-24) 6G.N Release notices in the in-app notification inbox (D42 C): the featureRelease kind, one more message type in the notification inbox work (PRs #3932, #3938 and #3941 to #3947). The notification implementation restarted on 24 October 2026 with the owner's yes. It is built as N6 (recorded fan-out) and N7 (the kind) of the notifications implementation plan (/home/chris/workspace/syrf/handover/notifications-implementation-plan-2026-10-24.md, outside the repository). The kind is in-app only (category none), is created by the MaterialiseWhatsNew handler (D47 A) through a fan-out intent for non-quiet releases, and shares seen and dismissed state with What's New through RuntimeFeatureDiscoveryStore (collection runtimeFeatureDiscoveryState). Flag featureReleaseNotices, default off, depends on notificationInbox. Decided 26 October 2026: D47 A (the What's New step, MaterialiseWhatsNew, creates release notices; expansion stops on AnnouncementCancelled; a stopped release shows the notice as "no longer available"), D45 B (new PR from main re-applies #3965's ten commits by cherry-pick after #3944 merges; #3965 closed with a link), D46 B (staging switch-on in-app only, with Chris's recorded approval; per-project admission waits until before production), D48 A (Restore brings back the inbox row only; What's New stays dismissed), D49 (opening a notice marks it read; release notices in-app only; digests skip resolved and dismissed items at claim time) and D50 A (notificationInbox registers as Release, lifecycle Developing). Until it ships, the "New to you" label on What's New is the in-app notice B, C; the inbox work (N1 to N3, N6) —
AD4, #4534 (in review, awaiting owner screen acceptance) 6G.13 Content preview: per feature and audience, the What's New entry, guide section and walkthrough that audience would get (read-only) A, W1, AD1 AD2, AD3
flowchart LR
  A[A content] --> B[B disclosure + handlers]
  B --> C[C 6E.4 client]
  C --> D[D What's New screens]
  A --> E1[E1 page metadata]
  E1 --> E2[E2 help link chip]
  C --> E2
  B --> F[F public projection]
  D --> F
  A --> G[G bundle in image]
  G --> P1[P1 publication manifest]
  E1 --> P1
  P1 -.-> P2[P2 publication record]
  E1 --> H[H protected reader]
  W1[W1 walkthrough source] --> W2[W2 tour component]
  C --> W2
  AD1[AD1 admin shell] --> AD2[AD2 Releases]
  AD1 --> AD3[AD3 Ready to settle]
  AD1 --> AD4[AD4 Content preview]
  W1 --> AD4
  A --> AD4
  AD1 --> W2T[6F W2 Targeting page]

MVP boundary: A + B + C + D. On an isolated Bramble stack (Shared mode, RuntimeFeatureReleases:Enabled=true, dispatch on, a production-delivery binding fixture for the notice (no longer needed since D60 A, 26 Oct 2026), a rehearsal-only registry entry), an applied and confirmed release of project-membership-disable shows a sanitised "New in this project" entry, with its Europe/London date and "New to you", to a project administrator in that project. A reviewer, a non-member and the home page show nothing; "Hide" removes it for that administrator only; a stop hides it and a restore brings it back with the same date and no "New to you" for people who had not seen it (D14). Shortest critical path: A → B → C → D.

PR A acceptance (6G.1)

# Criterion Verification
6G.1-1 whats-new.json differs from main only by isoDate; no runtime entry, featureId or runtime text appears in it or in the public bundle (byte scan for each runtime title) pnpm run validate:generated; compiler test
6G.1-2 A public-class entry outside user-guide/, a restricted entry with a foreign audienceId, a Retired entry with a public historical class, an unparseable static date, a runtime entry with an image, raw HTML or an http: link, and a runtime entry for a quiet feature each fail with an exact message; the membership entry passes (positive control) tsx --test for the compiler, generator and validator tests
6G.1-3 C# and TypeScript agree on a schema 2 golden digest and a tampered byte fails; schema 1 bundles still verify RuntimeReleaseGuidanceBundleTests
6G.1-4 The Apply preview refuses WhatsNewEntryMissing with no write; the same release with Announcement: Quiet and a reason is admitted; an emergency stop never reads the bundle RuntimeReleaseGuidanceAdmissionTests; RuntimeReleaseIntentAdministrationIntegrationTests

Flag decision for A: not flagged. It changes build-time sources and generation, and adds a stricter admission inside the existing default-off RuntimeFeatureReleases:Enabled Apply. There is no migration and no start-up work. Release records prepared before it merges need preparing again only if their definition changed; none exist outside isolated stacks.

6G.P publication record (D40 B). The full specification is the maintainer's handover note spec-6GP.md. An environment records which public topic revisions its own guide site served: the guide image serves a public-only publication manifest (P1), and the API's recorder fetches it anonymously from the environment's guide origin, checks each listed page (200, no redirect, its section anchor present) and upserts one runtimeHelpPublication row per environment, topic variant and revision (P2). Only a production binding with the production origin can count as production evidence. MVP = P1 + P2; shortest path P1 → P2 → the isolated Bramble run (P-9).

# Criterion PR Verification
P-1 Fixture trees with one public and one protected page give a manifest with the public entry (its revision equals the bundle entry's) and no protected entry, title or ID; C# and TypeScript agree on a golden digest; a tampered byte or an extra field fails P1 compile-release-content.test.ts ("the publication manifest lists public topics only, with the bundle revisions"); RuntimeHelpPublicationManifestTests
P-2 The guide image holds exactly one /usr/share/nginx/html/syrf-publication.json, byte-identical to the compile output, and no server-*.json; the API image is unchanged (G-1 still passes) P1 _docker-build.yml step "Check the publication manifest in the guide image"; test-user-guide-publication-image-contract.sh, run by validate-workflows.sh
P-3 to P-9 Only served pages are recorded; restricted entries never; binding and origin guards; idempotent and bounded; no start-up work and fail-soft; switch round trip; isolated end-to-end run P2 spec-6GP.md acceptance 3–9
P-10 Docs and guards. P1: the manifest, image and "served" meaning in manage-feature-flags.md ("Publication manifest"), the user-guide README build steps and this plan row. P2: the mongodb-reference.md row, the setting and guard, and enrolment in RuntimeReleaseCollectionsForwardRuleTests P1, P2 pnpm run validate:generated; docs/scripts/validate-docs.sh --skip-indexes; the forward rule test (P2)

Flag decision for 6G.P: P1 is not flagged (the manifest is static, public-only and read by nothing until P2); P2 sits behind the existing RuntimeFeatureReleases:Enabled, on the Shared-mode API writer only. No migration. Open owner question Q-P1 (recording before production runs releases) affects P2 only.

Status (2026-10-24). PR A (#4466) and PR B (#4474) are merged. PRs C and D are one PR, #4478: the 6E.4 browser availability client (FeatureAvailabilityService, 6E.4-1 and 6E.4-2) and the 6G.3 screens. The home page What's New merges runtime entries with the static feed in date order, and the project overview gains a "New in this project" card, built to D36's recommendation A (answered). Specs cover 6G.3-1 to 6G.3-4, with RT-41's rendering half also proven in the component spec. The MVP-boundary run passed on an isolated Bramble stack (21 of 21 criteria, with a rehearsal-only registry entry and a production-delivery binding fixture, which the spec no longer sets since D60 A; e2e/tests/runtime-fleet-browser-whats-new.spec.ts). 6G.3-5 (owner screen acceptance) waits for Chris. E1 to AD4 have not started. The owner answered D36 to D43 on 2026-10-24 (D43 B); D45 to D50 were decided on 26 October 2026. G (D38 A) is #4513. 6G.P (D40 B) is P1 #4531 and P2 #4532. AD3 (Ready to settle, D43 B) is #4521, merged. W1 is #4497. N restarted on 24 October 2026 (D42 C; see the N row in the PR split table).

Owner questions (D36 to D43 answered by Chris on 2026-10-24)

# Question Options Answer or recommendation
D36 D25 A hides project-permission features when no project is open, so the first real release (disabling memberships, for project administrators) would never appear in the home-page What's New. Where should it appear? A: a "New in this project" card on the project overview, fed by that project's availability; the home page shows features that need no project. B: reopen D25 and evaluate the person's projects on the home page (bounded, cached). C: home page only, accepting that project features never show there Answered A (2026-10-24). The "New in this project" card is built in #4478, awaiting owner screen acceptance. Was: recommendation A. It shows the news where the feature is used, keeps D25 and adds no server work
D37 The accepted plan shows "Planned" for scheduled releases (RT-29), which tells readers about a feature before it is released. Keep it? A: drop it: What's New shows only confirmed releases; schedules stay on the admin Releases screen. B: show "Planned" to people who would be permitted to use the feature Answered A (2026-10-24). The "Planned" label is dropped; What's New shows only confirmed releases (RT-29 minus "Planned"). Was: recommendation A. It removes a surface and keeps What's New from claiming an unconfirmed release
D38 How does the API get the compiled help bundle? Today it would be a Kubernetes Secret, capped at 1 MiB, needing a cluster-gitops change for every content edit A: build it into the API image (CI compiles it; the Secret mount goes). B: keep the Secret (1 MiB limit). C: a ConfigMap (same limit) Answered A (Chris, 2026-10-24): "CI generates it and builds it into the API image, so each API version carries the file made from the same code. There's no extra step and nothing to keep in step." Built as PR G (#4513). Was: recommendation A. No size cap, no secret handling and no per-edit GitOps change; the cost is an API image build when guide content changes
D39 Build the protected in-app help reader (articles, server search, assets) now, or when needed? No private-pilot article exists and the first release's guide is public A: plan it (6G.8) and build it with the first private-pilot article; until then public topics open the public guide, with the availability chip in the app. B: build it now Answered A (2026-10-24). The protected in-app help reader is planned as 6G.8 and built with the first private-pilot article. Was: recommendation A. Nothing would use it yet; RT-28's API half moves with it (C4)
D40 A retired page may claim it "was public", and nothing can prove that today (#4382: a declaration is not proof) A: refuse such claims everywhere in 6G; 6H adds a publication record if retired archives are needed. B: record now which public revisions each environment has served Answered B (2026-10-24): record now which public revisions each environment has served, so that if 6H ever keeps retired pages in a public archive, "this page was public" rests on evidence rather than a typed historicalPublication. Planned as 6G.P (P1 #4531, P2 #4532); the compiler's refusal of Retired historical-public claims (PR A) stays until 6H consumes the record. Retired pages are served nowhere today. Was: recommendation A. No retired content exists; B adds a collection for no current use
D41 Walkthrough text ships in the web bundle, so anyone can read it in the JavaScript. Acceptable? A: yes: it describes UI that ships in the same bundle; the validator refuses a walkthrough for a feature whose topics are all restricted. B: serve it from the server bundle through availability Answered A (2026-10-24). Walkthrough text ships in the web bundle; the validator refuses a walkthrough for a feature whose help topics are all restricted (6G W1, #4497). Was: recommendation A. It needs no API and exposes nothing the shipped UI code does not
D42 What is the in-app notice for a release? A: the "New to you" label on the What's New entry (and the walkthrough, when there is one); no extra banner. B: also a dismissible banner where the feature is used. C: a notification centre Answered C (2026-10-24): a notification centre, built as 6G.N. Release notices become one more message type (featureRelease) in the in-app notification inbox work (PRs #3932, #3938 and #3941 to #3947). The notification implementation restarted on 2026-10-24 with the owner's yes, so the pause no longer applies. Until it ships, the "New to you" label on What's New is the in-app notice. Was: recommendation A. One surface fewer; B or C can follow if people miss announcements
D43 "Ready to settle": the plan names each flag's audit history as an interim source beside release records A: release records only (historical import covers features released before 6D); features without a GeneralRelease record are not listed. B: also read flag audit history Answered B (2026-10-24). Read both; when they disagree about when the feature went live for everyone, use the later date (never ready sooner than both allow) and note the disagreement on the feature. A feature switched on by hand with no release record appears through the history. Built in AD3, #4521 (merged). Was: recommendation A

Amendments to the accepted plan (recorded here rather than reopened)

  • C1 (§12.3, §7.4): the What's New projection is derived at read time from confirmed records, the current bundle and live decisions, and shown once its MaterialiseWhatsNew row is Done; there is no projection store. Personalised entries travel in the availability response (6E.2 gains rehearsal, retired and always false since D60 A, announcement.delivered and whatsNew).
  • C2 (§12.1, §12.2, #4382): a public class is evidenced by the source living in user-guide/; a restricted audienceId must equal its featureId until 6F; Retired historical-public claims are refused (D40). Built in PR A. D40 B (2026-10-24) adds 6G.P, the record of served public revisions that such a claim would need; the refusal stays until 6H consumes it.
  • C3 (RT-28): unpermitted, foreign-audience and old-revision requests answer the same 404 as an unknown topic, not the 403 RT-28 names, so they reveal nothing (6E's no-leak rule).
  • C4 (§7): 6G acceptance becomes RT-29 (without "Planned", D37 A), RT-41's rendering half, RT-68 and RT-69's in-app half; RT-28's API half moves with 6G.8 (D39 A). The 6G row's "#4382" dependency is a merged PR, not open work.
  • C5 (§5A 6R.1): release-content.yaml is replaced by page front matter (E1); whats-new.yaml gains runtime: entries (PR A).
  • C6 (§5A "Admin Ready to settle", wave 7 Releases screen): delivered as 6G.11 and 6G.12 in the 6G.10 shell; release records and the flag audit history are both read, and the later date wins (D43 = B).
  • C7 (production prerequisite P12a): D38 A (2026-10-24), so the bundle ships in the API image instead of a Secret (PR G, #4513); the Secret mount and its chart values are removed.

Operational prerequisites (written only). Using 6G in an environment needs RuntimeFeatureReleases:Enabled=true and RuntimeFeatureReleases:OutboxDispatch:Enabled=true with RuntimeFeatureFlags:Mode=Shared and a bound environment; staging needs an owner decision (§8) and production stays Legacy and off. With D38 A no chart value is needed for the bundle. 6G.P's recorder (P2) also needs RuntimeFeatureReleases:PublicationRecord:GuideOrigin per environment (written only; production must use https://help.syrf.org.uk). No migration and no pre-deploy Job change.

Not in scope. Any staging or production execution and any RuntimeFeatureReleases:* or cluster-gitops change; the public renderer migration, redirects and retirement of the Jekyll reader (6H); a wider help-guide overhaul (accepted plan §17); targeted audiences and per-audience guidance (6F); re-notification revisions above 1 (§12.4); a notification centre or email (6G.N is the inbox message type, restarted 2026-10-24; in-app only); a signed-in landing page; "Planned" entries (D37 A); the archive of retired pages itself (6H; 6G.P only records evidence); the protected reader before its trigger (D39 A); an operator requeue for Failed outbox rows and the refusal of Apply under a stop (#4456); cross-environment administration (D24's later option).

7.4 6H detail

Verified against main at c2620d82c (6G.P P1 and P2 and 6H H0 merged). The full specification (starting point, line-level evidence, risks) is the maintainer's handover note spec-6H.md; this section holds what a reviewer needs. 6H covers the public help reader: URLs that never break, link and content parity, automated accessibility checks, the renderer migration and the retirement of the old reader.

What "public renderer" means. Accepted plan §5 Increment H, OD-7 ("keep the provisional Starlight recommendation, subject to the design work and a representative migration proof", accepted 15 October) and OD-15 (keep the public site public; no new hosted docs service). 6H replaces the Jekyll build with a Starlight (Astro) static build of the same user-guide/ Markdown, served by the same user-guide nginx image at the same hosts. It is not served by the web app or the API (6G.8, D39 A). The switch is conditional on the migration proof, so the go or no-go after H3 is owner decision D53. RT-28 moved to 6G.8 (C4) and runtime flag retirement is not in the 6H row, so neither is here.

Rules. One source: pages stay Markdown with today's front matter, so authors, the content compiler, help-topics.generated.ts and the publication manifest do not change. URLs never break: every URL in the history answers 200, or one 301 with a relative Location to a 200, from H1 onwards, on Jekyll and then on Starlight. Same URL forms and same heading IDs as Jekyll (an explicit {#id} where the slugger differs, never a silent change). Search becomes Pagefind's static index of public pages. A retired page is a 301 to its replacement (reason: retired), never a claim that it "was public"; a public archive copy needs 6G.P production evidence (H6, D55).

The slices

  • H0 In-app link fix (#4536, merged). review-settings links stages/settings.html and create-search links studies/upload-search.html#wizard-step-3-upload; the repo-wide help-links-resolve.spec.ts refuses a literal guide path that names no page.
  • H1 URL history, redirect map and link-parity gate (#4537). scripts/generate-user-guide-urls.ts (run by pnpm generate, checked by validate:generated) keeps user-guide/url-history.json: every URL the guide has served, seeded from git only (the tree before b35f161899, user-guide-v3.9.1 and main at 6db36f1fd), with kinds page, file (figures, CSV, LICENSE-CC-BY) and generator (theme CSS and JS, Lunr index, feed.xml, syrf-publication.json). It is append-only. A history URL the tree stops serving must be the from of an entry in user-guide/redirects.yaml ({ from, to, reason: moved | retired | alias }). The generator writes user-guide/nginx/redirects.conf (exact-match return 301 locations for x.html, x and, for directory pages, x/ and x/index.html, skipping forms still served); the image copies it to /etc/nginx/syrf-redirects.conf (generated Dockerfile, dependency-map.yaml image_files) and the entrypoint includes it with absolute_redirect off. scripts/user-guide-parity.ts checks a running image against the history and the in-app links, or compares two images (status, Location, heading IDs, internal links, image targets, a digest of the normalised main-content text), and writes parity-report.json and .md. The _docker-build.yml step "Check guide links" (user-guide only, before the push, existing juniper-ci normal Docker route) loads the image and runs .github/scripts/check-user-guide-links.sh: the check, self-parity of two containers and test-seo-headers.sh on the same image. Pinned by test-user-guide-link-check-contract.sh in validate-workflows.sh.
  • H2 Automated accessibility checks (#4539, stacked on H1). @axe-core/playwright 4.13.0 (exact; 4.14.0 was ten days old) in e2e/, e2e/playwright.user-guide.config.ts and e2e/user-guide/a11y.spec.ts (outside e2e/tests/, so the E2E configs never pick it up), run by .github/scripts/check-user-guide-a11y.sh as the last stage of "Check guide links" in mcr.microsoft.com/playwright:v1.58.2-noble (e2e's locked version, pinned by digest), joined to a guide container's network. axe with the WCAG 2.0/2.½.2 A and AA tags on every history page the image serves (URLs answering a redirect are skipped) at 1280 and 390 px; keyboard checks: skip link first, visible focus and no trap over the first 80 Tab stops, and search by keyboard with focus return on the home page. Dark runs when the guide offers a dark scheme; Jekyll's just-the-docs 0.3.3 does not, so the dark contexts record "skipped" until H3. Findings are keyed by page, rule and target against user-guide/a11y-baseline.json (1,365 entries for the Jekyll guide: 699 colour contrast, 516 link name, 69 focus visible, 62 skip link, 10 target size, 6 label, 2 frame title, 1 keyboard search); a new finding fails, and so does an entry no complete run finds (the baseline only shrinks). About four minutes per guide build (two workers, two CPUs).
  • H3 Starlight migration proof (#4540, stacked on H2; nothing shipped). user-guide/site/ is a pnpm workspace member (syrf-user-guide: Astro 7.3.8, Starlight 0.42.6, @astrojs/markdown-remark 7.3.2, exact versions at least two weeks old). A content loader reads the pages in user-guide/ in place by Jekyll's rules; one route writes each page at its Jekyll output file (build.format: "file"; directory pages are moved to x/index.html before Pagefind indexes them); a remark plugin handles the kramdown and Liquid subset in use (kramdown heading IDs computed with the compiler's own headingText/anchorOf, attribute lists, {:toc}, smart quotes and dashes, 1) text, figures as raw <img> at their own URLs; anything unknown fails the build); the sidebar and parent pages' "Table of contents" follow just-the-docs 0.3.3's parent/grand_parent/ nav_order rules; content files and /feed.xml are copied verbatim; the look is the web app's M3 tokens, light and dark. Build argument GUIDE_RENDERER=jekyll|starlight (default jekyll, generated Dockerfile; BuildKit skips the unused builder). Every guide image build also loads the Starlight build of the same commit (never pushed) and runs .github/scripts/check-user-guide-starlight.sh before the push, report-only until D53 and H4 (both Starlight steps continue-on-error; a failure shows as a warning and a job-summary line, the reports are still uploaded, and the Jekyll image is pushed; H1's and H2's Jekyll gates stay blocking; the contract test pins both and fails on a mutation of either). H4 makes the Starlight checks blocking when it makes Starlight the default. No source edit changes Jekyll's output: the default image's served files are byte-identical to H2's apart from feed.xml's build time and the copied README.md.
  • H4 Cut over. Default renderer starlight, docs updated; help-topics.generated.ts and publication.json byte-identical. Rollback is a forward release with the default back to jekyll.
  • H5 Retire Jekyll (after H4 runs in production and the owner confirms): remove the Gemfiles, Jekyll config, Sass, compose file, test-permalinks.sh, the Ruby stage and the build argument; rewrite kramdown constructs natively.
  • H6 Retired pages (planned; trigger: the first public topic marked Retired). A retired page gets a reason: retired redirect; a public archive copy only for a revision with production evidence from 6G.P (D55).

Acceptance criteria

# Criterion Verification
H0-1 Every literal path given to userGuideUrl under src/app/** names an existing guide page by path or permalink; a fixture with '/stage-settings.html' fails with file and line, the corrected path passes help-links-resolve.spec.ts (full ng test)
H0-2 Both fixed links open the intended page Spec assertions on the rendered hrefs
H1-1 History is append-only: deleting or renaming a page (or a figure) without a redirect fails with the lost URLs; adding the redirect passes; a removed history entry is refused against the git base generate-user-guide-urls.test.ts ("a removed page needs a redirect", "the history is append-only"); tooling-e2e.test.ts (a page deleted in a repository copy fails validate:generated, passes after the redirect and pnpm generate)
H1-2 The redirect map is refused, with an exact message, for a from the guide serves, a chain or loop, an absolute or external to, a to not in the history, an unsafe path and overlapping entries; a valid map gives all three forms generate-user-guide-urls.test.ts, each negative beside the valid map as positive control
H1-3 The image serves the history: every page and file form answers 200, or one 301 with a relative Location to a 200; a served page never redirects; /stage-settings.html → /stages/settings.html "Check guide links"; user-guide-parity.test.ts on fixture sites (a page dropped without a redirect fails, the same site with it passes); Bramble image run
H1-4 In-app links resolve with anchors: H0's literals, whats-new.json paths, every HELP_TOPICS path and anchor (each a history URL), runtime What's New and walkthrough body links answer 200 with id="<anchor>" Same step; fixture with a renamed heading fails, the original passes; a link through a redirect fails
H1-5 Self-parity: the same image twice gives zero differences; a dropped paragraph or renamed heading is a difference user-guide-parity.test.ts; the step's compare run
H1-6 Headers unchanged: test-seo-headers.sh passes on the checked image; the manifest check (P-2) still passes "Check guide links"; "Check the publication manifest in the guide image"; Bramble header diff against the main image
H2-1 axe finds no new (page, rule, target) finding beyond the Jekyll baseline at both widths and themes, and a baseline entry no complete run finds fails until removed; a fixture with an unlabelled button and 3:1 text fails, fixed passes a11y.spec.ts in "Check guide links"; a11y-controls.spec.ts; Bramble fixture and stale-entry runs
H2-2 Skip link, visible focus, no keyboard trap, keyboard search with focus return, recorded for Jekyll Same spec; control fixtures for each
H3-1 Every page's normalised text, headings, image targets and resolved links equal Jekyll's, or the difference is listed in user-guide/site/parity-exceptions.yaml with a reason check-user-guide-starlight.sh cross-renderer compare --exceptions (theme chrome removed: heading anchor links, Starlight's hidden link labels, pagination); user-guide-parity.test.ts (a paragraph the adapter drops fails, the listed difference passes, an exception that matches nothing fails); one unit test per construct in user-guide/site/test/
H3-2 H1-3 and H1-4 pass on the Starlight image and every Jekyll heading ID exists there; generator URLs are exempt only when listed; /feed.xml stays check-user-guide-links.sh with GUIDE_RENDERER=starlight (check --exceptions; the exceptions file refuses a page or content file as an exempt URL); the heading lists compared per page
H3-3 anchorOf equals the served ID for every heading on both renderers user-guide-parity.ts anchors on each image (in the gate); user-guide/site/test/heading-ids.test.ts (every page; a heading github-slugger would number differently keeps the compiler's ID; {#id}); anchors fixture in user-guide-parity.test.ts (a differing ID fails). guide-topics.ts unchanged
H3-4 Zero axe violations and the keyboard checks on every Starlight page, both themes and widths H2's spec with GUIDE_A11Y_MODE=zero (no baseline; a guide without a dark scheme fails) on the Starlight image
H3-5 The Starlight image passes P-2 and test-seo-headers.sh check-user-guide-starlight.sh (manifest byte-identical to the staged file, read-only, exactly one) and the gate's header test
H3-6 Screens of home, a membership page, the data dictionary, What's New, 404, a long page, a video page and search open, light and dark, 1280 and 390 px Maintainer's evidence folder 6H-H3/; owner go or no-go (D53)
H4-1 H3-1 to H3-5 pass on the default build with zero unexplained differences CI parity report
H4-2 help-topics.generated.ts and publication.json are byte-identical to main's before the change pnpm run validate:generated; manifest digest comparison
H4-3 The 6G.P recorder records the membership row against the Starlight image (same revision, new lastPageSha256) Isolated Bramble run as in 6G.P P-9
H4-4 Owner screen acceptance on the PR preview's guide host, light and dark, desktop and phone Owner sign-off (D54)
H5-1 No jekyll, kramdown, Gemfile or ruby: reference remains outside history notes rg check in the PR
H5-2 H1's history gate, H1-4 and H2 still pass CI

Flag decisions. H0: none (two link strings). H1: none, build-time only: checks plus nginx redirects that answer only paths that are 404 today (H1-2 refuses shadowing a served page); no data, runtime switch or start-up work. H2: none (test-only). H3: none (the pushed image stays Jekyll, and the Starlight proof is report-only, so it never blocks a Jekyll release). H4: not runtime-flagged: the guide is a stateless static image, every image passes H1–H3 before push, and rollback is a forward release with the build default back to jekyll. H5: none (clean-up). H6: decided at its trigger.

PR split

PR Content Depends on Parallel with
H0, #4536 (merged) Two in-app links and the repo-wide spec main all
H1, #4537 URL history, redirect map, parity tool, "Check guide links", this section 6G E1 (#4484), 6G.P P1 (#4531); after H0 (its in-app check fails on the two old links) — (H2 stacks on it)
H2, #4539 axe and keyboard checks H1 (stacked: it runs inside "Check guide links") —
H3, #4540 Starlight proof H1, H2 (stacked on H2) —
H4 Cut over H3, D53 = go, D54 answered —
H5 Retire Jekyll H4 live in production, owner confirmation —
H6 Retired pages trigger, 6G.P P2 (#4532), D55 —

MVP. The first usable increment is H0 + H1: today's Jekyll guide answers every URL it has served (the 29 URLs the reorganisation lost redirect), both in-app links work, and CI refuses a change that would break a URL or an in-app anchor. The 6H MVP is H0–H4; shortest path H1 → H3 → H4 (H2 stacked on H1).

H1 as built (2026-10-25). The reorganisation lost 29 URLs, not the 20 the spec counted: 24 renamed pages, 3 parent pages replaced by directory pages (membership, project, stages) and the .html forms of the old extensionless permalinks /data-export and /whats-new (their extensionless forms are still served by the directory pages). The seed's site model was checked against images built from all three trees: pages and content files match exactly; only build tooling Jekyll copies (README.md, GitVersion.yml, shell scripts, theme source maps, nginx's 50x.html) is outside the contract. /favicon.ico is not served by main's image, so it is not in the history. test-permalinks.sh is not wired into CI: its integration mode expects 301 for /data-export, which nginx answers 200 through try_files; H5 removes it. The in-app check runs where the guide image builds (main and labelled previews); a web-only change to a literal link is covered at source level by H0's spec. The isolated Bramble run (image built from the PR, every redirect form curled, headers compared with main's image) is in the maintainer's evidence folder 6H-H1/. Broken links inside existing pages (8 on 2026-10-25) are reported as warnings, not gated.

H3 as built (2026-10-25). On an isolated Bramble run (images built from the PR; evidence folder 6H-H3/):

  • Cross-renderer parity: zero unexplained differences over 212 history URLs in 307 request forms. Explained (parity-exceptions.yaml): six just-the-docs theme files (CSS, JS, the Lunr index) that Starlight does not serve, and three pages whose Markdown kramdown and CommonMark read differently: ../stages\.html (kramdown keeps the backslash, a broken link; Starlight's link reaches /stages/ through the history redirect), \| in a code span inside a table (GFM shows |) and unbalanced emphasis on studies/prepare-references.md (a different asterisk shows). Each would need a source edit that changes the Jekyll page, so H5 rewrites them.
  • Links and anchors: the Starlight image answers every history URL and in-app link as Jekyll does (0 failures; 7 broken-link warnings against Jekyll's 8); all 375 compiler anchors on the 60 Markdown pages are heading IDs on both images; help-topics.generated.ts and the publication manifest come from the source, not the renderer, and are byte-identical (the manifest checked in both images).
  • Accessibility: zero findings on all 62 served pages at 1280 and 390 px, light and dark (Jekyll's baseline holds 1,365). Four renderer adaptations, none changing page text: a frame with an empty title takes the page title; a focused video frame gets the focus ring (a four-line script, since no CSS selector reaches a frame whose document has focus); tables are focusable, because on a phone a wide table scrolls inside the page; task-list checkboxes are labelled by their item.
  • Kept as Jekyll has it: navigation (Membership's three children and nine What's New pages are in no list, because their parent/grand_parent match no page; a content fix for both renderers), the kramdown table of contents inside pages (beside Starlight's "On this page") and the empty /feed.xml.
  • Cost: astro build takes about 6 s in its stage (dependencies install in a cached layer); the proof adds about 7.5 minutes to a guide image build, nearly all of it the accessibility run (four contexts instead of Jekyll's two).

Owner questions (programme decisions D53 to D56; all open)

# Question Options Answer or recommendation
D53 (Q-H1) OD-7 accepted Starlight "subject to the design work and a representative migration proof". After H3's report, go or no-go? A: Starlight static site in the same image and host (H4, H5). B: keep Jekyll; 6H ends at H0–H2 plus a Ruby/Jekyll upgrade. C: render the guide in the web app or API Open. Recommendation: A, gated on H3's report. One Node toolchain instead of Ruby 3.1 (past end of life), a real slugger in place of the kramdown re-implementation, static public search. C contradicts OD-15 and D39
D54 (Q-H2) How should the public guide look? There is no canvas board for it A: Starlight's layout with SyRF's M3 colour and type tokens, light and dark, accepted on H4's preview. B: a canvas board first, then build to it. C: full M3 Expressive components Open. Recommendation: A. The smallest change that matches the app's look; screens are accepted on the preview before merge. B adds a design round for a docs site
D55 (Q-H3) Build the archive of retired pages now, or at the first retirement? A: at the trigger (H6); until then retirement is a redirect. B: now Open. Recommendation: A. No public page has retired; a redirect never claims "was public", so no evidence is needed
D56 (Q-H4) Production's guide is 3.9.1 (May 2026). Promote a current Jekyll image (after H1) before H4? A: yes, so the H4 promotion changes only the renderer and the parity report applies directly. B: promote once after H4 Open. Recommendation: A. It separates content review from the renderer switch and brings the redirects to production sooner

Operational prerequisites (written only). No DNS, ingress, chart or External-DNS change: same hosts, port 80, health path /, SITE_URL/ROBOTS_META and the 6G.P GuideOrigin. Staging takes H1 and H4 automatically on merge; production takes each through an owner-approved cluster-gitops promotion of the user-guide image (D56 on order).

Not in scope. Any staging or production execution; serving the guide from the web app or API; the protected reader and RT-28 (6G.8); a wider content overhaul (accepted plan §17); runtime flag retirement; search analytics; fixing the compiler's path for permalink: pages (H1-4 refuses a topic on an unserved path); fixing the broken links inside pages that H1 reports; the archive itself before its trigger.

8. Parallel lanes and owner gates

  • The wave 2–5 chain (#4344–#4385) has merged. Parallel lanes now: the open 6B.4 PRs, 6D PRs C and F, and 6E, each in its own worktree. Delegated work goes to Sonnet or Haiku only with a written specification, and every result is reviewed (diff read and checks rerun) before it is pushed.
  • Owner gates (stop and ask): any staging execution of binding, import, enrolment or activation; any production step; any destructive migration or compensation; any change to infrastructure identities or secrets.
  • Open owner decisions gating planned work (tracked on the programme page and in the programme ledger): none for 6E: D25 to D27 were answered A on 2026-10-23 (§7.1). Programme D15 and D16 were answered A on 2026-10-23, and 6B.4 PR 4 (#4449) has merged. 6H: D53 gates H4 (with D54), D55 gates H6 and D56 orders the production promotions (§7.4); none gates H1 or H2.
  • Approved architecture decisions (2026-10-23): a unified Features and releases admin area, built in steps (proposed design canvas, temporary review material pending Chris's review, not a maintained reference: https://claude.ai/artifact/EKxMc2Xq9ihkCM6uwTNkdC); walkthroughs as 6G.4 with our own M3 CDK component; no paid or closed-source third-party tools, so targeting (6F) is built in-house.
  • Cross-environment administration (D24, Chris, 2026-10-23): now, each environment's own admin page, linked to the others; later, possibly one admin web app calling each environment's API with the person's own sign-in for it (each environment's preview, confirm and audit still apply). A central service holding write credentials is ruled out. A per-environment flag-state metrics export with a drift dashboard is proposed and owner-gated.

9. Cross-service propagation evidence

The criteria recorded in the 20 October handover apply to every wave:

  • flag-to-consumer inventory;
  • independently reconciled workload, replica and generation inventory;
  • fresh exact-authority acknowledgements;
  • behaviour assertions;
  • disruption, restart, scaling and mixed-version tests;
  • measured bounds.

Wave 2 covers API membership, PM statistics serving, pages, stage overview and SignalR, and the browser runtime snapshot, with an in-process multi-instance harness. 6B adds separate processes, a browser and measured timings. Independent workload observation comes from wave 3 (#4363, #4370–#4372). Deployment-specific readiness remains a separate later gate.