# ADR-139: WorldGraph: Environmental Digital Twin with Typed Petgraph

| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-28 |
| **Deciders** | ruv |
| **Codebase target** | New module/crate `wifi-densepose-worldgraph` alongside `v2/crates/wifi-densepose-geo` and `v2/crates/homecore`; petgraph bridge pattern from `v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs`; integrates `homecore/src/registry.rs` `area_id` and `wifi-densepose-mat/src/domain/scan_zone.rs` |
| **Relates to** | ADR-044 (Geospatial Satellite Integration), ADR-113 (Multistatic Placement Strategy), ADR-127 (HomeCore State Machine), ADR-030 (Persistent Field Model), ADR-136 (RuView Streaming Engine), ADR-137 (Fusion Quality Scoring), ADR-138 (LinkGroup / ArrayCoordinator), ADR-142 (Evolution Tracker), ADR-144 (UWB Range-Constraint Fusion), ADR-145 (Ablation Eval Harness) |

---

## 1. Context

### 1.1 The Gap

There is no single, queryable model of *the environment a RuView installation senses*. The spatial knowledge that exists in the workspace is fragmented across four crates, each holding one projection of "where things are" with no edges connecting them:

- **`v2/crates/wifi-densepose-geo`** holds the *outdoor / global* frame. `src/types.rs` defines `GeoPoint { lat, lon, alt }` (the ADR-044 WGS84 anchor), `GeoBBox`, `GeoScene`, and `GeoRegistration { origin, heading_deg, scale }`. `src/coord.rs` implements `wgs84_to_enu()` / `enu_to_wgs84()` — the exact transform needed to pin a room into a local East-North-Up frame relative to a `GeoPoint`. But `GeoScene` only models buildings and roads (`OsmFeature::Building`, `OsmFeature::Road`); it has no concept of an interior room, wall, doorway, sensor placement, or a person inside.
- **`v2/crates/homecore/src/registry.rs`** holds the *entity / automation* frame. `EntityEntry` carries `area_id: Option<String>` and `device_id: Option<String>` (mirroring Home Assistant `core.entity_registry` v13 per ADR-127). This is the canonical handle for "which room an entity is in" — but `area_id` is an opaque string with no geometry, no adjacency, and no link to the sensors that observe it.
- **`v2/crates/wifi-densepose-mat/src/domain/scan_zone.rs`** holds the *sensing geometry* frame. `ScanZone` has `ZoneBounds` (Rectangle/Circle/Polygon), `SensorPosition { id, x, y, z, sensor_type }`, and `contains_point()`. This is the only place that knows sensor coordinates relative to a monitored area — but its coordinates are bare `f64` meters with no declared origin, no link to `homecore` `area_id`, and no link to a `GeoPoint`.
- **`v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs`** demonstrates the *graph algorithm* pattern we want: it bridges a domain `BrainGraph` to `petgraph::graph::{Graph, UnGraph}` (`to_petgraph()` / `from_petgraph()`) so that petgraph's traversal/shortest-path algorithms run over a typed domain model. But its nodes are bare `usize` and its edges carry only an `f64` weight plus a `ConnectivityMetric` enum — there is no node *type* and no edge *semantics*. It is the right mechanical pattern, the wrong domain.

Concretely, what is **missing**:

1. **No node typing.** Nothing in the workspace represents `room`, `zone`, `wall`, `doorway`, `sensor`, `rf_link`, `person_track`, `object_anchor`, `event`, or `semantic_state` as first-class graph nodes with a shared identity space.
2. **No typed edges.** There is no `observes` edge (sensor → node), no `located_in` (person → room), no `adjacent_to` (room ↔ room through a doorway), no `supports` / `contradicts` (evidence relations), no `derived_from` (provenance), and no `privacy_limited_by` (sensor capability constrained by a privacy mode).
3. **No provenance / contradiction tracking.** ADR-137's fusion engine produces `EvidenceRef` and `ContradictionFlag` records, but there is nowhere to *attach* them — they cannot point at the world entity they support or contradict.
4. **No privacy-impact rollup.** ADR-141's privacy control plane will define named modes and per-action allow/deny, but no structure answers "given the current mode, which world nodes can sensor X still observe?"
5. **No persistence of topology.** Each of the four crates persists independently (HomeCore to `core.entity_registry`, geo to a tile cache, MAT in memory). There is no single artifact a RuView appliance can load at boot to reconstitute "the rooms, the sensors, who's where, and why we believe it."

This ADR closes the gap with a **WorldGraph**: a typed `petgraph` over a serde-serializable node enum and typed edges, persisted as an RVF bundle, pinned to a `GeoPoint`, keyed by HomeCore `area_id`, and carrying ADR-137 evidence/contradiction provenance plus ADR-141 privacy constraints.

### 1.2 What "WorldGraph" Means Here

The WorldGraph is an **environmental digital twin** of a *single installation*: the static room/zone/wall/doorway/sensor topology plus the dynamic person/object/event/semantic overlay that sensing produces. It is:

