Architecture Decision Record
ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection
Status
Superseded by ADR-097 (2026-09-01). Originally Accepted
(2026-07-21, storage model recorded as superseded 2026-08-26). Revised 2026-10-01: three statements corrected
against source. Revised 2026-10-06: the body's "Today" citations now point at AuthSessionIssuer, where
the refresh workflow's rotation, reuse revocation and lifetime guard live. Revised 2026-10-07: the
body's "Today" parentheticals now record the rotation grace window, under which a token rotated moments
ago is answered 409 with nothing revoked instead of revoking the user's sessions, and the
AuthSessionIssuer anchors are refreshed. The
body below is retained as the historical record (its present-tense citations re-verified against
current source on 2026-10-07) of the rotation, reuse-detection
and sliding-expiry policy this record decided, which ADR-097 keeps and generalizes to a per-user family
of per-device sessions hashed at rest; read ADR-097 for what ships today. Its storage, revocation,
claim and single-session details no longer describe the code: refresh tokens are gone from IAuthUser
(Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:9-14), UpdateRefreshToken and
RevokeRefreshToken are gone from that interface and from both app User aggregates, so no production
type declares them; neither Domain analyzer ledger mentions them any more (the unshipped ledger holds
only #nullable enable, Source/Core/MMCA.Common.Domain/PublicAPI.Unshipped.txt:1, and the shipped
IAuthUser entries list only PasswordHash and PasswordSalt, PublicAPI.Shipped.txt:14-16), so the
removal record survives only in Common's changelog (MMCA.Common/CHANGELOG.md:3046). The only callable copies left are on a
Common test double
(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:83,89), which is
not dead code: DeleteUserHandlerBaseTests.cs:74 still calls UpdateRefreshToken on it. Beyond that,
the user id rides the standard sub claim rather than a user_id claim
(Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:116), the one-token-per-user model
is replaced by a per-user family of per-device sessions
(Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:40-52), and revocation on an
account-state change moved out of the aggregates into each app's user-administration service: locking
an account (Store's lock is its IsActive deactivation) and changing its role both revoke every live
refresh session (ADC
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Administration/UserAdministrationService.cs:138
and :191, Store
MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/Administration/UserAdministrationService.cs:112
and :164), while the aggregate transitions themselves carry no refresh state (ADC
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:485, :507, Store
MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/User.cs:204, :303).
Note (2026-09-03): the body bullet on account deactivation and erasure described shipped code when this
record was written (ADC revoked in Delete() and Anonymize(), Store in Deactivate() and
Anonymize()), and stopped describing it on 2026-08-26, when the refresh-sessions sweep removed the
refresh-token members from both aggregates. Neither aggregate transition touches refresh state today;
the administrative lock and role change do, from the application layer (see the Status paragraph
above).
Note (2026-09-19): three content corrections re-verified against current source, with the body's other
line anchors left as they stood. The removed UpdateRefreshToken and RevokeRefreshToken are recorded
in Common's analyzer ledgers and are still called from a Common test, not merely surviving on a test
double; RefreshTokenAsync no longer routes through IssueTokensAsync, which within the base now has only
login and registration as callers (ADC's external-login path also calls it, see the Decision); and the constructor wiring of each app's subclass was re-read (its current
shape is recorded in the 2026-09-25 note below).
Note (2026-09-25): four corrections re-verified against current source. Both apps' subclass
constructors then took IOptions<RefreshSessionSettings> directly and forwarded it unchanged to the base
(ADC MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:57,
forwarded at :67; Store
MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/AuthenticationService.cs:33,
forwarded at :43). Account lock and role change revoke live refresh sessions in both apps (Status
paragraph above). The refresh workflow was then not identical across the two apps in one respect: Store
alone overrode CreateRefreshUserMissingError to answer a vanished user with 404, where ADC kept the
base's 401. That difference is gone: see Revision (2026-10-06) at the end.
The Common anchors for the class, the lifetime guard, the sliding expiry, the atomic rotation and
the reuse revocation are refreshed in the body.
Context
Identity issues two credentials on every successful sign-in: a short-lived, stateless JWT access token that every service validates by signature and expiry (ADR-004), and a long-lived refresh token the client presents to obtain a fresh access token without re-entering a password. The framework needs one canonical issuance-and-rotation workflow so the token lifetime, the rotation rule, and the reuse-detection response are decided once and inherited by every consuming Identity module, rather than re-implemented per app.
Two forces shape the model. A stateless access token cannot be revoked before it expires, so its
lifetime must stay short to bound exposure, which in turn makes a refresh token necessary for a usable
session. And a refresh token is a bearer credential with a long life: if it is captured, replay must be
detectable and answerable. The workflow lives once in AuthenticationServiceBase<TUser>
(Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:64); the app-specific claim set
and account gates stay in each app's sealed subclass (ADR-004 dual-fetch, ADR-032 hashing).
Decision
Mint a stateless JWT access token plus a single, server-stored refresh token that rotates on every use, with a token mismatch triggering revocation.
- Access token is stateless; refresh token is one column on the user row. The access token is a
signed JWT carrying the user claims and an
expset fromJwtSettings.AccessTokenExpirationMinutes(default 15 minutes,Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:61), written byTokenService.GenerateAccessToken(Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:101). The refresh token is a single nullableRefreshTokenstring plus itsRefreshTokenExpiryonIAuthUser(both members since removed,Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:9-14), persisted on the app'sUseraggregate. There is exactly one stored refresh token per user, not a per-device or per-session set. - The refresh token is 64 random bytes, base64-encoded, opaque.
TokenService.GenerateRefreshTokendraws 64 bytes fromRandomNumberGenerator.GetBytesand base64-encodes them (TokenService.cs:155-159). It carries no claims and is meaningful only by exact match against the stored value. - Rotation on every issuance. Both login (
AuthenticationServiceBase.cs:183) and refresh (AuthenticationServiceBase.cs:267) routed throughIssueTokensAsync(AuthenticationServiceBase.cs:474), which mints a new access token, generates a new refresh token, and overwrites the stored one viauser.UpdateRefreshToken(...)beforeSaveChangesAsync. Registration seeds the first refresh token the same way (AuthenticationServiceBase.cs:263).UpdateRefreshTokensets the token and its expiry on each app'sUseraggregate. The previous refresh token is therefore invalid the moment a new one is issued. Today that shared entry point is gone from the refresh path: only login (AuthenticationServiceBase.cs:212) and registration (AuthenticationServiceBase.cs:305) still callIssueTokensAsync(body atAuthenticationServiceBase.cs:443, which delegates tosessionIssuer.IssueAsyncat:451), whileRefreshTokenAsync(AuthenticationServiceBase.cs:309) does not call it at all, delegating instead tosessionIssuer.RotateAsync(AuthenticationServiceBase.cs:363). That method (Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:92) runsResolveRotatableSessionAsync(called atAuthSessionIssuer.cs:103, body at:296) and thenRotateSessionAsync(called atAuthSessionIssuer.cs:111, body at:392). (Today that overwrite is a successor session row claimed atomically throughIRefreshSessionStore.TryRotateAsync, called atAuthSessionIssuer.cs:416.) - Refresh binds to the same principal via the expired access token.
RefreshTokenAsyncrequires the client to present the expired access token alongside the refresh token and callsTokenService.GetPrincipalFromExpiredToken(AuthenticationServiceBase.cs:323). That method validates issuer, audience, signing key, and the pinned algorithm but skips only the lifetime check (TokenService.cs:178-180,182,187,196-197), so an unsigned, wrong-audience, or algorithm-swapped token yields no principal and the refresh fails (AuthenticationServiceBase.cs:324-328). The user id claim from that principal (principal.GetUserId(),AuthenticationServiceBase.cs:333) selects the user whose stored refresh credential is then compared. (Today that compare is a lookup by token hash plus a user-id match,AuthSessionIssuer.cs:307-314.) - Sliding per-rotation expiry, from a bound setting. Every issuance (login, refresh, and the first
token seeded at registration) stamps the stored refresh token's expiry as now plus the
RefreshTokenLifetimeproperty (today private to the session issuer,AuthSessionIssuer.cs:60; stamped at:362when a session opens and:405on the rotation successor), so the window restarts from the moment of each successful rotation rather than staying pinned to the opening login. That property reads the value the token service derives fromJwtSettings.RefreshTokenExpirationDays(JwtSettings.cs:64, default 7 days) viaTokenService.RefreshTokenLifetime(TokenService.cs:165), guarding against a non-positive configured value by falling back to the BR-205 default of 7 days (AuthSessionIssuer.cs:60-61; interface defaultITokenService.cs:40). A client that refreshes at least once inside each window therefore stays signed in indefinitely; re-login is required only after a full lifetime elapses with no successful refresh, or after the token is revoked. - Mismatch or expiry revokes the stored token. On refresh, if the presented token does not equal the
stored
RefreshToken, or the stored expiry is in the past, the workflow callsuser.RevokeRefreshToken()and saves before returning a 401.RevokeRefreshTokennulls both the token and its expiry on each app'sUseraggregate, so a presented token that has already been rotated away (the signature of reuse or theft) invalidates the current stored token as well, forcing a fresh password login rather than silently reissuing. (Today the equivalent rule runs against session rows: a presented token that lands on a row revoked as a reuse signal (guardIsReuseSignalatAuthSessionIssuer.cs:318, defined at:460) revokes every live session the user holds,RevokeLiveSessionsAsyncwithReasonReuseDetectedatAuthSessionIssuer.cs:333, unless the row was rotated within the rotation grace window (IsWithinRotationGrace, called at:326, defined at:477;RefreshSessionSettings.ReuseGraceSeconds, default 10,Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:98), which answers a409 ConflictAuth.RefreshSuperseded(AuthSessionIssuer.cs:276-280) with nothing revoked. A token that loses the atomic rotation claim branches the same three ways on a re-read of its row: lost to a sign-out or a cap eviction, only that request fails (AuthSessionIssuer.cs:428-434); rotated inside the grace, the 409 with nothing revoked (:436-439); otherwise the whole live family is revoked (:444). A row revoked by sign-out, sign-out-everywhere, a password change, or eviction by the session cap fails only that request and leaves the user's other sessions live,AuthSessionIssuer.cs:316-324.) - Explicit revocation and account-state changes clear the same slot.
RevokeTokenAsyncloads the user and revokes the stored token on demand (todayAuthenticationServiceBase.cs:378, delegating tosessionIssuer.SignOutAsyncat:389). Both apps also revoked on account deactivation and erasure, so those transitions immediately ended the refresh chain: ADC inDelete()andAnonymize(), Store inDeactivate()andAnonymize(). That half stopped applying on 2026-08-26 (see the Status note): those methods leave refresh state untouched today (ADCMMCA.ADC/.../Identity.Domain/Users/User.cs:485,507, StoreMMCA.Store/.../Identity.Domain/Users/User.cs:204,303), and the revocation on an account lock or a role change now runs in each app's user-administration service instead (ADCUserAdministrationService.cs:138,191, StoreUserAdministrationService.cs:112,164). - Both apps inherit the workflow through a sealed subclass. ADC's
AuthenticationService(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:50,IAuthSessionIssuertaken at:56and forwarded to the base at:64) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/AuthenticationService.cs:26, taken at:31, forwarded at:38) both passIAuthSessionIssuerinto the base constructor and supply only app-specific hooks (the claim set, deactivated-account gates, the registration side-effect); both constructors also takeIOptions<EmailConfirmationSettings>(ADCAuthenticationService.cs:57, StoreAuthenticationService.cs:32) and forward it to the base unchanged (ADC:66, Store:40). Neither takesIRefreshSessionStoreorIOptions<RefreshSessionSettings>: session issuing and the refresh settings sit behind the framework'sIAuthSessionIssuer. The rotation, reuse-detection, and lifetime logic is identical across both apps because it lives once in the base, with no app-level difference at the edge: neither app overridesCreateRefreshUserMissingError, so a refresh for a vanished user answers the base's 401 in both (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:600-601, called at:343). ADC's external OAuth path (ADR-036) issues the same refresh credential when it exchanges an external identity for the local token pair, by routing into the sharedIssueTokensAsync(AuthenticationService.cs:338).
Rationale
- Short access token plus refresh keeps the hot path stateless. Every service validates the access
token with no store lookup (ADR-004); the short
expbounds the revocation gap, and the refresh token is the one place a database round trip is paid, only when the access token has already expired. - Rotation makes theft self-limiting. Because each refresh invalidates the prior refresh token, a
stolen token is useful for at most one rotation; the legitimate client's next refresh then presents a
token that no longer matches, which surfaces the compromise instead of letting two parties refresh
indefinitely from the same secret. (Today that detection has a blind spot: a replay of a stolen
token that arrives within the rotation grace window of a legitimate rotation is answered 409 with
nothing revoked,
RefreshSessionSettings.cs:90-93; see the Trade-offs.) - Revoke-on-mismatch turns a silent replay into a forced re-login. Treating any mismatch as reuse and
clearing the stored token means a captured-and-replayed refresh cannot quietly mint tokens; it ends the
session for everyone holding that token and requires a password to reopen it. (Today a token
re-presented within the rotation grace window is treated as a race rather than reuse: it is answered
409 with nothing revoked,
AuthSessionIssuer.cs:326-331; see the Trade-offs.) - Rotation plus a sliding inactivity window, not an absolute cap. Because the expiry is re-stamped on
every rotation (
AuthSessionIssuer.cs:362,405), an actively refreshing client is never forced onto a fixed re-authentication schedule; the window bounds inactivity instead, lapsing a chain that goes a full lifetime with no successful refresh. There is deliberately no absolute session cap: rotation (each refresh invalidates its predecessor) and reuse-detection revocation are the backstops that make a captured chain self-limiting. - One workflow, app-specific edges. Putting issuance, rotation, and reuse detection in the shared base means a future hardening (shorter lifetime, a per-device token table, a different reuse response) is one edit both apps inherit, while the claim set and account gates stay in each subclass.
Trade-offs
- One refresh token per user means one live session. A new login overwrites the single stored token, so signing in on a second device invalidates the first device's refresh chain; the first device's next refresh mismatches and is revoked. Concurrent multi-device sessions that each keep their own refresh token are not supported by this model. A per-device or per-session token table would be required for that, and is deliberately out of scope here.
- Reuse detection is aggressive by design. A benign race (two client tabs refreshing near-simultaneously,
the second presenting the just-rotated-away token) is indistinguishable from theft, so it revokes the
stored token and forces a re-login. The safety of failing closed is chosen over the convenience of a
short reuse grace window. (Today the session issuer does apply such a window: a token rotated less
than
RefreshSessionSettings.ReuseGraceSecondsago (default 10,Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:98) is answered409 ConflictAuth.RefreshSupersededwith nothing revoked, on both the lookup path and the lost-claim path (AuthSessionIssuer.cs:326,:436, defined at:477), so a sibling-tab race inside the window fails only that request. The cost is the converse: a replay of a stolen token that arrives inside the window of a legitimate rotation gets the same 409 and revokes nothing, a trade-off the setting itself documents (RefreshSessionSettings.cs:90-93; set it to0to treat every returning rotated token as reuse,:94). A re-presentation after the window still revokes the whole live family.) - The refresh token is server-side state. Unlike the fully stateless access token, the refresh token is a column that must be written on every login and every refresh, so the refresh path always incurs a write to the Identity database; it is not a stateless operation.
- No absolute session cap. Because the refresh lifetime is re-stamped on every rotation
(
AuthSessionIssuer.cs:362,405), the configured window (seven days by default) bounds inactivity, not total session age: a continuously active client that refreshes at least once per window stays signed in indefinitely without re-entering a password. The flip side is exposure: a captured refresh-token chain that keeps refreshing never lapses on its own, so rotation (each refresh invalidates its predecessor) and reuse-detection revocation are the only backstops that end it. An absolute cap anchored to the opening login would bound that exposure but is deliberately not imposed here. - A non-positive configured lifetime falls back silently. Since 2026-07-21 the refresh lifetime is
honored from configuration:
RefreshTokenLifetime(AuthSessionIssuer.cs:60-61) applies the valueTokenServicederives fromJwtSettings.RefreshTokenExpirationDays(TokenService.cs:165;JwtSettings.cs:64). The guard treats a non-positive configured value (a zero or negativeRefreshTokenExpirationDays, or a test double that overrides the member and reportsTimeSpan.Zero; the interface default atITokenService.cs:40itself returns seven days) as absent and falls back to the BR-205 seven-day default rather than failing startup, so a misconfiguration silently reverts to the baseline instead of surfacing an error.
Revision (2026-10-01)
No decision or rationale changed. Three statements were corrected against current source. The removed
UpdateRefreshToken and RevokeRefreshToken no longer appear in either Domain analyzer ledger
(Source/Core/MMCA.Common.Domain/PublicAPI.Unshipped.txt:1, PublicAPI.Shipped.txt:14-16); their
removal is recorded only in MMCA.Common/CHANGELOG.md:2680 (Status). IssueTokensAsync has login and
registration as its only callers inside the base (AuthenticationServiceBase.cs:245,330), and ADC's
external-login path also calls it (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:320)
(2026-09-19 note). The interface default for RefreshTokenLifetime is seven days
(Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ITokenService.cs:40), so only a
double that overrides the member can report TimeSpan.Zero (Trade-offs). Line anchors were refreshed
for TokenService (:101, :116, :155-159, :165, :178-197), AuthenticationServiceBase
(:40-52, :735-736, :748, :770, :799, :826, :839, :850, :858), Store's
AuthenticationService (:25, :32-33, :37, :42-43, :136-137) and ADC's external-login call. The
login, refresh, registration and IssueTokensAsync anchors in the "Rotation on every issuance" bullet
(AuthenticationServiceBase.cs:183, :263, :267, :474) record the pre-2026-08-26 code shape and do not
point at that code today.
Revision (2026-10-06)
Both apps answer a vanished refresh user with 401, and neither constructor takes the refresh-session
types. Store no longer overrides CreateRefreshUserMissingError, so both apps inherit the
framework's 401 (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:600-601,
called at :343). Both subclass constructors take IAuthSessionIssuer (ADC
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:56,
Store MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/AuthenticationService.cs:31)
and IOptions<EmailConfirmationSettings> (ADC :57, Store :32) in place of IRefreshSessionStore
and IOptions<RefreshSessionSettings>; the Decision body is corrected to match. The 2026-09-25 note
keeps the anchors it recorded.
- The refresh mechanics moved out of the base into
AuthSessionIssuer.RefreshTokenAsync(AuthenticationServiceBase.cs:309) delegates tosessionIssuer.RotateAsync(:363), andRevokeTokenAsync(:378) tosessionIssuer.SignOutAsync(:389). Rotation (Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:90,:101,:281,:367,:391), reuse revocation (:311,:399; a revoked row triggers it only whenIsReuseSignalholds,:303, so a signed-out or cap-evicted row fails just that request), the privateRefreshTokenLifetimeguard (:58-59) and the sliding expiry stamps (:340,:380) are cited there; the base has noRefreshTokenLifetimemember. The refresh credential compare is a token-hash lookup plus a user-id match (AuthSessionIssuer.cs:292-299), not a compare in the base. The 2026-10-01 anchor list forAuthenticationServiceBase(:735-858) pointed past the end of that file. - Related no longer describes the single-slot model in the present tense: ADR-036's path issues a per-device session (ADR-097), and the revocation runs against session rows, not the user row.
- Current locations of facts recorded only in the 2026-10-01 revision: the removal record is at
MMCA.Common/CHANGELOG.md:3021(not:2680);IssueTokensAsyncis called at login (AuthenticationServiceBase.cs:212) and registration (:305), body:443; ADC's external-login call isMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:338(not:320). - Line anchors in Status, Context, Decision, Rationale and Trade-offs were re-verified against current
source (
AuthenticationServiceBase.cs:64,:212,:305,:309,:323-333,:363,:378,:443; ADCUser.cs:485,:507).
Revision (2026-10-07)
Re-verified against current source. The decision, the superseded status and the
AuthenticationServiceBase delegation are unchanged (AuthenticationServiceBase.cs:212, :305,
:309, :323, :343, :363, :378, :389, :443, :451, :600); what moved is the session
issuer, which now applies a rotation grace window and whose anchors all shifted.
- A token re-presented within the rotation grace is a race, not reuse. A revoked row whose reason
is
Rotatedand whose revocation is younger thanRefreshSessionSettings.ReuseGraceSeconds(default 10,Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:98;IsWithinRotationGrace,Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:477) is answered409 ConflictAuth.RefreshSuperseded(AuthSessionIssuer.cs:276-280) with nothing revoked, on the lookup path (:326-331) and on the lost-claim path (:436-439). The Decision's reuse-revocation parenthetical, the Rationale's theft and revoke-on-mismatch bullets and the Trade-offs' aggressive-reuse bullet now say so, including the cost: a stolen-token replay inside the window is also answered 409 with nothing revoked (RefreshSessionSettings.cs:90-93). - Losing the atomic rotation claim no longer always revokes the family. The loser re-reads its row
and branches three ways: lost to a sign-out or a cap eviction, only that request fails
(
AuthSessionIssuer.cs:428-434); rotated inside the grace, the 409 above (:436-439); otherwise the whole live family is revoked withReasonReuseDetected(:444). The Decision parenthetical that stated an unconditional revoke is corrected. - Anchors re-verified against current source:
AuthSessionIssuer.csRotateAsync:92,ResolveRotatableSessionAsynccalled:103with body:296,RotateSessionAsynccalled:111with body:392,TryRotateAsynccalled:416, the token-hash lookup plus user-id match:307-314, theIsRevokedblock:316with the non-reuse branch:318-324,IsReuseSignaldefined:460, the reuse revoke:333, the privateRefreshTokenLifetimeguard:60-61(:57-58is nowAccessTokenLifetime), and the sliding-expiry stamps:362(open) and:405(rotation successor); the removal record in Status is atMMCA.Common/CHANGELOG.md:3046. The 2026-10-06 revision and the 2026-09-25 note keep the anchors they recorded.
Related
ADR-004 (the stateless RS256/JWKS access token this refresh flow reissues, and the algorithm pinning
GetPrincipalFromExpiredToken relies on), ADR-032 (the password hashing that gates the login which opens
a refresh session, sharing the same AuthenticationServiceBase<TUser>), ADR-036 (the external OAuth path
that exchanges a federated identity for the same rotating refresh credential, today a per-device
session under ADR-097), ADR-047 (the soft-deleted-user middleware that bounds the stateless access
token's revocation gap, complementing the refresh revocation this ADR decided, which ADR-097 runs
against session rows rather than the user row), ADR-097 (the multi-device refresh sessions that
supersede this record).