ppoppo-token 0.5.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! Per-account `session_version` epoch revocation port
//! (RFC_2026-05-04_jwt-full-adoption Phase 5 — sv-port).
//!
//! Account-wide revocation axis: token's `sv` claim must be `>=` the
//! substrate's current value. break-glass and `LogoutAll` bump the
//! current value, invalidating every prior token within the substrate's
//! TTL window. STANDARDS_AUTH_PPOPPO §17.7 + STANDARDS_AUTH_INVALIDATION
//! §2.3.
//!
//! ── Deep module — composition lives behind the port ────────────────────
//!
//! chat-auth's existing two-trait split (`SessionVersionCache` +
//! `SessionVersionFetcher`) — best-effort cache layer + authoritative
//! fetcher — is a *production-validated* decomposition. It moves into
//! the implementation of this port (an adapter that internally composes
//! cache + fetcher with fail-closed fall-through), NOT onto the engine
//! boundary. The engine sees ONE port; impls decide their own internal
//! structure (chat-auth: cache + DB fetcher; pas-external: cache +
//! HTTP-userinfo fetcher; in-memory: HashMap).
//!
//! This is the deep-module win: the engine's contract is "give me the
//! current epoch for this subject"; substrates with different latency /
//! consistency profiles each compose their own internal stack and
//! satisfy the same trait.
//!
//! ── Failure-mode contract (fail-closed) ────────────────────────────────
//!
//! Returns `Ok(i64)` always when a value can be determined — adapters
//! choose the genesis convention (typical: 0 means "no break-glass yet").
//! A wired validator REFUSES a token whose `claims.sv` is absent (R6
//! legacy-admit retired, RFC_202607150428 S6). `Err(Transient)`
//! signals substrate failure; engine maps to
//! `AuthError::SessionVersionLookupUnavailable` and refuses the token.
//!
//! ── Phase 10 split (RFC §6.11) ──────────────────────────────────────────
//!
//! Moves to `access_token::EpochRevocation` in Phase 10 D1. id_token
//! does not carry an `sv` claim — break-glass invalidates bearer
//! credentials, not the assertion-of-authentication that id_token
//! represents.

/// Current per-account `session_version` lookup.
///
/// `sub` is the subject claim (the account's ppnum_id ULID). The engine
/// calls this whenever the token carries an `sv` claim — Human session
/// tokens AND AI-agent client_credentials tokens minted via PAS's
/// `AgentService.IssueToken` (the AI-agent kill-switch). For tokens with
/// no `sv` (Token-Exchange delegated / exchange paths, legacy tokens) the
/// gate short-circuits before reaching this port.
#[async_trait::async_trait]
pub trait EpochRevocation: std::fmt::Debug + Send + Sync {
    async fn current(&self, sub: &str) -> Result<i64, EpochRevocationError>;
}

#[derive(Debug, thiserror::Error)]
pub enum EpochRevocationError {
    /// Substrate composition failure (cache miss + fetcher error,
    /// HTTP userinfo timeout, etc.). Engine maps to
    /// `AuthError::SessionVersionLookupUnavailable`.
    #[error("session_version lookup transient failure: {0}")]
    Transient(String),
}

/// Required typed stance on the sv epoch axis (RFC_202607150428 §7 Q3,
/// resolved 2026-07-17).
///
/// Every [`super::VerifyConfig`] carries exactly one of these — the
/// former `epoch: Option<Arc<dyn EpochRevocation>>` slot let four
/// shipped consumers silently skip the axis (`None` was both "not yet
/// wired" and "deliberately unwired", indistinguishable). Silence is
/// now unrepresentable: a verify site that does not enforce the axis
/// must SAY SO, in a variant whose name and payload are greppable and
/// whose boot declaration is visible in logs (STANDARDS_FAILURE_MODE
/// §4 — emission IS declaration).
///
/// ── Variant semantics ──────────────────────────────────────────────
///
/// - [`Enforced`](Self::Enforced) — the engine gates every verify
///   through the port (`check_epoch::run`): missing `sv` rejects
///   (`unstamped`, S6), stale `sv` rejects, substrate-down rejects
///   fail-closed. The only variant that reaches the port.
/// - [`EnforcedElsewhere`](Self::EnforcedElsewhere) — the axis IS
///   enforced on this request path, by a named gate outside this
///   engine call. Sole legitimate case today: PAS self-verify, where
///   `port.current(sub)` would self-loop into the same
///   `session_version_repo` that performs the bump, so the
///   `session::liveness` single gate checks `sv` AFTER verify
///   (`accounts-api/src/auth.rs`). Boot-declares at INFO — a correct
///   configuration must not cry wolf at WARN.
/// - [`Unenforced`](Self::Unenforced) — a declared, bounded gap: this
///   deployment has no epoch substrate (RCW/CTW: no KVRocks, separate
///   DB, published-SDK consumers — RFC_202607150428 §6·S5 deferral).
///   Boot-declares at WARN so the gap is permanently visible in logs.
///
/// The engine short-circuits (admits past the sv axis) for both named
/// non-`Enforced` variants — identical wire behavior to the retired
/// `None`, but the stance is now typed, named, and logged.
#[derive(Debug, Clone)]
pub enum EpochEnforcement {
    /// sv axis gated through the port on every verify (fail-closed).
    Enforced(std::sync::Arc<dyn EpochRevocation>),
    /// sv axis enforced by a NAMED gate elsewhere on the same request
    /// path (engine skips; the gate runs outside this verify call).
    EnforcedElsewhere {
        /// The gate that owns the check, e.g. `"session::liveness"`.
        gate: &'static str,
    },
    /// sv axis NOT enforced at this deployment — a declared, bounded
    /// gap. `declare()` WARNs at boot.
    Unenforced {
        /// Why the substrate is absent and what bounds the gap.
        reason: &'static str,
    },
}

impl EpochEnforcement {
    /// Boot-time declaration — call ONCE per long-lived verifier /
    /// perimeter, not per request. Emission IS declaration: the stance
    /// a face runs under must be readable from its boot log.
    pub fn declare(&self, face: &str) {
        match self {
            Self::Enforced(port) => tracing::info!(
                target: "ppoppo_token::revocation",
                face,
                port = ?port,
                "sv epoch axis ENFORCED via port"
            ),
            Self::EnforcedElsewhere { gate } => tracing::info!(
                target: "ppoppo_token::revocation",
                face,
                gate,
                "sv epoch axis enforced by external gate (engine skips)"
            ),
            Self::Unenforced { reason } => tracing::warn!(
                target: "ppoppo_token::revocation",
                face,
                reason,
                "sv epoch axis UNENFORCED — declared gap, no substrate"
            ),
        }
    }

    /// The port, iff this stance is [`Enforced`](Self::Enforced).
    /// `check_epoch::run` short-circuits on `None`.
    pub(crate) fn port(&self) -> Option<&std::sync::Arc<dyn EpochRevocation>> {
        match self {
            Self::Enforced(port) => Some(port),
            Self::EnforcedElsewhere { .. } | Self::Unenforced { .. } => None,
        }
    }
}