- A `petgraph::stable_graph::StableDiGraph<WorldNode, WorldEdge>` (directed; stable indices so node removal does not invalidate other handles).
- The single authority for *spatial identity*: every `area_id` in HomeCore, every `ScanZone` in MAT, and every sensor placement in ADR-113 maps to exactly one WorldGraph node.
- Append-with-provenance, not overwrite: a node update that supersedes a prior belief adds a `derived_from` edge to the old state and (when sources disagree) a `contradicts` edge, so the graph retains *why* it holds its current belief.

It is **not**:

- A real-time per-frame buffer. The streaming engine (ADR-136) owns per-frame data; the WorldGraph is updated at the *event / semantic-state* cadence (sub-Hz to low-Hz), not the 20 Hz CSI cadence.
- A geometry/CAD engine. Walls and doorways are coarse topological elements (an adjacency relation + a 2D segment), not a BIM model.
- A temporal reconfiguration history. v1 models the *current* static topology only; topology reconfiguration history is deferred to ADR-142's evolution tracker (see §2.7).

### 1.3 Frame and Identity Context

A WorldGraph is pinned to one `GeoRegistration { origin: GeoPoint, heading_deg, scale }` (ADR-044, already in `geo/src/types.rs`). All interior coordinates are **local ENU meters** relative to `origin`, exactly the frame produced by `geo::coord::wgs84_to_enu()`. This means:

- A `room`/`zone` node carries its `ScanZone`-style `ZoneBounds` in ENU meters and can be re-projected to WGS84 via `enu_to_wgs84()` for the ADR-044 map overlay.
- A `sensor` node reuses the `SensorPosition { x, y, z }` semantics from `scan_zone.rs`, now anchored to the installation origin.
- A `room`/`zone` node carries `area_id: Option<String>` so a HomeCore `EntityEntry.area_id` resolves to exactly one WorldGraph node (entity linkage per ADR-127).

### 1.4 Pipeline Position

```
                  ADR-044 GeoPoint / GeoRegistration (installation origin)
                                 │  pins local ENU frame
                                 ▼
  ADR-136 streaming frames ─► ADR-137 FusionEngine ─► (EvidenceRef, ContradictionFlag)
                                 │                                │
                                 │ person/object/event             │ provenance
                                 ▼                                ▼
  ADR-113 sensor placement ─► ┌──────────────── WorldGraph ───────────────────┐
  ADR-138 LinkGroup        ─► │ nodes: room/zone/wall/doorway/sensor/rf_link/  │
  homecore area_id         ─► │        person_track/object_anchor/event/       │
  MAT ScanZone bounds      ─► │        semantic_state                          │
                              │ edges: observes/located_in/adjacent_to/        │
  ADR-141 privacy modes ───► │        supports/contradicts/derived_from/       │
                              │        privacy_limited_by                       │
                              └───────────────┬───────────────┬───────────────┘
                                              │ query API     │ RVF write-through
                                              ▼               ▼
                       observability / location / privacy   .rvf bundle (persisted)
                       rollup queries (ADR-140, ADR-144,
                       ADR-145 consume)
```

The WorldGraph sits *downstream* of fusion (it stores fused beliefs, not raw frames) and *upstream* of the semantic/agent layer (ADR-140) and evaluation harness (ADR-145). ADR-144 (UWB range constraints) reads `sensor`/`object_anchor` nodes as the anchor set for range-constraint solving.

---

## 2. Decision

### 2.1 Node and Edge Model: serde Enum, Not Trait Objects

Nodes are a **`#[derive(Serialize, Deserialize)]` enum**, not boxed trait objects. This is the single most consequential decision: a serde enum gives deterministic, schema-versioned, RVF-friendly persistence (every variant serializes to the same wire layout regardless of build), whereas `Box<dyn WorldNodeTrait>` would require `typetag` (an extra dependency, non-deterministic across crate versions) and could not be field-walked by an evaluation harness. The `petgraph_bridge.rs` precedent already stores concrete weights (`usize`, `f64`) rather than trait objects; we extend that to a typed enum.

