//! `AuditSink` port + `AuditEvent` value type + ship-with adapters
//! (`NoopAuditSink`, `MemoryAuditSink`).
//!
//! See module-level docs in `audit/mod.rs` for the deep-module rationale
//! (β1 + composition + non-blocking failure-mode contract).

use std::collections::BTreeMap;
#[cfg(any(test, feature = "test-support"))]
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

use super::RateLimitKey;

/// Audit emission port for verify-failure events (M48).
///
/// **One method, no `Result`**: M48 is observability, NOT auth-flow
/// critical. Adapters that fail to persist MUST log internally via
/// `tracing::error!` and continue — the verify hot path NEVER bubbles
/// audit failures into the auth contract. The trait surface enforces
/// this by giving the caller no error to propagate in the first place.
///
/// **`&self` not `&mut self`**: a single verifier emits concurrently
/// across many request handlers; interior mutability lives inside each
/// adapter (e.g. [`MemoryAuditSink`] uses `Mutex`).
///
/// **`Send + Sync` bounds**: required for `Arc<dyn AuditSink>` use in
/// Axum handlers and tokio tasks.
#[async_trait]
pub trait AuditSink: std::fmt::Debug + Send + Sync {
    async fn record_failure(&self, event: AuditEvent);
}

/// Single typed event emitted on every `BearerVerifier::verify` rejection.
///
/// `kind` drives audit-pivot grouping; `source_id` drives rate-limiting
/// and per-source dashboards; `metadata` carries free-form context
/// (engine M-row identifier for `Other`, claim names, etc.).
///
/// **Best-effort hint decoding**: `client_id_hint` and `kid_hint` come
/// from the rejected token's payload/header via defensive base64+JSON
/// parse. Either may be `None` if the token was malformed; callers MUST
/// NOT treat absence as a security signal — by definition the token
/// was rejected, so its claims are untrusted. The hints exist for
/// grouping, not authentication.
///
/// **`source_id` derivation**: per Phase 9 design call (e), source_id
/// is the compound `client_id_hint ‖ kid_hint` key. Anonymous /
/// kid-less rejections collapse into a canonical `"anon::nokid"`
/// bucket so attacker-controlled token mangling can't explode the
/// bucket count. See [`compose_source_id`] and [`AuditEvent::from_hints`].
///
/// All fields are `pub` so adapters can serialize them or pivot on
/// arbitrary subsets. The canonical construction path is
/// [`AuditEvent::from_hints`], which guarantees `source_id` matches
/// the hints. Hand-constructing with mismatched values is technically
/// possible (and useful for fault-injection in tests) but a code
/// review concern in production.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
    /// Failure classification — drives audit-pivot grouping.
    pub kind: VerifyErrorKind,
    /// Wall-clock at engine reject (UTC, RFC 3339 wire format).
    #[serde(with = "time::serde::rfc3339")]
    pub occurred_at: OffsetDateTime,
    /// Compound `client_id_hint ‖ kid_hint` key for rate-limiting +
    /// per-source pivot.
    pub source_id: String,
    /// Best-effort `client_id` claim from the rejected token's payload.
    pub client_id_hint: Option<String>,
    /// Best-effort `kid` from the rejected token's header.
    pub kid_hint: Option<String>,
    /// Free-form structured context — engine M-row identifier for
    /// `Other`, claim names for telemetry, etc. `BTreeMap` (not
    /// `HashMap`) for deterministic ordering in snapshot tests.
    pub metadata: BTreeMap<String, serde_json::Value>,
}

impl AuditEvent {
    /// Canonical constructor — composes `source_id` from the hints so
    /// the two never disagree. Production callers (Phase 9.D
    /// `PasJwtVerifier::verify`) use this.
    #[must_use]
    pub fn from_hints(
        kind: VerifyErrorKind,
        occurred_at: OffsetDateTime,
        client_id_hint: Option<String>,
        kid_hint: Option<String>,
        metadata: BTreeMap<String, serde_json::Value>,
    ) -> Self {
        let source_id = compose_source_id(client_id_hint.as_deref(), kid_hint.as_deref());
        Self {
            kind,
            occurred_at,
            source_id,
            client_id_hint,
            kid_hint,
            metadata,
        }
    }

