Skip to content

Android-native migration: shared Rust client-core/client-runtime through F2b - #8

Open
deymosh wants to merge 155 commits into
masterfrom
claude/native-android
Open

Android-native migration: shared Rust client-core/client-runtime through F2b#8
deymosh wants to merge 155 commits into
masterfrom
claude/native-android

Conversation

@deymosh

@deymosh deymosh commented Sep 11, 2026

Copy link
Copy Markdown
Owner

What this is

The Android-native migration (docs/CLIENT-CORE.md), F0 through F2b: a shared Rust client-core (pure protocol/state logic) + client-runtime (the async host — sockets, crypto, lifecycle, ports) that both a future native Android UI and the current Tauri app can consume, replacing the TS apps/mobile/src/core duplication this was designed to retire.

Nothing about the shipping app changes in this PR. apps/mobile/src-tauri's native-core Cargo feature is off by default — the APK stays byte-identical. Everything here is additive: two new Rust crates plus a Node devtool, and an opt-in Rust command surface in the Tauri shell that nothing currently calls in production.

Where it stands

  • F0 (derisking, 4 probes: UniFFI binding, NDK/SQLCipher/Marmot, background survival, Markdown/Compose fidelity) — all GO.
  • F1 (transport + crypto in Rust, hosted in the Tauri process behind native-core) — done, field-tested on a real APK.
  • F2a (the full pure client-core state layer — every store, reducer, and the presentation model) — complete.
  • F2b (the composed client_runtime::Core: the full bridge protocol, NIP-17 DMs, Marmot/MLS group messaging, session image upload, the plan's View/Intent/CoreEvent API) — complete and proven end-to-end against a real bridge over a real socket (crates/client-runtime/tests/contract_harness.rs driving tools/contract-harness, a new devtool: a real BridgeCore + fake SDK behind a genuine ws:// relay). That test runs the whole plan Scenario A — pair → session → live output → input ack → bridge restart → phone reconnect → sync gap-refill — and asserts the phone's transcript ends up byte-identical to the bridge's own.
  • The one item left before the F2b stop-point: switching apps/mobile/src's stores/UI to actually call the now-complete Rust surface (corebridge.rs's core_dispatch/core_*_view/core://event, plus the TS-side nativeCore.ts wrapper this PR adds) instead of its own socket path, then deleting src/core. Deliberately not started here — it's large, touches currently-shipping code, and deserves its own focused pass with a real device/QA cycle.

Structure

crates/client-core     pure Rust: codec, crypto, reducers, stores as state
                        machines, presentation model. No tokio/sockets/threads.
crates/client-runtime   tokio reactor, Transport, lifecycle, ports, the
                        composed Core (dispatch/views/events).
tools/contract-harness  Node devtool: real BridgeCore + fake SDK behind a
                        real ws:// relay, driven by a documented stdin/
                        stdout control protocol — what the Rust integration
                        test above talks to.
apps/mobile/src-tauri   `native_ports.rs` (real SQLite-backed persistence,
                        schema-compatible with the existing app DB) +
                        corebridge.rs's expanded command surface, all behind
                        `native-core` (off by default).

docs/CLIENT-CORE.md is the living port-tracking doc — every row there names the exact TS origin, the Rust destination, and what's verified.

A regression this branch caught and fixed

Partway through, an earlier commit on this branch removed rusqlite from apps/mobile/src-tauri's direct dependencies (its own use of it, unrelated to MDK's, got dropped along with MDK's copy during an engine relocation). That broke the crate's build entirely, in every feature configuration — undetected locally because verifying it needs the Tauri Linux system deps a plain Rust container doesn't have, and because no PR had run this branch through CI yet. Fixed and re-verified against the exact commands .github/workflows/ci.yml's cargo job runs. Flagging it here since it's exactly the kind of thing this PR's CI run should catch on its own from now on.

Verification

Every commit was built/tested before landing, in Docker (rust:1-bookworm, with the Tauri Linux system deps installed for anything touching apps/mobile/src-tauri, and a Node container for tools/contract-harness + apps/mobile's TS side) — no host toolchain used. Current numbers: ~425 crates/ workspace tests + 317 client-core lib tests with --features marmot, clippy clean both ways; apps/mobile/src-tauri cargo test/cargo build --features native-core clean; the full pnpm workspace (pnpm -r typecheck + pnpm -r test, 6 packages) clean, apps/mobile at 782 tests.

This is also the first time this branch runs through GitHub Actions — a good independent check on all of the above.

deymosh and others added 30 commits September 10, 2026 16:54
F0 probe-1 from the native-android migration plan. Answers, with running
code, whether a UniFFI-generated API for the future Rust client-core is
ergonomic from Kotlin/Compose and usable from Tauri.

Verdict: GO. Exercised through the real FFI (JNA against the cdylib):
- foreign-implemented callback trait (CoreListener)
- async fn command -> Kotlin suspend fun (Core.dispatch)
- typed errors -> sealed class CoreException subclasses
- object lifecycle with a tokio background task, no thread leak
- 50 concurrent suspend dispatches during background emission
- real NIP-44 v2 (nostr crate) crossing the boundary as String
The same Rust API also compiles behind a tauri command (tauri-consumer).

Findings, now conventions for the real client-core:
- a fielded uniffi error variant must not name a field "message"
  (collides with Kotlin Throwable.message; 0.28 emits no override)
- a tauri command macro must live in a submodule, not a library crate root
Data: nostr 0.44 with default-features=false plus nip44 is a tight dep
tree; leaning on it for NIP-44 in F1 is fine.

Reproduce: ./spike/uniffi-binding-probe/run.sh (Docker only).
Delete spike/ once the verdict is recorded in the plan or an ADR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dict: GO)

The MDK 0.8 / MLS + SQLCipher stack from apps/mobile/src-tauri survives the
re-layout: crates/client-core as a workspace member + cdylib, alongside the
uniffi proc-macro stack, and still cross-compiles to aarch64-linux-android.

- host tests: two SQLCipher stores open (encryption verified), MLS key
  package minted, 1:1 group created (2 members)
- android: cargo ndk -t arm64-v8a build -> libclient_core.so, ELF ARM
  aarch64, 14.5 MB; SQLCipher + vendored OpenSSL + secp256k1 C all linked
- feature unification (rusqlite bundled-sqlcipher-vendored-openssl) holds
  across a two-member workspace
- uniffi macros coexist with openmls/mdk-core in one crate

Finding: cargo-ndk + NDK r28c cross-builds the vendored-openssl SQLCipher
with zero AR_/RANLIB_ env hacks; the manual exports in
apps/mobile/docker/Dockerfile (CDX-012) can likely go once apps/android's
Gradle build uses cargo-ndk.

Reproduce: ./spike/ndk-marmot-probe/run.sh (Docker only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ness

The networking half of "sockets in a Rust core inside the foreground service
survive backgrounding, where WebView sockets do not":

- rust/heartbeat-core: a real tokio-tungstenite Nostr client that subscribes
  to kind-30515, counts deliveries, reconnects with backoff. Host tests
  (in-process WS relay) cover delivery counting + reconnect-after-drop.
  Consumed on Android via UniFFI (the probe-1 pattern).
- android/: minimal native app — a dataSync foreground service owns the Rust
  core; MainActivity shows received / last-heartbeat / reconnects.
- pulse-relay bin: WS stand-in for a bridge emitting 30515 every 15s.
- build.sh: one Docker image (Rust+NDK+cargo-ndk + JDK+Android SDK) does
  cargo test -> .so (x86_64+arm64) -> uniffi bindgen -> APK.
- run-emulator.sh: install + run the background matrix (baseline, HOME,
  screen-off, forced Doze, airplane blip) and report DELIVERING/STALLED.

Notes: probe pins compileSdk 36 (API-37 platform ships SDK XML v4 the
Dockerized AGP 8.11.1 sdklib cannot parse — a native Studio build handles 37;
the real apps/android still targets 37). A stock emulator is lenient and will
NOT reproduce OEM background kills — emulator GREEN is necessary, not
sufficient; a real Samsung + Xiaomi pass remains a hard F1 gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Networking core (Rust relay client: subscribe kind-30515, count deliveries,
reconnect with backoff) is GREEN via host tests and packaged into the APK
through UniFFI. The on-device matrix (FGS survival through HOME / screen-off /
forced Doze / airplane) is built and ready but not run: the only installed
AVD is 96% full and is the maintainer's populated test AVD (not safe to
wipe). run-emulator.sh + a roomy/clean AVD (or a device) finishes it.

MainActivity now starts the service on `am start --ez auto true` so the
harness needs no exported service and no screen taps.

Emulator caveat recorded: a stock AOSP/Google-APIs image honours forced Doze
but does NOT reproduce Samsung/Xiaomi OEM background kills — emulator GREEN is
necessary, not sufficient; real-device pass stays a hard F1 gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On a factory-reset API-36 emulator, the Rust relay core hosted by a native
dataSync foreground service kept delivering kind-30515 through every phase:

  foreground baseline   2 -> 4    reconnects 0   DELIVERING
  backgrounded (HOME)   4 -> 7    reconnects 0   DELIVERING
  screen off            7 -> 10   reconnects 0   DELIVERING
  forced Doze           10 -> 15  reconnects 0   DELIVERING  (socket survived Doze)
  after airplane blip   15 -> 17  reconnects 4->5 DELIVERING  (backoff-reconnected)

Full log in artifacts/emulator-matrix.log. Socket lives in the service
process (UniFFI-loaded .so), not a WebView.

Still required before F1 commits: a real Samsung/OneUI + Xiaomi/MIUI pass — a
stock emulator honours forced Doze but does not reproduce OEM background kills.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…itional GO)

Paparazzi screenshot testing runs in Docker on the JVM (no emulator) — the
right fidelity guardrail for F3. Bespoke rows (diff card, tool group) port to
Compose with matching near-black monochrome fidelity (goldens in artifacts/).

The Compose-native Markdown renderer (mikepenz 0.35, the leading option) does
NOT drop in for parity:
- Markdown(content=) parses async; Paparazzi's static snapshot catches the
  empty pre-parse frame (blank golden)
- rememberMarkdownState(immediate=true) hit NoSuchMethodError — the -android
  /-m3/-code modules drift transitively; must pin one exact version
- text colour now comes from markdownTypography per-slot TextStyles, not
  markdownColor(text=); dark-only theme needs every slot set explicitly
- syntax highlighting is a separate module covering ~12 languages with
  coarser tokens than highlight.js's ~190 — exact per-token parity is the
  biggest gap, not free

