ppoppo-schema-constrained 0.24.0

Enum ↔ SQL CHECK anchor: compile-time exhaustiveness guard + DB-drift binding, shared by PAS (scaccounts) and PCS (scchat)
Documentation
//! **NOT a stable public API.** Engine-tier binding primitive — published to
//! crates.io only because the SDK closure requires it on the registry; 3rd
//! parties never name this crate. They meet the value-sets it binds through an
//! SDK product facade or a wire contract, never here.
//!
//! # Schema-Constrained Value-Sets (the enum ↔ `CHECK` anchor)
//!
//! A *schema-constrained* enum is a domain value-set whose members are also
//! enumerated by a PostgreSQL `CHECK (col IN (…))` constraint. The enum and the
//! constraint are two reifications of one fact ("the legal values of this
//! column"); left unbound they drift independently — a migration widens the
//! `CHECK`, or a variant is added, and the other side silently goes stale.
//!
//! This crate is the single seam that binds them. The same drift class exists
//! on both sides of the monorepo (PAS `scaccounts`, PCS `scchat`) and neither
//! core may depend on the other, so the binding primitive is hoisted out of
//! both — a pure, dependency-free trait + macro (`std::BTreeSet` only), which
//! both cores (that ban IO/transport crates) can depend on.
//!
//! ## Why engine tier and not `crates/shared/`
//!
//! It sat in `crates/shared/` (`publish = false`) until `RFC_202607252223`
//! T-03, which is when the placement was first *tested* rather than assumed:
//! `ppoppo-identity` needs to enroll its own `EntityType`, and `engine →
//! shared` is forbidden by the crate lattice (`xtask::policy::rules::taxonomy`)
//! — so the enrollment was unreachable, and the vocabulary had to keep a
//! second PAS-local enum alive just to carry it.
//!
//! The fix was to notice that the folder was wrong, not the lattice. Engine
//! tier means *published substrate that no 3rd party names* — a dependency-free
//! trait + macro consumed by two service cores and one vocabulary crate is
//! exactly that. The move corrected a misfile that predates the tier; it did
//! not trade a principle for convenience.
//!
//! ## The anchor triple (per `STS_SSOT_GOVERNANCE`)
//!
//! - **Owner**: the domain enum in `accounts-core` / `accounts-api` /
//!   `chat-core` (the closest reified form of the value-set decision).
//! - **Anchor**: domain-specific — these are tuned domain vocabularies with no
//!   external standard, so *this crate doc-comment is the anchor of record*
//!   (governance §4). (Formerly PAS
//!   `ADR_202605242324_schema-constrained-value-sets.md`, folded into
//!   `accounts-core` on its retirement, then hoisted here when PCS adopted the
//!   same gate.)
//! - **Verification**: [`bindings`](SchemaConstrained::bindings) feeds each
//!   service's `schema_check_drift.rs` DB test
//!   (`accounts-api/tests/` for `scaccounts`, `chat-api/tests/` for `scchat`),
//!   which reads each `CHECK` from the *materialized* schema
//!   (`pg_get_constraintdef`) and asserts set-equality with `ALL`. The
//!   compile-time half lives in the [`impl_schema_constrained!`] macro: it
//!   emits an exhaustive `match`, so adding a variant without listing it fails
//!   to build.
//!
//! ## Why a DB test and not a file parse
//!
//! The value-sets evolve through `ALTER … DROP/ADD CONSTRAINT` migrations (e.g.
//! `lifecycle_state` gained `tombstoned`; `oauth_audit_events.event_type`
//! gained `otp_issue`/`otp_verify`). The authoritative set is therefore the
//! *result of applying every migration*, which only the database knows —
//! parsing the baseline `.sql` would report phantom drift. Asking Postgres via
//! `pg_get_constraintdef` is the only correct anchor (and avoids the brittle
//! bespoke-parser ops-tax rejected in `STS_RATE_LIMITS_PPOPPO` §Anchor
//! "Rationale" option A).
//!
//! ## Two more rejected alternatives
//!
//! - **`#[sqlx::Type]` alone.** Binds the column *type* (text), not the
//!   `CHECK`'s value-set — a typo in the enum still compiles and the set still
//!   drifts. Complementary at the query boundary, not a substitute for the
//!   verification.
//! - **Accept drift under human review.** Leaves security-adjacent value-sets
//!   (audit taxonomy, lifecycle, step-up purpose) under governance §2's
//!   "aspiration, not enforcement" gate. Rejected.
//!
//! ## Caveat — the value-equality half is integration-tier
//!
//! The compile-time exhaustiveness guard covers the Rust side on every build.
//! The `ALL`-vs-`CHECK` set-equality half needs a live database, and there is
//! no CI job running DB-backed tests — so it bites via each service's
//! `just test-integration` and the `/deploy-ppoppo` pre-flight, not on a plain
//! `cargo test`.