```rust
//! v2/crates/wifi-densepose-worldgraph/src/model.rs

use serde::{Deserialize, Serialize};
use wifi_densepose_geo::types::GeoRegistration; // ADR-044

/// Stable, monotonic identity for a world entity. Distinct from petgraph's
/// NodeIndex (which is a graph-internal handle); WorldId survives RVF
/// round-trips and node removal.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorldId(pub u64);

/// Local ENU coordinate in meters relative to the installation origin.
/// Mirrors `scan_zone::SensorPosition` {x,y,z} but in a named frame.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EnuPoint {
    pub east_m: f64,
    pub north_m: f64,
    pub up_m: f64,
}

/// A typed world node. Persistence-deterministic serde enum (no trait objects).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorldNode {
    /// A bounded interior space. Linked to HomeCore `area_id` (ADR-127).
    Room {
        id: WorldId,
        /// HomeCore registry area_id; the entity-linkage join key.
        area_id: Option<String>,
        name: String,
        /// ZoneBounds in local ENU meters (reuses MAT ZoneBounds shape).
        bounds_enu: ZoneBoundsEnu,
        floor: i16,
    },
    /// A sub-region of a room targeted for sensing (MAT ScanZone analogue).
    Zone {
        id: WorldId,
        parent_room: WorldId,
        name: String,
        bounds_enu: ZoneBoundsEnu,
    },
    /// A wall segment (coarse topological element, 2D segment in ENU).
    Wall {
        id: WorldId,
        a: EnuPoint,
        b: EnuPoint,
        /// Coarse RF attenuation estimate in dB (drywall ≈ 3, brick ≈ 12).
        rf_attenuation_db: f32,
    },
    /// A passable opening between two rooms.
    Doorway {
        id: WorldId,
        center: EnuPoint,
        width_m: f32,
    },
    /// A physical sensing device placement (ADR-113 placement target).
    Sensor {
        id: WorldId,
        device_id: String,     // matches homecore EntityEntry.device_id
        position: EnuPoint,    // SensorPosition x/y/z analogue
        modality: SensorModality,
    },
    /// A directed RF propagation channel between two sensors (ADR-138 LinkGroup member).
    RfLink {
        id: WorldId,
        tx: WorldId,           // Sensor node
        rx: WorldId,           // Sensor node
        link_group_id: Option<String>, // ADR-138 MLO LinkGroup
        center_freq_mhz: u32,
    },
    /// A tracked person (Kalman track id from ruvsense pose_tracker).
    PersonTrack {
        id: WorldId,
        track_id: u64,
        last_position: EnuPoint,
        reid_embedding_ref: Option<String>, // AETHER re-ID handle
    },
    /// A persistent static reflector / object (ADR-143 RF SLAM anchor; ADR-144 UWB anchor).
    ObjectAnchor {
        id: WorldId,
        position: EnuPoint,
        anchor_kind: AnchorKind,
        confidence: f32,
    },
    /// A discrete detected event (fall, entry, gesture) at a point in time.
    Event {
        id: WorldId,
        event_type: String,
        at_unix_ms: i64,
        located_in: Option<WorldId>, // Room/Zone
    },
    /// A fused semantic belief about the world (the ADR-140 record's graph anchor).
    SemanticState {
        id: WorldId,
        statement: String,        // e.g. "occupant present, seated, room=living_room"
        confidence: f32,
        /// Mandatory provenance per the house rule (see §2.3).
        provenance: SemanticProvenance,
        valid_from_unix_ms: i64,
    },
}

/// MAT ZoneBounds reprojected into the installation ENU frame.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "shape", rename_all = "snake_case")]
pub enum ZoneBoundsEnu {
    Rectangle { min_e: f64, min_n: f64, max_e: f64, max_n: f64 },
    Circle { center_e: f64, center_n: f64, radius_m: f64 },
    Polygon { vertices: Vec<(f64, f64)> },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorModality { WifiCsi, MmWave, Uwb, Presence }

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorKind { Reflector, Furniture, UwbBeacon }
```