Bounded, known integration work — not a blocker. F3 must start with a
transcript-screen spike + a reference capture from the running React app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(GO, scoped)

Got the Compose Markdown renderer actually rendering the corpus (mikepenz
0.27, synchronous parse). Findings sharpen from "conditional" to GO with a
scoped F3 renderer decision:

RENDERS: headings, paragraphs, ordered/unordered/nested lists, inline code,
5 fenced code blocks (bash/ts/rust/json/diff), on the dark near-black theme.
GAPS: GFM tables render as stacked lines (need custom table component even
with GFMFlavourDescriptor); task-list checkboxes render as bullets; no syntax
highlighting (monochrome); blockquote not visually distinct.

Version treadmill to lock in F3: sync Markdown(content) only in <=0.27;
0.39.x needs Kotlin 2.2 (project is on 2.0.21 per Paparazzi 1.3.5/AGP 8.7.3);
mikepenz modules must all be pinned to avoid runtime NoSuchMethodError.

Not a blocker; widens F3/F4 UI scope but doesn't threaten stopping at F2b.
Bespoke rows (diff, tool group) still port with pixel-close fidelity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Starts the shared Rust client (migration plan F1). Node/TS side untouched;
apps/mobile frozen.

- root Cargo workspace: crates/client-core (pure logic) + crates/client-runtime
  (async host skeleton). apps/mobile/src-tauri and tauri-plugin-* are NOT
  members yet (excluded); they join when apps/android is built.
- client-core::crypto — faithful port of packages/core/src/nostr/crypto.ts:
  generate_keypair / keypair_from_secret{,_hex} / npub_from_hex / hex helpers
  with the TS strictness / encrypt_to / decrypt_from (NIP-44 v2 via the nostr
  crate, validated against this crate in the F0 uniffi probe). decrypt_from is
  total from the caller's side (Err, never panic). 5 vector tests incl.
  cross-key round-trip with the NIP-44 spec keys and total-on-garbage.
