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
13 KiB
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:
- Gradle CLI property (
-Pdoctate.serverUrl=…,-Pdoctate.apiKey=…) clients/wearos/local.properties(gitignored, sticky default)- 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:
- Brummel-Watch ↔ dev server (Laptop,
http://192.168.178.27:3000) — for server and watch co-development. - Brummel-Watch ↔ minerva (
http://minerva.lan:3000) — for deployment smoke-testing on the alpha host. - 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> installbuilds 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, nojq, noyq. - 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
applicationIdSuffixwas 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.
# 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"
)
# 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"
)
# 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:
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:
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_SERVERenv var andDEFAULT_DEV_SERVERconstantDOCTATE_API_KEYenv vardoctate.apiKey=…line inlocal.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:
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:
<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:
@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:
- Pair via
./run.sh connect <host:port>(Pixel Watch over ADB-WiFi). - List the attached devices via
./run.sh devicesand copy the watch's serial, which has the shapeadb-XXXXXXXX-yyyy._adb-tls-connect._tcp. - Paste it into the corresponding profile file's
ALLOWED_SERIALSarray.
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
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-devinstalled: launcher shows "Doctate (Dev)", CaseListScreen and RecordingScreen show red "DEV" badge. - Brummel-Watch with
brummel-minervainstalled: launcher shows "Doctate", no badge anywhere. - Krey-Watch with
krey-minervainstalled: 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.