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
8 changes: 4 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ coverage your run reported — 100% line coverage is the gate),
- [ ] **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.
re-litigated? File it in [`docs/adr/`](../docs/adr/) as the next numbered
ADR, 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.
- [ ] `just lint`, `just check-planning`, `just check-adrs`, `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 docs/adr/check.py
uv run python planning/links.py

pytest:
Expand Down
17 changes: 9 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ run alembic ...`.
- `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 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.
- `just index` prints the deferred listing; `just check-planning` validates
`planning/deferred/` frontmatter (and that every item carries a revisit
trigger); `just check-adrs` validates `docs/adr/` numbering, naming and
revisit triggers; `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`.

Expand All @@ -81,8 +82,8 @@ 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
Two things outlive the PR and are committed: an alternative **rejected** with
reasoning goes to `docs/adr/` as a numbered ADR, 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
Expand Down Expand Up @@ -195,10 +196,10 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
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`.
`docs/adr/0005-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
`planning/decisions/` and the PR body, never in the source — those are where
`docs/adr/` 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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Message ids are a Postgres BigInt identity sequence, not snowflake ids.
---

# Sequence ids, not snowflakes

`messages.id` is a plain BigInt identity column. Postgres assigns values
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Authentication is a JWT in a cookie rather than a bearer header, because EventSource cannot set an Authorization header.
---

# Cookie auth, not a bearer header

**Decision:** The JWT travels in a cookie (`JWTCookieAuth[UsersTable]`), not in
Expand Down Expand Up @@ -30,7 +26,7 @@ realistic shape.
The cost is accepted deliberately: cookie auth needs CSRF consideration on
state-changing endpoints, and `jwt_cookie_secure` must be `True` behind
HTTPS — see
[`2026-08-21-explicit-cookie-secure-flag.md`](2026-08-21-explicit-cookie-secure-flag.md).
[`0003-explicit-cookie-secure-flag.md`](0003-explicit-cookie-secure-flag.md).

## Revisit trigger

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: The session cookie's Secure flag comes from an explicit jwt_cookie_secure setting, not from inspecting service_environment.
---

# Cookie security is an explicit setting

`Settings.jwt_cookie_secure` defaults to `False` and is passed straight to
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: The auth exclude list carries four anchored prefixes — /docs, /health, /static and /metrics — and nothing else.
---

# The anonymous surface is four prefixes

`jwt_cookie_auth`'s `exclude` list holds `^/docs`, `^/health`, `^/static` and
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Domain failures are split across PermissionDeniedError (403), ValidationError (400) and ConflictError (409) rather than expressed as authorization failures.
---

# Three domain exceptions, not one

`app/exceptions.py` defines `ChatAppError` and three subclasses, each with a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Editing and deleting a message requires chat membership as well as authorship; the check order is existence, membership, authorship.
---

# Mutation requires membership, not just authorship

`fetch_message_for_author` (`app/use_cases/message_authorization.py`) is the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Direct-chat creation and message send recover from a unique-constraint violation and re-read, rather than trusting a pre-check.
---

# Upsert by recovering from DuplicateKeyError

Both `CreateChatUseCase` and `CreateMessageUseCase` read first to see whether
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Message idempotency is scoped to (chat_id, idempotency_key), not to the key alone.
---

# Idempotency is scoped per chat

`messages` carries `UniqueConstraint("chat_id", "idempotency_key")`. The
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: The read marker advances only to a message in its own chat, and advances atomically via GREATEST so it can never move backwards.
---

# Read-marker integrity

`MarkReadUseCase` does three things in order: verify membership, verify that
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Soft-deleting a chat's newest message repoints chats.last_message_id in the same transaction, rather than filtering the deleted row out of the listing preview.
---

# Repoint last_message_id on delete

`DeleteMessageUseCase` soft-deletes the message and, if it was the chat's
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Event fan-out uses one Redis channel per user; per-chat and hybrid topologies rejected.
---

# Per-user channel topology

Every connected client subscribes to exactly one channel, `user:{user_id}`. A
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Reconnect recovery uses channel history plus a REST resync; no durable per-user event log honouring Last-Event-ID.
---

# No server-side event replay

On reconnect the client recovers in two layers. Fast path:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
---
summary: Coverage exclusions are reserved for code pytest structurally cannot execute; unreachable-in-production branches are tested through repository seams instead.
---

# Coverage exclusions are structural only

The suite runs at `--cov-fail-under=100`. Two mechanisms can exempt code, and
Expand Down
70 changes: 70 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Architecture decision records

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. `just check-adrs` validates the set, and
CI runs it.

## Numbering

Numbers run contiguously from `0001`, are permanent, and mean nothing beyond
identity. A new ADR takes the next free number. Nothing is ever renumbered.

## Status lives in the frontmatter, or nowhere

An ADR with no frontmatter is **accepted**. There is no exit from this
directory: a superseded decision stays readable, or it gets re-argued.

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

```yaml
---
superseded_by: 0014-its-slug
---
```

## The admission test

All three must be true, or it is not an ADR:

1. **Hard to reverse.** Changing your mind later carries a real cost.
2. **Surprising without context.** A reader will look at the code and wonder why
it was done this way.
3. **A real trade-off.** There were genuine alternatives and one was picked for
specific reasons.

## Template

```md
# One-line capitalized title

**Decision:** What was decided, in a sentence.

What the code actually does, and the constraint that forced it.

## Rejected: deriving it from the environment

Why it was not taken. Enough that a future explorer does not re-litigate it.

## Rejected: defaulting to True

One heading per alternative, named in the heading so it gets its own anchor.

