# Reliability & Accuracy

> **Status:** v0.1 — first pass, finalized 2026-05-15
> (review-pass verified all file:line citations against the
> live source). Last updated 2026-05-15.
> A federation-sibling-scoped reliability audit: walked the single
> source file (`_env.py`) end-to-end against the **RA** / **EH** /
> **SC** rule set from `mgf-standard/docs/standards/`. The sibling's
> public surface is one name (`configure_env`).
>
> **Scope:** v1.0 readiness for mgf-alembic.
> **Audience:** the maintainer + future AI agents.
> **Cross-references:** [`PUBLIC_API.md`](../../PUBLIC_API.md),
> [`docs/standards/README.md`](../standards/README.md),
> [`mgf-common/docs/inprogress/RELIABILITY_AND_ACCURACY.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/inprogress/RELIABILITY_AND_ACCURACY.md)
> (cornerstone), [`mgf-sqlalchemy/docs/inprogress/RELIABILITY_AND_ACCURACY.md`](https://codeberg.org/magogi-admin/mgf-sqlalchemy/src/branch/main/docs/inprogress/RELIABILITY_AND_ACCURACY.md)
> (sister-sibling — async/sync URL detection patterns are
> related).

The sibling is at v0.2.0. The `configure_env` helper is invoked
from each consumer's `alembic/env.py`; a reliability gap here
breaks migrations across every downstream project.

---

## §1 You are here

mgf-alembic ships one public name; `mypy --strict` clean;
ruff clean; the async-vs-sync URL detection works correctly
for the documented driver set; engines are NullPool +
disposed in `finally`; `compare_type` + `compare_server_default`
default to `True` (catching column-type + default-clause
changes that alembic's stock defaults silently ignore).

What this tracker exposes is the **integration-context shape**
— concerns that surface when `configure_env` runs in a
context with an existing event loop, or against a URL that
the driver-detection heuristic gets wrong, or with shared
metadata that mutates across multiple `configure_env` calls
in the same process.

**Counts (2026-05-17):**

| Priority | Open | Closed | Total |
|---|---|---|---|
| P0 — v1.0 surface-lock blockers | 0 | 0 | 0 |
| P1 — should land before/with v1.0 | 0 | 2 | 2 |
| P2 — post-v1.0 hardening | 0 | 3 | 3 |
| **Total** | **0** | **5** | **5** |

**Do first:** ALL items closed. RA-01, RA-02 (2026-05-17, P1
sweep); RA-03 / RA-04 / RA-05 (2026-05-17, P2 sweep — the
`is_async=` + `transaction_per_migration=` parameters).

---

## §2 Priority table

| ID | Concern | One-line | Effort | Status |
|---|---|---|---|---|
| [**RA-01**](#ra-01-asyncio-run-from-running-event-loop) | Embedded-use compatibility | `configure_env` calls `asyncio.run(...)` for the async path. Fails with `RuntimeError("cannot be called from a running event loop")` when invoked from a context that already has one (e.g., a pytest-asyncio fixture that runs migrations inline, a long-running test harness that bundles alembic into its own loop). | S | ✅ closed |
| [**RA-02**](#ra-02-naming-convention-mutates-consumer-metadata) | Hidden side-effect | `target_metadata.naming_convention = naming_convention` MUTATES the consumer's metadata object. If they reuse `Base.metadata` across `configure_env` calls + their runtime model setup, the mutation persists. Subtle to debug; the same `Base.metadata` would carry the convention from migration-time into runtime queries. | XS | ✅ closed |
| [**RA-03**](#ra-03-postgresql-psycopg-dual-mode-routing) | URL-detection misrouting | `postgresql+psycopg://` is treated as async unconditionally. psycopg 3 supports BOTH sync and async modes under the same URL prefix — the driver behaviour depends on whether the consumer code uses the sync or async API. Our heuristic gets it wrong half the time. | S | ✅ closed |
| [**RA-04**](#ra-04-async-prefix-list-maintainability) | Driver-detection robustness | `_ASYNC_URL_PREFIXES` is a hardcoded tuple. Adding a new async driver (`postgresql+asyncpg-v2://` future, alternate-driver shapes) requires a release. Stretches the "configure_env is a one-line replacement" promise. | S | ✅ closed |
| [**RA-05**](#ra-05-transactional-ddl-opt-out) | DB-engine-specific behaviour | `context.begin_transaction()` wraps migrations in a transaction. Postgres has transactional DDL (rollback works mid-migration); MySQL does not (each ALTER TABLE auto-commits, breaking the transaction). The helper offers no opt-out for transactional-DDL-less DBs. | XS | ✅ closed |

Effort scale: XS (≤ 15 min); S (≤ 1 h); M (≤ 1 session).

---

## §3 Invariants we hold

The list of "closed elsewhere; do not re-open":

- **RA-INV-1** — Migrations use **NullPool**. Both
  `_run_online_sync` and `_run_online_async` pass
  `poolclass=pool.NullPool`
  ([`_env.py:233`, `_env.py:260`](../../src/mgf/alembic/_env.py#L233)).
  Correct for one-shot migration runs; pooling would just
  leak connections.
- **RA-INV-2** — Engine **disposed in `finally`** on both
  sync and async paths
  ([`_env.py:244-245`, `_env.py:271-272`](../../src/mgf/alembic/_env.py#L244)).
  Per **DP-13**.
- **RA-INV-3** — Naming convention applied **BEFORE**
  `context.configure(...)` runs
  ([`_env.py:120-121`](../../src/mgf/alembic/_env.py#L120)).
  Autogenerated constraints pick up the convention; doing
  this after configure would be too late.
- **RA-INV-4** — `include_object` + `include_schemas` are
  **passed through verbatim** to alembic. The library
  doesn't filter or wrap; consumer's callable runs with
  alembic's native signature. Per PAPER-22.
- **RA-INV-5** — Offline mode (`alembic upgrade --sql`)
  detected via `context.is_offline_mode()`
  ([`_env.py:123`](../../src/mgf/alembic/_env.py#L123)) — the
  canonical alembic API; we don't reinvent the detection.

---

## §4 Work units

### RA-01 `asyncio.run` from running event loop

**Why:** `configure_env` calls `asyncio.run(...)` for the
async path
([`_env.py:134-145`](../../src/mgf/alembic/_env.py#L134)):

```python
is_async = database_url.startswith(_ASYNC_URL_PREFIXES)
if is_async:
    asyncio.run(_run_online_async(...))
```

`asyncio.run(...)` creates a new event loop. Per CPython
docs: "This function cannot be called when another asyncio
event loop is running in the same thread." It raises
`RuntimeError("cannot be called from a running event loop")`.

When does this hit?

- **alembic CLI invocation** (`alembic upgrade head`): the
  CLI starts with no event loop — works correctly.
- **Pytest-asyncio fixtures** that run migrations inline:
  the test runner has an event loop active — would raise.
- **Embedded use cases** where the consumer wraps migration
  logic in their own async harness (rare but exists):
  would raise.

The fix isn't a one-liner — async-context detection +
fallback to `loop.run_until_complete` is the safest shape
but has its own pitfalls (running until complete on a
non-owned loop is a bad idea).

**Concrete output:**

- Detect the running-loop case at the call site:
  ```python
  try:
      loop = asyncio.get_running_loop()
  except RuntimeError:
      asyncio.run(_run_online_async(...))
  else:
      raise RuntimeError(
          "configure_env: cannot run async migrations from a "
          "running event loop. Either invoke from a sync "
          "context (CLI, plain script), or use the async "
          "primitives directly: `await _run_online_async(...)`."
      )
  ```
- Optionally: export `_run_online_async` as a public name
  (`run_async_migrations`) so embedded async harnesses can
  call it with `await` directly.
- Add a unit test asserting the typed RuntimeError fires
  when called from inside `asyncio.run(...)`.

**Locked in by:** the unit test; a regression that drops
the detection breaks it.

**Cross-references:** EH-09 (actionable hints); the error
message names the two valid call shapes.

---

### RA-02 `naming_convention` mutates consumer metadata

**Why:** `configure_env`
([`_env.py:120-121`](../../src/mgf/alembic/_env.py#L120)):

```python
if naming_convention is not None:
    target_metadata.naming_convention = naming_convention
```

This MUTATES the consumer's `MetaData` object. The most
common consumer pattern:

```python
# alembic/env.py
from myapp.models import Base
from mgf.alembic import configure_env

configure_env(
    database_url=URL,
    target_metadata=Base.metadata,
    naming_convention={...},
)
```

After this call, `Base.metadata.naming_convention` is set
PERMANENTLY for the process. If the same process later does
runtime model work (rare with alembic; happens with embedded
migration runners), the convention persists.

Subtle, but the kind of bug that surfaces in production
when a consumer adds a new model + the constraint names
suddenly follow the migration convention.

**Concrete output:**

- Two options:
  1. **Document the side-effect.** Add a "Side effects"
     section to the docstring: "Sets
     `target_metadata.naming_convention` on the passed object.
     Pass a copy (`copy.copy(Base.metadata)`) if you don't
     want the convention to persist."
  2. **Avoid the mutation.** Make a shallow copy of the
     metadata internally before mutating: `target_metadata =
     copy.copy(target_metadata)` — but this is more complex
     because alembic needs the same identity later. Likely
     too invasive.
- Option 1 is the cheap, correct fix. The mutation IS the
  documented behavior for naming-convention propagation;
  just document it loudly.
- Add a regression test: build a fresh metadata, call
  configure_env with a convention, assert the metadata's
  convention is set.

**Locked in by:** the docstring change is review-level; the
regression test pins the contract.

**Cross-references:** DP-13 (deterministic side-effects —
documented mutation is fine; undocumented is the violation).

---

### RA-03 `postgresql+psycopg://` dual-mode routing

**Why:** `_ASYNC_URL_PREFIXES`
([`_env.py:39-45`](../../src/mgf/alembic/_env.py#L39)) includes
`postgresql+psycopg://`. psycopg 3 (the package name without
the digit, distinct from psycopg2) supports both **sync** and
**async** modes:

- `psycopg.connect(...)` — sync
- `psycopg.AsyncConnection.connect(...)` — async

SQLAlchemy's URL `postgresql+psycopg://` selects the psycopg 3
driver, but which mode is used depends on whether the
SQLAlchemy engine is built sync (`create_engine`) or async
(`create_async_engine`).

`configure_env`'s heuristic ALWAYS routes
`postgresql+psycopg://` through the async path. If the
consumer's migration actually wants sync semantics (because
their app uses psycopg 3 in sync mode), the migration runs
on `create_async_engine(...)` which is incorrect.

**Concrete output:**

- Two options:
  1. **Remove `postgresql+psycopg://` from `_ASYNC_URL_PREFIXES`.**
     Force consumers to be explicit:
     - Use `postgresql+asyncpg://` (clearly async)
     - Use `postgresql+psycopg://` (treated as sync; consumers
       wanting async psycopg 3 must... not work? this option
       needs more thought)
     - This is breaking for any consumer already using
       async-psycopg through `+psycopg`.
  2. **Add an explicit `is_async` parameter to
     `configure_env`.** Default to None (use heuristic);
     consumers can override:
     ```python
     configure_env(
         database_url=URL,
         target_metadata=Base.metadata,
         is_async=False,  # override heuristic
     )
     ```
- Option 2 is non-breaking; ship it. Option 1 is the future
  v0.3 shape.
- Add a test that asserts `is_async=False` routes through
  `_run_online_sync` even when the URL is async-prefixed.

**Locked in by:** the unit test.

**Cross-references:** the SQLAlchemy URL grammar; mgf-sqlalchemy
RA-05 (related URL-detection brittleness).

---

### RA-04 Async-prefix list maintainability

**Why:** `_ASYNC_URL_PREFIXES` is a hardcoded tuple at module
top:

```python
_ASYNC_URL_PREFIXES: tuple[str, ...] = (
    "postgresql+asyncpg://",
    "postgresql+psycopg://",
    "mysql+aiomysql://",
    "mysql+asyncmy://",
    "sqlite+aiosqlite://",
)
```

Adding a new async driver requires a release. The promise of
`configure_env` is "one-line replacement for the env.py
boilerplate"; a consumer using a brand-new async driver finds
the boilerplate is faster after all.

The fix is straightforward if RA-03's `is_async=` parameter
ships — the heuristic becomes optional:

- `is_async=True` forces async.
- `is_async=False` forces sync.
- `is_async=None` (default) uses the heuristic.

Then the hardcoded list only matters for the default case;
consumers using cutting-edge drivers override.

**Concrete output:**

- Bundle with RA-03's fix. The `is_async=` parameter solves
  both.
- Optionally: document the supported async drivers in the
  docstring + cross-link to the v0.3+ roadmap if/when the
  next-generation drivers (asyncmy v2, asyncpg v2, etc.)
  ship.

**Locked in by:** RA-03's test exercises the `is_async=`
override path.

**Cross-references:** RA-03 (the broader fix this rolls
into).

---

### RA-05 Transactional-DDL opt-out

**Why:** Both online paths wrap migrations in
`context.begin_transaction()`
([`_env.py:188`, `_env.py:217`](../../src/mgf/alembic/_env.py#L188)).

Postgres has **transactional DDL**: `CREATE TABLE`, `ALTER
TABLE`, `DROP COLUMN`, etc. all participate in transactions
and can be rolled back. A migration that fails halfway leaves
the schema unchanged.

MySQL does NOT have transactional DDL: every DDL statement
auto-commits. A migration with multiple ALTER TABLE statements
that fails on the third leaves the first two committed; the
transaction wrapper is meaningless.

The helper applies `begin_transaction()` unconditionally.
Consumers with MySQL who EXPECT no transaction (and want
alembic to use `transaction_per_migration=True` instead) get
silent behaviour they don't want.

This is alembic's standard shape — `context.begin_transaction()`
just opens whatever transaction the dialect supports. But the
helper could explicitly support the `transaction_per_migration`
flag for MySQL-shaped consumers.

**Concrete output:**

- Add a `transaction_per_migration: bool = False` parameter
  to `configure_env`; forward to alembic's
  `context.configure(transactional_ddl=False,
  transaction_per_migration=True, ...)` when set.
- Document the MySQL case in the docstring: "MySQL users
  SHOULD pass `transaction_per_migration=True` so each
  migration is its own transaction (MySQL DDL auto-commits;
  the default wrap-all-in-one-transaction is meaningless)."
- Add a test that verifies the kwarg passes through to
  context.configure.

**Locked in by:** the unit test.

**Cross-references:** alembic's docs on transactional DDL;
DEP-06 (reversible deploys — migration rollbackability is
the load-bearing case).

---

## §5 Closed items

- **RA-01** — Running-event-loop guard + public async entry
  point. Added a `try: asyncio.get_running_loop()` probe in
  `configure_env`'s async dispatch branch; if a loop is
  active, raise an actionable `RuntimeError` whose message
  names the public alternative (`run_async_migrations`) AND
  cross-references this tracker entry (`RA-01`). The standard
  alembic-CLI shape (no loop active) is unchanged. New public
  symbol `run_async_migrations` is a thin awaitable wrapper
  over the existing `_run_online_async` — embedded callers
  (pytest-asyncio fixtures, notebooks, embedded test
  harnesses) `await run_async_migrations(...)` directly and
  bypass the asyncio.run shape entirely. Same defaults as
  `configure_env`'s async path; same kwargs. Three new tests:
  guard fires from a pytest-asyncio context with the actionable
  message; sync-context path unaffected; new public symbol is
  exported + reachable + awaitable from a running loop.
  Retroactively makes PC-02's docstring note accurate — the
  earlier note forward-referenced RA-01's export. (2026-05-17,
  commit pending.)
- **RA-02** — `naming_convention` mutation side-effect
  documented. Expanded the `naming_convention` kwarg's
  docstring with a new "Side effect (RA-02)" paragraph that
  spells out the mutation, lists when it matters (long-lived
  processes that share `Base.metadata` between migration-time
  and runtime), and gives the explicit workaround
  (`copy.copy(Base.metadata)` if you want isolation). Two
  regression tests pin the contract: the mutation IS the
  documented behaviour (a refactor that copies the metadata
  internally would fail the test AND force a docstring
  update); the default `naming_convention=None` case does
  NOT mutate. (2026-05-17, commit pending.)
- **RA-03 + RA-04** — `is_async: bool | None = None` override
  shipped on `configure_env`. The hardcoded
  `_ASYNC_URL_PREFIXES` heuristic is now only consulted when
  `is_async is None`. Consumers using psycopg 3 in sync mode
  pass `is_async=False`; consumers on cutting-edge async
  drivers pass `is_async=True`. Non-breaking; the existing
  heuristic remains the default for the common case.
  (2026-05-17, P2 sweep.)
- **RA-05** — `transaction_per_migration: bool = False` kwarg
  threaded through to alembic's `context.configure(...)`.
  MySQL consumers (where DDL auto-commits and the outer
  `begin_transaction()` is meaningless) opt in by passing
  `transaction_per_migration=True`; alembic wraps EACH
  migration in its own transaction-or-equivalent unit
  instead of one transaction across the whole batch.
  Default is False — Postgres consumers (transactional DDL)
  retain the existing one-transaction-for-all-migrations
  shape. (2026-05-17, P2 sweep.)

---

## §6 Notes on methodology

This first pass walked the single source file (~230 LOC)
end-to-end against:

- **RA** rule patterns from mgf-common's
  `inprogress/RELIABILITY_AND_ACCURACY.md`.
- **EH** rules from `mgf-standard/docs/standards/ERROR_HANDLING.md`
  — translation seams, actionable hints.
- **DP** rules from `mgf-standard/docs/standards/DESIGN_PRINCIPLES.md`
  — DP-13 (deterministic resource release).
- **DEP** rules from `mgf-standard/docs/standards/DEPLOYMENT.md`
  — DEP-06 (reversible deploys; migration rollback the
  canonical case).

The audit method:

1. Read the source file end-to-end with the rule set in mind.
2. Verify `file:line` against the live source.
3. Cross-reference against the cornerstone's RA tracker and
   the sister-sibling mgf-sqlalchemy's tracker.
4. Triage: items that don't tie to a rule + concrete evidence
   are observations.

Five items + five invariants. The library is tightly scoped
(one helper); the RA shape reflects that. Future passes
should look for:

- **Alembic version bumps.** This sibling pins to alembic
  ≥ 1.x; a 2.0 release would warrant a re-audit, especially
  the offline-mode + transaction-handling contracts.
- **New async drivers.** As the Python async-DB ecosystem
  matures, new drivers will arrive. RA-03/04's `is_async=`
  parameter (if shipped) makes this a config concern rather
  than a release concern.
- **Cross-DB transactional-DDL handling.** MySQL is the
  obvious case (RA-05); Oracle / SQL Server have their own
  quirks. As Magogi consumers reach for those DBs, the
  helper may need DB-specific knobs.

When an item closes, move it to §5 with the closing-commit
SHA.
