Files
doctate/docs/specs/0001-watch-multi-profile-design.md
Brummel a4ecad08ba chore(skills): add dev-cycle profile and switch docs to counter-prefix naming
Activate the skills plugin for this project with a project profile at
.claude/dev-cycle-profile.yml: server-focused inner-loop build/test,
scripts/check.sh as the audit regression gate, and the Gitea tracker
wired as the boss forward queue.

Set the naming policy to stable_per_directory_4digit (0001-slug.md) for
docs/specs and docs/plans, and rename the existing date-prefixed
artefacts to match. The one internal spec cross-link is updated.
2026-06-01 13:56:58 +02:00

342 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Watch Multi-Profile Build Mechanism
**Status:** Draft
**Date:** 2026-05-20
**Related issues:** #1 (BLOCKER, prerequisite), #3 (this spec), #4 (follow-up hardening), #5 (long-term replacement)
## Context
The Wear OS app today bakes a single `SERVER_URL` and `API_KEY` into the APK at
build time via `BuildConfig` fields (see `clients/wearos/app/build.gradle.kts`).
The values come from one of three sources in order:
1. Gradle CLI property (`-Pdoctate.serverUrl=…`, `-Pdoctate.apiKey=…`)
2. `clients/wearos/local.properties` (gitignored, sticky default)
3. Hardcoded fallback (`http://10.0.2.2:3000`, `MISSING_API_KEY`)
`clients/wearos/run.sh` injects the Gradle properties for the `watch` target
based on the `DOCTATE_DEV_SERVER` and `DOCTATE_API_KEY` environment variables.
This mechanism handles a single developer talking to a single backend. It does
not cover the three scenarios that the project now needs:
1. **Brummel-Watch ↔ dev server** (Laptop, `http://192.168.178.27:3000`) —
for server and watch co-development.
2. **Brummel-Watch ↔ minerva** (`http://minerva.lan:3000`) — for deployment
smoke-testing on the alpha host.
3. **Krey-Watch ↔ minerva** — for Dr. Krey's clinical alpha use; this is
**production data** (real patient dictations).
Switching today means manually editing `local.properties` or juggling env
vars, with no safeguard against the worst-case mistake: installing the wrong
profile on the wrong watch. Krey's production data is the failure mode that
forces an explicit defensive design.
## Goals
- **Three named profiles** addressable by short, explicit names:
`brummel-dev`, `brummel-minerva`, `krey-minerva`.
- **One-command switch**: `./run.sh watch <profile> install` builds and installs
with the chosen profile in one step.
- **Hard refusal** when a profile targets a watch that is not in its allow-list
— this is the load-bearing safeguard against cross-contamination.
- **Zero external runtime dependencies** for `run.sh`: bash + adb + gradle only.
No Python, no `jq`, no `yq`.
- **Single source of truth** per profile: server URL, API key, allowed ADB
serials live in one place each, gitignored.
## Non-Goals
- **No self-service install for Krey** — APK deployment is physical via ADB on
Brummel's laptop. A pairing flow (Issue #5) is explicitly out of scope.
- **No release-signed APKs / no signed artifact handoff** — debug builds over
ADB are sufficient for the current testing tier.
- **No coexistence on a single watch** — one APK per watch, profile switches
reinstall. Coexistence via `applicationIdSuffix` was considered and rejected
as unneeded.
- **No server-side validation that an api_key is not a bcrypt hash** — that is
Issue #4 and lives in a separate follow-up.
- **No runtime profile selection on the watch** — build-time only; runtime
selection would require a full pairing/settings UI (Issue #5).
## Design
### Profile data structure
One file per profile under `clients/wearos/profiles.d/<name>.sh`, sourced as
bash. All profile files are gitignored; a sibling `*.sh.example` is checked in
as a format template.
```bash
# clients/wearos/profiles.d/brummel-dev.sh (gitignored)
SERVER_URL="http://192.168.178.27:3000"
API_KEY="<plaintext key from server's users.toml under slug 'brummel'>"
ALLOWED_SERIALS=(
"adb-XXXXXXXX-yyyy._adb-tls-connect._tcp"
)
```
```bash
# clients/wearos/profiles.d/brummel-minerva.sh (gitignored)
SERVER_URL="http://minerva.lan:3000"
API_KEY="<plaintext key from minerva's users.toml under slug 'brummel'>"
ALLOWED_SERIALS=(
"adb-XXXXXXXX-yyyy._adb-tls-connect._tcp"
)
```
```bash
# clients/wearos/profiles.d/krey-minerva.sh (gitignored)
SERVER_URL="http://minerva.lan:3000"
API_KEY="<plaintext key from minerva's users.toml under slug 'krey'>"
ALLOWED_SERIALS=(
"adb-ZZZZZZZZ-wwww._adb-tls-connect._tcp"
)
```
`.gitignore` rule:
```
clients/wearos/profiles.d/*.sh
!clients/wearos/profiles.d/*.sh.example
```
A bash-source format was chosen over TOML/JSON to keep `run.sh` zero-dependency.
The risk of code execution via a profile file is acceptable because the file is
single-user, gitignored, and authored by the developer themselves. A move to
TOML would become attractive if profiles ever come from elsewhere or the user
base grows.
`ALLOWED_SERIALS` is an array, not a scalar, so a future watch swap can list
both old and new serials during a transition without a schema change.
### CLI form
```
./run.sh watch <profile> <subcmd> [args]
./run.sh emulator <subcmd> [args] # unchanged, no profile
```
The profile name is a required second positional argument for the `watch`
target. The `emulator` target keeps its existing hardcoded loopback config.
Existing subcommands (`install`, `build`, `start`, `stop`, `logcat`, `shot`,
`test`) all accept the profile in the new position.
### run.sh internals
New helper functions:
```bash
load_profile() {
local name="$1"
local file="$SCRIPT_DIR/profiles.d/$name.sh"
if [[ ! -f "$file" ]]; then
local available
available="$(cd "$SCRIPT_DIR/profiles.d" 2>/dev/null && \
ls *.sh 2>/dev/null | sed 's/\.sh$//' | tr '\n' ' ')"
[[ -z "$available" ]] && available="<none — create one in profiles.d/>"
die "Unknown profile '$name'. Available: ${available}"
fi
# Reset to detect missing fields after sourcing.
SERVER_URL=""; API_KEY=""; ALLOWED_SERIALS=()
# shellcheck disable=SC1090
source "$file"
[[ -n "$SERVER_URL" ]] || die "Profile '$name': SERVER_URL missing"
[[ -n "$API_KEY" ]] || die "Profile '$name': API_KEY missing"
[[ "${#ALLOWED_SERIALS[@]}" -gt 0 ]] || die "Profile '$name': ALLOWED_SERIALS empty"
}
verify_serial_allowed() {
local serial="$1"
local s
for s in "${ALLOWED_SERIALS[@]}"; do
[[ "$s" == "$serial" ]] && return 0
done
die "Watch serial '$serial' is NOT in ALLOWED_SERIALS for profile '$DOCTATE_PROFILE'.
Allowed: ${ALLOWED_SERIALS[*]}
This refusal protects against installing the wrong key on the wrong watch
(e.g. Brummel-key on Krey's watch → patient data goes to dev server)."
}
```
Updated `apply_target` for the `watch` target:
```bash
case "$DOCTATE_TARGET" in
watch)
[[ -n "$DOCTATE_PROFILE" ]] || die \
"Watch target requires a profile. Usage: ./run.sh watch <profile> <subcmd>"
load_profile "$DOCTATE_PROFILE"
if [[ -z "$ADB_SERIAL" ]]; then
resolve_watch_serial
ADB_SERIAL="$RESOLVED_SERIAL"
fi
verify_serial_allowed "$ADB_SERIAL"
GRADLE_PROPS+=(
"-Pdoctate.serverUrl=$SERVER_URL"
"-Pdoctate.apiKey=$API_KEY"
"-Pdoctate.profileName=$DOCTATE_PROFILE"
)
info "Target: watch (profile=$DOCTATE_PROFILE, serial=$ADB_SERIAL, server=$SERVER_URL)"
;;
emulator)
# existing logic, unchanged
;;
esac
```
The `verify_serial_allowed` check runs BEFORE Gradle is invoked, so a mismatch
fails fast without burning a minute-long build.
Argument-parsing changes: the second positional after `watch` becomes the
profile name; subcmd shifts to the third position. The `emulator` target keeps
the second-positional-is-subcmd shape.
Removed (breaking change inside `run.sh`, no callers outside):
- `DOCTATE_DEV_SERVER` env var and `DEFAULT_DEV_SERVER` constant
- `DOCTATE_API_KEY` env var
- `doctate.apiKey=…` line in `local.properties` (the file becomes empty or
deleted; profile data is canonical)
Help-text block at the top of `run.sh` is rewritten to document the new form.
### BuildConfig and visual badge (nice-to-have)
`clients/wearos/app/build.gradle.kts` resolves two new build config fields:
```kotlin
val profileName = resolveProp("doctate.profileName", "unknown")
val isDevProfile = profileName.endsWith("-dev")
buildConfigField("String", "PROFILE_NAME", "\"$profileName\"")
buildConfigField("boolean", "IS_DEV_PROFILE", "$isDevProfile")
manifestPlaceholders["appLabel"] = if (isDevProfile) "Doctate (Dev)" else "Doctate"
```
`AndroidManifest.xml` uses the placeholder:
```xml
<application android:label="${appLabel}" ...>
```
A `DevBadge` composable in `presentation/components/DevBadge.kt` renders a small
red "DEV" tag, but only when `BuildConfig.IS_DEV_PROFILE` is true:
```kotlin
@Composable
fun DevBadge(modifier: Modifier = Modifier) {
if (!BuildConfig.IS_DEV_PROFILE) return
Text(
text = "DEV",
color = Color(0xFFFF5252),
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
modifier = modifier
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
```
Mounted in `CaseListScreen` (top-right) and `RecordingScreen` (top-right next to
the `mm:ss` counter). Production profiles render no badge — Krey's UI stays
clean.
This visual layer is classified as nice-to-have. The hard-lock in `run.sh`
prevents the worst-case misinstall regardless; the badge protects against the
softer case where Brummel forgets which profile is currently installed.
## Prerequisites
These are external to the Watch code but must be done before the watch profiles
work end-to-end.
### V1 — Resolve Issue #1 (BLOCKER)
Edit `/opt/stacks/doctate-server/config/users.toml` on minerva: replace bcrypt
hashes in `api_key` fields with fresh plaintext values (one per user, e.g.
`openssl rand -base64 24`). Reload the container.
The server's auth path (`server/src/auth.rs:52`) does a plaintext HashMap
lookup; bcrypt-shaped values in `api_key` never match the plaintext the watch
sends, producing silent 401s.
### V2 — Add Krey to minerva's users.toml
Create the `krey` user entry on minerva with:
- `slug = "krey"`
- `display_name = "Dr. Krey"` (or her preferred form)
- `api_key = "<fresh plaintext, openssl rand -base64 24>"`
- `web_password = "<bcrypt hash via the hash-password CLI>"`
- `role = "doctor"` (not admin — Krey should not see reset/purge controls)
### V3 — Collect ADB serials
For each watch in turn:
1. Pair via `./run.sh connect <host:port>` (Pixel Watch over ADB-WiFi).
2. List the attached devices via `./run.sh devices` and copy the watch's
serial, which has the shape `adb-XXXXXXXX-yyyy._adb-tls-connect._tcp`.
3. Paste it into the corresponding profile file's `ALLOWED_SERIALS` array.
A serial can appear in more than one profile (e.g. Brummel's watch is allowed
under both `brummel-dev` and `brummel-minerva`). Krey's serial appears only
under `krey-minerva`.
## Roll-out
```mermaid
graph TD
V1[V1: minerva users.toml plaintext keys<br/>= Issue #1 fix] --> V2
V2[V2: add Krey user to minerva] --> S1
V3[V3: collect ADB serials of both watches] --> S1
S1[S1: profiles.d/ mechanism in run.sh] --> S2
S2[S2: create three profile files<br/>brummel-dev, brummel-minerva, krey-minerva] --> S3
S3[S3: BuildConfig PROFILE_NAME + IS_DEV_PROFILE<br/>+ manifest appLabel] --> S4
S4[S4: visual DEV badge (optional)] --> S5
S5[S5: smoke-test matrix]
```
V1, V2, V3 can run in parallel. S1S5 are sequential. S4 may be deferred
without blocking the rest.
## Smoke-test matrix
After implementation, run each row once and confirm the expected outcome:
| Command | Watch attached | Expected |
|---|---|---|
| `./run.sh watch brummel-dev install` | Brummel | Build + install, app talks to laptop IP, `/api/oneliners` → 200 |
| `./run.sh watch brummel-dev install` | Krey | Hard-refuse with allowed_serials mismatch message |
| `./run.sh watch brummel-minerva install` | Brummel | App talks to `minerva.lan:3000`, 200 |
| `./run.sh watch krey-minerva install` | Krey | App talks to `minerva.lan:3000` under slug `krey`, 200 |
| `./run.sh watch krey-minerva install` | Brummel | Hard-refuse |
| `./run.sh watch unknown-profile install` | any | Error listing available profiles |
| `./run.sh watch brummel-dev` | any | Error: subcommand missing |
| `./run.sh emulator install` | (emulator) | Unchanged behavior, no profile required |
Visual checks for the optional badge layer:
- Brummel-Watch with `brummel-dev` installed: launcher shows "Doctate (Dev)",
CaseListScreen and RecordingScreen show red "DEV" badge.
- Brummel-Watch with `brummel-minerva` installed: launcher shows "Doctate",
no badge anywhere.
- Krey-Watch with `krey-minerva` installed: launcher shows "Doctate",
no badge anywhere.
## Out of scope (deliberate)
- **Issue #4 (Server sanity-check for bcrypt-shaped api_key)** — natural
follow-up after V1, before S1. Tracked separately. Same risk surface; a
single PR would mix concerns.
- **Issue #5 (Watch pairing flow)** — the principled long-term replacement
for build-time profile baking. Out of reach for this spec.
- **APK distribution to Krey via filesharing / release signing** — not needed
while Brummel installs physically via ADB.
- **Theme tinting / boot splash per profile** — judged unnecessary given the
launcher label and optional badge already cover the visibility need.