Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!--
This body IS the spec for the change. It is reviewed alongside the diff and is
the permanent record of why — there is no change file to write.

Trivial PR (typo, dep bump, formatter, CI tweak, mechanical rename)? Delete this
whole template and ship a conventional-commit title.
-->

## Why

The problem or need. What is wrong, missing, or costly today — not what you did
about it.

## Design

The approach, and the trade-off it takes. Show a sketch if the design needs code;
never the full diff-to-be. Most PRs fit well under ~700 words — length must buy
information.

## Non-goals

What this deliberately does **not** do, and why. This is the scope boundary that
stops "why didn't you also fix X" in review and six months from now.

## Verification

How you know it works: the tests added, `just test` (give the pass count and the
coverage your run reported — 100% line coverage is the gate),
`just test-migrations`, `just lint`. State the numbers, not "tested".

---

### Before merging

- [ ] **Behaviour changed?** If a wrong change here could pass silently, pin it
with a test whose name is the claim and whose docstring opens `INVARIANT:`
and says what breaks it. Do **not** write prose about mechanism — there is
no page for it. See [`planning/README.md`](../planning/README.md#where-a-fact-goes).
- [ ] **Adding a fact anywhere?** Run the admission check: derivable from `app/`
→ don't write it; enforceable → a test; otherwise it does not get written.
- [ ] **Rejected an alternative** with reasoning that would otherwise be
re-litigated? File it in [`planning/decisions/`](../planning/decisions/)
with a revisit trigger — not here.
- [ ] **Found real work you are not doing now?** File it in
[`planning/deferred/`](../planning/deferred/), self-contained, with a
revisit trigger — not here.
- [ ] `just lint`, `just check-planning`, `just check-links`, `just test` and
`just test-migrations` all pass.
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
uv run ruff check . --no-fix
uv run ty check
uv run python planning/index.py --check
uv run python planning/links.py

pytest:
runs-on: ubuntu-latest
Expand Down
86 changes: 68 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ template breaks the transaction model or the DI wiring.
`chats.last_message_id` update together. This exists because a single
operation can span more than one repository write and they must succeed or
fail as a unit; giving that back to individually auto-committing
repositories would make that impossible. See `architecture/messages.md`
and `architecture/chats.md` for the two hazards this creates around
repositories would make that impossible. This creates a hazard around
`Transaction.__aexit__`'s unconditional rollback-on-open-transaction
behavior (returning a loaded ORM object from inside an uncommitted `async
with self.transaction:` block detaches it).
Expand All @@ -39,9 +38,10 @@ only covers what isn't obvious from the recipe names.

Almost everything runs through Docker Compose: the app and Postgres come up
together, and running tests/migrations outside Docker is **not** the
supported path (`just install` and `just lint` are the exceptions — they run
on the host). Inside the container, raw commands look like `uv run pytest
...`, `uv run alembic ...`.
supported path (`just install`, `just lint`, `just index`, `just
check-planning` and `just check-links` are the exceptions — they run on the
host). Inside the container, raw commands look like `uv run pytest ...`, `uv
run alembic ...`.

- `just test` cycles the DB (downgrade to `base`, upgrade to `head`) before
pytest and tears the stack down before and after. Pass pytest args through,
Expand All @@ -66,12 +66,34 @@ on the host). Inside the container, raw commands look like `uv run pytest
- `just lint` runs `eof-fixer`, `ruff format`, `ruff check --fix`, then `ty
check` — this project uses `ty`, not mypy; suppress with `# ty:
ignore[<rule>]` (not `# type: ignore`).
- `just index` prints the planning change/decision listing; `just
check-planning` validates `planning/changes/` and `planning/decisions/`
frontmatter (CI-equivalent check, run before pushing a planning change).
- `just index` prints the deferred/decision listing; `just check-planning`
validates `planning/deferred/` and `planning/decisions/` frontmatter (and
that every deferred item carries a revisit trigger); `just check-links`
validates every relative Markdown link and heading anchor in the repo.

Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`.

## Workflow

**The spec for a change is its PR body**, not a committed file.
`.github/PULL_REQUEST_TEMPLATE.md` carries the shape (why, design, non-goals,
verification); it is reviewed with the diff. There is no change file and no lane
to choose. A trivial PR (typo, dep bump, formatter) deletes the template and
ships a conventional-commit title.

Two things outlive the PR and are committed under `planning/`: an alternative
**rejected** with reasoning goes to `planning/decisions/`, and real work **not
scheduled** goes to `planning/deferred/` (self-contained, with a revisit
trigger). There is no capability-page home — the living truth about behaviour is
the code and its `INVARIANT:`-marked tests, and a behaviour change is reviewed
with the diff, not promoted to a page. See `planning/README.md` for the full
convention, including the admission check that decides where a given fact
belongs.

An invariant is a test whose name is the claim, with a docstring opening
`INVARIANT:` and a second paragraph naming what breaks it. Applied to new
claims; the existing suite is not retrofitted.

## Architecture

**Stack**: Litestar + SQLAlchemy 2 (async) + advanced-alchemy + Alembic +
Expand Down Expand Up @@ -128,10 +150,11 @@ back. `app`/`client` fixtures build the real app and run it through
`modern_di_pytest.expose(ioc.Repositories, ioc.UseCases,
container_fixture="request_container")` (`tests/use_cases/conftest.py`)
exposes every repository/use case provider as a same-named pytest fixture —
the template predates this and hand-assembles dependencies instead. Full
detail, including the race-simulation pattern used to test the
concurrent-retry paths without a second real connection, is in
`architecture/testing.md`.
the template predates this and hand-assembles dependencies instead. The
race-simulation pattern used to test the concurrent-retry paths without a second
real connection is the `_Racing*Repository` classes in
`tests/use_cases/test_create_chat.py` and `tests/use_cases/test_create_message.py`;
the invariant each one pins is in the `INVARIANT:` docstring on the test that uses it.

**Migrations**: `migrations/env.py` reads the shared `METADATA` and rewrites
the DSN driver from `postgresql+asyncpg` → `postgresql` (Alembic uses sync
Expand Down Expand Up @@ -169,14 +192,15 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
- Domain exceptions (`app/exceptions.py`: `PermissionDeniedError`,
`ValidationError`, `ConflictError`) are registered as handlers in
`build_app`'s `exception_handlers` dict alongside the `advanced_alchemy`
exceptions (`NotFoundError`, `DuplicateKeyError`, `ForeignKeyError`). Full
mapping table and the one deliberate exception (login's `401` via Litestar's
own `NotAuthorizedException`) are in `architecture/messages.md` and
`architecture/auth.md`.
exceptions (`NotFoundError`, `DuplicateKeyError`, `ForeignKeyError`). Every
mapping, and why login's `401` deliberately uses Litestar's own
`NotAuthorizedException` instead, is described in
`planning/decisions/2026-08-21-domain-error-vocabulary.md`.
- **Comments.** None, unless the code would read as a bug without one; then a
single line. Rationale, design decisions and "why not X" belong in
`architecture/<capability>.md` and `planning/changes/`, never in the source —
those are the durable homes, and a comment restating them goes stale in place.
`planning/decisions/` and the PR body, never in the source — those are where
such reasoning is reviewed and kept, and a comment restating it goes stale in
place.
What survives in `app/` today is the whole permitted category: a setting that
looks arbitrary (`join_transaction_mode`, `populate_existing`,
`capture_parameters=False`), an `orm.foreign()` on a column with no
Expand All @@ -186,3 +210,29 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
- `ruff` is configured with `select = ["ALL"]` and a line length of 120 —
expect strict lint. Type-check with `ty`; use `# ty: ignore[<rule>]` for
suppressions.

