From e68efcc4647fcd97d3d660b3bac8d046c6897856 Mon Sep 17 00:00:00 2001 From: Madeuss Date: Tue, 1 Sep 2026 22:08:58 -0300 Subject: [PATCH] feat(api): persist cases and matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 21 ++ Makefile | 9 +- apps/api/alembic.ini | 39 ++++ apps/api/migrations/env.py | 51 +++++ apps/api/migrations/script.py.mako | 24 ++ .../versions/0f90e37c4ebe_initial_schema.py | 71 ++++++ apps/api/pyproject.toml | 7 + apps/api/src/firenze/storage/__init__.py | 26 +++ apps/api/src/firenze/storage/engine.py | 27 +++ apps/api/src/firenze/storage/store.py | 179 +++++++++++++++ apps/api/src/firenze/storage/tables.py | 94 ++++++++ apps/api/tests/test_storage.py | 195 ++++++++++++++++ apps/api/uv.lock | 214 ++++++++++++++++++ docs/00-plano-de-projeto.md | 2 + docs/08-achados.md | 12 + ...09-a-case-is-a-document-a-match-is-rows.md | 93 ++++++++ 16 files changed, 1061 insertions(+), 3 deletions(-) create mode 100644 apps/api/alembic.ini create mode 100644 apps/api/migrations/env.py create mode 100644 apps/api/migrations/script.py.mako create mode 100644 apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py create mode 100644 apps/api/src/firenze/storage/__init__.py create mode 100644 apps/api/src/firenze/storage/engine.py create mode 100644 apps/api/src/firenze/storage/store.py create mode 100644 apps/api/src/firenze/storage/tables.py create mode 100644 apps/api/tests/test_storage.py create mode 100644 docs/adr/0009-a-case-is-a-document-a-match-is-rows.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3080ed..aaaceff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,30 @@ jobs: defaults: run: working-directory: apps/api + # The storage tests run against the database the application actually uses. + # Passing on SQLite would prove the tests pass, not that the schema works. + services: + db: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: firenze + POSTGRES_PASSWORD: firenze + POSTGRES_DB: firenze + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U firenze" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + FIRENZE_TEST_DATABASE_URL: postgresql+psycopg://firenze:firenze@localhost:5432/firenze + FIRENZE_DATABASE_URL: postgresql+psycopg://firenze:firenze@localhost:5432/firenze steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-python-env + - name: migrations apply cleanly from scratch + run: uv run alembic upgrade head - run: uv run pytest - name: contrato commitado está atualizado run: | diff --git a/Makefile b/Makefile index d04d3fa..b1325b7 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ COMPOSE := docker compose -f infra/compose/docker-compose.yml API := apps/api .DEFAULT_GOAL := help -.PHONY: help dev down logs psql install api case ask openapi lint fmt typecheck test check migrate evals +.PHONY: help dev down logs psql install api case ask openapi lint fmt typecheck test check migrate migration evals help: ## lista os alvos @grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) | sed 's/:.*## /\t/' | expand -t 14 @@ -51,8 +51,11 @@ test: ## pytest check: lint typecheck test ## tudo que o CI cobra -migrate: ## aplica as migrations (alembic) — fase 1 - @echo "Ainda não existe schema. Chega junto com o gerador de casos (fase 1)." && exit 1 +migrate: ## aplica as migrations (alembic) + cd $(API) && uv run alembic upgrade head + +migration: ## cria migration a partir do schema (make migration M="add x") + cd $(API) && uv run alembic revision --autogenerate -m "$(M)" evals: ## roda a suíte de avaliação — fase 3 @echo "Suíte de evals chega na fase 3. Ver docs/06-plano-de-evals.md." && exit 1 diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..4c2b470 --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = migrations +prepend_sys_path = src +path_separator = os +# The URL comes from FIRENZE_DATABASE_URL, resolved in migrations/env.py. +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/apps/api/migrations/env.py b/apps/api/migrations/env.py new file mode 100644 index 0000000..f536751 --- /dev/null +++ b/apps/api/migrations/env.py @@ -0,0 +1,51 @@ +"""Alembic wiring. + +The URL is never written in alembic.ini: it carries a password, and a file that +carries a password is a file somebody commits eventually. It comes from +settings, which read the environment. +""" + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from firenze.config import settings +from firenze.storage import metadata + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/migrations/script.py.mako b/apps/api/migrations/script.py.mako new file mode 100644 index 0000000..99c8842 --- /dev/null +++ b/apps/api/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py b/apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py new file mode 100644 index 0000000..1105a75 --- /dev/null +++ b/apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py @@ -0,0 +1,71 @@ +"""initial schema + +Revision ID: 0f90e37c4ebe +Revises: +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '0f90e37c4ebe' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('cases', + sa.Column('id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False), + sa.Column('seed', sa.Integer(), nullable=False), + sa.Column('generator_version', sa.String(length=16), nullable=False), + sa.Column('setting', sa.String(length=64), nullable=False), + sa.Column('document', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('seed', 'generator_version', 'setting', name='uq_cases_identity') + ) + op.create_table('matches', + sa.Column('id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False), + sa.Column('case_id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False), + sa.Column('locale', sa.String(length=16), nullable=False), + sa.Column('turns_left', sa.Integer(), nullable=False), + sa.Column('stances', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('solutions', + sa.Column('case_id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False), + sa.Column('document', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('case_id') + ) + op.create_table('statements', + sa.Column('id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False), + sa.Column('match_id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False), + sa.Column('turn', sa.Integer(), nullable=False), + sa.Column('character', sa.String(length=32), nullable=False), + sa.Column('question', sa.Text(), nullable=False), + sa.Column('line', sa.Text(), nullable=False), + sa.Column('stance', sa.String(length=16), nullable=False), + sa.Column('lied', sa.Boolean(), nullable=False), + sa.Column('fact_referenced', sa.String(length=16), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['match_id'], ['matches.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('match_id', 'turn', name='uq_statements_turn') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('statements') + op.drop_table('solutions') + op.drop_table('matches') + op.drop_table('cases') + # ### end Alembic commands ### diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index f761bf1..448a9b3 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ "uvicorn[standard]>=0.34", "pydantic-settings>=2.7", "openai>=3.6.0", + "sqlalchemy>=2.0", + "psycopg[binary]>=3.2", + "alembic>=1.19.1", ] [project.scripts] @@ -32,6 +35,9 @@ packages = ["src/firenze"] line-length = 100 target-version = "py313" src = ["src", "tests"] +# Alembic writes these; reformatting generated files on every autogenerate is +# churn that hides the one line a human actually changed. +extend-exclude = ["migrations/versions"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] @@ -40,6 +46,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] python_version = "3.13" strict = true warn_unreachable = true +exclude = ["migrations/versions"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/apps/api/src/firenze/storage/__init__.py b/apps/api/src/firenze/storage/__init__.py new file mode 100644 index 0000000..82d7fe9 --- /dev/null +++ b/apps/api/src/firenze/storage/__init__.py @@ -0,0 +1,26 @@ +"""Persistence. The only package that writes SQL.""" + +from firenze.storage.engine import engine, transaction +from firenze.storage.store import ( + NotFound, + load_case, + load_full_case, + load_match, + record_turn, + save_case, + start_match, +) +from firenze.storage.tables import metadata + +__all__ = [ + "NotFound", + "engine", + "load_case", + "load_full_case", + "load_match", + "metadata", + "record_turn", + "save_case", + "start_match", + "transaction", +] diff --git a/apps/api/src/firenze/storage/engine.py b/apps/api/src/firenze/storage/engine.py new file mode 100644 index 0000000..c8d09f3 --- /dev/null +++ b/apps/api/src/firenze/storage/engine.py @@ -0,0 +1,27 @@ +"""One engine, built from configuration. + +`pool_pre_ping` because the managed database sits behind a network that drops +idle connections, and a stale one surfaces as a failed turn rather than as a +reconnect (ADR-0002). +""" + +from collections.abc import Iterator +from contextlib import contextmanager +from functools import cache + +from sqlalchemy import Engine, create_engine +from sqlalchemy.engine import Connection + +from firenze.config import settings + + +@cache +def engine(url: str | None = None) -> Engine: + return create_engine(url or settings.database_url, pool_pre_ping=True, future=True) + + +@contextmanager +def transaction(url: str | None = None) -> Iterator[Connection]: + """A unit of work. One turn is one of these.""" + with engine(url).begin() as connection: + yield connection diff --git a/apps/api/src/firenze/storage/store.py b/apps/api/src/firenze/storage/store.py new file mode 100644 index 0000000..3f96fbd --- /dev/null +++ b/apps/api/src/firenze/storage/store.py @@ -0,0 +1,179 @@ +"""Reading and writing matches. + +Two methods load a case, and which one you call decides what you are allowed to +know: + + load_case(case_id) -> Case # no solution reachable + load_match(match_id) -> Match # solution included, for the verdict + +That is the same boundary as everywhere else in this project, expressed once +more in the layer where it is easiest to lose (RN-011). A caller that only needs +to render a briefing takes the first and physically cannot obtain the culprit — +the query does not touch the table it lives in. + +A turn is one transaction: the statement, the stance and the budget move +together or not at all. Splitting them would let a crash leave a match that was +charged for an answer it never recorded (RN-030). +""" + +import uuid + +from sqlalchemy import insert, select, update +from sqlalchemy.engine import Connection + +from firenze.domain import Case, CaseWithSolution, Match, Solution, Stance, Statement +from firenze.storage.tables import cases, matches, solutions, statements + + +class NotFound(LookupError): + """No row with that id.""" + + +def save_case(connection: Connection, full: CaseWithSolution) -> uuid.UUID: + """Store a case and its solution. Idempotent on the case's identity.""" + case = full.case + existing = connection.execute( + select(cases.c.id).where( + cases.c.seed == case.seed, + cases.c.generator_version == case.generator_version, + cases.c.setting == case.setting, + ) + ).scalar_one_or_none() + if existing is not None: + return uuid.UUID(str(existing)) + + case_id = uuid.uuid4() + connection.execute( + insert(cases).values( + id=case_id, + seed=case.seed, + generator_version=case.generator_version, + setting=case.setting, + document=case.model_dump(mode="json"), + ) + ) + connection.execute( + insert(solutions).values(case_id=case_id, document=full.solution.model_dump(mode="json")) + ) + return case_id + + +def load_case(connection: Connection, case_id: uuid.UUID) -> Case: + """The case as a player's side of the system may know it. No solution.""" + row = connection.execute( + select(cases.c.document).where(cases.c.id == case_id) + ).scalar_one_or_none() + if row is None: + raise NotFound(f"no case {case_id}") + return Case.model_validate(row) + + +def load_full_case(connection: Connection, case_id: uuid.UUID) -> CaseWithSolution: + """Case plus solution. For the generator, the solver and the verdict.""" + row = connection.execute( + select(cases.c.document, solutions.c.document) + .join(solutions, solutions.c.case_id == cases.c.id) + .where(cases.c.id == case_id) + ).first() + if row is None: + raise NotFound(f"no case {case_id}") + return CaseWithSolution( + case=Case.model_validate(row[0]), solution=Solution.model_validate(row[1]) + ) + + +def start_match(connection: Connection, full: CaseWithSolution, locale: str) -> uuid.UUID: + """Save the case if needed and open a match on it.""" + case_id = save_case(connection, full) + match_id = uuid.uuid4() + connection.execute( + insert(matches).values( + id=match_id, + case_id=case_id, + locale=locale, + turns_left=Match.model_fields["turns_left"].default, + stances={}, + ) + ) + return match_id + + +def load_match(connection: Connection, match_id: uuid.UUID) -> Match: + row = connection.execute( + select(matches.c.case_id, matches.c.locale, matches.c.turns_left, matches.c.stances).where( + matches.c.id == match_id + ) + ).first() + if row is None: + raise NotFound(f"no match {match_id}") + + said = connection.execute( + select( + statements.c.turn, + statements.c.character, + statements.c.question, + statements.c.line, + statements.c.stance, + statements.c.lied, + statements.c.fact_referenced, + ) + .where(statements.c.match_id == match_id) + .order_by(statements.c.turn) + ).all() + + return Match( + full_case=load_full_case(connection, uuid.UUID(str(row[0]))), + locale=row[1], + turns_left=row[2], + stances={who: Stance(value) for who, value in (row[3] or {}).items()}, + statements=tuple( + Statement( + turn=s[0], + character=s[1], + question=s[2], + line=s[3], + stance=Stance(s[4]), + lied=s[5], + fact_referenced=s[6], + ) + for s in said + ), + ) + + +def record_turn( + connection: Connection, + match_id: uuid.UUID, + match: Match, + statement: Statement | None, +) -> None: + """Persist what one turn changed, in one transaction. + + `statement` is None when the reply was rejected — the budget still moves, + because a turn that only charged for answers the system liked would be a + turn a player could farm by provoking failures. + """ + connection.execute( + update(matches) + .where(matches.c.id == match_id) + .values( + turns_left=match.turns_left, + stances={who: stance.value for who, stance in match.stances.items()}, + ) + ) + if statement is None: + return + + connection.execute( + insert(statements).values( + id=uuid.uuid4(), + match_id=match_id, + turn=statement.turn, + character=statement.character, + question=statement.question, + line=statement.line, + stance=statement.stance.value, + lied=statement.lied, + fact_referenced=statement.fact_referenced, + ) + ) diff --git a/apps/api/src/firenze/storage/tables.py b/apps/api/src/firenze/storage/tables.py new file mode 100644 index 0000000..9752948 --- /dev/null +++ b/apps/api/src/firenze/storage/tables.py @@ -0,0 +1,94 @@ +"""The schema. + +Two shapes of data, stored differently on purpose. + +**A case is a document.** It is generated, immutable once published, and only +ever read whole. Normalising facts and cast into tables would buy joins nobody +needs and a migration every time the generator gains a field. It goes in JSONB. + +**A match is relational.** Turns accumulate, statements are queried per +character to find contradictions (RN-021), and budgets change under +concurrency. Those are rows. + +## Why the solution has its own table + +`Case` and `Solution` are separate entities so that isolation is a type +signature rather than a discipline (RN-011). The same argument applies one layer +down: if the solution were a column on `cases`, every `SELECT *` would carry the +culprit, and the guarantee would hold only as long as nobody wrote a convenient +query. In its own table, a read of `cases` **cannot** return it. +""" + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + MetaData, + String, + Table, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID + +metadata = MetaData() + +# JSONB on Postgres, plain JSON elsewhere, so the schema stays readable in a +# SQLite session without pretending SQLite is what production runs. +JSON_DOC = JSON().with_variant(JSONB(), "postgresql") +UUID_PK = UUID(as_uuid=True).with_variant(String(36), "sqlite") + +cases = Table( + "cases", + metadata, + Column("id", UUID_PK, primary_key=True), + Column("seed", Integer, nullable=False), + Column("generator_version", String(16), nullable=False), + Column("setting", String(64), nullable=False), + Column("document", JSON_DOC, nullable=False), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + # Seed alone does not identify a case; these three together do. + UniqueConstraint("seed", "generator_version", "setting", name="uq_cases_identity"), +) + +solutions = Table( + "solutions", + metadata, + Column("case_id", UUID_PK, ForeignKey("cases.id", ondelete="CASCADE"), primary_key=True), + Column("document", JSON_DOC, nullable=False), +) + +matches = Table( + "matches", + metadata, + Column("id", UUID_PK, primary_key=True), + Column("case_id", UUID_PK, ForeignKey("cases.id"), nullable=False), + Column("locale", String(16), nullable=False), + Column("turns_left", Integer, nullable=False), + Column("stances", JSON_DOC, nullable=False, default=dict), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), +) + +statements = Table( + "statements", + metadata, + Column("id", UUID_PK, primary_key=True), + Column("match_id", UUID_PK, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False), + Column("turn", Integer, nullable=False), + Column("character", String(32), nullable=False), + Column("question", Text, nullable=False), + Column("line", Text, nullable=False), + Column("stance", String(16), nullable=False), + Column("lied", Boolean, nullable=False), + Column("fact_referenced", String(16), nullable=True), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + # Contradiction detection reads one character's statements within one match, + # never across matches and never across characters (RN-013, RN-021). + UniqueConstraint("match_id", "turn", name="uq_statements_turn"), +) + +__all__ = ["cases", "matches", "metadata", "solutions", "statements"] diff --git a/apps/api/tests/test_storage.py b/apps/api/tests/test_storage.py new file mode 100644 index 0000000..bdc2c6f --- /dev/null +++ b/apps/api/tests/test_storage.py @@ -0,0 +1,195 @@ +"""Storage tests, against a real Postgres. + +Not against SQLite. The schema uses JSONB and a composite unique constraint, and +a test that passes on a database the application will never run on proves that +the test passes. `make dev` starts the one these expect. +""" + +import os +import uuid + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.exc import OperationalError + +from firenze.domain import Match, Stance, Statement +from firenze.generation import generate +from firenze.i18n import load +from firenze.interrogation import ask +from firenze.model import FakeModel +from firenze.storage import ( + NotFound, + load_case, + load_full_case, + load_match, + metadata, + record_turn, + save_case, + start_match, +) + +URL = os.environ.get( + "FIRENZE_TEST_DATABASE_URL", + "postgresql+psycopg://firenze:firenze@localhost:5433/firenze", +) + + +def _reachable() -> bool: + try: + create_engine(URL).connect().close() + except OperationalError: + return False + return True + + +pytestmark = pytest.mark.skipif( + not _reachable(), + reason=f"no database at {URL} — start one with `make dev`", +) + + +@pytest.fixture +def connection(): # type: ignore[no-untyped-def] + """A transaction rolled back at the end, so tests never see each other.""" + engine = create_engine(URL) + metadata.create_all(engine) + with engine.connect() as connection: + transaction = connection.begin() + yield connection + transaction.rollback() + + +def test_a_case_survives_a_round_trip(connection) -> None: # type: ignore[no-untyped-def] + full = generate(seed=42) + + case_id = save_case(connection, full) + + assert load_full_case(connection, case_id) == full + + +def test_saving_the_same_case_twice_gives_the_same_row(connection) -> None: # type: ignore[no-untyped-def] + """Identity is seed, generator version and setting — not a fresh uuid.""" + full = generate(seed=42) + + first = save_case(connection, full) + second = save_case(connection, full) + + assert first == second + assert connection.execute(text("SELECT count(*) FROM cases")).scalar_one() == 1 + + +def test_loading_a_case_cannot_reach_the_solution(connection) -> None: # type: ignore[no-untyped-def] + """RN-011 in the layer where it is easiest to lose. + + The solution lives in another table, so this read does not touch it — the + guarantee is the query, not the caller's restraint. + """ + full = generate(seed=42) + case_id = save_case(connection, full) + + serialised = load_case(connection, case_id).model_dump_json() + + assert full.solution.culprit not in serialised.replace('"sus-', "") + assert full.solution.means_key not in serialised + assert full.solution.motive_key not in serialised + + +def test_a_missing_case_is_not_found(connection) -> None: # type: ignore[no-untyped-def] + with pytest.raises(NotFound): + load_case(connection, uuid.uuid4()) + + +def test_a_match_survives_a_round_trip(connection) -> None: # type: ignore[no-untyped-def] + match_id = start_match(connection, generate(seed=42), "pt-BR") + + match = load_match(connection, match_id) + + assert match.locale == "pt-BR" + assert match.turns_left == 30 + assert match.statements == () + assert match.full_case == generate(seed=42) + + +def test_a_turn_persists_the_statement_the_stance_and_the_budget(connection) -> None: # type: ignore[no-untyped-def] + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + + result = ask(match, "sus-1", "onde você estava?", catalog=load("pt-BR"), model=FakeModel()) + record_turn(connection, match_id, result.match, result.statement) + + reloaded = load_match(connection, match_id) + assert reloaded.turns_left == 29 + assert len(reloaded.statements) == 1 + assert reloaded.statements[0].character == "sus-1" + assert reloaded.stance_of("sus-1") is result.match.stance_of("sus-1") + + +def test_a_rejected_turn_still_moves_the_budget(connection) -> None: # type: ignore[no-untyped-def] + """No statement to record, and the turn is spent anyway. (RN-030)""" + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + spent = match.model_copy(update={"turns_left": 29}) + + record_turn(connection, match_id, spent, None) + + reloaded = load_match(connection, match_id) + assert reloaded.turns_left == 29 + assert reloaded.statements == () + + +def test_statements_come_back_in_the_order_they_were_said(connection) -> None: # type: ignore[no-untyped-def] + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + + for question in ("primeira?", "segunda?", "terceira?"): + result = ask(match, "sus-1", question, catalog=load("pt-BR"), model=FakeModel()) + record_turn(connection, match_id, result.match, result.statement) + match = result.match + + said = load_match(connection, match_id).statements + + assert [s.turn for s in said] == [1, 2, 3] + assert [s.question for s in said] == ["primeira?", "segunda?", "terceira?"] + + +def test_two_matches_on_the_same_case_do_not_share_statements(connection) -> None: # type: ignore[no-untyped-def] + """One case, two playthroughs, no leakage between them.""" + full = generate(seed=42) + first_id = start_match(connection, full, "pt-BR") + second_id = start_match(connection, full, "en") + + match = load_match(connection, first_id) + result = ask(match, "sus-1", "onde?", catalog=load("pt-BR"), model=FakeModel()) + record_turn(connection, first_id, result.match, result.statement) + + assert len(load_match(connection, first_id).statements) == 1 + assert load_match(connection, second_id).statements == () + + +def test_the_turn_number_is_unique_within_a_match(connection) -> None: # type: ignore[no-untyped-def] + """The database refuses two turn 1s, so a retry cannot double-record.""" + from sqlalchemy.exc import IntegrityError + + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + statement = Statement( + turn=1, + character="sus-1", + question="?", + line="...", + stance=Stance.cooperative, + lied=False, + ) + + record_turn(connection, match_id, match, statement) + + with pytest.raises(IntegrityError): + record_turn(connection, match_id, match, statement) + + +def test_a_match_in_memory_and_a_match_from_the_database_behave_the_same(connection) -> None: # type: ignore[no-untyped-def] + """The store is a detail; the domain object is the same either way.""" + full = generate(seed=42) + match_id = start_match(connection, full, "pt-BR") + + assert load_match(connection, match_id) == Match(full_case=full, locale="pt-BR") diff --git a/apps/api/uv.lock b/apps/api/uv.lock index b0e9f08..a11a834 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -6,6 +6,20 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[[package]] +name = "alembic" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -148,9 +162,12 @@ name = "firenze-api" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "fastapi" }, { name = "openai" }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, + { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -164,17 +181,67 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.19.1" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, { name = "openai", specifier = ">=3.6.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic-settings", specifier = ">=2.7" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" }, + { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, ] provides-extras = ["dev"] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -433,6 +500,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -534,6 +665,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/8fb739d0f6bba247b9b93c9840c402a4f88545be5f1d4b02b23366371c00/psycopg-3.3.5.tar.gz", hash = "sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4", size = 166508, upload-time = "2026-08-31T22:45:43.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/2e/d0a645bcaadde68bd6d93c43f02f14b0191bdda367ce3f7722abe3da744a/psycopg-3.3.5-py3-none-any.whl", hash = "sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e", size = 213598, upload-time = "2026-08-31T22:39:02.184Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1c/e718752cc63cf4e99e4a10fd36e3a3364dabdd0819484a24c0d79fbb9685/psycopg_binary-3.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e85d50b87257fb117675a19ee59daa7bf9a57f6431500adf7059df799232ef4", size = 4704421, upload-time = "2026-08-31T22:42:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/af/cf/a0e748e27c09b92738e4460582d121ba1908be3e36791e150f435e54b332/psycopg_binary-3.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5becd311f9af8d180bad372f51fb2252fd02cb2073056e2b170c9274f95fe7f", size = 4765054, upload-time = "2026-08-31T22:43:05.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/62/0cbac0266d56c94dd1f702d9af2b5d54bf80b6e658048f4f2c5bd63dd7e3/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:19e5bf9872dbd164c220567fd385ba2309c7d9df1541f78343510c6b0f36a1b7", size = 5547137, upload-time = "2026-08-31T22:43:11.768Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3a/73c6f8871f38fc07a9c0b4cbc9467beb116a2783cecf87dfa53900a396dc/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb3b3bffebfe07110730626e76238161124f35ac87b748d663316a28d22f58b0", size = 5227577, upload-time = "2026-08-31T22:43:20.767Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/9f17b9f4d297b774dc574199c4dcd02dadc32a00c4056265918de5c70635/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2111f880add40fb03c60556069ad68e884a0908a74d2debafc603caf93b73552", size = 6824606, upload-time = "2026-08-31T22:43:33.698Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/d84dadd94a004dbbb43ce0579f1f766fdc6b8cbef745090e24bea77b5283/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:40505676b1526b9ea387dace034040a8c8b0bcf984cd6bd4720a2ab15e813586", size = 5060854, upload-time = "2026-08-31T22:43:42.258Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/e5078be2c7d2490c0d6cd4cb7b3601aec44be2fa8b3fe927e9419b06004f/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5816472e3bb05615f33a741e0835043d1f4bf9709ff30d2f4aed71815cfc6b5e", size = 4589511, upload-time = "2026-08-31T22:43:50.012Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/08dfb5b18fa482e025864fd91a022310d0782c42e4cf5dda5d0e010b6790/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:358748fc4c8ccdc0e2bdf55420494930e19c3ade586ea9c3a6de3dad1f897311", size = 4268144, upload-time = "2026-08-31T22:43:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/88/b9/cb01dc1d63f3241b49b2ca7fb9f98d0f5c76127f0dbb9440635a6ad0233e/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1ef2e498be47800f6202b9a2304c22646325ca6d54001b7c785bcfdb24a1e8ab", size = 4001036, upload-time = "2026-08-31T22:44:04.429Z" }, + { url = "https://files.pythonhosted.org/packages/95/49/7c17dd832c05b380562ff2ff5f6ab2bcaeca8b7fff2c2b355854368a5bdb/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88e01aa2e938a45655a8a5213fc3a44ba78cb4cab8a569b3e0bcb3d1d0eaba16", size = 4313112, upload-time = "2026-08-31T22:44:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/b0b2f887185d6a2ec0b3bef948cc07656d2d1a5d96fa7f2bb03f6ef06ca4/psycopg_binary-3.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:ba466011569297114449df9d523438e1adeedf3e4f31ffb78e897ec3fef3076b", size = 3647313, upload-time = "2026-08-31T22:44:19.139Z" }, + { url = "https://files.pythonhosted.org/packages/46/7f/4e2395da194558533bd9c31f35e4dc58ecbbae6a7176b0d2f72d629e8a51/psycopg_binary-3.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f8b132c7243ef5f503f0b6f986bf16d38a51b0df1c6ba2577743f128be03e3", size = 4712612, upload-time = "2026-08-31T22:44:25.723Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/fdb2093c8ccd7048449156db526ad746740cf34fe9c01e5dc1b7a7a8b257/psycopg_binary-3.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0cac998b9b1e82dec853d2e53b3d34d56a525cf231f9441a636cfd5992929a9", size = 4775139, upload-time = "2026-08-31T22:44:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/31/52/5195e87960715f7be2005761b72d56fce4e7757d5333fd40384f071c2de1/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:479b96fd78149cfa10369dc53fbfb89ee729be13146b584a23dbc7e164c0cf1e", size = 5556807, upload-time = "2026-08-31T22:44:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/f7/42/2d616210a91e1327516ed5ae71961aaa31bb740e4a61d7190f2221685a40/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f45d77e398542ce0937d9fa3cd9d84e9c5fc6b34c50a66404ae840bada312750", size = 5236206, upload-time = "2026-08-31T22:44:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/08/2e/e54b0d4cc263b3526e3728bb50a69660d5e79e52255ccd2a1a71e40e6f9a/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a388509306e5e08a4203253ac52846bc1b034e5cbd0ae6da1211593cc28594", size = 6838066, upload-time = "2026-08-31T22:44:55.701Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/ec22a110f81a44c411965982097efeee86006bdd5a1f51f628318106c84c/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab39e2794b95af61a2ff69e33e5ab6ac5df36e9ffea9a3b18e38b2aaca8c5ad5", size = 5072036, upload-time = "2026-08-31T22:45:02.838Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/7bc3c3ac769ab4fe0c619f2179b4aab3ae043f4d478283dd8279d24c0be4/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c071bf78e5c2e6efa40bc9089a954d7b41221347a72f35c6bf2d8c96e632f75", size = 4604058, upload-time = "2026-08-31T22:45:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/8e7414f7dc10b2959e664330cdcf393e412f67355975dafa876cef265264/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:14fdfd65a96ecbd8b586d14546105641f4a6ac7cbe335c786830ea4de94bbe60", size = 4284766, upload-time = "2026-08-31T22:45:17.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/33f293c1d3ee9114f47e9ef4880f31c9de864e84a9b09454c26e106c25ed/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8dbd694f3741dd4ac5bc60b70e17f7841aefb3f0f38cef4d2756de270e03af43", size = 4011958, upload-time = "2026-08-31T22:45:24.792Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/8e38840e5a7d006987bbc9acb29951912253fa50549a4db92b3aa535f089/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14f432430fd9e1a9e7d9ab2fe14956c77f5d074ebdc556a1ad04e9a1bd3fca04", size = 4323273, upload-time = "2026-08-31T22:45:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -723,6 +900,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + [[package]] name = "starlette" version = "1.6.0" @@ -765,6 +970,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "uvicorn" version = "0.52.4" diff --git a/docs/00-plano-de-projeto.md b/docs/00-plano-de-projeto.md index a670ac8..c05949b 100644 --- a/docs/00-plano-de-projeto.md +++ b/docs/00-plano-de-projeto.md @@ -184,6 +184,8 @@ ADR escrita para convencer, não para decidir. qualquer fornecedor de modelo - [`0008`](adr/0008-magalu-prosa-as-the-model-provider.md) — Magalu Prosa como fornecedor, com adaptador OpenAI-compatible +- [`0009`](adr/0009-a-case-is-a-document-a-match-is-rows.md) — caso é documento, + partida é linhas; solução em tabela própria Todas as ADRs são escritas em inglês (ADR-0006) — as quatro primeiras foram traduzidas depois de escritas. diff --git a/docs/08-achados.md b/docs/08-achados.md index 884459a..e13ceed 100644 --- a/docs/08-achados.md +++ b/docs/08-achados.md @@ -128,6 +128,18 @@ barato — contra uma VM que consome créditos dormindo. --- +### 2026-08-31 — Isolamento também se perde no SQL + +A solução ficou em tabela própria, não em coluna de `cases`. Como coluna, o +culpado viajaria em todo `SELECT *`, e a garantia duraria só até alguém escrever +a query conveniente ([#21](https://github.com/Madeuss/firenze/pull/21)). + +**Por que importa:** o mesmo argumento que separou `Case` de `Solution` no +domínio vale uma camada abaixo. Fronteira que existe só em um nível vaza no +outro. + +--- + ## Idioma ### 2026-08-29 — Gramática portuguesa vazou para dentro do modelo de domínio diff --git a/docs/adr/0009-a-case-is-a-document-a-match-is-rows.md b/docs/adr/0009-a-case-is-a-document-a-match-is-rows.md new file mode 100644 index 0000000..bc0a303 --- /dev/null +++ b/docs/adr/0009-a-case-is-a-document-a-match-is-rows.md @@ -0,0 +1,93 @@ +# ADR-0009: A case is a document, a match is rows + +## Status + +Accepted — 2026-08-31 + +## Context + +Matches have to survive a restart before there is an HTTP endpoint worth +writing, and before NPC memory can live in the same transaction as game state — +which is the whole argument of ADR-0002. + +The two things being stored are not alike. + +A **case** is generated from a seed, validated once, and then immutable. It is +always read whole: a briefing needs the cast, the facts and the timeline +together, and nothing ever queries "all facts in the cellar across every case". +Its shape also moves — the generator gained `setting` days after it gained +`interval_count`. + +A **match** is the opposite. Turns accumulate one at a time, statements are read +back per character to find contradictions (RN-021), and the budget changes on +every turn under whatever concurrency the future brings. + +## Decision + +**The case is a document.** One `cases` row with a JSONB column, plus the three +columns that identify it — seed, generator version, setting — as real columns +with a unique constraint over them. Normalising facts and cast into tables would +buy joins nobody performs and a migration every time the generator learns a new +field. + +**The match is relational.** `matches` and `statements` are ordinary tables with +foreign keys, because they are appended to, queried by character, and updated +under contention. + +**The solution gets its own table.** `Case` and `Solution` are separate entities +so that isolation is a type signature rather than a discipline (RN-011). The +same argument applies to storage: 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. + +**SQLAlchemy Core, synchronously.** Core rather than the ORM because these are +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 because the slow thing in a turn is the model call, not the +database, and FastAPI runs sync endpoints in a threadpool — an async driver +would add a second execution model to reason about for a saving that does not +show up at one player per match. + +**Storage tests run against Postgres**, in CI as a service container. A schema +using JSONB and a composite unique constraint, tested on SQLite, proves the +tests pass. + +## Consequences + ++ The case round-trips exactly, canary tokens and all, so a stored match + reproduces the mystery it started from. ++ The generator can gain fields without a migration. ++ Isolation survives into the persistence layer, where it is easiest to lose. ++ One transaction covers the statement, the stance and the budget, so a crash + cannot leave a match charged for an answer it never recorded (RN-030). ++ NPC memory can join match state in the same transaction when it arrives, which + is the reason ADR-0002 chose one database. +− A JSONB case is opaque to SQL: "which cases put someone in the cellar at + 22:00" is a scan, not an index. Acceptable while nothing asks; a generated + column would be the answer if something does. +− No schema validation on the document. A case written by an older generator + deserialises into whatever the current models accept, and `generator_version` + is what makes that detectable rather than silent. +− Synchronous I/O will need revisiting if a single process ever serves many + concurrent matches. The store is small enough that this is a rewrite of one + module. +− CI now needs a database service, which costs seconds per run and a moving part + that can fail on its own. + +## Alternatives considered + +- **Fully normalised, facts and cast as tables.** The obvious relational answer, + and it would make the case queryable. Rejected because nothing queries it: the + access pattern is "load this whole case", and the price is a migration for + every field the generator gains — which, at one generator change per week + lately, is the wrong side of the trade. +- **Documents all the way, matches in JSONB too.** Simplest to write and wrong + by the second concurrent turn: appending a statement would mean read, modify, + write, and losing one under a race. +- **SQLModel.** One class serving as both Pydantic model and table. Attractive + until the domain model wants to be frozen, computed and free of persistence + concerns — which this one is, deliberately. +- **Async SQLAlchemy with asyncpg.** The default reflex for FastAPI. Deferred: + it buys concurrency the workload does not have yet, at the cost of a second + execution model in every test and CLI path.