## Consequence

The non-obvious downstream effect, including what this deliberately leaves
uncovered.

## Revisit trigger

The concrete signal that should reopen this decision.
```

`## Consequence` is optional. `## Revisit trigger` is required and enforced.

## Where other facts go

This is one of four homes, and the narrowest. See
[`../../planning/README.md`](../../planning/README.md#where-a-fact-goes) for the
admission check that decides between code, an `INVARIANT:`-marked test, an ADR
here, and a deferred item in [`../../planning/deferred/`](../../planning/deferred/).
107 changes: 107 additions & 0 deletions docs/adr/check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Validate the ADR set: numbering, naming, revisit triggers, supersession pointers.

Run via ``just check-adrs``. Globs ``docs/adr/*.md`` and reports every violation
at once rather than failing on the first.

ADRs carry no ``summary`` frontmatter: the number, the slug and the ``# `` title
already say what the file is, and a fourth telling would be the copy nobody
edits. Frontmatter appears only on a superseded ADR, so *no frontmatter* means
accepted.

Numbers must be contiguous from ``0001``. An ADR is never deleted — it is
superseded — so a gap is a mistake worth failing on, not a deliberate state.
"""

import pathlib
import re
import sys


ROOT = pathlib.Path(__file__).parent
ADR_RE = re.compile(r"^(?P<number>\d{4})-(?P<slug>[a-z0-9]+(?:-[a-z0-9]+)*)$")
REVISIT_HEADING = "## Revisit trigger"


def parse_frontmatter(text: str) -> dict[str, str]:
"""Parse a single-line-scalar YAML frontmatter block into a dict."""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}
fields: dict[str, str] = {}
for line in lines[1:]:
if line.strip() == "---":
break
if line[:1] in (" ", "\t"):
continue
key, sep, value = line.partition(": ")
if not sep:
continue
fields[key.strip()] = value.strip().strip('"').strip("'")
return fields


def adr_paths(root: pathlib.Path) -> list[pathlib.Path]:
"""Every ADR file, sorted by name; README and underscore-prefixed files are not ADRs."""
return [path for path in sorted(root.glob("*.md")) if path.name != "README.md" and not path.name.startswith("_")]


def _check_numbering(paths: list[pathlib.Path], violations: list[str]) -> None:
"""Require each name to be `NNNN-slug.md`, numbered contiguously from 0001."""
numbers: dict[int, str] = {}
for path in paths:
match = ADR_RE.match(path.stem)
if match is None:
violations.append(f"{path.name}: file name is not 'NNNN-slug.md' with a lowercase hyphenated slug")
continue
number = int(match.group("number"))
if number in numbers:
violations.append(f"{path.name}: number {number:04d} is already taken by {numbers[number]}")
continue
numbers[number] = path.name
expected = set(range(1, len(numbers) + 1))
violations.extend(
f"ADR {number:04d} is missing — numbers run contiguously from 0001" for number in expected - numbers.keys()
)


def _check_body(path: pathlib.Path, stems: set[str], violations: list[str]) -> None:
"""Require a revisit trigger, and a `superseded_by` that names a real ADR."""
text = path.read_text(encoding="utf-8")
if REVISIT_HEADING not in text:
violations.append(
f"{path.name}: no '{REVISIT_HEADING}' section — a decision with no trigger is never revisited"
)
superseded_by = parse_frontmatter(text).get("superseded_by")
if superseded_by is None:
return
if superseded_by == path.stem:
violations.append(f"{path.name}: superseded_by points at itself")
elif superseded_by not in stems:
violations.append(f"{path.name}: superseded_by '{superseded_by}' does not name an ADR in docs/adr/")


def check(root: pathlib.Path) -> list[str]:
"""Validate every ADR; return the list of violation strings."""
violations: list[str] = []
paths = adr_paths(root)
_check_numbering(paths, violations)
stems = {path.stem for path in paths}
for path in paths:
_check_body(path, stems, violations)
return violations


def main(root: pathlib.Path | None = None) -> int:
"""Report every violation on stderr, or confirm the set is clean on stdout."""
violations = check(ROOT if root is None else root)
if violations:
sys.stderr.write(f"adr: {len(violations)} violation(s)\n")
for violation in violations:
sys.stderr.write(f" - {violation}\n")
return 1
sys.stdout.write("adr: OK\n")
return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 1 addition & 1 deletion docs/agents/domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ If either doesn't exist yet, **proceed silently**. Don't flag its absence; don't
└── tests/
```

ADR numbers are permanent and assigned in dependency order, so reading from `0001` upward introduces the system in the order its decisions build on each other. A new ADR takes the next free number.
ADR numbers are permanent and mean nothing beyond identity: a new ADR takes the next free number, so the sequence is the order decisions were adopted. Read the ones relevant to your area, not the sequence front to back.

## Use the glossary's vocabulary

Expand Down
8 changes: 6 additions & 2 deletions Justfile → justfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,18 @@ lint:
uv run ruff check . --fix
uv run ty check

# Print the planning index (deferred, then decisions) to stdout.
# Print the planning index (the deferred queue) to stdout.
index:
uv run python planning/index.py

# Validate planning/deferred/ + planning/decisions/ frontmatter and naming; CI runs this.
# Validate planning/deferred/ frontmatter and naming; CI runs this.
check-planning:
uv run python planning/index.py --check

# Validate docs/adr/ numbering, naming and revisit triggers; CI runs this.
check-adrs:
uv run python docs/adr/check.py

# Check every relative Markdown link and heading anchor in the repo.
check-links:
uv run python planning/links.py
Loading