ppoppo-identity 0.31.0

Principal-identity vocabulary for the ppoppo ecosystem — the Ppnum/PpnumId pair, the EntityType, LifecycleState and OAuth Scope value-sets, and the admin predicate, shared by the token engine, both services and every SDK
Documentation
//! Ppnum lifecycle — the `scaccounts.ppnums.lifecycle_state` value-set and its
//! transition lattice. Private module; [`LifecycleState`] is re-exported at the
//! crate root, which is the only public path. **Why it lives in this crate
//! rather than in `accounts-core`: see the crate docs, §`LifecycleState`.**

use crate::EntityType;

/// Ppnum lifecycle state machine.
///
/// ```text
/// Available → Reserved → Active → Suspended → Active
///                │          │         │
///                │          │         └→ Deactivated → Quarantined → Available
///                │          │                    │
///                │          │                    └→ Tombstoned   (terminal, sweeper after 30d)
///                │          │
///                │          └→ Deactivated → Quarantined → Available
///                │          └→ Expired → Available           (Mask only)
//////                └→ Available (release reservation)
/// ```
///
/// **8 variants — the DB set, not the proto's 7.** The 1st-party wire
/// `LifecycleState` proto has no `tombstoned`; PCS's wire converters project
/// [`Tombstoned`](Self::Tombstoned) to `Unspecified`. Those projections carry no
/// wildcard, so a 9th PAS-side state breaks compilation rather than silently
/// inheriting another arm.
///
/// The `serde` impls are **feature-gated and off by default**, exactly as on
/// [`EntityType`] and for the same reason: PCS caches `PpnumAccount` as JSON in
/// KVRocks and needs the round-trip, nothing else does, and gating is what keeps
/// this crate free of external dependencies. The wire form is
/// `rename_all = "snake_case"`, byte-identical to [`as_str`](Self::as_str) — a
/// test below pins that, because a divergence would mint a second spelling of
/// the DB strings and hard-error every cached account until its TTL expired.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum LifecycleState {
    /// Number exists in pool but is not assigned.
    Available,
    /// Temporarily held for a pending registration.
    Reserved,
    /// In active use by an entity.
    Active,
    /// Temporarily disabled (e.g., abuse, billing).
    Suspended,
    /// Permanently disabled by owner or admin.
    Deactivated,
    /// Cooling-off period before recycling.
    Quarantined,
    /// Time-based expiration. Reachable only by [`EntityType::Mask`] — the
    /// table-level `ck_ppnums_expired_only_mask` check constraint mirrors this
    /// rule at the DB layer, and
    /// [`can_transition_for_entity`](Self::can_transition_for_entity) applies it
    /// in memory.
    Expired,
    /// Terminal state set by the tombstone sweeper 30 days after Deactivated.
    /// Once Tombstoned, `ppnum_humans` is hard-deleted (DPR §5 PII
    /// minimization). No further transitions are permitted from this state.
    Tombstoned,
}

// SSOT binding: this value-set must equal all three `lifecycle_state` CHECKs
// (the `ppnums` column plus the `ppnum_lifecycle_events` from/to audit columns).
// Verified against the *materialized* schema by
// `accounts-api/tests/schema_check_drift.rs` — the set has already moved once by
// ALTER (`tombstoned` was added), which is why the anchor is the database rather
// than a baseline parse.
ppoppo_schema_constrained::impl_schema_constrained!(LifecycleState via as_str {
    all: [
        Available, Reserved, Active, Suspended,
        Deactivated, Quarantined, Expired, Tombstoned,
    ],
    constraints: [
        "ck_ppnums_lifecycle_state_enum",
        "ck_ppnum_lifecycle_events_from_state_enum",
        "ck_ppnum_lifecycle_events_to_state_enum",
    ],
});

impl LifecycleState {
    /// Every variant, in DB-lattice order.
    pub const ALL: [LifecycleState; 8] = [
        Self::Available,
        Self::Reserved,
        Self::Active,
        Self::Suspended,
        Self::Deactivated,
        Self::Quarantined,
        Self::Expired,
        Self::Tombstoned,
    ];

