Skip to content

Operate the application-authority mode (M1a–M5a)

Milestone M1a of the application authority transition adds a PM-owned decision for one application capability, ListUsers (GET /api/investigators). Milestone M1b adds the second, ManageApplicationRoles: the revisioned, audited grant and revoke of the Administrator role, and a self-capabilities read. See grant and revoke application roles. Milestone M1c puts every path that can remove an active administrator (account deletion and Identity's destructive calls included) behind the same last-administrator guard; see the lifecycle guard. Milestone M2a converges every remaining application capability onto the same decision: the email template, batch-administration and runtime feature-flag management checks, the SignalR application branch, and the web app's menus and admin pages (the claim-consumer ledger lists every consumer and its owner). Milestone M3a puts project and stage policy, and the project permission report, on one decision source under the same mode: see project and stage policy. Milestone M5a binds delegated background jobs to persisted authority behind its own project-management flag: see delegated jobs. Two deployment flags select whether PM authority is ignored, compared, or enforced. Both are default off everywhere. No staging or production value changed in M1a, M1b, M1c, M2a, M2b or M3a.

The two flags

Both are backend-only, generated from src/charts/syrf-common/env-mapping.yaml (section applicationAuthorityFlags, Helm values featureFlags.syrfOwnedApplicationRoles and featureFlags.syrfOwnedApplicationRolesEnforced). Since M1c the same pair is also rendered into Identity, which reads only whether the mode is enforced: then its admin delete requires the PM pending-deactivation reservation and its self-service delete is refused. Set both services to the same mode; like the API, Identity refuses to start with the enforced flag but not the base flag.

syrfOwnedApplicationRoles syrfOwnedApplicationRolesEnforced Mode Behaviour
false false off (default) No authority read. The existing claim-based decision runs unchanged. The M1b endpoints answer 404.
true false shadow Every application-capability request (ListUsers and, since M2a, the others below) also reads PM authority and evaluates it, and logs whether it agrees with the claim decision. The response is always the claim decision. The M1b endpoints answer 404: shadow never writes roles.
true true enforced PM authority decides every application capability and role administration. Claims grant nothing. If authority is unavailable, the request is refused with 503. The M1b endpoints exist. Account deletion goes through the M1c lifecycle guard, and Identity refuses destructive deletions without a PM reservation.
false true invalid The API and Identity refuse to start.

They are deliberately not in the runtime override catalog (RuntimeFeatureFlagCatalog). The mode is read once at API startup from deployed configuration, so every replica of a deployment runs the same mode and no administrator can switch it per pod. The design requires this: "no contradictory per-pod overrides".

What PM authority decides

The pure evaluator (SyRF.ProjectManagement.Core.Authorization.AuthorityEvaluator) takes the authenticated InvestigatorId, the capability, and one snapshot of that Investigator read by MongoAuthoritySnapshotReader:

  • one pmInvestigator document by its CSUUID _id, projected to ApplicationRoles, Deactivated and the M1c PendingDeactivation marker;
  • read from the primary with linearizable read concern, with no session, no cache and no unit of work, within a 5 second deadline (maxTimeMS, a client cancellation token, and a hard wait bound);
  • any exception, including the MongoDB.Driver 3.10 ArgumentNullException defect found in the P3 benchmark, becomes "unavailable".
Facts Decision HTTP (enforced)
Active, holds Administrator (exact, case-sensitive) allow 200
Active, no Administrator role (including a lower-case administrator or an unknown role name) deny, role-not-held 403
Deactivated: true deny, subject-deactivated 403
A pending deactivation (M1c: the account's deletion is reserved and not yet settled) deny, subject-deactivation-pending 403
No Investigator record deny, subject-unknown 403
Deadline, failover, connection or any other error unavailable, authority-unavailable 503, Retry-After: 5
Unreadable field shapes unavailable, authority-malformed 503

The 503 body is a fixed problem document whose only specific field is "code": "authority-unavailable". Nothing in any response, log or metric names the user, their roles or the storage error.

The same table decides every PM-decided capability. Each one requires Administrator, except ImpersonateUsers, which requires SupportImpersonator alone:

Capability Protects Claim decision in off/shadow
ListUsers (M1a) GET /api/investigators ApplicationListUsersPolicy
ManageApplicationRoles (M1b) role administration, reconciliation none: the endpoints exist only when enforced
ListEmailTemplates (M2a) GET /api/admin-email/email-templates ApplicationListEmailTemplatesPolicy
ModifyEmailTemplates (M2a) create, update, delete email templates ApplicationModifyEmailTemplatesPolicy
BatchAdminProjects (M2a) cross-project inclusion update, the statistics administration controllers, the human branch of the parity read ApplicationBatchAdminProjectsPolicy
ManageRuntimeFeatureFlags (M2a) runtime flag provider status and every flag mutation inline check: syrf_groups contains administrator or SyrfAdmin
SendEmail (M2a) the three api/admin-email/send-* actions (#3466) ApplicationSendEmailPolicy
ImpersonateUsers (M2b) support impersonation, the impersonation picker and edit contexts, only with impersonationReadOnlyEnforcement on (support impersonation) none: flag off keeps the legacy administrator claim gate

In enforced mode the SyrfAdmin group, like the lower-case administrator claim, confers nothing. The production flag-mutation restriction (MutationAllowed, 403 … disabled in production) is separate: it applies after authorization, in every mode, exactly as before. A SignalR hub requirement named Application… goes through the same gate as HTTP (no hub method uses one today). While impersonating, the decision is about the impersonated user, as it has been for ListUsers since M1a; M2b replaces that with the explicit ImpersonateUsers capability. With impersonationReadOnlyEnforcement on (it requires enforced mode; the API refuses to start otherwise), impersonation needs the actor's PM ImpersonateUsers, is decided on the target only and is read-only unless an edit context is presented: see support impersonation.

Stored role names (the M2b reader gate)

SupportImpersonator maps only to ImpersonateUsers. Administrator alone does not imply it, and it confers no administrator capability, no application group (it is left out of the claim ∪ stored-role union that project and stage policy see) and no project or stage permission. The policy version is application-authority.m2b.1.

Every reader tolerates a stored role name it does not recognise: the name confers nothing, is never parsed with a throwing parser, and survives an ordinary Investigator save unchanged. When the authority read meets one, the API logs Application authority read N unrecognised application role name(s); they confer no capability at Warning, with the count only (no subject and no stored value). Identity's migration never emits SupportImpersonator as a syrf_groups token, and its readiness report does not require claim parity for it. Nobody holds SupportImpersonator: holders are approved through M0b, and the role editor still refuses it.

Suspended, deleted and pending-deletion subjects (M4a)

The evaluator denies every application capability to a subject whose account is deleted (subject-deactivated), suspended (subject-suspended) or reserved for deletion (subject-deactivation-pending), in that order. A suspension is its own PM field, never Deactivated. With applicationSuspension on (it requires enforced mode; the API refuses to start otherwise), administrators can suspend and restore accounts, and every authenticated request and hub invocation is refused for such a subject, not only application capabilities, with one exception: a suspended subject may still delete their own account (DELETE /api/account/profile, decided 2026-10-02, #3896). Deleted and pending-deletion subjects are still refused there, the M1c guard and Identity's two-step deletion are unchanged, and it needs an existing session (Identity still refuses a suspended account's sign-in). A token refresh that cannot read the suspension state ends the session (fail-closed, confirmed). See suspend and restore application access.

Project and stage policy (M3a)

With the mode enforced, every project and stage policy (Project…Policy, Stage…Policy, and their SignalR forms) is decided by the pure ProjectAuthorityEvaluator (policy project-authority.m3a.1) on the project's facts: owner, memberships (active or disabled, with groups), and every project and stage grant with the ResourceSecurity.json defaults merged with the project's own overrides.

  • Fresh facts. MongoProjectAuthorityReader reads the pmProject document by _id with the same linearizable, uncached, 5-second-deadline path as the Investigator read. It never uses the unit of work or the 2-second repository cache, so a membership removed or disabled on one replica is denied by the next request on every replica. An HTTP request reads a project once, however many project policies it evaluates; a hub invocation always reads afresh (SignalR shares the connection's HttpContext, so nothing is reused across invocations).
  • Active membership first (A1). No membership row (never a member, or removed) or a disabled membership denies every protected activity, whatever groups or individual stage grants (AllowedMemberIds) remain on record.
  • No site-administrator content access (A2). Application groups and roles grant nothing on a project or stage. An administrator who is not an active member is denied protected content, even where a permission names an application group (no writer creates one; the evaluator ignores it so the rule does not depend on data).
  • Public metadata only (P8). View and RequestToJoin are the only activities a non-member can be granted, and only where the grant opens them to all signed-in users. "All users" on any other activity grants nothing, and a stage activity is never public.
  • Grants stay additive (A3). Any one of: all active members, one of the member's groups, the owner where the grant names the owner (AssignPermissions, ChangeOwner, Delete, ExportData, View, EditMemberships, ViewMemberships by default), or the member's own stage individual grant.
  • Answers. Allowed: the request continues. Known denial: 403 (the body names no reason). Absent project: the existing 404. A stage that is not part of the routed project: 403, never a 500. Unavailable or unreadable facts: 503 authority-unavailable with Retry-After: 5, like the application decisions; a hub invocation is refused.
  • The permission report is the same decision. In enforced mode PermissionReportResolver returns the evaluator's report on the project being mapped, so every entry is exactly what the server enforces on the same facts.

Off and shadow are unchanged. No project read happens; the legacy domain decision (claim groups and stored roles included) answers every request and the legacy report is returned. Shadow also evaluates the new rules on the project the legacy path already loaded (no extra read) and records agreement. On the shipped defaults the two agree for every subject; the visible changes are freshness and the two configured shortcuts above.

Telemetry (meter SyRF.API.ApplicationAuthority): syrf.authority.project.decisions (tags mode, scope, activity, outcome, reason, agreement, policy_version) and syrf.authority.project.read.duration. Only a shadow disagreement or an unavailable decision logs (Warning), with no project, stage, subject or group identifier.

Not yet converted (see the ledger, rows 15–16): checks that endpoints re-run inside the action (statistics shaping, review eligibility, review writes) still evaluate the legacy model on the cached project after the handler has admitted the request.

SendEmail (administrative email authorization) is an unflagged administrator gate: no mode removes it. Off and shadow keep its administrator claim check; enforced decides the same Administrator requirement from PM authority, like every other capability here.

Realtime delivery (M4b)

With the mode enforced, every protected SignalR push is decided again when it is about to be sent, from fresh facts, by IRealtimeDeliveryAuthority (SignalR/Authority/RealtimeDeliveryAuthority.cs). Groups, roles or a membership captured when the client subscribed are never consulted.

  • What is read. The subscriber's Investigator (IAuthoritySnapshotReader) and, for a project stream, the project (IProjectAuthorityReader), concurrently, both linearizable and uncached, on whichever replica holds the socket. Under the M2b impersonation contract the actor is read too and must still hold ImpersonateUsers for this target.
  • Subject first. Whether the subject is active is the application AuthorityEvaluator's lifecycle decision (it checks every lifecycle state before any role): a deactivated subject, a pending deactivation (the M1c marker), an unknown subject, and M4a's application suspension deny every stream whatever the project says. The project decision is the M3a evaluator's.
  • Audience. Project View is open to every signed-in user by the shipped catalogue (P8 public metadata), so streams carrying membership content require an active membership as well: full statistics and study presence (reviewer identities, per-member statistics) are decided on ViewStudies, the policy their subscriptions require since #3892, and the claim-release notice on View plus an active membership. The project-details stream and the statistics invalidation stay at the View level but still refuse a disabled member (the existing contract). A member who is removed therefore stops receiving member content and drops to the non-member payload elsewhere. Export job updates need ExportData. Project summaries keep the listing filter on the change's post-image and are sent only while the subject is active.
  • The late-query barrier. A full-statistics emission runs its query first and reads authority only after the query returns, immediately before the send. A query already running when access is removed or the subject suspended cannot deliver after it, whether or not any notification about the change arrived.
  • Outcomes. Deliver: sent. Deny: nothing protected is sent; a member-content stream ends (a held stream stops and never resubscribes itself), project details send the payload-free delete, and statistics invalidations drop the subscriber. Unavailable: that one payload is withheld and the stream continues; nothing is ever delivered on unknown authority.
  • Resubscription. SubscribeToProject, SubscribeToProjectFullStats, SubscribeToStudyPresence, SubscribeToDataExportJob and SubscribeToProjectSummaries make the same decision and refuse with a HubException, so a suspended user's reconnecting client cannot restart its streams. JoinStudyReview, Heartbeat, StartedAnnotating and StoppedAnnotating also refuse a suspended subject (ReviewSessionSubjectHubFilter); a removed member is already refused there by the M3a stage policy.
  • Claim-release notices read once. The claim-revocation consumer reads the study, then makes the decision; the decision's own subject and project reads replace the legacy Investigator and project reads (the stage must exist on the same fresh project facts), so an enforced delivery makes three reads, not five (#3896).
  • Commit notifications are cleanup, not the fence. The project change stream still ends member-content streams as soon as the post-change document denies the subscriber. Suspension changes no project document, and a change stream can be missed or disconnected, so the delivery check is what guarantees nothing protected is sent.
  • Capacity is unchanged. Nothing here removes a reservation. A refused heartbeat or rejoin leaves the reservation to the existing contract: the liveness check, then the suspended-session grace period, or an explicit LeaveStudyReview (never gated). See active reviewer tracking.

Off and shadow are unchanged: every delivery takes its legacy path with no extra read (Enforced is false). A host that resolves enforced mode without the realtime authority refuses to construct the hub.

Telemetry (meter SyRF.API.ApplicationAuthority): syrf.authority.realtime.deliveries (tags surface, outcome, reason). Only an unavailable decision logs (Warning), with no identifier.

Delegated jobs (M5a)

Project management's delegated job consumers can recheck authority at every execution and retry, from a binding the producer persisted when it admitted the job. A separate deployment-only flag, DelegatedWorkAdmissionEnforced (Helm featureFlags.delegatedWorkAdmissionEnforced, project-management only, not runtime-overridable), turns it on. It is default off everywhere, and with it off every consumer runs exactly as before and reads nothing. Families, producers and the P9 classification (decided 2026-10-02) are in the queued-work ledger.

Family Consumer Job key Action rechecked
Reference import ReferenceFileParseJobConsumer search ID ImportSearch
Bulk study update StartBulkStudyUpdateJobConsumer update file ID BulkUpdateStudies
Batch risk of bias RobProcessingJobConsumer job ID (= PM RoB job ID) CalculateRob
Screening inclusion recalculation UpdateStudyScreeningStatsConsumer message correlation ID (else message ID) Edit

With the flag on, before any effect, each consumer:

  1. Reads the admission (pmDelegatedWorkAdmission, _id = the job key as CSUUID), linearizably and uncached.
  2. Backfills legacy bulk study updates (P9). A bulk study update with no admission gets one from its job's server-written StartedByInvestigatorId (policy version delegated-work.m5b.legacy-backfill.1), which is then rechecked like any other admission. A job with no recorded actor, or a record that cannot be read, is not backfilled: it is quarantined, or retried as unavailable. This also covers a job whose producer-time admission was refused or failed: its starter is decided here on current authority, so a starter who had already lost access is denied, as at the producer.
  3. Quarantines the message if there is no admission, the admission is malformed or a newer version, or its family, project or resource differs from the message. The whole message goes to pmDelegatedWorkQuarantine (one document per family and key, with an occurrence count), and the message is acknowledged. It is never dropped and never retried in a loop, and nothing executes.
  4. Rechecks current authority for a delegated binding. The actor must be active (the application evaluator's lifecycle decision: deactivated, pending deactivation, unknown and suspended all deny). The M3a project evaluator must also allow the family's action, which requires an active membership. A system-obligation binding needs an approved contract and an existing project, and consults no person. P9 (2026-10-02) approves screening-inclusion-consistency.v1 (screening recalculation) and bulk-study-update-rollback.v1 (ADR-020 D8).
  5. Acts on the decision.
  6. Proceed: the decision is appended to the admission, then the job runs.
  7. Deny: the denial is appended first and the admission becomes Denied. That is terminal: restoring access later does not revive the job. The consumer then throws DelegatedWorkDeniedException. Retry policies ignore it, so the job faults through its family's existing path without running.
  8. Unavailable: if the admission or authority cannot be read, DelegatedWorkAuthorityUnavailableException is retried by the family's existing policy. Families with no retry policy fault. Unavailable never executes.

Producers (M5a-2). The API writes each job's admission before the job can start, under the actor the request resolved: - Reference import: the upload signature (key = search ID). - Bulk study update: the update signature, after the job is saved (key = file ID). - Batch RoB: before SubmitJob (key = the RoB job ID). - Screening recalculation: the settings domain event handler. Since P9 it admits the system obligation screening-inclusion-consistency.v1 (no person; the setting was already saved and authorised) and sends with that ID as the correlation ID, so the recalculation finishes even if the requester loses access first. A PM that predates this approval denies such an admission, so deploy PM and API before turning the flag on.

The write is additive and never blocks or fails the request. It is bounded to 6 s. A denied, unavailable or failed admission is logged and counted (syrf.authority.delegated_work.admissions on meter SyRF.API.ApplicationAuthority, tags family, outcome and reason), and the job is still enqueued. A consumer on older code, or with the flag off, never reads admissions, so either promotion order is safe.

Batch RoB (M5b). With BatchRiskOfBiasAtomicApply on, a run's save also rechecks the admission before every batch of results, and a denial rolls back every result the run wrote (all-or-nothing save).

Do not turn the flag on until every API replica writes admissions, the legacy backlog has drained or been classified, and bulkStudyUpdateAtomicApply is on (only a version 2 bulk update rolls back when stopped). P9 was decided on 2026-10-02. With the flag on, a job that has no admission is quarantined.

Inspect quarantined work without message contents leaving the database:

db.pmDelegatedWorkQuarantine.aggregate([{ $group: { _id: { family: "$Family", reason: "$Reason" }, n: { $sum: 1 } } }])

Telemetry: meter SyRF.ProjectManagement.DelegatedWork, counter syrf.authority.delegated_work.decisions (tags family, outcome, reason). Logs carry the family and a bounded reason, never an identifier.

The web app (M2a)

The web app presents the backend's capabilities response; it never enforces anything.

  • When it reads. At sign-in, on every entry to a guarded admin page (never a remembered answer), and after any 403 from the API. It never re-reads while a read is running, and a refused capabilities read never triggers another.
  • Where PM decides (the read returns 200), menus and pages follow each capability exactly. The Admin menu appears when any capability is allowed, and each entry needs its own. The API's coded refusal (403 with a code, e.g. while impersonating) or 503 authority-unavailable offers nothing.
  • Direct navigation. Impersonation, Email Templates, Statistics Pilot and Feature Flags each check their capability (listUsers, listEmailTemplates, batchAdminProjects, manageRuntimeFeatureFlags) after the fresh read and send a refusal to /not-found.
  • Where PM does not decide (off or shadow: the read answers 404; or before the answer, or on any other failure, including an uncoded 403/503 from an ingress and a read that takes longer than 10 seconds), the web keeps the pre-M2a claim-group checks exactly. That includes leaving Impersonation and Email Templates unguarded. The only visible difference is one GET /api/account/capabilities (404) at sign-in and on admin page entry.

What to watch

  • Logs. One line per decision on each API replica, without identifiers (a request abandoned by its caller logs only at Debug: see abandoned requests). A request makes one decision, even though ASP.NET Core evaluates the policy more than once per request; the decision is reused only within that request, never across requests: Application authority shadow decision for ListUsers: authority deny (role-not-held), claims allow, disagree; policy application-authority.m2b.1. An agreement logs at Information and a disagreement at Warning. A PM-only decision (role administration and the capabilities read, enforced mode only) logs … decision for ManageApplicationRoles: authority allow (allowed); PM-only capability; policy …. These log at Information, including denials, because an ordinary user's capabilities read produces them as expected traffic. Only an unavailable decision logs at Warning. A committed role change logs one line with only the self-change flag and the policy version.
  • Metrics (meter SyRF.API.ApplicationAuthority, exported with the API's other meters):
  • syrf.authority.decisions, tagged mode, capability, outcome, reason, legacy, agreement and policy_version. For a PM-only capability, legacy and agreement are none;
  • syrf.authority.read.duration (ms), tagged status and cause.

Use the disagreement rate in shadow mode to find people whose claim and PM role differ before anything is enforced. - Latency. Locally a linearizable read took p50 about 3 ms unloaded, and p50 about 6 ms with p99 about 26 ms at 96 concurrent reads (P3). On Atlas, expect an extra client→primary round trip and a primary→majority round trip. Record staging p50/p95/p99 from syrf.authority.read.duration when shadow first runs there. - Sign-in is not on the authority path. Shadow (like off) makes no authority read during sign-in: Identity reads only whether the mode is enforced (and, only with applicationSuspension, which requires enforced mode, the account's suspension: see suspend and restore), the BFF /api/auth/login and /api/auth/callback never call the gate, and the self-capabilities endpoint answers 404 without a read unless enforced. Shadow reads happen only on requests protected by an application capability, once per request; project and stage policies only compare in memory on the project the legacy check already loaded. A two-minute first sign-in seen in the authority lane (#3893) was a dropped browser navigation on the shared CI host, also seen in off and enforced runs, not shadow work: see the fixture's troubleshooting.

Failover behaviour (accepted under P5)

During an unplanned primary loss or partition, authority reads fail for about 10–12 s, and each failed read takes up to 5 s. In enforced mode, ListUsers answers 503 for that window. This temporary unavailability was accepted on 2026-09-30 under decision P5: temporary unavailability is preferred to silently restoring revoked access. Atlas planned maintenance steps the primary down gracefully, and the benchmark measured no failures in that case.

What an outage answers (#3859)

Besides the authority read, the API's authorization handler makes ordinary PM calls on every request: it creates the Investigator on first sight, transfers seed-data ownership to the first real user, and loads the Investigator for the legacy claim groups. Those calls use the ordinary driver settings, so when the store stops answering they wait for server selection (30 s by default) and then fail.

  • Enforced. The authority read comes first: for an application capability the subject's read, for a project or stage policy the project's read. If it is unavailable, the request answers 503 authority-unavailable (Retry-After: 5) within the 5 s read deadline and the other PM calls are not made. If it answered, the other calls run next; a store outage in them (a connection failure, a server-selection or operation timeout, a lost primary or a recovering node) is the same 503, never a 500, even though the PM decision itself was available, and is remembered for the rest of that request (the handler runs several times per request). A write or command error there (a data answer, such as a duplicate key) is not an outage and propagates as before. The API logs PM store unavailable before the application-authority decision completed (<exception type>); answering 503 at Warning, with no message text. An unavailable application decision logs … authority unavailable (authority-unavailable); claims not compared; …: the claim comparison needs the same unreachable store, so it is not made, and the decision metric's legacy tag is none.
  • Off and shadow keep the old order exactly: the PM calls run first and an outage there behaves as before (the request waits, then fails).

Shadow mode adds read latency during an outage

Shadow mode makes one extra authority read per request on an application-capability policy (ListUsers and the M2a administration endpoints; project and stage shadow make no read). It never changes the answer, but the request waits for it:

  • Healthy store: the measured read latency above.
  • Authority read failing while the rest of the store answers (no majority for linearizable reads, a step-down or failover in progress): each such request takes up to 5 s longer before it answers with the unchanged claim decision.
  • Whole store unavailable: the earlier PM calls already wait for the store; the shadow read can add up to 5 s more once they return.

Accept this before enabling shadow anywhere, and watch syrf.authority.read.duration with status=unavailable while it runs. Turning shadow off removes the read.

Abandoned requests

If the caller goes away (the request is aborted) while the authority read is running, the read ends as cancelled. Nobody receives that decision, so it logs at Debug (… read for <capability> abandoned: the request was cancelled by the caller), counts no decision, and is not a disagreement. The read duration still records cause=cancelled. A cancelled read whose request was not aborted remains an unavailable decision with the usual Warning.

Turning it on and off

Values are GitOps-owned. Change them only in cluster-gitops, never with a live override.

  1. Shadow first. Set syrfOwnedApplicationRoles: true for one environment's API. Watch the disagreement count and read latency. The decision does not change.
  2. Enforce only where authorised. Enforcing requires a separate approval. M1b delivers the PM grant/revoke path, and the M6 rollout gates must be met, before any shared environment enforces. Until then, use enforced mode only in the isolated fixture below.
  3. Rollback (P5). Before enforcement, turning shadow off is harmless. Once enforcement has been relied on to remove access, turning it off would let old claims grant again, which the design prohibits. M1b adds that revocation path (enforced mode only), so from the first revocation in an environment, rolling back must preserve the denial: freeze the operation or roll forward, never re-enable claim grants. M6 adds the monotonic authority-version guard that enforces this across a fleet.

Try it locally

The application-authority fixture runs both API replicas in any mode. Since M1b its default is enforced:

bash e2e/authority/run.sh                                     # enforced: PM decides; M1b grant/revoke
bash e2e/authority/run.sh --application-roles-mode shadow     # decisions unchanged, disagreements logged
bash e2e/authority/run.sh --application-roles-mode off        # the deployed default, unchanged

Code map

Piece Location
Pure evaluator, policy table, decision/reason types src/libs/project-management/SyRF.ProjectManagement.Core/Authorization/
Reader contract src/libs/project-management/SyRF.ProjectManagement.Core/Interfaces/IAuthoritySnapshotReader.cs
Dedicated linearizable single-document reader src/libs/mongo/SyRF.Mongo.Common/LinearizableDocumentReader.cs
pmInvestigator adapter src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Authorization/MongoAuthoritySnapshotReader.cs
Mode, gate, telemetry, 503 result handler src/services/api/SyRF.API.Endpoint/Authorization/ApplicationAuthority/
Wiring into the application policy src/services/api/SyRF.API.Endpoint/Authorization/AuthorizationHandler.cs, SignalRAuthorizationHandler.cs, ProjectStatisticsParityReadAuthorization.cs
Project/stage evaluator, facts and report (M3a) src/libs/project-management/SyRF.ProjectManagement.Core/Authorization/ProjectAuthority{Evaluator,Facts}.cs
Project facts reader (M3a) src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Authorization/MongoProjectAuthorityReader.cs
Project gate, wiring into HTTP/SignalR handlers and the report (M3a) src/services/api/SyRF.API.Endpoint/Authorization/ApplicationAuthority/ProjectAuthorityGate.cs, AuthorizationHandler.cs, SignalRAuthorizationHandler.cs, Models/ValueResolvers/PermissionReportResolver.cs
Realtime delivery authority, review-session hub filter (M4b) src/services/api/SyRF.API.Endpoint/SignalR/Authority/, wired into SignalR/NotificationHub.cs, AggregateRootEntitySubscriptionManager.cs, Statistics/ProjectStatisticsChangedConsumer.cs, ClaimRevocations/ActivityClaimRevokedConsumer.cs
Delegated jobs: binding, gate, authority check, admission (M5a) src/libs/project-management/SyRF.ProjectManagement.Core/Authorization/DelegatedWork/; store …/Mongo.Data/Authorization/MongoDelegatedWorkAdmissionStore.cs; host wiring …/Mongo.Data/DelegatedWorkRegistry.cs; consumers in src/services/project-management/SyRF.ProjectManagement.Endpoint/Consumers/; API producer recorder src/services/api/SyRF.API.Endpoint/Authorization/ApplicationAuthority/DelegatedWorkAdmissions.cs (M5a-2)
Runtime flag management (M2a) src/services/api/SyRF.API.Endpoint/Controllers/RuntimeFeatureFlagsController.cs
Web presentation, guard and 403 refetch (M2a) src/services/web/src/app/core/auth/application-access.ts, application-capabilities.service.ts, core/http-interceptors/capability-refresh-interceptor.service.ts, core/state/ui/admin/admin-ui.selectors.ts
M1b role writer, M1c lifecycle guard, endpoints and responses See grant and revoke application roles

The design calls the evaluator IAuthorizationEvaluator. In code it is IAuthorityEvaluator, because ASP.NET Core already defines Microsoft.AspNetCore.Authorization.IAuthorizationEvaluator.