October 2, 2026
3 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
, closes #471, closes #472. One commit per issue, each independently revertable.
#368: Python relay-answer prefixes
0.27.0 (#453) already corrected the prefix names and attribution at each call site. What was left is the issue's third item: Python held no copy of the relay-answer list and no pin.
relay_answer_prefixes.pyholds the six prefixes._inject_group_framepasses every actor throughattributable_actor, so an answer reaches the core with no transport identity whatever the call site passes. This matches the central rule the Swift and Kotlin bridges already have.test_relay_answer_prefixes.pypins the six literals. A new injection test proves an attributed answer is still stripped.- Every doc that counted three copies now counts four:
prefixes.rs, the control-messages chapter, ADR 0004, C5, K7, S7, and the bridge headers. New contract P12 indocs/bridges/python.md.
Deployed apps: no behaviour change for a correct caller.
#471: DiagnosticEvent.level
- The union is now
'debug' | 'info' | 'warning' | 'error'intypes.tsand the RN README. Both platforms emitdebug, at about 70 Swift and Kotlin sites. - The scan also found that Android's
PeripheralGattServeremitted four diagnostics aswarn, a spelling nothing declared. They arewarningnow. react_native_diagnostic_levels_match_every_bridgereads the union and every level literal handed to a diagnostic sink in Swift, Kotlin and Python. It fails in both directions. It has floors on files and literals, so a scan that stops matching cannot pass on nothing.
Deployed apps: the type now matches what already arrives at runtime, but this is not automatically source-compatible. An exhaustive switch or a Record keyed by the level needs a debug entry. A filter comparing against 'warn' for the four GATT diagnostics should compare against 'warning'.
#472: setInterest before start() on React Native
The engine's start-up exchange offers every held space with the interest in force at that moment, and narrowing never deletes what a wider exchange pulled in. React Native's start() opens storage and starts the engine in one call. So the documented "before start()" was rejected natively, and "after start()" let the first exchange ask for everything.
DataStore.setInterestholds a declaration made beforestart().start()applies it after MLS initialization and before the native start. After that, calls go straight to native.- A refused pattern rejects
start()with an error naming the space and carrying the nativecode. The engine is not started, because starting would replicate the space whole. - When the data layer is off, the held call is dropped with a warning and
start()proceeds. This is checked with a probe, because the engine validates the space name before it checks the layer. destroy()discards the hold. It does so only for an instance that created an engine, since the state is module-wide.- Both native
destroy()paths now release the data store. It holds a strong reference to the engine, so data calls afterdestroy()reached the stopped engine instead of rejecting, and on Android the engine stayed alive until garbage collection. - Docs updated:
data.md, the API reference, the RN integration guide, UPGRADING section 23, and a C7 row. A note indocs/bridges/python.mdsays thatProtocolManagerstill runs its first exchange under the default interest. That follow-up is not fixed here.
Deployed apps: an invalid pattern declared before start() used to reject that call and now rejects start(). A call after start() is unchanged. An app that worked around 0.27.0 by calling after start() can move the call back.
Validation
cargo clippy --workspace -- -D warnings,cargo test --workspace --lib,RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps,cargo fmt --all -- --check: all clean.- Python: the full suite passes (808 passed, 4 skipped).
npm run test:js: all 11 harness files pass. The newdata-interest-order.test.jshas 12 cases.- iOS: the CI
swiftc -typecheckstep, which includesOfflineProtocolModule.swift, passes locally. A negative control proved the run was not vacuous. - Android: the module compiles and 556 unit tests pass in a clean copy. The 10
PeerStreamFramingTestfailures there come from the copy living outside the repository, where the test cannot find its vector file. - Mutation-tested: each literal pin, the central attribution rule, the level guard in three directions, the start ordering (JS harness and Rust guard), the destroy reset and its scoping, the array copy, and the layer-off branch and probe. Every mutant fails a test.
- Not run: no device or simulator run of the start ordering or the native destroy change.
What this does
mls_mark_key_package_synced deleted a key package's record and left its private init key in the MLS provider. Keeping the key was right, because a Welcome built from the uploaded copy has to open. Deleting the record was not, because the record is the only thing that carries expiry. A published package nobody claimed kept its key for the life of the install (#367).
- Marking keeps the record and sets
syncedon it. Nothing has ever set that field, which has been on the bundle since the first MLS commit. The package now expires, and its key is destroyed past the 7-day grace window like any other. - A synced package is never handed out. The push path and the peer-less getter both skip it, through the same predicate that already skips a Nostr slot package. A stranger may hold it, and pointing a peer at it too is the shared-key failure ADR 0012 exists to prevent.
- The pending list holds only packages the app may publish: unclaimed, unreserved and unsynced. It used to return every live package, so an app following the documented upload loop would have marked the engine's own slot and push packages synced too.
- Marking an unknown, expired or consumed id does nothing. Marking a package the push path already gave a peer is allowed and logged.
- A source guard refuses any new record-only delete of a key package outside the loader and the purge.
The second commit fixes a separate bug on the same methods. The React Native wrappers read createdAt and isSynced, but both native bridges send createdAtMs, expiresAtMs and synced. JavaScript has always received undefined for both fields. The three key package wrappers now share one mapper, and MlsKeyPackage gains expiresAt.
ADR 0012's gap section, the session lifecycle state machine, the MLS integration guide, the React Native method table and the changelog are updated.
Verification
cargo clippy --workspace -- -D warnings,cargo test --workspace --lib,cargo fmt --all -- --checkand rustdoc under-D warningspass. Doctests were not run locally.- The React Native typecheck and
npm run test:jspass, including the newkey-package-mapping.test.js. - Mutation: four mutants of the Rust fix and one of the old TypeScript mapping were applied in turn. Each fails at least one new test.
- The UDL is unchanged, so no bindings were regenerated and the local API guard is untouched.
- No device run. The change is in the MLS crate and the TypeScript layer, and the native bridges are unchanged.
Not in this PR
- The website docs repository's
mesh-sdk/methods.mdxstill describes the old upload loop. - The push path can claim a package between an app's list call and its mark. The window is one discovery event wide, it predates this issue, and the outcome is the logged shared-package case above.
Closes #367.
What this does
#496 fixed what #315 reported: the four wheels were all py3-none-any, and the Linux floor is now read from the library. This PR closes the rest of that class. Every claim in a wheel's platform tag is now checked against the library inside it.
- The glibc floor counts glibc's ABI markers. These are version needs that no symbol carries, so the old
--dyn-symsgrep could not see them. A library linked with packed relative relocations needsGLIBC_ABI_DT_RELR, which is glibc 2.36 whatever its symbols say.GLIBC_ABI_GNU2_TLSandGLIBC_ABI_DT_X86_64_PLTmean 2.42. The numbers match auditwheel's manylinux policies. A marker the script does not know is refused rather than ignored, and so isGLIBC_PRIVATE. - The macOS tag is checked against the library. 14.0 stays the chosen floor: it is the oldest arm64 runner the suite runs on. A library whose minimum macOS is newer than that is refused. So is a universal binary or one built for iOS.
- The architecture is read from each library's header (ELF, Mach-O, PE) instead of trusted from the matrix row.
None of these markers appear in today's libraries, so the 0.27.0 tags were right. The danger is a toolchain or runner-image change: the tag would go wrong while CI stayed green, because the release runner's pip is newer than the claim.
Verification
scripts/tests/test-build-wheel.shpasses 28 checks in anubuntu:24.04amd64 container. They include a real DT_RELR library, taggedmanylinux_2_36. Canned version listings cover the markers no toolchain emits on demand. Hand-built Mach-O and PE headers let the macOS and Windows checks run on the Linux runner.- Mutation: each of the 14 new guards was removed in turn, and each removal fails a test.
- Rebuilding from the 0.27.0 dry run's real libraries (run 36853692220) gives the same four file names, so the packager and publish jobs see no change.
- On arm64 Linux the DT_RELR fixture skips: binutils 2.42 ignores
-z pack-relative-relocson aarch64 until 2.43. It runs on the x86_64Release Packagingjob. - Not run yet: a
workflow_dispatchdry run from this branch withversion=0.27.0.
Not in this PR
The 2.34 glibc floor itself. It excludes Ubuntu 20.04, Debian 11 and RHEL 8. Lowering it means building the Linux libraries in a manylinux_2_28 container or with zigbuild, which is a separate decision.
Closes #315.