- CI: the cargo job grows a `core` path filter -> cargo test --workspace +
  cargo clippy -D warnings on crates/**, Cargo.toml, Cargo.lock.
- docs/CLIENT-CORE.md: the client-core/client-runtime contract, API-surface
  target, in/out split, F0 conventions, anti-drift plan, and a port-tracking
  table.

Verify: cargo test --workspace + cargo clippy --workspace --all-targets
-- -D warnings (green), run in rust:1-bookworm via Docker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three small, self-contained pure ports from packages/protocol, each with the
TS test vectors carried over.

- wire::kinds — the v10 storage-class kind split (30515 heartbeat / 4515
  command / 4516 response / 24515 live) + NIP-40 expiry constants.
- wire::capabilities — PROTOCOL_VERSION 10, the 10 capability strings with
  their three-tier docs (hard gate / presence marker / transport beacon),
  ALL_BRIDGE_CAPABILITIES / ALL_PHONE_CAPABILITIES, BridgeHostKind with
  total from_wire.
- ranges — normalize / missing / chunk / union / subtract / range_size /
  covers, faithful to ranges.ts. chunk_ranges takes NonZeroU64 (the TS throws
  on size < 1; here that's unrepresentable). 13 tests ported 1:1 from
  ranges.test.ts including the missing+have partition property.

26 tests total, cargo clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of packages/protocol/src/chunking.ts — oversize-event fragmentation,
the transport layer below the semantic protocol.

- MAX_EVENT_CONTENT_BYTES / NIP44_SAFE_PLAINTEXT_BYTES / CHUNK_ENVELOPE_MARGIN
  / CHUNK_MESSAGE_TYPE / CHUNK_ASSEMBLY_TTL_MS constants.
- ChunkEnvelope (serde, field order = JSON.stringify order so the
  {"type":"chunk" prefix pre-filter works).
- frame_encoded_message: <= safe cap -> [json] unchanged; oversize -> N
  envelopes sized by a measured binary search, slices snapped to UTF-8 char
  boundaries (Rust has no lone surrogates, so the TS surrogate guard becomes
  the boundary snap). Concatenating parts in index order == the original.
- parse_chunk_envelope: prefix check + serde + n>=2 + non-empty cid.
- ChunkAssembler: out-of-order / dedup / i>=n invalid / different-n restart /
  TTL sweep / maxOpen + maxBytes eviction. now_ms passed per call (Clock port
  lives one layer up).

Port bug caught by the ported tests: sweep must compute
`now - first_seen >= ttl` (the TS does `first_seen <= now - ttl` in signed
arithmetic; a saturating_sub on the cutoff wrongly drops everything at now=0).

42 tests total (incl. the real-nip44 padding boundary: one byte past
NIP44_SAFE_PLAINTEXT_BYTES lands on content=65628, the exact bug-report
number — the nostr crate's NIP-44 v2 padding is byte-identical to
nostr-tools). clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of the reducer half of apps/mobile/src/core/stores/connection.ts (bug-A
design). The effect interpreter + zustand store stay for client-runtime.

- ConnectionStatus / Presence / ConnectionState / ConnectionEvent (12) /
  ConnectionEffect (7), initial_connection_state.
- ReconnectConfig + DEFAULT / TOR configs; backoff_delay_ms(attempt, random,
  cfg) = exp(base -> max) + floor(clamp(random) * base * jitter). checked_shl
  so a 50-attempt storm clamps at the cap instead of overflowing.
- connection_reducer: one deterministic match, one arm per TS `case` —
  visibility flips only debounce (never tear a healthy socket), socket-close
  while offline waits for `online` instead of burning retries, decrypt
  failures are diagnostics not disconnects, resume is cheap while connected.
- presence_of (3 honest states) + heartbeats_all_stale (CDX-020
  dead-subscription detection with the last_connected_at loop guard).

26 vector tests ported 1:1 from connectionReducer.test.ts (lifecycle, backoff
ladder [2000,4000,8000,16000,30000,30000,30000,30000], jitter bounds,
network transitions, visibility storm, resume, decrypt threshold, presence,
CDX-020 grace window). 68 tests total; clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of packages/protocol/src/{codec,schemas/common,schemas/commands,
schemas/events}.ts — the wire contract, mirrored from the normative zod spec.

- wire::common: the shared enums (PermissionMode / EffortLevel / SessionState
  / OutputEntryType / DiffLineType / DeviceRole / AppUnderTest) and object
  types (RemoteSessionInfo, OutputEntry+DiffData, UsageData+UsageWindow,
  GsdState+phases/execution/actions, AuthStatus, ProviderModel/ProfileInfo,
  DeviceConfig). is_valid_provider_base_url hand-rolled (no url crate — the
  protocol package stays dep-light): https anywhere, http only on
  localhost/127.0.0.1/[::1].
- wire::tristate: Tristate<T> {Keep,Clear,Set} with custom Serialize/Deserialize
  for the wire's absent/null/value distinction (set-credentials,
  set-provider-profile.authToken). Use with
  #[serde(default, skip_serializing_if = "Tristate::is_keep")].
- wire::commands: 24 phone->bridge structs, VersionFields (v/caps) flattened
  into each, PhoneToBridge union #[serde(tag = "type")]. upload-image is an
  #[serde(untagged)] Blossom|Chunk sub-union (mirrors zod's z.union, same
  `type`, disambiguated by shape).
- wire::events: 24 bridge->phone structs, BridgeToPhone union.
- wire::codec: decode_{phone_to_bridge,bridge_to_phone} (total: Err never
  panic, with the TS's `schema mismatch for type "X"` error prefix),
  encode_* (encode_phone_to_bridge enforces the CDX-071 https base-url gate
  on egress so a cleartext profile fails at the sender).

Faithful to zod: unknown message `type` and unknown enum value are decode
errors (the plan-§3 #[serde(other)] forward-compat leniency is a separate
tested layer). Unknown *fields* are ignored (matches zod's default strip).

91 tests total: every message type round-trips from a representative fixture;
Tristate keep/clear/set; upload-image shape disambiguation; unknown-type
error; extra-field forward-compat; provider base-url egress rejection.
clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
packages/protocol/fixtures/corpus.json is the executable half of "one source
of truth": ~60 valid + ~14 rejected messages across both directions, plus
forward-compatible (extra-field) cases.

Consumed by:
- packages/protocol/src/__tests__/fixtures.test.ts (vitest) — 86 tests
- crates/client-core/tests/codec_conformance.rs (cargo) — 5 tests

Both run the identical assertions on the identical bytes: decode every valid
entry + semantic round-trip (encode -> decode -> equal), reject every invalid
entry, and accept forwardCompatible messages (unknown fields ignored). A
zod-schema change mirrored on only one side fails CI there.

CI: the cargo job's `core` path filter now also fires on
packages/protocol/fixtures/**.

Full TS suite still green (protocol 196, core 454, testkit 28, bridge 40,
mobile 766). Workspace: 96 Rust tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review/improve pass on the codec conformance corpus:

Generation — kept hand-curated (a random generator produces non-deterministic,
non-representative data and can't express the deliberate edge cases). Instead:
- fixtures.test.ts now introspects phoneToBridgeSchema / bridgeToPhoneSchema
  for the full set of `type` literals and asserts the corpus covers every one
  (and names no unknown type). Adding a message type to the union fails this
  until a fixture exists. The spec drives completeness, not a hand-kept count.
- every `valid`/`rejected` entry was already spec-validated by the decode call.
- packages/protocol/fixtures/README.md documents the contract.

Consumption — crates/client-core/build.rs resolves the corpus (owned by
packages/protocol) to an ABSOLUTE path via CARGO_MANIFEST_DIR + canonicalize
and exports CODEDECK_PROTOCOL_CORPUS + cargo:rerun-if-changed. The Rust test
is now `include_str!(env!("CODEDECK_PROTOCOL_CORPUS"))` — no `../../..`.
Added a distinct-type-count tripwire (23 p2b / 24 b2p) on the Rust side.

TS: fixtures.test.ts 88 tests (protocol 198). Rust: 97 tests. clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of apps/mobile/src/core/services/nostrClient.ts + the per-class filter
split from platform/poolOptions.ts, behind a Transport port (callback-style
subscribe, mirroring the TS TransportSubscriptionParams). Rc<RefCell> mirrors
the single-threaded TS `this`; the real tokio-tungstenite + SOCKS5 transport
drives it from one task later.

- build_phone_filters: the 3 per-traffic-class filters (30515 no since / 4516
  since = cursor-60s / 24515 no since) — the bug-A kind split.
- NostrClient: generation guard (CDB-037 — epoch++ BEFORE closing subs, so a
  deliberate teardown never fakes a socket-close), EOSE-on-all => on_socket_open,
  one real sub death => teardown epoch + exactly one on_socket_close, seenIds
  dedup (cap 2000, FIFO), stored-cursor tracking (30515+4516 only, never
  ephemeral, never regresses), vacuous open when unpaired, set_relays.

15 tests ported 1:1 from nostrClient.test.ts (the "reconnect resumes 4516"
case is now a direct filter assertion — the Rust FakeTransport records the
whole Filter, not just kinds). client-runtime: 12 tests. Workspace: 109 Rust
tests, clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…line)

Ports the pure half of apps/mobile/src/core/services/bridgeApi.ts (plan
section 6.2): policy and validation in client-core, socket I/O deferred to
client-runtime.

- kind_for_message: the one place phone->bridge kind policy is stated (every
  command is a stored COMMAND_KIND event; bridge->phone is split three ways,
  that lives in nostr_client).
- build_command: encode (egress-validated, so a cleartext provider baseUrl
  fails at the sender), stamp v + caps (CDX-050), NIP-44 encrypt, schnorr
  sign, NIP-40 expiration tag. Returns one SignedCommand the caller re-uses
  verbatim on retry — rebuilding would change created_at, the NIP-44 nonce
  and the id, defeating the bridge's id dedup and double-injecting an image
  (CDX-086).
- BridgeApi::ingest: decrypt -> ChunkAssembler -> decode_bridge_to_phone,
  total. A bad payload is a returned variant plus a diagnostics bump, never a
  panic; a decrypt failure from a known machine is a diagnostic, never a fake
  disconnect.
- classify_publish / combine_publish: the CDX-086 four-way verdict
  (accepted / unconfirmed / rejected / unreachable), softest-wins across
  relays. The retry loop that consumes them is runtime.
- folder-ack request-id allocation + pending set (the timeout timer and
  caller promise are runtime).

Tests ported 1:1 from bridgeApiChunk.test.ts and bridgeApiUploadImage.test.ts
plus native units for the verdict logic and the egress gate. Adds Debug to
ChunkAssembler so BridgeApi can derive it.

cargo test --workspace: 130 pass (client-core 112, codec_conformance 6,
client-runtime 12). cargo clippy --workspace --all-targets -- -D warnings
clean. Run in rust:1-bookworm via Docker. No TS touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports packages/protocol/src/nip42.ts. The bridge and the phone both answer a
relay's ["AUTH", challenge] with a kind-22242 event signed by their own
identity keypair — one allowlisted pubkey per side on a private / Haven relay,
no separate auth credential. Where the TS signs a template nostr-tools builds,
the Rust transport builds the event itself, so build_auth_event is that one
call; a relay that never challenges never triggers it.

Factors the nostr::Event -> plain-data conversion into
client_core::nostr_event::SignedEvent, now shared by build_command and
build_auth_event instead of each hand-rolling the same seven fields.

cargo test --workspace: 133 pass (+3 nip42). clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The pure half of the real transport: the ten client-side relay frame shapes —
parse EVENT / EOSE / CLOSED / OK / NOTICE / AUTH, build REQ / CLOSE / EVENT /
AUTH, and the relay-JSON form of a subscription Filter. No sockets.

An unrecognised inbound verb is RelayMessage::Unknown, not an error, so a relay
sending COUNT or a future frame does not drop the read loop; a malformed known
frame is an error the caller can log.

Decision recorded in docs/CLIENT-CORE.md: the transport is hand-rolled on
tokio-tungstenite + tokio-socks, not nostr-sdk / nostr-relay-pool. The port's
shape is a thin transport under the connection FSM; a relay pool re-introduces
the self-timed idle close (CDX-020) and the pool-fires-its-own-onclose trap
(CDB-037) the TS spent effort defeating, and its publish result collapses
unconfirmed vs unreachable — the one distinction CDX-086 keeps. The client
Nostr wire is ~10 frame shapes; nostr gives event types + signing + NIP-44 +
NIP-42, which is all the leverage needed.

cargo test --workspace --locked: 141 pass (client-runtime 20, +8 frames).
clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gic)

The fan-out / aggregation half of the real transport, extracted pure the way
nostr_client and connection were. One logical subscription (one sub_id per
Transport::subscribe) is REQ'd to every relay; the router turns per-relay
frames into the callbacks nostr_client expects:

- on_event per received event (cross-relay dedup stays in nostr_client's
  SeenIds).
- on_eose ONCE, when every still-connected REQ'd relay has EOSEd, or when the
  last relay that had not EOSEd drops — matching nostr-tools subscribeMany.
- on_close ONCE, only when the sub is dead on EVERY relay; one relay closing a
  sub while another still carries it is not a close (the FSM must not back off
  while a socket is live).
- NIP-42: ["AUTH", challenge] -> NeedAuth; a CLOSED with auth-required ->
  ResubAfterAuth (the sub is not marked dead — it is re-REQ'd after AUTH).
- publish: per-relay OK/reject/timeout/drop fed through
  bridge_api::classify_publish, settled with combine_publish. Settles
  immediately on the first acceptance (TS raceForAcceptance), otherwise waits
  for every relay; a budget timeout settles the silent relays as unconfirmed.

34 client-runtime tests (+14). cargo test --workspace --locked: 155 pass.
clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ort)

WsTransport implements nostr_client::Transport over tokio-tungstenite +
tokio-socks: one connect + read task per relay, wired through the pure frames
codec and router.

- dial: direct TcpStream, or through a SOCKS5 proxy (Orbot) when configured —
  every relay then goes via Tor, the only way a .onion relay is routable.
  wss:// via rustls + webpki roots (no OpenSSL, so it cross-compiles for
  Android; cost is the ring transitive).
- read loop: parse -> router.route -> run actions after dropping the state
  borrow (a user callback may re-enter subscribe / close). Callbacks are Rc
  (was Box) so one can be cloned out from under the RefCell borrow.
- ping liveness: a socket with no inbound traffic for 75s is dropped, so a
  silently-rotted relay is detected — the enablePing intent, without
  nostr-tools' idle-close bug.
- no auto-reconnect: a dead socket is one on_close; the connection FSM owns
  backoff and calls ensure_connected(). set_relays diffs and re-dials.
- NIP-42: an AUTH challenge is answered with client_core::nip42; a CLOSED
  auth-required re-sends that sub's REQ.
- publish_confirmed: fan out the SAME signed event to every up relay, feed
  per-relay OK/reject/timeout/drop through the router, return the CDX-086
  verdict. Retries only on unreachable (transient) within the budget.

Single-threaded by construction (SubCallbacks are !Send) -> current-thread
runtime + LocalSet. Integration-tested against a loopback mock relay:
subscribe/REQ/EVENT/EOSE, NIP-42 AUTH round-trip, publish verdict, socket-drop
-> on_close, sub.close() -> CLOSE frame + silence.

cargo test --workspace --locked: 160 pass (client-runtime 39, +19). clippy
-D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…elay

Composes the connection FSM (client_core::connection), the epoch-guarded
subscription client (nostr_client::NostrClient over the real WsTransport), and
bridge_api (egress build + total ingest) behind one tokio event loop on the FG
service's LocalSet. This is the handle the UniFFI / tauri bindings attach to.

- lifecycle: start / stop / pause / resume / set_online / set_machines. Each is
  an mpsc message; NostrClientHost callbacks and internal timers post the same
  way, so a callback firing inside the transport task never re-enters the loop.
- effect interpreter: OpenSocket -> ws.ensure_connected + nostr.connect;
  CloseSocket -> nostr.disconnect + ws.shutdown; ScheduleRetry /
  ScheduleVisibilityCheck -> spawn_local sleep tasks tracked by AbortHandle.
  The transport never reconnects itself — the FSM's backoff drives every
  redial.
- CDX-020 watchdog: a 30s tick runs heartbeats_all_stale; if the socket claims
  Connected but every paired-machine heartbeat has aged out, it injects a
  SocketClose so the normal backoff path reconnects.
- ingest: a 30515 from a PAIRED machine feeds HeartbeatReceived (presence +
  CDX-020) whether or not the payload decodes; every event then goes through
  bridge_api::ingest -> observer.bridge_message / action_failed. A decrypt
  failure raises the needs-pairing-check diagnostic, never a disconnect.
- egress: Core::send builds the signed command and publishes off the loop, so a
  12s confirmation budget never blocks socket-close handling; publish_confirmed
  awaits the CDX-086 verdict.
- CoreObserver is the seed of the F2 CoreEvent stream — semantic, no UI strings.
  The full View / Intent surface is F2.

The loopback mock relay moves to transport::mock, shared by the ws and core
integration tests (start -> connected after all EOSE, message delivery,
socket-drop -> backoff -> reconnect, stop is terminal). NostrEvent gains
`content` so the layer above can decrypt.

cargo test --workspace --locked: 165 pass (client-runtime 44, +10). clippy
-D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Core::set_relays(relays) forwards to NostrClient::set_relays (transport
  re-dials the diff; if connected, the subs re-REQ).
- Core::connection_status() -> (ConnectionStatus, bool) — a fresh read for a UI
  that just attached, since CoreObserver only reports changes. Served by a
  QueryStatus message so it reads the loop's own state with no shared lock.
- WsSub::close now sends the CLOSE frame only to `up` relays. A CLOSE queued on
  a still-dialling relay sat ahead of the REQs on_relay_up replays and arrived
  out of order — surfaced by the set_relays test (the new relay's first frame
  was a stale CLOSE, not a REQ).

cargo test --workspace --locked: 167 pass (client-runtime 46, +2). clippy
-D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e, gated)

The Rust side of "run the Nostr sockets in the app process, not the WebView".
Behind the `native-core` Cargo feature — OFF by default, so the crate builds
byte-identical to before and the shipping APK is unchanged. A test APK turns it
on; it flips to default once field-tested.

corebridge.rs:
- CoreBridge managed state; core_init spins a dedicated "codedeck-core" thread
  with a current-thread tokio runtime + LocalSet that hosts the !Send Core
  loop, and hands the Send Core handle back to Tauri state.
- commands: core_{init,start,stop,pause,resume,set_online,set_machines,
  set_relays,send,publish,connection_status}. send/publish take the phone→bridge
  command as wire JSON (the same object the TS encoder builds) and run it
  through the real decode_phone_to_bridge.
- TauriObserver fans CoreObserver out as core://{connection,message,
  action-failed} events — semantic payloads, no UI strings; `message` is the
  decoded BridgeToPhone in its wire shape so the WebView feeds it to the same
  handlers.
- the identity secret arrives once via core_init, consumed into the keypair,
  never logged.

CI builds `cargo build --features native-core` in the src-tauri job so a broken
corebridge fails CI, not a test APK.

Verified in a Tauri-deps container: cargo test --locked (default) 7 pass;
--features native-core builds + 7 pass; clippy --features native-core clean.
The WebView opt-in wiring (listen to the events, disable the TS transport) and
the eventual TS network-path removal are the next F1 sub-steps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…untime)

The isTauri-guarded wrapper over corebridge: the core_* commands + the
core://{connection,message,action-failed} events, in the house style
(lazy @tauri-apps imports, untyped JSON re-validated at the boundary).

- nativeCoreOver(invoke, listen) — the testable core; maps each method to its
  command, normalises the connection snapshot (snake_case + unknown status),
  and runs every inbound core://message through the real decodeBridgeToPhone so
  a Rust/JS drift fails here, not in a store.
- createNativeCore(log) — the production seam: null when there is no Tauri
  runtime OR the APK was built without native-core (the probe core_init
  rejects), so callers fall back to the WebView transport.
- identity secret passes through init once, never logged.

createPhoneCore consumes this in place of its own transport + BridgeApi + nostr
client when present — that wiring is the next F1 sub-step.

./codedeck check green (mobile 770 tests, +4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… / dispatchDecoded)

The BridgeApi-side hook for the in-process runtime. When deps.nativeSend /
nativePublishConfirmed are set (createPhoneCore passes them when a native core
is present):

- send() hands the UNSTAMPED phone→bridge command to the native core — Rust
  stamps v/caps, NIP-44-encrypts, signs and publishes — instead of
  buildCommand + this.deps.publish. A native failure is contained the same way
  (false / rejected verdict, logged, never thrown).
- sendConfirmed() routes to nativePublishConfirmed (the image path keeps its
  CDX-086 verdict).
- dispatchDecoded(msg, machine) routes a message the native core already
  decrypted + decoded straight into dispatch, skipping ingest's
  decrypt→reassemble→decode pipeline.

buildCommand / publish / ingest / the ChunkAssembler are simply unused in that
mode (Rust owns them). No behaviour change when the hooks are absent.

./codedeck check green (mobile 773 tests, +4). testkit contract Scenario B2
flaked once mid-run (retry-exhaustion timing); passes on re-run, unrelated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When deps.nativeCore is present the bridge protocol (30515/4515/4516/24515)
rides the in-process Rust runtime; deps.transport still serves DM (1059) +
Marmot (445) until F2a.

- core/nativeCore.ts: NativeCoreControl port (init/start/stop/setMachines/
  setRelays/send/publish). Kept out of ports.ts, which stays protocol-free.
- on boot: native.init once with relays + hex identity secret + Orbot proxy
  (deps.nativeCoreProxy) + tor flag. The secret is passed here and nowhere
  else, never logged.
- connection FSM openSocket/closeSocket effects -> native.start()/native.stop()
  (idempotent; the runtime owns the socket + reconnect). client.connect() is
  not called.
- BridgeApi gets nativeSend/nativePublishConfirmed -> every api.send /
  sendConfirmed / typed convenience method goes to the runtime unstamped.
- authors changes (pairing candidate/paired, removeMachine) ->
  native.setMachines(currentAuthors()); relay changes -> native.setRelays.
- inbound is wired by the boot layer onto api.dispatchDecoded /
  connection.dispatch (next commit).

No behaviour change when nativeCore is absent (every branch is `if (native)`).
./codedeck check green (mobile 779 tests, +6).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Completes the WebView side. createNativeCore(log) probes for the native-core
build; null → the WebView transport path is unchanged.

When present:
- adapt the platform seam to the core's NativeCoreControl port (send → bool,
  publish → PublishResult), pass it to createPhoneCore alongside `transport`
  (which still serves DM/Marmot) with the Orbot proxy address.
- inbound: nativeSeam.onMessage → core.api.dispatchDecoded; onConnection →
  mirror the runtime's status onto the WebView FSM (connected → socket-open,
  waiting-retry/offline → socket-close) so the chip + resync-on-reconnect stay
  honest; onActionFailed(decrypt-failed) → connection decrypt-failure.
- skip the WebView CDX-020 checkHeartbeats tick — the runtime runs its own.

Connectivity (visibility/online/offline/resume) needs no change: it flows
through the FSM's openSocket/closeSocket effects, which now call
native.start()/native.stop().

./codedeck check green (mobile 779). main.tsx is boot glue (untested);
typecheck + the full suite cover the pieces it wires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
./codedeck apk benchmark --features native-core builds a release-optimized,
debug-signed APK whose src-tauri is compiled with the native-core feature —
the Nostr sockets + crypto + connection FSM run in the app process. Output is
dist/codedeck-benchmark-native-core.apk. No --features → the build is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
./codedeck apk benchmark --features native-core cross-compiles the
client-core / client-runtime / tokio-tungstenite / tokio-socks / tokio-rustls
tree for aarch64-linux-android with no errors and no NDK workarounds.
libcodedeck_mobile_lib.so = 34.1 MB (whole APK 37.5 MB) — under the 50 MB
gate, and essentially unchanged from the pre-F1 baseline because ring / rustls
/ tokio were already in the tree via reqwest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`./codedeck apk debug --features native-core --target x86_64` builds for an
x86 emulator; default stays --target aarch64 (real devices). Output name gets
a non-default ABI suffix so an emulator build does not overwrite the device
one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deymosh and others added 30 commits September 11, 2026 17:31
corebridge.rs's CorePorts used ..CorePorts::default() for both `notifier`
and `http`, i.e. NullNotifier and NoHttpFetch — found while wiring the
main.tsx cutover and the SessionScreen.tsx native image-send branch, not
part of the three gaps this pass set out to close. Left as-is, a native-core
boot would have silently delivered zero OS notifications (DMs,
turn-finished, permission prompts) and failed every Blossom image
upload/download.

TauriNotifier (corebridge.rs) calls tauri-plugin-notification's Rust API
(AppHandle::notification().builder().title(..).body(..).show()) directly
from the core's own thread — the same plugin the WebView path drives over
its JS bindings, no new IPC surface needed. `cancel` stays the trait's own
default no-op: the plugin's remove-by-id call is exposed to the JS side only
(`removeActive`), and duplicating the WebView notifier's per-tag id
bookkeeping just to auto-dismiss a resolved permission-request notification
isn't worth it yet.

ReqwestHttpFetch (native_http.rs) reuses tauri-plugin-http's OWN reqwest
(re-exported as tauri_plugin_http::reqwest) rather than adding a second HTTP
stack — the WebView path already depends on this exact crate as its CDX-029
CORS escape hatch for Blossom uploads. It is SOCKS5-aware: core_init's
InitConfig::proxy (the same host:port the WS transport dials when Tor is on)
configures an identical reqwest::Proxy, so a Blossom upload never bypasses
Orbot while the relay sockets don't either. The `socks` feature is forwarded
onto tauri-plugin-http only through the native-core Cargo feature
(tauri-plugin-http/socks), never unconditionally on the dependency itself,
so the default build's dependency graph — and the shipped APK — stays
byte-identical without native-core.

Neither port hot-reconfigures if Tor is toggled while the app is already
running (same known limitation as the WS transport; documented in
docs/CLIENT-CORE.md, not fixed here).

Also fixes an unrelated clippy lint (cloned_ref_to_slice_refs, newly added to
stable clippy) in a native_ports.rs test, caught while running the required
cargo clippy --all-targets pass for this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main.tsx now decides its ENTIRE composition from one capability probe
(createNativeCore(), already used for F1): non-null picks bootNative
(createPhoneCoreNative — identity/settings load, transport, the Tor WebView
proxy override, and the Marmot platform seam are all skipped, since
createPhoneCoreNative owns them itself or Rust does); null picks bootLocal,
the unchanged pre-F2b WebView-driven path (also always taken in
plain-browser dev). This retires F1's old partial nativeCore wiring from the
real boot path — createPhoneCore.ts's own nativeCore option still exists and
is still covered by its own dedicated test, just never reached from
main.tsx now that a native-core build always has the full F2b surface.

The stay-connected foreground service and connectivity wiring are
interface-only (core.settings / core.connection) and stay shared, unchanged,
between both paths. attachTorProxy (a WebView-only PROXY_OVERRIDE toggle) is
skipped for native mode, whose transport dials its own SOCKS5 at core.init
instead — toggling Tor while a native-core boot is already running does not
hot-reconfigure that transport, a known, separate, documented gap.

Also closes the two remaining F2b UI gaps this pass set out for:

removeMachine now dispatches Intent::RemoveMachine instead of being a
logged no-op (the Rust side landed in an earlier commit).

PhoneCore gains an optional sendSessionImageNative method — the one method
the native composition defines that the local one does not, since
Intent::SendSessionImage already does the whole Blossom-upload-then-chunk-
fallback as one step in Rust, with no way to plug that into BridgeApiLike's
uploadImageBlossom/uploadImageChunk shape without either double-uploading or
silently breaking the documented fallback. SessionScreen.tsx's sendWithImage
checks for the method's presence and, when native, decodes the staged
file's base64 to raw bytes (base64ToBytes, already used elsewhere) and
dispatches directly — no BridgeApi calls, no fine-grained upload progress
(the spinner covers the one dispatch), and no true cancellation of an
in-flight send (the same outer withDeadline backstop applies, but a timeout
only stops the spinner, not a send already handed to Rust).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Updates the status summary and port-tracking table for this phase's work:
Intent::RemoveMachine, the main.tsx capability check, the
sendSessionImageNative native image-send branch, and the Notifier/HttpFetch
ports the cutover surfaced were missing. Notes what is left before the F2b
stop-point is no longer code — a real end-to-end manual smoke pass on a
device — and records the one remaining known gap (Tor toggle does not
hot-reconfigure a running native-core transport or HTTP client).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…DX-026c)

Intent::SelectSession/SelectDmPeer already returned the correct
UiEffect::SessionViewed/DmOpened in IntentResult::ui_effects (proven by the
intent module's own tests), but Core::interpret_intent never read that
field at all — opening a session or DM the user had been notified about
left its OS notification sitting in the tray forever under native-core.

interpret_intent now maps each effect to session_notify_tag/dm_notify_tag
(client_core::notifications — the same helpers emit_notify already uses for
delivery) and calls notifier.cancel(tag), mirroring what
notificationsCoordinator.ts already does for the WebView path.

Found by re-auditing every "needs a ... seam (F2b)" comment left in core.rs
after being asked whether any code gaps remained, rather than assuming the
three originally-scoped gaps were the only ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CorePorts.marmot was the last field still defaulting to a stub (NoMarmot):
the runtime-side wiring (Core's full connect sequence, AcceptMarmotWelcome/
SendMarmotMessage/StartMarmotChat) was already complete and tested, but
corebridge.rs never constructed a real engine, so every Marmot chat would
have failed outright under native-core. Forwards client-runtime's own
marmot feature (already just ["client-core/marmot"]) through native-core --
client-core's marmot feature was already unconditionally on for this crate
(the WebView path's own marmot.rs Tauri commands need it), so this adds no
new compiled dependencies -- then constructs
MarmotEngineImpl::new(marmot_db_path). marmot_db_path resolves to the SAME
app_data_dir()/marmot.db the WebView path's marmot_init command already
opens: an install switching between the two compositions must keep its MLS
group state, since losing it is unrecoverable.

TauriNotifier::cancel was the trait's own default no-op (a deliberate scope
cut when TauriNotifier was first added). Now mirrors platform/notifier.ts
exactly: assigns an id per delivery (tauri-plugin-notification's mobile-only
remove_active call removes by id, not tag), remembers ids per cancellation
tag (capped, same as the JS side), and removes them on cancel. Desktop has
no remove_active equivalent at all -- #[cfg(mobile)]-gated, a no-op on
desktop, matching the JS notifier's own graceful fallback.

Both found the same way: re-auditing every real stub left in CorePorts
after being asked whether any code gaps remained.

Verified with a real `./codedeck apk benchmark --features native-core`
build (aarch64-linux-android cross-compile + Gradle + signing), not just
cargo check/test/clippy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…crates

apps/mobile/package.json pinned @tauri-apps/plugin-http and
@tauri-apps/plugin-notification to npm minors whose locked versions (2.5.9,
2.3.3) trailed the Rust crates' resolved minors (2.6.0, 2.4.0) -- both
caret ranges already allowed the newer versions, but pnpm-lock.yaml had
never been asked to pick them up. `cargo tauri build` refuses to package an
APK when the JS and Rust halves of a plugin are on different minor
releases, which blocked the native-core benchmark APK build outright.
Bumped the package.json minimums and regenerated the lockfile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Updates the status summary and port-tracking table: the CDX-026c
notification-cancel bugfix, TauriNotifier::cancel, and MarmotEngineImpl are
now real -- CorePorts has no stub fields left under native-core. Records
that a real `--features native-core` benchmark APK now builds, installs,
and was smoke-tested (pairing confirmed working). Notes the two remaining
non-CorePorts gaps: the attention chime (ping) has no platform port yet,
and one-QR mesh auto-join is out of scope for F2b by design (its own F6
Kotlin phase in the plan).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pires

Reported from a real device running the native-core benchmark APK: closing
a session and never tapping undo left the undo banner on screen forever.

Core::on_undo_timer already cleared stores.ui.undo_toast correctly when the
delete controller's timer fired (delete_controller::commit's HideUndoToast
effect), but the function emitted StateChanged(Cards) at the end instead of
StateChanged(Ui) -- commit() never produces a card effect at all, so this
notified nothing that could see the toast had just been cleared. A UiView
consumer (the native adapter) has no other way to learn to re-fetch, so the
toast it had already fetched (with the countdown) just sat there
unchanged, permanently, once the window elapsed without a tap on undo.
Tapping undo itself was never affected -- that path already routes through
apply_delete_effects, which sets ui_changed correctly.

Added a regression test that pairs a machine, lists a real session,
dispatches DeleteSession, lets the actual ~4s undo window elapse without
ever dispatching UndoDelete, and asserts both that the toast is gone from a
fresh ui_view() query and that a StateChanged(Ui) event fired for it --
confirmed it fails on the pre-fix code and passes after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pass

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
apps/mobile/src/core's pre-F2b implementation is gone: createPhoneCore.ts
(the local composition root), the local FSM/persistence half of every
store, services/nostrClient.ts, platform/relayTransport.ts,
deleteController.ts, notifications.ts, selectionPersistence.ts,
defaultSessionMode.ts, and every test that exercised them (~28 files).
createPhoneCoreNative.ts (the native-core composition) is now the sole
PhoneCore implementation.

Each surviving store file (connection, dm, machines, marmot, outbox,
pairing, pendingSessions, quickPrompts, settings, transcript, ui) keeps
only the shared types and pure helper functions still called directly by
a native adapter or by UI code (parsePairingUrl, sessionKeyOf,
truncatePeerLabel, parsePeerInput, unifiedConversations, hydrateSettings,
loadPersistedSettings, ...); every stateful create*Store() factory
implementing local reducer/FSM logic is gone, since client_runtime::Core
owns all of that now. bridgeApi.ts keeps only the BridgeApiLike
interface; ports.ts drops the PhoneTransport/TransportSubscription types
the deleted transport used (PublishVerdict/PublishResult/
PublishConfirmOptions survive — BridgeApiLike's two documented gaps still
type against them).

The PhoneCore interface itself moves to a new core/phoneCore.ts (client
field dropped — nothing read it; sendSessionImageNative made required,
the only implementation left). main.tsx loses its bootLocal path
entirely: boot() now throws if createNativeCore() comes back null (no
Tauri, or a Tauri build without the feature) instead of falling back to
anything, and plain-browser dev no longer starts the app at all. The
WebView-only Tor proxy override call site is gone from main.tsx too;
platform/torProxy.ts and its Tauri plugin are now dead code, flagged as
a follow-up cleanup rather than pulled into this pass.

SessionScreen.tsx's image-send path collapses to the single native
dispatch (the old Blossom/chunk-fallback branch is unreachable now that
sendSessionImageNative is required), which let imageFile.ts drop the
whole client-side upload orchestration and SessionScreen drop the
vestigial upload-progress/unconfirmed-banner state that went with it.

Also fixes two pre-existing test bugs surfaced by actually running the
suite for the first time since this migration began: foregroundService's
tauriListen mock and connectivity's airplane-mode test were asserting an
incomplete event sequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
~20 UI test files fixtured themselves against createPhoneCore() purely as
a convenient in-memory PhoneCore, unrelated to local-vs-native — they
just needed something real to render against. With that composition
gone, they now build a PhoneCore via createPhoneCoreNative() over a
shared, stateful fake NativeCore (core/__tests__/nativeCoreFixture.ts):
fakeNativeCore()/buildFakePhoneCore() seed the *View responses a test
needs, and an onDispatch hook lets a test script exactly what a
dispatched Intent would change on the fake's views — the fixture itself
stays dumb rather than re-implementing client_runtime::Core's behavior.
The fixture also auto-echoes the optimistic local state every
select*-style adapter method sets before dispatching (selectSession,
selectDmPeer, selectMarmotGroup), so a test's own onDispatch handler for
an unrelated side effect on the same view can't race it back to a stale
value once the next refresh lands.

A few scenarios changed rather than just moved: session-image send tests
collapsed to the single native path (see the previous commit);
DM/Marmot's real gift-wrap/MLS round trips are now Rust's job, so those
tests seed the resulting view state directly instead of driving two real
PhoneCores through actual crypto; a couple of optimistic-UI assertions
(provider-profile "Saving...", credentials "Saving...") are dropped as
documented, still-open native gaps (see nativeUi.ts) rather than asserted
falsely; and the Marmot start-chat failure message changed from a
specific "no KeyPackage" string to nativeMarmot.ts's documented generic
failure reason, since the native adapter no longer distinguishes the two
causes.

multiRootPicker.contract.test.tsx drops the real BridgeCore-over-relay
round trip it used to drive (that belongs to a future Rust
contract-harness test, not a TS one) and instead proves the same CDX-031
regression — NewSessionModal offering every advertised root, not just
what's inside them — against a MachinesView built with the shape a real
heartbeat produces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
apps/mobile/src-tauri's native-core feature (the in-process
client-runtime hosting the whole bridge protocol, not just F1's
transport/crypto) has been field-tested on a real device and is now
`default = ["native-core"]` — every build ships it unless
`--no-default-features` is passed explicitly, which stays buildable (see
[features] in Cargo.toml) but has no remaining production caller now
that apps/mobile/src/core's local composition is deleted.

Updates the places that talked about it as an opt-in test flag:
build-apk.sh's --features passthrough, the codedeck script's apk
usage text, and the CI cargo-test job (which built --features
native-core as a second, now-redundant step — it builds
--no-default-features instead, to keep that configuration honest for
CI rather than duplicating what the default build already covers).

docs/CLIENT-CORE.md's status section is rewritten to record F2b as
complete: the src/core deletion, the shared test fixture, the feature
flip, and platform/torProxy.ts's resulting dead-code status (a follow-up
cleanup, not pulled into this pass — removing a whole Tauri plugin is a
bigger, separate change than a TS module deletion). The F1 status
table's "delete the TS network path" gate flips to done, since the
deleted local composition was the whole network path, not just its
transport half.

Verified: ./codedeck check (typecheck + test, every package) and
cargo test / cargo clippy --all-targets -- -D warnings for both the root
crates/ workspace and apps/mobile/src-tauri (default and
--no-default-features) are green. A real benchmark APK
(./codedeck apk benchmark) builds, installs, and pairs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pulls client-core's src/wire/ mirror (common/commands/events/capabilities/
kinds/relays/codec/tristate) plus ranges.rs, chunking.rs, nip42.rs, crypto.rs
and nostr_event.rs into a new crates/protocol crate — all self-contained wire
and wire-crypto primitives with no dependency on client-core's stores or
reducers. client-core now depends on protocol; protocol depends on nothing
else in this workspace, so a future Rust bridge (bridge-core/bridge-runtime)
can consume it too without reorganizing anything here.

client-runtime re-exports protocol (client_runtime::protocol::...), matching
the existing client_core re-export pattern, so apps/mobile/src-tauri needs no
new direct Cargo dependency.

Pure move: every internal crate::/super:: reference between the moved
modules already resolved relatively and needed no changes; only the handful
of crate::wire:: call sites elsewhere in client-core/client-runtime were
rewritten to protocol::. packages/protocol's fixture-corpus conformance
mechanism (corpus.json consumed by both fixtures.test.ts and
codec_conformance.rs) keeps working unchanged, just at its new path.

Verified: cargo test --workspace, cargo clippy --workspace --all-targets
-D warnings, cargo test -p client-core --features marmot, cargo clippy
-p client-runtime --features marmot, and apps/mobile/src-tauri in both the
default and --no-default-features configs all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t yet wired to consumers)

Adds #[derive(specta::Type)] alongside the existing serde derives across
protocol, client-core's stores, and client-runtime's View/Intent/CoreEvent
types, plus a tauri-specta Builder (corebridge::ts_bindings_builder) and an
#[ignore]d regeneration test (tests/gen_ts_bindings.rs) that exports the
whole graph to apps/mobile/src/core/nativeCoreTypes.ts.

Notable fixes needed to get a correct export, kept as inline comments where
they land: Tristate<T>'s wire shape isn't a plain enum (Keep is a
skip_serializing_if-omitted field, not a variant) so it gets a manual
specta::Type forwarding to Option<T> instead of a derive; two genuinely
untyped serde_json::Value fields are overridden to specta_typescript::Unknown
rather than expanded (Value is structurally recursive and overflows the
exporter's stack when inlined); every u64/i64/usize field reachable from the
export graph is overridden to specta_typescript::Number (this app's actual
values — timestamps, counters, seq numbers — stay well inside JS's safe-
integer range, so this matches current behavior rather than exporting
bigint); SeqRange (a bare (u64, u64) tuple) is overridden per field to
[number, number]. core_send/core_publish's `message` parameter changed from
serde_json::Value + a manual decode_phone_to_bridge call to a plain
PhoneToBridge parameter — Tauri's own deserialization now does exactly what
that decode call did, one layer earlier, with no behavior change.

specta/specta-typescript become unconditional (non-optional) dependencies of
protocol and client-core — pure derive macros with no runtime footprint, so
an apps/mobile/src-tauri --no-default-features build should still dead-code-
eliminate them, but this is disclosed in that crate's own feature-flag
comment since it's no longer a literally-unchanged compile graph.

Deliberately NOT included in this commit: the regenerated
nativeCoreTypes.ts itself, or the consumer-side fixes it requires.
Regenerating surfaces real, mostly-welcome shape corrections (e.g.
Option<u64> fields now correctly type as `T | null` instead of the old
hand-written `T | undefined`) alongside tauri-specta's dual-phase
Serialize/Deserialize type splitting for any type with asymmetric
skip_serializing_if fields anywhere in its dependency graph — both require
updating roughly a dozen apps/mobile/src TS files, which is the next step,
not this one. apps/mobile stays on its current hand-written
nativeCoreTypes.ts and typechecks clean.

Verified: cargo test --workspace, cargo clippy --workspace --all-targets
-D warnings, cargo test -p client-core --features marmot, cargo clippy
-p client-runtime --features marmot, apps/mobile/src-tauri in both the
default and --no-default-features configs, and the gen_ts_bindings test
itself (run manually — it writes to the working tree, so it stays #[ignore]d
in normal `cargo test`) all green. pnpm -r typecheck confirms apps/mobile
(and everything else) is unaffected since nativeCoreTypes.ts didn't change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s consumers

nativeCoreTypes.ts is no longer hand-written — it's now a thin facade
(export * plus 7 direction-picking aliases) over nativeCoreTypes.generated.ts
(committed as-produced by tests/gen_ts_bindings.rs, never hand-edited).
Rust's View/Intent/CoreEvent types are the source of truth; regenerating
after any of them changes is now a mechanical `cargo test ... -- --ignored`
away, with CI (next step) failing the build if someone forgets to.

Why a facade, not a straight rename: tauri-specta emits a `X_Serialize`/
`X_Deserialize` pair (plus an `X = X_Serialize | X_Deserialize` union under
the bare name) for any type whose dependency graph has an asymmetric
skip_serializing_if field anywhere in it — several `protocol` leaf types
carry one. Every one of these types crosses this app's wire in exactly ONE
direction (Views/CoreEvent are Rust-serialized and never deserialized back;
Intent is TS-constructed and Rust-deserialized), so the facade picks the
real direction per split type instead of forcing every read site to
redundantly narrow a case that can't occur.

Fixed consumer-side, surfaced by regenerating (all real, most welcome):

- `event.stateChanged?.slice === X` / `intent.someVariant` truthy checks
  replace `'x' in y && y.x.field` everywhere (9 native adapters + ~15 test
  files) — TS's `in` narrowing doesn't exclude specta's `{ x: T } & { other?:
  never }` mutual-exclusion encoding the way it does a plain discriminated
  union, so the old pattern always left `y.x` typed as "possibly undefined."
- `Option<u64>` fields (`lastHeartbeatAt`, `protocolVersion`, …) now
  correctly type as `T | null` (matching what Rust actually sends — these
  fields have no `skip_serializing_if`, so they're never actually absent)
  instead of the old hand-written `T | undefined`; ~15 test fixtures gained
  the two fields they were previously allowed to omit.
- `skip_serializing_if` fields (`diff`, ack-status maps, `RemoteSessionInfo`'s
  optional fields, …) specta types conservatively as `T | null` even though
  they're only ever actually omitted — normalized null-to-undefined at the
  handful of seams where a native adapter still feeds a pre-migration
  domain-store shape that predates the distinction.
- Plain Rust floats (`SettingsData.ui_scale: f64`, no `Option`) type as
  `number | null` because `serde_json` serializes NaN/Infinity as `null`;
  falls back to `UI_SCALE_DEFAULT` (the value is already clamped Rust-side,
  so this is a type-level formality, not a real runtime case).
- `PairingView.phase` crosses as a plain Rust `&'static str`, not a literal
  union specta can see — kept the same trust the hand-written type placed
  in this value, now as an explicit cast with a comment instead of an
  implicit one.
- `core_send`/`core_publish`'s `message: PhoneToBridge_Deserialize` parameter
  needed no fixes — that simplification already landed in the prior commit.

Verified: `pnpm -r typecheck` and `pnpm -r test` both green across every
workspace package (1282 tests total; a `packages/core`/`packages/testkit`
run needs `git` on PATH and can show one unrelated flake without it — both
confirmed passing in isolation with git present).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a "TS bindings drift check" step to the cargo job: regenerate via the
same command a developer runs (./codedeck gen-protocol-types), then
git diff --exit-code the committed file. Runs whenever src-tauri OR
crates/** changed, since either can move the exported View/Intent/CoreEvent
shapes — broadens the existing Tauri-toolchain install steps' condition to
match (a crates/client-runtime-only change still needs to build src-tauri to
run the check). Same anti-drift shape as crates/protocol's fixture-corpus
check.

./codedeck gen-protocol-types wraps the regeneration in a throwaway Rust
container (same pattern as the existing check/typecheck/test commands' Node
container), so no local toolchain is needed to keep the generated file
current after a Rust type change.

Also regenerates nativeCoreTypes.generated.ts once more: the previous commit
built it before gen_ts_bindings.rs's header-comment text was finalized,
leaving a one-line drift the check above would otherwise have caught
immediately on the next CI run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ription, per-relay status

Root cause of "connection dot stays orange after close/reopen, a bridge-
confirmed session doesn't show up": every native adapter (nativeConnection,
nativeMachines, nativeDm, nativeMarmot, nativeOutbox, nativePairing,
nativePendingSessions, nativeQuickPrompts, nativeSettings, nativeUi) fired
its one-shot initial snapshot fetch and its live-update listener
registration unawaited and concurrently, racing with no guaranteed order.
Rust only re-announces state on an actual transition (never replays history
for a late listener), so on an ordinary reopen against an already-connected,
already-populated core (the singleton CoreBridge outlives a WebView reload;
core_init is explicitly idempotent), nothing is about to transition again
soon — the one-shot fetch was the ONLY chance to observe reality, and a lost
race or a silently-swallowed rejection stranded the store at its
construction-time default forever.

Fixed via a new shared `hydrateFromCore` helper (core/stores/
nativeHydration.ts): await listener registration to actually complete
BEFORE firing the snapshot fetch. Once registration resolves, any future
transition is guaranteed to arrive over the listener, and the fetch that
follows reads whatever is true at that instant — no version/generation
counter needed. Every adapter with this pattern now goes through it.

Also fixed while tracing the same code:
- Sidebar.tsx's per-machine presence dot read `core.connection.getState()
  .presence(...)` directly (non-reactive) inside MachineGroup, repainting
  only because a sibling subscription happened to force a re-render. Now
  subscribes to the two pieces `presence()` actually reads (connection
  status, this machine's cached heartbeat).
- corebridge.rs's core_init logs which branch it took (fresh init vs.
  idempotent no-op reusing the running Core) — a diagnostic for confirming
  on a real device whether the singleton actually survives a close/reopen,
  as the fix above assumes.
- nativeDm.ts's and nativeMarmot.ts's `subscribed` field was hardcoded
  `true` — there's no independent per-traffic-class subscription concept
  left post-F2b (client-runtime multiplexes everything over one socket), so
  both now derive it from the shared `connection.status` instead of a
  constant that could never show a real disconnect.

New: a per-relay status dot in Settings (CoreObserver::connection_changed
and Core::connection_status both now carry a `connected_relays` snapshot,
sourced from Router's already-tracked-internally `connected: HashSet
<String>` — the plumbing existed, it just never left the transport layer).
Not a fully live push: relay-level connect/disconnect has no observer
channel of its own (WsTransport has no CoreObserver reference, and wiring
one would be a much larger change than this warrants), so the dot refreshes
on every reconnect-class transition rather than on every individual relay
flap — documented inline rather than silently overclaiming full liveness.

Verified: cargo test/clippy across the full matrix (root workspace, marmot
feature, both apps/mobile/src-tauri feature configs) and pnpm -r typecheck
+ test (apps/mobile: 64 files / 553 tests, including new coverage for
subscribed's reactivity and the relay dot) all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…flake

Scenario B2 (and the same disconnected-burst pattern in A and B1) waited
only on transcript.seqHigh() before reconnecting the simulated phone and
running the sync-drop scenario. seqHigh bumps synchronously at append call
time, but the bridge's onOutput (which drives the live 24515 publish) fires
only after the durable write flushes (CDX-060) — asynchronously, with no
bound on how far behind it can lag under load.

Ephemeral Nostr kinds are delivered only to whoever is subscribed at the
exact instant publish() runs. Under CPU contention, a straggler live-output
publish for a burst the phone was meant to stay dark for could land after
world.sim.connect() re-subscribed, delivering some of the deliberately
'dropped' chunk's entries over the live channel instead of the sync
channel — no dropChunkIf/dropNextSyncChunks rule ever sees that path. That
corrupted the healing resync's computed gap ([[1,50]] observed as [[1,1]]),
reproduced reliably (~1 in 5) by running the full suite under load and
root-caused with temporary instrumentation showing the resync's own
haveRanges already covering [2,61] before it ever ran.

emitAndSettle() waits on the relay's own publish count catching up to the
expected new-entry count, not just seqHigh, closing the window before the
phone reconnects. Verified with 40 repeated full-suite runs under load
(0 failures, versus ~4/20 before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
android.app.RemoteServiceException$ForegroundServiceDidNotStopInTimeException
was crashing the whole process, twice roughly a day apart in the field.
Android 15+ time-limits the dataSync foreground service type (the existing
comment on the startForeground() call already documents the ~6h/24h budget
for a NEW start); this is the OTHER half of that same platform behavior — a
RUNNING instance whose window runs out gets Service.onTimeout(startId,
fgsType) instead of just being killed, and is expected to call stopSelf()
back promptly. Without an override, the system's own force-stop didn't
complete inside its timeout and tore down the whole process instead of just
this service.

The service owns no sockets (the WebView's own relay connection recovers on
its own — see the class doc), so stopping on the spot is always safe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tore

Real-device dogfooding surfaced two related pushes that never arrive after
the app is backgrounded and reopened, even while the bridge confirms it
received everything the phone sent:

1. corebridge.rs's TauriObserver discards every app.emit(...) result
   ('let _ = ...'), and Android can suspend a backgrounded WebView's JS
   execution for long enough that a state_changed push racing that window
   is silently dropped -- not just at boot (the listener-registration race
   hydrateFromCore already closed), but at any later point a background/
   foreground cycle straddles a real state change. Rust never repeats an
   announcement once made, so a push-only design has nothing left to notice
   or retry with. hydrateFromCore now also re-pulls the same snapshot on
   every Tauri resume/focus event (NativeCore.onResume, backed by the same
   injected listen() every other subscription already uses) -- never trust
   a push alone to have survived whatever the OS did while backgrounded.

2. Per-relay connect/disconnect (Router::relay_connected/disconnected,
   driven straight from each relay's own WS task) never runs through
   dispatch's status-transition gate at all: NostrClient's aggregate
   on_socket_close only fires once EVERY relay for a subscription is dead,
   so one relay of several flapping leaves the overall ConnectionStatus
   completely untouched and the observer never learns the connected-relay
   set changed. This is why Settings' per-relay dot could stay colorless
   (frozen on the empty set an early snapshot happened to catch) and, by
   extension, why the DM dot (which now correctly derives from the shared
   connection status) could look permanently stuck. The existing 30s stale
   watchdog now also compares the connected set on every tick and notifies
   the observer when it changed, independent of any status transition.

Every native store adapter's hydrateFromCore call is updated for the new
onResume parameter; a new client-runtime test drives two mock relays and
confirms a lone relay dying is invisible to dispatch's own gate but caught
by the watchdog within one tick.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t for runtime validation

apps/mobile now sources every wire TYPE from nativeCoreTypes.ts (generated
from crates/protocol/client-core/client-runtime) instead of hand-maintained
zod-inferred types from @codedeck/protocol. 39 of 48 files that imported
protocol only for types are migrated; the remaining 9 keep the import
because they need protocol's actual RUNTIME behavior (effortLevelSchema/
permissionModeSchema backing Settings' dropdowns, decodeBridgeToPhone
re-validating the core://message decode boundary, CAPABILITIES and relay
constants) -- there is no generated equivalent for a zod validator, and
dropping the re-validation at an IPC boundary would be a real regression,
not a cleanup.

Two real problems surfaced and got fixed along the way, not papered over:

- BridgeToPhone (the bridge-to-phone wire enum) was never reachable from
  the generated bindings at all -- nothing in ts_bindings_builder() ever
  registered it, since core_send/core_publish only carry the OTHER
  direction (PhoneToBridge) and the core://message event is emitted ad hoc
  outside tauri-specta's typed event system. Added .typ::<BridgeToPhone>()
  in corebridge.rs and regenerated.

- PhoneToBridge/BridgeToPhone are internally tagged
  (#[serde(tag = "type", rename_all = "kebab-case")] -- confirmed against
  packages/protocol/fixtures/corpus.json that the real wire JSON is flat,
  {"type":"input",...}), but specta's generated TypeScript for that repr
  still nests each variant's payload under an extra key matching the
  variant name. Confirmed directly: TypeScript accepts only the nested
  shape for PhoneToBridge_Deserialize and rejects the real flat one.
  nativeCoreTypes.ts's message unions are reconstructed instead from
  protocol's own inner message structs (InputMsg, PairAckMsg, ...), which
  don't have this problem since they're plain structs, not enum variants,
  each intersected with its own literal `type` tag matching the corpus
  fixtures.

A third, narrower issue: specta models every Option<T> field as `T | null`
even when #[serde(skip_serializing_if = "Option::is_none")] means the field
is only ever omitted, never actually serialized as null. The old zod
schemas modelled these as `T | undefined` only, which is what every
consumer here is written against -- RemoteSessionInfo/UsageData/
ProviderProfileInfo/ProviderModel get narrowed overrides in the facade,
field by field, checked against crates/protocol/src/common.rs's actual
skip_serializing_if attributes; a genuinely nullable field (title,
subscriptionType) is left alone.

Verified: full Rust workspace test+clippy clean, apps/mobile/src-tauri
clippy clean in both feature configs, ./codedeck check green (all 7
packages, 555 mobile tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…contracts

BridgeApiLike.ingest, DmStoreState.ingest, and MarmotStoreState's
ingestGiftWrap/ingestGroupMessage all typed their parameter as nostr-tools'
NostrEvent, even though every one of them is a confirmed no-op under native
mode (Rust owns ingest entirely now) and every test that exercises them
already bypasses the type with an `as never` cast rather than constructing
a real event. The only thing this bought was a nostr-tools import in
contract types the UI and its native adapters otherwise have no reason to
know are Nostr-shaped at all.

Retyped all four as `unknown`. nip19 stays in dm.ts -- npub/hex encoding is
a real identity-format concern this app's UI genuinely deals with, not
transport leakage, and keypair assignment (core/crypto.ts) is deliberately
left alone as the seam a future external signer (Android Intents) would
want anyway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bscribed

`Core::spawn()` always started the subscription author list (`HostBridge.
machines`, and the loop's own mirror of it) empty, regardless of what was
already in the persisted `machines` store from a prior run. Nothing in the
ordinary connect path ever repopulates it for an already-paired machine:
`refresh_authors()` — the only thing that writes to it — is called solely as
a reactive side effect of a pairing-related route or intent result, never
from the plain connect/reconnect/post-connect-reconcile sequence a normal
boot goes through.

`NostrClient::connect()` treats an empty author list as vacuous and opens
none of the three bridge subscriptions, so a phone that reopens without going
through a fresh pairing exchange this process lifetime never receives another
30515 heartbeat or session update from a machine it paired in a previous run
— even though its own `refresh-sessions` request still reaches the bridge
fine (nothing gates outbound publishes on the author list, only the inbound
subscription filter). This is the mechanism behind a machine's presence dot
staying stuck after the very first pairing session and a bridge-confirmed
session never showing up on the phone.

Seed the author list from the same persisted `machines` store (plus any live
pairing candidate) that `refresh_authors()` itself reads, at construction
time, so the very first `connect()` a process makes already has the right
subscription instead of relying on a later event to fix it up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d dual-mode design

Every native store adapter still carried write methods and lifecycle hooks
that made sense when the phone could run either the WebView-driven local
composition or the Rust native core, and needed one shared interface both
could satisfy. That composition was deleted in F2b (native-core is now the
only one), so a whole family of methods became permanent, unreachable no-ops:
production never calls them, and each one's own doc comment already said so.
Removed, rather than left as documented dead weight, from every store
interface and its native adapter implementation, plus the tests that existed
solely to pin the no-op behavior in place:

- BridgeApiLike: input, ingest, dispatchDecoded (Rust's Router owns all
  inbound routing and the outbox lifecycle now; nothing decodes a wire
  message in TS to hand to these).
- DmStoreState: start, stop, ingest — client-runtime owns the 1059
  subscription itself; there is no separate subscription lifecycle in TS.
- MarmotStoreState: start, stop, ingestGiftWrap, ingestGroupMessage — same
  reasoning as DmStoreState, for the 445 subscription and MDK engine seam.
- MachinesStoreState: every write method (registerMachine, removeMachine,
  applySessionList, applySessionUpsert, applySessionReplaced,
  updateSessionInfo, noteFirstUserMessage, userRemoveSession, dismissSession,
  restoreSession, applyUsage, applyGsd, applyModels, applyProviderProfiles)
  — the Rust Router folds every bridge message into MachinesView directly.
- PairingStoreState: handlePairAck — the Router folds a pair-ack into the
  pairing store directly.
- PendingSessionsStoreState: applyPending, resolve, applyFailed, sweep — the
  Router applies session-pending/ready/failed directly and runs its own
  periodic sweep.
- TranscriptStoreState: applyOutput, applySyncBegin, applySyncChunk,
  applySyncEnd, onReconnect, removeSession — the Router applies every
  Output/Sync*/CloseSessionAck message into the real persistent store.
  ensureSynced/retrySweep stay: production still calls both (the sync-request
  path and the periodic connectivity retry), even though Rust's own
  reconciliation makes them no-ops here too.
