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

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 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.