    /// id_token-specific canonical constructor (Phase 10.11.D, δ2).
    ///
    /// Composes the 3-tuple `azp ‖ aud ‖ kid` source key — strongest
    /// per-source discrimination for log-flood DoS prevention on the
    /// RP side. azp (when present) is the canonical "authorized party";
    /// aud may be array (the engine surfaces only the first element to
    /// the hint pipeline); kid identifies the signing key.
    ///
    /// **Field repurpose**: stores `azp_hint` in
    /// [`Self::client_id_hint`] (the SDK-shaped "authorized party"
    /// shares semantic with access-token's `client_id`); pushes
    /// `aud_hint` into `metadata` under the key `"aud_hint"`. Dashboard
    /// pivots on the access-token side use `client_id_hint` directly;
    /// id_token pivots use the same field plus the `aud_hint` metadata
    /// entry.
    ///
    /// Production caller: [`crate::oidc::PasIdTokenVerifier::emit_failure`].
    #[must_use]
    pub fn from_id_token_hints(
        kind: VerifyErrorKind,
        occurred_at: OffsetDateTime,
        azp_hint: Option<String>,
        aud_hint: Option<String>,
        kid_hint: Option<String>,
        mut metadata: BTreeMap<String, serde_json::Value>,
    ) -> Self {
        let source_id = compose_id_token_source_id(
            azp_hint.as_deref(),
            aud_hint.as_deref(),
            kid_hint.as_deref(),
        );
        if let Some(aud) = &aud_hint {
            metadata.insert(
                "aud_hint".to_owned(),
                serde_json::Value::String(aud.clone()),
            );
        }
        Self {
            kind,
            occurred_at,
            source_id,
            client_id_hint: azp_hint,
            kid_hint,
            metadata,
        }
    }

    /// Per-bucket rate-limit key. By default 1:1 with `source_id`.
    /// Composing once at construction keeps this O(1).
    #[must_use]
    pub fn rate_limit_key(&self) -> RateLimitKey {
        RateLimitKey::new(self.source_id.clone())
    }
}

/// Compose a Phase 9 (e) compound source key from optional hints.
///
/// Free function (not a method) because it is referentially transparent:
/// same inputs → same output, no Self needed. `PasJwtVerifier::verify`
/// (Phase 9.D) calls this to derive the `source_id` field from a
/// best-effort token decode; tests use it to construct expected keys
/// without building an `AuditEvent` first.
///
/// Sentinels `"anon"` (missing client_id) and `"nokid"` (missing kid)
/// collapse anonymous rejections into a single bucket so attacker-
/// controlled token mangling can't explode bucket count. The `"::"`
/// separator is a deliberate choice for grep-friendliness ("all
/// anon::*" surfaces every anonymous bucket as a glob).
#[must_use]
pub fn compose_source_id(client_id_hint: Option<&str>, kid_hint: Option<&str>) -> String {
    let cid = client_id_hint.unwrap_or("anon");
    let kid = kid_hint.unwrap_or("nokid");
    format!("{cid}::{kid}")
}

/// Phase 10.11.D δ2 — id_token compound source key from `azp ‖ aud ‖ kid`.
///
/// Sibling of [`compose_source_id`] for the RP-side id_token verify
/// pipeline. Three components give strongest discrimination for
/// log-flood DoS prevention: the canonical authorized party (`azp`),
/// the audience (`aud`, first element when array), and the signing key
/// (`kid`). Sentinels `"anon"` / `"noaud"` / `"nokid"` collapse
/// anonymous rejections into canonical buckets.
///
/// Used by [`AuditEvent::from_id_token_hints`] to derive `source_id`;
/// boundary tests reference it to construct expected keys without
/// building a full event first.
#[must_use]
pub fn compose_id_token_source_id(
    azp_hint: Option<&str>,
    aud_hint: Option<&str>,
    kid_hint: Option<&str>,
) -> String {
    let azp = azp_hint.unwrap_or("anon");
    let aud = aud_hint.unwrap_or("noaud");
    let kid = kid_hint.unwrap_or("nokid");
    format!("{azp}::{aud}::{kid}")
}

