Skip to content

fix(ble): offline routing by device id, one GATT operation per link, owed frames reached for - #1030

Merged
cryptskii merged 10 commits into
mainfrom
fix/ble-offline-routing-and-redelivery
Sep 27, 2026
Merged

cryptskii merged 10 commits into
mainfrom
fix/ble-offline-routing-and-redelivery

Conversation

@cryptskii

@cryptskii cryptskii commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

The four-device run of 2026-09-26 showed A54's offline prepare for 5GN written to 8XK (the sole-ready-peer fallback), the first chunk colliding with the pairing write-back ("prior command is not finished" ×5), the outbox dropping it, and nothing re-driving the owed step (392XXK8ZMME2E, Prepared since). Owner rulings applied: BLE is transport only; a lost link is liveness; a misaddressed message is meaningless bytes; the SDK's owed frames are the only redelivery authority (outbox deleted).

Commits

  1. fix(ble): one GATT operation in flight per link (GattOperationQueue covers the MTU request, both CCCD writes, identity read, pairing writes, message chunks and chunk ACKs); a send reports what the stack did (writeMessage, no hard-coded true); BleOutbox/BleOutboxRepository deleted.
  2. fix(ble): the single-peer fallback in resolveSession deleted.
  3. fix(ble): sends keyed by the counterparty's device id, with the address only as a hint. A client link counts as a route once the identity read on it is anchored. A reach tries the hint first, then scans past other appliances, and a non-target's identity read records nothing (gatt_identity::identity_read_verdict). Pairing is decided by the contact's device id. Replies go back on the link they answer. bleNotifyLink(deviceId, …) replaces the address-keyed link event, and owed_frame_driver reaches for counterparties whose owed frames were never delivered.
  4. fix(offline): Core decide_prepare decides NotAddressed before anything else, and the receiver reads, decides and only then writes. A stale proposal's claimed tip is kept only when the commitment recomputes on it.
  5. fix(ble): identity-read result extractors answer empty (not null) for absent fields. This fixes an NPE seen on A54.
  6. fix(frontend): an incoming offline step awaiting the user is shown again after a restart. Without this, no screen offered it.
  7. fix(ble): a finished reach's leftover candidate stays excluded (its late identity read is judged against the reach's appliance).
  8. fix(frontend): the resurfaced incoming dialog waits until the intro is over.
  9. fix(ble): the BLE pairing frames (identity write-back, accept, confirm) are addressed envelopes the peer's strict decoder takes (C2; 9FF refused the old ones on hardware).
  10. fix(ble): an MTU the link never reported is no longer assumed to be 517.

Docs: CONFORMANCE_GAPS §6.34; VERIFICATION_MATRIX rows for the addressing, claimed-tip, identity-verdict and delivery gates.

Verification so far

  • make lint clean. cargo ndk … check and CI's Android-target test build (test --no-run --release) clean.
  • Targeted release tests: dsm_sdk 45/0, dsm bilateral::offline 10/0, offline step tests and handler 35/0. Android bridge unit tests 236/0. Frontend dialog suites pass.
  • Mutation controls: every one of the queue, routing, identity-verdict, driver, addressing, claimed-tip and resurfacing gates went red on a named test when removed, and was restored.
  • Not run locally: the full workspace board. CI is the board here.

Proven on real Android devices (A54 + 3×A16, GCP beta fleet)

  • A54's driver drained the stuck step 392XXK8ZMME2E: it reached 5GN by identity and wrote 255 chunks with 0 refusals. 5GN stored the step for its user.

  • 8XK → 9FF offline, with 5GN running nearby: 8XK had no stored address for 9FF, so the reach scanned, identified 9FF and wrote the 255-chunk prepare. 9FF's user accepted, and its 254-chunk response came back over the same link. 5GN received no frames.

  • 8XK's confirm was refused, fail-closed, because no anchor appliance was attached. The step stays owed on both phones.

  • Second run (A54 with a Pico attached, 5GN, with 8XK and 9FF running nearby). A54's owed-frame driver redelivered 392XXK8ZMME2E on each restart. Its reach identified 8XK and 9FF, excluded both, and reached 5GN. After a restart, 5GN offered the waiting step again. I accepted it, and the response reached A54 after one server-side retry.

  • The confirm failed again because the Pico does not answer: with a bench build carrying the anchor factory, PicoSelfTestActivity reports "H2 FAIL: no real chip response".

Open

  • The confirm → commit leg, once the Pico answers.
  • C2, second half: a contact's address is persisted on every identity observation, not only on confirm (recorded in §6.34).
  • C3: the chunk budget against the link's MTU is the owner's call (two options recorded in §6.34).
  • The 34 s server-notification response leg, which this PR does not touch.