## Vocabulary

A term is listed only when there is a synonym to reject, or a meaning subtle
enough that code and docs must agree on it.

- **Chat** — a row in `chats`: a type (`direct` or `group`), an optional title,
its creator, and a pointer to its newest non-deleted message. *Avoid:*
conversation, room, thread.
- **Direct chat** — a chat between exactly two users, identified by `direct_key`,
the canonical `min(user_id):max(user_id)` string under a unique constraint.
That key is what makes opening one twice an upsert instead of a read-then-race.
*Avoid:* DM, 1:1.
- **Member** — the `(chat_id, user_id)` row granting access to a chat, plus that
user's read marker. Necessary for every read or write on a chat; not
sufficient for editing or deleting a message. *Avoid:* participant, subscriber.
- **Idempotency key** — the client-supplied UUID on a send, unique per
`(chat_id, idempotency_key)`. Scoped to one chat, because the key identifies a
retry of "send this message to this chat". *Avoid:* dedupe key, request id.
- **Unread** — a count computed at read time against one marker per member, not
a set of per-message receipt rows. *Avoid:* unseen, badge count.
- **Cursor** — a message id passed as `before_id` or `after_id`. The two are
mutually exclusive on one request. *Avoid:* page token, offset.
- **Read marker** — a member's `last_read_message_id`, the highest id they have
acknowledged. Advances only forward, via `GREATEST` inside the UPDATE. *Avoid:*
read receipt, watermark.
8 changes: 6 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ lint:
uv run ruff check . --fix
uv run ty check

# Print the planning change index (flat, newest-first) to stdout.
# Print the planning index (deferred, then decisions) to stdout.
index:
uv run python planning/index.py

# Validate planning changes + decisions (frontmatter, lanes, spec links); CI runs this.
# Validate planning/deferred/ + planning/decisions/ frontmatter and naming; CI runs this.
check-planning:
uv run python planning/index.py --check

# Check every relative Markdown link and heading anchor in the repo.
check-links:
uv run python planning/links.py
2 changes: 1 addition & 1 deletion app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@


async def retrieve_user_handler(token: Token, connection: ASGIConnection) -> tables.UsersTable | None:
# Auth middleware runs before request-scoped DI exists; see architecture/auth.md.
# Auth middleware runs before request-scoped DI exists, so this opens its own session.
try:
user_id = int(token.sub)
except ValueError:
Expand Down
1 change: 1 addition & 0 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Settings(pydantic_settings.BaseSettings):
service_name: str = "chat-app"
service_version: str = "1.0.0"
service_environment: str = "local"
# echo/echo_pool log bound parameters (password_hash on registration); Litestar returns stack traces in responses.
service_debug: bool = False
log_level: str = "info"

Expand Down
24 changes: 0 additions & 24 deletions architecture/README.md

This file was deleted.

91 changes: 0 additions & 91 deletions architecture/auth.md

This file was deleted.

Loading