Files
member-console/openspec/changes/runtime-database-user/tasks.md
T
cgalo5758 40ce2d3d7f Read the database's -file key in every command that opens the database
Second section of #23, a quick fix listed in its change. Only start and
config validate resolved db-dsn-file, so migrate, config list|set|clear
and seed-demo, run in a container that mounts the connection string as a
file, found no string and let the driver connect with its defaults. They
now resolve the database's secret pair before opening it, and openDB
refuses an empty db-dsn ("db-dsn is not set (MC_DB_DSN or
MC_DB_DSN_FILE)") before it opens anything. connectSettings returns its
error, so a test can call it; the config commands print it as before.

TestDatabaseCommandsReadTheFileKeys runs migrate up, down and status,
connectSettings and seed-demo with only db-dsn-file set, naming a closed
port, and expects the connection failure; with neither key set it
expects the refusal. It blanks MC_DB_DSN and MC_DB_DSN_FILE, so the rows,
migrate down among them, never reach a real database.

Full suite on reset databases: 4207 pass, 15 skip. make lint passes.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (6):
  migrate up, migrate down, connectSettings and seed-demo each without
  resolveDatabaseSecretFiles
  openDB without the empty db-dsn check
  resolveDatabaseSecretFiles resolves an empty list
2026-10-23 06:06:40 -04:00

60 KiB

Tests follow docs/testing.md: database tests open the database through dbtest, a test whose subject is a migration or the migrator gets a dbtest.Scratch database, and tests meet the same complexity limit as production code, so a table runs its rows through a named function. A test that proves a privilege by running a statement drops to db.RuntimeGroupRole with SET LOCAL ROLE inside a transaction it rolls back, and asserts the SQLSTATE (42501 for a privilege, 22023 for a refused argument), never only a word of the message. A test that creates a role does it inside a transaction it rolls back, or drops the role in its cleanup. A refusal's test compares the whole sentence with its entry in the design's Copy table.

Sections 1 to 6 are one commit each, and their items are that commit's parts; section 7 is not a commit. Every commit passes make test with the test stack up, which runs make lint first, including TestConsoleReaderColumns, TestEnvironmentReferenceDocumentsEveryConfigKey, filler-copy and em-dash-copy. No golden file and no screen changes in any section, and no existing test's result changes except where a section's items name the test; any other change is a stop. The copy is the design's Copy table, approved by the maintainer before section 1 starts; a string that differs from its approved entry is a stop. Sections 1 to 3 prove the roles by SET LOCAL ROLE console_runtime on the superuser TEST_DATABASE_URL, so they need no runtime login and pass on any worktree's stack as it stands; section 4 does not (its preamble says so). Paths under migrations/ are in the stream the item names: internal/db/migrations/ (core), internal/domains/migrations/, and internal/integrations/<key>/store/migrations/.

Breaks. Each section's list is written to a file outside the repository and run with go run ./scripts/mutation-check -list <file> -side new on the section's commit, after its green control run; a new test has no old side. The enclosure tests that section 1 moves from core_writer or applicationRole to console_runtime are converted checks: their breaks also run with -side old on a checkout of the parent commit, and a break the old side misses is named in the commit message as the strength the move adds. A break in a migration file reaches the shared test database only after it is migrated again, so a list with such a break carries "prepare": "cd test && ./reset-test-db.sh"; a break in a role membership, which is cluster-wide and survives a reset, reaches its test without one (design D16). Every record must be CAUGHT. An old text below is the code as the section's tasks write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped. The commit message lists every break with its result.

