Skip to main content

pas_external/epoch/
fetcher.rs

1//! [`Fetcher`] port — authoritative substrate readout for sv-axis.
2
3use async_trait::async_trait;
4
5/// Authoritative `sv:{sub}` source for the cache-miss path.
6///
7/// Promoted to the SDK in Phase 11.Z from chat-auth's private
8/// `SessionVersionFetcher` trait (§3 Row 6 "replace, don't layer"). The
9/// shape stays narrow: one async method returning the current `sv` for
10/// a given `sub` (ULID-form `ppnum_id`), or a typed [`FetchError`] on
11/// failure.
12///
13/// **Fail-closed contract.** Failure here surfaces as
14/// [`super::EpochRevocationError::Transient`] from the composer, which
15/// the engine maps to [`AuthError::SessionVersionLookupUnavailable`]
16/// and the SDK maps to
17/// [`crate::TokenVerifyError::SessionVersionLookupUnavailable`].
18/// Silently admitting on fetch failure would let stale tokens slip
19/// through after a break-glass — the entire point of the sv axis.
20///
21/// ## Implementations
22///
23/// - chat-api `PgFetcher` — cross-schema SQL `SELECT session_version
24///   FROM scaccounts.ppnums WHERE id = $1` (per-account epoch; keyed by
25///   the account ULID = token `sub`). Kept consumer-side because it
26///   depends on chat-api's `PgPool` and the cross-schema search-path
27///   policy. Single-DB architecture only.
28/// - RCW/CTW — currently wire NO Fetcher: they opted out of the sv-axis
29///   entirely (dropped the `sv` column; revocation bound is the
30///   access-token `exp` ≈1h via the refresh-token liveness axis). The
31///   deferred PAS per-user readout design (gRPC
32///   `pas.session.v1.SessionService` + `AdminSvFetcher`) is recorded
33///   design-on-ice in `STANDARDS_AUTH_INVALIDATION §5.4.1` (RFC retired
34///   2026-05-24). 3rd-party apps cannot use chat-api's cross-schema
35///   pattern (separate DB clusters from PAS).
36/// - 0.9.0 `UserinfoFetcher` — DELETED in 0.10.0 (RFC_2026-05-08 §4.3).
37///   The API was misleading — PAS's userinfo authenticates the caller
38///   only, not an arbitrary queried subject.
39///
40/// [`AuthError::SessionVersionLookupUnavailable`]: ppoppo_token::access_token::AuthError::SessionVersionLookupUnavailable
41#[async_trait]
42pub trait Fetcher: Send + Sync {
43    /// Read the authoritative current `sv` for the given subject
44    /// (ULID-form `ppnum_id` from the `sub` claim).
45    async fn fetch(&self, sub: &str) -> Result<i64, FetchError>;
46}
47
48/// Typed fetch failure. Each variant carries a free-form detail for
49/// audit logs; the composer collapses every variant onto
50/// [`super::EpochRevocationError::Transient`] (fail-closed) so the
51/// engine sees a uniform contract regardless of substrate flavor.
52///
53/// 0.11.0 BREAKING — widened from `pub struct FetchError(pub String)`
54/// to a typed enum. The variants exist so `Fetcher` impls (today
55/// chat-api's `PgFetcher`; a possible future `AdminSvFetcher` over a PAS
56/// per-user readout — design-on-ice in
57/// `STANDARDS_AUTH_INVALIDATION §5.4.1`) can hand the composer a
58/// substrate-grained reason that survives into dashboards. The composer still collapses to `Transient` —
59/// fail-closed contract preserved.
60///
61/// `#[non_exhaustive]` — adding a new variant in a future minor is
62/// non-breaking for downstream `Fetcher` impls (they construct, they
63/// don't match) but requires consumers that DO match to carry a
64/// wildcard arm. The composer matches via `to_string()` /
65/// `Display`-collapse rather than variant-specific arms, so
66/// downstream additions don't ripple.
67#[derive(Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum FetchError {
70    /// Authority denied — the caller credential is rejected by the
71    /// authoritative substrate (e.g. JWT-signed Admin gRPC client
72    /// lacks `pas.session.read` scope, or API-key revoked). Distinct
73    /// from `SubjectNotFound` because the substrate refused even to
74    /// answer the query, not "answered: subject doesn't exist".
75    #[error("authority denied: {0}")]
76    AuthorityDenied(String),
77    /// Subject not found — substrate confirmed the queried `sub` is
78    /// absent. For sv-axis this is a "fail closed" signal because
79    /// admitting a token whose subject the authority can't confirm
80    /// would let dangling tokens slip through.
81    #[error("subject not found: {0}")]
82    SubjectNotFound(String),
83    /// Throttled — substrate rate-limit / 429-class response.
84    /// Surfaces as transient so the caller backs off; not a security
85    /// signal.
86    #[error("throttled: {0}")]
87    Throttled(String),
88    /// Catch-all for substrate transients (network, deserialization,
89    /// unspecified 5xx). Replaces 0.10.x's `FetchError(String)`.
90    #[error("session_version fetch failed: {0}")]
91    Other(String),
92}