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}