Skip to content

Model-B background Network Extension (RNS/LXMF node in the NE) + reticulum-swift main pin#90

Merged
torlando-tech merged 53 commits into
mainfrom
feat/model-b-background-ne
Jun 18, 2026
Merged

Model-B background Network Extension (RNS/LXMF node in the NE) + reticulum-swift main pin#90
torlando-tech merged 53 commits into
mainfrom
feat/model-b-background-ne

Conversation

@torlando-tech

Copy link
Copy Markdown
Owner

Model-B background Network Extension — the RNS/LXMF node moves into the NE

This makes Model B the sole architecture for Columba-iOS: the full Reticulum + LXMF
node runs inside the Network Extension (Sources/ColumbaNetworkExtension/NEReticulumNode.swift),
and the app is a thin SwiftUI client that talks to it over an App-Group + Darwin-notification
seam
. This is what enables background / suspended message delivery on iOS — the node keeps
running and posts notifications even when the app is not foregrounded.

What's in here (43 commits)

NE node core & seam

  • In-NE Reticulum+LXMF node, App-Group bridge NetworkInterface + bidirectional frame queue, shared keychain access-group so app and NE share one identity.
  • GRDB message store relocated to the App-Group container (single shared store; UI reads NE writes cross-process).
  • App↔NE ProxyRnsBackend IPC skeleton; durable App-Group outbox for sends issued while the NE is stopped.
  • ExtensionDiagLog observability with NE-side PII redaction; ext-diag.log stays live for on-device tailing.

Background transport reliability

  • Capped-exponential-backoff reconnect + NWPathMonitor; on-demand connect for jetsam/reboot auto-relaunch.
  • Suspend-safe path-table persistence; network-state pushed to the UI via Darwin (no polling).

RNode-over-BLE (Model B)

  • Radio in the app, RNS in the NE: BLE seam across the NE↔app boundary, survives NE restart, CoreBluetooth state-restoration for background BLE wake. Legacy Model-A python RNode path removed.

LXMF propagation (Model B)

  • App-selected propagation node + sync wired into the NE: config seam, NE-side periodic sync scheduler, Sync-Now Darwin trigger, sync-state channel, and an in-app SyncStatusBottomSheet.
  • Re-wire a manually-selected PN when its announce lands.

UI

  • Drive NE-backed status/BLE UI off Darwin pushes; Lucide icon font + RNode antenna on announce cards.

Dependencies

  • reticulum-swift pinned to main (b366f29) — resource disk-streaming + the full conformance/wire-hardening parity work.
  • LXMF-swift @ feat/lxmfdb-appgroup-sharing, LXST-swift @ feat/transport-agnostic.

Relationship to PR #57

PR #57 (feat/enable-tunnel-flip-flag) is the earlier tunnel approach — it ran interface
bridges in the NE and tunneled packets while the node stayed in the app. This branch
architecturally supersedes it: the node now lives in the NE outright, so the tunnel/packet-bridge
plumbing is replaced rather than extended. The two branches have diverged (neither is an ancestor of
the other) and overlap heavily on the NE files, so they should not both land. A background audit is
in flight to confirm none of #57's background-hardening fixes (background re-announce, NE lifecycle
reliability, suspended-notification scheduling) need porting before #57 is retired; any genuine gaps
will be added to this PR.

Verification

  • On-device smoke (physical iPhone, Model B): direct_echo and propagated_echo both pass — direct/opportunistic and propagation-node send + sync-back round-trips.
  • reticulum-swift main (the new pin) is green on its own conformance + 757-test suite.
  • Merge: origin/main merged in (was 22 behind); conflicts resolved toward main (NE CURRENT_PROJECT_VERSION=2 + team-via-xcconfig; [RNS]-only interop markers). xcodebuild -resolvePackageDependencies clean.

🤖 Generated with Claude Code

torlando-agent Bot and others added 30 commits June 2, 2026 10:52
…rack A3)

Model B's Network Extension must load the SAME RNS identity as the app to sign delivery
proofs / decrypt inbound LXMF while the host is suspended. Add a shared keychain group:
- Both target entitlements declare keychain-access-groups =
  [$(AppIdentifierPrefix)network.columba.Columba.shared].
- Identity.save/load/deleteFromKeychain take an optional accessGroup (kSecAttrAccessGroup);
  existing callers default to nil (behavior unchanged).
- AppServices resolves the group at runtime (team-id prefix probed from the keychain, never
  hardcoded — no deployment PII), threads it through loadOrCreateIdentity, and migrates a
  legacy default-group identity into the shared group on first launch. Accessibility stays
  AfterFirstUnlockThisDeviceOnly (NE-readable while locked; no backup/sync).
- The unencrypted identity.key fallback is neutralized: removed once the keychain holds the
  identity, and otherwise written with CompleteUntilFirstUserAuthentication protection.

Builds Columba-Swift green. Cross-process keychain sharing is entitlement-enforced only on a
signed build — device-verified with the NE wiring (A5/Track C).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…anches

The background-LXMF / Model-B work depends on in-development changes in the two Swift
libraries (resource segmentation + completion fixes in reticulum-swift; cross-process GRDB
store config + durable dedup + .complete-gate in LXMF-swift). Repin both from exactVersion
to the feature branches (reticulum-swift perf/resource-disk-streaming, LXMF-swift
feat/lxmfdb-appgroup-sharing) so this branch builds against them. Reverts to versions once
those land and are released. Columba-Swift builds green against both (API-compatible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… A0)

The UI read the Compat raw-SQLite RNSAPI.LXMFDatabase while the Swift/NE backend writes
the GRDB LXMFSwift.LXMFDatabase (configDir/lxmf-swift.db) — so Swift/NE-delivered messages
never appeared. Repoint MessageRepository to the GRDB store, keeping every public method
returning RNSAPI types via pure static adapters, so the UI/ViewModels are unchanged.

