Files
doctate/docs/specs/2026-05-20-watch-multi-profile-design.md
T
Brummel 430bc2f371 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
2026-05-20 18:32:01 +02:00

13 KiB
Raw Blame History

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.

# 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_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:

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:

  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

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.