Feature: PostgREST --schemas flag with wipe-and-rewrite reconciliation
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Completed
Goal: Extend ./uis configure postgrest to expose multiple PostgreSQL schemas via a single PostgREST instance, with deterministic reconfigure semantics.
Last Updated: 2026-05-06
Completed: 2026-05-06 — PR #140 merged (commits feb1562 + 120327b); CI workflows green (Build UIS Container, Test UIS Scripts, Generate UIS Documentation, Deploy Documentation); rebuilt image published as ghcr.io/helpers-no/uis-provision-host:latest@sha256:42cd40d5f66916a6f6071ab4d69fcf0080a2915b1cf93295bd3b169b8af42f31. Atlas validated row-flow + privacy boundary against the live deployment in cross-repo talk Message 4.
Investigation: INVESTIGATE-postgrest-multi-schema-reconciliation.md
Cross-repo coordination: atlas talk thread Messages 1–4 — direction confirmed (atlas Message 3), implementation summary (uis Message 2), atlas validation green (atlas Message 4). Atlas's setup.md lines 294–295 update lands on their side once they pull this image.
Overview
PLAN-002 shipped a single-schema PostgREST handler. Atlas now needs three schemas exposed (api_v1, marts, raw) and the design must support arbitrary schema-list changes across configure runs without leaking grants. The investigation settled on a wipe-and-rewrite approach (DROP OWNED BY + per-schema GRANTs in one transaction) with the schema list memorized in the per-app secret. Deploy reads the schema list from the secret, eliminating configure/deploy drift.
The full design is in the investigation's Final contract section. This plan executes it.
Phase 1: Input parsing and validation
Pure CLI / handler input handling. No DB or k8s side effects.
Tasks
- 1.1 In
provision-host/uis/manage/uis-cli.shcmd_configure: replace the existing--schemaflag with--schemas(line 308).--schemafalls through to the unknown-option error. Atlas is the only consumer; atlas's setup.md is being updated per atlas Message 3; the only docs / call-sites still using--schemaare inside this repo and are all rewritten by this plan. - 1.2 In
provision-host/uis/lib/configure.shrun_configure: replace--schemawith--schemasin the argument-parsing case block (line 126). Internal variable is renamedschema→schemasfor clarity. The parsed value flows through to the handler viaconfigure_service(line 259) — same dispatch, new name. - 1.3 In
configure-postgrest.sh: add a_pgrst_normalize_schemas()helper that takes the raw schemas value and returns the normalized comma-separated list. Steps: split on,, trim each component, reject empty value, reject empty components after trim, reject components not matching^[a-zA-Z_][a-zA-Z0-9_]*$, de-dupe while preserving first occurrence (calllog_warnto stderr for any drop, naming the duplicate), join with,(no spaces). - 1.4 In
configure-postgrest.shdefault-path entry: call the normalizer first; surface its rejections via_configure_error "usage" …with the offending input named in the message. - 1.5 Update the existing usage error at
configure-postgrest.sh:166to reference--schemas api_v1,marts,rawin the example.
Validation
# Build local image (contributor-side; the tester will run the integration cycle later)
./uis build
# Sanity checks on read-only command help
docker run --rm uis-provision-host:local uis configure postgrest --help 2>&1 | grep -E '\-\-schemas?'
Help output mentions only --schemas (no --schema). User confirms.
Phase 2: Wipe-and-rewrite reconciliation
Implement the State Matrix dispatch from the investigation. This phase is structural; SQL contents and path-by-path behavior are defined in the investigation's State Matrix section.
Tasks
-
2.1 Add
_pgrst_get_secret_schemas()helper toconfigure-postgrest.sh— reads the existing secret (if present) and returns thePGRST_DB_SCHEMASvalue, empty string if key absent or secret missing. (Companion to existing_pgrst_secret_existsand_pgrst_role_exists.) -
2.2 Add
_pgrst_get_secret_uri()helper — readsPGRST_DB_URIfrom the existing secret, empty if absent. Needed for the Reconfigure-preserve-URI path. -
2.3 After the normalize call wired in task 1.4 (string-level checks complete), add R4 step 4: per-schema existence check via
SELECT 1 FROM pg_namespace WHERE nspname=$1. Any miss → reject naming the offender via_configure_error "usage" …. Must run before any state inspection (no role/secret reads happen until validation passes). -
2.4 Implement state detection: read the three booleans (
web_anon_exists,authenticator_exists,secret_schemas_value) and dispatch to one of the five paths from the investigation's State Matrix. Onecasestatement; one helper per path. Inconsistent cells emit the error messages exactly as written in §State Matrix → "Inconsistent" subsection. -
2.5 Implement the four non-trivial paths as separate helper functions, each containing a single SQL transaction:
_pgrst_path_first_time()_pgrst_path_reconfigure_preserve_uri()_pgrst_path_reconfigure_fresh_password()- (No-op path: just logs and returns; no helper needed.)
SQL contents per the investigation's path definitions. Replace the existing CREATE-ROLE + GRANT block (
configure-postgrest.sh:386–404). -
2.6 Extend
_pgrst_create_secret(or add_pgrst_create_secret_with_schemas) to write bothPGRST_DB_URIandPGRST_DB_SCHEMASkeys. Existing single-key callers updated; rotate path also calls through this. -
2.7 At the end of every path that committed SQL, issue
NOTIFY pgrst, 'reload schema'via_pgrst_exec_dbagainst the per-app database. -
2.8 Update the JSON success output to include
"schemas": "<normalized list>". Update_configure_errorJSON shape only if a new error code is added (none expected).
Validation
# After tester deploys with the test brief (see Phase 7), sanity from contributor side:
docker exec uis-provision-host kubectl get secret atlas-postgrest -n postgrest \
-o jsonpath='{.data.PGRST_DB_SCHEMAS}' | base64 -d
# Expected: api_v1,marts,raw
docker exec uis-provision-host kubectl exec -n default postgresql-0 -- \
psql -U postgres -d atlas_db -c "\dn+"
# Expected: atlas_web_anon listed with USAGE on api_v1, marts, raw — and only those.
User confirms wipe-and-rewrite produces exact end state for: first-run, add-schema, remove-schema, replace-schema, order-only-change.
Phase 3: Rotate path preservation + missing-key error
Tasks
- 3.1 In
configure-postgrest.shrotate path (lines ~290–336): before generating the new password, read the existing secret'sPGRST_DB_SCHEMAS. If the key is missing, error with: "PGRST_DB_SCHEMASnot present in secret. Run./uis configure postgrest --app <app> --schemas <list>first to establish the schema list, then retry rotate." If the key is present but malformed (e.g. operator hand-edited the secret), trust the value — re-validation isn't rotate's job; configure was the only legitimate writer. - 3.2 On successful rotate, rewrite the secret with both
PGRST_DB_URI(new password) and the preservedPGRST_DB_SCHEMAS.
Validation
# After Phase 2 lands and atlas is configured:
docker exec uis-provision-host uis configure postgrest --app atlas --rotate
docker exec uis-provision-host kubectl get secret atlas-postgrest -n postgrest \
-o jsonpath='{.data.PGRST_DB_SCHEMAS}' | base64 -d
# Expected: still api_v1,marts,raw (preserved across rotate).
User confirms rotate preserves schema list and fails loudly on PLAN-002-era secrets.
Phase 4: Deploy template + playbook switch to secretKeyRef
Tasks
- 4.1 In
ansible/playbooks/templates/088-postgrest-config.yml.j2: replace thePGRST_DB_SCHEMASenv entry'svalue: "{{ _schema }}"withvalueFrom.secretKeyRefpointing at the per-app secret'sPGRST_DB_SCHEMASkey. - 4.2 In
ansible/playbooks/088-setup-postgrest.yml: drop_schemafrom the required-extra-vars assertion (task 1, lines 38–46). - 4.3 In the same playbook: remove the
Schema: {{ _schema }}line from the debug output (task 2, lines 51–56). - 4.4 (Decided: skipped.) No extra key-presence check in the playbook. Kubernetes' built-in
secretKeyRefresolution will surface a clearCreateContainerConfigError("couldn't find key PGRST_DB_SCHEMAS in Secret postgrest/-postgrest") on pod start if configure wasn't run first. That error is readable enough; the rotate-path error in Phase 3.1 already forces an upgrade-via-configure before deploy.
Validation
docker exec uis-provision-host ansible-playbook \
/mnt/urbalurbadisk/ansible/playbooks/088-setup-postgrest.yml \
-e "_app_name=atlas _url_prefix=api-atlas" --check
Dry-run completes without "missing extra-var" errors. User confirms the rendered Deployment references valueFrom.secretKeyRef for PGRST_DB_SCHEMAS.
Phase 5: CLI cleanup on the deploy path
Tasks
- 5.1 In
uis-cli.shcmd_deploy(lines ~297–371): remove--schemaflag parsing and theschemalocal variable. - 5.2 Remove the
schema="${schema:-api_v1}"default and theschemaargument from thedeploy_single_servicecall. - 5.3 In the same file's "Service is single-instance" rejection (line ~346), remove
--schemafrom the disallowed-flags message (the message currently lists--app/--url-prefix/--schema). - 5.4 In
provision-host/uis/services/integration/service-postgrest.sh: update the example command at line 10 to show--schemason configure and bare./uis deploy postgrest --app <name>on deploy. - 5.5 In
ansible/playbooks/088-setup-postgrest.yml(line 80): update the fail message that prints the configure-retry hint to use--schemas <list>instead of--schema {{ _schema }}.
(Note: configure.sh is the configure-side dispatcher only; it has no deploy-side dispatch to clean up. Phase 1.2 already migrates it from --schema to --schemas.)
Validation
docker run --rm uis-provision-host:local uis deploy postgrest --help 2>&1 | grep -i schema
Expected: no mention of --schema or --schemas for the deploy subcommand. User confirms.
Phase 6: Tests
Tests live at provision-host/uis/tests/{unit,deploy}/. Follow the existing test-configure-namespace.sh (unit) / test-configure-namespace-integration.sh (integration) naming pattern.
Tasks
- 6.1 Add
provision-host/uis/tests/unit/test-configure-postgrest-schemas.shcovering_pgrst_normalize_schemas(): valid lists; whitespace trimming; empty value rejection; empty-component rejection; identifier-regex rejection; duplicate de-dupe with warning to stderr. Plus a CLI-parsing case asserting--schemais rejected as an unknown option (no aliasing). - 6.2 Add
provision-host/uis/tests/deploy/test-configure-postgrest-schemas-integration.shwith the following cases. (Each case is a function in the file; the file mirrors the structure oftest-configure-namespace-integration.sh.) - 6.3 Case: first-time configure with
--schemas api_v1,marts,raw— assert all three grants present (SELECT has_schema_privilege('atlas_web_anon', '<schema>', 'USAGE')per schema), secret has both keys, secret'sPGRST_DB_SCHEMASvalue equalsapi_v1,marts,raw. - 6.4 Case: re-run with same list — assert no-op message printed, no rows changed (compare
(SELECT array_agg(nspname) FROM pg_namespace WHERE has_schema_privilege('atlas_web_anon', oid, 'USAGE'))before/after). - 6.5 Case: reconfigure with
--schemas api_v1,marts— assertrawgrants gone:SELECT count(*) = 0 FROM pg_default_acl
WHERE defaclnamespace = (SELECT oid FROM pg_namespace WHERE nspname='raw')
AND defaclacl::text LIKE '%atlas_web_anon%';
SELECT NOT has_schema_privilege('atlas_web_anon', 'raw', 'USAGE'); - 6.6 Case: order-only change
api_v1,marts,raw→marts,raw,api_v1— assert SQL end-state grants identical (samepg_namespaceset), secret'sPGRST_DB_SCHEMASexactly equalsmarts,raw,api_v1. - 6.7 Case: missing schema in
--schemas(e.g.--schemas api_v1,bogus) — assert pre-validation rejects with a message namingbogus; assert no roles created (SELECT count(*) FROM pg_roles WHERE rolname IN ('atlas_web_anon','atlas_authenticator')= 0 if first-time, unchanged if reconfigure). - 6.8 Case:
<app>_authenticator → <app>_web_anonmembership survivesDROP OWNED BY. Configure first-time, run a reconfigure that triggersDROP OWNED BY, assert membership still present:SELECT EXISTS (
SELECT 1 FROM pg_auth_members
WHERE roleid = (SELECT oid FROM pg_authid WHERE rolname='atlas_web_anon')
AND member = (SELECT oid FROM pg_authid WHERE rolname='atlas_authenticator')
); - 6.9 Case: rotate fails with the stated error message when secret has no
PGRST_DB_SCHEMASkey (simulate bykubectl patch secret … --type=json -p='[{"op":"remove","path":"/data/PGRST_DB_SCHEMAS"}]'first). - 6.10 Case: recovery — delete secret manually (
kubectl delete secret atlas-postgrest -n postgrest), re-run configure with--schemas api_v1,marts,raw. Assert: secret rebuilt with both keys; password changed (auth check with the new secret's URI succeeds, with the prior captured URI fails); grants correct on all three schemas. Per task 2.5, this hits the "reconfigure with missing secret" sub-path withALTER USER … PASSWORD. - 6.11 Case: PLAN-002-era upgrade — write a secret with only
PGRST_DB_URI(noPGRST_DB_SCHEMAS), then run configure with--schemas api_v1,marts,raw. Assert: secret gainsPGRST_DB_SCHEMAS; originalPGRST_DB_URIpreserved (password unchanged); grants applied correctly. - 6.12 Case: partial-role state — manually
DROP ROLE atlas_authenticator(leavingatlas_web_anon), re-run configure. Assert: error message names the surviving role; no SQL state change; suggests--purge. - 6.13 Case: deploy with no schema flags reads
PGRST_DB_SCHEMASfrom secret correctly. After configure with--schemas api_v1,marts,rawand./uis deploy postgrest --app atlas, assert OpenAPI athttp://api-atlas.localhost/advertises endpoints from all three schemas (one HEAD or GET per schema returning 200; rows or empty array, both pass).
Validation
docker exec uis-provision-host bash -lc \
'cd /mnt/urbalurbadisk/provision-host/uis/tests && ./run-tests.sh postgrest'
All tests pass. User confirms.
Phase 7: Documentation
Tasks
- 7.1 In
website/docs/services/integration/postgrest.md: at lines 77 and 220 (the two./uis configure postgrestexamples that currently pass--schema api_v1), replace--schema api_v1with--schemas api_v1,marts,rawand update surrounding prose to match. Audit the rest of the page for any other--schemareferences. - 7.2 Add a new subsection "Reconfigure semantics": explain wipe-and-rewrite, the deterministic-end-state contract, and the order-significance note (first schema is PostgREST's default schema served when
Accept-Profileis omitted). - 7.3 Add a new subsection "Inspecting the current schema list" with the kubectl one-liner:
kubectl get secret <app>-postgrest -n postgrest -o jsonpath='{.data.PGRST_DB_SCHEMAS}' | base64 -d. - 7.4 Add a "UIS-managed role contract" callout: "
<app>_web_anongrants are exclusively UIS-managed. ManualGRANT … TO <app>_web_anonwill be lost on the next configure." - 7.5 Add a "Upgrading from PLAN-002 single-schema" subsection: "Run
./uis configure postgrest --app <app> --schemas <list>once to populate the newPGRST_DB_SCHEMASsecret key before any rotate. Roles persist; the existingPGRST_DB_URI(and therefore the password) is preserved; grants are reset to match<list>exactly via the wipe-and-rewrite path. If<list>includesapi_v1(the PLAN-002 default), the resulting grants onapi_v1are functionally identical to before."
Validation
# Build docs locally if a local docs server is available; otherwise rely on CI's
# "Generate UIS Documentation" + "Deploy Documentation" workflows.
cd website && npm run build
User confirms postgrest.md renders correctly and the new subsections read cleanly.
Phase 8: End-to-end smoke against atlas's use case
This phase is run by the tester, via a talk*.md brief in /Users/terje.christensen/learn/helpers/testing/uis1/talk/. The contributor writes the brief; tester executes; contributor reviews results.
Tasks
- 8.1 Contributor: build local image (
./uis build) and write the test brief. - 8.2 Tester: ensure atlas's database exists. Run
UIS_IMAGE=uis-provision-host:local ./uis configure postgresql --app atlas --database atlas_db --json— idempotent; runs once if the cluster has been reset since this plan was written, no-op otherwise. - 8.3 Tester: run
UIS_IMAGE=uis-provision-host:local ./uis configure postgrest --app atlas --database atlas_db --schemas api_v1,marts,raw --url-prefix api-atlas. Assert clean exit with JSON listing all three schemas. - 8.4 Tester: run
UIS_IMAGE=uis-provision-host:local ./uis deploy postgrest --app atlas --url-prefix api-atlas. Assert clean PLAY RECAP and pod readiness. - 8.5 Tester:
curl -fsS http://api-atlas.localhost/returns OpenAPI advertising endpoints from all three schemas. Spot-check one endpoint per schema returns 200 (rows or empty array, both pass —marts.*/raw.*may be empty in the tester's environment if dbt/ingest hasn't run). - 8.6 Tester: re-run configure with
--schemas api_v1,marts(dropraw). Assert OpenAPI no longer advertisesraw.*. Assertpg_default_aclcleared forraw:SELECT count(*) = 0 FROM pg_default_acl
WHERE defaclnamespace = (SELECT oid FROM pg_namespace WHERE nspname='raw')
AND defaclacl::text LIKE '%atlas_web_anon%'; - 8.7 Tester: re-run configure with
--schemas api_v1,marts,raw(restore). Assert restoration. - 8.8 Contributor: open PR, ensure CI green (Build UIS Container, Test UIS Scripts, Generate UIS Documentation, Deploy Documentation), merge.
- 8.9 Surface the rebuilt-image SHA to the user (contributor) once CI publishes it. The user owns cross-repo communication with atlas — they decide when/whether to update
atlas/website/docs/ai-developer/plans/talk/talk.md. Claude does not proactively write to the atlas talk thread.
Validation
User (contributor) confirms tester results, CI green, merge complete, atlas pinged.
Acceptance Criteria
-
./uis configure postgrest --app <app> --schemas <list>accepts comma-separated lists and runs the normalize/validate pipeline (R4) before any SQL. - First-time configure creates roles + grants + secret (with both
PGRST_DB_URIandPGRST_DB_SCHEMAS) atomically. - Reconfigure with a different list wipes prior grants (including
pg_default_aclentries for dropped schemas) and re-applies the new list, in one transaction. - No-op short-circuit fires only when both roles + secret exist AND
PGRST_DB_SCHEMASexact-string-matches the normalized incoming list. -
--rotatepreservesPGRST_DB_SCHEMAS; errors loudly when the key is absent. -
--purgeclears both keys (no change required; verify only). - Deploy CLI no longer accepts
--schemaor--schemas; deploy template readsPGRST_DB_SCHEMASviavalueFrom.secretKeyRef. -
<app>_authenticator → <app>_web_anonmembership survivesDROP OWNED BY(covered by test 6.8). -
postgrest.mddocuments--schemas, the wipe-and-rewrite contract, the kubectl listing one-liner, the order-significance note, the UIS-managed-role invariant, and the PLAN-002-upgrade note. - CI workflows green; rebuilt
uis-provision-host:latestpublished to GHCR; SHA reported to the user (cross-repo comms with atlas are user-driven, not Claude-driven).
Files to Modify
provision-host/uis/lib/configure-postgrest.sh— normalize/validate helper, no-op short-circuit, role-state detection, three-way path (first-time / reconfigure-with-secret / reconfigure-without-secret), wipe-and-rewrite SQL, secret-with-schemas write, NOTIFY, rotate-preserves-key, error-message updates.provision-host/uis/lib/configure.sh— replace--schemawith--schemasin argument parsing; rename internal variableschema→schemas.provision-host/uis/manage/uis-cli.sh— replace--schemawith--schemason the configure path; remove both--schemaand--schemasfrom the deploy path.provision-host/uis/services/integration/service-postgrest.sh— example commands.ansible/playbooks/templates/088-postgrest-config.yml.j2—PGRST_DB_SCHEMASviavalueFrom.secretKeyRef.ansible/playbooks/088-setup-postgrest.yml— drop_schemaextra-var assertion + debug-line reference.provision-host/uis/tests/unit/test-configure-postgrest-schemas.sh— new (Phase 6.1).provision-host/uis/tests/deploy/test-configure-postgrest-schemas-integration.sh— new (Phase 6.2 onward).website/docs/services/integration/postgrest.md— Phase 7 content.
Implementation Notes
- SQL identifier safety: the existing handler bash-substitutes
$schemadirectly into SQL (configure-postgrest.sh:400). The R4 regex (^[a-zA-Z_][a-zA-Z0-9_]*$) is the SQL-injection guard — Phase 1 task 1.3 (the normalizer) must reject before any string interpolation. Do not skip the regex even though Atlas's identifiers are clean today. - Transaction wrapping: the current handler runs CREATE ROLE inside a DO block, then grants outside it. With
--set ON_ERROR_STOP=oneach top-level statement is its own implicit transaction. Phase 2 task 2.5 must wrap the whole SQL block in explicitBEGIN; … COMMIT;so a per-schema GRANT failure rolls back the role creation. Pre-validation (task 2.3, exercised by test 6.7) covers the primary failure mode (missing schema named in--schemas); the transaction wrapping is defense-in-depth against a schema being dropped by another process between pre-validation and the GRANT — race-condition territory, not directly exercised by tests. DROP OWNED BYscope: per-database, drops objects owned by + revokes privileges granted to the role. Run inside the per-app database (the_pgrst_exec_dbpattern already does this). Does NOT touch cluster-level role memberships —<app>_authenticator → <app>_web_anonsurvives. Test 6.8 verifies.- Secret rewrite atomicity:
kubectl create secret … --dry-run=client -o yaml | kubectl applyreplaces the entire secret. Read existing keys first; rewrite both. Don't dropPGRST_DB_URIaccidentally during a--rotateor schema-only reconfigure. - NOTIFY scope:
NOTIFY pgrst, 'reload schema'runs against the per-app database. PostgREST'sdb-channeldefaults topgrston the database the connection string points at. No special config needed. --schemais gone, not aliased: any operator passing--schemagets a standard "Unknown option" error from the CLI parser. Atlas's setup.md updates to--schemasper atlas Message 3; UIS-internal docs are rewritten in this PR.
Out of Scope
- Authenticated schemas / role-pairs (Atlas's
private_marts/private_raw) — PLAN-003 territory. - Per-table grant control —
--schemasis schema-granularity only. - Per-instance
./uis statusreporting (Decision #19 fromINVESTIGATE-postgrest.md) — separate plan. - Atlas-side
setup.mdupdates — Atlas owns those (atlas Message 3).