/// Failure classification — mirrors the
/// [`VerifyError`](crate::token::VerifyError) and
/// [`IdVerifyError`](crate::oidc::IdVerifyError) surfaces but lives at
/// the audit layer.
///
/// Stable, low-cardinality enum suitable for audit-dashboard pivots.
/// `MissingClaim` carries the claim name as `String` (~6 well-known
/// claims in production: `"aud"`, `"iat"`, `"jti"`, `"sub"`, `"exp"`,
/// `"client_id"`) — still low-cardinality at the dashboard layer.
/// The String allocation (vs `&'static str` in
/// [`VerifyError::MissingClaim`](crate::token::VerifyError::MissingClaim))
/// is the cost of full serde round-trip support; per-event overhead
/// is negligible on the rare-failure emission path.
///
/// `Other` is a flat catch-all — engine M-row identifier goes into
/// [`AuditEvent::metadata`] under the key `"engine_msg"` to keep this
/// enum bounded.
///
/// ── id_token nesting (Phase 10.11.B) ───────────────────────────────────
///
/// OIDC-specific failure rows (M66-M73 + M29-mirror) live inside
/// [`IdTokenFailureKind`] under a single nested variant `IdToken(_)`.
/// The audit-pivot pattern callers use:
///
/// ```ignore
/// match event.kind {
///     VerifyErrorKind::IdToken(_) => bucket_for("id_token failures"),
///     VerifyErrorKind::IdTokenAsBearer => bucket_for("M73 misuse"),
///     VerifyErrorKind::Expired => bucket_for("token expired"),
///     // ...
/// }
/// ```
///
/// A single `IdToken(_)` arm filters the entire OIDC failure family —
/// dashboards do not need to enumerate 14 names. Profile-agnostic JOSE
/// failures (`Expired`, `IssuerInvalid`, `AudienceInvalid`,
/// `MissingClaim`, `SignatureInvalid`, `KeysetUnavailable`,
/// `InvalidFormat`) reuse the existing flat variants — they are shared
/// between access_token and id_token verify surfaces, and forcing
/// consumers to write a 2nd match arm for "id_token expired"
/// distinct from "access_token expired" would noise the call site
/// without changing the operator action ("token expired, refresh").
/// The audit log distinguishes the two via [`AuditEvent::source_id`]
/// composition (compound `azp ‖ aud ‖ kid` for id_token vs
/// `client_id ‖ kid` for access).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "tag", content = "claim")]
pub enum VerifyErrorKind {
    InvalidFormat,
    SignatureInvalid,
    Expired,
    IssuerInvalid,
    AudienceInvalid,
    MissingClaim(String),
    KeysetUnavailable,
    IdTokenAsBearer,
    /// Phase 10.11.B — nested OIDC-specific failure family. See
    /// [`IdTokenFailureKind`].
    IdToken(IdTokenFailureKind),
    /// Phase 11.Z — token's `sv` claim below authoritative substrate
    /// (engine `check_epoch` reject). Distinct from `Expired`: stale
    /// is revocation-driven, expired is `exp`-bound. Audit dashboards
    /// pivot on this kind to surface break-glass propagation lag.
    SessionVersionStale,
    /// Phase 11.Z — engine `check_epoch` could not reach its substrate
    /// (cache miss + fetcher transient). Fail-closed surface; audit
    /// dashboards pivot on this kind to surface substrate health
    /// problems distinct from cryptographic failure.
    SessionVersionLookupUnavailable,
    /// Phase 11.Z 0.10.0 (RFC_2026-05-08 §4.2) — L2 session liveness
    /// reject. Token's `sid` resolved to a row absent OR with
    /// `revoked_at` set. Distinct from `SessionVersionStale` (L1):
    /// L2 = consumer-DB row revocation; L1 = cross-service break-glass
    /// propagation. Pivots help operators distinguish per-session
    /// logout traffic from cluster-wide break-glass bumps.
    SessionRevoked,
    /// Phase 11.Z 0.10.0 — L2 session liveness substrate unreachable.
    /// Fail-closed surface; audit dashboards pivot on this kind to
    /// surface consumer-DB substrate health problems distinct from
    /// `SessionVersionLookupUnavailable` (L1 substrate health).
    SessionLivenessLookupUnavailable,
    Other,
}