Architecture: `import LXMFSwift` is confined to MessageRepository.swift (the adapter
boundary). AppServices/ColumbaApp/MapView stay RNSAPI-typed and pass only a String path —
they do NOT import LXMFSwift (it transitively exposes ReticulumSwift, whose names collide
with RNSAPI's Compat layer; a prior attempt that imported it into AppServices was reverted).
AppServices routes Python-path inbound-persist + delivery-state through MessageRepository's
RNSAPI-typed methods; the Swift backend's LXMRouter already persists to GRDB itself.

Adapters map ConversationRecord/MessageRecord/IconAppearance/LXMessage + state/method enums
(Date<-Double, enum<-Int, String<-String?); LXMessage bridges via the LxmfFieldCodec
field-map. Tests: MessageRepositoryAdapterTests — TEST SUCCEEDED on iPhone 17 sim; builds green.

KNOWN FOLLOW-UPS (tracked — do not lose):
1. IncomingMessageHandler (block_unknown_senders favorite-check, senderIsFavorite) and
   NotificationService (sender displayName) still read the now-empty Compat `db` — must
   repoint to the GRDB MessageRepository. block_unknown_senders is off-by-default (low
   impact) but notification sender-name + favorite gating degrade until fixed. (Next commit.)
2. NE/Swift-backend rows store the full LXMF wire in packed_lxmf while app-written rows store
   the field-map; LxmfFieldCodec.unpack returns nil on wire bytes, so attachments/icons on
   NE-delivered rows won't render (text/state/timestamp/direction map fine). Needs a
   wire-unpack path in the adapter, or the NE storing the field-map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ue (Track A1)

Model B: the NE is the single RNS node; the app forwards radio (BLE/RNode) frames into the
NE's RNS instance and relays NE-originated frames back out. Add the bridge primitives:
- SharedFrameQueue is now bidirectional: e2a ("frame_queue", NE->app, #57-compatible default)
  + a2e ("frame_queue_a2e", app->NE radio ingest), each with its own backing file + lock.
  FrameInterfaceTag gains .bleMesh/.rnode; radioFrameReadyNotificationName (app->NE) added.
- AppGroupBridgeInterface (actor) conforms to ReticulumSwift.NetworkInterface: send() HDLC-frames
  + enqueues to e2a + posts the Darwin notification; setDelegate() stores a weak delegate;
  deliverInbound() fires didReceivePacket for app->NE frames. mode=.full (announces propagate
  both ways); hwMtu = the radio's negotiated MTU.

Collision-safe: the bridge file imports only ReticulumSwift + Foundation (NOT RNSAPI, whose
Compat layer re-declares NetworkInterface). The NE target doesn't link ReticulumSwift yet, so
the file is in the ColumbaApp target only; A5/C2 add it to the NE once the NE runs the Swift
backend. Registration into the NE transport + the app-side radio relay loop are A5.

Columba-Swift builds green. Runtime (NE using the bridge) is exercised in A5 on-device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Track C3b)

The destination-hash list was a dead Compat.ReticulumTransport stub returning [] — so the
NE's would-be sniff-only filter + local-destinations publisher never matched real registered
destinations. Promote it to the backend-neutral RnsCore protocol so both backends answer
truthfully:
- RnsCore gains `func registeredDestinationHashes() async -> [String]` (lowercase-hex).
- SwiftRNSBackend returns the lxmf.delivery + lxst.telephony Destination hashes it registered
  in start() ([deliveryDestination, telephonyDestination].compactMap { $0?.hexHash }).
- PythonRNSBackend returns the cached delivery hash (localInfo.destinationHash); telephony
  isn't surfaced by the Python bridge's LocalInfo — documented, true subset (delivery is the
  LXMF-inbound hash the NE filter needs).
- AppServices diagnostic dump rewired from the dead Compat stub to backend.core.
- Deleted Compat.ReticulumTransport.registeredDestinationHashes() (grep-confirmed zero refs;
  sibling registeredLinkCallbackHashes() stays — still used).

This is the C3(b) seam the NE full-delivery path (C3c, A5-gated) consumes. Columba-Swift green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion (Track C7)

Lands first per plan: NE diagnostics over WiFi can't use the unified-log relay, so route
them through an App-Group file the host copies out for devicectl retrieval. Unblocks all
on-device NE verification.

- Sources/Shared/ExtensionDiagLog.swift (NEW, Foundation-only -> both targets): append-only
  App-Group ext-diag.log, ISO8601 lines, NSLock-serialized, FileProtectionType
  .completeUntilFirstUserAuthentication (NE writes while locked-after-first-unlock). NO-PII
  contract in the header: envelope/metadata only.
- PacketTunnelProvider: all ~24 NSLog("[EXT]...") migrated to ExtensionDiagLog.log (0 NSLog
  remain). Redacted the C5-flagged PII: relay host:port at the old :139/:492 -> "TCP relay
  config" (host/port dropped); defense-in-depth on NWConnection.State / multicast-state whose
  description can embed host:port -> per-case labels. NWError strings (no endpoint) + groupId
  (non-secret) retained. No payload bytes logged.
- DiagLog.copyExtensionDiagToDocuments() (AppServices) copies the App-Group log into Documents,
  called on launch in ColumbaApp RootView.initializeServices().
- pbxproj: ExtensionDiagLog.swift in BOTH the NE (ESRCBP) + app (SRCBP) Sources phases, absent
  from tests.

Validated via BOTH schemes: Columba-Swift (app) AND ColumbaNetworkExtension (NE) build green.
Note: the app scheme does NOT compile the NE target -- NE-side work must be built via the
ColumbaNetworkExtension scheme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… C4)

NE TCP-relay reliability hardening (PacketTunnelProvider, all on configQueue):
- Replace the fixed 5s reconnect with capped exponential backoff: 1->2->4->8->16->32->60s
  (cap 60), attempt counter reset to base on .ready and on a fresh applyConfigs. A
  tcpReconnectScheduled guard prevents a storm of .waiting/.failed callbacks stacking
  overlapping reconnects; a stale-connection guard (=== tcpConnection) stops a late callback
  from a replaced socket tearing down the live one. Both .failed and .waiting now drive the
  backoff (previously .waiting only logged). tcpReceiveBuffer is reset on every teardown path
  so a half-frame from a dead socket can't corrupt the next connection's HDLC framing.
- Add NWPathMonitor (started in startTunnel on configQueue, cancelled in stopTunnel): on a
  satisfied path whose primary interface type actually changed (wifi<->cellular) with an
  active relay, proactively tear down + re-apply instead of waiting for the dead socket to
  time out. Guarded against redundant re-applies (baseline sample + real-change only).
- NO-PII: all logs via ExtensionDiagLog, coarse interface labels only (wifi/cellular/...),
  no SSID/address/iface-name, no host:port, no payload.

NWPath qualified as Network.NWPath (NetworkExtension also exports a legacy NWPath -> ambiguous).
Validated via the ColumbaNetworkExtension scheme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…parked NE wiring (C2a)

Enable the background Network-Extension wiring in the Swift build:
- add-swift-backend-config.rb refactored from clone_config (early-returns if the -Swift
  config exists, so it could never add a NEW condition) to ensure_swift_config: find-or-create
  the config, then idempotently ensure each Swift compilation condition (token-exact match) on
  BOTH first create and re-run. APP_CONDITIONS = [COLUMBA_BACKEND_SWIFT, ENABLE_NETWORK_EXTENSION].
  Re-running added ENABLE_NETWORK_EXTENSION to the existing Debug-Swift/Release-Swift app configs.
- This compiles the ~9 parked #if ENABLE_NETWORK_EXTENSION blocks (TunnelManager,
  ExtensionFrameReader, AppServices tunnel-mode wiring, 2 Settings views). The flip surfaced
  exactly one parked-code gap: applyTunnelModeToInterfaces calls beginTunnelMode/endTunnelMode
  on the Compat AutoInterface, which (unlike Compat TCPInterface) lacked them. Added the same
  no-op stubs to Compat.AutoInterface (the Compat facade has no underlying ReticulumSwift
  interface; the real tunnel-mode hook is Swift-backend-side, wired NE-side in A5/C3).

Validated: Columba-Swift (gated app code compiles) AND ColumbaNetworkExtension schemes green;
pbxproj Xcodeproj round-trip preserved all manual A1/C7 entries (AGBF/EDLF/EDL1B/EDL2B), diff
is 7+/7- (conditions only, no reformat). C2(c) on-demand-connect + C2(e) NE lib-linking remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TunnelManager.install() now sets isOnDemandEnabled = true + onDemandRules =
[NEOnDemandRuleConnect()] before saveToPreferences. The NE can't wake itself after iOS
terminates it (jetsam under memory pressure, reboot, user toggle), so a connect-always rule
(no interface match -> WiFi + cellular) keeps the tunnel up whenever a network path exists,
resuming background delivery without the app being foregrounded. This is the deliver-while-
locked always-on posture; pairs with C4's reconnect backoff once the socket is up.

Gated behind ENABLE_NETWORK_EXTENSION (compiled now that C2a flipped it). Columba-Swift green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…store (A0 follow-up)

Post-A0 the canonical conversation store is the GRDB LXMFSwift.LXMFDatabase accessed via
MessageRepository: the UI writes favorites/display-names through messageRepository.setFavorite/
ensureConversation (-> GRDB) and the Swift/NE backend persists inbound there too. Two reads in
IncomingMessageHandler still queried the OLD Compat raw-SQLite3 `database`, so they'd miss
Swift/NE-delivered (and UI-set) contacts -- block-unknown-senders would wrongly drop, and
favorite/name lookups would be blank.

- block_unknown_senders check: db.getConversation -> messageRepository.fetchConversation
  (ConversationRecord.isFavorite is Int; != 0 semantics preserved; the optional `let db` guard
  is gone since messageRepository is non-optional).