Edges carry **typed metadata per edge kind** — the metadata for `observes` (a sensor's field-of-regard weight) is structurally different from `contradicts` (a disagreement magnitude) or `privacy_limited_by` (the limiting mode + action). Like `petgraph_bridge.rs`'s `BrainEdge`, this is a single enum stored as the petgraph edge weight:

```rust
/// Typed edge between two WorldNodes. Stored as the petgraph edge weight.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "rel", rename_all = "snake_case")]
pub enum WorldEdge {
    /// sensor/rf_link -> any observable node. Weight is field-of-regard quality.
    Observes { quality: f32, last_seen_unix_ms: i64 },
    /// person_track/object_anchor/event -> room/zone containment.
    LocatedIn { since_unix_ms: i64 },
    /// room <-> room through a doorway (undirected pair stored as two edges).
    AdjacentTo { via_doorway: WorldId },
    /// sensor/rf_link -> sensor/rf_link: physical/clock support (ADR-138).
    Supports { strength: f32 },
    /// evidence/state -> evidence/state: sources disagree (ADR-137).
    Contradicts { magnitude: f32, flag: ContradictionFlagRef },
    /// semantic_state -> prior state/evidence: provenance chain (ADR-137).
    DerivedFrom { evidence: EvidenceRefHandle },
    /// sensor -> any node: observation constrained by a privacy mode (ADR-141).
    PrivacyLimitedBy { mode: String, action: String, allowed: bool },
}
```

`EvidenceRefHandle`, `ContradictionFlagRef`, and `SemanticProvenance` are defined in ADR-137 / ADR-140 and re-exported here; this ADR depends on them but does not own them (see §2.3). Where those crates are not yet present, the handles degrade to opaque `String` content-addresses so the WorldGraph compiles and persists independently.

### 2.2 Graph Container and Bridge

Following `petgraph_bridge.rs`, the WorldGraph wraps petgraph and exposes a domain API. We use `StableDiGraph` (not `Graph`) because nodes are removed at runtime (a person leaves, a track dies) and stable indices keep `WorldId → NodeIndex` resolution valid.

```rust
//! v2/crates/wifi-densepose-worldgraph/src/graph.rs

use petgraph::stable_graph::{StableDiGraph, NodeIndex};
use std::collections::HashMap;
use crate::model::{WorldNode, WorldEdge, WorldId};

pub struct WorldGraph {
    inner: StableDiGraph<WorldNode, WorldEdge>,
    /// Stable WorldId -> petgraph handle. Survives removals.
    index: HashMap<WorldId, NodeIndex>,
    /// Installation origin; all ENU coords are relative to this (ADR-044).
    registration: wifi_densepose_geo::types::GeoRegistration,
    next_id: u64,
    schema_version: u16,
}

impl WorldGraph {
    pub fn new(registration: wifi_densepose_geo::types::GeoRegistration) -> Self;

    /// Insert a node, returning its stable WorldId. Allocates the id if the
    /// node's embedded id is WorldId(0) (sentinel = "assign me one").
    pub fn upsert_node(&mut self, node: WorldNode) -> WorldId;

    /// Add a typed edge. Errors if either endpoint is unknown.
    pub fn add_edge(&mut self, from: WorldId, to: WorldId, edge: WorldEdge)
        -> Result<(), WorldGraphError>;

    /// Resolve a HomeCore area_id to its Room node (entity linkage, ADR-127).
    pub fn room_for_area(&self, area_id: &str) -> Option<WorldId>;

    pub fn node(&self, id: WorldId) -> Option<&WorldNode>;
    pub fn neighbors(&self, id: WorldId) -> impl Iterator<Item = (WorldId, &WorldEdge)>;
}
```

A `bridge.rs` module mirrors `petgraph_bridge.rs`'s `to_petgraph` / `from_petgraph` so external algorithm code can borrow a plain `&StableDiGraph` for petgraph's `dijkstra`, `connected_components`, etc., without leaking the domain wrapper.

### 2.3 Provenance: derived_from and contradicts from ADR-137

The house rule is honored structurally: **every `SemanticState` node carries a `SemanticProvenance`** and is reachable along `DerivedFrom` edges back to the evidence that produced it. The provenance tuple binds the four required traces:

```rust
//! Mandatory provenance for every SemanticState (house rule).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SemanticProvenance {
    /// Signal evidence: ADR-137 EvidenceRef content-address(es).
    pub evidence: Vec<EvidenceRefHandle>,
    /// Model version that produced this belief.
    pub model_version: String,
    /// Calibration version (ADR-135 baseline id) in effect.
    pub calibration_version: String,
    /// Privacy decision (ADR-141 mode + action) under which it was derived.
    pub privacy_decision: PrivacyDecisionRef,
}
```

When the fusion engine (ADR-137) emits a new `SemanticState`:

1. `upsert_node()` inserts the new `SemanticState` node.
2. For each `EvidenceRef` in its provenance, the engine adds a `DerivedFrom` edge from the new state to the corresponding `Event` / prior `SemanticState` / `Observes` source.
3. If ADR-137 attached a `ContradictionFlag` (the new belief disagrees with a still-live prior belief), the engine adds a `Contradicts` edge between the two `SemanticState` nodes carrying the flag's magnitude. The prior node is **not deleted** — it is retained so a query can surface the disagreement; a downstream resolver (ADR-140) decides which belief wins.

This makes node updates *append-with-provenance*: the graph never loses the chain of reasoning, which is exactly what ADR-145's ablation harness needs to attribute a wrong belief to a specific sensor/model/calibration.

### 2.4 Privacy: privacy_limited_by edges from ADR-141

For each `(sensor, observable-node)` pair, the WorldGraph materializes a `PrivacyLimitedBy` edge derived from the ADR-141 privacy mode/action registry. The edge records the limiting `mode`, the `action` evaluated, and whether observation is `allowed` under the current mode. This is computed by a reducer that runs whenever the active privacy mode changes:

```rust
/// Recompute privacy_limited_by edges for the active mode (ADR-141).
/// For every Observes edge (sensor -> node), evaluate the mode's policy for
/// that sensor's modality + the node kind, and write/update a matching
/// PrivacyLimitedBy edge.
pub fn apply_privacy_mode(
    &mut self,
    mode: &PrivacyMode,                 // from ADR-141 control plane
) -> PrivacyRollup;

/// Result of a privacy-impact rollup query (§2.5).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrivacyRollup {
    pub mode: String,
    /// Nodes that become unobservable under this mode.
    pub suppressed_nodes: Vec<WorldId>,
    /// (sensor, node) pairs newly denied.
    pub denied_pairs: Vec<(WorldId, WorldId)>,
    pub allowed_pairs: usize,
}
```

Because `PrivacyLimitedBy` is a first-class edge, "what can sensor X still see under mode Y?" is a one-hop neighbor filter — no separate policy index is needed, and the privacy posture is *visible in the persisted graph* (an auditor can read the `.rvf` and see what was suppressed).

### 2.5 Query API Surface (v1 Scope)

The v1 query API is intentionally narrow — three families, all expressible as petgraph traversals over the typed edges:

```rust
//! v2/crates/wifi-densepose-worldgraph/src/query.rs

impl WorldGraph {
    /// OBSERVABILITY CHAIN: sensor -> all nodes it currently observes.
    /// Follows Observes edges (one hop) filtered by current PrivacyLimitedBy.
    pub fn observed_by(&self, sensor: WorldId) -> Vec<ObservedNode>;

    /// LOCATION QUERY: contents of room X.
    /// Reverse LocatedIn traversal: all PersonTrack/ObjectAnchor/Event/Zone
    /// nodes located_in this room (transitively through child Zones).
    pub fn contents_of(&self, room: WorldId) -> RoomContents;

    /// PRIVACY-IMPACT ROLLUP: for a candidate mode, what is suppressed.
    /// Pure (does not mutate); ADR-145 uses it to score privacy leakage.
    pub fn privacy_impact(&self, mode: &PrivacyMode) -> PrivacyRollup;

    /// ADR-144 anchor accessor: sensors + object anchors with known ENU pos.
    pub fn anchors(&self) -> Vec<(WorldId, EnuPoint)>;
}
```

**Scope boundary for v1:** the graph models the **current static topology** of a single installation. Temporal reconfiguration history (rooms repartitioned, sensors relocated over weeks) is **deferred to ADR-142** (Evolution Tracker / temporal VoxelMap). The WorldGraph emits a `TopologyChanged` domain event when static structure changes; ADR-142 subscribes and aggregates the history. This keeps the WorldGraph a clean *current-state* projection and avoids baking a time-series store into the graph itself.

### 2.6 Persistence: RVF Bundle with Async Write-Through

The graph persists as an **RVF bundle**, reusing the segment-based format already implemented in `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` (64-byte aligned segments, `SEG_META` for JSON metadata, `SEG_MANIFEST` for the directory, CRC32 content hashes). No new file format is introduced.

- **Layout:** one `SEG_META` segment holds the serde-JSON of `{ registration, schema_version, nodes: Vec<WorldNode>, edges: Vec<(WorldId, WorldId, WorldEdge)> }`. A `SEG_MANIFEST` segment carries node/edge counts and the schema version. A `SEG_WITNESS` segment carries the SHA-256 of the node+edge payload for the ADR-028 proof chain.
- **Async write-through:** mutations (`upsert_node`, `add_edge`, `apply_privacy_mode`) are applied to the in-memory graph synchronously and enqueued to a bounded `tokio::sync::mpsc` channel drained by a single writer task that coalesces bursts and rewrites the `.rvf` (write-temp-then-rename). The hot path never blocks on disk. This mirrors the `homecore/src/registry.rs` "in-memory now, persistence to a backing store later" staging — except the backing store (RVF) is specified up front.
- **Pinning:** the bundle stores its `GeoRegistration` so a reloaded graph re-establishes the same local ENU frame. `enu_to_wgs84()` (ADR-044) regenerates lat/lon for any node on demand for the map overlay.

```rust
//! v2/crates/wifi-densepose-worldgraph/src/persist.rs

pub struct WorldGraphStore {
    path: std::path::PathBuf,
    tx: tokio::sync::mpsc::Sender<WriteOp>,
}

impl WorldGraphStore {
    /// Open or create an RVF-backed store; spawns the write-through task.
    pub async fn open(path: impl Into<std::path::PathBuf>) -> Result<(Self, WorldGraph), WorldGraphError>;

    /// Enqueue a snapshot write (non-blocking, coalesced by the writer task).
    pub fn enqueue_snapshot(&self, graph: &WorldGraph) -> Result<(), WorldGraphError>;

    /// Force-flush and await durability (used at shutdown / before witness).
    pub async fn flush(&self) -> Result<(), WorldGraphError>;
}
```

### 2.7 Error Type and Domain Events

```rust
#[derive(Debug, thiserror::Error)]
pub enum WorldGraphError {
    #[error("unknown node: {0:?}")]
    UnknownNode(WorldId),
    #[error("edge endpoint type mismatch: {0}")]
    EdgeTypeMismatch(String),
    #[error("schema version {found} unsupported (expected {expected})")]
    SchemaMismatch { found: u16, expected: u16 },
    #[error("RVF (de)serialisation error: {0}")]
    Rvf(String),
    #[error("privacy mode references unknown action: {0}")]
    UnknownPrivacyAction(String),
}

/// Event-sourced change notifications (per project DDD rule).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum WorldGraphEvent {
    NodeUpserted(WorldId),
    NodeRemoved(WorldId),
    EdgeAdded { from: WorldId, to: WorldId },
    TopologyChanged,             // consumed by ADR-142
    PrivacyModeApplied(String),  // emitted by apply_privacy_mode
    ContradictionRecorded { a: WorldId, b: WorldId, magnitude: f32 },
}
```

### 2.8 Interface Boundaries

| Boundary | This crate provides | This crate consumes |
|----------|---------------------|---------------------|
| ADR-044 `wifi-densepose-geo` | — | `GeoRegistration`, `GeoPoint`, `wgs84_to_enu`/`enu_to_wgs84` |
| ADR-127 `homecore/registry.rs` | `room_for_area(area_id)` | `EntityEntry.area_id`, `EntityEntry.device_id` (join keys) |
| MAT `scan_zone.rs` | `ZoneBoundsEnu`, `Sensor` node | `ZoneBounds`, `SensorPosition` shapes (reprojected to ENU) |
| ADR-137 fusion | `DerivedFrom`/`Contradicts` edges, `SemanticState` nodes | `EvidenceRef`, `ContradictionFlag` |
| ADR-141 privacy | `apply_privacy_mode`, `privacy_impact` | `PrivacyMode`, action registry |
| ADR-138 LinkGroup | `RfLink.link_group_id` field | LinkGroup ids |
| ADR-142 evolution | `WorldGraphEvent::TopologyChanged` stream | — |
| ADR-144 UWB | `anchors()` accessor | — |
| ADR-145 ablation | `privacy_impact()`, provenance chains | — |

The crate must compile **standalone**: where ADR-137/141 types are not yet present, their handles are `String` content-addresses (feature-gated `full-fusion` swaps them for the real types). This keeps `wifi-densepose-worldgraph` a no-internal-dep leaf on `wifi-densepose-geo` only, matching the publishing-order discipline in CLAUDE.md.

---

## 3. Consequences

### 3.1 Positive

- **One spatial identity space.** `area_id` (HomeCore), `ScanZone` (MAT), and sensor placement (ADR-113) finally resolve to one node set. `room_for_area()` is the single join.
- **Provenance is structural, not bolted on.** Every belief traces to signal evidence + model version + calibration version + privacy decision via `SemanticProvenance` and `DerivedFrom` edges — the house rule is enforced by the type system, not by convention.
- **Privacy posture is auditable.** `PrivacyLimitedBy` edges live in the persisted `.rvf`, so an auditor can read what each mode suppressed without re-running the system.
- **Deterministic persistence.** The serde-enum-over-RVF choice produces byte-stable snapshots suitable for the ADR-028 witness proof chain (SHA-256 of the node/edge payload).
- **Reuses proven mechanics.** The petgraph bridge pattern (`ruv-neural-graph`) and the RVF container (`sensing-server`) are existing, tested code — no new graph engine or file format.
- **Unblocks four downstream ADRs.** ADR-140 (semantic records anchor to `SemanticState` nodes), ADR-142 (consumes `TopologyChanged`), ADR-144 (consumes `anchors()`), ADR-145 (scores over `privacy_impact()` + provenance).

### 3.2 Negative

- **New crate to maintain.** `wifi-densepose-worldgraph` adds a 16th workspace crate and an entry to the publishing order (leaf on `wifi-densepose-geo`).
- **Cross-crate handle coupling.** The full-fidelity provenance/privacy edges depend on ADR-137/141 types. Until those land, the `String`-handle fallback means provenance is content-addressed but not yet richly typed — a temporary loss of compile-time guarantees.
- **Snapshot-rewrite cost.** Async write-through rewrites the whole `.rvf` on flush rather than appending a delta. For a single-installation graph (hundreds of nodes, low-Hz mutation) this is sub-millisecond, but it does not scale to thousands of installations in one file (out of scope — one bundle per installation).
- **No history in v1.** Querying "where was the sofa last month" requires ADR-142; the WorldGraph alone answers only "now."

### 3.3 Risks

| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Stale `petgraph` `NodeIndex` after node removal | Medium | Dangling edge / panic | Use `StableDiGraph` (indices survive removal) and the `WorldId → NodeIndex` map; never expose raw `NodeIndex` across the API boundary |
| Schema drift breaks old `.rvf` bundles | Medium | Reload failure | `schema_version` in `SEG_MANIFEST`; `WorldGraphError::SchemaMismatch` with an explicit migration path; refuse-and-warn rather than mis-parse |
| Contradiction edges accumulate without resolution | Medium | Graph bloat, ambiguous beliefs | A retention policy prunes `Contradicts` edges whose losing `SemanticState` has `valid_from` older than a TTL once ADR-140's resolver has chosen a winner |
| Privacy edge recompute lags a fast mode switch | Low | Brief window of stale `allowed` flags | `apply_privacy_mode` runs synchronously on the mutation path before any new `Observes` edge is honored; rollup returned to caller for confirmation |
| ENU origin re-pinned after partial population | Low | Coordinate frame mismatch | Origin is immutable after `WorldGraph::new`; re-pinning requires a new bundle + ADR-142 migration event |

---

## 4. Alternatives Considered

### 4.1 Trait-Object Nodes (`Box<dyn WorldNode>`)

Rejected. `typetag`-style polymorphic serde is non-deterministic across crate/serde versions, cannot be field-walked by ADR-145's harness, and breaks the byte-stable witness proof. The serde enum gives closed-world exhaustiveness (the compiler forces every query to handle every node kind) and deterministic bytes. The `petgraph_bridge.rs` precedent already stores concrete weights, not trait objects.

### 4.2 Extend `GeoScene` with Interior Features

Rejected. `geo::types::GeoScene` is a WGS84 outdoor scene (buildings/roads from OSM). Bolting rooms/sensors/people onto it would (a) conflate the global frame with the local ENU frame, (b) force the geo crate to depend on fusion/privacy types it has no business knowing, and (c) provide no edges. We *reuse* `GeoRegistration` and the ENU transforms from geo, but the WorldGraph is a separate concern.

### 4.3 Reuse `homecore` Area Registry Directly

Rejected as the home. `EntityEntry.area_id` is an opaque string with no geometry and no adjacency; HomeCore's job is HA-compatible entity bookkeeping, not spatial reasoning. The WorldGraph *links to* `area_id` (so automations and sensing share identity) but owns geometry, sensors, and the typed-edge topology HomeCore deliberately does not model.

### 4.4 A Relational/SQLite Store with Join Tables

Rejected for v1. Edges-as-rows + recursive CTEs can express the same queries, but (a) the workspace already standardizes on RVF for portable, witness-hashable artifacts, (b) petgraph gives shortest-path/connectivity algorithms for free (observability chains, adjacency reachability) that would be hand-rolled SQL, and (c) an embedded SQLite file is not byte-stable for the proof chain. RVF + petgraph matches existing patterns; a SQL backend remains a future option behind `WorldGraphStore` if scale demands it.

### 4.5 Temporal Graph from Day One

Rejected for v1. A bitemporal graph (valid-time + transaction-time on every node/edge) is the correct long-term model, but it doubles the schema complexity and the persistence size before any consumer needs history. v1 ships current-state-only and emits `TopologyChanged`; ADR-142 builds the temporal aggregation on top. This keeps the first deliverable small and the query API simple.

---

## 5. Testing / Acceptance

### 5.1 Unit Tests (CI, no hardware)

**T1 — Node/edge round-trip determinism.** Build a graph with one of every `WorldNode` variant and one of every `WorldEdge` variant. Serialize to RVF bytes, deserialize, assert structural equality and assert the SHA-256 of the node/edge payload is byte-stable across two independent serializations (deterministic-persistence acceptance).

**T2 — `room_for_area` entity linkage.** Insert a `Room { area_id: Some("living_room") }`; assert `room_for_area("living_room")` returns its `WorldId` and `room_for_area("garage")` returns `None`. Mirrors the HomeCore `registry.rs` register-and-read test.

**T3 — ENU pinning round-trip.** Pin a graph to `GeoRegistration { origin: lat/lon }`; place a `Sensor` at a known `EnuPoint`; reproject to WGS84 via `enu_to_wgs84` and back via `wgs84_to_enu`; assert agreement within 1e-6 m (validates the ADR-044 frame reuse).

**T4 — Observability chain.** Sensor S observes nodes A,B,C (three `Observes` edges); assert `observed_by(S)` returns exactly {A,B,C}.

**T5 — Location query (transitive).** Room R contains Zone Z; PersonTrack P `located_in` Z. Assert `contents_of(R)` includes P (transitive through the child zone) and Object/Event nodes located directly in R.

**T6 — Provenance chain (house rule).** Insert a `SemanticState` with `SemanticProvenance { evidence, model_version, calibration_version, privacy_decision }` and `DerivedFrom` edges to two `Event` sources. Assert every `SemanticState` in the graph has non-empty `evidence`, a `model_version`, a `calibration_version`, and a `privacy_decision` (acceptance: the four-fold trace is present on every belief node).

**T7 — Contradiction retention.** Insert belief B1, then a contradicting belief B2 (ADR-137 `ContradictionFlag`). Assert a `Contradicts` edge exists, B1 is **not** removed, and a `WorldGraphEvent::ContradictionRecorded` was emitted.

**T8 — Privacy-impact rollup.** With sensor S observing person P, apply a `PrivacyMode` that denies person observation for S's modality. Assert `privacy_impact(mode).suppressed_nodes` contains P, a `PrivacyLimitedBy { allowed: false }` edge is written, and `observed_by(S)` no longer returns P.

**T9 — Schema-mismatch refusal.** Hand-craft an RVF `SEG_MANIFEST` with `schema_version = 999`; assert `open()` returns `WorldGraphError::SchemaMismatch` (refuse, do not mis-parse).

**T10 — Stable index after removal.** Insert 5 nodes, remove the middle one, add a 6th; assert all surviving `WorldId → WorldNode` lookups still resolve and no edge dangles (validates `StableDiGraph` choice).

### 5.2 Async Persistence Test

**T11 — Write-through coalescing.** Open a `WorldGraphStore`, enqueue 1,000 rapid snapshots, `flush()`, reopen the bundle, assert the final state matches the last snapshot and that the writer task coalesced (write count < enqueue count). Hot-path `enqueue_snapshot` must not block (assert it returns within a tight bound while the disk write is in flight).

### 5.3 Witness / Proof (ADR-028 chain)

Add rows to `docs/WITNESS-LOG-028.md`:

| Row | Capability | Evidence | Hash |
|-----|-----------|----------|------|
| W-39 | WorldGraph RVF round-trip determinism | `cargo test worldgraph::tests::roundtrip_determinism` | SHA-256 of node/edge payload |
| W-40 | Provenance four-fold trace present on every SemanticState | `cargo test worldgraph::tests::provenance_complete` | SHA-256 of test binary |
| W-41 | Privacy rollup suppresses denied nodes | `cargo test worldgraph::tests::privacy_rollup` | SHA-256 of rollup output |

`source-hashes.txt` in the witness bundle gains `SHA-256(worldgraph/model.rs)` and `SHA-256(worldgraph/graph.rs)`.

### 5.4 Acceptance Criteria (Definition of Done)

1. `wifi-densepose-worldgraph` compiles standalone (`cargo check -p wifi-densepose-worldgraph --no-default-features`) depending only on `wifi-densepose-geo` + `petgraph` + `serde`.
2. T1–T11 pass in `cargo test --workspace --no-default-features`; total workspace test count rises and stays at 0 failures.
3. Every `SemanticState` node carries the four-fold provenance trace (signal evidence + model version + calibration version + privacy decision) — enforced by T6 and by the non-`Option` `SemanticProvenance` field.
4. A persisted `.rvf` bundle reloads to a structurally identical graph and re-establishes the same ENU origin.
5. The three query families (observability chain, location, privacy rollup) each have a passing test and a documented signature in `query.rs`.
6. v1 explicitly does **not** store reconfiguration history; a `TopologyChanged` event is emitted for ADR-142 to consume (verified by a unit test asserting the event fires on a wall/room change).

---

## 6. Related ADRs

| ADR | Relationship |
|-----|-------------|
| ADR-044 (Geospatial Satellite Integration) | **Substrate**: reuses `GeoRegistration`, `GeoPoint`, and `wgs84_to_enu`/`enu_to_wgs84` to pin the local ENU frame |
| ADR-113 (Multistatic Placement Strategy) | **Source**: sensor placements become `Sensor` nodes; placement geometry feeds `position` |
| ADR-127 (HomeCore State Machine) | **Linkage**: `EntityEntry.area_id`/`device_id` join to `Room`/`Sensor` nodes via `room_for_area()` |
| ADR-030 (Persistent Field Model) | **Adjacent**: the field model is a per-link signal model; WorldGraph is the spatial/semantic model that field-model events annotate |
| ADR-136 (RuView Streaming Engine) | **Upstream**: frames flow through the streaming engine before fusion populates the WorldGraph |
| ADR-137 (Fusion Quality Scoring) | **Source of provenance**: `EvidenceRef`/`ContradictionFlag` populate `DerivedFrom`/`Contradicts` edges |
| ADR-138 (LinkGroup / ArrayCoordinator) | **Source**: `RfLink.link_group_id` references MLO LinkGroups; `Supports` edges encode clock/physical support |
| ADR-142 (Evolution Tracker) | **Consumer**: subscribes to `TopologyChanged`; owns the deferred temporal history |
| ADR-144 (UWB Range-Constraint Fusion) | **Consumer**: reads `anchors()` (sensors + object anchors) as the range-constraint anchor set |
| ADR-145 (Ablation Eval Harness) | **Consumer**: scores privacy leakage via `privacy_impact()` and attributes errors via provenance chains |

---

## 7. References

### Production Code

- `v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs` — petgraph bridge pattern (`to_petgraph`/`from_petgraph`, typed domain edges) this crate follows
- `v2/crates/wifi-densepose-geo/src/types.rs` — `GeoPoint`, `GeoBBox`, `GeoRegistration`, `GeoScene` (ADR-044 anchor types reused)
- `v2/crates/wifi-densepose-geo/src/coord.rs` — `wgs84_to_enu`/`enu_to_wgs84` (local ENU frame transforms)
- `v2/crates/homecore/src/registry.rs` — `EntityEntry { area_id, device_id }`, in-memory-then-persist staging mirrored by `WorldGraphStore`
- `v2/crates/wifi-densepose-mat/src/domain/scan_zone.rs` — `ZoneBounds`, `SensorPosition`, `contains_point()` shapes reprojected into `ZoneBoundsEnu` / `Sensor`
- `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` — RVF segment format (64-byte headers, `SEG_META`/`SEG_MANIFEST`/`SEG_WITNESS`, CRC32) reused for persistence
- `v2/crates/wifi-densepose-geo/src/temporal.rs` — precedent for change tracking that ADR-142 generalizes

### External

- petgraph crate — `StableDiGraph`, `dijkstra`, `connected_components` traversal algorithms used by the query API
- Mardia, K.V. & Jupp, P.E. (2000). *Directional Statistics*. Wiley — circular geometry for ENU/heading consistency (shared with ADR-135 calibration phase model)
