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
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; seedocs/PLAN.md, "How rides actually reach the app"). So the rejected option is in fact the only one available, and the chain ishead 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.pythat 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 byuser_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 noactivitiestable), 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 aTEXT/BLOBcolumn with spatial math done in application code. procrastinateis 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 gistconstraint design forcomponent_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:3000over plain HTTP internally) terminating TLS with that cert, on NPMplus's existing HTTPS port (9537on this host — found by readingdocker port NPMplusrather than assuming 443, which is a different nginx process on this box entirely). /etc/hostson the Unraid host mappingregistry.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 owndockerd(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.crton the Unraid host, trusting that cert for that host:port specifically. Confirmed (Docker's own docs) thatcerts.dis read per-connection, not baked in at daemon start — nodockerdrestart, no impact on any other container.docker/setup-buildx-action@v3pinned todriver: dockerinrelease.ymlinstead of its defaultdocker-containerdriver — 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 thedocker-containerdriver 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.toml → velodrome.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 indocker inspect, in the Unraid template's saved config on disk, and in the container's own/proc/1/environfor 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 attempt — containrrr/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;
velodromeis 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
dockerdwith ~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-pointing192.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/bootwas 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.