- OutboxStoreState: confirm, fail — the bridge-message ingest path is the
  Router's job. sweep stays: main.tsx's connectivity tick still calls it.
- UiStoreState: selectMachine, markSessionUnread, clearSessionUnread,
  applyCredentialsAck, applyDeviceConfigAck, applyProviderProfileAck,
  setUndoToast — each transition already lands via the matching Intent's
  Rust-side effect. markCardResponded and the three noteXSent methods stay:
  the first still has real call sites (an optimistic local mark ahead of the
  refresh), the other three cover a genuinely open gap (no Rust Intent exists
  yet for sending those three commands, only for receiving their ack).
- ConnectionStoreState: checkHeartbeats — client_runtime::Core runs its own
  copy of the dead-subscription watchdog; the one call site this existed for
  was itself deleted with the local composition.

No behavior changes: every removed method was already a no-op, or (for
sweep/ensureSynced/retrySweep/markCardResponded, kept) already redundant with
what Rust does on its own. Header comments that explained the no-op in terms
of the retired composition are rewritten to state the current architecture
plainly instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Its whole job was reconfiguring androidx.webkit.ProxyController — a
process-wide override for the WebView's OWN network requests, including the
relay WebSockets the pre-F2b WebView-driven composition opened directly. That
composition is gone: native-core's client_runtime::Core dials its own SOCKS5
proxy at core_init (CoreConfig.proxy/tor, sourced from the same persisted
torProxyEnabled setting), so the WebView no longer originates any Nostr
traffic for the plugin to have routed. main.tsx already stopped attaching it
during the F2b cutover — this removes the plugin crate, its Android module,
its generated permission manifests, the TS wrapper (platform/torProxy.ts,
which had zero remaining callers or test coverage), and its capability entry,
closing the follow-up docs/CLIENT-CORE.md flagged at the time.

