Add spec for watch multi-profile build mechanism
Three named profiles (brummel-dev, brummel-minerva, krey-minerva) with hard-locked watch-serial whitelist in run.sh, to enable safe parallel testing on Brummel's and Krey's watches against dev and minerva servers. refs #3
This commit is contained in:
@@ -0,0 +1,341 @@
|
|||||||
|
# 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. S1–S5 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.
|
||||||
Reference in New Issue
Block a user