SQL & Database Architectural Contracts
This document defines the canonical database design standards, PostgREST API conventions, and architectural boundaries between PostgreSQL and FastAPI for vibrant-platform.
Reviewers (both human engineers and automated agents) must evaluate all SQL migrations and RPC definitions against these rules.
1. Boundary of Responsibilities
What Belongs in PostgreSQL
- ACID Transaction Boundaries & Invariants: State machine status transitions (e.g.
invoicing.transition_receipt_status_for_worker), row locks (FOR UPDATE SKIP LOCKED), and relational consistency. - Relational Integrity: Foreign keys, unique indexes, nullability, table
CHECKconstraints. - Row-Level Security (RLS) & Access Control: Declarative security-invoker views in
apiandinternal_api, policy helpers inaccess.*andinvoicing.*. - Set-Based Calculations: Derived status checks and aggregate summations (e.g.
bill_line_items_total).
What Belongs in FastAPI
- Input Validation & Sanitization: Pydantic models for payload structure, regexes, and finite number bounds.
- Payload Ingestion & DTO Parsing: Unpacking multi-line external documents (e.g. OCR/AI extraction output) into parameterized database queries.
- External System Orchestration: Calling third-party APIs (OpenAI, Plaid, Connecteam, SendGrid), signing object-storage (S3/MinIO) URLs, and rendering/combining PDFs.
- Composite Detail Aggregation: Querying multiple tables and assembling client DTOs (e.g.
BillReviewRepository.load_bill).
Reads and Writes
PostgREST is the read path: exposed security-invoker views are queried directly and carry RLS. Move a write to FastAPI only when it needs real payload parsing or validation — simple inserts stay as RPCs. A relocated write keeps its authorization in a private SQL function so both entry paths enforce the same rules and cannot drift.
Scheduled jobs and background workers do not go through PostgREST at all: they call private *_for_worker functions over asyncpg with the service's constrained role. See docs/architecture/background-jobs.md for the decision rule and the *_for_worker function contract.
2. RPC Function Signatures
Prohibited: Ad-Hoc RETURNS jsonb / jsonb_build_object
Do not wrap standard CRUD or mutation results in arbitrary JSON objects (RETURNS jsonb with jsonb_build_object('changed', true, 'id', ...)).
Reason: PostgREST cannot reflect field types or nullability for jsonb return types in the generated OpenAPI schema, and client SDK generators lose TypeScript typings. Furthermore, clients cannot use PostgREST column projection (?select=...) or filtering on JSON return values.
Required: Strongly-Typed Returns
Choose the appropriate return type based on the operation:
-
Entity Table Rows (Preferred for PostgREST CRUD/Mutations):
sql CREATE FUNCTION internal_api.add_bill_line_item(...) RETURNS TABLE ( id uuid, bill_id uuid, position integer, description text, amount numeric, is_tax boolean )Or return existing domain view/table records:RETURNS SETOF internal_api.billsorRETURNS api.user_devices. -
Simple Identifiers / Booleans:
sql CREATE FUNCTION internal_api.create_vendor(p_name text) RETURNS uuid CREATE FUNCTION invoicing.bill_amounts_reconcile(p_bill_id uuid) RETURNS boolean -
Approved
RETURNS jsonbExceptions:RETURNS jsonbis permitted only for: auth.jwt(): Low-level access to full request JWT claims.- Polymorphic Audit Snapshots:
details jsonbfields in event tables (invoicing.receipt_events,invoicing.bill_events). - Composite Inspection Checklists: Non-relational readiness checklists (e.g.
invoicing.bill_readiness,internal_api.receipt_readiness).
Every function exception must be documented directly above its definition:
sql
-- allow-jsonb-return: inspection - The keys form a heterogeneous readiness checklist.
CREATE FUNCTION invoicing.bill_readiness(p_bill_id uuid)
RETURNS jsonb
The accepted categories are inspection and polymorphic. Historical status alone is not
an acceptable exception.
Prohibited: jsonb Payload Parameters
Exposed api / internal_api functions must not accept a payload as jsonb. Declare a composite type and take an array of it:
CREATE TYPE invoicing.bill_line_item_input AS (position integer, description text, amount numeric);
CREATE FUNCTION invoicing.replace_bill_line_items(
p_bill_id uuid,
p_line_items invoicing.bill_line_item_input[]
)
Reason: the same OpenAPI and projection losses as RETURNS jsonb, and the parser ends up in PL/pgSQL. A composite array is rejected at the wire on wrong arity instead of shifting columns, and asyncpg encodes list[tuple] with no codec registration.
Parameters named p_details or p_result are exempt by name — they are the polymorphic audit snapshots above. Anything else needs a signature-keyed entry in JSONB_PARAMETER_ALLOWLIST with a reason. Because the key is the full signature, a retype retires its own exemption: the stranded entry is then reported as a violation.
3. PL/pgSQL #variable_conflict use_column Invariant
When a PL/pgSQL function returns RETURNS TABLE(...), PostgreSQL creates output variables in the function scope for each returned column name. Any unadorned SQL identifier in INSERT ... ON CONFLICT, UPDATE ... SET, or WHERE clauses will conflict with the return column variable.
Mandatory Rule: Every PL/pgSQL function returning RETURNS TABLE(...) must declare #variable_conflict use_column directly below AS $$:
CREATE FUNCTION internal_api.create_project_budget(
p_project_id uuid
) RETURNS TABLE (
id uuid,
project_id uuid
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
#variable_conflict use_column
DECLARE
v_actor_user_id uuid := auth.uid();
BEGIN
...
RETURN QUERY
INSERT INTO invoicing.project_budgets (project_id, created_by_user_id)
VALUES (p_project_id, v_actor_user_id)
ON CONFLICT (project_id) DO NOTHING
RETURNING id, project_id;
END;
$$;
4. Security, Grants & Privilege Revocation
Explicit REVOKE ... FROM PUBLIC
PostgreSQL grants function EXECUTE privileges to PUBLIC by default. Creating or replacing a function without revoking execution from PUBLIC inadvertently exposes internal RPCs to anonymous callers.
Mandatory Rule: Every CREATE FUNCTION or CREATE OR REPLACE FUNCTION in api, internal_api, or domain schemas must include an explicit full-signature revocation after the definition and before granting access to named roles:
REVOKE EXECUTE ON FUNCTION internal_api.my_function(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION internal_api.my_function(uuid) TO internal_api_role;
Role Targets
authenticated: Signed-in public application users (used in publicapischema).internal_api_role: Internal operations, staff, and private invoicing workflows (used ininternal_apischema).service_role: Trusted service automation.internal_anon/anon: No grants by default (fail closed).
Security Invoker vs. Security Definer
- Views: Always specify
WITH (security_invoker = true). - Functions: Default to
SECURITY INVOKER. SECURITY DEFINER: Use only when crossing schema boundaries or evaluating RLS helpers. When used, you must specifySET search_path = pg_catalogto eliminate search-path hijacking.
5. PostgREST OpenAPI & Documentation
-
SQL Comments: Every view and function exposed in
apiorinternal_apimust include a descriptive comment. PostgREST extracts these comments as the endpointsummaryanddescriptionin the OpenAPI specification:sql COMMENT ON FUNCTION internal_api.claim_receipt_extraction_jobs(integer, integer, text) IS 'Claims pending receipt extraction jobs with strongly-typed tabular rows for internal services.'; -
OpenAPI Allowlist Verification: Every new or renamed RPC/view must be deliberately listed in the allowlist in
scripts/test-db-local.sh.
6. Migration Standards
- Format: Every migration file under
infrastructure/db/migrations/must contain balanced-- migrate:upand-- migrate:downsections. - Deterministic Replay:
scripts/test-db-local.shrolls the complete migration chain back to zero and replays it before running integration tests. schema.sqlSynchronization: After running migrations, regenerate and commitinfrastructure/db/schema.sql.- Explicit Definition Changes: Declare complete target definitions with
CREATE OR REPLACE FUNCTIONorCREATE OR REPLACE VIEWin both migration directions instead of usingpg_get_*defor dynamicEXECUTE. Repair deployed definitions in a new forward migration. - Both Directions: Objects recreated in down migrations must satisfy the same return-type, privilege, comment, view-security, and search-path rules as up migrations.
- Static Verification: Run
python3 scripts/check-sql-contracts.py --allbefore database replay. - Enforcement Scope:
check-sql-contracts.pyreads migration source text (fix violations in place).check-schema-health.pyvalidates the resolvedschema.sql(fix via forward migrations). - Unique Versions: Migration filenames must have unique numeric version prefixes to avoid collisions in
schema_migrations.
7. Schema Health Rules
scripts/check-schema-health.py validates the resolved infrastructure/db/schema.sql during test-db-local.sh.
- Foreign key indexes: Every FK column requires an index leading with that column usable by referential-integrity scans (
WHERE fk_col = $1).WHERE (deleted_at IS NULL)partial indexes do not qualify. - Exemption: Parent tables with an unconditional row-level
BEFORE DELETEguard trigger (aborts deletion before RI checks run). - Single evaluation: No duplicate call expressions within a single statement.
- Explicit
COST: Required on every function reachable from an RLS policy or exposed view (neverCOST 100, whichpg_dumpdrops as language default). - Monetary columns: Must be
numeric(18,4)(enumerated inMONETARY_COLUMNS). - No blocking
FOR UPDATE: Disallowed in function bodies that useSKIP LOCKEDto prevent worker serialization. - No
jsonbpayload parameters: Exposed functions must take composite types or scalars (see §2).p_details/p_resultare exempt by name; anything else needs a signature-keyedJSONB_PARAMETER_ALLOWLISTentry, which is itself reported once its signature no longer matches. - Scope: exposed schemas only. Private helpers may take
jsonb, since the rule is about the HTTP surface.
Notes & Pitfalls
COSTis checked for presence, not accuracy: The rule cannot distinguish a good estimate from a bad one. Committed values were chosen by relative ordering; re-measure at production volume.COST 100is omitted bypg_dump: Use a non-default value so it persists inschema.sql.ALTER FUNCTION ... COSTresets: Replaced functions without aCOSTclause revert to default.LATERALdeduplication: Requires anOFFSET 0optimization fence to prevent subquery pull-up and re-evaluation.- Non-inlinable helpers:
SECURITY DEFINERandSET search_path = pg_catalogprevent inlining. PreferLANGUAGE plpgsqloverLANGUAGE sqlfor non-inlinable helpers to avoid SQL executor overhead.
8. Reviewed, Not Linted
- Row-level
UPDATEtrigger withoutUPDATE OF: Often valid (append-only guards, immutability guards,set_updated_at,NOTIFY). Review per case. - Trigger-maintained column without readers: Requires full query analysis to detect; audited manually.
- Derived-status coupling:
receipts.workflow_status,bills.status, andprojects.is_active/deleted_atstatus propagation: - Trigger
UPDATE OFlists must cover all columns read by their status derivation functions. - Policy helpers (
can_read_receipt,can_read_bill) rely on<> 'needs_attention'matching trigger-maintained column dependencies. Verified by tests.