Files
BBergleandClaude Opus 5 7f33cb1593
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 14s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s
docs: correct the false Wi-Fi premise; add UI and live-tracking phases
The plan's headline section claimed the Rider 650 has on-device Wi-Fi and a
Data Sync menu that uploads to Bryton's cloud with no phone involved. It
does not. The unit has ANT+ and Bluetooth only; its sole sync route is BLE
to the Bryton Active app. Confirmed on the physical device, corroborated by
BikeRadar's hands-on. Likely origin: conflation with the Rider 750 / S800,
which do have Wi-Fi.

Impact is narrower than it first appears and no built code is invalidated:
everything downstream of Bryton's cloud never depended on how a ride got
into that cloud, so the poller, ingestion, schema, wear engine and all of
Phase 0 stand. What was invalidated is the product promise — Phase 1 was
called "Zero-touch ride history" and claimed to fix the original complaint
(having to remember to open the Active app). It does not; it is one-tap.
Renamed accordingly rather than leaving the doc overclaiming.

Corrections propagated everywhere the premise had spread: PLAN.md's opening
sections, Phase 1, top risks (the chain is now longer and has a human link
that fails silently — earns a "nothing ingested in N days" nudge), and the
verification checklist; DECISIONS.md D3's justification; README.md, which
was additionally stale on nearly every other point (claimed no code written,
Postgres, compose, four containers); and RESEARCH.md, where the claim
originated under a "verified" header it had not earned. RESEARCH.md is
annotated rather than rewritten — it is a record of what was found, and the
correction is part of that record. USB path facts are marked unverified too,
since they came from the same unverified batch.

