ADR-010: Alembic Retention Through 0.6.x and Rust Schema Cutover¶
Status: accepted. The schema-cutover mechanism is to be built and made
opt-in during the 0.6.x point releases (epic 2Z3Y46); retiring the Alembic
dependency and the legacy conversion is gated to a 0.7 release.
Partially superseded: the 0.6.x retention posture was retired 2026-09-15 by ADR-052
Context¶
degenbot’s database schema is, today, Alembic-owned: the canonical schema
lives in src/degenbot/migrations/versions/, alembic_version is the
authority table, and production databases are stamped at an Alembic revision.
The Rust core (degenbot-db) reads these databases but does not write
schema to them — DegenbotDb::open reports one of four states
(migrate.rs::ensure_schema):
AlembicCurrent—alembic_version.version_num == ALEMBIC_HEAD. The Rust core honorsPRAGMA query_only=onand reads.AlembicStale { head, expected }— older revision. Refuse; the Python Alembic path must advance the stamp.FreshStandalone { schema_version }— empty file. Apply the embeddedSCHEMA_HEADDDL, stamp_degenbot_db_schema_versionwithRUST_SCHEMA_VERSION.Unrecognized— has tables, noalembic_version. Refuse (a foreign SQLite file passed by mistake).
This hybrid period is deliberate (migrate.rs doc comment): the Rust core
never downgrades or forwards an Alembic DB. The end state, however, is that
the schema is Rust-owned: future schema bumps are Rust ALTER scripts
tracked by RUST_SCHEMA_VERSION, and the alembic_version table is gone.
The problem is that there is no path from Alembic ownership to Rust
ownership. A DB that has crossed the boundary (tables present,
alembic_version dropped, _degenbot_db_schema_version stamped) is — under
today’s ensure_schema — indistinguishable from a foreign SQLite file:
alembic_version is absent, table_count > 0, so it returns Unrecognized
and DegenbotDb::open refuses. Worse, even a FreshStandalone DB created by
the Rust core cannot be re-opened: the second open sees no
alembic_version, table_count > 0, and also returns Unrecognized.
Closing this gap is not a local fix; it is a schema-ownership transition that
must be coordinated with releases, because pip users on production
Alembic-stamped databases must be able to upgrade through the final Alembic
revision before ownership flips. Dropping the Alembic dependency prematurely
strands those users on a database the Rust core refuses to open.
Decision¶
Two end-states, version-gated, with the cutover mechanism built and proven during 0.6.x and the Alembic retirement executed only in 0.7.
1. During 0.6.x — Alembic retained, cutover opt-in¶
The DB stays Alembic-stamped by default; the Rust core continues to read via
query_only=onon theAlembicCurrentpath.The schema-cutover operation is built in
degenbot-dband surfaced as an explicit, opt-in CLI command,degenbot database cutover. Nothing auto-runs cutover onDegenbotDb::open. Rationale: a one-way schema- ownership flip on first open is surprising and hard to roll back; an explicit command lets users cutover deliberately, and lets a pytest prove the boundary deterministically.ensure_schemagains a fifth state,RustOwned { schema_version }, for a DB with tables present, noalembic_version, and a stamped_degenbot_db_schema_version. This subsumes the re-openedFreshStandalonecase (it now reads back asRustOwnedrather thanUnrecognized) and covers the post-cutover state. A foreign SQLite file (tables, no alembic, no_degenbot_db_schema_version) remainsUnrecognized.convert_alembic_to_rust_owned(conn)is the one operation that writes schema to an otherwise-Alembic DB, and only because the DB is leaving Alembic ownership: verifyalembic_version.version_num == ALEMBIC_HEAD, drop thealembic_versiontable, stamp_degenbot_db_schema_versionwithRUST_SCHEMA_VERSION. RefuseAlembicStale(upgrade via Alembic first) andUnrecognized.
2. The cutover state machine¶
ensure_schema decisions, enumerated exhaustively over the three observable
predicates (tables_present?, alembic_version?, _degenbot_db_schema_version?):
tables |
alembic_version |
_degenbot_db_schema_version |
state |
|---|---|---|---|
0 |
— |
— |
|
>0 |
at |
— |
|
>0 |
older than head |
— |
|
>0 |
absent |
present |
|
>0 |
absent |
absent |
|
0 |
present |
— |
|
The predicate tables_present? counts non-sqlite_% tables. The
_degenbot_db_schema_version table is private to the Rust core and is
not counted as a content table (it is filtered, like sqlite_%).
After FreshStandalone applies its DDL on first open, the next open of
that file reads back as RustOwned (tables present, no alembic, version table
stamped) — FreshStandalone is the one-shot “I just created this” report;
RustOwned is the steady state for any Rust-owned DB thereafter.
3. Several 0.6.x point releases ship the cutover path¶
The cutover command and the RustOwned branch ship across 0.6.x point
releases so that every pip user can upgrade a stale database through the
final Alembic revision and then cutover at a time of their choosing. No
0.6.x release removes Alembic or forces cutover.
4. 0.7 — retirement¶
The actual retirement is gated to a 0.7 release and tracked as the blocked
task JFFQV2. Only in 0.7 may an agent touch the forbidden-until-0.7 kill
list (below). The retirement:
drops the
alembicand (if nothing else uses it)sqlalchemydependencies frompyproject.toml;deletes
src/degenbot/migrations/;removes the
ALEMBIC_HEADconstant inrust/crates/foundation/degenbot-db/src/schema.rsand thealembic_version-reading branch ofensure_schema;removes the
database upgradeAlembic fall-back path (theDatabaseSchemaStale→alembic.command.upgradeshell);decides whether
DegenbotDb::openauto-converts a stale-but-recognized legacy shape or refuses (resolved at 0.7 design time).
Forbidden-until-0.7 kill list¶
No change before the 0.7 retirement task (JFFQV2) may delete or stub any of:
src/degenbot/migrations/(the Alembic migration scripts);the
alembicandsqlalchemyentries inpyproject.toml;DatabaseSessionManagerand the SQLAlchemysrc/degenbot/database/models/package;the
ALEMBIC_HEADconstant inrust/crates/foundation/degenbot-db/src/schema.rs;the
alembic_version-reading branch ofrust/crates/foundation/degenbot-db/src/migrate.rs::ensure_schema;the
PRAGMA query_only=onsetting on theAlembicCurrentpath inDegenbotDb::open.
An import falling out of use is not permission to delete it. If a 0.6.x task makes an Alembic/SQLAlchemy symbol unused, the task leaves it in place and notes the orphaned symbol in its completion summary; removal is the 0.7 retirement task’s exclusive responsibility.
Considered options (rejected alternatives)¶
Auto-cutover on open during 0.6.x. Have
DegenbotDb::opensilently convert anAlembicCurrentDB toRustOwnedon first open. Rejected: a one-way schema-ownership flip (dropsalembic_version, switches the authority table) is surprising, hard to roll back, and impossible for a pytest to exercise deterministically against an arbitrary production DB. An explicitdegenbot database cutovercommand makes the transition a deliberate user action and a precisely testable boundary.Retire Alembic in 0.6.x (skip the hybrid releases). Build the cutover and drop Alembic in the same release train. Rejected:
pipusers on production Alembic-stamped databases need at least one release that ships both the cutover path and the Alembic upgrade path, so they can move a stale database toALEMBIC_HEADand then cutover. Removing Alembic in the same release that introduces the cutover strands anyone whose database is not yet at head.Keep Alembic indefinitely. Treat the hybrid period as permanent. Rejected: the long-term vision (AGENTS.md) is that the Rust core owns everything a bot needs, including schema. Alembic is a Python build/runtime concern; a standalone
cargo add degenbotconsumer has no Alembic. The cutover mechanism is how the project gets to that end state without stranding existing databases.
Consequences¶
0.6.x releases are dual-path. Both the Alembic upgrade path and the Rust cutover path ship. Users may upgrade through the final Alembic revision and cutover to Rust ownership, in either order across releases.
ensure_schemagainsRustOwned. Both the post-cutover DB and the re-openedFreshStandaloneDB reportRustOwned;FreshStandaloneremains the one-shot “just created” report.Unrecognizedis now unambiguous: tables, no alembic, no_degenbot_db_schema_version.The cutover is the one Rust-writes-schema-to-an-Alembic-DB operation. Documented as an exception in
convert_alembic_to_rust_owned’s doc comment; it is bounded to the boundary-crossing moment and never runs on theAlembicCurrentread path.0.7 retirement is the only deletion point. Mid-0.6.x cleanups observe the forbidden-until-0.7 kill list; an orphaned-but-still-needed import stays until 0.7.
The cutover boundary is pytest-proven. A test migrates a stale Alembic-tagged database through
ALEMBIC_HEADtoRustOwned, asserting noalembic_versiontable remains and the data round-trips. That test is the contract artifact for the whole transition.