…the stack did

Android allows one outstanding GATT operation per client connection. The
client serialized only its TX_REQUEST writes and the identity read, so the
identity write-back and the first message chunk collided on the device run:
"writePairingData: wrote 95 bytes", then "writeCharacteristic() - prior
command is not finished" five times, and the outbox dropped the item after
five attempts while sendOffline still answered OK.

- GattOperationQueue: every operation on a link (the MTU request, both
  CCCD writes including the TX_RESPONSE re-subscribe, the identity read,
  the pairing write-back and confirm, message chunks and chunk ACKs) goes
  through one queue. Only the operation's own callback, or the link ending,
  clears the in-flight slot. A start the stack refuses is reported as
  refused; the writeDescriptor and writeCharacteristic returns are honoured
  (the CCCD writes ignored theirs).
- GattClientSession.sendMessage takes a message's chunks at once, keeps them
  contiguous, and completes true only when the stack took every chunk. The
  TX_REQUEST write type is set explicitly (write without response, as the
  characteristic's default already was).
- BleCoordinator.writeMessage replaces sendTransactionRequest, which returned
  a hard-coded true. The UnifiedBleBridge send paths use it.
- BleOutbox and BleOutboxRepository are deleted (owner, 2026-09-27): a
  second, durable, address-keyed store that retried by attempt count and
  held one appliance's prepare under another's address. The SDK's owed
  frames are the only redelivery authority. The TransactionWriteCompleted
  sequencing, currentTransaction, the dead pending-confirm stash (always
  cleared by clearClientState before it could be retried),
  requestGattWrite and retryLastBleTransaction go with it.
- A message write that fails no longer reports ErrorOccurred, so it no
  longer tears down the session as "stale".
- Status-133 retries are bounded: cleanup() reset the retry count on every
  retry, so the bound was never reached.

Tests: GattOperationQueueTest runs the queue against a fake stack that
refuses a second outstanding operation; a pairing write followed by a
message meets zero refusals. Mutations (in-flight check removed; any
callback clears the slot) each turned a named test red and were restored.
resolveSession's step 4 routed a send for an unknown or stale address to
the sole ready session, whoever that was. On the device run A54's prepare
for 5GN went to 8XK: "resolveSession: single-peer fallback
2C:DA:46:4B:73:FA → 48:4F:B6:BA:D4:02", one second after 48:4F had been
anchored to 8XK's identity.

No identity match is "no route" — a liveness state; the frame stays owed
until the addressed appliance is reached. The two tests that pinned the
fallback now assert null, and a new one puts one ready peer anchored to B
against a send addressed to A.

Mutation: re-adding a sole-ready-peer route turned
resolveSession_neverRoutesATargetToAReadyPeerAnchoredToAnotherIdentity red;
restored.
…ce its identity is anchored; owed frames are reached for

The device run routed A54's prepare for 5GN by address. A stale address fell
through to "the sole ready peer" (deleted in the previous commit), a link
counted as ready at raw GATT Connected, pairing was decided by the rotating
address, the scan stopped at the first DSM advertiser, and nothing re-drove
the owed prepare: step 392XXK8ZMME2E has sat Prepared since.

Routing (Kotlin + JNI, a wire change on the JNI surface):
- send_ble_chunks_via_unified takes the 32-byte device id and an address
  hint; Unified.requestGattWriteChunks(deviceId, hint, chunks). All callers
  pass the counterparty: sendOffline, accept, reject, cancel, owed-frame
  delivery. The address-required refusals are gone — the address is a hint.
- BleCoordinator.resolveRoute(deviceId, hint): our client link anchored to
  the appliance, its server link anchored to it, or an unanchored server
  link at the hint — never a link anchored to another appliance.
- A client link is a route only once the identity read on it is anchored,
  after the CCCD chain (PeerSession.clientRouteReady); connect completes
  then, not at MtuNegotiated.
- reach(deviceId, hint) replaces connectToDevice: the hint first, and a
  scan that connects DSM advertisers one at a time (the hint in its own
  slot beside one scanned candidate) until the identity read on one names
  the appliance. A non-target is disconnected and excluded for the reach.
- Replies (Kotlin follow-ups, the receiver's commit ack) go back on the link
  the frame they answer arrived on, and nothing reconnects for them.
- Deleted: addressIndex, persistedIdentityLookup, hydratePersistedIdentity,
  updatePeerAddress, resolvePeerIdentityForBleAddressBin and its SQLite
  echo recorded as a sighting, get_contact_by_ble_address, the dead
  sendBleChunks export, and the no-op markDeviceAsPaired /
  getPairedDeviceIds (a hard-coded empty list) / isDevicePaired.

Identity (Rust):
- processGattIdentityRead(address, raw, expectedDeviceId) replaces the
  observe/process pair. gatt_identity::identity_read_verdict decides before
  anything is recorded: a reach candidate that is not the expected
  appliance records nothing (no contact address, no pairing session, no
  sighting); otherwise pairing is decided by the contact's device id, not
  the address the link is at.

Redelivery (Rust):
- bleNotifyLink(deviceId, address, up) replaces the address-keyed
  bleNotifyConnectionState: link-up after the identity is anchored delivers
  what is owed to that counterparty; link-down (every close path, the
  server side included, when the link carried an identified appliance)
  marks its frames undelivered again.
- owed_frame_driver: while an owed frame has not been delivered on a live
  link, reach for its counterparty through the same device-id send, paced
  15 s doubling to 5 min. Started when the BLE transport is injected and
  kicked after session restore, an undelivered send and link-down.
  Transport pacing only: it never ends, orders or validates a step.

Tests: resolveRoute (anchored client link, current address, waits for the
identity read on a new link to a known appliance, never a link anchored to
another appliance, unanchored server link only at the hint);
gatt_identity (a reach establishes nothing on another appliance; pairing by
device id); owed_frame_driver (pacing; a delivered frame waits for its link
to end; per frame and per counterparty). Mutations, each red on its named
test and restored: the identity-read gate on client routes; the identity
match; the hint rule's anchor check; the verdict's expected check; pairing
by device id; link-down's reset; delivered frames skipped.
…tes; a claimed tip is kept only when it recomputes

The receiver's door read, decided and wrote in the wrong order for a prepare
that was not its own. On the device run 8XK was handed A54's prepare for 5GN:
it synced the sender's contact, established the relationship, cleared the
§5.4 online gate, and then decided — and a proposal that does not extend its
own tip is answered with a signed rejection and records the sender's claimed
tip. Owner ruling: a misaddressed message is meaningless bytes.

- Core `decide_prepare` takes this device's id; `PrepareClaims.addressed_to`
  carries the request's addressee. A prepare whose request names another
  device, or whose committed transfer pays another, is `NotAddressed`,
  decided before the signature, the step in flight or the tip.
- `StaleTip.expected_recomputes`: the claimed tip is evidence of the
  sender's tip only when the proposal's commitment is its operation's
  commitment on that tip.
- `handle_prepare_request` reads first (the held tip, the sender's pinned
  contact, the step in flight), lets Core decide, and returns at once on
  NotAddressed; the §5.4 gate, the contact sync, the relationship and the
  manager's tip follow the decision. A stale proposal's claim is recorded
  only when it recomputes.

Tests: Core — a prepare addressed to another device (by the request, by the
transfer) is not addressed here, before the signature; a stale proposal says
whether its claimed tip recomputes. Byte carrier — C, holding A as a
contact, handed A's prepare for B answers nothing and writes nothing (no
session, no tip, no observed tip); B handed A's prepare with its claimed tip
replaced keeps nothing of the claim. Mutations, each red on its named tests
and restored: both address checks dropped; the request's addressee
unchecked; the transfer's payee unchecked; the recompute flag forced false;
the claim kept regardless.

Docs: VERIFICATION_MATRIX rows for the addressing and claim gates, the GATT
identity verdict and the owed-frame deliveries; CONFORMANCE_GAPS §6.34
records this BLE round, with C2, C3, C4 and the device run open.
On A54 (2026-09-27 02:08:52) anchoring a paired peer threw a
NullPointerException right after the route was handed over: the three
BleGattIdentityReadResult extractors answered null both for a field the
result does not carry (a paired peer owes no write-back; a non-target reach
candidate carries no identity) and for a result that does not decode, while
Kotlin declared them non-null. A non-target candidate reached its rejection
only through the exception.

- identityReadResultExtractWriteBack / ExtractPeerDeviceId /
  ExtractPeerGenesisHash return an empty array for a field the result does
  not carry and null only when the result does not decode; their doc
  comments said "empty on decode error" and the code did the opposite.
- The Kotlin declarations are ByteArray?; the coordinator anchors only on a
  32-byte id and genesis and writes back only a non-empty envelope.
…ain after a restart

Found on the device run: 5GN held A54's step 392XXK8ZMME2E as
pending_user_action across an app restart, and nothing on any screen offered
it. The incoming dialog opens only on a PREPARE_RECEIVED event, which does
not outlive a restart, and a prepare delivered again is answered from the
stored step without announcing anything. No screen renders the SDK's pending
list for incoming steps (the pending panel is gone).

Once the wallet is up, BilateralTransferDialog reads bilateral.pending_list
and shows the first incoming step the SDK lists as awaiting this user, in
the same dialog, with the SDK's counterparty, amount and token. A list that
cannot be read is shown as an error, not as nothing waiting.

Tests (BilateralTransferDialog.pendingAfterRestart): shown once the wallet is
up; not read before; an outgoing step, or one past its decision, is not
offered; an unreadable list is said. Mutations: the direction check, the
phase check, the wallet-ready guard and the error report each removed →
red.
On A54 (03:31:12) a reach found 5GN while a second candidate, 9FF, was still
connecting. The reach's cleanup closes leftover candidates on the lifecycle
lane, which runs ahead of the queued identity read, so that read arrived with
no reach active and was processed as an ordinary link: 9FF was anchored and
its address recorded, though the reach had been for 5GN.

A finished reach now records its leftover candidates with the appliance it
was for (abandonedCandidates). takeExpectedIdentity gives an identity read the
active reach's appliance, else that recorded one (taken once), else none. A
late read that does not name it is closed with nothing recorded; the map is
cleared at each new reach.

Test: BleCoordinatorTest.anAbandonedCandidatesIdentityReadExpectsTheReachsAppliance.
Mutation: the recorded candidates not consulted → red.
The resurfaced incoming dialog came up over the boot intro the moment the
wallet was ready, hiding the cutscene (seen on 5GN, 03:23). It is armed only
once the intro is over and the wallet is ready.

Test: App.pendingDialogWaitsForIntro (not armed while the intro shows, armed
past it). Mutation: the intro not consulted → red.
…akes

The three BLE pairing frames a device sends its peer — the scanner's identity
write-back, the advertiser's pairing accept, the scanner's pairing confirm —
were built with build_ble_event_envelope, the headerless local-answer form the
WebView's BLE events use. The peer decodes them in processBleIdentityEnvelope
with the strict addressed decoder, which refuses a frame without headers and
a message id: on hardware, 9FF answered 8XK's write-back with "Envelope.headers
is required" (2026-09-27), so BLE pairing could not complete.

bluetooth::bilateral_envelope::pairing_frame builds them as Envelope v3 from
this device (its id and genesis in the headers, the content-addressed message
id) behind the 0x03 framing byte; ble_events::local_pairing_frame supplies the
local identity and refuses without one. WebView events keep the local-answer
form. requiresBleAck still sees the 0x03 byte, so the server routes them as
before.

Test: a_pairing_frame_is_taken_by_the_peers_strict_decoder (the frame decodes
with its headers and event; the local-answer form is refused). Mutation: the
frame built as a local answer → red. CONFORMANCE_GAPS §6.34 records it; the
address-persistence half of C2 stays open with its reason.
When requestMtu was refused and no onMtuChanged followed, a fallback timer
assumed MTU 517 after 2 s and started the CCCD chain on it: an invented value
the chunk size was then sized against.

The fallback (runnable, flag, MTU_FALLBACK_DELAY_MS and its test) is deleted.
An MTU the link reports on its own — a peer-initiated or automatic exchange —
still starts the chain whenever it arrives; a refused request with no such
report is an ErrorOccurred (mtu_request_refused) and the link does not become
a route. Every link on the rig reports its MTU (517) through the callback.

CONFORMANCE_GAPS §6.34: C3's chunk budget against the link's MTU stays open,
with the two ways it can be closed.
@cryptskii
cryptskii force-pushed the fix/ble-offline-routing-and-redelivery branch from d9d172d to c36c688 Compare September 27, 2026 07:54
@cryptskii
cryptskii merged commit a4ed751 into main Sep 27, 2026
20 of 21 checks passed
cryptskii added a commit that referenced this pull request Sep 27, 2026
#1030 routes an offline send by the counterparty's device id; a known BLE
address is only where the transport looks first, so the send no longer
refuses "no BLE address is known for the counterparty". The test pinned in
#1021 still expected that refusal, and main's Rust board went red on it
(a4ed751, f69f33f: dsm_sdk --lib 1042 passed, 1 failed), which also
stopped the board before dsm_sdk's integration binaries.

The test now pins the routing #1030 ships: with no address the contact is
not refused for want of one; its send goes exactly as far as one to a
contact holding an address, which on a host build is the dispatch.

Verification: dsm_sdk --release send_offline_tests 1/1; every dsm_sdk
integration binary the red board never reached, --no-fail-fast: 11/11 ok.
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