Skip to content

feat(core): develop-only lockdown fields (disable, max_session_seconds, DISABLED)#7

Draft
jamesarich wants to merge 16 commits into
mainfrom
feat/lockdown-develop-proto-fields
Draft

feat(core): develop-only lockdown fields (disable, max_session_seconds, DISABLED)#7
jamesarich wants to merge 16 commits into
mainfrom
feat/lockdown-develop-proto-fields

Conversation

@jamesarich

Copy link
Copy Markdown
Collaborator

Stacked on #6 (feat/android-cutover-prereqs). Do not merge until #6 lands and a stable org.meshtastic:protobufs release ships the develop-only lockdown fields.

#6 added storage-lockdown admin support (AdminApi.lockdown, MeshEvent.LockdownStatusChanged) against the pinned 2.7.25 proto. Three lockdown capabilities exist only in newer proto and are split out here:

  • LockdownAuth.disable
  • LockdownAuth.max_session_seconds
  • LockdownStatus.State.DISABLED

This PR bumps org.meshtastic:protobufsdevelop-SNAPSHOT and restores the tests + KDoc that exercise those three symbols.

Why draft

A moving -SNAPSHOT must not back a tagged/merged release (non-reproducible builds). Merge only once a stable proto release publishes these fields; at that point repin to that version and rebase onto main.

Verification

:core:jvmTest checkKotlinAbi detekt spotlessCheck green against develop-SNAPSHOT (lockdown tests 22/0/0). Public ABI is unchanged from #6 — the new proto fields don't alter the SDK surface.

🤖 Generated with Claude Code

jamesarich and others added 16 commits June 11, 2026 17:36
…id hard cutover

Remote admin (correctness — previously rejected by firmware 2.5+):
- Route remote ADMIN_APP packets over PKC when both nodes have published
  keys (pki_encrypted + target public_key, channel 0), falling back to a
  channel named "admin"; priority defaults to RELIABLE. One choke point
  (prepareOutboundAdminPacket) shared by the RPC, ACK'd-send, and
  fire-and-forget paths.
- Cache session passkeys per node (each node issues its own in every
  admin response) instead of one global slot that cross-contaminated
  concurrent admin sessions and stamped the local passkey onto remote
  targets. The SessionKeyExpired single-shot retry now re-seeds against
  the target node, whose passkey is the one the replay needs.

Inbound coverage:
- Surface MQTT client-proxy, XModem, and FileInfo frames as typed
  MeshEvents instead of dropping them with a ProtocolWarning (outbound
  already works via sendRaw).
- Buffer mesh packets that arrive mid-handshake (drop-oldest at 64,
  observable via PacketsDropped) and flush them through the normal
  packet pipeline at Ready — live traffic interleaved with the config
  drain was silently lost.
- Fast-fail QueueStatus res != 0 as SendFailure.QueueRejected (and fail
  any pending RPC sharing the wire id) instead of waiting out the full
  ACK timer for a packet the firmware already rejected.

Ergonomics:
- sendText(replyId) for threaded replies.
- Document that DeviceStorage.loadNodes() is host-facing (the engine
  reseeds the node DB from the handshake and never calls it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…on Android

Android keeps the ATT MTU at the BLE minimum (23) unless the central
requests otherwise, capping write-without-response payloads at 20 bytes —
nearly every real ToRadio frame (setConfig, sendText) exceeds that, so the
transport was effectively broken on Android (conformance had only ever run
on JVM/macOS, where CoreBluetooth auto-negotiates).

The BleTransport(address) factory now installs a best-effort post-connect
hook that requests MTU 517 and CONNECTION_PRIORITY_HIGH for the 30-second
handshake window before downgrading to Balanced (mirrors the Android
reference client). The hook is internal to the transport and runs once per
link establishment, including auto-reconnect cycles.

Also bumps Kable 0.42.0 -> 0.43.0 to align with Meshtastic-Android.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…e audit

Audited the engine against firmware main (PhoneAPI.cpp, AdminModule.cpp,
MeshService.cpp @ 4e7181c79) and protobufs v2.7.25. Two real defects:

- Stage 1 accumulators now replace by key (channel index / config
  section) instead of appending: a want_config_id retry restarts the
  firmware's config drain from scratch (PhoneAPI handleStartConfig
  resets its read index), which duplicated channels and config sections
  in the committed ConfigBundle and channels state.