#![deny(rust_2018_idioms)]
#![warn(missing_debug_implementations)]

use std::collections::BTreeSet;

/// One `(constraint, allowed-value-set)` pair, erased of the originating enum
/// type so the drift test can iterate heterogeneous value-sets.
#[derive(Debug, Clone)]
pub struct SchemaBinding {
    /// The `pg_constraint.conname` (e.g. `"ck_ppnums_entity_type_enum"`).
    pub constraint: &'static str,
    /// The DB-text values the owning enum permits — must equal the constraint's
    /// `IN (…)` set in the materialized schema.
    pub allowed: BTreeSet<&'static str>,
}

/// A domain value-set whose members are mirrored by one or more SQL `CHECK`
/// constraints.
///
/// Implemented via [`impl_schema_constrained!`]; never hand-written, so the
/// compile-time exhaustiveness guard is always emitted alongside.
pub trait SchemaConstrained: Sized + 'static {
    /// Every variant, in any order. The macro hand-lists these (no `strum`);
    /// the exhaustiveness guard makes an omission a build error and the DB test
    /// makes a stale list a pre-flight failure.
    const ALL: &'static [Self];

    /// The `CHECK` constraint(s) whose `IN (…)` set must equal
    /// `{ ALL.map(db_value) }`. More than one when several columns share the
    /// value-set (e.g. `lifecycle_state` on three columns).
    const CHECK_CONSTRAINTS: &'static [&'static str];

    /// The DB-text form of a variant — the literal stored in the column and
    /// named in the `CHECK`. Delegates to the enum's inherent `as_str` /
    /// `as_wire`.
    fn db_value(&self) -> &'static str;

    /// Erased `(constraint, allowed)` pairs for the drift test — one per entry
    /// in [`CHECK_CONSTRAINTS`](Self::CHECK_CONSTRAINTS).
    fn bindings() -> Vec<SchemaBinding> {
        let allowed: BTreeSet<&'static str> = Self::ALL.iter().map(Self::db_value).collect();
        Self::CHECK_CONSTRAINTS
            .iter()
            .map(|&constraint| SchemaBinding {
                constraint,
                allowed: allowed.clone(),
            })
            .collect()
    }
}

/// Implement [`SchemaConstrained`] for a unit-variant enum and emit a
/// compile-time exhaustiveness guard from the same variant list.
///
/// ```ignore
/// ppoppo_schema_constrained::impl_schema_constrained!(EntityType via as_str {
///     all: [Human, AiAgent, Enterprise, Programmable, Mask],
///     constraints: ["ck_ppnums_entity_type_enum"],
/// });
/// ```
///
/// `via $method` is the enum's inherent value accessor (`as_str` for most,
/// `as_db_str` / `as_wire` for others). Place the invocation next to the enum
/// so the constraint name lives *on the fact* (governance §6 carrier).
///
/// `non_stored` lists render-only variants that exist in the enum but are never
/// persisted (and so never appear in the `CHECK`) — e.g. `EntityType::Delegated`.
/// They are excluded from `ALL` yet still covered by the exhaustiveness guard,
/// so the asymmetry is declared, not hidden.
#[macro_export]
macro_rules! impl_schema_constrained {
    (
        $ty:ident via $method:ident {
            all: [ $( $variant:ident ),+ $(,)? ],
            $( non_stored: [ $( $ns:ident ),+ $(,)? ], )?
            constraints: [ $( $constraint:literal ),+ $(,)? ] $(,)?
        }
    ) => {
        impl $crate::SchemaConstrained for $ty {
            const ALL: &'static [Self] = &[ $( $ty::$variant ),+ ];
            const CHECK_CONSTRAINTS: &'static [&'static str] = &[ $( $constraint ),+ ];
            fn db_value(&self) -> &'static str {
                self.$method()
            }
        }

        // Compile-time half: if a variant is added to the enum but listed in
        // neither `all` nor `non_stored`, this match is non-exhaustive and the
        // build fails — pointing the author at the enrollment.
        const _: fn($ty) = |x| match x {
            $( $ty::$variant => () ),+
            $( , $( $ty::$ns => () ),+ )?
        };
    };
}