/// id_token-specific failure classification (Phase 10.11.B).
///
/// Mirrors the OIDC-specific variants of
/// [`IdVerifyError`](crate::oidc::IdVerifyError). Nests inside
/// [`VerifyErrorKind::IdToken`] so dashboard pivots can filter "all
/// id_token failures" via a single match arm.
///
/// Variants in this enum cover M66-M73 + M29-mirror — JOSE-layer
/// rejections (Expired, SignatureInvalid, etc.) are *shared* with
/// access_token and remain on the outer [`VerifyErrorKind`] flat
/// variants. The boundary mapping (`From<&IdVerifyError> for
/// VerifyErrorKind`, lands in Phase 10.11.D) routes JOSE rejections to
/// the flat variants and id_token-specific rejections to
/// `IdToken(IdTokenFailureKind::*)`.
///
/// Two payload-carrying variants:
///
/// - `UnknownClaim(String)` — engine M72 carries the offending claim
///   name (e.g. `"backdoor"`, `"email"` at Openid scope). Audit logs
///   distinguish forgery (random key) from issuer drift (legitimate
///   OIDC claim outside scope) by reading the inner string.
/// - `CatMismatch(String)` — engine M29-mirror carries the offending
///   `cat` value (e.g. `"access"` for an attacker presenting a forged
///   id_token; `""` for a stripped-claims forgery; arbitrary string
///   for bespoke forgery).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IdTokenFailureKind {
    /// M66 — `nonce` claim absent.
    NonceMissing,
    /// M66 — `nonce` claim present but does not match expected.
    NonceMismatch,
    /// M67 — `at_hash` claim absent while access_token binding is configured.
    AtHashMissing,
    /// M67 — `at_hash` claim present but does not match.
    AtHashMismatch,
    /// M68 — `c_hash` claim absent while authorization_code binding is configured.
    CHashMissing,
    /// M68 — `c_hash` claim present but does not match.
    CHashMismatch,
    /// M69 — `azp` claim absent on multi-aud id_token.
    AzpMissing,
    /// M69 — `azp` claim present but does not equal expected client_id.
    AzpMismatch,
    /// M70 — `auth_time` claim absent while max_age is configured.
    AuthTimeMissing,
    /// M70 — `now - auth_time > max_age`.
    AuthTimeStale,
    /// M71 — `acr` claim absent while acr_values is configured.
    AcrMissing,
    /// M71 — `acr` claim present but not in allowlist.
    AcrNotAllowed,
    /// M72 — claim name outside per-scope allowlist. Carries name.
    UnknownClaim(String),
    /// M29-mirror — `cat` claim value is not `"id"`. Carries the
    /// offending value.
    CatMismatch(String),
}

/// Default sink — explicitly does nothing.
///
/// Used when a consumer constructs `PasJwtVerifier::from_jwks_url`
/// without calling `with_audit`. Named "Noop" (not "Empty" / "Null")
/// so call sites read as an explicit choice. Cheap to clone; carries
/// no state.
#[derive(Debug, Default, Clone)]
pub struct NoopAuditSink;

#[async_trait]
impl AuditSink for NoopAuditSink {
    async fn record_failure(&self, _event: AuditEvent) {}
}

/// Test-support sink — accumulates events in insertion order behind a
/// `Mutex`.
///
/// Test code calls [`MemoryAuditSink::events`] to assert ordering and
/// contents. The `Arc<Mutex<Vec<...>>>` shape is deliberate:
/// [`AuditSink::record_failure`] takes `&self`, so interior mutability
/// is required; `Mutex` (rather than `RwLock`) is right because every
/// access is a mutation.
///
/// Uses `std::sync::Mutex` rather than `tokio::sync::Mutex` so the
/// adapter compiles unconditionally — no tokio runtime required when
/// downstream consumers enable just `test-support` without `oauth` /
/// `well-known-fetch`. This is safe because no `.await` happens while
/// the lock is held; the future stays `Send`.
#[cfg(any(test, feature = "test-support"))]
#[derive(Debug, Default, Clone)]
pub struct MemoryAuditSink {
    events: Arc<Mutex<Vec<AuditEvent>>>,
}

