Skip to content

feat(autopilot:postgres-support): phase 2 — SeaORM dialect layer foundation + exemplar repo ports (ADR-036) - #238

Merged
pacphi merged 20 commits into
developfrom
autopilot/postgres-support/phase-2
Aug 4, 2026
Merged

feat(autopilot:postgres-support): phase 2 — SeaORM dialect layer foundation + exemplar repo ports (ADR-036)#238
pacphi merged 20 commits into
developfrom
autopilot/postgres-support/phase-2

Conversation

@pacphi

@pacphi pacphi commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Automated phase-2 run (autopilot, pr_ci mode). Re-planned mid-phase per ADR-036: the hand-rolled dialect dispatch pivoted to SeaORM 2.0 as the dialect layer; the 11 pre-re-plan wip conversion commits are the phase's inherited starting point.

Deliverables (pipeline phase 2)

  • sea-orm 2.0.1 in [dependencies], default-features = false, trimmed to sqlx-sqlite, sqlx-postgres, runtime-tokio, with-chrono, with-uuid, macros, sqlite-use-returning-for-3_35 (the last kept deliberately — the RETURNING insert path the spike verified). No arrow in the resolved build graph (cargo tree --prefix none | grep -ci arrow → 0).
  • ADR-036 committed (decision + 11-point spike table + raw-escape-hatch policy for phase 6); ADR-035 status → superseded-as-strategy / retained-as-catalog.
  • backend/src/db/entities/: partial models for cleanup_plans, cleanup_plan_account_etags, cleanup_plan_operations, cleanup_apply_jobs — types match the migration DDL exactly (Vec<u8> BYTEA/BLOB, i64 BIGINT millis, i32 INT4 seq).
  • Database::sea_orm() — the one surviving composition-root backend match, wrapping the SAME pool (spike check ci(deps): bump DavidAnson/markdownlint-cli2-action from 19 to 23 #2); AppState.orm carries the handle alongside the legacy enum for the transition.
  • plan_repo.rs + job_repo.rs re-ported to single-code-path SeaORM (renamed SeaOrm*Repo; trait signatures unchanged): OnConflict upserts, TransactionTrait transactions, read-modify-write replaces json_set/jsonb_set (ADR-036 §2.4). These are the exemplar ports phase 3 mimics.
  • backend/examples/spike_seaorm.rs deleted (graduated; ADR-036 §3 records its results).

DoD evidence

Check Result
cargo build --features vectors green
cargo test 1205 backend tests, 0 failed (+ 30 frontend test files via just test)
cargo clippy --all-targets --features vectors -- -D warnings green
single sqlx version cargo tree -e no-build --prefix none | grep -E '^sqlx v'sqlx v0.9.0 + its (*) dedup repeat — one version, no dual stack (cargo tree -d prints nothing). Note: the DoD line's "exactly one line" phrasing is unsatisfiable as written because cargo marks the second occurrence with (*); recorded in the court record as a planner authoring bug, intent satisfied.
grep -E 'json_set|jsonb_set' backend/src/cleanup/repository/plan_repo.rs 0 hits
sea-orm in [dependencies] line 39–41 of backend/Cargo.toml
single-code-path repos, live-Postgres verified no match Database:: in either repo body; full-trait round-trips ran against a real container (below)
ADR-036 spike table + escape-hatch policy docs/ADRs/ADR-036-seaorm-dialect-layer.md §3 (11 rows), §2 item 5

Live-Postgres reproduction (DoD)

docker run -d --rm --name emailibrium-pg-test -p 55434:5432 \
  -e POSTGRES_PASSWORD=test -e POSTGRES_DB=emailibrium_test postgres:16-alpine
cd backend && EMAILIBRIUM_TEST_PG_URL='postgres://postgres:test@localhost:55434/emailibrium_test' \
  cargo test cleanup::repository -- --nocapture
docker rm -f emailibrium-pg-test

Runs all 26 Postgres migrations end-to-end, then postgres_full_trait_round_trip (every CleanupPlanRepository method) and postgres_job_lifecycle_round_trip against the live instance. Verified green during the gate.

qe-court (risk phase, court: auto) — verdict: SHIP

Record: .autopilot/court/postgres-support/phase-2.md (committed on this branch). 36 charges filed across 4 seated prosecutors (2 vendors); 0 surviving. Highlights: the mutation prosecutor empirically proved 12 surviving mutations (owner-scoping gaps) — the suite was hardened with two-owner scoping tests and 5 mutation classes re-verified KILLED; overturn round 1 succeeded (sea-orm default features, overstated concurrency claim, one unsatisfiable DoD literal) and was remediated in 0378b0d; overturn round 2 withdrew on adjudication (lockfile-vs-build-graph). Two prosecutor seats stalled on a convener sandbox error and are recorded as skipped with explicit coverage mapping — never as a pass.

Discovered work (recorded, not silently folded in)

12 open parking-lot records in .autopilot/discovered/postgres-support.jsonl, including: pl-concurrent-apply-guard (pre-existing begin_apply TOCTOU), pl-partial-pool-conversions (rules.rs/ingestion.rs raw .pool() sites the wip commits claimed converted — phase 3 must sweep), pl-timestamp-write-tz, pl-phase4-test-pg-env (phase 4's CI job must export EMAILIBRIUM_TEST_PG_URL). No blockers.

🤖 Generated with Claude Code

pacphi added 20 commits August 3, 2026 13:27
…,plan_repo}.rs to Database

job_repo.rs and plan_repo.rs now dispatch on Database::{Sqlite,Postgres} via
Database::adapt() + audited_sql(), matching the pattern established for
content/jobs.rs. plan_repo.rs's INSERT OR REPLACE / ON CONFLICT and
json_set/jsonb_set divergences are hand-written per backend per ADR-035 §2.3.

Fixed along the way (all confirmed via live postgres:16-alpine):
- list_operations() silently swallowed every SQL error via
  `.or_else(|_| Ok(Vec::new()))`, masking real failures as "no rows".
- list_operations()/max_seq() decoded the `seq` column (INTEGER/INT4) as i64;
  Postgres rejects the width mismatch at decode time (SQLite is lenient).
- append_operations()'s INSERT column list had a typo (`action_json` instead
  of the actual `action` column).