The torProxyEnabled setting and its Settings screen toggle are unaffected —
they still drive CoreConfig.proxy/tor directly and remain the real, only
mechanism controlling Orbot routing now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nine files still imported the wire package for real runtime behavior rather
than types: effort-level/permission-mode enumeration and validation, the
custom-providers capability string, the default relay lists, the
provider-base-url rule, and the core://message decode boundary. Each is
resolved the same way the layering rule already resolves @codedeck/core:

- `platform/nativeCore.ts`'s `send`/`publish`/`onMessage` (and the
  `decodeBridgeToPhone` call inside `onMessage`) had zero production callers
  — the phone dispatches Intents and reads *View() snapshots exclusively now,
  the same F1-era transport-only surface `main.tsx`'s send/publish never grew
  into. Deleted outright, along with the matching dead stubs in every native
  adapter test's `NativeCore` fake and the fixture's `emitMessage` helper.
- Everything else (EffortLevel/PermissionMode enumeration and validation, the
  CAPABILITIES.customProviders string, DEFAULT_RELAYS/MARMOT_RELAYS,
  isValidProviderBaseUrl/PROVIDER_BASE_URL_ERROR) is real, still-needed
  behavior. New `core/protocolConstants.ts` mirrors each value from its
  `crates/protocol` source (common.rs, capabilities.rs, relays.rs) WITHOUT
  importing the TS package — the same reasoning `core/crypto.ts` already
  applies to `@codedeck/core`'s crypto helpers. The Rust side stays
  authoritative for anything that's actually applied (a provider profile is
  re-validated independently inside `Intent::SetProviderProfile`'s handler,
  so a drift here could only produce a confusing client-side moment, never a
  bypass of the wire rule), and the codec-conformance fixture corpus already
  fails loudly if `EffortLevel`/`PermissionMode`'s wire spelling drifts
  between the zod schema and the Rust enum this file's values are read from.

