Generating erun's PostgreSQL Migrations with Ptah Compat
Keep erun's Atlas commands and migration history while generating PostgreSQL triggers, RLS policies, grants, and conditional role declarations.
Ptah helps you plan, review, and apply database migrations. Try in your browser
erun declares its PostgreSQL schema in SQL, including the functions, triggers, policies, and grants its application needs. Its migration workflow still required manual additions for objects that the pinned Atlas 1.2.0 binary would not generate without login. Those additions had to stay aligned with a separate desired schema.
In our proposed change, Ptah Compat
replaces that binary under the existing atlas command name. The 80 SQL source
files, 39 migrations, atlas.hcl, and original atlas.sum stay unchanged.
The proposal is open; this is a tested integration, not an announcement that
erun has adopted it.
We reproduced the migration generation with Ptah Compat 0.11.4 and PostgreSQL 18.6. We also checked the current edge implementation of conditional role creation. That newer capability removes the role-bootstrap limitation still described in the PR. The supporting files contain the source snapshot, generated SQL, and recorded results for both versions.
Keep the command and the history
Section titled “Keep the command and the history”The proposed database image copies our compatibility binary from the pinned
0.11.4 image into /usr/local/bin/atlas. The API-test and devops images use
the same release. The migration command and environment name remain:
atlas migrate apply --env default --url "$DATABASE_URL"We applied all 39 existing migrations to an empty database. Separately, we
loaded the declared SQL in the order listed by atlas.hcl. erun’s catalog
comparison found matching columns, constraints, indexes, functions, triggers,
RLS settings, policies, and grants.
Before changing the desired schema, we ran:
atlas migrate diff unchanged --env defaultThe result was an empty diff. No migration file or checksum changed. Replacing the binary did not require a new baseline or a rewritten migration history.
Generate the objects the application depends on
Section titled “Generate the objects the application depends on”The regression case in the PR adds a temporary ptah_probe table to a copy of
the desired schema. Its timestamp trigger calls erun’s existing function.
RLS is enabled and forced. A tenant policy scopes rows to the current tenant;
an operations policy permits cross-tenant access. Table grants permit reads
and inserts, while the tenant’s update grant covers only the name column.
After adding the probe declaration to atlas.hcl’s
source list, we generated and applied its migration:
atlas migrate diff generated_objects --env defaultatlas migrate apply --env default --url "$DATABASE_URL"The generated migration contains the table, timestamp trigger, both RLS settings, both policies, table grants, and the column grant. No SQL was added to that migration by hand.
We compared the resulting catalog with the declared schema, then exercised the table through PostgreSQL roles:
| Check | Observed result |
|---|---|
| Insert rows for two tenants | The timestamp trigger populated both timestamps |
| Read as one tenant | Only that tenant’s row was visible |
| Insert a row for another tenant | PostgreSQL rejected the write |
Update name as the tenant |
The update succeeded |
Update tenant_id as the tenant |
PostgreSQL rejected the column access |
| Read as operations | Both rows were visible |
A repeated generation then reported:
atlas migrate diff settled --env defaultThe migration directory is synced with the desired state, no changes to be madeThe migration directory and checksum stayed unchanged on that second diff. The catalog comparison remains part of the proposed integration: it can catch an omission in a generator as well as a mistake in a migration.
Edge also reads conditional role declarations
Section titled “Edge also reads conditional role declarations”The PR’s role-creation caveat describes 0.11.4. Its existing bootstrap
statements remain in migration history, and that version needs an explicit
migration when adding a role declared inside the schema’s procedural DO blocks.
Current edge supports that conditional role-bootstrap pattern. Ptah reads
the selected CREATE ROLE declarations into the desired schema and generates
role DDL before dependent grants. The existing bootstrap migrations still
remain unchanged.
We tested an addition to erun’s schema/roles.sql:
DO $$BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'erun_auditor') THEN CREATE ROLE erun_auditor NOLOGIN; END IF;END;$$;
GRANT USAGE ON SCHEMA public TO erun_auditor;GRANT SELECT ON audit_events TO erun_auditor;With the edge binary installed as atlas, the same workflow generated the change:
atlas migrate diff add_auditor --env defaultatlas migrate apply --env default --url "$DATABASE_URL"The generated SQL creates erun_auditor before
its grants. PostgreSQL confirmed the result (catalog excerpt):
rolname | rolcanlogin | schema_usage | audit_select | audit_insert--------------+-------------+--------------+--------------+-------------- erun_auditor | f | t | t | fThe role cannot log in. It has schema usage and table-level SELECT, with
no INSERT privilege on audit_events.
These grants do not bypass the table’s existing RLS policies; choosing which
rows this role may read is a separate policy decision.
All 39 earlier SQL migrations remained byte-identical. A second diff was empty.
There was no need to replace the old DO blocks or handwrite the new role’s
migration.
This support covers a defined role-bootstrap subset, not arbitrary PL/pgSQL.
Ptah interprets supported conditions without executing the desired DO body.
Dynamic SQL, loops, and procedural ALTER ROLE or DROP ROLE are refused.
Migration replay still executes historical SQL on a disposable server; roles
are cluster-wide, so resetting a database is not enough to reset them.
The role-bootstrap reference
describes the supported forms and replay requirements.
Keep the independent checks
Section titled “Keep the independent checks”The proposed switch keeps erun’s deployment workflow and its catalog comparison. It adds a regression that asks the generator to create the objects the application actually uses, then checks their behavior and convergence.
The released integration uses 0.11.4. Conditional role generation is the edge addition, so using it requires the newer binary. Both paths preserve the existing SQL migration history.
Put Ptah to work
Example files
- behavior.sql
- catalog.sql
- erun-db/atlas.hcl
- erun-db/migrations/default/20260501120000_initial_tenant_identity.sql
- erun-db/migrations/default/20260503143000_tenant_issuer_names.sql
- erun-db/migrations/default/20260503170000_audit_events.sql
- erun-db/migrations/default/20260621080214_tenant_issuer_org_scoping.sql
- erun-db/migrations/default/20260624145559_config_read_model.sql
- erun-db/migrations/default/20260624160330_tenant_env_quota.sql
- erun-db/migrations/default/20260625120000_context_provisioning.sql
- erun-db/migrations/default/20260714120000_env_provision_status.sql
- erun-db/migrations/default/20260816120000_env_deployed_version.sql
- erun-db/migrations/default/20260816150000_release_queue.sql
- erun-db/migrations/default/20260819120000_tenant_resource_quotas_and_usage_events.sql
- erun-db/migrations/default/20260820120000_tenant_quota_runtime_pod_floor.sql
- erun-db/migrations/default/20260821120000_env_expose_error.sql
- erun-db/migrations/default/20260822120000_context_capacity.sql
- erun-db/migrations/default/20260822130000_tenant_aggregate_resource_quota.sql
- erun-db/migrations/default/20260822140000_env_delete_lifecycle.sql
- erun-db/migrations/default/20260823130000_env_delete_attempts.sql
- erun-db/migrations/default/20260824120000_comment_body_file_path_author.sql
- erun-db/migrations/default/20260824130000_review_author_reviewers_branch_uniqueness.sql
- erun-db/migrations/default/20260824150000_builds_gate_kind.sql
- erun-db/migrations/default/20260826160000_audit_events_api_parameters.sql
- erun-db/migrations/default/20260827160000_invites.sql
- erun-db/migrations/default/20260830100000_invite_requests_and_rate_limits.sql
- erun-db/migrations/default/20260831120000_ai_sessions.sql
- erun-db/migrations/default/20260902120000_env_exposed_hostname.sql
- erun-db/migrations/default/20260902130000_gate_runs.sql
- erun-db/migrations/default/20260903120000_builds_environment_and_optional_review.sql
- erun-db/migrations/default/20260903130000_ai_sessions_delete_grant.sql
- erun-db/migrations/default/20260903140000_retention_runs.sql
- erun-db/migrations/default/20260903150000_builds_gate_version_check.sql
- erun-db/migrations/default/20260906120000_builds_profile.sql
- erun-db/migrations/default/20260921120000_environment_events.sql
- erun-db/migrations/default/20260921130000_jobs.sql
- erun-db/migrations/default/20260922120000_review_repository_identity.sql
- erun-db/migrations/default/20260923120000_builds_tenant_environment_fk.sql
- erun-db/migrations/default/20260923130000_reviews_merged_without_build.sql
- erun-db/migrations/default/20260925120000_environment_definitions.sql
- erun-db/migrations/default/20260925125000_jobs_planned_status.sql
- erun-db/migrations/default/20260925130000_reviews_issue_ref.sql
- erun-db/migrations/default/atlas.sum
- erun-db/schema/fks/review_builds.sql
- erun-db/schema/indexes/audit_events.sql
- erun-db/schema/indexes/builds.sql
- erun-db/schema/indexes/comments.sql
- erun-db/schema/indexes/contexts.sql
- erun-db/schema/indexes/environment_definitions.sql
- erun-db/schema/indexes/environment_events.sql
- erun-db/schema/indexes/environments.sql
- erun-db/schema/indexes/gate_runs.sql
- erun-db/schema/indexes/invite_requests.sql
- erun-db/schema/indexes/invites.sql
- erun-db/schema/indexes/jobs.sql
- erun-db/schema/indexes/releases.sql
- erun-db/schema/indexes/retention_runs.sql
- erun-db/schema/indexes/review_merge_queue.sql
- erun-db/schema/indexes/review_reviewers.sql
- erun-db/schema/indexes/reviews.sql
- erun-db/schema/indexes/role_permissions.sql
- erun-db/schema/indexes/usage_events.sql
- erun-db/schema/indexes/user_external_ids.sql
- erun-db/schema/indexes/user_roles.sql
- erun-db/schema/indexes/users.sql
- erun-db/schema/rls/ai_sessions.sql
- erun-db/schema/rls/audit_events.sql
- erun-db/schema/rls/builds.sql
- erun-db/schema/rls/cloud_provider_aliases.sql
- erun-db/schema/rls/comments.sql
- erun-db/schema/rls/context_credentials.sql
- erun-db/schema/rls/context.sql
- erun-db/schema/rls/contexts.sql
- erun-db/schema/rls/environment_definitions.sql
- erun-db/schema/rls/environment_events.sql
- erun-db/schema/rls/environments.sql
- erun-db/schema/rls/gate_runs.sql
- erun-db/schema/rls/invites.sql
- erun-db/schema/rls/jobs.sql
- erun-db/schema/rls/releases.sql
- erun-db/schema/rls/review_merge_queue.sql
- erun-db/schema/rls/review_reviewers.sql
- erun-db/schema/rls/reviews.sql
- erun-db/schema/rls/role_permissions.sql
- erun-db/schema/rls/roles.sql
- erun-db/schema/rls/tenant_quotas.sql
- erun-db/schema/rls/usage_events.sql
- erun-db/schema/rls/user_external_ids.sql
- erun-db/schema/rls/user_roles.sql
- erun-db/schema/rls/users.sql
- erun-db/schema/roles.sql
- erun-db/schema/tables/ai_sessions.sql
- erun-db/schema/tables/audit_events.sql
- erun-db/schema/tables/builds.sql
- erun-db/schema/tables/cloud_provider_aliases.sql
- erun-db/schema/tables/comments.sql
- erun-db/schema/tables/context_credentials.sql
- erun-db/schema/tables/contexts.sql
- erun-db/schema/tables/environment_definitions.sql
- erun-db/schema/tables/environment_events.sql
- erun-db/schema/tables/environments.sql
- erun-db/schema/tables/gate_runs.sql
- erun-db/schema/tables/invite_requests.sql
- erun-db/schema/tables/invites.sql
- erun-db/schema/tables/issuers.sql
- erun-db/schema/tables/jobs.sql
- erun-db/schema/tables/platform_rate_limits.sql
- erun-db/schema/tables/releases.sql
- erun-db/schema/tables/retention_runs.sql
- erun-db/schema/tables/review_merge_queue.sql
- erun-db/schema/tables/review_reviewers.sql
- erun-db/schema/tables/reviews.sql
- erun-db/schema/tables/role_permissions.sql
- erun-db/schema/tables/roles.sql
- erun-db/schema/tables/tenant_issuers.sql
- erun-db/schema/tables/tenant_quotas.sql
- erun-db/schema/tables/tenants.sql
- erun-db/schema/tables/usage_events.sql
- erun-db/schema/tables/user_external_ids.sql
- erun-db/schema/tables/user_roles.sql
- erun-db/schema/tables/users.sql
- erun-db/schema/triggers/comments.sql
- erun-db/schema/triggers/timestamps.sql
- ERUN-LICENSE.txt
- generated-objects.sql
- generated-role.sql
- measured/baseline-declared-catalog.txt
- measured/baseline-migrated-catalog.txt
- measured/commands.json
- measured/declared-baseline.txt
- measured/declared-probe.txt
- measured/edge-baseline-apply.txt
- measured/edge-generate-role.txt
- measured/edge-role-apply.txt
- measured/edge-role-assert.txt
- measured/edge-role-check.txt
- measured/edge-settled.txt
- measured/edge-unchanged.txt
- measured/edge-version.txt
- measured/erun_declared-create.txt
- measured/erun_edge-create.txt
- measured/erun_stable-create.txt
- measured/generated-declared-catalog.txt
- measured/generated-migrated-catalog.txt
- measured/history-checks.json
- measured/postgres-image.json
- measured/postgres-version.txt
- measured/stable-baseline-apply.txt
- measured/stable-behavior.txt
- measured/stable-generate.txt
- measured/stable-generated-apply.txt
- measured/stable-settled.txt
- measured/stable-unchanged.txt
- measured/stable-version.txt
- new-role.sql
- probe.sql
- README.md
- role-check.sql
- upstream-files.json
- verified.json