- FromRadio.lockdown_status (protobufs 2.7.25+) fell through every
  routing arm and was silently dropped, violating the no-silent-loss
  rule. It now surfaces as a structured ProtocolWarning until typed
  lockdown support lands alongside AdminMessage.lockdown_auth.

Audit confirmations baked into tests/comments: firmware forces from=0
on all phone packets (MeshService.cpp:188), so local admin authorizes
via the from==0 branch and never needs a passkey; every phone send gets
a per-packet QueueStatus(res, mesh_packet_id) from sendToMesh, so the
QueueRejected fast-fail is live; the 69420/69421 special nonces are the
firmware-sanctioned config-only/nodes-only drains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Kotlin library best-practices pass:

- Remove RadioClient's AutoCloseable/close() — the runBlocking bridge
  was an ANR trap on Android main and a deadlock risk on iOS main, and
  a radio session has no safe non-suspending teardown. Lifecycle is
  suspend-only: try { ... } finally { client.disconnect() }. Docs
  (threading-model.md, RadioClient KDoc) updated to match.
- Bridge the two byte-string vocabularies in the public surface:
  okio.ByteString (Wire proto types, per ADR-001) <->
  kotlinx.io.bytestring.ByteString (Frame/SessionPasskey) via
  toKotlinxByteString()/toOkioByteString(), so consumers never
  round-trip raw arrays.
- Codify the API conventions in docs/api-reference.md: proto exposure,
  byte vocabularies, no blocking bridges, the data-class trade
  (deliberate, ABI-dump-gated; copy()/destructuring are conveniences
  not contract), and the Kotlin/Swift-first interop stance.
- Track Poko adoption for event/result types in the roadmap: 0.23.1
  fails against Kotlin 2.3.21 (NoClassDefFoundError:
  ExtensionPointDescriptor), so @poko migration waits on upstream.

