Skip to content

feat(api): persist cases and matches - #21

Open
Madeuss wants to merge 1 commit into
mainfrom
feat/persist-the-match
Open

feat(api): persist cases and matches#21
Madeuss wants to merge 1 commit into
mainfrom
feat/persist-the-match

Conversation

@Madeuss

@Madeuss Madeuss commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Part of E2 (#6). Matches survive a restart, which the HTTP endpoint and NPC
memory both wait on. ADR-0009.

Two shapes, stored differently

A case is generated, immutable, and always read whole. Nothing queries
across cases, and its shape moves with the generator — setting arrived days
after interval_count. One JSONB document, with seed / generator version /
setting as real columns under a unique constraint, because seed alone
identifies nothing
.

A match is the opposite: turns append, statements are read per character for
contradictions (RN-021), the budget moves every turn. Ordinary tables.

The part that matters

The solution has its own table. Case and Solution are separate entities
so isolation is a type signature rather than a discipline (RN-011). One layer
down, the same argument holds: as a column on cases, the culprit rides along
in every SELECT *, and the guarantee lasts exactly as long as nobody writes a
convenient query.

load_case(connection, case_id)       -> Case              # cannot reach it
load_full_case(connection, case_id)  -> CaseWithSolution  # for the verdict

The first does not touch the table the solution lives in. That is the guarantee
— not the caller's restraint.

A turn is one transaction

Statement, stance and budget move together or not at all. A crash cannot leave a
match charged for an answer it never recorded (RN-030). And uq_statements_turn
means the database refuses two turn 1s, so a retry cannot double-record.

Tests run against Postgres

Not SQLite. A schema built on JSONB and a composite unique constraint, tested on
a database the application will never run on, proves the tests pass.

CI gets a pgvector/pgvector:pg16 service and applies the migrations from
scratch on every run
— the only way to learn that they still apply.

125 passed (11 of them against a real database) · ruff clean · mypy strict: 31 files

Verified locally end to end: alembic upgrade head against the compose
database, four tables plus alembic_version, and a full turn recorded and read
back.

Choices worth arguing with

  • SQLAlchemy Core, not the ORM. Five queries over four tables, and Pydantic
    already owns the domain objects; an ORM would add a second object model to
    keep in agreement with the first.
  • Synchronous. The slow thing in a turn is the model call. FastAPI runs sync
    endpoints in a threadpool, and async would add a second execution model to
    reason about for a saving that does not appear at one player per match.
  • migrations/versions excluded from lint. Alembic writes those;
    reformatting generated files on every autogenerate hides the one line a human
    changed.

Costs are in the ADR, including the one that will bite first: a JSONB case is
opaque to SQL, so any future "find cases where…" is a scan.

@Madeuss Madeuss added this to the Phase 2 — Single NPC milestone Sep 2, 2026
A match now survives a restart, which is what the HTTP endpoint and NPC memory
both wait on — and memory sharing a transaction with game state is the argument
ADR-0002 was making.

The two things being stored are not alike, so they are not stored alike.

A case is generated, immutable and always read whole; nothing queries across
cases, and its shape moves with the generator. It is one JSONB document with
the three columns that identify it — seed, generator version, setting — carrying
a unique constraint, because seed alone identifies nothing.

A match is the opposite: turns append, statements are read per character to find
contradictions, and the budget changes every turn. Ordinary tables.

The solution gets its own table, and that is the point of the change rather than
a detail of it. `Case` and `Solution` are separate entities so isolation is a
type signature instead of a discipline (RN-011); as a column on `cases`, the
culprit would ride along in every SELECT *, and the guarantee would last exactly
as long as nobody wrote a convenient query. In its own table, `load_case` cannot
return it — the read does not touch where it lives.

A turn is one transaction: statement, stance and budget move together, so a
crash cannot leave a match charged for an answer it never recorded (RN-030). The
database also refuses two turn 1s, so a retry cannot double-record.

Storage tests run against Postgres, in CI as a service container. On SQLite they
would prove the tests pass, not that a schema using JSONB and a composite unique
constraint works. CI also applies the migrations from scratch on every run,
which is the only way to find out that they still do.

ADR-0009 records the shape of it, including what a JSONB case costs: it is
opaque to SQL, so "which cases put someone in the cellar at 22:00" is a scan.
Nothing asks yet.
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