0002 — Per-module Alembic branches
- Status: accepted
- Date: 2026-04-27
- Tags: modules, migrations, uninstall
Context
Removable modules need a clean uninstall path: drop their tables, drop their migration history, leave the rest of the schema intact. With a single shared Alembic chain, downgrading a removable module's first migration walks back through every later migration that happened to land after it — including migrations belonging to other modules. That breaks any module whose revisions were threaded through another's chain.
Issue #56 captured a regression where this exact failure mode broke the uninstall round-trip. Fix: each module owns its own Alembic branch via branch_labels, and the loader reconstructs version_locations dynamically from the modules currently installed.
Decision
Every module's first migration sets branch_labels = ("<module>",). Every later migration in that module is on the same branch (its down_revision always points to a revision in the same branch).
Operational implications:
alembic upgrade heads(plural) is the canonical command.alembic upgrade headis wrong here and may pick an arbitrary branch.- Cross-module FKs are still allowed but only against modules listed in
manifest.depends(so we know the dep was up before us). - The loader uses
_module_branch_label()to detect a module's branch and assembleversion_locationsper active install.
Consequences
Good
- Uninstall round-trip works without rewriting unrelated history (
backend/tests/test_uninstall_roundtrip.py). - Adding or removing a module never breaks another module's history.
- New modules can be developed independently without merge conflicts on a global Alembic file.
Bad / accepted trade-offs
- Authors must remember
branch_labelson the first migration of every module. CI now catches the missing case. alembic upgrade head(singular) is footgun in this repo — always useheads.
Alternatives considered
- Global linear chain. Rejected: caused #56, makes uninstall unsafe.
- One Alembic env per module. Rejected: too much boilerplate, breaks ad-hoc tooling that assumes one env.
How to verify the rule still holds
backend/tests/test_alembic_branches.py— discovery + branch isolationbackend/tests/test_alembic_roundtrip.py— full upgrade/downgrade cyclebackend/tests/test_uninstall_roundtrip.py— the regression testbackend/app/core/plugins/processor.py:483—_module_branch_label
References
- Issue #56
docs/technical/creating-modules.md§3 (migrations/)- Alembic branch labels docs: https://alembic.sqlalchemy.org/en/latest/branches.html