From 30be8d34e1908179b76baaa93076f8aa00b44611 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:39:29 +0000 Subject: [PATCH 1/2] docs(architecture): developer onboarding doc set + CLAUDE.md orientation Capture the system as a stack of interoperable systems in durable, referenceable design artifacts under docs/architecture/, plus a root CLAUDE.md so any agent session auto-discovers them (the mechanism that makes them "referenced in other chat agents"). Each doc carries a Source-of-truth header + Verified-against commit so it can be kept in sync with the code it describes; diagrams are Mermaid. - CLAUDE.md orientation + doc-maintenance rule + build/test/deliver essentials - README.md manifest/router, per-doc header schema, path->doc map, protocol - 00 overview layered stack, 5 communication planes, glossary, module map - 01 engine (MOFE) pipeline, EKF predict/correct loop, constellation state machine - 02 mesh & consensus Raft pre-vote, registry replication paths, quorum/operating modes - 03 networking two stacks, plane/port/transport map, CBOR envelope, UWB band - 04 netcode & time mesh clock, interpolation, claim->arbitration->verdict, kill-switch - 05 security & trust Ed25519 CA, credentials, link handshake, group-key datagram AEAD - 06 server CA endpoint, leader uplink handshake, three telemetry tiers - 07 client & render Compose shell, host StateFlow bridges, render-readiness gate - 08 contributing offline :common:jvmTest recipe, ClusterHarness, git am --keep-cr - 09 workflows mesh bootstrap, client join, predicate lifecycle sequences Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- CLAUDE.md | 45 +++++ docs/architecture/00-overview.md | 159 ++++++++++++++++ docs/architecture/01-positioning-engine.md | 173 ++++++++++++++++++ docs/architecture/02-mesh-and-consensus.md | 132 +++++++++++++ .../03-networking-and-protocols.md | 113 ++++++++++++ docs/architecture/04-netcode-and-time.md | 87 +++++++++ docs/architecture/05-security-and-trust.md | 92 ++++++++++ docs/architecture/06-server-and-telemetry.md | 113 ++++++++++++ .../07-client-app-and-rendering.md | 110 +++++++++++ docs/architecture/08-contributing.md | 82 +++++++++ docs/architecture/09-workflows.md | 109 +++++++++++ docs/architecture/README.md | 75 ++++++++ 12 files changed, 1290 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/architecture/00-overview.md create mode 100644 docs/architecture/01-positioning-engine.md create mode 100644 docs/architecture/02-mesh-and-consensus.md create mode 100644 docs/architecture/03-networking-and-protocols.md create mode 100644 docs/architecture/04-netcode-and-time.md create mode 100644 docs/architecture/05-security-and-trust.md create mode 100644 docs/architecture/06-server-and-telemetry.md create mode 100644 docs/architecture/07-client-app-and-rendering.md create mode 100644 docs/architecture/08-contributing.md create mode 100644 docs/architecture/09-workflows.md create mode 100644 docs/architecture/README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f03cb5d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# tome-client — agent & contributor orientation + +**Aether** is an indoor **sub-centimetre UWB + IMU positioning mesh**. A device ranges +its peers over Ultra-Wideband, fuses that with its IMU, and the mesh cooperatively +solves a shared, metric coordinate frame — then draws peers/anchors in a live 3-D / FPV +scene and fires geometric & pointing "predicate" events. + +This repo (`tome-client`) is the device side: an **Android/Jetpack-Compose** app +(`androidApp`) over a **Kotlin-Multiplatform** engine + mesh stack (`common`). The +cloud gateway (CA, telemetry, web console) is the separate **tome-server** repo. + +## Read this first + +The architecture & onboarding docs live in **[`docs/architecture/`](docs/architecture/)** +— start at **[`docs/architecture/README.md`](docs/architecture/README.md)**, the index/router. +Every doc lists the code it describes under **Source of truth**, so you can jump from a +file you're touching to the doc that explains it (the manifest has the path→doc map). + +## Keep the docs true (maintenance rule) + +When you change code, update the `docs/architecture/` doc whose **Source of truth** globs +cover your files, and bump its **Verified against** commit. Diagrams are **Mermaid** — +edit the fenced Mermaid block, never paste a screenshot. New subsystem? Add a doc and +register it in the manifest table. + +## Build · test · deliver (the essentials) + +- **Modules:** `:common` (pure KMP engine + mesh + netcode; unit-tested, no Android) and + `:androidApp` (Compose UI + Android platform actuals; needs the Android Gradle Plugin). +- **Verify engine/mesh logic:** `./gradlew :common:jvmTest`. In a network-restricted env + the pinned JetBrains toolchain + AGP can't be fetched — see + [`docs/architecture/08-contributing.md`](docs/architecture/08-contributing.md) for the + offline-isolation recipe (it runs `:common:jvmTest` green with the system JDK). +- **Deliver to the dev branch (`feature/uiux-update2`):** changes are `git format-patch` + files applied with **`git am --keep-cr`** — several files (`MeshCoordinator.kt`, + `MeshMessagingService.kt`, `LinkHandshake.kt`, server `mesh.py`) are **CRLF**; plain + `git am` strips the CR and fails. Full convention in `08-contributing.md`. + +## Two things that surprise everyone + +1. **The netcode event seam ships OFF.** `MessagingConfig.netcodeBringUp` defaults `false`; + the mesh solves and renders without it. See `04-netcode-and-time.md`. +2. **There are two meshing stacks.** An always-on **BroadcastBand** (UDP-broadcast gossip + for presence/discovery/UWB-address + IP exchange) bootstraps the **MMNF stack** (the + real UDP data + TCP control + Raft transport). See `03-networking-and-protocols.md`. diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md new file mode 100644 index 0000000..d4bf786 --- /dev/null +++ b/docs/architecture/00-overview.md @@ -0,0 +1,159 @@ +# 00 · Overview — Aether as a stack of interoperable systems + +> **Layer** all · **Status** stable · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — whole repo; this doc is the map, the numbered docs are the territory +> **Related** — everything; start here then follow the reading order in [README](README.md) + +## What Aether is + +Aether turns a room full of phones and UWB anchors into a **shared, sub-centimetre metric +coordinate frame**. Each device measures distances (and angle-of-arrival) to its peers over +Ultra-Wideband, fuses that with its IMU in an error-state Kalman filter, and the mesh +cooperatively agrees on one anchor **constellation** everyone solves against. On top of that +frame it renders a live 3-D / first-person scene of who's where, and evaluates **predicates** +— geometric regions and "point at that" gestures — into mesh-wide events. + +Two coordinated codebases: + +- **tome-client** (this repo) — the device. Android/Compose UI (`androidApp`) over a + Kotlin-Multiplatform engine + mesh + netcode core (`common`, runs anywhere the JVM does, + which is why it is unit-testable headless). +- **tome-server** — a Python/Flask gateway: the mesh **certificate authority**, the + **telemetry** sink (Prometheus / InfluxDB / Loki), a web **console**, and the command plane. + +## The layered stack + +```mermaid +flowchart TB + subgraph SENSE["Sensing (androidApp platform actuals)"] + UWB["UWB radios — DS-TWR ranging + AoA"] + IMU["IMU — accel + gyro"] + end + subgraph ENGINE["MOFE engine (common/engine) — doc 01"] + PIPE["5-stage per-measurement pipeline
pre-filter → cross-anchor fuse → multilaterate → IMU/UWB EKF → TWR verify"] + CONST["Anchor constellation
bootstrap · orient · lock · re-solve"] + PIPE --> FS["FusedState
(position, velocity, orientation, σ)"] + CONST --> FRAME["Shared mesh frame + MeshOperatingMode"] + end + subgraph MESH["Mesh — MMNF (common/messaging) — docs 02·03"] + MSG["MeshMessagingService
data (UDP) · control (TCP) · uplink (WS)"] + RAFT["Raft consensus (pre-vote)"] + REG["Replicated state registry"] + RAFT --> REG + end + subgraph NET["Netcode + time (common/engine/netcode) — doc 04 ⚠ default OFF"] + CLK["MeshClock (NTP-style)"] + BUF["Temporal buffers + interpolation"] + EVT["Claim → leader rewind → verdict"] + end + subgraph SEC["Security & trust (common/messaging/security) — doc 05"] + HS["Link handshake (X25519+Ed25519)"] + GK["Group-key datagram AEAD"] + CRED["Mesh-CA DeviceCredential"] + end + subgraph BAND["BroadcastBand bootstrap (androidApp/band) — doc 03"] + GOSSIP["UDP-broadcast gossip:
presence · UWB addrs · peer IPs"] + end + subgraph APP["App & rendering (androidApp) — doc 07"] + HOST["MofeEngineHost / MeshRepository
(StateFlow bridges)"] + UI["Compose shell · 3D/FPV scene · diagnostics"] + end + subgraph SERVER["tome-server — doc 06"] + CA["Ed25519 mesh CA · /mesh/ca"] + TEL["Telemetry: Prom · Influx · Loki"] + CON["Web console"] + end + + UWB --> PIPE + IMU --> PIPE + FS --> MSG + FRAME --> MSG + MSG <--> RAFT + REG --> HOST + FS --> HOST + MSG -. "fused samples (gated)" .-> BUF + CLK --- BUF --- EVT + GOSSIP -. "peer IP + UWB addr" .-> MSG + HS --- MSG + GK --- MSG + CRED --- HS + CA -. "fetch + pin" .-> CRED + HOST --> UI + MSG -- "leader uplink (WS/JSON)" --> TEL + CRED -. "enroll / mint" .-> CA + REG -. "mirror" .-> CON + + classDef off stroke-dasharray:4 3; + class NET off; +``` + +## Five planes of communication + +Aether is easiest to reason about as **five planes**, each with its own medium, format, and +trust rules. Every arrow type the brief asked about lives in one of these. + +| Plane | Medium / port | Format | Trust | Carries | Doc | +|-------|---------------|--------|-------|---------|-----| +| **UWB ranging** | UWB radio (DS-TWR) | vendor PHY | physical | distance + AoA between two devices | 01 | +| **Band (bootstrap)** | UDP **broadcast :47600** | CBOR CRDT frames | Ed25519-signed lifecycle; AEAD member frames | presence, UWB addresses, peer IPs, kinematics, motion assertions | 03 | +| **Mesh data** | UDP **:47474** | CBOR envelope + group-key AEAD | group key | fused-state samples, ranging batches (best-effort, latest-wins) | 03 | +| **Mesh control** | TCP **:47475** | CBOR envelope in AEAD link | per-link X25519+Ed25519 | Raft RPCs, membership, deltas/snapshots, claims/verdicts, key distribution | 02·03·05 | +| **Uplink** | WebSocket → server | JSON frames | device credential | leader→server telemetry, alerts, state mirror; server→leader commands | 06 | + +Two guarantees worth internalizing early: +- **The channel is the QoS contract.** `RANGING` is best-effort + sequence-gated; `CONTROL` + is TCP-reliable/ordered; `UPLINK` is at-least-once store-and-forward. +- **`meshEpoch` fences incarnations** and **`sequence` fences replays**; producers never set + them — the messaging hub stamps them and the `StalenessGate` enforces them inbound. + +## The three role vocabularies (don't conflate them) + +| Vocabulary | Values | Where it lives | Decides | +|------------|--------|----------------|---------| +| **Assembly role** | `ROOT_ANCHOR`, `ANCHOR`, `LEARNER` | `MeshNode` | which `start()` path a node takes | +| **Device label** | `MeshDeviceType {ANCHOR,CLIENT,GATEWAY,CUSTOM}`, `MeshRole {MEMBER,GATEWAY,ROOT,ANCHOR,CLIENT}` | `model/mesh/DeviceContract.kt` | membership, UI, permissions | +| **Operating mode** | `UNINITIALIZED → PRE_QUORUM → QUORUM ⇄ DEGRADED` | `model/StateTypes.kt` | whether the frame is trustworthy enough to render | + +## Repo & module map + +``` +tome-client/ +├─ CLAUDE.md ← agent orientation (points here) +├─ common/ ← Kotlin-Multiplatform core (JVM-testable, no Android) +│ └─ src/commonMain/kotlin/com/aether/mofe/ +│ ├─ engine/ ← MOFE: pipeline, EKF, constellation, calibration (doc 01) +│ │ ├─ netcode/ ← time, interpolation, events (doc 04) +│ │ └─ render/ ← render-readiness + role display policy (docs 01·07) +│ ├─ messaging/ ← MMNF: hub, coordinator, Raft, registry, codec (docs 02·03) +│ │ ├─ raft/ ← Raft consensus +│ │ └─ security/ ← handshake, group keys, op signing (doc 05) +│ ├─ model/ ← data types (measurements, state, wire payloads, config) +│ └─ math/ ← Vector3D, Quaternion, Matrix, Shape geometry +├─ androidApp/ ← Compose UI + Android platform actuals (doc 07) +│ └─ src/main/java/com/aether/mofe/ +│ ├─ ui/ ← AppShell, scene, HUD, manage, predicate, mesh +│ ├─ viewmodel/ ← HudViewModel (render/aim brain) +│ ├─ platform/ ← UWB/IMU/band services, MofeEngineHost, netcode bring-up +│ └─ data/ ← MeshRepository (roster + shared state) +├─ design/ ← design-decision records (history/rationale; complements docs/) +└─ docs/architecture/ ← you are here +``` + +`common` is the reusable brain; `androidApp` supplies the hands (radios, sensors, screen) and +the on-device assembly. iOS (`iosApp`) is scaffolded but out of scope. + +## Glossary + +| Term | Meaning | +|------|---------| +| **MOFE** | Multi-Observer Fusion Engine — the positioning pipeline (`common/engine`). | +| **MMNF** | The mesh networking stack: data/control planes, Raft, registry, AEAD. | +| **Band / BroadcastBand** | Always-on UDP-broadcast gossip layer that bootstraps the MMNF stack. | +| **Constellation** | The rigid set of anchor positions that defines the shared frame. | +| **Reference anchor / point** | A device with a known frame position the solver trusts (root + placed anchors). | +| **Quorum** | ≥3 anchors → replicated frame + failover (`MeshOperatingMode.QUORUM`). | +| **Netcode** | The mesh-time + interpolation + event-arbitration seam (default OFF). | +| **Predicate** | An authored condition — a geometric region or a "point-at" gesture — that fires mesh events. | +| **Uplink** | The leader's WebSocket to tome-server (telemetry + commands). | +| **Self-seed** | Bootstrapping a mobile client's self-EKF from the anchor-broadcast solved position. | +| **ZUPT / ZARU** | Zero-velocity / zero-angular-rate updates that de-bias the EKF at rest. | diff --git a/docs/architecture/01-positioning-engine.md b/docs/architecture/01-positioning-engine.md new file mode 100644 index 0000000..3fd3086 --- /dev/null +++ b/docs/architecture/01-positioning-engine.md @@ -0,0 +1,173 @@ +# 01 · The MOFE Positioning Engine + +> **Layer** `common/engine` (+ `platform/MofeRuntime.kt`) · **Status** stable · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `common/src/commonMain/kotlin/com/aether/mofe/engine/**`, `common/…/platform/MofeRuntime.kt`, `common/…/model/{MeasurementTypes,StateTypes,ConfigTypes}.kt` +> **Related** — [00-overview](00-overview.md) · [02-mesh-and-consensus](02-mesh-and-consensus.md) · [04-netcode-and-time](04-netcode-and-time.md) · [07-client-app-and-rendering](07-client-app-and-rendering.md) + +MOFE (**M**ulti-**O**bserver **F**usion **E**ngine) is the pure-logic, platform-agnostic +brain. Hardware flows in as `RawRangingMeasurement` + `ImuSample`; a per-target `FusedState` +(position, velocity, orientation, per-axis σ) flows out. It has two clocks: a **per-measurement +pipeline** (fast) and a **maintenance loop** (~5 s) that owns the anchor **constellation** — +the shared frame everything else solves against. + +## Assembly & runtime + +- **`MofeBuilder`** (`engine/MofeBuilder.kt`) — fluent build; requires `.frameManager(CoordinateFrameManager)` + `.clock(PlatformClock)`; folds per-stage config into one `MofeConfig` (`model/ConfigTypes.kt`). +- **`MofeRuntime`** (`platform/MofeRuntime.kt`) — the glue that subscribes `PlatformProvider` hardware flows and pumps the engine. `start(selfDeviceId, isRootAnchor=false)` launches five collectors: **UWB ranging** → `processRanging`, **IMU** → `processImu`, **peer observations** → `processRanging`, **maintenance** (5 s) → `performMaintenance`, **telemetry** (1 s) → `publishTelemetry`. `trackTarget`/`untrackTarget` open/close UWB sessions. A self target is always registered (fed by IMU + peers, no self-session — a device can't range itself). +- **`MultiObserverFusionEngine`** (`engine/MultiObserverFusionEngine.kt`, ~2100 lines) — the orchestrator. Owns the mesh-wide stages + one `TargetContext` per tracked device (its own `ImuFusionEKF`, TWR trigger, counters). Fans results to many `MofeListener`s and a `Flow`. + +## The per-measurement pipeline + +```mermaid +flowchart LR + RAW["RawRangingMeasurement
(anchor,target,dist,az?,el?)"] --> S1 + IMU["ImuSample"] --> S4 + subgraph P["runFullPipeline (per tracked target)"] + S1["1 · MeasurementPreFilter
2-state 1-D Kalman, 3σ NLOS gate"] + S2["2 · CrossAnchorFuser
inverse-variance combine, triangle check"] + S3["3 · MultilaterationSolver
Gauss-Newton + LM, covariance, GDOP"] + S4["4 · ImuFusionEKF
15-state ES-EKF"] + S5["5 · TwrVerificationTrigger
+ EventDetector / BehaviorEngine"] + S1 --> S2 --> S3 --> S4 --> S5 + end + S3 -. GDOP .-> GDOP["GdopAnalyzer"] + S4 --> OUT["FusedState → listeners + frameManager"] + S5 --> EV["MeshEvent / TwrVerificationDecision"] + REF{{"target IS a reference anchor?"}} -. "yes: skip 2·3,
feed known position (σ≈5cm)" .-> S4 +``` + +| Stage | Class (`engine/…`) | In → Out | +|-------|--------------------|----------| +| 1 · Pre-filter | `MeasurementPreFilter` | `RawRangingMeasurement` → `PreFilteredMeasurement` (smooths ±10 cm→±5–7 cm; soft NLOS reject) | +| 2 · Cross-anchor fuse | `CrossAnchorFuser` | `List` → `FusedDistanceSet` (keeps N constraints; inflates triangle-inequality violators ×10) | +| 3 · Multilateration | `MultilaterationSolver` | `FusedDistanceSet` + reference points → `PositionSolution` (position, covariance, GDOP=√trace P, residualRms) | +| 4 · IMU/UWB EKF | `ImuFusionEKF` | `PositionSolution` + `ImuSample` → **`FusedState`** | +| 5 · Verify + events | `TwrVerificationTrigger`, `EventDetector`, `BehaviorEngine` | `FusedState` → `TwrVerificationDecision`, `MeshEvent` | + +**Reference short-circuit:** an anchor/root is seen by only N−1 peers and can never reach +`minAnchorsForFusion`, so when the target *is* a reference point the engine skips stages 2–3 +and feeds the EKF its known frame position (`REFERENCE_ANCHOR_VARIANCE=0.0025`, σ≈5 cm). + +Steady-state entry points on the engine: `processRanging` (stage 1 → collect → gate on +`canSolvePositions` → `runFullPipeline`), `processImu` (EKF predict + gravity), `processInterAnchorRanging` +(EMA-accumulate inter-anchor distances for the constellation + drift detector), `performMaintenance` +(reap → self reference → antenna cal → constellation bootstrap → periodic refine). + +## The EKF — 15-state error-state filter (`ImuFusionEKF.kt`) + +Error state `δx = [δp, δv, δθ, δba, δbg]` (position, velocity, orientation-error, accel-bias, +gyro-bias), kept separate from the nominal state and injected-then-reset each step. Measurement +model `H = [I₃ | 0₁₂]`. + +```mermaid +flowchart TB + subgraph PRED["predict() — IMU rate (~100 Hz)"] + A["inject δx → nominal"] --> B["bias-correct IMU"] + B --> C["to world, remove gravity
aW = R·a + G_WORLD"] + C --> D["integrate p,v,q"] + D --> E["P = F·P·Fᵀ + Q"] + E --> F{"stationary?
(raw accel/gyro gate)"} + F -- yes --> Z["ZUPT (pin v≈0)
ZARU (observe gyro bias → kill yaw drift)"] + F -- no --> G["clamp covariance/state"] + Z --> G + end + subgraph UPD["update() — UWB rate (~10 Hz)"] + H["innovation y = z − Hx"] --> I{"Mahalanobis χ²(3)
≤ 7.815 ?"} + I -- reject --> J["count; force-accept after 3"] + I -- accept --> K["K = P·Hᵀ·S⁻¹
(block accel-bias while moving)"] + J --> K + K --> L["δx += K·y ; P = (I−KH)P"] + end + subgraph ABS["absolute attitude side-updates"] + M["updateGravity → roll/pitch vs +Z"] + N["updateBearing (UWB AoA) → yaw"] + end + PRED -->|between fixes| UPD --> PRED + M -.-> PRED + N -.-> UPD +``` + +Key behaviours: **stationarity** is detected on **raw** IMU only (a single loud sample releases +instantly); **ZARU on by default**, **ZUPT armed at runtime** (trust-gated by asserted motion +mode); the **Mahalanobis gate** rejects UWB outliers but force-accepts after +`maxConsecutiveRejections=3` to escape deadlock; divergence **clamps** let a pickup/spin recover +instead of running to 1e28. Absolute orientation is observable via gravity (roll/pitch) and UWB +AoA to a known anchor (yaw) — see `AoaCalibration`. + +## The anchor constellation — establishing, locking, re-solving the frame + +The constellation is **distance-only authoritative** (phone UWB elevation is unusable — ±90° on +a flat table — so AoA is used only for orientation, never placement). + +- **Bootstrap** `AnchorMeshBootstrap.bootstrap` — closed-form trilateration cascade (root at + origin → a1 on +X → a2 in XY → a3 in 3D → multilaterate the rest) with a **deterministic gauge** + (a1/a2/a3 chosen in id-sorted order, else the frame flips every tick). +- **Refine** `AnchorConstellationSolver.refine` — rigid-body LM fit minimizing + `Σ(‖pᵢ−pⱼ‖−dᵢⱼ)² + λ·Σ‖pᵢ−pᵢ⁰‖²`; root pinned (translation gauge), small prior (rotation gauge), + `seedOutOfPlaneIfCoplanar` escapes the elevation-less flat stationary point. +- **Orient + level** `ConstellationOrientation` + `FrameGravity` — gravity-up → world +Z, fit yaw + from AoA, resolve the mirror (both chiralities fit; lower-residual wins). Two-tier leveling: + a genuinely raised anchor (Tier 1) or a best-fit plane normal + `UpSignAccumulator` vote (Tier 2). +- **Authority** `engine.bootstrapReferenceConstellation()` runs every maintenance tick, consumes + antenna-delay-corrected distances, and **overwrites** stored positions. + +### Frame-lock state machine (`ConstellationFrameState`) + +```mermaid +stateDiagram-v2 + [*] --> CONVERGING + CONVERGING --> LOCKED: latch gates pass + LOCKED --> RESOLVING_MOTION: reference anchor MOVING (self ZUPT / peer assertion) + LOCKED --> RESOLVING: anchor dropped / re-settling + RESOLVING_MOTION --> LOCKED: settled + re-latched + RESOLVING --> LOCKED: settled + re-latched + RESOLVING --> CONVERGING: reference set lost + + note right of LOCKED + Latch gates (accuracy > speed): + oriented + full set + not-in-motion + + complete (not fallback) + + well-fit (residRMS ≤ 0.06 m) + + non-degenerate (minSep ≥ 0.10 m) + + stable (signature agrees 3 ticks) + end note +``` + +`LOCKED` means the frame is held — `refineAnchorConstellation` becomes a no-op, so anchors stop +jittering. A **motion override** (`anyReferenceAnchorInMotion`) releases the lock the instant a +reference anchor reports movement, tracks it, then re-latches. This machine sits under the mesh's +`MeshOperatingMode` (`UNINITIALIZED→CALIBRATING→PRE_QUORUM→QUORUM⇄DEGRADED`, in `CoordinateFrameManager`); +`canSolvePositions` = `refs ≥ 4`. + +## Calibration + +| Concern | Class | What it does | +|---------|-------|--------------| +| **Antenna delay** | `AntennaDelayCalibrator` | Per-device range bias from surveyed truth (`d ≈ ‖pᵢ−pⱼ‖ + bᵢ + bⱼ`, least-squares); engine subtracts `bᵢ+bⱼ` from every inter-anchor range before solving. | +| **AoA convention** | `AoaCalibration` | Live-tunable (az,el)→body mapping for yaw; `AoaBoresight` default **`Z_NEG`** (screen-normal out the back). | +| **AoA auto-cal** | `AoaAutoCalibrator` | User aims the lens (body −Z) at a known target; brute-forces the convention that maps measured AoA onto the wand. "NO FIT" ⇒ AoA unreliable / frame fault, not convention. | +| **Frame / device** | `CoordinateFrameManager` | Canonical frame, topology, quorum; `initializeAsRoot`, `registerReferenceAnchor`, `calibrateDevice` (with degeneracy pre-check), `repairReferencePosition`, `reestablishFrame`/`relevelFrame`. | +| **Survey truth** | `MeshGroundTruth` | Operator-entered surveyed poses feeding antenna-delay cal. | + +## Self-localization, self-seed & render-readiness + +- **`maintainSelfReferenceAnchor()`** keeps a reference anchor's self-EKF live so its own IMU can + flag a bump (the frame-lock motion signal). +- **`seedSelfFromExternalPosition(position, var)`** — the **client self-seed**: a mobile client's + UWB only ranges peers, so its self-EKF is fed the **anchor-broadcast solved position** (what the + anchors compute for it) as a `PositionSolution`; IMU predict smooths between band-rate updates. + `σ≈20 cm`. No-op if this device is itself a reference point. +- **`MeshRenderReadiness.evaluate(engineStarted, meshSolved, referenceCount, selfLocalized)`** + (`engine/render/`) → `INITIALIZING → ACQUIRING_MESH → ACQUIRING_SELF → READY`; `canRenderSpatial = + (state==READY)`. **`MeshDisplayPolicy.decide(isAnchorOrRoot, readiness, autoJoinBringup)`** → + `MeshSurface {SPATIAL_SCENE, HOLDING, DIAGNOSTICS}`: a client HOLDs before READY (never shows + unsolved locations), an anchor/root sees DIAGNOSTICS (that pre-quorum state is what its operator + watches). Consumed by the UI in [07](07-client-app-and-rendering.md). + +## Diagnostics the engine emits + +`MofePipelineTrace` (opt-in flight recorder taps every stage boundary, no-op by default), +`getTelemetryFor(target)` → `MofeTelemetryRecord` (~1 Hz gauges), `getDiagnostics(target)` → +`TargetDiagnostics` (counter snapshot), and health reports `interAnchorLinkReport()` / +`constellationGeometryReport()` / `antennaDelayCalibration()`. These are the values the UI's +diagnostics pane and the server telemetry tiers consume. diff --git a/docs/architecture/02-mesh-and-consensus.md b/docs/architecture/02-mesh-and-consensus.md new file mode 100644 index 0000000..a7a18ad --- /dev/null +++ b/docs/architecture/02-mesh-and-consensus.md @@ -0,0 +1,132 @@ +# 02 · Mesh & Consensus + +> **Layer** `common/messaging` (+ `messaging/raft`) · **Status** stable · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `common/…/messaging/{MeshCoordinator,MeshStateRegistry,MeshNode}.kt`, `common/…/messaging/raft/**`, `common/…/model/mesh/DeviceContract.kt`, `common/…/model/StateTypes.kt` +> **Related** — [03-networking-and-protocols](03-networking-and-protocols.md) · [04-netcode-and-time](04-netcode-and-time.md) · [05-security-and-trust](05-security-and-trust.md) + +The mesh agrees on **one replicated truth** — who the nodes are, where the frame is, which +predicates exist — using **Raft**. `MeshCoordinator` is the wiring hub; `RaftModule` is the +consensus core; `MeshStateRegistry` is the replicated key-value store. This doc is the "how the +mesh agrees" layer; the wires it runs over are [03](03-networking-and-protocols.md). + +## MeshCoordinator — the wiring hub (`messaging/MeshCoordinator.kt`) + +Wires messaging ⇄ engine ⇄ Raft ⇄ netcode. `isLeader` is **derived from Raft**, never assigned. + +- Constructs `RaftModule(onRoleChange→onRaftRoleChange, onCommit→onCommitted)`. +- Owns the mesh clock + netcode seam: `meshClock`, `netcode = NetcodeSession(clock=meshClock)`, + `claimTracker`, `predicateShapes` (COW cache for arbitration). +- **Single side-effect path:** `registry.deltas.collect { applyLocalSideEffects(it) }` runs for + **both** roles (voters via `RaftModule.applyCommitted`, learners via re-broadcast deltas). Never + call `applyLocalSideEffects` from anywhere else. +- **Single write path:** `write(vararg ops) = raft.propose(ops)`. +- `startVoter(initialVoters, bootstrapAsLeader)` (leader/anchor path) vs `startAsMember(leader,…)` + (member/learner path). +- `onRaftRoleChange`: on becoming LEADER → **bump epoch** (incarnation fence), write the frame + record, **rotate the group key** + broadcast it, start the uplink; on losing it → stop uplink. + +Assembly: **`MeshNode.build(deps).start()`** wires transports → security wrappers → hub → registry +→ group keys → `EventNotifier` → coordinator, and dispatches on `MeshNodeRole`. + +## Raft (`messaging/raft/RaftModule.kt`, `RaftTypes.kt`) + +Roles `FOLLOWER, PRE_CANDIDATE, CANDIDATE, LEADER, LEARNER`. Config: heartbeat 1000 ms, election +timeout 3000–5000 ms, `snapshotEvery=1024`. + +```mermaid +stateDiagram-v2 + [*] --> FOLLOWER + FOLLOWER --> PRE_CANDIDATE: election timeout + PRE_CANDIDATE --> FOLLOWER: pre-vote fails / leader fresh + PRE_CANDIDATE --> CANDIDATE: pre-vote majority (no term bump yet) + CANDIDATE --> LEADER: vote majority → append no-op + CANDIDATE --> FOLLOWER: higher term seen + LEADER --> FOLLOWER: higher term seen + LEARNER --> LEARNER: receives committed deltas (non-voting) + note right of PRE_CANDIDATE + Pre-Vote (§9.6): probe without + bumping term → no disruption from + a partitioned node. Grant needs + log-up-to-date AND leader-not-fresh + (leader stickiness). + end note +``` + +**Log ≡ registry revision.** `LogEntry(term, index, delta)` with the invariant that log index +equals registry revision (`StateDelta.prevRevision = index−1`). Commit = median `matchIndex` among +voters, **current-term entries only**; `applyCommitted` applies **every** entry including the +leader's no-op (skipping it would desync revision from index and silently wedge later deltas). +`propose(ops)` suspends until majority-commit; throws if not leader (callers redirect to `leaderId`). + +**Catch-up:** `AppendEntries` does the consistency check + conflict truncation + accelerated +backoff; a follower that falls below the leader's log base gets a **`RaftInstallSnapshot`** (a full +`MeshSnapshot`) and compacts. + +**Persistence** saves **only `currentTerm` + `votedFor`** (the log is *not* persisted on mobile — a +restarted node rejoins as follower and catches up via snapshot+append). ⚠ `InMemoryRaftPersistence` +is volatile (root bootstrap + tests); production needs the durable `AndroidRaftPersistence` actual. + +## Replicated state registry (`messaging/MeshStateRegistry.kt`) + +A revisioned KV store under a mutex. `applyDelta` → `Applied` / `Gap(expected,got)` (member then +requests a snapshot) / `AlreadyApplied`; `applySnapshot` replaces the store and emits one synthetic +delta so watchers re-evaluate. Keys (`RegistryKeys`): `mesh/mode`, `mesh/frame`, `mesh/voters`, +`mesh/fit`, `nodes/{id}`, `predicates/{id}`, `composites/{id}`, `tracking/{id}`, `roles/requests/{id}`, +`mesh/revoked/{id}`. Values are the `RegistryValue` sealed set (`NodeRecord`, `PredicateRecord`, +`VoterSetRecord`, `FrameRecord`, …). + +```mermaid +flowchart TB + P["client: coordinator.write(ops)"] --> RP["raft.propose"] + RP --> C{"majority commit?"} + C -- yes --> AC["RaftModule.applyCommitted"] + AC --> V["voters: registry.applyDelta"] + AC --> OC["MeshCoordinator.onCommitted"] + OC --> L["learners: broadcastControlTo(learnerIds)"] + OC --> U["uplink.enqueueDelta → server"] + L --> LR["learner: registry.applyDelta"] + V --> W["registry.deltas (single watcher)"] + LR --> W + W --> SE["applyLocalSideEffects
(engine, netcode, mode, UI)"] + LR -. "Gap detected" .-> SN["request Snapshot → applySnapshot"] +``` + +## Roles, quorum & operating mode + +Three role vocabularies (see [00](00-overview.md)); the one that gates rendering is +**`MeshOperatingMode`** (`model/StateTypes.kt`): + +```mermaid +stateDiagram-v2 + [*] --> UNINITIALIZED + UNINITIALIZED --> PRE_QUORUM: root anchor + mesh points (single frame) + PRE_QUORUM --> QUORUM: ≥3 anchors (replicated frame + failover) + QUORUM --> DEGRADED: dropped below 3 anchors + DEGRADED --> QUORUM: anchor recovered / re-added +``` + +**Join → quorum:** the root does `startVoter(setOf(self), bootstrapAsLeader=true)` — a majority of +one self-elects, giving PRE_QUORUM. A joiner does `startAsMember` → sends `NodeHello` → leader +registers the link, proposes a `NodeRecord` (Raft), replies a **full snapshot** (one-round-trip +convergence), and starts a lease (`heartbeat × leaseMissesBeforeExpiry`). Anchors become Raft +**voters** when the leader commits `mesh/voters` (`VoterSetRecord` → `raft.updateVoters`, then the +app calls `promoteToVoter()`). Silent members are reaped by `expireLeases` (emits `LEASE_EXPIRED` + +`engine.removeAnchor`). + +```mermaid +sequenceDiagram + participant M as Joiner (member) + participant L as Leader (root/anchor) + M->>L: NodeHello (control :47475, epoch-exempt) + L->>L: registerLink + raft.propose(NodeRecord) + L-->>M: MeshSnapshot (full registry, one round-trip) + loop lease + M->>L: NodeHeartbeat + L->>L: renew lease (else LEASE_EXPIRED → removeAnchor) + end + Note over L: on commit of mesh/voters → promote joiner to Raft voter +``` + +The render gate then opens only at **`QUORUM` + self-localized** — see +[01 · render-readiness](01-positioning-engine.md) and its UI consumption in +[07](07-client-app-and-rendering.md). diff --git a/docs/architecture/03-networking-and-protocols.md b/docs/architecture/03-networking-and-protocols.md new file mode 100644 index 0000000..64619ba --- /dev/null +++ b/docs/architecture/03-networking-and-protocols.md @@ -0,0 +1,113 @@ +# 03 · Networking & Wire Protocols + +> **Layer** `common/messaging` + `androidApp/platform/band` · **Status** stable · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `common/…/messaging/{MeshMessagingService,MeshCodec,Transports,KtorTransports,SequenceGate}.kt`, `common/…/model/messaging/**`, `androidApp/…/platform/band/**`, `androidApp/…/platform/netcode/NetcodePeerBook.kt` +> **Related** — [02-mesh-and-consensus](02-mesh-and-consensus.md) · [04-netcode-and-time](04-netcode-and-time.md) · [05-security-and-trust](05-security-and-trust.md) · [06-server-and-telemetry](06-server-and-telemetry.md) + +## Two stacks + +There are **two** device-to-device networks, and conflating them is the most common onboarding +mistake: + +```mermaid +flowchart LR + subgraph BAND["BroadcastBand — always on (androidApp)"] + direction TB + B["UDP broadcast :47600
CBOR CRDT gossip (~750 ms)"] + end + subgraph MMNF["MMNF stack — gated (common)"] + direction TB + D["Data · UDP :47474"] + C["Control · TCP :47475"] + U["Uplink · WebSocket → server"] + end + B -->|"NetcodePeerBook.observe(nodeId, sourceIP)"| PB["PeerAddressBook
→ control :47475"] + PB --> C + B -. "presence · UWB addrs · kinematics" .-> APP["roster / UI / engine"] +``` + +- **BroadcastBand** (`platform/band/BroadcastBand.kt`) — offline-first, runs today. It does + **presence, discovery, UWB-address exchange, and peer-IP discovery** — not fabricated ranging + (real position comes from UWB + MOFE). Every inbound datagram hands `NetcodePeerBook` the peer's + **source IP** (which the payload never carries), and `PeerAddressBook` maps it to the control port. +- **MMNF** (`messaging/**`) — the real transport once bootstrapped: UDP data, TCP control, WS uplink, + Raft, AEAD. Comes up only when trust material + a leader IP are known (see + [05](05-security-and-trust.md)). + +## MeshMessagingService — the hub (`messaging/MeshMessagingService.kt`) + +A pure hub: it stamps `sequence`+`meshEpoch` outbound, applies the `StalenessGate` inbound, and +demultiplexes payloads into typed hot flows. **No business logic** lives here. + +### Plane / port / transport map + +| Plane | `MeshChannel` | Port (`MessagingConfig`) | Transport | Direction | Delivery | +|-------|---------------|--------------------------|-----------|-----------|----------| +| **Data** | `RANGING` | `dataPort=47474` UDP | `DatagramTransport` (`KtorDatagramTransport`) | fan-out to all peers | best-effort, latest-wins, seq-gated | +| **Control** | `CONTROL` | `controlPort=47475` TCP | `ControlTransport`/`ControlLink` (length-prefixed) | leader ⇄ members | reliable, ordered | +| **Uplink** | `UPLINK` | WebSocket → server | `UplinkClient` (Ktor WS) | leader → server | at-least-once, store-and-forward | + +`MessagingConfig`: `dataPort`, `controlPort`, `heartbeatIntervalMillis=1000`, +`leaseMissesBeforeExpiry=3`, `rangingPublishHz=10`, `fusedSamplePublishHz=10`, +`timeSyncIntervalMillis=5000`, **`netcodeBringUp=false`** (kill-switch, [04](04-netcode-and-time.md)). + +**Typed inbound flows** (subscribe-side `SharedFlow`s): `rangingBatches`, `fusedSamples` (data); +`events`, `stateDeltas`, `snapshots`, `hellos`, `heartbeats`, `snapshotRequests`, `commands`, +`resyncNeeded`, `raftInbound` (→ RaftModule), `groupKeys` (→ GroupKeyManager), `eventClaims`, +`eventVerdicts`, `timeSyncResponses`. **Publish API:** `publishRanging` (datagram fan-out); +`sendControl`/`broadcastControl`/`sendControlTo` (control); `registerLink`/`updatePeers`. + +**StalenessGate** (`SequenceGate.kt`): verdicts `ADMIT` / `DROP_STALE` (older epoch or non-increasing +RANGING seq) / `RESYNC` (newer epoch → trigger snapshot). **Sequence is enforced only on `RANGING`** +(CONTROL is TCP-ordered). `NodeHello` is **epoch-exempt** — a joiner hasn't learned the epoch yet. + +## Serialization — `MeshCodec` + the envelope + +- **CBOR** is the mesh wire format (`Cbor { ignoreUnknownKeys = true }`); `decodeOrNull` **never + throws** (a malformed datagram must not kill a collector loop). Handshake frames are **bare + `MeshPayload`** (pre-session, no envelope). **JSON** is used for the uplink + credential parsing. +- **`encodeCredentialBody`** is a hand-rolled **canonical JSON** (sorted keys, Python `ensure_ascii` + escaping) — a byte-for-byte cross-repo contract with tome-server's `gateway/ca.py::_canonical` + ([05](05-security-and-trust.md) · [06](06-server-and-telemetry.md)). + +```mermaid +flowchart LR + PL["MeshPayload
(sealed, @SerialName)"] --> ENV["MessageEnvelope
schemaVersion·messageId·sourceNodeId·
channel·sequence·sentAtMicros·meshEpoch·payload"] + ENV -->|CBOR| SEC{{"secure wrapper"}} + SEC -->|"control"| SL["SecureControlLink AEAD
(per-link keys)"] + SEC -->|"data"| SD["SecureDatagramTransport
keyId‖senderHash‖seq‖AEAD"] + SL --> TCP["TCP :47475"] + SD --> UDP["UDP :47474"] +``` + +`MessageEnvelope`: `schemaVersion=1`, `messageId` (UUID, uplink dedup key), `sourceNodeId`, +`channel`, `sequence` (monotonic per source+channel), `sentAtMicros`, `meshEpoch` (incarnation +fence), `payload`. Producers never touch the guard fields — the hub's `envelope()` fills them. + +**Wire payload catalog** (`MeshPayload` sealed interface, polymorphic by `@SerialName`): + +| Group | File | Members | +|-------|------|---------| +| Data | `MeshPayloads.kt` | `RangingBatch`, `FusedStateSample` | +| Control/membership | `MeshPayloads.kt` | `NodeHello`, `NodeHeartbeat`, `StateDelta`, `SnapshotRequest`, `MeshSnapshot`, `EventNotification`, `TimeSync`, `MeshCommand`, `Ack` | +| Raft | `RaftMessages.kt` | `LogEntry`, `RaftRequestVote`, `RaftVote`, `RaftAppend`, `RaftAppendReply`, `RaftInstallSnapshot` | +| Registry values | `RegistryTypes.kt` | `RegistryOp` (Put/Delete/RoleRequest/RoleDecision), `RegistryValue.*`, `NodeDiag` | +| Security | `SecurityMessages.kt` | `DeviceCredential`, `SecHelloInit/Ack/Done`, `GroupKeyDistribution` | +| Netcode | `NetcodeWirePayloads.kt` | `WireEventClaim`, `WireEventVerdict` | +| Fragments | `WireFragments.kt` | `WireObservation`, `WireFusedSample` (incl. `orientation`) | + +## The UWB band in detail (`platform/band/`) + +`BroadcastBand` gossips `BandNodeRecord`s (`BandProtocol.kt`) — LWW-merged by Lamport clock in +`SharedMeshState` (a CRDT). A record carries: identity (`nodeId`, `displayName`, `deviceType`, +`meshRoles`, `ed25519Pub`, `x25519Pub`), honest world-frame `position`/`velocity`, a **peer motion +assertion** (`motionMode` — peers gear-shift ZUPT on a `STATIONARY` assertion), **UWB addresses** +(`uwbControllerAddr`/`uwbControleeAddr` + per-peer `uwbLinks`/`uwbAck` rendezvous), live +`observations` (cross-anchor fusion input), and the anchor's `solved` position map. Frames: +`Beacon`, `NodePut/Delete`, `Snapshot`, `Hello`, `MeshMetaPut`, `JoinRequestPut`, `AnchorInvitePut`, +`SealedKeyPut`, `PredicatePut/Delete`, `CompositePut/Delete`, `Event`. + +**Band security:** discovery/admission frames stay plaintext; member frames are AEAD-sealed under a +mesh **group key** the owner X25519-seals per admitted device (`SealedKeyPut`); `lifecycleVerifier` +checks owner Ed25519 signatures before any CRDT state is touched (details in +[05](05-security-and-trust.md)). diff --git a/docs/architecture/04-netcode-and-time.md b/docs/architecture/04-netcode-and-time.md new file mode 100644 index 0000000..a291a75 --- /dev/null +++ b/docs/architecture/04-netcode-and-time.md @@ -0,0 +1,87 @@ +# 04 · Netcode & Time + +> **Layer** `common/engine/netcode` (+ `MeshCoordinator` seam) · **Status** stable, **default OFF** · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `common/…/engine/netcode/**`, `common/…/messaging/MeshCoordinator.kt` (`emitClaims`, init gate), `androidApp/…/platform/netcode/NetcodeBringUp.kt` +> **Related** — [01-positioning-engine](01-positioning-engine.md) · [02-mesh-and-consensus](02-mesh-and-consensus.md) · [07-client-app-and-rendering](07-client-app-and-rendering.md) + +Netcode makes the mesh feel **smooth and consistent in time**: it puts every device on one mesh +clock, buffers per-device history so you can ask "where was peer X at mesh-time T?", interpolates +10 Hz solves into a 60 Hz render, and adjudicates **events** (region crossings, aim gestures) with +a leader-authoritative rewind so optimistic client predictions can be confirmed or rolled back. + +> ⚠ **Ships OFF.** `MessagingConfig.netcodeBringUp` defaults `false`. The `NetcodeSession` is still +> *constructed* (render/aim APIs return empty, never null), but the live seam — feeding samples, +> the claim/verdict transport, `emitClaims` — does not run, so a bring-up defect cannot regress +> ranging / solve / Raft. Flip it on per-device to go live; flip it off to revert without a rebuild. + +## Components + +| Type | File | Responsibility | +|------|------|----------------| +| `NetcodeSession` | `NetcodeSession.kt` | Holder: per-device `TemporalStateBuffer`, the `MeshClock`, reject-rate + quality windows. `recordSample`, `renderPose`, `renderPositions(nowMesh)`, `arbitrate`. Everything in **mesh time**. | +| `NetcodeAdapter` | `NetcodeAdapter.kt` | Feeds the session from `fusedSamples` + TimeSync; `onFusedSample` records a `TemporalPose`. | +| `NetcodeTransport` | `NetcodeTransport.kt` | Leader-authoritative (NOT Raft) claim/verdict routing + domain↔wire mapping. | +| `MeshClock` | `MeshClock.kt` | NTP 4-timestamp estimator: `offset=((t2−t1)+(t3−t4))/2`, best = lowest-RTT sample; `toMeshTime`/`toLocalTime`. | +| `TemporalStateBuffer` | `TemporalStateBuffer.kt` | Per-device ring buffer; `resolve(T)` → `Resolved(pose, Fidelity, age)` with `Fidelity {EXACT, INTERPOLATED, EXTRAPOLATED, CLAMPED, EMPTY}`; lerp/**slerp**; 1 s retention, 120 ms max extrapolation. | +| `RenderClock` | `RenderClock.kt` | Pure: `queryMicros = latestSampleMesh + clamp(nowWall − latestSampleWall, 0, max)` so a 60 Hz view samples **between** 10 Hz solves (kills step-function jitter). | +| `EventClaimTracker` | `EventClaimTracker.kt` | Observer-side optimistic predictions keyed by `eventId`: `track`(PENDING) → `onVerdict`(CONFIRMED/ROLLED_BACK). | +| `MofeNetcodeBridge` | `MofeNetcodeBridge.kt` | Maps engine `MeshEvent` → `EventClaim`(s). | +| `GeometricTransition` | `GeometricTransition.kt` | Rewindable, time-parameterized crossing detection; `Kind {CROSS,ENTER,EXIT,APPROACH,AIM}`. | +| `EventLifecycle` | `EventLifecycle.kt` | `EventClaim`, `EventVerdict`+`Reason`, `ReconcilePolicy` (maxLag 250 ms, timeTol 80 ms, reeval 120 ms), pure `NetcodeEvents.reevaluate/reconcile/applyVerdict`, content-addressed `eventId`. | +| `MeshAimTracker` | `MeshAimTracker.kt` | Continuous per-client aim tracking → `Track(deviceId, aimErrorRadians, reticleOffset, onReticle)`. | + +## Time sync + +```mermaid +sequenceDiagram + participant M as Member + participant L as Leader + M->>L: TimeSync (t1) + L->>L: stamp t2 (recv), t3 (send) + L-->>M: TimeSync (t1,t2,t3) + M->>M: t4 (recv) → offset=((t2−t1)+(t3−t4))/2, rtt + Note over M: keep lowest-RTT sample → meshClock.observe +``` + +Fused samples are stamped `timestamp + meshOffsetMicros` (`FusedState.toWireSample`) so every +device's history lands on one axis. The render path then advances a **wall-clock** query between +solves via `RenderClock`, so motion is smooth even though solves arrive at 10 Hz. + +## Event arbitration — optimistic fire, leader rewind, verdict + +```mermaid +sequenceDiagram + participant O as Observer (any device) + participant T as EventClaimTracker + participant L as Leader (arbitrator) + participant A as All devices + Note over O: engine fires MeshEvent + O->>O: EventNotifier optimistic local fire + O->>T: track(claim) = PENDING + alt member + O->>L: sendControl(WireEventClaim) + else leader + O->>L: arbitrate locally + end + L->>L: rewind subject's TemporalStateBuffer to tEvent
(needs EXACT/INTERPOLATED fidelity) + L->>L: re-detect via GeometricTransition + reconcile
(lag/kind/time/direction) + L->>A: broadcastControl(WireEventVerdict) + A->>T: onVerdict → CONFIRMED or ROLLED_BACK +``` + +Verdict reasons: `ACCEPTED / STALE / NO_TRANSITION / KIND_MISMATCH / TIME_MISMATCH / +DIRECTION_MISMATCH`. This is *separate* from Raft — it's leader-authoritative low-latency +adjudication, not replicated log consensus. Geometric predicates and pointing predicates both flow +through here (see the predicate lifecycle in [09](09-workflows.md)). + +## The kill-switch (`MeshCoordinator`) + +`init` gates, as one unit, (a) folding `fusedSamples` into `netcodeAdapter` and (b) +`netcodeTransport.start`; `emitClaims` early-returns when off. On-device, +`androidApp/…/platform/netcode/NetcodeBringUp.maybeStart` is the idempotent constructor of the whole +`MeshNode`, gated on `MeshTrustReadiness.Ready` + a known leader IP + a site-local IPv4. Trust +material comes from `NetcodeTrust` ([05](05-security-and-trust.md)). + +> **Onboarding note:** `NetcodeBringUp` and the UI wiring that consumes netcode are delivered as +> reviewed patches but are **not compiled in the headless env** (they need AGP). The `common` +> netcode logic *is* unit-tested (`:common:jvmTest`, incl. `NetcodeBringUpGateTest`). diff --git a/docs/architecture/05-security-and-trust.md b/docs/architecture/05-security-and-trust.md new file mode 100644 index 0000000..03bee4a --- /dev/null +++ b/docs/architecture/05-security-and-trust.md @@ -0,0 +1,92 @@ +# 05 · Security & Trust + +> **Layer** `common/messaging/security` + `androidApp/platform/netcode` · **Status** stable (one TODO, noted) · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `common/…/messaging/security/**`, `common/…/messaging/MeshTrustMaterial.kt`, `common/…/model/messaging/SecurityMessages.kt`, `androidApp/…/platform/netcode/NetcodeTrust.kt` +> **Related** — [03-networking-and-protocols](03-networking-and-protocols.md) · [06-server-and-telemetry](06-server-and-telemetry.md) + +The trust model is a single **Ed25519 mesh CA** (in tome-server) whose public key devices +**fetch-and-pin** over web-PKI TLS at startup. From that anchor hang: signed device credentials, +an authenticated link handshake, per-link AEAD, and a rotating group key for datagram encryption. +Device **private keys never leave the device**; the CA only ever signs over a supplied public key. + +## The trust chain + +```mermaid +flowchart TB + CA["tome-server Ed25519 mesh CA
(gateway/ca.py)"] -->|"GET /api/v1/mesh/ca (TLS)"| PIN["NetcodeTrust: pin CA key
(SharedPreferences, keyId rotation-aware)"] + CA -->|"mint over device pubkey"| CRED["DeviceCredential
(canonical-JSON, Ed25519 sig)"] + PIN --> GATE["MeshTrustMaterial.evaluate
→ MeshTrustReadiness"] + CRED --> GATE + GATE -->|Ready| HS["LinkHandshake (per control link)
X25519 ECDH + Ed25519 auth"] + HS --> LK["SecureControlLink
per-direction AEAD keys"] + LEADER["leader (per Raft term)"] --> GK["GroupKeyManager.rotate()
32-byte group key"] + GK -->|"unicast, sealed on each link"| DIST["GroupKeyDistribution"] + GK --> SDT["SecureDatagramTransport
keyId‖senderHash‖seq‖AEAD (UDP)"] + LK -. "distributes" .-> DIST +``` + +## Crypto suite (`MeshCryptoProvider`) + +Ed25519 (RFC 8032) · X25519 (RFC 7748) · HKDF-SHA-256 (RFC 5869) · ChaCha20-Poly1305 AEAD +(RFC 8439). Android actual = `AndroidMeshCryptoProvider` (java.security / javax.crypto). Identity is +`DeviceIdentity(deviceId, ed25519Public, ed25519Private, credential)`. + +## Device credential (`SecurityMessages.kt` ↔ server `gateway/ca.py`) + +A **field-for-field, byte-for-byte** cross-repo contract. Signed fields: `deviceId`, `ed25519Pub`, +`deviceType`, `deviceName`, `deviceRoles`, `meshId`, `meshName`, `notAfter` (microseconds), +`claims` (e.g. `voterEligible`), `is_emulated`; then `signature` (base64 Ed25519 over the canonical +body, **excluding** `signature`). Canonicalization = sorted keys, compact separators, Python +`ensure_ascii` escaping (`MeshCodec.encodeCredentialBody` ≡ server `_canonical`). `deviceId = +uuid5(namespace 6f4b2e2a-2a5e-5c9e-9b7a-1f3d5e7a9c11, ed25519Pub)` — declared identically in both +repos; **changing the field set requires the same change on both sides or signatures stop +verifying** (a golden-vector test guards the drift). Server side in [06](06-server-and-telemetry.md). + +## Link handshake (`LinkHandshake` + `SecureControlLink`) + +A 3-message X25519+Ed25519 authenticated key exchange on the raw TCP link **before any envelope**: +`SecHelloInit → SecHelloAck` (sig over transcript₁) `→ SecHelloDone` (sig over transcript₂). Both +sides verify the peer credential's **CA signature**, its **expiry**, and its **revocation** +(registry `mesh/revoked/{id}`). Directional AEAD keys derive `HKDF(x25519secret, salt=nonceM‖nonceL, +info="mmnf/link/v1"‖transcript₁, 64 B)`. `SecureControlLink` then AEAD-frames every control frame +(per-direction 64-bit counter nonce). **No downgrade path.** + +## Group key & datagram encryption (`GroupKeyManager` + `SecureDatagramTransport`) + +The leader `rotate()`s a 32-byte group key **per Raft term** and distributes +`GroupKeyDistribution` **unicast on each already-sealed control link** (so distribution is itself +sealed per member). Per-sender subkey `senderKey = HKDF(groupKey, info="mmnf/data/v1"‖senderNodeId)`. +Each datagram is framed `keyId(4)‖senderIdHash(8)‖seq(8)‖AEAD`, nonce `keyId‖seq`, AAD = the 20-byte +header. **Drops if no key — never sends plaintext.** The previous key stays valid for a grace window +across rotation; replays are caught by the `StalenessGate`. + +## Trust readiness gate (`MeshTrustMaterial`, pure `common`) + +`evaluate(...) → MeshTrustReadiness { Ready(credential), NoCaKey, NoCredential, DeviceMismatch, +MeshMismatch, Expired }` — checks the cached credential binds **this** device + the **active** mesh +and is unexpired. Offline-first: a mismatch/expiry means "re-enroll online," not a hard failure. +`NetcodeBringUp` will not construct a `MeshNode` until this returns `Ready` ([04](04-netcode-and-time.md)). + +## Where each key lives + +| Key | Location | Lifetime | +|-----|----------|----------| +| Mesh CA **public** key (anchor) | pinned in `NetcodeTrust` + SharedPreferences → `LinkHandshake` | until rotated (detected via `keyId`) | +| Device **Ed25519 private** | 32-byte seed (Android Keystore-backed) in `DeviceIdentity` | device lifetime | +| X25519 ephemeral | inside `LinkHandshake`, per connection | discarded after key derivation | +| Link AEAD keys | `SecureControlLink`, per connection + direction | connection | +| Group (data) key | in-memory `GroupKeyManager` (current+previous); band: X25519-sealed per member | one Raft term | + +## Band admission (recap) + +On the band ([03](03-networking-and-protocols.md)), discovery/admission frames are plaintext; member +frames are AEAD-sealed under the group key the owner **X25519-seals per admitted device** +(`SealedKeyPut`), and `lifecycleVerifier` checks the owner's Ed25519 signature before any CRDT state +is applied. + +## Known gap to call out + +`MeshOpSigning` (Ed25519 sign/verify for `RegistryOp.RoleDecision`) exists, but **verification is not +yet wired** — there is no mesh-admin-pubkey-by-deviceId directory in `common` (`TODO` in +`MeshStateRegistry.applyOps`), so a committed `RoleDecision` is currently trusted like any +Raft-committed op. Flagged so nobody assumes signed-role enforcement is live. diff --git a/docs/architecture/06-server-and-telemetry.md b/docs/architecture/06-server-and-telemetry.md new file mode 100644 index 0000000..4243fd2 --- /dev/null +++ b/docs/architecture/06-server-and-telemetry.md @@ -0,0 +1,113 @@ +# 06 · Server & Telemetry (tome-server) + +> **Layer** `tome-server` (separate repo) · **Status** stable (README there is stale — trust the code) · **Verified against** `bdf7e0f` (client) / tome-server `app/**` +> **Source of truth** — `tome-server/app/com/aether/tome/api/{routes,gateway,service,observability}/**`, `…/db/**`, `…/model/**` +> **Related** — [05-security-and-trust](05-security-and-trust.md) · [03-networking-and-protocols](03-networking-and-protocols.md) + +tome-server is the cloud side: the mesh **certificate authority**, the **uplink** sink for a mesh +leader, a **telemetry** fan-out (Prometheus / InfluxDB / Loki), the server→mesh **command** plane, +and a web **console**. It is *not* in the ranging path — positions are solved on-device; the server +observes and administers. + +> ⚠ The repo `README.md` is aspirational (Flask 2.3, JWT-in-localStorage, `/api/v1/meshes`). The +> **actual** code is Flask 3.1, session-cookie auth delegated to a separate `auth-api` service, and +> the mesh prefix is **`/api/v1/mesh`** (singular). Trust the paths below. + +## Tech stack & components + +Python 3.13 · Flask 3.1 (blueprints) · `flask-sock` WebSockets · gunicorn+gevent · **PostgreSQL** +(device/mesh/predicate records) · **pynacl** (Ed25519 CA) · **InfluxDB 2.x** / **Loki** / +**Prometheus** (telemetry) · session-cookie auth via external `auth-api`. + +| Component | Path | Responsibility | +|-----------|------|----------------| +| App factory | `api/tome_server.py` | `create_app()`, blueprint registration, JWKS, `/metrics`, `/health` | +| Route blueprints | `api/routes/` | `mesh.py` (CA, enroll, credential, uplink WS, commands), `device.py`, `predicate*.py`, `user.py`, `discovery.py`, `agent.py`, … | +| **Gateway** | `api/gateway/` | `ca.py` (Ed25519 **MeshCA** minting), `credentials.py` (verify + possession), `frames.py` (uplink wire types), `hub.py` (`GatewayHub`: per-mesh mirror + fan-out + commands), `reconcile.py` | +| Observability | `api/observability/` | `metrics.py` (Prom), `influx_shipper.py`, `loki_shipper.py`, `events.py` | +| Services / DB | `api/service/**`, `db/**` | business logic + PostgreSQL repositories | + +## Endpoints devices care about (`routes/mesh.py`) + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| GET | `/api/v1/mesh/ca` | none | **Publish CA trust anchor** `{alg, meshCaPublicKey, keyId}` for fetch-and-pin | +| POST | `/api/v1/mesh//device/enroll` | cookie | Enroll a device by `ed25519Pub` (`deviceId=uuid5(ns,pub)`) | +| GET | `/api/v1/mesh//device//credential?pub=` | cookie (admin/owner) | **Mint** a CA-signed `DeviceCredential` | +| WS | `/api/v1/mesh/stream` | device-cred | **Leader uplink** (challenge→hello→verify→welcome, then `UplinkFrame` ingest) | +| PUT/POST | `/api/v1/mesh//{snapshot,events}` | device-cred | REST fallback for WS-less deployments | +| POST | `/api/v1/mesh//command` | cookie (mesh-admin) | Push a `ServerCommand` into the mesh | +| WS | `/api/v1/mesh//live` | cookie | Console live viewer (snapshot + deltas) | + +Also unauthenticated by design: `/api/v1/agent/diag`, `/api/v1/agent/report` (device self-report), +`/api/v1/health`, `/api/v1/.well-known/jwks.json`, `/metrics`. + +## CA & credential minting (`gateway/ca.py`) + +`MeshCA.mint(...)` builds the credential body and appends a detached Ed25519 signature over the +**canonical JSON** (`_canonical`: `json.dumps(sort_keys=True, separators=(",",":"))`, `ensure_ascii` +default, `signature` excluded) — the byte-for-byte partner of the client's `encodeCredentialBody` +([05](05-security-and-trust.md)). Signer abstraction: `LocalSeedSigner` (dev, seed from +`MESH_CA_SEED_B64`) or `KmsSigner` (prod, **stub** — wire to HSM/KMS so key material never leaves). +`GET /mesh/ca` returns `keyId = sha256(rawKey)[:16]` so a client can pin and later detect rotation. + +```mermaid +sequenceDiagram + participant D as Device (leader) + participant S as tome-server + D->>S: GET /api/v1/mesh/ca (TLS) + S-->>D: {alg:Ed25519, meshCaPublicKey, keyId} + Note over D: pin key (NetcodeTrust) + D->>S: POST /mesh//device/enroll (ed25519Pub) [cookie] + D->>S: GET /mesh//device//credential?pub=… + S->>S: verify caller = admin|owner; deviceId==uuid5(ns,pub) + S-->>D: DeviceCredential (CA-signed, canonical JSON) + Note over D: now usable for the uplink handshake + link handshake +``` + +## Leader uplink handshake (WS `/api/v1/mesh/stream`) + +```mermaid +sequenceDiagram + participant L as Mesh leader + participant S as Server + S-->>L: {gw.challenge, nonce} + L->>S: {gw.hello, credential, nonceSignature} + S->>S: verify_credential() + verify_possession() + alt ok + S-->>L: {gw.welcome, meshId} + loop + L->>S: UplinkFrame (ALERT|STATE_DELTA|SNAPSHOT|TELEMETRY|TRACE|ACK) + S-->>L: ServerCommand / ACK + end + else bad + S-->>L: close 4401/4403 (or 4409 name conflict) + end +``` + +`verify_possession` requires `deviceId == uuid5(ns, ed25519Pub)` (closes key-substitution). On +welcome the server enrolls **only the connecting leader**; peers are reconciled/admin-gated, never +asserted from node records. + +## Telemetry — three tiers (all from the leader uplink) + +| Tier | Frame | Store | What | +|------|-------|-------|------| +| **Prometheus** | `TELEMETRY` | `/metrics` gauges | per-node signal/confidence/posErr/gdop/latencies/… (**positions never exported**) | +| **InfluxDB 2.x** | `TRACE` | `mofe` bucket, line protocol | `mofe_solve` (incl. `pos_*`, `vel_*`, `cov_*`, per-anchor rejects — **positions included** for accuracy eval), `mofe_lifecycle` | +| **Loki** | `ALERT` + events | log push | discrete mesh/membership/predicate/fleet events + HTTP access | + +Both shippers are background, bounded-queue, **drop-oldest** — telemetry never backpressures ingest. +Timestamps use server ingest wall-clock (device clocks are boot-relative monotonic). + +## Server → mesh commands (`frames.py`) + +`POST /mesh//command` pushes a `ServerCommand` (`PREDICATE_PUT/DELETE`, `TRACK/UNTRACK_TARGET`, +`RECALIBRATE`, `SET_ROLES`, `ADD/REMOVE_NODE`, `EJECT_USER`, …) with a pending-ACK (10 s). The leader +applies it (via Raft where it mutates replicated state) and replies `ACK`. + +## Gaps to know + +Server-side **revocation table** is a TODO (design defers to the mesh's replicated +`mesh/revoked/{id}`); `KmsSigner` is a stub; `ADD/REMOVE_NODE` Raft-membership push is stubbed. The +whole gateway is inert unless `mesh_gateway_enabled` + a `mesh_gateway` config block are present. diff --git a/docs/architecture/07-client-app-and-rendering.md b/docs/architecture/07-client-app-and-rendering.md new file mode 100644 index 0000000..f0876b4 --- /dev/null +++ b/docs/architecture/07-client-app-and-rendering.md @@ -0,0 +1,110 @@ +# 07 · Client App & Rendering + +> **Layer** `androidApp` · **Status** stable · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `androidApp/src/main/java/com/aether/mofe/{AetherApp.kt,ui/**,viewmodel/**,platform/**,data/MeshRepository.kt}` +> **Related** — [01-positioning-engine](01-positioning-engine.md) · [02-mesh-and-consensus](02-mesh-and-consensus.md) · [04-netcode-and-time](04-netcode-and-time.md) · [09-workflows](09-workflows.md) + +The Android app supplies the **hands** (UWB/IMU/band radios), the **on-device assembly**, and the +**screen**. It reads the `common` engine + mesh through two bridge objects and renders a Filament +3-D / first-person scene gated by render-readiness. + +## Boot chain + +`AndroidManifest` → **`AetherApp`** (Application; builds the object graph once) → `MainActivity` → +`PermissionGateScreen` (UWB is the hard requirement) → **`AppShell(app, hud)`**. + +`AetherApp.onCreate` wires everything: builds `AndroidUwbService`, `MeshRepository`, and +`MofeEngineHost` (starts the engine); resolves the device seed identity; sets up the control-plane +API + `MeshRegistrar`; and launches long-lived collectors that (a) push engine `fusedStates` → +`MeshRepository.setFusedPoses`, and (b) drive from `MeshRepository.sharedState`: set root anchor, +`NetcodeBringUp.maybeStart`, track/untrack targets, feed inter-anchor ranges, place/repair anchors, +**client self-seed**, register composites, and emit predicate-satisfied events. + +## The two bridges + +```mermaid +flowchart LR + subgraph HW["platform actuals"] + U["AndroidUwbService"]:::hw + I["AndroidImuService"]:::hw + B["BroadcastBand"]:::hw + end + U --> H["MofeEngineHost
(wraps common engine)"] + I --> H + B --> R["MeshRepository
(roster + shared state)"] + H -->|"fusedStates"| R + R -->|"track / seed / place"| H + H -->|"meshMode · anchorLinks · renderReadiness ·
antennaDelays · constellationFrame · geometryReport"| SHELL["AppShell"] + R -->|"sharedState · snapshot · selfDeviceId"| SHELL + SHELL --> SCR["screens (scene · diagnostics · manage · predicates)"] + classDef hw fill:#eef; +``` + +- **`MofeEngineHost`** (`platform/MofeEngineHost.kt`) — application-scoped wrapper around the engine, + pinned to a single engine dispatcher. Exposes StateFlows the UI consumes: `fusedStates`, `meshMode`, + `quorum`, `anchorHealth`, `anchorLinks` (`InterAnchorLinkReport`), `antennaDelays`, + `constellationFrame`, `geometryReport`, **`renderReadiness`**, `behaviorEvents`, `selfDiag`. Key + methods: `start(selfId)` (engine build + ~60 Hz self-pose poll + 2 s maintenance refresh), + `becomeRoot`, `track`/`untrack`, **`placeReferenceAnchor`** (drives CALIBRATING→QUORUM), + **`seedSelfFromExternalPosition`** (client self-seed), `registerComposite`, `latestSelfPose()`. +- **`MeshRepository`** (`data/MeshRepository.kt`) — the band/roster/UWB orchestrator + single source + of shared state: `sharedState` (band roster), `snapshot` (HUD model: self at origin + placed peers), + `selfDeviceId`, `isAnchorRole`, `frameReferenceAnchors()`, `selfBroadcastSolvedPosition()` (fed into + self-seed). Owns UWB session orchestration, band lifecycle, and X25519+AEAD group-key admission. + +## Screens (`ui/`) + +| Route | Screen | File | +|-------|--------|------| +| `visualizer` (home) | 3-D / FPV scene | `ui/scene/MeshSceneScreen.kt` (+ `MeshSceneView.kt`) | +| `predicates` | pointing-predicate realizer | `ui/predicate/PredicateRealizerScreen.kt` | +| `calibrate` | mesh diagnostics / health | `ui/MeshHealthPane.kt` | +| `meshes` | create / discover / requests / gateway | `ui/manage/MeshManagerScreen.kt` | +| `settings` | app settings | `ui/SettingsScreen.kt` | + +`AppShell` computes the **start destination** from `MeshDisplayPolicy.startupSurface(isAnchorOrRoot, +renderReadiness, autoJoinBringup=true)` → an anchor/root lands on **Diagnostics**, a client on the +**Visualizer**. The scene's diagnostics pane (`DiagnosticPane` in `ui/HudScreen.kt`) shows the +`SelfAttitudeDiag` rows + AoA calibration controls + the **`LayoutEditorDialog`** (the surveyed +`MeshGroundTruth` editor). + +## Render & aim path + +The render brain is **`HudViewModel`** (`viewmodel/HudViewModel.kt`). It owns a client-local +`NetcodeSession` (interp delay 120 ms) + `MeshAimTracker` fed from `mofeEngineHost.fusedStates`. + +```mermaid +flowchart LR + FS["fusedStates"] --> NS["NetcodeSession.recordSample"] + RC["RenderClock.queryMicros
(wall-clock advanced)"] --> RP["renderNodePositions()
= renderPositions(query) − self world pose"] + NS --> RP + RP --> OF["MeshSceneView.onFrame
slew Filament nodes (F1 smoothing)"] + LSP["latestSelfPose().orientation
facing = body −Z (lens)"] --> AIM["aimTracks(cone) → Track"] + AIM --> OV["AimMarkersOverlay (reticle)"] + RG{"readiness.canRenderSpatial?"} -- no --> HOLD["MeshHoldingState (status label)"] + RG -- yes --> OF +``` + +- **`RenderClock`** advances a wall-clock query so the 60 Hz view interpolates *between* 10 Hz solves + (the F1 smoothness fix). `MeshSceneView.onFrame` slews retained Filament spheres to + `renderNodePositions()` each frame. +- **FPV** seats the camera at origin and `lookTowards(facing)` (body −Z), using + `FpvCameraCalibration.DEFAULT` (vertical FOV + `worldToFilament` swap) so the 3-D spheres and the + 2-D overlays coincide. +- **Aim** uses the same body −Z "lens" as the engine's pointing predicate, so aim, camera, and the + gesture share one "forward." +- **Gate:** `MeshSceneScreen` draws the scene only when `readiness.canRenderSpatial`, else a labelled + `MeshHoldingState` — the client-protection safeguard from + [01 · render-readiness](01-positioning-engine.md). + +## Platform bring-up (`platform/`) + +`AndroidUwbService` (AndroidX-UWB, a pool of per-link DS-TWR sessions) · `AndroidImuService` · +`BroadcastBand` (UDP :47600) · `NetcodeBringUp`/`NetcodeTrust`/`NetcodePeerBook` (the on-device MMNF +assembly, [04](04-netcode-and-time.md)/[05](05-security-and-trust.md)) · control plane +(`ControlPlaneApi`, `AuthClient`, `MeshRegistrar`, `GatewayUplinkClient`). No foreground service — +background tears down ranging, foreground re-activates. + +> **Onboarding note:** `androidApp` requires the Android Gradle Plugin and can't be compiled in the +> network-restricted env. UI changes are delivered as reviewed patches; the logic they consume is +> unit-tested in `:common`. See [08](08-contributing.md). diff --git a/docs/architecture/08-contributing.md b/docs/architecture/08-contributing.md new file mode 100644 index 0000000..73215b1 --- /dev/null +++ b/docs/architecture/08-contributing.md @@ -0,0 +1,82 @@ +# 08 · Contributing — build, test, deliver + +> **Layer** build/process · **Status** stable · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `settings.gradle.kts`, `build.gradle.kts`, `common/build.gradle.kts`, `gradle/gradle-daemon-jvm.properties`, `common/…/messaging/support/ClusterHarness.kt` +> **Related** — [00-overview](00-overview.md) · [README](README.md) + +## Module layout & what's testable where + +| Module | Contains | Testable headless? | +|--------|----------|--------------------| +| `:common` | the KMP engine, mesh, netcode, security, models | **Yes** — `./gradlew :common:jvmTest` (pure JVM, no Android) | +| `:androidApp` | Compose UI + Android platform actuals | **No** here — needs the Android Gradle Plugin (AGP) | + +**Put logic in `:common`.** Anything you can express platform-agnostically belongs there because it +is unit-testable without a device. `androidApp` should be thin glue (radios, sensors, screen, +assembly). This is why the render-readiness model, the display policy, the netcode kill-switch, etc. +all live in `common` with tests, and only their *wiring* lives in `androidApp`. + +## The offline `:common:jvmTest` recipe + +The build pins a **JetBrains-vendor JDK 21 daemon** (`gradle/gradle-daemon-jvm.properties`), a +**JDK-17 compile toolchain** (`common/build.gradle.kts` `jvmToolchain(17)`), and resolves AGP from +Google Maven. In a network-restricted environment none of those can be fetched. To run the `common` +tests with only the system JDK, neutralize the four network-coupled constraints **in a throwaway git +worktree** (never the real tree), then discard it: + +```bash +git worktree add -b _verify /tmp/verify origin/feature/uiux-update2 # or any base +cd /tmp/verify +rm -f gradle/gradle-daemon-jvm.properties # daemon → JAVA_HOME +sed -i 's/jvmToolchain(17)/jvmToolchain(21)/' common/build.gradle.kts # use system JDK 21 +sed -i -E '/^[[:space:]]*alias\(libs\.plugins\..*apply false/ s|^|// |' build.gradle.kts # drop AGP plugin markers +sed -i 's|^include(":androidApp")|// &|' settings.gradle.kts # exclude the Android module +JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew :common:jvmTest --offline --no-daemon +cd - && git worktree remove /tmp/verify --force +``` + +This runs the full `:common` suite green (≈760 tests) against the system JDK. It is a **fidelity +shortcut**, not a production build: `commonMain`/`commonTest` are platform-agnostic Kotlin, so the +JDK 17-vs-21 swap does not affect test logic — but it is **not** a substitute for CI under the pinned +toolchain, and it says nothing about whether `androidApp` compiles. Use a worktree so a killed run +can never leave the real tree edited. + +## The test harness — `ClusterHarness` + +Mesh/consensus/netcode behaviour is tested against `common/…/messaging/support/ClusterHarness.kt`, an +in-memory N-node cluster over virtual transports (`VirtualEther`/`VirtualControlBus`) with a virtual +clock. It supports per-node scopes (`kill`/`killLeader`), fault injection (latency/loss/partition), +and `pump(rounds)` to advance the clock + scheduler in lockstep. Netcode tests opt into the seam with +`ClusterHarness(this, netcodeBringUp = true)` (default off, mirroring production). Prefer extending +this harness over mocking. + +## Delivering to the dev branch (`feature/uiux-update2`) + +The dev does **not** use the `claude/*` branch. Deliver changes as **`git format-patch` files** that +apply cleanly onto the dev tip, verified with `:common:jvmTest` green **against that base** (use the +worktree recipe above). Convention: + +```bash +# generate: linearize your commits onto the dev tip (a worktree cherry-pick proves clean apply) +git format-patch origin/feature/uiux-update2..HEAD -o /tmp/stack + +# the dev applies: +git checkout feature/uiux-update2 +git am --keep-cr /tmp/stack/*.patch # ⚠ --keep-cr is REQUIRED (see below) +./gradlew :common:jvmTest # expect green +``` + +> ⚠ **`git am --keep-cr` is mandatory.** Several files are **CRLF**-terminated +> (`MeshCoordinator.kt`, `MeshMessagingService.kt`, `LinkHandshake.kt`, and server `mesh.py`). Plain +> `git am` strips the CR and fails with *"patch does not apply"*. `git apply` then commit also works. + +Ship a short **README table** with each stack: `# | what | layer | verified`, marking `common` (✅ +jvmTest) vs `androidApp` (⚠ not compilable here — mechanical, mirrors existing flows). Roadmap/design +docs are **not** bundled by default — offer them separately. + +## Keeping these docs true + +Each doc's **Source of truth** header lists the code it describes; the [README](README.md) has the +path→doc map. When your change touches those files, update the doc and bump **Verified against** in +the same change. Diagrams are Mermaid — edit the fenced block. New subsystem → new numbered doc + +manifest row. diff --git a/docs/architecture/09-workflows.md b/docs/architecture/09-workflows.md new file mode 100644 index 0000000..226f682 --- /dev/null +++ b/docs/architecture/09-workflows.md @@ -0,0 +1,109 @@ +# 09 · End-to-end Workflows + +> **Layer** cross-cutting · **Status** stable · **Verified against** `bdf7e0f` (2026-08-04) +> **Source of truth** — `androidApp/…/ui/manage/**`, `…/ui/predicate/**`, `…/platform/control/MeshRegistrar.kt`, `…/platform/netcode/**`, `…/data/MeshRepository.kt`, `common/…/messaging/MeshCoordinator.kt`, `common/…/engine/BehaviorEngine.kt` +> **Related** — all docs; this is where the layers meet. The user-facing versions of these are the layperson guide. + +Three workflows carry the whole system. Each is shown as the user's steps + the under-the-hood path ++ the entry-point files, so you can follow one thread top to bottom. + +## A · Set up a new mesh (root anchor → QUORUM) + +```mermaid +sequenceDiagram + actor Op as Operator + participant App as App (MeshManager / Diagnostics) + participant Reg as MeshRegistrar + participant Repo as MeshRepository / Band + participant Eng as MofeEngineHost / engine + Op->>App: grant UWB/BT permissions + Op->>App: Meshes ▸ + ▸ create (name, visibility) + App->>Reg: createLocalMesh(name, visibility) + Reg->>Repo: setSelfIdentity(roles=[ROOT,ANCHOR]) + advertise BandMeshMeta (signed) + Reg-->>Eng: onBecameRoot → becomeRoot(self) (pin frame origin) + Op->>App: Requests ▸ invite anchors ; targets accept + Repo->>Eng: inter-anchor UWB ranges → feedInterAnchorRange + Op->>App: Diagnostics ▸ LAYOUT (truth) → enter surveyed X/Y/Z + App->>Eng: MeshGroundTruth.poses (antenna-delay cal) + App->>Eng: placeReferenceAnchor × N (≥4 refs) + Eng-->>App: meshMode UNINITIALIZED→CALIBRATING→PRE_QUORUM→QUORUM + Note over App: anchor/root lands on Diagnostics (MeshDisplayPolicy) +``` + +Entry files: `ui/manage/MeshManagerScreen.kt` (`OfflineCreateDialog`) → `MeshManagerViewModel.createLocalMesh` +→ `platform/control/MeshRegistrar.kt` → `MeshRepository` / `MofeEngineHost`; surveyed layout via +`ui/HudScreen.kt` `LayoutEditorDialog` → `engine/MeshGroundTruth.kt`. The mode machine + constellation +lock are [01](01-positioning-engine.md) / [02](02-mesh-and-consensus.md); no server is required +(offline-first). + +## B · A client joins an existing mesh (→ scene unlock) + +```mermaid +sequenceDiagram + actor U as User + participant App as App (Meshes ▸ Discover) + participant Reg as MeshRegistrar + participant Srv as tome-server + participant NB as NetcodeBringUp + participant Eng as engine + U->>App: Discover ▸ Join / Request + App->>Reg: requestJoinLocal(meshId) (roles=[MEMBER,CLIENT]) + Note over Reg: owner approves → group key X25519-sealed to member + opt go online + App->>Srv: fetch+pin CA key ; pull/enroll DeviceCredential + end + App->>NB: maybeStart (gated: MeshTrustReadiness.Ready + leader IP + LAN) + Eng->>Eng: seedSelfFromExternalPosition(anchor-broadcast solved pos) + Eng-->>App: renderReadiness → READY (QUORUM + self-localized) + Note over App: MeshHoldingState → Filament 3-D / FPV scene +``` + +Entry files: `ui/manage/MeshManagerScreen.kt` (Discover) → `MeshRegistrar.requestJoinLocal`/`connectTo` +→ `MeshRepository` (self-seed via `selfBroadcastSolvedPosition`) → `MofeEngineHost.seedSelfFromExternalPosition`; +trust via `platform/netcode/NetcodeTrust.kt` + `NetcodeBringUp.kt`. A client **HOLDs** (never shows +unsolved locations) until READY; an anchor would instead see Diagnostics +([01 · MeshDisplayPolicy](01-positioning-engine.md)). + +## C · An author contributes a predicate + +Two families share the arbitration seam: + +**Pointing predicate (the on-device authoring surface):** + +```mermaid +sequenceDiagram + actor A as Author + participant PR as PredicateRealizer + participant Repo as Band (composites) + participant BE as BehaviorEngine (every device) + A->>PR: "Point at " + PR->>Repo: publishComposite(PcdManifest: forward→direction→angle→cone→for_duration→edge) + Repo->>BE: registerComposite(manifest, bindings) on every device + loop each fused frame + BE->>BE: tick(pose): is body −Z within the cone for the dwell? + end + BE-->>Repo: CompositeFired(pointedAtIds) → PREDICATE_SATISFIED BandEvent + Note over PR: lights ✓ as actor (source==self) or target (target==self) +``` + +**Geometric predicate (region entry/exit, replicated + arbitrated):** + +```mermaid +sequenceDiagram + participant L as Leader + participant R as Raft / registry + participant N as Every node's engine + participant NC as Netcode (if ON) + L->>R: registerPredicateMeshWide(GeometricPredicate: Shape.Sphere/Box/…) + R-->>N: on commit → applyLocalSideEffects → predicate registered + N->>N: observer detects RegionEntry/Exit locally → optimistic fire + N->>NC: EventClaim → leader rewind arbitration → EventVerdict + NC-->>N: CONFIRMED / ROLLED_BACK +``` + +Entry files: `ui/predicate/PredicateRealizerScreen.kt` + `PredicteRealizerViewModel.kt`; manifests +`model/pcd/PointingPredicates.kt`; evaluation `engine/BehaviorEngine.kt`; geometric predicates +`model/EventTypes.kt` + `math/Geometry.kt` (`Shape.Sphere/Line/Box/Plane`); mesh-wide registration + +arbitration `messaging/MeshCoordinator.kt` (`registerPredicateMeshWide`, `emitClaims`) → +[04 · arbitration](04-netcode-and-time.md). The geometric arbitration path only runs when the netcode +kill-switch is on; the pointing/composite path runs on the band regardless. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..974a06a --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,75 @@ +# Aether Architecture — Developer Onboarding + +> The system as a **stack of interoperable systems**. Read these to ramp from zero to +> contributing. They are **living design artifacts**: kept in the repo, versioned with the +> code, and meant to be edited by whoever changes the code they describe (human or agent). + +## How to use this set + +- **Ramping up?** Read in order: [00](00-overview.md) → [01](01-positioning-engine.md) → + [02](02-mesh-and-consensus.md) → [03](03-networking-and-protocols.md) → + [04](04-netcode-and-time.md) → [05](05-security-and-trust.md) → + [06](06-server-and-telemetry.md) → [07](07-client-app-and-rendering.md) → + [08](08-contributing.md) → [09](09-workflows.md). +- **Touching a file?** Find it in the **Source-of-truth map** below → open that doc first. +- **Want the user's-eye view?** The workflows in [09](09-workflows.md) mirror the + layperson guide (mesh setup, client join, predicate authoring). + +## The doc set + +| # | Doc | What it covers | Primary layer | +|---|-----|----------------|---------------| +| 00 | [Overview & the stack](00-overview.md) | The layered stack, the 5 communication planes, glossary, module map | all | +| 01 | [The MOFE positioning engine](01-positioning-engine.md) | UWB→pre-filter→multilaterate→IMU-EKF→constellation solve/lock; calibration | `common/engine` | +| 02 | [Mesh & consensus](02-mesh-and-consensus.md) | Roles, Raft (pre-vote), the replicated state registry, quorum & operating modes | `common/messaging`, `raft` | +| 03 | [Networking & wire protocols](03-networking-and-protocols.md) | The two stacks, planes/ports/transports, CBOR envelope, the UWB band | `common/messaging`, `androidApp/band` | +| 04 | [Netcode & time](04-netcode-and-time.md) | Mesh clock, temporal buffers, entity interpolation, claim→arbitration→verdict, the kill-switch | `common/engine/netcode` | +| 05 | [Security & trust](05-security-and-trust.md) | Ed25519 CA, credentials, link handshake, group-key datagram AEAD, op signing | `common/messaging/security` | +| 06 | [Server & telemetry](06-server-and-telemetry.md) | tome-server: CA endpoint, uplink, three telemetry tiers, commands | `tome-server` | +| 07 | [Client app & rendering](07-client-app-and-rendering.md) | Compose shell, screens, host StateFlow bridges, render-readiness + role gate | `androidApp` | +| 08 | [Contributing](08-contributing.md) | Repo layout, build & offline-test recipe, the test harness, patch delivery, doc upkeep | build/process | +| 09 | [End-to-end workflows](09-workflows.md) | Mesh bootstrap, client join, predicate lifecycle — as dev sequences | cross-cutting | + +## Source-of-truth map (path → doc) + +Match the file you're editing to the most specific glob; update that doc + its **Verified +against** commit when you land the change. + +| Code path glob | Doc | +|----------------|-----| +| `common/…/engine/**` (except `engine/netcode/**`, `engine/render/**`) | 01 | +| `common/…/engine/render/**` | 01 (model) + 07 (UI consumption) | +| `common/…/messaging/raft/**`, `common/…/messaging/MeshStateRegistry.kt`, `MeshCoordinator.kt` | 02 | +| `common/…/messaging/MeshMessagingService.kt`, `MeshCodec.kt`, `Transports.kt`, `model/messaging/**` | 03 | +| `androidApp/…/platform/band/**` | 03 | +| `common/…/engine/netcode/**` | 04 | +| `common/…/messaging/security/**`, `MeshTrustMaterial.kt`, `androidApp/…/platform/netcode/**` | 05 | +| `tome-server/**` | 06 | +| `androidApp/…/ui/**`, `…/platform/MofeEngineHost.kt`, `…/data/MeshRepository.kt`, `AetherApp.kt` | 07 | + +## The per-doc header + +Every doc opens with a metadata blockquote — human-readable **and** the contract an editor +keeps current: + +``` +> **Layer** · **Status** stable|draft · **Verified against** () +> **Source of truth** — +> **Related** — +``` + +## Maintenance protocol (for humans and agents) + +1. **Edit the doc with the code.** If your diff touches a doc's *Source of truth*, edit the + doc in the same change and set *Verified against* to the new commit. +2. **Diagrams are text.** All diagrams are Mermaid fenced blocks — edit them; don't replace + them with images. They render on GitHub and in Claude artifacts natively. +3. **One topic per file, stable headings.** Link across docs by relative path + anchor so + references survive edits. +4. **Say what's aspirational.** Mark not-yet-wired or OFF-by-default behaviour explicitly + (e.g. the netcode kill-switch, the RoleDecision verify TODO) so readers don't assume it. +5. **New subsystem → new doc + manifest row + Source-of-truth-map row.** + +--- +*These docs were built from a read-only sweep of the codebase at the commit in each +header. Line numbers drift; treat file paths + symbol names as the durable anchors.* -- 2.43.0