Application authority transition: phased implementation plan¶
Goal and shortest delivery path¶
First deliver one real grant/revoke feature whose next protected request is denied on either API replica, even with an old browser session or token. Then make all consumers use that authority, prove suspension and live/queued-work behavior, and remove duplicated role data only when safe. Keep Identity's useful authentication work during this correction. Evaluate consolidation later; do not turn current topology into a requirement.
This is a reviewable plan, not implementation authorization. Read the design for exact authority, consistency, failure and rollback contracts. The shortest critical path is approval → M0 isolated native fixture and approved mapping → M1a/M1b vertical feature → M1c global administrator guard → M2–M5 coverage → M6 staged cutover. M7 cleanup and M8 topology follow; broker B0–B2 runs separately and must not wait for a browser redesign.
No runtime code, accounts, imports, broker changes or deployment occur in this PR; it records the 2026-09-30 approval decisions. Implementation PRs
listed below are proposed slices, not created tickets/branches. Reconcile assignments with #3335/#2466 before
creating them. Every PR uses foreground wt, the resolved pr/ worktree, tests and ready-for-review status.
Contents¶
- Evidence and programme alignment
- Milestones and bounded PR slices
- Dependencies and parallel work
- Evidence-to-milestone coverage
- Test environment and acceptance matrix
- Data reconciliation and rollout gates
- Review gate and exact next action
Evidence and programme alignment¶
Engineering anchors refer to source 0026b56a80fe85f1aea0f690663b02663de6899e, with one-based lines in the
audit evidence table E1–E16.
The comparison preserves the independent
proposal's provenance and primary-source research; the verification ledger
records all three investigations, exact commands, 222 component tests and runtime limits. Verify current source
and deployed image digests before using these paths operationally; observed Deployment SHA metadata is not provenance.
| Existing owner/work | Integration, not competing execution |
|---|---|
| #3335 WP2/WP3/WP4 | M1/M2 implement evaluator and application-role authority in bounded slices; preserve accepted C1 impact approval. |
| #3335 WP6/WP7/WP9/WP10 | M3–M5 cover reports, revocation/jobs and retained impersonation; no second evaluator. |
| #3335 endpoint/catalog work | M2/M3 reuse its inventory and denied-side-effect tests; refresh stale issue/PR state before assignment. |
| #2042 membership; #3264 allocation; #2470 capacity | Consume existing lifecycle/grant shapes, coordinate capacity release; no new membership schema or allocation semantics here. |
| #2466 M005 | Preserve mapping/recovery/Google/security-generation/session parity and its S08A/S30/S09/provider gates; this plan supplies authorization proof only. |
| #3762 | Retire mothballed MapsGroup prototype only. Separate batch RoB remains a protected workload and inventory item. |
| Infrastructure/GitOps owners | B0–B2 broker hardening in the existing declarative owner repositories; never kubectl apply, manual secrets or live test publications. |
The historical #3335 plan's unflagged parity assumption and reversible claim-union rollback are not sufficient for the fresh findings. All access-changing slices below have explicit flags/impact gates; old zero-population claims and old PR states must be refreshed, not reused as approval. This pair proposes that integration correction for the existing programme owner; it does not silently edit its private handover or deployment ledger.
Milestones and bounded PR slices¶
Paths below are repository-relative engineering targets, not permission to edit main. New names are proposed. Each slice PR must list its own affected source/tests, flag decision, before/after access table, migration compatibility and measurable exit. Split a large slice along the indicated suffixes; do not make one omnibus PR.
M0 — establish the safe proof environment and ownership¶
M0a, test(auth): add isolated native two-replica authority fixture — SyRF, prerequisite to M1 acceptance.
Extend e2e/ with a separate native-auth lane (proposed e2e/authority/), not the generic /api/e2e-auth/login
bypass. Compose two API replicas, native Identity, disposable Redis, Mongo replica set, PM and RabbitMQ with
unique project/network/ports/databases and synthetic accounts. Reuse Identity's real-host tests as appropriate;
never use rehearsal helpers or production settings. Test process verifies local endpoints before mutations,
uses no developer user-secrets, and refuses remote DB/broker targets. Cleanup addresses only its exact stack.
Before: 222 mock/in-memory tests, no native multi-replica proof. After: reproducible issue-free sign-in, refresh, direct bearer and sockets across both replicas; deterministic barriers and majority-commit timestamps. Exit: successful baseline and expected failing R1 characterizations recorded separately; cleanup leaves shared containers untouched. CI lane remains opt-in until resource costs and routing are reviewed. No runtime flag.
M0a status (2026-09-30): delivered by #3853. Lane
e2e/authority/, one command bash e2e/authority/run.sh
(how-to). Baseline passes (9): local-stack guard, native
BFF sign-in, refresh, direct bearer and SignalR connect on both replicas. Expected-failing R1
characterizations recorded separately (3) with majority-commit operationTime annotations: PM+Identity role
removal (flips in M1b), PM deactivation (M4a), PM-only Administrator grant, whose case-sensitive group match
means only the Identity claim grants today (M1b). Cost ~1.9 GB (~2.3 GB peak), ~2 min per full run; no CI lane
yet. The P3 linearizable-read/deadline/failover benchmark was the remaining M0 gate before M1a; see below.
P3 benchmark status (2026-09-30): PASS, delivered by #3856
(results, one command bash e2e/authority/benchmark/run.sh). Real three-member MongoDB 7.0
replica set, SyRF's driver 3.10 via MongoContext, CSUUID _id. At 96 concurrent linearizable reads, p50 5.8–5.9 ms and
p99 ≤ 26 ms locally; 0 hangs and 0 reads over the 5 s deadline, including under step-down, kill, partition and loss of
majority; 0 stale reads in 760k post-revoke checks. In 3/3 dual-primary partitions, the linearizable read denied at the
deadline, while a majority read on the stale primary returned the revoked role. A hard primary loss denies authority
reads for ~10–12 s, the same as majority reads. P3 accepted 2026-09-30 (controller) under P5: that unplanned-failover
denial window is the temporary unavailability P5 already prefers to silently restoring revoked access, and Atlas planned
maintenance uses a graceful step-down, which cost 0 failures. Test-only; no runtime flag.
M0b, evidence/approval gate, not a live-data coding task — owner + #3335/#2466 leads provide sanitized role/group and client evidence under the inventory request. Refresh route/grant census and deployed-version matrix; obtain exact import mappings/holders and explicit access changes. Confirm protected/public field classification and other active executors. No raw tokens/user census in git. Exit: approved mapping manifest hash and restricted access-impact record, or a clearly blocked production gate.
M1 — first usable role grant/revoke vertical feature¶
M1a, feat(authz): add authoritative PM decision boundary — library/API seam, default-off/shadow.
Targets: src/libs/project-management/SyRF.ProjectManagement.Core/{Authorization,Interfaces,Model/InvestigatorAggregate}/,
src/libs/mongo/SyRF.Mongo.Common/ (dedicated reader only), API Authorization/, existing role value object in
src/libs/kernel/SyRF.SharedKernel/ApplicationRole.cs, API ResourceSecurity.json, matching tests.
Implement pure evaluator + uncached consistency-proven snapshot reader; preserve CSUUID IDs and existing data.
Add current-role/deactivation checks, safe denial reasons and aggregate telemetry. Do not change general repository
cache behavior. Linearizable reads/failover/deadline benchmarked in M0 (P3 benchmark: PASS);
unsupported guarantees stop approval, not weaken R1. Treat any exception from a majority authority write as an
ambiguous outcome (driver 3.10 can throw ArgumentNullException on a write-concern error; see the benchmark follow-ups).
Exit: all decision-matrix tests, no I/O in pure evaluator, outage denies, no old claim grant in enforced slice.
M1a status (2026-09-30): implemented dark by #3858. Pure
AuthorityEvaluator (the design's IAuthorizationEvaluator, renamed to avoid ASP.NET Core's type of that name) in PM
Core/Authorization/; IAuthoritySnapshotReader in Core/Interfaces/; the dedicated LinearizableDocumentReader in
SyRF.Mongo.Common (primary, linearizable, no session or cache, 5 s deadline, any exception = unavailable) with the
pmInvestigator adapter in PM Mongo.Data; API gate, aggregate telemetry and a 503 result handler. General repository
caching is unchanged, and no data changes. The vertical scope is exactly ListUsers, requiring the existing
ApplicationRole Administrator (exact match); other application activities keep their claim policy until M2a.
Flags from the generator, both default-off everywhere: syrfOwnedApplicationRoles (shadow: evaluate and log, never change
a decision) and syrfOwnedApplicationRolesEnforced (PM decides ListUsers, claims grant nothing, outage 503; requires
the first flag or the API does not start). Not runtime-overridable. Operation, telemetry and rollback:
application-authority mode. In the M0a lane,
--application-roles-mode enforced closes both ListUsers R1 characterizations; in the then-default off and shadow modes
they remained expected failures (M1b made enforced the lane default; see below). No grant/revoke API or UI (M1b),
last-admin guard (M1c), other consumers (M2+) or environment flag change.
M1a follow-ups (#3859, #3901):
enforced mode reads authority before the handler's other PM calls, so an outage answers 503 within the read deadline
and a store failure in those calls is the same 503, never a 500; caller aborts are no longer logged as unavailable
disagreements; shadow's outage latency is documented; the lane pauses the API's MongoDB mid-request.
M1b, feat(authz): deliver application role grant and revoke — completes the end-to-end MVP after M1a.
Targets: API Controllers/InvestigatorController.cs:23, Controllers/AccountController.cs, proposed role service/
audit transaction + revision guard, Angular src/app/admin/ minimal role editor and core/auth/ capabilities client;
existing API/Identity tests plus new M0 acceptance scenarios. Read web CLAUDE.md before implementation.
Add the design's self-capabilities and revisioned role-update contracts; authorize ListUsers and role management
from PM. Operator-approved bootstrap import has dry-run/CAS/idempotency protections; no runtime claim mapping.
UI shows authoritative current role/revision, reason-required grant/revoke, conflict/error handling and no optimistic
success. Revoke actor permission immediately; preserve the existing last-admin guard on the role-administration
path and distinguish self from target.
Before: claim + PM union and no general role administration. After: named capability grant/revoke works through
existing sessions on two replicas. Acceptance: grant → allow; majority revoke → next request denies on A/B with
old BFF/direct token/refresh; no-positive-cache test; concurrent last-admin removals rejected; audit committed;
admin nonmember cannot read protected project content. Zero unauthorized side effects. M1b does not assert global
last-admin safety; that is M1c's exit.
M1 does not assert global revocation while other claim consumers remain. Flag: schema-generated
syrfOwnedApplicationRoles with explicit
capability allowlist for the vertical scope; environment-coherent policy version; default-off/shadow.
M1b status (2026-09-30): API delivered dark by #3860 (M1b-api);
the Angular editor by #3861 (M1b-ui: Admin Console → Application Roles,
visible only where the capabilities read succeeds; core/auth capabilities client; authoritative reads, required reason,
conflict/unavailable/unknown-outcome states with same-operation retry, no optimistic success); and the operator bootstrap
import by #3862 (SyRF.ProjectManagement.ApplicationRoleBootstrap: strict
restricted manifest identified by SHA-256, dry run by default and predicting the apply exactly, --apply only with
--confirm-database, grant-only through the same guarded transaction with CAS on the role revision, deterministic per-entry
operation IDs so re-runs are idempotent, operator-bootstrap audit records; the fixture bootstraps its first administrator
with it; not run against any real environment). Everything exists only in the
enforced mode (both M1a flags on); off and shadow answer 404 and write nothing, so the deployed default is unchanged.
GET /api/account/capabilities (one linearizable read, safe self reasons); GET/PUT /api/investigators/{id}/application-roles
with the complete desired set, expectedRevision, a required reason and a client operationId. New PM capability
ManageApplicationRoles → Administrator, policy application-authority.m1b.1. Only Administrator is grantable;
SupportImpersonator is refused until the M2b gate, other stored values are preserved and unmanageable. The writer
(MongoApplicationRoleAdministration) runs one snapshot, majority, journaled transaction: it writes the
pmApplicationRoleGuard serialization document first, returns a replay for a committed operationId, re-evaluates the actor
with the pure evaluator, compares ApplicationRolesRevision, refuses removal of the last active Administrator, then writes roles,
revision, an Audit.Version fence against stale aggregate saves, and the pmApplicationRoleChange audit record atomically.
Any exception after the transaction starts is 503 role-change-outcome-unknown (re-read or retry the same operationId),
distinct from 503 authority-unavailable (nothing written); only a pre-commit transient conflict is retried. Identity's copy
is never written; impersonated role administration is refused. The M0a lane now defaults to enforced; the ListUsers R1
characterizations pass there, and in off/shadow (deployed until M6) they remain recorded expected failures. The new
authority-m1b-role-administration.spec.ts proves grant → allow, revoke → deny on A/B with old BFF, direct token and
refreshed session, no positive cache, conflicts and idempotent retry, four concurrent last-two-administrator races (one always
remains), committed audit, and A2 (admin nonmember 403). The M4a deactivation xfail stays. How-to:
grant and revoke application roles. Not claimed by M1b: global last-admin safety (delivered by M1c, below),
any environment flag change, or bootstrap execution anywhere real.
M1c, feat(authz): apply the last-active-administrator guard to every lifecycle path — depends M1a/M1b;
split out of M1b so that slice stays bounded. Targets: API account deletion (Controllers/AccountController.cs),
every Identity destructive entry point, the cross-service pending-deactivation reservation (PM Investigator
lifecycle), and administrative deactivation/import paths, all routed through the shared serialized revision guard;
matching PM/API/Identity tests. Include these in the mutation inventory. Reserve PM eligibility before destruction
through the guarded operation, retain the reservation on ambiguous failure, and block unsupported bypass callers.
Exit: concurrent role removal, suspension, deletion and administrative deactivation/import each tested against another
removal and existing pending-deactivation reservations, with partial failure, retry and idempotent reconciliation
for every path. M1c must land before anything claims that administrator removal is safe globally.
M1c status (2026-09-30): implemented by #3865, dark (enforced mode only).
Mutation inventory (paths that can remove an active Administrator or an Investigator's ability to act as one):
the M1b role revoke; API account deletion (AccountController.DeleteProfile, the only caller of
IIdentityService.DeleteUser and of PM Investigator.DeactivateAccount); Identity's admin delete
(AdminApiController.DeleteUser, whose only client is the API's admin:users service client); Identity's self-service
delete (AccountApiController.DeleteProfile); the operator bootstrap (grant-only). No application suspension,
administrative deactivation or other Deactivated/ApplicationRoles writer exists (M4 must use the guard); Identity's
admin profile update and link, registration compensation and the Identity migration tool neither remove a role nor
disable a usable account. Every path now takes the M1b pmApplicationRoleGuard serialization (shared
GuardedTransaction). Account deletion reserves first: a pending-deactivation marker on the Investigator (a denial
input, subject-deactivation-pending, and excluded from the active-administrator count) plus a reservation record
(pmInvestigatorDeactivation: server-generated operation ID, Identity subject, 3-minute lease); then Identity is called
outside any transaction with the operation ID, and an enforcing Identity verifies the marker (linearizable read over its
read-only PM connection) before its unchanged revoke → rotate stamp → delete. Finalize on a confirmed deletion; release only
when Identity provably did nothing for the holder's own call (never sent, or its pre-work 409 deactivation-not-reserved);
keep the reservation on every other outcome; retry by the owner or POST /api/investigators/{id}/pending-deactivation/reconcile
resumes the same operation ID idempotently. Enforcing Identity refuses its self-service delete (409
deletion-requires-application). Flags: the existing M1a pair, now also rendered into Identity (one Helm value);
off/shadow keep today's deletion exactly (tested at API, Identity and lane). No path needed a cross-service transaction,
and Identity's deletion order and 503 meanings are unchanged. With enforcement on, no supported path removes the last
active administrator; this is the first milestone that makes that claim, and it holds for a deployment only while it
enforces (deployed environments stay off until M6). Direct database edits and provider consoles remain unsupported.
Evidence: PM Core 137, PM Mongo.Data authority (real replica set, MongoDB-RS-Authority) 111 including cross-path
races, pending-reservation, partial-failure, retry and settlement tests (negative control: without the pending exclusion
4 fail); API 207 focused; Identity 296 focused; identity chart 153; lane enforced 21 + 1 expected failure (M4a), off
and shadow 13 + 3. How-to: the lifecycle guard.
M2 — converge remaining application consumers, UI and impersonation¶
M2a, feat(authz): converge application capabilities and direct API checks — depends M1.
Targets: API Authorization/{AuthorizationHandler,SignalRAuthorizationHandler,EffectiveApplicationGroups}.cs,
Controllers/RuntimeFeatureFlagsController.cs:46,84, application/email/admin endpoints and capability catalog;
web core/auth/auth.selectors.ts:187, admin/admin.routes.ts:36, direct navigation/menus and feature-flag UI.
Replace claim-group booleans with backend capability responses, enforce all corresponding API operations with
the same authority reader (including direct bearer), and keep provider authentication separate. Browser caches
are presentation only. Exit: enumerated claim consumer ledger has no unowned endpoint; HTTP/UI/capability agreement,
403 refetch and direct-navigation tests; production flag-management restrictions remain unchanged.
M2a status (2026-09-30): implemented dark by #3867, enforced mode only.
Claim-consumer ledger: 32 rows, each owned (M2a converts 15; the rest are
M1a/M1b, M2b impersonation, M3a project/stage group shortcuts, M4b realtime delivery, M7a/M7b claim emission and storage).
The PM allowlist now covers every ApplicationAuthorization activity (ListEmailTemplates, ModifyEmailTemplates,
BatchAdminProjects, and SendEmail from #3466) plus ManageRuntimeFeatureFlags (the flag controller's inline administrator/SyrfAdmin claim check),
all → Administrator, policy application-authority.m2a.1; a PM Core test fails if a new application policy lacks one.
HTTP (BFF and direct bearer), the SignalR application branch and the parity read's nested policy use the same gate
(outage 503, which the parity read now carries out of its nested policy). The production flag-mutation restriction is unchanged in every mode.
Web: menus, the admin-UI option and admin loading present the backend capabilities where PM decides (mirrored into the
store); direct navigation to Impersonation, Email Templates, Statistics Pilot and Feature Flags re-reads capabilities and
guards on its capability; a 403 refetches capabilities where PM decides. With flags off or shadow the capabilities read
is 404 and every web and API decision is the pre-M2a one (API matrices per policy; web guard and selector matrices;
lane off/shadow). Evidence: PM Core 142, API 482 + 70 focused, web 287 + 45 + 159 guard/affected specs, lane enforced
24 + 1 expected failure (M4a), off and shadow 15 + 3. The api/admin-email/send-* endpoints, which M2a found open to any signed-in user, were fixed
in every mode by #3466 and are PM-decided when enforced
(ledger row 12). Not claimed: impersonation (M2b), project/stage policy (M3), realtime delivery (M4b), any
environment flag change.
M2b, feat(authz): preserve support impersonation with target authority — depends M1 (including M1c where the guard is claimed), coordinated with M2a.
Targets: src/libs/webhostconfig/SyRF.WebHostConfig.Common/Infrastructure/UserImpersonationMiddleware.cs:58,
CurrentUserService, API operation authorization/SignalR, existing impersonation UI. Preserve functionality with
explicit approved ImpersonateUsers holders, actor/target separation, read-only/edit server context and dual audit.
Edit context is tab-scoped, memory-held browser handle to server-recognized short-lived state; reset on reload/
target/end; stale handle is rejected. Proposed initial max lifetime 30 minutes, not a framework default.
Add the proposed SupportImpersonator value-object name/parser case in existing PM roles, mapping only to ImpersonateUsers,
after serialization/old-reader tests and explicit holder approval; Administrator alone does not imply it.
Gate: ApplicationRole.ParseRole throws on unknown values, so every reader of ApplicationRoles is upgraded
before any holder is granted SupportImpersonator (readers first, as in M5a's "consumers first").
Test Save/Discard/Cancel on deliberate mode exit, no final save after revoke, HTTP and hub/job side effects,
actor permission removal, target suspension and target-only grants. Flag impersonationReadOnlyEnforcement via
catalog; mapping/holder access-impact approval required. Do not assign this capability to every admin by assumption.
M2b status (2026-10-01): authorised (relayed by the controller); the reader gate is delivered by
#3868, which must merge before anything else in M2b. Readers audited:
the PM aggregate class map (one Name member; unknown names round-trip through an ordinary save), the authority
snapshot reader, the M1b role writer and last-admin guard, the API gate and EffectiveApplicationGroups, and Identity's
migration import, reconciler and readiness report. None called ParseRole; all already carried names verbatim. The gate
adds ApplicationRole.SupportImpersonatorName, a non-throwing TryParseRole/IsKnown, and the ImpersonateUsers
capability → SupportImpersonator only (policy application-authority.m2b.1; Administrator alone does not imply it). An
unrecognised stored name confers nothing and is logged as a count at Warning. SupportImpersonator is excluded from the
application-group union (so no project/stage group check can match it), is never emitted as a syrf_groups token, and
needs no readiness claim parity. It stays ungrantable through the role editor and the bootstrap; nobody holds it.
Evidence: PM Core 171, PM Mongo.Data snapshot reader (real replica set) 24, API 416 focused, Identity migration 76
focused; lane scenario "SupportImpersonator alone" in the M1a/M2a spec.
M2b impersonation (server) by #3869, dark: flag
impersonationReadOnlyEnforcement (generator, supportImpersonationFlags, API only; not runtime-overridable; requires
enforced authority or startup stops; default off). With it off the impersonation middleware takes its legacy path
unchanged (tested with and without the admission registered, and in the lane's off/shadow modes). With it on: the actor
needs PM ImpersonateUsers, re-read on every request and hub invocation (revocation denies the next one on A and B);
the target must be active (the M1c marker refuses) and hold no application role the actor lacks (no escalation);
GetSyrfAppGroups() is empty while impersonating, so only the target's permissions apply; the target's profile comes
from PM. Read-only by default: every non-safe HTTP method except two marked body queries, the four review-allocation
GETs and the five review-presence hub methods are refused (403 impersonation-read-only); sign-in and project-view
bookkeeping is not recorded for the target. Edit mode: POST/DELETE /api/impersonation/edit-context and GET …/session
over a new pmImpersonationEditContext collection (existing PM Mongo store; linearizable reads, majority writes, only
the handle's SHA-256 stored, TTL cleanup), 30-minute maximum (proposed), never extended, bound to actor and target;
ended/expired/foreign handles are refused. Every admitted or refused change writes a dual-attribution record to
pmImpersonationAudit (admitted: before it runs, or it does not run). GET /api/impersonation/candidates gives the
picker names/email only under ImpersonateUsers. How-to: support impersonation.
M2b web by #3870: the banner shows read-only/edit mode and the
deliberate switch where the contract is on (unchanged where the API answers 404); the edit handle is held in tab
memory only and sent as syrf-imp-edit-context; a reload, target change, Stop or the time limit returns to read-only
and ends the server contexts; an impersonation refusal drops the handle; deliberately leaving edit mode with unsaved
changes offers Save (confirmed by the server before switching; a failed save keeps edit mode), Discard or Cancel through
the SupportImpersonationDrafts registry. Follow-ups: the picker for non-administrator holders (candidates), the
SignalR edit handle (review presence stays read-only), and registering real editors with the draft registry.
M3 — project/stage policy and reporting convergence¶
M3a, refactor(authz): share protected-resource decisions and reports — depends M1; can parallel M2.
Targets: PM Model/ValueObjects/{ProjectPermission,StagePermission}.cs, API ResourceSecurity.json,
Models/ValueResolvers/PermissionReportResolver.cs:22, ProjectAuthorizationContext, HTTP/hub resource handlers.
One fact set produces enforcement and reports. Active membership precedes protected-content grants; owner
permission-assignment authority and additive/all-active-member semantics remain. Do not delete AllowedMemberIds
or transform membership schemas based on September census; if populated, return to #3335/#2042 for conversion.
Exit: subject×action×grant matrix including disabled/removed members, owner, admin nonmember, cross-project IDs,
stage defaults and public metadata; no information leakage in reasons; reports match decisions exactly.
M3b, test(authz): enforce entry-point and permission catalog coverage — M3a, reuse #3335 endpoint work.
Targets: API controller policy inventory, ResourceSecurity.json, SharedKernel activity constants, API tests;
include exports, PDF/corrections, emails, direct downloads and separate batch RoB. Classify owned anonymous/public
exceptions by response fields, not route title. Repair confirmed gaps in small per-endpoint PRs with no-side-effect
denial tests. Exit: CI fails an uncatalogued protected endpoint/permission; no broad public-content assumption.
Do not silently change export requester-ownership policy or unrelated allocation/capacity semantics.
M3a status (2026-10-01): implemented dark by #3879, enforced mode only.
One fact set decides project and stage policy: ProjectAuthorityFacts (owner, memberships with lifecycle and groups,
every project and stage grant after defaults and overrides), read per request from pmProject by the linearizable,
uncached MongoProjectAuthorityReader, and decided by the pure ProjectAuthorityEvaluator (policy
project-authority.m3a.1). The HTTP and SignalR project/stage branches and PermissionReportResolver all use it, so a
report entry is the enforced decision on the same facts. Rules: public metadata (View, RequestToJoin, only where
opened to all users) first, then active membership (A1), then additive grants (all active members, groups, owner flag
(A3), stage individual grants); application groups never grant project or stage content (A2). Absent project keeps
404; a foreign stage is 403, not 500; unavailable facts are 503. Off and shadow are byte-for-byte the legacy
decision with no project read (shadow compares on the already-loaded aggregate). AllowedMemberIds and custom groups are
read as stored: no deletion, conversion or schema change. Evidence: PM Core evaluator matrix 111, API 20 new plus 817
in the authority/mapping/SignalR/export filter, PM Mongo.Data reader on a real replica set 6 (30 with the snapshot
reader), lane scenario "membership removal denies the next request on A and B, even for an administrator". Open: the
in-endpoint re-evaluations (ledger rows 15–16), part of them behind paused review-eligibility/allocation work.
M3b status (2026-10-01). Confirmed sign-in-only holes on the flags-off production path, each fixed unflagged
in its own PR with no-side-effect denial tests: StudyController study reads, risk-of-bias writes and PDF corrections
(#3875, merged bd8910c3); SearchController.CalculateRob
(#3060, re-taken onto main, merged e1e352df). The catalogue test that
fails CI on an uncatalogued entry point or permission is #3882. With the programme
owner's approval, #3887 deletes ReviewController.GetMockStudies (every
study of any project to any signed-in user) and adds StageReviewPolicy to GetInProgress, both unflagged; paused #3746
rebases over it.
M4 — suspension, replicas and protected live delivery¶
M4a, feat(authz): implement reversible application suspension — M2/M3 authority coverage.
Targets: PM Investigator lifecycle, API guarded suspension/restore endpoint + audit, Identity adapter/revocation
service and BFF session validation integration. Confirm PM Deactivated provenance against deletion first.
The shared last-active-administrator guard must admit the transition before PM denial is committed;
commit denial before native-session withdrawal. Suspension, restoration and
deletion reconcile with the same durable pending-deactivation reservations; no separate count-then-save path.
On partial withdrawal failure, report committed suspension with withdrawal pending and idempotently reconcile
native withdrawal without undoing denial. Restore only a reversible
suspension, not deletion; preserve independent lockouts/recovery and require new login where sessions were revoked.
Exit: old BFF/direct/refresh/socket sessions deny application work; partial withdrawal/outage recovery tests;
restore cannot resurrect grants or missing Identity account. Catalogued lifecycle flag, default-off, no real users here.
M4a status (2026-10-01): implemented dark by #3888, behind the new
default-off flag applicationSuspension (generator, applicationSuspensionFlags, API only, not runtime-overridable;
requires enforced authority or startup stops). Provenance confirmed first: Deactivated has exactly one writer
(account deletion, M1c finalization and Investigator.DeactivateAccount), is never cleared and has no restore path,
so it stays the irreversible deletion record. A suspension is its own field, pmInvestigator.ApplicationSuspension,
a denial input for every authority read and excluded from the active-administrator count; nothing is granted to a
suspended account. POST /api/investigators/{id}/application-suspension runs one guarded transaction: the actor
must still be an active Administrator, self-suspension is refused, the shared last-active-administrator check
admits it, then the suspension, a pending-deactivation reservation of source application-suspension, the
Audit.Version fence and a pmApplicationSuspensionAudit record commit together (the denial). Only then does the
API call Identity's new POST /api/admin/investigators/{id}/session-withdrawal. Identity checks the PM marker
first, and its fence now matches the reservation's source, so a suspension never authorizes a delete. It then
revokes tokens and authorizations and rotates the stamp, never deleting. A confirmed (or not-applicable) withdrawal
settles the reservation. Otherwise the answer is 202 suspension-committed-withdrawal-pending, the suspension
stands, and …/pending-deactivation/reconcile resumes the reservation and dispatches on its source (the #3866
item: a deletion request never resumes, settles or deletes through another source's reservation). Restore
(…/application-suspension/restore) removes only the suspension, only once the withdrawal settled and never for a
deleted account. It never calls Identity or writes roles, so withdrawn sessions need a new sign-in and
lockouts/recovery are untouched. With the flag on, subject admission (a middleware after authentication and
impersonation, plus a hub filter on connect and every invocation) re-reads the subject and the impersonated
target, and refuses deleted, suspended or pending-deletion subjects for every credential on every replica (403,
503 when unreadable). Only /api/auth/* and the self-capabilities read are exempt. Off/shadow: the endpoints are
404 and nothing is refused (tested at API and lane). Evidence: PM Mongo.Data authority (real replica set) 141,
including races of suspension against suspension, revoke and deletion (negative control: without the suspended
exclusion from the active count, the settled-withdrawal test fails); PM Core authorization 315; API 361 focused +
66 Identity-adapter; Identity 137 focused; API chart 48. Lane enforced: authority-m4a-suspension.spec.ts 3/3,
and the R1 deactivation characterization now passes. Off and shadow: the M4a off test passes and the R1 gap stays
recorded. How-to: suspend and restore application access. Not
claimed: server-pushed delivery to held sockets (M4b), any environment flag change, any real account.
M4b, fix(authz): enforce current authority on realtime delivery — M3, parallel M4a after shared contract.
Targets: API SignalR/NotificationHub.cs, SignalR/Statistics/ProjectStatisticsChangedConsumer.cs,
SignalR/ClaimRevocations/ActivityClaimRevokedConsumer.cs, subscription context, backend capability refresh and
capacity release integration. Replace saved-group authority with fresh facts at delivery; stop/recheck held queries
and resubscriptions after removal/suspension. Use commit notifications for prompt cleanup, not as sole safety fence.
Exit: both replicas, disconnected notification channel and late-query barriers cannot deliver protected payload;
reservations release according to existing capacity contract; existing component tests remain green.
M4b status (2026-10-01): implemented dark by #3889, enforced mode
only. IRealtimeDeliveryAuthority decides every protected SignalR push when it is about to be sent: the subscriber's
Investigator (suspended, pending-deactivation or unknown denies) and the project's M3a facts, both read linearizably on
the replica holding the socket, plus the M2b actor's ImpersonateUsers on an impersonated connection. Saved groups are
gone from enforced delivery (the full-stats leaderboard tier comes from the same fresh facts). Because the shipped
catalogue opens View to all signed-in users, member-content streams (full statistics and study presence, decided on
ViewStudies like their subscriptions since #3892, and claim-release notices) also need an active membership; project details and statistics invalidations stay View-level but refuse a
disabled member. Full statistics read authority after the query returns (the late-query barrier). Deny ends a
member-content stream, unavailable withholds one payload, and every subscribe method refuses on the same decision; a
suspended reviewer's join, heartbeat and annotating calls are refused, so reservations release through the existing
liveness/grace/leave contract (no capacity change). The project change stream still ends streams promptly but is not
the fence. Off/shadow: every legacy path unchanged, no reads. Evidence: API 935 focused (SignalR, authorization,
statistics, mapping, host registration), 31 new realtime tests plus the consumer and filter tests, red-checked (removing
the barrier fails 3); lane: see the PR. How-to.
Found during M4b and fixed unflagged in #3892 (every mode): View
open to all signed-in users exposed presence and full statistics of any project to any signed-in user; those entry
points (HTTP and hub) now require ViewStudies. Issue #3891.
M5 — queued authority and system-owned obligations¶
M5a, feat(authz): bind delegated jobs to persisted authority — M1/M3 shared evaluator; deploy consumers first.
Targets: API job producers/handlers, PM message contracts/consumers/sagas including reference import, study updates,
screening recalculation and batch RoB; scheduler contract. Add versioned trusted actor/target/resource/action/job
binding for new delegated actions, persisted server-side admission; recheck execution/retry and audit denial.
Consumers quarantine legacy unclassified messages without dropping them or retry-storming. Proven admitted
obligations use explicit system contract, not a guessed actor. Producers upgraded only after compatible consumers.
M5a status (2026-10-02): consumers implemented dark by #3894,
behind DelegatedWorkAdmissionEnforced (project management, default off). A version-1 DelegatedWorkBinding
(family, job key, project, resource, the family's fixed action, delegated actor or named system contract) is persisted
server-side by DelegatedWorkAdmissionService after the same authority check that execution uses. The reference
import, bulk study update, batch RoB and screening recalculation consumers read it linearizably at every execution and
retry and recheck it through IAuthorityEvaluator and the M3a project evaluator. A denial is audited on the admission,
is terminal, and is never retried. A message with no admission, an unsupported one or a mismatched one is quarantined
whole in Mongo: it is not dropped, it adds no broker resource and it causes no retry storm. Unavailable never executes.
No obligation contract is approved. There is no message-contract change, and an older consumer ignores admissions.
M5a-2 (#3898): the API producers persist each job's admission
before it can start: reference-import and bulk-update signatures, RoB before SubmitJob, and the screening handler with
the correlation ID. The write never blocks a request. Both promotion orders are tested: old consumer with a new
producer, and new consumer with an old producer. Inventory, deployment order and the P9 proposal: queued-work ledger.
Evidence: PM Core 26, Mongo replica-set store 10, consumer gate 7, real RabbitMQ + replica-set broker fixture 6
(restart, retry recheck, unknown old message, denial after revoke, admitted, flag off); red-checked.
M5b, contract/test slice per job family — dependencies M5a and owner classification. Name owner, admission/commit boundary, cancellation, partial progress/retry/idempotency and recipient policy for each family. Screening consistency recalculation and bulk-PDF cleanup may need completion after actor revoke; new data-changing requests cannot inherit permanent authority. Committed facts still process; fresh disclosures re-authorize recipients. Exit: three-category deterministic tests in isolated RabbitMQ/PM fixture, including restart/retry and unknown old message handling. No generic exactly-once claim or mandatory JWT forwarding.
M5b status (2026-10-02): P9 decided by Chris (ledger):
data-changing jobs are atomic and roll back when stopped (permission change, cancellation, failure); tidy-up and
consistency jobs finish. #3921 proves, with no code change, that a
denied reference import leaves no Study (real RabbitMQ, replica set, job service and saga) and that the D5 denial of a
version 2 bulk update rolls back through the production recheck and admission store. #3929 admits the screening
recalculation as the obligation screening-inclusion-consistency.v1 and backfills admissions for legacy bulk updates
from StartedByInvestigatorId. #3931 makes the batch RoB save all or
nothing behind the default-off batchRiskOfBiasAtomicApply (recheck before every batch, exact rollback, recovery of a
dead run). Every P9 classification is now implemented, dark.
M6 — staged coherent cutover and evidence gate¶
M6a, release/config PRs, not new auth design — all scoped M1–M5 exits + M0b confirmation.
SyRF flags/catalog first; cluster-gitops environment values second, with digest/version-matched reader fleet.
Run the rollout gates below, record approved access-impact changes and preserve #2466 provider gates. No
deployment rollback may reach an old unaware binary: add a reviewed compatible-image allowlist gate to the
existing GitOps/deployment validation path and verify traffic draining. A persisted minimum version only constrains
binaries that implement it; it cannot protect against redeploying older code by itself. No per-user
canary may fall back to a weaker evaluator. Stage one named capability first, then the complete covered set;
do not declare global R1 until every protected surface is covered.
Exit: current-head CI/reviews, isolated matrix, staging synthetic acceptance, all served replicas compatible,
denial-preserving rollback rehearsal and owner production approval. No declaration of success from 222 mocks alone.
M7 — retire duplicate role claims/storage only after compatibility proof¶
M7a, refactor(identity): stop application role claim projection — M6, complete claim-consumer ledger.
Targets: Identity Services/UserClaimsService.cs:45, BFF callback/session DTO/refresh, token/userinfo tests,
migration UserImporter/InvestigatorReconciler role writers, web selectors. Stop emitting/using mutable role
claims; maintain identity, assurance and stable mapping. Old sessions/tokens with groups are ignored for authority;
new ones omit them. M005 parity documents updated through its owner, not silently contradicted.
M7b, delayed field cleanup — only after old readers/rollback and actual client compatibility are retired.
Remove obsolete Identity SyrfGroups storage/writers through expand-contract with restricted backup/verification;
no field deletion merely because emission stopped. Keep PM canonical authority and no reverse sync. Exit:
old/new session interoperability, no role-reader code/config/client, approved recovery path and explicit destructive
cleanup authorization. Physical deletion can remain deferred indefinitely if it adds risk without benefit.
M8 — explicit retain-versus-consolidate decision, not an automatic deletion PR¶
Produce an ADR after M6 using measured operations/latency/failure costs and lifecycle parity: credentials, recovery/abuse, Google linking, assurance, mapping, token/session revocation, campaigns, deployment isolation, signing-key boundary, and real worker/client contracts. Compare retained separate Identity with co-hosted authentication/session handling and the cost of removing first-party OAuth indirection. Preserve useful code. Include option cost for possible future external systems authenticating as users, secure delegation versus password sharing, client/consent questions and reversibility. No external API feature is authorized or required. Exit: owner selects retain/consolidate/defer with quantified evidence; any resulting migration gets its own plan.
B0–B2 — independent RabbitMQ security workstream¶
B0 topology/rollback contract (broker security contract, 2026-09-30; design only,
nothing live changed): infrastructure owner inventories required configure/write/read resources per
API/PM/Quartz/notifier/PDF/scheduler workload and environment, including dynamic temporary queues/exchanges,
saga/job routing and external consumers. Use sanitized read-only broker metadata; no message contents. Current
observed shared rabbit admin and unrestricted two-vhost ACLs are the starting finding, not proposed defaults.
Test least-privilege ACLs, connection/reconnect and TLS/certificate/hostname failure locally. Unknown consumers
block removal of legacy authority, not approval to ignore the finding. Identify existing declarative resource owner
and secret-management/rotation path before proposing config; never publish credential material.
B1 encrypted transport + distinct credentials expand: coordinate SyRF RabbitMqConfig/MassTransit tests,
charts and owner-controlled GitOps broker configuration. Introduce server-authenticated TLS and parallel
per-environment/per-workload credentials/ACLs, leaving a bounded migration window for old clients. Verify trusted
CA/hostname validation, scheduler topology and consumer retries in isolated fixtures; no skip-certificate bypass.
Use staging first, migrate one workload class at a time, monitor ordinary workload connection metadata/readiness
without live test publication. Preserve an approved restricted recovery operator identity; no deployment uses admin.
B2 contract/remove broad access: after every real consumer is accounted for and migrated, remove old principal grants/plaintext listener via separately approved GitOps change. Exit: sanitized live metadata shows expected identities, bounded ACLs and TLS for every connection; legitimate backlog drains with no new auth failures; local negative tests prove publish/consume/configure and cross-environment denial. No live forged messages. Rollback during B1 restores the previous reviewed client route without losing queue/job state; after B2, recover with scoped credentials/TLS, not automatic restoration of shared admin. Stop rollout on auth failures, stuck schedulers or unaccounted consumers. Rotate/revoke only after approval and overlap verification to avoid lockout.
Dependencies and parallel work¶
flowchart LR
A[Plan approval] --> H[M0 fixture and evidence]
H --> V[M1a/M1b usable grant revoke]
V --> G[M1c global admin guard]
G --> C[M2 app UI impersonation]
G --> P[M3 project reports catalog]
P --> L[M4 suspension and live delivery]
C --> L
P --> J[M5 queued contracts]
C --> R[M6 staged cutover]
L --> R
J --> R
R --> D[M7 safe duplicate cleanup]
R --> T[M8 topology decision]
A --> B[B0 topology and local ACL tests]
B --> X[B1 staged TLS and identities]
X --> Y[B2 remove legacy broker access]
M0b census and B0 can run beside fixture work. M2/M3 can parallelize only with a single owner for shared API authorization files; M4/M5 share the same evaluator contract. B work does not wait for M7/M8 but needs the client inventory and compatible transport configuration. Avoid overlapping edits by staging library API changes first. Do not delay a separately approved narrow security fix until broad programme completion.
Evidence-to-milestone coverage¶
| Material evidence or uncertainty | Milestone / explicit treatment |
|---|---|
| Independent cookie/session proposal versus developed Identity | Design separation; M8 costed choice after authority correction, no premature deletion. |
| PM/Identity duplicate role sources and missing continuous synchronization | M0b approved one-time mapping; M1a/M1b authority, M1c lifecycle guard; M7 stops obsolete writers. No new dual writer. |
| BFF groups survive refresh; native refresh reads Identity groups | M1/M2 ignore snapshots; M7 emission cleanup; real session/refresh tests in M0/M6. |
| Claim-only UI, reports, direct admin checks and impersonation union | M2 UI/direct/support; M3 reports/policies; consumer ledger before M7. |
| Raw role-name casing / historical import normalization | M0b explicit canonical mappings and unknown-value stop; M1 role-parser tests. Do not reopen the fixed import bug. |
| PM Deactivated not uniform native/runtime admission | M4 reversible denial and separately tracked token withdrawal; restore tests. |
| Cache TTL, saved SignalR groups, multiple API replicas | M0/M1 consistency proof, M4 delivery barriers; no 2-second grace or reliance on invalidation delivery. |
| A2/public summary distinction, conditional app grants, stage paths | M3 field/route matrix; preserve public discovery only as an owned exception; no universal admin-bypass claim. |
| Queued actor absent/log-only, committed-event and durable-work distinction | M5 family contracts, versioned consumers, quarantine unknown legacy work and execution tests. |
| Ordinary Mongo save then bus dispatch is not atomic publication | M1 audit commit correctness; M5 explicit durable admission/outbox where required, no blanket exactly-once promise. |
| Legacy subject/native profile mismatch; two-service profile precedence | Separate bounded #2466 integration follow-up before M8 consolidation: explicit ID translation, failure/retry, writer ownership tests. Not prerequisite to first role slice unless touched; no silent profile rewrite. |
| Mapping resolution can create missing claimed-ID PM record | #2466 reconciliation follow-up; preserve stable ID/no GUID inference, characterize missing-PM behavior before changing it. M0 fixtures cover missing/ambiguous mapping. |
| Recovery/Google/admission/deletion failure semantics and existing revocation | Preserve/test in M0/M4/M7 and M005; no redesign, unlock or account deletion in this plan execution by default. |
| Shared administrator broker ACLs and observed non-TLS connections | B0–B2 independent security priority; workload identity not human authority. B0 verified (2026-09-30) public plaintext AMQP on rabbitmq.camarades.net:5672 and Erlang-cookie distribution to every SyRF/preview namespace; see the broker security contract. |
| Deployed versions differ from source; readiness not activity | M0 version inventory, M6 immutable image verification; no source-to-production extrapolation. |
| No usable external-client census; empty metric-name search only | M0b sanitized registry/aggregate request; M7/M8 cannot infer no consumers from absence. |
| Potential future user-account integrations, not required APIs | M8 option cost; no password-sharing design, no revived required-machine/delegated product scope. |
| MapsGroup prototype vs separate batch RoB; exports are API-local | #3762 separate cleanup; M3/M5 retain/protect batch RoB and export paths without inventing OAuth workers. |
| Imports, study updates, Quartz, PDF cleanup, email, Sheets/helpdesk, living search | M0b lifecycle/owner census; M5 relevant job contracts; B0 worker ACLs; M8 compatibility. Unknown use remains unknown. |
| Historical docs/roadmap divergences and 222 mock tests | M0 refresh and native fixture, M6 evidence gates; M005 ledger remains authoritative for provider deployment. |
| Allocation/capacity/group schema and history-reader expansion | Existing programme dependencies only; capacity release in M4. Broad group UI/schema, history reader and new support access deferred with #3335 owners, not dropped or recreated. |
Test environment and acceptance matrix¶
M0 is a required deliverable, not an assumed available environment. Current 222 passing tests and exclusions are in the verification ledger; re-run appropriate suites on each implementation head. Use real native issuance only against the isolated stack, not S30 or deployed accounts. Never certify R1 from mocks or provider-bypass login.
| Test family | Required observable exit |
|---|---|
| Grant/revoke + concurrent writes | Acknowledged change followed by protected requests on A/B gives expected allow/deny immediately; CAS conflicts do not clobber grants; M1b: last-admin guard on the role-administration path. M1c: the guard covers role removal, suspension, deletion and administrative deactivation/import before any Identity destruction. Exercise every path concurrently with another removal and existing pending-deactivation reservations, including partial failure, ambiguous outcomes, retries and idempotent reconciliation. |
| BFF/direct/refresh | Old sessions, old role claims and refreshed tokens cannot revive a withdrawn capability; role-free principal works; native validity failures still deny. |
| Project policy | Removal and disablement independently tested; cross-project IDs, owner scope, admin nonmember, stage defaults, public metadata versus content. |
| Suspension/restore | Every protected surface denies; native withdrawal failure recorded separately; restoration preserves independent blocks and cannot restore deleted accounts. |
| SignalR | Post-commit invocation/delivery denied on both replicas; held query/resubscription cannot restore streams; missed notification and restart covered. |
| Queued work | New action rechecked at execution/retry; admitted obligation follows approved contract; committed fact processed with current recipient checks; unclassified legacy messages not silently allowed. |
| Failure / race / mixed fleet | Partition/primary failover/timeout and inconsistent policy revision deny, no stale allow; old binaries cannot serve migrated scope; in-flight contract exercised with deterministic barriers. |
| Audit and privacy | Actor/target/reason/operation ID and before/after recorded durably; no token, password or research payload in telemetry/public reports; safe reasons only. |
| Broker | Local positive/negative topology tests and TLS validation, staging read-only connection/ACL observations, reconnection and backlog recovery without live test messages. |
| Performance | Record p50/p95/p99 authority latency, error rate and throughput before/after on representative synthetic fixtures; proposed 5-second fail-closed ceiling. Approval required for SLO tradeoffs; no weakening R1 to hit a benchmark. |
Data reconciliation and rollout gates¶
- Cross-programme gate (2026-09-30): production cutover (M005 S10–S13) waits until authority M1 has landed,
meaning roles come from PM and not from token-copied
SyrfGroups. Staging S30/S09 may proceed, because authentication stays in Identity either way. Mirrored in M005 G1/G3. - G-P plan (approved for M0 on 2026-09-30, see below): approve design defaults/slice assignment. Refresh source and dependency PR state; no implementation while architectural review findings remain. Public docs hold only aggregate evidence, never real holder lists.
- G-I import: owner approves canonical mapping, gains/losses and restricted holder manifest. Dry-run reports conflicts/unknowns, backups verified, CAS/idempotency/audit/restore tested locally. No initial admin inferred from email or a GUID-shaped subject. A second dry-run against changed revisions must be reviewed, not blindly applied.
- G-L isolated: M0 native fixture and the applicable matrix pass on exact image digests; cancellation and failure outcomes documented. No stale authority from old token, process cache or socket principal.
- G-S staging: deploy compatible readers everywhere dark; verify policy versions and image digests; shadow compare approved cases, then activate selected scope coherently. Staging synthetic accounts only under an explicit later rollout authorization; this planning task does not authorize creating them. Collect at least one full operational cycle for affected jobs and a proposed 24-hour observation window; elapsed time cannot substitute for all test scenarios. Release owner may extend it for scheduled/rare work.
- G-C cutover: #3335 and #2466 owners confirm compatible provider/session state and impact approval. Drain incompatible replicas; persist authority-version minimum and enact GitOps flags/configuration. Fail readiness on mismatch, not per-pod fallback. Check authoritative reads, grant/revoke and UI capabilities without probing real users' private content. Production tests/changes require separate owner-approved procedure and subjects.
- G-R rollback: before enforcement, off/shadow rollback is harmless. After revocations, preserve PM state, minimum authority version and deny boundary; compatible release or temporary protected-operation freeze only. Do not reverse import over subsequent edits or restore stale claim authority. Demonstrate this in staging before G-C.
- G-D cleanup: after all claim readers, machine/client requirements and rollback dependencies are resolved, approve M7 contraction separately. Keep backups restricted and prove restoration does not replay revoked grants.
Flags are generated from src/charts/syrf-common/env-mapping.yaml; API/web/PM consumers declare their services,
run the generator and tests. Production values are GitOps-owned, no live override. Do not make a protection's
rollback flag equivalent to "allow legacy claims" after its authority version has advanced. Audit-only docs
in this PR need no runtime flag; each later behavior slice states its own flag decision.
Review gate and exact next action¶
Decisions recorded 2026-09-30 by Chris:
- Approved as written: P2, P4, P5, P6, P8.
- P1/P1b: approach approved. Holder and source mapping is approved separately through M0b; never inferred.
- P3: conditional on the M0 benchmark proving linearizable reads, deadline and failover are acceptable. Benchmark result (2026-09-30): PASS on the design's criteria (P3 benchmark). Accepted 2026-09-30 (controller) under P5: the ~10–12 s window of fail-closed denials on an unplanned primary kill or partition is P5's accepted temporary unavailability; a graceful step-down, as Atlas planned maintenance uses, cost 0 failures.
- P7, P9, P10: deferred until their milestones.
- Now authorised: M0a (test-only isolated native fixture, with failing R1 characterization, in a new wt PR referencing this plan) and M0b (evidence request), which can run in parallel.
- M1a authorised 2026-09-30 (Chris approved the programme; relayed by the controller) and implemented dark in #3858.
- M1b authorised 2026-09-30 (Chris approved the programme; relayed by the controller). API implemented dark in #3860, UI in #3861 and the operator bootstrap import in #3862.
- M1c authorised 2026-09-30 (Chris approved the programme; relayed by the controller) and implemented dark in #3865.
- M2a authorised 2026-09-30 (Chris approved the programme; relayed by the controller) and implemented dark in #3867.
- M2b authorised 2026-10-01 (relayed by the controller) and implemented dark in #3868–#3870.
- M3a and M3b authorised 2026-10-01 (Chris: "go ahead with it all"; relayed by the controller). M3a is implemented dark in #3879; M3b repairs are listed under M3 above.
- M4 and M5a authorised (Chris approved the programme; relayed by the controller). M4a is implemented dark in #3888 and M4b in #3889; M5a is implemented dark in #3894.
- M5b authorised 2026-10-02 with Chris's P9 decision (relayed by the controller), behind default-off flags.
- Not authorised: M6 or later code, any staging/production flag change, production, broker or account changes, or running the bootstrap import against any real environment (that also needs the M0b holder list and gate G-I); each needs separate approval.
- PDF notifier transport comparison: deferred until bulk-PDF work next touches the notifier.
Plan status: approved for M0, M1a, M1b, M1c, M2a, M2b, M3, M4, M5a and M5b. No implementation executor starts beyond M5b merely because a PR passes CI or cloud review.
Before final plan approval, request one current-head Codex cloud review only after checks pass; triage concrete mechanical fixes versus architecture/owner decisions. Re-review only changed substantive findings, avoid duplicates. Stop at approval gate; no merge. Remaining external evidence gates are the actual role/client census, deployed version/provenance alignment, exact public-content classification and workload/job-owner contracts. The future topology choice and possible user-account integration remain explicitly deferred decisions, not missing MVP code.