#[cfg(any(test, feature = "test-support"))]
impl MemoryAuditSink {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Snapshot the recorded events. Returns a clone so the caller
    /// doesn't hold the lock during assertion logic. `AuditEvent` is
    /// shallow-cloneable; copying the whole vec is cheap for test
    /// volumes.
    pub fn events(&self) -> Vec<AuditEvent> {
        self.events
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .clone()
    }

    /// Convenience: count without cloning the vec.
    pub fn len(&self) -> usize {
        self.events
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .len()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

#[cfg(any(test, feature = "test-support"))]
#[async_trait]
impl AuditSink for MemoryAuditSink {
    async fn record_failure(&self, event: AuditEvent) {
        let mut events = self
            .events
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        events.push(event);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fixture(kind: VerifyErrorKind, source_id: &str) -> AuditEvent {
        AuditEvent {
            kind,
            occurred_at: OffsetDateTime::UNIX_EPOCH,
            source_id: source_id.to_owned(),
            client_id_hint: None,
            kid_hint: None,
            metadata: BTreeMap::new(),
        }
    }

    #[test]
    fn compose_source_id_uses_compound_separator() {
        assert_eq!(
            compose_source_id(Some("rcw-client"), Some("k1")),
            "rcw-client::k1"
        );
    }

    #[test]
    fn compose_source_id_collapses_anonymous_into_canonical_bucket() {
        // Phase 9 (e): missing client_id and missing kid both collapse
        // to canonical sentinels so attacker-controlled token mangling
        // can't explode the bucket count.
        assert_eq!(compose_source_id(None, None), "anon::nokid");
    }

    #[test]
    fn compose_id_token_source_id_uses_three_tuple_separator() {
        // δ2 — `azp ‖ aud ‖ kid`. Three-component key gives strongest
        // discrimination for RP-side id_token DoS prevention.
        assert_eq!(
            compose_id_token_source_id(Some("rp-id"), Some("rp-aud"), Some("k1")),
            "rp-id::rp-aud::k1"
        );
    }

    #[test]
    fn compose_id_token_source_id_collapses_anonymous_into_canonical() {
        assert_eq!(
            compose_id_token_source_id(None, None, None),
            "anon::noaud::nokid"
        );
    }

    #[test]
    fn compose_id_token_source_id_partial_anonymity() {
        // azp absent but aud present — common when the IdP omits azp
        // on a single-aud id_token (OIDC §2 SHOULD, not MUST).
        assert_eq!(
            compose_id_token_source_id(None, Some("rp-aud"), Some("k1")),
            "anon::rp-aud::k1"
        );
        // azp present, aud absent (multi-aud token where the engine
        // failed to surface either element) — degenerate but possible.
        assert_eq!(
            compose_id_token_source_id(Some("rp-id"), None, Some("k1")),
            "rp-id::noaud::k1"
        );
    }

    #[test]
    fn from_id_token_hints_derives_three_tuple_source_id_and_pushes_aud_into_metadata() {
        let event = AuditEvent::from_id_token_hints(
            VerifyErrorKind::IdToken(IdTokenFailureKind::NonceMismatch),
            OffsetDateTime::UNIX_EPOCH,
            Some("rp-id".to_owned()),
            Some("rp-aud".to_owned()),
            Some("k1".to_owned()),
            BTreeMap::new(),
        );
        assert_eq!(event.source_id, "rp-id::rp-aud::k1");
        assert_eq!(event.client_id_hint.as_deref(), Some("rp-id"));
        assert_eq!(event.kid_hint.as_deref(), Some("k1"));
        assert_eq!(
            event
                .metadata
                .get("aud_hint")
                .and_then(|v| v.as_str()),
            Some("rp-aud")
        );
    }

    #[test]
    fn compose_source_id_partial_anonymity() {
        assert_eq!(compose_source_id(None, Some("k1")), "anon::k1");
        assert_eq!(compose_source_id(Some("rcw"), None), "rcw::nokid");
    }

    #[test]
    fn from_hints_derives_source_id_from_hints() {
        let event = AuditEvent::from_hints(
            VerifyErrorKind::SignatureInvalid,
            OffsetDateTime::UNIX_EPOCH,
            Some("rcw".to_owned()),
            Some("k1".to_owned()),
            BTreeMap::new(),
        );
        assert_eq!(event.source_id, "rcw::k1");
        assert_eq!(event.client_id_hint.as_deref(), Some("rcw"));
        assert_eq!(event.kid_hint.as_deref(), Some("k1"));
    }

    #[test]
    fn rate_limit_key_round_trip() {
        let event = fixture(VerifyErrorKind::SignatureInvalid, "rcw::k1");
        assert_eq!(event.rate_limit_key().as_str(), "rcw::k1");
    }

    #[test]
    #[allow(clippy::expect_used)]
    fn verify_error_kind_round_trips_through_serde() {
        let kind = VerifyErrorKind::MissingClaim("aud".to_owned());
        let json = serde_json::to_string(&kind).expect("serialize");
        let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(kind, back);
    }

    #[test]
    #[allow(clippy::expect_used)]
    fn verify_error_kind_id_token_variants_round_trip_through_serde() {
        // Unit nested variant — wire shape:
        // {"tag": "IdToken", "claim": "NonceMissing"}.
        let unit = VerifyErrorKind::IdToken(IdTokenFailureKind::NonceMissing);
        let json = serde_json::to_string(&unit).expect("serialize unit");
        let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize unit");
        assert_eq!(unit, back);

        // Payloaded nested variant — wire shape:
        // {"tag": "IdToken", "claim": {"UnknownClaim": "backdoor"}}.
        let payload = VerifyErrorKind::IdToken(IdTokenFailureKind::UnknownClaim(
            "backdoor".to_owned(),
        ));
        let json = serde_json::to_string(&payload).expect("serialize payload");
        let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize payload");
        assert_eq!(payload, back);

        // CatMismatch with empty value — engine signals stripped-claims
        // forgery this way; round-trip must preserve the empty string.
        let cat = VerifyErrorKind::IdToken(IdTokenFailureKind::CatMismatch(String::new()));
        let json = serde_json::to_string(&cat).expect("serialize cat");
        let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize cat");
        assert_eq!(cat, back);
    }

    #[tokio::test]
    async fn noop_sink_is_a_no_op() {
        let sink = NoopAuditSink;
        let event = fixture(VerifyErrorKind::Expired, "x");
        // Contract: returns without panic. Nothing observable to assert.
        sink.record_failure(event).await;
    }

    #[tokio::test]
    async fn memory_sink_records_events_in_insertion_order() {
        let sink = MemoryAuditSink::new();
        sink.record_failure(fixture(VerifyErrorKind::Expired, "a"))
            .await;
        sink.record_failure(fixture(VerifyErrorKind::SignatureInvalid, "b"))
            .await;
        sink.record_failure(fixture(VerifyErrorKind::IdTokenAsBearer, "c"))
            .await;

        let events = sink.events();
        assert_eq!(events.len(), 3);
        assert_eq!(events[0].kind, VerifyErrorKind::Expired);
        assert_eq!(events[1].kind, VerifyErrorKind::SignatureInvalid);
        assert_eq!(events[2].kind, VerifyErrorKind::IdTokenAsBearer);
        assert_eq!(events[0].source_id, "a");
    }

    #[tokio::test]
    async fn memory_sink_is_empty_initially() {
        let sink = MemoryAuditSink::new();
        assert!(sink.is_empty());
        sink.record_failure(fixture(VerifyErrorKind::Other, "x"))
            .await;
        assert_eq!(sink.len(), 1);
    }

    /// Compile-time guard: MemoryAuditSink + NoopAuditSink must be
    /// usable behind `Arc<dyn AuditSink>`. If a future change adds a
    /// generic method to `AuditSink`, object-safety regresses and this
    /// fails to compile.
    #[allow(dead_code)]
    fn dyn_object_safety() {
        let _: Arc<dyn AuditSink> = Arc::new(NoopAuditSink);
        let _: Arc<dyn AuditSink> = Arc::new(MemoryAuditSink::new());
    }
}