    /// Canonical DB/wire string — the literal stored in
    /// `scaccounts.ppnums.lifecycle_state` and named in the `CHECK`.
    ///
    /// **The only place these strings are spelled.** [`parse`](Self::parse)
    /// reads through here rather than hand-listing them again, so the two
    /// directions cannot disagree.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Available => "available",
            Self::Reserved => "reserved",
            Self::Active => "active",
            Self::Suspended => "suspended",
            Self::Deactivated => "deactivated",
            Self::Quarantined => "quarantined",
            Self::Expired => "expired",
            Self::Tombstoned => "tombstoned",
        }
    }

    /// Parse a `lifecycle_state` string. Exact inverse of
    /// [`as_str`](Self::as_str), derived from it rather than restated.
    ///
    /// **SSOT for the string→state parse** on both organs — PAS's row mappers
    /// and PCS's accounts-adapter boundary route here, so a 9th state is added
    /// in exactly one place.
    ///
    /// `None` = outside the value-set. The fail-direction is the *caller's*:
    /// display degrades to no-state (fail-open), a liveness check treats `None`
    /// as not-active (fail-safe), and a row mapper on the PAS side rejects the
    /// row outright. A value the `CHECK` admits but this type cannot name is
    /// value-set drift, not a routine degrade.
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|st| st.as_str() == s)
    }

    /// Whether this state is the single live state. **SSOT for the liveness
    /// bit** — only [`Active`](Self::Active) is live; every other state
    /// (including `Available`/`Reserved`, which are pre-activation) reads
    /// `false`.
    #[must_use]
    pub const fn is_active(self) -> bool {
        matches!(self, Self::Active)
    }

    /// Valid target states from this state (entity-agnostic).
    #[must_use]
    pub const fn valid_transitions(self) -> &'static [LifecycleState] {
        match self {
            Self::Available => &[Self::Reserved],
            Self::Reserved => &[Self::Available, Self::Active],
            Self::Active => &[Self::Suspended, Self::Deactivated, Self::Expired],
            Self::Suspended => &[Self::Active, Self::Deactivated],
            Self::Deactivated => &[Self::Quarantined, Self::Tombstoned],
            Self::Quarantined => &[Self::Available],
            Self::Expired => &[Self::Available],
            Self::Tombstoned => &[],
        }
    }

    /// Raw state-machine check: is `target` reachable from `self` by a single
    /// transition? Does NOT take entity_type into account. Use
    /// [`can_transition_for_entity`](Self::can_transition_for_entity) when the
    /// caller knows the entity_type and wants the stricter
    /// `Expired`-only-for-`Mask` rule applied.
    #[must_use]
    pub fn can_transition_to(self, target: Self) -> bool {
        self.valid_transitions().contains(&target)
    }

    /// Entity-type-aware transition check.
    ///
    /// Layers the [`Expired`](Self::Expired) restriction on top of the raw state
    /// machine: only [`EntityType::Mask`] may enter `Expired`. Every other
    /// transition obeys [`valid_transitions`](Self::valid_transitions)
    /// unchanged.
    ///
    /// Mirrors the DB-layer `ck_ppnums_expired_only_mask` check constraint so
    /// application code refuses the same moves the database would have rejected
    /// at commit time. **This is the cross-fact that puts the state machine in
    /// this crate** — it is a statement about both vocabularies at once, and
    /// only a crate owning both can state it once.
    #[must_use]
    pub fn can_transition_for_entity(self, target: Self, entity_type: EntityType) -> bool {
        if !self.can_transition_to(target) {
            return false;
        }
        if target == Self::Expired && entity_type != EntityType::Mask {
            return false;
        }
        true
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
    use super::*;

    #[test]
    fn available_allows_reserved_only() {
        assert!(LifecycleState::Available.can_transition_to(LifecycleState::Reserved));
        assert!(!LifecycleState::Available.can_transition_to(LifecycleState::Active));
    }

    #[test]
    fn reserved_transitions() {
        assert!(LifecycleState::Reserved.can_transition_to(LifecycleState::Available));
        assert!(LifecycleState::Reserved.can_transition_to(LifecycleState::Active));
        assert!(!LifecycleState::Reserved.can_transition_to(LifecycleState::Suspended));
    }

    #[test]
    fn active_transitions() {
        assert!(LifecycleState::Active.can_transition_to(LifecycleState::Suspended));
        assert!(LifecycleState::Active.can_transition_to(LifecycleState::Deactivated));
        assert!(LifecycleState::Active.can_transition_to(LifecycleState::Expired));
        assert!(!LifecycleState::Active.can_transition_to(LifecycleState::Quarantined));
    }

    #[test]
    fn suspended_transitions() {
        assert!(LifecycleState::Suspended.can_transition_to(LifecycleState::Active));
        assert!(LifecycleState::Suspended.can_transition_to(LifecycleState::Deactivated));
        assert!(!LifecycleState::Suspended.can_transition_to(LifecycleState::Expired));
    }

    #[test]
    fn deactivated_transitions() {
        assert!(LifecycleState::Deactivated.can_transition_to(LifecycleState::Quarantined));
        assert!(!LifecycleState::Deactivated.can_transition_to(LifecycleState::Active));
    }

    #[test]
    fn quarantined_transitions() {
        assert!(LifecycleState::Quarantined.can_transition_to(LifecycleState::Available));
        assert!(!LifecycleState::Quarantined.can_transition_to(LifecycleState::Active));
    }

    #[test]
    fn expired_transitions() {
        assert!(LifecycleState::Expired.can_transition_to(LifecycleState::Available));
        assert!(!LifecycleState::Expired.can_transition_to(LifecycleState::Active));
    }

    #[test]
    fn deactivated_can_transition_to_tombstoned() {
        assert!(LifecycleState::Deactivated.can_transition_to(LifecycleState::Tombstoned));
        // Quarantined path still works.
        assert!(LifecycleState::Deactivated.can_transition_to(LifecycleState::Quarantined));
        assert!(!LifecycleState::Deactivated.can_transition_to(LifecycleState::Active));
    }

    #[test]
    fn tombstoned_is_terminal() {
        assert_eq!(LifecycleState::Tombstoned.valid_transitions(), &[]);
        assert!(!LifecycleState::Tombstoned.can_transition_to(LifecycleState::Available));
        assert!(!LifecycleState::Tombstoned.can_transition_to(LifecycleState::Deactivated));
    }

    #[test]
    fn parse_is_the_inverse_of_as_str() {
        for state in LifecycleState::ALL {
            assert_eq!(LifecycleState::parse(state.as_str()), Some(state));
        }
    }

    #[test]
    fn unknown_strings_do_not_parse() {
        for s in ["", "legacy_unknown", "ACTIVE", "active ", "unspecified"] {
            assert_eq!(LifecycleState::parse(s), None, "{s:?} must not parse");
        }
    }

    #[test]
    fn active_is_the_only_live_state() {
        for state in LifecycleState::ALL {
            assert_eq!(state.is_active(), state == LifecycleState::Active);
        }
    }

    #[test]
    fn entity_aware_refuses_expired_for_non_mask() {
        // Every non-mask entity_type cannot cross Active → Expired.
        for entity in EntityType::ALL {
            if entity == EntityType::Mask {
                continue;
            }
            assert!(
                !LifecycleState::Active.can_transition_for_entity(LifecycleState::Expired, entity),
                "{entity:?} should not be eligible for Expired"
            );
        }
    }

    #[test]
    fn entity_aware_allows_expired_for_mask() {
        assert!(
            LifecycleState::Active
                .can_transition_for_entity(LifecycleState::Expired, EntityType::Mask)
        );
    }

    #[test]
    fn entity_aware_preserves_other_transitions() {
        // Non-expired transitions are unchanged by the entity-aware wrapper —
        // e.g. Human can still be Suspended.
        for entity in EntityType::ALL {
            assert!(
                LifecycleState::Active.can_transition_for_entity(LifecycleState::Suspended, entity)
            );
            assert!(
                LifecycleState::Active
                    .can_transition_for_entity(LifecycleState::Deactivated, entity)
            );
            assert!(
                !LifecycleState::Active.can_transition_for_entity(LifecycleState::Reserved, entity)
            );
        }
    }

    /// The `serde` derive must encode exactly [`LifecycleState::as_str`] — the
    /// twin of `EntityType`'s pin, and load-bearing for the same reason: PCS
    /// caches `PpnumAccount` as JSON in KVRocks and `get_typed` propagates a
    /// deserialize failure rather than treating it as a miss, so an encoding
    /// change would turn every cached account into a hard error until its TTL
    /// expired. The retired `chat_core::port::LifecycleState` encoded via
    /// `rename_all = "snake_case"` over the same eight variants; this pins that
    /// the replacement is byte-identical, so entries written before the collapse
    /// still read back.
    #[cfg(feature = "serde")]
    #[test]
    fn serde_encoding_is_exactly_as_str() {
        for state in LifecycleState::ALL {
            let json = serde_json::to_string(&state).expect("serialize");
            assert_eq!(
                json,
                format!("\"{}\"", state.as_str()),
                "the serde derive drifted from `as_str` for {state:?} — this \
                 breaks every JSON-cached PpnumAccount and mints a second \
                 spelling of the DB strings"
            );
            assert_eq!(
                serde_json::from_str::<LifecycleState>(&json).expect("deserialize"),
                state,
            );
        }
    }
}