ppoppo-schema-constrained 0.31.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
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
//! **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 = flatten(sql);

        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.
    /// Every `RENAME CONSTRAINT <old> TO <new>` in one SQL text.
    ///
    /// # Why a text scan of migrations needs this at all
    ///
    /// [`value_set_constraints_in`] sees a constraint at the name the migration
    /// that *created* it used. A later migration is free to rename it, and the
    /// text of the first file never changes — so a corpus scan without this
    /// reports a constraint that no longer exists, and the guard demands an
    /// enum bound to a name nothing will ever match while the enum correctly
    /// names the new one. That is a false positive on every rename, and it
    /// reads as a missing enrolment, which sends the reader to the wrong file.
    ///
    /// Lives here for the same reason the parser does: both organs need it, and
    /// a resolver duplicated per organ is a resolver that gets fixed in one.
    pub fn constraint_renames_in(sql: &str) -> Vec<(String, String)> {
        let flat = flatten(sql);
        let mut renames = Vec::new();
        let mut rest = flat.as_str();
        while let Some(at) = rest.find("RENAME CONSTRAINT ") {
            rest = &rest[at + "RENAME CONSTRAINT ".len()..];
            let mut parts = rest.split_whitespace();
            let (Some(from), Some(to_kw), Some(to)) = (parts.next(), parts.next(), parts.next())
            else {
                continue;
            };
            if !to_kw.eq_ignore_ascii_case("TO") {
                continue;
            }
            renames.push((
                from.trim_end_matches(';').to_string(),
                to.trim_end_matches(';').to_string(),
            ));
        }
        renames
    }

    /// Follows a constraint name through every rename that applies to it.
    ///
    /// Bounded by the rename count: a cycle is a migration bug, and a guard
    /// that hangs on one is worse than a guard that reports it.
    pub fn resolve_renamed(name: &str, renames: &[(String, String)]) -> String {
        let mut current = name.to_string();
        for _ in 0..=renames.len() {
            match renames.iter().find(|(from, _)| *from == current) {
                Some((_, to)) => current = to.clone(),
                None => break,
            }
        }
        current
    }

    /// Every value-set CHECK across an ordered migration corpus, **at its final
    /// name**.
    ///
    /// `sources` must be in chronological order — for this repo's stamped
    /// filenames that is lexicographic order.
    ///
    /// This composition lives here rather than in each service's guard on
    /// purpose: two organs that each assemble "scan, then resolve" for
    /// themselves are two places for the second half to be forgotten, which is
    /// exactly how PCS's guard shipped without it.
    pub fn value_set_constraints_across<'a>(
        sources: impl IntoIterator<Item = &'a str>,
    ) -> BTreeSet<String> {
        let texts: Vec<&str> = sources.into_iter().collect();
        let renames: Vec<(String, String)> = texts
            .iter()
            .flat_map(|s| constraint_renames_in(s))
            .collect();
        texts
            .iter()
            .flat_map(|s| value_set_constraints_in(s))
            .map(|c| resolve_renamed(&c, &renames))
            .collect()
    }

    /// Collapses runs of whitespace, so `find` can match across the line breaks
    /// migrations put between a constraint name and its clause.
    fn flatten(sql: &str) -> 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
    }

    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 {
        use super::{constraint_renames_in, resolve_renamed, value_set_constraints_across};

        // ─── rename resolution ────────────────────────────────────────────
        //
        // The resolver is half the guard, so it is tested like one: a resolver
        // that silently found no renames would restore exactly the false
        // positive it exists to remove, and would do it quietly.

        #[test]
        fn renames_parse_across_the_line_breaks_migrations_use() {
            let sql = "ALTER TABLE scchat.t\n    RENAME CONSTRAINT one_check\n                  TO two_check;";
            assert_eq!(
                constraint_renames_in(sql),
                vec![("one_check".to_string(), "two_check".to_string())],
            );
        }

        #[test]
        fn a_rename_chain_resolves_to_its_final_name() {
            let renames = constraint_renames_in(
                "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
                 ALTER TABLE t RENAME CONSTRAINT b_check TO c_check;",
            );
            assert_eq!(resolve_renamed("a_check", &renames), "c_check");
            assert_eq!(resolve_renamed("b_check", &renames), "c_check");
            assert_eq!(
                resolve_renamed("untouched_check", &renames),
                "untouched_check",
                "a constraint nobody renamed keeps its name"
            );
        }

        #[test]
        fn a_cyclic_rename_terminates_instead_of_hanging() {
            // A cycle is a migration bug. The guard must be able to report it.
            let renames = constraint_renames_in(
                "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
                 ALTER TABLE t RENAME CONSTRAINT b_check TO a_check;",
            );
            let _ = resolve_renamed("a_check", &renames);
        }

        #[test]
        fn a_corpus_reports_the_check_at_its_final_name_only() {
            // The whole point: the creating migration's text never changes, so
            // without the second pass the guard demands an enum bound to a name
            // that no longer exists — and that reads as a missing enrolment,
            // which sends the reader to the wrong file entirely.
            let created =
                "ALTER TABLE scchat.t ADD CONSTRAINT old_check CHECK (kind IN ('a', 'b'));";
            let renamed = "ALTER TABLE scchat.t RENAME CONSTRAINT old_check TO new_check;";

            let one_file = value_set_constraints_across([created]);
            assert!(
                one_file.contains("old_check"),
                "control: the scan finds it before any rename"
            );

            let corpus = value_set_constraints_across([created, renamed]);
            assert!(
                corpus.contains("new_check"),
                "the final name is what the schema has"
            );
            assert!(
                !corpus.contains("old_check"),
                "the retired name must not be demanded"
            );
        }

        #[test]
        fn a_non_rename_use_of_the_word_is_not_read_as_one() {
            // `RENAME TO` on a table is not `RENAME CONSTRAINT`, and prose that
            // merely says the word is not a statement.
            let renames = constraint_renames_in(
                "-- we RENAME CONSTRAINT names when a table moves\n\
                 ALTER TABLE scchat.old_t RENAME TO new_t;",
            );
            assert!(
                renames.iter().all(|(from, _)| from != "names"),
                "a comment must not be parsed as a rename: {renames:?}"
            );
        }

        /// 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}"
                );
            }
        }
    }
}