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
pmInvestigatordocument by its CSUUID_id, projected toApplicationRoles,Deactivatedand the M1cPendingDeactivationmarker; - 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
ArgumentNullExceptiondefect 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.
MongoProjectAuthorityReaderreads thepmProjectdocument by_idwith 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).
ViewandRequestToJoinare 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,ViewMembershipsby 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 existing404. A stage that is not part of the routed project:403, never a500. Unavailable or unreadable facts:503 authority-unavailablewithRetry-After: 5, like the application decisions; a hub invocation is refused. - The permission report is the same decision. In enforced mode
PermissionReportResolverreturns 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 holdImpersonateUsersfor 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
Viewis 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 onViewStudies, the policy their subscriptions require since #3892, and the claim-release notice onViewplus an active membership. The project-details stream and the statistics invalidation stay at theViewlevel 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 needExportData. 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,SubscribeToDataExportJobandSubscribeToProjectSummariesmake the same decision and refuse with aHubException, so a suspended user's reconnecting client cannot restart its streams.JoinStudyReview,Heartbeat,StartedAnnotatingandStoppedAnnotatingalso 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:
- Reads the admission (
pmDelegatedWorkAdmission,_id= the job key as CSUUID), linearizably and uncached. - Backfills legacy bulk study updates (P9). A bulk study update with no admission gets one from its job's
server-written
StartedByInvestigatorId(policy versiondelegated-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. - 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. - 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) andbulk-study-update-rollback.v1(ADR-020 D8). - Acts on the decision.
- Proceed: the decision is appended to the admission, then the job runs.
- 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 throwsDelegatedWorkDeniedException. Retry policies ignore it, so the job faults through its family's existing path without running. - Unavailable: if the admission or authority cannot be read,
DelegatedWorkAuthorityUnavailableExceptionis 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
403from 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 (403with a code, e.g. while impersonating) or503 authority-unavailableoffers 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 uncoded403/503from 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 oneGET /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, taggedmode,capability,outcome,reason,legacy,agreementandpolicy_version. For a PM-only capability,legacyandagreementarenone;syrf.authority.read.duration(ms), taggedstatusandcause.
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 same503, never a500, 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 logsPM store unavailable before the application-authority decision completed (<exception type>); answering 503at 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'slegacytag isnone. - 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.
- Shadow first. Set
syrfOwnedApplicationRoles: truefor one environment's API. Watch the disagreement count and read latency. The decision does not change. - 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.
- 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.