Elaborates STEERING.md's Architecture and Reliability Doctrine sections. Written 2026-07-10 while designing waypoint announcements (M4b), the app's first real-time side effect — but the principles here govern every future feature (vario beeps, airspace alerts, altitude callouts).
Every behavior falls into one of three classes, and the class — not convenience — decides which layer owns it:
| Class | Definition | Examples | Owner |
|---|---|---|---|
| Derived state | Pure function of fix timestamps; replay reconstructs it identically | takeoff/landing detection, flight of record, stats, all visuals | JS engine |
| Real-time side effect | Only meaningful at the moment it happens; replay cannot redo it | "Waypoint reached" speech, vario beeps, airspace warnings | whoever is guaranteed awake |
| Platform capability | Requires an OS API | GPS/baro capture, permissions, TTS voice output | native shims |
"Whoever is guaranteed awake" differs by platform, and that asymmetry is the whole design:
- Native app: the webview may be suspended (backgrounded) or dead (jetsam), but the app process survives under background-location. The Rust core is the awake party.
- PWA: there is no supported background recording (a hidden tab loses its geolocation watch — this is precisely why the native apps exist). Foreground is the only supported state, so JS is always awake when it matters and can own side effects directly.
- "JS handles as much as practicable" — satisfied by the class table: everything replayable stays in JS. The engine, WAL, detection, finalization, storage, and every pixel remain exactly where they are. Rust gets only what must act while JS may be suspended. Nothing moves to Rust for performance, taste, or symmetry.
- "Rust is the natural home for reusable native-necessary logic" — yes, with a sharpened definition of necessary: real-time side-effect decisions plus the durable fix core (below). Swift/Kotlin are forbidden from containing decisions; they sense and actuate.
- "Foreground PWA operates the same way" — achieved by seams, not by
sharing a runtime. The same interface, two providers (native-backed and
web-backed), selected the same way
PositionSourcealready is. - "Consider the existing abstractions" — the design deliberately rhymes
with
engine/: every seam below follows thePositionSourcepattern (one interface, a web implementation, a native implementation, mock where useful, selected in one place by platform detection).
Five primitives, no business logic, no storage:
capture— CoreLocation / FusedLocation → in-memory buffer of raw fixesdrain()— return-and-clear the buffer (called by Rust)permissions— check/requestspeak(text)— AVSpeechSynthesizer / TextToSpeech, audio session configured to duck the pilot's musicshareFile(name, content)— system share sheet (WKWebView has no download manager, so exports leave the app through it)
Everything currently in the Swift plugin beyond these (session file, JSONL, cursors, torn-tail handling) migrates down into Rust so Kotlin never has to re-implement it.
- Ingest: a task polls
drain()at 1 Hz viarun_mobile_plugin(supported API; ≤1 s latency is invisible at 1 Hz GPS; a hard kill loses ≤1 s of in-memory fixes — within the accepted torn-tail class). Upgrade path if a future feature needs sub-second reaction: directextern "C"push from the sensor thread. Documented, not built. - Durable session log: the append-only fix log and session lifecycle,
owned here,
cargo test-able on Linux CI (today this logic is Swift and only testable in a Mac simulator — moving it is a large test-rigor win). - Serving:
fixes_since(cursor)as a plain Tauri command. The JS engine'snativeSourcekeeps its exact contract; multiple consumers, each with its own cursor, are inherent to the design. - Announcer: an in-process consumer evaluating waypoint geofences on
ingest (event-driven, no polling) and calling
speak(). Holds re-arm hysteresis (must exit radius before re-announcing). Waypoint config is pushed from JS on every change and persisted beside the session so a webview death cannot silence the callouts. - Announcements are deliberately not journaled: if the process dies, the callout is simply missed. Fire-and-forget is the correct durability class for audio.
RecordingEngine, IndexedDB WAL, detection, lifecycle
(recording → landed → ended), replay, storage, UI. Background parity via
burst replay, per STEERING.md.
One overlay sits above that lifecycle: blocked, a live-source health
state (permission denied, reduced accuracy, recorder held by another tab).
It is derived from an in-memory error, never journaled, and strictly
pre-takeoff — the error setters refuse to install a blocking error once a
flight has started, so nothing can block a flight in progress. It never
touches the WAL, the track, or finalization, so burst-replay byte-identity
is unaffected.
Every source is responsible for knowing its own refusals by its platform's
means, and the engine believes what it is told. The native source ASKS
(check_permissions, the real CoreLocation authorization); the browser
source EXPERIMENTS (a fresh watch, and a wall-clock latch on the
reduced-accuracy fix signature, because the browser Geolocation API has
nothing to ask). Wall-clock detection is legitimate — the signature is
partly an absence of fixes, which no function of fix timestamps can
observe — but it belongs to the source that needs it. The engine holds no
timers.
One refusal is retracted by evidence rather than by report, and that is
the engine's remaining share of the fix signature: an imprecise takeover
keeps its watch alive on purpose, so handlePositions clears it on the
first non-reduced fix. A bounce would tear down the very watch producing
the disproof, which is why this one does not travel the report channel.
One channel carries it. A source's onRefusal reports what stands RIGHT
NOW: a refusal, or null for "nothing refuses any more". Both are reports
about the same thing, so the engine has one place to apply one set of
rules (handleRefusal) — a started flight is never blocked, busy is
nobody's to replace, a non-blocking report never tears down a takeover, an
unchanged reason is not republished, and null buys a fresh watch. A
pilot who trades one refusal for another (Precise Location off, then
Location Services off) sees the screen follow, and on a source that can
ask its platform, without a bounce for a refusal already known.
The one capability a source declares is revive(): "find out whether you
still refuse, and report it." Every foreground and the error screen's Try
Again forward to it, and what it costs is the source's business — a
browser reruns its watch (Safari kills one silently while the page is
backgrounded, and a Settings trip is exactly that); the native source asks
CoreLocation once, and only from a state it actually refused from, because
reporting null bounces the watch and bouncing a healthy capture would
stop CoreLocation and delete the native session log for nothing.
The WAL hydrates the engine exactly once per page load; after that, in-memory state is authoritative and WAL reads are never re-applied. A replay burst delivers many fixes in one task, so any WAL read racing it is stale by construction — re-applying one tears fixes out of the live buffer (the sleep-through-takeoff straight-line bug).
Consumers follow signal-then-read: the engine pushes no payloads and
has no per-fix event stream. It fires one coalesced "changed" signal per
task (a thousand-fix replay burst is a single wake-up) and consumers read
snapshotSync() — a pure, cached view whose identity is stable between
changes, so React binds to it directly via useSyncExternalStore and no
consumer maintains its own mirror of the track. Every read is a complete,
consistent view; there is no delta protocol to fall behind on. Within a
session snapshot.track is append-only and prefix-stable (the live map's
incremental GPU upload builds on this contract; it rebuilds if ever
violated); session boundaries reset it.
The engine sees the same surface on every platform — a single injected
CoreClient { source, setWaypoints } (nativeCore /
webCore) — and runs ONE code path: establish the watch,
push setWaypoints. All lifecycle logic rides the
watch, exactly as it does natively:
- Native:
start_watchstarts capture AND the Rust core (detection reset on a fresh session);stop_watchstops capture and clears the session + waypoint config;set_waypointsis config-only; the Rust ingest thread announces and speaks. - Web:
engine/core.tsis core.rs's TS twin — the same surface, function for function (start/stop/setWaypoints/ingest(batch)), carried by the same watch (webCorewraps the browser source: watch start →core.start, teardown →core.stop, each batch →ingest→ speak). Fixes move through the whole JS seam in batches, mirroring Rust'singest(&[Fix]): a native poll response, a simulator tick, or a single live browser fix is oneonPositions(batch)call, so a backlog replay is structurally one delivery — one WAL flush, one change notification. A change to the web lifecycle that has no named Rust counterpart is a smell by construction. - The engine pushes config in exactly two places on every platform:
establishing the watch (session start AND post-reload rehydration) and
addWaypoints. It never runs announce lifecycle logic itself. - The simulator is just another
PositionSource(simulatorSource.ts), wrapped by the same web core: mock and real GPS share the ENTIRE engine — WAL, replay, and every status derivation. Compressed delivery is simply a continuous burst replay, which the fix-time doctrine already handles. - Audio has a single authority — Rust speaks on native, the web core speaks on the PWA; never both.
- Waypoint config is flight-scoped. Starting a flight copies the plan's
pins into the session (
engine/session.ts→ engine start options → WAL); the plan is never read mid-flight, and additions during a flight join that flight only (engine.addWaypoints). Detection state resets per flight in both languages:core.starton both sides. Onstop_watchthe core (both languages) clears its waypoint config (waypoints.jsondeleted) along with the session log — a process death MID-flight still rehydrates both, but nothing survives a clean stop.
Visual "waypoint reached" state must derive in JS anyway (it is derived state — replayable, shown on the map/tiles). So the TS geofence math exists regardless, and the Rust announcer re-implements it for audio. That is two implementations of one small pure function — accepted for v1, guarded by shared golden test vectors: one JSON fixture of (fix stream, waypoint set) → expected announcement sequence, executed by both the vitest and cargo suites. Divergence fails CI in both languages.
Upgrade path: if real-time logic grows real mass (airspace polygon math is the trigger), promote the decision core to a Rust crate compiled both natively and to WASM for the web annunciator — write-once restored at the cost of a wasm toolchain. Not before then; ~100 lines of haversine does not justify a build pipeline. (Considered and rejected: embedding a JS runtime — JavaScriptCore/QuickJS — in the native layer to run the TS logic in background. Adds a runtime, a debugging surface, and an Android dependency to avoid duplicating arithmetic.)
| Concern | PWA (foreground) | Native foreground | Native background |
|---|---|---|---|
| Fix capture | navigator.geolocation→JS | sensors→Rust | sensors→Rust |
| Durable in-flight fix store | JS WAL (IndexedDB) | Rust log + JS WAL | Rust log (JS catches up) |
| Detection/lifecycle/record | JS | JS | JS, on replay |
| Visuals | JS | JS | — (rehydrated later) |
| Waypoint audio | JS (Web Speech) | Rust (speak shim) |
Rust (speak shim) |
- iOS background speech:
.playback+.duckOtherssession, possiblyaudioinUIBackgroundModes— proven pattern in nav/fitness apps but Apple's rules are empirical. Device drill before building on it. run_mobile_pluginfrom a long-lived Rust task is a less-trodden Tauri path — spike it on the Mac before committing the M4b schedule.Announcer scopeResolved at implementation: session-scoped (arming through finalization) on both platforms — consistency across the seam beats the recording-only nicety, and the arms-silently-inside rule already covers the launch-waypoint case.- Baro (CMAltimeter) joins through the same drain with added fix fields — define the fix schema versioning when it lands.
- Migration sequencing: the Rust core replaces device-drilled Swift code — land it with M4b, not before the first real flight.
- ✅ Rust:
store.rs(append-only log, hydration, ordering guard),core.rs(lifecycle, persist-then-announce, waypoint persistence),announcer.rs(golden vectors),wire.rs(contract fixtures) — 13 cargo tests, run by the macOSiosCI job on the host target. Not the Linux job:tauri's wry feature would drag webkit2gtk onto the runner that everything else waits on. - ✅ Swift: dieted to capture/drain/permissions/speak (~compiles on Mac only — unverified here).
- ✅ JS:
nativeSourcewire contract unchanged (commands now answered by Rust); web core twin of core.rs (src/engine/core.ts);src/flight/waypoints.tstwin passing the same golden vectors; pins double as waypoints, live-synced from PlanPage, session-scoped from FlyPage. - ⏳ Mac session: compile Swift, re-run sim drills (webview kill, relaunch — the Rust log replaces the Swift session file), and the two audio device drills (background speech, music ducking).
- Note: JS's engine still keeps its own IndexedDB WAL — two durable copies during a flight (Rust log = native truth, JS WAL = replay cache). Redundant but harmless; consolidation is a possible later simplification once the Rust core has device mileage.