EP052 / PostgreSQL migration

Moves lean_exchange persistence off local SQLite onto PostgreSQL, so the live Render service can hold an owner credential across restarts/redeploys instead of losing it whenever the container's ephemeral filesystem resets. Triggered by the live Arena having no way to bootstrap an owner credential durably. Click each stage for its contract.

1. Trigger — no durable owner credential on the live service

The owner-bootstrap CLI (admin.py create-owner) wrote straight into whatever SQLite file the running process had open. On Render, without a persistent disk confirmed, that file resets on redeploy — so any credential created was never provably durable. Decision: move the system of record to PostgreSQL, which Render already hosts durably elsewhere in this workspace.

2. Schema ownership — dedicated ep052 Postgres schema

All 17 tables move into CREATE SCHEMA IF NOT EXISTS ep052, not the bare public schema, so this app can share one Postgres instance with other epics (e.g. ep047-directory-db) without table-name collisions. The connection sets search_path=ep052,public at connect time, so every unqualified table reference in existing SQL resolves into that schema automatically — no per-statement schema-qualification needed.

3. Driver swap — sqlite3 → psycopg 3

records.py's Store class now opens psycopg.connect(EP052_DATABASE_URL or DATABASE_URL) instead of a SQLite file path. psycopg[binary]>=3.2 added to pyproject.toml. One connection per transaction() call, matching the previous per-transaction sqlite3.connect pattern — a pooled connection is a reasonable later optimisation, not required for this migration.

4. Call-site compatibility shim — 15 other files untouched

auth.py, access.py, connections.py, arena.py, pricing.py, trades.py and 9 more files all call db.execute(sql, params) written against SQLite's ? placeholders, INSERT OR IGNORE, and sqlite3.Row's dual row['col']/row[0] access. Rather than rewrite every SQL string across the package, a compatibility layer in records.py translates ?%s and INSERT OR IGNORE INTOINSERT INTO ... ON CONFLICT DO NOTHING per-call, and a custom row type supports both keyed and positional access. One genuine schema fix was still required: window is a Postgres reserved word, so the rate-limiter's rate_windows.window column was renamed to window_ordinal.

5. Verification — real Postgres, not a mock

A throwaway local PostgreSQL instance (initdb + pg_ctl on a scratch data directory, port 5544 — the machine's own Postgres Windows service was left untouched) was used to run a real end-to-end smoke test: schema creation, insert, keyed and positional row reads, the rate-limiter's upsert, and INSERT OR IGNORE conflict semantics all confirmed correct, and the agents table confirmed to live in information_schema.tables under table_schema='ep052', not public.

6. Test fixtures — SQLite temp file → disposable Postgres database per test

tests/conftest.py's autouse fixture previously pointed every test at a fresh tmp_path/exchange.sqlite file. It now creates a uniquely-named ep052_test_<hash> database on an admin Postgres connection (EP052_TEST_DATABASE_ADMIN_URL, defaulting to the same local throwaway instance) before each test and drops it after, exporting EP052_DATABASE_URL so Store() finds it exactly as it would a real deployment's URL. Individual test files that explicitly passed a SQLite path into fixture_app()/setup_app()/create_app() (~41 call sites across 10 files) have that argument removed so they fall back to the per-test env var instead.

7. Explicitly out of scope — not converted

recovery.py (SQLite-file backup/integrity-check/restore tooling — PRAGMA integrity_check, WAL-file copying — has no direct Postgres equivalent; would need a pg_dump/pg_basebackup-based rewrite as a separate decision) and simulated_intelligence.py (a separate mock external-provider store) both still open raw sqlite3 connections, untouched. tests/test_recovery_security.py's four tests exercise recovery.py directly against real SQLite files and were left as-is for the same reason — converting them would require deciding recovery.py's own Postgres story first, not just swapping a test fixture.

8. Remaining before this is closed

Confirm whether the live ep052-agentic-arena Render service has a persistent disk or an attached Postgres instance (checked against the Render dashboard, not assumed from code); set EP052_DATABASE_URL there; redeploy; re-run the owner-bootstrap CLI against the live service and verify the resulting credential survives a restart. Full local test-suite run (107 tests) not yet completed end-to-end post-fixture-conversion.