ppoppo-schema-constrained 0.26.0

Enum ↔ SQL CHECK anchor: compile-time exhaustiveness guard + DB-drift binding, shared by PAS (scaccounts) and PCS (scchat)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! **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 => () ),+ )?
        };
    };
}

/// Finding value-set `CHECK` constraints in migration SQL.
///
/// The enrollment registries answer "which enums claim a constraint". This
/// answers the opposite question — "which constraints exist" — so a service
/// can assert that every value-set in its schema is either enrolled or
/// explicitly declared unbound. Both organs need it, and a parser duplicated
/// per organ is a parser that gets fixed in one of them.
pub mod migration_scan {
    use std::collections::BTreeSet;

    /// Names every **single-column value-set** CHECK in one SQL text.
    ///
    /// Both spellings count, because both occur: `pg_dump` writes
    /// `CHECK ((col = ANY (ARRAY['a'::text])))` and a hand-written migration
    /// writes `CHECK (col IN ('a', 'b'))`. Postgres treats them as the same
    /// constraint; so does this.
    ///
    /// Out of scope, and deliberately so — these are not value-sets and have no
    /// enum to bind to: range checks, regex checks, `IS NULL` checks, and any
    /// multi-clause CHECK such as `CHECK ((plan = 'enterprise') = (limit IS
    /// NULL))`. They are excluded by *structure* (the expression must open on a
    /// bare column followed immediately by the membership operator), not by a
    /// blocklist that would need maintaining.
    pub fn value_set_constraints_in(sql: &str) -> BTreeSet<String> {
        // Whitespace is not meaningful in SQL but is very meaningful to
        // `find`, and migrations put the name and its CHECK on separate lines.
        // Collapse first, match second.
        let flat: String = {
            let mut out = String::with_capacity(sql.len());
            let mut in_space = false;
            for ch in sql.chars() {
                if ch.is_whitespace() {
                    if !in_space {
                        out.push(' ');
                    }
                    in_space = true;
                } else {
                    out.push(ch);
                    in_space = false;
                }
            }
            out
        };

        let mut found = BTreeSet::new();
        for (at, _) in flat.match_indices("CONSTRAINT ") {
            let rest = &flat[at + "CONSTRAINT ".len()..];
            let Some((name, tail)) = rest.split_once(' ') else {
                continue;
            };
            let Some(body) = tail.strip_prefix("CHECK ") else {
                continue;
            };
            let Some(body) = balanced(body) else {
                continue;
            };
            if is_pure_value_set(body) {
                found.insert(name.to_string());
            }
        }
        found
    }

    /// The contents of the leading `(...)` group, without its outer parens.
    fn balanced(s: &str) -> Option<&str> {
        let s = s.strip_prefix('(')?;
        let mut depth = 1usize;
        for (i, ch) in s.char_indices() {
            match ch {
                '(' => depth += 1,
                ')' => {
                    depth -= 1;
                    if depth == 0 {
                        return Some(&s[..i]);
                    }
                }
                _ => {}
            }
        }
        None
    }

    /// Is this expression *nothing but* a membership test on one column?
    ///
    /// The "nothing but" is the whole difficulty. An earlier version stripped
    /// leading parens and checked only that the expression *began* with
    /// `col IN (` — which accepts
    /// `((state = ANY (…)) AND (x IS NOT NULL)) OR (…)`, a compound predicate
    /// whose first clause happens to be a membership test. PAS has exactly one
    /// of those (`ck_signup_sessions_reservation_pair`) and it was misreported
    /// as an unbound value-set, i.e. the guard demanded an enum for a
    /// constraint that can never have one.
    ///
    /// So the test is structural on both ends: unwrap redundant parens, then
    /// require the membership group to consume the entire remainder.
    fn is_pure_value_set(expr: &str) -> bool {
        let mut expr = expr.trim();
        // Unwrap only parens that wrap the *whole* expression.
        while let Some(inner) = balanced(expr) {
            if inner.len() + 2 == expr.len() {
                expr = inner.trim();
            } else {
                break;
            }
        }

        // The column is either bare (`kind`) or parenthesised, which is how
        // `pg_dump` writes a cast of a varchar column: `(kind)::text`.
        let mut after = if expr.starts_with('(') {
            match balanced(expr) {
                Some(inner)
                    if !inner.is_empty()
                        && inner.chars().all(|c| c.is_alphanumeric() || c == '_') =>
                {
                    expr[inner.len() + 2..].trim_start()
                }
                _ => return false,
            }
        } else {
            let column_len = expr
                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
                .unwrap_or(expr.len());
            if column_len == 0 {
                return false;
            }
            expr[column_len..].trim_start()
        };
        loop {
            if let Some(r) = after.strip_prefix(')') {
                after = r.trim_start();
                continue;
            }
            if let Some(r) = after.strip_prefix("::") {
                after = r
                    .trim_start_matches(|ch: char| ch.is_alphanumeric() || ch == '_')
                    .trim_start();
                continue;
            }
            break;
        }

        let list = if let Some(r) = after.strip_prefix("IN ") {
            r.trim_start()
        } else if let Some(r) = after.strip_prefix("= ANY ") {
            r.trim_start()
        } else {
            return false;
        };
        // The membership group must be the last thing in the expression — no
        // trailing ` AND …`, no ` OR …`.
        match balanced(list) {
            Some(inner) => list.len() == inner.len() + 2,
            None => false,
        }
    }