1. The runtime group role, the writers' privileges and the baselines' logins

  • 1.1 Core migrations/00029_runtime_group_role.sql (design D5, D6, D7), one StatementBegin block per section. Up: a DO block that creates console_runtime NOLOGIN when pg_roles has no such role, and revokes core_writer from member_console when pg_roles has that role and pg_auth_members records the membership; then GRANT core_writer TO console_runtime and GRANT SELECT ON goose_db_version_core TO console_runtime (unqualified, as goose created it). Down: REVOKE SELECT ON goose_db_version_core FROM console_runtime, and DROP ROLE IF EXISTS console_runtime inside a block that catches dependent_objects_still_exist and raises a notice, as the baselines' Down sections do. The Down neither revokes core_writer from console_runtime nor grants it back to member_console. The file's header comment says why the role exists, why the grant is unqualified, and why the Down leaves both memberships.
  • 1.2 The same for the other four streams: domains migrations/00003_runtime_group_role.sql, FedWiki migrations/00005_runtime_group_role.sql, Stripe migrations/00006_runtime_group_role.sql, Discourse migrations/00004_runtime_group_role.sql, each with its own writer and its own ledger, the guarded revoke from member_console (the role must exist and hold the membership, because revoking from a role that does not exist is an error), and a Down that revokes only the ledger grant. None creates or drops console_runtime.
  • 1.3 The five baselines name no login (design D6): delete GRANT core_writer TO member_console and its Down REVOKE from core internal/db/migrations/00001_init.sql (:1173 and its Down), and the same statement and Down REVOKE for domains_writer (internal/domains/migrations/00001_init.sql:201), fedwiki_writer (internal/integrations/fedwiki/store/migrations/00001_init.sql:100), stripe_writer (internal/integrations/stripe/store/migrations/00001_init.sql:165) and discourse_writer (internal/integrations/discourse/store/migrations/00001_init.sql:122). The comment above core's statement, which says the role keeps its privileges explicitly, is rewritten to say that the runtime user reaches the writers through console_runtime (migrations 00029 and its siblings). Nothing else in the baselines changes in this section.
  • 1.4 The writers keep the four DML privileges (design D13): migrations/00030_writer_dml_only.sql in core, 00004_writer_dml_only.sql in domains, 00006_writer_dml_only.sql in FedWiki, 00007_writer_dml_only.sql in Stripe and 00005_writer_dml_only.sql in Discourse. Each Up is one statement, REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA <schema> FROM <schema>_writer; each Down does nothing, and the header comment says why (re-granting would also undo enclosures that revoked TRUNCATE earlier, and no code path used the privileges) and that a table added later must be granted SELECT, INSERT, UPDATE, DELETE by name, never ALL. The earlier GRANT ALL lines are not edited.
  • 1.5 internal/db/runtime_user.go (new): the constant RuntimeGroupRole = "console_runtime", with a doc comment naming the migrations that grant to it.
  • 1.6 The enclosure tests move to the group role (design D15). Delete applicationRole from internal/db/integration_delivery_fixtures_test.go and update the file's header comment; every caller (acts_test.go, integration_delivery_enclosure_test.go, plan_act_obligations_test.go) passes db.RuntimeGroupRole to asRole. TestConferralEnclosure_WriterRole (internal/entitlements/conferral_enclosure_test.go) and TestRuleTableDMLRevoked (internal/entitlements/enclosure_test.go) run SET LOCAL ROLE console_runtime through the constant, and their comments say that the test login is a superuser and that the group role is what the runtime user holds. TestConferralEnclosure_WriterRole gains three probes on each of the three position tables, each in a savepoint expecting SQLSTATE 42501: TRUNCATE <table>; CREATE TRIGGER naming a trigger function the test created in pg_temp first, so the error cannot be a missing function; and LOCK TABLE <table> IN ACCESS EXCLUSIVE MODE. Its header comment names TRUNCATE, TRIGGER and MAINTAIN with the other three writes. The tests that drop to core_reader, core_owner, core_writer (plan_act_obligations_test.go:848) or a bare role keep doing so. The other tests' results do not change.
  • 1.7 TestStreamsGrantTheirWriterToTheRuntimeRole (internal/db/runtime_role_test.go), on a dbtest.SimpleProtocol connection to the test database, in one transaction it rolls back: revoke the five writers and the five ledger grants from console_runtime; grant the writers to member_console when that role exists, which simulates a database an earlier release migrated; run the Up section of each of the five group-role migrations (dbtest.UpSQL), core first; then assert that console_runtime inherits each writer (pg_has_role(..., 'USAGE')), that member_console is a direct member of none, and that console_runtime holds SELECT on each stream's ledger. A last row runs each of the five Up sections again with member_console replaced by a name no role has (dbtest.Name("mc_absent_")) and expects no error: on the test stack member_console always exists, so no other row notices a missing guard. TestBaselinesGrantNoLogin, on a scratch database: revoke the five writers from member_console first, when the role exists; migrate core to version 28, the one before 00029, with dbtest.UpTo, then apply version 1 of domains, FedWiki, Stripe and Discourse; assert that member_console is a direct member of no writer role; revoke again in cleanup, because the membership would be cluster-wide. Core goes first and goes that far because a real run migrates core to its end before any other stream, and domains' baseline inserts a kind column that core 00008 adds, so version 1 of core alone fails the test on correct code; the run stops before the group-role migrations because they would revoke a restored grant and hide it. TestRuntimeRoleOutlivesARollbackElsewhere migrates a scratch database with migrate.Sources(), rolls back each stream's group-role migration with dbtest.ApplyVersion(..., false), integrations first, then core 00029, and asserts that console_runtime still exists, because the shared test database still grants it the ledgers, that it still inherits core_writer, and that member_console is still a direct member of no writer role.
  • 1.8 TestRuntimeRolePrivileges (internal/db/runtime_role_privileges_test.go, design D16), on the shared test database: the locked list as a table in the test, each entry naming the privileges it holds (the three position tables hold SELECT); for every relation of the five schemas, partitions and views included, console_runtime holds none of TRUNCATE, REFERENCES, TRIGGER and MAINTAIN. Every failure names the relation and the privilege. It also asserts that no role but console_runtime is a direct member of a writer role, apart from fedwiki_writer, which the FedWiki stream makes a member of domains_writer.
  • 1.9 Breaks:
    • core 00029 without GRANT core_writer TO console_runtime: caught by TestStreamsGrantTheirWriterToTheRuntimeRole (no reset);
    • FedWiki 00005 grants domains_writer in place of fedwiki_writer: caught by the same test;
    • Stripe 00006 without its revoke from member_console: caught by the same test;
    • Discourse 00004 without its ledger grant: caught by the same test and, with the reset, by TestRuntimeRolePrivileges;
    • the core baseline with GRANT core_writer TO member_console restored: caught by TestBaselinesGrantNoLogin;
    • the Discourse baseline with GRANT discourse_writer TO member_console restored: caught by TestBaselinesGrantNoLogin;
    • core 00029's Down revokes core_writer from console_runtime: caught by TestRuntimeRoleOutlivesARollbackElsewhere;
    • core 00029's Down drops console_runtime without the dependent_objects_still_exist handler: caught by the same test, whose rollback then fails;
    • core 00029's Down grants core_writer back to member_console: caught by the same test;
    • core 00030 emptied, so the baseline's GRANT ALL stands (with the reset): caught by TestRuntimeRolePrivileges, naming a core table and TRIGGER, and by the three probes of TestConferralEnclosure_WriterRole;
    • core 00030 revokes TRUNCATE, REFERENCES and MAINTAIN and leaves TRIGGER (with the reset): caught by TestRuntimeRolePrivileges naming the table and TRIGGER, and by the CREATE TRIGGER probe;
    • core 00030 revokes from two position tables only, by listing the tables in place of ALL TABLES (with the reset): caught by the probes on the third table;
    • domains 00004, FedWiki 00006, Stripe 00007 and Discourse 00005, each emptied in turn (with the reset): caught by TestRuntimeRolePrivileges, naming a table of that stream's schema and TRIGGER;
    • GRANT ALL ON core.integration_config_overrides TO core_writer appended after the revoke in core 00030 (with the reset): caught by TestRuntimeRolePrivileges, naming that table and TRIGGER;
    • 00009's GRANT ALL ON core.integration_config_overrides TO core_owner, core_writer loses core_writer (with the reset): caught by TestRuntimeRolePrivileges, naming the table and INSERT;
    • 00027's GRANT SELECT ON core.acts, core.plan_act_details TO core_writer becomes GRANT SELECT, INSERT (with the reset): caught by TestRuntimeRolePrivileges and by TestActRecordIsEnclosed;
    • a locked entry for a table that does not exist added to the list: caught by TestRuntimeRolePrivileges (a test-side record; it proves the stale-entry check);
    • the membership assertion of TestRuntimeRolePrivileges without its fedwiki_writer exception: caught, naming fedwiki_writer (a test-side record; a production break would leave a cluster-wide membership behind);
    • domains 00003's revoke without its guard on the role existing: caught by the absent-role row of TestStreamsGrantTheirWriterToTheRuntimeRole (no reset);
    • as converted checks, on both sides (with the reset): 00005's REVOKE INSERT, UPDATE, DELETE ON core.pool_provisions FROM core_writer loses INSERT: caught by TestConferralEnclosure_WriterRole on both sides; 00024's GRANT SELECT ON core.person_cut_offs TO core_writer becomes GRANT SELECT, INSERT: caught by TestPersonCutOffEnclosure on both sides;
    • on the new side only, since console_runtime does not exist on the old one (with the reset): core 00029 also grants INSERT on core.pool_provisions to console_runtime: caught by TestConferralEnclosure_WriterRole and TestRuntimeRolePrivileges, a grant that a test run as core_writer cannot see; the same grant of TRIGGER: caught by the CREATE TRIGGER probe and TestRuntimeRolePrivileges.
  • 1.10 docs/building-an-integration.md, section 7: the "member_console membership" bullet becomes "console_runtime membership": a baseline grants {key}_writer and SELECT on goose_db_version_<key> to console_runtime, never to a login, and core creates the role first; a table is granted SELECT, INSERT, UPDATE, DELETE by name and never ALL, and the stream's writer_dml_only migration revokes the other four; the Down bullet says the Down revokes the ledger grant and leaves the membership (design D7). Section 9's FedWiki paragraph says "its role triple and its grants to console_runtime". docs/database-management.md, "Roles": the paragraph about member_console describes console_runtime, the five memberships, the ledger grants, that the writers hold the four DML privileges and no other, that the console grants the role to the runtime user at start, and that owner roles are granted to no login; "Owner roles are reserved for running migrations" goes. The ownership paragraph lands in section 3.
  • 1.11 No golden moves; internal/migrate/testdata/console-reader-columns.txt does not change.

