autoincrement: default pk ∧ integer upstream, pk downstream. nullable: derived twice. The #153 regression lives in a compiler comment.
After — one deep value object
ColumnSpec
built once at class-definition time
pk · autoincrement · nullable · db_type · fk — each derived exactly once
↓ consumed by
SchemaIR compiler
Alembic bridge
Problem. One field declaration is packed, unpacked, re-serialized, and re-parsed across five files; two modules compute autoincrement and nullable independently with different defaulting rules.
Solution. A ColumnSpec value object produced once when the class body executes, consumed directly by the SchemaIR compiler and the Alembic bridge — the JSON-schema dict stops doubling as an untyped IR.
locality: one defaulting rule per fact
new column option = one edit, not five
ColumnSpec unit-tests need no Model compile
2 · Put resolved registration behind a Registry module
Strongin-process
ferro/state.py:56–139 · src/state.rs:137–224 · tests/conftest.py:213–277 · 25 test files
Before — tests reach past the interface
flowchart TD
T1[25 test files] --> G1[_MODEL_REGISTRY_PY]
T1 --> G2[_PENDING_RELATIONS]
T1 --> G3[_JOIN_TABLE_REGISTRY]
F[clean_registry fixture] --> G1
F --> G4[4 envelope + modelset stores]
F --> G5[generation counters]
F --> R[Rust clear_registry]
Q[am I synced?] -.-> G5
Q -.-> G6[Py fingerprint]
Q -.-> G7[Rust INSTALLED_FINGERPRINT]
classDef leak stroke:#dc2626,stroke-width:2px;
class T1,F,Q leak
The clear ritual is hand-rolled ~50×; the fixture wipes 8 globals; the synced invariant lives in 3 representations across 2 languages.
Problem. The registry's seven stores are module globals, so provisional and resolved registration have no interface for tests to cross — 25 files import internals, and "is the runtime current?" must be answered by reading counters in state.py, a fingerprint in state.py, and a fingerprint in state.rs.
Solution. A Registry module owning stores, counters, and fingerprint behind is_current() / reset() / isolated(); the metaclass writes through an injectable current-registry handle.
Extends ADR-0001, doesn't contradict it — install_registration stays the single atomic push; this deepens the Python side the ADR already centralized.
leverage: one fixture replaces ~50 hand-rolled clears
locality: sync invariant readable in one module
tests stop importing ferro.state internals
test-only FFI counters become ordinary assertions
3 · Split operations.rs along its horizontal seams
Strongin-process
src/operations.rs (2,640 non-test lines; 1,200 more are 8 inline test modules)
Before — five concerns interleaved in every op
route_engine preamble — repeated ×16
inline SQL build (48 lines inside fetch_filtered)
execute + engine-bind marshalling
hydration loop — triplicated verbatim ×3
identity-map store manager (lines 109–246)
fetch_all / fetch_one / fetch_filtered each re-implement the same identity-map hit → refresh, miss → hydrate loop.
After — three deep bands, ops become glue
sql_build(plan, schema, dialect) → (sql, binds)pure — no DB
each #[pyfunction]: route → build → execute → assemble
Problem. Five modules share one file — routing, identity-map store, SQL building, execution, hydration — interleaved inside each async closure; the read-path loop is paid three times and the routing preamble sixteen.
Solution. Split horizontally (not per-operation): a pure sql_build module, the existing Executor as exec, and assemble_rows hiding the identity-map fast-path behind a ReadCtx.
SQL building unit-tests need no DB
identity-map branch matrix tested over synthetic rows
delete two of three hydration loops
the 8 inline test modules already test the extractable seams — evidence they want to exist
flowchart LR
P[migrate.rs] --> E2[emit_sql_with_ir]
E2 --> DDL[ferro-ddl-lowering]
T1[its own tests only] -.-> E1[emit_sql — 7/10 ops are placeholder comments]
T2[test_provisional_import only] -.-> RMS[register_model_schema FFI]
RMS -- writes MODEL_REGISTRY directly --> REG[(Rust registry)]
INST[install_registration build-then-swap] --> REG
classDef dead stroke:#dc2626,stroke-width:2px,stroke-dasharray:4 4,color:#dc2626;
classDef good fill:#0f172a,color:#fff;
class E1,RMS,T1,T2 dead
class INST,E2 good
After — one emit path, one install path
flowchart LR
P[migrate.rs] --> E2[emit_sql_with_ir + DropColumn now executable]
E2 --> DDL[ferro-ddl-lowering]
INST[install_registration] --> REG[(Rust registry)]
classDef good fill:#0f172a,color:#fff;
class INST,E2 good
ADR-0001's single-install invariant becomes structural: you can't call what isn't exported.
Problem.emit_sql is production-dead (kept alive by its own tests); register_model_schema has zero production callers and writes MODEL_REGISTRY directly, bypassing the atomic build-then-swap; DROP COLUMN is rendered in two places with two quoting strategies; index_models is copied verbatim across two files.
Solution. Delete both dead entries, make DropColumn executable through emit_sql_with_ir, hoist index_models.
Deletion test: complexity vanishes — these are pass-throughs and stubs, not load-bearing modules.
sequenceDiagram
participant Py as nodes.py / builder.py
participant Rs as query.rs
Py->>Rs: {ir_kind, ir_version: 1, payload} as JSON string
Note over Rs: deserializes payload directly — ir_kind and ir_version never read
Py->>Rs: operator "LIKE" (string)
Note over Rs: match operator.as_str() unknown → runtime Err mid-query
After — gate on ingest, one vocabulary
sequenceDiagram
participant Py as nodes.py / builder.py
participant Rs as query.rs
Py->>Rs: envelope
Note over Rs: validate ir_kind + ir_version on every ingest (queries too)
Note over Py,Rs: operator vocabulary generated from one shared source — unknown operator fails at build
Problem. The interface between the query builder and the Rust planner is a stringly contract living in two languages: the version field is written and silently dropped, and the operator vocabulary is duplicated — golden IR fixtures are the only enforcement.
Solution. Validate kind + version on every ingest as the schema push already does; source the operator vocabulary from one shared table so both sides fail fast.
version mismatch fails loudly, not silently
new operator = one edit, build-time failure
IR vectors become conformance suite, not sole safety net
6 · Collapse the naming FFI band into one deep call
_annotation_utils — the one unwrap module; other four call in
__ferro_pk__ — computed once at class definition; everyone reads it
composite groups — one parametrized module, two thin declarations
Problem. Three rules — peel Annotated/T | None, find the primary key, validate composite groups — are each re-implemented in several modules with subtly different scope, so a fix in one copy doesn't propagate.
Solution. One home per rule; the copies become calls.
Deletion test: the duplicates vanish into the shared module — they are copied logic, not independent concerns.
locality: unwrap bug fixed once, fixed everywhere
delete ~150 duplicate lines and one dead loop
8 · Test the codec belt through its existing pure seams
OID-correct Postgres binds — the code most likely to regress silently — is verified only via built extension + live Postgres.
After — the interface is the test surface
decode_engine_value(EngineValue, plan) → RustValue — already pure, just lacks a test module
schema_bind_expr(...).to_string() — assertable without a DB (pattern operations.rs already uses)
drop-column decision(index rows) → plan — extract as pure function over introspected rows
Problem.RustValue is a clean seam between plan, application, and hydration — but only the plan side has Rust tests; bind/decode correctness rides entirely on end-to-end Python suites.
Solution. No restructure — the pure seams exist; give them #[cfg(test)] modules, and extract the one DB-coupled decision (SQLite autoindex blocking) into a pure function.
bind regressions caught in cargo test, no Postgres needed
conftest hard-fails the whole session if _core won't import; ~53 of 80 test files boot a real engine.
After — two adapters, real seam
Python ORM
Rust engine (prod + e2e)
in-memory fake (unit)
Routing, precedence, and query-immutability tests run without the .so or a DB.
Problem. The FFI seam has exactly one adapter — the real engine — so pure-Python behaviour (route resolution, operation-scope precedence, query immutability) is only exercisable end-to-end.
Solution. A narrow Python-level backend interface (fetch_filtered / save_record / …) with an in-memory fake as the second adapter.
One adapter means a hypothetical seam — the fake must earn its keep or it tests a fiction. Whether the Python-only logic is rich enough to justify a second adapter is the design question to grill.
It deepens the seam ADR-0001 already built, exactly where the friction is measured: 25 test files reaching past the interface, a fixture wiping eight globals, and a sync invariant split across three representations in two languages. And it compounds — once tests cross one Registry interface, every other candidate here (ColumnSpec, the operations.rs split, the dead-path deletions) becomes safer to verify. Do the seam that makes the rest of the work testable first.
Runner-up: 4 · dead-path deletion — an afternoon's work that closes a live violation channel around the atomic install.