main.rs's SqliteCleanupPlanRepo construction site updated to pass the
Database handle instead of a raw pool clone.
…cleanup/repository/*, rules/*, and their call sites to Database

Adds Database::adapt() (placeholder + datetime('now') translation) and
audited_sql() usage across the second batch of phase-2 call sites:

- db/mod.rs: adapt() + sqlite_placeholders_to_postgres(), 8 new unit tests.
- content/jobs.rs (JobQueue): all 7 methods dispatch on Database; fixed a
  priority/attempts/max_retries i64->i32 column-width mismatch caught by
  live Postgres testing.
- cleanup/repository/job_repo.rs (CleanupApplyJobRepo): same pattern.
- cleanup/repository/adapters.rs: SqlxEmailRepository, SqlxSubscriptionRepository,
  SqlxClusterRepository, SqlxAccountStateProvider, SqlxRuleEvaluator all converted
  from raw Row::get() to portable tuple decode + Database dispatch. list_by_cluster's
  json_each() join gets hand-written jsonb_array_elements_text SQL for Postgres
  (ADR-035 §2.3). evaluate_scope's emails.received_at decodes as NaiveDateTime, not
  DateTime<Utc>, since the column is TIMESTAMP (no tz) in both dialects.
- rules/rule_engine.rs: load/save/get/delete_rule take &Database. rules.enabled is
  INTEGER (not BOOLEAN) in both dialects, so it round-trips as i32 with a bool
  conversion at the edges rather than relying on SQLite's lenient int/bool coercion.
- rules/executor.rs: apply_rule_action/apply_rules_to_email take &Database. Switched
  is_read/is_starred from SQL-literal `= 1` to a bound `true`, since those ARE real
  BOOLEAN columns and Postgres rejects an integer literal there; is_trash stays a
  literal since it's INTEGER in both dialects.
- api/rules.rs, api/ingestion.rs, cleanup/api/plan.rs, tools/readonly/cleanup_preview.rs:
  updated call sites for all of the above signature changes.

Everything above verified against a live postgres:16-alpine container (schema via
Database::run_migrations()), not just cargo test against SQLite.

Also commits ADR-035 (the placeholder-translation design decision these call sites
implement), written earlier this session but not yet checked in.
SqliteCleanupAuditWriter now dispatches on Database::{Sqlite,Postgres}.
SQLite's INSERT OR IGNORE has no Postgres equivalent, so write() hand-writes
an INSERT ... ON CONFLICT (plan_id, job_id, seq, outcome) DO NOTHING for
Postgres against the same UNIQUE constraint (ADR-035 §2.3). seq is bound and
decoded as i32 to match the actual INTEGER/INT4 column (same real-4-byte-int
class of bug fixed earlier in job_repo.rs/plan_repo.rs).

Verified against a live postgres:16-alpine container via a temporary #[ignore]
test (added, run manually, then removed) — ON CONFLICT DO NOTHING dedupes
correctly and the i32 seq round-trips.

Also fixes cleanup/orchestrator/apply.rs's one audit-writer test construction
site for the new Database-based constructor.

Discovered while testing: `cleanup::{audit,orchestrator,telemetry,api}` live in
the binary crate (declared in main.rs), not the library — cargo test --lib
never exercises them. cargo test --bin emailibrium (or plain cargo test) is
required to cover this module going forward.
…eue}.rs to Database

CheckpointService and OfflineQueue now dispatch on Database::{Sqlite,Postgres}.

New dialect divergence discovered and fixed here (not seen in earlier files):
sync_queue.created_at/processed_at are real TIMESTAMPTZ columns in Postgres
(ADR-033's dialect port) but plain TEXT in SQLite, where the app has always
hand-formatted RFC3339 strings. Binding/decoding those strings against Postgres
fails outright — confirmed via psql (`column "created_at" is of type timestamp
with time zone but expression is of type text`), since Postgres has no
text->timestamptz assignment cast. Fixed by binding/decoding a native
DateTime<Utc> on the Postgres arm instead of a String, while leaving the
SQLite arm's string handling untouched. Documented as its own case in
ADR-035 alongside the existing int-width and boolean-literal classes.

checkpoint.rs's cleanup_old() hand-writes a Postgres `make_interval`-based
cutoff (SQLite's `datetime('now', ? || ' days')` modifier syntax has no
equivalent) formatted to match SQLite's own cutoff string shape exactly,
deliberately preserving that comparison's existing narrow same-day
lexicographic-ordering quirk rather than silently fixing an unrelated
pre-existing bug during a dialect port.

total_count/processed_count/retry_count/max_retries all narrow from i64 to
i32 to match their actual INTEGER/INT4 columns (same class of bug fixed
in job_repo.rs/plan_repo.rs/cleanup/audit.rs).

sync_scheduler.rs needed no production changes (it only holds Arc<CheckpointService>/
Arc<OfflineQueue>, never touches SQL directly) — only its test module's
constructor call sites were updated.

All of the above verified against a live postgres:16-alpine container via a
temporary examples/ binary (added, run, then removed).
… to Database

ConflictResolver now dispatches on Database::{Sqlite,Postgres}, same
TIMESTAMPTZ-vs-TEXT divergence as offline_queue.rs: sync_conflicts.resolved_at/
created_at are real TIMESTAMPTZ columns in Postgres but TEXT (hand-formatted
RFC3339) in SQLite, so the Postgres arm binds/decodes DateTime<Utc> natively
instead of going through the SQLite path's string conversions.

Verified against a live postgres:16-alpine container via a temporary
examples/ binary (added, run, then removed), including the
sync_conflicts.queue_entry_id -> sync_queue(id) foreign key.
…ites drifted format on Postgres

Database::adapt()'s datetime('now') -> now() substitution is only correct
when the target column is TIMESTAMPTZ. background_jobs.updated_at/completed_at
are TEXT in both dialects (ADR-033), with a to_char(...)-shaped DEFAULT
specifically so the string format matches SQLite's datetime('now') output.
Letting adapt() rewrite these to bare now() still "worked" (Postgres allows
assigning any type into TEXT via its output function) but produced a
differently-shaped string (fractional seconds + tz offset) than SQLite writes
for the same logical timestamp — a silent cross-backend behavior drift, not
a crash, since JobRecord's timestamp fields are plain, unparsed Strings.

Fixed all 5 affected call sites (dequeue, mark_completed, mark_failed,
cancel, resume_abandoned) to hand-write the Postgres arm's SQL with
to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') instead of relying
on adapt(), matching the exact shape both SQLite and the migration's own
column DEFAULT produce. Verified via a live postgres:16-alpine container that
the string shape is now identical across backends.

Documented as ADR-035 §2.5 so the remaining call sites in this phase check
column type before reaching for adapt()'s datetime('now') substitution.
OAuthManager now dispatches on Database::{Sqlite,Postgres}. This completes
the email/*.rs batch (checkpoint, offline_queue, conflict_resolution, oauth).

Applies both ADR-035 patterns already established this phase:
- §2.3 hand-written-per-backend SQL for save_account/save_imap_account's
  INSERT ... ON CONFLICT (SQLite's INSERT OR IGNORE for the sync_state row
  becomes Postgres's ON CONFLICT (account_id) DO NOTHING).
- §2.5 hand-written to_char(...) for every updated_at = datetime('now')
  write (save_account, save_imap_account, update_tokens,
  update_account_settings, disconnect_account) — connected_accounts'
  created_at/updated_at are TEXT in both dialects, so these do not route
  through Database::adapt()'s datetime('now') substitution.

Also fixes three more i64-vs-actual-column-width mismatches: sync_state's
emails_synced and connected_accounts' imap_port/smtp_port are all INTEGER/
INT4, decoded as i32 (was i64).

Verified against a live postgres:16-alpine container via a temporary
examples/ binary (added, run, then removed) covering the full account
lifecycle: insert, ON CONFLICT upsert, token refresh, IMAP config
round-trip, list_accounts' TEXT-timestamp parsing, settings update,
disconnect, and the AccountNotFound error path.
…t_worker,apply}.rs to Database

ApplyOrchestrator.db and AccountWorkerCtx.db change from
Option<sqlx::SqlitePool> to Option<crate::db::Database>. account_worker.rs's
two archive-sync call sites (mark local is_archived after a successful
provider archive, delete local row after a successful permanent delete) are
factored into mark_archived_locally()/delete_locally() helpers that dispatch
per backend.

is_archived is a real BOOLEAN column in both dialects — the SQL literal
`is_archived = 1` this file used previously would have failed on Postgres
the same way is_read/is_starred did in rules/executor.rs, so the fix binds
`true` instead of embedding the literal (ADR-035).

Verified against a live postgres:16-alpine container via a temporary
#[ignore] test in account_worker.rs (added, run, then removed).
ConsentManager already held Arc<Database> but every query still called
self.db.pool() directly (the not-yet-migrated bridge). Converted all six
methods to dispatch on Database::{Sqlite,Postgres}.

Discovers and documents a third timestamp-column shape (ADR-035 §2.6):
ai_consent/ai_audit_log use plain TIMESTAMP (no timezone) in both dialects —
distinct from the TEXT-shaped (§2.5) and TIMESTAMPTZ cases already known.
Confirmed via a throwaway sqlx example that encoding a DateTime<Utc> into a
TIMESTAMP column works fine (Postgres's timestamptz->timestamp assignment
cast), but decoding a TIMESTAMP column into DateTime<Utc> fails outright —
same asymmetry already hit once for emails.received_at. Fixed by decoding as
NaiveDateTime on the Postgres arm and widening via .and_utc(); binds are
unchanged on both arms.

Also fixes ai_audit_log.id/input_token_count/output_token_count/latency_ms
from i64 to i32 to match their actual INTEGER/INT4 columns (same pattern as
prior files), confirmed via psql that the reverse (binding i64 into an
INTEGER column) is safe via Postgres's int8->int4 assignment cast, so only
the decode side needed narrowing.

Verified against a live postgres:16-alpine container via a temporary
examples/ binary (added, run, then removed).
CloudApiAuditLogger converted to dispatch on Database::{Sqlite,Postgres}
across ensure_table/log/get_log/get_summary.

Discovers and documents a fourth dialect-mismatch class (ADR-035 §2.7):
Postgres's avg(integer) returns NUMERIC, not a float — SQLite's AVG() always
returns a real number. Confirmed via psql (pg_typeof(AVG(int_col)) = numeric)
and a throwaway sqlx example (decoding NUMERIC into f64 fails outright; this
codebase doesn't carry the bigdecimal/rust_decimal sqlx feature). Fixed by
casting the aggregate expression explicitly (AVG(latency_ms)::float8) on the
Postgres arm rather than pulling in a new decimal-decode dependency.

ensure_table()'s ad-hoc DDL (a defensive idempotent fallback now redundant
with migration 008_cloud_api_audit.sql, but kept for tests/callers that
haven't migrated) hand-writes id's auto-increment syntax per backend
(AUTOINCREMENT vs GENERATED ALWAYS AS IDENTITY).

Also applies the now-familiar i64->i32 width fix (id/input_tokens/
output_tokens/latency_ms are INTEGER/INT4) and the TIMESTAMP-decodes-as-
NaiveDateTime fix from consent.rs (§2.6) to get_log()'s row type.

Verified against a live postgres:16-alpine container via a temporary
examples/ binary (added, run, then removed), including the AVG cast and the
IDENTITY-based ensure_table() DDL.
…tabase

RemoteWipeService dispatches on Database::{Sqlite,Postgres}. The many
single-bind DELETE statements share one exec_delete() helper so the
per-backend dispatch is written once. ensure_table() hand-writes id's
auto-increment DDL per backend (wipe_audit_log has no numbered migration —
this method is that table's sole source of truth), same AUTOINCREMENT vs
GENERATED ALWAYS AS IDENTITY split live-verified for vectors/audit.rs.

SQLite-path verified via the module's 9 unit tests; the Postgres arms reuse
the exact adapt()/audited_sql()/IDENTITY patterns already live-Postgres-
verified in prior files this phase (no new dialect constructs introduced).
…alect layer (ADR-036)

Mid-phase-2 pivot, user-directed ("complete minimization of custom dialect
code") and spike-validated. 15 of the (revised) 51 call-site files into the
hand-rolled ADR-035 strategy, four additional dialect-divergence classes had
surfaced (§2.5–§2.7), each demanding more per-backend custom code.

A spike (backend/examples/spike_seaorm.rs, committed here as the exemplar
pattern; deleted when phase 2's real port lands) ported plan_repo.rs's hardest
operations to SeaORM 2.0 and passed 11/11 checks against BOTH backends
through the pools our existing Database::connect() creates:
single sqlx-0.9 tree, pool-wrapping interop, Vec<u8>+composite PKs,
one-code-path upserts/transactions/dynamic-filters/aggregates,
json_set eliminated via read-modify-write, update_many+rows_affected,
per-backend raw escape hatch, ConnectionTrait-generic repo fns.
ADR-036 records the full table and the alternatives considered.

Plan changes (phases 0/1 keep their gate-PASSED markers; in-flight phase-2
branch is the re-planned phase 2's starting point, not discarded):
- phase 2 rewritten: SeaORM foundation + exemplar ports (plan_repo, job_repo)
- new phase 3: full port; delete adapt()/audited_sql()/pool() bridge + match
  dispatch
- old phases 3/4/5 renumbered 4/5/6, bodies unchanged; deps/risk_phases
  updated ([2,3,4,6])
- ADR-035 status: superseded as strategy, retained as the divergence catalog
- schema debt (TEXT timestamps, JSON-array columns, migration-system
  convergence) split out to a queued db-schema-modernization pipeline
  (parked locally per lifecycle; promoted only after this pipeline ships)

sea-orm rides in dev-dependencies for the spike; phase 2 promotes it to a
real dependency with trimmed features.
…ort; sea-orm to [dependencies]

19 green pinning tests capture the equivalence contract the ADR-036 re-port
must preserve (json_set semantics, absent-row no-ops, cursor pagination,
FK-seeded job lifecycle). Also fixes tests/mcp_integration.rs left behind by
the oauth.rs wip conversion (OAuthManager::new now takes Database).
…_orm() accessor, plan/job repos (ADR-036)

- db/entities/: partial models for the 4 cleanup tables, types matching the
  migration DDL (Vec<u8> BYTEA, i64 BIGINT millis, i32 INT4 seq)
- Database::sea_orm(): the one surviving composition-root backend match,
  wrapping the SAME pool (spike Q2); AppState carries the handle as .orm
- plan_repo/job_repo: single-code-path SeaORM bodies (OnConflict upsert,
  TransactionTrait, read-modify-write replaces json_set/jsonb_set), renamed
  Sqlite* -> SeaOrm*; trait signatures unchanged; all 19 pinned behaviors green
- RepoError gains Db(#[from] sea_orm::DbErr)
- env-gated live-PG full-trait tests, verified against postgres:16-alpine
- spike_seaorm.rs deleted (graduated; ADR-036 §3 records its results)
…r of record

- yamllint: per-rule indentation ignore for docs/api/openapi.yaml (prettier's
  multiline flow-mapping style; same contradiction class as the braces rule,
  same per-rule-ignore precedent as line-length)
- markdownlint: ignore the untracked personal note under docs/ (starts with a
  blockquote, MD041 unfixable without altering content; CI never sees it)
…honesty prosecutor)

- document the read-modify-write lost-update window + single-writer-per-row
  invariant at both status updaters and in ADR-036 §2.4 (charge 1, MAJOR)
- paraphrase doc comments so the DoD's literal grep:absent json_set|jsonb_set
  check passes as written (charge 2)
- insert_operation: checked i32::try_from(seq) instead of a silent truncating
  cast, erroring like PostgreSQL itself would (charge 4)
… charges; fix ADR-035 citations

- two-owner scoping tests kill the 8 MAJOR surviving mutations (dropped
  user_id/plan_id/job_id filters), plus: expire_due status-guard fixture,
  timestamp+hash round-trip asserts, applied_at/status/email_id/source_kind
  COLUMN-level asserts, risk_max medium/high round trip, list_by_user ordering,
  SQLite-path upsert coverage
- payload_with_status doc: note key-order normalization on rewrite
- fix ADR-035 cross-references (upserts were never its §2.3) in ADR-036 §5,
  plan_repo.rs, cleanup/audit.rs; precision fixes in entity/module docs
- parking-lot: timestamp write-side TZ class, partial pool() conversions in
  rules.rs/ingestion.rs, phase-4 EMAILIBRIUM_TEST_PG_URL handoff note
…a-orm defaults, correct concurrency claim

- sea-orm: default-features = false; keep sqlite-use-returning-for-3_35
  deliberately (the RETURNING insert path the spike verified). Resolved
  features now exactly the trimmed set (no with-json/with-rust_decimal/
  with-time/stream)
- correct the RMW concurrency documentation in plan_repo.rs + ADR-036 §2.4:
  single-writer-per-row is the INTENDED topology, not machine-enforced —
  begin_apply's snapshot status gate is a pre-existing TOCTOU (recorded as
  pl-concurrent-apply-guard) permitting duplicate apply workers
@pacphi

pacphi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@pacphi
pacphi merged commit 99fa17f into develop Aug 4, 2026
20 checks passed
@pacphi
pacphi deleted the autopilot/postgres-support/phase-2 branch August 4, 2026 03:32
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.

1 participant