Skip to content

fix(agent): enrollment screen refuses when already enrolled; retry loop re-checks identity - #119

Merged
quadseven merged 1 commit into
mainfrom
fix/enrolled-screen-105
Oct 10, 2026
Merged

quadseven merged 1 commit into
mainfrom
fix/enrolled-screen-105

Conversation

@quadseven

@quadseven quadseven commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Refs #105. Part of #121.

Why

The enrollment screen kept trying after the device was already enrolled, and force-quitting was the only way out. Measured on the second Pixel: it held a valid identity at 22:55:15 and the screen was still presenting at 22:57:52, logging code-used refusals. To the operator that looks like enrollment failed, for an enrollment that had succeeded two minutes earlier. Two defects compose: EnrollActivity never asked whether the device was enrolled, and the retry loop never re-checked.

Summary

 EnrollActivity.onCreate
+  if flow.isEnrolled()
+    say "This device is already enrolled."; disable form; return
 EnrollmentFlow
+  isEnrolled() = store.hasIdentity()
   present(code)
+    if enrolled: return Enrolled        # before the keystore or the network
   collect(requestId)
+    if enrolled: return Enrolled        # a stray poll cannot overwrite it

Back still leaves the screen; nothing traps the operator.

Evidence

  • Before: with both guards removed, anAlreadyEnrolledDeviceIsToldSoInsteadOfPresenting and aCertificateArrivingByAnotherRouteEndsThePoll fail.
    After: the agent JVM harness gives 335 tests passed (MUSTER_JDK pointing at an arm64 JDK 17).

Merge Danger

Door: two-way. Reverting restores the old retry behavior.

Blast Radius: the enrollment screen. A device that already holds an identity can no longer present a code from this screen; re-enrolling it means clearing the app's data. That is what #105 asked for, and re-presenting reuses the same key, so it could only re-join as the same device.

Acceptance criteria

Size: S

Out of scope

Resuming a poll across an activity restart, which is filed separately.

https://claude.ai/code/session_01UkWktkaYXZwdwzUvZQrN9q

…op re-checks identity

The enrollment screen kept trying after the device was already enrolled,
and force-quit was the only way out. Two defects composed into it:

1. `EnrollActivity` never asked whether the device is already enrolled.
   It built a `FileIdentityStore` and never called `hasIdentity()` - so on
   the scanned path, where the presenting activity is torn down and the
   operator opens the app later, the enrollment form was offered to a
   device with nothing to enroll, and the spent code went back on the wire.

2. Neither retry loop re-checked identity. A certificate arriving by any
   route while the screen was open did not end the loop.

`EnrollmentFlow.isEnrolled()` is the new decision point (the Activity stays
thin: it turns taps into calls). `present()` and `collect()` both return
`Enrolled` when the store already holds an identity - checked before the
keystore and before the network, so a stray poll landing after enrollment
cannot overwrite "Enrolled" with "this enrollment is no longer available".
`EnrollActivity` shows "already enrolled" with the form disabled instead of
offering it; the back button still leaves.

Terminal refusals were already terminal in the flow (`code-used` ->
Stopped); the missing piece was the enrolled device ever reaching the
screen, and the loops never noticing it got enrolled underneath them.

Closes #105.
@grug-tribe

grug-tribe Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Walkthrough

This PR resolves muster#105, where an already-enrolled device was being shown the enrollment form and could re-present a spent pairing code. The enrollment flow now checks for an existing identity at three points—when the screen opens, before each present, and before each collect poll—returning Enrolled immediately instead of touching the network or keystore. The EnrollActivity disables (rather than hides) the form and informs the operator, so the screen still reads as the enrollment screen and the back button remains functional. Tests cover both the upfront refusal and the mid-poll exit.

Review effort: moderate (~15 min)

Changed files (3)
File Summary Changes
agent/android/app/src/main/java/app/muster/agent/EnrollActivity.kt Added an isEnrolled guard that disables the form and shows a message when the device is already enrolled. +12/-0
agent/android/app/src/main/java/app/muster/agent/EnrollmentFlow.kt Added isEnrolled() and early Enrolled returns in present() and collect() so an existing identity short-circuits before network or keystore use. +24/-2
agent/android/app/src/test/java/app/muster/agent/EnrollmentFlowTest.kt Added two tests: presenting while enrolled returns Enrolled without network, and a certificate arriving mid-poll exits the collect loop. +36/-0
Shape of the change
graph TD
  subgraph "agent"
    N0["EnrollActivity.kt"]
    N1["EnrollmentFlow.kt"]
    N2["EnrollmentFlowTest.kt"]
  end
Loading

Last walked at 3c0785b60008.

@quadseven
quadseven merged commit 15f545c into main Oct 10, 2026
10 checks passed
@quadseven
quadseven deleted the fix/enrolled-screen-105 branch October 10, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant