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 = flatten(sql);
207
208 let mut found = BTreeSet::new();
209 for (at, _) in flat.match_indices("CONSTRAINT ") {
210 let rest = &flat[at + "CONSTRAINT ".len()..];
211 let Some((name, tail)) = rest.split_once(' ') else {
212 continue;
213 };
214 let Some(body) = tail.strip_prefix("CHECK ") else {
215 continue;
216 };
217 let Some(body) = balanced(body) else {
218 continue;
219 };
220 if is_pure_value_set(body) {
221 found.insert(name.to_string());
222 }
223 }
224 found
225 }
226
227 /// The contents of the leading `(...)` group, without its outer parens.
228 /// Every `RENAME CONSTRAINT <old> TO <new>` in one SQL text.
229 ///
230 /// # Why a text scan of migrations needs this at all
231 ///
232 /// [`value_set_constraints_in`] sees a constraint at the name the migration
233 /// that *created* it used. A later migration is free to rename it, and the
234 /// text of the first file never changes — so a corpus scan without this
235 /// reports a constraint that no longer exists, and the guard demands an
236 /// enum bound to a name nothing will ever match while the enum correctly
237 /// names the new one. That is a false positive on every rename, and it
238 /// reads as a missing enrolment, which sends the reader to the wrong file.
239 ///
240 /// Lives here for the same reason the parser does: both organs need it, and
241 /// a resolver duplicated per organ is a resolver that gets fixed in one.
242 pub fn constraint_renames_in(sql: &str) -> Vec<(String, String)> {
243 let flat = flatten(sql);
244 let mut renames = Vec::new();
245 let mut rest = flat.as_str();
246 while let Some(at) = rest.find("RENAME CONSTRAINT ") {
247 rest = &rest[at + "RENAME CONSTRAINT ".len()..];
248 let mut parts = rest.split_whitespace();
249 let (Some(from), Some(to_kw), Some(to)) = (parts.next(), parts.next(), parts.next())
250 else {
251 continue;
252 };
253 if !to_kw.eq_ignore_ascii_case("TO") {
254 continue;
255 }
256 renames.push((
257 from.trim_end_matches(';').to_string(),
258 to.trim_end_matches(';').to_string(),
259 ));
260 }
261 renames
262 }
263
264 /// Follows a constraint name through every rename that applies to it.
265 ///
266 /// Bounded by the rename count: a cycle is a migration bug, and a guard
267 /// that hangs on one is worse than a guard that reports it.
268 pub fn resolve_renamed(name: &str, renames: &[(String, String)]) -> String {
269 let mut current = name.to_string();
270 for _ in 0..=renames.len() {
271 match renames.iter().find(|(from, _)| *from == current) {
272 Some((_, to)) => current = to.clone(),
273 None => break,
274 }
275 }
276 current
277 }
278
279 /// Every value-set CHECK across an ordered migration corpus, **at its final
280 /// name**.
281 ///
282 /// `sources` must be in chronological order — for this repo's stamped
283 /// filenames that is lexicographic order.
284 ///
285 /// This composition lives here rather than in each service's guard on
286 /// purpose: two organs that each assemble "scan, then resolve" for
287 /// themselves are two places for the second half to be forgotten, which is
288 /// exactly how PCS's guard shipped without it.
289 pub fn value_set_constraints_across<'a>(
290 sources: impl IntoIterator<Item = &'a str>,
291 ) -> BTreeSet<String> {
292 let texts: Vec<&str> = sources.into_iter().collect();
293 let renames: Vec<(String, String)> = texts
294 .iter()
295 .flat_map(|s| constraint_renames_in(s))
296 .collect();
297 texts
298 .iter()
299 .flat_map(|s| value_set_constraints_in(s))
300 .map(|c| resolve_renamed(&c, &renames))
301 .collect()
302 }
303
304 /// Collapses runs of whitespace, so `find` can match across the line breaks
305 /// migrations put between a constraint name and its clause.
306 fn flatten(sql: &str) -> String {
307 let mut out = String::with_capacity(sql.len());
308 let mut in_space = false;
309 for ch in sql.chars() {
310 if ch.is_whitespace() {
311 if !in_space {
312 out.push(' ');
313 }
314 in_space = true;
315 } else {
316 out.push(ch);
317 in_space = false;
318 }
319 }
320 out
321 }
322
323 fn balanced(s: &str) -> Option<&str> {
324 let s = s.strip_prefix('(')?;
325 let mut depth = 1usize;
326 for (i, ch) in s.char_indices() {
327 match ch {
328 '(' => depth += 1,
329 ')' => {
330 depth -= 1;
331 if depth == 0 {
332 return Some(&s[..i]);
333 }
334 }
335 _ => {}
336 }
337 }
338 None
339 }
340
341 /// Is this expression *nothing but* a membership test on one column?
342 ///
343 /// The "nothing but" is the whole difficulty. An earlier version stripped
344 /// leading parens and checked only that the expression *began* with
345 /// `col IN (` — which accepts
346 /// `((state = ANY (…)) AND (x IS NOT NULL)) OR (…)`, a compound predicate
347 /// whose first clause happens to be a membership test. PAS has exactly one
348 /// of those (`ck_signup_sessions_reservation_pair`) and it was misreported
349 /// as an unbound value-set, i.e. the guard demanded an enum for a
350 /// constraint that can never have one.
351 ///
352 /// So the test is structural on both ends: unwrap redundant parens, then
353 /// require the membership group to consume the entire remainder.
354 fn is_pure_value_set(expr: &str) -> bool {
355 let mut expr = expr.trim();
356 // Unwrap only parens that wrap the *whole* expression.
357 while let Some(inner) = balanced(expr) {
358 if inner.len() + 2 == expr.len() {
359 expr = inner.trim();
360 } else {
361 break;
362 }
363 }
364
365 // The column is either bare (`kind`) or parenthesised, which is how
366 // `pg_dump` writes a cast of a varchar column: `(kind)::text`.
367 let mut after = if expr.starts_with('(') {
368 match balanced(expr) {
369 Some(inner)
370 if !inner.is_empty()
371 && inner.chars().all(|c| c.is_alphanumeric() || c == '_') =>
372 {
373 expr[inner.len() + 2..].trim_start()
374 }
375 _ => return false,
376 }
377 } else {
378 let column_len = expr
379 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
380 .unwrap_or(expr.len());
381 if column_len == 0 {
382 return false;
383 }
384 expr[column_len..].trim_start()
385 };
386 loop {
387 if let Some(r) = after.strip_prefix(')') {
388 after = r.trim_start();
389 continue;
390 }
391 if let Some(r) = after.strip_prefix("::") {
392 after = r
393 .trim_start_matches(|ch: char| ch.is_alphanumeric() || ch == '_')
394 .trim_start();
395 continue;
396 }
397 break;
398 }
399
400 let list = if let Some(r) = after.strip_prefix("IN ") {
401 r.trim_start()
402 } else if let Some(r) = after.strip_prefix("= ANY ") {
403 r.trim_start()
404 } else {
405 return false;
406 };
407 // The membership group must be the last thing in the expression — no
408 // trailing ` AND …`, no ` OR …`.
409 match balanced(list) {
410 Some(inner) => list.len() == inner.len() + 2,
411 None => false,
412 }
413 }
414
415 #[cfg(test)]
416 #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
417 mod tests {
418 use super::{constraint_renames_in, resolve_renamed, value_set_constraints_across};
419
420 // ─── rename resolution ────────────────────────────────────────────
421 //
422 // The resolver is half the guard, so it is tested like one: a resolver
423 // that silently found no renames would restore exactly the false
424 // positive it exists to remove, and would do it quietly.
425
426 #[test]
427 fn renames_parse_across_the_line_breaks_migrations_use() {
428 let sql = "ALTER TABLE scchat.t\n RENAME CONSTRAINT one_check\n TO two_check;";
429 assert_eq!(
430 constraint_renames_in(sql),
431 vec![("one_check".to_string(), "two_check".to_string())],
432 );
433 }
434
435 #[test]
436 fn a_rename_chain_resolves_to_its_final_name() {
437 let renames = constraint_renames_in(
438 "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
439 ALTER TABLE t RENAME CONSTRAINT b_check TO c_check;",
440 );
441 assert_eq!(resolve_renamed("a_check", &renames), "c_check");
442 assert_eq!(resolve_renamed("b_check", &renames), "c_check");
443 assert_eq!(
444 resolve_renamed("untouched_check", &renames),
445 "untouched_check",
446 "a constraint nobody renamed keeps its name"
447 );
448 }
449
450 #[test]
451 fn a_cyclic_rename_terminates_instead_of_hanging() {
452 // A cycle is a migration bug. The guard must be able to report it.
453 let renames = constraint_renames_in(
454 "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
455 ALTER TABLE t RENAME CONSTRAINT b_check TO a_check;",
456 );
457 let _ = resolve_renamed("a_check", &renames);
458 }
459
460 #[test]
461 fn a_corpus_reports_the_check_at_its_final_name_only() {
462 // The whole point: the creating migration's text never changes, so
463 // without the second pass the guard demands an enum bound to a name
464 // that no longer exists — and that reads as a missing enrolment,
465 // which sends the reader to the wrong file entirely.
466 let created =
467 "ALTER TABLE scchat.t ADD CONSTRAINT old_check CHECK (kind IN ('a', 'b'));";
468 let renamed = "ALTER TABLE scchat.t RENAME CONSTRAINT old_check TO new_check;";
469
470 let one_file = value_set_constraints_across([created]);
471 assert!(
472 one_file.contains("old_check"),
473 "control: the scan finds it before any rename"
474 );
475
476 let corpus = value_set_constraints_across([created, renamed]);
477 assert!(
478 corpus.contains("new_check"),
479 "the final name is what the schema has"
480 );
481 assert!(
482 !corpus.contains("old_check"),
483 "the retired name must not be demanded"
484 );
485 }
486
487 #[test]
488 fn a_non_rename_use_of_the_word_is_not_read_as_one() {
489 // `RENAME TO` on a table is not `RENAME CONSTRAINT`, and prose that
490 // merely says the word is not a statement.
491 let renames = constraint_renames_in(
492 "-- we RENAME CONSTRAINT names when a table moves\n\
493 ALTER TABLE scchat.old_t RENAME TO new_t;",
494 );
495 assert!(
496 renames.iter().all(|(from, _)| from != "names"),
497 "a comment must not be parsed as a rename: {renames:?}"
498 );
499 }
500
501 /// The control for every guard built on this parser.
502 ///
503 /// Each input below is a shape a migration in this repo is or could be written
504 /// in. The old matcher found only the last one; the four before it are the
505 /// ones a PR would actually add, and every one of them was invisible.
506 #[test]
507 fn the_parser_sees_the_shapes_a_migration_is_actually_written_in() {
508 let must_find = [
509 (
510 "hand-written ALTER, name and CHECK on separate lines, IN spelling",
511 "ALTER TABLE scchat.t\n ADD CONSTRAINT ck_target\n CHECK (kind IN ('a', 'b'));",
512 ),
513 (
514 "hand-written ALTER, separate lines, pg_dump spelling",
515 "ALTER TABLE scchat.t\n ADD CONSTRAINT ck_target\n CHECK ((kind = ANY (ARRAY['a'::text])));",
516 ),
517 (
518 "inline in CREATE TABLE, IN spelling",
519 "CREATE TABLE scchat.t (\n kind text,\n CONSTRAINT ck_target CHECK (kind IN ('a', 'b'))\n);",
520 ),
521 (
522 "cast between column and operator",
523 "ADD CONSTRAINT ck_target CHECK (((kind)::text = ANY (ARRAY['a'::text])));",
524 ),
525 (
526 "single-line pg_dump form (the only one the first matcher caught)",
527 "ADD CONSTRAINT ck_target CHECK ((kind = ANY (ARRAY['a'::text, 'b'::text])));",
528 ),
529 ];
530 for (label, sql) in must_find {
531 assert!(
532 super::value_set_constraints_in(sql).contains("ck_target"),
533 "parser missed a real value-set CHECK — {label}\n input: {sql}"
534 );
535 }
536
537 // The exclusions must stay exclusions, or the guard starts demanding an
538 // enum for constraints that can never have one.
539 let must_ignore = [
540 (
541 "multi-clause pairing check (this repo has one)",
542 "ADD CONSTRAINT ck_pairing CHECK ((plan = 'enterprise') = (monthly_message_limit IS NULL));",
543 ),
544 (
545 "range check",
546 "ADD CONSTRAINT ck_range CHECK ((retention_days > 0));",
547 ),
548 (
549 "regex check",
550 "ADD CONSTRAINT ck_regex CHECK ((ppnum ~ '^[0-9]{4}$'));",
551 ),
552 (
553 "membership nested inside a compound predicate is not a value-set",
554 "ADD CONSTRAINT ck_compound CHECK ((a IS NULL) OR (kind IN ('a', 'b')));",
555 ),
556 (
557 "membership as the FIRST clause of a compound predicate — PAS has\
558 exactly this shape in ck_signup_sessions_reservation_pair, and a\
559 parser that only checks the start of the expression accepts it",
560 "ADD CONSTRAINT ck_pair CHECK ((((state = ANY (ARRAY['a'::text])) AND\
561 (r IS NOT NULL)) OR ((state = 'b'::text) AND (r IS NULL))));",
562 ),
563 ];
564 for (label, sql) in must_ignore {
565 assert!(
566 super::value_set_constraints_in(sql).is_empty(),
567 "parser claimed a non-value-set CHECK — {label}\n input: {sql}"
568 );
569 }
570 }
571 }
572}