Skip to content

Enforce SQLAlchemy-only database connections and Alembic-only schema changes - #3979

Draft
anth-volk wants to merge 1 commit into
microdata-weight-rulesfrom
sqlalchemy-alembic-database-rule
Draft

anth-volk wants to merge 1 commit into
microdata-weight-rulesfrom
sqlalchemy-alembic-database-rule

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3978

Summary

Stack: this PR is the top of stack #3917, based on #3938 (microdata-weight-rules). It was first opened against master and then moved onto the stack, so it lands after the ruff work.

Adds an enforced rule, api.data.sqlalchemy-and-alembic-only:

Code connects to relational databases only through SQLAlchemy engines, connections and sessions, never by calling a database driver directly, and changes persistent schema only through Alembic revisions.

It is a mechanical-tier rule in the "Runtime persistence and route boundaries" category, run by the existing pr.yml test job (make test).

How the guard finds violations

tests/unit/data/test_database_access_boundaries.py walks every Python module under policyengine_api/, scripts/, migrations/ and .github/. It skips tests (any tests directory) and the generated revisions under migrations/*/versions/, and parses each module with ast, so comments and docstrings are never read as imports, calls or SQL. It walks the tree the same way the api.data.runtime-sql-confined guard does (test_runtime_sql_boundaries.py): collect the offenders, then compare them with the expected set. That guard matches facade-call substrings in the raw source and has no SQL-keyword matching to reuse, so this one checks parsed string literals instead.

  • Driver access (test_non_test_code_reaches_database_drivers_only_where_declared):
    • imports of psycopg, psycopg2, pymysql, MySQLdb, mysql.connector, sqlite3, asyncpg, pg8000, aiosqlite or aiomysql, including importlib.import_module and __import__ calls with a constant name;
    • access to the driver connection beneath SQLAlchemy: the driver_connection and dbapi_connection attributes, and raw_connection() calls.
  • Schema changes (test_non_test_code_changes_schema_only_through_alembic):
    • create_all() and drop_all();
    • create() or drop() on a table, index or sequence. That means a name the module assigns from Table(...), Index(...) or Sequence(...), a loop variable over those or over metadata.tables / sorted_tables, Model.__table__, metadata.tables[...], or any call that passes checkfirst=. URL.create(...) and other unrelated create methods are not flagged;
    • SQLAlchemy DDL constructs (DDL, CreateTable, DropTable, CreateIndex, DropIndex, CreateSchema, DropSchema, CreateSequence, DropSequence, AddConstraint, DropConstraint);
    • Alembic operations (op.* calls, alembic.op / alembic.operations imports) outside migrations/;
    • string literals, other than docstrings, in which a CREATE, ALTER or DROP of a table, type, schema, database, index, sequence, view or extension, or a TRUNCATE, starts the string, a line in it, or a statement after ;. Matching ignores case. Requiring the start of a statement keeps messages such as "failed to create table rows" from matching.

Each test compares the findings, as (module, construct) pairs, with a declared set. It fails on any finding that isn't declared, and on any declaration that nothing in the code uses any more, so the declared exceptions can't grow or go stale unnoticed.

Declared exceptions

Both are in policyengine_api/data/v2/catalog/publication_staging.py:

  1. COPY through the driver connection: import psycopg.sql, import psycopg.types.json.Jsonb and attribute .driver_connection. Catalog publication streams rows with Postgres COPY ... FROM STDIN, and SQLAlchemy has no COPY API. copy_rows uses the psycopg connection beneath a SQLAlchemy Connection that SQLAlchemy opened and still manages, so the transaction stays SQLAlchemy's.
  2. Temporary staging tables: call STAGING_METADATA.create_all(). The tables are declared TEMPORARY with ON COMMIT DROP, so Postgres drops them when the publishing transaction commits, and they are not persistent schema. test_publication_staging_tables_are_temporary_and_dropped_at_commit compiles every table on STAGING_METADATA for Postgres and requires CREATE TEMPORARY TABLE ... ON COMMIT DROP.

No other violations exist, either on master or at the top of the stack (#3938), so the guard passes without a baseline.

Out of scope

Redis (policyengine_api/runtime_cache/) is a key-value cache, not a relational database. The rule and its documentation say so.

Proof

  • rules/fixtures/api.data.sqlalchemy-and-alembic-only/bad_database_access.py reproduces two past cases from this repository: the direct psycopg.connect(...) calls in the Stage 12 provisioning script removed in d8bd721, and the runtime V1Base.metadata.create_all(engine) from the local bootstrap added in 6c78041.
  • good_database_access.py queries through a SQLAlchemy session and changes no schema.
  • test_rule_proof_sqlalchemy_and_alembic_only checks that the bad fixture yields exactly import psycopg and call V1Base.metadata.create_all(), and that the good fixture yields nothing.
  • The registry's [rule.proof] fields point at both fixtures and the test, and rules.md now lists the rule as proven.

Related rules it complements

  • api.data.runtime-sql-confined (raw SQL stays in the data-access layer);
  • api.services.no-direct-queries (services delegate persistence);
  • api.v2.single-orm-layer (v2 uses SQLModel only);
  • api.migration.alembic-v1-qualified and api.migration.alembic-v2-qualified (CI applies the revisions to disposable MySQL and Postgres databases);
  • api.migration.alembic-autogenerated-only (doc-only: revisions are autogenerated);
  • api.startup.no-import-side-effects (importing and starting the application opens no connections).

None of them forbade a direct driver connection, or a runtime schema change, outside the raw-SQL boundary.

Other changes

  • A new section, "Database access through SQLAlchemy and Alembic", in docs/engineering/skills/alembic-migrations.md (the registry rationale), and rule citations in two existing paragraphs of that doc.
  • The AGENTS.md pointer to that doc now also covers "code that connects to a database".
  • Regenerated docs/generated/rules.md and the AGENTS.md Rules block.
  • A changelog fragment.

This PR adds no inline suppressions and changes no application code.

Testing

After moving onto #3938 (head 2b3f7e77e):

  • The rebase conflicted only in docs/generated/rules.md, which was regenerated.
  • Ruff checks every rule from Refactor tests, scripts and the rules tool, then select every ruff rule (ALL) #3934 up. It found one finding in this PR: the bad fixture's docstring summary was 91 characters long. The summary was shortened. Fixture content and the findings the proof test expects are unchanged.
  • ruff format --check . and ruff check . (0.15.12) are clean.
  • The quality guards and python -m scripts.rules guard --base-ref origin/microdata-weight-rules pass.
  • Rendered rules are fresh, and uv lock --check passes.
  • pytest tests/unit/data/test_database_access_boundaries.py: 4 passed.
  • CI: this PR is above the lowest unmerged PR in the stack, so only CI complete runs, and it fails as designed. Its full run starts once the PRs below it merge.

Before the move, against master:

All with the Python 3.12 environment from uv sync --frozen --extra dev --python 3.12:

  • ruff format --check .: 598 files already formatted
  • ruff check .: all checks passed
  • python scripts/run_quality_guards.py: migration-contracts, rules-registry and ci-scripts passed
  • python -m scripts.rules guard --base-ref origin/master: passed (notes that the rule is new)
  • python -m unittest discover -s scripts/rules/tests -t .: 80 tests OK
  • pytest tests/unit/data/test_database_access_boundaries.py: 4 passed
  • pytest tests/unit: 2690 passed

I also ran throwaway scripts, not committed, against synthetic sources. They confirmed that the guard reports each construct listed above, ignores URL.create, prose, docstrings and SQLAlchemy dialect imports, and fails when a declared exception is removed from or added to the declared set. The temporary-table test fails for a staging table without TEMPORARY and for one without ON COMMIT DROP.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (microdata-weight-rules@21a9d2d). Learn more about missing BASE report.

Additional details and impacted files
@@                    Coverage Diff                    @@
##             microdata-weight-rules    #3979   +/-   ##
=========================================================
  Coverage                          ?   48.04%           
=========================================================
  Files                             ?      239           
  Lines                             ?    14119           
  Branches                          ?     2222           
=========================================================
  Hits                              ?     6783           
  Misses                            ?     6772           
  Partials                          ?      564           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@anth-volk
anth-volk changed the base branch from master to microdata-weight-rules October 20, 2026 14:01
@anth-volk
anth-volk force-pushed the sqlalchemy-alembic-database-rule branch from e3c1190 to 2b3f7e7 Compare October 20, 2026 14:01
@anth-volk
anth-volk added this pull request to stack #3917 October 20, 2026 14:02
@anth-volk
anth-volk force-pushed the sqlalchemy-alembic-database-rule branch from 2b3f7e7 to 1955a42 Compare October 20, 2026 16:31
@anth-volk
anth-volk force-pushed the sqlalchemy-alembic-database-rule branch from 1955a42 to 4b3743c Compare October 20, 2026 18:01
@anth-volk
anth-volk force-pushed the sqlalchemy-alembic-database-rule branch 4 times, most recently from ed1402d to 1c6f05b Compare October 21, 2026 15:49
@anth-volk
anth-volk force-pushed the sqlalchemy-alembic-database-rule branch 3 times, most recently from f4677e9 to 6e6c1d1 Compare October 21, 2026 22:31
…changes

Register api.data.sqlalchemy-and-alembic-only as an enforced mechanical
rule. tests/unit/data/test_database_access_boundaries.py parses every
non-test module under policyengine_api/, scripts/, migrations/ and
.github/ (excluding migrations/*/versions/) and fails on:

