Files
bike-app/CLAUDE.md
BBergleandClaude Sonnet 5 6c48000d7b
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 15s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s
chore(deploy): single-container Dockerfile, Caddy, and Unraid template
Builds the container the "1 container" decision (D15) actually needs, which
D15 itself deferred as follow-up work: Caddy + the FastAPI app + the static
SvelteKit build in one image, SQLite on a mounted volume. See docs/DECISIONS.md
D16 for the specific choices and why (entrypoint-run migrations instead of a
separate deploy-pipeline step, tini + a small supervisor script instead of
s6-overlay/supervisord, copying the Caddy binary out of its official image).

Removes apps/api/Dockerfile and apps/web/Dockerfile from the old 4-container
compose plan (PR #4, closed as superseded) — the root Dockerfile replaces both
with one multi-stage build.

deploy/unraid-template.xml turns VELODROME_PUBLIC_URL, VELODROME_SECRET_KEY,
etc. into fillable Unraid Community Applications web UI fields, per the
earlier decision to keep config there instead of a .env file.

.gitea/workflows/release.yml builds and pushes the image to the Gitea registry
on a version tag or manual dispatch; it does not touch the running container.

Verified by actually running the built image, not just building it: the
health endpoint responds through Caddy's proxy, the SPA serves with working
client-route fallback, alembic ran and produced a real (non-empty) SQLite file
under /data, the process runs as the non-root velodrome user, and killing the
uvicorn process brings the whole container down (exit 143) rather than
leaving Caddy serving alone — confirming the entrypoint's coupled-lifetime
behavior actually holds, not just that it reads correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 15:55:50 -04:00

186 lines
8.9 KiB
Markdown

# CLAUDE.md
Conventions for this repo. Agents and humans both follow these. Read before making changes.
## What this is
A self-hosted cycling app: ride sync from a Bryton Rider 650, mileage tracking, spare-parts
inventory, maintenance records with mileage-milestone reminders. See `docs/PLAN.md` for the full
design, `docs/DECISIONS.md` for settled decisions, `docs/RESEARCH.md` for source material.
**Read `docs/DECISIONS.md` before proposing an architectural change.** If a decision there is wrong,
say so and argue it — but don't silently contradict it.
## Layout
```
apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic
apps/web/ SvelteKit static SPA (installable PWA)
packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code
Dockerfile single-container build (root, not deploy/ — needs both apps/api and apps/web
as build context). See docs/DECISIONS.md D15/D16 for why one container.
deploy/ Caddyfile, entrypoint.sh, unraid-template.xml, systemd backup units
docs/ plan, decisions, research
scripts/ repo tooling (PR helpers, etc.)
.gitea/workflows/ CI
```
## Non-negotiable invariants
These are load-bearing. Breaking one is a correctness bug, not a style choice.
1. **Raw bytes are the only truth.** Every ingested file is written to the content-addressed blob
store *before* parsing, and is never mutated or deleted. Every table is a rebuildable projection:
deleting everything derived from a `raw_file_id` and re-parsing must be semantically a no-op.
2. **No odometer columns.** Component wear is always derived by replaying activities against
time-ranged `component_installs`. Never add a stored running total to a component.
3. **All physical quantities are SI integers** in storage — metres, seconds, mm/s, centimetres,
grams, minor currency units. Imperial is display-only. Never store a float mile.
4. **Every user-owned table has `user_id`, and every query against it goes through the
repository-layer scope helper — never a raw query filtered by hand.** This used to be backed
by Postgres RLS as a second, database-enforced layer (see `docs/DECISIONS.md` D4/D15); SQLite
has no equivalent, so the repository-layer scope is now the *only* enforcement, which makes it
non-negotiable rather than defense-in-depth. A new domain table without a passing isolation
test (see `tests/test_auth.py`'s pattern) is not done.
5. **Secrets never leave the server.** The Bryton credential is password-equivalent. It must not
appear in any API response model, any log line, or any error message.
6. **One ingestion path.** All sources funnel through `ingest_bytes()`. Never add a second parse
path for a new source.
## Style
- **Python:** `ruff` (lint + format), `mypy --strict`. Type everything. Async throughout; no sync DB
calls in request handlers.
- **SQL:** migrations via Alembic only, never manual DDL. Every migration must survive
`upgrade -> downgrade -1 -> upgrade`.
- **Tests:** pytest against a real SQLite file, never mocks for DB behaviour. Parser changes need
a golden fixture in `apps/api/tests/fixtures/fit/`.
- **Commits:** imperative mood, explain *why* in the body. Conventional-commit prefixes
(`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `ci:`).
- Match surrounding code. Don't introduce a new pattern when one exists.
## Branching
**Never commit directly to `main`.** `main` is protected and only moves via merged PRs.
```
feat/<scope>-<short-desc> new capability feat/ingest-fit-parser
fix/<scope>-<short-desc> bug fix fix/wear-wet-multiplier
refactor/<scope>-<desc> no behaviour change
test/<scope>-<desc> tests only
docs/<desc> documentation
chore/<desc> tooling, deps, CI
```
Scope is usually the area: `ingest`, `wear`, `auth`, `notify`, `web`, `deploy`, `ci`.
One branch = one reviewable change. If a branch grows past roughly 400 changed lines, it should
probably have been two.
## PR workflow
1. Branch from an up-to-date `main`.
2. Commit in logical steps. Push the branch.
3. Open a PR with `scripts/pr.sh` (see below) or the web UI. Fill in the template honestly —
especially "How this was verified."
4. CI must be green. A red PR is not ready for review, and shouldn't be requested.
5. Review happens on the PR. Address feedback with new commits, don't force-push over review history
unless asked.
6. Squash-merge into `main`. Delete the branch.
**Agents:** you open PRs, you do not merge them. Merging is a human decision.
## Model allocation
The orchestrator runs **Opus 5**. Worker agents do not default to it.
The rule is **match the model to the cost of being wrong, not the size of the task.** A big,
well-specified job with obvious failure modes is cheap work. A small change to dedupe logic that
silently corrupts data for six months is expensive work.
### Opus 5 — orchestration, and anything subtly wrong-able
- Task decomposition, planning, and **all code review** (see below — this is where it pays for itself)
- Anything touching the six invariants above
- `ingest/` — FIT parsing, the three dedupe layers, activity-vs-course discrimination. Errors here
are silent and corrupt the archive.
- `wear/` — the wear SQL. Wrong numbers that still look plausible are the worst failure mode in the
product, because nobody notices.
- `auth/`, any repository-layer user-scoping code — security, and a mistake exposes another user's
data. This carries more weight than it used to: there is no database-enforced RLS backstop
anymore (see invariant #4 and `docs/DECISIONS.md` D15), so this code *is* the isolation
boundary, not one layer of it.
- `sources/bryton/` — a reverse-engineered protocol with no spec to check against.
- Schema migrations that alter or drop existing columns.
- Debugging anything that two Sonnet attempts have already failed to fix.
### Sonnet — the bulk of implementation
Default for normal feature work. `docs/PLAN.md` is detailed enough that most implementation is
careful transcription plus ordinary judgement, and CI catches the rest:
- CRUD endpoints, Pydantic models, repository methods
- SvelteKit components, routes, styling, the service worker
- Tests against an already-decided behaviour
- Additive migrations, the deploy Dockerfile/Caddy config, CI workflows
- Documentation
### Haiku — mechanical work
- Dependency bumps, formatting, renames, changelog entries
- Log triage, "find every call site of X"
- Anything where a script would also work
### The economics
**Sonnet implements, Opus reviews** is the default pairing, and it is much cheaper than Opus
implementing while catching most of the same problems. Review reads a focused diff; implementation
reads the whole repo and writes for hours. If budget is tight, cut Opus from implementation before
you cut it from review.
Escalate a task to a stronger model when it has actually failed, not preemptively. Two failed Sonnet
attempts is a real signal; "this feels hard" is not.
Never run more agents in parallel than there are genuinely independent branches of work. Parallel
agents that touch the same files cost more than one agent working in sequence, because the merge
conflicts and re-review are paid twice.
## Working as a parallel agent
Each agent works in its own git worktree so parallel branches don't collide:
```bash
git worktree add ../bike-app-<branch> -b feat/<scope>-<desc>
# work there, push, open PR
git worktree remove ../bike-app-<branch>
```
Rules:
- **Stay in your lane.** Touch only files your task needs. If you need a change in someone else's
area, note it in the PR rather than making it.
- **Never rebase or force-push another agent's branch.**
- **Rebase on `main` before opening the PR**, so the reviewer sees a clean diff.
- **Don't invent scope.** If the task is ambiguous, ask rather than guess — a wrong guess costs a
full review cycle.
- **Report honestly.** If tests fail, say so with the output. If you skipped something, say that.
"Done" means done and verified.
## What needs a human decision
Don't do these autonomously:
- Merging a PR
- Anything touching `docs/DECISIONS.md` (propose it in a PR, argue the case)
- Schema changes that drop or rewrite existing columns
- Anything that sends data off the server, or adds a third-party runtime dependency on one
- Rotating or changing secrets
- Force-pushing anything, ever, to `main`
## Environment
- Gitea at `http://192.168.0.3:3000`, repo `BBergle/bike-app`, act_runner on the same host.
- SSH remote `git@192.168.0.3:BBergle/bike-app.git`, key pinned in `~/.ssh/config`.
- **`secrets.GITEA_TOKEN` cannot push to the Gitea container registry** — use the `REGISTRY_TOKEN`
PAT secret (`package:write`). This is a documented Gitea limitation, not a misconfiguration.
- `jobs.*.environment` is silently ignored by Gitea Actions. Don't build a gate on it.
- Always pair `schedule:` with `workflow_dispatch:` — Gitea's cron has shipped flaky.