fix(mobile): accept the confirmation-time notification re-render, and let an unresolved start be cleared - #512
Merged
Merged
Conversation
… let an unresolved start be cleared
A drill started from the phone reached the server, created the event and sent
its notifications, and then the app refused the response and said it could not
determine the outcome. The device was left unable to start or join anything
afterwards. Two separate defects combined to produce that.
The first is the activation match check. `buildNotification` re-renders the
notification copy at confirmation on purpose: the message that actually goes
out carries the real start time, the person confirming, and the school and
response names as they stand at that moment, rather than the values the
preview guessed. The server documents this and then replaces `renderedMessage`
on every channel of the intent. `activationMatchesPreview` nonetheless
compared the intent's channels to the preview's with `structurallyEqual`, a
byte-for-byte deep equality. `{{startTime}}` is rendered at minute
granularity, so the two agreed whenever the preview and the confirmation fell
in the same clock minute and disagreed whenever they straddled one. A correct
server was then read as one that had returned the wrong event, and a start
that had already notified staff was reported as unresolved.
The check now compares what the operator actually authorized: the channel set
and its order, the endpoint count per channel, the sending integration, and
the classification marker that separates a drill from a real incident. Those
must still match exactly; the wording is allowed to move.
The second is that an unknown outcome was an absorbing state.
`acknowledge` clears only a `succeeded` or `failed` record, every other
`clearDurableRecordOrBlock` path carries the same guard, sign-out removes the
session vault but not the start-mutation record, and the unresolved screen
offered nothing but a read-only check. `resolveActivationFromFreshEvents` used
to promote an ambiguous activation from the active-event list and was removed,
correctly, because that list carries no request-specific idempotency
correlation. Removing the machine's inference left no exit at all: one
ambiguous request disabled every later start and join on the device, and the
record survives restarts in the keychain. Being unable to raise a new
emergency is a worse failure than an unproven old one, so the decision returns
to the operator: `acknowledgeUnresolved` clears the record on an explicit
press of "Clear and allow new decisions", while the copy continues to make no
claim about whether the earlier request succeeded or failed. The inference
stays removed.
The activation fixture built the intent as `channels: preview.channels`, so
the strictest condition in the client compared the preview to itself and could
never fail. It now models the confirmation-time re-render, which reproduces
the original rejection when the old comparison is restored. New coverage: a
re-rendered message is accepted; a changed classification marker, endpoint
count, integration, dropped channel or reordered channel set is still refused;
an unresolved fence clears on acknowledgement and admits the next start;
the clear is refused for another owner and once already idle, and is disabled
while offline.
Gate: Prettier, ESLint and typecheck clean across the repository; 6/6 test
shards green and 61 mobile native tests pass.
Review: #512 — accept confirmation-time re-render, allow clearing an unresolved startClean diff. No IMPORTANT findings. This is a well-scoped fix with strong test coverage for both defects it addresses. What I checked
Nits (0)None worth raising. |
This was referenced Sep 16, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What broke
A drill started from the phone at 18:54 PDT on 2026-09-15 reached the server, created the event, and sent three push notifications. The app then refused the response, reported that it could not determine the outcome, and left the device unable to start or join anything afterwards.
Server evidence:
start-event"outcome":"success"at2026-09-16T01:54:05.930Z, requeste11a614b, followed bypush-worker-message-completed count 3. No server error. The failure was entirely on the client's side of a successful exchange.Root cause
buildNotificationre-renders notification copy at confirmation on purpose — the sent message carries the real start time, the confirming person, and current school and response names rather than the preview's guesses. The source says so atevents.ts:512, then replacesrenderedMessageon every channel of the intent.activationMatchesPreviewcompared the intent's channels to the preview's withstructurallyEqual— byte-for-byte deep equality. The preview renders with its owncreatedAt(start-preview.ts:214); the activation renders with the real activation time.{{startTime}}formats at minute granularity (START_TIME_FORMATTER, no seconds), so the two agree whenever preview and confirmation fall in the same clock minute and disagree whenever they straddle one. The longer an operator spends on the confirmation screen, the more likely the start is rejected — the opposite of what an emergency tool should do. The same applies to the other values the comment names: the confirming person's name, and school or response names changed inside the preview's fifteen-minute window.Both the confirmation-time re-render and the
{{startTime}}token entered in34432c58(2026-09-09), which reached production in the 2026-09-09 deploys. The mechanism is proven and reproduced in test; which of those values differed in this particular run is not individually confirmed, and the fix covers all of them.Second defect: the unknown outcome was absorbing
Once latched, nothing could clear it.
acknowledgeaccepts onlysucceededorfailed; every otherclearDurableRecordOrBlockpath carries the same guard; sign-out clears the session vault but not the start-mutation record, which lives in the keychain and survives restarts. The screen offered only a read-only check.resolveActivationFromFreshEventspreviously promoted an ambiguous activation from the active-event list and was removed — correctly, since that list carries no request-specific idempotency correlation. But removing the inference left no exit, so one ambiguous request disabled every later start and join on the device.The inference stays removed; the decision returns to the operator via an explicit Clear and allow new decisions control. The copy still makes no claim about whether the earlier request succeeded or failed.
The test that should have caught this
The activation fixture built the intent as
channels: preview.channels— comparing the preview to itself. The strictest condition in the client was never exercised. It now models the confirmation re-render, and restoring the old comparison reproduces the original rejection.Coverage added
Gate
Prettier, ESLint, and typecheck clean across the repository. 6/6 test shards green; 61 mobile native tests pass. Database-backed suite not run in this worktree.
Platform parity
Client-only logic and shared React Native UI — no platform-specific code. Needs verifying on iOS and Android before release.