ppoppo-sdk-core 0.1.1

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! M48 + M49 — verify-failure audit emission port + per-source rate
//! limiter (RFC_2026-05-04_jwt-full-adoption Phase 9).
//!
//! ── Why pas-external owns the port, not the schema (β1) ─────────────────
//!
//! [`AuditSink`] is purely abstract. pas-external ships only:
//!
//! - [`NoopAuditSink`] — explicit "I don't want audit emission" choice
//! - `MemoryAuditSink` — `test-support`-gated adapter for boundary
//!   verification (downstream consumers' integration tests)
//!
//! Production adapters (chat-auth in PCS, future RCW/CTW middleware)
//! live in their own crates and decide their own persistence schema
//! (Postgres table, tracing-subscriber piping into Cloud Logging, etc).
//! The SDK does not bake in `sqlx` or any specific schema — schema
//! decisions belong to whichever service operates the audit pipeline.
//! See [`super::token::port::BearerVerifier`] for the matching
//! port-and-adapter precedent (D-04 γ, locked 2026-05-05).
//!
//! ── Why composition, not orchestration (refinement #1) ─────────────────
//!
//! [`super::token::PasJwtVerifier`] holds ONE port (`Arc<dyn AuditSink>`),
//! not two. Rate-limiting is a property of the sink, expressed by
//! wrapping any sink in a future `RateLimitedAuditSink<S, L>` (Phase
//! 9.C). This matches `epoch_revocation`'s deep-module note:
//! composition lives in the adapter layer; the engine sees a single
//! port. Future stacking is free (`BatchedAuditSink`,
//! `AsyncSpawnAuditSink`, etc).
//!
//! ── Failure-mode contract — non-blocking ────────────────────────────────
//!
//! [`AuditSink::record_failure`] returns `()` (no [`Result`]). M48 is
//! observability, NOT auth-flow critical. Adapters log internal substrate
//! failures via `tracing::error!` and continue. The verify hot path
//! MUST NEVER degrade because audit persistence failed; this contract
//! is enforced at the trait surface (no error to bubble in the first
//! place). Callers needing a Result for instrumentation can wrap in a
//! private struct that records the result internally.
//!
//! ── SLA contract ────────────────────────────────────────────────────────
//!
//! Implementations SHOULD return within 10ms. Heavier work (HTTP
//! roundtrip, batch flush, retry) MUST be spawned onto a background
//! task so the verify hot path is not blocked. The `&self` (not
//! `&mut self`) + `Send + Sync` bounds let one verifier emit
//! concurrently without per-call locking.
//!
//! ── Phase 10 inheritance ────────────────────────────────────────────────
//!
//! Phase 10.11 (RP middleware — `pas-external::oidc::IdTokenVerifier`)
//! emits through the same [`AuditSink`] port. id_token verify failures
//! and access_token verify failures share the audit pipeline; the
//! [`VerifyErrorKind`] enum gains id_token-specific variants in 10.11
//! without breaking the contract.

pub mod rate_limit;
pub mod rate_limited_sink;
pub mod sink;

pub use rate_limit::{MemoryRateLimiter, RateLimiter};
pub use rate_limited_sink::RateLimitedAuditSink;
pub use sink::{
    AuditEvent, AuditSink, IdTokenFailureKind, NoopAuditSink, VerifyErrorKind,
    compose_id_token_source_id, compose_source_id,
};

#[cfg(any(test, feature = "test-support"))]
pub use sink::MemoryAuditSink;

/// Opaque per-source bucket key for a `RateLimiter`.
///
/// Wraps a `String` so the underlying derivation strategy stays a hidden
/// implementation detail of whoever builds the key.
/// [`AuditEvent::rate_limit_key`] derives compound `client_id_hint ‖
/// kid_hint` keys per Phase 9 design call (e); future callers (Phase
/// 10.11 nonce-store, OAuth callback PKCE throttle, etc) compose their
/// own keys from substrate-relevant fields.
///
/// Newtype (rather than passing `&str` through the limiter trait) lets
/// the type system prove that callers used the correct derivation —
/// stringly-typed keys would silently re-bucket on stray formatting
/// drift (whitespace, casing, separator choice).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RateLimitKey(String);

impl RateLimitKey {
    /// Construct from any string-like source.
    #[must_use]
    pub fn new(key: impl Into<String>) -> Self {
        Self(key.into())
    }

    /// Borrow the underlying string. Limiter substrates need this to
    /// build their backend key (HashMap entry, Redis key, etc).
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl From<&str> for RateLimitKey {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

impl From<String> for RateLimitKey {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl std::fmt::Display for RateLimitKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

#[cfg(test)]
mod rate_limit_key_tests {
    use super::RateLimitKey;

    #[test]
    fn display_round_trips_through_as_str() {
        let key = RateLimitKey::new("rcw-client::k1");
        assert_eq!(key.as_str(), "rcw-client::k1");
        assert_eq!(format!("{key}"), "rcw-client::k1");
    }

    #[test]
    fn equal_keys_hash_equal() {
        use std::collections::HashSet;
        let mut set = HashSet::new();
        set.insert(RateLimitKey::from("rcw::k1"));
        set.insert(RateLimitKey::from("rcw::k1".to_owned()));
        assert_eq!(set.len(), 1, "From<&str> and From<String> must produce equal keys");
    }
}