The phone now depends on neither `@codedeck/core` nor `@codedeck/protocol` at
runtime. Removed the now-unused `@codedeck/protocol` dependency from
`apps/mobile/package.json` and tightened `layering.test.ts`'s guard (it only
had a narrow, documented exception before) into a full ban alongside the
existing `@codedeck/core`/`@codedeck/testkit` checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`codedeck-bridge run --test-mode` serves every session from a new
`TestModeSdkFacade` instead of spawning a real Claude Code subprocess — no
`claude` executable, no API key, no network to Anthropic required at all.
Its session recognizes five input commands and drives the SAME
`canUseTool` callback and `SdkMessage` shapes a real session produces, so
everything downstream of the SDK boundary (the adapter, the permission
broker, the phone UI) runs its real, unmocked code:

- /test-message [text] — a plain assistant reply, or a default greeting.
- /test-tool — a Read tool_use, arbitrated through the real permission
  broker (auto-allowed in yolo mode, a real card in plan/acceptEdits).
- /test-plan — an ExitPlanMode tool_use with an example plan, producing the
  same plan-approval card and yolo/edit/deny choice a real plan does.
- /test-question / /test-question-multiple — one or two AskUserQuestion
  groups; once the phone answers, the session replies "received <answer>".

An unrecognized command gets the list of the ones that exist rather than
silent nothing. `startBridge` skips `resolveClaudeExecutable` entirely under
test-mode (there is nothing to resolve), and the startup banner marks the
run as TEST MODE so it can't be confused with a real one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. The `last_stored_seen` cursor advanced in memory on every stored-kind
   (4516/30515) event, but `note_stored_seen` never told the `Kv` port about
   it — every restart re-hydrated at 0 and re-fetched a peer's entire stored
   response history instead of resuming from where it left off. The read
   side (`LAST_STORED_SEEN_KEY` hydration) and the write helper
   (`Persister::save_last_stored_seen`) already existed; only the live wire
   between them was missing. `note_stored_seen` now also fires
   `Msg::NoteStoredSeen`, persisted from the loop's own async context (the
   trait method itself is synchronous, called from inside the transport's
   task, with no `Kv` access of its own).