2. Quick fix: every command that opens the database reads the -file keys

  • 2.1 cmd/start.go (design D12): split the core secret pairs into databaseSecretPairs (db-dsn/db-dsn-file) and the rest; the loop of resolveSecretFiles becomes resolveSecretPairs(pairs); resolveSecretFiles resolves the core pairs, the database pairs and the integrations' pairs as today; a new resolveDatabaseSecretFiles resolves databaseSecretPairs alone. Doc comments say which commands call which and why a command resolves once.
  • 2.2 migrate up, migrate down, migrate status (cmd/migrate.go), connectSettings (cmd/config.go, the one path of config list, set and clear) and seed-demo (cmd/seed_demo.go) call resolveDatabaseSecretFiles before openDB. connectSettings returns its error, and the three Run functions print it and exit as they do today, so a test can call it.
  • 2.3 openDB refuses an empty db-dsn with copy 13 before it opens anything.
  • 2.4 TestDatabaseCommandsReadTheFileKeys (cmd/database_files_test.go), one row per path through a named function: migrate up and migrate status (their RunE), connectSettings, and seed-demo (its RunE). Each row sets only db-dsn-file, naming a file that holds postgres://u:p@127.0.0.1:<a closed port>/x?connect_timeout=1, and asserts that the error is a connection failure that names that port, not copy 13. A second table sets neither key and asserts copy 13 for each path. TestResolveSecretFiles_DBDSN passes unchanged.
  • 2.5 Breaks:
    • migrate up without its resolveDatabaseSecretFiles call: caught by its row (copy 13 in place of the connection failure);
    • connectSettings without the call: caught by its row;
    • seed-demo without the call: caught by its row;
    • openDB without the empty check: caught by the second table;
    • resolveDatabaseSecretFiles resolves an empty list: caught by every row of the first table.

3. Runtime paths through functions owned by core_owner: webhook partitions and the plan-act obligation lock

