Protocol version handshake, mismatch detection, refuse-to-start (#53) #96

Merged
robert merged 1 commit from area/protocol-version-handshake into main 2026-09-04 17:17:23 +02:00
Owner

Implements #53 end to end: the handshake exchange, mismatch detection on both sides, and the
refuse-to-start behaviour from the 2026-09-02 "say what a mismatch actually does" update (D46).

Builds on #7 (PR #94): PROTO_VERSION/PROTO_CONTRACT_VERSION/Proto.CONTRACT_VERSION already
existed and are untouched — shared/message_keys.json regenerates byte-identical (confirmed by
running the generator). This PR is the runtime logic.

Watch side (watchapp/src/c/)

  • ride_link.c sends PROTO_VERSION the instant connection_service reports a live connection
    (both the reconnect edge and the cold-start-already-connected case), and decodes it back from
    the phone in ride_link_handle_inbox(). The watch sends first — see
    prv_send_protocol_version()'s comment for why: it already owns the only "just connected"
    detection point in this codebase (#11); the companion's own transport (#4) hasn't landed yet, so
    there's no symmetric phone-side trigger to design around yet.
  • state.c/state.h gain a small, pure (no Pebble SDK, host-testable per D34/#68) protocol-version
    block: ride_state_set_local/remote_protocol_version(), ride_state_protocol_mismatch(), and
    getters for display.
  • ride_state_start_pause() is the actual gate — wired into the existing idle→running transition,
    not a parallel one, per the issue's explicit instruction. A known mismatch makes that edge a
    no-op (RIDE_TRANSITION_NONE, same as every other no-op). Scoped to starting a ride only:
    pause/resume of a ride already running is unaffected even if a mismatch is discovered mid-ride.
    No degraded mode anywhere.
  • carousel.c makes the refusal visible and distinguishable from the older "already stopped"
    no-op: a new CONFIRM_PROTOCOL_MISMATCH notice shows both versions and names the stale side
    ("Watch v1 / Phone v2 / Phone is old"), dismissed by either button, plus a distinct 3-pulse
    vibration so a refused Select doesn't read as a dead button.
  • watchapp/tests/test_state.c: 6 new host tests.

No proto.c: the issue named it speculatively, but this codebase's existing split is state.c
(pure) / ride_link.c (SDK-facing wire glue), with no third file for AppMessage handling. Extending
ride_link.c keeps that split intact.

Companion side (companion/pebble/)

  • ProtocolHandshake.kt: pure comparison logic (evaluate(watchVersion, phoneVersion) →
    Match/Mismatch(watchVersion, phoneVersion, staleSide)), zero PebbleKit/Android dependency —
    same "pure logic tested directly, SDK-touching half is a thin adapter" split RouteLibrary uses.
    Real logic on the phone side, not just the watch deciding and the phone trusting it. #4 is
    expected to call evaluate() from a real inbound handler once PebbleTransport exists.
  • build.gradle.kts: adds the kotest test trio + testOptions.unitTests.all { useJUnitPlatform() }
    — the first JVM-tested logic in this Android-library module.
  • ProtocolHandshakeTest.kt: 6 Kotest cases.

Docs

docs/PROTOCOL.md section 6 gets a short "Implementation" paragraph pointing at where the code
landed. The policy text itself — including the version-bump rules the issue asks for — already
existed (section 1 rule 6, section 6, and shared/message_keys.json's own
_protocol_version_note), written ahead of time alongside #7/D46; this PR doesn't restate it, just
links to it.

Verified for real

  • pebble build: clean on gabbro, emery and basalt, no new warnings.
  • watchapp/tests/*.c: all four suites hand-compiled per tests/CMakeLists.txt's exact flags and
    run — 102/102 pass, including the 6 new state.c cases.
  • ./gradlew :companion:pebble:testDebugUnitTest :companion:pebble:assembleDebug and
    :companion:core:test and :companion:assembleDebug: all BUILD SUCCESSFUL; the pebble
    module's XML test report confirms all 6 ProtocolHandshakeTest cases actually ran.
  • uv run tools/gen_message_keys.py generate: both generated files reported unchanged.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Implements #53 end to end: the handshake exchange, mismatch detection on both sides, and the refuse-to-start behaviour from the 2026-09-02 "say what a mismatch actually does" update (D46). Builds on #7 (PR #94): `PROTO_VERSION`/`PROTO_CONTRACT_VERSION`/`Proto.CONTRACT_VERSION` already existed and are untouched — `shared/message_keys.json` regenerates byte-identical (confirmed by running the generator). This PR is the runtime logic. ## Watch side (`watchapp/src/c/`) - `ride_link.c` sends `PROTO_VERSION` the instant `connection_service` reports a live connection (both the reconnect edge and the cold-start-already-connected case), and decodes it back from the phone in `ride_link_handle_inbox()`. The watch sends first — see `prv_send_protocol_version()`'s comment for why: it already owns the only "just connected" detection point in this codebase (#11); the companion's own transport (#4) hasn't landed yet, so there's no symmetric phone-side trigger to design around yet. - `state.c`/`state.h` gain a small, pure (no Pebble SDK, host-testable per D34/#68) protocol-version block: `ride_state_set_local/remote_protocol_version()`, `ride_state_protocol_mismatch()`, and getters for display. - `ride_state_start_pause()` is the actual gate — wired into the existing idle→running transition, not a parallel one, per the issue's explicit instruction. A known mismatch makes that edge a no-op (`RIDE_TRANSITION_NONE`, same as every other no-op). Scoped to *starting* a ride only: pause/resume of a ride already running is unaffected even if a mismatch is discovered mid-ride. No degraded mode anywhere. - `carousel.c` makes the refusal visible and distinguishable from the older "already stopped" no-op: a new `CONFIRM_PROTOCOL_MISMATCH` notice shows both versions and names the stale side ("Watch v1 / Phone v2 / Phone is old"), dismissed by either button, plus a distinct 3-pulse vibration so a refused Select doesn't read as a dead button. - `watchapp/tests/test_state.c`: 6 new host tests. No `proto.c`: the issue named it speculatively, but this codebase's existing split is state.c (pure) / ride_link.c (SDK-facing wire glue), with no third file for AppMessage handling. Extending ride_link.c keeps that split intact. ## Companion side (`companion/pebble/`) - `ProtocolHandshake.kt`: pure comparison logic (`evaluate(watchVersion, phoneVersion)` → `Match`/`Mismatch(watchVersion, phoneVersion, staleSide)`), zero PebbleKit/Android dependency — same "pure logic tested directly, SDK-touching half is a thin adapter" split `RouteLibrary` uses. Real logic on the phone side, not just the watch deciding and the phone trusting it. `#4` is expected to call `evaluate()` from a real inbound handler once `PebbleTransport` exists. - `build.gradle.kts`: adds the kotest test trio + `testOptions.unitTests.all { useJUnitPlatform() }` — the first JVM-tested logic in this Android-library module. - `ProtocolHandshakeTest.kt`: 6 Kotest cases. ## Docs `docs/PROTOCOL.md` section 6 gets a short "Implementation" paragraph pointing at where the code landed. The policy text itself — including the version-bump rules the issue asks for — already existed (section 1 rule 6, section 6, and `shared/message_keys.json`'s own `_protocol_version_note`), written ahead of time alongside #7/D46; this PR doesn't restate it, just links to it. ## Verified for real - `pebble build`: clean on gabbro, emery and basalt, no new warnings. - `watchapp/tests/*.c`: all four suites hand-compiled per `tests/CMakeLists.txt`'s exact flags and run — **102/102 pass**, including the 6 new `state.c` cases. - `./gradlew :companion:pebble:testDebugUnitTest :companion:pebble:assembleDebug` and `:companion:core:test` and `:companion:assembleDebug`: all `BUILD SUCCESSFUL`; the pebble module's XML test report confirms all 6 `ProtocolHandshakeTest` cases actually ran. - `uv run tools/gen_message_keys.py generate`: both generated files reported unchanged. Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Protocol version handshake, mismatch detection, refuse-to-start (#53)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
413f183b58
Implements #53's handshake exchange, mismatch detection and refuse-to-start
behaviour on top of #7's PROTO_VERSION/CONTRACT_VERSION constants (PR #94).
Both the version constant and the version-bump-rules policy already existed
in shared/message_keys.json and docs/PROTOCOL.md section 6 (written ahead of
time for D46) — this PR is the actual runtime logic.

Watch side (watchapp/src/c/):
- ride_link.c sends PROTO_VERSION the instant connection_service reports a
  live connection (both the reconnect edge and the cold-start-already-
  connected case), and decodes it back from the phone in
  ride_link_handle_inbox(). See prv_send_protocol_version()'s comment for
  why the watch goes first: it already owns the only "just connected"
  detection point in this codebase (#11); the companion's own transport
  (#4) hasn't landed yet, so there's no symmetric trigger to hang a
  phone-first design off of.
- state.c/state.h gain a small, pure (no Pebble SDK, host-testable per D34)
  protocol-version block: ride_state_set_local/remote_protocol_version(),
  ride_state_protocol_mismatch(), and getters for display. No proto.h
  dependency here on purpose, matching this file's existing split.
- ride_state_start_pause() is the actual gate (D46's "wire into the
  existing idle->running transition, not a parallel gate"): a known
  mismatch makes the idle->running edge a no-op, same RIDE_TRANSITION_NONE
  as every other no-op case. Deliberately scoped to *starting* a ride only —
  pause/resume of a ride already running is unaffected even if a mismatch
  is discovered mid-ride. No degraded mode anywhere.
- carousel.c makes the refusal visible and distinguishable from the older
  "already stopped" no-op: a new CONFIRM_PROTOCOL_MISMATCH notice shows both
  versions and names the stale side ("Watch v1 / Phone v2 / Phone is old"),
  dismissed by either button, plus a 3-pulse vibration pattern distinct from
  start/pause/stop so a refused Select doesn't read as a dead button.
- watchapp/tests/test_state.c: 6 new host tests covering unknown-remote-is-
  not-a-mismatch, matching/mismatched versions, the refusal's
  distinguishability from "already stopped", that pause/resume of a running
  ride survives a mid-ride mismatch, and the version getters.

No proto.c added: the issue named it speculatively, but this codebase's
existing split is state.c (pure) / ride_link.c (SDK-facing wire glue) with
no third file for AppMessage handling — extending ride_link.c keeps that
split intact rather than fragmenting it.

Companion side (companion/pebble/):
- ProtocolHandshake.kt: pure comparison logic (evaluate(watchVersion,
  phoneVersion) -> Match | Mismatch(watchVersion, phoneVersion, staleSide)),
  zero PebbleKit/Android dependency, following the same "pure logic tested
  directly, SDK-touching half is a thin adapter" split RouteLibrary uses
  over RouteRecordStorage. Real logic on the phone side, not just the watch
  deciding and the phone trusting it — satisfies "detected on both sides".
  #4 (Spike A) is expected to call evaluate() from its inbound-message
  handler once PebbleTransport is a real implementation rather than a
  placeholder; nothing here talks to a real watch yet, same as the rest of
  :companion:pebble today.
- build.gradle.kts: adds the kotest testImplementation trio and
  testOptions.unitTests.all { useJUnitPlatform() } — the first JVM-tested
  logic in this Android-library module.
- ProtocolHandshakeTest.kt: 6 Kotest cases covering match, mismatch in both
  directions, the message text, the Proto.CONTRACT_VERSION default, and that
  there is no third "degraded" result.

docs/PROTOCOL.md section 6 gets a short "Implementation" paragraph pointing
at where the code landed; the policy text itself (D46) is untouched.

Verified for real, not just reviewed:
- pebble build: clean on gabbro, emery and basalt, no new warnings.
- watchapp/tests/*.c: all four suites (fields/page/state/page_render_geometry)
  hand-compiled per tests/CMakeLists.txt's flags and run — 102/102 pass,
  including the 6 new state.c cases.
- ./gradlew :companion:pebble:testDebugUnitTest :companion:pebble:assembleDebug
  and :companion:core:test and :companion:assembleDebug: all BUILD
  SUCCESSFUL; the pebble module's test report confirms all 6
  ProtocolHandshakeTest cases actually ran (not skipped).
- uv run tools/gen_message_keys.py generate: proto.h and Proto.kt both
  reported unchanged, confirming shared/message_keys.json needed no edits.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 366c412e43 into main 2026-09-04 17:17:23 +02:00
Sign in to join this conversation.
No description provided.