    #[cfg(test)]
    #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
    mod tests {
        /// The control for every guard built on this parser.
        ///
        /// Each input below is a shape a migration in this repo is or could be written
        /// in. The old matcher found only the last one; the four before it are the
        /// ones a PR would actually add, and every one of them was invisible.
        #[test]
        fn the_parser_sees_the_shapes_a_migration_is_actually_written_in() {
            let must_find = [
                (
                    "hand-written ALTER, name and CHECK on separate lines, IN spelling",
                    "ALTER TABLE scchat.t\n    ADD CONSTRAINT ck_target\n    CHECK (kind IN ('a', 'b'));",
                ),
                (
                    "hand-written ALTER, separate lines, pg_dump spelling",
                    "ALTER TABLE scchat.t\n    ADD CONSTRAINT ck_target\n    CHECK ((kind = ANY (ARRAY['a'::text])));",
                ),
                (
                    "inline in CREATE TABLE, IN spelling",
                    "CREATE TABLE scchat.t (\n  kind text,\n  CONSTRAINT ck_target CHECK (kind IN ('a', 'b'))\n);",
                ),
                (
                    "cast between column and operator",
                    "ADD CONSTRAINT ck_target CHECK (((kind)::text = ANY (ARRAY['a'::text])));",
                ),
                (
                    "single-line pg_dump form (the only one the first matcher caught)",
                    "ADD CONSTRAINT ck_target CHECK ((kind = ANY (ARRAY['a'::text, 'b'::text])));",
                ),
            ];
            for (label, sql) in must_find {
                assert!(
                    super::value_set_constraints_in(sql).contains("ck_target"),
                    "parser missed a real value-set CHECK — {label}\n  input: {sql}"
                );
            }

            // The exclusions must stay exclusions, or the guard starts demanding an
            // enum for constraints that can never have one.
            let must_ignore = [
                (
                    "multi-clause pairing check (this repo has one)",
                    "ADD CONSTRAINT ck_pairing CHECK ((plan = 'enterprise') = (monthly_message_limit IS NULL));",
                ),
                (
                    "range check",
                    "ADD CONSTRAINT ck_range CHECK ((retention_days > 0));",
                ),
                (
                    "regex check",
                    "ADD CONSTRAINT ck_regex CHECK ((ppnum ~ '^[0-9]{4}$'));",
                ),
                (
                    "membership nested inside a compound predicate is not a value-set",
                    "ADD CONSTRAINT ck_compound CHECK ((a IS NULL) OR (kind IN ('a', 'b')));",
                ),
                (
                    "membership as the FIRST clause of a compound predicate — PAS has\
                     exactly this shape in ck_signup_sessions_reservation_pair, and a\
                     parser that only checks the start of the expression accepts it",
                    "ADD CONSTRAINT ck_pair CHECK ((((state = ANY (ARRAY['a'::text])) AND\
                     (r IS NOT NULL)) OR ((state = 'b'::text) AND (r IS NULL))));",
                ),
            ];
            for (label, sql) in must_ignore {
                assert!(
                    super::value_set_constraints_in(sql).is_empty(),
                    "parser claimed a non-value-set CHECK — {label}\n  input: {sql}"
                );
            }
        }
    }
}