The plan-act obligation lock (items 3.10 to 3.14) lives in this section, not in a section of its own, because it is the same kind of change as the partition function (a core migration that gives the runtime user a path through the enclosure that its role alone does not have), it needs only console_runtime from section 1, it passes make test alone, and it lands before section 4, which is the first section that serves anything as the runtime user. A section of its own would renumber sections 4 to 7 and every reference to them.

  • 3.1 Core migrations/00031_webhook_partition_function.sql (design D3, D4): Up moves core.webhook_events to core_owner, then each of its partitions through a DO loop over pg_inherits; creates core.ensure_webhook_event_partitions as the design writes it; sets its owner, revokes EXECUTE from PUBLIC and grants it to console_runtime. Down drops the function, gives the parent and each partition to CURRENT_USER, and then, in the same loop, runs GRANT ALL ON core.webhook_events TO core_owner and the same for each partition, so the database is back where the baseline's GRANT ALL ON ALL TABLES IN SCHEMA core TO core_owner left it (the Up's OWNER TO core_owner merges that explicit entry into the owner's, and without the re-grant the Down leaves core_owner none). The header comment says why core_owner owns the table, why the Down re-grants, and that the bounds must match the baseline's DO block and the Go names.

  • 3.2 Core internal/db/migrations/00001_init.sql, the initial-partition DO block (:1048-1064; design D4): m_start becomes a TIMESTAMP computed as DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC') + make_interval(months => i), the upper bound is m_start + INTERVAL '1 month', and both bounds are rendered with AT TIME ZONE 'UTC', so the block names and bounds the partitions as the function does in any session time zone. The block's comment says so. Nothing else in the baseline changes in this section.

  • 3.3 internal/db/partitions.go: EnsureWebhookEventPartitions(ctx, ex, now, monthsAhead) takes an executor (ExecContext) and runs SELECT core.ensure_webhook_event_partitions($1, $2). The doc comment says that the function, owned by core_owner, creates the partitions, and that the naming and bounds parity is between the function and the baseline's DO block; the pgx placeholder paragraph goes. cmd/bootstate.go and internal/workflows/maintenance/activities.go compile unchanged.

  • 3.4 internal/db/partitions_test.go, rewritten. TestEnsureWebhookEventPartitions_ClosesTheGapAndIsIdempotent runs in one transaction it rolls back, under SET LOCAL ROLE console_runtime and SET LOCAL TimeZone = 'Pacific/Kiritimati', with the synthetic month of today's test: an insert into the target month fails with 23514 in a savepoint; the ensure creates the four partitions; inserts at the target month's first hour and last hour (UTC) each land in the target month's partition; a second ensure creates nothing; every created partition is owned by core_owner. Nothing is dropped, since the transaction rolls back. TestWebhookPartitionsBelongToCoreOwner asserts the owner of core.webhook_events and of every partition, and that the function is SECURITY DEFINER, owned by core_owner, configured search_path=core, pg_temp, and not executable by PUBLIC. TestRuntimeRoleCannotCreatePartitionsDirectly, as console_runtime, runs the CREATE TABLE ... PARTITION OF the Go code ran before and expects 42501. TestEnsureWebhookEventPartitionsRefusesAWindow, one row each for 13 months, -1 months and a null time, expects 22023 and no new partition. The file's header comment says what runs as whom.

  • 3.5 schemaSnapshot (internal/db/integration_delivery_migration_test.go:152-153) renders every ACL order-insensitively, for relacl, attacl, nspacl and proacl: for example (SELECT string_agg(x::text, ',' ORDER BY x::text) FROM unnest(c.relacl) x) in place of c.relacl::text. Sorting both sides keeps equal sets equal and unequal sets unequal, so the three existing up/down/up tests keep their results, and a Down that re-grants an entry appended at the end of the ACL compares equal. TestWebhookOwnershipMoveRoundTrips (internal/db), on a scratch database migrated with dbtest.UpTo to core 00030: record schemaSnapshot (which records relowner and the ACLs); apply 00031 up and record again; apply it down and assert that the snapshot equals the first, so the parent and every partition are back with the user that ran the Down; apply it up again and assert that the snapshot equals the second.

  • 3.6 TestBaselinePartitionBoundsAreUTC (internal/db), on a scratch database: apply core version 1 on a connection whose TimeZone is Pacific/Kiritimati (a runtime parameter of the connection string), then assert that each of the three initial partitions' bounds, read as timestamptz, is midnight UTC on the first of the current UTC month and of the two months after it, and that the names carry those months.

  • 3.7 TestRuntimeRolePrivileges asserts EXECUTE on core.ensure_webhook_event_partitions and on core.lock_plan_act_obligation for console_runtime.

  • 3.8 Breaks (the migrations' with the reset):

    • the function computes the month in the session's time zone (date_trunc('month', p_now) without AT TIME ZONE 'UTC'): caught by the last-hour insert;
    • no GRANT EXECUTE ... TO console_runtime: caught by the gap test and TestRuntimeRolePrivileges;
    • SECURITY INVOKER in place of SECURITY DEFINER: caught by the gap test (42501 on the create) and the ownership test;
    • no SET search_path: caught by the ownership test;
    • no REVOKE EXECUTE ... FROM PUBLIC: caught by the ownership test and TestConsoleReaderColumns;
    • the partition loop in the Up removed: caught by the ownership test, on a partition the baseline created;
    • the window check allows 13: caught by the refusal test;
    • EnsureWebhookEventPartitions passes monthsAhead - 1: caught by the gap test;
    • the baseline block takes DATE_TRUNC('month', NOW()) in the session's time zone, as it did: caught by TestBaselinePartitionBoundsAreUTC (a scratch database; no reset);
    • 00031's Down gives the parent to core_owner in place of CURRENT_USER: caught by TestWebhookOwnershipMoveRoundTrips;
    • 00031's Down leaves the partitions with core_owner: caught by the same test;
    • 00031's Down without the core_owner re-grant: caught by TestWebhookOwnershipMoveRoundTrips;
    • schemaSnapshot with the ACLs in stored order, as the test code reads it (a test-side record): caught by TestWebhookOwnershipMoveRoundTrips on a correct Down, whose re-grant appends the entry at the end;
    • TestRuntimeRoleCannotCreatePartitionsDirectly without its SET LOCAL ROLE (a test-side record): caught, because the statement succeeds as the superuser;
  • 3.9 docs/database-management.md, "Roles": a paragraph on ownership (design D3): objects belong to the migration user, core_owner owns the definer functions and core.webhook_events with its partitions, and the runtime user owns nothing, neither in the schemas nor the database.

  • 3.10 Core migrations/00032_lock_plan_act_obligation.sql (design D19): Up creates core.lock_plan_act_obligation(p_act_id UUID, p_pool_id UUID) as the design writes it, sets its owner to core_owner, revokes EXECUTE from PUBLIC and grants it to core_writer, in the order and shape of 00028's other definer functions; Down drops the function. The header comment says that a locking read needs UPDATE, which the enclosure withholds from the writer, why the function reads only the obligation row, and that the lock, its mode and its order are those of the query it replaces.

  • 3.11 internal/entitlements/queries/plan_acts.sql: LockPlanActObligation becomes the query below (design D19); its comment keeps the account of why only the obligation row is read, adds that the function takes the lock for the runtime role, and says that sqlc cannot see a function's output columns, so the alias list names them and the casts give them their non-null types. Regenerate with make sqlc-generate, which restores the SPDX header that sqlc drops, so internal/entitlements/plan_acts.sql.go is regenerated; the row type, LockPlanActObligationRow, and plan_act_move.go do not change.

    SELECT l.act_id::uuid AS act_id, l.pool_id::uuid AS pool_id, l.org_type::varchar AS org_type,
           l.org_id::uuid AS org_id, l.status::varchar AS status, l.owned::boolean AS owned,
           l.motion_act_id::uuid AS motion_act_id
    FROM core.lock_plan_act_obligation(sqlc.arg(act_id)::uuid, sqlc.arg(pool_id)::uuid)
         AS l(act_id, pool_id, org_type, org_id, status, owned, motion_act_id);
    
  • 3.12 TestLockPlanActObligationRunsAsTheRuntimeRole (internal/entitlements/plan_act_queries_db_test.go, with the helpers of TestLockPlanActObligationReadsTheMove), in one transaction it rolls back: write the act and its obligation as the test login, SET LOCAL ROLE console_runtime, call LockPlanActObligation and assert the row of TestLockPlanActObligationReadsTheMove; requeue it and assert the Retry as the motion; assert sql.ErrNoRows for a pool with no obligation; and in a savepoint run SELECT ... FROM core.plan_act_obligations WHERE false FOR UPDATE and assert SQLSTATE 42501, which names the reason for the function. TestPlanActWritersAreEnclosedInTheCatalog (internal/db/plan_act_obligations_test.go) gains lock_plan_act_obligation in its list of functions and in its PUBLIC and core_reader refusal loop, which asserts that the function is SECURITY DEFINER, owned by core_owner, configured search_path=core, pg_temp, executable by core_writer and console_runtime, and not by PUBLIC or core_reader; no new test. TestLockPlanActObligationSurvivesARequeueItWaitedOn and the plan-act drain tests run unchanged.

  • 3.13 docs/database-locks.md: in the plan act obligation row's "Taken by" cell and in the move's paragraph, the LockPlanActObligation query is described as calling core.lock_plan_act_obligation, which takes the same FOR UPDATE after the pool row; the lock, its mode and the order are unchanged. Nothing else in the file changes.

  • 3.14 Breaks for 3.10 to 3.13 (the migration's with the reset):

    • the sqlc query returned to the bare SELECT ... FROM core.plan_act_obligations ... FOR UPDATE OF ob (the generated text edited the same way): caught by TestLockPlanActObligationRunsAsTheRuntimeRole (42501 at the lock);
    • the function without FOR UPDATE: caught by TestLockPlanActObligationSurvivesARequeueItWaitedOn (the move never waits);
    • SECURITY INVOKER in place of SECURITY DEFINER: caught by TestLockPlanActObligationRunsAsTheRuntimeRole and TestPlanActWritersAreEnclosedInTheCatalog;
    • no SET search_path: caught by TestPlanActWritersAreEnclosedInTheCatalog;
    • no REVOKE EXECUTE ... FROM PUBLIC: caught by TestPlanActWritersAreEnclosedInTheCatalog and TestConsoleReaderColumns;
    • no GRANT EXECUTE ... TO core_writer: caught by TestLockPlanActObligationRunsAsTheRuntimeRole and TestRuntimeRolePrivileges;
    • the function written to return a single composite row in place of TABLE (a missing pair would return a row of nulls): caught by the sql.ErrNoRows assertion of TestLockPlanActObligationRunsAsTheRuntimeRole;
    • UPDATE granted on core.plan_act_obligations to core_writer in place of the function (with the reset): caught by TestRuntimeRolePrivileges (the locked list holds SELECT only).

4. Two connection strings, the boot grant, and the test stack on two users

This section's make test passes only after the documented stack respin of item 4.9, which the implementer performs in the worktree before the section's suite runs: the guard refuses a test/.env without MC_DB_MIGRATE_DSN, and postgres-seed creates the runtime login only on docker compose up. "Every commit passes make test" therefore holds from a stack so respun, not from a fresh checkout beside an old test/.env.

  • 4.1 Keys (design D1): startCmd registers db-migrate-dsn and db-migrate-dsn-file (copies 23, 24), and the help of db-dsn and db-dsn-file changes (copies 21, 22); databaseSecretPairs gains db-migrate-dsn/db-migrate-dsn-file; ValidateStart refuses a malformed db-migrate-dsn (copy 14) and treats it as optional.
  • 4.2 internal/db: PendingMigrations(ctx, q, sources) (design D10) in migrations.go, which begins its transaction with SET LOCAL search_path = pg_catalog, pg_temp and reads each ledger as public.goose_db_version_<stream> by qualified name and calls pg_catalog.to_regclass and pg_catalog.has_table_privilege (design D2, the qualification principle), with a doc comment that says why it does not use goose's provider and why the names are qualified; ConnectMigration(ctx, logger, config), which opens the migration pool with the startup parameters search_path=public and role=none as runtime parameters of the connection string, replacing any search_path or role the string carries (ensureSearchPath, internal/db/database.go:43-60, keeps an operator's value and is not reused, so a db-migrate-dsn copied from the runtime string cannot open with core,public); the runtime pool's open gains role=none, always replacing the string's value, beside its search_path=core,public (ConnectPlain stays for dbConfig); CurrentUser and GrantRuntimeRole(ctx, tx, name) (design D8, which begins with SET LOCAL search_path = pg_catalog, pg_temp, and whose statement calls pg_catalog.set_config, pg_catalog.format and pg_catalog.current_setting; it takes a transaction because the setting lasts until the transaction ends, and cmd runs it through db.InTx) in runtime_user.go, and the refusal type RuntimeUserRefusal. Every SQL string of these functions is a constant whose name begins query (item 5.8 scans them). ConnectAndMigrate is deleted.
  • 4.3 cmd/database.go (design D11): modes dbRuntime, dbMigration and dbConfig. dbMigration opens db-migrate-dsn with search_path=public and role=none (design D2, rule 2) and refuses without it (copy 12). dbConfig is today's dbPlain on db-dsn. dbRuntime opens the runtime pool with role=none beside its search_path, and runs the boot sequence's steps 2, 3, 6 and 7, step 5's migrations, and the pending check in both modes (copies 4, 4b to 4d, 10, 18, 20); the other checks, and steps 4 and 8 in full, are section 5's, with copy 4a, which belongs to check 5. start and seed-demo open dbRuntime; start logs a refusal with copy 17; the migrate commands open dbMigration; the config commands open dbConfig. The comment on openDB says which mode each command uses and why.
  • 4.4 internal/embeds/mc-config.yaml: copies 27 and 27a.
  • 4.5 Test stack (design D14): test/compose.yaml gives postgres a health check and adds postgres-seed; test/seed/postgres/seed-runtime-user.sh (new, SPDX header, copies 30 and 31); test/bootstrap-stack.sh writes MC_DB_DSN and MC_DB_MIGRATE_DSN, prints copy 29, refuses a stale .env on a re-run with copy 28, and its header says what it writes; test/reset-test-db.sh refuses a stale .env (copy 28) after its existing guard and runs MC_DB_MIGRATE_DSN="$dsn" go run . migrate up; test/reset-app-db.sh refuses a stale .env and uses MC_DB_MIGRATE_DSN for migrate up, the pin, pg_dump and the snapshot's address, and its header says so; test/seed-demo.sh refuses a stale .env; test/mc-config.yaml carries both strings; test/mockshot-nuke.sql's comment (:111-114) says that the stack's migration user is a superuser, which SET ROLE core_owner relies on; test/seed/discourse/seed-discourse.sh's header says the runtime user needs console_runtime, which the first start or reset-app-db.sh grants.
  • 4.6 test/e2e/browsertest/session.go: AppAdminDB opens MC_DB_MIGRATE_DSN and skips with copy 32; AppDB's comment says it is the runtime user. test/e2e/screens/screens_test.go's pinTimestamps uses AppAdminDB.
  • 4.7 Docs: docs/environment-reference.md (rows for db-migrate-dsn and db-migrate-dsn-file; db-dsn and db-dsn-file name the runtime user); docs/hosting.md (five secrets); docs/database-management.md ("Migrations run automatically on startup" and the ConnectAndMigrate paragraph, design D17; the migration connection opens with search_path=public, and the ConnectPlain reason is restated for the application's core,public path); docs/testing.md, test/AGENTS.md (the .env keys block, the databases table's user column, the regeneration command at :210-211) and test/README.md (the Postgres row names both users).
  • 4.8 Tests:
    • TestPendingMigrations (internal/db), on scratch databases: migrated in full, nothing pending; a stream stopped short with dbtest.UpTo, its missing versions; a database with no Discourse ledger, Discourse with every version; a ledger whose SELECT grant to console_runtime is revoked, the unreadable detail; a temporary table named like a ledger, created on the checking connection, changes no result; each read as console_runtime on a connection with search_path=core,public.
    • TestMigrationPoolIgnoresADatabaseSearchPath (internal/db, in a db_test file such as internal/db/migration_pool_test.go, because it exercises db.ConnectMigration and schemaSnapshot lives in package db_test, which cmd cannot import), on scratch databases the test creates: with ALTER DATABASE setting search_path to a schema the test created and then public, the migrations run through the migration pool, SHOW search_path on it returns public, and every ledger is in public and read by PendingMigrations; a row whose database sets role to a role the test creates, expecting SELECT current_user on the pool to be the migration login; and two fresh scratch databases with no extra schema (schemaSnapshot reads every user schema, so a database that carries the first row's extra schema never compares equal), one migrated through the migration pool and one by a plain replay (dbtest), with equal snapshots, so a dropped object fails the test.
    • TestGrantRuntimeRole (internal/db), in a transaction it rolls back, which GrantRuntimeRole takes because its setting is local to a transaction: a role named Mc-Runtime-<n>, created LOGIN NOINHERIT, inherits console_runtime after the grant; a second grant succeeds and changes nothing.
    • TestOpenRuntimeDatabase (cmd/database_db_test.go), on scratch databases with login roles the test creates with a password and drops in its cleanup: with both strings on a fresh scratch database, the runtime login inherits console_runtime, SELECT current_user on the returned pool is the runtime login, the ledgers read through the returned pool are at every embedded version, and pg_stat_activity, read from the shared test database, shows no session of the migration user on the scratch database; without db-migrate-dsn on a scratch database stopped short, copy 4 with 4d, and the ledgers unchanged; without it on a current database, the pool is returned and nothing ran; migrate up without db-migrate-dsn, copy 12; a runtime login that is a member of console_runtime and has run ALTER ROLE CURRENT_USER SET role = 'console_runtime' (its later sessions would report current_user as the group role), a normal boot with the login as current_user on the returned pool.
    • TestValidateStart gains a row for copy 14; TestResolveSecretFiles_DBDSN gains the db-migrate-dsn pair's two rows; TestDatabaseCommandsReadTheFileKeys's migrate rows set db-migrate-dsn-file and expect copy 12 when neither key is set.
    • TestScriptsRefuseAnEnvWithoutTheMigrationDSN (test/scripts_env_test.go), with the shims of TestScriptsRefuseUnknownArguments, runs each of the four scripts beside a .env with every other key it reads and asserts copy 28 on stderr, exit status 1, and that no shim ran. TestBootstrapChecksTheFarmName's fixture .env gains MC_DB_MIGRATE_DSN; its result does not change.
  • 4.9 Before this section's make test, regenerate this worktree's test/.env with copy 28's commands; docker compose logs postgres-seed shows copy 30. Then cd test && ./reset-app-db.sh --rebuild, start the application, and check that it boots, that the boot log shows copy 20, and that the operator and member pages load.
  • 4.10 Walkthroughs from the demo snapshot before and after (test/AGENTS.md, "Browser walkthroughs"), with go test -count=1 ./test/e2e/operator-walkthroughs/ on the parent commit with the test/.env it writes, and on this one with the regenerated file: the test names, results and skip messages match. make screens lists no changed screen.
  • 4.11 Breaks (the migration-free ones need no reset):
    • dbRuntime runs the migrations on the runtime pool: caught by TestOpenRuntimeDatabase's first row (the runtime login cannot create a schema);
    • dbRuntime skips the boot grant: caught by the first row's membership assertion;
    • dbRuntime leaves the migration pool open: caught by the first row's pg_stat_activity assertion;
    • dbRuntime skips the pending check when db-migrate-dsn is unset: caught by the second row;
    • dbRuntime migrates on db-dsn when db-migrate-dsn is unset: caught by the second row (the ledgers change);
    • GrantRuntimeRole without WITH INHERIT TRUE: caught by TestGrantRuntimeRole;
    • GrantRuntimeRole with %s in place of %I: caught by TestGrantRuntimeRole (the mixed-case name);
    • PendingMigrations takes a missing ledger as current: caught by TestPendingMigrations;
    • PendingMigrations resolves the ledger without the public. qualifier: caught by the temporary-table row of TestPendingMigrations;
    • the migration pool opened without search_path=public: caught by TestMigrationPoolIgnoresADatabaseSearchPath (the ledgers land in the test's schema);
    • the migration pool opened without role=none: caught by the role row of TestMigrationPoolIgnoresADatabaseSearchPath (current_user is the test's role);
    • the runtime pool opened without role=none: caught by the self-set-role row of TestOpenRuntimeDatabase (the boot grant names console_runtime and fails, copy 10);
    • ConnectMigration keeps the search_path or role that the string carries, as ensureSearchPath does: caught by TestMigrationPoolIgnoresADatabaseSearchPath, whose scratch connection string carries search_path=core,public&role=<the test's role>;
    • dbMigration falls back to db-dsn: caught by the migrate up row;
    • the bootstrap guard removed: caught by TestScriptsRefuseAnEnvWithoutTheMigrationDSN's bootstrap row;
    • reset-app-db.sh's guard after its first psql: caught by its row (a shim ran).

5. The start-up check refuses a runtime user that can bypass the grants

  • 5.1 internal/db/runtime_user.go: CheckBeforeMigrating(ctx, q) runs the membership half of check 3 and the runtime half of check 4 of design D9 in one read-only REPEATABLE READ transaction and returns a *RuntimeUserRefusal with copy 3a, 3b or 3c, or copy 11 on a failed query; it scans the direct role as nullable. CheckMigrationDatabaseOwner(ctx, q) runs the owner half of check 4 on the migration connection, with qualified names only, and returns a *DatabaseOwnerRefusal with copy 3d, or copy 11 on a failed query. The identity read on the migration connection is SELECT current_user, pg_catalog.current_database() and nothing else; the superuser bit and the quoted name come from the runtime connection. CheckSchemasBeforeMigrating(ctx, q, login, schemas) runs check 12 (design D9): the CREATE query over public and the five schemas, then check 9's query restricted to public with the login as a parameter, and returns a *RuntimeUserRefusal with copy 3e, 7 or 8, or copy 11; cmd calls it on the migration connection after CheckMigrationDatabaseOwner, and on the runtime connection in the separate-step mode. CheckRuntimeUser(ctx, q, check) runs checks 5 to 11 in that order in one such transaction and returns a *RuntimeUserRefusal with copies 4 to 9, 8a and 9a, or copy 11. RuntimeCheck carries the stream names and the owner role names; cmd builds both from migrate.Sources(). Every function and catalog relation of every query is written with pg_catalog. (design D2, the qualification principle), and every query is a constant whose name begins query. Every transaction these functions open, on either connection (the identity reads, the owner half of check 4, check 12, CheckRuntimeUser's), begins with SET LOCAL search_path = pg_catalog, pg_temp, a constant of its own, before its first query (design D2, the search-path pin). The second query of checks 9 and 12 looks the login up as (SELECT r.oid FROM pg_catalog.pg_roles r WHERE r.rolname = $2::name), never with regrole. The queries of checks 3, 4, 8 and 9 select pg_catalog.quote_ident of the direct role; check 9's first query also selects pg_catalog.pg_get_userbyid(owner), which copy 8 prints; and check 11's two reads return the grantee (PUBLIC, or a quoted role name) from pg_catalog.aclexplode of the relation's ACL, restricted to entries that reach the user (a grant to PUBLIC or to a role the user inherits), so copy 9 prints the one REVOKE that removes the privilege; when the grantee is null, a third query names the predefined role (pg_write_all_data for INSERT, pg_maintain for MAINTAIN) and the role it is granted to, and the refusal is copy 9a. Check 11 also reads the first relation of the five schemas on which the user holds TRUNCATE, REFERENCES, TRIGGER or MAINTAIN. The pending check of section 4 moves into CheckRuntimeUser as check 7.
  • 5.2 cmd/database.go: dbRuntime reads both identities after it opens the pools (the migration connection's with the one qualified query) and refuses checks 1 to 3 (copies 1, 2, 3), calls CheckBeforeMigrating (copies 3a, 3b, 3c), then CheckMigrationDatabaseOwner (copy 3d), then CheckSchemasBeforeMigrating on the migration connection (copies 3e, 7, 8), before it migrates and before the boot grant (in the separate-step mode it calls CheckSchemasBeforeMigrating on the runtime connection before CheckRuntimeUser), and calls CheckRuntimeUser after it closes the migration pool; a passing check logs copy 19. dbMigration calls CheckMigrationDatabaseOwner before every migrate command (up, down and status) runs goose, because goose's Status creates any missing ledger in public. Nothing reads env.
  • 5.3 TestCheckBeforeMigrating (internal/db/runtime_user_test.go), one row per case through a named function, each dropping to the row's role with SET LOCAL ROLE inside a transaction it rolls back that creates the roles the row needs (the database rows run on a scratch database that a role the test creates owns, dropped in cleanup after the database): a member of console_runtime that owns nothing passes; one that is also a member of a superuser role created in the transaction, copy 3a; the owner of the scratch database, copy 3b naming the database; a member of the role that owns the scratch database, through an intermediate role named Mc-Via-<n>, copy 3c naming the owner and the direct role, quoted; a role that owns another database and not this one passes; and the check run on a closed database (dbtest.Closed), copy 11. (A superuser that does not own the database reaches copy 3a from this function, because the membership half of check 3 runs first and a superuser is a member of the bootstrap superuser; the superuser row of TestOpenRuntimeDatabase covers check 4's order.) The same test runs CheckSchemasBeforeMigrating (check 12) with a login role created in the transaction: a login that owns nothing and holds no CREATE passes; after GRANT CREATE ON SCHEMA public TO PUBLIC in the transaction, copy 3e naming public and the grantee PUBLIC; GRANT CREATE ON SCHEMA public to a role the test creates (Mc-Creator, mixed case so the quoting shows) and of which the login is a member with INHERIT FALSE, SET TRUE, copy 3e naming that role quoted, which proves both the grantee and the MEMBER test; after GRANT CREATE ON SCHEMA core TO the login, copy 3e naming core; a login that owns a function in public, copy 7 with the function's signature, and one that is a member of a role that owns such a function through Mc-Via-<n>, copy 8 naming the owner by name and the direct role, quoted, with the login itself named Mc-Rt-<n> so that its name needs quoting (copy 8 and not copy 11, because the second query of check 9 looks the login up by name); a login holding CREATE on public through a grant to PUBLIC after a role the test creates has planted public.=(oid, integer), whose function writes a marker row recording current_user into a table the test created, copy 3e and no marker row; and a closed database, copy 11. TestCheckMigrationDatabaseOwner adds rows: a scratch database owned by the migration user passes, one owned by a superuser role passes, one owned by a role the test creates, copy 3d naming the owner and the quoted database name, and a closed database, copy 11. Every row but the passing ones asserts the whole sentence.
  • 5.4 TestCheckRuntimeUser (internal/db/runtime_user_test.go), one row per case through a named function, each in a transaction it rolls back that creates the roles the row needs and drops to the row's role with SET LOCAL ROLE: a member of console_runtime passes; a role named Mc-Rt-<n> that is no member, copy 5 with the name quoted; a member of console_runtime granted WITH INHERIT FALSE, copy 5; a direct member of core_owner, copy 6 naming core_owner in both places, which also shows that the owner role is reported before check 10; a member of core_owner through another role named Mc-Via-<n>, copy 6 naming that role, quoted, in the REVOKE; a member of core_owner granted WITH INHERIT FALSE, SET TRUE, copy 6; the owner of a table in core created in the transaction, copy 7; the owner of a function in core, copy 7 with its schema-qualified signature (the pin of design D2 makes regprocedure::text qualify it); the owner of a function in public, copy 7; a member of a role that owns a table, copy 8; a holder of TRIGGER on a table of core through a grant to the user, copy 9 naming TRIGGER, the table and the user as grantee; a holder of TRUNCATE on a table of core through a grant to core_reader, which core_writer holds, copy 9 naming core_reader as grantee; a holder of MAINTAIN through pg_maintain granted to console_runtime, copy 9a naming pg_maintain and console_runtime; a direct member of an unrelated role named Mc-Extra-<n>, copy 8a with both names quoted; a direct member of pg_execute_server_program, of pg_read_server_files and of pg_write_all_data, copy 8a for each; a holder of INSERT on core.pool_provisions through a grant to the user, and through a grant to PUBLIC, copy 9 naming the user and PUBLIC as grantee; and the check run on a closed database (dbtest.Closed), copy 11. Every row but the passing one asserts the whole sentence.
  • 5.5 TestOpenRuntimeDatabase (rows of section 4 stay) gains rows: one user for both strings, copy 1, and the ledgers untouched; a superuser runtime login the test creates (LOGIN SUPERUSER), copy 3, the ledgers untouched and no membership granted; a runtime login that owns the scratch database, copy 3b, the ledgers untouched and no membership granted; the same login having created public.quote_ident(name), which writes a marker row (recording current_user and is_superuser) into a table the test created and the login may insert into, copy 3b, the ledgers untouched and no marker row, because neither connection runs the function; a runtime login that holds CREATE on public and has created public.format(text, text) with such a marker, copy 3e, the ledgers untouched and no marker row; the same login having created public.=(oid, integer) with such a marker, copy 3e, the ledgers untouched and no marker row; a runtime login that owns a function in public and holds no CREATE, copy 7, the ledgers untouched; migrate up, migrate down and migrate status against a scratch database owned by a role that is neither the migration user nor a superuser, copy 3d and the ledgers untouched (for status, no ledger created); the same against a scratch database owned by a third role that has run ALTER DATABASE ... SET role = '<itself>', copy 3d from migrate up and the ledgers untouched; a runtime login that holds pg_read_server_files, copy 8a after the migrations ran; two scratch databases, copy 2; and every row runs with env set to development. Each row's cleanup drops the scratch database before the login that owns it, because Go runs t.Cleanup functions in reverse order, dbtest.Scratch registers its drop first, and DROP ROLE fails while the role owns the database. Section 4's rows pass unchanged.
  • 5.6 Start this worktree's application on the stack and check that the boot log shows copy 19 naming member_console_runtime and member_console. Set MC_DB_DSN to the migration user's string in one shell and check that the start refuses with copy 1.
  • 5.7 Breaks:
    • check 4 before check 3: caught by the superuser row of TestOpenRuntimeDatabase (a superuser is a member of every role and matches check 4's membership case, so copy 3c with an empty direct role would print in place of copy 3);
    • checks 1 to 4 after the migrations: caught by the same-user, superuser and database-owner rows' ledger assertions;
    • the boot grant before the superuser check: caught by the superuser row's membership assertion;
    • the database-ownership check removed: caught by the copy 3b row of TestCheckBeforeMigrating and the database-owner row of TestOpenRuntimeDatabase;
    • check 4 tests datdba = current_user in place of pg_has_role(..., 'MEMBER'): caught by the copy 3c row;
    • check 4 reads the first database in pg_database in place of current_database(): caught by the copy 3b row;
    • check 4 without its datname = current_database() filter: caught by the row whose role owns another database;
    • the identity query run on the migration pool (unqualified quote_ident, pg_roles): caught by the planted-function row of TestOpenRuntimeDatabase (the marker row is written);
    • the runtime identity read with an unqualified quote_ident: caught by the planted-function row (a marker row is written);
    • the migration pool without role=none: caught by the owner-set-role row of TestOpenRuntimeDatabase (the owner half passes and goose runs as the owner);
    • check 12's query reads has_schema_privilege in place of the ACL entries (so a SET-only membership is not counted and the grantee is lost): caught by the Mc-Creator row of TestCheckBeforeMigrating;
    • the CREATE check removed: caught by the CREATE row of TestCheckBeforeMigrating and the public.format row of TestOpenRuntimeDatabase;
    • check 12's public half removed: caught by the owned-function rows of both tests;
    • check 12 run after the migrations: caught by the public.format and owned-function rows' ledger assertions;
    • the boot grant's pg_catalog.format written format: caught by the scan of item 5.8;
    • a check transaction (check 12's, on the migration connection) without its SET LOCAL search_path = pg_catalog, pg_temp: caught by the planted-operator rows of TestCheckBeforeMigrating and TestOpenRuntimeDatabase (a marker row is written);
    • CheckRuntimeUser's transaction without the pin: caught by the function rows of TestCheckRuntimeUser, whose copy 7 asserts the schema-qualified signature (core.<name>(core.<type>), not <name>(<type>));
    • the second query of check 9 or 12 casting the login with regrole: caught by the mixed-case Mc-Rt-<n> indirect-owner row of TestCheckBeforeMigrating (copy 11 in place of copy 8);
    • CheckMigrationDatabaseOwner removed from dbMigration: caught by the migrate up row of TestOpenRuntimeDatabase;
    • dbMigration checks before up only: caught by the migrate down and migrate status rows of TestOpenRuntimeDatabase (the status row finds a ledger created);
    • CheckMigrationDatabaseOwner removed from dbRuntime: caught by start's copy 3d row;
    • the owner half refuses a superuser-owned database: caught by TestCheckMigrationDatabaseOwner's superuser-owner row;
    • the refusal of copy 3d prints the name unquoted: caught by the copy 3d row;
    • check 11 without the four-privilege read: caught by the TRIGGER row of TestCheckRuntimeUser;
    • copy 9 prints the user in place of the grantee: caught by the core_reader row;
    • check 11's grantee filter dropped, so every ACL entry counts: caught by the core_reader row (a grantee the user does not reach is named in place of core_reader);
    • check 11's predefined-role query removed: caught by the pg_maintain row (copy 9 prints with an empty grantee);
    • copy 3c or copy 6 prints the direct role unquoted: caught by the Mc-Via-<n> rows;
    • check 6 tests MEMBER in place of USAGE: caught by a row whose role holds the membership with INHERIT FALSE;
    • check 8 tests USAGE in place of MEMBER: caught by a row whose role holds core_owner with INHERIT FALSE, SET TRUE;
    • check 9 without functions: caught by both function rows;
    • check 9 without public: caught by the public function row;
    • check 9 tests owner = current_user in place of pg_has_role(..., 'MEMBER'): caught by the copy 8 row;
    • the direct-membership check removed: caught by the unrelated-role row, the three predefined-role rows and the pg_read_server_files row of TestOpenRuntimeDatabase;
    • the direct-membership check counts console_runtime: caught by the passing rows;
    • the direct-membership check before check 8: caught by the direct core_owner row (copy 8a in place of copy 6);
    • check 11 removed: caught by the two INSERT rows;
    • check 3 without its membership half: caught by the copy 3a row;
    • the refusal quotes the name in Go (%q): caught by the copy 5 row;
    • an exemption when env is development: caught by the env rows of TestOpenRuntimeDatabase;
    • a failed check query passes (the error dropped): caught by the closed-database rows, which expect copy 11;
    • TestOpenRuntimeDatabase's cleanup dropping the login before the scratch database (a test-side record): caught, because DROP ROLE fails while the login owns the database;
    • an unqualified quote_ident( in the identity query: caught by the scan, naming the query;
    • an unqualified pg_roles in the owner check: caught by the scan;
    • PendingMigrations calls to_regclass without pg_catalog.: caught by the scan, naming the query and to_regclass(;
    • the queries' constants renamed so that none begins query (a test-side record): caught by the scan's own empty-collection failure.
  • 5.8 TestCheckQueriesQualifyEveryName (internal/db/qualified_names_test.go, design D2, the qualification principle), in package db_test: parse runtime_user.go and migrations.go with go/parser, collect every string constant whose name begins query (the check queries, the boot-grant statement and the pending check's queries), remove string literals and -- comments, and fail for each function call (an identifier directly followed by (, after optional spaces, that no pg_catalog. precedes) and each catalog relation (an identifier starting pg_ that no pg_catalog. precedes and that is not pg_catalog itself), naming the query's constant and the token. The exceptions are a table in the test, each with its reason: ANY, ALL, IN, EXISTS, VALUES, CAST, COALESCE, CASE, AND, OR, ON, FROM, JOIN, SELECT, WHERE, WHEN, THEN, ELSE are SQL syntax that precedes a parenthesis, not calls; an identifier directly after AS is an alias with a column list (AS p(priv)). The scan does not look at operators; the search-path pin covers them (design D2), and the planted-operator rows of items 5.3 and 5.5 show it. The pin's own constant (SET LOCAL search_path = pg_catalog, pg_temp) is an exception listed with its reason: pg_temp is a schema name, not a catalog relation. The test also fails when it finds no query constant in either file, so a rename cannot empty it. The breaks in item 5.7 name what the scan must catch.

6. Deployment docs and the procedure for an existing deployment

  • 6.1 docs/production-deployment.md, step 5 and step 7, and the procedure for an existing deployment, as design D17 writes them, with placeholders only, including that the runtime login owns neither the database nor any object and holds no role but console_runtime, and that the verification reads pg_db_role_setting and the functions of public (design D17 step 5). "Retry a failed desired-state delivery by hand" says "the runtime user".
  • 6.2 docs/database-management.md, "Running the script" and "Logging in": the script runs as the migration user (POSTGRES_USER with the official image, a superuser); on a managed database, as a user with CREATEROLE that owns the console's tables.
  • 6.3 No code changes; make test passes.

7. Landing

  • 7.1 With the test stack up, make test passes (it runs make lint first), and openspec validate runtime-database-user --strict passes.
  • 7.2 Post a comment on #23 under CONTRIBUTING.md's rules for forge text, and close it. The comment states that the console serves from a runtime user that is not a superuser and owns nothing, and migrates on a second connection string that it closes before serving; that the migrations' revokes now bind the console; that the console refuses to start on a configuration that would reopen the bypass, with no exemption; that a host can run migrations as a separate step and keep the migration password out of the running process; that the writer roles now hold only SELECT, INSERT, UPDATE and DELETE (no TRUNCATE, TRIGGER, MAINTAIN or REFERENCES), that the plan-act obligation move takes its row lock through a function owned by core_owner, because a locking read of an enclosed table needs UPDATE, that the core baseline's initial partitions are computed in UTC, and that the -file keys of the other commands are fixed in the same change; and that a deployment changes its configuration to upgrade, following the procedure in docs/production-deployment.md. It names the commits of sections 1 to 6.
  • 7.3 Post a comment on #252 under the same rules: core.webhook_events and its partitions now belong to core_owner, and partitions are created by a SECURITY DEFINER function it owns; the runtime user is refused when it owns or can act as the owner of the database or any object, whoever owns it, so moving the rest is not needed for the fence; moving them needs a per-object ALTER ... OWNER TO loop, because Postgres refuses REASSIGN OWNED BY for the bootstrap superuser, and SET membership in each owner role for the migration user; and scripts/db-reader-role.sql is run by the schemas' owner, which that move would change.
  • 7.4 Hand the host procedure of docs/production-deployment.md to the maintainer, with the values his deployment needs filled in outside the repository. No agent runs it.