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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
`ForeignKey`, a `return` from inside a transaction block, discarded work that
is not dead code (`AuthenticateUserUseCase`'s hash-anyway). Alembic's own
`# ###` autogenerate markers stay — they are regenerated on every migration.
- **Coverage.** The suite runs at `--cov-fail-under=100` and no `# pragma: no cover`
appears anywhere. The only exemptions are structural: `[tool.coverage.run] omit`
for files the default run never imports, and `exclude_also` for
`if typing.TYPE_CHECKING:`. A branch that looks untestable gets a test that
produces its condition for real, such as the concurrent create tests'
second connection holding an uncommitted insert.
- `ruff` is configured with `select = ["ALL"]` and a line length of 120 —
expect strict lint. Type-check with `ty`.

Expand Down
8 changes: 8 additions & 0 deletions docs/adr/0005-idempotency-scoped-per-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Idempotency is scoped per chat

`messages` carries `UniqueConstraint("chat_id", "idempotency_key")`, and the
send's `ON CONFLICT` names that constraint. Idempotency belongs to the operation
"send this message to this chat", so a key reused in another chat is a different
operation, not a retry. A global constraint on `idempotency_key` shipped first.
A client that reused a key across chats got the other chat's message back, and
the message it meant to send was never written.
21 changes: 0 additions & 21 deletions docs/adr/0005-upsert-via-duplicate-key-recovery.md

This file was deleted.

14 changes: 0 additions & 14 deletions docs/adr/0006-idempotency-scoped-per-chat.md

This file was deleted.

9 changes: 9 additions & 0 deletions docs/adr/0006-read-marker-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Read-marker integrity

`MarkReadUseCase` rejects a `last_read_message_id` that does not name a message
in that chat with a `400`, and moving the marker to a lower id is a silent
no-op. Accepting any id would let a client set its marker arbitrarily high and
permanently zero its own unread counts, and storing self-inflicted corruption is
worse than refusing the request. A lower id usually comes from a replayed or
reordered request, which is not a client mistake worth reporting, so it gets the
current marker back instead of an error.
16 changes: 0 additions & 16 deletions docs/adr/0007-read-marker-integrity.md

This file was deleted.

15 changes: 0 additions & 15 deletions docs/adr/0009-coverage-exclusions-structural-only.md

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ that user, and short-lived tokens with refresh because they replace the whole
auth model. The cost is one primary-key lookup per authenticated request, on a
session the middleware closes before the handler opens its own, so a request
still holds one pooled connection at a time. That lookup is the read
[ADR-0010](0010-auth-carries-an-actor-id.md) kept out of authentication; the
[ADR-0008](0008-auth-carries-an-actor-id.md) kept out of authentication; the
user row is still never loaded. A token without a `jti`, which includes every
token issued before this change, gets a 401. Each logout prunes expired rows,
so the table holds at most a token lifetime of logouts.
File renamed without changes.
20 changes: 20 additions & 0 deletions docs/adr/0011-idempotent-creates-are-one-upsert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Idempotent creates are one INSERT ... ON CONFLICT DO UPDATE

Message sends (`MessagesRepository.get_or_create`, conflicting on
`uk_messages_chat_id_idempotency_key`) and direct chats
(`ChatsRepository.get_or_create_direct`, conflicting on `direct_key`) are each
written by one `INSERT ... ON CONFLICT ... DO UPDATE SET <key> = EXCLUDED.<key>
RETURNING ..., xmax = 0`. The no-op update returns the existing row on a replay,
and a request racing an uncommitted insert of the same key waits on its lock and
then gets the winner's row. `xmax = 0` is true only for a row version the
statement inserted, so it decides 201 or 200 and whether the follow-up writes
run. A read before the insert cannot make it safe: at READ COMMITTED both
requests miss the read, `postgres_retry` does not retry the loser's integrity
error, and `SELECT ... FOR UPDATE` has no row to lock yet. Re-reading after a
`DuplicateKeyError` worked but cost a round trip and ran outside the retried
transaction; `ON CONFLICT DO NOTHING` returns no row to the loser. A replay
writes a row version and consumes a sequence value, so a retried send leaves a
gap that [ADR-0001](0001-sequence-ids-not-snowflakes.md)'s gap detection answers
with one empty fetch, and a DM replay takes three statements where a pre-check
took two. Postgres does not document `xmax` in `RETURNING`; the concurrent
tests in `test_create_message.py` and `test_create_chat.py` would catch a change.
19 changes: 0 additions & 19 deletions docs/adr/0012-message-send-is-one-upsert.md

This file was deleted.

15 changes: 0 additions & 15 deletions docs/adr/0013-direct-chat-create-is-one-upsert.md

This file was deleted.

21 changes: 7 additions & 14 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

One file per decision taken, especially the options **rejected**, so reviews do
not re-litigate them. The directory listing is the index: there is no generated
listing and no `summary` frontmatter. Nothing validates the set mechanically;
listing and no frontmatter. Nothing validates the set mechanically;
the standard below is held up by review.

## Numbering
Expand All @@ -14,20 +14,13 @@ sequence stays contiguous, and every citation moves in the same change:
`tests/test_adr_citations.py` fails on a `docs/adr/` path that no longer
resolves.

## Status lives in the frontmatter, or nowhere
## Every record is current

An ADR with no frontmatter is **accepted**. A decision that is re-argued and
replaced is superseded, not deleted, so the earlier reasoning stays readable. A
record that no longer passes the admission test below is deleted outright; git
history keeps it.

When a later ADR supersedes an earlier one, add to the earlier file:

```yaml
---
superseded_by: 0010-its-slug
---
```
Every file here is an accepted decision, so ADRs carry no status frontmatter.
When a later decision replaces an earlier one, the reasoning that still holds,
usually why the rejected options fail, moves into the new record and the old
file is deleted. A record that no longer passes the admission test below is
deleted too. Git history keeps both.

## The admission test

Expand Down
5 changes: 5 additions & 0 deletions tests/use_cases/test_unread_counts.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ async def test_system_messages_count_as_unread(
direct_chat: tables.ChatsTable,
alice: Actor,
) -> None:
"""INVARIANT: system messages (user_id IS NULL) count as unread.

Broken by comparing user_id with != instead of IS DISTINCT FROM, since NULL != 1 is NULL.
"""
await messages_repository.create(
tables.MessagesTable(chat_id=direct_chat.id, user_id=None, idempotency_key=uuid.uuid4(), text="Bob joined")
)
Expand Down Expand Up @@ -175,6 +179,7 @@ async def test_marking_read_is_monotonic(
bob: Actor,
send: SendFixture,
) -> None:
"""INVARIANT: marking read with a lower id leaves the marker where it is and raises nothing."""
first, _ = await send(bob, direct_chat.id, "one")
second, _ = await send(bob, direct_chat.id, "two")
await mark_read_use_case(
Expand Down
Loading