- notification path: resolve the sender ONCE via messageRepository.fetchConversation, derive
  senderIsFavorite + the display name, and pass senderName to postMessageNotification.
  senderName takes precedence over NotificationService's own (stale Compat) displayName lookup
  (NotificationService.swift:182), so the sole caller (IncomingMessageHandler) now bypasses it.

Verified write/read consistency: favorites are written via messageRepository.setFavorite ->
GRDB (ContactsViewModel/ChatsViewModel), so reading from the same store is correct, not a
store mismatch. Columba-Swift green (pre-existing actor-isolation warnings only). Remaining A0
follow-up: NE-row packed_lxmf attachment unpack (task #9 part 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…C2e)

Model B's NE runs the native RNS+LXMF stack itself (Track A5), so the ColumbaNetworkExtension
target must link it. Added support/add-ne-backend-deps.rb (idempotent Xcodeproj script): attaches
ReticulumSwift + LXMFSwift as package product dependencies + Frameworks-phase entries on the NE
target only, reusing the SAME XCRemoteSwiftPackageReference the app target already pins (no second
resolution). The NE's EFWBP Frameworks phase (previously empty) now carries both.

Feasibility validated: the ColumbaNetworkExtension scheme builds + LINKS the full Swift RNS+LXMF
stack (incl. transitive GRDB) clean -- the packaging fits. Runtime memory footprint under load
remains the GATE Phase 1b device measurement. A5 adds the code that instantiates the backend +
registers AppGroupBridge on the NE transport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Model B keystone, part a: the NE can now BE the RNS node. NEReticulumNode (actor) builds the
node directly on ReticulumSwift + LXMFSwift (C2e-linked) — mirroring SwiftRNSBackend.start
without RNSAPI/RNSBackendSwift (collision-safe; LXMFSwift re-exports ReticulumSwift types rather
than redeclaring, unlike RNSAPI's Compat layer):
- loadSharedIdentity(): reads the raw 64-byte key from the shared keychain group directly via
  SecItemCopyMatching (service com.columba.identity / account reticulum-identity / runtime
  team-prefix group, matching AppServices A3) -> ReticulumSwift.Identity. nil when unsigned.
- node: PathTable -> ReticulumTransport -> LXMRouter(identity:databasePath:) -> register the
  lxmf.delivery Destination -> enable ratchets -> register AppGroupBridgeInterface via addInterface.
- NEDeliveryDelegate (LXMRouterDelegate): on inbound delivery (LXMF already persisted) posts a
  UNUserNotification (8-hex sender prefix + <=80-char preview, gated on existing authorization)
  + the newMessage Darwin notification so the app refreshes. No plaintext/identity/host in logs.
- Gated: starts ONLY under `NEReticulumNode.modelBNodeEnabled` (false) — wired inert in
  startTunnel so it links without run-conflicting with the live PoC dumb-pipe. C3 flips it +
  adds the live TCP/relay interface (TODO(C3) marked) replacing the dumb-pipe.

pbxproj: NEReticulumNode.swift + AppGroupBridgeInterface.swift added to the NE Sources phase
(ESRCBP). Validated via the ColumbaNetworkExtension scheme. A5b (app ProxyRnsBackend IPC) + A5c
(outbox) next. NOTE: the NE roots the store under the App-Group container, but A2 hasn't moved
the APP off process-local Application Support yet -> they don't converge until that lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…th helper (A2 follow-up)

Model B needs the NE and app to share ONE store. A5a rooted the NE's lxmf-swift.db under the
App-Group container, but the app's grdbDatabaseFilePath still pointed at process-local
Application Support (unreachable by the NE) -> they never converged. Fix the mismatch at its
root with a single source of truth:
- Sources/Shared/AppGroupPaths.swift (NEW, Foundation-only, both targets): canonical
  <App-Group container>/Columba/python-<identityHashHex>/lxmf-swift.db (+ ratchets), nil when
  the container is unavailable (no process-local fallback inside the helper — that's the drift
  it prevents).
- AppServices.grdbDatabaseFilePath -> AppGroupPaths (legacy process-local kept only as the
  container-unavailable fallback). migrateLXMFDatabaseToAppGroupIfNeeded(): one-time, SharedDefaults
  flag lxmf_db_migrated_to_appgroup, copies lxmf-swift.db + -wal + -shm (correct snapshot of an
  unopened WAL DB) before any MessageRepository attaches; called at all 3 repo-open sites; leaves
  old files as fallback; retries if the container isn't ready yet. Both backends use MessageRepository
  post-A0, so both relocate. No `import LXMFSwift` added.
- NEReticulumNode now delegates its path accessors to AppGroupPaths -> app == NE provably.

pbxproj: AppGroupPaths.swift in BOTH SRCBP (app) + ESRCBP (NE). Both schemes build green. Known
nuance (single-identity assumption, matches the file): the migration flag is global, so only the
first identity's store migrates if multiple ever coexist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Model B send path: in Model B the NE owns the lxmf.delivery destination + node (A5a), so the
app becomes a thin client that marshals node-owning ops to the NE over sendProviderMessage.
Architecture skeleton (inert: BackendPreference.modelB defaults false):
- Sources/Shared/ProxyIPC.swift (NEW, Foundation-only, both targets): magic-byte (0xF5, outside
  the FrameInterfaceTag space) + version + JSON envelope. ProxyRequest (start/stop/announce/
  announceTelephony/statusSnapshot/persist/registeredDestinationHashes/lxmfSend) + ProxyResponse
  (.ok/.error/.unsupported) + Foundation-only DTOs. isProxyRequest does a cheap first-byte check
  so non-proxy data falls through to the PoC path.
- Sources/RNSBackendProxy/ProxyRnsBackend.swift (NEW, ColumbaApp; imports Foundation+os+RNSAPI
  ONLY — no ReticulumSwift/LXMFSwift): full RnsBackend conformance. Marshals start/stop/announce/
  announceTelephony/statusSnapshot/persist/registeredDestinationHashes/lxmfSend via an injected
  `send: (Data) async -> Data?`; builds the LXMF field map app-side via LxmfFieldCodec. Link/
  interface/reaction/propagation-node/telemetry ops throw BackendError.unsupportedInProxy; sync/
  nomadnet/telemetry-config return sensible no-ops. events inert (NE pushes inbound via App-Group
  + Darwin per A5a); localInfo cached from start.
- NE: handleAppMessage gains a leading isProxyRequest branch -> handleProxyRequest -> dispatch to
  NEReticulumNode (nil node -> .unsupported, the live case while gated) -> encoded ProxyResponse;
  PoC frame-forwarding untouched below. NEReticulumNode gains Foundation-only *ForIPC dispatch
  methods (msgpack field-map unpack via reticulum-swift's unpackMsgPack; still no RNSAPI).
- BackendPreference.modelB (App-Group key, default false); BackendFactory.make(proxySend:) returns
  ProxyRnsBackend when modelB (always-the-node invariant: proxy OR destination-owning backend,
  never both); TunnelManager.proxySend wraps sendProviderMessage in a continuation (not yet wired
  into make() -> A5c).

pbxproj: ProxyIPC in both Sources phases; ProxyRnsBackend in the app phase only (imports RNSAPI,
not linked in NE). Both schemes (Columba-Swift + ColumbaNetworkExtension) build green. A5c wires
proxySend live + the durable outbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the A5 send path: an app-composed LXMF message must survive the NE being stopped and
deliver on the next NE start, not silently drop.
- Sources/Shared/OutboxQueue.swift (NEW, Foundation-only, both targets): structural mirror of
  SharedFrameQueue (flock'd App-Group file `outbox`, [4B length][JSON] framing, append + read-all-
  and-clear, truncated/undecodable-record tolerant). OutboxEntry mirrors ProxyRequest.lxmfSend
  (destHashHex/content/method/fieldsData) + optional messageHashHex + createdAt.
- ProxyRnsBackend.sendLxmfMessage: when the NE does NOT accept the send (IPC nil/garbled ->
  ipcFailed, or .error/.unsupported -> node not running), enqueue an OutboxEntry + return
  .queued (optimistic UI row). .ok unchanged. messageHashHex is nil by design: the canonical
  LXMF hash is assigned at pack time NE-side, so the RNSAPI-only proxy can't compute it;
  reconciliation rides the existing NE-delivery -> shared GRDB -> newMessage Darwin refresh.
- NEReticulumNode.start(): after the node is fully up, drainOutbox() replays each entry through
  sendLxmfForIPC. Log+skip on failure (no re-append -> no unbounded requeue loop; a pack/sign
  failure would just recur). Receiver-side LXMF dedup makes replay safe.
- Inert when off: ProxyRnsBackend is constructed only under modelB (false); drain runs only in
  start() (gated by modelBNodeEnabled=false). With Model B off the outbox file is never touched.

pbxproj: OutboxQueue.swift in both Sources phases. Both schemes build green. The A5 keystone is
now code-complete (node + store + IPC proxy + outbox); C3 flips it live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…3c/d)

Make the in-NE node (A5a-c) the live delivery path when Model B is enabled — additive, PoC
dumb-pipe preserved as the default-off fallback:
- Unified gate: NEReticulumNode.modelBNodeEnabled now reads the App-Group flag modelBBackgroundNE
  (same key + accessor as BackendPreference.modelB), default false on absent/non-Bool. App (proxy
  selection) + NE (node activation) share ONE switch.
- Live TCP relay interface (the A5a TODO(C3)): NEReticulumNode.start() reads the App-Group TCP
  interface config (Foundation-only parse -> plain (host,port), no Network type across the
  collision boundary) and registers a ReticulumSwift TCPInterface on the node transport alongside
  the AppGroupBridge — mirrors SwiftRNSBackend.buildAndAdd's .tcpClient case. Auto/multicast +
  path-change rebind left as TODO(C3-followup) (TCPInterface self-reconnects via its own backoff).
- startTunnel branch: Model-B true -> start the node, skip applyConfigs/startPathMonitor/the
  configChanged observer (PoC-only, avoids a double-bound relay); false -> the PoC path unchanged.
  Fixed a latent double-bind: wake() now guards `reticulumNode == nil` so it can't spin up a
  duplicate PoC NWConnection under Model B. stopTunnel tears down whichever is active.
- proxySend injected at the BackendFactory.make() call site (AppServices startPythonBackend, under
  ENABLE_NETWORK_EXTENSION) via the established lazy MainActor tunnelManager read — so the proxy
  has the live IPC send whenever Model B is exercised; inert otherwise.
- C3(d): the throwaway PoC memory-measurement code (measureRNSFootprint/poc*/startNEMemoryPoC/
  nepoc-mem/NE-PoC.xcscheme) was already absent from the source tree — nothing to delete.

Default stays FALSE (both sides): the committed runtime default is the PoC dumb-pipe; Model B is
opt-in for on-device verification. Both schemes build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…llow-up #9p2)

Live bug: LXMFSwift.LXMRouter persists the signed LXMF WIRE into packed_lxmf (inbound
unpackFromBytes sets .packed=data -> MessageRecord copies it), while the app/Python path stores
a MessagePack FIELD-MAP. The chat UI renders via LxmfFieldCodec.unpack(record.packedLxmf), which
returns nil on wire bytes -> Swift/NE-backend-delivered attachments + icons silently didn't render.

MessageRepository now normalizes both forms (recoverFields + normalizedFieldMap):
- Strict WIRE-FIRST discriminator: try LXMFSwift.LXMessage.unpackFromBytes(bytes, sourceIdentity:
  nil); non-empty .fields -> wire row (extract fields, no sig re-validation — the router already
  validated at receive time + nil identity still populates .fields); else -> field-map (or empty).
  Wire-first is load-bearing: the reverse (field-codec first) is unsafe because unpackMsgPack reads
  byte 0 and IGNORES trailing bytes, so a wire row whose leading hash byte is a fixmap marker
  (~1/16) would decode a bogus map and drop attachments. unpackFromBytes's size guard (>96) + strict
  typed-array shape make a field-map-misread-as-wire near-zero.
- mapRecord re-packs wire rows to a field map so the UI's LxmfFieldCodec.unpack works uniformly;
  mapToLXMessage uses recoverFields for .fields + normalizedFieldMap for .packed. Field-map rows
  pass through verbatim (only the wire branch does extra work). All read paths flow through these.

Tests (MessageRepositoryAdapterTests, exercise the real production adapter): a genuine field-map
row AND a genuine signed-wire row (real Identity + msg.pack()) both recover image/file/icon through
mapRecord + mapToLXMessage. TEST SUCCEEDED (13 tests, 0 failures; existing A0 adapter tests intact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rack C8)

So iOS relaunches the app on BLE events while backgrounded (the NE can't own background BLE):
- SwiftBLEBridge creates both managers with restore identifiers
  (CBCentralManagerOptionRestoreIdentifierKey / CBPeripheralManagerOptionRestoreIdentifierKey;
  stable "network.columba.ble.central"/".peripheral").
- centralManager(_:willRestoreState:): re-adopts CBCentralManagerRestoredStatePeripheralsKey
  (strong-refs into discoveredPeripherals/gattClients, re-sets delegate, re-drives
  discoverServices for already-connected peers so the identity-read handshake re-runs), re-arms
  the scan from RestoredStateScanServicesKey.
- peripheralManager(_:willRestoreState:): re-binds rx/tx/identity chars from
  RestoredStateServicesKey by wire UUID + sets gattServiceAdded=true so the next poweredOn does
  NOT re-add() the service (re-adding breaks subscribed centrals), re-arms advertising.
- restoreAtLaunch() re-creates both managers with the same identifiers; called early in
  ColumbaApp.init() (this is a pure-SwiftUI app with no UIApplicationDelegate, so it can't read
  launchOptions.bluetoothCentrals/.bluetoothPeripherals — called unconditionally; the
  UIApplicationDelegateAdaptor alternative is noted inline). Info.plist already declares the
  bluetooth-central/peripheral background modes.

CAVEAT (documented inline): the BLE DELIVERY path is Python-coupled — the Swift backend has NO
native BLE delivery, so a background BLE wake only completes delivery+notify under the Python
backend. Native Swift BLE delivery is a follow-on. Scoped to BLE-direct; RNode wake left as
best-effort (SwiftRNodeBridge intentionally not given a restore id). App scheme builds green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In-app UX for the background Network Extension (the C6 App-Store reviewer-note doc already exists
in the vault):
- BackgroundTransportView.swift (NEW, app target, entire file #if ENABLE_NETWORK_EXTENSION): a
  Settings sheet explaining the feature in plain language (keeps mesh delivery alive while
  closed/locked), a live NEVPNStatus card (all 6 cases + transitions), a "VPN badge" explainer
  (mock status-bar pill + why iOS shows it), an explicit "not a commercial VPN — no traffic
  proxied/monetized, on-device only" section, an error card, and an Enable/Disable primary action.
- Wiring: Enable -> TunnelManager.install() (also arms on-demand per C2c) then start(); Disable ->
  stop(); status via @observable TunnelManager.
- Integration: surfaced as an opt-in "Learn more & set up" sheet from SettingsView's existing
  ADVANCED backgroundTransportCard — NOT forced into mandatory onboarding (the feature needs a paid
  dev account + explicit VPN-profile consent). The pre-existing quick toggle was upgraded to
  install()+start() + full NEVPNStatus status text. Added gated `import NetworkExtension` to
  SettingsView.

pbxproj: BackgroundTransportView.swift in the app Sources phase only (not the extension). App
scheme builds green. Visual polish is device/screenshot-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app target had NO dependency on the NE target and NO Embed-App-Extensions phase — so a
device build produced ColumbaApp.app with NO PlugIns/ColumbaNetworkExtension.appex (the tunnel
was never actually shipped in the app). The plan assumed this wiring "already present"; it wasn't.

support/embed-ne.rb (idempotent Xcodeproj script): adds the app->NE target dependency, an
"Embed App Extensions" copy-files phase (dstSubfolderSpec=13 PlugIns, CodeSignOnCopy), and
ensures the NE target carries DEVELOPMENT_TEAM + Automatic signing.

Verified on a signed device build (generic/platform=iOS, -allowProvisioningUpdates): the NE now
builds, embeds into PlugIns/, and code-signs with the correct auto-managed profile —
entitlements confirmed: packet-tunnel-provider + App Group group.network.columba.Columba +
keychain group <team>.network.columba.Columba.shared (A3). Installed to a physical iPhone 14 via
devicectl. Default runtime path remains the PoC dumb-pipe (modelBBackgroundNE=false).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…black-holed)

Diagnosed from on-device logs: with background transport enabled and an interface added AFTER the
tunnel came up (e.g. switching Auto -> a TCP relay), the new interface stayed in local-socket mode
and its traffic was black-holed by the active packet tunnel — the TCP interface showed
"connected (rx=0 tx=0)" and announces never flowed in or out.

Root cause: applyTunnelModeToInterfaces was only invoked from TunnelManager.onStatusChange (VPN
status transitions), never from applyInterfaceChanges (hot add/remove). So any interface added
while the tunnel was already up never entered tunnel mode (never bridged through the extension).

Fix: track tunnelModeActive (set in applyTunnelModeToInterfaces); applyInterfaceChanges now calls
reapplyTunnelModeIfActive() after the hot-add loop, so an interface added while the tunnel is up
is brought into tunnel mode immediately. Gated #if ENABLE_NETWORK_EXTENSION. Built + reinstalled
to device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ch race)

Deeper cause of "announces don't flow with background transport on": the persistent NE can
already be `.connected` when the app cold-starts, so TunnelManager.load() fires onStatusChange
-> applyTunnelModeToInterfaces(active:true) BEFORE any interface is registered — the log showed
"enabled tunnel mode on 0 TCP + 0 Auto", then the TCP interface started in local-socket mode and
its traffic was black-holed by the active packet tunnel (connected, rx=0 tx=0). The prior
hot-reload fix (9fafd97) didn't cover the launch path (interfaces come up via Step 7 ->
connectTCPInterface, not applyInterfaceChanges).

Fix: connectTCPInterface and startAutoInterface now call reapplyTunnelModeIfActive() after the
interface is registered. tunnelModeActive is set true even when onStatusChange fires with zero
interfaces, so the re-assert engages the moment the interface exists — regardless of
launch/connect ordering. Combined with onStatusChange (status changes) and applyInterfaceChanges
(hot-reload), all three interface-establishment paths now bridge through the extension. Device
build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stics

On-device debugging aids (Maestro/idb can't drive this physical iOS 26.5 device; devicectl can
post Darwin notifications):
- Darwin-notification trigger: AppServices observes `network.columba.test.announce` and fires
  sendAllAnnounces("") — drive a deterministic announce on the device via
  `xcrun devicectl device notification post --name network.columba.test.announce`. Registered
  idempotently after backend start in both initialize overloads; unregistered on shutdown.
- Frame-bridge diagnostics (no PII — tags/lengths/counts only) tracing app<->NE<->relay:
  [BRIDGE-OUT] at the tunnel-mode outbound hook (iface->sendFrame, tcp/auto) + TunnelManager.
  sendFrame (tag/len + session=yes|NIL, plus an explicit DROPPED log when no NETunnelProviderSession);
  [BRIDGE] app->NE frame and relay->NE frames-queued in the extension (ExtensionDiagLog);
  [BRIDGE-IN] frames-from-queue->transport in ExtensionFrameReader. Makes the previously-invisible
  PoC frame bridge observable so we can pinpoint where a frame stops.

Both schemes build green. Diagnostics are low-noise (frames>0 only) and can be pruned post-debug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ identity sharing

Model B's NE node now actually starts on-device (verified). Three fixes:
- Fixed the keychain bundle-seed-id probe (AppServices.keychainAccessGroupPrefix): it read the
  access group from SecItemAdd's RESULT (which omits kSecAttrAccessGroup) and added a value-less
  item — so it always returned nil. A3's shared keychain group was therefore NEVER resolved; the
  identity silently lived in the app's default group, unreachable by the NE. Now: add a value +
  read the group back via CopyMatching.
- shareIdentityForModelB(): on the active multi-identity init path (initialize(identity:), which
  never calls loadOrCreateIdentity), resolve the shared group, write it to the App Group
  (resolvedSharedKeychainGroup) so the NE doesn't have to probe (its probe fails while locked),
  and persist the identity into the shared keychain group so the NE can load it.
- NEReticulumNode.sharedKeychainAccessGroup() reads the app-shared group from the App Group first
  (probe-free, locked-safe), falling back to the local probe.
- TEMP (bring-up): BackendPreference.modelB + NEReticulumNode.modelBNodeEnabled default to true so
  every launch runs Model B. REVERT to false + add a UI toggle before ship (task #13).

Verified on device: NEReticulumNode loads identity=6c7adfca, registers the TCP relay interface,
starts with delivery dest=a3979878. Remaining: ProxyRnsBackend IPC start returns ipcFailed; verify
NE TCP connects to the relay + inbound/outbound traffic flows (next bring-up iterations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NE-canonical LXMF node (Model B) working on-device — LXMF messages + announces
flow in/out while the app is backgrounded, no APNS:

- NEReticulumNode: self-announce on node start + on relay reconnect; heard-announce
  PathTable snapshot over IPC for the app's network-announce list.
- ProxyRnsBackend: poll the NE's heard announces and re-emit them as .announce
  events (the incoming-announce bridge); start the poller on start().
- TunnelManager.proxySend: await a .connected session before sending, fixing the
  proxy start/announce launch race (ipcFailed -> transportNotConnected).
- ProxyIPC: heardAnnounces op + ProxyHeardAnnounce DTO.
- UI: Model B settings toggle (Network Backend card); the interface card and
  Network Status now reflect the NE relay via the proxy statusSnapshot, since the
  app owns no TCP interface under Model B.
- docs/MODEL_B_BACKGROUND_DELIVERY.md: consolidated as-built architecture doc
  (linked from ARCHITECTURE.md).

Verified on iPhone 14: inbound LXMF delivery (decrypt/persist/notify/delivery
proof), announce out + in, the announce button, and Network Status.

TEMP for testing: BackendPreference.modelB / NEReticulumNode.modelBNodeEnabled
default ON (flip to OFF before ship — the new toggle persists the choice). The
build currently depends on a local reticulum-swift bypassTunnelEgress checkout
patch (a defensive revert-candidate; fork-commit + SPM pin-bump still pending).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p + name fix

Make Model B (NE-owned LXMF node, app-as-proxy) the only architecture on the
Swift build, and fix the bring-up race that left it degraded.

- BackendPreference.modelB: build-flag constant (#if COLUMBA_BACKEND_SWIFT) — no
  runtime flag, no Settings toggle. NEReticulumNode.modelBNodeEnabled hardcoded
  true (the NE only exists on this build). Removes the cross-process flag race
  that left the NE in sniff mode while the app came up as the proxy (ipcFailed).
- ProxyRnsBackend.start: retry the handshake until the NE node is ready (~12s),
  so a cold start / jetsam relaunch no longer fails ipcFailed/backendNotReady.
  Verified on-device: clean Model B bring-up every launch, end-to-end DIRECT
  delivery proven (DELIVERED proof).
- Removed the "Background delivery (Model B)" Settings toggle +
  modelBEnabled / applyModelBSelection.
- Display-name fix: when an announce carrying a name is heard, stamp it onto an
  existing nil-name conversation (the announce arrives after the NE creates the
  row on inbound, so the title was stuck on the "Peer <hash>" fallback). Verified
  on-device.
- reticulum-swift pin -> 9fa6645 (bypassTunnelEgress merged via PR #18).
- docs/MODEL_B_TESTING_TODO.md (radio / lock-screen / under-pressure GATE) +
  flows/bug1-network-tab-repro.yml.

TEMP (remove when the render investigation closes): the [MSG] loadMessages and
[DIAG-STORE] announce-read diag logs. The empty-thread-via-Network-tab bug is
narrowed to a view/nav issue — cross-process reads are proven fresh (msgs=1),
not a read-path problem — so the A5 readonly:true refactor is not needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a sim-reproduced regression test for BUG #1 (empty thread when a
conversation is opened via the Network tab), and update the inbound-diag
waits to accept both the legacy [PY] and the new [RNS] diag-marker prefix
(renamed with the backend abstraction).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te in UI

Run reticulum-swift's BLEInterface in the Network Extension (the NE
sandbox can't drive CoreBluetooth) and marshal the BLEDriver +
BLEPeerConnection protocol surface across a dedicated App-Group seam to
the app, which hosts the real CoreBluetoothBLEDriver -- mirroring the
python<->kotlin driver abstraction on Android.

Seam: BLEDriverSeam (binary codec), AppGroupBLESeamTransport (App-Group
queues + Darwin notifications), AppGroupBLEDriver (NE side),
AppGroupBLEServer (app side), ModelBBLEService (app radio host).

UI: the BLE connections page, the interface status badge, the Network
Status list and the Settings network card all now read the NE's native
interfaces -- over a new .bleConnections proxy IPC and a statusSnapshot
enriched with type/peer/state -- instead of the app's Compat transport
stub, which never holds the NE's real interfaces under Model B.

Also trims PacketTunnelProvider to Model-B-only (dead Model A / PoC
dumb-pipe removed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… thread

Under Model B the NE owns LXMF delivery and signals the app only via a
cross-process Darwin notification (the app-local IncomingMessageHandler
in-process path never fires). Two gaps left incoming messages AND delivery
proofs reaching the NE but never surfacing in an open conversation:

- The NE posted the Darwin refresh notification only on inbound delivery
  (didReceiveMessage), not on delivery-proof / outbound state changes -- so
  sent-message checkmarks never advanced past the single tick. Post it from
  didConfirmDelivery / didUpdateMessage / didFailMessage too.

- The open thread (MessagingViewModel) only observes the in-process
  messageReceivedNotification, which is dead under Model B. Re-post that
  in-process notification from the Darwin observer so the open thread reloads
  on NE-delivered events -- loadMessages re-reads both new messages and
  delivery states, covering both symptoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Log central connect / readIdentity / writeIdentity outcomes (OK/FAILED) across
the NE<->app BLE seam for connection visibility. Low-volume (per-connection),
pulled via DiagLog. Made the central-handshake stall tractable to diagnose and
is useful operationally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
torlando-agent Bot and others added 7 commits June 12, 2026 14:13
The NE writes diagnostics to the App-Group container; the app bridges it into
Documents/ext-diag.log so it's retrievable via devicectl (the App-Group
container isn't reliably reachable that way). That copy only ran on launch, so
the file was a frozen snapshot while the NE kept writing live — NE markers (e.g.
inbound delivery) couldn't be tailed in real time. Add a DEBUG-only,
self-rescheduling 2s refresh so the copy stays current.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ce lands

A manually-saved relay sets autoSelect=false. loadPreferences wires the router
at launch when the node isn't in knownNodes yet, so it uses a placeholder
stampCost=0. When the node's announce later arrives, processPathEntry only
re-wired via autoSelectBestNode() (skipped when autoSelect is off), so the
router stayed at stampCost=0 and a stamp-requiring PN rejects the upload —
messages queue (nodeFound=false at launch was the tell). Re-wire the selected
node when its announce arrives.

Note: on-device Model-B propagation is still unimplemented in the NE; this fixes
the app-side wiring and helps the python backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under Model B the LXMF router lives in the Network Extension, so the old
in-app propagation path (Compat router + app-side sync loop) was a no-op:
PROPAGATED sends stayed queued and sync never ran. The swift LXMF stack
already implements propagation end-to-end — this wires the app's selected
propagation node + a sync schedule across the App-Group seam into the NE's
router, and mirrors live sync state back for the UI.

Seam (Foundation-only, both targets):
- PropagationSeam.swift: PropagationSeamConfig (PN hash, stamp cost, interval,
  periodic flag) app→NE + sync-now trigger; PropagationSyncStateSnapshot NE→app.
- 5 SharedDefaultsConstants keys (config / sync-now / sync-state + Darwin names).

NE (NEReticulumNode):
- applyPropagationConfig wires the PN onto the router (setOutboundPropagationNode
  + setPropagationStampCost); config + sync-now Darwin observers; a periodic
  sync scheduler mirroring the announce scheduler; runOneSyncFireAndForget runs
  syncFromPropagationNode in a detached child with a 150s watchdog + in-flight
  guard so the 120s SYNC_TIMEOUT never blocks the loop/IPC. One-shot sync on
  relay reconnect. didUpdateSyncState/didCompleteSyncWithNewMessages write the
  state snapshot (message-arrival push stays on the existing inbound path only).

App:
- PropagationNodeManager publishes the seam from save/loadPreferences (modelB-
  gated); syncNow + startPeriodicSync gain modelB branches (post sync-now /
  publish, NE owns cadence); tracks + persists stamp cost (SettingsRepository)
  for a correct cold start. NotificationObserver bridges the NE sync-state
  channel; the manager mirrors snapshots into syncState.
- SettingsViewModel.saveDeliverySettings persists so interval/periodic edits
  (incl. disable) reach the NE.
- SyncStatusBottomSheet (mirrors Columba-Android), auto-shown from Chats while a
  sync is active.
- test-prop-sync (ColumbaTestPropSync) routes through the manager under Model B
  (backend.propagationSync is a no-op proxy there).

Verified on device (iPhone 14, Debug-Swift): propagated_echo smoke PASS —
device→PN→echo-bot→PN→device, echo delivered, sync_attempts=1. Both schemes
build (Columba python + ColumbaNetworkExtension swift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`postInboundNotification` logged only the failure/unauthorized paths. Add a
success marker (with the message preview) when the local notification reaches
the iOS notification center — the user-visible proof that a message arriving
while the host app is suspended still notifies. Makes background-delivery
notification behavior assertable (the iOS smoke harness's suspended_notification
scenario greps this) and aids debugging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… #if DEBUG

The `onOpenURL` trigger for the test deep-link surface was already `#if DEBUG`,
but the matching `addPythonObserver("ColumbaTest*")` registrations in
`startPythonBackend` (+ the `addPythonObserver` helper) compiled into release
builds. They were inert there (nothing posts those NotificationCenter names in
release), but they were dead weight and a latent footgun if any other code ever
posted a `ColumbaTest*` name. Gate the registrations + the helper behind
`#if DEBUG` so the test surface is fully absent from release; `shutdown()` still
tears down the (empty) token array unconditionally.

Debug-Swift + Release-Swift both build clean (no orphaned-helper warning); the
DEBUG smoke surface is unchanged, so the on-device 6/6 suite still applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…treaming + parity

reticulum-swift PR #24 merged to main (a strict superset of the previous
feat/rnode-over-ble-ios pin): resource disk-streaming, RNS conformance parity
(744->148), the BLE/Model-B work, and a series of concurrency/security hardening
fixes (centralized fire-once resource conclusion, outbound register-window/reentrancy
guards, and a proactive wire-input-hardening sweep closing single-packet remote-crash
vectors in the msgpack/advertisement parsers). API change is additive — no Columba
source changes; ColumbaNetworkExtension (Debug-Swift) builds clean against the remote
main checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the Model-B background-NE branch up to date with main (22 commits)
ahead of opening the model-b → main PR.

Conflict resolutions (all took main's direction; lost nothing functional):
- project.pbxproj: 4 NE build configs — adopt main's CURRENT_PROJECT_VERSION=2
  and main's drop of the inline DEVELOPMENT_TEAM (inherited from Signing.xcconfig
  via baseConfigurationReference on all four configs).
- Tests/interop/conftest.py, test_attachments.py: collapse the transitional
  [PY]|[RNS] dual-marker regexes to main's [RNS]-only form. No [PY] markers are
  emitted anywhere in Sources (verified), so the dual-accept was dead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 17.88079% with 124 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
Sources/SwiftBLEBridge/SwiftBLEBridge.swift 15.74% 107 Missing ⚠️
Sources/RNSAPI/Protocols/RnsBackend.swift 0.00% 12 Missing ⚠️
Sources/RNSAPI/Models/Identity.swift 70.00% 3 Missing ⚠️
Sources/RNSAPI/Compat.swift 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes Model B the sole architecture for Columba iOS: the full Reticulum + LXMF node is moved into a NEPacketTunnelProvider-hosted Network Extension (NEReticulumNode), with the app becoming a thin SwiftUI client communicating over App-Group shared storage and Darwin notifications.

  • Core NE node (NEReticulumNode, 1 635 lines): actor-based lifecycle with isStarting/stopRequested re-entrancy guards, startGeneration zombie-task prevention, tracked rnodeAddInterfaceTask for RNode interface rollback, and CFNotificationCenterRemoveEveryObserver in both stop() and deinit.
  • App-side IPC proxy (ProxyRnsBackend): generation-checked retry loop, OutboxQueue durable fallback for failed sends, announce-polling cancellation.
  • Shared seam types: PropagationSeamConfig (with effectiveSyncInterval busy-loop floor), AppGroupBLESeamTransport, AppGroupRNodeSeamWire, AppGroupBridgeInterface, SharedFrameQueue POSIX-flock queue, OutboxQueue length-framed durable outbox.

Confidence Score: 5/5

PR is safe to merge; only P2 hardening gaps remain, no blocking correctness or security issues.

All previously identified P0/P1 issues (Darwin observer leaks, double-start re-entrancy, stop-during-start orphaning, RNode frame-stealing, zombie announce poller, port overflow, keychain probe accumulation, ExtensionDiagLog PII) are addressed in this PR. The three remaining findings are P2: an untracked BLE addInterface task and missing deinit safety nets in two seam classes. These are hardening gaps rather than present defects.

NEReticulumNode.swift (untracked BLE task), AppGroupBLESeamTransport.swift and AppGroupRNodeSeamWire.swift (missing deinit observer removal)

Important Files Changed

Filename Overview
Sources/ColumbaNetworkExtension/NEReticulumNode.swift Core NE actor — well-guarded lifecycle, re-entrancy, and generation tracking; BLE addInterface task is untracked (P2)
Sources/RNSBackendProxy/ProxyRnsBackend.swift App-side IPC proxy with generation-checked retry loop, OutboxQueue fallback, and zombie-poller prevention
Sources/Shared/OutboxQueue.swift Durable length-framed outbox with POSIX flock locking and atomic truncation on drain
Sources/Shared/AppGroupBLESeamTransport.swift BLE seam transport — missing deinit CFNotificationCenterRemoveEveryObserver safety net (P2)
Sources/Shared/AppGroupRNodeSeamWire.swift RNode seam wire — missing deinit CFNotificationCenterRemoveEveryObserver safety net (P2)
Sources/Shared/AppGroupBridgeInterface.swift App-Group NetworkInterface bridge — only posts Darwin notifications, no observer registration, no deinit hazard
Sources/Shared/PropagationSeam.swift PropagationSeamConfig and PropagationSyncStateSnapshot with effectiveSyncInterval busy-loop floor — clean
Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift Simplified to thin host delegating to NEReticulumNode; on-demand rule correctly set in TunnelManager

Reviews (11): Last reviewed commit: "fix(ne): floor the propagation sync inte..." | Re-trigger Greptile

Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift
Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift Outdated
Comment thread Sources/ColumbaApp/Services/AppServices.swift
…ranch

Surfaced by an audit of PR #57's (feat/enable-tunnel-flip-flag) unique
background-connectivity commits against the Model-B branch. These three are
architecture-independent correctness bugs Model B lacked; the rest of #57 is
MOOT (tunnel/interface-bridge plumbing the in-NE node replaces) or already
COVERED (the in-NE node's decode + ExtensionDiagLog). The deferred multi-relay
capability is tracked separately (issue #91).

1. App cold-start no longer blocks on the notification auth sheet (#57 fc9b0b8).
   ColumbaApp Step 8 awaited requestPermission() before setting isInitialized,
   so the OS prompt held RootView setup hostage — and on a fresh-install device
   the smoke harness (no UI driver) could never tap Allow, so init hung. Now
   fire-and-forget; the foreground UN delegate is already installed in init().

2. Register app-side notification/announce defaults at launch (#57 dc1024b).
   notifications_enabled:true was registered only inside SettingsViewModel
   .loadLocalSettings() (lazy, on first Settings open), so a fresh install that
   never opened Settings left it unregistered and NotificationService's
   `guard bool(forKey:)` suppressed the foreground notification path. Moved to a
   static registerLocalDefaults() called from App.init(). (Model-B background
   notifications come from the NE, which gates only on system auth — unaffected.)

3. "Disable Background Transport" now actually disables (#57 38f8d2e).
   install() arms isOnDemandEnabled + NEOnDemandRuleConnect(); stop() was a bare
   stopVPNTunnel(), so iOS auto-reconnected the NE via the armed rule — the
   toggle was a no-op. New TunnelManager.disable() clears on-demand + isEnabled
   and persists before stopping; both affordances (BackgroundTransportView,
   SettingsView) call it.

Also: applyTunnelModeToInterfaces is now TCP-only — it no longer tunnels the
AutoInterface (#57 d3719c2 #3). Forwarding Auto frames to tunnel.sendFrame
black-holes them (PacketTunnelProvider drops non-ProxyRequest frames; the NE has
no UDP/Auto path), so tunneling Auto could only break background Auto outbound.

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift
…ig parse (greptile #90 iter 1)

Addresses Greptile's review of PR #90 (4/5). Two P1s were the same bug class —
NEReticulumNode.stop() leaking raw passUnretained Darwin observers / seam
transports across a same-process NE restart (PacketTunnelProvider builds a fresh
node per startTunnel; iOS reuses the process) → frame-stealing + use-after-free.

P1 — Darwin observers leaked on stop (use-after-free):
  stop() never removed the 3 observers self-registered with
  Unmanaged.passUnretained(self): the RNode-config observer and BOTH propagation
  observers (config-changed + sync-now). Swept ALL of them (not just the flagged
  ones) via CFNotificationCenterRemoveEveryObserver(self) and reset both
  *ObserverRegistered guards so the next start() re-registers cleanly.

P1 — RNode seam transport not torn down on stop (frame stealing + dangling):
  stop() tore down the BLE seam but left rnodeInterface live; its
  AppGroupRNodeSeamWire keeps a rnodeSeamA2N Darwin observer that, on restart,
  steals KISS frames from the fresh node and dangles after deinit. Now
  disconnect()+removeInterface()+nil it while transport is still alive.

P2 — TCP relay port silently wrapped:
  loadTCPRelayConfig used UInt16(truncatingIfNeeded: port) on an unconstrained
  JSON Int, so 65536→0 / 131071→65535 dialed a wrong/zero port instead of
  skipping. Now `guard port > 0, port <= 65535` and a checked UInt16(port).
  (Swept for sibling truncations: the other truncatingIfNeeded uses are
  intentional MsgPack/LXMF wire decodes; BLE MTU already uses UInt16(clamping:).)

P2 — bundleSeedProbe keychain item accumulated:
  the team-id-prefix probe added a generic-password item and never removed it.
  Fixed BOTH mirror sites Greptile noted (NEReticulumNode + AppServices) with a
  SecItemDelete after the read; the probe re-adds cheaply on next resolution.

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift Outdated
… (greptile #90 iter 2)

Greptile P0 (dropped the PR to 2/5): NEDeliveryDelegate.postInboundNotification
wrote the resolved sender DISPLAY NAME (PII) and up to 80 chars of DECRYPTED
message body (`preview`) to ext-diag.log on every inbound. That file's own header
is an explicit NO-PII contract ("ENVELOPE / METADATA ONLY … MUST NOT contain
message plaintext") and is device-extractable via `devicectl … copy from`, so any
log capture leaked plaintext — defeating LXMF end-to-end encryption.

Log the sender HASH PREFIX only now (envelope metadata, matching the adjacent
`from=…`/`hash=…` markers). The notification BODY still shows the preview to the
USER — that is the intended UX; only the persisted diagnostic log must stay clean.

Swept every ExtensionDiagLog.log call in the tree: this was the sole plaintext/PII
leak (all other call sites already log hash prefixes / states only).

The on-device smoke `suspended_notification` scenario correlated the notification
by the nonce-in-preview; updated the harness to correlate by the echo bot's
sender-hash prefix instead (requiring a NEW occurrence so stale markers can't
false-positive). Re-verified on device: direct_echo clean + suspended_notification
PASS (notif_posted=true) against the new envelope-only marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift
torlando-agent Bot and others added 2 commits June 18, 2026 01:54
Greptile P1 — double-start re-entrancy. start() guards on `!isRunning` but only
sets isRunning=true after 10+ awaits (GRDB/LXMRouter/path-table opens,
registerDeliveryDestination, addInterface, …). Swift actors suspend at every
await, so the ProxyRnsBackend `.start` retry storm (PacketTunnelProvider fires
Task { start() }; the app then retries `.start` up to 30×/400ms, each a fresh
Task) slips a second start() past the guard mid-init — opening the same App-Group
GRDB twice and registering a duplicate lxmf.delivery destination on a second
transport, clobbering refs and leaking the orphan. Fixed with an `isStarting`
latch claimed SYNCHRONOUSLY before the first await (defer-released so a failed
start, e.g. identity not yet created, still retries).

Proactive sweep of the same bug class (check-then-act across await on actor
state) found the identical defect, NOT flagged by greptile, in
runOneSyncFireAndForget: it set `syncInFlight = true` only AFTER
`await waitForRelayConnected(2s)`, so the periodic scheduler racing a manual
"Sync Now" Darwin trigger could start an OVERLAPPING sync. Moved the claim
before the await. (The *Scheduler methods already cancel() synchronously before
reassigning their Task — verified safe, no change.)

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on leak (greptile #90 iter 4)

The two non-blocking edge cases noted in greptile's 4/5 summary (both real, both in
ProxyRnsBackend):

1. start()/stop() lifecycle race. start() runs a ~12s (30×400ms) handshake retry
   loop; if stop() is called mid-loop it clears cachedLocalInfo + cancels the
   poller, but a late-completing start() would then re-cache localInfo and restart
   the poller — resurrecting a stopped backend. Added a `startGeneration` token:
   start() snapshots it up front and, under stateLock, refuses to commit if stop()
   bumped it meanwhile.

2. announce-poller swallowed its own cancellation. `try? await Task.sleep` ate the
   CancellationError stop() raises, firing one extra `.heardAnnounces` IPC round-
   trip before the while-guard re-checked. Switched to a throwing sleep that returns
   on cancel.

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). All 6 prior
P1s remain fixed; on-device direct_echo + propagated_echo verified green on the
preceding revision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread Sources/RNSBackendProxy/ProxyRnsBackend.swift Outdated
#90 iter 5)

Follow-on the previous generation-token fix exposed: start() releases stateLock
before calling startAnnouncePolling(), so a stop() landing in that window bumps
startGeneration and cancels a still-nil poller, after which the late
startAnnouncePolling() would create a brand-new Task stop() can never cancel — a
zombie poller issuing .heardAnnounces forever whose stale lastSeen then silently
drops announces after the next start().

Thread the start()'s generation snapshot into startAnnouncePolling(expectedGeneration:)
and re-check `expectedGeneration == startGeneration` UNDER the lock before creating
the Task; if stop() bumped it, don't spawn the poller.

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift
…eptile #90 iter 6)

Greptile use-after-free: stop()'s `guard isRunning else { return }` silently DROPS
a stop that arrives mid-start() (isRunning isn't set until the end of the 10+ await
init). start() then completes, registers the 3 Darwin observers
(passUnretained(self)), and returns — but PacketTunnelProvider already nil'd
reticulumNode, so the actor is orphaned, deallocates, and the live observers dangle
on freed memory; the next rnodeConfigChanged / propagationConfigChanged fires
fromOpaque() on it → crash.

Two-pronged:
- Root cause: `stopRequested` flag set by stop() unconditionally (before the
  isRunning guard) and reset at the top of each start(); start() checks it after
  full init and runs a complete teardown (observers + seam transports) instead of
  returning a started-but-meant-to-stop node.
- Safety net: a `deinit` that unconditionally CFNotificationCenterRemoveEveryObserver
  for self (same idiom as NotificationObserver.deinit), so the observers can never
  outlive the actor regardless of orphaning path. Idempotent w.r.t. stop().

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@torlando-tech

Copy link
Copy Markdown
Owner Author

@greptile review

Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift
Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift Outdated
torlando-agent Bot and others added 2 commits June 18, 2026 02:47
…#90 iter 7)

Greptile (two threads, one root cause): setupRNodeInterface() sets
`rnodeInterface = iface` then registers the interface in a fire-and-forget
`Task.detached { tp.addInterface(iface) }` (kept off the setup critical path,
since connect() may block on the radio handshake). That task was unmanaged, so:
- if stop() runs before it executes, stop()'s `ri.disconnect()` is a no-op
  (connect() hasn't run), then the detached task later connect()s and registers
  the rnodeSeamA2N Darwin observer on an orphaned transport; and
- a rapid rnodeConfigChanged re-invocation tears down iface_v1 and builds iface_v2,
  but iface_v1's detached task still connect()s and re-registers its observer.
Either way two live wires observe rnodeSeamA2N and steal each other's KISS frames
on the next NE restart — the same hazard the BLE-seam teardown already guards.

Fix: store the task in `rnodeAddInterfaceTask` and cancel it at the top of
setupRNodeInterface (reconfig) and in stop() — mirroring announceTask/
propagationSyncTask. The detached body now checks cancellation before AND after
addInterface and, if superseded mid-connect, rolls back (disconnect →
removeInterface on the captured tp/iface) so no orphaned seam observer survives.

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…greptile #90 iter 8)

Greptile: startPropagationSyncScheduler sleeps `cfg.syncInterval` between syncs with
no lower bound, so a 0 / near-0 value — reachable via a corrupted or unset App-Group
entry — spins the loop at the ~2s relay-recheck floor, generating continuous IPC +
relay traffic (battery/network drain) once Model B ships to users who set the interval.

Added PropagationSeamConfig.minSyncInterval (30s) + an `effectiveSyncInterval`
accessor (max(floor, syncInterval)) and used it in the scheduler sleep + the PN-set
log. The floor lives at the point of use, NOT init: `loadFromAppGroup()` decodes via
Codable, which bypasses the custom init — an init-time clamp wouldn't catch the
corrupted-defaults path. Manual "Sync Now" + reconnect-triggered syncs are unaffected.

Proactive sweep of sibling config-driven sleeps: the announce scheduler already
floors via configuredAnnounceIntervalHours() (`h > 0 ? h : 3`); the 150s sync
watchdog and 300s idle-poll are constants. No other unbounded interval found.

Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit 20254be into main Jun 18, 2026
3 checks passed
@torlando-tech torlando-tech deleted the feat/model-b-background-ne branch June 18, 2026 16:50
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