D20 records the process lesson: the plan contained the right check ("first
action before writing any code"), it was never run, and nothing downstream
required it to have been. Device capabilities get confirmed on the device
before being written as fact.

Also adds the two phases requested before this came up, both grounded in
feasibility research rather than assumption:
- Phase 1A, an open-ended UI pass done together, including the verbose field
  surface driven off activity_field_inventory.
- Phase 1B, live tracking. Constrained hard by reality: iOS suspends
  backgrounded PWAs and implements no Web Bluetooth, and Bryton's own Live
  Track needs the phone relaying over BLE, so the tracking client cannot be
  our PWA. Shape that works is OwnTracks POSTing to our API for position,
  with the server deriving distance/pace/elevation; HR and power need BLE and
  are explicitly a second-class opt-in, not a blocker. Two rules written in:
  live positions must never become activities (invariant #6), and "no privacy
  zones, ever" does not extend to a public live link.

Inserted as 1A/1B rather than renumbering Phases 2-5, whose numbers are
referenced from DECISIONS.md, deploy/README.md and code comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 23:20:37 -04:00

34 KiB

Decisions

Settled during the planning session on 2026-09-20. Each entry records what was chosen, what was rejected, and why — so we don't relitigate them.


D1 — Build fresh, don't fork

Chosen: greenfield, borrowing data models only. Rejected: forking Endurain (AGPL, FastAPI+Vue, already has gear/component tracking, but in a feature freeze with a thin maps story) or FitTrackee (AGPL, mature heatmaps, but its "equipment" is one flat tag per workout with no wear intervals — the entire parts/maintenance system would be bolted on anyway). Why: nothing existing covers rides + real parts inventory + maintenance + the self-host features. Take Endurain's gear/component table structure and strava-gear's retroactive wear computation as references; own every line; avoid AGPL entanglement.

D2 — PWA, not a native iOS app

Chosen: SvelteKit static SPA, installed to the iPhone home screen. Rejected: a native SwiftUI app. Why: a home-screen PWA gets the icon, standalone display, offline caching, and — since iOS 16.4 — real push notifications, which was the only thing that used to force native. Native would cost $99/yr for an Apple Developer account, TestFlight/sideloading to reach family phones, and a second codebase forever. The two real PWA gaps on iOS (Web Bluetooth, Background Sync) are irrelevant here: Bryton BLE is a dead end regardless, and the server does all syncing. Kept as insurance: the backend stays strictly API-first with a CI-enforced OpenAPI contract, so if Apple ever makes the PWA route untenable, a native client is a code-generation exercise.

D3 — Bryton cloud poller is the primary ingestion path — premise corrected, decision survives

Chosen: server-side poller against the reverse-engineered Bryton Active API, every 15-20 min. Rejected: Strava as a source (no export_original — decoded smoothed streams only; plus the June 2026 tier restructure caps new apps at 10 users and requires a paid dev subscription); BLE/ANT-FS direct (nobody has reverse-engineered Bryton's BLE — weeks of work, breaks on firmware updates, and separately impossible from an iOS PWA, which has no Web Bluetooth at all). Why: the cloud API returns the original unmodified FIT bytes — higher fidelity than the Strava route, and everything downstream of the cloud is ours. Fallbacks, both built: USB watch folder (also the historical-backfill mechanism, so it stays exercised rather than bit-rotting) and manual upload.

Corrected 2026-09-22. This entry originally justified itself with "the Rider 650 has on-device Wi-Fi (Main Menu -> Data Sync) and uploads to Bryton's cloud with no phone involved… That's both zero-touch and higher fidelity," and listed "depending on the Bryton Active phone app (the original complaint)" as rejected. The Wi-Fi premise was false — the Rider 650 has ANT+ and Bluetooth only, and its only sync route is BLE to the Active app (confirmed on the physical device; see docs/PLAN.md, "How rides actually reach the app"). So the rejected option is in fact the only one available, and the chain is head unit → BLE → Active app → Bryton cloud → poller.

The decision itself still stands — polling Bryton's cloud for original FIT bytes remains the best available primary path, and nothing downstream of the cloud depended on how rides got into it. What changes is the claim: this is one-tap, not zero-touch, and it does not fix the original complaint. See D20 for the process lesson.

D4 — Python / FastAPI / Postgres+PostGIS — database choice superseded by D15

Chosen: Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 + PostGIS 3.4. Rejected: TypeScript full-stack, Go. Why: fitdecode is Python-only, and the Bryton poller reference implementation is Python (~200 lines of Meteor DDP to vendor rather than reimplement — DDP over SockJS is fiddlier than it looks in JS). FastAPI emits OpenAPI 3.1 for free. PostGIS is needed for heatmaps, bbox queries, and self-segment matching. Go has the best raw performance and a fine FIT library but the weakest data-analysis ecosystem for the later analytics work.

The Python/FastAPI half of this still stands. The database half — Postgres+PostGIS — was replaced with SQLite in D15, after Phase 0 was already built and merged against Postgres. Kept here, marked superseded rather than deleted, so the PostGIS-specific reasoning (heatmaps, bbox queries, segment matching) is still visible as context for whatever Phase 3 ends up doing about spatial storage without it.

D5 — procrastinate for background jobs — needs a replacement, see D15

Chosen: procrastinate (Postgres-backed queue). Rejected: Celery (needs Redis/RabbitMQ, heavyweight, weak async, second-class Postgres broker), arq (Redis-only — a whole container for ~50 tasks/day), APScheduler (a scheduler, not a durable queue: no retries, no dead-lettering, no multi-worker coordination). Why: the decisive property is transactional enqueue — the raw_files INSERT and the parse-job enqueue commit atomically on one connection, so there are no orphaned blobs and no jobs pointing at rolled-back rows. That's impossible with a Redis broker without inventing an outbox. It also has built-in cron, which removes the scheduler container, and keeps job state inside the same pg_dump.

procrastinate is Postgres-only — no SQLite backend exists, so D15's move to SQLite invalidates this choice. Nothing consumes a job queue yet (no ingestion pipeline exists), so this is a deferred decision, not an urgent one: whatever replaces it (APScheduler for a scheduler, or a hand-rolled SELECT ... WHERE claimed_at IS NULL LIMIT 1 polling table for real job durability — SQLite's single-writer model makes even a crude polling table viable at this app's scale) needs picking before Phase 1's ingestion pipeline, not before.

D6 — Opaque bearer tokens, no JWT

Chosen: Argon2id passwords + opaque tokens in a sessions table, HttpOnly cookie for the PWA. Rejected: JWT. Why: at 5-15 users, verification is one indexed PK lookup (~0.1ms), and you get instant revocation, a real device list, and no key-rotation or clock-skew bug class. JWT's only advantage is stateless horizontal scale, which will never arrive — choosing it would be a permanent complexity tax against a benefit that never materialises.

D7 — Raw bytes are the only truth

Chosen: every ingested file is written to a content-addressed blob store before parsing, and is never mutated or deleted. All tables are rebuildable projections. Why: this converts "a parser bug wrote wrong elevation to 4,000 rides" and "reprocess a decade of history against a better DEM" from incidents/migrations into routine batch jobs (parser_version bump + requeue). It is the single most load-bearing rule in the design, and it's also the primary data-loss control.

D8 — No odometer column anywhere; wear is derived

Chosen: component_installs as a time-ranged association (strava-gear's model, made relational with a GIST EXCLUDE constraint), with wear computed by replaying the activity stream. Rejected: a stored running odometer per component. Why: correcting "I actually swapped that chain a week earlier" becomes one UPDATE and every downstream number self-corrects. Parts moving between bikes is two rows. A stored counter can do neither without a reconciliation nightmare — which is exactly where FitTrackee's flat equipment tag falls over in year two.

The time-ranged-association model doesn't depend on Postgres. The EXCLUDE USING gist constraint enforcing "a component is in exactly one place at a time" does — SQLite has no range types and no exclusion constraints. component_installs doesn't exist yet (Phase 2), so this is another deferred casualty of D15, not an active one: the same invariant will need enforcing at the application layer (check-then-insert inside a transaction) instead of the database refusing an overlapping row outright.

D9 — Streams as columnar int32 arrays

Chosen: one row per channel per activity, values_i32[] with a scale factor. Rejected: a normalized per-sample table (~5x larger with index, and every real query wants the whole stream anyway), JSONB (untyped, 3-5x larger, slow to deserialise), TimescaleDB (solves cross-entity firehose scans; we do per-entity blob reads — and it would mean abandoning the postgis/postgis base image and taking on extension-version coupling at every Postgres upgrade). Bonus: FIT stores position as int32 semicircles, so lat/lon are lossless and free in this encoding. Aggregates are precomputed at ingest into activity_stats, never scanned from streams.

D10 — Imperial display units

Chosen: users.unit_system defaults to imperial. Storage stays SI integers throughout (metres, seconds, mm/s, minor currency units); units are strictly a presentation concern.

D11 — Notification dedupe by cycle sequence

Chosen: notification_log with UNIQUE (user_id, dedupe_key) where the key is service_due:<rule_id>:<component_id>:<cycle_seq>:<threshold> and cycle_seq counts service events logged against that (component, rule). Why: a naive nightly evaluator nags you about the same chain every night until you fix it, and you learn to ignore it. This fires exactly once at 80%, once at 100%, then goes quiet; logging the service increments the cycle and re-arms the next 200 miles. It's how recurrence works without a cron-style recurrence engine.

D12 — ntfy first, Web Push second

Chosen: apprise -> ntfy as the primary notification channel; Web Push (VAPID/pywebpush) as the nicer layer on top; every notification is also an in-app inbox row. Why: apprise is already in the stack for poller alerts and works on iOS with no PWA-install requirement, so notifications can ship early. Apple's web.push.apple.com does speak standard RFC 8291 (no Apple Developer account needed), but only for home-screen-installed PWAs, and iOS silently drops subscriptions after OS updates. Push must never be the only path to the information.

D13 — Monorepo

Chosen: one repo for API + web + deploy + workflows. Why: one maintainer, and API and client change together constantly. D2 removed the only real argument for splitting (a native app would have needed macOS runners that a Linux act_runner can't provide) — now every artefact builds on the same runner.

D14 — Research agent models

Chosen: Opus for the Bryton protocol research and the architecture design (ambiguous, reverse-engineering, synthesis-heavy); Sonnet for the two breadth surveys (existing self-hosted apps, Gitea CI patterns) where material is well-documented and the work is volume.

D15 — SQLite, single container, no database-level RLS

Chosen: SQLite as the database, and the whole app (Caddy + API, static web build baked in) as a single container. Made explicitly after Phase 0 was already built, tested, and merged against Postgres+PostGIS with a two-role RLS architecture (D4, and the velodrome_app/velodrome_auth split in apps/api/velodrome/db.py) — this is a deliberate reversal of a shipped decision, not a greenfield choice, and it was made with the costs stated plainly first.

Rejected, with reasons on the record: keeping Postgres as a second container (rejected — explicitly wanted exactly one container total); bundling Postgres+PostGIS inside the single container via a process supervisor (offered as the way to get "one container" without losing RLS or PostGIS — rejected in favour of SQLite specifically).

What this costs, stated once here rather than re-litigated every time it's felt:

  • Row-level security is gone. SQLite has no roles, no session variables, no policy engine — there is no database-enforced layer left, only the repository-layer scope. CLAUDE.md's invariant #4 is revised accordingly (see the file) to describe app-layer scoping as the sole mechanism rather than one of two layers. The user isolation test in tests/test_auth.py that used to prove RLS itself now proves the repository-layer scope does the same job in its absence — read it before touching any query that filters by user_id.
  • PostGIS is gone. No native geometry columns, no GIST spatial indexes, no ST_Envelope. Nothing in the schema uses it yet (Phase 0 has no activities table), so this is a live decision for Phase 1/3 to make, not a retrofit — options include SpatiaLite, or storing tracks as GeoJSON/WKB in a TEXT/BLOB column with spatial math done in application code.
  • procrastinate is gone (D5) — Postgres-only, no SQLite backend. Also nothing consumes it yet; a replacement gets picked before Phase 1's ingestion pipeline needs one, not now.
  • The EXCLUDE USING gist constraint design for component_installs (D8) — doesn't exist yet either (Phase 2); the "one place at a time" invariant will need application-layer enforcement instead of the database refusing an overlapping row outright.

Why proceed anyway: raised as a concern in-session, with each cost above stated before this decision was made; the user heard the full list and confirmed SQLite regardless. That's their call to make about their own single-user/family-scale instance, not an oversight to correct for them.

What's unchanged: invariants #1 (raw bytes immutable), #3 (SI integers in storage), #5 (secret containment), #6 (single ingestion path) — none of those were ever Postgres-specific. Auth design (D6, opaque bearer tokens) is unaffected. FastAPI/SQLAlchemy/Alembic stay exactly as chosen in D4; only the database engine underneath them changed.

D16 — Single-container packaging: entrypoint migrations, tini + a two-line supervisor, Caddy binary copy, Unraid template

Chosen: one Docker image (root Dockerfile), built by copying the SvelteKit static build and the API's venv into a runtime stage alongside a copied-out caddy binary. deploy/entrypoint.sh runs alembic upgrade head, then starts uvicorn (loopback-only) and Caddy as two background processes under tini as PID 1, and kills+exits if either one dies. Config surfaces as env vars read by the existing VELODROME_-prefixed Pydantic settings; deploy/unraid-template.xml exposes the required ones as Unraid Community Applications web UI fields instead of a .env file.

Why not a real process manager (s6-overlay, supervisord): two long-running processes with no dependency graph between them (Caddy doesn't need to wait on uvicorn — it just proxies) doesn't need a supervisor with restart policies, readiness ordering, or log multiplexing. A ~20-line bash script under tini (for correct signal forwarding and zombie reaping, which a bare shell script as PID 1 doesn't do) gets the one property that matters — if either process dies, the whole container exits non-zero so Docker/Unraid restarts it — without a new dependency or a config format to learn. Revisit if a third long-running process gets added later; two is the reasonable ceiling for "just write the script."

Why migrations run from the entrypoint, contradicting what apps/api/Dockerfile's own comment used to say ("Migrations run as an explicit step before this in deploy.yml... never from the entrypoint, so a failed migration fails the deploy visibly instead of crash-looping here"): that comment described the 3-container Postgres plan, where a separate run --rm api alembic upgrade head step existed before compose up -d. A single container has nowhere else to put that step. The property it was protecting — a failed migration must be visible, not silently served — still holds: set -e means the script exits non-zero on migration failure, so the container never starts serving traffic and shows as exited/restarting in docker ps/Unraid, which is the same visibility by a different mechanism. What's genuinely lost is the old mechanism's failure mode of "the previous version keeps running while the bad migration is investigated" — a single container that fails to start migrations has no previous version still up. Acceptable for a single-instance home-lab deployment; would need reconsidering (e.g. a blue/green swap) if this ever needed zero-downtime deploys.

Why the Caddy binary is copied from caddy:2 rather than using a Caddy base image: the runtime needs both Python (for uvicorn) and Caddy; picking either official base image as the starting point means installing the other stack into it by hand. Caddy's official images are a single statically-linked Go binary with no CGO, so COPY --from=caddy:2 /usr/bin/caddy /usr/bin/caddy into a python:3.12-slim base is the documented, standard way to get both without a second package manager or a source build.

Why an Unraid template file, not just documentation: the earlier decision (in-session) was to move configuration out of a .env file and into fields the Unraid web UI can fill in — a plain env var table in a README doesn't do that by itself, since Unraid still needs a Config-tagged XML entry per field to render one. deploy/unraid-template.xml is that; every field stays hand-editable in the UI afterward regardless of what the template pre-fills, so getting a default slightly wrong here isn't load-bearing.

What this doesn't do: .gitea/workflows/release.yml builds and pushes the image to the Gitea registry; it does not SSH into the Unraid host and recreate the running container. Rolling a new image out is a manual/Unraid-side action (pull + Apply, or Unraid's own update check), not something CI does unattended — consistent with treating "affects a shared, already-running system" as something a human triggers, not automation.

D17 — Registry TLS: self-signed cert behind NPMplus, not insecure-registries, not a real domain

Problem: release.yml's first real run failed — docker/login-action against 192.168.0.3:3000 (Gitea's plain-HTTP address) hit server gave HTTP response to HTTPS client. Docker refuses TLS-less registries by default; this was never a workflow misconfiguration, it's expected Docker behaviour for any non-localhost registry.

Rejected: insecure-registries in daemon.json. The obvious fix. Rejected after actually reading /etc/rc.d/rc.docker on the Unraid host rather than assuming: applying a daemon.json change requires a full dockerd restart, and (with Live Restore disabled on this host) both Unraid's own restart path and a raw kill of dockerd stop every one of the ~40 other containers running on the box first, as part of the restart/shutdown sequence — Plex, Home Assistant, Vaultwarden, everything. Correct fix for the narrow problem, unacceptable blast radius for this specific host.

Rejected: a real Let's Encrypt cert on a new bbergle.com subdomain routed publicly. The user's other NPMplus-fronted subdomains resolve through Cloudflare's proxy (orange-cloud), not directly to the home IP. A Cloudflare-proxied hostname would have terminated TLS at Cloudflare's edge with Cloudflare's own cert, never reaching our self-signed cert or NPMplus's own TLS config at all — the entire trust chain would depend on Cloudflare's origin SSL mode, and likely on firewall rules restricting port 443 to Cloudflare's IP ranges, neither of which this problem needed to involve.

Chosen: a small, fully self-contained fix, scoped to touch nothing already working:

  • A 10-year self-signed cert for registry.bbergle.com (SAN-only, no real domain dependency).
  • An NPMplus proxy host (registry.bbergle.com -> 192.168.0.3:3000 over plain HTTP internally) terminating TLS with that cert, on NPMplus's existing HTTPS port (9537 on this host — found by reading docker port NPMplus rather than assuming 443, which is a different nginx process on this box entirely).
  • /etc/hosts on the Unraid host mapping registry.bbergle.com -> 192.168.0.103 (itself) — chosen over a real DNS record specifically because the only client that ever needs to resolve this hostname is the Unraid host's own dockerd (Gitea Actions runs in DooD mode against that same host's Docker socket). This sidesteps Cloudflare, the router's NAT/hairpin behaviour, and any port-forwarding question entirely — verified separately that hairpin NAT works by default on this user's UniFi gateway, but it turned out to be unnecessary for this fix regardless.
  • /etc/docker/certs.d/registry.bbergle.com:9537/ca.crt on the Unraid host, trusting that cert for that host:port specifically. Confirmed (Docker's own docs) that certs.d is read per-connection, not baked in at daemon start — no dockerd restart, no impact on any other container.
  • docker/setup-buildx-action@v3 pinned to driver: docker in release.yml instead of its default docker-container driver — the default runs BuildKit in an isolated builder container that does not see the host's /etc/docker/certs.d, which would have silently defeated the whole point of the trust setup above. We don't build multi-platform images, so nothing the docker-container driver offers is actually needed here.

Not persisted across a reboot, deliberately, for now: neither the /etc/hosts line nor the certs.d file are wired into /boot/config/go — both live under /, which Unraid rebuilds fresh from /boot on every boot. Raised explicitly rather than assumed: the user was (rightly) wary of hand-editing anything under /boot after an earlier, unrelated discussion of what a broken go script could do to boot. Persisting this is a five-minute follow-up (append two lines to go) once they're ready to make that call deliberately, not bundled into this fix.

What's unaffected: Gitea's own web UI, git remote, and API — all still plain http://192.168.0.3:3000, exactly as CLAUDE.md documents. NPMplus's existing public proxy hosts and certs (vaultwarden.bbergle.com etc.) — untouched, new proxy host only. No other container on the Unraid host was restarted, reconfigured, or otherwise touched to make this work.

D18 — Admin bootstrap is a CLI command, not an HTTP endpoint or a first-run mode

Chosen: velodrome create-admin, a console script ([project.scripts] in apps/api/pyproject.tomlvelodrome.cli:main) installed into the same venv as alembic and uvicorn, so the deployed image already has it on PATH:

docker exec -it velodrome velodrome create-admin --email you@example.com

Why this exists at all: registration requires a valid invite code (docs/PLAN.md "Auth" — open signup does not exist, not even as a setting), invites can only be created by an existing admin, and a freshly migrated database has neither. A new deployment was therefore unusable: there was no way to create the first account. docs/PLAN.md always called for this command; it was simply never built during Phase 0, and the gap only became visible once D16 made a real deployment possible.

Why a CLI rather than the alternatives:

  • A bootstrap HTTP endpoint that works only while the users table is empty — rejected. It puts an unauthenticated account-creating route on the public internet permanently, whose safety depends entirely on a row count staying zero. The window is real (between first start and first login), it's the exact window where the deployment is least watched, and the failure is silent: whoever wins the race owns the instance.
  • An env var like VELODROME_INITIAL_ADMIN_PASSWORD — rejected. A password in an env var is visible in docker inspect, in the Unraid template's saved config on disk, and in the container's own /proc/1/environ for the process's whole life. D16 deliberately moved configuration into Unraid's UI, which would mean the bootstrap password sitting in that UI indefinitely.
  • Seeding a default account in a migration — rejected outright. It would mean a known-credential account existing on every deployment, and it contradicts the reason migrations are schema-only.

Why it refuses an email that already exists, rather than updating it: creating an account and resetting an existing account's password are different operations with different blast radii, and the realistic scenario — an operator re-running a command they last ran months ago, from shell history — means the first, never the second. Silently accepting it would make this an undocumented password-reset tool that any container-exec grants, and would make the command's behaviour depend on state the operator can't see. It exits 1 and says what it refused. A genuine password reset is a separate future command that should have to say so in its name.

Why it is not restricted to "only when there are zero users": that restriction sounds safer and isn't. It buys nothing — the command already requires the ability to run a process inside the container, which is already the ability to read and rewrite the SQLite file directly, so a restriction only constrains the legitimate operator, never an attacker who is by definition already past it. Meanwhile it removes the two cases that actually happen: a second admin for a family member, and recovering an instance whose only admin account was lost. The invite system remains the normal path for adding users; this stays the operator's escape hatch.

Why role="admin" is recorded but nothing enforces it yet: there is no admin-only endpoint to protect. Invite management — the first thing that genuinely needs the distinction — is Phase 1. Writing the column now means the first account is correctly marked when that check does arrive, rather than needing a data fix-up later; writing an enforcement mechanism now would be guessing at the shape of a check with no caller. ROLE_ADMIN/ROLE_MEMBER are named constants in models/identity.py, and the column stays a plain string rather than a DB enum or CHECK constraint so a third role later is an application change, not a migration. AuthenticatedSession.role carries the value for that future check; it is deliberately absent from schemas.auth.UserOut, so this changes no HTTP response and no OpenAPI contract.

Why there is no --password flag: an argument lands in shell history, in ps output for the process's lifetime, and — because the realistic invocation is docker exec — in the Docker daemon's record of the exec'd command. A TTY prompt (with confirmation) and --password-stdin are the two forms that avoid all three, which is the same pair docker login offers for the same reason. Pydantic's ValidationError rendering is also deliberately not printed verbatim: it embeds the offending value, which for a too-short password prints the password to the terminal. Only loc and msg are shown (CLAUDE.md invariant #5); tests/test_cli.py asserts this on both the failure and success paths.

argparse, not Typer/Click: one command with three options doesn't justify a runtime dependency the deployed image has to carry.


D19 — Auto-update: attempted, deferred; Unraid's own update checker needed a separate fix

The immediate bug: Unraid's Docker "check for updates" reported not available for velodrome after D17's registry move. Root cause, found by reading the actual PHP source (dynamix.docker.manager's DockerClient.php): it queries the registry's manifest API directly over curl from PHP, which is a completely different trust path from dockerd's own — it doesn't read Docker's /etc/docker/certs.d at all, only the OS-wide CA bundle. Fixed by also adding the D17 self-signed cert to /usr/local/share/ca-certificates/ and running update-ca-certificates on the Unraid host — a third, independent place this cert now needs to be trusted (alongside certs.d and the /etc/hosts entry from D17), and like those two, not yet persisted across a reboot (/boot/config/go again — same deliberate non-decision as D17).

A second, structural problem this exposed, not fixed: even with the checker itself working, "up to date" on this host doesn't reliably mean the running container matches the registry. Gitea Actions builds directly on this same host's dockerd (DooD), which means every CI build also leaves its own result sitting in the local image cache tagged :latest — so the local-vs-remote digest comparison Unraid's checker does is comparing the registry against a tag that CI keeps fresh on its own, independent of whether the velodrome container was ever recreated from it. Confirmed directly: the checker reported "up to date" while the running container's actual manifest digest (read via docker inspect) provably differed from the registry's current Docker-Content-Digest. This is a consequence of building CI on the same host as the app runs, not a bug to patch around — see the Gitea-to-VM item below.

Attempted: Watchtower, label-scoped (WATCHTOWER_LABEL_ENABLE=true + a com.centurylinklabs.watchtower.enable=true label on velodrome only, specifically so it can never touch any of the ~40 other containers on this host) with the CA bundle mounted in for the same registry-trust reason as above. Failed on the first attemptcontainrrr/watchtower's published image talks a Docker API version (1.25) too old for this host's dockerd, a stale-image problem, not a design problem. Not yet retried with a maintained fork. The velodrome container does carry the watch-enable label already (added when it was recreated to pick up D18's CLI), so turning this on later is "run the right watchtower image," not "redesign anything."

Why not have CI redeploy the container directly (it already has host dockerd access via DooD): considered and explicitly rejected, again — see D16/D17's reasoning, which this doesn't change. Turning every merge to main into an unattended production change on a personal server is a bigger step than "install an auto-updater," and wasn't asked for.

Until this is finished: redeploying after a merge is docker pull + recreate, same as any manual deploy — deploy/README.md's "Publishing the image" section.


D20 — The Rider 650 has no Wi-Fi; verify device capabilities on the device

What happened: the plan's headline section, "The sync breakthrough," asserted that the Rider 650 has on-device Wi-Fi and a Main Menu → Data Sync entry that uploads rides to Bryton's cloud with no phone involved. It does not. The Rider 650 has ANT+ and Bluetooth only; its sole sync route is BLE to the Bryton Active app. Confirmed on the physical device, and corroborated by BikeRadar's hands-on ("ANT+ and Bluetooth connectivity", syncing via "Bryton's Active App"). The most likely origin of the error is conflation with the Rider 750 / S800, which do have Wi-Fi.

Why it survived so long: the plan did contain the right check — "First action before writing any code: on the Rider 650, go to Main Menu → Data Sync… and confirm a test ride uploads without the phone." It was never run, and nothing downstream required it to have been. An entire phase was planned, and Phase 0 fully built and deployed, on top of an unverified device capability that was written down in the declarative voice of a finding rather than the provisional voice of an assumption.

What it cost, and didn't: less than it first appeared. Everything downstream of Bryton's cloud — ingestion, dedupe, schema, wear engine, garage, notifications, all of Phase 0 — never depended on how a ride reached that cloud, so no built code was invalidated. What was invalidated was the product promise: Phase 1 was called "Zero-touch ride history" and claimed to fix the original complaint (having to remember to open the Active app). It does not. It is one-tap, and the complaint stands. That renaming, not a refactor, was the actual repair.

The rule going forward: a physical-device capability that a phase depends on is confirmed on the device before it is written down as fact. Model-adjacent sources (a spec page for a different unit in the same family, a review of a sibling model) do not count. Until confirmed, such a claim is written as an open question in docs/RESEARCH.md, not as a premise in docs/PLAN.md — and any phase resting on it carries the verification as its first task, not as a footnote. The same applies to the remaining unverified device claims: the USB .fit path layout, and whether the intervalssync protocol still retrieves activities from a current Bryton account.


Deliberately deferred

  • Finish the Watchtower auto-updater (D19) — retry with a maintained image; velodrome is already labeled for it.

  • Migrate Gitea + its Actions runners to a dedicated VM, off the Unraid host the app itself runs on. Raised explicitly (not yet started) after D17/D19 both turned out to be fighting the same root cause from different angles: CI sharing a dockerd with ~40 unrelated production containers means every registry-trust fix and every update-check quirk this session hit was more contained, and more repeatable to reason about, than it should have needed to be. A dedicated VM removes that coupling entirely — CI's own Docker config becomes free to change without any blast-radius conversation about Plex or Vaultwarden ever again. Real migration work (new VM, moving Gitea's and both runners' appdata, re-pointing 192.168.0.3, updating every reference to it across this repo and this session's own tooling), not a quick fix — a deliberate choice to do later, not an oversight now.

  • Persist the D17/D19 host-local trust files across a reboot (/etc/hosts, certs.d, the CA bundle addition) via /boot/config/go. Left un-persisted through both decisions specifically because editing anything under /boot was raised as a real concern mid-session — worth revisiting together once, for all three at once, rather than as three separate asks.

  • Routing (Valhalla/Photon/Overpass) — Phase 5, optional. Several GB of RAM for something Komoot already does well.

  • Local LLM ride summaries (Ollama) — Phase 4, behind a compose profile.

  • Friends/family cross-visibility — Phase 4, as an additive widening of the repository-layer scope (an RLS policy pre-D15; see D15 for why that's no longer the mechanism), never as removal of the default per-user scope.

  • Legacy Bryton format support — out of scope entirely. The Rider 650 writes .fit.

  • Reverse-engineering Bryton's BLE — explicitly rejected. See RESEARCH.md §1.