ABI dumps regenerated (close()/AutoCloseable removal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…s the API

Wire-generated proto types force okio.ByteString into every proto-typed
call for both this SDK and its consumers (Meshtastic-Android consumes
the same protobufs artifact), so the kotlinx-io ByteString on Frame /
SessionPasskey was a gratuitous second vocabulary. Standardize:

- Frame.bytes and SessionPasskey.bytes are okio.ByteString.
- send(portnum, payload: kotlinx.io.Buffer) is replaced by
  send(portnum, payload: okio.ByteString) — the type proto payloads
  already arrive in, so payloads pass through without array copies.
- Internal frame assembly (WireCodec.FrameDecoder, TcpTransport,
  SerialFrameAssembler) moves to okio.Buffer.
- kotlinx-io is dropped from every published module (and the version
  catalog); okio is promoted to api in :core since it now appears in
  core's own public signatures.
- The toKotlinxByteString()/toOkioByteString() adapters added earlier
  this cycle are deleted — nothing left to bridge.

Consumers (and the app cutover) now use exactly one byte type
end-to-end: proto payloads, frames, and storage. ABI dumps regenerated;
docs/api-reference.md API-conventions section updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…ode, leaner accumulators

Four-angle cleanup review (reuse / simplification / efficiency / altitude)
over the branch diff, fixes applied:

- testing: add FromRadio.toFrame() — the device-side wire framing that 8
  test files (and FakeRadioTransport) each re-implemented now lives in
  one helper; the local copies delegate to it.
- engine: decode AdminMessage once per inbound ADMIN_APP packet — the
  passkey latch and external-config-change detection each ran their own
  decode of the same payload on a hot path.
- engine: pendingConfigs/pendingModuleConfigs become immutable-list vars
  reassigned via mergeConfigs/mergeModuleConfigs, dropping the
  clear()+addAll() churn per config frame during the handshake.
- engine: drain handshakePacketBuffer in place instead of copying the
  backlog to a list at Ready.
- transport-ble: source START1/START2 from WireFraming (TCP and serial
  already did).

Reviewed and deliberately NOT changed: prepareOutboundAdminPacket's
decode+re-encode passkey stamp (binary-patching Wire payloads is fragile;
remote admin is low-frequency and the single engine choke point is worth
keeping); postConnectHook as an internal var (constructor plumbing isn't
worth it for one platform hook); the queueStatus arm's bookkeeping
(pre-transmit rejection is semantically distinct from routing NAKs);
moving ScriptedTransport to :testing (900-line virtual-time fixture —
tracked as follow-up, too churny for this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
… factory, one decoder family

API-shape review for consumer convenience:

- RadioClient.withConnection { } — connect, run the block, always
  disconnect (success, exception, AND cancellation via NonCancellable
  teardown). The structured-concurrency replacement for the blocking
  use{} idiom this API deliberately dropped.
- Flow<NodeChange>.asNodeMap() + RadioClient.nodeMap() — folds the node
  delta stream into a live Map<NodeId, NodeInfo>. This is the exact
  scan accumulator every consumer (including the Android migration
  guide) was hand-writing; now it ships with stateIn-ready semantics.
- RadioClient { } builder-lambda factory as Kotlin-idiomatic sugar over
  Builder (which stays, for Swift and step-wise construction).
- Removed the duplicate typed-decoder family (decodeAsText/Position/
  User/NodeInfo/Telemetry/Routing/Admin) that shadowed the asText()/
  asPosition()/… accessors in PayloadAccessors.kt — one accessor family
  remains, plus the generic decodeAs(adapter) escape hatch for portnums
  without a typed accessor (Paxcount, StoreAndForward, …).

ABI dumps refreshed; new RadioClientSugarTest covers all three sugars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…e admin, ERRNO queue results

Findings from a four-angle adversarial review of this branch
(correctness / concurrency / API surface / tests), all fixed:

HIGH — nodes flow late-subscriber contract (new in this branch):
  Every subscription now receives a fresh NodeChange.Snapshot first,
  seeded via onSubscription from the engine's current node map; the
  engine SharedFlow drops its replay slot. Previously a late subscriber
  got whatever DELTA was emitted last instead of the Snapshot, leaving
  nodeMap()-style folds near-empty until the next reconnect.

HIGH — off-actor mutation of sessionPasskeys (regression in this
  branch): the fire-and-forget admin path (enterDfuMode, setTimeOnly)
  ran prepareOutboundAdminPacket — which prunes the actor-owned per-node
  passkey map — on the caller's coroutine. sendAdmin now posts an
  EngineMessage.AdminFireAndForget through the inbox (ADR-002).

Firmware conformance — QueueStatus.res is the ERRNO namespace, not
  Routing.Error: 35 (ERRNO_SHOULD_RELEASE) is success and counts as
  Sent; ERRNO rejections (>= 32) fail pending RPCs as NodeUnreachable
  via the new CommandDispatcher.tryFail; 1..31 map through the normal
  routing taxonomy. Previously 32 read as BAD_REQUEST, 33 as
  NOT_AUTHORIZED, and the success code 35 produced a false failure.

Remote-admin correctness:
  - Passkeys latch only from response-shaped admin messages; a remote
    node administering US no longer poisons our passkey cache with the
    key WE issued it.
  - Managed-mode client gate applies to local targets only (firmware
    only rejects local admin on managed devices; remote targets
    authorize via their own admin keys).
  - Session passkeys are no longer persisted (local ones are never
    required — firmware rewrites phone packets to from=0; remote ones
    expire in ~4 min). Storage methods remain, documented host-facing.

Pre-existing engine defects (surfaced by the same review):
  - dispatchSend guards caller-supplied wire-id collisions at the real
    key — the second send no longer strands the first handle forever.
  - Stage-1 settle replay re-buffers frames behind a duplicate
    completion instead of silently dropping them.
  - The seeding window routes non-packet variants (node_info, client
    notifications, MQTT proxy, ...) instead of silently dropping them.
  - Stage-2 commit snapshots actor-owned collections before its async
    storage flush (CME risk mis-reported as StorageDegraded).

API shape (pre-publication, nothing on Central):
  - MeshEvent.FileInfo -> FileInfoReceived (no longer shadows
    org.meshtastic.proto.FileInfo verbatim).
  - sendText is (text, to, channel, replyId) — aligned with
    sendReaction; all callers were named-arg or text-only.
  - withConnection gains teardownTimeout (default 10s): the
    NonCancellable disconnect is bounded so a wedged transport cannot
    pin a cancelled caller forever.
  - detekt ForbiddenImport now bans kotlinx.io.* (one byte vocabulary).

Tests: 16 new tests pin the riskiest logic — PKC asymmetric-key
fallbacks, caller-built PKC passthrough, caller priority + broadcast
guards, two-remote passkey isolation, passkey TTL pruning, identity-
rebind passkey clearing, queue rejection of in-flight RPCs, ERRNO vs
routing-error mapping, res=35 success, unmapped res, buffer-overflow
PacketsDropped + drop-oldest order, seeding-window buffering, settle-
replay preservation, post-Ready dedupe of flushed packets, seeded
late-subscriber snapshots, withConnection cancellation teardown, and
direct decodeAs escape-hatch coverage. ABI dumps regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…de can't leak across sessions

The Android factory's delayed CONNECTION_PRIORITY downgrade was launched
on the transport-lifetime scope; disconnect() didn't cancel it, and the
transport supports reuse-after-disconnect (the engine's auto-reconnect
path), so a 30-second timer from session N could fire mid-handshake of
session N+1 and downgrade the connection priority during exactly the
boost window it exists to protect.

postConnectHook now receives a per-connect-cycle scope (child of the
transport scope), cancelled in cancelJobs()/disconnect(). New
BleTransportHookTest drives a fake Kable Peripheral through
connect/disconnect/reconnect and proves: the hook runs once per
connect and failures are non-fatal; hook-scheduled work is cancelled
at disconnect; and prior-session work never fires into the next
session (each test fails against the pre-fix code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
The review found the documentation tree contradicting the shipped
surface on every axis this branch changed. Fixed across 16 files:

- CONTRIBUTING/AGENTS/GEMINI/.specify templates: the byte-payload house
  rule inverted to okio.ByteString (kotlinx-io is deliberately not a
  dependency; now also detekt-banned).
- SPEC.md v2.2 -> v2.3: AutoCloseable/close() removed, okio vocabulary,
  send overload, sendText signature, new MeshEvent/SendFailure variants,
  nodes-flow onSubscription seeding, storage passkey methods marked
  host-facing, decision-log reversal entries appended with rationale.
- ADR-003 + ADR-000/001/006: superseded-by notes appended (history
  preserved, not rewritten).
- api-reference.md: Frame signature, new sendText row, RadioClient {}
  factory, withConnection, nodeMap()/asNodeMap(), the three new
  MeshEvent rows, AutoReconnectConfig since-tag corrected to 0.1.0.
- error-taxonomy.md: QueueRejected + the QueueStatus/ERRNO namespace
  section (32/33/34 rejections, 35 = success, 1..31 Routing.Error).
- transport-isolation.md: close() -> disconnect() throughout.
- README/bom README: transport-tcp is Ktor (not kotlinx-io); proto row
  is Wire-generated (not kotlinx.serialization); 233-byte payload limit.
- security.md: storage at-rest guidance references SQLDelight.
- CHANGELOG: restructured per versioning.md with a consolidated
  '### Breaking' section; adds the testing toFrame() entry and all of
  this branch's review fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…rgets

FakePeripheral overrides Peripheral.identifier, which is kotlin.uuid.Uuid
on Kable's iOS targets (experimental API) — the override compiled on
JVM/Android (String typealias) but failed compileTestKotlinIosX64 /
IosSimulatorArm64 in CI's full-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
androidDeviceTest source set running the cs1-cs6 conformance envelope
against a real radio: scan by Meshtastic service UUID (bonded-first,
RSSI-sorted, 3-candidate retry), connect through the production
BleTransport(address) factory (MTU-517 + connection-priority hook),
two-stage handshake, read-only admin RPC round-trips, a 177-byte send
to the radio's own node num as MTU proof (no LoRa TX), SQLDelight
persistence, and same-transport reconnect. Assume-skips when no radio
is advertising so CI never requires hardware.

Run with: ./gradlew :transport-ble:connectedAndroidDeviceTest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Five ship-blocking bugs caught by MeshtasticBleConformanceTest on a
Pixel 6a against live radios (fw 2.7.24), none reachable by JVM/fake
tests:

- transport-ble: TORADIO is CHR_PROPS_WRITE only on real firmware
  (NRF52Bluetooth.cpp:213) — write-without-response is refused by
  Android outright; use WriteType.WithResponse. Docs updated to match.
- engine: seed the wire packet-id counter randomly per instance.
  Starting at 1 every session collided with the firmware's ~10-minute
  packet-history dedup across reconnects, silently swallowing every
  RPC of the new session (Timeout with no response on the air).
  nextMessageId() now also skips 0 ("unset" on the wire).
- engine: publish configBundle/channels synchronously at the Stage-2
  commit, before connect() resumes — visibility was previously gated
  on the async storage flush and lost the race on real disk latency.
- storage-sqldelight (Android): enable WAL via
  enableWriteAheadLogging(); execSQL("PRAGMA journal_mode=WAL")
  returns a result row and throws on re-activation.
- transport-ble: frames() is re-collectable after disconnect()
  (per-cycle frame channel + collector guard reset), honouring the
  documented reuse-after-disconnect contract exercised by cs6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Completes AdminMessage coverage with the hardened-build (MESHTASTIC_LOCKDOWN)
lockdown surface, and migrates the protobuf dependency model + all docs from the
vendored-submodule scheme to the published org.meshtastic:protobufs artifact.

Lockdown:
- AdminApi.lockdown(LockdownAuth): local-only (rejects forNode targeting; the
  firmware consumes the passphrase inline on the phone link), fire-and-forget;
  the device replies with a fresh lockdown_status.
- MeshEvent.LockdownStatusChanged: replaces the prior ProtocolWarning stub;
  handled identically mid- and post-handshake.
- Tests (outbound + inbound), updated CapturingAdminApi fake, regenerated ABI.
- Builds on the pinned 2.7.25 proto; the newer LockdownAuth.disable /
  max_session_seconds and LockdownStatus.State.DISABLED arrive in a follow-up
  once a stable proto release ships them.

Protobufs:
- Stays pinned at 2.7.25. Audit found zero field changes across the 46
  SDK-consumed messages vs develop-SNAPSHOT; the only delta is the device-only
  NodeDatabase restructure, not consumed here.

Docs:
- protocol.md: correct the AdminMessage field numbers and add a lockdown section.
- ADR-015 records the submodule + :proto-module -> published-artifact migration;
  superseded-by notes added to ADR-000/001/003/006/008/013.
- Sweep guidance/architecture docs (AGENTS, GEMINI, CONTRIBUTING, README,
  versioning, ci-cd, module-graph, enforcement, transport-*, migration guides,
  wasm roadmap) to the artifact model; drop the dead git-submodules Renovate
  manager; fix Dokka V1 -> V2 (dokkaGenerate) command references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…_seconds, DISABLED)

Stacked on the 2.7.25 lockdown support. Bumps org.meshtastic:protobufs to
develop-SNAPSHOT to pick up LockdownAuth.max_session_seconds / .disable and
LockdownStatus.State.DISABLED, and restores the tests + KDoc that exercise them.

DRAFT — do not merge until a stable proto release ships these fields; a moving
-SNAPSHOT must not back a tagged release (see CHANGELOG / versioning.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
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