2. `WsTransport::dial` accepted a cleartext `ws://` relay to any host —
   the "cleartext ws:// only for .onion" rule had no transport-level
   enforcement at all, and turned out to have no enforcement anywhere else
   either (checked: neither the phone nor the bridge ever validated a relay
   URL's scheme). `dial` now refuses a non-tls, non-.onion, non-loopback URL
   before attempting to connect. Loopback stays cleartext-allowed for the
   same reason `is_valid_provider_base_url` already carves it out elsewhere
   in this codebase: it never leaves the machine — and every mock-relay test
   in this file depends on exactly that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… TS mirror

`protocolConstants.ts` (this session's earlier fix for the last
`@codedeck/protocol` runtime import) still hand-copied effort levels,
permission modes, default relay lists, and the provider-base-url error text
as TS literals — exactly the kind of "someone reads the Rust source and
retypes it" duplication the whole native-core migration has been closing
everywhere else. It now has a real Rust-sourced view instead:

- `crates/protocol/src/defaults.rs`: `protocol_defaults()` builds a
  `ProtocolDefaults` struct straight from this crate's own constants
  (`common::{EffortLevel, PermissionMode}`, `capabilities::CUSTOM_PROVIDERS`,
  `relays::{DEFAULT_RELAYS, MARMOT_RELAYS}`, `common::PROVIDER_BASE_URL_ERROR`)
  — one place enumerates the enum variants, everything else is a direct read.
- `corebridge.rs`'s `core_defaults` Tauri command exposes it — a pure read
  needing no running `Core`, callable before `core_init` (same shape as
  `core_available`).
- `main.tsx` calls it once at boot and feeds the result to
  `protocolConstants.ts`'s new `applyProtocolDefaults`, which reassigns that
  module's own `let` exports. ES module imports are live bindings, so every
  file that already imported `EFFORT_LEVELS`/`PERMISSION_MODES`/
  `DEFAULT_RELAYS`/`MARMOT_RELAYS`/`CAPABILITIES`/`PROVIDER_BASE_URL_ERROR`
  sees the real values with no code changes at any of those call sites — the
  hand-written literals survive only as the pre-boot fallback (and what
  plain unit tests still see, via each fixture's own `defaults()` stub).

`isValidProviderBaseUrl` stays a local pure function rather than a
Rust round-trip: Rust remains authoritative in the sense that matters (a
profile is re-validated independently when `Intent::SetProviderProfile`
actually applies it), and a synchronous per-keystroke validator has no good
async equivalent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e codebase

A sweep for every "known gap" / "genuine gap" / "real gap" comment left over
from the F2b native-core migration, each closed at the root:

1. createFolder (Intent::CreateFolder + CoreEvent::FolderAck): the wire
   messages (CreateFolderMsg/FolderAckMsg) already existed end to end on the
   bridge, but nothing on the phone's native side could reach them. Added
   the Intent, its dispatch (builds and sends CreateFolder), the route()
   handling of the bridge's folder-ack (surfaced verbatim, no store side
   effect — a one-shot RPC-style exchange, not persisted state), and the
   CoreEvent that carries it. `createNativeBridgeApi.ts`'s createFolder no
   longer rejects: it dispatches with a fresh request id, registers its
   CoreEvent listener before the dispatch (so a same-tick reply can't be
   lost), and resolves with the matching ack or times out.

2. note*Sent optimistic "saving" state (nativeUi.ts): the doc comment claimed
   set-credentials/set-device-config/set-provider-profile sending had "not
   been ported" — false; those Intents were already dispatched by
   `nativeBridgeApi.ts` and had real call sites (MachineCredentials.tsx,
   MachineProviders.tsx, MeshSection.tsx) expecting the optimistic "saving…"
   state these three methods were supposed to set. They were still no-ops.
   Now they set a local `{ state: 'saving', at }` entry, the same pattern
   `setPlanApprovalChoice` already uses, overwritten by the real ack on the
   next view refresh either way.

3. The in-app attention chime never actually fired: `NotifyEffect::Ping` was
   matched and then silently dropped in `interpret_route`, AND separately
   `Router::new`'s conservative `ping_available: false` default was never
   overridden anywhere — the pure `decide_ping` logic could never even run.
   Added `CoreEvent::Ping` (a bare unit variant) and wired both: the Router
   now runs with `ping_available: true`, and the WebView plays
   `platform/pingSound.ts`'s tone (previously dead code with zero callers)
   through a new `onCoreEvent` listener in `main.tsx`.

4. Toggling Tor live now actually reconfigures the transport instead of
   requiring an app restart: `WsTransport::set_proxy` (redials every open
   connection through the new setting) and `ReqwestHttpFetch::set_proxy`
   (rebuilds the client — a proxy bakes in at construction) are wired to
   `Intent::SetTorEnabled` via `Loop::tor_proxy_address`, which remembers the
   SOCKS5 address independent of on/off state (the Intent only carries a
   bool). This needed `InitConfig.proxy` to actually reach Rust even when
   Tor starts off — `createPhoneCoreNative.ts` used to null it out in that
   case, leaving nothing to switch back to.

Verified: `cargo test --workspace` + `cargo clippy --workspace --all-targets
-- -D warnings` at the repo root, `apps/mobile/src-tauri` in both feature
configs (test + clippy + `--no-default-features` check), and `pnpm -r
typecheck`/`test` across all 6 TS packages — all green. TS bindings
regenerated for `ProtocolDefaults`... `Intent::CreateFolder`, and
`CoreEvent::{FolderAck,Ping}`.

Two things checked and deliberately left as documented, permanent
architecture rather than gaps: `uploadImageBlossom`/`uploadImageChunk`
(their two-stage shape is already one step inside `Intent::SendSessionImage`
— shimming them individually would double-upload) and mesh auto-join
(`Intent::mesh_join` — expected to wait for the mesh's own F6 Kotlin phase).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.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