September 30, 2026
3 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
Clears every residual that a review of a PR merged since v0.27.0 left on main, before the 0.28.0 cut. Each residual was a claim someone verified by hand once and nothing in CI verified afterwards: two release-workflow steps no run has exercised, sentences that described a different order or parser than the code, and branches whose tests passed with the branch deleted.
Twelve commits, one per residual family, each independent of the others:
| Commit | Came from | What changes |
|---|---|---|
ci(release): tell a failing npm view from a staging publish | #464 | The provenance verify poll keeps npm's stderr. Nothing, a warning or an E404 is staging. Any other npm error is printed, and the timeout names it instead of blaming the registry. |
ci: key the iOS Rust caches on the podspec | #475 | cargo does not rebuild Rust crates when IPHONEOS_DEPLOYMENT_TARGET changes. Both iOS caches put the podspec's hash in shared-key. It cannot go in key, which rust-cache ignores when shared-key is set. |
fix(bindings): check the deployment target inside package_xcframework | #474 | The gate moved into the pod's packaging function, which now takes the target. The Swift package's packager still relies on the CI job's own gate step, and the docs say so. The test drives the function with stand-ins for otool, lipo and xcodebuild, replacing line-order greps that missed 4 of 6 breaking edits. |
fix(bindings): say where the Kotlin timestamp parser parts from Instant | #475 | Four sentences claimed parity with Instant. The parser matches Instant for every timestamp a relay sends, and refuses hour 24, an empty fraction, lowercase t/z and a leap second, which Instant accepted. \d became [0-9], pinned by a Rust guard. |
test(bindings): pin a mesh wake whose service does not start | #475 | Robolectric's startService never returns null, so the null branch goes through an internal seam and is now tested. |
ci(bindings): lint the Android library for calls newer than minSdk | #475 | Lint's NewApi check runs alone on the library and fails the build. Generated bindings are excluded: their one newer call sits behind a Class.forName. |
test(leaf): pin the pointer guard's tokenizer | #476 | Four tokenizer behaviours get pins. The module doc names the three routes a one-file scan cannot follow. |
fix(protocol): tell an unreadable sealed record from one that does not open | #468 | A read error was reported as "does not open under this store key" and counted towards WrongStoreKey. The probe skips an unreadable record, so a readable one can still prove the key, and reports the read failure, naming the record, only when nothing opens. The listing warns with the real cause and still skips the record. The warn-once memo forgets a path when it is written or removed. |
test(protocol): open the file stores over a relative root | #468 | A new test binary pins flush_target at its call site. Before this, bypassing it passed every test. |
fix(protocol): a sealed record that opens outranks a pairing-id mismatch | #470 | A mismatch now asks the records, and only one that opens under this MLS store's record key admits the state root. Two tests pin branches that survived mutation. Three sentences said the MLS store is written first; the state store is. |
fix(protocol,mls): check key-type isolation before the state suite deletes | #466 | The FFI-exposed protocol-state suite deleted what it listed before any check ran, so a merging backend lost real records to it. It now checks isolation first and stops on a failure. Both suites gain a probe type related by suffix. Check count and names are unchanged. |
fix(bindings): two orderings in the peer-stream managers | #465 | iOS stop() clears the session before ending links. Android resets the redial delay on a new connection after a disconnect. Both are pinned by a source guard. |
Already closed on main, so not in this PR: the second podspec reader and the gate's error text from #474 (fixed by #475), the rustix comment from #468, and the React Native guide's appId gap (#462). The Android library's missing INTERNET permission is by design and documented in bindings/kotlin/README.md.
Verification
- Every behavioural fix was mutation-checked: each mutant of the fix fails a test. 31 mutants across the gate, the Kotlin parser, the wake seam, the tokenizer, the sealed store, the pairing and both suites; all caught.
- npm verify step: extracted from the YAML and run under a fake
npmfor four scenarios (staging then live, persistent DNS failure, a transient failure then staging, never visible). Each ends with the right annotation. - Android lint: green on the library. A planted
java.timecall fails it, and withoutlint.xmlthe only hit is the generatedCleaner. - Android: 551 unit tests pass in a clean copy. The other 10 are the vector-file lookup that works only inside the repository.
- iOS: the CI bridge typecheck step, run verbatim, is clean, and a planted type error fails it.
- A fresh-context reviewer read the whole diff before the push. It found two blocking issues, both fixed: the cache key first went in
key, which rust-cache ignores, and the docs claimed the gate covered the Swift package's packager. Its five non-blocking findings were fixed too. - Workspace: 3,033 unit tests pass, plus the checklist below.
Not in this PR
The release cut itself, an rc tag, and the two-phone smoke test. The public API of the native packages. The ARM32 UniFFI checksum defect. Dependabot.
Type of change
-
fix: bug fix -
test/ci
Checklist
- Commits follow Conventional Commits
-
cargo fmt --all -- --check -
cargo clippy --workspace --locked -- -D warnings -
cargo test --workspace --lib -
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps - Leaf
no_stdclippy on all four embedded targets - CHANGELOG updated under
[Unreleased]
Follow-up to #473. Its review left three residuals, and they went to main unfixed.
What changes
The source guard matches the class, not a list of shapes. It keeps alloc::sync out of every leaf file but src/shared.rs, because a host always has atomics and a stray import only breaks the targets without compare-and-swap. The old line matcher missed use alloc::{.., sync as s}; and use alloc::{.., sync::*};. It false-alarmed on use std::sync::{Mutex, MutexGuard}; and on a comment. It also skipped any file it could not read.
The guard now tokenizes the source, dropping comments, literals and lifetimes. It flattens every use tree and follows renames of alloc. It reports any path through alloc::sync, including one relative to crate, self or super. It also reports any glob over alloc and any rename of alloc, since a rename made in one file is visible in every other. An unreadable directory or file fails the test, and only src/shared.rs itself is exempt.
The footprint harness builds its store with shared_store. It was the only firmware-shaped code in the repo, and it still used alloc::sync::Arc::new, which the guard cannot see.
Three sentences are corrected.
- The leaf README now says CI covers the first four rows of its target table, not the two Xtensa rows.
- The CLAUDE.md rule now says the leaf reaches
alloc::syncin one file. Four files nameArc. - The
shared.rsmodule doc no longer claims host tests exercise the fallback pointer.
Verification
| Check | Result |
|---|---|
| Guard pinned cases | 27 caught, 14 not reported |
| Mutants of the scanner | 11 of 11 killed |
| Real-file controls: an offending file, an unreadable file | Both fail the test |
| Leaf unit, integration and doc tests | Pass |
cargo clippy --workspace --locked -- -D warnings | Pass |
Leaf clippy with --all-targets | Pass |
| Leaf clippy and build, four bare-metal targets | 8 of 8 pass |
| Harness clippy, three feature sets | Pass |
Leaf rustdoc with -D warnings | Pass |
Footprint on rustc 1.97.1, flash bytes:
| Image | Before | After |
|---|---|---|
| Never-committing leaf, shipping profile | 460,260 | 460,260 |
| rfc_compliant with X.509 | 472,720 | 472,728 |
Both round to the published KiB figures, so no document changes.
A fresh-context review of the first push found three more missed routes: crate-relative paths, cross-file renames of alloc, and two literal forms that threw the tokenizer out of step. The last commit fixes and pins all three.
Not in this PR
These stay open from #473's plan:
- A host run of the leaf tests against the replacement pointer. It needs the integration tests moved to
shared_storefirst. - The upstream mls-rs issue about the portable-atomic backend conflict with esp-hal.
- A CI gate for the esp-hal firmware graph, with a negative control.
What
A native iOS or Android application had no package to install. This adds both, built from the bridge sources the React Native module already compiles, where they are.
| Swift | Android | |
|---|---|---|
| Name | OfflineProtocolSDK | com.offlineprotocol:offline-protocol-android |
| Built by | scripts/assemble-swift-package.sh | the Gradle build in bindings/kotlin |
| Contains | generated bindings, every transport manager, both storage providers, the Rust library | the same |
| Leaves out | OfflineProtocolModule.swift, OfflineProtocolModule.m | OfflineProtocolModule.kt, OfflineProtocolPackage.kt, MeshHeadlessWakeService.kt |
Nothing is published by this PR. There is no release wiring in it. CI builds and tests both packages on every pull request.
Why assembled, and not moved
About fifty Rust guards read the Swift and Kotlin bridge sources by path. Most panic when a file is missing. Four skip it on purpose, and three list a directory and take what is there. A moved source would break the first kind and quietly stop being checked by the rest. ADR 0025 records the decision and what would undo it.
What this changes for the React Native package
Four small things. Everything else is new files.
- The iOS harness shims have their own flag. They were switched on
SWIFT_PACKAGE, which SwiftPM defines for every package. - The consumer rules gain one
-dontwarn. See the first finding below. - Two iOS test files and one Kotlin test file are repaired.
- Two stale comments are corrected.
Found along the way
| Finding | State |
|---|---|
| No Android application that minifies could build a release against the SDK. Tink refers to annotation classes it does not ship, and R8 stops on them. | Fixed here |
| Two iOS mesh controller tests were failing. The suite ran nowhere, so nobody saw. The Kotlin twins were repaired in #120. | Fixed here |
java.time.Instant.parse is called in RelayTimestamps.kt and InternetManager.kt. It needs API 26 and minSdk is 24. The surrounding catch (e: Exception) does not catch the NoClassDefFoundError. | Fixed here: parsed by hand, and a Rust guard refuses java.time in the bridge. Not run on an Android 7 device |
The release build and the Swift package read the podspec's deployment target with two parsers that disagreed on "15", "13." and a second declaration. | Fixed here: one reader. The Swift Package job now runs the release's deployment-target gate |
| An opted-in mesh wake with no wake service declared, which is every native Android application, held "Mesh Active" until the watchdog fired. | Fixed here: the service must resolve, or it stops at once |
| The AAR carried no license or notices, while the Swift package carries four. | Fixed here: packed at the AAR root, and required by the AAR check |
The library's manifest does not declare INTERNET, because the module's does not. A native application must declare it. | Documented, not changed |
| 162 Swift declarations and every non-internal Kotlin one become public API. Nobody chose that surface. | Not decided. Must be before a first release |
Verification
All run locally. The three new CI jobs were extracted from the workflow and run as written.
| Check | Result |
|---|---|
| Swift package on an iOS 26.5 simulator, every suite | 415 tests, 0 failures |
| Swift consumer check, plain import, call into the library | pass |
| Android library unit suite, Gradle 8.9, AGP 8.5.2, Kotlin 1.9.24 | 552 tests, 0 failures |
| Android consumer check, minified release build | pass |
AAR with all four ABIs, --require-natives | pass |
| Existing SwiftPM harness | 403 tests, 0 failures |
| Existing React Native Android harness | 552 tests, 0 failures |
Rust guards in offline-protocol-uniffi | 173 pass |
| Assemble script fixture test, under BSD awk, gawk and mawk | 120 assertions |
| AAR check unit tests | 38 tests |
Negative controls, each confirmed red and then restored:
- A shared Swift source given
import React: the assemble script refuses it by name. - A shared Kotlin source given a React import: the library does not compile.
- The wake service renamed in the module's manifest: the manifest task fails.
- A React-only file renamed without updating the list: both builds refuse.
- The harness manifest without its flag: the harness does not compile.
- The library without the new R8 rule: the consumer check fails.
Mutation testing, in scratch copies: 24 mutants of the assemble script and 18 of the AAR check. All killed but one of the first, which is equivalent.
Not covered
- Nothing ran on a device. BLE does not exist on a simulator, and no relay was involved.
- The device slice was never linked. CI builds one simulator slice. The Swift sources typecheck for a device.
- Xcode 27 was not available. Everything ran on Xcode 26.5.
- No job on a GitHub runner has run yet. This PR is its first run.
Review
Two fresh-context reviews, one per platform. One blocker and sixteen other findings, all addressed in this version. The ones that changed the design:
- The Swift job would have passed once and failed forever after. It assembled under
target/, and the Rust cache restores that directory as an empty tree. It now writes underbuild/. - The Android library was built by a newer Kotlin than its tests. No application on Kotlin 1.9 or 2.0 could have used it. It is now built by the toolchain the module's tests run on, read from the harness, and published by Gradle's own plugin.
- Versions were read from the first textual match, comments included. They are now read from a line that declares them, exactly once.
- JNA was published at runtime scope while generated public types extend JNA's. It is now
api.