ppoppo_token/access_token/epoch_revocation.rs
1//! Per-account `session_version` epoch revocation port
2//! (RFC_2026-05-04_jwt-full-adoption Phase 5 — sv-port).
3//!
4//! Account-wide revocation axis: token's `sv` claim must be `>=` the
5//! substrate's current value. break-glass and `LogoutAll` bump the
6//! current value, invalidating every prior token within the substrate's
7//! TTL window. STANDARDS_AUTH_PPOPPO §17.7 + STANDARDS_AUTH_INVALIDATION
8//! §2.3.
9//!
10//! ── Deep module — composition lives behind the port ────────────────────
11//!
12//! chat-auth's existing two-trait split (`SessionVersionCache` +
13//! `SessionVersionFetcher`) — best-effort cache layer + authoritative
14//! fetcher — is a *production-validated* decomposition. It moves into
15//! the implementation of this port (an adapter that internally composes
16//! cache + fetcher with fail-closed fall-through), NOT onto the engine
17//! boundary. The engine sees ONE port; impls decide their own internal
18//! structure (chat-auth: cache + DB fetcher; pas-external: cache +
19//! HTTP-userinfo fetcher; in-memory: HashMap).
20//!
21//! This is the deep-module win: the engine's contract is "give me the
22//! current epoch for this subject"; substrates with different latency /
23//! consistency profiles each compose their own internal stack and
24//! satisfy the same trait.
25//!
26//! ── Failure-mode contract (fail-closed) ────────────────────────────────
27//!
28//! Returns `Ok(i64)` always when a value can be determined — adapters
29//! choose the genesis convention (typical: 0 means "no break-glass yet").
30//! A wired validator REFUSES a token whose `claims.sv` is absent (R6
31//! legacy-admit retired, RFC_202607150428 S6). `Err(Transient)`
32//! signals substrate failure; engine maps to
33//! `AuthError::SessionVersionLookupUnavailable` and refuses the token.
34//!
35//! ── Phase 10 split (RFC §6.11) ──────────────────────────────────────────
36//!
37//! Moves to `access_token::EpochRevocation` in Phase 10 D1. id_token
38//! does not carry an `sv` claim — break-glass invalidates bearer
39//! credentials, not the assertion-of-authentication that id_token
40//! represents.
41
42/// Current per-account `session_version` lookup.
43///
44/// `sub` is the subject claim (the account's ppnum_id ULID). The engine
45/// calls this whenever the token carries an `sv` claim — Human session
46/// tokens AND AI-agent client_credentials tokens minted via PAS's
47/// `AgentService.IssueToken` (the AI-agent kill-switch). For tokens with
48/// no `sv` (Token-Exchange delegated / exchange paths, legacy tokens) the
49/// gate short-circuits before reaching this port.
50#[async_trait::async_trait]
51pub trait EpochRevocation: std::fmt::Debug + Send + Sync {
52 async fn current(&self, sub: &str) -> Result<i64, EpochRevocationError>;
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum EpochRevocationError {
57 /// Substrate composition failure (cache miss + fetcher error,
58 /// HTTP userinfo timeout, etc.). Engine maps to
59 /// `AuthError::SessionVersionLookupUnavailable`.
60 #[error("session_version lookup transient failure: {0}")]
61 Transient(String),
62}
63
64/// Required typed stance on the sv epoch axis (RFC_202607150428 §7 Q3,
65/// resolved 2026-07-17).
66///
67/// Every [`super::VerifyConfig`] carries exactly one of these — the
68/// former `epoch: Option<Arc<dyn EpochRevocation>>` slot let four
69/// shipped consumers silently skip the axis (`None` was both "not yet
70/// wired" and "deliberately unwired", indistinguishable). Silence is
71/// now unrepresentable: a verify site that does not enforce the axis
72/// must SAY SO, in a variant whose name and payload are greppable and
73/// whose boot declaration is visible in logs (STANDARDS_FAILURE_MODE
74/// §4 — emission IS declaration).
75///
76/// ── Variant semantics ──────────────────────────────────────────────
77///
78/// - [`Enforced`](Self::Enforced) — the engine gates every verify
79/// through the port (`check_epoch::run`): missing `sv` rejects
80/// (`unstamped`, S6), stale `sv` rejects, substrate-down rejects
81/// fail-closed. The only variant that reaches the port.
82/// - [`EnforcedElsewhere`](Self::EnforcedElsewhere) — the axis IS
83/// enforced on this request path, by a named gate outside this
84/// engine call. Sole legitimate case today: PAS self-verify, where
85/// `port.current(sub)` would self-loop into the same
86/// `session_version_repo` that performs the bump, so the
87/// `session::liveness` single gate checks `sv` AFTER verify
88/// (`accounts-api/src/auth.rs`). Boot-declares at INFO — a correct
89/// configuration must not cry wolf at WARN.
90/// - [`Unenforced`](Self::Unenforced) — a declared, bounded gap: this
91/// deployment has no epoch substrate (RCW/CTW: no KVRocks, separate
92/// DB, published-SDK consumers — RFC_202607150428 §6·S5 deferral).
93/// Boot-declares at WARN so the gap is permanently visible in logs.
94///
95/// The engine short-circuits (admits past the sv axis) for both named
96/// non-`Enforced` variants — identical wire behavior to the retired
97/// `None`, but the stance is now typed, named, and logged.
98#[derive(Debug, Clone)]
99pub enum EpochEnforcement {
100 /// sv axis gated through the port on every verify (fail-closed).
101 Enforced(std::sync::Arc<dyn EpochRevocation>),
102 /// sv axis enforced by a NAMED gate elsewhere on the same request
103 /// path (engine skips; the gate runs outside this verify call).
104 EnforcedElsewhere {
105 /// The gate that owns the check, e.g. `"session::liveness"`.
106 gate: &'static str,
107 },
108 /// sv axis NOT enforced at this deployment — a declared, bounded
109 /// gap. `declare()` WARNs at boot.
110 Unenforced {
111 /// Why the substrate is absent and what bounds the gap.
112 reason: &'static str,
113 },
114}
115
116impl EpochEnforcement {
117 /// Boot-time declaration — call ONCE per long-lived verifier /
118 /// perimeter, not per request. Emission IS declaration: the stance
119 /// a face runs under must be readable from its boot log.
120 pub fn declare(&self, face: &str) {
121 match self {
122 Self::Enforced(port) => tracing::info!(
123 target: "ppoppo_token::revocation",
124 face,
125 port = ?port,
126 "sv epoch axis ENFORCED via port"
127 ),
128 Self::EnforcedElsewhere { gate } => tracing::info!(
129 target: "ppoppo_token::revocation",
130 face,
131 gate,
132 "sv epoch axis enforced by external gate (engine skips)"
133 ),
134 Self::Unenforced { reason } => tracing::warn!(
135 target: "ppoppo_token::revocation",
136 face,
137 reason,
138 "sv epoch axis UNENFORCED — declared gap, no substrate"
139 ),
140 }
141 }
142
143 /// The port, iff this stance is [`Enforced`](Self::Enforced).
144 /// `check_epoch::run` short-circuits on `None`.
145 pub(crate) fn port(&self) -> Option<&std::sync::Arc<dyn EpochRevocation>> {
146 match self {
147 Self::Enforced(port) => Some(port),
148 Self::EnforcedElsewhere { .. } | Self::Unenforced { .. } => None,
149 }
150 }
151}