Skip to main content

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}