- imports of psycopg, psycopg2, pymysql, MySQLdb, mysql.connector,
  sqlite3, asyncpg, pg8000, aiosqlite or aiomysql, including through
  importlib.import_module, and access to the driver connection beneath
  SQLAlchemy (driver_connection, dbapi_connection, raw_connection());
- create_all, drop_all, create or drop on a table, index or sequence,
  SQLAlchemy DDL constructs, Alembic operations outside migrations/, and
  string literals other than docstrings that begin a CREATE, ALTER or
  DROP of a table, type, schema, database, index, sequence, view or
  extension, or a TRUNCATE.

The only allowed findings are in catalog publication staging: the COPY
step through the psycopg connection beneath a SQLAlchemy Connection, and
STAGING_METADATA.create_all for temporary tables. A separate test checks
that every staging table compiles to CREATE TEMPORARY TABLE ... ON COMMIT
DROP. The comparison fails both when a finding is undeclared and when a
declaration is no longer used.

The proof fixtures reproduce two past cases from this repository: the
direct psycopg.connect calls in the removed Stage 12 provisioning script
and the runtime V1Base.metadata.create_all in the local bootstrap.

The rule is explained in docs/engineering/skills/alembic-migrations.md,
which also states that Redis is out of scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the sqlalchemy-alembic-database-rule branch from 6e6c1d1 to 6eb793d Compare October 22, 2026 17:43

This branch was previously deployed

1 inactive (outdated) deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enforce SQLAlchemy-only database connections and Alembic-only schema changes

1 participant