Spaces:
Running on Zero
Running on Zero
agharsallah
feat: require live infrastructure for event store and inference, removing offline mode
eed2172 | # ADR-0014: Durable Postgres Event Store Behind the Ledger Interface | |
| ## Status | |
| Accepted | |
| ## Context | |
| The append-only event ledger is the single source of truth: all projections, | |
| memory, and crash recovery derive from it (ADR-0001). `SQLiteLedger` (ADR-0013, | |
| `docs/architecture/persistence.md`) made that log durable on a local file, which | |
| is enough for one process on one disk. A hosted, multi-instance deployment wants | |
| a managed database: durable across restarts, reachable by several workers, and | |
| operated by someone else. Managed Postgres β e.g. Neon β is the natural target, | |
| but the engine must not be coupled to a single vendor or to a database being | |
| present at all: the suite has to stay green with no connection. | |
| Two product requests shaped the implementation: use **SQLAlchemy** for the store, | |
| and use the **`eventsourcing`** library. We evaluated `eventsourcing`'s | |
| persistence primitives against our model and they do not compose cleanly (see | |
| below). | |
| ## Decision | |
| Add a durable backend *for* the ledger; do not replace the ledger. The `Event` | |
| envelope and the `Ledger` interface are unchanged. | |
| **Layered.** `SqlAlchemyLedger(Ledger)` (`src/core/sqlalchemy_ledger.py`) is a | |
| drop-in backend that mirrors `SQLiteLedger`'s surface β `append`, `events`, | |
| `reset`, `extend`, plus `snapshot_to` / `from_file` / `tail` / `latest_offset` / | |
| `close`. Idempotency is a `UNIQUE` constraint on the event `id`; insertion order | |
| is a serial `offset` column (not `created_at`, which is subject to clock skew, nor | |
| `turn`, which repeats on retry) β the same guarantees as the SQLite backend. The | |
| *same* SQLAlchemy code drives both Postgres and SQLite, so the backend is | |
| exercised in CI against SQLite without a server, and the Neon path is | |
| code-identical. | |
| **The durable store is required.** A small factory (`src/core/ledger_factory.py`, | |
| `make_ledger()`) constructs the backend from `DATABASE_URL` (`SqlAlchemyLedger`). | |
| There is **no in-memory fallback**: with no URL resolved, `make_ledger()` raises β | |
| the app persists to a real event store and refuses to run without one (this is | |
| part of dropping the offline product mode; see ADR-0010). The store deps | |
| (`sqlalchemy>=2.0`, `psycopg[binary]>=3` β the Neon driver) are therefore **core | |
| dependencies** in `pyproject.toml`, not an optional extra. SQLAlchemy is still | |
| imported lazily inside the backend (so `src.core.*` stays importable in minimal | |
| contexts), but it always ships. Tests pass an explicit ephemeral `sqlite://` URL | |
| as the mock store β a real `SqlAlchemyLedger` with no server. | |
| **SQLAlchemy-direct, not the `eventsourcing` library.** `eventsourcing` is built | |
| around DDD aggregates: its `StoredEvent` is keyed on `originator_id` + | |
| `originator_version` (a per-aggregate sequence), reads are | |
| `select_events(originator_id, gt=version)`, and event state is opaque serialised | |
| `bytes`. Our ledger is a *flat envelope* with a single global insertion-ordered | |
| log and idempotency by UUID `id` β not aggregate streams. Mapping onto its | |
| recorder would force either one synthetic aggregate per run (conflating | |
| idempotency-by-`id` with version-by-sequence and losing a clean global order) or | |
| opaque blobs (losing the queryable, indexed `run_id` / `kind` / `actor` columns | |
| the ledger relies on). That is the awkward aggregate model to avoid, so the lib is | |
| **not** a dependency. SQLAlchemy is the right level for a flat event table. | |
| *How `eventsourcing` could layer in later:* if a scenario ever needs true DDD | |
| aggregates (per-entity invariants, optimistic-concurrency version checks), it can | |
| adopt `eventsourcing` *above* this store β an `ApplicationRecorder` backed by the | |
| same Postgres β while the flat ledger remains the cross-cutting source of truth. | |
| The two are complementary, not competing. | |
| ## Consequences | |
| - A hosted deployment points `DATABASE_URL` at Neon | |
| (`postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require`) and the durable | |
| log lives in managed Postgres; everything else is unchanged. | |
| - A `DATABASE_URL` is required to run; `make_ledger()` raises without one. Tests | |
| pass an ephemeral `sqlite://` URL (a real `SqlAlchemyLedger`, no server) as the | |
| mock store, so the suite stays green with no database server and no network. | |
| - `snapshot_to` is backend-agnostic (it replays the log into a destination | |
| ledger, default a SQLite file) since Postgres has no portable in-process backup | |
| API like SQLite's `.backup()`; a Postgres run can be checkpointed to a portable | |
| file that `from_file` reopens. | |
| - `scripts/resume_run.py` and `modal_app.py` use `DATABASE_URL` when set and fall | |
| back to their local SQLite file otherwise, so their offline behaviour is intact. | |
| - Caveat: the live multi-scenario UI shares one `DATABASE_URL` across scenarios | |
| (one `events` table). For isolated durable runs, use `resume_run.py`, which | |
| keeps one database per scenario. Multi-run/multi-scenario partitioning within a | |
| single store (e.g. filtering by `run_id`) is a follow-up. | |
| - `pgvector`-based episodic retrieval and Postgres `LISTEN/NOTIFY` tailing (noted | |
| in `docs/architecture/persistence.md`) become possible now that the durable | |
| backend is Postgres; neither is built here. | |