Skip to main content

ppoppo_schema_constrained/
lib.rs

1//! **NOT a stable public API.** Engine-tier binding primitive — published to
2//! crates.io only because the SDK closure requires it on the registry; 3rd
3//! parties never name this crate. They meet the value-sets it binds through an
4//! SDK product facade or a wire contract, never here.
5//!
6//! # Schema-Constrained Value-Sets (the enum ↔ `CHECK` anchor)
7//!
8//! A *schema-constrained* enum is a domain value-set whose members are also
9//! enumerated by a PostgreSQL `CHECK (col IN (…))` constraint. The enum and the
10//! constraint are two reifications of one fact ("the legal values of this
11//! column"); left unbound they drift independently — a migration widens the
12//! `CHECK`, or a variant is added, and the other side silently goes stale.
13//!
14//! This crate is the single seam that binds them. The same drift class exists
15//! on both sides of the monorepo (PAS `scaccounts`, PCS `scchat`) and neither
16//! core may depend on the other, so the binding primitive is hoisted out of
17//! both — a pure, dependency-free trait + macro (`std::BTreeSet` only), which
18//! both cores (that ban IO/transport crates) can depend on.
19//!
20//! ## Why engine tier and not `crates/shared/`
21//!
22//! It sat in `crates/shared/` (`publish = false`) until `RFC_202607252223`
23//! T-03, which is when the placement was first *tested* rather than assumed:
24//! `ppoppo-identity` needs to enroll its own `EntityType`, and `engine →
25//! shared` is forbidden by the crate lattice (`xtask::policy::rules::taxonomy`)
26//! — so the enrollment was unreachable, and the vocabulary had to keep a
27//! second PAS-local enum alive just to carry it.
28//!
29//! The fix was to notice that the folder was wrong, not the lattice. Engine
30//! tier means *published substrate that no 3rd party names* — a dependency-free
31//! trait + macro consumed by two service cores and one vocabulary crate is
32//! exactly that. The move corrected a misfile that predates the tier; it did
33//! not trade a principle for convenience.
34//!
35//! ## The anchor triple (per `STS_SSOT_GOVERNANCE`)
36//!
37//! - **Owner**: the domain enum in `accounts-core` / `accounts-api` /
38//!   `chat-core` (the closest reified form of the value-set decision).
39//! - **Anchor**: domain-specific — these are tuned domain vocabularies with no
40//!   external standard, so *this crate doc-comment is the anchor of record*
41//!   (governance §4). (Formerly PAS
42//!   `ADR_202605242324_schema-constrained-value-sets.md`, folded into
43//!   `accounts-core` on its retirement, then hoisted here when PCS adopted the
44//!   same gate.)
45//! - **Verification**: [`bindings`](SchemaConstrained::bindings) feeds each
46//!   service's `schema_check_drift.rs` DB test
47//!   (`accounts-api/tests/` for `scaccounts`, `chat-api/tests/` for `scchat`),
48//!   which reads each `CHECK` from the *materialized* schema
49//!   (`pg_get_constraintdef`) and asserts set-equality with `ALL`. The
50//!   compile-time half lives in the [`impl_schema_constrained!`] macro: it
51//!   emits an exhaustive `match`, so adding a variant without listing it fails
52//!   to build.
53//!
54//! ## Why a DB test and not a file parse
55//!
56//! The value-sets evolve through `ALTER … DROP/ADD CONSTRAINT` migrations (e.g.
57//! `lifecycle_state` gained `tombstoned`; `oauth_audit_events.event_type`
58//! gained `otp_issue`/`otp_verify`). The authoritative set is therefore the
59//! *result of applying every migration*, which only the database knows —
60//! parsing the baseline `.sql` would report phantom drift. Asking Postgres via
61//! `pg_get_constraintdef` is the only correct anchor (and avoids the brittle
62//! bespoke-parser ops-tax rejected in `STS_RATE_LIMITS_PPOPPO` §Anchor
63//! "Rationale" option A).
64//!
65//! ## Two more rejected alternatives
66//!
67//! - **`#[sqlx::Type]` alone.** Binds the column *type* (text), not the
68//!   `CHECK`'s value-set — a typo in the enum still compiles and the set still
69//!   drifts. Complementary at the query boundary, not a substitute for the
70//!   verification.
71//! - **Accept drift under human review.** Leaves security-adjacent value-sets
72//!   (audit taxonomy, lifecycle, step-up purpose) under governance §2's
73//!   "aspiration, not enforcement" gate. Rejected.
74//!
75//! ## Caveat — the value-equality half is integration-tier
76//!
77//! The compile-time exhaustiveness guard covers the Rust side on every build.
78//! The `ALL`-vs-`CHECK` set-equality half needs a live database, and there is
79//! no CI job running DB-backed tests — so it bites via each service's
80//! `just test-integration` and the `/deploy-ppoppo` pre-flight, not on a plain
81//! `cargo test`.
82
83#![deny(rust_2018_idioms)]
84#![warn(missing_debug_implementations)]
85
86use std::collections::BTreeSet;
87
88/// One `(constraint, allowed-value-set)` pair, erased of the originating enum
89/// type so the drift test can iterate heterogeneous value-sets.
90#[derive(Debug, Clone)]
91pub struct SchemaBinding {
92    /// The `pg_constraint.conname` (e.g. `"ck_ppnums_entity_type_enum"`).
93    pub constraint: &'static str,
94    /// The DB-text values the owning enum permits — must equal the constraint's
95    /// `IN (…)` set in the materialized schema.
96    pub allowed: BTreeSet<&'static str>,
97}
98
99/// A domain value-set whose members are mirrored by one or more SQL `CHECK`
100/// constraints.
101///
102/// Implemented via [`impl_schema_constrained!`]; never hand-written, so the
103/// compile-time exhaustiveness guard is always emitted alongside.
104pub trait SchemaConstrained: Sized + 'static {
105    /// Every variant, in any order. The macro hand-lists these (no `strum`);
106    /// the exhaustiveness guard makes an omission a build error and the DB test
107    /// makes a stale list a pre-flight failure.
108    const ALL: &'static [Self];
109
110    /// The `CHECK` constraint(s) whose `IN (…)` set must equal
111    /// `{ ALL.map(db_value) }`. More than one when several columns share the
112    /// value-set (e.g. `lifecycle_state` on three columns).
113    const CHECK_CONSTRAINTS: &'static [&'static str];
114
115    /// The DB-text form of a variant — the literal stored in the column and
116    /// named in the `CHECK`. Delegates to the enum's inherent `as_str` /
117    /// `as_wire`.
118    fn db_value(&self) -> &'static str;
119
120    /// Erased `(constraint, allowed)` pairs for the drift test — one per entry
121    /// in [`CHECK_CONSTRAINTS`](Self::CHECK_CONSTRAINTS).
122    fn bindings() -> Vec<SchemaBinding> {
123        let allowed: BTreeSet<&'static str> = Self::ALL.iter().map(Self::db_value).collect();
124        Self::CHECK_CONSTRAINTS
125            .iter()
126            .map(|&constraint| SchemaBinding {
127                constraint,
128                allowed: allowed.clone(),
129            })
130            .collect()
131    }
132}
133
134/// Implement [`SchemaConstrained`] for a unit-variant enum and emit a
135/// compile-time exhaustiveness guard from the same variant list.
136///
137/// ```ignore
138/// ppoppo_schema_constrained::impl_schema_constrained!(EntityType via as_str {
139///     all: [Human, AiAgent, Enterprise, Programmable, Mask],
140///     constraints: ["ck_ppnums_entity_type_enum"],
141/// });
142/// ```
143///
144/// `via $method` is the enum's inherent value accessor (`as_str` for most,
145/// `as_db_str` / `as_wire` for others). Place the invocation next to the enum
146/// so the constraint name lives *on the fact* (governance §6 carrier).
147///
148/// `non_stored` lists render-only variants that exist in the enum but are never
149/// persisted (and so never appear in the `CHECK`) — e.g. `EntityType::Delegated`.
150/// They are excluded from `ALL` yet still covered by the exhaustiveness guard,
151/// so the asymmetry is declared, not hidden.
152#[macro_export]
153macro_rules! impl_schema_constrained {
154    (
155        $ty:ident via $method:ident {
156            all: [ $( $variant:ident ),+ $(,)? ],
157            $( non_stored: [ $( $ns:ident ),+ $(,)? ], )?
158            constraints: [ $( $constraint:literal ),+ $(,)? ] $(,)?
159        }
160    ) => {
161        impl $crate::SchemaConstrained for $ty {
162            const ALL: &'static [Self] = &[ $( $ty::$variant ),+ ];
163            const CHECK_CONSTRAINTS: &'static [&'static str] = &[ $( $constraint ),+ ];
164            fn db_value(&self) -> &'static str {
165                self.$method()
166            }
167        }
168
169        // Compile-time half: if a variant is added to the enum but listed in
170        // neither `all` nor `non_stored`, this match is non-exhaustive and the
171        // build fails — pointing the author at the enrollment.
172        const _: fn($ty) = |x| match x {
173            $( $ty::$variant => () ),+
174            $( , $( $ty::$ns => () ),+ )?
175        };
176    };
177}
178
179/// Finding value-set `CHECK` constraints in migration SQL.
180///
181/// The enrollment registries answer "which enums claim a constraint". This
182/// answers the opposite question — "which constraints exist" — so a service
183/// can assert that every value-set in its schema is either enrolled or
184/// explicitly declared unbound. Both organs need it, and a parser duplicated
185/// per organ is a parser that gets fixed in one of them.
186pub mod migration_scan {
187    use std::collections::BTreeSet;
188
189    /// Names every **single-column value-set** CHECK in one SQL text.
190    ///
191    /// Both spellings count, because both occur: `pg_dump` writes
192    /// `CHECK ((col = ANY (ARRAY['a'::text])))` and a hand-written migration
193    /// writes `CHECK (col IN ('a', 'b'))`. Postgres treats them as the same
194    /// constraint; so does this.
195    ///
196    /// Out of scope, and deliberately so — these are not value-sets and have no
197    /// enum to bind to: range checks, regex checks, `IS NULL` checks, and any
198    /// multi-clause CHECK such as `CHECK ((plan = 'enterprise') = (limit IS
199    /// NULL))`. They are excluded by *structure* (the expression must open on a
200    /// bare column followed immediately by the membership operator), not by a
201    /// blocklist that would need maintaining.
202    pub fn value_set_constraints_in(sql: &str) -> BTreeSet<String> {
203        // Whitespace is not meaningful in SQL but is very meaningful to
204        // `find`, and migrations put the name and its CHECK on separate lines.
205        // Collapse first, match second.
206        let flat: String = {
207            let mut out = String::with_capacity(sql.len());
208            let mut in_space = false;
209            for ch in sql.chars() {
210                if ch.is_whitespace() {
211                    if !in_space {
212                        out.push(' ');
213                    }
214                    in_space = true;
215                } else {
216                    out.push(ch);
217                    in_space = false;
218                }
219            }
220            out
221        };
222
223        let mut found = BTreeSet::new();
224        for (at, _) in flat.match_indices("CONSTRAINT ") {
225            let rest = &flat[at + "CONSTRAINT ".len()..];
226            let Some((name, tail)) = rest.split_once(' ') else {
227                continue;
228            };
229            let Some(body) = tail.strip_prefix("CHECK ") else {
230                continue;
231            };
232            let Some(body) = balanced(body) else {
233                continue;
234            };
235            if is_pure_value_set(body) {
236                found.insert(name.to_string());
237            }
238        }
239        found
240    }
241
242    /// The contents of the leading `(...)` group, without its outer parens.
243    fn balanced(s: &str) -> Option<&str> {
244        let s = s.strip_prefix('(')?;
245        let mut depth = 1usize;
246        for (i, ch) in s.char_indices() {
247            match ch {
248                '(' => depth += 1,
249                ')' => {
250                    depth -= 1;
251                    if depth == 0 {
252                        return Some(&s[..i]);
253                    }
254                }
255                _ => {}
256            }
257        }
258        None
259    }
260
261    /// Is this expression *nothing but* a membership test on one column?
262    ///
263    /// The "nothing but" is the whole difficulty. An earlier version stripped
264    /// leading parens and checked only that the expression *began* with
265    /// `col IN (` — which accepts
266    /// `((state = ANY (…)) AND (x IS NOT NULL)) OR (…)`, a compound predicate
267    /// whose first clause happens to be a membership test. PAS has exactly one
268    /// of those (`ck_signup_sessions_reservation_pair`) and it was misreported
269    /// as an unbound value-set, i.e. the guard demanded an enum for a
270    /// constraint that can never have one.
271    ///
272    /// So the test is structural on both ends: unwrap redundant parens, then
273    /// require the membership group to consume the entire remainder.
274    fn is_pure_value_set(expr: &str) -> bool {
275        let mut expr = expr.trim();
276        // Unwrap only parens that wrap the *whole* expression.
277        while let Some(inner) = balanced(expr) {
278            if inner.len() + 2 == expr.len() {
279                expr = inner.trim();
280            } else {
281                break;
282            }
283        }
284
285        // The column is either bare (`kind`) or parenthesised, which is how
286        // `pg_dump` writes a cast of a varchar column: `(kind)::text`.
287        let mut after = if expr.starts_with('(') {
288            match balanced(expr) {
289                Some(inner)
290                    if !inner.is_empty()
291                        && inner.chars().all(|c| c.is_alphanumeric() || c == '_') =>
292                {
293                    expr[inner.len() + 2..].trim_start()
294                }
295                _ => return false,
296            }
297        } else {
298            let column_len = expr
299                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
300                .unwrap_or(expr.len());
301            if column_len == 0 {
302                return false;
303            }
304            expr[column_len..].trim_start()
305        };
306        loop {
307            if let Some(r) = after.strip_prefix(')') {
308                after = r.trim_start();
309                continue;
310            }
311            if let Some(r) = after.strip_prefix("::") {
312                after = r
313                    .trim_start_matches(|ch: char| ch.is_alphanumeric() || ch == '_')
314                    .trim_start();
315                continue;
316            }
317            break;
318        }
319
320        let list = if let Some(r) = after.strip_prefix("IN ") {
321            r.trim_start()
322        } else if let Some(r) = after.strip_prefix("= ANY ") {
323            r.trim_start()
324        } else {
325            return false;
326        };
327        // The membership group must be the last thing in the expression — no
328        // trailing ` AND …`, no ` OR …`.
329        match balanced(list) {
330            Some(inner) => list.len() == inner.len() + 2,
331            None => false,
332        }
333    }
334
335    #[cfg(test)]
336    #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
337    mod tests {
338        /// The control for every guard built on this parser.
339        ///
340        /// Each input below is a shape a migration in this repo is or could be written
341        /// in. The old matcher found only the last one; the four before it are the
342        /// ones a PR would actually add, and every one of them was invisible.
343        #[test]
344        fn the_parser_sees_the_shapes_a_migration_is_actually_written_in() {
345            let must_find = [
346                (
347                    "hand-written ALTER, name and CHECK on separate lines, IN spelling",
348                    "ALTER TABLE scchat.t\n    ADD CONSTRAINT ck_target\n    CHECK (kind IN ('a', 'b'));",
349                ),
350                (
351                    "hand-written ALTER, separate lines, pg_dump spelling",
352                    "ALTER TABLE scchat.t\n    ADD CONSTRAINT ck_target\n    CHECK ((kind = ANY (ARRAY['a'::text])));",
353                ),
354                (
355                    "inline in CREATE TABLE, IN spelling",
356                    "CREATE TABLE scchat.t (\n  kind text,\n  CONSTRAINT ck_target CHECK (kind IN ('a', 'b'))\n);",
357                ),
358                (
359                    "cast between column and operator",
360                    "ADD CONSTRAINT ck_target CHECK (((kind)::text = ANY (ARRAY['a'::text])));",
361                ),
362                (
363                    "single-line pg_dump form (the only one the first matcher caught)",
364                    "ADD CONSTRAINT ck_target CHECK ((kind = ANY (ARRAY['a'::text, 'b'::text])));",
365                ),
366            ];
367            for (label, sql) in must_find {
368                assert!(
369                    super::value_set_constraints_in(sql).contains("ck_target"),
370                    "parser missed a real value-set CHECK — {label}\n  input: {sql}"
371                );
372            }
373
374            // The exclusions must stay exclusions, or the guard starts demanding an
375            // enum for constraints that can never have one.
376            let must_ignore = [
377                (
378                    "multi-clause pairing check (this repo has one)",
379                    "ADD CONSTRAINT ck_pairing CHECK ((plan = 'enterprise') = (monthly_message_limit IS NULL));",
380                ),
381                (
382                    "range check",
383                    "ADD CONSTRAINT ck_range CHECK ((retention_days > 0));",
384                ),
385                (
386                    "regex check",
387                    "ADD CONSTRAINT ck_regex CHECK ((ppnum ~ '^[0-9]{4}$'));",
388                ),
389                (
390                    "membership nested inside a compound predicate is not a value-set",
391                    "ADD CONSTRAINT ck_compound CHECK ((a IS NULL) OR (kind IN ('a', 'b')));",
392                ),
393                (
394                    "membership as the FIRST clause of a compound predicate — PAS has\
395                     exactly this shape in ck_signup_sessions_reservation_pair, and a\
396                     parser that only checks the start of the expression accepts it",
397                    "ADD CONSTRAINT ck_pair CHECK ((((state = ANY (ARRAY['a'::text])) AND\
398                     (r IS NOT NULL)) OR ((state = 'b'::text) AND (r IS NULL))));",
399                ),
400            ];
401            for (label, sql) in must_ignore {
402                assert!(
403                    super::value_set_constraints_in(sql).is_empty(),
404                    "parser claimed a non-value-set CHECK — {label}\n  input: {sql}"
405                );
406            }
407        }
408    }
409}