diff --git a/.gitignore b/.gitignore index c8bba90..8bb192b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ Cargo.lock.bak *.pdb .claude-flow/ + +# RuCelium metaharness generation ledger (commit deliberately if you want shared lineage) +.rucelium/ +harness/node_modules/ diff --git a/Cargo.lock b/Cargo.lock index 6b74720..39c62fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,163 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rucelium-abi" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rucelium-core", + "sha2", +] + +[[package]] +name = "rucelium-bench" +version = "0.1.0" +dependencies = [ + "rucelium-abi", + "rucelium-calibration", + "rucelium-core", + "rucelium-federation", + "rucelium-ingest", + "rucelium-policy", + "rucelium-worldgraph", + "rufield-core", + "serde", + "serde_json", +] + +[[package]] +name = "rucelium-calibration" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rucelium-core", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "rucelium-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "rucelium-examples" +version = "0.1.0" +dependencies = [ + "rucelium-abi", + "rucelium-calibration", + "rucelium-core", + "rucelium-federation", + "rucelium-ingest", + "rucelium-policy", + "rucelium-store", + "rucelium-transport", + "rucelium-worldgraph", + "serde", + "serde_json", +] + +[[package]] +name = "rucelium-federation" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rucelium-abi", + "rucelium-core", + "rucelium-ingest", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "rucelium-gateway" +version = "0.1.0" +dependencies = [ + "axum", + "quinn", + "reqwest", + "rucelium-abi", + "rucelium-calibration", + "rucelium-core", + "rucelium-federation", + "rucelium-ingest", + "rucelium-policy", + "rucelium-store", + "rucelium-transport", + "rucelium-worldgraph", + "rustls", + "serde", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "rucelium-ingest" +version = "0.1.0" +dependencies = [ + "rucelium-abi", + "rucelium-core", + "serde", + "serde_json", +] + +[[package]] +name = "rucelium-notary" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rucelium-core", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "rucelium-policy" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rucelium-core", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "rucelium-store" +version = "0.1.0" +dependencies = [ + "rucelium-core", + "serde", + "serde_json", +] + +[[package]] +name = "rucelium-transport" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rucelium-abi", +] + +[[package]] +name = "rucelium-worldgraph" +version = "0.1.0" +dependencies = [ + "rucelium-core", + "rufield-core", + "serde", + "serde_json", +] + [[package]] name = "rufield-adapters" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9de60d8..2bbf7f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,19 @@ members = [ "crates/rufield-fusion", "crates/rufield-bench", "crates/rufield-viewer", + "crates/rucelium-core", + "crates/rucelium-abi", + "crates/rucelium-ingest", + "crates/rucelium-calibration", + "crates/rucelium-worldgraph", + "crates/rucelium-policy", + "crates/rucelium-federation", + "crates/rucelium-bench", + "crates/rucelium-store", + "crates/rucelium-transport", + "crates/rucelium-gateway", + "crates/rucelium-notary", + "examples", ] [workspace.package] @@ -20,7 +33,12 @@ repository = "https://github.com/ruvnet/rufield" [workspace.dependencies] # Serialization serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +# `float_roundtrip` is LOAD-BEARING, not a nicety: every signature in this +# workspace is computed over canonical JSON, and a peer verifies by +# re-serializing what it parsed. Without exact float parsing, ~9% of realistic +# sensor values come back one ULP off, so a genuine signed summary or event +# fails verification after a wire round-trip. Do not remove. +serde_json = { version = "1.0", features = ["float_roundtrip"] } toml = "0.8" # Provenance: hashing + signatures @@ -35,6 +53,19 @@ rufield-adapters = { version = "0.1.0", path = "crates/rufield-adapters" } rufield-fusion = { version = "0.1.0", path = "crates/rufield-fusion" } rufield-bench = { version = "0.1.0", path = "crates/rufield-bench" } rufield-viewer = { version = "0.1.0", path = "crates/rufield-viewer" } +rucelium-core = { version = "0.1.0", path = "crates/rucelium-core" } +rucelium-abi = { version = "0.1.0", path = "crates/rucelium-abi" } +rucelium-ingest = { version = "0.1.0", path = "crates/rucelium-ingest" } +rucelium-calibration = { version = "0.1.0", path = "crates/rucelium-calibration" } +rucelium-worldgraph = { version = "0.1.0", path = "crates/rucelium-worldgraph" } +rucelium-policy = { version = "0.1.0", path = "crates/rucelium-policy" } +rucelium-federation = { version = "0.1.0", path = "crates/rucelium-federation" } +rucelium-bench = { version = "0.1.0", path = "crates/rucelium-bench" } +rucelium-store = { version = "0.1.0", path = "crates/rucelium-store" } +rucelium-transport = { version = "0.1.0", path = "crates/rucelium-transport" } +rucelium-gateway = { version = "0.1.0", path = "crates/rucelium-gateway" } +rucelium-notary = { version = "0.1.0", path = "crates/rucelium-notary" } +rucelium-examples = { version = "0.1.0", path = "examples" } [workspace.lints.rust] unsafe_code = "forbid" @@ -57,3 +88,13 @@ uninlined_format_args = "allow" [profile.release] opt-level = 3 lto = true + +# The RuCelium biome benchmark signs/verifies ~10^5 ed25519 envelopes per +# run; unoptimized curve arithmetic makes debug `cargo test` minutes-slow. +# Optimize just the crypto dependencies in dev builds. +[profile.dev.package.curve25519-dalek] +opt-level = 3 +[profile.dev.package.ed25519-dalek] +opt-level = 3 +[profile.dev.package.sha2] +opt-level = 3 diff --git a/README.md b/README.md index b581dc5..f27babe 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,99 @@ The full specification of record is | [`rufield-bench`](crates/rufield-bench) | Deterministic benchmark runner: F1 per task (SYNTHETIC), p95 latency, provenance coverage, privacy violations, and the ADR-260 §31 acceptance test. | | [`rufield-viewer`](crates/rufield-viewer) | Read-only web dashboard (Axum + vanilla JS, no build step): room state, event log with privacy badges, fusion graph, signed-receipt viewer. **Two sources** — `--source synthetic` (default) replays `SyntheticSim → RuFieldFusion`; `--source live --upstream ` ingests **real** `FieldEvent`s from a RuField upstream (RuView `/ws/field` / `/api/field`, ADR-262 P3), verifying each receipt on ingest. Honest, mutually-exclusive `SYNTHETIC` / `LIVE` / `DISCONNECTED` banner. Not a device-management console. | +## RuCelium — federated environmental intelligence fabric + +[ADR-264](./docs/ADR-264-rucelium-federated-fabric.md) extends the stack +from room-scale field sensing to planetary environmental sensing — **not** as +a flat global peer mesh (which fails on battery, bandwidth, routing, +calibration, sovereignty, and compromised nodes) but as a **federated fabric** +with four layers: + +```text +Layer 4 Planetary federation discovery + aggregates (OGC SensorThings), no ownership +Layer 3 Biome regions sovereign owners of data, models, actuators +Layer 2 Rhizome gateways Rust: verify, normalize, calibrate, fuse, buffer, govern +Layer 1 Spore nodes C: sense, fixed-point calibrate, sign, transmit +``` + +C stays confined to the sensor boundary (drivers, fixed-point DSP, +serialization, transport — see +[`rucelium_env.h`](crates/rucelium-abi/include/rucelium_env.h)); +everything above it is safe Rust. RuView RF joins as a **contextual modality** +— supporting evidence with a hard `Advisory` severity cap, never ground truth. + +| Crate | Description | +|-------|-------------| +| [`rucelium-core`](crates/rucelium-core) | Domain model: `EnvSample` (twelve mandatory attributes), `EnvFrame`, `CalibrationRecord` (Q16.16, lineage-chained), `EnvironmentalEvent`, `SensorModality` (10), `GeoPoint` with exact privacy coarsening, three-tier `DataClass` residency. | +| [`rucelium-abi`](crates/rucelium-abi) | The versioned C ABI: packed 48-byte `rv_env_sample_v1`, bounds-checked allocation-free parse (no `unsafe`), deterministic CBOR (canonical heads enforced), COSE-inspired signed envelope, ed25519 device keys. | +| [`rucelium-ingest`](crates/rucelium-ingest) | Gateway ingest: envelope decode → registry/revocation → signature verify → anti-replay window → normalized `EnvSample`. Forged packets can't burn sequence numbers. | +| [`rucelium-calibration`](crates/rucelium-calibration) | Calibration lineage (anchor-rooted chains), affine application with stated uncertainty, EWMA drift detection, **quarantine — never silent correction**. | +| [`rucelium-worldgraph`](crates/rucelium-worldgraph) | Environmental WorldGraph: typed sensor/ecosystem/region/anchor nodes, geospatial queries, evidence + contradiction edges, RuView `FieldEvent` RF-context bridge (weight-capped). | +| [`rucelium-policy`](crates/rucelium-policy) | The ADR-264 §9 governed control path — proposal → policy → safety sim → authority → signed command → gateway validation → receipt — typed so **no stage can be skipped**. | +| [`rucelium-federation`](crates/rucelium-federation) | Biome sovereignty: outage buffer with duplicate-free replay, signed regional summaries, device revocation, disclosure coarsening + delay, OGC SensorThings 1.1 projection. | +| [`rucelium-bench`](crates/rucelium-bench) | Deterministic **SYNTHETIC** 64-node biome benchmark: 30 simulated days, 7-day offline partition, tamper/replay attack rejection, mid-run revocation — the ADR-264 §14 **fabric reference-model** acceptance test (in-memory library components; the runtime path — store/transport/gateway — is covered by `rucelium-gateway`'s own e2e and restart-attack tests). | + +Run the biome acceptance benchmark: + +```bash +cargo run -p rucelium-bench # default seed +cargo run -p rucelium-bench -- 2026 # custom seed +cargo run -p rucelium-bench -- 2026 --json +``` + +> **Honesty note:** like the RuField numbers, the RuCelium scorecard is +> produced by a deterministic synthetic biome simulator and labelled +> **SYNTHETIC** — it proves the fabric's mechanics (signatures, replay +> windows, dedup, quarantine, revocation, projection) against known ground +> truth. It is not a field deployment. + +### Runtime — run a gateway (ADR-265) + +The fabric is not only libraries — it runs. [ADR-265](./docs/ADR-265-rucelium-runtime.md) +adds the runtime layer: + +| Crate | Description | +|-------|-------------| +| [`rucelium-store`](crates/rucelium-store) | Durable append-only segmented store: dedup index rebuilt on open, torn-tail crash recovery, deterministic replay, per-DataClass retention that deletes whole expired segments. | +| [`rucelium-transport`](crates/rucelium-transport) | Constrained-link transport: compact **114-byte envelope v2** (pubkey by reference — v1's ~150 bytes doesn't fit LoRaWAN DR0's 51-byte cap) + MTU fragmentation/reassembly (exactly 3 DR0 datagrams per envelope, loss/duplication/reorder tolerant). | +| [`rucelium-gateway`](crates/rucelium-gateway) | The rhizome daemon: UDP envelope ingestion (v1/v2/fragments) → signature + anti-replay → calibration + quarantine → disk → WorldGraph → local alerts, an OGC SensorThings HTTP API, and **federation sync** — peers exchange only signed summaries and revocations, verified before use. | + +```bash +# Start a gateway with a built-in synthetic spore swarm (no hardware, SYNTHETIC): +cargo run -p rucelium-gateway -- --simulate 16 + +# Then: +curl -s localhost:7465/api/stats | jq +curl -s localhost:7465/api/sensorthings/Observations | jq '.value[0]' + +# Two-biome federation on one machine: +cargo run -p rucelium-gateway -- --biome-id biome/a --udp 7464 --http 7465 +cargo run -p rucelium-gateway -- --biome-id biome/b --udp 7474 --http 7475 \ + --peer http://127.0.0.1:7465 +``` + +`rucelium-abi` also gains a `std` default feature: with +`--no-default-features --features alloc` the wire format + deterministic CBOR +compile for `no_std` targets, so Rust-based spore nodes can share the encoder. + +### Metaharness — the Darwin flywheel + +[`harness/`](harness) ships **`rucelium-harness`**, a zero-dependency npm +CLI that turns the implementation process itself into a selection loop +(vary → evaluate → select → retain). Fitness is derived from the same +commands CI runs — workspace tests, clippy, and the ADR-264 §14 acceptance +benchmark — and surviving generations are retained in an append-only ledger: + +```bash +node harness/bin/rucelium.js fitness # score the working tree (0..100) +node harness/bin/rucelium.js evolve -m "message" # select vs the ledger head +node harness/bin/rucelium.js ledger # lineage of surviving generations +node harness/bin/rucelium.js gate # strict §14 acceptance gate (CI) +``` + +A change that lowers fitness is **rejected** (not recorded) unless the +regression is recorded deliberately — see [`harness/README.md`](harness/README.md). + ## Install / Quickstart This repository is a standalone Cargo workspace. The fastest way to see it diff --git a/crates/rucelium-abi/Cargo.toml b/crates/rucelium-abi/Cargo.toml new file mode 100644 index 0000000..139b131 --- /dev/null +++ b/crates/rucelium-abi/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rucelium-abi" +version.workspace = true +edition.workspace = true +description = "RuCelium versioned C ABI boundary: rv_env_sample_v1 wire format (bounds-checked, allocation-free parse), deterministic CBOR, ed25519 signed record envelope (ADR-264 §11)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "ffi", "cbor", "wire", "iot"] +categories = ["science", "embedded"] + +[features] +# std (default): full surface — domain conversion into rucelium-core and the +# ed25519 signing module. +# --no-default-features --features alloc: the no_std surface for Rust-based +# spore nodes — wire format (parse/encode/validate) + deterministic CBOR +# (ADR-265 §5). +default = ["std"] +std = ["alloc", "dep:rucelium-core", "dep:ed25519-dalek", "dep:sha2"] +alloc = [] + +[dependencies] +rucelium-core = { workspace = true, optional = true } +ed25519-dalek = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } + +[lints] +workspace = true diff --git a/crates/rucelium-abi/include/rucelium_env.h b/crates/rucelium-abi/include/rucelium_env.h new file mode 100644 index 0000000..de47b69 --- /dev/null +++ b/crates/rucelium-abi/include/rucelium_env.h @@ -0,0 +1,95 @@ +/* + * rucelium_env.h — RuCelium spore-node wire contract, version 1 + * (ADR-264 §11). This header is the C side of the C ↔ Rust boundary. + * + * Contract (ADR-096 posture): + * - C owns hardware interaction, fixed-point calibration, deterministic + * DSP, serialization, and transport. Nothing else. + * - The on-wire encoding is this struct, PACKED, LITTLE-ENDIAN, exactly + * RV_ENV_SAMPLE_V1_WIRE_LEN (48) bytes. The Rust gateway parses it with + * bounds-checked reads and validates every field before any conversion + * into the domain model. Unknown versions / modalities are rejected, + * never guessed. + * - Sign the 48 wire bytes with the device ed25519 key; transmit the + * COSE-inspired envelope [payload, pubkey, signature] as deterministic + * CBOR (see rucelium-abi::cbor). + */ + +#ifndef RUCELIUM_ENV_H +#define RUCELIUM_ENV_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Wire schema version carried in rv_env_sample_v1.schema_version. */ +#define RV_ENV_SCHEMA_V1 1u + +/* Exact serialized size in bytes (packed, little-endian). */ +#define RV_ENV_SAMPLE_V1_WIRE_LEN 48u + +/* flags bit 0: this sample is a ring-buffer retransmit after an outage + * (store-and-forward), so the gateway can distinguish recovery replay from a + * replay ATTACK — the sequence window still deduplicates either way. */ +#define RV_ENV_FLAG_RETRANSMIT (1u << 0) + +/* Sensor modality codes (must match rucelium_core::SensorModality). */ +enum rv_sensor_type { + RV_SENSOR_WIFI_CSI = 0, /* RuView RF context (supporting evidence) */ + RV_SENSOR_AIR_QUALITY = 1, /* CO2 / VOC / PM1 / PM2.5 / PM10 */ + RV_SENSOR_SOIL_MOISTURE = 2, /* soil moisture + conductivity */ + RV_SENSOR_WATER_QUALITY = 3, /* water level / flow / quality */ + RV_SENSOR_ACOUSTIC = 4, /* acoustic biodiversity */ + RV_SENSOR_WEATHER = 5, /* temp / humidity / leaf wetness / rain */ + RV_SENSOR_BIOELECTRIC = 6, /* mycelial bioelectric potential */ + RV_SENSOR_RADIATION = 7, /* ionizing radiation */ + RV_SENSOR_OPTICAL = 8, /* light / UV / IR */ + RV_SENSOR_CHEMICAL = 9 /* chemical concentration probes */ +}; + +/* + * One environmental sample. Q-format fixed point keeps spore nodes + * float-free: value_q16 is Q16.16, quality_q15 is Q0.15 where + * 0x8000 == 1.0. Coordinates are degrees x 1e7; altitude is millimetres. + * + * NOTE ON PACKING: without packing, natural C alignment would insert 4 bytes + * of padding before node_id (sizeof == 56). The wire format is the PACKED + * 48-byte layout. Serialize field-by-field on compilers without + * __attribute__((packed)). + */ +#if defined(__GNUC__) || defined(__clang__) +typedef struct __attribute__((packed)) { +#else +#pragma pack(push, 1) +typedef struct { +#endif + uint8_t schema_version; /* == RV_ENV_SCHEMA_V1 */ + uint8_t sensor_type; /* enum rv_sensor_type */ + uint16_t flags; /* RV_ENV_FLAG_* */ + uint64_t node_id; /* device identity */ + uint64_t timestamp_ns; /* measurement time, ns since Unix epoch */ + uint32_t sequence; /* per-device monotonic sequence number */ + int32_t latitude_e7; /* degrees x 1e7, |lat| <= 900000000 */ + int32_t longitude_e7; /* degrees x 1e7, |lon| <= 1800000000 */ + int32_t altitude_mm; /* millimetres above reference ellipsoid */ + int32_t value_q16; /* measurement, Q16.16 */ + uint16_t quality_q15; /* quality, Q0.15 (0x0000..0x8000) */ + uint16_t battery_mv; /* battery level, millivolts */ + uint32_t calibration_id; /* applied calibration record (0 = none) */ +} rv_env_sample_v1; +#if !defined(__GNUC__) && !defined(__clang__) +#pragma pack(pop) +#endif + +#if defined(__GNUC__) || defined(__clang__) +_Static_assert(sizeof(rv_env_sample_v1) == RV_ENV_SAMPLE_V1_WIRE_LEN, + "rv_env_sample_v1 must serialize to exactly 48 bytes"); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* RUCELIUM_ENV_H */ diff --git a/crates/rucelium-abi/src/cbor.rs b/crates/rucelium-abi/src/cbor.rs new file mode 100644 index 0000000..971bb2e --- /dev/null +++ b/crates/rucelium-abi/src/cbor.rs @@ -0,0 +1,480 @@ +//! Dependency-free **deterministic CBOR** (RFC 8949 core deterministic +//! encoding requirements: definite lengths, shortest-form integer heads, +//! fixed field order) plus the COSE_Sign1-inspired signed record envelope +//! (ADR-264 §11.2). +//! +//! The same input always yields byte-identical output, and the decoder +//! *rejects* non-canonical heads — so a signature over an encoding is a +//! signature over the one possible encoding. + +use crate::wire::{RvEnvSampleV1, RV_ENV_SAMPLE_V1_WIRE_LEN}; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; + +/// CBOR decode errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CborError { + /// Input ended mid-item. + Truncated, + /// Reserved / unsupported head byte. + BadHead(u8), + /// An integer head was not shortest-form (non-canonical). + NotCanonical, + /// Expected a different major type. + WrongType { + /// Major type expected. + expected: u8, + /// Major type found. + found: u8, + }, + /// A fixed-length field had the wrong length. + WrongLength { + /// Expected byte/item count. + expected: usize, + /// Actual count. + actual: usize, + }, + /// Bytes remained after the top-level item. + TrailingBytes(usize), + /// An integer did not fit the target field. + IntOutOfRange, +} + +impl fmt::Display for CborError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CborError::Truncated => write!(f, "cbor input truncated"), + CborError::BadHead(b) => write!(f, "unsupported cbor head byte {b:#04x}"), + CborError::NotCanonical => write!(f, "non-canonical (non-shortest-form) cbor head"), + CborError::WrongType { expected, found } => { + write!( + f, + "wrong cbor major type: expected {expected}, found {found}" + ) + } + CborError::WrongLength { expected, actual } => { + write!( + f, + "wrong cbor field length: expected {expected}, got {actual}" + ) + } + CborError::TrailingBytes(n) => write!(f, "{n} trailing bytes after cbor item"), + CborError::IntOutOfRange => write!(f, "cbor integer out of range for field"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for CborError {} + +// --------------------------------------------------------------------------- +// Encoder +// --------------------------------------------------------------------------- + +/// Append a shortest-form head for `major` (0..=5) with argument `value`. +fn write_head(out: &mut Vec, major: u8, value: u64) { + let m = major << 5; + if value < 24 { + out.push(m | value as u8); + } else if value <= u64::from(u8::MAX) { + out.push(m | 24); + out.push(value as u8); + } else if value <= u64::from(u16::MAX) { + out.push(m | 25); + out.extend_from_slice(&(value as u16).to_be_bytes()); + } else if value <= u64::from(u32::MAX) { + out.push(m | 26); + out.extend_from_slice(&(value as u32).to_be_bytes()); + } else { + out.push(m | 27); + out.extend_from_slice(&value.to_be_bytes()); + } +} + +/// Append an unsigned integer. +pub fn write_uint(out: &mut Vec, v: u64) { + write_head(out, 0, v); +} + +/// Append a signed integer (major 0 or 1). +pub fn write_int(out: &mut Vec, v: i64) { + if v >= 0 { + write_head(out, 0, v as u64); + } else { + // CBOR nint encodes -1 - n. + write_head(out, 1, !(v as u64)); + } +} + +/// Append a definite-length byte string. +pub fn write_bytes(out: &mut Vec, b: &[u8]) { + write_head(out, 2, b.len() as u64); + out.extend_from_slice(b); +} + +/// Append a definite-length array header. +pub fn write_array(out: &mut Vec, len: usize) { + write_head(out, 4, len as u64); +} + +// --------------------------------------------------------------------------- +// Decoder +// --------------------------------------------------------------------------- + +struct Reader<'a> { + b: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(b: &'a [u8]) -> Self { + Reader { b, pos: 0 } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], CborError> { + let end = self.pos.checked_add(n).ok_or(CborError::Truncated)?; + if end > self.b.len() { + return Err(CborError::Truncated); + } + let s = &self.b[self.pos..end]; + self.pos = end; + Ok(s) + } + + /// Read a head, enforcing shortest-form (canonical) encoding. + fn read_head(&mut self) -> Result<(u8, u64), CborError> { + let first = self.take(1)?[0]; + let major = first >> 5; + let ai = first & 0x1f; + let value = match ai { + 0..=23 => u64::from(ai), + 24 => { + let v = u64::from(self.take(1)?[0]); + if v < 24 { + return Err(CborError::NotCanonical); + } + v + } + 25 => { + let v = u64::from(u16::from_be_bytes( + self.take(2)?.try_into().expect("len checked"), + )); + if v <= u64::from(u8::MAX) { + return Err(CborError::NotCanonical); + } + v + } + 26 => { + let v = u64::from(u32::from_be_bytes( + self.take(4)?.try_into().expect("len checked"), + )); + if v <= u64::from(u16::MAX) { + return Err(CborError::NotCanonical); + } + v + } + 27 => { + let v = u64::from_be_bytes(self.take(8)?.try_into().expect("len checked")); + if v <= u64::from(u32::MAX) { + return Err(CborError::NotCanonical); + } + v + } + _ => return Err(CborError::BadHead(first)), + }; + Ok((major, value)) + } + + fn read_uint(&mut self) -> Result { + let (major, v) = self.read_head()?; + if major != 0 { + return Err(CborError::WrongType { + expected: 0, + found: major, + }); + } + Ok(v) + } + + fn read_int(&mut self) -> Result { + let (major, v) = self.read_head()?; + match major { + 0 => i64::try_from(v).map_err(|_| CborError::IntOutOfRange), + 1 => { + let n = i64::try_from(v).map_err(|_| CborError::IntOutOfRange)?; + Ok(-1 - n) + } + found => Err(CborError::WrongType { expected: 0, found }), + } + } + + fn read_bytes(&mut self) -> Result<&'a [u8], CborError> { + let (major, len) = self.read_head()?; + if major != 2 { + return Err(CborError::WrongType { + expected: 2, + found: major, + }); + } + let len = usize::try_from(len).map_err(|_| CborError::IntOutOfRange)?; + self.take(len) + } + + fn read_array(&mut self, expected_len: usize) -> Result<(), CborError> { + let (major, len) = self.read_head()?; + if major != 4 { + return Err(CborError::WrongType { + expected: 4, + found: major, + }); + } + if len != expected_len as u64 { + return Err(CborError::WrongLength { + expected: expected_len, + actual: usize::try_from(len).unwrap_or(usize::MAX), + }); + } + Ok(()) + } + + fn finish(&self) -> Result<(), CborError> { + if self.pos != self.b.len() { + return Err(CborError::TrailingBytes(self.b.len() - self.pos)); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// rv_env_sample_v1 <-> CBOR (13-element fixed-order array) +// --------------------------------------------------------------------------- + +const SAMPLE_FIELDS: usize = 13; + +/// Encode a wire sample as a fixed-order 13-element CBOR array: +/// `[schema_version, sensor_type, flags, node_id, timestamp_ns, sequence, +/// latitude_e7, longitude_e7, altitude_mm, value_q16, quality_q15, +/// battery_mv, calibration_id]`. Deterministic by construction. +#[must_use] +pub fn encode_sample_v1(s: &RvEnvSampleV1) -> Vec { + let mut out = Vec::with_capacity(64); + write_array(&mut out, SAMPLE_FIELDS); + write_uint(&mut out, u64::from(s.schema_version)); + write_uint(&mut out, u64::from(s.sensor_type)); + write_uint(&mut out, u64::from(s.flags)); + write_uint(&mut out, s.node_id); + write_uint(&mut out, s.timestamp_ns); + write_uint(&mut out, u64::from(s.sequence)); + write_int(&mut out, i64::from(s.latitude_e7)); + write_int(&mut out, i64::from(s.longitude_e7)); + write_int(&mut out, i64::from(s.altitude_mm)); + write_int(&mut out, i64::from(s.value_q16)); + write_uint(&mut out, u64::from(s.quality_q15)); + write_uint(&mut out, u64::from(s.battery_mv)); + write_uint(&mut out, u64::from(s.calibration_id)); + out +} + +fn to_u8(v: u64) -> Result { + u8::try_from(v).map_err(|_| CborError::IntOutOfRange) +} +fn to_u16(v: u64) -> Result { + u16::try_from(v).map_err(|_| CborError::IntOutOfRange) +} +fn to_u32(v: u64) -> Result { + u32::try_from(v).map_err(|_| CborError::IntOutOfRange) +} +fn to_i32(v: i64) -> Result { + i32::try_from(v).map_err(|_| CborError::IntOutOfRange) +} + +/// Decode a wire sample from canonical CBOR, rejecting non-canonical heads, +/// wrong arity, and trailing bytes. +pub fn decode_sample_v1(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + r.read_array(SAMPLE_FIELDS)?; + let s = RvEnvSampleV1 { + schema_version: to_u8(r.read_uint()?)?, + sensor_type: to_u8(r.read_uint()?)?, + flags: to_u16(r.read_uint()?)?, + node_id: r.read_uint()?, + timestamp_ns: r.read_uint()?, + sequence: to_u32(r.read_uint()?)?, + latitude_e7: to_i32(r.read_int()?)?, + longitude_e7: to_i32(r.read_int()?)?, + altitude_mm: to_i32(r.read_int()?)?, + value_q16: to_i32(r.read_int()?)?, + quality_q15: to_u16(r.read_uint()?)?, + battery_mv: to_u16(r.read_uint()?)?, + calibration_id: to_u32(r.read_uint()?)?, + }; + r.finish()?; + Ok(s) +} + +// --------------------------------------------------------------------------- +// Signed record envelope +// --------------------------------------------------------------------------- + +/// COSE_Sign1-inspired deterministic envelope: `[payload, pubkey, signature]` +/// as definite-length byte strings. The payload is the 48-byte packed wire +/// record; the signature is ed25519 over exactly those payload bytes. +/// Honest label (ADR-264 §11.2): COSE-*inspired* framing, not RFC 9052. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedEnvRecordV1 { + /// The 48-byte packed `rv_env_sample_v1` payload. + pub payload: [u8; RV_ENV_SAMPLE_V1_WIRE_LEN], + /// ed25519 verifying key (32 bytes). + pub pubkey: [u8; 32], + /// ed25519 detached signature over `payload` (64 bytes). + pub signature: [u8; 64], +} + +impl SignedEnvRecordV1 { + /// Encode the envelope as deterministic CBOR. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(4 + 48 + 34 + 66); + write_array(&mut out, 3); + write_bytes(&mut out, &self.payload); + write_bytes(&mut out, &self.pubkey); + write_bytes(&mut out, &self.signature); + out + } + + /// Decode an envelope, enforcing exact field lengths and canonical form. + pub fn decode(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + r.read_array(3)?; + let payload: [u8; RV_ENV_SAMPLE_V1_WIRE_LEN] = + r.read_bytes()? + .try_into() + .map_err(|_| CborError::WrongLength { + expected: RV_ENV_SAMPLE_V1_WIRE_LEN, + actual: 0, + })?; + let pubkey: [u8; 32] = r + .read_bytes()? + .try_into() + .map_err(|_| CborError::WrongLength { + expected: 32, + actual: 0, + })?; + let signature: [u8; 64] = + r.read_bytes()? + .try_into() + .map_err(|_| CborError::WrongLength { + expected: 64, + actual: 0, + })?; + r.finish()?; + Ok(SignedEnvRecordV1 { + payload, + pubkey, + signature, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::RV_ENV_SCHEMA_V1; + + fn sample() -> RvEnvSampleV1 { + RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: 2, + flags: 1, + node_id: 7, + timestamp_ns: 1_754_000_000_000_000_000, + sequence: 42, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: 1_802_240, + quality_q15: 0x7000, + battery_mv: 3_612, + calibration_id: 3, + } + } + + #[test] + fn sample_cbor_round_trips_and_is_deterministic() { + let s = sample(); + let a = encode_sample_v1(&s); + let b = encode_sample_v1(&s); + assert_eq!(a, b, "same input must produce byte-identical CBOR"); + assert_eq!(decode_sample_v1(&a).unwrap(), s); + } + + #[test] + fn known_vector_stability() { + // Freeze the encoding: array(13), then 1, 2, flags=1... Changing the + // encoder in any way must break this test. + let s = sample(); + let enc = encode_sample_v1(&s); + assert_eq!(enc[0], 0x8d); // array(13) + assert_eq!(enc[1], 0x01); // schema_version 1 + assert_eq!(enc[2], 0x02); // sensor_type 2 + assert_eq!(enc[3], 0x01); // flags 1 + assert_eq!(enc[4], 0x07); // node_id 7 + // timestamp needs 8-byte head. + assert_eq!(enc[5], 0x1b); + // Full-message determinism pin via length: + // 1 (array) + 1+1+1+1 (small uints) + 9 (u64 ts) + 2 (seq 42) + // + 5 (lat) + 3 (lon nint) + 3 (alt) + 5 (value) + 3 (quality) + // + 3 (battery) + 1 (calibration 3) = 39 bytes. + assert_eq!(enc.len(), 39); + } + + #[test] + fn non_canonical_heads_rejected() { + // uint 7 encoded long-form as 0x18 0x07 (should be 0x07). + let mut bad = vec![0x81]; // array(1) + bad.extend_from_slice(&[0x18, 0x07]); + let mut r = Reader::new(&bad); + r.read_array(1).unwrap(); + assert_eq!(r.read_uint(), Err(CborError::NotCanonical)); + } + + #[test] + fn truncated_and_trailing_rejected() { + let enc = encode_sample_v1(&sample()); + assert!(decode_sample_v1(&enc[..enc.len() - 1]).is_err()); + let mut extra = enc.clone(); + extra.push(0x00); + assert_eq!(decode_sample_v1(&extra), Err(CborError::TrailingBytes(1))); + } + + #[test] + fn negative_ints_round_trip() { + let mut s = sample(); + s.latitude_e7 = -900_000_000; + s.longitude_e7 = -1_800_000_000; + s.altitude_mm = -11_000_000; + s.value_q16 = i32::MIN; + let enc = encode_sample_v1(&s); + assert_eq!(decode_sample_v1(&enc).unwrap(), s); + } + + #[test] + fn envelope_round_trips_and_rejects_bad_lengths() { + let rec = SignedEnvRecordV1 { + payload: sample().encode(), + pubkey: [0xAA; 32], + signature: [0xBB; 64], + }; + let enc = rec.encode(); + assert_eq!(SignedEnvRecordV1::decode(&enc).unwrap(), rec); + + // Envelope with a 47-byte payload must be rejected. + let mut bad = Vec::new(); + write_array(&mut bad, 3); + write_bytes(&mut bad, &[0u8; 47]); + write_bytes(&mut bad, &[0u8; 32]); + write_bytes(&mut bad, &[0u8; 64]); + assert!(SignedEnvRecordV1::decode(&bad).is_err()); + } +} diff --git a/crates/rucelium-abi/src/lib.rs b/crates/rucelium-abi/src/lib.rs new file mode 100644 index 0000000..641bfec --- /dev/null +++ b/crates/rucelium-abi/src/lib.rs @@ -0,0 +1,39 @@ +//! # rucelium-abi +//! +//! The versioned C ABI boundary of the RuCelium fabric (ADR-264 §11). +//! +//! This crate is the Rust side of the ADR-096 posture: the C world (spore +//! nodes) produces a **packed, little-endian, 48-byte** `rv_env_sample_v1` +//! record (header of record: [`include/rucelium_env.h`]); this crate parses +//! it with **bounds-checked, allocation-free** field reads — the workspace +//! forbids `unsafe`, so no transmute ever happens — and validates every field +//! before conversion into the `rucelium-core` domain model. +//! +//! Above the fixed struct sits **deterministic CBOR** (definite lengths, +//! fixed field order, shortest-form integers) and a COSE_Sign1-*inspired* +//! signed envelope `[payload, pubkey, signature]` with real ed25519 +//! signatures. Honest label: this is deterministic COSE-inspired framing, +//! not a full RFC 9052 implementation (stated follow-up in ADR-264 §11.2). +//! +//! [`include/rucelium_env.h`]: +//! https://github.com/ruvnet/rufield/blob/main/crates/rucelium-abi/include/rucelium_env.h + +#![doc(html_root_url = "https://docs.rs/rucelium-abi/0.1.0")] +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(feature = "alloc")] +extern crate alloc; + +#[cfg(feature = "alloc")] +pub mod cbor; +#[cfg(feature = "std")] +pub mod sign; +pub mod wire; + +#[cfg(feature = "alloc")] +pub use cbor::{CborError, SignedEnvRecordV1}; +#[cfg(feature = "std")] +pub use sign::{sign_payload, verify_record, NodeSigner}; +pub use wire::{ + AbiError, RvEnvSampleV1, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SAMPLE_V1_WIRE_LEN, RV_ENV_SCHEMA_V1, +}; diff --git a/crates/rucelium-abi/src/sign.rs b/crates/rucelium-abi/src/sign.rs new file mode 100644 index 0000000..d380b9c --- /dev/null +++ b/crates/rucelium-abi/src/sign.rs @@ -0,0 +1,180 @@ +//! Device signing over the wire payload (ADR-264 §11.2 / §12). +//! +//! ed25519 detached signatures over the exact 48 payload bytes, carried in +//! the [`SignedEnvRecordV1`] envelope. Signing is deterministic (RFC 8032): +//! same key + payload ⇒ same signature — required by the deterministic +//! benchmark, and matching `rufield-provenance`'s posture. + +use crate::cbor::SignedEnvRecordV1; +use crate::wire::{RvEnvSampleV1, RV_ENV_SAMPLE_V1_WIRE_LEN}; +use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey}; +use sha2::{Digest, Sha256}; +use std::fmt; + +/// Signature-layer errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignError { + /// The embedded public key bytes were not a valid ed25519 point. + BadKey, + /// The signature did not verify over the payload. + VerifyFailed, +} + +impl fmt::Display for SignError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SignError::BadKey => write!(f, "invalid ed25519 public key"), + SignError::VerifyFailed => write!(f, "signature verification failed"), + } + } +} + +impl std::error::Error for SignError {} + +/// A deterministic per-device signer, as run by spore-node firmware (in v0.1, +/// by the synthetic node simulator). +pub struct NodeSigner { + key: SigningKey, +} + +impl NodeSigner { + /// Construct from a fixed 32-byte seed. Same seed ⇒ same key. + #[must_use] + pub fn from_seed(seed: &[u8; 32]) -> Self { + NodeSigner { + key: SigningKey::from_bytes(seed), + } + } + + /// Derive a device key deterministically from a provisioning seed and the + /// device id: `sha256(provision_seed || node_id_le)`. This mirrors how a + /// provisioning ceremony hands each spore node a unique key. + #[must_use] + pub fn for_node(provision_seed: &[u8; 32], node_id: u64) -> Self { + let mut h = Sha256::new(); + h.update(provision_seed); + h.update(node_id.to_le_bytes()); + let digest: [u8; 32] = h.finalize().into(); + Self::from_seed(&digest) + } + + /// The verifying (public) key bytes, as registered with the gateway. + #[must_use] + pub fn public_key(&self) -> [u8; 32] { + self.key.verifying_key().to_bytes() + } + + /// Hex-encoded public key (the form `SampleProvenance` carries). + #[must_use] + pub fn public_key_hex(&self) -> String { + let mut s = String::with_capacity(64); + for b in self.public_key() { + s.push_str(&format!("{b:02x}")); + } + s + } + + /// Sign a wire sample: encode to the packed 48-byte payload, sign those + /// exact bytes, and wrap in the envelope. + #[must_use] + pub fn sign_sample(&self, sample: &RvEnvSampleV1) -> SignedEnvRecordV1 { + sign_payload(self, &sample.encode()) + } +} + +/// Sign an exact 48-byte payload. +#[must_use] +pub fn sign_payload( + signer: &NodeSigner, + payload: &[u8; RV_ENV_SAMPLE_V1_WIRE_LEN], +) -> SignedEnvRecordV1 { + let sig: Signature = signer.key.sign(payload); + SignedEnvRecordV1 { + payload: *payload, + pubkey: signer.public_key(), + signature: sig.to_bytes(), + } +} + +/// Verify the ed25519 signature carried in an envelope over its payload. +/// This proves the payload is intact and was signed by the embedded key — +/// whether that key belongs to a *registered, unrevoked* device is the +/// ingest pipeline's job (`rucelium-ingest`). +pub fn verify_record(record: &SignedEnvRecordV1) -> Result<(), SignError> { + let vk = VerifyingKey::from_bytes(&record.pubkey).map_err(|_| SignError::BadKey)?; + let sig = Signature::from_bytes(&record.signature); + vk.verify(&record.payload, &sig) + .map_err(|_| SignError::VerifyFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::RV_ENV_SCHEMA_V1; + + fn sample() -> RvEnvSampleV1 { + RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: 5, + flags: 0, + node_id: 11, + timestamp_ns: 1_000_000, + sequence: 1, + latitude_e7: 0, + longitude_e7: 0, + altitude_mm: 0, + value_q16: 65_536, + quality_q15: 0x8000, + battery_mv: 3_300, + calibration_id: 0, + } + } + + #[test] + fn sign_verify_round_trip_through_cbor() { + let signer = NodeSigner::for_node(b"rucelium-provision-seed-32-byte!", 11); + let rec = signer.sign_sample(&sample()); + verify_record(&rec).unwrap(); + // Through the CBOR envelope and back. + let enc = rec.encode(); + let back = SignedEnvRecordV1::decode(&enc).unwrap(); + verify_record(&back).unwrap(); + } + + #[test] + fn any_payload_tamper_breaks_verification() { + let signer = NodeSigner::for_node(b"rucelium-provision-seed-32-byte!", 11); + let rec = signer.sign_sample(&sample()); + for i in 0..RV_ENV_SAMPLE_V1_WIRE_LEN { + let mut t = rec.clone(); + t.payload[i] ^= 0x01; + assert_eq!( + verify_record(&t), + Err(SignError::VerifyFailed), + "tampered byte {i} must break the signature" + ); + } + } + + #[test] + fn wrong_key_rejected() { + let a = NodeSigner::for_node(b"rucelium-provision-seed-32-byte!", 11); + let b = NodeSigner::for_node(b"rucelium-provision-seed-32-byte!", 12); + let mut rec = a.sign_sample(&sample()); + rec.pubkey = b.public_key(); + assert!(verify_record(&rec).is_err()); + } + + #[test] + fn node_key_derivation_is_deterministic_and_unique() { + let seed = b"rucelium-provision-seed-32-byte!"; + assert_eq!( + NodeSigner::for_node(seed, 1).public_key(), + NodeSigner::for_node(seed, 1).public_key() + ); + assert_ne!( + NodeSigner::for_node(seed, 1).public_key(), + NodeSigner::for_node(seed, 2).public_key() + ); + } +} diff --git a/crates/rucelium-abi/src/wire.rs b/crates/rucelium-abi/src/wire.rs new file mode 100644 index 0000000..9532c13 --- /dev/null +++ b/crates/rucelium-abi/src/wire.rs @@ -0,0 +1,418 @@ +//! `rv_env_sample_v1`: packed little-endian wire struct, bounds-checked +//! allocation-free parse, field validation, and domain conversion +//! (ADR-264 §11.1). + +use core::fmt; +#[cfg(feature = "std")] +use rucelium_core::{EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty}; + +/// Maximum valid latitude in 1e-7 degree units. Defined locally so the wire +/// layer stays `no_std`; a std-only test pins it to `rucelium_core::geo`. +pub const LAT_E7_MAX: i32 = 900_000_000; +/// Maximum valid longitude in 1e-7 degree units (see [`LAT_E7_MAX`]). +pub const LON_E7_MAX: i32 = 1_800_000_000; +/// Highest valid `sensor_type` wire code. Pinned to +/// `rucelium_core::SensorModality::ALL` by a std-only test. +pub const SENSOR_TYPE_MAX: u8 = 9; + +/// Wire schema version 1. +pub const RV_ENV_SCHEMA_V1: u8 = 1; + +/// Exact serialized length of `rv_env_sample_v1` (packed, little-endian). +pub const RV_ENV_SAMPLE_V1_WIRE_LEN: usize = 48; + +/// Flags bit 0: ring-buffer retransmit after an outage (store-and-forward +/// recovery, distinct from a replay attack — the sequence window still +/// deduplicates either way). +pub const RV_ENV_FLAG_RETRANSMIT: u16 = 1; + +/// Maximum `quality_q15` value (Q0.15 encoding of 1.0). +pub const Q15_ONE: u16 = 0x8000; + +/// One Q16.16 unit. +const Q16_ONE_F64: f64 = 65_536.0; + +/// Errors raised at the ABI boundary. Every failure is a rejection — the +/// boundary never repairs or guesses (ADR-264 §11). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AbiError { + /// The byte slice was not exactly 48 bytes. + WrongLength { + /// Expected length (48). + expected: usize, + /// Actual length received. + actual: usize, + }, + /// Unknown schema version. + BadSchemaVersion(u8), + /// Unknown sensor modality code. + UnknownModality(u8), + /// Latitude/longitude outside valid range. + GeoOutOfRange(&'static str, i32), + /// `quality_q15` above `Q15_ONE`. + QualityOutOfRange(u16), + /// Zero measurement timestamp. + ZeroTimestamp, + /// Domain validation failed after conversion (std only — conversion into + /// the domain model requires `rucelium-core`). + #[cfg(feature = "std")] + Domain(String), +} + +impl fmt::Display for AbiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AbiError::WrongLength { expected, actual } => { + write!( + f, + "wire record must be exactly {expected} bytes, got {actual}" + ) + } + AbiError::BadSchemaVersion(v) => write!(f, "unknown schema version {v}"), + AbiError::UnknownModality(c) => write!(f, "unknown sensor modality code {c}"), + AbiError::GeoOutOfRange(field, v) => write!(f, "{field} out of range: {v}"), + AbiError::QualityOutOfRange(q) => { + write!(f, "quality_q15 {q:#06x} above Q15 1.0 ({:#06x})", Q15_ONE) + } + AbiError::ZeroTimestamp => write!(f, "zero measurement timestamp"), + #[cfg(feature = "std")] + AbiError::Domain(m) => write!(f, "domain validation failed: {m}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for AbiError {} + +/// Rust mirror of the C `rv_env_sample_v1` struct (ADR-264 §11.1). `repr(C)` +/// documents the field contract; parsing never transmutes — it reads each +/// little-endian field from the byte slice after a single bounds check. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RvEnvSampleV1 { + /// Must equal [`RV_ENV_SCHEMA_V1`]. + pub schema_version: u8, + /// [`SensorModality`] wire code. + pub sensor_type: u8, + /// Flag bits ([`RV_ENV_FLAG_RETRANSMIT`], …). + pub flags: u16, + /// Device identity. + pub node_id: u64, + /// Measurement time, ns since Unix epoch. + pub timestamp_ns: u64, + /// Per-device monotonic sequence number. + pub sequence: u32, + /// Latitude, degrees × 1e7. + pub latitude_e7: i32, + /// Longitude, degrees × 1e7. + pub longitude_e7: i32, + /// Altitude, millimetres. + pub altitude_mm: i32, + /// Measurement value, Q16.16. + pub value_q16: i32, + /// Quality score, Q0.15 (`0x0000..=0x8000`). + pub quality_q15: u16, + /// Battery level, millivolts. + pub battery_mv: u16, + /// Applied calibration record id (0 = uncalibrated). + pub calibration_id: u32, +} + +// Little-endian field readers over a length-checked 48-byte slice. The +// `expect`s are unreachable: offsets are compile-time constants inside the +// checked length. +fn rd_u16(b: &[u8], off: usize) -> u16 { + u16::from_le_bytes(b[off..off + 2].try_into().expect("checked length")) +} +fn rd_u32(b: &[u8], off: usize) -> u32 { + u32::from_le_bytes(b[off..off + 4].try_into().expect("checked length")) +} +fn rd_i32(b: &[u8], off: usize) -> i32 { + i32::from_le_bytes(b[off..off + 4].try_into().expect("checked length")) +} +fn rd_u64(b: &[u8], off: usize) -> u64 { + u64::from_le_bytes(b[off..off + 8].try_into().expect("checked length")) +} + +impl RvEnvSampleV1 { + /// Parse a packed little-endian wire record. Exactly one bounds check + /// (the length), no allocation, no `unsafe`, no panics on any input. + /// Parsing does **not** validate field semantics — call [`Self::validate`] + /// (or [`Self::parse_validated`]) before trusting the contents. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() != RV_ENV_SAMPLE_V1_WIRE_LEN { + return Err(AbiError::WrongLength { + expected: RV_ENV_SAMPLE_V1_WIRE_LEN, + actual: bytes.len(), + }); + } + Ok(RvEnvSampleV1 { + schema_version: bytes[0], + sensor_type: bytes[1], + flags: rd_u16(bytes, 2), + node_id: rd_u64(bytes, 4), + timestamp_ns: rd_u64(bytes, 12), + sequence: rd_u32(bytes, 20), + latitude_e7: rd_i32(bytes, 24), + longitude_e7: rd_i32(bytes, 28), + altitude_mm: rd_i32(bytes, 32), + value_q16: rd_i32(bytes, 36), + quality_q15: rd_u16(bytes, 40), + battery_mv: rd_u16(bytes, 42), + calibration_id: rd_u32(bytes, 44), + }) + } + + /// Parse and validate in one step — the form gateways use. + pub fn parse_validated(bytes: &[u8]) -> Result { + let s = Self::parse(bytes)?; + s.validate()?; + Ok(s) + } + + /// Serialize to the packed little-endian wire layout. Used by the + /// synthetic spore-node simulator and by tests; real nodes serialize + /// in C per `rucelium_env.h`. + #[must_use] + pub fn encode(&self) -> [u8; RV_ENV_SAMPLE_V1_WIRE_LEN] { + let mut b = [0u8; RV_ENV_SAMPLE_V1_WIRE_LEN]; + b[0] = self.schema_version; + b[1] = self.sensor_type; + b[2..4].copy_from_slice(&self.flags.to_le_bytes()); + b[4..12].copy_from_slice(&self.node_id.to_le_bytes()); + b[12..20].copy_from_slice(&self.timestamp_ns.to_le_bytes()); + b[20..24].copy_from_slice(&self.sequence.to_le_bytes()); + b[24..28].copy_from_slice(&self.latitude_e7.to_le_bytes()); + b[28..32].copy_from_slice(&self.longitude_e7.to_le_bytes()); + b[32..36].copy_from_slice(&self.altitude_mm.to_le_bytes()); + b[36..40].copy_from_slice(&self.value_q16.to_le_bytes()); + b[40..42].copy_from_slice(&self.quality_q15.to_le_bytes()); + b[42..44].copy_from_slice(&self.battery_mv.to_le_bytes()); + b[44..48].copy_from_slice(&self.calibration_id.to_le_bytes()); + b + } + + /// Validate every field before any domain conversion (ADR-264 §11.1: + /// "every value is validated before conversion into the domain model"). + pub fn validate(&self) -> Result<(), AbiError> { + if self.schema_version != RV_ENV_SCHEMA_V1 { + return Err(AbiError::BadSchemaVersion(self.schema_version)); + } + if self.sensor_type > SENSOR_TYPE_MAX { + return Err(AbiError::UnknownModality(self.sensor_type)); + } + if self.latitude_e7.abs() > LAT_E7_MAX { + return Err(AbiError::GeoOutOfRange("latitude_e7", self.latitude_e7)); + } + if self.longitude_e7.abs() > LON_E7_MAX { + return Err(AbiError::GeoOutOfRange("longitude_e7", self.longitude_e7)); + } + if self.quality_q15 > Q15_ONE { + return Err(AbiError::QualityOutOfRange(self.quality_q15)); + } + if self.timestamp_ns == 0 { + return Err(AbiError::ZeroTimestamp); + } + Ok(()) + } + + /// The modality, if the code is known (std only — the registry lives in + /// `rucelium-core`). + #[cfg(feature = "std")] + #[must_use] + pub fn modality(&self) -> Option { + SensorModality::from_code(self.sensor_type) + } + + /// Raw measurement value as `f64` (Q16.16 → float). + #[must_use] + pub fn value_f64(&self) -> f64 { + f64::from(self.value_q16) / Q16_ONE_F64 + } + + /// Quality as `f32` (Q0.15 → float, `0x8000` ⇒ 1.0). + #[must_use] + pub fn quality_f32(&self) -> f32 { + f32::from(self.quality_q15) / f32::from(Q15_ONE) + } + + /// Convert a **validated** wire record into an *uncalibrated* domain + /// [`EnvSample`] (std only — requires `rucelium-core`). The uncertainty starts at the Q16.16 quantization + /// half-step; `rucelium-calibration` widens it with the calibration's + /// stated uncertainty. Provenance identity comes from the verified wire + /// envelope, supplied by the ingest pipeline. + #[cfg(feature = "std")] + pub fn to_env_sample( + &self, + received_ns: u64, + firmware_hash: &str, + signer_pubkey_hex: &str, + verified: bool, + ) -> Result { + self.validate()?; + let modality = self + .modality() + .ok_or(AbiError::UnknownModality(self.sensor_type))?; + let (property, unit) = modality.default_property_unit(); + let value = self.value_f64(); + let sample = EnvSample { + node_id: self.node_id, + sequence: self.sequence, + measured_ns: self.timestamp_ns, + received_ns, + geo: GeoPoint { + latitude_e7: self.latitude_e7, + longitude_e7: self.longitude_e7, + altitude_mm: self.altitude_mm, + }, + modality, + observed_property: property.to_string(), + unit: unit.to_string(), + value, + quality: self.quality_f32(), + uncertainty: Uncertainty::symmetric(value, 0.5 / Q16_ONE_F64), + calibration_id: self.calibration_id, + flags: self.flags, + battery_mv: self.battery_mv, + provenance: SampleProvenance { + firmware_hash: firmware_hash.to_string(), + signer_pubkey_hex: signer_pubkey_hex.to_string(), + verified, + lineage: vec!["abi:rv_env_sample_v1".to_string()], + }, + }; + sample + .validate() + .map_err(|e| AbiError::Domain(e.to_string()))?; + Ok(sample) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(crate) fn wire_sample() -> RvEnvSampleV1 { + RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: SensorModality::SoilMoisture.code(), + flags: 0, + node_id: 0xDEAD_BEEF_0000_0007, + timestamp_ns: 1_754_000_000_000_000_000, + sequence: 42, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: 27 * 65_536 + 32_768, // 27.5 %VWC + quality_q15: 0x7000, + battery_mv: 3_612, + calibration_id: 3, + } + } + + #[test] + fn encode_parse_round_trip() { + let s = wire_sample(); + let bytes = s.encode(); + assert_eq!(bytes.len(), RV_ENV_SAMPLE_V1_WIRE_LEN); + let back = RvEnvSampleV1::parse_validated(&bytes).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn wrong_length_rejected() { + let s = wire_sample().encode(); + assert!(matches!( + RvEnvSampleV1::parse(&s[..47]), + Err(AbiError::WrongLength { + expected: 48, + actual: 47 + }) + )); + let mut long = s.to_vec(); + long.push(0); + assert!(RvEnvSampleV1::parse(&long).is_err()); + assert!(RvEnvSampleV1::parse(&[]).is_err()); + } + + #[test] + fn every_invalid_field_rejected() { + let mut s = wire_sample(); + s.schema_version = 2; + assert!(matches!(s.validate(), Err(AbiError::BadSchemaVersion(2)))); + + let mut s = wire_sample(); + s.sensor_type = 10; + assert!(matches!(s.validate(), Err(AbiError::UnknownModality(10)))); + + let mut s = wire_sample(); + s.latitude_e7 = LAT_E7_MAX + 1; + assert!(matches!(s.validate(), Err(AbiError::GeoOutOfRange(..)))); + + let mut s = wire_sample(); + s.longitude_e7 = -(LON_E7_MAX + 1); + assert!(matches!(s.validate(), Err(AbiError::GeoOutOfRange(..)))); + + let mut s = wire_sample(); + s.quality_q15 = 0x8001; + assert!(matches!(s.validate(), Err(AbiError::QualityOutOfRange(_)))); + + let mut s = wire_sample(); + s.timestamp_ns = 0; + assert!(matches!(s.validate(), Err(AbiError::ZeroTimestamp))); + } + + #[test] + fn fixed_point_conversions() { + let s = wire_sample(); + assert!((s.value_f64() - 27.5).abs() < 1e-9); + assert!((s.quality_f32() - 0.875).abs() < 1e-6); + } + + #[test] + fn domain_conversion_carries_all_twelve_attributes() { + let s = wire_sample(); + let env = s + .to_env_sample(s.timestamp_ns + 1_000_000, "sha256:fw", "aabb", true) + .unwrap(); + env.validate().unwrap(); + assert_eq!(env.node_id, s.node_id); + assert_eq!(env.sequence, 42); + assert_eq!(env.observed_property, "soil_volumetric_water_content"); + assert_eq!(env.unit, "%"); + assert_eq!(env.calibration_id, 3); + assert!(env.provenance.verified); + assert_eq!(env.provenance.lineage, vec!["abi:rv_env_sample_v1"]); + // Quantization uncertainty brackets the value. + assert!(env.uncertainty.lower <= env.value && env.value <= env.uncertainty.upper); + } + + #[test] + fn local_constants_pin_the_core_registry() { + // The no_std wire layer duplicates these so it can drop rucelium-core; + // this std-only test keeps the copies honest. + assert_eq!(LAT_E7_MAX, rucelium_core::geo::LAT_E7_MAX); + assert_eq!(LON_E7_MAX, rucelium_core::geo::LON_E7_MAX); + assert_eq!( + usize::from(SENSOR_TYPE_MAX) + 1, + SensorModality::ALL.len(), + "SENSOR_TYPE_MAX must track the SensorModality registry" + ); + assert!(SensorModality::from_code(SENSOR_TYPE_MAX).is_some()); + assert!(SensorModality::from_code(SENSOR_TYPE_MAX + 1).is_none()); + } + + #[test] + fn parse_never_panics_on_arbitrary_bytes() { + // Deterministic pseudo-fuzz over lengths and contents. + let mut x: u64 = 0x1234_5678_9ABC_DEF0; + for len in 0..96usize { + let mut buf = vec![0u8; len]; + for b in &mut buf { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + *b = (x >> 56) as u8; + } + let _ = RvEnvSampleV1::parse_validated(&buf); // must not panic + } + } +} diff --git a/crates/rucelium-bench/Cargo.toml b/crates/rucelium-bench/Cargo.toml new file mode 100644 index 0000000..c27974f --- /dev/null +++ b/crates/rucelium-bench/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rucelium-bench" +version.workspace = true +edition.workspace = true +description = "RuCelium deterministic 64-node biome benchmark: 30 simulated days, 7-day offline partition, tamper/replay rejection, revocation continuity, calibrated-observation yield — the ADR-264 §14 acceptance test (SYNTHETIC)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "benchmark", "simulation"] +categories = ["science"] + +[[bin]] +name = "rucelium-bench" +path = "src/main.rs" + +[dependencies] +rucelium-core = { workspace = true } +rucelium-abi = { workspace = true } +rucelium-ingest = { workspace = true } +rucelium-calibration = { workspace = true } +rucelium-worldgraph = { workspace = true } +rucelium-policy = { workspace = true } +rucelium-federation = { workspace = true } +rufield-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-bench/src/lib.rs b/crates/rucelium-bench/src/lib.rs new file mode 100644 index 0000000..1b9a5d7 --- /dev/null +++ b/crates/rucelium-bench/src/lib.rs @@ -0,0 +1,20 @@ +//! # rucelium-bench +//! +//! Deterministic **SYNTHETIC** biome benchmark for RuCelium — the ADR-264 +//! §14 acceptance test. A 64-node biome runs 30 simulated days through the +//! real production pipeline (ABI → ingest → calibration → WorldGraph + RF +//! context → biome federation → SensorThings → governed control path) while +//! the simulator injects drift, a flood anomaly, a 7-day uplink outage, +//! tamper/replay/forged-key attacks, and a mid-run device compromise. +//! +//! Same seed ⇒ identical deterministic report (wall-clock latencies aside). +//! These numbers prove the fabric's mechanics against known ground truth; +//! they are NOT a field deployment. + +pub mod report; +pub mod runner; +pub mod sim; + +pub use report::{BiomeReport, Criterion}; +pub use runner::run; +pub use sim::{BiomeSim, Emission, EmissionKind, SimConfig, DEFAULT_SEED}; diff --git a/crates/rucelium-bench/src/main.rs b/crates/rucelium-bench/src/main.rs new file mode 100644 index 0000000..60177eb --- /dev/null +++ b/crates/rucelium-bench/src/main.rs @@ -0,0 +1,35 @@ +//! `rucelium-bench` binary — runs the deterministic ADR-264 §14 biome +//! acceptance benchmark and prints the human table plus JSON. +//! +//! Usage: +//! cargo run -p rucelium-bench # default seed +//! cargo run -p rucelium-bench -- 2026 # custom seed +//! cargo run -p rucelium-bench -- 2026 --json # JSON only + +use rucelium_bench::{run, SimConfig, DEFAULT_SEED}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let seed: u64 = args + .iter() + .find(|a| !a.starts_with("--")) + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SEED); + let json_only = args.iter().any(|a| a == "--json"); + + let report = run(SimConfig { + seed, + ..SimConfig::default() + }); + + if json_only { + println!("{}", report.to_json()); + } else { + print!("{}", report.to_table()); + println!("\n--- JSON ---\n{}", report.to_json()); + } + + if !report.accepted_all() { + std::process::exit(1); + } +} diff --git a/crates/rucelium-bench/src/report.rs b/crates/rucelium-bench/src/report.rs new file mode 100644 index 0000000..a51072e --- /dev/null +++ b/crates/rucelium-bench/src/report.rs @@ -0,0 +1,193 @@ +//! The deterministic biome benchmark report (ADR-264 §14). Serializes to +//! stable JSON and renders a human table. All numbers are **SYNTHETIC** — +//! produced by the deterministic biome simulator, NOT a field deployment. + +use serde::{Deserialize, Serialize}; + +/// One acceptance criterion line (ADR-264 §14). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Criterion { + /// Criterion number (1–8). + pub number: u8, + /// Short name. + pub name: String, + /// Measured value, rendered. + pub value: String, + /// Target, rendered. + pub target: String, + /// Whether the criterion passes. + pub pass: bool, +} + +/// The full biome benchmark report. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BiomeReport { + /// Spec version the run targets. + pub spec_version: String, + /// Always true — these numbers come from the synthetic biome simulator. + pub synthetic: bool, + /// PRNG seed used (determinism anchor). + pub seed: u64, + /// Simulated nodes. + pub nodes: u32, + /// Simulated days. + pub days: u32, + /// Consecutive simulated offline days survived. + pub offline_days: u32, + /// Total emissions processed (genuine + adversarial). + pub emissions_total: usize, + /// Genuine samples accepted end-to-end. + pub accepted: u64, + /// Adversarial emissions injected (tamper + replay + forged key + + /// post-revocation). + pub attacks_injected: u64, + /// Adversarial emissions rejected (must equal `attacks_injected`). + pub attacks_rejected: u64, + /// Samples restored from the outage buffer after reconnect. + pub restored_after_outage: u64, + /// Duplicate samples admitted during restore (must be 0). + pub restore_duplicates: u64, + /// Fraction of accepted observations that are usable and calibrated, %. + pub usable_calibrated_pct: f64, + /// Accepted observations mapped into the WorldGraph, %. + pub worldgraph_coverage_pct: f64, + /// Accepted observations projected into SensorThings entities, %. + pub sensorthings_coverage_pct: f64, + /// p50 per-emission gateway pipeline latency, ms (wall clock, in-process). + pub p50_pipeline_ms: f64, + /// p95 per-emission gateway pipeline latency, ms. + pub p95_pipeline_ms: f64, + /// p95 anomaly-sample → local alert latency, ms (target < 500). + pub p95_alert_ms: f64, + /// Local alerts raised for the injected anomaly. + pub anomaly_alerts: u64, + /// Node index quarantined for drift (quarantine, never silent correction). + pub quarantined_nodes: u64, + /// Samples accepted from OTHER nodes after the compromised device was + /// revoked (biome continuity through revocation). + pub accepted_after_revocation: u64, + /// WorldGraph contradiction edges recorded (RF vs physical evidence). + pub contradictions: u64, + /// Governed control-path commands executed with receipts. + pub commands_executed: u64, + /// Governed control-path proposals rejected by a gate. + pub proposals_rejected: u64, + /// The eight §14 acceptance criteria. + pub criteria: Vec, +} + +impl BiomeReport { + /// Whether every §14 criterion passes. + #[must_use] + pub fn accepted_all(&self) -> bool { + !self.criteria.is_empty() && self.criteria.iter().all(|c| c.pass) + } + + /// The deterministic portion of the report — everything except wall-clock + /// latency measurements. Two runs at the same seed must agree on this + /// exactly. + #[must_use] + pub fn deterministic_fingerprint(&self) -> String { + let mut r = self.clone(); + r.p50_pipeline_ms = 0.0; + r.p95_pipeline_ms = 0.0; + r.p95_alert_ms = 0.0; + // Latency-derived criterion values are re-rendered without numbers. + for c in &mut r.criteria { + if c.name.contains("latency") || c.name.contains("alert") { + c.value = String::from(""); + } + } + serde_json::to_string(&r).expect("report serializes") + } + + /// Render the report as a human-readable table with the SYNTHETIC label + /// printed prominently. + #[must_use] + pub fn to_table(&self) -> String { + let mut s = String::new(); + s.push_str( + "====== RuCelium v0.1 — Fabric Reference-Model Acceptance (ADR-264 §14, SYNTHETIC) ======\n", + ); + s.push_str(&format!( + "spec={} seed={} nodes={} days={} offline_days={} emissions={}\n", + self.spec_version, + self.seed, + self.nodes, + self.days, + self.offline_days, + self.emissions_total + )); + s.push_str( + "ALL NUMBERS ARE *SYNTHETIC* — a deterministic biome simulator, not a field pilot.\n", + ); + s.push_str( + "This scores the fabric REFERENCE MODEL (in-memory library components: signatures,\n", + ); + s.push_str( + "replay windows, dedup, quarantine, revocation, projection) against known ground\n", + ); + s.push_str( + "truth. It does NOT exercise the runtime path (store/transport/gateway daemon),\n", + ); + s.push_str("which has its own end-to-end and restart-attack tests in rucelium-gateway.\n"); + s.push_str( + "----------------------------------------------------------------------------------------\n", + ); + s.push_str(&format!( + "{:<3} {:<38} {:>16} {:>14} {:>6}\n", + "#", "CRITERION (SYNTHETIC)", "VALUE", "TARGET", "PASS" + )); + for c in &self.criteria { + s.push_str(&format!( + "{:<3} {:<38} {:>16} {:>14} {:>6}\n", + c.number, + c.name, + c.value, + c.target, + if c.pass { "yes" } else { "NO" } + )); + } + s.push_str( + "----------------------------------------------------------------------------------------\n", + ); + s.push_str(&format!( + "accepted={} attacks {}/{} rejected restored={} (dup={}) usable={:.2}%\n", + self.accepted, + self.attacks_rejected, + self.attacks_injected, + self.restored_after_outage, + self.restore_duplicates, + self.usable_calibrated_pct + )); + s.push_str(&format!( + "pipeline p50={:.4} ms p95={:.4} ms alert p95={:.4} ms\n", + self.p50_pipeline_ms, self.p95_pipeline_ms, self.p95_alert_ms + )); + s.push_str(&format!( + "quarantined={} contradictions={} commands_executed={} proposals_rejected={}\n", + self.quarantined_nodes, + self.contradictions, + self.commands_executed, + self.proposals_rejected + )); + s.push_str(&format!( + "ACCEPTANCE: {}\n", + if self.accepted_all() { + "PASS — all ADR-264 §14 criteria met (SYNTHETIC)" + } else { + "FAIL" + } + )); + s.push_str( + "========================================================================================\n", + ); + s + } + + /// Stable, pretty JSON. + #[must_use] + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).expect("report serializes") + } +} diff --git a/crates/rucelium-bench/src/runner.rs b/crates/rucelium-bench/src/runner.rs new file mode 100644 index 0000000..0ff1330 --- /dev/null +++ b/crates/rucelium-bench/src/runner.rs @@ -0,0 +1,651 @@ +//! The end-to-end gateway + biome runner: feeds the simulated emission +//! stream through the REAL production pipeline — ABI envelopes → ingest +//! (signatures, revocation, anti-replay) → calibration (lineage, drift, +//! quarantine) → WorldGraph + RF context → biome (dedup, outage buffer, +//! revocation, summaries) → SensorThings projection → governed control path — +//! and scores the ADR-264 §14 acceptance criteria against the simulator's +//! ground truth. + +use crate::report::{BiomeReport, Criterion}; +use crate::sim::{ + anchor_expectation, noise_sd, BiomeSim, EmissionKind, SimConfig, EPOCH_START_NS, NODE_ID_BASE, + NS_PER_S, S_PER_DAY, +}; +use rucelium_calibration::{CalibrationOutcome, CalibrationStore, Calibrator, DriftDetector}; +use rucelium_core::{ + CalibrationRecord, EnvironmentalEvent, EventKind, EvidenceRef, SensorModality, Severity, + SPEC_VERSION, +}; +use rucelium_federation::{ + project_sample, verify_event, verify_summary, AcceptOutcome, Biome, BiomeConfig, FederationBus, + OutageBuffer, +}; +use rucelium_ingest::{DeviceRegistry, IngestPipeline}; +use rucelium_policy::{ + verify_receipt, AgentProposal, AuditTrail, AuthorityRegistry, CommandSigner, GatewayValidator, + PolicyConfig, PolicyEngine, ProposalKind, SafetyConfig, SafetySimulator, +}; +use rucelium_worldgraph::{ + assess_plausibility, fuse_rf_context, GraphNode, Plausibility, RfContext, WorldGraph, +}; +use std::time::Instant; + +/// Water-level threshold (metres) for the local flood alert rule. The +/// synthetic baseline peaks ≈ 1.36 m; the injected surge starts ≈ 1.7 m. +const FLOOD_THRESHOLD_M: f64 = 1.6; + +/// Biome signing seed (deterministic identity). +const BIOME_SEED: &[u8; 32] = b"rucelium-biome-owner-key-32b-v1!"; +/// Governance (control-path) signing seed. +const GOV_SEED: &[u8; 32] = b"rucelium-governance-key-32b-v01!"; +/// Gateway receipt-signing identity seed (ADR-264 §9: receipts are signed +/// attestations, so the gateway needs its own deterministic identity). +const GATEWAY_SEED: &[u8; 32] = b"rucelium-gateway-identity-32b-1!"; +/// Federation key epoch the benchmark registers its biome under. +const KEY_EPOCH: u32 = 1; +/// The single actuator the benchmark's biome owner exposes. +const ACTUATOR_ID: &str = "sluice-gate-1"; + +/// Build the calibration store: one anchor-rooted record per modality, then +/// one colocation record per node chaining to its modality anchor. +fn build_calibration(sim: &BiomeSim) -> CalibrationStore { + let mut store = CalibrationStore::new(); + let created = EPOCH_START_NS - S_PER_DAY * NS_PER_S; + let expires = EPOCH_START_NS + u64::from(sim.config.days + 10) * S_PER_DAY * NS_PER_S; + // Anchor records: ids 1..=10 by modality code (skip 0 = WifiCsi context). + for m in SensorModality::ALL { + if m == SensorModality::WifiCsi { + continue; + } + store + .insert(CalibrationRecord { + calibration_id: u32::from(m.code()) + 1, + node_id: 0, // the reference anchor station + modality: m, + method: "anchor_reference".into(), + reference_station: Some(format!("anchor/{}", m.as_str())), + parent_id: None, + created_ns: created, + expires_ns: expires, + scale_q16: 65_536, + offset_q16: 0, + uncertainty_q16: 6_554, // ±0.1 in-unit anchor uncertainty + data_hash: format!("sha256:anchor-{}", m.as_str()), + signature_hex: None, + signer_pubkey_hex: None, + }) + .expect("anchor record valid"); + } + for (i, spec) in sim.nodes.iter().enumerate() { + store + .insert(CalibrationRecord { + calibration_id: 1000 + i as u32, + node_id: spec.node_id, + modality: spec.modality, + method: "colocation".into(), + reference_station: Some(format!("anchor/{}", spec.modality.as_str())), + parent_id: Some(u32::from(spec.modality.code()) + 1), + created_ns: created + 1, + expires_ns: expires, + scale_q16: 65_536, // identity: nodes left the factory true + offset_q16: 0, + uncertainty_q16: 19_661, // ±0.3 in-unit + data_hash: format!("sha256:colo-{}", spec.node_id), + signature_hex: None, + signer_pubkey_hex: None, + }) + .expect("node record valid"); + } + store +} + +fn percentile(sorted_ms: &[f64], p: f64) -> f64 { + if sorted_ms.is_empty() { + return 0.0; + } + let idx = ((sorted_ms.len() as f64 - 1.0) * p).round() as usize; + sorted_ms[idx.min(sorted_ms.len() - 1)] +} + +/// Build the daily gateway-side RuView RF context event (ADR-264 §8 — +/// supporting evidence, never ground truth). +fn rf_context_for_day(day: u32, motion_energy: f32) -> RfContext { + let ts = EPOCH_START_NS + (u64::from(day) * S_PER_DAY + S_PER_DAY / 2) * NS_PER_S; + let tensor = rufield_core::FieldTensor::new( + ts, + rufield_core::Modality::WifiCsi, + vec![rufield_core::FieldAxis::Frequency], + vec![2], + vec![0.1, 0.2], + 0.9, + 0.01, + Some("rf-cal".into()), + rufield_core::PrivacyClass::P2, + ) + .expect("tensor valid"); + let mut obs = rufield_core::Observation::occupancy(0.9, rufield_core::PrivacyClass::P2); + obs.features.insert("motion_energy".into(), motion_energy); + obs.labels = vec!["water_boundary_shift".into()]; + let ev = rufield_core::FieldEvent::new( + format!("rf-day-{day}"), + ts, + rufield_core::SensorDescriptor { + modality: "wifi_csi".into(), + vendor: "ruview_gw".into(), + device_id: "rf-gw-01".into(), + placement: "river_bank".into(), + clock_domain: "gateway".into(), + }, + tensor, + obs, + rufield_core::ProvenanceRef { + raw_hash: "sha256:rf".into(), + firmware_hash: "sha256:rf-fw".into(), + model_id: "ruview_env_ctx_v1".into(), + calibration_id: "rf-cal".into(), + synthetic: true, + signature_hex: None, + signer_pubkey_hex: None, + }, + ); + RfContext::from_field_event(&ev).expect("wifi_csi context") +} + +/// Run the governed control path twice (one authorized command per the §9 +/// pipeline, one actuator proposal without authority) and return +/// `(executed, rejected)`. +fn run_control_path(biome_id: &str, quarantined_node: u64, now_ns: u64) -> (u64, u64) { + let mut audit = AuditTrail::new(); + let mut policy_cfg = PolicyConfig::default(); + policy_cfg.allowed_actuators.insert(ACTUATOR_ID.into()); + let engine = PolicyEngine::new(policy_cfg); + let mut safety = SafetySimulator::new(SafetyConfig::default()); + let mut authority = AuthorityRegistry::new(); + authority.grant(biome_id, "agent/flood", ACTUATOR_ID); + let signer = CommandSigner::from_seed(GOV_SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + + let mut executed = 0u64; + let mut rejected = 0u64; + + // 1. Calibration agent: raise the quarantined node's sampling interval + // (non-actuator — auto-authorized for the proposing biome). + let p1 = AgentProposal { + proposal_id: "prop-recal-1".into(), + agent_id: "agent/calibration".into(), + biome_id: biome_id.into(), + kind: ProposalKind::SetSamplingRate { + node_id: quarantined_node, + interval_s: 3600, + }, + justification: "node quarantined for drift; reduce cadence until recalibrated".into(), + proposed_ns: now_ns, + }; + let done = engine + .evaluate(p1, now_ns, &mut audit) + .and_then(|e| safety.simulate(e, now_ns, &mut audit)) + .and_then(|s| authority.authorize(s, now_ns, &mut audit)) + .map(|a| signer.sign(a, now_ns, 3_600 * NS_PER_S, &mut audit)) + .and_then(|cmd| { + gateway.validate_and_execute( + &cmd, + now_ns + 1, + |_k| Ok("applied".to_string()), + &mut audit, + ) + }); + if let Ok(receipt) = &done { + // Receipts are gateway-signed attestations (ADR-264 §9). + debug_assert!(verify_receipt(receipt), "receipt must verify"); + executed += 1; + } else { + rejected += 1; + } + + // 2. Flood agent: authorized actuator command through every §9 stage. + let p2 = AgentProposal { + proposal_id: "prop-sluice-1".into(), + agent_id: "agent/flood".into(), + biome_id: biome_id.into(), + kind: ProposalKind::ActuatorCommand { + actuator_id: ACTUATOR_ID.into(), + action: "open_fraction".into(), + magnitude: 0.5, + }, + justification: "flood risk warning: pre-emptively relieve water level".into(), + proposed_ns: now_ns, + }; + let done = engine + .evaluate(p2, now_ns, &mut audit) + .and_then(|e| safety.simulate(e, now_ns, &mut audit)) + .and_then(|s| authority.authorize(s, now_ns, &mut audit)) + .map(|a| signer.sign(a, now_ns, 3_600 * NS_PER_S, &mut audit)) + .and_then(|cmd| { + gateway.validate_and_execute( + &cmd, + now_ns + 1, + |_k| Ok("opened 50%".to_string()), + &mut audit, + ) + }); + if let Ok(receipt) = &done { + debug_assert!(verify_receipt(receipt), "receipt must verify"); + // Budgets are checked at safety and charged at execution: only a + // command the gateway actually executed consumes the actuator budget. + safety.record_execution(ACTUATOR_ID); + executed += 1; + } else { + rejected += 1; + } + + // 3. A rogue agent proposing an actuator it has no authority over — the + // control path must stop it (never reaches signing). + let p3 = AgentProposal { + proposal_id: "prop-rogue-1".into(), + agent_id: "agent/unknown".into(), + biome_id: biome_id.into(), + kind: ProposalKind::ActuatorCommand { + actuator_id: ACTUATOR_ID.into(), + action: "open_fraction".into(), + magnitude: 0.7, + }, + justification: "no".into(), + proposed_ns: now_ns, + }; + let outcome = engine + .evaluate(p3, now_ns, &mut audit) + .and_then(|e| safety.simulate(e, now_ns, &mut audit)) + .and_then(|s| authority.authorize(s, now_ns, &mut audit)); + if outcome.is_err() { + rejected += 1; + } else { + executed += 1; // would be a bug; the criterion below catches it + } + + (executed, rejected) +} + +/// Run the full ADR-264 §14 biome benchmark. +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn run(config: SimConfig) -> BiomeReport { + let sim = BiomeSim::generate(config.clone()); + + // --- Gateway + biome assembly (the real production components). --- + let mut registry = DeviceRegistry::new(); + for spec in &sim.nodes { + registry.register(spec.node_id, spec.pubkey, spec.firmware_hash.clone()); + } + let mut ingest = IngestPipeline::new(registry); + let store = build_calibration(&sim); + let calibrator = Calibrator::default(); + let mut drift = DriftDetector::default(); + let mut graph = WorldGraph::new(); + graph.add_node( + "region/synthetic-watershed", + GraphNode::Region { + biome_id: "biome/synthetic-watershed".into(), + name: "Synthetic Watershed".into(), + }, + ); + let mut biome = Biome::new(BiomeConfig::new("biome/synthetic-watershed"), BIOME_SEED); + let mut bus = FederationBus::new(); + // Federation identity binding (ADR-264 §6): the bus binds this biome id + // to this key at epoch 1 — nothing else may publish under the id. + bus.register_biome( + biome.config().biome_id.clone(), + biome.public_key_hex(), + KEY_EPOCH, + ) + .expect("biome registers on the federation bus"); + // Store-and-forward now buffers the ORIGINAL signed envelopes: decoded + // samples cannot be re-verified, so only the wire bytes are replayable. + let mut buffer = OutageBuffer::new(); + + // --- Counters / metrics. --- + let mut pipeline_ms: Vec = Vec::with_capacity(sim.emissions.len()); + let mut alert_ms: Vec = Vec::new(); + let mut attacks_injected = 0u64; + let mut attacks_rejected = 0u64; + let mut attacks_accepted = 0u64; + let mut accepted = 0u64; + let mut usable = 0u64; + let mut worldgraph_mapped = 0u64; + let mut sensorthings_projected = 0u64; + let mut anomaly_alerts = 0u64; + let mut restored_after_outage = 0u64; + let mut restore_duplicates = 0u64; + let mut buffered_during_outage = 0u64; + let mut accepted_after_revocation = 0u64; + let mut revocation_done = false; + let mut was_offline = false; + let mut rf_day_done: Option = None; + let mut first_quarantine_ns: Option = None; + let mut water_sensor_key: Option = None; + let mut event_seq = 0u64; + + let revoked_node_id = NODE_ID_BASE + u64::from(config.compromised_node); + + for em in &sim.emissions { + // Day-boundary duties: revocation, RF context. + if !revocation_done && em.day >= config.revoke_day { + ingest.registry_mut().revoke(revoked_node_id); + let rev_event = + biome.revoke_device(revoked_node_id, em.received_ns, "compromised device key"); + debug_assert!(verify_event(&rev_event)); + bus.publish_event(rev_event) + .expect("signed revocation event publishes"); + revocation_done = true; + } + if rf_day_done != Some(em.day) && em.kind == EmissionKind::Genuine { + rf_day_done = Some(em.day); + // Daily RuView RF context: high motion on the anomaly day + // (flood boundary shift) and on day 3 (deliberate disagreement — + // RF alone must never win; it records a contradiction instead). + let motion = if em.day == config.anomaly_day || em.day == 3 { + 0.9 + } else { + 0.2 + }; + let rf = rf_context_for_day(em.day, motion); + let change_expected = em.day == config.anomaly_day; + let plaus = + assess_plausibility(change_expected, rf.timestamp_ns, &rf, S_PER_DAY * NS_PER_S); + // Attach the context to a representative water sensor (first + // days have none registered yet — the context is then skipped). + if let Some(key) = &water_sensor_key { + if plaus != Plausibility::NoContext { + let _ = fuse_rf_context(&mut graph, key, &rf, plaus); + } + } + } + + // Reconnect transition: uplink restored ⇒ restore buffered data. + if was_offline && !em.uplink_down { + was_offline = false; + // Prove restart-safety: serialize + restore the buffer state, + // drain the restored copy, and re-verify every stored envelope + // before it may enter the biome. `reverify_stored` runs the full + // registry + revocation + key-match + signature + payload checks + // WITHOUT touching the anti-replay window (those sequences were + // consumed on the live path); duplicate suppression on this path + // is the biome's global dedup index. + let snapshot = buffer.to_json().expect("buffer serializes"); + let mut restored = OutageBuffer::from_json(&snapshot).expect("buffer restores"); + for (envelope, recv_ns) in restored.drain() { + let sealed = ingest + .reverify_stored(&envelope, recv_ns) + .expect("buffered envelope re-verifies on restore"); + match biome.accept(sealed) { + AcceptOutcome::Accepted => restored_after_outage += 1, + AcceptOutcome::Duplicate => restore_duplicates += 1, + AcceptOutcome::Revoked => {} + } + } + // Second restore of the SAME snapshot: every sample must dedup. + let mut again = OutageBuffer::from_json(&snapshot).expect("buffer restores"); + for (envelope, recv_ns) in again.drain() { + let sealed = ingest + .reverify_stored(&envelope, recv_ns) + .expect("buffered envelope re-verifies on restore"); + match biome.accept(sealed) { + AcceptOutcome::Accepted => restore_duplicates += 1, // duplicates admitted = failure + AcceptOutcome::Duplicate | AcceptOutcome::Revoked => {} + } + } + buffer = OutageBuffer::new(); + } + if em.uplink_down { + was_offline = true; + } + + let is_attack = em.kind != EmissionKind::Genuine; + if is_attack { + attacks_injected += 1; + } + + let t0 = Instant::now(); + match ingest.ingest(&em.envelope, em.received_ns) { + Err(_) => { + if is_attack { + attacks_rejected += 1; + } + pipeline_ms.push(t0.elapsed().as_secs_f64() * 1e3); + } + Ok(mut sample) => { + if is_attack { + // A tampered/replayed/forged/post-revocation emission got + // through — acceptance criterion 4 fails. + attacks_accepted += 1; + pipeline_ms.push(t0.elapsed().as_secs_f64() * 1e3); + continue; + } + + // Calibration (lineage-checked affine + stated uncertainty). + // `modify` keeps the ingest seal across the transformation: + // the change is committed only if the result still validates, + // so calibration can never smuggle an invalid sample through. + let outcome = sample + .modify(|s| calibrator.apply(&store, s, em.received_ns)) + .expect("calibrated sample stays valid"); + let calibrated = matches!(outcome, Ok(CalibrationOutcome::Applied { .. })); + + // Drift monitoring vs the modality anchor expectation, + // normalized so one threshold spans all modalities. + // Samples that fire the local anomaly rule are EXCLUDED from + // drift accounting: drift is a slow, single-sensor + // phenomenon; an environmental event (many sensors deviating + // together) must not quarantine healthy sensors. + // Read access to the sealed sample; the seal never leaves the + // wrapper, so nothing downstream can fabricate one. + let view = sample.sample(); + let is_local_anomaly = + view.modality == SensorModality::WaterQuality && view.value > FLOOD_THRESHOLD_M; + if !is_local_anomaly { + let t_s = (view.measured_ns - EPOCH_START_NS) / NS_PER_S; + let expected = anchor_expectation(view.modality, em.node_index, t_s); + let residual = (view.value - expected) / (4.0 * noise_sd(view.modality)); + drift.observe(view.node_id, residual); + } + let quarantined = drift.is_quarantined(view.node_id); + if quarantined && first_quarantine_ns.is_none() { + first_quarantine_ns = Some(em.received_ns); + } + + // WorldGraph registration (criterion 6). + let key = graph.register_observation(view); + let _ = graph.link_within_region(&key, "region/synthetic-watershed"); + if water_sensor_key.is_none() && view.modality == SensorModality::WaterQuality { + water_sensor_key = Some(key.clone()); + } + worldgraph_mapped += 1; + + // SensorThings projection (criterion 6). + let bundle = project_sample(view); + debug_assert!(bundle.observation.result.is_finite()); + sensorthings_projected += 1; + + // Local flood alert rule (< 500 ms target, criterion 5). + if is_local_anomaly { + event_seq += 1; + let mut alert = EnvironmentalEvent { + spec_version: SPEC_VERSION.into(), + event_id: format!("evt-flood-{event_seq:05}"), + biome_id: biome.config().biome_id.clone(), + kind: EventKind::FloodRisk, + severity: Severity::Warning, + modality: SensorModality::WaterQuality, + geo: view.geo, + window_start_ns: view.measured_ns, + window_end_ns: view.measured_ns, + detected_ns: em.received_ns, + evidence: vec![EvidenceRef { + node_id: view.node_id, + sequence: view.sequence, + }], + confidence: 0.92, + // Bind the cited observation's CONTENT, not just its + // (node, sequence) identity — ADR-266 §3.1. + evidence_digest: Some(rucelium_core::evidence_digest(&[sample.sample()])), + message: format!("water level {:.2} m above flood threshold", view.value), + signature_hex: None, + signer_pubkey_hex: None, + }; + biome.sign_event(&mut alert); + if !em.uplink_down { + bus.publish_event(alert).expect("signed alert publishes"); + } + alert_ms.push(t0.elapsed().as_secs_f64() * 1e3); + if em.true_anomaly { + anomaly_alerts += 1; + } + } + + // Usability metric (criterion 8): calibrated, healthy, high + // quality. Quarantined-node data stays stored but flagged. + if calibrated && !quarantined && view.quality >= 0.9 { + usable += 1; + } + + // Biome admission: live when online, store-and-forward when + // the uplink is down. The buffer stores the ORIGINAL signed + // envelope (dedup key `(node, sequence)` is read structurally) + // so restore can re-verify it cryptographically. + let admitted = if em.uplink_down { + let pushed = buffer + .push(&em.envelope, em.received_ns) + .expect("genuine envelope decodes structurally"); + buffered_during_outage += u64::from(pushed); + pushed + } else { + matches!(biome.accept(sample), AcceptOutcome::Accepted) + }; + if admitted { + accepted += 1; + if revocation_done { + accepted_after_revocation += 1; + } + } + pipeline_ms.push(t0.elapsed().as_secs_f64() * 1e3); + } + } + } + + // End-of-run: publish the signed regional summary for the final week. + let sum_start = EPOCH_START_NS + u64::from(config.days - 7) * S_PER_DAY * NS_PER_S; + let sum_end = EPOCH_START_NS + u64::from(config.days) * S_PER_DAY * NS_PER_S; + let summary = biome.summarize(sum_start, sum_end); + debug_assert!(verify_summary(&summary)); + bus.publish(summary).expect("signed summary publishes"); + + // Governed control path (§9): reacting to the quarantine + flood. + let control_now = first_quarantine_ns.unwrap_or(sum_end); + let (commands_executed, proposals_rejected) = run_control_path( + &biome.config().biome_id.clone(), + NODE_ID_BASE + u64::from(config.drift_node), + control_now, + ); + + pipeline_ms.sort_by(f64::total_cmp); + alert_ms.sort_by(f64::total_cmp); + + let total_accepted = accepted.max(1); + let usable_calibrated_pct = usable as f64 / total_accepted as f64 * 100.0; + let worldgraph_coverage_pct = worldgraph_mapped as f64 / total_accepted as f64 * 100.0; + let sensorthings_coverage_pct = sensorthings_projected as f64 / total_accepted as f64 * 100.0; + let p95_alert = percentile(&alert_ms, 0.95); + let quarantined_nodes = drift.quarantined().len() as u64; + + let criteria = vec![ + Criterion { + number: 1, + name: "operates 30 simulated days".into(), + value: format!("{} days", config.days), + target: ">= 30".into(), + pass: config.days >= 30, + }, + Criterion { + number: 2, + name: "survives 7 consecutive offline days".into(), + value: format!( + "{} days, {} buffered", + config.offline_days, buffered_during_outage + ), + target: "7 offline".into(), + pass: config.offline_days >= 7 && buffered_during_outage > 0, + }, + Criterion { + number: 3, + name: "restores without duplicates".into(), + value: format!("{restored_after_outage} restored / {restore_duplicates} dup"), + target: "0 duplicates".into(), + pass: restored_after_outage > 0 && restore_duplicates == 0, + }, + Criterion { + number: 4, + name: "rejects modified/replayed packets".into(), + value: format!("{attacks_rejected}/{attacks_injected} rejected"), + target: "100 %".into(), + pass: attacks_accepted == 0 && attacks_rejected == attacks_injected, + }, + Criterion { + number: 5, + name: "local alerts within 500 ms".into(), + value: format!("p95 {p95_alert:.3} ms, {anomaly_alerts} alerts"), + target: "< 500 ms".into(), + pass: anomaly_alerts > 0 && p95_alert < 500.0, + }, + Criterion { + number: 6, + name: "maps to SensorThings + WorldGraph".into(), + value: format!("{worldgraph_coverage_pct:.1} % / {sensorthings_coverage_pct:.1} %"), + target: "100 %".into(), + pass: (worldgraph_coverage_pct - 100.0).abs() < 1e-9 + && (sensorthings_coverage_pct - 100.0).abs() < 1e-9, + }, + Criterion { + number: 7, + name: "revokes device without interruption".into(), + value: format!("{accepted_after_revocation} accepted post-revocation"), + target: "> 0 & 0 from revoked".into(), + pass: revocation_done + && biome.is_revoked(revoked_node_id) + && accepted_after_revocation > 0, + }, + Criterion { + number: 8, + name: ">= 95 % usable calibrated obs".into(), + value: format!("{usable_calibrated_pct:.2} %"), + target: ">= 95 %".into(), + pass: usable_calibrated_pct >= 95.0, + }, + ]; + + BiomeReport { + spec_version: SPEC_VERSION.into(), + synthetic: true, + seed: config.seed, + nodes: config.nodes, + days: config.days, + offline_days: config.offline_days, + emissions_total: sim.emissions.len(), + accepted, + attacks_injected, + attacks_rejected, + restored_after_outage, + restore_duplicates, + usable_calibrated_pct, + worldgraph_coverage_pct, + sensorthings_coverage_pct, + p50_pipeline_ms: percentile(&pipeline_ms, 0.50), + p95_pipeline_ms: percentile(&pipeline_ms, 0.95), + p95_alert_ms: p95_alert, + anomaly_alerts, + quarantined_nodes, + accepted_after_revocation, + contradictions: graph.contradiction_count(), + commands_executed, + proposals_rejected, + criteria, + } +} diff --git a/crates/rucelium-bench/src/sim.rs b/crates/rucelium-bench/src/sim.rs new file mode 100644 index 0000000..e12bd13 --- /dev/null +++ b/crates/rucelium-bench/src/sim.rs @@ -0,0 +1,511 @@ +//! Deterministic 64-node biome simulator (ADR-264 §14, **SYNTHETIC**). +//! +//! Generates the chronological emission stream of a synthetic biome: genuine +//! signed spore-node envelopes (diurnal signal models per modality), plus the +//! adversarial stream the acceptance test requires — tampered envelopes, +//! exact replays, and forged-key packets — and the operational scenario: +//! a drifting sensor, a compromised (later revoked) device, a 7-day offline +//! window, and a flood-style anomaly. +//! +//! Same seed ⇒ byte-identical emission stream. No wall clocks, no OS entropy. + +use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SCHEMA_V1}; +use rucelium_core::{GeoPoint, SensorModality}; + +/// SplitMix64 — tiny deterministic PRNG (same generator the RuField synthetic +/// simulator uses). +#[derive(Debug, Clone)] +pub struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + /// Seed the generator. + #[must_use] + pub fn new(seed: u64) -> Self { + SplitMix64 { state: seed } + } + + /// Next raw `u64`. + pub fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform `f64` in `[0, 1)`. + pub fn next_f64(&mut self) -> f64 { + let bits = self.next_u64() >> 11; // 53 bits + (bits as f64) / (1u64 << 53) as f64 + } + + /// Uniform integer in `[0, n)` (n > 0). + pub fn below(&mut self, n: u64) -> u64 { + self.next_u64() % n + } + + /// Approximately normal noise (sum of 4 uniforms, centred), scaled by `sd`. + pub fn noise(&mut self, sd: f64) -> f64 { + let s = self.next_f64() + self.next_f64() + self.next_f64() + self.next_f64(); + (s - 2.0) * sd + } +} + +/// Nanoseconds per second / day. +pub const NS_PER_S: u64 = 1_000_000_000; +/// Seconds per simulated day. +pub const S_PER_DAY: u64 = 86_400; + +/// Base device id for simulated spore nodes (`"MY"` prefix in the high bytes). +pub const NODE_ID_BASE: u64 = 0x4D59_0000_0000_0000; + +/// Simulation configuration (ADR-264 §14 acceptance scenario). +#[derive(Debug, Clone, PartialEq)] +pub struct SimConfig { + /// PRNG seed (determinism anchor). + pub seed: u64, + /// Number of spore nodes (acceptance: 64). + pub nodes: u32, + /// Simulated duration in days (acceptance: 30). + pub days: u32, + /// Per-node reporting interval, seconds (LoRaWAN-class cadence). + pub sample_interval_s: u32, + /// First day (0-based) of the uplink outage. + pub offline_start_day: u32, + /// Consecutive offline days (acceptance: 7). + pub offline_days: u32, + /// Node index that drifts. + pub drift_node: u32, + /// Day drift begins. + pub drift_start_day: u32, + /// Drift added per day, in the node's unit. + pub drift_per_day: f64, + /// Node index that is compromised and later revoked. + pub compromised_node: u32, + /// Day the compromised node is revoked. + pub revoke_day: u32, + /// Day of the water-surge anomaly. + pub anomaly_day: u32, + /// Tampered-envelope attacks injected per day. + pub tamper_per_day: u32, + /// Exact-replay attacks injected per day. + pub replay_per_day: u32, + /// Forged-key attacks injected per day. + pub forge_per_day: u32, +} + +/// Default seed (matches the repo convention of year-seeds). +pub const DEFAULT_SEED: u64 = 2026; + +impl Default for SimConfig { + fn default() -> Self { + SimConfig { + seed: DEFAULT_SEED, + nodes: 64, + days: 30, + sample_interval_s: 1800, // 30-minute cadence + offline_start_day: 10, + offline_days: 7, + drift_node: 2, // SoilMoisture — low-noise, so drift is attributable + drift_start_day: 5, + drift_per_day: 0.9, + compromised_node: 13, + revoke_day: 20, + anomaly_day: 25, + tamper_per_day: 4, + replay_per_day: 4, + forge_per_day: 2, + } + } +} + +/// Simulated epoch start (fixed, arbitrary): 2025-06-15T00:00:00Z-ish. +pub const EPOCH_START_NS: u64 = 1_750_000_000 * NS_PER_S; + +/// Provisioning seed for genuine device keys. +pub const PROVISION_SEED: &[u8; 32] = b"rucelium-biome-provision-v0.1!!!"; +/// Seed the ATTACKER uses for forged-key packets (never registered). +pub const ATTACKER_SEED: &[u8; 32] = b"attacker-controlled-forged-key!!"; + +/// What a single emission on the "radio" is, from the gateway's perspective +/// unknown — the sim keeps ground truth for scoring. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmissionKind { + /// A genuine, correctly signed sample from a registered node. + Genuine, + /// A genuine envelope with one byte flipped in transit (tamper). + Tampered, + /// An exact byte-for-byte replay of a previously sent genuine envelope. + Replayed, + /// A well-formed envelope signed by an attacker key claiming a + /// registered node id. + ForgedKey, + /// A genuine envelope from the compromised node sent AFTER its key was + /// revoked (must be rejected by the registry). + PostRevocation, +} + +/// One on-air emission, with sim ground truth attached for scoring only — +/// the gateway pipeline never reads `kind` / `true_anomaly`. +#[derive(Debug, Clone)] +pub struct Emission { + /// CBOR-encoded `SignedEnvRecordV1` bytes as they arrive at the gateway. + pub envelope: Vec, + /// Ground truth: what this emission actually is. + pub kind: EmissionKind, + /// Ground truth: emitting node index (attacker emissions reference the + /// node they imitate). + pub node_index: u32, + /// Gateway reception time, ns. + pub received_ns: u64, + /// Simulated day (0-based). + pub day: u32, + /// Ground truth: this sample carries the anomaly surge. + pub true_anomaly: bool, + /// Whether the uplink to the biome/federation is down when this arrives. + pub uplink_down: bool, +} + +/// Per-node static description, shared with the gateway-side registry setup. +#[derive(Debug, Clone)] +pub struct NodeSpec { + /// Device id. + pub node_id: u64, + /// Modality (physical modalities only — RF context is gateway-side). + pub modality: SensorModality, + /// Deployed location. + pub geo: GeoPoint, + /// Public key registered at provisioning. + pub pubkey: [u8; 32], + /// `sha256:` firmware hash registered at provisioning. + pub firmware_hash: String, +} + +/// The nine physical modalities, round-robin across nodes (WifiCsi is the +/// gateway-side RF context modality, not a spore node). +const PHYSICAL_MODALITIES: [SensorModality; 9] = [ + SensorModality::Weather, + SensorModality::AirQuality, + SensorModality::SoilMoisture, + SensorModality::WaterQuality, + SensorModality::Acoustic, + SensorModality::Bioelectric, + SensorModality::Radiation, + SensorModality::Optical, + SensorModality::Chemical, +]; + +/// Signal model: baseline, diurnal amplitude, noise sd per modality. +fn signal_model(m: SensorModality) -> (f64, f64, f64) { + match m { + SensorModality::Weather => (15.0, 8.0, 0.4), + SensorModality::AirQuality => (12.0, 5.0, 0.8), + SensorModality::SoilMoisture => (27.0, 2.0, 0.3), + SensorModality::WaterQuality => (1.2, 0.15, 0.02), + SensorModality::Acoustic => (0.5, 0.3, 0.05), + SensorModality::Bioelectric => (40.0, 10.0, 1.5), + SensorModality::Radiation => (0.10, 0.02, 0.005), + SensorModality::Optical => (500.0, 480.0, 20.0), + SensorModality::Chemical => (5.0, 1.0, 0.15), + SensorModality::WifiCsi => (0.0, 0.0, 0.0), + } +} + +/// The full simulated biome: node specs + the chronological emission stream. +pub struct BiomeSim { + /// Node descriptions (registry provisioning input). + pub nodes: Vec, + /// Chronological emissions. + pub emissions: Vec, + /// Ground truth: number of genuine anomaly samples emitted. + pub true_anomaly_samples: u32, + /// Config used. + pub config: SimConfig, +} + +/// Noise standard deviation of a modality's signal model — the scale the +/// gateway uses to normalize anchor residuals before drift detection (so one +/// threshold works across modalities with very different units). +#[must_use] +pub fn noise_sd(modality: SensorModality) -> f64 { + signal_model(modality).2 +} + +/// Expected (drift-free) value of a node's signal at time `t_s` — what a +/// co-located reference anchor would read. Used by the gateway's drift +/// detector as the anchor residual baseline. +#[must_use] +pub fn anchor_expectation(modality: SensorModality, node_index: u32, t_s: u64) -> f64 { + let (base, amp, _sd) = signal_model(modality); + let phase = f64::from(node_index % 9) * 0.7; + let frac = (t_s % S_PER_DAY) as f64 / S_PER_DAY as f64; + base + amp * (core::f64::consts::TAU * frac + phase).sin() +} + +impl BiomeSim { + /// Build the deterministic biome simulation. + #[must_use] + pub fn generate(config: SimConfig) -> Self { + let mut rng = SplitMix64::new(config.seed); + let mut nodes = Vec::with_capacity(config.nodes as usize); + let mut signers = Vec::with_capacity(config.nodes as usize); + + // Provision nodes on a grid around a fixed centre (a synthetic + // watershed at ~51.5N, 0.0E), one modality per node round-robin. + for i in 0..config.nodes { + let node_id = NODE_ID_BASE + u64::from(i); + let modality = PHYSICAL_MODALITIES[(i as usize) % PHYSICAL_MODALITIES.len()]; + let signer = NodeSigner::for_node(PROVISION_SEED, node_id); + let geo = GeoPoint { + latitude_e7: 515_000_000 + i32::try_from(i / 8).unwrap_or(0) * 9_000, + longitude_e7: -1_000_000 + i32::try_from(i % 8).unwrap_or(0) * 14_000, + altitude_mm: 25_000, + }; + nodes.push(NodeSpec { + node_id, + modality, + geo, + pubkey: signer.public_key(), + firmware_hash: format!("sha256:spore-fw-1.4.2-{}", modality.as_str()), + }); + signers.push(signer); + } + let attacker = NodeSigner::from_seed(ATTACKER_SEED); + + let ticks_per_day = (S_PER_DAY / u64::from(config.sample_interval_s)) as u32; + let offline_end_day = config.offline_start_day + config.offline_days; + let mut sequences = vec![0u32; config.nodes as usize]; + let mut sent_genuine: Vec> = Vec::new(); + let mut emissions = Vec::new(); + let mut true_anomaly_samples = 0u32; + + for day in 0..config.days { + let uplink_down = day >= config.offline_start_day && day < offline_end_day; + for tick in 0..ticks_per_day { + let t_s = u64::from(day) * S_PER_DAY + + u64::from(tick) * u64::from(config.sample_interval_s); + for (idx, spec) in nodes.iter().enumerate() { + let i = idx as u32; + let (base, amp, sd) = signal_model(spec.modality); + let phase = f64::from(i % 9) * 0.7; + let frac = (t_s % S_PER_DAY) as f64 / S_PER_DAY as f64; + let mut value = + base + amp * (core::f64::consts::TAU * frac + phase).sin() + rng.noise(sd); + + // Drift injection: a slow additive bias on one node. + if i == config.drift_node && day >= config.drift_start_day { + let drift_days = f64::from(day - config.drift_start_day) + + f64::from(tick) / f64::from(ticks_per_day); + value += config.drift_per_day * drift_days; + } + + // Anomaly: water-surge on all WaterQuality nodes during + // the anomaly day's second half (ramp to +2.0 m). + let mut is_anomaly = false; + if spec.modality == SensorModality::WaterQuality + && day == config.anomaly_day + && tick >= ticks_per_day / 2 + { + let ramp = + f64::from(tick - ticks_per_day / 2) / f64::from(ticks_per_day / 2); + value += 2.0 * ramp.min(1.0) + 0.5; + is_anomaly = true; + } + + let seq = sequences[idx]; + sequences[idx] = seq.wrapping_add(1); + let measured_ns = EPOCH_START_NS + t_s * NS_PER_S; + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: spec.modality.code(), + flags: if uplink_down { + RV_ENV_FLAG_RETRANSMIT + } else { + 0 + }, + node_id: spec.node_id, + timestamp_ns: measured_ns, + sequence: seq, + latitude_e7: spec.geo.latitude_e7, + longitude_e7: spec.geo.longitude_e7, + altitude_mm: spec.geo.altitude_mm, + value_q16: (value * 65_536.0) + .clamp(f64::from(i32::MIN), f64::from(i32::MAX)) + as i32, + quality_q15: 0x7C00 + (rng.below(0x400) as u16), // 0.97..1.0 + battery_mv: 3_650_u16.saturating_sub((t_s / 40_000) as u16), + calibration_id: 1000 + i, + }; + let envelope = signers[idx].sign_sample(&wire).encode(); + let received_ns = measured_ns + 120_000_000; // 120 ms uplink + let kind = if i == config.compromised_node && day >= config.revoke_day { + EmissionKind::PostRevocation + } else { + EmissionKind::Genuine + }; + if kind == EmissionKind::Genuine { + if is_anomaly { + true_anomaly_samples += 1; + } + sent_genuine.push(envelope.clone()); + } + emissions.push(Emission { + envelope, + kind, + node_index: i, + received_ns, + day, + true_anomaly: is_anomaly, + uplink_down, + }); + } + } + + // Daily adversarial stream (arrives at end of day; ordering + // within a day does not matter to the checks). + let day_end_ns = EPOCH_START_NS + (u64::from(day) + 1) * S_PER_DAY * NS_PER_S; + for a in 0..config.tamper_per_day { + if sent_genuine.is_empty() { + break; + } + let pick = rng.below(sent_genuine.len() as u64) as usize; + let mut env = sent_genuine[pick].clone(); + let flip = rng.below(env.len() as u64) as usize; + env[flip] ^= 1 << (rng.below(8) as u8); + emissions.push(Emission { + envelope: env, + kind: EmissionKind::Tampered, + node_index: config.nodes + a, + received_ns: day_end_ns + u64::from(a), + day, + true_anomaly: false, + uplink_down, + }); + } + for a in 0..config.replay_per_day { + if sent_genuine.is_empty() { + break; + } + let pick = rng.below(sent_genuine.len() as u64) as usize; + emissions.push(Emission { + envelope: sent_genuine[pick].clone(), + kind: EmissionKind::Replayed, + node_index: config.nodes + a, + received_ns: day_end_ns + 1_000 + u64::from(a), + day, + true_anomaly: false, + uplink_down, + }); + } + for a in 0..config.forge_per_day { + // Attacker forges a plausible sample for a real node id with + // their own (unregistered) key. + let target = rng.below(u64::from(config.nodes)) as u32; + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: nodes[target as usize].modality.code(), + flags: 0, + node_id: nodes[target as usize].node_id, + timestamp_ns: day_end_ns, + sequence: sequences[target as usize] + 100 + a, // fresh seq + latitude_e7: nodes[target as usize].geo.latitude_e7, + longitude_e7: nodes[target as usize].geo.longitude_e7, + altitude_mm: nodes[target as usize].geo.altitude_mm, + value_q16: 999 * 65_536, // absurd injected value + quality_q15: 0x8000, + battery_mv: 3_700, + calibration_id: 1000 + target, + }; + emissions.push(Emission { + envelope: attacker.sign_sample(&wire).encode(), + kind: EmissionKind::ForgedKey, + node_index: target, + received_ns: day_end_ns + 2_000 + u64::from(a), + day, + true_anomaly: false, + uplink_down, + }); + } + } + + BiomeSim { + nodes, + emissions, + true_anomaly_samples, + config, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn small() -> SimConfig { + SimConfig { + nodes: 8, + days: 3, + sample_interval_s: 7200, + offline_start_day: 1, + offline_days: 1, + drift_node: 1, + drift_start_day: 1, + compromised_node: 2, + revoke_day: 2, + anomaly_day: 2, + ..SimConfig::default() + } + } + + #[test] + fn same_seed_identical_stream() { + let a = BiomeSim::generate(small()); + let b = BiomeSim::generate(small()); + assert_eq!(a.emissions.len(), b.emissions.len()); + for (x, y) in a.emissions.iter().zip(&b.emissions) { + assert_eq!(x.envelope, y.envelope); + assert_eq!(x.kind, y.kind); + assert_eq!(x.received_ns, y.received_ns); + } + } + + #[test] + fn stream_contains_all_emission_kinds() { + let sim = BiomeSim::generate(small()); + for kind in [ + EmissionKind::Genuine, + EmissionKind::Tampered, + EmissionKind::Replayed, + EmissionKind::ForgedKey, + EmissionKind::PostRevocation, + ] { + assert!( + sim.emissions.iter().any(|e| e.kind == kind), + "missing {kind:?}" + ); + } + assert!(sim.true_anomaly_samples > 0); + } + + #[test] + fn emissions_are_chronological_per_day() { + let sim = BiomeSim::generate(small()); + let mut last_day = 0; + for e in &sim.emissions { + assert!(e.day >= last_day); + last_day = e.day; + } + } + + #[test] + fn offline_window_flagged() { + let sim = BiomeSim::generate(small()); + assert!(sim.emissions.iter().any(|e| e.uplink_down)); + assert!(sim + .emissions + .iter() + .all(|e| e.uplink_down == (e.day >= 1 && e.day < 2))); + } +} diff --git a/crates/rucelium-bench/tests/acceptance.rs b/crates/rucelium-bench/tests/acceptance.rs new file mode 100644 index 0000000..a43403b --- /dev/null +++ b/crates/rucelium-bench/tests/acceptance.rs @@ -0,0 +1,96 @@ +//! ADR-264 §14 acceptance test: the 64-node biome pilot passes when all +//! eight criteria hold, and the report is deterministic across two runs at +//! the same seed. All numbers are **SYNTHETIC** (deterministic simulator). + +use rucelium_bench::{run, SimConfig}; + +/// The full §14 acceptance run: 64 nodes, 30 days, 7 offline days, attacks, +/// drift, revocation — every criterion must pass. +#[test] +fn adr_264_section_14_acceptance() { + let report = run(SimConfig::default()); + + assert!(report.synthetic, "the report must be labelled SYNTHETIC"); + assert_eq!(report.nodes, 64); + assert_eq!(report.days, 30); + assert_eq!(report.offline_days, 7); + assert_eq!(report.criteria.len(), 8, "all eight §14 criteria evaluated"); + + for c in &report.criteria { + assert!( + c.pass, + "criterion {} ({}) failed: value={} target={}", + c.number, c.name, c.value, c.target + ); + } + assert!(report.accepted_all()); + + // Structural cross-checks beyond the pass/fail flags. + assert_eq!( + report.attacks_rejected, report.attacks_injected, + "every tampered/replayed/forged/post-revocation packet rejected" + ); + assert!( + report.attacks_injected > 0, + "attacks were actually injected" + ); + assert_eq!( + report.restore_duplicates, 0, + "outage restore is duplicate-free" + ); + assert!(report.restored_after_outage > 0, "outage data was restored"); + assert!(report.usable_calibrated_pct >= 95.0); + assert_eq!(report.quarantined_nodes, 1, "exactly the drifting node"); + assert!( + report.contradictions >= 1, + "RF disagreement recorded, not believed" + ); + assert_eq!( + report.commands_executed, 2, + "governed control path executed" + ); + assert_eq!( + report.proposals_rejected, 1, + "unauthorized proposal stopped" + ); + assert!(report.anomaly_alerts > 0); + assert!(report.p95_alert_ms < 500.0); +} + +/// Determinism: two runs at the same seed produce identical reports +/// (wall-clock latency fields excluded). Uses a reduced biome so the double +/// run stays fast; determinism is a property of the pipeline, not the scale. +#[test] +fn same_seed_same_report() { + let cfg = SimConfig { + nodes: 16, + days: 10, + sample_interval_s: 3600, + offline_start_day: 3, + offline_days: 2, + drift_node: 2, + drift_start_day: 1, + compromised_node: 5, + revoke_day: 6, + anomaly_day: 8, + ..SimConfig::default() + }; + let a = run(cfg.clone()); + let b = run(cfg); + assert_eq!( + a.deterministic_fingerprint(), + b.deterministic_fingerprint(), + "same seed must yield an identical deterministic report" + ); +} + +/// A different seed changes the data but not the verdict: the §14 criteria +/// must be seed-robust. +#[test] +fn different_seed_still_accepts() { + let report = run(SimConfig { + seed: 7, + ..SimConfig::default() + }); + assert!(report.accepted_all(), "acceptance must not be seed-tuned"); +} diff --git a/crates/rucelium-calibration/Cargo.toml b/crates/rucelium-calibration/Cargo.toml new file mode 100644 index 0000000..ee9cb61 --- /dev/null +++ b/crates/rucelium-calibration/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rucelium-calibration" +version.workspace = true +edition.workspace = true +description = "RuCelium calibration lineage, drift detection, and sensor quarantine — never silent correction (ADR-264 §12)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "calibration", "drift", "quality"] +categories = ["science"] + +[dependencies] +rucelium-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-calibration/src/authority.rs b/crates/rucelium-calibration/src/authority.rs new file mode 100644 index 0000000..3c1d20e --- /dev/null +++ b/crates/rucelium-calibration/src/authority.rs @@ -0,0 +1,389 @@ +//! Calibration authorities: ed25519-signed calibration records and the +//! registry of keys trusted to sign them (ADR-264 §12 items 1–3). +//! +//! Lineage structure alone is not enough — a record's *content* must be +//! attested by a key the operator actually trusts, otherwise anyone who can +//! insert a record can declare an "anchor" simply by writing the right method +//! string. This module provides: +//! +//! - [`CalibrationSigner`] — deterministic ed25519 signing of +//! [`CalibrationRecord`]s from a 32-byte seed (mirrors +//! `rufield-provenance::Signer`; no RNG anywhere). +//! - [`verify_record_signature`] — detached-signature verification over the +//! record's canonical bytes. +//! - [`CalibrationAuthority`] / [`AuthorityRegistry`] — which public keys are +//! trusted to sign calibrations, optionally scoped per sensor modality. +//! +//! The canonical bytes that get signed are the `serde_json` encoding of the +//! record with its own `signature_hex` / `signer_pubkey_hex` fields cleared, +//! so the signature covers every content field (coefficients, expiry, method, +//! lineage pointers, …) but never itself. + +use crate::error::CalibrationError; +use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey}; +use rucelium_core::{CalibrationRecord, SensorModality}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +/// Compute a `sha256:` digest over calibration source material, suitable +/// for a record's `data_hash` field (same format as +/// `rufield-provenance::sha256_hex`). +#[must_use] +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + let digest = h.finalize(); + let mut s = String::from("sha256:"); + for b in digest { + s.push_str(&format!("{b:02x}")); + } + s +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +fn hex_decode(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} + +/// Canonical bytes that get signed for a calibration record: the record with +/// its own signature fields cleared, serialized as JSON. The signature +/// therefore covers all content fields but not itself. +fn canonical_record_bytes(record: &CalibrationRecord) -> Result, CalibrationError> { + let mut r = record.clone(); + r.signature_hex = None; + r.signer_pubkey_hex = None; + serde_json::to_vec(&r).map_err(|e| { + CalibrationError::Core(rucelium_core::EnvError::Invalid(format!( + "calibration {} could not be canonicalized: {e}", + record.calibration_id + ))) + }) +} + +/// A calibration authority: a named ed25519 public key trusted to sign +/// calibration records. +/// +/// `modalities` scopes the trust: an **empty** set means the authority is +/// trusted for **all** modalities; a non-empty set restricts it to exactly +/// those modalities (e.g. a weather-station operator that must not attest +/// soil-moisture calibrations). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CalibrationAuthority { + /// Human-readable authority name (operator, lab, vendor). + pub name: String, + /// Hex-encoded ed25519 public key. + pub pubkey_hex: String, + /// Modalities this authority may sign for; empty = all modalities. + pub modalities: BTreeSet, +} + +/// Registry of [`CalibrationAuthority`]s, keyed by public key. +/// +/// Adding an authority with an already-registered `pubkey_hex` replaces the +/// previous entry (last write wins). +#[derive(Debug, Clone, Default)] +pub struct AuthorityRegistry { + authorities: std::collections::BTreeMap, +} + +impl AuthorityRegistry { + /// Create an empty registry (trusts no one). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Register an authority. A repeated `pubkey_hex` replaces the earlier + /// entry. + pub fn add(&mut self, authority: CalibrationAuthority) { + self.authorities + .insert(authority.pubkey_hex.clone(), authority); + } + + /// Whether `pubkey_hex` is trusted to sign calibrations for `modality`. + /// An authority with an empty modality set is trusted for all modalities. + #[must_use] + pub fn trusted_for(&self, pubkey_hex: &str, modality: SensorModality) -> bool { + self.authorities + .get(pubkey_hex) + .is_some_and(|a| a.modalities.is_empty() || a.modalities.contains(&modality)) + } + + /// Whether the registry holds no authorities. + #[must_use] + pub fn is_empty(&self) -> bool { + self.authorities.is_empty() + } +} + +/// A deterministic ed25519 signer for calibration records, derived from a +/// 32-byte seed (mirrors `rufield-provenance::Signer`). Same seed ⇒ same key +/// ⇒ same signatures — no RNG anywhere. +pub struct CalibrationSigner { + key: SigningKey, +} + +impl CalibrationSigner { + /// Construct a signer from a fixed 32-byte seed. + #[must_use] + pub fn from_seed(seed: &[u8; 32]) -> Self { + CalibrationSigner { + key: SigningKey::from_bytes(seed), + } + } + + /// Hex-encoded public key. + #[must_use] + pub fn public_hex(&self) -> String { + hex_encode(self.key.verifying_key().as_bytes()) + } + + /// Sign a record in place: clear its signature fields, sign the canonical + /// bytes, then populate `signature_hex` and `signer_pubkey_hex`. + pub fn sign_record(&self, record: &mut CalibrationRecord) -> Result<(), CalibrationError> { + record.signature_hex = None; + record.signer_pubkey_hex = None; + let bytes = canonical_record_bytes(record)?; + let sig: Signature = self.key.sign(&bytes); + record.signature_hex = Some(hex_encode(&sig.to_bytes())); + record.signer_pubkey_hex = Some(self.public_hex()); + Ok(()) + } +} + +/// Verify the ed25519 signature carried on a calibration record. +/// +/// Fails with [`CalibrationError::MissingSignature`] when either +/// `signature_hex` or `signer_pubkey_hex` is absent, and +/// [`CalibrationError::BadSignature`] when the encoding is malformed or the +/// signature does not verify over the record's canonical bytes. +pub fn verify_record_signature(record: &CalibrationRecord) -> Result<(), CalibrationError> { + let id = record.calibration_id; + let sig_hex = record + .signature_hex + .as_ref() + .ok_or(CalibrationError::MissingSignature(id))?; + let pk_hex = record + .signer_pubkey_hex + .as_ref() + .ok_or(CalibrationError::MissingSignature(id))?; + + let pk_arr: [u8; 32] = hex_decode(pk_hex) + .and_then(|b| b.try_into().ok()) + .ok_or(CalibrationError::BadSignature(id))?; + let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CalibrationError::BadSignature(id))?; + + let sig_arr: [u8; 64] = hex_decode(sig_hex) + .and_then(|b| b.try_into().ok()) + .ok_or(CalibrationError::BadSignature(id))?; + let sig = Signature::from_bytes(&sig_arr); + + let msg = canonical_record_bytes(record)?; + vk.verify(&msg, &sig) + .map_err(|_| CalibrationError::BadSignature(id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_core::calibration::Q16_ONE; + + const SEED: &[u8; 32] = b"rucelium-cal-test-seed-32-bytes!"; + + fn record() -> CalibrationRecord { + CalibrationRecord { + calibration_id: 1, + node_id: 7, + modality: SensorModality::Weather, + method: "anchor_reference".into(), + reference_station: Some("anchor-01".into()), + parent_id: None, + created_ns: 1_000, + expires_ns: 2_000_000, + scale_q16: Q16_ONE, + offset_q16: -32_768, + uncertainty_q16: Q16_ONE / 10, + data_hash: sha256_hex(b"cal-source-data"), + signature_hex: None, + signer_pubkey_hex: None, + } + } + + #[test] + fn sha256_is_real_and_stable() { + assert_eq!( + sha256_hex(b""), + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!(sha256_hex(b"abc"), sha256_hex(b"abc")); + assert_ne!(sha256_hex(b"abc"), sha256_hex(b"abd")); + } + + #[test] + fn sign_then_verify_ok() { + let signer = CalibrationSigner::from_seed(SEED); + let mut r = record(); + signer.sign_record(&mut r).unwrap(); + assert_eq!(r.signer_pubkey_hex.as_deref(), Some(&*signer.public_hex())); + verify_record_signature(&r).unwrap(); + } + + #[test] + fn unsigned_record_is_missing_signature() { + let r = record(); + assert_eq!( + verify_record_signature(&r).unwrap_err(), + CalibrationError::MissingSignature(1) + ); + // Half-signed records (only one field present) are also missing. + let signer = CalibrationSigner::from_seed(SEED); + let mut half = record(); + signer.sign_record(&mut half).unwrap(); + half.signer_pubkey_hex = None; + assert_eq!( + verify_record_signature(&half).unwrap_err(), + CalibrationError::MissingSignature(1) + ); + let mut half = record(); + signer.sign_record(&mut half).unwrap(); + half.signature_hex = None; + assert_eq!( + verify_record_signature(&half).unwrap_err(), + CalibrationError::MissingSignature(1) + ); + } + + #[test] + fn changing_any_content_field_breaks_the_signature() { + let signer = CalibrationSigner::from_seed(SEED); + + let mut r = record(); + signer.sign_record(&mut r).unwrap(); + r.scale_q16 += 1; + assert_eq!( + verify_record_signature(&r).unwrap_err(), + CalibrationError::BadSignature(1) + ); + + let mut r = record(); + signer.sign_record(&mut r).unwrap(); + r.expires_ns += 1; + assert_eq!( + verify_record_signature(&r).unwrap_err(), + CalibrationError::BadSignature(1) + ); + + let mut r = record(); + signer.sign_record(&mut r).unwrap(); + r.method = "factory".into(); + assert_eq!( + verify_record_signature(&r).unwrap_err(), + CalibrationError::BadSignature(1) + ); + + let mut r = record(); + signer.sign_record(&mut r).unwrap(); + r.offset_q16 = 0; + assert_eq!( + verify_record_signature(&r).unwrap_err(), + CalibrationError::BadSignature(1) + ); + } + + #[test] + fn malformed_signature_or_key_is_bad_signature() { + let signer = CalibrationSigner::from_seed(SEED); + let mut r = record(); + signer.sign_record(&mut r).unwrap(); + + let mut bad = r.clone(); + bad.signature_hex = Some("zz".into()); + assert_eq!( + verify_record_signature(&bad).unwrap_err(), + CalibrationError::BadSignature(1) + ); + + let mut bad = r.clone(); + bad.signer_pubkey_hex = Some("00ff".into()); // not 32 bytes + assert_eq!( + verify_record_signature(&bad).unwrap_err(), + CalibrationError::BadSignature(1) + ); + + let mut bad = r; + bad.signature_hex = Some("abc".into()); // odd hex length + assert_eq!( + verify_record_signature(&bad).unwrap_err(), + CalibrationError::BadSignature(1) + ); + } + + #[test] + fn signing_is_deterministic() { + let mut a = record(); + let mut b = record(); + CalibrationSigner::from_seed(SEED) + .sign_record(&mut a) + .unwrap(); + CalibrationSigner::from_seed(SEED) + .sign_record(&mut b) + .unwrap(); + assert_eq!(a.signature_hex, b.signature_hex); + assert_eq!(a.signer_pubkey_hex, b.signer_pubkey_hex); + // Re-signing an already-signed record clears the old fields first, so + // the result is identical too. + CalibrationSigner::from_seed(SEED) + .sign_record(&mut a) + .unwrap(); + assert_eq!(a.signature_hex, b.signature_hex); + } + + #[test] + fn registry_scopes_trust_by_modality() { + let mut reg = AuthorityRegistry::new(); + assert!(reg.is_empty()); + assert!(!reg.trusted_for("00", SensorModality::Weather)); + + // Empty modality set = trusted for everything. + reg.add(CalibrationAuthority { + name: "global-lab".into(), + pubkey_hex: "aa".into(), + modalities: BTreeSet::new(), + }); + // Scoped authority: Weather only. + reg.add(CalibrationAuthority { + name: "weather-op".into(), + pubkey_hex: "bb".into(), + modalities: BTreeSet::from([SensorModality::Weather]), + }); + assert!(!reg.is_empty()); + for m in SensorModality::ALL { + assert!(reg.trusted_for("aa", m)); + } + assert!(reg.trusted_for("bb", SensorModality::Weather)); + assert!(!reg.trusted_for("bb", SensorModality::SoilMoisture)); + assert!(!reg.trusted_for("cc", SensorModality::Weather)); + + // Re-adding the same pubkey replaces the entry. + reg.add(CalibrationAuthority { + name: "weather-op-v2".into(), + pubkey_hex: "bb".into(), + modalities: BTreeSet::from([SensorModality::SoilMoisture]), + }); + assert!(!reg.trusted_for("bb", SensorModality::Weather)); + assert!(reg.trusted_for("bb", SensorModality::SoilMoisture)); + } +} diff --git a/crates/rucelium-calibration/src/calibrator.rs b/crates/rucelium-calibration/src/calibrator.rs new file mode 100644 index 0000000..ed4fbe0 --- /dev/null +++ b/crates/rucelium-calibration/src/calibrator.rs @@ -0,0 +1,287 @@ +//! Applying calibration records to samples — with rejection, never repair +//! (ADR-264 §12 items 4 and 6). + +use crate::error::CalibrationError; +use crate::store::CalibrationStore; +use rucelium_core::{EnvSample, Uncertainty}; +use serde::{Deserialize, Serialize}; + +/// What [`Calibrator::apply`] did to a sample. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CalibrationOutcome { + /// A calibration record was verified and its affine correction applied. + Applied { + /// The record that was applied. + calibration_id: u32, + }, + /// The sample carried `calibration_id == 0`: no correction was invented + /// (ADR-264 §12 item 6); the quality score was penalised instead. + Uncalibrated, +} + +/// Applies verified calibration records to [`EnvSample`]s. +/// +/// The calibrator never invents a correction: an uncalibrated sample keeps +/// its raw value and pays a quality penalty; a sample referencing a record +/// that fails any check (unknown, wrong device, wrong modality, expired, +/// broken lineage) is rejected with the sample left **completely untouched** +/// (ADR-264 §12 item 6). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Calibrator { + /// Multiplier applied to `quality` for uncalibrated samples. + quality_penalty_uncalibrated: f32, +} + +impl Default for Calibrator { + /// The reference penalty: uncalibrated samples lose half their quality. + fn default() -> Self { + Calibrator::new(0.5) + } +} + +impl Calibrator { + /// Create a calibrator with the given uncalibrated-quality multiplier + /// (the resulting quality is clamped to `0.0..=1.0`). + #[must_use] + pub fn new(quality_penalty_uncalibrated: f32) -> Self { + Calibrator { + quality_penalty_uncalibrated, + } + } + + /// Apply the sample's referenced calibration record from `store`. + /// + /// - `calibration_id == 0` (uncalibrated): multiplies `quality` by the + /// penalty, records `"cal:none"` in the provenance lineage, changes + /// nothing else, and returns [`CalibrationOutcome::Uncalibrated`]. + /// - Otherwise the record is looked up and checked against the sample's + /// node, modality, expiry at `now_ns`, and full lineage + /// ([`CalibrationStore::verify_lineage`]). On success the affine + /// correction is applied, the uncertainty interval is recentred on the + /// corrected value with half-width + /// `max(existing half-width, record.uncertainty_half_width())`, + /// `"cal:"` is pushed onto the lineage, quality is unchanged, and + /// the sample is re-validated. + /// + /// On **any** error the sample is left exactly as it was. + pub fn apply( + &self, + store: &CalibrationStore, + sample: &mut EnvSample, + now_ns: u64, + ) -> Result { + if sample.calibration_id == 0 { + // Penalise, never correct (§12 item 6). + sample.quality = (sample.quality * self.quality_penalty_uncalibrated).clamp(0.0, 1.0); + sample.provenance.lineage.push("cal:none".to_string()); + return Ok(CalibrationOutcome::Uncalibrated); + } + + let id = sample.calibration_id; + let record = store.get(id).ok_or(CalibrationError::UnknownRecord(id))?; + if record.node_id != sample.node_id { + return Err(CalibrationError::WrongDevice { + id, + expected: record.node_id, + actual: sample.node_id, + }); + } + if record.modality != sample.modality { + return Err(CalibrationError::WrongModality(id)); + } + if record.is_expired(now_ns) { + return Err(CalibrationError::Expired { + id, + expires_ns: record.expires_ns, + now_ns, + }); + } + store.verify_lineage(id)?; + + // All checks passed; mutate a working copy so a re-validation failure + // still leaves the caller's sample untouched. + let mut updated = sample.clone(); + updated.value = record.apply(sample.value); + let existing_half_width = sample.uncertainty.width() / 2.0; + let half_width = existing_half_width.max(record.uncertainty_half_width()); + updated.uncertainty = Uncertainty::symmetric(updated.value, half_width); + updated.provenance.lineage.push(format!("cal:{id}")); + updated.validate()?; + *sample = updated; + Ok(CalibrationOutcome::Applied { calibration_id: id }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_core::{CalibrationRecord, GeoPoint, SampleProvenance, SensorModality}; + + fn record() -> CalibrationRecord { + CalibrationRecord { + calibration_id: 3, + node_id: 7, + modality: SensorModality::Weather, + method: "anchor_reference".into(), + reference_station: Some("anchor-01".into()), + parent_id: None, + created_ns: 1_000, + expires_ns: 2_000_000, + scale_q16: 66_536, // ≈ 1.0153 + offset_q16: -32_768, // -0.5 + uncertainty_q16: 26_214, // ≈ 0.4 + data_hash: "sha256:cal".into(), + signature_hex: None, + signer_pubkey_hex: None, + } + } + + fn store() -> CalibrationStore { + let mut s = CalibrationStore::new(); + s.insert(record()).unwrap(); + s + } + + fn sample() -> EnvSample { + EnvSample { + node_id: 7, + sequence: 42, + measured_ns: 1_000, + received_ns: 2_000, + geo: GeoPoint::new(514_778_216, -14_767, 46_000).unwrap(), + modality: SensorModality::Weather, + observed_property: "air_temperature".into(), + unit: "Cel".into(), + value: 21.5, + quality: 0.98, + uncertainty: Uncertainty::symmetric(21.5, 0.3), + calibration_id: 3, + flags: 0, + battery_mv: 3600, + provenance: SampleProvenance { + firmware_hash: "sha256:abc".into(), + signer_pubkey_hex: "00ff".into(), + verified: true, + lineage: vec![], + }, + } + } + + #[test] + fn applies_affine_correction_exactly_and_widens_uncertainty() { + let store = store(); + let cal = Calibrator::default(); + let mut s = sample(); + let raw = s.value; + let outcome = cal.apply(&store, &mut s, 10_000).unwrap(); + assert_eq!(outcome, CalibrationOutcome::Applied { calibration_id: 3 }); + assert_eq!(s.value, record().apply(raw)); + // Record half-width (≈0.4) exceeds the sample's (0.3), so the + // interval is recentred with the record's half-width. + let hw = record().uncertainty_half_width(); + assert!((s.uncertainty.lower - (s.value - hw)).abs() < 1e-12); + assert!((s.uncertainty.upper - (s.value + hw)).abs() < 1e-12); + assert_eq!(s.provenance.lineage, vec!["cal:3".to_string()]); + // Quality is untouched on the calibrated path. + assert_eq!(s.quality, 0.98); + s.validate().unwrap(); + } + + #[test] + fn never_narrows_an_uncertainty_interval() { + let store = store(); + let cal = Calibrator::default(); + let mut s = sample(); + s.uncertainty = Uncertainty::symmetric(s.value, 1.5); // wider than the record's 0.4 + cal.apply(&store, &mut s, 10_000).unwrap(); + assert!((s.uncertainty.width() - 3.0).abs() < 1e-12); + assert!((s.uncertainty.lower - (s.value - 1.5)).abs() < 1e-12); + } + + #[test] + fn uncalibrated_sample_is_penalised_never_corrected() { + let store = store(); + let cal = Calibrator::default(); + let mut s = sample(); + s.calibration_id = 0; + let before = s.clone(); + let outcome = cal.apply(&store, &mut s, 10_000).unwrap(); + assert_eq!(outcome, CalibrationOutcome::Uncalibrated); + assert_eq!(s.quality, 0.98f32 * 0.5); + assert_eq!(s.provenance.lineage, vec!["cal:none".to_string()]); + // Value and uncertainty are untouched — no invented correction. + assert_eq!(s.value, before.value); + assert_eq!(s.uncertainty, before.uncertainty); + s.validate().unwrap(); + } + + #[test] + fn expired_record_rejects_and_leaves_sample_unchanged() { + let store = store(); + let cal = Calibrator::default(); + let mut s = sample(); + let before = s.clone(); + let err = cal.apply(&store, &mut s, 2_000_000).unwrap_err(); + assert_eq!( + err, + CalibrationError::Expired { + id: 3, + expires_ns: 2_000_000, + now_ns: 2_000_000 + } + ); + assert_eq!(s, before); + } + + #[test] + fn wrong_node_and_wrong_modality_reject_unchanged() { + let store = store(); + let cal = Calibrator::default(); + + let mut s = sample(); + s.node_id = 8; + let before = s.clone(); + assert_eq!( + cal.apply(&store, &mut s, 10_000).unwrap_err(), + CalibrationError::WrongDevice { + id: 3, + expected: 7, + actual: 8 + } + ); + assert_eq!(s, before); + + let mut s = sample(); + s.modality = SensorModality::SoilMoisture; + s.observed_property = "soil_volumetric_water_content".into(); + let before = s.clone(); + assert_eq!( + cal.apply(&store, &mut s, 10_000).unwrap_err(), + CalibrationError::WrongModality(3) + ); + assert_eq!(s, before); + } + + #[test] + fn unknown_record_rejects_unchanged() { + let store = store(); + let cal = Calibrator::default(); + let mut s = sample(); + s.calibration_id = 99; + let before = s.clone(); + assert_eq!( + cal.apply(&store, &mut s, 10_000).unwrap_err(), + CalibrationError::UnknownRecord(99) + ); + assert_eq!(s, before); + } + + #[test] + fn outcome_serde_round_trips() { + let o = CalibrationOutcome::Applied { calibration_id: 3 }; + let j = serde_json::to_string(&o).unwrap(); + let back: CalibrationOutcome = serde_json::from_str(&j).unwrap(); + assert_eq!(o, back); + } +} diff --git a/crates/rucelium-calibration/src/drift.rs b/crates/rucelium-calibration/src/drift.rs new file mode 100644 index 0000000..fbc2051 --- /dev/null +++ b/crates/rucelium-calibration/src/drift.rs @@ -0,0 +1,279 @@ +//! EWMA drift detection against reference anchors, with sticky sensor +//! quarantine (ADR-264 §12 items 2, 5, 6). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Quarantine state of a node in the drift monitor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuarantineState { + /// EWMA residual within the drift threshold. + Healthy, + /// EWMA residual over threshold, but not yet confirmed. + Suspect, + /// Drift confirmed. **Sticky**: a quarantined node never self-heals — + /// silent correction is forbidden (ADR-264 §12 item 6); the only way back + /// is an explicit recalibration via [`DriftDetector::reinstate`]. + Quarantined, +} + +/// Configuration for the EWMA drift monitor. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct DriftConfig { + /// EWMA weight for the newest residual (`0.0..=1.0`). + pub alpha: f64, + /// Absolute EWMA residual above which a node becomes + /// [`QuarantineState::Suspect`]. + pub threshold: f64, + /// Consecutive over-threshold observations required to confirm drift and + /// move a node from `Suspect` to `Quarantined`. + pub confirm_count: u32, +} + +impl Default for DriftConfig { + /// Reference defaults: `alpha = 0.2`, `threshold = 1.0`, + /// `confirm_count = 3`. + fn default() -> Self { + DriftConfig { + alpha: 0.2, + threshold: 1.0, + confirm_count: 3, + } + } +} + +/// Per-node monitor state. +#[derive(Debug, Clone, Copy, PartialEq)] +struct NodeDrift { + /// EWMA of the residuals observed so far (seeded with the first). + ewma: f64, + /// Consecutive over-threshold observations. + over_count: u32, + /// Current state. + state: QuarantineState, +} + +/// EWMA residual monitor versus co-located reference anchors +/// (ADR-264 §12 items 2 and 5). +/// +/// The caller computes each residual as *node value minus co-located anchor +/// value* and feeds it to [`DriftDetector::observe`]; the detector maintains +/// a per-node EWMA and a three-state machine +/// `Healthy → Suspect → Quarantined`. Dropping back under the threshold +/// resets `Suspect` to `Healthy`, but `Quarantined` is sticky — recovery is +/// only ever explicit, through [`DriftDetector::reinstate`] with a new +/// calibration id (§12 item 6). +/// +/// Fully deterministic: identical residual streams always produce identical +/// states. +#[derive(Debug, Clone, Default)] +pub struct DriftDetector { + config: DriftConfig, + nodes: BTreeMap, +} + +impl DriftDetector { + /// Create a detector with the given configuration. + #[must_use] + pub fn new(config: DriftConfig) -> Self { + DriftDetector { + config, + nodes: BTreeMap::new(), + } + } + + /// Feed one residual (node value minus co-located anchor value) for + /// `node_id` and return the node's resulting state. + pub fn observe(&mut self, node_id: u64, residual: f64) -> QuarantineState { + let node = self + .nodes + .entry(node_id) + .and_modify(|n| { + n.ewma = self.config.alpha * residual + (1.0 - self.config.alpha) * n.ewma; + }) + .or_insert(NodeDrift { + ewma: residual, + over_count: 0, + state: QuarantineState::Healthy, + }); + // Sticky: once quarantined, no residual stream can heal the node. + if node.state == QuarantineState::Quarantined { + return QuarantineState::Quarantined; + } + if node.ewma.abs() > self.config.threshold { + node.over_count += 1; + node.state = if node.over_count >= self.config.confirm_count { + QuarantineState::Quarantined + } else { + QuarantineState::Suspect + }; + } else { + node.over_count = 0; + node.state = QuarantineState::Healthy; + } + node.state + } + + /// Current state of `node_id` (`Healthy` for never-observed nodes). + #[must_use] + pub fn state(&self, node_id: u64) -> QuarantineState { + self.nodes + .get(&node_id) + .map_or(QuarantineState::Healthy, |n| n.state) + } + + /// Whether `node_id` is quarantined. + #[must_use] + pub fn is_quarantined(&self, node_id: u64) -> bool { + self.state(node_id) == QuarantineState::Quarantined + } + + /// Explicitly reinstate a quarantined node after recalibration — the only + /// path out of quarantine (ADR-264 §12 item 6). Clears the node's monitor + /// state only if it is currently quarantined **and** a real calibration id + /// is supplied (`new_calibration_id != 0`; 0 is reserved for + /// "uncalibrated" and cannot reinstate anything). Returns whether the + /// node was quarantined and has now been cleared. + pub fn reinstate(&mut self, node_id: u64, new_calibration_id: u32) -> bool { + if new_calibration_id == 0 || !self.is_quarantined(node_id) { + return false; + } + self.nodes.remove(&node_id); + true + } + + /// All quarantined node ids, sorted ascending. + #[must_use] + pub fn quarantined(&self) -> Vec { + self.nodes + .iter() + .filter(|(_, n)| n.state == QuarantineState::Quarantined) + .map(|(&id, _)| id) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> DriftConfig { + DriftConfig { + alpha: 0.2, + threshold: 0.5, + confirm_count: 3, + } + } + + #[test] + fn steady_drift_becomes_suspect_then_quarantined_and_is_sticky() { + let mut d = DriftDetector::new(config()); + assert_eq!(d.observe(7, 1.0), QuarantineState::Suspect); + assert_eq!(d.observe(7, 1.1), QuarantineState::Suspect); + assert_eq!(d.observe(7, 1.2), QuarantineState::Quarantined); + // Residuals returning to zero do NOT heal the node (§12 item 6). + for _ in 0..50 { + assert_eq!(d.observe(7, 0.0), QuarantineState::Quarantined); + } + assert!(d.is_quarantined(7)); + assert_eq!(d.quarantined(), vec![7]); + } + + #[test] + fn healthy_node_never_leaves_healthy() { + let mut d = DriftDetector::new(config()); + for i in 0..100 { + let residual = if i % 2 == 0 { 0.05 } else { -0.05 }; + assert_eq!(d.observe(9, residual), QuarantineState::Healthy); + } + assert_eq!(d.state(9), QuarantineState::Healthy); + assert!(!d.is_quarantined(9)); + assert!(d.quarantined().is_empty()); + } + + #[test] + fn dip_below_threshold_resets_suspect_to_healthy() { + let mut d = DriftDetector::new(config()); + assert_eq!(d.observe(3, 1.0), QuarantineState::Suspect); + assert_eq!(d.observe(3, 1.0), QuarantineState::Suspect); + // A strong opposite residual pulls the EWMA back under threshold: + // 0.2 * (-4.0) + 0.8 * 1.0 = 0.0. + assert_eq!(d.observe(3, -4.0), QuarantineState::Healthy); + // The confirmation counter restarts from scratch. + assert_eq!(d.observe(3, 5.0), QuarantineState::Suspect); + } + + #[test] + fn unknown_nodes_are_healthy() { + let d = DriftDetector::new(config()); + assert_eq!(d.state(12345), QuarantineState::Healthy); + assert!(!d.is_quarantined(12345)); + } + + #[test] + fn reinstate_clears_only_quarantined_nodes_with_a_real_calibration() { + let mut d = DriftDetector::new(config()); + // Node 1: quarantined. + for _ in 0..3 { + d.observe(1, 2.0); + } + // Node 2: merely suspect. + d.observe(2, 2.0); + assert_eq!(d.state(1), QuarantineState::Quarantined); + assert_eq!(d.state(2), QuarantineState::Suspect); + + // calibration_id 0 is reserved for "uncalibrated": no reinstatement. + assert!(!d.reinstate(1, 0)); + assert!(d.is_quarantined(1)); + // Suspect and unknown nodes are not cleared. + assert!(!d.reinstate(2, 42)); + assert_eq!(d.state(2), QuarantineState::Suspect); + assert!(!d.reinstate(999, 42)); + // A real recalibration clears the quarantined node. + assert!(d.reinstate(1, 42)); + assert_eq!(d.state(1), QuarantineState::Healthy); + assert!(d.quarantined().is_empty()); + } + + #[test] + fn quarantined_list_is_sorted() { + let mut d = DriftDetector::new(config()); + for id in [30_u64, 10, 20] { + for _ in 0..3 { + d.observe(id, 2.0); + } + } + assert_eq!(d.quarantined(), vec![10, 20, 30]); + } + + #[test] + fn identical_streams_produce_identical_states() { + let residuals: Vec = (0..40).map(|i| f64::from(i) * 0.031 - 0.3).collect(); + let mut a = DriftDetector::new(config()); + let mut b = DriftDetector::new(config()); + for r in &residuals { + let sa = a.observe(5, *r); + let sb = b.observe(5, *r); + assert_eq!(sa, sb); + } + assert_eq!(a.state(5), b.state(5)); + assert_eq!(a.quarantined(), b.quarantined()); + } + + #[test] + fn defaults_and_serde() { + let c = DriftConfig::default(); + assert_eq!(c.alpha, 0.2); + assert_eq!(c.threshold, 1.0); + assert_eq!(c.confirm_count, 3); + let d = DriftDetector::default(); + assert_eq!(d.config, DriftConfig::default()); + assert_eq!( + serde_json::to_string(&QuarantineState::Quarantined).unwrap(), + "\"quarantined\"" + ); + let back: QuarantineState = serde_json::from_str("\"suspect\"").unwrap(); + assert_eq!(back, QuarantineState::Suspect); + } +} diff --git a/crates/rucelium-calibration/src/error.rs b/crates/rucelium-calibration/src/error.rs new file mode 100644 index 0000000..a207bd2 --- /dev/null +++ b/crates/rucelium-calibration/src/error.rs @@ -0,0 +1,198 @@ +//! Error type for calibration lineage, application, and drift handling +//! (ADR-264 §12). + +use rucelium_core::EnvError; +use std::fmt; + +/// Errors raised while validating calibration lineage or applying a +/// calibration record to a sample. Per ADR-264 §12, every failure is a +/// rejection — nothing is silently repaired. +#[derive(Debug, Clone, PartialEq)] +pub enum CalibrationError { + /// The referenced calibration record is not in the store. + UnknownRecord(u32), + /// A record's `parent_id` points at a record that does not exist. + BrokenLineage { + /// The record whose parent link is broken. + id: u32, + /// The parent id that could not be resolved. + missing_parent: u32, + }, + /// The lineage chain revisited a record (a forged cycle never reaches an + /// anchor and is rejected, §12 item 1). + LineageCycle(u32), + /// A lineage root (`parent_id: None`) whose method is neither `factory` + /// nor `anchor_reference` — lineage must terminate at a reference-grade + /// anchor (§12 items 1–3). + UnanchoredRoot(u32), + /// The record had expired at the caller-supplied `now_ns`. + Expired { + /// The expired record. + id: u32, + /// Expiry time, nanoseconds since Unix epoch. + expires_ns: u64, + /// The caller-supplied evaluation time. + now_ns: u64, + }, + /// The record calibrates a different device than the sample's producer. + WrongDevice { + /// The mismatched record. + id: u32, + /// Node the record calibrates. + expected: u64, + /// Node the sample actually came from. + actual: u64, + }, + /// The record applies to a different sensor modality than the sample's. + WrongModality(u32), + /// The record carries no signature (or no signer public key) where a + /// cryptographically verified lineage requires one (§12 items 1–3). + MissingSignature(u32), + /// The record's signature (or its encoding) failed to verify over the + /// record's canonical bytes — the content was tampered with or the + /// signature is forged. + BadSignature(u32), + /// The record's signature verifies, but the signing key is not a + /// registered authority for the record's modality. + UntrustedSigner { + /// The record with the untrusted signer. + id: u32, + /// Hex-encoded public key that signed the record. + signer: String, + }, + /// A core data-model validation failure (record or sample invariants). + Core(EnvError), +} + +impl fmt::Display for CalibrationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CalibrationError::UnknownRecord(id) => { + write!(f, "unknown calibration record {id}") + } + CalibrationError::BrokenLineage { id, missing_parent } => write!( + f, + "calibration {id} references missing parent {missing_parent}" + ), + CalibrationError::LineageCycle(id) => { + write!(f, "calibration lineage cycle detected at record {id}") + } + CalibrationError::UnanchoredRoot(id) => write!( + f, + "calibration root {id} is not anchored \ + (method must be `factory` or `anchor_reference`)" + ), + CalibrationError::Expired { + id, + expires_ns, + now_ns, + } => write!(f, "calibration {id} expired at {expires_ns} (now {now_ns})"), + CalibrationError::WrongDevice { + id, + expected, + actual, + } => write!( + f, + "calibration {id} calibrates node {expected}, sample is from node {actual}" + ), + CalibrationError::WrongModality(id) => { + write!( + f, + "calibration {id} does not apply to the sample's modality" + ) + } + CalibrationError::MissingSignature(id) => { + write!(f, "calibration {id} is unsigned (signature required)") + } + CalibrationError::BadSignature(id) => { + write!(f, "calibration {id} signature verification failed") + } + CalibrationError::UntrustedSigner { id, signer } => write!( + f, + "calibration {id} was signed by untrusted key {signer} \ + (not a registered authority for this modality)" + ), + CalibrationError::Core(e) => write!(f, "core validation error: {e}"), + } + } +} + +impl std::error::Error for CalibrationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + CalibrationError::Core(e) => Some(e), + _ => None, + } + } +} + +impl From for CalibrationError { + fn from(e: EnvError) -> Self { + CalibrationError::Core(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_is_informative() { + let cases: Vec<(CalibrationError, &str)> = vec![ + (CalibrationError::UnknownRecord(9), "unknown"), + ( + CalibrationError::BrokenLineage { + id: 3, + missing_parent: 2, + }, + "missing parent 2", + ), + (CalibrationError::LineageCycle(4), "cycle"), + (CalibrationError::UnanchoredRoot(5), "not anchored"), + ( + CalibrationError::Expired { + id: 6, + expires_ns: 10, + now_ns: 20, + }, + "expired", + ), + ( + CalibrationError::WrongDevice { + id: 7, + expected: 1, + actual: 2, + }, + "node", + ), + (CalibrationError::WrongModality(8), "modality"), + (CalibrationError::MissingSignature(9), "unsigned"), + (CalibrationError::BadSignature(10), "verification failed"), + ( + CalibrationError::UntrustedSigner { + id: 11, + signer: "aabb".into(), + }, + "untrusted key aabb", + ), + ( + CalibrationError::Core(EnvError::MissingField("unit")), + "unit", + ), + ]; + for (err, needle) in cases { + assert!( + err.to_string().contains(needle), + "{err} should mention {needle}" + ); + } + } + + #[test] + fn core_error_converts_and_sources() { + let err: CalibrationError = EnvError::MissingField("method").into(); + assert!(matches!(err, CalibrationError::Core(_))); + assert!(std::error::Error::source(&err).is_some()); + assert!(std::error::Error::source(&CalibrationError::UnknownRecord(1)).is_none()); + } +} diff --git a/crates/rucelium-calibration/src/lib.rs b/crates/rucelium-calibration/src/lib.rs new file mode 100644 index 0000000..2e755f7 --- /dev/null +++ b/crates/rucelium-calibration/src/lib.rs @@ -0,0 +1,44 @@ +//! # rucelium-calibration +//! +//! Calibration lineage, calibration application, EWMA drift detection, and +//! sensor quarantine for the RuCelium fabric (ADR-264 §12). +//! +//! This crate enforces the §12 countermeasures on the gateway side: +//! +//! 1. **Signed calibration lineage** — every [`rucelium_core::CalibrationRecord`] +//! chains via `parent_id` up to a reference-grade anchor; broken chains are +//! rejected ([`CalibrationStore::verify_lineage`], §12 items 1–3). In +//! strict mode ([`CalibrationStore::with_authorities`]) every record must +//! additionally carry an ed25519 signature from a registered +//! [`CalibrationAuthority`] trusted for the record's modality — a method +//! string alone can never declare an anchor. +//! 2. **Measurement uncertainty on every observation** — applying a +//! calibration recentres and (only ever) widens the sample's uncertainty +//! interval to at least the record's stated half-width +//! ([`Calibrator::apply`], §12 item 4). +//! 3. **Automatic drift detection** — an EWMA residual monitor against +//! co-located anchor stations ([`DriftDetector`], §12 item 5). +//! 4. **Sensor quarantine rather than silent correction** — drifted sensors +//! are quarantined and stay quarantined until an explicit recalibration +//! ([`DriftDetector::reinstate`], §12 item 6); values are never rewritten, +//! and an uncalibrated sample is penalised, never "corrected". +//! +//! Everything here is fully deterministic: no clocks, no RNG — callers pass +//! `now_ns` explicitly, and identical inputs always produce identical +//! outputs. + +#![doc(html_root_url = "https://docs.rs/rucelium-calibration/0.1.0")] + +pub mod authority; +pub mod calibrator; +pub mod drift; +pub mod error; +pub mod store; + +pub use authority::{ + sha256_hex, verify_record_signature, AuthorityRegistry, CalibrationAuthority, CalibrationSigner, +}; +pub use calibrator::{CalibrationOutcome, Calibrator}; +pub use drift::{DriftConfig, DriftDetector, QuarantineState}; +pub use error::CalibrationError; +pub use store::CalibrationStore; diff --git a/crates/rucelium-calibration/src/store.rs b/crates/rucelium-calibration/src/store.rs new file mode 100644 index 0000000..9faa36b --- /dev/null +++ b/crates/rucelium-calibration/src/store.rs @@ -0,0 +1,594 @@ +//! Calibration record store with anchor-rooted lineage verification +//! (ADR-264 §12 items 1–3). + +use crate::authority::{verify_record_signature, AuthorityRegistry}; +use crate::error::CalibrationError; +use rucelium_core::{CalibrationRecord, SensorModality}; +use std::collections::BTreeMap; + +/// Whether a lineage root with this method counts as anchored: only records +/// produced at the factory or directly against a reference-grade anchor +/// station may terminate a chain (ADR-264 §12 items 1–3). +fn is_anchored_method(method: &str) -> bool { + method == "factory" || method == "anchor_reference" +} + +/// An in-memory store of [`CalibrationRecord`]s keyed by `calibration_id`, +/// enforcing anchor-rooted lineage at insert time and on demand via +/// [`CalibrationStore::verify_lineage`]. +/// +/// Records are immutable once inserted — a duplicate `calibration_id` is +/// rejected rather than overwritten, because rewriting calibration history +/// would be exactly the silent correction ADR-264 §12 item 6 forbids. +/// +/// The store has two modes: +/// +/// - **Strict** ([`CalibrationStore::with_authorities`]): every record — +/// roots and children alike — must carry an ed25519 signature that verifies +/// over its canonical bytes, and the signing key must be a registered +/// [`crate::CalibrationAuthority`] for the record's modality. Lineage +/// verification re-checks each link's signature, so a chain containing any +/// unsigned or untrusted record fails. +/// - **Permissive** ([`CalibrationStore::new`]): structure-only checks, for +/// tests and simulation only. +#[derive(Debug, Clone, Default)] +pub struct CalibrationStore { + records: BTreeMap, + /// `Some` = strict mode: signatures required and checked against these + /// authorities. `None` = permissive legacy mode. + authorities: Option, +} + +impl CalibrationStore { + /// Create an empty **permissive** store. + /// + /// # WARNING — legacy mode, tests/simulation only + /// + /// A store built with `new()` accepts **unsigned** records and never + /// checks signatures: anyone who can insert a record can declare an + /// "anchor" just by writing `method: "anchor_reference"`. Production + /// deployments must use [`CalibrationStore::with_authorities`], which + /// cryptographically verifies every record against a registry of trusted + /// calibration authorities (ADR-264 §12 items 1–3). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Create an empty **strict** store: every inserted record must carry a + /// valid ed25519 signature ([`verify_record_signature`]) from a key that + /// `registry` trusts for the record's modality + /// ([`AuthorityRegistry::trusted_for`]), and [`Self::verify_lineage`] + /// re-verifies every link of a chain. This applies to roots and children + /// alike — an anchored method string alone proves nothing. + #[must_use] + pub fn with_authorities(registry: AuthorityRegistry) -> Self { + CalibrationStore { + records: BTreeMap::new(), + authorities: Some(registry), + } + } + + /// In strict mode, check the record's signature and its signer's + /// registration for the record's modality; permissive mode accepts all. + fn check_authority(&self, record: &CalibrationRecord) -> Result<(), CalibrationError> { + let Some(registry) = &self.authorities else { + return Ok(()); + }; + verify_record_signature(record)?; + // `verify_record_signature` guarantees the pubkey field is present. + let signer = record.signer_pubkey_hex.as_deref().unwrap_or_default(); + if !registry.trusted_for(signer, record.modality) { + return Err(CalibrationError::UntrustedSigner { + id: record.calibration_id, + signer: signer.to_string(), + }); + } + Ok(()) + } + + /// Number of records in the store. + #[must_use] + pub fn len(&self) -> usize { + self.records.len() + } + + /// Whether the store holds no records. + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + /// Insert a record after validating it structurally + /// ([`CalibrationRecord::validate`]) and against the lineage rules: + /// a `parent_id` must already exist in the store, and a root record + /// (`parent_id: None`) must use an anchored method (`factory` or + /// `anchor_reference`). Duplicate ids are rejected. + /// + /// In strict mode ([`CalibrationStore::with_authorities`]) the record — + /// root or child — must additionally carry a signature that verifies + /// ([`verify_record_signature`]) from a key trusted for its modality, + /// else [`CalibrationError::MissingSignature`], + /// [`CalibrationError::BadSignature`], or + /// [`CalibrationError::UntrustedSigner`] is returned. + pub fn insert(&mut self, record: CalibrationRecord) -> Result<(), CalibrationError> { + record.validate()?; + self.check_authority(&record)?; + if self.records.contains_key(&record.calibration_id) { + return Err(CalibrationError::Core(rucelium_core::EnvError::Invalid( + format!( + "calibration id {} already exists; records are immutable", + record.calibration_id + ), + ))); + } + match record.parent_id { + Some(parent) => { + if !self.records.contains_key(&parent) { + return Err(CalibrationError::BrokenLineage { + id: record.calibration_id, + missing_parent: parent, + }); + } + } + None => { + if !is_anchored_method(&record.method) { + return Err(CalibrationError::UnanchoredRoot(record.calibration_id)); + } + } + } + self.records.insert(record.calibration_id, record); + Ok(()) + } + + /// Look up a record by id. + #[must_use] + pub fn get(&self, id: u32) -> Option<&CalibrationRecord> { + self.records.get(&id) + } + + /// Walk the parent chain from `id` to its root and return the visited ids + /// root-last (`[id, parent, …, root]`). + /// + /// Fails with [`CalibrationError::UnknownRecord`] if `id` is absent, + /// [`CalibrationError::BrokenLineage`] if an ancestor's parent is missing, + /// [`CalibrationError::LineageCycle`] if the chain revisits a record, and + /// [`CalibrationError::UnanchoredRoot`] if the root's method is not + /// anchored (ADR-264 §12 items 1–3). + /// + /// In strict mode every record along the chain is additionally + /// re-verified: its signature must check out and its signer must be a + /// trusted authority for its modality — a chain with any unsigned, + /// tampered, or untrusted link fails with + /// [`CalibrationError::MissingSignature`], + /// [`CalibrationError::BadSignature`], or + /// [`CalibrationError::UntrustedSigner`] respectively. + pub fn verify_lineage(&self, id: u32) -> Result, CalibrationError> { + let mut chain: Vec = Vec::new(); + let mut current = id; + loop { + if chain.contains(¤t) { + return Err(CalibrationError::LineageCycle(current)); + } + let Some(record) = self.records.get(¤t) else { + return match chain.last() { + None => Err(CalibrationError::UnknownRecord(current)), + Some(&child) => Err(CalibrationError::BrokenLineage { + id: child, + missing_parent: current, + }), + }; + }; + self.check_authority(record)?; + chain.push(current); + match record.parent_id { + Some(parent) => current = parent, + None => { + if !is_anchored_method(&record.method) { + return Err(CalibrationError::UnanchoredRoot(current)); + } + return Ok(chain); + } + } + } + } + + /// The newest (highest `created_ns`, ties broken by highest id) record for + /// `node_id` + `modality` that has not expired at `now_ns` and whose + /// lineage verifies. `None` when no such record exists. + #[must_use] + pub fn active_for( + &self, + node_id: u64, + modality: SensorModality, + now_ns: u64, + ) -> Option<&CalibrationRecord> { + self.records + .values() + .filter(|r| { + r.node_id == node_id + && r.modality == modality + && !r.is_expired(now_ns) + && self.verify_lineage(r.calibration_id).is_ok() + }) + .max_by_key(|r| (r.created_ns, r.calibration_id)) + } + + /// Test-only backdoor that bypasses all checks, used to forge broken + /// stores (e.g. lineage cycles) that `insert` correctly refuses to build. + #[cfg(test)] + pub(crate) fn insert_unchecked(&mut self, record: CalibrationRecord) { + self.records.insert(record.calibration_id, record); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authority::{CalibrationAuthority, CalibrationSigner}; + use rucelium_core::calibration::Q16_ONE; + use std::collections::BTreeSet; + + fn record(id: u32, method: &str, parent_id: Option, created_ns: u64) -> CalibrationRecord { + CalibrationRecord { + calibration_id: id, + node_id: 7, + modality: SensorModality::Weather, + method: method.into(), + reference_station: Some("anchor-01".into()), + parent_id, + created_ns, + expires_ns: created_ns + 1_000_000, + scale_q16: Q16_ONE, + offset_q16: 0, + uncertainty_q16: Q16_ONE / 10, + data_hash: "sha256:cal".into(), + signature_hex: None, + signer_pubkey_hex: None, + } + } + + #[test] + fn anchor_rooted_chain_inserts_and_verifies_root_last() { + let mut store = CalibrationStore::new(); + store + .insert(record(1, "anchor_reference", None, 1_000)) + .unwrap(); + store + .insert(record(2, "colocation", Some(1), 2_000)) + .unwrap(); + store + .insert(record(3, "colocation", Some(2), 3_000)) + .unwrap(); + assert_eq!(store.len(), 3); + assert!(!store.is_empty()); + assert_eq!(store.verify_lineage(3).unwrap(), vec![3, 2, 1]); + assert_eq!(store.verify_lineage(1).unwrap(), vec![1]); + } + + #[test] + fn missing_parent_is_rejected_at_insert() { + let mut store = CalibrationStore::new(); + let err = store + .insert(record(2, "colocation", Some(99), 2_000)) + .unwrap_err(); + assert_eq!( + err, + CalibrationError::BrokenLineage { + id: 2, + missing_parent: 99 + } + ); + assert!(store.get(2).is_none()); + } + + #[test] + fn unanchored_root_is_rejected() { + let mut store = CalibrationStore::new(); + let err = store + .insert(record(1, "colocation", None, 1_000)) + .unwrap_err(); + assert_eq!(err, CalibrationError::UnanchoredRoot(1)); + // Factory roots are fine. + store.insert(record(1, "factory", None, 1_000)).unwrap(); + } + + #[test] + fn invalid_record_and_duplicate_id_are_rejected() { + let mut store = CalibrationStore::new(); + let mut bad = record(1, "factory", None, 1_000); + bad.scale_q16 = 0; + assert!(matches!(store.insert(bad), Err(CalibrationError::Core(_)))); + store.insert(record(1, "factory", None, 1_000)).unwrap(); + assert!(matches!( + store.insert(record(1, "factory", None, 2_000)), + Err(CalibrationError::Core(_)) + )); + } + + #[test] + fn unknown_record_and_forged_dangling_parent() { + let mut store = CalibrationStore::new(); + assert_eq!( + store.verify_lineage(42).unwrap_err(), + CalibrationError::UnknownRecord(42) + ); + // Forge a record whose parent vanished (insert would refuse this). + store.insert_unchecked(record(5, "colocation", Some(4), 1_000)); + assert_eq!( + store.verify_lineage(5).unwrap_err(), + CalibrationError::BrokenLineage { + id: 5, + missing_parent: 4 + } + ); + } + + #[test] + fn forged_cycle_reports_lineage_cycle() { + let mut store = CalibrationStore::new(); + store.insert_unchecked(record(10, "colocation", Some(11), 1_000)); + store.insert_unchecked(record(11, "colocation", Some(10), 1_000)); + assert_eq!( + store.verify_lineage(10).unwrap_err(), + CalibrationError::LineageCycle(10) + ); + // Self-loop is also a cycle. + store.insert_unchecked(record(12, "colocation", Some(12), 1_000)); + assert_eq!( + store.verify_lineage(12).unwrap_err(), + CalibrationError::LineageCycle(12) + ); + } + + #[test] + fn forged_unanchored_root_fails_verification() { + let mut store = CalibrationStore::new(); + store.insert_unchecked(record(20, "colocation", None, 1_000)); + store.insert_unchecked(record(21, "colocation", Some(20), 2_000)); + assert_eq!( + store.verify_lineage(21).unwrap_err(), + CalibrationError::UnanchoredRoot(20) + ); + } + + #[test] + fn active_for_picks_newest_non_expired_with_valid_lineage() { + let mut store = CalibrationStore::new(); + // Old but long-lived. + store + .insert(record(1, "anchor_reference", None, 1_000)) + .unwrap(); + // Newest, but expires early. + let mut short = record(2, "colocation", Some(1), 3_000); + short.expires_ns = 4_000; + store.insert(short).unwrap(); + // Middle age, long-lived. + store + .insert(record(3, "colocation", Some(1), 2_000)) + .unwrap(); + + // Before record 2 expires it wins (newest created_ns). + assert_eq!( + store + .active_for(7, SensorModality::Weather, 3_500) + .unwrap() + .calibration_id, + 2 + ); + // After it expires, record 3 (created 2_000) beats record 1. + assert_eq!( + store + .active_for(7, SensorModality::Weather, 5_000) + .unwrap() + .calibration_id, + 3 + ); + // Wrong node or modality: nothing. + assert!(store + .active_for(8, SensorModality::Weather, 3_500) + .is_none()); + assert!(store + .active_for(7, SensorModality::SoilMoisture, 3_500) + .is_none()); + // Broken lineage disqualifies even a fresh record. + store.insert_unchecked(record(9, "colocation", Some(999), 4_000)); + assert_eq!( + store + .active_for(7, SensorModality::Weather, 4_500) + .unwrap() + .calibration_id, + 3 + ); + } + + // ------------------------------------------------------------------ + // Strict mode (calibration authorities, ADR-264 §12 items 1–3) + // ------------------------------------------------------------------ + + const AUTHORITY_SEED: &[u8; 32] = b"rucelium-cal-test-seed-32-bytes!"; + const ATTACKER_SEED: &[u8; 32] = b"attacker-controlled-seed-32bytes"; + + fn signer() -> CalibrationSigner { + CalibrationSigner::from_seed(AUTHORITY_SEED) + } + + /// Registry trusting the test authority for the given modalities + /// (empty slice = all modalities). + fn registry(modalities: &[SensorModality]) -> AuthorityRegistry { + let mut reg = AuthorityRegistry::new(); + reg.add(CalibrationAuthority { + name: "test-authority".into(), + pubkey_hex: signer().public_hex(), + modalities: modalities.iter().copied().collect::>(), + }); + reg + } + + fn signed(id: u32, method: &str, parent_id: Option, created_ns: u64) -> CalibrationRecord { + let mut r = record(id, method, parent_id, created_ns); + signer().sign_record(&mut r).unwrap(); + r + } + + #[test] + fn strict_store_accepts_signed_anchor_and_child() { + let mut store = CalibrationStore::with_authorities(registry(&[])); + store + .insert(signed(1, "anchor_reference", None, 1_000)) + .unwrap(); + store + .insert(signed(2, "colocation", Some(1), 2_000)) + .unwrap(); + assert_eq!(store.verify_lineage(2).unwrap(), vec![2, 1]); + assert_eq!( + store + .active_for(7, SensorModality::Weather, 2_500) + .unwrap() + .calibration_id, + 2 + ); + } + + #[test] + fn strict_store_rejects_unsigned_root() { + let mut store = CalibrationStore::with_authorities(registry(&[])); + let err = store + .insert(record(1, "anchor_reference", None, 1_000)) + .unwrap_err(); + assert_eq!(err, CalibrationError::MissingSignature(1)); + assert!(store.is_empty()); + } + + #[test] + fn strict_store_rejects_unsigned_child_too() { + let mut store = CalibrationStore::with_authorities(registry(&[])); + store + .insert(signed(1, "anchor_reference", None, 1_000)) + .unwrap(); + let err = store + .insert(record(2, "colocation", Some(1), 2_000)) + .unwrap_err(); + assert_eq!(err, CalibrationError::MissingSignature(2)); + } + + #[test] + fn strict_store_rejects_tampered_record() { + let mut store = CalibrationStore::with_authorities(registry(&[])); + let mut r = signed(1, "anchor_reference", None, 1_000); + r.offset_q16 += 1; // tampered after signing + assert_eq!( + store.insert(r).unwrap_err(), + CalibrationError::BadSignature(1) + ); + } + + #[test] + fn strict_store_rejects_signer_not_in_registry() { + let mut store = CalibrationStore::with_authorities(registry(&[])); + let rogue = CalibrationSigner::from_seed(ATTACKER_SEED); + let mut r = record(1, "colocation", None, 1_000); + rogue.sign_record(&mut r).unwrap(); + // The signature itself is valid — but the key is nobody we trust. + assert_eq!( + store.insert(r).unwrap_err(), + CalibrationError::UntrustedSigner { + id: 1, + signer: rogue.public_hex(), + } + ); + } + + #[test] + fn modality_scoped_authority_cannot_sign_other_modalities() { + // Trusted for Weather only. + let mut store = CalibrationStore::with_authorities(registry(&[SensorModality::Weather])); + store + .insert(signed(1, "anchor_reference", None, 1_000)) + .unwrap(); + // Same authority signing a SoilMoisture record: untrusted. + let mut soil = record(2, "anchor_reference", None, 1_000); + soil.modality = SensorModality::SoilMoisture; + signer().sign_record(&mut soil).unwrap(); + assert_eq!( + store.insert(soil).unwrap_err(), + CalibrationError::UntrustedSigner { + id: 2, + signer: signer().public_hex(), + } + ); + } + + #[test] + fn attacker_cannot_declare_anchor_with_method_string_alone() { + // The reviewer's exact attack: insert a record claiming + // method == "anchor_reference", self-signed with a key that is not a + // registered authority. The structural checks would pass — the + // authority check must reject it. + let mut store = CalibrationStore::with_authorities(registry(&[])); + let attacker = CalibrationSigner::from_seed(ATTACKER_SEED); + let mut forged = record(66, "anchor_reference", None, 1_000); + attacker.sign_record(&mut forged).unwrap(); + assert_eq!( + store.insert(forged).unwrap_err(), + CalibrationError::UntrustedSigner { + id: 66, + signer: attacker.public_hex(), + } + ); + assert!(store.get(66).is_none()); + // And an entirely unsigned forgery fails even earlier. + assert_eq!( + store + .insert(record(67, "anchor_reference", None, 1_000)) + .unwrap_err(), + CalibrationError::MissingSignature(67) + ); + } + + #[test] + fn strict_verify_lineage_recheck_catches_forged_links() { + let mut store = CalibrationStore::with_authorities(registry(&[])); + store + .insert(signed(1, "anchor_reference", None, 1_000)) + .unwrap(); + // Forge an unsigned link past `insert` via the test backdoor. + store.insert_unchecked(record(2, "colocation", Some(1), 2_000)); + store.insert_unchecked(signed(3, "colocation", Some(2), 3_000)); + assert_eq!( + store.verify_lineage(3).unwrap_err(), + CalibrationError::MissingSignature(2) + ); + // A tampered link fails with BadSignature. + let mut tampered = signed(4, "colocation", Some(1), 2_000); + tampered.scale_q16 += 1; + store.insert_unchecked(tampered); + store.insert_unchecked(signed(5, "colocation", Some(4), 3_000)); + assert_eq!( + store.verify_lineage(5).unwrap_err(), + CalibrationError::BadSignature(4) + ); + // `active_for` routes through verify_lineage, so forged chains never + // win: only the clean anchor remains eligible. + assert_eq!( + store + .active_for(7, SensorModality::Weather, 3_500) + .unwrap() + .calibration_id, + 1 + ); + } + + #[test] + fn permissive_store_still_accepts_unsigned_records() { + // Legacy mode: no registry, no signature checks (tests/simulation). + let mut store = CalibrationStore::new(); + store + .insert(record(1, "anchor_reference", None, 1_000)) + .unwrap(); + store + .insert(record(2, "colocation", Some(1), 2_000)) + .unwrap(); + assert_eq!(store.verify_lineage(2).unwrap(), vec![2, 1]); + } +} diff --git a/crates/rucelium-core/Cargo.toml b/crates/rucelium-core/Cargo.toml new file mode 100644 index 0000000..5a5dd7a --- /dev/null +++ b/crates/rucelium-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rucelium-core" +version.workspace = true +edition.workspace = true +description = "RuCelium federated environmental fabric core data model: EnvSample, EnvFrame, CalibrationRecord, EnvironmentalEvent, SensorModality, GeoPoint, DataClass (ADR-264 §5/§7/§10)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "sensing", "federation", "iot", "calibration"] +categories = ["science"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +# Content-binding digest for EnvironmentalEvent::evidence_digest. +sha2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-core/src/calibration.rs b/crates/rucelium-core/src/calibration.rs new file mode 100644 index 0000000..0fdf3a7 --- /dev/null +++ b/crates/rucelium-core/src/calibration.rs @@ -0,0 +1,161 @@ +//! Signed calibration records with lineage (ADR-264 §12). + +use crate::error::EnvError; +use crate::modality::SensorModality; +use serde::{Deserialize, Serialize}; + +/// One Q16.16 unit (1.0 in fixed point). +pub const Q16_ONE: i32 = 65_536; + +/// A calibration record. Records chain via `parent_id` up to a +/// reference-grade anchor (ADR-264 §12 items 1–3); the lineage check lives in +/// `rucelium-calibration`. +/// +/// Coefficients are Q16.16 fixed point so the identical affine correction can +/// run on a float-free spore node and on the gateway: +/// `calibrated = raw * scale + offset`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CalibrationRecord { + /// Calibration identity (referenced by `EnvSample::calibration_id`). + /// 0 is reserved for "uncalibrated" and never a valid record id. + pub calibration_id: u32, + /// Device this record calibrates. + pub node_id: u64, + /// Modality this record applies to. + pub modality: SensorModality, + /// Method: `factory`, `colocation`, or `anchor_reference`. + pub method: String, + /// Reference anchor station id, when method used one. + pub reference_station: Option, + /// Parent record in the lineage chain (`None` only for anchor-rooted + /// records, i.e. `method == "anchor_reference"` or `"factory"`). + pub parent_id: Option, + /// Creation time, nanoseconds since Unix epoch. + pub created_ns: u64, + /// Expiry time, nanoseconds since Unix epoch. + pub expires_ns: u64, + /// Affine scale, Q16.16 (65_536 = 1.0). + pub scale_q16: i32, + /// Affine offset, Q16.16, in the sample's unit. + pub offset_q16: i32, + /// Half-width of the calibrated measurement uncertainty, Q16.16, in the + /// sample's unit (requirement 9 of §7.1 — every calibration states the + /// uncertainty it confers). + pub uncertainty_q16: i32, + /// `sha256:` hash of the calibration source data. + pub data_hash: String, + /// Hex-encoded ed25519 signature over the record by the calibrating + /// authority, if signed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature_hex: Option, + /// Hex-encoded signer public key, if signed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_pubkey_hex: Option, +} + +impl CalibrationRecord { + /// Apply the affine correction to a raw value. + #[must_use] + pub fn apply(&self, raw: f64) -> f64 { + raw * (f64::from(self.scale_q16) / f64::from(Q16_ONE)) + + f64::from(self.offset_q16) / f64::from(Q16_ONE) + } + + /// Stated uncertainty half-width in the sample's unit. + #[must_use] + pub fn uncertainty_half_width(&self) -> f64 { + f64::from(self.uncertainty_q16).abs() / f64::from(Q16_ONE) + } + + /// Whether the record has expired at `now_ns`. + #[must_use] + pub fn is_expired(&self, now_ns: u64) -> bool { + now_ns >= self.expires_ns + } + + /// Structural validation. + pub fn validate(&self) -> Result<(), EnvError> { + if self.calibration_id == 0 { + return Err(EnvError::Invalid( + "calibration_id 0 is reserved for uncalibrated".into(), + )); + } + if self.method.is_empty() { + return Err(EnvError::MissingField("method")); + } + if self.expires_ns <= self.created_ns { + return Err(EnvError::Invalid(format!( + "calibration {} expires ({}) at or before creation ({})", + self.calibration_id, self.expires_ns, self.created_ns + ))); + } + if self.scale_q16 == 0 { + return Err(EnvError::Invalid( + "calibration scale of 0 would destroy the measurement".into(), + )); + } + if self.data_hash.is_empty() { + return Err(EnvError::MissingField("data_hash")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> CalibrationRecord { + CalibrationRecord { + calibration_id: 3, + node_id: 7, + modality: SensorModality::Weather, + method: "colocation".into(), + reference_station: Some("anchor-01".into()), + parent_id: Some(1), + created_ns: 1_000, + expires_ns: 2_000_000, + scale_q16: 66_536, // ≈ 1.0153 + offset_q16: -32_768, // -0.5 + uncertainty_q16: 19_661, // ≈ 0.3 + data_hash: "sha256:cal".into(), + signature_hex: None, + signer_pubkey_hex: None, + } + } + + #[test] + fn affine_apply_matches_fixed_point() { + let r = record(); + let got = r.apply(10.0); + let expect = 10.0 * (66_536.0 / 65_536.0) - 0.5; + assert!((got - expect).abs() < 1e-9); + assert!((r.uncertainty_half_width() - 0.3).abs() < 1e-3); + } + + #[test] + fn expiry_and_validation() { + let r = record(); + r.validate().unwrap(); + assert!(!r.is_expired(1_999_999)); + assert!(r.is_expired(2_000_000)); + + let mut bad = record(); + bad.calibration_id = 0; + assert!(bad.validate().is_err()); + let mut bad = record(); + bad.scale_q16 = 0; + assert!(bad.validate().is_err()); + let mut bad = record(); + bad.expires_ns = bad.created_ns; + assert!(bad.validate().is_err()); + } + + #[test] + fn serde_round_trip() { + let r = record(); + let j = serde_json::to_string(&r).unwrap(); + let back: CalibrationRecord = serde_json::from_str(&j).unwrap(); + assert_eq!(r, back); + } +} diff --git a/crates/rucelium-core/src/error.rs b/crates/rucelium-core/src/error.rs new file mode 100644 index 0000000..f4181b7 --- /dev/null +++ b/crates/rucelium-core/src/error.rs @@ -0,0 +1,70 @@ +//! Core error type for RuCelium data-model validation (ADR-264 §7.1). + +use std::fmt; + +/// Errors raised by the core environmental data model. +#[derive(Debug, Clone, PartialEq)] +pub enum EnvError { + /// A geospatial reference was outside valid latitude/longitude ranges. + GeoOutOfRange { + /// Which coordinate failed (`"latitude_e7"` / `"longitude_e7"`). + field: &'static str, + /// The offending fixed-point value. + value: i64, + }, + /// Quality score was outside `0.0..=1.0`. + QualityOutOfRange(f32), + /// The uncertainty interval did not bracket the value + /// (`lower <= value <= upper` violated). + UncertaintyInverted { + /// Interval lower bound. + lower: f64, + /// Measured value. + value: f64, + /// Interval upper bound. + upper: f64, + }, + /// Reception time preceded measurement time. + TimeInverted { + /// Measurement time (ns since Unix epoch). + measured_ns: u64, + /// Reception time (ns since Unix epoch). + received_ns: u64, + }, + /// A required field (ADR-264 §7.1 twelve requirements) was empty. + MissingField(&'static str), + /// A generic validation failure with a message. + Invalid(String), +} + +impl fmt::Display for EnvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EnvError::GeoOutOfRange { field, value } => { + write!(f, "geospatial reference out of range: {field} = {value}") + } + EnvError::QualityOutOfRange(q) => { + write!(f, "quality score {q} outside 0.0..=1.0") + } + EnvError::UncertaintyInverted { + lower, + value, + upper, + } => write!( + f, + "uncertainty interval [{lower}, {upper}] does not bracket value {value}" + ), + EnvError::TimeInverted { + measured_ns, + received_ns, + } => write!( + f, + "reception time {received_ns} precedes measurement time {measured_ns}" + ), + EnvError::MissingField(name) => write!(f, "missing required field: {name}"), + EnvError::Invalid(m) => write!(f, "invalid environmental data: {m}"), + } + } +} + +impl std::error::Error for EnvError {} diff --git a/crates/rucelium-core/src/event.rs b/crates/rucelium-core/src/event.rs new file mode 100644 index 0000000..a5e0836 --- /dev/null +++ b/crates/rucelium-core/src/event.rs @@ -0,0 +1,260 @@ +//! `EnvironmentalEvent` — the signed, federable event class +//! (ADR-264 §6 / §10). + +use crate::error::EnvError; +use crate::geo::GeoPoint; +use crate::modality::{DataClass, SensorModality}; +use serde::{Deserialize, Serialize}; + +/// Event severity ladder. RF-only evidence may never exceed `Advisory` +/// (ADR-264 §8) — enforced by `rucelium-worldgraph`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + /// Informational; may rest on contextual (RF-only) evidence. + Advisory, + /// Elevated attention. + Watch, + /// Action recommended. + Warning, + /// Immediate action; local safety path (< 250 ms target). + Critical, +} + +/// What kind of event this is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + /// A local anomaly threshold fired. + ThresholdExceeded, + /// Statistical anomaly relative to expected behaviour. + Anomaly, + /// Physical tampering or displacement suspected. + SensorTampered, + /// A sensor was quarantined for drift (never silently corrected). + SensorQuarantined, + /// A device key was revoked. + DeviceRevoked, + /// Calibration drift detected against an anchor. + CalibrationDrift, + /// Flood risk assessment. + FloodRisk, + /// Wildfire risk assessment. + WildfireRisk, + /// A cross-boundary alert federated from / to a neighbouring biome. + CrossBoundaryAlert, +} + +/// Compute the content-binding digest for a set of cited observations +/// (ADR-266 §3.1). Feeds each observation's canonical JSON, length-prefixed +/// and in citation order, into one `sha256`. +/// +/// Length prefixing matters: without it, two different citation lists could +/// concatenate to the same byte stream, so an exporter could swap where one +/// observation ends and the next begins. Order matters too — reordering +/// citations changes the digest, because a reordered evidence list is a +/// different claim. +/// +/// Put the result in [`EnvironmentalEvent::evidence_digest`] *before* +/// signing; the signature then covers the observations' content, not just +/// their identities. +#[must_use] +pub fn evidence_digest(observations: &[&crate::sample::EnvSample]) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(b"rucelium.evidence.v1"); + h.update((observations.len() as u64).to_le_bytes()); + for o in observations { + let bytes = serde_json::to_vec(o).unwrap_or_default(); + h.update((bytes.len() as u64).to_le_bytes()); + h.update(&bytes); + } + let mut s = String::from("sha256:"); + for b in h.finalize() { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// Reference to a contributing observation (dedup key of an accepted +/// `EnvSample`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EvidenceRef { + /// Producing device. + pub node_id: u64, + /// Sample sequence number on that device. + pub sequence: u32, +} + +/// A biome-scoped environmental event. Events are `DataClass::FederatedEvent` +/// — the only class that leaves the biome (ADR-264 §10). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvironmentalEvent { + /// Wire spec version. + pub spec_version: String, + /// Unique event id (deterministic in the simulator). + pub event_id: String, + /// Owning biome. + pub biome_id: String, + /// Event kind. + pub kind: EventKind, + /// Severity. + pub severity: Severity, + /// Primary modality that produced the evidence. + pub modality: SensorModality, + /// Location (possibly coarsened per the biome's disclosure policy). + pub geo: GeoPoint, + /// Evidence window start, ns since Unix epoch. + pub window_start_ns: u64, + /// Evidence window end, ns since Unix epoch. + pub window_end_ns: u64, + /// Detection time, ns since Unix epoch. + pub detected_ns: u64, + /// Contributing observations, by identity. + /// + /// Note what this does **not** do: an [`EvidenceRef`] pins *which* + /// observation was cited, never its *content*. Two different readings + /// from the same `(node_id, sequence)` are indistinguishable here — so + /// evidence refs alone cannot detect an edited value in an exported + /// bundle. Use [`Self::evidence_digest`] for that. + pub evidence: Vec, + /// `sha256:` digest binding the *content* of the cited observations into + /// the event's signature (ADR-266 §3.1: compliance evidence must be + /// verifiable by a third party who does not trust the exporter). + /// + /// Computed with [`evidence_digest`] over the cited observations in + /// citation order. Because this field is inside the signed structure, + /// altering any cited observation's value invalidates the event + /// signature — which `evidence` alone cannot achieve. + /// + /// `None` for events that make no content claim (e.g. `DeviceRevoked`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_digest: Option, + /// Detection confidence `0.0..=1.0`. + pub confidence: f32, + /// Human-readable summary. + pub message: String, + /// Hex-encoded ed25519 signature by the biome/gateway key, if signed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature_hex: Option, + /// Hex-encoded signer public key, if signed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_pubkey_hex: Option, +} + +impl EnvironmentalEvent { + /// The data class of every environmental event. + #[must_use] + pub fn data_class(&self) -> DataClass { + DataClass::FederatedEvent + } + + /// Structural validation. + pub fn validate(&self) -> Result<(), EnvError> { + self.geo.validate()?; + if self.event_id.is_empty() { + return Err(EnvError::MissingField("event_id")); + } + if self.biome_id.is_empty() { + return Err(EnvError::MissingField("biome_id")); + } + if !(0.0..=1.0).contains(&self.confidence) || !self.confidence.is_finite() { + return Err(EnvError::QualityOutOfRange(self.confidence)); + } + if self.window_end_ns < self.window_start_ns { + return Err(EnvError::TimeInverted { + measured_ns: self.window_start_ns, + received_ns: self.window_end_ns, + }); + } + if self.evidence.is_empty() { + return Err(EnvError::MissingField("evidence")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event() -> EnvironmentalEvent { + EnvironmentalEvent { + spec_version: crate::SPEC_VERSION.into(), + event_id: "evt-0001".into(), + biome_id: "biome/thames-estuary".into(), + kind: EventKind::FloodRisk, + severity: Severity::Warning, + modality: SensorModality::WaterQuality, + geo: GeoPoint::new(514_000_000, 500_000, 0).unwrap(), + window_start_ns: 1_000, + window_end_ns: 5_000, + detected_ns: 5_100, + evidence: vec![EvidenceRef { + node_id: 7, + sequence: 42, + }], + confidence: 0.9, + evidence_digest: None, + message: "water level rising across 3 nodes".into(), + signature_hex: None, + signer_pubkey_hex: None, + } + } + + #[test] + fn valid_event_round_trips() { + let e = event(); + e.validate().unwrap(); + assert_eq!(e.data_class(), DataClass::FederatedEvent); + let j = serde_json::to_string(&e).unwrap(); + let back: EnvironmentalEvent = serde_json::from_str(&j).unwrap(); + assert_eq!(e, back); + } + + #[test] + fn severity_orders() { + assert!(Severity::Advisory < Severity::Watch); + assert!(Severity::Watch < Severity::Warning); + assert!(Severity::Warning < Severity::Critical); + } + + #[test] + fn empty_evidence_rejected() { + let mut e = event(); + e.evidence.clear(); + assert!(matches!( + e.validate(), + Err(EnvError::MissingField("evidence")) + )); + } + + #[test] + fn evidence_digest_binds_content_not_just_identity() { + use crate::sample::tests_support::sample_for_digest; + let a = sample_for_digest(7, 42, 21.5); + let b = sample_for_digest(7, 42, 99.9); // SAME identity, different value + + // Evidence refs cannot tell these apart — that is the gap this closes. + let ref_a = EvidenceRef { + node_id: a.node_id, + sequence: a.sequence, + }; + let ref_b = EvidenceRef { + node_id: b.node_id, + sequence: b.sequence, + }; + assert_eq!(ref_a, ref_b, "evidence refs are identity-only by design"); + + // The digest does. + assert_ne!(evidence_digest(&[&a]), evidence_digest(&[&b])); + assert_eq!(evidence_digest(&[&a]), evidence_digest(&[&a])); + + // Order is part of the claim. + assert_ne!(evidence_digest(&[&a, &b]), evidence_digest(&[&b, &a])); + + // Length prefixing: a 2-item list never collides with a 1-item list. + assert_ne!(evidence_digest(&[&a, &b]), evidence_digest(&[&a])); + assert!(evidence_digest(&[]).starts_with("sha256:")); + } +} diff --git a/crates/rucelium-core/src/geo.rs b/crates/rucelium-core/src/geo.rs new file mode 100644 index 0000000..249edcf --- /dev/null +++ b/crates/rucelium-core/src/geo.rs @@ -0,0 +1,128 @@ +//! Geospatial references with fixed-point storage and privacy coarsening +//! (ADR-264 §6 / §11). + +use crate::error::EnvError; +use serde::{Deserialize, Serialize}; + +/// Maximum valid latitude in 1e-7 degree units. +pub const LAT_E7_MAX: i32 = 900_000_000; +/// Maximum valid longitude in 1e-7 degree units. +pub const LON_E7_MAX: i32 = 1_800_000_000; + +/// A geospatial reference in the fixed-point encoding the C ABI carries: +/// degrees × 1e7 and altitude in millimetres. Fixed point keeps spore nodes +/// float-free and makes coarsening exact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GeoPoint { + /// Latitude in 1e-7 degrees (`-900_000_000..=900_000_000`). + pub latitude_e7: i32, + /// Longitude in 1e-7 degrees (`-1_800_000_000..=1_800_000_000`). + pub longitude_e7: i32, + /// Altitude above the reference ellipsoid, millimetres. + pub altitude_mm: i32, +} + +impl GeoPoint { + /// Construct and validate. + pub fn new(latitude_e7: i32, longitude_e7: i32, altitude_mm: i32) -> Result { + let p = GeoPoint { + latitude_e7, + longitude_e7, + altitude_mm, + }; + p.validate()?; + Ok(p) + } + + /// Range-check both coordinates. + pub fn validate(&self) -> Result<(), EnvError> { + if self.latitude_e7.abs() > LAT_E7_MAX { + return Err(EnvError::GeoOutOfRange { + field: "latitude_e7", + value: i64::from(self.latitude_e7), + }); + } + if self.longitude_e7.abs() > LON_E7_MAX { + return Err(EnvError::GeoOutOfRange { + field: "longitude_e7", + value: i64::from(self.longitude_e7), + }); + } + Ok(()) + } + + /// Latitude in degrees. + #[must_use] + pub fn latitude_deg(&self) -> f64 { + f64::from(self.latitude_e7) / 1e7 + } + + /// Longitude in degrees. + #[must_use] + pub fn longitude_deg(&self) -> f64 { + f64::from(self.longitude_e7) / 1e7 + } + + /// Privacy coarsening for sensitive locations (ADR-264 §6): snap both + /// coordinates to a grid of `keep_decimals` decimal degrees (0..=7). + /// `keep_decimals = 2` ≈ 1.1 km cells; altitude is dropped to 0. + /// Coarsening is exact integer arithmetic — no float round-trip. + #[must_use] + pub fn coarsen(&self, keep_decimals: u32) -> GeoPoint { + let d = keep_decimals.min(7); + let step = 10_i32.pow(7 - d); + GeoPoint { + latitude_e7: (self.latitude_e7.div_euclid(step)) * step, + longitude_e7: (self.longitude_e7.div_euclid(step)) * step, + altitude_mm: 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_ranges_accepted_invalid_rejected() { + assert!(GeoPoint::new(LAT_E7_MAX, LON_E7_MAX, -5_000).is_ok()); + assert!(GeoPoint::new(-LAT_E7_MAX, -LON_E7_MAX, 8_848_000).is_ok()); + assert!(matches!( + GeoPoint::new(LAT_E7_MAX + 1, 0, 0), + Err(EnvError::GeoOutOfRange { + field: "latitude_e7", + .. + }) + )); + assert!(matches!( + GeoPoint::new(0, -(LON_E7_MAX + 1), 0), + Err(EnvError::GeoOutOfRange { + field: "longitude_e7", + .. + }) + )); + } + + #[test] + fn coarsen_snaps_to_grid_and_drops_altitude() { + // 51.4778216°N, -0.0014767°E (Greenwich), altitude 46 m. + let p = GeoPoint::new(514_778_216, -14_767, 46_000).unwrap(); + let c = p.coarsen(2); + assert_eq!(c.latitude_e7, 514_700_000); // 51.47 + assert_eq!(c.longitude_e7, -100_000); // -0.01 (floor, exact grid) + assert_eq!(c.altitude_mm, 0); + // Coarsening is idempotent. + assert_eq!(c.coarsen(2), c); + // keep_decimals = 7 is the identity on coordinates. + let full = p.coarsen(7); + assert_eq!(full.latitude_e7, p.latitude_e7); + assert_eq!(full.longitude_e7, p.longitude_e7); + } + + #[test] + fn degrees_conversion() { + let p = GeoPoint::new(514_778_216, -14_767, 0).unwrap(); + assert!((p.latitude_deg() - 51.4778216).abs() < 1e-9); + assert!((p.longitude_deg() + 0.0014767).abs() < 1e-9); + } +} diff --git a/crates/rucelium-core/src/lib.rs b/crates/rucelium-core/src/lib.rs new file mode 100644 index 0000000..34cb6fe --- /dev/null +++ b/crates/rucelium-core/src/lib.rs @@ -0,0 +1,32 @@ +//! # rucelium-core +//! +//! Core data model for **RuCelium** — the federated environmental +//! intelligence fabric (ADR-264). Defines the domain types every layer above +//! the C sensor boundary shares: [`EnvSample`], [`EnvFrame`], +//! [`CalibrationRecord`], [`EnvironmentalEvent`], the [`SensorModality`] +//! registry, [`GeoPoint`] geospatial references, and the three-tier +//! [`DataClass`] residency model. +//! +//! Nothing in this crate touches hardware or the network. All numbers in the +//! v0.1 reference stack come from a deterministic **synthetic** biome +//! simulator (`rucelium-bench`) — nothing here claims field-validated +//! accuracy. + +#![doc(html_root_url = "https://docs.rs/rucelium-core/0.1.0")] + +pub mod calibration; +pub mod error; +pub mod event; +pub mod geo; +pub mod modality; +pub mod sample; + +pub use calibration::CalibrationRecord; +pub use error::EnvError; +pub use event::{evidence_digest, EnvironmentalEvent, EventKind, EvidenceRef, Severity}; +pub use geo::GeoPoint; +pub use modality::{DataClass, Residency, SensorModality}; +pub use sample::{EnvFrame, EnvSample, SampleProvenance, Uncertainty}; + +/// Wire spec version for the RuCelium fabric (ADR-264). +pub const SPEC_VERSION: &str = "rucelium.fabric.v0.1"; diff --git a/crates/rucelium-core/src/modality.rs b/crates/rucelium-core/src/modality.rs new file mode 100644 index 0000000..8ae4548 --- /dev/null +++ b/crates/rucelium-core/src/modality.rs @@ -0,0 +1,196 @@ +//! Sensor modality registry and the three-tier data-class model +//! (ADR-264 §5.2 / §10). + +use serde::{Deserialize, Serialize}; + +/// Environmental sensor modalities (ADR-264 §5.2). Extends the ADR-139 +/// WorldGraph modality set rather than creating a second registry: `WifiCsi` +/// is the RuView RF-context modality; the rest are physical environmental +/// sensors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SensorModality { + /// RuView-compatible RF observation (contextual evidence, ADR-264 §8). + WifiCsi, + /// CO₂, volatile organic compounds, PM1 / PM2.5 / PM10. + AirQuality, + /// Soil moisture and conductivity. + SoilMoisture, + /// Water level, flow, and quality. + WaterQuality, + /// Acoustic biodiversity. + Acoustic, + /// Temperature, humidity, leaf wetness, rainfall. + Weather, + /// Mycelial bioelectric potential. + Bioelectric, + /// Ionizing radiation. + Radiation, + /// Light, UV, and infrared. + Optical, + /// Chemical concentration probes. + Chemical, +} + +impl SensorModality { + /// All modalities in wire-code order. + pub const ALL: [SensorModality; 10] = [ + SensorModality::WifiCsi, + SensorModality::AirQuality, + SensorModality::SoilMoisture, + SensorModality::WaterQuality, + SensorModality::Acoustic, + SensorModality::Weather, + SensorModality::Bioelectric, + SensorModality::Radiation, + SensorModality::Optical, + SensorModality::Chemical, + ]; + + /// Stable `u8` wire code used by the C ABI (`rv_env_sample_v1.sensor_type`). + #[must_use] + pub fn code(self) -> u8 { + match self { + SensorModality::WifiCsi => 0, + SensorModality::AirQuality => 1, + SensorModality::SoilMoisture => 2, + SensorModality::WaterQuality => 3, + SensorModality::Acoustic => 4, + SensorModality::Weather => 5, + SensorModality::Bioelectric => 6, + SensorModality::Radiation => 7, + SensorModality::Optical => 8, + SensorModality::Chemical => 9, + } + } + + /// Decode a wire code; `None` for unknown codes (reject, never guess). + #[must_use] + pub fn from_code(code: u8) -> Option { + SensorModality::ALL.get(code as usize).copied() + } + + /// Stable string code (matches the serde representation). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + SensorModality::WifiCsi => "wifi_csi", + SensorModality::AirQuality => "air_quality", + SensorModality::SoilMoisture => "soil_moisture", + SensorModality::WaterQuality => "water_quality", + SensorModality::Acoustic => "acoustic", + SensorModality::Weather => "weather", + SensorModality::Bioelectric => "bioelectric", + SensorModality::Radiation => "radiation", + SensorModality::Optical => "optical", + SensorModality::Chemical => "chemical", + } + } + + /// Default observed property + UCUM unit for samples arriving over the + /// C ABI, which carries only a modality code (ADR-264 §11). Gateways may + /// override via device metadata; these are the registry defaults. + #[must_use] + pub fn default_property_unit(self) -> (&'static str, &'static str) { + match self { + SensorModality::WifiCsi => ("rf_channel_feature", "1"), + SensorModality::AirQuality => ("pm2_5_mass_concentration", "ug/m3"), + SensorModality::SoilMoisture => ("soil_volumetric_water_content", "%"), + SensorModality::WaterQuality => ("water_level", "m"), + SensorModality::Acoustic => ("acoustic_activity_index", "1"), + SensorModality::Weather => ("air_temperature", "Cel"), + SensorModality::Bioelectric => ("bioelectric_potential", "mV"), + SensorModality::Radiation => ("ambient_dose_rate", "uSv/h"), + SensorModality::Optical => ("illuminance", "lx"), + SensorModality::Chemical => ("analyte_concentration", "umol/L"), + } + } +} + +/// Where data of a given class is allowed to live (ADR-264 §10). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Residency { + /// Never leaves the producing gateway. + GatewayOnly, + /// May move within the owning biome. + Biome, + /// May federate globally. + Global, +} + +/// The three data classes of the fabric's data economics (ADR-264 §10). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DataClass { + /// Raw signal data (raw CSI, raw acoustic waveforms). Hours–days, + /// gateway-local only. + RawSignal, + /// Derived features and model outputs. Weeks–months, biome-resident. + DerivedFeature, + /// Signed events and statistical aggregates. Years, globally federable. + FederatedEvent, +} + +const NS_PER_DAY: u64 = 86_400_000_000_000; + +impl DataClass { + /// Where this class is allowed to reside. + #[must_use] + pub fn residency(self) -> Residency { + match self { + DataClass::RawSignal => Residency::GatewayOnly, + DataClass::DerivedFeature => Residency::Biome, + DataClass::FederatedEvent => Residency::Global, + } + } + + /// Default retention in nanoseconds (biomes may tighten, never loosen + /// residency; retention itself is biome policy — these are defaults). + #[must_use] + pub fn default_retention_ns(self) -> u64 { + match self { + DataClass::RawSignal => 2 * NS_PER_DAY, + DataClass::DerivedFeature => 90 * NS_PER_DAY, + DataClass::FederatedEvent => 3650 * NS_PER_DAY, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_round_trip() { + for m in SensorModality::ALL { + assert_eq!(SensorModality::from_code(m.code()), Some(m)); + } + assert_eq!(SensorModality::from_code(10), None); + assert_eq!(SensorModality::from_code(255), None); + } + + #[test] + fn serde_uses_snake_case() { + let j = serde_json::to_string(&SensorModality::SoilMoisture).unwrap(); + assert_eq!(j, "\"soil_moisture\""); + let back: SensorModality = serde_json::from_str("\"wifi_csi\"").unwrap(); + assert_eq!(back, SensorModality::WifiCsi); + } + + #[test] + fn raw_signal_never_leaves_gateway() { + assert_eq!(DataClass::RawSignal.residency(), Residency::GatewayOnly); + assert_eq!(DataClass::DerivedFeature.residency(), Residency::Biome); + assert_eq!(DataClass::FederatedEvent.residency(), Residency::Global); + // Retention ordering: raw << derived << federated. + assert!( + DataClass::RawSignal.default_retention_ns() + < DataClass::DerivedFeature.default_retention_ns() + ); + assert!( + DataClass::DerivedFeature.default_retention_ns() + < DataClass::FederatedEvent.default_retention_ns() + ); + } +} diff --git a/crates/rucelium-core/src/sample.rs b/crates/rucelium-core/src/sample.rs new file mode 100644 index 0000000..648005f --- /dev/null +++ b/crates/rucelium-core/src/sample.rs @@ -0,0 +1,292 @@ +//! `EnvSample` / `EnvFrame` — the normalized environmental observation and +//! its twelve mandatory attributes (ADR-264 §7.1). + +use crate::error::EnvError; +use crate::geo::GeoPoint; +use crate::modality::SensorModality; +use serde::{Deserialize, Serialize}; + +/// A measurement uncertainty interval bracketing the value +/// (requirement 9 of ADR-264 §7.1). Always absolute, in the sample's unit. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Uncertainty { + /// Interval lower bound (same unit as the value). + pub lower: f64, + /// Interval upper bound (same unit as the value). + pub upper: f64, +} + +impl Uncertainty { + /// Symmetric interval `value ± half_width`. + #[must_use] + pub fn symmetric(value: f64, half_width: f64) -> Self { + let hw = half_width.abs(); + Uncertainty { + lower: value - hw, + upper: value + hw, + } + } + + /// Interval width. + #[must_use] + pub fn width(&self) -> f64 { + self.upper - self.lower + } +} + +/// Provenance carried on a normalized sample after gateway ingest +/// (requirements 10–12 of ADR-264 §7.1). The raw signature lives on the wire +/// envelope (`rucelium-abi::SignedEnvRecordV1`); after verification the +/// gateway records who signed and what transformations produced this value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SampleProvenance { + /// `sha256:` hash of the firmware measurement implementation. + pub firmware_hash: String, + /// Hex-encoded ed25519 public key that signed the wire record. + pub signer_pubkey_hex: String, + /// Whether the gateway verified the wire signature at ingest. Samples + /// with `verified = false` never leave the gateway (ADR-264 §12). + pub verified: bool, + /// Derivation lineage: ordered transformation-receipt ids applied since + /// the raw wire value (e.g. `"cal:42"`, `"unit:q16_to_f64"`). Reproducible + /// transformation receipts, ADR-264 §12 item 10. + pub lineage: Vec, +} + +/// A single normalized environmental observation (ADR-264 §7.1). +/// +/// Carries all twelve mandatory attributes: device identity (`node_id`), +/// sequence number, measurement time, reception time, geospatial reference, +/// unit + observed property, calibration identifier, quality score, +/// uncertainty interval, firmware implementation, signature (via +/// [`SampleProvenance`]), and derivation lineage. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvSample { + /// Producing device identity. + pub node_id: u64, + /// Per-device monotonic sequence number. + pub sequence: u32, + /// Measurement time, nanoseconds since Unix epoch (device clock domain). + pub measured_ns: u64, + /// Gateway reception time, nanoseconds since Unix epoch. + pub received_ns: u64, + /// Geospatial reference of the measurement. + pub geo: GeoPoint, + /// Sensor modality. + pub modality: SensorModality, + /// Observed property (e.g. `air_temperature`, `soil_volumetric_water_content`). + pub observed_property: String, + /// UCUM unit code (e.g. `Cel`, `%`, `ug/m3`). + pub unit: String, + /// Calibrated value in `unit`. + pub value: f64, + /// Quality score `0.0..=1.0` (ADR-264 §12 public quality scores). + pub quality: f32, + /// Uncertainty interval bracketing `value`. + pub uncertainty: Uncertainty, + /// Calibration record applied to produce `value` (0 = uncalibrated). + pub calibration_id: u32, + /// Wire flags (bit 0 = retransmit-after-outage; see `rucelium-abi`). + pub flags: u16, + /// Battery level at measurement time, millivolts. + pub battery_mv: u16, + /// Provenance: firmware, signer, verification state, lineage. + pub provenance: SampleProvenance, +} + +impl EnvSample { + /// Validate the twelve-attribute contract. Invalid samples are rejected + /// at ingest, never repaired (ADR-264 §7.1). + pub fn validate(&self) -> Result<(), EnvError> { + self.geo.validate()?; + if !(0.0..=1.0).contains(&self.quality) || !self.quality.is_finite() { + return Err(EnvError::QualityOutOfRange(self.quality)); + } + if !self.value.is_finite() + || self.uncertainty.lower > self.value + || self.value > self.uncertainty.upper + { + return Err(EnvError::UncertaintyInverted { + lower: self.uncertainty.lower, + value: self.value, + upper: self.uncertainty.upper, + }); + } + if self.received_ns < self.measured_ns { + return Err(EnvError::TimeInverted { + measured_ns: self.measured_ns, + received_ns: self.received_ns, + }); + } + if self.measured_ns == 0 { + return Err(EnvError::MissingField("measured_ns")); + } + if self.observed_property.is_empty() { + return Err(EnvError::MissingField("observed_property")); + } + if self.unit.is_empty() { + return Err(EnvError::MissingField("unit")); + } + if self.provenance.firmware_hash.is_empty() { + return Err(EnvError::MissingField("provenance.firmware_hash")); + } + if self.provenance.signer_pubkey_hex.is_empty() { + return Err(EnvError::MissingField("provenance.signer_pubkey_hex")); + } + Ok(()) + } + + /// Stable dedup key: a device may never emit two distinct observations + /// with the same sequence number, so `(node_id, sequence)` identifies a + /// sample across outage replay (ADR-264 §14 criterion 3). + #[must_use] + pub fn dedup_key(&self) -> (u64, u32) { + (self.node_id, self.sequence) + } +} + +/// A batch of samples from one node (e.g. one ring-buffer flush after an +/// outage). All samples must share `node_id`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvFrame { + /// Producing device identity. + pub node_id: u64, + /// Samples, in transmission order. + pub samples: Vec, +} + +impl EnvFrame { + /// Validate every sample and the shared-node invariant. + pub fn validate(&self) -> Result<(), EnvError> { + for s in &self.samples { + if s.node_id != self.node_id { + return Err(EnvError::Invalid(format!( + "frame for node {} contains sample from node {}", + self.node_id, s.node_id + ))); + } + s.validate()?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(crate) fn sample() -> EnvSample { + EnvSample { + node_id: 7, + sequence: 42, + measured_ns: 1_000, + received_ns: 2_000, + geo: GeoPoint::new(514_778_216, -14_767, 46_000).unwrap(), + modality: SensorModality::Weather, + observed_property: "air_temperature".into(), + unit: "Cel".into(), + value: 21.5, + quality: 0.98, + uncertainty: Uncertainty::symmetric(21.5, 0.3), + calibration_id: 3, + flags: 0, + battery_mv: 3600, + provenance: SampleProvenance { + firmware_hash: "sha256:abc".into(), + signer_pubkey_hex: "00ff".into(), + verified: true, + lineage: vec!["cal:3".into()], + }, + } + } + + #[test] + fn valid_sample_passes_and_round_trips() { + let s = sample(); + s.validate().unwrap(); + let j = serde_json::to_string(&s).unwrap(); + let back: EnvSample = serde_json::from_str(&j).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn each_missing_attribute_is_rejected() { + let mut s = sample(); + s.quality = 1.5; + assert!(matches!(s.validate(), Err(EnvError::QualityOutOfRange(_)))); + + let mut s = sample(); + s.uncertainty = Uncertainty { + lower: 22.0, + upper: 23.0, + }; + assert!(matches!( + s.validate(), + Err(EnvError::UncertaintyInverted { .. }) + )); + + let mut s = sample(); + s.received_ns = 500; + assert!(matches!(s.validate(), Err(EnvError::TimeInverted { .. }))); + + let mut s = sample(); + s.unit.clear(); + assert!(matches!(s.validate(), Err(EnvError::MissingField("unit")))); + + let mut s = sample(); + s.provenance.firmware_hash.clear(); + assert!(matches!(s.validate(), Err(EnvError::MissingField(_)))); + + let mut s = sample(); + s.value = f64::NAN; + assert!(s.validate().is_err()); + } + + #[test] + fn frame_rejects_foreign_node() { + let mut f = EnvFrame { + node_id: 7, + samples: vec![sample()], + }; + f.validate().unwrap(); + f.samples[0].node_id = 8; + assert!(f.validate().is_err()); + } + + #[test] + fn dedup_key_is_node_and_sequence() { + assert_eq!(sample().dedup_key(), (7, 42)); + } +} + +/// Test-support constructors shared across the crate's unit tests. +#[cfg(test)] +pub(crate) mod tests_support { + use super::*; + + /// A minimal valid sample with a caller-chosen identity and value. + pub(crate) fn sample_for_digest(node_id: u64, sequence: u32, value: f64) -> EnvSample { + EnvSample { + node_id, + sequence, + measured_ns: 1_000, + received_ns: 2_000, + geo: GeoPoint::new(514_778_216, -14_767, 46_000).unwrap(), + modality: SensorModality::Weather, + observed_property: "air_temperature".into(), + unit: "Cel".into(), + value, + quality: 0.98, + uncertainty: Uncertainty::symmetric(value, 0.3), + calibration_id: 3, + flags: 0, + battery_mv: 3600, + provenance: SampleProvenance { + firmware_hash: "sha256:abc".into(), + signer_pubkey_hex: "00ff".into(), + verified: true, + lineage: vec!["cal:3".into()], + }, + } + } +} diff --git a/crates/rucelium-federation/Cargo.toml b/crates/rucelium-federation/Cargo.toml new file mode 100644 index 0000000..1c4bf26 --- /dev/null +++ b/crates/rucelium-federation/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rucelium-federation" +version.workspace = true +edition.workspace = true +description = "RuCelium biome sovereignty: outage buffer with duplicate-free replay, signed regional summaries, device revocation, disclosure coarsening, OGC SensorThings projection (ADR-264 §6/§7/§10)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "federation", "sensorthings", "sovereignty"] +categories = ["science"] + +[dependencies] +rucelium-core = { workspace = true } +rucelium-ingest = { workspace = true } +rucelium-abi = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-federation/src/biome.rs b/crates/rucelium-federation/src/biome.rs new file mode 100644 index 0000000..7e9eac9 --- /dev/null +++ b/crates/rucelium-federation/src/biome.rs @@ -0,0 +1,587 @@ +//! `Biome` — the sovereign regional aggregate (ADR-264 §6, §12): verified-only +//! ingest with global dedup, device revocation as signed events, and +//! policy-driven disclosure (delay + coordinate coarsening). +//! +//! Admission is **sealed at the type level**: [`Biome::accept`] takes a +//! [`VerifiedEnvSample`], which is not serializable and has no public +//! constructor — the only producers are +//! `rucelium_ingest::IngestPipeline::ingest` and +//! `rucelium_ingest::IngestPipeline::reverify_stored`, both of which run the +//! full registry + signature verification. A `serde`-deserialized +//! [`rucelium_core::EnvSample`] whose bytes claim `provenance.verified = +//! true` cannot reach `accept` at all: the call does not type-check. + +use crate::sig; +use ed25519_dalek::{Signature, Signer as _, SigningKey}; +use rucelium_core::{ + DataClass, EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, + Severity, SPEC_VERSION, +}; +use rucelium_ingest::VerifiedEnvSample; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +/// How a biome discloses events beyond its own boundary (ADR-264 §6: +/// sensitive biodiversity locations support coordinate coarsening, delayed +/// disclosure, and access-controlled raw data). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DisclosurePolicy { + /// Coordinate coarsening: `None` keeps full precision, `Some(d)` snaps + /// event locations to a `d`-decimal-degree grid via [`GeoPoint::coarsen`]. + pub coarsen_decimals: Option, + /// Delayed disclosure: events are withheld until + /// `detected_ns + delay_ns` has passed. + pub delay_ns: u64, + /// Whether raw data behind the event is open access (`false` = access + /// controlled by the biome owner). + pub open_access: bool, +} + +impl Default for DisclosurePolicy { + /// Privacy-preserving default: ≈1.1 km coarsening, no delay, access + /// controlled. + fn default() -> Self { + DisclosurePolicy { + coarsen_decimals: Some(2), + delay_ns: 0, + open_access: false, + } + } +} + +/// Per-biome sovereignty configuration: identity, retention per data class, +/// and disclosure policy (ADR-264 §6, §10). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BiomeConfig { + /// Biome identity (e.g. `"biome/thames-estuary"`). + pub biome_id: String, + /// Retention for `DataClass::RawSignal`, nanoseconds. + pub raw_retention_ns: u64, + /// Retention for `DataClass::DerivedFeature`, nanoseconds. + pub derived_retention_ns: u64, + /// Retention for `DataClass::FederatedEvent`, nanoseconds. + pub event_retention_ns: u64, + /// Disclosure policy applied to everything that leaves the biome. + pub disclosure: DisclosurePolicy, +} + +impl BiomeConfig { + /// Config with the [`DataClass::default_retention_ns`] retention defaults + /// and the default (privacy-preserving) disclosure policy. + #[must_use] + pub fn new(biome_id: impl Into) -> Self { + BiomeConfig { + biome_id: biome_id.into(), + raw_retention_ns: DataClass::RawSignal.default_retention_ns(), + derived_retention_ns: DataClass::DerivedFeature.default_retention_ns(), + event_retention_ns: DataClass::FederatedEvent.default_retention_ns(), + disclosure: DisclosurePolicy::default(), + } + } +} + +/// Outcome of [`Biome::accept`] for one sample. +/// +/// There is deliberately **no `Unverified` variant**: `accept` takes a +/// [`VerifiedEnvSample`], which can only be produced by the ingest +/// pipeline's full cryptographic verification — an unverified sample is +/// unrepresentable at this API, so the outcome cannot occur (ADR-264 §12, +/// enforced by the type system instead of a runtime boolean). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcceptOutcome { + /// Stored in the biome's observation log. + Accepted, + /// The `(node_id, sequence)` key was already accepted (live or replay). + Duplicate, + /// The producing device has been revoked. + Revoked, +} + +/// A sovereign biome region. Owns its observations, its ed25519 identity key +/// (derived deterministically from a seed), its revocation list, and its +/// disclosure policy (ADR-264 §6). +pub struct Biome { + /// Sovereignty configuration. + config: BiomeConfig, + /// Deterministic biome identity key. + key: SigningKey, + /// Accepted observations, in arrival order. + observations: Vec, + /// Global `(node_id, sequence)` index spanning live ingest *and* buffer + /// replay — this is what makes post-outage restore duplicate-free. + seen: BTreeSet<(u64, u32)>, + /// Revoked devices with revocation reason. + revoked: BTreeMap, + /// How many samples were rejected as duplicates. + duplicate_count: usize, +} + +impl Biome { + /// Create a biome whose ed25519 identity derives deterministically from + /// `signer_seed` (same seed ⇒ same key ⇒ same signatures). + #[must_use] + pub fn new(config: BiomeConfig, signer_seed: &[u8; 32]) -> Self { + Biome { + config, + key: SigningKey::from_bytes(signer_seed), + observations: Vec::new(), + seen: BTreeSet::new(), + revoked: BTreeMap::new(), + duplicate_count: 0, + } + } + + /// The biome's sovereignty configuration. + #[must_use] + pub fn config(&self) -> &BiomeConfig { + &self.config + } + + /// Hex-encoded ed25519 public key — the biome's federated identity. + #[must_use] + pub fn public_key_hex(&self) -> String { + sig::hex_encode(self.key.verifying_key().as_bytes()) + } + + /// Crate-internal access to the identity key (used by summary signing). + pub(crate) fn signing_key(&self) -> &SigningKey { + &self.key + } + + /// Admit one cryptographically verified sample. Revoked devices are + /// blocked, and the global dedup index rejects any `(node_id, sequence)` + /// key already accepted — whether it arrived live or via + /// [`crate::OutageBuffer`] replay (re-verified through + /// `IngestPipeline::reverify_stored`) after an outage. + /// + /// Unverified data is unrepresentable here (ADR-264 §12): the parameter + /// type [`VerifiedEnvSample`] has no public constructor and is not + /// deserializable, so only the ingest pipeline's full registry + + /// signature checks can mint one. Passing a bare + /// [`rucelium_core::EnvSample`] — however its `provenance.verified` flag + /// is set — does not compile: + /// + /// ```compile_fail + /// use rucelium_federation::Biome; + /// + /// fn smuggle(biome: &mut Biome, forged: rucelium_core::EnvSample) { + /// // ERROR: expected `VerifiedEnvSample`, found `EnvSample` — a + /// // deserialized sample claiming `provenance.verified = true` + /// // cannot impersonate a sealed one. + /// biome.accept(forged); + /// } + /// ``` + pub fn accept(&mut self, sample: VerifiedEnvSample) -> AcceptOutcome { + let sample = sample.into_inner(); + if self.revoked.contains_key(&sample.node_id) { + return AcceptOutcome::Revoked; + } + if !self.seen.insert(sample.dedup_key()) { + self.duplicate_count += 1; + return AcceptOutcome::Duplicate; + } + self.observations.push(sample); + AcceptOutcome::Accepted + } + + /// Accepted observations, in arrival order. + #[must_use] + pub fn observations(&self) -> &[EnvSample] { + &self.observations + } + + /// Number of accepted observations. + #[must_use] + pub fn accepted_count(&self) -> usize { + self.observations.len() + } + + /// Number of samples rejected as duplicates. + #[must_use] + pub fn duplicate_count(&self) -> usize { + self.duplicate_count + } + + /// Whether a device has been revoked. + #[must_use] + pub fn is_revoked(&self, node_id: u64) -> bool { + self.revoked.contains_key(&node_id) + } + + /// Revoke a device: its key is invalid at this biome immediately — + /// subsequent [`accept`](Biome::accept) calls return + /// [`AcceptOutcome::Revoked`] while other nodes keep flowing — and the + /// revocation propagates outward as a signed [`EnvironmentalEvent`] + /// (ADR-264 §12, §14 criterion 7). + pub fn revoke_device(&mut self, node_id: u64, now_ns: u64, reason: &str) -> EnvironmentalEvent { + let last = self + .observations + .iter() + .rev() + .find(|s| s.node_id == node_id); + let evidence = vec![EvidenceRef { + node_id, + sequence: last.map_or(0, |s| s.sequence), + }]; + let modality = last.map_or(SensorModality::WifiCsi, |s| s.modality); + let geo = last.map_or( + GeoPoint { + latitude_e7: 0, + longitude_e7: 0, + altitude_mm: 0, + }, + |s| s.geo, + ); + let window_start_ns = last.map_or(now_ns, |s| s.measured_ns.min(now_ns)); + self.revoked.insert(node_id, reason.to_string()); + + let mut event = EnvironmentalEvent { + spec_version: SPEC_VERSION.into(), + event_id: format!("revoke:{}:{node_id}:{now_ns}", self.config.biome_id), + biome_id: self.config.biome_id.clone(), + kind: EventKind::DeviceRevoked, + severity: Severity::Warning, + modality, + geo, + window_start_ns, + window_end_ns: now_ns, + detected_ns: now_ns, + evidence, + confidence: 1.0, + // A revocation makes no claim about observation content. + evidence_digest: None, + message: format!("device {node_id} revoked: {reason}"), + signature_hex: None, + signer_pubkey_hex: None, + }; + self.sign_event(&mut event); + event + } + + /// Sign an event in place with the biome key: the signature covers the + /// canonical JSON of the event with both signature fields cleared (same + /// pattern as `rufield-provenance`). + pub fn sign_event(&self, event: &mut EnvironmentalEvent) { + // Fail closed: never mint a signature over a payload that cannot + // survive its own wire format (see `crate::round_trips`). A + // non-finite float becomes JSON `null`, which verifies in-process and + // is unparseable at the peer — so leave it unsigned and let + // `FederationBus::publish_event` reject it as `Unsigned`, rather than + // shipping an artifact that looks valid and is not. + if !crate::round_trips(event) { + event.signature_hex = None; + event.signer_pubkey_hex = None; + return; + } + let bytes = canonical_event_bytes(event); + let signature: Signature = self.key.sign(&bytes); + event.signature_hex = Some(sig::hex_encode(&signature.to_bytes())); + event.signer_pubkey_hex = Some(self.public_key_hex()); + } + + /// Apply the disclosure policy to an event bound for outside the biome + /// (ADR-264 §6). Returns `None` while the delayed-disclosure window is + /// still open (`now_ns < detected_ns + delay_ns`); otherwise a clone with + /// its location coarsened per policy, re-signed by the biome so the + /// disclosed form still verifies. + #[must_use] + pub fn disclose_event( + &self, + event: &EnvironmentalEvent, + now_ns: u64, + ) -> Option { + let release_ns = event + .detected_ns + .saturating_add(self.config.disclosure.delay_ns); + if now_ns < release_ns { + return None; + } + let mut out = event.clone(); + if let Some(d) = self.config.disclosure.coarsen_decimals { + out.geo = out.geo.coarsen(d); + } + self.sign_event(&mut out); + Some(out) + } +} + +impl std::fmt::Debug for Biome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Biome") + .field("biome_id", &self.config.biome_id) + .field("public_key_hex", &self.public_key_hex()) + .field("accepted", &self.observations.len()) + .field("duplicates", &self.duplicate_count) + .field("revoked", &self.revoked.keys().collect::>()) + .finish_non_exhaustive() + } +} + +/// Canonical bytes signed for an event: the event with its signature fields +/// cleared, as compact JSON. +pub(crate) fn canonical_event_bytes(event: &EnvironmentalEvent) -> Vec { + let mut ev = event.clone(); + ev.signature_hex = None; + ev.signer_pubkey_hex = None; + serde_json::to_vec(&ev).expect("EnvironmentalEvent JSON serialization cannot fail") +} + +/// Verify the biome signature carried on an event. `true` only when both +/// signature fields are present and the signature verifies over the +/// canonical bytes — any field tamper breaks it. +#[must_use] +pub fn verify_event(event: &EnvironmentalEvent) -> bool { + let (Some(sig_hex), Some(pk_hex)) = (&event.signature_hex, &event.signer_pubkey_hex) else { + return false; + }; + sig::verify_detached(pk_hex, sig_hex, &canonical_event_bytes(event)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::{pipeline, sample, signed_envelope, verified_sample, SEED}; + use crate::OutageBuffer; + + fn biome() -> Biome { + Biome::new(BiomeConfig::new("biome/test-forest"), SEED) + } + + #[test] + fn config_defaults_track_data_classes() { + let c = BiomeConfig::new("biome/x"); + assert_eq!( + c.raw_retention_ns, + DataClass::RawSignal.default_retention_ns() + ); + assert_eq!( + c.derived_retention_ns, + DataClass::DerivedFeature.default_retention_ns() + ); + assert_eq!( + c.event_retention_ns, + DataClass::FederatedEvent.default_retention_ns() + ); + assert!(!c.disclosure.open_access); + } + + /// The only way to reach `accept` is through the ingest pipeline's full + /// cryptographic verification — `VerifiedEnvSample` is not + /// deserializable and has no public constructor, so a + /// `serde_json`-deserialized `EnvSample` with `provenance.verified = + /// true` cannot be passed in (see the `compile_fail` doctest on + /// [`Biome::accept`]). This test exercises the one honest path. + #[test] + fn only_the_ingest_pipeline_can_mint_acceptable_samples() { + // A forged serialized sample claiming verified = true deserializes + // fine as an EnvSample... + let forged = sample(1, 1, 1_000, 20.0); + let json = serde_json::to_string(&forged).unwrap(); + let back: EnvSample = serde_json::from_str(&json).unwrap(); + assert!(back.provenance.verified); + // ...but `Biome::accept(back)` does not compile. The honest path: + let mut p = pipeline(&[1]); + let mut b = biome(); + let sealed = verified_sample(&mut p, 1, 1, 1_000, 20.0); + assert!(sealed.sample().provenance.verified); + assert_eq!(b.accept(sealed), AcceptOutcome::Accepted); + assert_eq!(b.accepted_count(), 1); + } + + #[test] + fn duplicates_across_live_and_replay_counted_once() { + let mut p = pipeline(&[1]); + let mut b = biome(); + // Live ingest. + assert_eq!( + b.accept(verified_sample(&mut p, 1, 1, 1_000, 20.0)), + AcceptOutcome::Accepted + ); + assert_eq!( + b.accept(verified_sample(&mut p, 1, 2, 2_000, 20.5)), + AcceptOutcome::Accepted + ); + + // Outage: the gateway buffered overlapping signed envelopes, then + // replays them through full re-verification. + let mut buf = OutageBuffer::new(); + // Already live-ingested. + assert!(buf + .push(&signed_envelope(1, 2, 2_000, 20.5), 3_000_000) + .unwrap()); + // New. + assert!(buf + .push(&signed_envelope(1, 3, 3_000, 21.0), 3_000_000) + .unwrap()); + let mut outcomes = Vec::new(); + for (envelope, received_ns) in buf.drain() { + let sealed = p.reverify_stored(&envelope, received_ns).unwrap(); + outcomes.push(b.accept(sealed)); + } + assert_eq!( + outcomes, + vec![AcceptOutcome::Duplicate, AcceptOutcome::Accepted] + ); + assert_eq!(b.accepted_count(), 3); + assert_eq!(b.duplicate_count(), 1); + } + + #[test] + fn revoked_device_blocked_while_healthy_device_flows() { + let mut p = pipeline(&[7, 8]); + let mut b = biome(); + assert_eq!( + b.accept(verified_sample(&mut p, 7, 1, 1_000, 20.0)), + AcceptOutcome::Accepted + ); + assert_eq!( + b.accept(verified_sample(&mut p, 8, 1, 1_000, 19.0)), + AcceptOutcome::Accepted + ); + + let event = b.revoke_device(7, 5_000, "key compromised"); + assert!(b.is_revoked(7)); + assert!(!b.is_revoked(8)); + assert_eq!(event.kind, EventKind::DeviceRevoked); + assert_eq!(event.severity, Severity::Warning); + assert_eq!( + event.evidence, + vec![EvidenceRef { + node_id: 7, + sequence: 1 + }] + ); + event.validate().unwrap(); + + // Revoked node blocked (even with a cryptographically valid sample), + // healthy node keeps flowing. + assert_eq!( + b.accept(verified_sample(&mut p, 7, 2, 2_000, 20.5)), + AcceptOutcome::Revoked + ); + assert_eq!( + b.accept(verified_sample(&mut p, 8, 2, 2_000, 19.5)), + AcceptOutcome::Accepted + ); + assert_eq!(b.accepted_count(), 3); + } + + #[test] + fn revoking_never_seen_device_uses_sequence_zero() { + let mut b = biome(); + let event = b.revoke_device(99, 1_000, "preemptive"); + assert_eq!( + event.evidence, + vec![EvidenceRef { + node_id: 99, + sequence: 0 + }] + ); + assert!(verify_event(&event)); + } + + #[test] + fn revocation_event_verifies_and_tamper_breaks_it() { + let mut p = pipeline(&[7]); + let mut b = biome(); + b.accept(verified_sample(&mut p, 7, 1, 1_000, 20.0)); + let event = b.revoke_device(7, 5_000, "drift"); + assert!(verify_event(&event)); + + let mut t = event.clone(); + t.severity = Severity::Advisory; + assert!(!verify_event(&t)); + + let mut t = event.clone(); + t.biome_id = "biome/other".into(); + assert!(!verify_event(&t)); + + let mut t = event.clone(); + t.message.push('!'); + assert!(!verify_event(&t)); + + let mut t = event.clone(); + t.signature_hex = None; + assert!(!verify_event(&t)); + } + + /// An unsigned event for determinism checks. + fn unsigned_event() -> EnvironmentalEvent { + EnvironmentalEvent { + evidence_digest: None, + spec_version: SPEC_VERSION.into(), + event_id: "evt-det".into(), + biome_id: "biome/test-forest".into(), + kind: EventKind::Anomaly, + severity: Severity::Watch, + modality: SensorModality::Weather, + geo: GeoPoint::new(1, 2, 3).unwrap(), + window_start_ns: 1, + window_end_ns: 2, + detected_ns: 3, + evidence: vec![EvidenceRef { + node_id: 1, + sequence: 1, + }], + confidence: 0.5, + message: "det".into(), + signature_hex: None, + signer_pubkey_hex: None, + } + } + + #[test] + fn signing_is_deterministic() { + let b1 = biome(); + let b2 = biome(); + assert_eq!(b1.public_key_hex(), b2.public_key_hex()); + let mut e1 = unsigned_event(); + let mut e2 = unsigned_event(); + b1.sign_event(&mut e1); + b2.sign_event(&mut e2); + assert_eq!(e1.signature_hex, e2.signature_hex); + assert!(verify_event(&e1)); + } + + #[test] + fn disclosure_delay_withholds_then_releases_coarsened() { + let mut config = BiomeConfig::new("biome/protected"); + config.disclosure = DisclosurePolicy { + coarsen_decimals: Some(2), + delay_ns: 1_000_000, + open_access: false, + }; + let mut b = Biome::new(config, SEED); + let mut p = pipeline(&[7]); + b.accept(verified_sample(&mut p, 7, 1, 1_000, 20.0)); + let event = b.revoke_device(7, 5_000, "tamper"); + + // Before the delay elapses: withheld. + assert!(b.disclose_event(&event, 5_000).is_none()); + assert!(b.disclose_event(&event, 5_000 + 999_999).is_none()); + + // After: released with coarsened geo, still verifying. + let out = b.disclose_event(&event, 5_000 + 1_000_000).unwrap(); + assert_eq!(out.geo, event.geo.coarsen(2)); + assert_ne!(out.geo, event.geo); + assert!(verify_event(&out)); + } + + #[test] + fn open_full_precision_policy_passes_geo_through() { + let mut config = BiomeConfig::new("biome/open"); + config.disclosure = DisclosurePolicy { + coarsen_decimals: None, + delay_ns: 0, + open_access: true, + }; + let mut b = Biome::new(config, SEED); + let mut p = pipeline(&[7]); + b.accept(verified_sample(&mut p, 7, 1, 1_000, 20.0)); + let event = b.revoke_device(7, 5_000, "x"); + let out = b.disclose_event(&event, 5_000).unwrap(); + assert_eq!(out.geo, event.geo); + assert!(verify_event(&out)); + } +} diff --git a/crates/rucelium-federation/src/buffer.rs b/crates/rucelium-federation/src/buffer.rs new file mode 100644 index 0000000..301ab2d --- /dev/null +++ b/crates/rucelium-federation/src/buffer.rs @@ -0,0 +1,315 @@ +//! `OutageBuffer` — gateway store-and-forward of **original signed +//! envelopes** with duplicate-free replay (ADR-264 §5 responsibility 7, §14 +//! criteria 2–3). +//! +//! While the uplink is down the gateway pushes the raw signed envelope bytes +//! here (not decoded samples — decoded samples cannot be re-verified). The +//! buffer is a **dumb store**: [`OutageBuffer::push`] only structurally +//! decodes the envelope to extract the `(node_id, sequence)` dedup key; it +//! performs no signature or registry verification and never yields trusted +//! data by itself. +//! +//! # Restore contract +//! +//! On restore, [`OutageBuffer::drain`] returns the envelopes in +//! deterministic `(node_id, sequence)` order. **Every drained envelope must +//! go through `rucelium_ingest::IngestPipeline::reverify_stored`** — the +//! full cryptographic re-check (registry, revocation, key match, signature, +//! payload validation) — before the resulting +//! `rucelium_ingest::VerifiedEnvSample` can be handed to +//! [`crate::Biome::accept`]. An envelope tampered with while at rest fails +//! `reverify_stored` and never reaches the biome. +//! +//! The dedup index is part of the serialized form, so a gateway restart +//! (serialize → deserialize) never reintroduces an envelope it already +//! buffered. + +use crate::sig; +use crate::FederationError; +use rucelium_abi::{RvEnvSampleV1, SignedEnvRecordV1}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Store-and-forward log of signed envelopes a gateway fills while its +/// uplink is down. +/// +/// Duplicate suppression uses the stable `(node_id, sequence)` dedup key +/// extracted from the envelope payload (ADR-264 §14 criterion 3). The `seen` +/// index is retained across [`drain`](OutageBuffer::drain) calls and across +/// serialization, so replayed wire packets after a restart are still +/// dropped. See the module docs for the mandatory re-verification step on +/// restore. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OutageBuffer { + /// Buffered envelopes keyed by `(node_id, sequence)`; the value is the + /// original envelope bytes plus the reception timestamp. + entries: BTreeMap<(u64, u32), (Vec, u64)>, + /// Every `(node_id, sequence)` key ever pushed — the dedup state. + seen: BTreeSet<(u64, u32)>, +} + +/// One persisted buffer entry: envelope bytes as lowercase hex. +#[derive(Serialize, Deserialize)] +struct PersistedEntry { + node_id: u64, + sequence: u32, + envelope_hex: String, + received_ns: u64, +} + +/// The JSON form of the whole buffer — entries *and* dedup state. +#[derive(Serialize, Deserialize)] +struct PersistedBuffer { + entries: Vec, + seen: Vec<(u64, u32)>, +} + +impl OutageBuffer { + /// Create an empty buffer. + #[must_use] + pub fn new() -> Self { + OutageBuffer::default() + } + + /// Buffer one signed envelope received at `received_ns`. + /// + /// The envelope is decoded (`SignedEnvRecordV1` + `RvEnvSampleV1` parse) + /// **only** to extract the `(node_id, sequence)` dedup key — this is a + /// structural check, not verification; full crypto re-checking happens + /// on restore via `IngestPipeline::reverify_stored`. Returns + /// `Ok(false)` (dropped) when the key has already been buffered — + /// including keys seen before a serialize/deserialize restart cycle — + /// and [`FederationError::BadEnvelope`] when the bytes do not decode as + /// an envelope at all. + pub fn push( + &mut self, + envelope_bytes: &[u8], + received_ns: u64, + ) -> Result { + let key = dedup_key_of(envelope_bytes)?; + if !self.seen.insert(key) { + return Ok(false); + } + self.entries + .insert(key, (envelope_bytes.to_vec(), received_ns)); + Ok(true) + } + + /// Number of envelopes currently buffered. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether no envelopes are currently buffered. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Remove and return all buffered envelopes as + /// `(envelope_bytes, received_ns)` pairs in `(node_id, sequence)` order + /// (deterministic replay order). The dedup index is deliberately *not* + /// cleared: a key that was drained is still a duplicate if it arrives + /// again. + /// + /// The returned envelopes are **untrusted stored bytes**: each must pass + /// `IngestPipeline::reverify_stored` before its sample may enter a + /// [`crate::Biome`] (see the module docs). + pub fn drain(&mut self) -> Vec<(Vec, u64)> { + std::mem::take(&mut self.entries).into_values().collect() + } + + /// Serialize the whole buffer — envelopes (as lowercase hex) *and* dedup + /// state — so it survives a gateway restart. + pub fn to_json(&self) -> Result { + let persisted = PersistedBuffer { + entries: self + .entries + .iter() + .map( + |(&(node_id, sequence), (bytes, received_ns))| PersistedEntry { + node_id, + sequence, + envelope_hex: sig::hex_encode(bytes), + received_ns: *received_ns, + }, + ) + .collect(), + seen: self.seen.iter().copied().collect(), + }; + serde_json::to_string(&persisted) + } + + /// Restore a buffer previously produced by + /// [`to_json`](OutageBuffer::to_json). Each entry's dedup key is + /// re-derived from its envelope bytes (never trusted from the JSON), so + /// the in-memory invariant that keys match envelope content holds even + /// for a hand-edited file. Note that restoring does **not** verify + /// signatures — that remains `reverify_stored`'s job after draining. + pub fn from_json(json: &str) -> Result { + use serde::de::Error as _; + let persisted: PersistedBuffer = serde_json::from_str(json)?; + let mut buf = OutageBuffer { + entries: BTreeMap::new(), + seen: persisted.seen.into_iter().collect(), + }; + for entry in persisted.entries { + let bytes = sig::hex_decode(&entry.envelope_hex) + .ok_or_else(|| serde_json::Error::custom("invalid envelope hex"))?; + let key = dedup_key_of(&bytes) + .map_err(|e| serde_json::Error::custom(format!("stored envelope: {e}")))?; + buf.seen.insert(key); + buf.entries.insert(key, (bytes, entry.received_ns)); + } + Ok(buf) + } +} + +/// Structurally decode an envelope just far enough to read its +/// `(node_id, sequence)` dedup key. No verification of any kind. +fn dedup_key_of(envelope_bytes: &[u8]) -> Result<(u64, u32), FederationError> { + let record = SignedEnvRecordV1::decode(envelope_bytes) + .map_err(|e| FederationError::BadEnvelope(e.to_string()))?; + let wire = RvEnvSampleV1::parse(&record.payload) + .map_err(|e| FederationError::BadEnvelope(e.to_string()))?; + Ok((wire.node_id, wire.sequence)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::biome::{AcceptOutcome, Biome, BiomeConfig}; + use crate::testutil::{pipeline, signed_envelope, SEED}; + + const RECV: u64 = 2_000_000; + + #[test] + fn push_drops_duplicates_and_rejects_garbage() { + let mut buf = OutageBuffer::new(); + assert!(buf.push(&signed_envelope(1, 1, 1_000, 20.0), RECV).unwrap()); + assert!(buf.push(&signed_envelope(1, 2, 2_000, 20.5), RECV).unwrap()); + // Same (node_id, sequence), even with a different payload: dropped. + assert!(!buf.push(&signed_envelope(1, 1, 9_000, 99.0), RECV).unwrap()); + assert_eq!(buf.len(), 2); + assert!(!buf.is_empty()); + + // Bytes that are not an envelope at all. + assert!(matches!( + buf.push(b"not cbor", RECV), + Err(FederationError::BadEnvelope(_)) + )); + assert_eq!(buf.len(), 2); + } + + #[test] + fn drain_is_ordered_and_empties() { + let mut buf = OutageBuffer::new(); + buf.push(&signed_envelope(2, 5, 5_000, 1.0), RECV).unwrap(); + buf.push(&signed_envelope(1, 9, 4_000, 2.0), RECV).unwrap(); + buf.push(&signed_envelope(1, 3, 3_000, 3.0), RECV).unwrap(); + let drained = buf.drain(); + assert_eq!(drained.len(), 3); + assert_eq!(drained[0].0, signed_envelope(1, 3, 3_000, 3.0)); // (1, 3) + assert_eq!(drained[1].0, signed_envelope(1, 9, 4_000, 2.0)); // (1, 9) + assert_eq!(drained[2].0, signed_envelope(2, 5, 5_000, 1.0)); // (2, 5) + assert!(buf.is_empty()); + assert_eq!(buf.len(), 0); + } + + #[test] + fn dedup_state_survives_restart() { + let mut buf = OutageBuffer::new(); + buf.push(&signed_envelope(1, 1, 1_000, 20.0), RECV).unwrap(); + buf.push(&signed_envelope(1, 2, 2_000, 21.0), RECV).unwrap(); + let json = buf.to_json().unwrap(); + // Envelope bytes are persisted as lowercase hex. + assert!(json.contains(&crate::sig::hex_encode(&signed_envelope(1, 1, 1_000, 20.0)))); + + // Gateway restarts: restore from disk. + let mut restored = OutageBuffer::from_json(&json).unwrap(); + assert_eq!(restored, buf); + assert_eq!(restored.len(), 2); + // Replayed wire packets with already-buffered keys are dropped. + assert!(!restored + .push(&signed_envelope(1, 1, 1_000, 20.0), RECV) + .unwrap()); + assert!(!restored + .push(&signed_envelope(1, 2, 2_000, 21.0), RECV) + .unwrap()); + // New keys still flow. + assert!(restored + .push(&signed_envelope(1, 3, 3_000, 22.0), RECV) + .unwrap()); + + // Drain after restore contains zero duplicates, in key order. + let drained = restored.drain(); + assert_eq!(drained.len(), 3); + assert_eq!(drained[2].0, signed_envelope(1, 3, 3_000, 22.0)); + + // Even after draining, previously seen keys stay duplicates. + assert!(!restored + .push(&signed_envelope(1, 3, 3_000, 22.0), RECV) + .unwrap()); + } + + #[test] + fn restored_envelopes_reverify_and_accept_into_a_biome() { + let mut buf = OutageBuffer::new(); + buf.push(&signed_envelope(1, 1, 1_000, 20.0), RECV).unwrap(); + buf.push(&signed_envelope(1, 2, 2_000, 21.0), RECV).unwrap(); + + // Restart cycle, then drain and run the mandatory restore contract: + // reverify_stored (full crypto re-check) before Biome::accept. + let mut restored = OutageBuffer::from_json(&buf.to_json().unwrap()).unwrap(); + let mut p = pipeline(&[1]); + let mut b = Biome::new(BiomeConfig::new("biome/restore"), SEED); + for (envelope, received_ns) in restored.drain() { + let sealed = p.reverify_stored(&envelope, received_ns).unwrap(); + assert_eq!(b.accept(sealed), AcceptOutcome::Accepted); + } + assert_eq!(b.accepted_count(), 2); + assert_eq!(p.stats().restored, 2); + } + + #[test] + fn tampered_buffered_envelope_fails_reverify_and_never_enters_biome() { + // Flip a byte inside the signed payload (the value field, so the + // dedup key is unchanged): the envelope still decodes structurally, + // so the dumb buffer stores it — but restore-time verification kills + // it. + let mut tampered = signed_envelope(1, 1, 1_000, 20.0); + // Envelope layout: array(3) head (1 byte) + bytes(48) head (2 bytes) + // + 48-byte payload; value_q16 sits at payload offset 36. + tampered[3 + 36] ^= 0x01; + + let mut buf = OutageBuffer::new(); + assert!(buf.push(&tampered, RECV).unwrap()); + + let mut restored = OutageBuffer::from_json(&buf.to_json().unwrap()).unwrap(); + let mut p = pipeline(&[1]); + let b = Biome::new(BiomeConfig::new("biome/tamper"), SEED); + for (envelope, received_ns) in restored.drain() { + assert!(matches!( + p.reverify_stored(&envelope, received_ns), + Err(rucelium_ingest::RejectReason::BadSignature(1)) + )); + // No VerifiedEnvSample exists, so nothing can reach b.accept. + } + assert_eq!(b.accepted_count(), 0); + assert_eq!(p.stats().bad_signature, 1); + } + + #[test] + fn from_json_rejects_bad_hex_and_bad_envelopes() { + assert!(OutageBuffer::from_json( + r#"{"entries":[{"node_id":1,"sequence":1,"envelope_hex":"zz","received_ns":1}],"seen":[]}"# + ) + .is_err()); + assert!(OutageBuffer::from_json( + r#"{"entries":[{"node_id":1,"sequence":1,"envelope_hex":"00ff","received_ns":1}],"seen":[]}"# + ) + .is_err()); + assert!(OutageBuffer::from_json("not json").is_err()); + } +} diff --git a/crates/rucelium-federation/src/lib.rs b/crates/rucelium-federation/src/lib.rs new file mode 100644 index 0000000..0311030 --- /dev/null +++ b/crates/rucelium-federation/src/lib.rs @@ -0,0 +1,222 @@ +//! # rucelium-federation +//! +//! Biome sovereignty for the RuCelium fabric (ADR-264 §6, §7, §10, §12): +//! +//! - [`OutageBuffer`] — gateway store-and-forward log of **original signed +//! envelopes** with duplicate-free replay across restarts (§14 criteria +//! 2–3); drained envelopes must pass +//! `rucelium_ingest::IngestPipeline::reverify_stored` (full cryptographic +//! re-verification) before they can enter a biome, +//! - [`Biome`] — the sovereign regional aggregate: admission requires a +//! [`rucelium_ingest::VerifiedEnvSample`] (a sealed type only the ingest +//! pipeline can produce, so unverified data is unrepresentable at this +//! layer), global dedup spanning live ingest and buffer replay, device +//! revocation as signed events, and delayed / coarsened disclosure, +//! - [`RegionalSummary`] + [`FederationBus`] — signed statistical summaries +//! are what federate between biomes instead of raw data (§6), with +//! biome-identity binding, key rotation by epoch, and replay-protected +//! publication, +//! - [`sensorthings`] — a SensorThings-*inspired* entity projection so every +//! accepted observation is externally consumable (§7, §14 criterion 6). +//! +//! Everything is deterministic: ed25519 signing is RFC 8032 deterministic, +//! keys derive from caller-supplied 32-byte seeds, and all timestamps are +//! passed in — no clocks, no RNG. + +#![doc(html_root_url = "https://docs.rs/rucelium-federation/0.1.0")] + +pub mod biome; +pub mod buffer; +pub mod sensorthings; +pub mod summary; + +pub use biome::{verify_event, AcceptOutcome, Biome, BiomeConfig, DisclosurePolicy}; +pub use buffer::OutageBuffer; +pub use sensorthings::{ + project_sample, rfc3339_from_ns, Datastream, FeatureOfInterest, GeoJsonPoint, Location, + Observation, ObservedProperty, Sensor, SensorThingsBundle, Thing, UnitOfMeasurement, +}; +pub use summary::{ + canonical_succession_bytes, sign_succession, verify_summary, FederationBus, FederationError, + KeySuccession, ModalityStats, RegionalSummary, +}; + +/// Does this artifact survive its own wire format? +/// +/// Serialize it, parse it back, and require the result to be *equal*. This is +/// the invariant every signature in the fabric silently depends on: a peer +/// verifies by re-serializing what it parsed, so an artifact that cannot +/// round-trip is signable but undeliverable — it verifies in-process and dies +/// at the far end. +/// +/// The concrete hazard is non-finite floats. JSON has no NaN and no Infinity, +/// so `serde_json` writes both as `null` — indistinguishably — and parsing +/// `null` back into an `f32`/`f64` field fails outright. A signature over +/// such a payload is worse than no signature: it looks valid locally and is +/// unusable everywhere else. +/// +/// Used by the signing paths to **refuse to sign** rather than mint an +/// artifact that cannot be verified by the peer it is meant for. +pub fn round_trips(value: &T) -> bool +where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq, +{ + match serde_json::to_vec(value) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(|parsed| &parsed == value) + .unwrap_or(false), + Err(_) => false, + } +} + +/// Shared hex + detached-signature helpers (same house style as +/// `rufield-provenance`). +pub(crate) mod sig { + use ed25519_dalek::{Signature, Verifier as _, VerifyingKey}; + + /// Lowercase hex encoding. + pub(crate) fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s + } + + /// Hex decoding; `None` on odd length or non-hex characters. + pub(crate) fn hex_decode(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() + } + + /// Verify a detached hex ed25519 signature over `msg` with a hex public + /// key. Any malformed input simply fails verification. + pub(crate) fn verify_detached(pubkey_hex: &str, sig_hex: &str, msg: &[u8]) -> bool { + let Some(pk_bytes) = hex_decode(pubkey_hex) else { + return false; + }; + let Ok(pk_arr) = <[u8; 32]>::try_from(pk_bytes) else { + return false; + }; + let Ok(vk) = VerifyingKey::from_bytes(&pk_arr) else { + return false; + }; + let Some(sig_bytes) = hex_decode(sig_hex) else { + return false; + }; + let Ok(sig_arr) = <[u8; 64]>::try_from(sig_bytes) else { + return false; + }; + let sig = Signature::from_bytes(&sig_arr); + vk.verify(msg, &sig).is_ok() + } +} + +#[cfg(test)] +pub(crate) mod testutil { + use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; + use rucelium_core::{EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty}; + use rucelium_ingest::{DeviceRegistry, IngestPipeline, VerifiedEnvSample}; + + /// A deterministic 32-byte biome signer seed for tests. + pub(crate) const SEED: &[u8; 32] = b"rucelium-test-seed-32-bytes-ok!!"; + + /// The device-provisioning seed all test node keys derive from. + pub(crate) const PROVISION_SEED: &[u8; 32] = b"rucelium-provision-seed-32-byte!"; + + /// Firmware hash registered for every test device. + pub(crate) const FW: &str = "sha256:fw-test"; + + /// A bare (unsealed) sample for projection tests — [`crate::sensorthings`] + /// operates on plain [`EnvSample`]s, so this never needs the seal. + pub(crate) fn sample(node_id: u64, sequence: u32, measured_ns: u64, value: f64) -> EnvSample { + EnvSample { + node_id, + sequence, + measured_ns, + received_ns: measured_ns + 1_000_000, + geo: GeoPoint::new(514_778_216, -14_767, 46_000).unwrap(), + modality: SensorModality::Weather, + observed_property: "air_temperature".into(), + unit: "Cel".into(), + value, + quality: 0.9, + uncertainty: Uncertainty::symmetric(value, 0.5), + calibration_id: 1, + flags: 0, + battery_mv: 3300, + provenance: SampleProvenance { + firmware_hash: FW.into(), + signer_pubkey_hex: "aa".into(), + verified: true, + lineage: vec!["cal:1".into()], + }, + } + } + + /// The wire record a test node emits. + pub(crate) fn wire(node_id: u64, sequence: u32, measured_ns: u64, value: f64) -> RvEnvSampleV1 { + RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: SensorModality::Weather.code(), + flags: 0, + node_id, + timestamp_ns: measured_ns, + sequence, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: (value * 65_536.0) as i32, + quality_q15: 0x7000, // 0.875 + battery_mv: 3300, + calibration_id: 1, + } + } + + /// A real signed wire envelope from `node_id`'s provisioned key. + pub(crate) fn signed_envelope( + node_id: u64, + sequence: u32, + measured_ns: u64, + value: f64, + ) -> Vec { + NodeSigner::for_node(PROVISION_SEED, node_id) + .sign_sample(&wire(node_id, sequence, measured_ns, value)) + .encode() + } + + /// An ingest pipeline with the given devices registered under their real + /// provisioned keys. + pub(crate) fn pipeline(node_ids: &[u64]) -> IngestPipeline { + let mut reg = DeviceRegistry::new(); + for &id in node_ids { + reg.register( + id, + NodeSigner::for_node(PROVISION_SEED, id).public_key(), + FW.to_string(), + ); + } + IngestPipeline::new(reg) + } + + /// A sealed sample, produced the only way possible: a real signed + /// envelope ingested through a real pipeline. + pub(crate) fn verified_sample( + p: &mut IngestPipeline, + node_id: u64, + sequence: u32, + measured_ns: u64, + value: f64, + ) -> VerifiedEnvSample { + p.ingest( + &signed_envelope(node_id, sequence, measured_ns, value), + measured_ns + 1_000_000, + ) + .expect("test envelope must ingest") + } +} diff --git a/crates/rucelium-federation/src/sensorthings.rs b/crates/rucelium-federation/src/sensorthings.rs new file mode 100644 index 0000000..42fe1dc --- /dev/null +++ b/crates/rucelium-federation/src/sensorthings.rs @@ -0,0 +1,452 @@ +//! **SensorThings-inspired projection** (ADR-264 §7): typed serde structs +//! modelled on the OGC SensorThings API 1.1 entity JSON shapes (`@iot.id`, +//! camelCase field names) so every accepted observation is externally +//! consumable (§14 criterion 6). +//! +//! Honest label: this is *inspired by* the SensorThings data model, not a +//! conformant implementation — it must not be described as OGC-conformant +//! until it passes an external OGC conformance suite. Known deliberate +//! deviations are documented on the fields involved (see +//! [`Sensor::encoding_type`]). +//! +//! v0.1 implements the biome → entity *projection*; serving these entities +//! over HTTP is a follow-up. + +use rucelium_core::{EnvSample, GeoPoint}; +use serde::{Deserialize, Serialize}; + +/// GeoJSON `Point` geometry as embedded in `Location.location` and +/// `FeatureOfInterest.feature`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GeoJsonPoint { + /// Always `"Point"`. + #[serde(rename = "type")] + pub r#type: String, + /// `[longitude_deg, latitude_deg]` — GeoJSON axis order. + pub coordinates: [f64; 2], +} + +impl GeoJsonPoint { + /// Project a [`GeoPoint`] to GeoJSON (longitude first). + #[must_use] + pub fn from_geo(geo: &GeoPoint) -> Self { + GeoJsonPoint { + r#type: "Point".into(), + coordinates: [geo.longitude_deg(), geo.latitude_deg()], + } + } +} + +/// SensorThings-inspired `Thing` — one per device. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Thing { + /// Stable entity id: `thing:node:`. + #[serde(rename = "@iot.id")] + pub iot_id: String, + /// Human-readable name. + pub name: String, + /// Description. + pub description: String, +} + +/// SensorThings-inspired `Location` of a Thing (GeoJSON encoded). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Location { + /// Stable entity id: `location:node:`. + #[serde(rename = "@iot.id")] + pub iot_id: String, + /// Human-readable name. + pub name: String, + /// Always `"application/geo+json"`. + pub encoding_type: String, + /// GeoJSON point geometry. + pub location: GeoJsonPoint, +} + +/// SensorThings-inspired `Sensor` — the measuring procedure/instrument. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Sensor { + /// Stable entity id: `sensor:node::`. + #[serde(rename = "@iot.id")] + pub iot_id: String, + /// Human-readable name. + pub name: String, + /// Description (mandatory in the SensorThings data model). + pub description: String, + /// Always `"text/plain"`. + /// + /// **Deliberate deviation** from SensorThings 1.1, which enumerates only + /// `application/pdf` and SensorML encodings here: our `metadata` field + /// carries a firmware measurement-implementation hash string, which is + /// plain text, not PDF content — labelling it `application/pdf` would be + /// a lie about the bytes. This deviation is part of why this module is a + /// SensorThings-*inspired* projection rather than a conformant one. + pub encoding_type: String, + /// Sensor metadata: the firmware measurement-implementation hash. + pub metadata: String, +} + +/// SensorThings-inspired `ObservedProperty` — what is being measured. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObservedProperty { + /// Stable entity id: `observedproperty:`. + #[serde(rename = "@iot.id")] + pub iot_id: String, + /// Property name (e.g. `air_temperature`). + pub name: String, + /// Definition URI. + pub definition: String, + /// Description. + pub description: String, +} + +/// SensorThings-inspired `unitOfMeasurement` value object. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnitOfMeasurement { + /// Unit name. + pub name: String, + /// Unit symbol (the UCUM code). + pub symbol: String, + /// Definition URI. + pub definition: String, +} + +/// SensorThings-inspired `Datastream` — the series linking Thing, Sensor, +/// and ObservedProperty. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Datastream { + /// Stable entity id: `datastream::`. + #[serde(rename = "@iot.id")] + pub iot_id: String, + /// Human-readable name. + pub name: String, + /// Description (mandatory in the SensorThings data model). + pub description: String, + /// Observation type URI (mandatory in the SensorThings data model); + /// always the O&M measurement type, + /// `http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_Measurement`. + #[serde(rename = "observationType")] + pub observation_type: String, + /// Unit of measurement for all observations in this stream. + pub unit_of_measurement: UnitOfMeasurement, + /// Linked [`ObservedProperty`] id. + pub observed_property_id: String, + /// Linked [`Sensor`] id. + pub sensor_id: String, + /// Linked [`Thing`] id. + pub thing_id: String, +} + +/// SensorThings-inspired `Observation` — one measured value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Observation { + /// Stable entity id: `obs::`. + #[serde(rename = "@iot.id")] + pub iot_id: String, + /// Measurement time (RFC 3339, from `measured_ns`). + pub phenomenon_time: String, + /// Result availability time (RFC 3339, from `received_ns`). + pub result_time: String, + /// Calibrated value. + pub result: f64, + /// Quality score `0.0..=1.0` (ADR-264 §12 public quality scores). + pub result_quality: f32, + /// Linked [`Datastream`] id. + pub datastream_id: String, +} + +/// SensorThings-inspired `FeatureOfInterest` — where the observation +/// applies. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeatureOfInterest { + /// Stable entity id: `foi:node:`. + #[serde(rename = "@iot.id")] + pub iot_id: String, + /// Human-readable name. + pub name: String, + /// Always `"application/geo+json"`. + pub encoding_type: String, + /// GeoJSON point geometry. + pub feature: GeoJsonPoint, +} + +/// A fully linked SensorThings-inspired entity set for one observation — +/// every accepted observation must be projectable (ADR-264 §14 criterion 6). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SensorThingsBundle { + /// The producing device. + pub thing: Thing, + /// Its location. + pub location: Location, + /// The measuring sensor. + pub sensor: Sensor, + /// The observed property. + pub observed_property: ObservedProperty, + /// The datastream linking them. + pub datastream: Datastream, + /// The observation itself. + pub observation: Observation, + /// The feature of interest. + pub feature_of_interest: FeatureOfInterest, +} + +/// The O&M measurement observation type URI stamped on every +/// [`Datastream`]. +const OM_MEASUREMENT: &str = "http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_Measurement"; + +/// Project one normalized [`EnvSample`] into a fully linked +/// SensorThings-inspired entity set with stable, deterministic ids. +#[must_use] +pub fn project_sample(sample: &EnvSample) -> SensorThingsBundle { + let thing_id = format!("thing:node:{}", sample.node_id); + let sensor_id = format!( + "sensor:node:{}:{}", + sample.node_id, + sample.modality.as_str() + ); + let observed_property_id = format!("observedproperty:{}", sample.observed_property); + let datastream_id = format!("datastream:{}:{}", sample.node_id, sample.observed_property); + let point = GeoJsonPoint::from_geo(&sample.geo); + + SensorThingsBundle { + thing: Thing { + iot_id: thing_id.clone(), + name: format!("spore-node-{}", sample.node_id), + description: format!( + "RuCelium spore node {} ({})", + sample.node_id, + sample.modality.as_str() + ), + }, + location: Location { + iot_id: format!("location:node:{}", sample.node_id), + name: format!("location of spore-node-{}", sample.node_id), + encoding_type: "application/geo+json".into(), + location: point.clone(), + }, + sensor: Sensor { + iot_id: sensor_id.clone(), + name: format!( + "{} sensor on node {}", + sample.modality.as_str(), + sample.node_id + ), + description: format!( + "{} sensor on RuCelium spore node {}, described by its firmware \ + measurement-implementation hash", + sample.modality.as_str(), + sample.node_id + ), + encoding_type: "text/plain".into(), + metadata: sample.provenance.firmware_hash.clone(), + }, + observed_property: ObservedProperty { + iot_id: observed_property_id.clone(), + name: sample.observed_property.clone(), + definition: format!("urn:rucelium:property:{}", sample.observed_property), + description: format!( + "{} observed by the {} modality", + sample.observed_property, + sample.modality.as_str() + ), + }, + datastream: Datastream { + iot_id: datastream_id.clone(), + name: format!("{} from node {}", sample.observed_property, sample.node_id), + description: format!( + "{} measurements from RuCelium spore node {}", + sample.observed_property, sample.node_id + ), + observation_type: OM_MEASUREMENT.into(), + unit_of_measurement: UnitOfMeasurement { + name: sample.unit.clone(), + symbol: sample.unit.clone(), + definition: format!("https://ucum.org/ucum#{}", sample.unit), + }, + observed_property_id, + sensor_id, + thing_id, + }, + observation: Observation { + iot_id: format!("obs:{}:{}", sample.node_id, sample.sequence), + phenomenon_time: rfc3339_from_ns(sample.measured_ns), + result_time: rfc3339_from_ns(sample.received_ns), + result: sample.value, + result_quality: sample.quality, + datastream_id, + }, + feature_of_interest: FeatureOfInterest { + iot_id: format!("foi:node:{}", sample.node_id), + name: format!("measurement site of spore-node-{}", sample.node_id), + encoding_type: "application/geo+json".into(), + feature: point, + }, + } +} + +/// Format nanoseconds since the Unix epoch as RFC 3339 UTC with millisecond +/// precision: `YYYY-MM-DDTHH:MM:SS.mmmZ`. +/// +/// Pure integer math via the inverse of Howard Hinnant's `days_from_civil` +/// (`civil_from_days`) — no `chrono`, no clocks. +#[must_use] +pub fn rfc3339_from_ns(ns: u64) -> String { + let secs = ns / 1_000_000_000; + let millis = (ns % 1_000_000_000) / 1_000_000; + let days = secs / 86_400; + let second_of_day = secs % 86_400; + let (hour, minute, second) = ( + second_of_day / 3_600, + (second_of_day % 3_600) / 60, + second_of_day % 60, + ); + + // civil_from_days (Hinnant): days since 1970-01-01 → (y, m, d). + // All values are non-negative here (u64 input), so plain division works. + let z = days + 719_468; + let era = z / 146_097; + let doe = z % 146_097; // day of era [0, 146096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + if month <= 2 { + year += 1; + } + + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::sample; + + #[test] + fn rfc3339_known_vectors() { + // Epoch. + assert_eq!(rfc3339_from_ns(0), "1970-01-01T00:00:00.000Z"); + // Millisecond precision. + assert_eq!(rfc3339_from_ns(1_000_000), "1970-01-01T00:00:00.001Z"); + assert_eq!(rfc3339_from_ns(999_999), "1970-01-01T00:00:00.000Z"); + // 1_754_006_400 s = 20_301 d since epoch = 2025-08-01 (hand check: + // 20_089 d to 2025-01-01, +212 d = Aug 1). + assert_eq!( + rfc3339_from_ns(1_754_006_400_000_000_000), + "2025-08-01T00:00:00.000Z" + ); + // 1_785_542_400 s = 20_666 d = 2026-08-01 (20_454 d to 2026-01-01, + // +212 d = Aug 1). + assert_eq!( + rfc3339_from_ns(1_785_542_400_000_000_000), + "2026-08-01T00:00:00.000Z" + ); + // Leap-year date: 1_709_164_800 s = 2024-02-29T00:00:00Z + // (2024-03-01T00:00:00Z = 1_709_251_200 minus one day). + assert_eq!( + rfc3339_from_ns(1_709_164_800_000_000_000), + "2024-02-29T00:00:00.000Z" + ); + // End of that leap day. + assert_eq!( + rfc3339_from_ns(1_709_251_199_999_000_000), + "2024-02-29T23:59:59.999Z" + ); + // Sub-day time components. + assert_eq!( + rfc3339_from_ns(3_661_500_000_000), + "1970-01-01T01:01:01.500Z" + ); + } + + #[test] + fn projection_json_has_sensorthings_shapes() { + let s = sample(7, 42, 1_754_006_400_000_000_000, 21.5); + let bundle = project_sample(&s); + let json = serde_json::to_string(&bundle).unwrap(); + assert!(json.contains("\"@iot.id\"")); + assert!(json.contains("\"phenomenonTime\":\"2025-08-01T00:00:00.000Z\"")); + assert!(json.contains("\"resultTime\"")); + assert!(json.contains("\"result\":21.5")); + assert!(json.contains("\"resultQuality\"")); + assert!(json.contains("\"unitOfMeasurement\"")); + assert!(json.contains("\"encodingType\":\"application/geo+json\"")); + // Sensor metadata is a firmware hash string, not PDF content. + assert!(json.contains("\"encodingType\":\"text/plain\"")); + assert!(!json.contains("application/pdf")); + // Mandatory-per-spec fields are present. + assert!(json.contains( + "\"observationType\":\ + \"http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_Measurement\"" + )); + assert!(json.contains("\"description\"")); + assert!(json.contains("\"type\":\"Point\"")); + // Round trips. + let back: SensorThingsBundle = serde_json::from_str(&json).unwrap(); + assert_eq!(bundle, back); + } + + #[test] + fn mandatory_descriptions_are_nonempty() { + let b = project_sample(&sample(7, 42, 1_000, 21.5)); + assert!(!b.thing.description.is_empty()); + assert!(!b.sensor.description.is_empty()); + assert!(!b.observed_property.description.is_empty()); + assert!(!b.datastream.description.is_empty()); + assert_eq!(b.datastream.observation_type, OM_MEASUREMENT); + assert_eq!(b.sensor.encoding_type, "text/plain"); + } + + #[test] + fn ids_are_stable_and_linked() { + let s = sample(7, 42, 1_000, 21.5); + let b1 = project_sample(&s); + let b2 = project_sample(&s); + assert_eq!(b1, b2); // deterministic + + assert_eq!(b1.thing.iot_id, "thing:node:7"); + assert_eq!(b1.location.iot_id, "location:node:7"); + assert_eq!(b1.sensor.iot_id, "sensor:node:7:weather"); + assert_eq!( + b1.observed_property.iot_id, + "observedproperty:air_temperature" + ); + assert_eq!(b1.datastream.iot_id, "datastream:7:air_temperature"); + assert_eq!(b1.observation.iot_id, "obs:7:42"); + assert_eq!(b1.feature_of_interest.iot_id, "foi:node:7"); + + // Entity linkage is consistent. + assert_eq!(b1.datastream.thing_id, b1.thing.iot_id); + assert_eq!(b1.datastream.sensor_id, b1.sensor.iot_id); + assert_eq!( + b1.datastream.observed_property_id, + b1.observed_property.iot_id + ); + assert_eq!(b1.observation.datastream_id, b1.datastream.iot_id); + + // Unit and firmware metadata carried through. + assert_eq!(b1.datastream.unit_of_measurement.symbol, "Cel"); + assert_eq!(b1.sensor.metadata, "sha256:fw-test"); + } + + #[test] + fn geojson_axis_order_is_lon_lat() { + let s = sample(7, 1, 1_000, 21.5); + let b = project_sample(&s); + let [lon, lat] = b.location.location.coordinates; + assert!((lon - s.geo.longitude_deg()).abs() < 1e-12); + assert!((lat - s.geo.latitude_deg()).abs() < 1e-12); + assert_eq!(b.feature_of_interest.feature, b.location.location); + } +} diff --git a/crates/rucelium-federation/src/summary.rs b/crates/rucelium-federation/src/summary.rs new file mode 100644 index 0000000..b8ce62a --- /dev/null +++ b/crates/rucelium-federation/src/summary.rs @@ -0,0 +1,1231 @@ +//! Signed regional summaries and the minimal federation exchange +//! (ADR-264 §6): biomes federate **signed events and statistical +//! summaries**, never raw measurements. + +use crate::biome::{verify_event, Biome}; +use crate::sig; +use ed25519_dalek::{Signature, Signer as _}; +use rucelium_core::EnvironmentalEvent; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Per-modality aggregate statistics over one summary window. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ModalityStats { + /// Number of contributing observations. + pub count: u64, + /// Arithmetic mean of the calibrated values. + pub mean: f64, + /// Minimum value in the window. + pub min: f64, + /// Maximum value in the window. + pub max: f64, + /// Mean quality score of the contributing observations. + pub mean_quality: f64, +} + +/// A signed statistical summary of one biome over one time window — the +/// `DataClass::FederatedEvent`-class aggregate that leaves the biome instead +/// of raw data (ADR-264 §6, §10). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RegionalSummary { + /// Wire spec version. + pub spec_version: String, + /// Producing biome. + pub biome_id: String, + /// Window start (inclusive), ns since Unix epoch. + pub window_start_ns: u64, + /// Window end (exclusive), ns since Unix epoch. + pub window_end_ns: u64, + /// Per-modality statistics, keyed by `SensorModality::as_str()` (BTreeMap + /// for deterministic canonical bytes). + pub stats: BTreeMap, + /// Hex ed25519 signature by the biome key, if signed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature_hex: Option, + /// Hex signer public key, if signed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_pubkey_hex: Option, +} + +/// Canonical bytes signed for a summary: the summary with its signature +/// fields cleared, as compact JSON. +fn canonical_summary_bytes(summary: &RegionalSummary) -> Vec { + let mut s = summary.clone(); + s.signature_hex = None; + s.signer_pubkey_hex = None; + serde_json::to_vec(&s).expect("RegionalSummary JSON serialization cannot fail") +} + +/// Verify the biome signature on a summary. `true` only when both signature +/// fields are present and verify over the canonical bytes — any field tamper +/// breaks it. +#[must_use] +pub fn verify_summary(summary: &RegionalSummary) -> bool { + let (Some(sig_hex), Some(pk_hex)) = (&summary.signature_hex, &summary.signer_pubkey_hex) else { + return false; + }; + sig::verify_detached(pk_hex, sig_hex, &canonical_summary_bytes(summary)) +} + +impl Biome { + /// Aggregate accepted observations with `measured_ns` in + /// `[window_start_ns, window_end_ns)` into a per-modality summary, signed + /// with the biome key. Deterministic: plain sum/count means over the + /// arrival-ordered observation log. + #[must_use] + pub fn summarize(&self, window_start_ns: u64, window_end_ns: u64) -> RegionalSummary { + struct Acc { + count: u64, + sum: f64, + min: f64, + max: f64, + quality_sum: f64, + } + let mut acc: BTreeMap = BTreeMap::new(); + for s in self.observations() { + if s.measured_ns < window_start_ns || s.measured_ns >= window_end_ns { + continue; + } + let e = acc.entry(s.modality.as_str().to_string()).or_insert(Acc { + count: 0, + sum: 0.0, + min: f64::INFINITY, + max: f64::NEG_INFINITY, + quality_sum: 0.0, + }); + e.count += 1; + e.sum += s.value; + e.min = e.min.min(s.value); + e.max = e.max.max(s.value); + e.quality_sum += f64::from(s.quality); + } + + let stats = acc + .into_iter() + .map(|(k, a)| { + let n = a.count as f64; + ( + k, + ModalityStats { + count: a.count, + mean: a.sum / n, + min: a.min, + max: a.max, + mean_quality: a.quality_sum / n, + }, + ) + }) + .collect(); + + let mut summary = RegionalSummary { + spec_version: rucelium_core::SPEC_VERSION.into(), + biome_id: self.config().biome_id.clone(), + window_start_ns, + window_end_ns, + stats, + signature_hex: None, + signer_pubkey_hex: None, + }; + self.sign_summary(&mut summary); + summary + } + + /// Sign a summary in place with the biome key (canonical bytes with the + /// signature fields cleared, same pattern as event signing). + pub fn sign_summary(&self, summary: &mut RegionalSummary) { + // Fail closed — see `Biome::sign_event` and `crate::round_trips`. + if !crate::round_trips(summary) { + summary.signature_hex = None; + summary.signer_pubkey_hex = None; + return; + } + let bytes = canonical_summary_bytes(summary); + let signature: Signature = self.signing_key().sign(&bytes); + summary.signature_hex = Some(sig::hex_encode(&signature.to_bytes())); + summary.signer_pubkey_hex = Some(self.public_key_hex()); + } +} + +/// Errors raised by [`FederationBus`] registration and publication, and by +/// [`crate::OutageBuffer`] envelope handling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FederationError { + /// The payload carried no signature / signer key. + Unsigned, + /// The signature did not verify over the canonical bytes. + BadSignature, + /// The payload's `biome_id` is not a registered biome. + UnknownBiome(String), + /// The payload's signer key is not the key registered for its claimed + /// `biome_id` — a registered key may not publish under another biome's + /// identity. + IdentityMismatch { + /// The biome identity the payload claimed. + biome_id: String, + }, + /// Re-registration attempted with a key epoch at or below the current + /// one while changing the key — rotation requires a strictly higher + /// epoch. + StaleKeyEpoch { + /// The biome being (re-)registered. + biome_id: String, + /// The rejected epoch. + epoch: u32, + }, + /// A summary for this `(biome_id, window_start_ns, window_end_ns)` was + /// already accepted — replayed summaries are rejected. + /// An established identity cannot be rebound without a signed + /// succession (ADR-270 §3) — the takeover path, closed. + SuccessionRequired { + /// The biome whose rebinding was refused. + biome_id: String, + }, + /// A succession carried neither the outgoing key's signature nor a + /// sufficient custodian quorum. + SuccessionUnauthorised { + /// The biome the succession targeted. + biome_id: String, + /// Distinct valid custodian signatures present. + custodian_signatures: u32, + /// How many were required. + threshold: u32, + }, + /// A custodian threshold larger than the custodian set can satisfy. + UnreachableThreshold { + /// The biome the declaration targeted. + biome_id: String, + /// The threshold requested. + threshold: u32, + /// How many custodians were declared. + custodians: usize, + }, + DuplicateSummary, + /// An event with this `event_id` was already accepted — replayed events + /// are rejected. + DuplicateEvent, + /// Bytes did not structurally decode as a signed wire envelope. + BadEnvelope(String), +} + +impl std::fmt::Display for FederationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FederationError::Unsigned => write!(f, "payload is unsigned"), + FederationError::BadSignature => write!(f, "signature verification failed"), + FederationError::UnknownBiome(id) => { + write!(f, "not a registered biome: {id}") + } + FederationError::IdentityMismatch { biome_id } => { + write!(f, "signer key is not the registered key for {biome_id}") + } + FederationError::StaleKeyEpoch { biome_id, epoch } => { + write!(f, "stale key epoch {epoch} for {biome_id}") + } + FederationError::SuccessionRequired { biome_id } => write!( + f, + "biome {biome_id} is already bound; rebinding requires a signed succession" + ), + FederationError::SuccessionUnauthorised { + biome_id, + custodian_signatures, + threshold, + } => write!( + f, + "succession for {biome_id} unauthorised: no continuity signature and \ + {custodian_signatures}/{threshold} custodian signatures" + ), + FederationError::UnreachableThreshold { + biome_id, + threshold, + custodians, + } => write!( + f, + "biome {biome_id}: custodian threshold {threshold} exceeds {custodians} declared custodians" + ), + FederationError::DuplicateSummary => { + write!(f, "summary for this biome and window already accepted") + } + FederationError::DuplicateEvent => { + write!(f, "event with this event_id already accepted") + } + FederationError::BadEnvelope(m) => write!(f, "envelope decode failed: {m}"), + } + } +} + +impl std::error::Error for FederationError {} + +/// A signed statement rotating a biome's federation key (ADR-270 §3). +/// +/// This is the artifact that makes rotation *provable* rather than merely +/// asserted. Without it an epoch bump is an unauthenticated rebinding: the +/// loudest claimant wins the identity. With it, a rotation must carry either +/// the outgoing key's signature (continuity) or a quorum of pre-declared +/// custodian signatures (recovery after the holder is gone). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeySuccession { + /// The biome whose key is being rotated. + pub biome_id: String, + /// The epoch this succession replaces; must equal the bus's current epoch + /// so a stale succession cannot be replayed forward. + pub from_epoch: u32, + /// The new epoch; must strictly exceed `from_epoch`. + pub to_epoch: u32, + /// The incoming hex ed25519 public key. + pub new_pubkey_hex: String, + /// When the succession takes effect (ns since Unix epoch); recorded for + /// audit, not enforced by the bus, which has no clock. + pub effective_ns: u64, + /// Optional replacement custodian set — a succession may hand over the + /// recovery quorum as well as the key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_custodians: Option>, + /// Threshold for `new_custodians` when present. + #[serde(default)] + pub new_custodian_threshold: u32, + /// `(signer_pubkey_hex, signature_hex)` pairs over + /// [`canonical_succession_bytes`]. Order is irrelevant; duplicates by the + /// same signer count once toward a quorum. + #[serde(default)] + pub signatures: Vec<(String, String)>, +} + +/// Canonical bytes a succession is signed over: the statement with its +/// `signatures` list cleared, so a signature commits to every other field — +/// including `from_epoch`, which is what stops replay onto a later epoch. +#[must_use] +pub fn canonical_succession_bytes(succession: &KeySuccession) -> Vec { + let mut bare = succession.clone(); + bare.signatures.clear(); + serde_json::to_vec(&bare).unwrap_or_default() +} + +/// Append a signature to a succession using a raw 32-byte ed25519 seed. +/// Used by biome owners (continuity) and custodians (recovery) alike. +pub fn sign_succession(succession: &mut KeySuccession, seed: &[u8; 32]) { + use ed25519_dalek::{Signer as _, SigningKey}; + let key = SigningKey::from_bytes(seed); + let canonical = canonical_succession_bytes(succession); + let sig = key.sign(&canonical); + succession.signatures.push(( + sig::hex_encode(key.verifying_key().as_bytes()), + sig::hex_encode(&sig.to_bytes()), + )); +} + +/// A biome's registered federation identity: current key, rotation epoch, +/// and the custodian quorum that can outlive its holder (ADR-270 §3). +#[derive(Debug, Clone, PartialEq, Eq)] +struct BiomeKey { + /// Hex ed25519 public key currently bound to the biome id. + pubkey_hex: String, + /// Monotonic rotation epoch; a succession must strictly increase it. + key_epoch: u32, + /// Custodian keys able to jointly authorise a rotation when the biome's + /// own key is *lost* rather than compromised (ADR-270 §3). + custodians: std::collections::BTreeSet, + /// How many distinct custodians must sign a recovery succession. + /// Zero means the identity dies with its key — a valid choice. + custodian_threshold: u32, +} + +/// Minimal in-memory federation exchange (ADR-264 §7): registered biomes +/// publish signed summaries and events. Publication binds federation +/// identity to biome identity — the payload's `biome_id` must be registered +/// and its signer key must be the key registered *for that id* — and is +/// replay-protected: a summary window or event id is accepted at most once. +#[derive(Debug, Clone, Default)] +pub struct FederationBus { + /// Registered biome identities and their current keys. + biomes: BTreeMap, + /// Accepted summaries, in publication order. + summaries: Vec, + /// Accepted events, in publication order. + events: Vec, + /// Replay guard: every accepted `(biome_id, window_start_ns, + /// window_end_ns)` summary window. + seen_windows: BTreeSet<(String, u64, u64)>, + /// Replay guard: every accepted `event_id`. + seen_events: BTreeSet, +} + +impl FederationBus { + /// Create an empty bus. + #[must_use] + pub fn new() -> Self { + FederationBus::default() + } + + /// Register a biome's **genesis** key (ADR-270 §3). + /// + /// Genesis is trust-on-first-use and is the *only* unauthenticated + /// binding this bus performs. Re-registering the same key is idempotent; + /// changing a key requires [`Self::rotate_biome`] with a signed + /// succession, because an unauthenticated epoch bump is an identity + /// takeover, not a rotation. + pub fn register_biome( + &mut self, + biome_id: impl Into, + pubkey_hex: impl Into, + key_epoch: u32, + ) -> Result<(), FederationError> { + self.register_biome_with_custodians(biome_id, pubkey_hex, key_epoch, &[], 0) + } + + /// Genesis registration that also declares a custodian recovery quorum. + /// + /// The custodians answer the twenty-year question a lone key cannot: + /// what happens when the *institution* holding it is dissolved, merged, + /// or simply loses it? An m-of-n quorum declared here can jointly + /// authorise a succession without the original key ever being available + /// again. `custodian_threshold = 0` opts out — the identity then dies + /// with its key, which is a legitimate choice, not an oversight. + pub fn register_biome_with_custodians( + &mut self, + biome_id: impl Into, + pubkey_hex: impl Into, + key_epoch: u32, + custodians: &[String], + custodian_threshold: u32, + ) -> Result<(), FederationError> { + let biome_id = biome_id.into(); + let pubkey_hex = pubkey_hex.into(); + if custodian_threshold as usize > custodians.len() { + return Err(FederationError::UnreachableThreshold { + biome_id, + threshold: custodian_threshold, + custodians: custodians.len(), + }); + } + if let Some(current) = self.biomes.get(&biome_id) { + if pubkey_hex == current.pubkey_hex && key_epoch <= current.key_epoch { + return Ok(()); // idempotent re-registration of the same key + } + return Err(FederationError::SuccessionRequired { biome_id }); + } + self.biomes.insert( + biome_id, + BiomeKey { + pubkey_hex, + key_epoch, + custodians: custodians.iter().cloned().collect(), + custodian_threshold, + }, + ); + Ok(()) + } + + /// Rotate a biome's key by presenting a **signed succession** + /// (ADR-270 §3). Two authorisation paths, and no third: + /// + /// 1. **Continuity** — signed by the key currently bound to the biome. + /// 2. **Recovery** — signed by at least `custodian_threshold` *distinct* + /// declared custodians. This is the path that outlives the + /// institution. + /// + /// Everything else is refused, including an epoch bump carrying no + /// signatures — which, before this existed, silently rebound the + /// identity to whoever asked last. + pub fn rotate_biome(&mut self, succession: &KeySuccession) -> Result<(), FederationError> { + let current = self + .biomes + .get(&succession.biome_id) + .ok_or_else(|| FederationError::UnknownBiome(succession.biome_id.clone()))?; + + if succession.to_epoch <= succession.from_epoch + || succession.from_epoch != current.key_epoch + { + return Err(FederationError::StaleKeyEpoch { + biome_id: succession.biome_id.clone(), + epoch: succession.to_epoch, + }); + } + if succession.new_pubkey_hex.is_empty() || succession.signatures.is_empty() { + return Err(FederationError::Unsigned); + } + + let canonical = canonical_succession_bytes(succession); + let continuity = succession + .signatures + .iter() + .any(|(k, sig)| *k == current.pubkey_hex && sig::verify_detached(k, sig, &canonical)); + + let mut quorum = std::collections::BTreeSet::new(); + if current.custodian_threshold > 0 { + for (k, sig) in &succession.signatures { + if current.custodians.contains(k) && sig::verify_detached(k, sig, &canonical) { + quorum.insert(k.clone()); + } + } + } + let recovered = + current.custodian_threshold > 0 && quorum.len() as u32 >= current.custodian_threshold; + + if !continuity && !recovered { + return Err(FederationError::SuccessionUnauthorised { + biome_id: succession.biome_id.clone(), + custodian_signatures: quorum.len() as u32, + threshold: current.custodian_threshold, + }); + } + + // A succession may also hand over the recovery quorum itself. + let (custodians, threshold) = match &succession.new_custodians { + Some(list) => { + if succession.new_custodian_threshold as usize > list.len() { + return Err(FederationError::UnreachableThreshold { + biome_id: succession.biome_id.clone(), + threshold: succession.new_custodian_threshold, + custodians: list.len(), + }); + } + ( + list.iter().cloned().collect(), + succession.new_custodian_threshold, + ) + } + None => (current.custodians.clone(), current.custodian_threshold), + }; + + self.biomes.insert( + succession.biome_id.clone(), + BiomeKey { + pubkey_hex: succession.new_pubkey_hex.clone(), + key_epoch: succession.to_epoch, + custodians, + custodian_threshold: threshold, + }, + ); + Ok(()) + } + + /// The identity gate every published artifact passes: the `biome_id` must + /// be registered, and the signer key must be *the key registered for that + /// id* — not merely some key the bus knows. + fn check_identity( + &self, + biome_id: &str, + signer_pubkey_hex: &str, + ) -> Result<(), FederationError> { + let Some(registered) = self.biomes.get(biome_id) else { + return Err(FederationError::UnknownBiome(biome_id.to_string())); + }; + if registered.pubkey_hex != signer_pubkey_hex { + return Err(FederationError::IdentityMismatch { + biome_id: biome_id.to_string(), + }); + } + Ok(()) + } + + /// Publish a signed regional summary. Checks, in order: signature fields + /// present ([`FederationError::Unsigned`]); `summary.biome_id` registered + /// ([`FederationError::UnknownBiome`]); signer key is the key registered + /// for that id ([`FederationError::IdentityMismatch`] — a registered key + /// claiming another biome's id is rejected); signature verifies + /// ([`FederationError::BadSignature`]); and the `(biome_id, + /// window_start_ns, window_end_ns)` window was never accepted before + /// ([`FederationError::DuplicateSummary`] — replay protection). + pub fn publish(&mut self, summary: RegionalSummary) -> Result<(), FederationError> { + let (Some(_), Some(pk)) = (&summary.signature_hex, &summary.signer_pubkey_hex) else { + return Err(FederationError::Unsigned); + }; + self.check_identity(&summary.biome_id, pk)?; + if !verify_summary(&summary) { + return Err(FederationError::BadSignature); + } + let window = ( + summary.biome_id.clone(), + summary.window_start_ns, + summary.window_end_ns, + ); + if !self.seen_windows.insert(window) { + return Err(FederationError::DuplicateSummary); + } + self.summaries.push(summary); + Ok(()) + } + + /// Publish a signed environmental event with the same identity binding + /// (via `event.biome_id`) and signature checks as [`Self::publish`], + /// plus dedup by `event_id` ([`FederationError::DuplicateEvent`]). + pub fn publish_event(&mut self, event: EnvironmentalEvent) -> Result<(), FederationError> { + let (Some(_), Some(pk)) = (&event.signature_hex, &event.signer_pubkey_hex) else { + return Err(FederationError::Unsigned); + }; + self.check_identity(&event.biome_id, pk)?; + if !verify_event(&event) { + return Err(FederationError::BadSignature); + } + if !self.seen_events.insert(event.event_id.clone()) { + return Err(FederationError::DuplicateEvent); + } + self.events.push(event); + Ok(()) + } + + /// Accepted summaries, in publication order. + #[must_use] + pub fn summaries(&self) -> &[RegionalSummary] { + &self.summaries + } + + /// Accepted events, in publication order. + #[must_use] + pub fn events(&self) -> &[EnvironmentalEvent] { + &self.events + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::biome::BiomeConfig; + use crate::testutil::{pipeline, verified_sample, SEED}; + + const BIOME_ID: &str = "biome/test-forest"; + + /// A biome populated through the sealed ingest path — `summarize` runs + /// over observations that all arrived via `Biome::accept`. + fn biome_with_data() -> Biome { + let mut p = pipeline(&[1, 2]); + let mut b = Biome::new(BiomeConfig::new(BIOME_ID), SEED); + b.accept(verified_sample(&mut p, 1, 1, 1_000, 10.0)); + b.accept(verified_sample(&mut p, 1, 2, 2_000, 20.0)); + b.accept(verified_sample(&mut p, 2, 1, 3_000, 30.0)); + b.accept(verified_sample(&mut p, 2, 2, 9_000, 99.0)); // outside [0, 5000) window + b + } + + /// A registered bus for `biome_with_data`'s biome at epoch 1. + fn registered_bus(b: &Biome) -> FederationBus { + let mut bus = FederationBus::new(); + bus.register_biome(BIOME_ID, b.public_key_hex(), 1).unwrap(); + bus + } + + #[test] + fn summarize_produces_exact_stats_over_sealed_observations() { + let b = biome_with_data(); + assert_eq!(b.accepted_count(), 4); + let s = b.summarize(0, 5_000); + assert_eq!(s.spec_version, rucelium_core::SPEC_VERSION); + assert_eq!(s.biome_id, BIOME_ID); + let w = &s.stats["weather"]; + assert_eq!(w.count, 3); + assert!((w.mean - 20.0).abs() < 1e-12); + assert!((w.min - 10.0).abs() < f64::EPSILON); + assert!((w.max - 30.0).abs() < f64::EPSILON); + // 0x7000 / 0x8000 in Q0.15 = 0.875 exactly. + assert!((w.mean_quality - 0.875).abs() < 1e-12); + // Window is half-open: measured_ns = 9_000 excluded. + assert_eq!(s.stats.len(), 1); + } + + #[test] + fn summary_sign_verify_round_trip_and_tamper() { + let b = biome_with_data(); + let s = b.summarize(0, 5_000); + assert!(verify_summary(&s)); + + // Serde round trip preserves the signature. + let json = serde_json::to_string(&s).unwrap(); + let back: RegionalSummary = serde_json::from_str(&json).unwrap(); + assert!(verify_summary(&back)); + + // Tampered mean fails. + let mut t = s.clone(); + t.stats.get_mut("weather").unwrap().mean = 21.0; + assert!(!verify_summary(&t)); + + // Tampered window fails. + let mut t = s.clone(); + t.window_end_ns += 1; + assert!(!verify_summary(&t)); + + // Unsigned fails. + let mut t = s.clone(); + t.signature_hex = None; + assert!(!verify_summary(&t)); + } + + #[test] + fn bus_rejects_unknown_tampered_and_unsigned_accepts_good() { + let b = biome_with_data(); + let s = b.summarize(0, 5_000); + let mut bus = FederationBus::new(); + + // Unregistered biome id. + assert_eq!( + bus.publish(s.clone()), + Err(FederationError::UnknownBiome(BIOME_ID.into())) + ); + + bus.register_biome(BIOME_ID, b.public_key_hex(), 1).unwrap(); + + // Unsigned. + let mut unsigned = s.clone(); + unsigned.signature_hex = None; + unsigned.signer_pubkey_hex = None; + assert_eq!(bus.publish(unsigned), Err(FederationError::Unsigned)); + + // Tampered. + let mut tampered = s.clone(); + tampered.stats.get_mut("weather").unwrap().count = 999; + assert_eq!(bus.publish(tampered), Err(FederationError::BadSignature)); + + // Good. + bus.publish(s).unwrap(); + assert_eq!(bus.summaries().len(), 1); + } + + #[test] + fn registered_key_claiming_another_biome_id_is_rejected() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + // A second, honestly registered biome with a different key. + let other = Biome::new( + BiomeConfig::new("biome/other"), + b"rucelium-other-seed-32-bytes-ok!", + ); + bus.register_biome("biome/other", other.public_key_hex(), 1) + .unwrap(); + + // Attack: our registered key signs a summary claiming biome/other's + // identity. The signature verifies and the key IS registered — but + // not for that biome_id. + let mut cross = b.summarize(0, 5_000); + cross.biome_id = "biome/other".into(); + b.sign_summary(&mut cross); + assert!(verify_summary(&cross)); + assert_eq!( + bus.publish(cross), + Err(FederationError::IdentityMismatch { + biome_id: "biome/other".into() + }) + ); + assert!(bus.summaries().is_empty()); + } + + #[test] + fn duplicate_summary_window_is_rejected() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let s = b.summarize(0, 5_000); + bus.publish(s.clone()).unwrap(); + // Exact replay. + assert_eq!(bus.publish(s), Err(FederationError::DuplicateSummary)); + // Same window, freshly re-signed: still a duplicate. + let mut again = b.summarize(0, 5_000); + b.sign_summary(&mut again); + assert_eq!(bus.publish(again), Err(FederationError::DuplicateSummary)); + // A different window is fine. + bus.publish(b.summarize(5_000, 10_000)).unwrap(); + assert_eq!(bus.summaries().len(), 2); + } + + #[test] + fn key_rotation_replaces_key_and_rejects_stale_epochs() { + const ROTATED: &[u8; 32] = b"rucelium-rotated-seed-32-bytes-!"; + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let old_key_summary = b.summarize(0, 5_000); + + // Rotation now requires a succession the outgoing key signed + // (ADR-270 §3) — an epoch bump alone is refused. + let rotated = Biome::new(BiomeConfig::new(BIOME_ID), ROTATED); + assert!(matches!( + bus.register_biome(BIOME_ID, rotated.public_key_hex(), 2), + Err(FederationError::SuccessionRequired { .. }) + )); + let mut handover = KeySuccession { + biome_id: BIOME_ID.to_string(), + from_epoch: 1, + to_epoch: 2, + new_pubkey_hex: rotated.public_key_hex(), + effective_ns: 1_000, + new_custodians: None, + new_custodian_threshold: 0, + signatures: Vec::new(), + }; + sign_succession(&mut handover, SEED); + bus.rotate_biome(&handover).unwrap(); + + // The old key's summary is now an identity mismatch. + assert_eq!( + bus.publish(old_key_summary), + Err(FederationError::IdentityMismatch { + biome_id: BIOME_ID.into() + }) + ); + // The rotated key publishes fine. + bus.publish(rotated.summarize(0, 5_000)).unwrap(); + + // Rolling the identity back to the retired key is refused, with or + // without an epoch that looks plausible. + assert!(matches!( + bus.register_biome(BIOME_ID, b.public_key_hex(), 1), + Err(FederationError::SuccessionRequired { .. }) + )); + assert!(matches!( + bus.register_biome(BIOME_ID, b.public_key_hex(), 3), + Err(FederationError::SuccessionRequired { .. }) + )); + // Idempotent re-registration of the current key is still a no-op. + bus.register_biome(BIOME_ID, rotated.public_key_hex(), 2) + .unwrap(); + } + + #[test] + fn bus_publishes_events_with_identity_binding_and_event_dedup() { + let mut b = biome_with_data(); + let event = b.revoke_device(1, 10_000, "compromised"); + let mut bus = FederationBus::new(); + + assert_eq!( + bus.publish_event(event.clone()), + Err(FederationError::UnknownBiome(BIOME_ID.into())) + ); + + bus.register_biome(BIOME_ID, b.public_key_hex(), 1).unwrap(); + + let mut tampered = event.clone(); + tampered.message.push('!'); + assert_eq!( + bus.publish_event(tampered), + Err(FederationError::BadSignature) + ); + + let mut unsigned = event.clone(); + unsigned.signature_hex = None; + assert_eq!(bus.publish_event(unsigned), Err(FederationError::Unsigned)); + + // Identity binding: the same registered key claiming another + // registered biome's id is rejected. + let other = Biome::new( + BiomeConfig::new("biome/other"), + b"rucelium-other-seed-32-bytes-ok!", + ); + bus.register_biome("biome/other", other.public_key_hex(), 1) + .unwrap(); + let mut cross = event.clone(); + cross.biome_id = "biome/other".into(); + b.sign_event(&mut cross); + assert_eq!( + bus.publish_event(cross), + Err(FederationError::IdentityMismatch { + biome_id: "biome/other".into() + }) + ); + + bus.publish_event(event.clone()).unwrap(); + assert_eq!(bus.events().len(), 1); + + // Replay by event_id is rejected. + assert_eq!( + bus.publish_event(event), + Err(FederationError::DuplicateEvent) + ); + assert_eq!(bus.events().len(), 1); + } + + #[test] + fn federation_error_displays() { + assert_eq!(FederationError::Unsigned.to_string(), "payload is unsigned"); + assert!(FederationError::UnknownBiome("biome/x".into()) + .to_string() + .contains("biome/x")); + assert!(!FederationError::BadSignature.to_string().is_empty()); + assert!(FederationError::IdentityMismatch { + biome_id: "biome/x".into() + } + .to_string() + .contains("biome/x")); + assert!(FederationError::StaleKeyEpoch { + biome_id: "biome/x".into(), + epoch: 3 + } + .to_string() + .contains('3')); + assert!(!FederationError::DuplicateSummary.to_string().is_empty()); + assert!(!FederationError::DuplicateEvent.to_string().is_empty()); + assert!(FederationError::BadEnvelope("boom".into()) + .to_string() + .contains("boom")); + } + + /// A signed summary must still verify **after a JSON wire round-trip**. + /// + /// This is the real federation path: a biome signs canonical JSON, sends + /// it, and the peer verifies by re-serializing what it parsed. Exact + /// float parsing is therefore load-bearing — with `serde_json`'s default + /// (fast, non-exact) float parser, roughly 9% of realistic sensor values + /// come back one ULP off and a *genuine* summary fails verification. The + /// workspace pins `serde_json`'s `float_roundtrip` feature for exactly + /// this reason; this test is the guard that keeps it pinned. + #[test] + fn signed_summary_survives_a_json_wire_round_trip() { + let biome = biome_with_data(); + // Values chosen to land on awkward binary fractions. + let mut summary = biome.summarize(0, u64::MAX); + summary.stats.insert( + "weather".into(), + ModalityStats { + count: 3, + mean: 23.470000000000002, + min: 0.1 + 0.2, + max: 1.0e-7 * 3.0, + mean_quality: 0.9700000000000001, + }, + ); + biome.sign_summary(&mut summary); + assert!(verify_summary(&summary), "verifies before the wire"); + + // Exactly what a peer does: serialize, transmit, parse, verify. + let wire = serde_json::to_string(&summary).expect("serialize"); + let received: RegionalSummary = serde_json::from_str(&wire).expect("parse"); + assert!( + verify_summary(&received), + "a genuine signed summary must verify after a JSON wire round-trip" + ); + + // And the floats must be bit-identical, not merely close. + for (k, before) in &summary.stats { + let after = &received.stats[k]; + assert_eq!(before.mean.to_bits(), after.mean.to_bits(), "mean {k}"); + assert_eq!(before.min.to_bits(), after.min.to_bits(), "min {k}"); + assert_eq!(before.max.to_bits(), after.max.to_bits(), "max {k}"); + assert_eq!( + before.mean_quality.to_bits(), + after.mean_quality.to_bits(), + "mean_quality {k}" + ); + } + } + + // --- ADR-270: key succession ------------------------------------------- + + const CUST_A: &[u8; 32] = b"rucelium-custodian-a-seed-32byt!"; + const CUST_B: &[u8; 32] = b"rucelium-custodian-b-seed-32byt!"; + const CUST_C: &[u8; 32] = b"rucelium-custodian-c-seed-32byt!"; + const HEIR: &[u8; 32] = b"rucelium-successor-key-seed-32b!"; + const THIEF: &[u8; 32] = b"rucelium-attacker-key-seed-32by!"; + + fn pubhex(seed: &[u8; 32]) -> String { + use ed25519_dalek::SigningKey; + sig::hex_encode(SigningKey::from_bytes(seed).verifying_key().as_bytes()) + } + + fn succession(from: u32, to: u32, new_key: &str) -> KeySuccession { + KeySuccession { + biome_id: BIOME_ID.to_string(), + from_epoch: from, + to_epoch: to, + new_pubkey_hex: new_key.to_string(), + effective_ns: 1_000, + new_custodians: None, + new_custodian_threshold: 0, + signatures: Vec::new(), + } + } + + /// THE VULNERABILITY THIS CLOSES. Before signed succession existed, a + /// higher epoch alone rebound the identity — so whoever claimed the + /// biome last owned it. An unsigned rotation must now be refused. + #[test] + fn an_unsigned_epoch_bump_cannot_steal_an_identity() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let thief = pubhex(THIEF); + + // The old path: just assert a higher epoch. + assert!(matches!( + bus.register_biome(BIOME_ID, &thief, 999), + Err(FederationError::SuccessionRequired { .. }) + )); + // And via the succession API with no signatures at all. + assert!(matches!( + bus.rotate_biome(&succession(1, 999, &thief)), + Err(FederationError::Unsigned) + )); + // The identity is untouched: the biome's own summary still publishes. + let mut sum = b.summarize(0, 5_000); + b.sign_summary(&mut sum); + assert!(bus.publish(sum).is_ok()); + } + + /// A succession signed by an attacker's key is not authorisation. + #[test] + fn a_succession_signed_by_a_stranger_is_refused() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let mut s = succession(1, 2, &pubhex(HEIR)); + sign_succession(&mut s, THIEF); + assert!(matches!( + bus.rotate_biome(&s), + Err(FederationError::SuccessionUnauthorised { .. }) + )); + } + + /// Continuity: the outgoing key authorises its own replacement. + #[test] + fn the_outgoing_key_can_hand_over() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let heir = pubhex(HEIR); + let mut s = succession(1, 2, &heir); + sign_succession(&mut s, SEED); // SEED is the biome's own key + bus.rotate_biome(&s).expect("continuity succession"); + + // The heir can now publish; the retired key cannot. + let heir_biome = Biome::new(BiomeConfig::new(BIOME_ID), HEIR); + let mut sum = heir_biome.summarize(0, 5_000); + heir_biome.sign_summary(&mut sum); + assert!(bus.publish(sum).is_ok()); + + let mut old = b.summarize(5_001, 9_999); + b.sign_summary(&mut old); + assert!(matches!( + bus.publish(old), + Err(FederationError::IdentityMismatch { .. }) + )); + } + + /// INSTITUTIONAL MORTALITY (ADR-270 §3). The body that held the biome key + /// is dissolved and the key is gone forever. A pre-declared 2-of-3 + /// custodian quorum can still hand the identity to a successor — which is + /// the only reason a 2026 record stays verifiable in 2046. + #[test] + fn custodians_can_recover_an_identity_whose_holder_is_gone() { + let b = biome_with_data(); + let mut bus = FederationBus::new(); + let custodians = vec![pubhex(CUST_A), pubhex(CUST_B), pubhex(CUST_C)]; + bus.register_biome_with_custodians(BIOME_ID, b.public_key_hex(), 1, &custodians, 2) + .unwrap(); + + let heir = pubhex(HEIR); + + // One custodian is not a quorum. + let mut one = succession(1, 2, &heir); + sign_succession(&mut one, CUST_A); + assert!(matches!( + bus.rotate_biome(&one), + Err(FederationError::SuccessionUnauthorised { + custodian_signatures: 1, + threshold: 2, + .. + }) + )); + + // Two are — with no involvement from the original key at all. + let mut two = succession(1, 2, &heir); + sign_succession(&mut two, CUST_A); + sign_succession(&mut two, CUST_B); + bus.rotate_biome(&two).expect("2-of-3 recovery"); + + let heir_biome = Biome::new(BiomeConfig::new(BIOME_ID), HEIR); + let mut sum = heir_biome.summarize(0, 5_000); + heir_biome.sign_summary(&mut sum); + assert!(bus.publish(sum).is_ok()); + } + + /// One custodian signing twice is still one custodian. + #[test] + fn duplicate_custodian_signatures_do_not_make_a_quorum() { + let b = biome_with_data(); + let mut bus = FederationBus::new(); + let custodians = vec![pubhex(CUST_A), pubhex(CUST_B)]; + bus.register_biome_with_custodians(BIOME_ID, b.public_key_hex(), 1, &custodians, 2) + .unwrap(); + let mut s = succession(1, 2, &pubhex(HEIR)); + sign_succession(&mut s, CUST_A); + sign_succession(&mut s, CUST_A); + assert!(matches!( + bus.rotate_biome(&s), + Err(FederationError::SuccessionUnauthorised { + custodian_signatures: 1, + .. + }) + )); + } + + /// A succession is bound to the epoch it was written for, so a captured + /// one cannot be replayed against a later state. + #[test] + fn a_succession_cannot_be_replayed_onto_a_later_epoch() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let mut first = succession(1, 2, &pubhex(HEIR)); + sign_succession(&mut first, SEED); + bus.rotate_biome(&first).unwrap(); + + // Replaying the same statement now targets a stale from_epoch. + assert!(matches!( + bus.rotate_biome(&first), + Err(FederationError::StaleKeyEpoch { .. }) + )); + } + + /// A succession may hand over the recovery quorum as well as the key, + /// and an impossible threshold is refused rather than silently stored. + #[test] + fn a_succession_can_rotate_the_custodian_set() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + + let mut bad = succession(1, 2, &pubhex(HEIR)); + bad.new_custodians = Some(vec![pubhex(CUST_A)]); + bad.new_custodian_threshold = 2; // 2-of-1 is unsatisfiable + sign_succession(&mut bad, SEED); + assert!(matches!( + bus.rotate_biome(&bad), + Err(FederationError::UnreachableThreshold { .. }) + )); + + let mut ok = succession(1, 2, &pubhex(HEIR)); + ok.new_custodians = Some(vec![pubhex(CUST_A), pubhex(CUST_B)]); + ok.new_custodian_threshold = 1; + sign_succession(&mut ok, SEED); + bus.rotate_biome(&ok).expect("quorum handover"); + + // The new quorum is live: one of the new custodians can now recover. + let mut rec = succession(2, 3, &pubhex(THIEF)); + sign_succession(&mut rec, CUST_B); + assert!(bus.rotate_biome(&rec).is_ok()); + } + + /// Genesis with threshold 0 is an explicit choice: no recovery path. + #[test] + fn threshold_zero_means_the_identity_dies_with_its_key() { + let b = biome_with_data(); + let mut bus = FederationBus::new(); + bus.register_biome_with_custodians(BIOME_ID, b.public_key_hex(), 1, &[pubhex(CUST_A)], 0) + .unwrap(); + let mut s = succession(1, 2, &pubhex(HEIR)); + sign_succession(&mut s, CUST_A); + assert!(matches!( + bus.rotate_biome(&s), + Err(FederationError::SuccessionUnauthorised { threshold: 0, .. }) + )); + } + + /// A succession survives the JSON wire the same way summaries must. + #[test] + fn succession_survives_a_json_wire_round_trip() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let mut s = succession(1, 2, &pubhex(HEIR)); + sign_succession(&mut s, SEED); + let wire = serde_json::to_string(&s).unwrap(); + let received: KeySuccession = serde_json::from_str(&wire).unwrap(); + assert_eq!(s, received); + bus.rotate_biome(&received) + .expect("verifies after the wire"); + } + + // --- wire-faithfulness: sign only what round-trips ----------------------- + + /// JSON has no NaN and no Infinity — `serde_json` writes both as `null`, + /// and parsing `null` into an f32 fails. So a signature over a non-finite + /// float verifies IN-PROCESS and is unparseable at the peer: signable, + /// undeliverable. The signing path must refuse instead. + #[test] + fn a_non_finite_float_is_never_signed() { + let b = biome_with_data(); + let mut src = biome_with_data(); + let template = src.revoke_device(1, 10_000, "compromised"); + for poison in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut ev = template.clone(); + ev.confidence = poison; + b.sign_event(&mut ev); + assert!( + ev.signature_hex.is_none(), + "{poison:?} must not be signed — it cannot survive the wire" + ); + assert!(!verify_event(&ev)); + + // And the bus rejects it rather than accepting an unusable artifact. + let mut bus = registered_bus(&b); + assert_eq!(bus.publish_event(ev), Err(FederationError::Unsigned)); + } + } + + /// The same guard on summaries. + #[test] + fn a_summary_with_a_non_finite_stat_is_never_signed() { + let b = biome_with_data(); + let mut sum = b.summarize(0, 5_000); + assert!(verify_summary(&sum), "the honest summary signs"); + + sum.stats.insert( + "weather".into(), + ModalityStats { + count: 1, + mean: f64::NAN, + min: 0.0, + max: 1.0, + mean_quality: 1.0, + }, + ); + b.sign_summary(&mut sum); + assert!(sum.signature_hex.is_none()); + assert!(!verify_summary(&sum)); + } + + /// `round_trips` is the invariant itself: true exactly when serialize → + /// parse → compare is an identity. + #[test] + fn round_trips_detects_exactly_the_unfaithful() { + let b = biome_with_data(); + let good = b.summarize(0, 5_000); + assert!(crate::round_trips(&good)); + + let mut bad = good.clone(); + bad.stats.insert( + "acoustic".into(), + ModalityStats { + count: 1, + mean: 1.0, + min: f64::NEG_INFINITY, + max: 1.0, + mean_quality: 1.0, + }, + ); + assert!(!crate::round_trips(&bad)); + + // Awkward-but-finite values are fine — this is not a blanket ban on + // hard floats, only on ones JSON cannot represent. + let mut fine = good.clone(); + fine.stats.insert( + "soil_moisture".into(), + ModalityStats { + count: 3, + mean: 23.470000000000002, + min: 0.1 + 0.2, + max: f64::MIN_POSITIVE, + mean_quality: 0.9700000000000001, + }, + ); + assert!(crate::round_trips(&fine)); + } + + /// A real summarize() can never produce a non-finite stat: an accumulator + /// exists only when it has at least one sample, so there is no 0/0, and + /// sample values are validated finite before they are ever accepted. + #[test] + fn summarize_cannot_produce_non_finite_stats() { + let b = biome_with_data(); + for window in [(0, 5_000), (0, u64::MAX), (9_000, 9_001), (7_000, 7_000)] { + let s = b.summarize(window.0, window.1); + for (k, st) in &s.stats { + assert!(st.mean.is_finite(), "{k} mean"); + assert!(st.min.is_finite(), "{k} min"); + assert!(st.max.is_finite(), "{k} max"); + assert!(st.mean_quality.is_finite(), "{k} mean_quality"); + } + // An empty window yields no stats at all — not NaN-filled ones. + assert!(crate::round_trips(&s)); + } + } +} diff --git a/crates/rucelium-gateway/Cargo.toml b/crates/rucelium-gateway/Cargo.toml new file mode 100644 index 0000000..ad792ad --- /dev/null +++ b/crates/rucelium-gateway/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "rucelium-gateway" +version.workspace = true +edition.workspace = true +description = "RuCelium rhizome gateway daemon: UDP envelope ingestion (v1/v2/fragments), calibration, durable store, WorldGraph, local alerts, OGC SensorThings HTTP API, and biome federation sync over the network (ADR-265 §4)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "gateway", "daemon", "sensorthings"] +categories = ["science", "web-programming"] + +[[bin]] +name = "rucelium-gateway" +path = "src/main.rs" + +[features] +default = [] +# ADR-269 §4: the optional QUIC federation transport. Off by default — the +# ADR-264 §14 acceptance path and the ADR-265 restart tests must both pass +# with this feature disabled (ADR-269 §5). +quic = ["dep:quinn", "dep:rustls"] + +[dependencies] +rucelium-core = { workspace = true } +rucelium-abi = { workspace = true } +rucelium-transport = { workspace = true } +rucelium-ingest = { workspace = true } +rucelium-calibration = { workspace = true } +rucelium-worldgraph = { workspace = true } +rucelium-federation = { workspace = true } +rucelium-policy = { workspace = true } +rucelium-store = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +axum = "0.7" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal", "sync"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } + +# ADR-269 §4 (optional, `quic` feature only). `rustls` is pinned to the +# *ring* provider so the feature builds without a C toolchain, and because +# ring supplies the Ed25519 signing the biome identity needs (RFC 7250 raw +# public keys, ADR-269 §4 constraint 2). +quinn = { version = "0.11", default-features = false, features = [ + "runtime-tokio", + "rustls-ring", + "log", +], optional = true } +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", +], optional = true } + +[dev-dependencies] +tower = "0.5" + +[lints] +workspace = true diff --git a/crates/rucelium-gateway/src/api.rs b/crates/rucelium-gateway/src/api.rs new file mode 100644 index 0000000..ba2b3d7 --- /dev/null +++ b/crates/rucelium-gateway/src/api.rs @@ -0,0 +1,415 @@ +//! The HTTP API (ADR-265 §4): health, stats, observations, events, an OGC +//! SensorThings-style projection, the federation surface peers poll, and a +//! local admin endpoint. +//! +//! # SECURITY (v0.1) +//! +//! **The admin endpoints carry NO authentication.** `POST +//! /api/admin/revoke/{node_id}` revokes a device key immediately, and `POST +//! /api/admin/command` drives the governed control path. Any deployment +//! beyond a workbench MUST bind the HTTP port to localhost or firewall it; +//! production authentication is deliberate follow-up work (ADR-265 §6). +//! +//! Note what "unauthenticated" does *not* buy an attacker on the command +//! endpoint: the request is a **proposal**, not a command. It still has to +//! clear deterministic policy, the safety envelope, the biome owner's +//! actuator authority grant, ed25519 command signing, gateway validation, and +//! the durable duplicate-command journal before anything executes. + +use crate::control::{actuator_proposal, run_proposal}; +use crate::federation::{accept_artifact, ArtifactEffect, ArtifactRejection}; +use crate::state::{now_ns, GatewayState}; +use crate::transport::FederationArtifact; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use rucelium_core::EventKind; +use rucelium_federation::{project_sample, SensorThingsBundle}; +use rucelium_policy::ControlError; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// Default `limit` for observation/event listings. +const DEFAULT_LIST_LIMIT: usize = 50; +/// Default `limit` for SensorThings listings. +const DEFAULT_ST_LIMIT: usize = 100; +/// Default `window_s` for `/api/federation/summary`. +const DEFAULT_SUMMARY_WINDOW_S: u64 = 3600; + +/// Handler error: status + plain-text reason. +type ApiError = (StatusCode, String); + +/// Map an internal error to a 500 with its message. +fn internal(e: E) -> ApiError { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +/// `?limit=N` query. +#[derive(Debug, Deserialize)] +struct LimitParam { + /// Maximum number of entries to return. + limit: Option, +} + +/// `?window_s=N` query. +#[derive(Debug, Deserialize)] +struct WindowParam { + /// Summary window length in seconds, ending now. + window_s: Option, +} + +/// Build the gateway's axum router (see module docs for the endpoint list +/// and the v0.1 admin-endpoint security posture). +pub fn router(state: GatewayState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/api/stats", get(stats)) + .route("/api/observations/recent", get(observations_recent)) + .route("/api/events", get(events_recent)) + .route("/api/sensorthings/Things", get(st_things)) + .route("/api/sensorthings/Datastreams", get(st_datastreams)) + .route("/api/sensorthings/Observations", get(st_observations)) + .route("/api/federation/pubkey", get(fed_pubkey)) + .route("/api/federation/summary", get(fed_summary)) + .route("/api/federation/revocations", get(fed_revocations)) + .route("/api/federation/peers", get(fed_peers)) + .route("/api/federation/announce", post(fed_announce)) + .route("/api/admin/revoke/:node_id", post(admin_revoke)) + .route("/api/admin/command", post(admin_command)) + .with_state(state) +} + +/// `GET /health` — liveness. +async fn health(State(state): State) -> Json { + Json(json!({ "ok": true, "biome_id": state.biome_id })) +} + +/// `GET /api/stats` — one JSON snapshot of every counter in the daemon. +async fn stats(State(state): State) -> Json { + let inner = state.inner.lock().await; + Json(json!({ + "biome_id": state.biome_id, + "uptime_s": state.started.elapsed().as_secs(), + "ingest": inner.ingest.stats(), + "datagrams": inner.datagrams, + "observations": inner.obs.stats(), + "events": { + "records": inner.events.len(), + "segments": inner.events.segments().len(), + }, + "biome": { + "accepted": inner.biome.accepted_count(), + "duplicates": inner.biome.duplicate_count(), + }, + "worldgraph": { + "nodes": inner.graph.len(), + "contradictions": inner.graph.contradiction_count(), + }, + "alerts": inner.alerts, + "control": inner.control, + "calibration_errors": inner.calibration_errors, + "quarantined_nodes": inner.drift.quarantined(), + "applied_peer_revocations": inner.applied_peer_revocations, + "peer_summaries": inner.peer_summaries.len(), + // Push federation (ADR-269 §3), flat for scriptability and grouped + // for readability. + "pushes_sent": inner.push.pushes_sent, + "pushes_received": inner.push.pushes_received, + "push_failures": inner.push.push_failures, + "backfills": inner.push.backfills, + "push": inner.push, + "known_peers": inner.known_peers.len(), + })) +} + +/// `GET /api/observations/recent?limit=50` — most recent stored samples in +/// append order. +async fn observations_recent( + State(state): State, + Query(q): Query, +) -> Result, ApiError> { + let inner = state.inner.lock().await; + let samples = inner + .obs + .recent(q.limit.unwrap_or(DEFAULT_LIST_LIMIT)) + .map_err(internal)?; + Ok(Json(json!(samples))) +} + +/// `GET /api/events?limit=50` — most recent stored events in append order. +async fn events_recent( + State(state): State, + Query(q): Query, +) -> Result, ApiError> { + let inner = state.inner.lock().await; + let events = inner + .events + .recent(q.limit.unwrap_or(DEFAULT_LIST_LIMIT)) + .map_err(internal)?; + Ok(Json(json!(events))) +} + +/// Project the most recent `limit` stored samples into SensorThings bundles. +async fn recent_bundles( + state: &GatewayState, + limit: usize, +) -> Result, ApiError> { + let inner = state.inner.lock().await; + let samples = inner.obs.recent(limit).map_err(internal)?; + Ok(samples.iter().map(project_sample).collect()) +} + +/// `GET /api/sensorthings/Things?limit=100` — Things over the recent +/// observations, deduplicated by `@iot.id`. +async fn st_things( + State(state): State, + Query(q): Query, +) -> Result, ApiError> { + let bundles = recent_bundles(&state, q.limit.unwrap_or(DEFAULT_ST_LIMIT)).await?; + let mut seen = BTreeSet::new(); + let things: Vec<_> = bundles + .into_iter() + .map(|b| b.thing) + .filter(|t| seen.insert(t.iot_id.clone())) + .collect(); + Ok(Json(json!({ "value": things }))) +} + +/// `GET /api/sensorthings/Datastreams?limit=100` — Datastreams over the +/// recent observations, deduplicated by `@iot.id`. +async fn st_datastreams( + State(state): State, + Query(q): Query, +) -> Result, ApiError> { + let bundles = recent_bundles(&state, q.limit.unwrap_or(DEFAULT_ST_LIMIT)).await?; + let mut seen = BTreeSet::new(); + let streams: Vec<_> = bundles + .into_iter() + .map(|b| b.datastream) + .filter(|d| seen.insert(d.iot_id.clone())) + .collect(); + Ok(Json(json!({ "value": streams }))) +} + +/// `GET /api/sensorthings/Observations?limit=100` — one Observation entity +/// per recent stored sample. +async fn st_observations( + State(state): State, + Query(q): Query, +) -> Result, ApiError> { + let bundles = recent_bundles(&state, q.limit.unwrap_or(DEFAULT_ST_LIMIT)).await?; + let obs: Vec<_> = bundles.into_iter().map(|b| b.observation).collect(); + Ok(Json(json!({ "value": obs }))) +} + +/// `GET /api/federation/pubkey` — the biome's federated identity. +async fn fed_pubkey(State(state): State) -> Json { + let inner = state.inner.lock().await; + Json(json!({ + "biome_id": state.biome_id, + "pubkey_hex": inner.biome.public_key_hex(), + })) +} + +/// `GET /api/federation/summary?window_s=3600` — the signed regional summary +/// over `[now - window_s, now)`. +async fn fed_summary( + State(state): State, + Query(q): Query, +) -> Json { + let window_ns = q + .window_s + .unwrap_or(DEFAULT_SUMMARY_WINDOW_S) + .saturating_mul(1_000_000_000); + let end = now_ns(); + let start = end.saturating_sub(window_ns); + let inner = state.inner.lock().await; + Json(json!(inner.biome.summarize(start, end))) +} + +/// `GET /api/federation/revocations` — every biome-signed `DeviceRevoked` +/// event in the durable event store; peers verify and apply these. +async fn fed_revocations(State(state): State) -> Result, ApiError> { + let inner = state.inner.lock().await; + let revocations: Vec<_> = inner + .events + .iter() + .map_err(internal)? + .into_iter() + .filter(|e| e.kind == EventKind::DeviceRevoked) + .collect(); + Ok(Json(json!(revocations))) +} + +/// `GET /api/federation/peers` — the latest verified summary per peer. +async fn fed_peers(State(state): State) -> Json { + let inner = state.inner.lock().await; + Json(json!(inner.peer_summaries)) +} + +/// `POST /api/federation/announce` — a peer **pushes** one signed artifact +/// at us (ADR-269 §3). This is the HTTP half of push federation: the peer +/// does not wait for our backfill timer, so a revocation propagates at link +/// speed instead of polling speed. +/// +/// The body is verified by exactly the same gate as polled data +/// ([`accept_artifact`], ADR-269 §4 normative): signature, `biome_id → key` +/// identity binding against the key we learned from that peer, and +/// `event_id` dedup. **Unverifiable artifacts get a 4xx and are not +/// applied** — being pushed buys an artifact nothing. +/// +/// * `200` — verified and applied (or stored); `pushes_received` bumped. +/// * `202` — verified, but no state change (duplicate revocation, or a node +/// we have not provisioned; a later backfill re-offers it). +/// * `403` — unsigned, unknown biome, or signer ≠ registered key. +/// * `400` — the signature did not verify. +async fn fed_announce( + State(state): State, + Json(artifact): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let mut inner = state.inner.lock().await; + match accept_artifact(&mut inner, &artifact) { + Ok(effect) => { + inner.push.pushes_received += 1; + let applied = matches!(effect, ArtifactEffect::RevocationApplied); + Ok(( + StatusCode::OK, + Json(json!({ + "ok": true, + "applied": applied, + "effect": match effect { + ArtifactEffect::SummaryStored => "summary_stored", + ArtifactEffect::RevocationApplied => "revocation_applied", + }, + })), + )) + } + // Verified, nothing to do. Accepted, not applied — the peer should + // not treat this as a delivery failure. + Err(ArtifactRejection::NoEffect) => Ok(( + StatusCode::ACCEPTED, + Json(json!({ "ok": true, "applied": false, "effect": "no_effect" })), + )), + Err(e) => { + let status = match e { + ArtifactRejection::BadSignature => StatusCode::BAD_REQUEST, + _ => StatusCode::FORBIDDEN, + }; + Err(( + status, + Json(json!({ "ok": false, "applied": false, "error": e.to_string() })), + )) + } + } +} + +/// `POST /api/admin/revoke/{node_id}` — revoke a device locally: registry +/// revocation (immediate ingest rejection), biome revocation, and a +/// biome-signed `DeviceRevoked` event appended to the event store — the +/// record federation peers pick up. +/// +/// **ADR-269 §3 (push on revocation)**: the signed event is queued for +/// immediate announcement to every configured peer before this handler +/// returns. It does *not* wait for the backfill timer, because revocation +/// latency is a security property and polling caps it at the interval. The +/// push is best-effort by design; the mandatory `sync_since` backstop +/// converges any peer that missed it. +/// +/// **UNAUTHENTICATED in v0.1** — see the module-level SECURITY note. +async fn admin_revoke( + State(state): State, + Path(node_id): Path, +) -> Result, ApiError> { + let mut inner = state.inner.lock().await; + let registry_revoked = inner.ingest.registry_mut().revoke(node_id); + let event = inner + .biome + .revoke_device(node_id, now_ns(), "admin revocation"); + inner.events.append(&event).map_err(internal)?; + drop(inner); + let pushed_to = state.announce_local(FederationArtifact::Event(event.clone())); + Ok(Json(json!({ + "node_id": node_id, + "registry_revoked": registry_revoked, + "pushed": pushed_to > 0, + "event": event, + }))) +} + +/// Request body of `POST /api/admin/command`. +#[derive(Debug, Deserialize)] +struct CommandRequest { + /// Proposal id; the resulting command id is `"cmd-{proposal_id}"`. + proposal_id: String, + /// Proposing agent identity (must hold the biome owner's grant). + agent_id: String, + /// Target actuator (must be in the configured allowed set). + actuator_id: String, + /// Action verb, e.g. `"open_fraction"`. + action: String, + /// Actuator magnitude; policy and safety both bound it. + magnitude: f64, +} + +/// `POST /api/admin/command` — run a proposal through the **whole** governed +/// control path (ADR-264 §9): policy → safety → authority → sign → gateway +/// validate → execute → signed receipt. +/// +/// `200` carries the gateway-signed [`rucelium_policy::ExecutionReceipt`]; +/// anything stopped along the way returns a non-2xx with a JSON error body +/// (`409 Conflict` for a duplicate command id — including one restored from +/// the durable journal after a restart — and `422` for every other refusal). +/// A refused proposal never produces a receipt. +/// +/// **UNAUTHENTICATED in v0.1** — see the module-level SECURITY note. +async fn admin_command( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let mut inner = state.inner.lock().await; + let now = now_ns(); + let proposal = actuator_proposal( + &state.biome_id, + &req.proposal_id, + &req.agent_id, + &req.actuator_id, + &req.action, + req.magnitude, + now, + ); + match run_proposal(&mut inner, proposal, now) { + Ok(receipt) => Ok(Json(json!({ "ok": true, "receipt": receipt }))), + Err(e) => { + let status = if matches!(e, ControlError::DuplicateCommand(_)) { + StatusCode::CONFLICT + } else { + StatusCode::UNPROCESSABLE_ENTITY + }; + Err(( + status, + Json(json!({ + "ok": false, + "error": e.to_string(), + "stage": control_stage(&e), + })), + )) + } + } +} + +/// Which gate stopped a proposal, as a stable machine-readable string. +fn control_stage(e: &ControlError) -> &'static str { + match e { + ControlError::PolicyViolation(_) => "policy", + ControlError::Unsafe(_) => "safety", + ControlError::NotAuthorized { .. } => "authority", + ControlError::UntrustedKey(_) + | ControlError::BadSignature + | ControlError::BadEncoding(_) => "gateway_signature", + ControlError::Expired { .. } => "gateway_freshness", + ControlError::DuplicateCommand(_) => "gateway_duplicate", + ControlError::ExecutionFailed(_) => "execution", + } +} diff --git a/crates/rucelium-gateway/src/config.rs b/crates/rucelium-gateway/src/config.rs new file mode 100644 index 0000000..faf2b44 --- /dev/null +++ b/crates/rucelium-gateway/src/config.rs @@ -0,0 +1,255 @@ +//! Gateway configuration: defaults plus a hand-rolled, unit-testable CLI +//! argument parser (no `clap` — zero new dependencies, ADR-265 §4). + +use std::path::PathBuf; + +/// Default biome identity. +pub const DEFAULT_BIOME_ID: &str = "biome/dev"; +/// Default UDP ingest port (ADR-265 §4). +pub const DEFAULT_UDP_PORT: u16 = 7464; +/// Default HTTP API port (ADR-265 §4). +pub const DEFAULT_HTTP_PORT: u16 = 7465; +/// Default on-disk data directory. +pub const DEFAULT_DATA_DIR: &str = "./rucelium-data"; +/// Default deterministic seed (biome identity + synthetic node keys). +pub const DEFAULT_SEED: u64 = 2026; +/// Default synthetic-node emission interval in milliseconds. +pub const DEFAULT_SIM_INTERVAL_MS: u64 = 1000; +/// Default retention-enforcement check interval in seconds. +pub const DEFAULT_RETENTION_CHECK_SECS: u64 = 3600; +/// Default peer federation poll interval in milliseconds. +pub const DEFAULT_FEDERATION_POLL_MS: u64 = 30_000; +/// Default actuator the biome owner exposes to the governed control path. +pub const DEFAULT_ACTUATOR_ID: &str = "sluice-gate-1"; +/// Default durability mode for the durable stores: the daemon fsyncs every +/// accepted append, so an accepted record survives power loss. +pub const DEFAULT_FSYNC: bool = true; + +/// Runtime configuration of one gateway daemon instance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayConfig { + /// Biome identity (e.g. `biome/thames-estuary`). Also seeds the biome's + /// deterministic signing key (see `GatewayState::open`). + pub biome_id: String, + /// UDP ingest port (`0` = ephemeral, useful for tests). + pub udp_port: u16, + /// HTTP API port (`0` = ephemeral, useful for tests). + pub http_port: u16, + /// Data directory; observation and event segments live in `obs/` and + /// `events/` beneath it. + pub data_dir: PathBuf, + /// Peer gateway base URLs to federate with (repeatable `--peer`). + pub peers: Vec, + /// Number of SYNTHETIC spore nodes to simulate (`0` = none). + pub simulate: u32, + /// Deterministic seed for the biome key and synthetic node keys. + pub seed: u64, + /// Synthetic-node emission interval in milliseconds. + pub sim_interval_ms: u64, + /// Retention-enforcement check interval in seconds. + pub retention_check_secs: u64, + /// Peer federation poll interval in milliseconds (short in tests). + pub federation_poll_ms: u64, + /// Interval of the **mandatory** `sync_since` backfill backstop, in + /// milliseconds (ADR-269 §3). `None` inherits [`Self::federation_poll_ms`], + /// so the ADR-265 §4 behaviour is the default; set it explicitly to slow + /// the backstop down once push is carrying the latency-critical traffic. + pub federation_backfill_ms: Option, + /// The actuator id the biome owner grants `agent/flood` authority over + /// (ADR-264 §6: actuator authority never leaves the biome owner). + pub actuator_id: String, + /// Durability mode for `ObservationStore` / `EventStore`: `true` fsyncs + /// every accepted append before it is acknowledged. + pub fsync: bool, +} + +impl Default for GatewayConfig { + fn default() -> Self { + GatewayConfig { + biome_id: DEFAULT_BIOME_ID.to_string(), + udp_port: DEFAULT_UDP_PORT, + http_port: DEFAULT_HTTP_PORT, + data_dir: PathBuf::from(DEFAULT_DATA_DIR), + peers: Vec::new(), + simulate: 0, + seed: DEFAULT_SEED, + sim_interval_ms: DEFAULT_SIM_INTERVAL_MS, + retention_check_secs: DEFAULT_RETENTION_CHECK_SECS, + federation_poll_ms: DEFAULT_FEDERATION_POLL_MS, + federation_backfill_ms: None, + actuator_id: DEFAULT_ACTUATOR_ID.to_string(), + fsync: DEFAULT_FSYNC, + } + } +} + +impl GatewayConfig { + /// Interval of the ADR-269 §3 backfill backstop, in milliseconds: + /// [`Self::federation_backfill_ms`] when set, otherwise the ADR-265 §4 + /// [`Self::federation_poll_ms`]. + #[must_use] + pub fn federation_backfill_ms(&self) -> u64 { + self.federation_backfill_ms + .unwrap_or(self.federation_poll_ms) + } + + /// Parse CLI arguments (without the program name). Unknown flags and + /// malformed values are hard errors — the daemon never guesses. + pub fn from_args(args: Vec) -> Result { + let mut config = GatewayConfig::default(); + let mut it = args.into_iter(); + while let Some(flag) = it.next() { + let mut value = + |flag: &str| it.next().ok_or_else(|| format!("missing value for {flag}")); + match flag.as_str() { + "--biome-id" => config.biome_id = value("--biome-id")?, + "--udp" => config.udp_port = parse_num(&value("--udp")?, "--udp")?, + "--http" => config.http_port = parse_num(&value("--http")?, "--http")?, + "--data-dir" => config.data_dir = PathBuf::from(value("--data-dir")?), + "--peer" => config.peers.push(value("--peer")?), + "--simulate" => config.simulate = parse_num(&value("--simulate")?, "--simulate")?, + "--seed" => config.seed = parse_num(&value("--seed")?, "--seed")?, + "--sim-interval-ms" => { + config.sim_interval_ms = + parse_num(&value("--sim-interval-ms")?, "--sim-interval-ms")?; + } + "--retention-check-secs" => { + config.retention_check_secs = + parse_num(&value("--retention-check-secs")?, "--retention-check-secs")?; + } + "--federation-poll-ms" => { + config.federation_poll_ms = + parse_num(&value("--federation-poll-ms")?, "--federation-poll-ms")?; + } + "--federation-backfill-ms" => { + config.federation_backfill_ms = Some(parse_num( + &value("--federation-backfill-ms")?, + "--federation-backfill-ms", + )?); + } + "--actuator" => config.actuator_id = value("--actuator")?, + "--fsync" => config.fsync = parse_num(&value("--fsync")?, "--fsync")?, + unknown => return Err(format!("unknown flag {unknown}")), + } + } + Ok(config) + } +} + +/// Parse a numeric flag value with a diagnostic naming the flag. +fn parse_num(raw: &str, flag: &str) -> Result { + raw.parse::() + .map_err(|_| format!("invalid value {raw:?} for {flag}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(ToString::to_string).collect() + } + + #[test] + fn defaults_match_adr_265() { + let c = GatewayConfig::from_args(Vec::new()).unwrap(); + assert_eq!(c, GatewayConfig::default()); + assert_eq!(c.biome_id, "biome/dev"); + assert_eq!(c.udp_port, 7464); + assert_eq!(c.http_port, 7465); + assert_eq!(c.data_dir, PathBuf::from("./rucelium-data")); + assert!(c.peers.is_empty()); + assert_eq!(c.simulate, 0); + assert_eq!(c.seed, 2026); + assert_eq!(c.sim_interval_ms, 1000); + assert_eq!(c.retention_check_secs, 3600); + assert_eq!(c.federation_poll_ms, 30_000); + // ADR-269 §3: the backstop defaults to the ADR-265 §4 poll interval. + assert_eq!(c.federation_backfill_ms, None); + assert_eq!(c.federation_backfill_ms(), 30_000); + assert_eq!(c.actuator_id, "sluice-gate-1"); + assert!(c.fsync, "the daemon fsyncs accepted appends by default"); + } + + #[test] + fn backfill_interval_overrides_the_poll_interval_when_set() { + let c = GatewayConfig::from_args(args(&[ + "--federation-poll-ms", + "200", + "--federation-backfill-ms", + "60000", + ])) + .unwrap(); + assert_eq!(c.federation_poll_ms, 200); + assert_eq!(c.federation_backfill_ms, Some(60_000)); + assert_eq!(c.federation_backfill_ms(), 60_000); + } + + #[test] + fn all_flags_parse() { + let c = GatewayConfig::from_args(args(&[ + "--biome-id", + "biome/x", + "--udp", + "1111", + "--http", + "2222", + "--data-dir", + "/tmp/gw", + "--simulate", + "8", + "--seed", + "42", + "--sim-interval-ms", + "250", + "--retention-check-secs", + "60", + "--federation-poll-ms", + "200", + "--actuator", + "weir-3", + "--fsync", + "false", + ])) + .unwrap(); + assert_eq!(c.biome_id, "biome/x"); + assert_eq!(c.udp_port, 1111); + assert_eq!(c.http_port, 2222); + assert_eq!(c.data_dir, PathBuf::from("/tmp/gw")); + assert_eq!(c.simulate, 8); + assert_eq!(c.seed, 42); + assert_eq!(c.sim_interval_ms, 250); + assert_eq!(c.retention_check_secs, 60); + assert_eq!(c.federation_poll_ms, 200); + assert_eq!(c.actuator_id, "weir-3"); + assert!(!c.fsync); + } + + #[test] + fn peer_is_repeatable_in_order() { + let c = GatewayConfig::from_args(args(&[ + "--peer", + "http://a:7465", + "--peer", + "http://b:7465", + ])) + .unwrap(); + assert_eq!(c.peers, vec!["http://a:7465", "http://b:7465"]); + } + + #[test] + fn unknown_flag_is_an_error() { + let err = GatewayConfig::from_args(args(&["--nope"])).unwrap_err(); + assert!(err.contains("--nope"), "{err}"); + } + + #[test] + fn missing_and_malformed_values_are_errors() { + let err = GatewayConfig::from_args(args(&["--udp"])).unwrap_err(); + assert!(err.contains("--udp"), "{err}"); + let err = GatewayConfig::from_args(args(&["--udp", "not-a-port"])).unwrap_err(); + assert!(err.contains("not-a-port"), "{err}"); + let err = GatewayConfig::from_args(args(&["--seed", "-3"])).unwrap_err(); + assert!(err.contains("--seed"), "{err}"); + } +} diff --git a/crates/rucelium-gateway/src/control.rs b/crates/rucelium-gateway/src/control.rs new file mode 100644 index 0000000..81a60c8 --- /dev/null +++ b/crates/rucelium-gateway/src/control.rs @@ -0,0 +1,266 @@ +//! The daemon's end of the governed control path (ADR-264 §9). +//! +//! The policy crate enforces stage *ordering* with type privacy — there is no +//! way to obtain an `AuthorizedProposal` without passing policy and safety +//! first. What the daemon owns is everything the library deliberately does +//! not: the wall clock, the local execution effect, the durable command +//! journal, and the budget bookkeeping that must only be charged once a +//! command actually ran. +//! +//! ```text +//! AgentProposal → PolicyEngine → SafetySimulator → AuthorityRegistry +//! → CommandSigner → GatewayValidator → execute → receipt +//! ``` +//! +//! Ordering of the post-execution steps matters: +//! +//! 1. `validate_and_execute` records `Executing` before the closure runs and +//! `Executed`/`Failed` after it; +//! 2. the journal is rewritten **after every attempt** — success or failure — +//! so a crash can never leave an executed command absent from disk; +//! 3. only then is the safety budget charged (`record_execution`), because a +//! budget is spent by execution, not by proposing. + +use crate::state::Inner; +use rucelium_policy::{AgentProposal, ControlError, ExecutionReceipt, ProposalKind, SignedCommand}; + +/// How long a signed command stays valid (1 hour) — long enough for a slow +/// local link, short enough that a captured command is not replayable forever. +pub const COMMAND_TTL_NS: u64 = 3_600 * 1_000_000_000; + +/// Build the actuator proposal the admin endpoint drives. +#[must_use] +pub fn actuator_proposal( + biome_id: &str, + proposal_id: &str, + agent_id: &str, + actuator_id: &str, + action: &str, + magnitude: f64, + now_ns: u64, +) -> AgentProposal { + AgentProposal { + proposal_id: proposal_id.to_string(), + agent_id: agent_id.to_string(), + biome_id: biome_id.to_string(), + kind: ProposalKind::ActuatorCommand { + actuator_id: actuator_id.to_string(), + action: action.to_string(), + magnitude, + }, + justification: format!("admin control request for {actuator_id}"), + proposed_ns: now_ns, + } +} + +/// Run one proposal through **every** stage of the governed path and, on +/// success, record the signed receipt. +/// +/// Returns the gateway's signed [`ExecutionReceipt`] or the first +/// [`ControlError`] that stopped the proposal. Either way the command journal +/// is rewritten before returning, and the control-path counters are updated. +pub fn run_proposal( + inner: &mut Inner, + proposal: AgentProposal, + now_ns: u64, +) -> Result { + let outcome = drive(inner, proposal, now_ns); + + // Journal after every attempt (including rejections: `validate_and_execute` + // may have recorded a phase before failing inside the closure). + if let Err(e) = inner.journal_command_phases() { + eprintln!("gateway: command journal write failed: {e}"); + } + + match &outcome { + Ok(receipt) => { + inner.control.commands_executed += 1; + inner.receipts.push(receipt.clone()); + inner.control.receipts = inner.receipts.len() as u64; + } + Err(_) => inner.control.proposals_rejected += 1, + } + outcome +} + +/// The stage pipeline itself, factored out so [`run_proposal`] can journal and +/// count around it on every path. +fn drive( + inner: &mut Inner, + proposal: AgentProposal, + now_ns: u64, +) -> Result { + // Split borrows: every stage needs a different field of `Inner` plus the + // shared audit trail. + let Inner { + policy, + safety, + authority, + command_signer, + gateway, + audit, + .. + } = inner; + + let evaluated = policy.evaluate(proposal, now_ns, audit)?; + let simulated = safety.simulate(evaluated, now_ns, audit)?; + let authorized = authority.authorize(simulated, now_ns, audit)?; + let command: SignedCommand = command_signer.sign(authorized, now_ns, COMMAND_TTL_NS, audit); + + let receipt = gateway.validate_and_execute(&command, now_ns, execute_locally, audit)?; + + // Charged only now: the command really ran (ADR-264 §9 "checked at + // safety, charged at execution"). + if let ProposalKind::ActuatorCommand { actuator_id, .. } = &command.payload.kind { + safety.record_execution(actuator_id); + } + Ok(receipt) +} + +/// The local execution effect. v0.1 has no physical actuator wired up, so +/// this is an honest description of what *would* be driven — it never claims +/// a physical effect it cannot produce (ADR-264 §12). +fn execute_locally(kind: &ProposalKind) -> Result { + match kind { + ProposalKind::ActuatorCommand { + actuator_id, + action, + magnitude, + } => Ok(format!("{actuator_id}: {action}={magnitude}")), + ProposalKind::SetSamplingRate { + node_id, + interval_s, + } => Ok(format!("node {node_id}: sampling interval {interval_s}s")), + ProposalKind::DeployModel { model_id, .. } => Ok(format!("model {model_id} staged")), + ProposalKind::RepositionSensor { node_id, .. } => { + Ok(format!("node {node_id}: reposition scheduled")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::testutil::test_inner; + use crate::state::GRANTED_AGENT_ID; + use rucelium_policy::verify_receipt; + + const NOW: u64 = 1_754_000_000_000_000_000; + const ACTUATOR: &str = "sluice-gate-1"; + + fn proposal(inner: &Inner, id: &str, agent: &str, magnitude: f64) -> AgentProposal { + let biome_id = inner.biome.config().biome_id.clone(); + actuator_proposal( + &biome_id, + id, + agent, + ACTUATOR, + "open_fraction", + magnitude, + NOW, + ) + } + + #[test] + fn authorized_proposal_executes_once_and_yields_a_verifiable_receipt() { + let mut inner = test_inner("control-ok"); + let p = proposal(&inner, "42", GRANTED_AGENT_ID, 0.5); + let receipt = run_proposal(&mut inner, p, NOW).expect("authorized command executes"); + + assert_eq!(receipt.command_id, "cmd-42"); + assert!( + verify_receipt(&receipt), + "receipts are gateway attestations" + ); + assert_eq!( + receipt.gateway_pubkey_hex, + inner.gateway.gateway_pubkey_hex() + ); + assert_eq!(inner.control.commands_executed, 1); + assert_eq!(inner.control.receipts, 1); + + // The journal recorded the executed phase durably. + assert_eq!( + crate::journal::load(&inner.command_journal).unwrap(), + vec![("cmd-42".to_string(), "executed".to_string())] + ); + + // A replay of the same command id is rejected, and no second receipt + // is produced. + let again = proposal(&inner, "42", GRANTED_AGENT_ID, 0.5); + assert!(matches!( + run_proposal(&mut inner, again, NOW), + Err(ControlError::DuplicateCommand(id)) if id == "cmd-42" + )); + assert_eq!(inner.receipts.len(), 1); + assert_eq!(inner.control.proposals_rejected, 1); + } + + #[test] + fn unauthorized_agent_never_reaches_signing() { + let mut inner = test_inner("control-unauthorized"); + let p = proposal(&inner, "rogue-1", "agent/unknown", 0.5); + assert!(matches!( + run_proposal(&mut inner, p, NOW), + Err(ControlError::NotAuthorized { .. }) + )); + assert_eq!(inner.control.commands_executed, 0); + assert!(inner.receipts.is_empty()); + // Nothing was ever executed, so the journal stays empty. + assert!(crate::journal::load(&inner.command_journal) + .unwrap() + .is_empty()); + } + + #[test] + fn unsafe_magnitude_is_stopped_by_the_safety_gate() { + let mut inner = test_inner("control-unsafe"); + // Policy allows up to 1.0; the safety envelope stops at 0.8. + let p = proposal(&inner, "hot-1", GRANTED_AGENT_ID, 0.95); + assert!(matches!( + run_proposal(&mut inner, p, NOW), + Err(ControlError::Unsafe(_)) + )); + assert_eq!(inner.control.proposals_rejected, 1); + } + + #[test] + fn unknown_actuator_is_stopped_by_policy() { + let mut inner = test_inner("control-unknown-actuator"); + let biome_id = inner.biome.config().biome_id.clone(); + let p = actuator_proposal( + &biome_id, + "other-1", + GRANTED_AGENT_ID, + "not-configured", + "open_fraction", + 0.5, + NOW, + ); + assert!(matches!( + run_proposal(&mut inner, p, NOW), + Err(ControlError::PolicyViolation(_)) + )); + } + + #[test] + fn safety_budget_is_charged_only_by_execution() { + let mut inner = test_inner("control-budget"); + // 10 successful executions exhaust `SafetyConfig::max_commands_per_actuator`. + for i in 0..10 { + let p = proposal(&inner, &format!("b-{i}"), GRANTED_AGENT_ID, 0.5); + run_proposal(&mut inner, p, NOW).expect("within budget"); + } + let p = proposal(&inner, "b-over", GRANTED_AGENT_ID, 0.5); + assert!(matches!( + run_proposal(&mut inner, p, NOW), + Err(ControlError::Unsafe(_)) + )); + assert_eq!(inner.control.commands_executed, 10); + // All ten phases are journaled. + assert_eq!( + crate::journal::load(&inner.command_journal).unwrap().len(), + 10 + ); + } +} diff --git a/crates/rucelium-gateway/src/federation.rs b/crates/rucelium-gateway/src/federation.rs new file mode 100644 index 0000000..e3ca12c --- /dev/null +++ b/crates/rucelium-gateway/src/federation.rs @@ -0,0 +1,693 @@ +//! Transport-driven, push-first biome federation (ADR-269 §3). +//! +//! Federation used to be a 30 s poller (ADR-265 §4). ADR-269 §3 calls that +//! out as a *security* problem before a performance one: polling caps +//! revocation latency at the polling interval, so a revoked device stayed +//! valid at peer gateways for up to 30 s after its owner revoked it. The +//! task below is therefore **push first, transport second**: +//! +//! * a locally minted artifact (today: a `DeviceRevoked` event from +//! `POST /api/admin/revoke/{node_id}`) is +//! [`announce`](crate::transport::FederationTransport::announce)d to every +//! peer the instant it exists — no waiting for a tick; +//! * [`subscribe`](crate::transport::FederationTransport::subscribe) drains +//! whatever a peer streamed at us; +//! * **the polling backstop is mandatory** (ADR-269 §3): +//! [`sync_since`](crate::transport::FederationTransport::sync_since) still +//! runs at startup, on reconnect, and on a slow timer, so a peer that +//! missed a push still converges. +//! +//! # Verification is transport-independent (ADR-269 §4, normative) +//! +//! **Everything received — over any transport, pushed or polled — goes +//! through exactly the same verification.** [`accept_artifact`] is the one +//! gate: the artifact's claimed `biome_id` must resolve to a federation key +//! this gateway learned from the peer itself, the artifact's signer key must +//! *be* that key, the ed25519 signature must verify over the canonical +//! bytes, and revocations are idempotent by `event_id`. A QUIC session, an +//! authenticated HTTP connection, or any other channel property is **never** +//! the reason an artifact is trusted; if it ever becomes one, that is a +//! regression. Unverifiable data is skipped and logged — never applied, +//! never repaired (ADR-264 §12). + +use crate::state::{now_ns, GatewayState, Inner, KnownPeer, PeerSummary}; +use crate::transport::{FederationArtifact, FederationTransport, PeerRef, TransportError}; +use rucelium_core::{EnvironmentalEvent, EventKind}; +use rucelium_federation::{verify_event, verify_summary}; +use std::sync::Arc; +use std::time::Duration; + +/// How often `subscribe` is drained. Only transports with real server push +/// (QUIC) return anything; for HTTP this is a no-op timer with no I/O. +const SUBSCRIBE_TICK: Duration = Duration::from_millis(200); +/// Floor on the backfill interval, so a misconfigured `0` cannot spin. +const MIN_BACKFILL_MS: u64 = 50; + +/// Why a received artifact was refused. Every variant means **not applied** +/// and, at the `POST /api/federation/announce` endpoint, a 4xx. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtifactRejection { + /// The artifact carried no signer key at all. + Unsigned, + /// The claimed `biome_id` is not a peer whose federation key this + /// gateway has learned. An unknown biome cannot be identity-bound, so it + /// cannot be trusted (ADR-269 §4). + UnknownBiome(String), + /// The signer key is not the key registered for the claimed `biome_id` — + /// a valid signature from some *other* key is still refused, because + /// peers may only speak on their own authority. + IdentityMismatch { + /// The biome identity the artifact claimed. + biome_id: String, + }, + /// The ed25519 signature did not verify over the canonical bytes. + BadSignature, + /// Verified, but nothing to do: a non-revocation event, a duplicate + /// `event_id`, or a revocation for a node this gateway has never + /// provisioned (left unapplied so a later provisioning picks it up). + NoEffect, +} + +impl std::fmt::Display for ArtifactRejection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ArtifactRejection::Unsigned => write!(f, "artifact is unsigned"), + ArtifactRejection::UnknownBiome(id) => { + write!(f, "not a known federation peer biome: {id}") + } + ArtifactRejection::IdentityMismatch { biome_id } => { + write!(f, "signer key is not the registered key for {biome_id}") + } + ArtifactRejection::BadSignature => write!(f, "signature verification failed"), + ArtifactRejection::NoEffect => write!(f, "verified, but no state change"), + } + } +} + +impl std::error::Error for ArtifactRejection {} + +/// What a verified artifact did to local state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtifactEffect { + /// A verified peer summary was stored. + SummaryStored, + /// A verified `DeviceRevoked` event was applied to the local registry. + RevocationApplied, +} + +/// Learn (or refresh) a peer's federation identity — the `biome_id → key` +/// binding [`accept_artifact`] resolves against. The peer's own published +/// key is authoritative for that peer, exactly as in the ADR-265 §4 poller. +pub fn register_peer_identity(inner: &mut Inner, biome_id: &str, pubkey_hex: &str, url: &str) { + inner.known_peers.insert( + biome_id.to_string(), + KnownPeer { + biome_id: biome_id.to_string(), + pubkey_hex: pubkey_hex.to_string(), + url: url.to_string(), + }, + ); +} + +/// Apply one peer `DeviceRevoked` event to the local registry. Returns +/// `true` only when the event was **verified and newly applied**: +/// +/// 1. `kind == DeviceRevoked`; +/// 2. the event's signer key equals the peer's published key (a valid +/// signature from any *other* key is refused — peers may only revoke on +/// their own authority); +/// 3. the ed25519 signature verifies over the canonical event bytes; +/// 4. the `event_id` was not already applied; +/// 5. the target node is registered locally (otherwise the event is left +/// unapplied so a later provisioning can pick it up on the next poll). +/// +/// Factored out of the network task so it is unit-testable without any I/O. +/// +/// **Normative (ADR-269 §4)**: this function is transport-independent and is +/// the *only* way a peer revocation reaches the registry. A revocation that +/// arrived over QUIC, over an HTTP push, or over an HTTP backfill runs these +/// same five checks; no transport property substitutes for any of them. +pub fn apply_peer_revocation( + inner: &mut Inner, + event: &EnvironmentalEvent, + peer_pubkey_hex: &str, +) -> bool { + if event.kind != EventKind::DeviceRevoked { + return false; + } + if event.signer_pubkey_hex.as_deref() != Some(peer_pubkey_hex) { + return false; + } + if !verify_event(event) { + return false; + } + let Some(evidence) = event.evidence.first() else { + return false; + }; + if inner.applied_revocation_ids.contains(&event.event_id) { + return false; + } + if inner.ingest.registry().get(evidence.node_id).is_none() { + return false; + } + inner.ingest.registry_mut().revoke(evidence.node_id); + inner.applied_revocation_ids.insert(event.event_id.clone()); + inner.applied_peer_revocations += 1; + true +} + +/// **The one verification gate** every federation artifact passes through, +/// whatever transport delivered it and whether it was pushed or polled +/// (ADR-269 §4, normative). +/// +/// In order: +/// +/// 1. the artifact must carry a signer key ([`ArtifactRejection::Unsigned`]); +/// 2. its claimed `biome_id` must be a peer whose key this gateway learned +/// from that peer ([`ArtifactRejection::UnknownBiome`]); +/// 3. its signer key must be *that* key +/// ([`ArtifactRejection::IdentityMismatch`] — a registered key claiming +/// another biome's identity is refused); +/// 4. the ed25519 signature must verify over the canonical bytes +/// ([`ArtifactRejection::BadSignature`]); +/// 5. summaries replace the stored summary for that biome; revocations go +/// through [`apply_peer_revocation`], which dedups by `event_id`. +pub fn accept_artifact( + inner: &mut Inner, + artifact: &FederationArtifact, +) -> Result { + let Some(signer) = artifact.signer_pubkey_hex() else { + return Err(ArtifactRejection::Unsigned); + }; + let biome_id = artifact.biome_id().to_string(); + let Some(known) = inner.known_peers.get(&biome_id) else { + return Err(ArtifactRejection::UnknownBiome(biome_id)); + }; + if known.pubkey_hex != signer { + return Err(ArtifactRejection::IdentityMismatch { biome_id }); + } + let peer_key = known.pubkey_hex.clone(); + let peer_url = known.url.clone(); + + match artifact { + FederationArtifact::Summary(summary) => { + if !verify_summary(summary) { + return Err(ArtifactRejection::BadSignature); + } + inner + .peer_summaries + .retain(|p| p.summary.biome_id != summary.biome_id); + inner.peer_summaries.push(PeerSummary { + peer: peer_url, + summary: summary.clone(), + fetched_ns: now_ns(), + }); + Ok(ArtifactEffect::SummaryStored) + } + FederationArtifact::Event(event) => { + if !verify_event(event) { + return Err(ArtifactRejection::BadSignature); + } + if apply_peer_revocation(inner, event, &peer_key) { + Ok(ArtifactEffect::RevocationApplied) + } else { + Err(ArtifactRejection::NoEffect) + } + } + } +} + +/// Run the transport-driven federation task forever (ADR-269 §3). +/// +/// Three things happen concurrently, and none can starve the others: +/// +/// * **push out** — every artifact published on [`GatewayState::push_tx`] is +/// announced to every peer immediately; +/// * **push in** — `subscribe` is drained on a fast tick (a no-op for +/// transports without server push); +/// * **backstop** — `sync_since` runs on `backfill_ms`, starting +/// immediately, so identities are learned at startup and a peer that +/// missed a push converges anyway. +/// +/// Peer failures are logged and never fatal: a dead peer must not stop the +/// others (or the gateway). +pub async fn run_federation( + state: GatewayState, + transport: Arc, + peers: Vec, + backfill_ms: u64, +) { + let mut push_rx = state.push_tx.subscribe(); + run_federation_with_receiver(state, transport, peers, backfill_ms, &mut push_rx).await; +} + +/// [`run_federation`] over a receiver the caller subscribed *before* +/// spawning, so an artifact minted between spawn and first poll is not lost. +pub async fn run_federation_with_receiver( + state: GatewayState, + transport: Arc, + peers: Vec, + backfill_ms: u64, + push_rx: &mut tokio::sync::broadcast::Receiver, +) { + /// Per-peer backfill cursor alongside the peer reference. + struct Tracked { + /// Address and (once learned) identity. + peer: PeerRef, + /// `sync_since` cursor: ns of the last successful backfill. + last_sync_ns: u64, + } + + let mut tracked: Vec = peers + .into_iter() + .map(|url| Tracked { + peer: PeerRef::new(url), + last_sync_ns: 0, + }) + .collect(); + + let mut backfill_tick = + tokio::time::interval(Duration::from_millis(backfill_ms.max(MIN_BACKFILL_MS))); + let mut subscribe_tick = tokio::time::interval(SUBSCRIBE_TICK); + + loop { + tokio::select! { + // Push out: an artifact this gateway just minted. + received = push_rx.recv() => match received { + Ok(artifact) => { + for t in &tracked { + announce_to_peer(&state, transport.as_ref(), &t.peer, &artifact).await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + eprintln!("gateway: federation push queue lagged, {n} artifact(s) dropped; the sync_since backstop will converge peers"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + }, + + // Push in: drain anything a peer streamed at us. + _ = subscribe_tick.tick() => { + for t in &tracked { + drain_subscription(&state, transport.as_ref(), &t.peer).await; + } + } + + // The mandatory backstop (ADR-269 §3). + _ = backfill_tick.tick() => { + for t in &mut tracked { + let now = now_ns(); + match backfill_peer(&state, transport.as_ref(), &mut t.peer, t.last_sync_ns).await { + Ok(()) => t.last_sync_ns = now, + Err(e) => eprintln!( + "gateway: federation backfill of peer {} over {}: {e}", + t.peer.url, + transport.name() + ), + } + } + } + } + } +} + +/// Push one artifact to one peer, counting the outcome. A failed push is +/// logged and counted, never retried inline: [`backfill_peer`] is the +/// convergence path (ADR-269 §3). +async fn announce_to_peer( + state: &GatewayState, + transport: &dyn FederationTransport, + peer: &PeerRef, + artifact: &FederationArtifact, +) { + match transport.announce(peer, artifact).await { + Ok(()) => state.inner.lock().await.push.pushes_sent += 1, + Err(e) => { + state.inner.lock().await.push.push_failures += 1; + eprintln!( + "gateway: federation push to peer {} over {}: {e}", + peer.url, + transport.name() + ); + } + } +} + +/// Drain whatever the peer streamed at us and run every artifact through +/// [`accept_artifact`] — same verification as the polled path. +async fn drain_subscription( + state: &GatewayState, + transport: &dyn FederationTransport, + peer: &PeerRef, +) { + let artifacts = match transport.subscribe(peer).await { + Ok(a) => a, + Err(e) => { + eprintln!( + "gateway: federation subscribe to peer {} over {}: {e}", + peer.url, + transport.name() + ); + return; + } + }; + if artifacts.is_empty() { + return; + } + let mut inner = state.inner.lock().await; + for artifact in &artifacts { + match accept_artifact(&mut inner, artifact) { + Ok(effect) => { + inner.push.pushes_received += 1; + eprintln!( + "gateway: streamed artifact from peer {}: {effect:?}", + peer.url + ); + } + Err(ArtifactRejection::NoEffect) => {} + Err(e) => eprintln!( + "gateway: rejecting streamed artifact from peer {}: {e}", + peer.url + ), + } + } +} + +/// One backfill pass against one peer (ADR-269 §3): learn/refresh identity, +/// then `sync_since`, then verify and apply everything it returned. +async fn backfill_peer( + state: &GatewayState, + transport: &dyn FederationTransport, + peer: &mut PeerRef, + since_ns: u64, +) -> Result<(), TransportError> { + if let Some(identity) = transport.identity(peer).await? { + peer.biome_id = Some(identity.biome_id.clone()); + peer.pubkey_hex = Some(identity.pubkey_hex.clone()); + let mut inner = state.inner.lock().await; + register_peer_identity( + &mut inner, + &identity.biome_id, + &identity.pubkey_hex, + &peer.url, + ); + } + + let artifacts = transport.sync_since(peer, since_ns).await?; + let mut inner = state.inner.lock().await; + for artifact in &artifacts { + match accept_artifact(&mut inner, artifact) { + Ok(ArtifactEffect::RevocationApplied) => eprintln!( + "gateway: applied revocation from peer {} (backfill)", + peer.url + ), + Ok(ArtifactEffect::SummaryStored) | Err(ArtifactRejection::NoEffect) => {} + Err(e) => eprintln!( + "gateway: skipping unverifiable artifact from peer {}: {e}", + peer.url + ), + } + } + inner.push.backfills += 1; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::testutil::test_inner; + use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; + use rucelium_federation::{Biome, BiomeConfig}; + use rucelium_ingest::RejectReason; + + const PEER_SEED: &[u8; 32] = b"rucelium-peer-biome-seed-32-b!!!"; + const OTHER_SEED: &[u8; 32] = b"rucelium-wrong-key-seed-32-byte!"; + const NODE_SEED: &[u8; 32] = b"rucelium-gateway-test-seed-32b!!"; + const NODE: u64 = 0x5C00_0000_0000_0042; + const PEER_BIOME: &str = "biome/peer"; + const PEER_URL: &str = "http://peer.invalid:7465"; + + /// A valid wire sample from `NODE`. + fn wire(sequence: u32) -> RvEnvSampleV1 { + RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: 5, // weather + flags: 0, + node_id: NODE, + timestamp_ns: 1_754_000_000_000_000_000, + sequence, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: 16 * 65_536, + quality_q15: 0x7000, + battery_mv: 3_600, + calibration_id: 0, + } + } + + fn peer_biome() -> Biome { + Biome::new(BiomeConfig::new(PEER_BIOME), PEER_SEED) + } + + fn inner_with_registered_node(tag: &str) -> Inner { + let mut inner = test_inner(tag); + inner.ingest.registry_mut().register( + NODE, + NodeSigner::for_node(NODE_SEED, NODE).public_key(), + "sha256:fw".into(), + ); + inner + } + + /// An inner with `NODE` provisioned and the peer biome's identity known. + fn inner_knowing_peer(tag: &str, peer: &Biome) -> Inner { + let mut inner = inner_with_registered_node(tag); + register_peer_identity(&mut inner, PEER_BIOME, &peer.public_key_hex(), PEER_URL); + inner + } + + #[test] + fn verified_peer_revocation_is_applied_once_and_registry_rejects() { + let mut inner = inner_with_registered_node("fed-apply"); + let mut peer = peer_biome(); + let event = peer.revoke_device(NODE, 1_000, "compromised"); + + assert!(apply_peer_revocation( + &mut inner, + &event, + &peer.public_key_hex() + )); + assert!(inner.ingest.registry().is_revoked(NODE)); + assert_eq!(inner.applied_peer_revocations, 1); + + // Idempotent: the same event never counts twice. + assert!(!apply_peer_revocation( + &mut inner, + &event, + &peer.public_key_hex() + )); + assert_eq!(inner.applied_peer_revocations, 1); + + // The revoked node's envelopes are rejected at ingest from now on. + let env = NodeSigner::for_node(NODE_SEED, NODE) + .sign_sample(&wire(1)) + .encode(); + assert_eq!( + inner.ingest.ingest(&env, crate::state::now_ns()), + Err(RejectReason::RevokedDevice(NODE)) + ); + } + + #[test] + fn event_signed_by_the_wrong_key_is_not_applied() { + let mut inner = inner_with_registered_node("fed-wrong-key"); + let peer = peer_biome(); + // A different biome signs a revocation but claims the peer's slot. + let mut impostor = Biome::new(BiomeConfig::new("biome/impostor"), OTHER_SEED); + let event = impostor.revoke_device(NODE, 1_000, "forged"); + + // The impostor's signature is valid — but not the peer's key. + assert!(verify_event(&event)); + assert!(!apply_peer_revocation( + &mut inner, + &event, + &peer.public_key_hex() + )); + assert!(!inner.ingest.registry().is_revoked(NODE)); + assert_eq!(inner.applied_peer_revocations, 0); + } + + #[test] + fn tampered_or_wrong_kind_events_are_not_applied() { + let mut inner = inner_with_registered_node("fed-tamper"); + let mut peer = peer_biome(); + let event = peer.revoke_device(NODE, 1_000, "compromised"); + + let mut tampered = event.clone(); + tampered.message.push('!'); + assert!(!apply_peer_revocation( + &mut inner, + &tampered, + &peer.public_key_hex() + )); + + let mut wrong_kind = event.clone(); + wrong_kind.kind = EventKind::Anomaly; + peer.sign_event(&mut wrong_kind); + assert!(!apply_peer_revocation( + &mut inner, + &wrong_kind, + &peer.public_key_hex() + )); + + assert!(!inner.ingest.registry().is_revoked(NODE)); + assert_eq!(inner.applied_peer_revocations, 0); + } + + #[test] + fn unregistered_node_leaves_event_unapplied_for_retry() { + let mut inner = test_inner("fed-unregistered"); + let mut peer = peer_biome(); + let event = peer.revoke_device(NODE, 1_000, "compromised"); + assert!(!apply_peer_revocation( + &mut inner, + &event, + &peer.public_key_hex() + )); + // After provisioning, the same event applies on the next poll. + inner + .ingest + .registry_mut() + .register(NODE, [0xAA; 32], "sha256:fw".into()); + assert!(apply_peer_revocation( + &mut inner, + &event, + &peer.public_key_hex() + )); + assert!(inner.ingest.registry().is_revoked(NODE)); + } + + // --- ADR-269 §4: the one verification gate, exercised directly. --- + + #[test] + fn accepted_artifacts_apply_revocations_and_store_summaries() { + let mut peer = peer_biome(); + let mut inner = inner_knowing_peer("gate-accept", &peer); + + let summary = FederationArtifact::Summary(peer.summarize(0, 5_000)); + assert_eq!( + accept_artifact(&mut inner, &summary), + Ok(ArtifactEffect::SummaryStored) + ); + assert_eq!(inner.peer_summaries.len(), 1); + assert_eq!(inner.peer_summaries[0].peer, PEER_URL); + + let event = FederationArtifact::Event(peer.revoke_device(NODE, 1_000, "compromised")); + assert_eq!( + accept_artifact(&mut inner, &event), + Ok(ArtifactEffect::RevocationApplied) + ); + assert!(inner.ingest.registry().is_revoked(NODE)); + + // Idempotent: the same pushed event applies exactly once. + assert_eq!( + accept_artifact(&mut inner, &event), + Err(ArtifactRejection::NoEffect) + ); + assert_eq!(inner.applied_peer_revocations, 1); + + // A second summary for the same biome replaces, never accumulates. + let again = FederationArtifact::Summary(peer.summarize(5_000, 10_000)); + assert_eq!( + accept_artifact(&mut inner, &again), + Ok(ArtifactEffect::SummaryStored) + ); + assert_eq!(inner.peer_summaries.len(), 1); + } + + #[test] + fn unknown_biome_and_unsigned_artifacts_are_refused() { + let mut peer = peer_biome(); + let mut inner = inner_with_registered_node("gate-unknown"); + let event = FederationArtifact::Event(peer.revoke_device(NODE, 1_000, "compromised")); + // Nothing learned yet: the biome cannot be identity-bound. + assert_eq!( + accept_artifact(&mut inner, &event), + Err(ArtifactRejection::UnknownBiome(PEER_BIOME.into())) + ); + assert!(!inner.ingest.registry().is_revoked(NODE)); + + register_peer_identity(&mut inner, PEER_BIOME, &peer.public_key_hex(), PEER_URL); + let FederationArtifact::Event(raw) = event else { + unreachable!("constructed as an event") + }; + let mut unsigned = raw; + unsigned.signature_hex = None; + unsigned.signer_pubkey_hex = None; + assert_eq!( + accept_artifact(&mut inner, &FederationArtifact::Event(unsigned)), + Err(ArtifactRejection::Unsigned) + ); + assert!(!inner.ingest.registry().is_revoked(NODE)); + } + + #[test] + fn pushed_artifact_signed_by_another_key_is_an_identity_mismatch() { + let peer = peer_biome(); + let mut inner = inner_knowing_peer("gate-identity", &peer); + + // An impostor with a perfectly valid signature claims the peer's + // biome id. Signature validity is not identity (ADR-269 §4). + let mut impostor = Biome::new(BiomeConfig::new(PEER_BIOME), OTHER_SEED); + let forged = impostor.revoke_device(NODE, 1_000, "forged"); + assert!(verify_event(&forged)); + assert_ne!(forged.signer_pubkey_hex, Some(peer.public_key_hex())); + assert_eq!(forged.biome_id, PEER_BIOME); + + assert_eq!( + accept_artifact(&mut inner, &FederationArtifact::Event(forged)), + Err(ArtifactRejection::IdentityMismatch { + biome_id: PEER_BIOME.into() + }) + ); + assert!(!inner.ingest.registry().is_revoked(NODE)); + assert_eq!(inner.applied_peer_revocations, 0); + } + + #[test] + fn pushed_artifact_with_a_bad_signature_is_refused() { + let mut peer = peer_biome(); + let mut inner = inner_knowing_peer("gate-badsig", &peer); + + let mut event = peer.revoke_device(NODE, 1_000, "compromised"); + event.message.push('!'); // tamper after signing + assert_eq!( + accept_artifact(&mut inner, &FederationArtifact::Event(event)), + Err(ArtifactRejection::BadSignature) + ); + assert!(!inner.ingest.registry().is_revoked(NODE)); + + let mut summary = peer.summarize(0, 5_000); + summary.window_end_ns += 1; // tamper after signing + assert_eq!( + accept_artifact(&mut inner, &FederationArtifact::Summary(summary)), + Err(ArtifactRejection::BadSignature) + ); + assert!(inner.peer_summaries.is_empty()); + } + + #[test] + fn rejection_reasons_display() { + assert!(ArtifactRejection::Unsigned.to_string().contains("unsigned")); + assert!(ArtifactRejection::UnknownBiome("biome/x".into()) + .to_string() + .contains("biome/x")); + assert!(ArtifactRejection::IdentityMismatch { + biome_id: "biome/x".into() + } + .to_string() + .contains("biome/x")); + assert!(!ArtifactRejection::BadSignature.to_string().is_empty()); + assert!(!ArtifactRejection::NoEffect.to_string().is_empty()); + } +} diff --git a/crates/rucelium-gateway/src/journal.rs b/crates/rucelium-gateway/src/journal.rs new file mode 100644 index 0000000..94e44f2 --- /dev/null +++ b/crates/rucelium-gateway/src/journal.rs @@ -0,0 +1,178 @@ +//! The durable command-phase journal (ADR-265 §4, ADR-264 §9 restart +//! posture). +//! +//! [`rucelium_policy::GatewayValidator`] keeps the command lifecycle table +//! ([`rucelium_policy::CommandPhase`]) in memory; the **daemon owns the +//! disk**. This module is that ownership: a tiny `commands.jsonl` file, one +//! JSON object per line (`{"command_id":"cmd-42","phase":"executed"}`), +//! rewritten from `GatewayValidator::export_phases()` after every execution +//! attempt and fed back through `GatewayValidator::restore_phases()` on +//! startup. +//! +//! Why a full rewrite rather than an append: the table is bounded by the +//! number of command ids a gateway has ever seen (tiny), and a rewrite makes +//! the file a straightforward snapshot with no compaction story. The write is +//! atomic — a temporary file is written, flushed, fsynced, and renamed over +//! the journal — so a crash mid-write leaves the previous complete journal +//! intact rather than a truncated one. +//! +//! Recovery is deliberately **fail-open on parse, fail-closed on content**: a +//! malformed line is skipped (a corrupt journal must not stop the gateway +//! from booting), but every phase it *does* restore — including `executing`, +//! left behind by a crash mid-execution — permanently blocks re-execution of +//! that command id. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// File name of the command journal inside the gateway data directory. +pub const JOURNAL_FILE: &str = "commands.jsonl"; + +/// The journal path for a data directory. +#[must_use] +pub fn journal_path(data_dir: &Path) -> PathBuf { + data_dir.join(JOURNAL_FILE) +} + +/// One journaled command phase. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct JournalLine { + /// The command id (`"cmd-{proposal_id}"`). + command_id: String, + /// Phase string, as produced by `CommandPhase::as_str`. + phase: String, +} + +/// Load the journaled `(command_id, phase)` pairs, ready to hand to +/// `GatewayValidator::restore_phases`. +/// +/// A missing journal yields an empty list (fresh data directory). Individual +/// unparseable lines are skipped so a damaged journal cannot wedge startup; +/// an unreadable *file* is an error the caller should surface. +pub fn load(path: &Path) -> Result, String> { + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(format!("read command journal {}: {e}", path.display())), + }; + let mut out = Vec::new(); + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(entry) => out.push((entry.command_id, entry.phase)), + Err(e) => eprintln!( + "gateway: skipping malformed command-journal line in {}: {e}", + path.display() + ), + } + } + Ok(out) +} + +/// Atomically rewrite the journal from a `GatewayValidator::export_phases()` +/// snapshot: write a sibling temp file, flush, `sync_data`, then rename over +/// the journal (and fsync the directory so the rename itself is durable). +pub fn store(path: &Path, phases: &[(String, String)]) -> Result<(), String> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let tmp = path.with_extension("jsonl.tmp"); + + let mut buf = String::new(); + for (command_id, phase) in phases { + let line = serde_json::to_string(&JournalLine { + command_id: command_id.clone(), + phase: phase.clone(), + }) + .map_err(|e| format!("encode command journal: {e}"))?; + buf.push_str(&line); + buf.push('\n'); + } + + { + let mut file = + fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?; + file.write_all(buf.as_bytes()) + .map_err(|e| format!("write {}: {e}", tmp.display()))?; + file.flush() + .map_err(|e| format!("flush {}: {e}", tmp.display()))?; + file.sync_data() + .map_err(|e| format!("fsync {}: {e}", tmp.display()))?; + } + fs::rename(&tmp, path).map_err(|e| format!("rename into {}: {e}", path.display()))?; + // Best effort: fsync the directory so the rename survives power loss. + // Not every platform/filesystem supports opening a directory for sync; + // failure here does not invalidate the (already durable) file contents. + if let Ok(handle) = fs::File::open(dir) { + let _ = handle.sync_all(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::testutil::temp_dir; + + #[test] + fn missing_journal_loads_empty() { + let dir = temp_dir("journal-missing"); + assert!(load(&journal_path(&dir)).unwrap().is_empty()); + } + + #[test] + fn store_then_load_round_trips_in_order() { + let dir = temp_dir("journal-round-trip"); + fs::create_dir_all(&dir).unwrap(); + let path = journal_path(&dir); + let phases = vec![ + ("cmd-1".to_string(), "executed".to_string()), + ("cmd-2".to_string(), "executing".to_string()), + ("cmd-3".to_string(), "failed".to_string()), + ]; + store(&path, &phases).unwrap(); + assert_eq!(load(&path).unwrap(), phases); + + // Rewriting replaces the whole snapshot (no stale leftovers). + let smaller = vec![("cmd-9".to_string(), "executed".to_string())]; + store(&path, &smaller).unwrap(); + assert_eq!(load(&path).unwrap(), smaller); + + // No temp file is left behind. + assert!(!path.with_extension("jsonl.tmp").exists()); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn malformed_lines_are_skipped_not_fatal() { + let dir = temp_dir("journal-malformed"); + fs::create_dir_all(&dir).unwrap(); + let path = journal_path(&dir); + fs::write( + &path, + "{\"command_id\":\"cmd-1\",\"phase\":\"executed\"}\nnot json\n\n{\"command_id\":\"cmd-2\",\"phase\":\"failed\"}\n", + ) + .unwrap(); + assert_eq!( + load(&path).unwrap(), + vec![ + ("cmd-1".to_string(), "executed".to_string()), + ("cmd-2".to_string(), "failed".to_string()), + ] + ); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn empty_snapshot_writes_an_empty_journal() { + let dir = temp_dir("journal-empty"); + fs::create_dir_all(&dir).unwrap(); + let path = journal_path(&dir); + store(&path, &[]).unwrap(); + assert!(path.exists()); + assert!(load(&path).unwrap().is_empty()); + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/rucelium-gateway/src/lib.rs b/crates/rucelium-gateway/src/lib.rs new file mode 100644 index 0000000..04d4285 --- /dev/null +++ b/crates/rucelium-gateway/src/lib.rs @@ -0,0 +1,219 @@ +//! # rucelium-gateway +//! +//! The RuCelium rhizome gateway daemon (ADR-265 §4): one tokio/axum binary +//! composing the library crates into the ADR-264 Layer-2 rhizome. +//! +//! ```text +//! UDP :7464 ──► envelope detect (v1 CBOR / v2 compact / fragments) +//! ──► reassemble ──► registry + signature + anti-replay (ingest) +//! ──► calibration + drift quarantine +//! ──► ObservationStore (disk) + WorldGraph + local alert rules +//! ──► EventStore + biome-signed events +//! HTTP :7465 ──► /health /api/stats /api/observations/recent /api/events +//! ──► /api/sensorthings/{Things,Datastreams,Observations} +//! ──► /api/federation/{pubkey,summary,revocations,peers} +//! ──► /api/federation/announce (push inbox, ADR-269 §3) +//! ──► /api/admin/{revoke/:node_id,command} +//! ``` +//! +//! ## Push federation (ADR-269 §3) +//! +//! Federation is push-first over a swappable [`transport::FederationTransport`]: +//! a revocation is `announce`d to every peer the instant it is signed, rather +//! than waiting up to a polling interval — revocation latency is a security +//! property. [`transport::HttpPollTransport`] is the always-available default; +//! `transport_quic::QuicTransport` is optional behind the `quic` feature. +//! The `sync_since` backstop is **mandatory** on every transport, so a peer +//! that missed a push still converges, and everything received — pushed or +//! polled, over any transport — passes the same +//! [`federation::accept_artifact`] verification (ADR-269 §4). +//! +//! ## Restart safety (ADR-265) +//! +//! Two pieces of security state are durable and restored by +//! [`GatewayState::open`], because neither is meaningful if it only lasts as +//! long as the process: +//! +//! * **Replay protection** — the ingest anti-replay windows are primed from +//! the observation store's durable dedup index +//! (`IngestPipeline::prime_from_dedup`), so a signed packet already ingested +//! before a restart is still rejected as a replay afterwards, even if +//! retention has since deleted the segment holding its payload. +//! * **Command de-duplication** — the governed control path's command phase +//! table is journaled to `commands.jsonl` after every execution attempt and +//! restored on startup, so a command id never executes twice across a +//! restart (see [`journal`] and [`control`]). +//! +//! A background task federates with configured peers (verified signed +//! summaries and `DeviceRevoked` events only — ADR-264 §6), a retention +//! timer enforces the ADR-264 §10 lifespans, and `--simulate N` spawns a +//! clearly-labelled SYNTHETIC spore-node traffic generator. +//! +//! **SECURITY (v0.1)**: the HTTP admin endpoints are unauthenticated — bind +//! the HTTP port to localhost or firewall it (see [`api`]). + +#![doc(html_root_url = "https://docs.rs/rucelium-gateway/0.1.0")] + +pub mod api; +pub mod config; +pub mod control; +pub mod federation; +pub mod journal; +pub mod net; +pub mod pipeline; +pub mod simulate; +pub mod state; +pub mod transport; +#[cfg(feature = "quic")] +pub mod transport_quic; + +pub use config::GatewayConfig; +pub use control::run_proposal; +pub use pipeline::{process_datagram, ProcessOutcome}; +pub use state::{ControlStats, GatewayState, Inner, KnownPeer, PeerSummary, PushStats}; +pub use transport::{ + FederationArtifact, FederationTransport, HttpPollTransport, PeerRef, TransportError, +}; + +use rucelium_core::DataClass; +use std::sync::Arc; +use std::time::Duration; +use tokio::task::JoinHandle; + +/// A running gateway: its shared state, the actual bound ports (useful when +/// the config asked for port `0`), and the spawned task handles. +pub struct GatewayHandle { + /// Shared runtime state (also usable for test provisioning). + pub state: GatewayState, + /// Actual UDP ingest port. + pub udp_port: u16, + /// Actual HTTP API port. + pub http_port: u16, + /// Every background task spawned for this gateway. Aborting them (or + /// dropping the runtime) stops the gateway. + pub tasks: Vec>, +} + +/// Open the gateway state and start the full stack (UDP loop, HTTP server, +/// retention timer, federation poller when peers are configured, simulator +/// when `--simulate N > 0`). Binds both ports before returning, so callers +/// can pass port `0` and read the real ports from the handle. +pub async fn spawn_gateway(config: GatewayConfig) -> Result { + let state = GatewayState::open(&config)?; + spawn_gateway_with_state(state, config).await +} + +/// Like [`spawn_gateway`], but over a pre-built [`GatewayState`] — lets +/// tests provision devices deterministically before any traffic or +/// federation poll can race them. +/// +/// Federates over [`HttpPollTransport`], the always-available default +/// (ADR-269 §3). Use [`spawn_gateway_with_transport`] to federate over the +/// optional QUIC transport instead. +pub async fn spawn_gateway_with_state( + state: GatewayState, + config: GatewayConfig, +) -> Result { + let transport = Arc::new(HttpPollTransport::new()?); + spawn_gateway_with_transport(state, config, transport).await +} + +/// Like [`spawn_gateway_with_state`], but over a caller-chosen federation +/// transport (ADR-269 §3: "the transport becomes swappable, so … anything +/// else can be added later without touching federation logic"). +pub async fn spawn_gateway_with_transport( + state: GatewayState, + config: GatewayConfig, + transport: Arc, +) -> Result { + let udp = tokio::net::UdpSocket::bind(("0.0.0.0", config.udp_port)) + .await + .map_err(|e| format!("bind udp port {}: {e}", config.udp_port))?; + let udp_port = udp + .local_addr() + .map_err(|e| format!("udp local_addr: {e}"))? + .port(); + let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.http_port)) + .await + .map_err(|e| format!("bind http port {}: {e}", config.http_port))?; + let http_port = listener + .local_addr() + .map_err(|e| format!("http local_addr: {e}"))? + .port(); + + let mut tasks = Vec::new(); + tasks.push(tokio::spawn(net::run_udp(udp, state.clone()))); + + let router = api::router(state.clone()); + tasks.push(tokio::spawn(async move { + if let Err(e) = axum::serve(listener, router).await { + eprintln!("gateway: http server error: {e}"); + } + })); + + tasks.push(tokio::spawn(run_retention( + state.clone(), + config.retention_check_secs, + ))); + + if !config.peers.is_empty() { + // Subscribe *before* spawning, so an artifact minted between here + // and the task's first poll is still pushed (ADR-269 §3). + let mut push_rx = state.push_tx.subscribe(); + let fed_state = state.clone(); + let peers = config.peers.clone(); + let backfill_ms = config.federation_backfill_ms(); + tasks.push(tokio::spawn(async move { + federation::run_federation_with_receiver( + fed_state, + transport, + peers, + backfill_ms, + &mut push_rx, + ) + .await; + })); + } + + if config.simulate > 0 { + tasks.push(tokio::spawn(simulate::run_simulator( + state.clone(), + config.simulate, + config.seed, + config.sim_interval_ms, + udp_port, + ))); + } + + Ok(GatewayHandle { + state, + udp_port, + http_port, + tasks, + }) +} + +/// Retention timer: every `check_secs`, drop expired observation segments +/// (normalized samples are `DataClass::DerivedFeature`, ADR-264 §10 — raw +/// signal never reaches the store, and events keep their years-long +/// retention untouched in v0.1) and evict stale partial reassemblies. +async fn run_retention(state: GatewayState, check_secs: u64) { + /// Partial messages older than this are abandoned (lost fragments). + const FRAG_TIMEOUT_NS: u64 = 60_000_000_000; + let retention_ns = DataClass::DerivedFeature.default_retention_ns(); + let mut tick = tokio::time::interval(Duration::from_secs(check_secs.max(1))); + tick.tick().await; // consume the immediate first tick + loop { + tick.tick().await; + let now = state::now_ns(); + let mut inner = state.inner.lock().await; + match inner.obs.enforce_retention(now, retention_ns) { + Ok(0) => {} + Ok(n) => eprintln!("gateway: retention deleted {n} expired observations"), + Err(e) => eprintln!("gateway: retention enforcement failed: {e}"), + } + inner + .reassembler + .evict_older_than(now.saturating_sub(FRAG_TIMEOUT_NS)); + } +} diff --git a/crates/rucelium-gateway/src/main.rs b/crates/rucelium-gateway/src/main.rs new file mode 100644 index 0000000..8e8370c --- /dev/null +++ b/crates/rucelium-gateway/src/main.rs @@ -0,0 +1,60 @@ +//! `rucelium-gateway` binary: parse CLI args, print the startup banner, +//! start the full stack, and run until Ctrl-C (ADR-265 §4). + +use rucelium_gateway::{spawn_gateway, GatewayConfig}; + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let config = match GatewayConfig::from_args(args) { + Ok(c) => c, + Err(e) => { + eprintln!("rucelium-gateway: {e}"); + eprintln!( + "usage: rucelium-gateway [--biome-id ] [--udp ] [--http ] \ + [--data-dir ] [--peer ]... [--simulate ] [--seed ] \ + [--sim-interval-ms ] [--retention-check-secs ] \ + [--federation-poll-ms ] [--actuator ] [--fsync ]" + ); + std::process::exit(2); + } + }; + + println!("rucelium-gateway (ADR-265 rhizome daemon)"); + println!(" biome: {}", config.biome_id); + println!(" udp: {}", config.udp_port); + println!(" http: {}", config.http_port); + println!(" data dir: {}", config.data_dir.display()); + println!(" fsync: {}", config.fsync); + println!(" actuator: {}", config.actuator_id); + println!(" simulate: {} synthetic node(s)", config.simulate); + if config.peers.is_empty() { + println!(" peers: none"); + } else { + for peer in &config.peers { + println!(" peer: {peer}"); + } + } + println!(" WARNING: admin endpoints are UNAUTHENTICATED in v0.1 — bind"); + println!(" the http port to localhost or firewall it."); + + let handle = match spawn_gateway(config).await { + Ok(h) => h, + Err(e) => { + eprintln!("rucelium-gateway: startup failed: {e}"); + std::process::exit(1); + } + }; + println!( + " listening: udp 0.0.0.0:{} http http://0.0.0.0:{}", + handle.udp_port, handle.http_port + ); + + if let Err(e) = tokio::signal::ctrl_c().await { + eprintln!("rucelium-gateway: signal wait failed: {e}"); + } + println!("rucelium-gateway: shutting down"); + for task in handle.tasks { + task.abort(); + } +} diff --git a/crates/rucelium-gateway/src/net.rs b/crates/rucelium-gateway/src/net.rs new file mode 100644 index 0000000..e2426b5 --- /dev/null +++ b/crates/rucelium-gateway/src/net.rs @@ -0,0 +1,30 @@ +//! The UDP front door (ADR-265 §4): one socket, one receive loop, every +//! datagram fed through [`crate::pipeline::process_datagram`] under the +//! state lock with the reception timestamp from the system clock. + +use crate::pipeline::process_datagram; +use crate::state::{now_ns, GatewayState}; +use tokio::net::UdpSocket; + +/// Largest datagram the gateway will read (a v1 envelope is 151 bytes; the +/// headroom tolerates future envelope kinds without silent truncation). +const MAX_DATAGRAM: usize = 2048; + +/// Run the UDP receive loop forever on an already-bound socket. Rejections +/// are counted in the shared state, not logged per-datagram (an attacker +/// must not be able to flood the log). +pub async fn run_udp(socket: UdpSocket, state: GatewayState) { + let mut buf = vec![0u8; MAX_DATAGRAM]; + loop { + match socket.recv_from(&mut buf).await { + Ok((len, _from)) => { + let received_ns = now_ns(); + let mut inner = state.inner.lock().await; + let _ = process_datagram(&mut inner, &buf[..len], received_ns); + } + Err(e) => { + eprintln!("gateway: udp receive error: {e}"); + } + } + } +} diff --git a/crates/rucelium-gateway/src/pipeline.rs b/crates/rucelium-gateway/src/pipeline.rs new file mode 100644 index 0000000..177433a --- /dev/null +++ b/crates/rucelium-gateway/src/pipeline.rs @@ -0,0 +1,401 @@ +//! The per-datagram ingest pipeline (ADR-265 §4): envelope detection +//! (v1 CBOR / v2 compact / fragments) → reassembly → ingest (registry + +//! signature + anti-replay) → calibration → drift → WorldGraph → durable +//! store → local alert rules → biome admission. + +use crate::state::Inner; +use rucelium_abi::RvEnvSampleV1; +use rucelium_core::{ + EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, SensorModality, Severity, SPEC_VERSION, +}; +use rucelium_transport::{to_v1, CompactEnvV2, COMPACT_ENV_MAGIC, FRAG_MAGIC}; + +/// Water-level threshold (metres) for the local flood alert rule — same +/// value as the ADR-264 §14 acceptance benchmark. +pub const WATER_ALERT_LEVEL_M: f64 = 1.6; + +/// Quality floor for water-quality samples; below it the sample raises an +/// anomaly alert (sensor likely degraded or fouled). +pub const WATER_ALERT_MIN_QUALITY: f32 = 0.2; + +/// Link-layer sender hint for the fragment reassembler. UDP v0.1 uses a +/// single shared sender id (`0`): the daemon cannot trust source addresses +/// (trivially spoofable) and the payload's own signature + sequence window +/// provide end-to-end integrity and dedup. Senders must therefore choose +/// distinct `msg_id`s while fragmenting concurrently — the synthetic +/// simulator does (one global counter). Real LoRaWAN deployments would pass +/// a DevAddr-derived hint instead. +const UDP_SENDER: u64 = 0; + +/// What one received datagram amounted to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessOutcome { + /// A sample was fully accepted: verified, calibrated, stored, graphed, + /// and offered to the biome. + Accepted, + /// The datagram (or the message it completed) was rejected; the string + /// is the human-readable reason. Rejection is final — the gateway never + /// repairs or forwards unverified data (ADR-264 §12). + Rejected(String), + /// A fragment was absorbed; the message is not yet complete. + Fragment, +} + +/// Process one received datagram at `received_ns`, updating every pipeline +/// component and the datagram-level counters exactly once per datagram. +pub fn process_datagram(inner: &mut Inner, datagram: &[u8], received_ns: u64) -> ProcessOutcome { + let outcome = dispatch(inner, datagram, received_ns); + match &outcome { + ProcessOutcome::Accepted => inner.datagrams.accepted += 1, + ProcessOutcome::Rejected(_) => inner.datagrams.rejected += 1, + ProcessOutcome::Fragment => inner.datagrams.fragments += 1, + } + outcome +} + +/// Dispatch on the first byte: `0xF7` fragment, `0xC2` compact envelope v2, +/// anything else (in practice `0x83`, the CBOR array(3) head) a v1 envelope. +/// Recurses (depth ≤ 1) when a fragment completes a message. +fn dispatch(inner: &mut Inner, datagram: &[u8], received_ns: u64) -> ProcessOutcome { + match datagram.first() { + None => ProcessOutcome::Rejected("empty datagram".to_string()), + Some(&FRAG_MAGIC) => match inner.reassembler.offer(UDP_SENDER, datagram, received_ns) { + Ok(Some(message)) => dispatch(inner, &message, received_ns), + Ok(None) => ProcessOutcome::Fragment, + Err(e) => ProcessOutcome::Rejected(format!("fragment: {e}")), + }, + Some(&COMPACT_ENV_MAGIC) => ingest_compact(inner, datagram, received_ns), + Some(_) => ingest_v1(inner, datagram, received_ns), + } +} + +/// Compact envelope v2: parse, look the registry key up by the `node_id` +/// inside the payload, rehydrate to v1 ([`to_v1`]), and feed the encoded v1 +/// bytes through the unchanged ingest pipeline (which re-verifies the +/// signature against the registry key — a forged `node_id` merely selects a +/// key the signature cannot match). +fn ingest_compact(inner: &mut Inner, datagram: &[u8], received_ns: u64) -> ProcessOutcome { + let env = match CompactEnvV2::parse(datagram) { + Ok(e) => e, + Err(e) => return ProcessOutcome::Rejected(format!("compact envelope: {e}")), + }; + let wire = match RvEnvSampleV1::parse(&env.payload) { + Ok(w) => w, + Err(e) => return ProcessOutcome::Rejected(format!("compact payload: {e}")), + }; + let Some(device) = inner.ingest.registry().get(wire.node_id) else { + return ProcessOutcome::Rejected(format!( + "compact envelope from unknown device {}", + wire.node_id + )); + }; + let record = to_v1(&env, device.pubkey); + ingest_v1(inner, &record.encode(), received_ns) +} + +/// Feed v1 envelope bytes through ingest and, on acceptance, the rest of the +/// pipeline: calibration, drift, WorldGraph, durable store, alert rule, +/// biome admission. +fn ingest_v1(inner: &mut Inner, envelope: &[u8], received_ns: u64) -> ProcessOutcome { + // `ingest` yields a sealed `VerifiedEnvSample`: the only type the biome + // layer accepts, and the only one the ingest pipeline can mint. + let mut sample = match inner.ingest.ingest(envelope, received_ns) { + Ok(s) => s, + Err(reason) => return ProcessOutcome::Rejected(reason.to_string()), + }; + + // Calibration: `Uncalibrated` is fine (quality already penalised by the + // calibrator); a hard error is counted and the sample stays raw — the + // gateway never invents a correction (ADR-264 §12 item 6). `modify` + // applies the correction *through* the seal: the change is committed only + // if the transformed sample still validates, so calibration can neither + // break the seal nor smuggle an invalid sample past it. + let calibrated = sample.modify(|s| inner.calibrator.apply(&inner.calibration, s, received_ns)); + match calibrated { + Ok(Ok(_)) => {} + // Either the calibrator rejected the record, or the corrected sample + // failed re-validation. Both leave the sample untouched. + Ok(Err(_)) | Err(_) => inner.calibration_errors += 1, + } + + // Read-only view of the sealed sample for everything downstream that + // works on plain `EnvSample`s (graph, store, projection, alert rules). + let view = sample.sample().clone(); + + // Drift: the daemon has no co-located anchor model yet, so real traffic + // feeds residual 0.0 — the call is kept so quarantine state (set by any + // future anchor feed or by tests) stays visible in /api/stats and no node + // can silently leave quarantine (sticky by design). + let _ = inner.drift.observe(view.node_id, 0.0); + + // WorldGraph registration (idempotent) before storage. + inner.graph.register_observation(&view); + + // Storage strips the seal by design: a sample read back from disk is + // untrusted bytes again, and re-earning verification requires the + // original signed envelope (`IngestPipeline::reverify_stored`). + if let Err(e) = inner.obs.append(&view) { + return ProcessOutcome::Rejected(format!("observation store append: {e}")); + } + + maybe_alert(inner, &view, received_ns); + + // Biome admission last; duplicates are counted inside the biome. + let _ = inner.biome.accept(sample); + ProcessOutcome::Accepted +} + +/// Local alert rule (ADR-265 §4): a water-quality sample above +/// [`WATER_ALERT_LEVEL_M`] raises a `FloodRisk` warning; one below +/// [`WATER_ALERT_MIN_QUALITY`] quality raises an `Anomaly` watch. The event +/// is biome-signed and appended to the durable event store. +fn maybe_alert(inner: &mut Inner, sample: &EnvSample, received_ns: u64) { + if sample.modality != SensorModality::WaterQuality { + return; + } + let flood = sample.value > WATER_ALERT_LEVEL_M; + let degraded = sample.quality < WATER_ALERT_MIN_QUALITY; + if !flood && !degraded { + return; + } + let (kind, severity, message) = if flood { + ( + EventKind::FloodRisk, + Severity::Warning, + format!( + "water level {:.2} m above flood threshold {WATER_ALERT_LEVEL_M} m", + sample.value + ), + ) + } else { + ( + EventKind::Anomaly, + Severity::Watch, + format!( + "water-quality sample quality {:.2} below floor {WATER_ALERT_MIN_QUALITY}", + sample.quality + ), + ) + }; + let mut event = EnvironmentalEvent { + spec_version: SPEC_VERSION.into(), + event_id: format!( + "alert:{}:{}:{}", + inner.biome.config().biome_id, + sample.node_id, + sample.sequence + ), + biome_id: inner.biome.config().biome_id.clone(), + kind, + severity, + modality: SensorModality::WaterQuality, + geo: sample.geo, + window_start_ns: sample.measured_ns, + window_end_ns: sample.measured_ns, + detected_ns: received_ns, + evidence: vec![EvidenceRef { + node_id: sample.node_id, + sequence: sample.sequence, + }], + confidence: 0.9, + // Bind the cited observation CONTENT into the signature + // (ADR-266 §3.1), not just its (node, sequence) identity. + evidence_digest: Some(rucelium_core::evidence_digest(&[sample])), + message, + signature_hex: None, + signer_pubkey_hex: None, + }; + inner.biome.sign_event(&mut event); + match inner.events.append(&event) { + Ok(_) => inner.alerts += 1, + Err(e) => eprintln!("gateway: event store append failed: {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::testutil::test_inner; + use rucelium_abi::{NodeSigner, RV_ENV_SCHEMA_V1}; + use rucelium_federation::verify_event; + use rucelium_transport::{fragment_compact, sign_compact}; + + const SEED: &[u8; 32] = b"rucelium-gateway-test-seed-32b!!"; + const NODE_A: u64 = 0x5C00_0000_0000_0001; + const NODE_B: u64 = 0x5C00_0000_0000_0002; + const TS: u64 = 1_754_000_000_000_000_000; + const RECV: u64 = TS + 1_000_000; + + fn wire(node_id: u64, sequence: u32, modality: SensorModality, value: f64) -> RvEnvSampleV1 { + RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: modality.code(), + flags: 0, + node_id, + timestamp_ns: TS, + sequence, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: (value * 65_536.0).round() as i32, + quality_q15: 0x7000, // 0.875 + battery_mv: 3_600, + calibration_id: 0, // uncalibrated: quality penalised, no record needed + } + } + + fn signer(node_id: u64) -> NodeSigner { + NodeSigner::for_node(SEED, node_id) + } + + fn v1_envelope(node_id: u64, sequence: u32) -> Vec { + signer(node_id) + .sign_sample(&wire(node_id, sequence, SensorModality::SoilMoisture, 27.5)) + .encode() + } + + fn inner_with_node(tag: &str) -> Inner { + let mut inner = test_inner(tag); + inner.ingest.registry_mut().register( + NODE_A, + signer(NODE_A).public_key(), + "sha256:fw-a".into(), + ); + inner + } + + #[test] + fn genuine_v1_envelope_accepted_end_to_end() { + let mut inner = inner_with_node("v1-ok"); + let out = process_datagram(&mut inner, &v1_envelope(NODE_A, 1), RECV); + assert_eq!(out, ProcessOutcome::Accepted); + assert_eq!(inner.ingest.stats().accepted, 1); + assert_eq!(inner.obs.len(), 1); + assert!(inner.graph.node(&format!("sensor/{NODE_A}")).is_some()); + assert_eq!(inner.biome.accepted_count(), 1); + assert_eq!(inner.datagrams.accepted, 1); + // Uncalibrated: quality penalised, value untouched. + let stored = &inner.obs.recent(1).unwrap()[0]; + assert!(stored.provenance.verified); + assert!((stored.value - 27.5).abs() < 1e-4); + assert!((stored.quality - 0.875 * 0.5).abs() < 1e-6); + } + + #[test] + fn compact_v2_envelope_accepted() { + let mut inner = inner_with_node("v2-ok"); + let w = wire(NODE_A, 1, SensorModality::SoilMoisture, 27.5); + let env = sign_compact(&signer(NODE_A), &w.encode()); + let out = process_datagram(&mut inner, &env.encode(), RECV); + assert_eq!(out, ProcessOutcome::Accepted); + assert_eq!(inner.ingest.stats().accepted, 1); + assert_eq!(inner.obs.len(), 1); + } + + #[test] + fn fragmented_compact_envelope_reassembles_out_of_order() { + let mut inner = inner_with_node("frag-ok"); + let w = wire(NODE_A, 1, SensorModality::SoilMoisture, 27.5); + let env = sign_compact(&signer(NODE_A), &w.encode()); + let frames = fragment_compact(&env, 7); + assert_eq!(frames.len(), 3); + // Out of order: 2, 0, then 1 completes. + assert_eq!( + process_datagram(&mut inner, &frames[2], RECV), + ProcessOutcome::Fragment + ); + assert_eq!( + process_datagram(&mut inner, &frames[0], RECV), + ProcessOutcome::Fragment + ); + assert_eq!( + process_datagram(&mut inner, &frames[1], RECV), + ProcessOutcome::Accepted + ); + assert_eq!(inner.obs.len(), 1); + assert_eq!(inner.datagrams.fragments, 2); + assert_eq!(inner.datagrams.accepted, 1); + } + + #[test] + fn tampered_envelope_rejected() { + let mut inner = inner_with_node("tamper"); + let mut env = v1_envelope(NODE_A, 1); + let mid = env.len() / 2; + env[mid] ^= 0x01; + let out = process_datagram(&mut inner, &env, RECV); + assert!(matches!(out, ProcessOutcome::Rejected(_)), "{out:?}"); + assert_eq!(inner.ingest.stats().accepted, 0); + assert_eq!(inner.obs.len(), 0); + assert_eq!(inner.datagrams.rejected, 1); + } + + #[test] + fn unknown_node_compact_rejected_before_ingest() { + let mut inner = inner_with_node("unknown"); + let w = wire(NODE_B, 1, SensorModality::SoilMoisture, 27.5); + let env = sign_compact(&signer(NODE_B), &w.encode()); + let out = process_datagram(&mut inner, &env.encode(), RECV); + match out { + ProcessOutcome::Rejected(msg) => assert!(msg.contains("unknown device"), "{msg}"), + other => panic!("expected rejection, got {other:?}"), + } + assert_eq!(inner.obs.len(), 0); + } + + #[test] + fn replayed_datagram_rejected_as_replay() { + let mut inner = inner_with_node("replay"); + let env = v1_envelope(NODE_A, 9); + assert_eq!( + process_datagram(&mut inner, &env, RECV), + ProcessOutcome::Accepted + ); + match process_datagram(&mut inner, &env, RECV) { + ProcessOutcome::Rejected(msg) => assert!(msg.contains("replayed"), "{msg}"), + other => panic!("expected replay rejection, got {other:?}"), + } + assert_eq!(inner.ingest.stats().replay, 1); + assert_eq!(inner.obs.len(), 1); + assert_eq!(inner.biome.accepted_count(), 1); + } + + #[test] + fn empty_and_garbage_datagrams_rejected() { + let mut inner = inner_with_node("garbage"); + assert!(matches!( + process_datagram(&mut inner, &[], RECV), + ProcessOutcome::Rejected(_) + )); + assert!(matches!( + process_datagram(&mut inner, b"\x83garbage", RECV), + ProcessOutcome::Rejected(_) + )); + assert_eq!(inner.datagrams.rejected, 2); + } + + #[test] + fn water_level_over_threshold_raises_signed_flood_alert() { + let mut inner = inner_with_node("alert"); + let w = wire(NODE_A, 1, SensorModality::WaterQuality, 1.8); + let env = signer(NODE_A).sign_sample(&w).encode(); + assert_eq!( + process_datagram(&mut inner, &env, RECV), + ProcessOutcome::Accepted + ); + assert_eq!(inner.alerts, 1); + let events = inner.events.iter().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, EventKind::FloodRisk); + assert!( + verify_event(&events[0]), + "alert must carry a biome signature" + ); + // A calm reading raises nothing further. + let calm = signer(NODE_A) + .sign_sample(&wire(NODE_A, 2, SensorModality::WaterQuality, 1.0)) + .encode(); + process_datagram(&mut inner, &calm, RECV); + assert_eq!(inner.alerts, 1); + } +} diff --git a/crates/rucelium-gateway/src/simulate.rs b/crates/rucelium-gateway/src/simulate.rs new file mode 100644 index 0000000..af0bc2e --- /dev/null +++ b/crates/rucelium-gateway/src/simulate.rs @@ -0,0 +1,290 @@ +//! **SYNTHETIC traffic generator** (ADR-265 §4, `--simulate N`). +//! +//! Spawns N make-believe spore nodes that sign *real* envelopes with real +//! per-node ed25519 keys and send them to the gateway's own UDP socket over +//! loopback — the full production pipeline is exercised with zero hardware. +//! Every value produced here is SYNTHETIC: a diurnal sine plus a small +//! deterministic wobble, with a periodic water-level spike so the alert path +//! demonstrably fires. Nothing here claims field-validated accuracy. + +use crate::state::{now_ns, GatewayState}; +use rucelium_abi::{sign_payload, NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; +use rucelium_core::{CalibrationRecord, SensorModality}; +use rucelium_transport::{fragment_compact, CompactEnvV2}; +use std::f64::consts::TAU; +use std::time::Duration; +use tokio::net::UdpSocket; + +/// Base node id for synthetic nodes (`0x5C` = "SC", spore-node class). +pub const SIM_NODE_ID_BASE: u64 = 0x5C00_0000_0000_0100; + +/// Nanoseconds per day. +const NS_PER_DAY: u64 = 86_400_000_000_000; +/// Seconds per day. +const S_PER_DAY: f64 = 86_400.0; +/// Synthetic wire quality (Q0.15 ≈ 0.9). +const SIM_QUALITY_Q15: u16 = 0x7333; +/// Every this many ticks, one water node spikes over the alert threshold. +const SPIKE_EVERY_TICKS: u32 = 60; +/// Spiked water level (metres) — above the 1.6 m flood threshold. +const SPIKE_WATER_LEVEL_M: f64 = 1.8; + +/// One synthetic node: identity, key, modality, calibration record id. +struct SimNode { + /// Provisioned device id. + node_id: u64, + /// Round-robin physical modality (WifiCsi is skipped — it is the RF + /// context modality, not a spore-node sensor). + modality: SensorModality, + /// The node's signing key. + signer: NodeSigner, + /// The node's colocation calibration record id. + calibration_id: u32, + /// Index (drives encoding rotation and value phase). + index: usize, + /// Whether this node produces the periodic alert spike. + spiker: bool, +} + +/// Derive the 32-byte synthetic provisioning seed from the numeric config +/// seed (deterministic; strength is irrelevant for synthetic keys). +fn provision_seed(seed: u64) -> [u8; 32] { + let sb = seed.to_le_bytes(); + let mut out = [0u8; 32]; + for (i, b) in out.iter_mut().enumerate() { + *b = sb[i % 8] ^ (i as u8).wrapping_mul(0x35) ^ 0x5C; + } + out +} + +/// Baseline and diurnal amplitude per physical modality (in the modality's +/// default unit). Chosen to look plausible on a dashboard, nothing more. +fn profile(modality: SensorModality) -> (f64, f64) { + match modality { + SensorModality::AirQuality => (12.0, 4.0), + SensorModality::SoilMoisture => (27.0, 3.0), + SensorModality::WaterQuality => (1.0, 0.3), + SensorModality::Acoustic => (0.5, 0.2), + SensorModality::Weather => (16.0, 6.0), + SensorModality::Bioelectric => (40.0, 10.0), + SensorModality::Radiation => (0.10, 0.02), + SensorModality::Optical => (400.0, 300.0), + SensorModality::Chemical => (5.0, 1.0), + // Never provisioned by the simulator. + SensorModality::WifiCsi => (0.0, 0.0), + } +} + +/// SYNTHETIC value model: diurnal sine (phase-shifted per node) plus a small +/// deterministic wobble, with the periodic water spike for the spiker node. +fn sim_value(node: &SimNode, ts_ns: u64, tick: u32) -> f64 { + if node.spiker && tick.is_multiple_of(SPIKE_EVERY_TICKS) { + return SPIKE_WATER_LEVEL_M; + } + let (base, amp) = profile(node.modality); + let day_frac = (ts_ns % NS_PER_DAY) as f64 / 1e9 / S_PER_DAY; + let phase = node.index as f64 * 0.7; + let wobble = 0.05 * amp * (f64::from(tick) * 0.9 + phase).sin(); + base + amp * (TAU * day_frac + phase).sin() + wobble +} + +/// Provision `n` synthetic nodes into the gateway: register their keys, +/// insert identity calibration records (one anchor per physical modality, +/// one colocation child per node — the ADR-264 benchmark shape), and return +/// the node list. +async fn provision(state: &GatewayState, n: u32, seed: u64) -> Vec { + let seed32 = provision_seed(seed); + let now = now_ns(); + let created = now.saturating_sub(NS_PER_DAY); + let expires = now.saturating_add(3650 * NS_PER_DAY); + let mut inner = state.inner.lock().await; + + // Anchor records: ids 1..=9 by modality code (skip 0 = WifiCsi). The + // store runs in STRICT mode, so every record is signed by the gateway's + // calibration authority before insertion — an unsigned "anchor_reference" + // root would be refused (ADR-264 §12 items 1–3). + for m in SensorModality::ALL { + if m == SensorModality::WifiCsi { + continue; + } + let _ = inner.insert_signed_calibration(CalibrationRecord { + calibration_id: u32::from(m.code()), + node_id: 0, // the reference anchor station + modality: m, + method: "anchor_reference".into(), + reference_station: Some(format!("anchor/{}", m.as_str())), + parent_id: None, + created_ns: created, + expires_ns: expires, + scale_q16: 65_536, + offset_q16: 0, + uncertainty_q16: 6_554, // ±0.1 in-unit + data_hash: format!("sha256:sim-anchor-{}", m.as_str()), + signature_hex: None, + signer_pubkey_hex: None, + }); + } + + let mut nodes = Vec::with_capacity(n as usize); + let mut spiker_chosen = false; + for i in 0..n as usize { + let node_id = SIM_NODE_ID_BASE + i as u64; + // Round-robin over the 9 physical modalities (codes 1..=9). + let modality = SensorModality::ALL[1 + (i % 9)]; + let signer = NodeSigner::for_node(&seed32, node_id); + inner.ingest.registry_mut().register( + node_id, + signer.public_key(), + format!("sha256:sim-fw-{i}"), + ); + let calibration_id = 1000 + i as u32; + let _ = inner.insert_signed_calibration(CalibrationRecord { + calibration_id, + node_id, + modality, + method: "colocation".into(), + reference_station: Some(format!("anchor/{}", modality.as_str())), + parent_id: Some(u32::from(modality.code())), + created_ns: created + 1, + expires_ns: expires, + scale_q16: 65_536, // identity: synthetic nodes left the factory true + offset_q16: 0, + uncertainty_q16: 19_661, // ±0.3 in-unit + data_hash: format!("sha256:sim-colo-{node_id}"), + signature_hex: None, + signer_pubkey_hex: None, + }); + let spiker = !spiker_chosen && modality == SensorModality::WaterQuality; + spiker_chosen |= spiker; + nodes.push(SimNode { + node_id, + modality, + signer, + calibration_id, + index: i, + spiker, + }); + } + nodes +} + +/// Run the synthetic traffic loop forever: every `interval_ms`, each node +/// signs one envelope for the current wall-clock instant and sends it to +/// `127.0.0.1:udp_port`, rotating encodings to exercise all three transport +/// paths — `i % 3 == 0` v1 CBOR, `1` compact v2, `2` compact v2 fragmented +/// at the LoRaWAN DR0 MTU (3 datagrams). +pub async fn run_simulator( + state: GatewayState, + n: u32, + seed: u64, + interval_ms: u64, + udp_port: u16, +) { + let nodes = provision(&state, n, seed).await; + let socket = match UdpSocket::bind(("127.0.0.1", 0)).await { + Ok(s) => s, + Err(e) => { + eprintln!("gateway: simulator disabled, socket bind failed: {e}"); + return; + } + }; + let target = (std::net::Ipv4Addr::LOCALHOST, udp_port); + let mut tick_timer = tokio::time::interval(Duration::from_millis(interval_ms.max(10))); + let mut tick: u32 = 0; + let mut msg_id: u16 = 0; + loop { + tick_timer.tick().await; + tick = tick.wrapping_add(1); + for node in &nodes { + let ts_ns = now_ns(); + let value = sim_value(node, ts_ns, tick); + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: node.modality.code(), + flags: 0, + node_id: node.node_id, + timestamp_ns: ts_ns, + sequence: tick, + latitude_e7: 514_778_216 + node.index as i32 * 1_000, + longitude_e7: -14_767 + node.index as i32 * 1_000, + altitude_mm: 46_000, + value_q16: (value * 65_536.0).round() as i32, + quality_q15: SIM_QUALITY_Q15, + battery_mv: 3_600, + calibration_id: node.calibration_id, + }; + let payload = wire.encode(); + match node.index % 3 { + 0 => { + // v1 CBOR envelope (151 bytes). + let env = sign_payload(&node.signer, &payload).encode(); + let _ = socket.send_to(&env, target).await; + } + 1 => { + // Compact envelope v2 (114 bytes). + let env = compact(&node.signer, &payload); + let _ = socket.send_to(&env.encode(), target).await; + } + _ => { + // Compact v2 fragmented at the DR0 MTU: 3 datagrams. The + // gateway reassembles with sender hint 0, so msg_ids are + // globally unique via one counter (see `pipeline`). + msg_id = msg_id.wrapping_add(1); + let env = compact(&node.signer, &payload); + for frame in fragment_compact(&env, msg_id) { + let _ = socket.send_to(&frame, target).await; + } + } + } + } + } +} + +/// Sign a payload into a compact v2 envelope. +fn compact(signer: &NodeSigner, payload: &[u8; 48]) -> CompactEnvV2 { + rucelium_transport::sign_compact(signer, payload) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provision_seed_is_deterministic_and_seed_sensitive() { + assert_eq!(provision_seed(1), provision_seed(1)); + assert_ne!(provision_seed(1), provision_seed(2)); + } + + #[test] + fn value_model_is_bounded_and_spikes_on_schedule() { + let spiker = SimNode { + node_id: SIM_NODE_ID_BASE + 2, + modality: SensorModality::WaterQuality, + signer: NodeSigner::for_node(&provision_seed(1), SIM_NODE_ID_BASE + 2), + calibration_id: 1002, + index: 2, + spiker: true, + }; + let ts = 1_754_000_000_000_000_000; + // Normal ticks stay well under the flood threshold. + for tick in 1..SPIKE_EVERY_TICKS { + let v = sim_value(&spiker, ts, tick); + assert!(v < crate::pipeline::WATER_ALERT_LEVEL_M, "tick {tick}: {v}"); + assert!(v > 0.0); + } + // The 60th tick spikes above it. + let v = sim_value(&spiker, ts, SPIKE_EVERY_TICKS); + assert!(v > crate::pipeline::WATER_ALERT_LEVEL_M, "{v}"); + } + + #[test] + fn all_physical_modalities_have_positive_profiles() { + for m in SensorModality::ALL { + if m == SensorModality::WifiCsi { + continue; + } + let (base, amp) = profile(m); + assert!(base > 0.0 && amp > 0.0, "{m:?}"); + } + } +} diff --git a/crates/rucelium-gateway/src/state.rs b/crates/rucelium-gateway/src/state.rs new file mode 100644 index 0000000..bad2f3c --- /dev/null +++ b/crates/rucelium-gateway/src/state.rs @@ -0,0 +1,524 @@ +//! Shared gateway runtime state (ADR-265 §4). +//! +//! v0.1 concurrency model: **one big lock**. Every mutable component lives in +//! [`Inner`] behind a single `Arc>`. The UDP loop, +//! HTTP handlers, federation poller, retention timer, and simulator all take +//! the same lock; per-datagram work is microseconds, so contention is +//! negligible at v0.1 scale and the simplicity buys obvious correctness. +//! Finer-grained locking is deliberate future work. + +use crate::config::GatewayConfig; +use crate::journal; +use rucelium_calibration::{ + AuthorityRegistry as CalibrationAuthorities, CalibrationAuthority, CalibrationError, + CalibrationSigner, CalibrationStore, Calibrator, DriftDetector, +}; +use rucelium_core::CalibrationRecord; +use rucelium_federation::{Biome, BiomeConfig, RegionalSummary}; +use rucelium_ingest::IngestPipeline; +use rucelium_policy::{ + AuditTrail, AuthorityRegistry, CommandSigner, ExecutionReceipt, GatewayValidator, PolicyConfig, + PolicyEngine, SafetyConfig, SafetySimulator, +}; +use rucelium_store::{EventStore, ObservationStore}; +use rucelium_transport::Reassembler; +use rucelium_worldgraph::WorldGraph; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{broadcast, Mutex}; + +/// Max records per observation segment file. +const OBS_SEGMENT_MAX_RECORDS: usize = 4096; +/// Max records per event segment file. +const EVT_SEGMENT_MAX_RECORDS: usize = 1024; +/// Max in-flight partially reassembled messages held by the gateway. +const REASSEMBLER_MAX_PENDING: usize = 256; +/// Agent identity the biome owner grants actuator authority to at startup. +pub const GRANTED_AGENT_ID: &str = "agent/flood"; +/// Name of the gateway's own local calibration authority. +const LOCAL_CALIBRATION_AUTHORITY: &str = "gateway-local"; + +/// Nanoseconds since the Unix epoch, from the system clock. The library +/// crates are clock-free; the daemon is where wall time enters the system. +#[must_use] +pub fn now_ns() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)) + .unwrap_or(0) +} + +/// Datagram-level counters for the UDP front door (one bump per received +/// datagram, distinct from the envelope-level `IngestStats`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct DatagramStats { + /// Datagrams that led to a fully accepted sample. + pub accepted: u64, + /// Datagrams rejected at any stage (transport, registry, ingest, store). + pub rejected: u64, + /// Fragment datagrams absorbed while awaiting the rest of their message. + pub fragments: u64, +} + +/// A verified regional summary fetched from a federation peer. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct PeerSummary { + /// Peer base URL the summary was fetched from. + pub peer: String, + /// The verified signed summary. + pub summary: RegionalSummary, + /// When this gateway fetched it (ns since Unix epoch). + pub fetched_ns: u64, +} + +/// Push-federation counters (ADR-269 §3): how well the push path and its +/// mandatory polling backstop are actually doing. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct PushStats { + /// Artifacts this gateway pushed to a peer and the peer accepted. + pub pushes_sent: u64, + /// Artifacts a peer pushed to this gateway that **verified** and were + /// accepted (`POST /api/federation/announce`, or a QUIC stream). + pub pushes_received: u64, + /// Push attempts that failed — unreachable peer, protocol refusal, or a + /// refused transport identity. Never fatal: the backstop converges. + pub push_failures: u64, + /// Completed `sync_since` backfill passes (the ADR-269 §3 backstop). + pub backfills: u64, +} + +/// A peer's federation identity as learned on first contact, and the address +/// it was learned from. This is the `biome_id → key` binding every received +/// artifact is checked against (ADR-269 §4). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct KnownPeer { + /// Peer biome identity (the map key). + pub biome_id: String, + /// The peer's published ed25519 federation key, hex. + pub pubkey_hex: String, + /// Where the identity was learned from. + pub url: String, +} + +/// Counters for the governed control path (ADR-264 §9). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct ControlStats { + /// Commands the gateway validated and executed, producing a receipt. + pub commands_executed: u64, + /// Proposals stopped at any stage of the governed path. + pub proposals_rejected: u64, + /// Signed execution receipts retained in memory. + pub receipts: u64, +} + +/// Everything mutable in the gateway, guarded by one lock (module docs). +pub struct Inner { + /// Wire ingest: registry, signatures, anti-replay (ADR-264 §5). + pub ingest: IngestPipeline, + /// Calibration records with anchor-rooted lineage, in **strict** mode: + /// every record must be signed by a registered calibration authority + /// (ADR-264 §12 items 1–3). + pub calibration: CalibrationStore, + /// The gateway's own calibration authority key. The daemon's synthetic + /// calibration records are signed with it before insertion; a record it + /// did not sign is rejected by the strict store. + pub cal_signer: CalibrationSigner, + /// Applies calibration; never repairs (ADR-264 §12). + pub calibrator: Calibrator, + /// EWMA drift monitor with sticky quarantine. + pub drift: DriftDetector, + /// Environmental WorldGraph (ADR-264 §5.2). + pub graph: WorldGraph, + /// The sovereign biome aggregate + signing identity. + pub biome: Biome, + /// Durable observation log (disk). + pub obs: ObservationStore, + /// Durable event log (disk). + pub events: EventStore, + /// Fragment reassembly for MTU-constrained links. + pub reassembler: Reassembler, + /// Latest verified summary per federation peer. + pub peer_summaries: Vec, + /// `event_id`s of peer revocation events already applied locally. + pub applied_revocation_ids: BTreeSet, + /// How many verified peer `DeviceRevoked` events were applied. + pub applied_peer_revocations: u64, + /// Federation identities learned on first contact, keyed by `biome_id` + /// (ADR-269 §4: the identity binding every artifact is checked against). + pub known_peers: BTreeMap, + /// Push-federation counters (ADR-269 §3). + pub push: PushStats, + /// Local alert events raised (flood / anomaly rule). + pub alerts: u64, + /// Calibration application errors (sample kept raw, never repaired). + pub calibration_errors: u64, + /// Datagram-level UDP counters. + pub datagrams: DatagramStats, + + // --- Governed control path (ADR-264 §9). Stage order is enforced by the + // policy crate's type privacy; the daemon just owns the components. --- + /// Stage 1: deterministic policy evaluation. + pub policy: PolicyEngine, + /// Stage 2: safety envelope. Budgets are checked here and charged by + /// `record_execution` only after the gateway confirms execution. + pub safety: SafetySimulator, + /// Stage 3: per-biome actuator authority (never leaves the biome owner). + pub authority: AuthorityRegistry, + /// Stage 4: the biome owner's deterministic command-signing key. + pub command_signer: CommandSigner, + /// Stages 5–6: gateway validation + two-phase local execution, with the + /// command phase table restored from [`Inner::command_journal`]. + pub gateway: GatewayValidator, + /// Append-only audit trail across every control-path stage. + pub audit: AuditTrail, + /// Signed receipts of executed commands, in execution order. + pub receipts: Vec, + /// Control-path counters. + pub control: ControlStats, + /// Path of the durable command-phase journal (`commands.jsonl`). + pub command_journal: PathBuf, +} + +impl Inner { + /// Build the full component stack, opening the durable stores under + /// `config.data_dir` (`obs/` and `events/` subdirectories) in the + /// durability mode `config.fsync` selects. + /// + /// Two pieces of state are **restored from disk here**, and both are + /// security-relevant (ADR-265): + /// + /// 1. The anti-replay windows are primed from the observation store's + /// durable dedup index ([`ObservationStore::dedup_keys`]). Without + /// this, a restarted gateway would happily re-accept signed packets it + /// had already ingested — replay protection would last only as long as + /// the process. + /// 2. The command phase table is restored from the `commands.jsonl` + /// journal, so a command id that was executed (or was mid-execution + /// when the process died) is never executed a second time. + pub fn open(config: &GatewayConfig) -> Result { + let obs = ObservationStore::open( + &config.data_dir.join("obs"), + OBS_SEGMENT_MAX_RECORDS, + config.fsync, + ) + .map_err(|e| format!("open observation store: {e}"))?; + let events = EventStore::open( + &config.data_dir.join("events"), + EVT_SEGMENT_MAX_RECORDS, + config.fsync, + ) + .map_err(|e| format!("open event store: {e}"))?; + + // (1) Replay protection must survive restart: the durable dedup index + // is the replay memory. + let mut ingest = IngestPipeline::default(); + ingest.prime_from_dedup(obs.dedup_keys()); + + // Strict calibration: the daemon trusts exactly one authority — its + // own deterministic key — and the store verifies every record's + // signature against it. `CalibrationStore::new()` (permissive) would + // let anyone who can insert a record declare an anchor. + let cal_signer = CalibrationSigner::from_seed(&derive_seed( + "calibration", + &config.biome_id, + config.seed, + )); + let mut authorities = CalibrationAuthorities::new(); + authorities.add(CalibrationAuthority { + name: LOCAL_CALIBRATION_AUTHORITY.to_string(), + pubkey_hex: cal_signer.public_hex(), + modalities: BTreeSet::new(), // trusted for every modality + }); + + // Governed control path. The biome owner's command key and the + // gateway's receipt identity are both deterministic in + // `(biome_id, seed)`, so they are stable across restarts — which is + // what lets a replayed command still verify and *then* be rejected as + // a duplicate rather than as an untrusted key. + let command_signer = + CommandSigner::from_seed(&derive_seed("command", &config.biome_id, config.seed)); + let mut policy_config = PolicyConfig::default(); + policy_config + .allowed_actuators + .insert(config.actuator_id.clone()); + let mut authority = AuthorityRegistry::new(); + authority.grant(&config.biome_id, GRANTED_AGENT_ID, &config.actuator_id); + + // (2) Restore the journaled command phases. + let command_journal = journal::journal_path(&config.data_dir); + let mut gateway = GatewayValidator::new( + vec![command_signer.public_hex()], + &derive_seed("gateway-identity", &config.biome_id, config.seed), + ); + gateway.restore_phases(journal::load(&command_journal)?); + + let seed = biome_seed(&config.biome_id, config.seed); + Ok(Inner { + ingest, + calibration: CalibrationStore::with_authorities(authorities), + cal_signer, + calibrator: Calibrator::default(), + drift: DriftDetector::default(), + graph: WorldGraph::new(), + biome: Biome::new(BiomeConfig::new(config.biome_id.clone()), &seed), + obs, + events, + reassembler: Reassembler::new(REASSEMBLER_MAX_PENDING), + peer_summaries: Vec::new(), + applied_revocation_ids: BTreeSet::new(), + applied_peer_revocations: 0, + known_peers: BTreeMap::new(), + push: PushStats::default(), + alerts: 0, + calibration_errors: 0, + datagrams: DatagramStats::default(), + policy: PolicyEngine::new(policy_config), + safety: SafetySimulator::new(SafetyConfig::default()), + authority, + command_signer, + gateway, + audit: AuditTrail::new(), + receipts: Vec::new(), + control: ControlStats::default(), + command_journal, + }) + } + + /// Sign a calibration record with the gateway's calibration authority key + /// and insert it into the strict store. + /// + /// This is the *only* way records enter the daemon's store: strict mode + /// rejects unsigned records, so an attacker who can reach the store still + /// cannot mint an "anchor_reference" root. + pub fn insert_signed_calibration( + &mut self, + mut record: CalibrationRecord, + ) -> Result<(), CalibrationError> { + self.cal_signer.sign_record(&mut record)?; + self.calibration.insert(record) + } + + /// Persist the gateway validator's command phase table to the journal. + /// Called after **every** execution attempt so a crash can never leave a + /// command that ran on disk-less state. + pub fn journal_command_phases(&self) -> Result<(), String> { + journal::store(&self.command_journal, &self.gateway.export_phases()) + } +} + +/// Depth of the push queue between the code that mints an artifact (e.g. the +/// admin revoke handler) and the federation task that announces it. A +/// `broadcast` channel is deliberate: a send with no federation task running +/// is a no-op instead of an unbounded leak, and an overrun drops the +/// *oldest* artifact rather than blocking the caller. Either way the ADR-269 +/// §3 backstop converges the peer, which is exactly why push is allowed to +/// be best-effort. +const PUSH_QUEUE_DEPTH: usize = 256; + +/// Handle shared by every task and HTTP handler. Cheap to clone. +#[derive(Clone)] +pub struct GatewayState { + /// Biome identity (mirrors the config; readable without the lock). + pub biome_id: String, + /// The single mutable state lock (module docs). + pub inner: Arc>, + /// Daemon start time, for `uptime_s`. + pub started: Instant, + /// Outbound push queue (ADR-269 §3). Anything that mints a locally + /// signed artifact publishes it here; the federation task announces it + /// to every peer immediately. + pub push_tx: broadcast::Sender, +} + +impl GatewayState { + /// Open the durable stores and assemble the gateway state. + pub fn open(config: &GatewayConfig) -> Result { + let (push_tx, _) = broadcast::channel(PUSH_QUEUE_DEPTH); + Ok(GatewayState { + biome_id: config.biome_id.clone(), + inner: Arc::new(Mutex::new(Inner::open(config)?)), + started: Instant::now(), + push_tx, + }) + } + + /// Queue one locally signed artifact for immediate push to every peer + /// (ADR-269 §3: a revoked device must not stay valid at peer gateways + /// for a polling interval). + /// + /// Returns how many federation tasks were listening — `0` means nothing + /// is federating right now, which is not an error: the receiving side's + /// `sync_since` backstop is what makes push safe to drop. + pub fn announce_local(&self, artifact: crate::transport::FederationArtifact) -> usize { + self.push_tx.send(artifact).unwrap_or(0) + } +} + +/// Derive the biome's 32-byte ed25519 signing seed from the biome id and the +/// numeric config seed: id bytes repeated/truncated, XORed with the seed +/// bytes and an index whitener. +/// +/// **Deliberately not cryptographically strong** (v0.1): what matters is +/// that the same `(biome_id, seed)` always yields the same biome identity — +/// determinism, restart-stable keys, distinct keys for distinct biomes. A +/// production deployment provisions the biome key from a real ceremony. +#[must_use] +pub fn biome_seed(biome_id: &str, seed: u64) -> [u8; 32] { + let id = biome_id.as_bytes(); + let sb = seed.to_le_bytes(); + let mut out = [0u8; 32]; + for (i, b) in out.iter_mut().enumerate() { + let idb = if id.is_empty() { + 0x7A + } else { + id[i % id.len()] + }; + *b = idb ^ sb[i % 8] ^ (i as u8).wrapping_mul(0x9E); + } + out +} + +/// Derive a **domain-separated** 32-byte seed from the biome identity and the +/// numeric config seed, so the biome key, the command-signing key, the +/// gateway's receipt identity, and the calibration authority key are all +/// distinct while each stays deterministic and restart-stable. +/// +/// Carries the same v0.1 caveat as [`biome_seed`]: this is **not** a +/// cryptographic KDF. It exists so a given `(domain, biome_id, seed)` always +/// yields the same identity; production provisions these from a real ceremony. +#[must_use] +pub fn derive_seed(domain: &str, biome_id: &str, seed: u64) -> [u8; 32] { + let base = biome_seed(biome_id, seed); + let d = domain.as_bytes(); + let mut out = [0u8; 32]; + for (i, b) in out.iter_mut().enumerate() { + let db = if d.is_empty() { 0x5E } else { d[i % d.len()] }; + *b = base[i] ^ db ^ (i as u8).wrapping_mul(0x1B); + } + out +} + +#[cfg(test)] +pub(crate) mod testutil { + use super::*; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + + /// Unique per-test temp data dir (name uniqueness only, never store + /// logic). + pub(crate) fn temp_dir(tag: &str) -> PathBuf { + let n = DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "rucelium-gateway-{tag}-{}-{n}-{t}", + std::process::id() + )) + } + + /// A fresh [`Inner`] over a unique temp data dir. + pub(crate) fn test_inner(tag: &str) -> Inner { + let config = GatewayConfig { + data_dir: temp_dir(tag), + ..GatewayConfig::default() + }; + Inner::open(&config).expect("test inner opens") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn biome_seed_is_deterministic_and_id_sensitive() { + assert_eq!(biome_seed("biome/a", 1), biome_seed("biome/a", 1)); + assert_ne!(biome_seed("biome/a", 1), biome_seed("biome/b", 1)); + assert_ne!(biome_seed("biome/a", 1), biome_seed("biome/a", 2)); + // Empty id still yields a stable, non-degenerate seed. + let empty = biome_seed("", 7); + assert_eq!(empty, biome_seed("", 7)); + assert!(empty.iter().any(|&b| b != 0)); + } + + #[test] + fn same_config_reproduces_the_biome_identity() { + let config = GatewayConfig { + data_dir: testutil::temp_dir("identity"), + ..GatewayConfig::default() + }; + let a = Inner::open(&config).unwrap(); + let b = Inner::open(&config).unwrap(); + assert_eq!(a.biome.public_key_hex(), b.biome.public_key_hex()); + std::fs::remove_dir_all(&config.data_dir).ok(); + } + + #[test] + fn derived_seeds_are_domain_separated_and_stable() { + let a = derive_seed("command", "biome/a", 1); + assert_eq!(a, derive_seed("command", "biome/a", 1)); + assert_ne!(a, derive_seed("gateway-identity", "biome/a", 1)); + assert_ne!(a, derive_seed("calibration", "biome/a", 1)); + assert_ne!(a, derive_seed("command", "biome/b", 1)); + assert_ne!(a, derive_seed("command", "biome/a", 2)); + assert_ne!(a, biome_seed("biome/a", 1)); + // Degenerate domain still produces a stable, non-zero seed. + let empty = derive_seed("", "biome/a", 1); + assert_eq!(empty, derive_seed("", "biome/a", 1)); + assert!(empty.iter().any(|&b| b != 0)); + } + + #[test] + fn strict_calibration_rejects_records_the_gateway_did_not_sign() { + use rucelium_core::SensorModality; + let mut inner = testutil::test_inner("strict-cal"); + let record = |id: u32| rucelium_core::CalibrationRecord { + calibration_id: id, + node_id: 0, + modality: SensorModality::Weather, + method: "anchor_reference".into(), + reference_station: Some("anchor/weather".into()), + parent_id: None, + created_ns: 1_000, + expires_ns: u64::MAX / 2, + scale_q16: 65_536, + offset_q16: 0, + uncertainty_q16: 6_554, + data_hash: "sha256:anchor".into(), + signature_hex: None, + signer_pubkey_hex: None, + }; + // Unsigned insertion is refused outright... + assert!(matches!( + inner.calibration.insert(record(1)), + Err(CalibrationError::MissingSignature(1)) + )); + // ...a record signed by a key the registry does not know is refused... + let mut foreign = record(2); + CalibrationSigner::from_seed(b"some-other-calibration-lab-key!!") + .sign_record(&mut foreign) + .unwrap(); + assert!(matches!( + inner.calibration.insert(foreign), + Err(CalibrationError::UntrustedSigner { id: 2, .. }) + )); + // ...and only the gateway's own authority gets in. + inner.insert_signed_calibration(record(3)).unwrap(); + assert_eq!(inner.calibration.verify_lineage(3).unwrap(), vec![3]); + } + + #[test] + fn now_ns_is_monotonic_enough_and_after_2020() { + let a = now_ns(); + let b = now_ns(); + assert!(b >= a); + assert!(a > 1_577_836_800_000_000_000, "clock reads before 2020"); + } +} diff --git a/crates/rucelium-gateway/src/transport.rs b/crates/rucelium-gateway/src/transport.rs new file mode 100644 index 0000000..bd959a3 --- /dev/null +++ b/crates/rucelium-gateway/src/transport.rs @@ -0,0 +1,553 @@ +//! The federation transport abstraction (ADR-269 §3): **push first, +//! transport second**. +//! +//! ADR-269 §3 replaces the 30 s poller with three verbs carried by a +//! [`FederationTransport`]: +//! +//! * [`announce`](FederationTransport::announce) — push one signed artifact +//! to one peer, so a revocation propagates at link speed instead of +//! polling speed; +//! * [`subscribe`](FederationTransport::subscribe) — take whatever a peer +//! has streamed to us since we last looked; +//! * [`sync_since`](FederationTransport::sync_since) — backfill after a +//! partition. **Not optional**: a peer that missed a pushed event must +//! still converge (ADR-269 §3), so the polling backstop runs on reconnect +//! and on a slow timer regardless of transport. +//! +//! Two implementations ship: [`HttpPollTransport`] here (always available, +//! zero new dependencies, the backfill of record) and +//! `transport_quic::QuicTransport` behind the `quic` feature. +//! +//! # The transport is never the trust boundary (ADR-269 §4, normative) +//! +//! Nothing in this module verifies anything. A transport moves bytes; +//! [`crate::federation::accept_artifact`] is the single gate every artifact +//! passes through — same ed25519 signature check, same `biome_id → key` +//! identity binding, same `event_id` dedup — whether it arrived by HTTP +//! poll, HTTP push, or QUIC. If a session ever becomes the reason a peer is +//! trusted, that is a regression. +//! +//! # No `async-trait` +//! +//! The trait is object-safe by hand: each verb returns a boxed future +//! ([`TransportFuture`]) rather than pulling in a proc-macro dependency, so +//! `Arc` works and the crate stays lean. + +use rucelium_core::EnvironmentalEvent; +use rucelium_federation::RegionalSummary; +use serde::{Deserialize, Serialize}; +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +/// Per-request HTTP timeout for [`HttpPollTransport`]. +const HTTP_TIMEOUT: Duration = Duration::from_secs(5); +/// Widest window [`HttpPollTransport::sync_since`] will ask a peer for, in +/// seconds. A `since_ns` older than this is clamped — the peer's summary +/// endpoint aggregates, so an unbounded window is a denial-of-service knob. +const MAX_SUMMARY_WINDOW_S: u64 = 86_400; +/// Window requested when the caller has never synced this peer before. +const DEFAULT_SUMMARY_WINDOW_S: u64 = 3_600; + +/// One signed thing that crosses the federation boundary (ADR-264 §6: +/// biomes federate signed events and statistical summaries, never raw +/// measurements). +/// +/// `#[serde(tag = "artifact")]` gives the wire form a stable discriminator, +/// so `POST /api/federation/announce` can decode either variant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "artifact", rename_all = "snake_case")] +pub enum FederationArtifact { + /// A signed regional summary. + Summary(RegionalSummary), + /// A signed environmental event (the ADR-269 §3 case that matters: + /// `DeviceRevoked`). + Event(EnvironmentalEvent), +} + +impl FederationArtifact { + /// The biome identity the artifact claims. Identity binding resolves the + /// expected signing key from this, never from the connection. + #[must_use] + pub fn biome_id(&self) -> &str { + match self { + FederationArtifact::Summary(s) => &s.biome_id, + FederationArtifact::Event(e) => &e.biome_id, + } + } + + /// The hex ed25519 key the artifact says signed it, if any. It is a + /// *claim*: [`crate::federation::accept_artifact`] checks it against the + /// registered key for [`Self::biome_id`] and then checks the signature. + #[must_use] + pub fn signer_pubkey_hex(&self) -> Option<&str> { + match self { + FederationArtifact::Summary(s) => s.signer_pubkey_hex.as_deref(), + FederationArtifact::Event(e) => e.signer_pubkey_hex.as_deref(), + } + } + + /// Which QUIC stream class carries this artifact (ADR-269 §4.3: a + /// stalled summary stream must not block revocations). + #[must_use] + pub fn stream_class(&self) -> StreamClass { + match self { + FederationArtifact::Summary(_) => StreamClass::Summary, + FederationArtifact::Event(_) => StreamClass::Event, + } + } +} + +/// Artifact classes that get their own independent stream (ADR-269 §4.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StreamClass { + /// Regional summaries — bulky, latency-tolerant. + Summary, + /// Events, including revocations — small, latency-critical. + Event, +} + +impl StreamClass { + /// One-byte tag written at the head of a QUIC stream. + #[must_use] + pub fn tag(self) -> u8 { + match self { + StreamClass::Summary => 0, + StreamClass::Event => 1, + } + } + + /// Decode a stream tag. + #[must_use] + pub fn from_tag(tag: u8) -> Option { + match tag { + 0 => Some(StreamClass::Summary), + 1 => Some(StreamClass::Event), + _ => None, + } + } +} + +/// A federation peer as this gateway currently knows it. +/// +/// `biome_id` and `pubkey_hex` start `None` and are learned on first contact +/// (over HTTP, from `GET /api/federation/pubkey`; over QUIC they must be +/// known *before* connecting, because they are the pinned TLS identity). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PeerRef { + /// Peer base URL (HTTP) or `host:port` (QUIC). + pub url: String, + /// Peer biome identity, once learned. + pub biome_id: Option, + /// Peer biome ed25519 public key in hex, once learned. + pub pubkey_hex: Option, +} + +impl PeerRef { + /// A peer known only by address; identity is learned on first contact. + #[must_use] + pub fn new(url: impl Into) -> Self { + PeerRef { + url: url.into(), + biome_id: None, + pubkey_hex: None, + } + } + + /// A peer whose federation identity is already known — the form + /// `transport_quic::QuicTransport` requires, since the key is + /// the pinned TLS identity (ADR-269 §4). + #[must_use] + pub fn with_identity( + url: impl Into, + biome_id: impl Into, + pubkey_hex: impl Into, + ) -> Self { + PeerRef { + url: url.into(), + biome_id: Some(biome_id.into()), + pubkey_hex: Some(pubkey_hex.into()), + } + } + + /// The peer URL without a trailing slash (HTTP path building). + #[must_use] + pub fn base(&self) -> &str { + self.url.trim_end_matches('/') + } +} + +/// A peer's federation identity as published by the peer itself. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PeerIdentity { + /// Peer biome identity. + pub biome_id: String, + /// Peer biome ed25519 public key, hex. + pub pubkey_hex: String, +} + +/// Why a transport could not move an artifact. Transport failures are never +/// fatal to the gateway: a dead peer must not stop the others (ADR-265 §4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportError { + /// The peer could not be reached at all (connect / DNS / timeout). + Unreachable(String), + /// The peer answered, but not in a way the protocol allows. + Protocol(String), + /// **The peer's transport identity is not its registered federation + /// key** (ADR-269 §4, normative). The connection is refused; no artifact + /// crosses. + IdentityRefused { + /// The registered federation key we pinned. + expected: String, + /// What the peer actually presented (hex, or a diagnostic). + got: String, + }, + /// An artifact could not be encoded or decoded. + Encoding(String), +} + +impl std::fmt::Display for TransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TransportError::Unreachable(m) => write!(f, "peer unreachable: {m}"), + TransportError::Protocol(m) => write!(f, "protocol error: {m}"), + TransportError::IdentityRefused { expected, got } => write!( + f, + "peer transport identity refused: expected {expected}, got {got}" + ), + TransportError::Encoding(m) => write!(f, "encoding error: {m}"), + } + } +} + +impl std::error::Error for TransportError {} + +/// Boxed future returned by every [`FederationTransport`] verb — the +/// `async-trait`-free way to keep the trait object safe. +pub type TransportFuture<'a, T> = + Pin> + Send + 'a>>; + +/// A way to move signed federation artifacts between biomes (ADR-269 §3). +/// +/// Implementations move bytes and **nothing else**: every artifact they +/// return is unverified until [`crate::federation::accept_artifact`] has run +/// (ADR-269 §4, normative). +pub trait FederationTransport: Send + Sync { + /// Stable transport name, for logs and `/api/stats`. + fn name(&self) -> &'static str; + + /// Push one signed artifact to one peer. Best effort by design: a + /// dropped push is recovered by [`Self::sync_since`] (ADR-269 §3, §5). + fn announce<'a>( + &'a self, + peer: &'a PeerRef, + artifact: &'a FederationArtifact, + ) -> TransportFuture<'a, ()>; + + /// Take whatever the peer has streamed to us since the last call. + /// Transports without server push return an empty vector. + fn subscribe<'a>(&'a self, peer: &'a PeerRef) -> TransportFuture<'a, Vec>; + + /// Backfill everything the peer has from `since_ns` onwards — the + /// mandatory convergence path after a partition or a dropped push + /// (ADR-269 §3). + fn sync_since<'a>( + &'a self, + peer: &'a PeerRef, + since_ns: u64, + ) -> TransportFuture<'a, Vec>; + + /// Discover the peer's published federation identity, so `biome_id` and + /// `pubkey_hex` can be learned on first contact. + /// + /// Defaulted to `Ok(None)` ("this transport cannot discover identity — + /// keep whatever you already had"). HTTP fetches + /// `GET /api/federation/pubkey`; QUIC returns the identity it already + /// pinned, because over QUIC the key is a *precondition* of connecting + /// (ADR-269 §4). + fn identity<'a>(&'a self, peer: &'a PeerRef) -> TransportFuture<'a, Option> { + let _ = peer; + Box::pin(async { Ok(None) }) + } +} + +/// The always-available default transport: plain HTTP over the endpoints the +/// gateway already serves (ADR-269 §3 — "the existing behaviour, kept as the +/// always-available default with no new dependencies"). +pub struct HttpPollTransport { + /// One shared `reqwest` client (connection pooling, fixed timeout). + client: reqwest::Client, +} + +impl HttpPollTransport { + /// Build the HTTP transport. Fails only if the TLS/client stack cannot + /// be initialised. + pub fn new() -> Result { + let client = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|e| format!("http client: {e}"))?; + Ok(HttpPollTransport { client }) + } + + /// GET a JSON body, mapping transport and decode failures onto + /// [`TransportError`]. + async fn get_json( + &self, + url: &str, + ) -> Result { + let response = self + .client + .get(url) + .send() + .await + .map_err(|e| TransportError::Unreachable(format!("GET {url}: {e}")))?; + let response = response + .error_for_status() + .map_err(|e| TransportError::Protocol(format!("GET {url}: {e}")))?; + response + .json::() + .await + .map_err(|e| TransportError::Encoding(format!("decode {url}: {e}"))) + } +} + +impl std::fmt::Debug for HttpPollTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpPollTransport").finish_non_exhaustive() + } +} + +impl FederationTransport for HttpPollTransport { + fn name(&self) -> &'static str { + "http" + } + + /// `POST {peer}/api/federation/announce` with the artifact as JSON. A + /// 4xx means the peer refused to verify it — that is the peer doing its + /// job (ADR-269 §4), so it is reported as [`TransportError::Protocol`] + /// and never retried blindly; the backstop re-offers it as backfill. + fn announce<'a>( + &'a self, + peer: &'a PeerRef, + artifact: &'a FederationArtifact, + ) -> TransportFuture<'a, ()> { + Box::pin(async move { + let url = format!("{}/api/federation/announce", peer.base()); + let response = self + .client + .post(&url) + .json(artifact) + .send() + .await + .map_err(|e| TransportError::Unreachable(format!("POST {url}: {e}")))?; + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let body = response.text().await.unwrap_or_default(); + Err(TransportError::Protocol(format!( + "POST {url}: {status}: {body}" + ))) + }) + } + + /// **HTTP has no server push here.** There is no long-poll, no SSE and + /// no websocket on the gateway's federation surface, so there is nothing + /// for a peer to have streamed at us: this always returns an empty + /// vector, immediately and without a request. + /// + /// That is not a gap in convergence. Over HTTP a peer *pushes to us* by + /// calling `POST /api/federation/announce` (which lands in + /// [`crate::api`], not here), and anything that push missed is recovered + /// by [`Self::sync_since`]. + fn subscribe<'a>(&'a self, peer: &'a PeerRef) -> TransportFuture<'a, Vec> { + let _ = peer; + Box::pin(async { Ok(Vec::new()) }) + } + + /// The ADR-265 §4 poll, unchanged in behaviour and now expressed as a + /// backfill: `GET /api/federation/summary` then + /// `GET /api/federation/revocations`, returned as artifacts for + /// [`crate::federation::accept_artifact`] to verify. + /// + /// `since_ns` selects the summary window; `0` (never synced) asks for + /// the default hour, and anything older than a day is clamped (see + /// [`summary_window_s`]). + fn sync_since<'a>( + &'a self, + peer: &'a PeerRef, + since_ns: u64, + ) -> TransportFuture<'a, Vec> { + Box::pin(async move { + let base = peer.base(); + let window_s = summary_window_s(since_ns, crate::state::now_ns()); + let summary: RegionalSummary = self + .get_json(&format!( + "{base}/api/federation/summary?window_s={window_s}" + )) + .await?; + let events: Vec = self + .get_json(&format!("{base}/api/federation/revocations")) + .await?; + let mut out = Vec::with_capacity(events.len() + 1); + out.push(FederationArtifact::Summary(summary)); + out.extend(events.into_iter().map(FederationArtifact::Event)); + Ok(out) + }) + } + + /// `GET {peer}/api/federation/pubkey` — the peer's own statement of its + /// federation identity, and the only thing this gateway will accept a + /// signature from on that peer's behalf. + fn identity<'a>(&'a self, peer: &'a PeerRef) -> TransportFuture<'a, Option> { + Box::pin(async move { + let url = format!("{}/api/federation/pubkey", peer.base()); + let identity: PeerIdentity = self.get_json(&url).await?; + Ok(Some(identity)) + }) + } +} + +/// Summary window in seconds for a backfill that last succeeded at +/// `since_ns`, clamped to `[1, MAX_SUMMARY_WINDOW_S]`. Pure and +/// deterministic in its arguments so it is unit-testable without a clock. +#[must_use] +pub fn summary_window_s(since_ns: u64, now_ns: u64) -> u64 { + if since_ns == 0 { + return DEFAULT_SUMMARY_WINDOW_S; + } + let elapsed_s = now_ns.saturating_sub(since_ns) / 1_000_000_000; + elapsed_s.clamp(1, MAX_SUMMARY_WINDOW_S) +} + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_federation::{Biome, BiomeConfig}; + + const SEED: &[u8; 32] = b"rucelium-transport-test-seed-32!"; + + fn biome() -> Biome { + Biome::new(BiomeConfig::new("biome/transport"), SEED) + } + + #[test] + fn artifact_round_trips_and_reports_its_identity_claims() { + let mut b = biome(); + let event = b.revoke_device(7, 1_000, "compromised"); + let artifact = FederationArtifact::Event(event.clone()); + assert_eq!(artifact.biome_id(), "biome/transport"); + assert_eq!( + artifact.signer_pubkey_hex(), + Some(b.public_key_hex().as_str()) + ); + assert_eq!(artifact.stream_class(), StreamClass::Event); + + let json = serde_json::to_string(&artifact).expect("artifact serializes"); + assert!(json.contains("\"artifact\":\"event\""), "{json}"); + let back: FederationArtifact = serde_json::from_str(&json).expect("artifact decodes"); + assert_eq!(back, artifact); + + let summary = FederationArtifact::Summary(b.summarize(0, 5_000)); + assert_eq!(summary.stream_class(), StreamClass::Summary); + assert_eq!(summary.biome_id(), "biome/transport"); + let json = serde_json::to_string(&summary).expect("summary serializes"); + let back: FederationArtifact = serde_json::from_str(&json).expect("summary decodes"); + assert_eq!(back, summary); + } + + #[test] + fn stream_classes_are_distinct_and_round_trip() { + assert_ne!(StreamClass::Summary.tag(), StreamClass::Event.tag()); + assert_eq!( + StreamClass::from_tag(StreamClass::Summary.tag()), + Some(StreamClass::Summary) + ); + assert_eq!( + StreamClass::from_tag(StreamClass::Event.tag()), + Some(StreamClass::Event) + ); + assert_eq!(StreamClass::from_tag(9), None); + } + + #[test] + fn peer_ref_trims_and_carries_learned_identity() { + let bare = PeerRef::new("http://peer:7465/"); + assert_eq!(bare.base(), "http://peer:7465"); + assert!(bare.pubkey_hex.is_none()); + let known = PeerRef::with_identity("127.0.0.1:9", "biome/x", "ab12"); + assert_eq!(known.biome_id.as_deref(), Some("biome/x")); + assert_eq!(known.pubkey_hex.as_deref(), Some("ab12")); + } + + #[test] + fn transport_errors_display_their_cause() { + assert!(TransportError::Unreachable("down".into()) + .to_string() + .contains("down")); + assert!(TransportError::Protocol("418".into()) + .to_string() + .contains("418")); + assert!(TransportError::Encoding("bad json".into()) + .to_string() + .contains("bad json")); + let refused = TransportError::IdentityRefused { + expected: "aa".into(), + got: "bb".into(), + }; + let text = refused.to_string(); + assert!(text.contains("aa") && text.contains("bb"), "{text}"); + } + + #[test] + fn summary_window_is_clamped_and_deterministic() { + // Never synced: the default hour. + assert_eq!( + summary_window_s(0, 10_000_000_000), + DEFAULT_SUMMARY_WINDOW_S + ); + // 5 s of elapsed time asks for a 5 s window. + assert_eq!(summary_window_s(5_000_000_000, 10_000_000_000), 5); + // Sub-second gaps still ask for at least a second. + assert_eq!(summary_window_s(9_999_999_999, 10_000_000_000), 1); + // An ancient cursor is clamped, not unbounded. + assert_eq!(summary_window_s(1, u64::MAX), MAX_SUMMARY_WINDOW_S); + // Clock going backwards cannot underflow. + assert_eq!(summary_window_s(10_000_000_000, 1), 1); + } + + #[tokio::test] + async fn http_subscribe_is_an_empty_no_op() { + let t = HttpPollTransport::new().expect("http transport builds"); + assert_eq!(t.name(), "http"); + let peer = PeerRef::new("http://127.0.0.1:1"); + assert_eq!( + t.subscribe(&peer).await.expect("no-op subscribe"), + Vec::new() + ); + } + + #[tokio::test] + async fn http_transport_reports_unreachable_peers() { + let t = HttpPollTransport::new().expect("http transport builds"); + // Port 1 on loopback: nothing listens, connection is refused fast. + let peer = PeerRef::new("http://127.0.0.1:1"); + let err = t.identity(&peer).await.expect_err("must fail"); + assert!( + matches!(err, TransportError::Unreachable(_)), + "unexpected error: {err}" + ); + let mut b = biome(); + let artifact = FederationArtifact::Event(b.revoke_device(1, 1, "x")); + let err = t + .announce(&peer, &artifact) + .await + .expect_err("announce must fail"); + assert!( + matches!(err, TransportError::Unreachable(_)), + "unexpected error: {err}" + ); + } +} diff --git a/crates/rucelium-gateway/src/transport_quic.rs b/crates/rucelium-gateway/src/transport_quic.rs new file mode 100644 index 0000000..3209c6b --- /dev/null +++ b/crates/rucelium-gateway/src/transport_quic.rs @@ -0,0 +1,836 @@ +//! The optional QUIC federation transport (ADR-269 §4), behind the `quic` +//! cargo feature. +//! +//! QUIC earns its place at the *biome-to-biome* hop — never at the sensor +//! boundary (ADR-269 §2) — for four reasons the ADR lists: connection +//! migration across LTE → satellite → wifi, resumption after a partition, +//! **no head-of-line blocking between artifact classes** (§4.3), and +//! channel encryption that closes the alert-*timing* side channel for +//! sensitive-species deployments (ADR-266 §3.1). +//! +//! # Identity: RFC 7250 raw public keys pinned to the biome key +//! +//! ADR-269 §4 is normative: *"TLS identity is the biome's existing ed25519 +//! key, carried as a raw public key (RFC 7250) rather than X.509. No +//! certificate authority, no new PKI, no name-based trust … A peer's TLS +//! identity must equal its registered federation key or the connection is +//! refused."* +//! +//! That is implemented literally, not approximated: +//! +//! * **Server side** — the endpoint's TLS credential is the biome's own +//! ed25519 key. The private key is handed to rustls as a PKCS#8 v1 +//! encoding of the same 32-byte seed [`crate::state::biome_seed`] derives +//! the [`rucelium_federation::Biome`] identity from, so the QUIC identity +//! and the signing identity are *the same key*, not two keys that happen +//! to be provisioned together. The "certificate chain" is a single +//! [`rustls::pki_types::SubjectPublicKeyInfoDer`] and the resolver is +//! [`rustls::server::AlwaysResolvesServerRawPublicKeys`], which sets +//! `server_certificate_type = RawPublicKey` in the handshake. There is no +//! X.509 anywhere: no self-signed certificate, no subject name, no +//! validity window, no CA. +//! * **Client side** — [`PinnedBiomeKeyVerifier`] accepts exactly one SPKI: +//! the one built from the peer's registered federation key. Anything else +//! fails the handshake, and [`QuicTransport::announce`] reports it as +//! [`TransportError::IdentityRefused`] carrying both the expected and the +//! presented key. The verifier also declares +//! `requires_raw_public_keys() = true`, so rustls negotiates RFC 7250 and +//! **refuses** a peer that answers with an X.509 chain instead — a +//! downgrade to name-based trust is not reachable. +//! +//! Installing any custom verifier in rustls requires +//! `ClientConfig::dangerous()`. That call is not a bypass here: the verifier +//! it installs is *stricter* than webpki (one key, exact bytes, no name +//! matching, no CA), and it never returns `Ok` for an unpinned key. +//! +//! ## Honest limitations +//! +//! 1. **The server does not authenticate the client.** TLS client +//! authentication is not requested, so any host may open a connection and +//! push artifacts. This is deliberate and safe *only* because ADR-269 §4 +//! makes the session non-load-bearing: every artifact that arrives is +//! verified by [`crate::federation::accept_artifact`] — signature plus +//! `biome_id → key` identity binding — exactly as if it had been polled. +//! An unauthenticated connection can therefore waste our bandwidth; it +//! cannot revoke a device. Mutual raw-public-key auth would need a +//! registry of *inbound* peer keys and is honest follow-up work. +//! 2. **`subscribe` is endpoint-wide, not per-peer.** Artifacts pushed to +//! us over any inbound connection land in one queue; +//! [`QuicTransport::subscribe`] drains it and ignores its `peer` +//! argument except for logging. That is sound because the queue's +//! contents are unverified until the same gate runs on them, but it does +//! mean the transport cannot attribute an artifact to a connection — +//! only to the key that signed it, which is the attribution that counts. +//! 3. **0-RTT is not enabled.** Connection migration and loss recovery come +//! free with QUIC; resumption (ADR-269 §4 item 2) is not wired up. +//! 4. **`sync_since` needs a local source.** Serving backfill requires +//! reading this gateway's own event store, which the transport does not +//! own; a [`BackfillSource`] callback supplies it. Without one the +//! endpoint answers backfill requests with an empty set, and peers fall +//! back to HTTP for the backfill of record. + +use crate::transport::{ + FederationArtifact, FederationTransport, PeerIdentity, PeerRef, StreamClass, TransportError, + TransportFuture, +}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{ + alg_id, CertificateDer, PrivatePkcs8KeyDer, ServerName, SubjectPublicKeyInfoDer, UnixTime, +}; +use rustls::{DigitallySignedStruct, SignatureScheme}; +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::sync::Mutex; + +/// ALPN protocol identifier for the RuCelium federation protocol. +const ALPN: &[u8] = b"rucelium-fed/1"; +/// Server name presented in the TLS SNI. Unused for trust — the pinned raw +/// public key is the identity — but rustls requires a syntactically valid +/// name. +const SNI: &str = "biome.rucelium.invalid"; +/// Largest artifact frame accepted, in bytes. +const MAX_FRAME_BYTES: usize = 1 << 20; +/// Largest backfill response accepted, in bytes. +const MAX_BACKFILL_BYTES: usize = 8 << 20; +/// Bytes of an ed25519 public key. +const ED25519_KEY_BYTES: usize = 32; + +/// Supplies this gateway's own artifacts when a peer asks for a backfill +/// over QUIC (`sync_since`). Takes the peer's cursor in ns. +pub type BackfillSource = Arc Vec + Send + Sync>; + +/// Lowercase hex, matching `rucelium_federation`'s key encoding. +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// Decode lowercase/uppercase hex; `None` on odd length or non-hex bytes. +fn hex_decode(text: &str) -> Option> { + if !text.len().is_multiple_of(2) { + return None; + } + (0..text.len() / 2) + .map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok()) + .collect() +} + +/// PKCS#8 v1 wrapper around a raw ed25519 seed (RFC 8410 §7), the form +/// rustls' ring key provider parses. The 16-byte prefix is +/// `SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.112 }, OCTET STRING { +/// OCTET STRING (32) } }`. +fn ed25519_pkcs8(seed: &[u8; 32]) -> Vec { + let mut der = Vec::with_capacity(48); + der.extend_from_slice(&[ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, + 0x20, + ]); + der.extend_from_slice(seed); + der +} + +/// The SPKI a peer holding `pubkey_hex` must present. Built with rustls' +/// own encoder so it is byte-identical to what a peer's +/// `AlwaysResolvesServerRawPublicKeys` will send. +fn expected_spki(pubkey_hex: &str) -> Result, TransportError> { + let raw = hex_decode(pubkey_hex) + .ok_or_else(|| TransportError::Encoding(format!("peer key is not hex: {pubkey_hex}")))?; + if raw.len() != ED25519_KEY_BYTES { + return Err(TransportError::Encoding(format!( + "peer key is {} bytes, expected {ED25519_KEY_BYTES}", + raw.len() + ))); + } + Ok(rustls::sign::public_key_to_spki(&alg_id::ED25519, &raw) + .as_ref() + .to_vec()) +} + +/// The ed25519 public key inside an SPKI, hex-encoded — used to report what +/// a peer *actually* presented in [`TransportError::IdentityRefused`]. +fn key_hex_from_spki(spki: &[u8]) -> String { + if spki.len() >= ED25519_KEY_BYTES { + hex_encode(&spki[spki.len() - ED25519_KEY_BYTES..]) + } else { + format!("<{} byte credential>", spki.len()) + } +} + +/// What the last refused handshake presented, so `announce` can turn a +/// generic TLS failure into a precise [`TransportError::IdentityRefused`]. +#[derive(Debug, Default)] +struct PinOutcome { + /// Hex key the peer presented, set only when the pin check failed. + refused: StdMutex>, +} + +/// **The identity gate for QUIC** (ADR-269 §4): a rustls +/// [`ServerCertVerifier`] that accepts exactly one RFC 7250 raw public key — +/// the peer's registered federation key — and nothing else. +/// +/// It performs no name matching, consults no trust anchors, and has no +/// notion of certificate validity, because there is no certificate: the +/// credential *is* the key. A peer presenting any other key, or an X.509 +/// chain instead of a raw public key, fails the handshake. +#[derive(Debug)] +pub struct PinnedBiomeKeyVerifier { + /// DER SPKI of the one key this verifier will accept. + expected_spki: Vec, + /// Hex form of the same key, for diagnostics. + expected_hex: String, + /// Records a refusal for the connecting side to report. + outcome: Arc, + /// Crypto provider supplying the signature verification algorithms. + provider: Arc, +} + +impl PinnedBiomeKeyVerifier { + /// Pin to the ed25519 federation key `pubkey_hex`. + pub fn new( + pubkey_hex: &str, + provider: Arc, + ) -> Result { + Ok(PinnedBiomeKeyVerifier { + expected_spki: expected_spki(pubkey_hex)?, + expected_hex: pubkey_hex.to_string(), + outcome: Arc::new(PinOutcome::default()), + provider, + }) + } + + /// The key this verifier pins, hex-encoded. + #[must_use] + pub fn expected_hex(&self) -> &str { + &self.expected_hex + } +} + +impl ServerCertVerifier for PinnedBiomeKeyVerifier { + /// With `requires_raw_public_keys() == true`, `end_entity` is the peer's + /// DER `SubjectPublicKeyInfo`, not a certificate. Accept it only when it + /// is byte-for-byte the registered federation key. + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + if !intermediates.is_empty() { + return Err(rustls::Error::General( + "raw public key identity must be a single SPKI".into(), + )); + } + if end_entity.as_ref() == self.expected_spki.as_slice() { + return Ok(ServerCertVerified::assertion()); + } + if let Ok(mut refused) = self.outcome.refused.lock() { + *refused = Some(key_hex_from_spki(end_entity.as_ref())); + } + Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )) + } + + /// QUIC is TLS 1.3 only; a TLS 1.2 handshake signature is unreachable + /// and is refused rather than silently accepted. + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Err(rustls::Error::General( + "TLS 1.2 is not offered for QUIC federation".into(), + )) + } + + /// Verify the handshake signature *against the pinned raw key* — the + /// proof that the peer holds the private half of the biome key, not + /// merely a copy of its public half. + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + if cert.as_ref() != self.expected_spki.as_slice() { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )); + } + rustls::crypto::verify_tls13_signature_with_raw_key( + message, + &SubjectPublicKeyInfoDer::from(cert.as_ref()), + dss, + &self.provider.signature_verification_algorithms, + ) + } + + /// Only ed25519 — the biome identity algorithm. + fn supported_verify_schemes(&self) -> Vec { + vec![SignatureScheme::ED25519] + } + + /// Negotiate RFC 7250 raw public keys; an X.509 answer is refused by + /// rustls before this verifier is even consulted. + fn requires_raw_public_keys(&self) -> bool { + true + } +} + +/// One peer's live QUIC connection and its per-class send streams. +struct PeerConnection { + /// The QUIC connection (survives IP changes — ADR-269 §4 item 1). + connection: quinn::Connection, + /// One long-lived unidirectional stream per artifact class, each behind + /// its **own** lock so a stalled summary write cannot block a + /// revocation write (ADR-269 §4.3). + streams: BTreeMap>>, +} + +/// QUIC federation transport (ADR-269 §4). +/// +/// One endpoint serves both roles: it accepts inbound pushes from peers and +/// dials outbound connections, one per peer, each pinned to that peer's +/// registered ed25519 federation key. +pub struct QuicTransport { + /// The shared quinn endpoint (client + server). + endpoint: quinn::Endpoint, + /// This gateway's own federation identity. + identity: PeerIdentity, + /// Live outbound connections, keyed by peer address. + peers: Mutex>, + /// Artifacts peers pushed at us, awaiting `subscribe`. + inbound: Arc>>, + /// The rustls crypto provider (ring) used for every connection. + provider: Arc, + /// Background accept loop; aborted on drop. + accept_task: tokio::task::JoinHandle<()>, +} + +impl std::fmt::Debug for QuicTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("QuicTransport") + .field("biome_id", &self.identity.biome_id) + .field("pubkey_hex", &self.identity.pubkey_hex) + .finish_non_exhaustive() + } +} + +impl Drop for QuicTransport { + fn drop(&mut self) { + self.accept_task.abort(); + self.endpoint.close(0u32.into(), b"shutdown"); + } +} + +impl QuicTransport { + /// Bind a QUIC endpoint whose TLS identity **is** the biome's ed25519 + /// key (ADR-269 §4): pass the same 32-byte seed + /// [`crate::state::biome_seed`] gives [`rucelium_federation::Biome`], and + /// the transport identity and the signing identity are one key. + /// + /// `backfill` supplies this gateway's own artifacts when a peer calls + /// `sync_since`; `None` answers backfill requests with an empty set. + pub fn bind( + addr: SocketAddr, + biome_id: impl Into, + biome_seed: &[u8; 32], + backfill: Option, + ) -> Result { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + + // The biome key, as a rustls signing key. Same 32 bytes as the + // ed25519 identity the biome signs summaries and events with. + let pkcs8 = PrivatePkcs8KeyDer::from(ed25519_pkcs8(biome_seed)); + let signing_key = rustls::crypto::ring::sign::any_eddsa_type(&pkcs8) + .map_err(|e| TransportError::Protocol(format!("biome key unusable for TLS: {e}")))?; + let spki = signing_key + .public_key() + .ok_or_else(|| TransportError::Protocol("biome key exposes no SPKI".into()))?; + let pubkey_hex = key_hex_from_spki(spki.as_ref()); + let certified = rustls::sign::CertifiedKey::new( + vec![CertificateDer::from(spki.as_ref().to_vec())], + signing_key, + ); + + let mut server_crypto = rustls::ServerConfig::builder_with_provider(provider.clone()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|e| TransportError::Protocol(format!("TLS 1.3 unavailable: {e}")))? + .with_no_client_auth() + .with_cert_resolver(Arc::new( + rustls::server::AlwaysResolvesServerRawPublicKeys::new(Arc::new(certified)), + )); + server_crypto.alpn_protocols = vec![ALPN.to_vec()]; + let quic_server = quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto) + .map_err(|e| TransportError::Protocol(format!("quic server config: {e}")))?; + let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server)); + + let endpoint = quinn::Endpoint::server(server_config, addr) + .map_err(|e| TransportError::Unreachable(format!("bind quic {addr}: {e}")))?; + + let inbound = Arc::new(StdMutex::new(Vec::new())); + let accept_task = tokio::spawn(accept_loop(endpoint.clone(), inbound.clone(), backfill)); + + Ok(QuicTransport { + endpoint, + identity: PeerIdentity { + biome_id: biome_id.into(), + pubkey_hex, + }, + peers: Mutex::new(BTreeMap::new()), + inbound, + provider, + accept_task, + }) + } + + /// The address the endpoint actually bound (useful with port `0`). + pub fn local_addr(&self) -> Result { + self.endpoint + .local_addr() + .map_err(|e| TransportError::Unreachable(format!("quic local_addr: {e}"))) + } + + /// This gateway's own federation identity, as peers must pin it. + /// + /// Named `local_identity` so it cannot be confused with + /// [`FederationTransport::identity`], which reports a *peer's*. + #[must_use] + pub fn local_identity(&self) -> &PeerIdentity { + &self.identity + } + + /// Which artifact classes currently hold an open send stream to `peer`. + /// + /// Exposed because it is the observable form of the ADR-269 §4.3 + /// guarantee: summaries and events must occupy *different* streams, so + /// loss or backpressure on one cannot stall the other. + pub async fn open_stream_classes(&self, peer: &PeerRef) -> Vec { + let peers = self.peers.lock().await; + peers + .get(&peer.url) + .map(|c| c.streams.keys().copied().collect()) + .unwrap_or_default() + } + + /// A [`PeerRef`] describing this endpoint, ready for another + /// [`QuicTransport`] to pin and dial. + pub fn peer_ref(&self) -> Result { + Ok(PeerRef::with_identity( + self.local_addr()?.to_string(), + &self.identity.biome_id, + &self.identity.pubkey_hex, + )) + } + + /// Dial `peer`, pinning its registered federation key as the only + /// acceptable TLS identity (ADR-269 §4). A mismatch is reported as + /// [`TransportError::IdentityRefused`], never as a generic failure. + async fn dial(&self, peer: &PeerRef) -> Result { + let Some(pubkey_hex) = peer.pubkey_hex.as_deref() else { + return Err(TransportError::Protocol(format!( + "peer {} has no registered federation key; QUIC requires it before connecting", + peer.url + ))); + }; + let addr: SocketAddr = peer + .url + .parse() + .map_err(|e| TransportError::Unreachable(format!("bad quic peer {}: {e}", peer.url)))?; + + let verifier = Arc::new(PinnedBiomeKeyVerifier::new( + pubkey_hex, + self.provider.clone(), + )?); + let outcome = verifier.outcome.clone(); + let mut crypto = rustls::ClientConfig::builder_with_provider(self.provider.clone()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|e| TransportError::Protocol(format!("TLS 1.3 unavailable: {e}")))? + // Installing ANY custom verifier requires this call. The + // verifier installed here is strictly *stronger* than webpki: + // one pinned key, exact bytes, no CA, no name matching. + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + crypto.alpn_protocols = vec![ALPN.to_vec()]; + let quic_client = quinn::crypto::rustls::QuicClientConfig::try_from(crypto) + .map_err(|e| TransportError::Protocol(format!("quic client config: {e}")))?; + let client_config = quinn::ClientConfig::new(Arc::new(quic_client)); + + let refuse = |fallback: TransportError| -> TransportError { + match outcome.refused.lock() { + Ok(guard) => match guard.as_ref() { + Some(got) => TransportError::IdentityRefused { + expected: pubkey_hex.to_string(), + got: got.clone(), + }, + None => fallback, + }, + Err(_) => fallback, + } + }; + + let connecting = self + .endpoint + .connect_with(client_config, addr, SNI) + .map_err(|e| refuse(TransportError::Unreachable(format!("connect {addr}: {e}"))))?; + connecting.await.map_err(|e| { + refuse(TransportError::Unreachable(format!( + "handshake {addr}: {e}" + ))) + }) + } + + /// The send stream for one artifact class on one peer, opening the + /// connection and/or the stream on first use. + /// + /// Each class gets its own stream and its own lock, which is the whole + /// point of ADR-269 §4.3: a large summary in flight neither occupies the + /// revocation stream nor holds a lock a revocation needs. + async fn class_stream( + &self, + peer: &PeerRef, + class: StreamClass, + ) -> Result>, TransportError> { + let mut peers = self.peers.lock().await; + // Drop a connection the peer has closed, so the next call redials. + if let Some(existing) = peers.get(&peer.url) { + if existing.connection.close_reason().is_some() { + peers.remove(&peer.url); + } + } + if !peers.contains_key(&peer.url) { + let connection = self.dial(peer).await?; + peers.insert( + peer.url.clone(), + PeerConnection { + connection, + streams: BTreeMap::new(), + }, + ); + } + let entry = peers + .get_mut(&peer.url) + .ok_or_else(|| TransportError::Unreachable(format!("peer {} vanished", peer.url)))?; + if let Some(stream) = entry.streams.get(&class) { + return Ok(stream.clone()); + } + let mut send = entry + .connection + .open_uni() + .await + .map_err(|e| TransportError::Unreachable(format!("open_uni: {e}")))?; + send.write_all(&[class.tag()]) + .await + .map_err(|e| TransportError::Unreachable(format!("write class tag: {e}")))?; + let stream = Arc::new(Mutex::new(send)); + entry.streams.insert(class, stream.clone()); + Ok(stream) + } +} + +/// Encode one artifact as a length-prefixed JSON frame. +fn encode_frame(artifact: &FederationArtifact) -> Result, TransportError> { + let body = serde_json::to_vec(artifact) + .map_err(|e| TransportError::Encoding(format!("encode artifact: {e}")))?; + if body.len() > MAX_FRAME_BYTES { + return Err(TransportError::Encoding(format!( + "artifact is {} bytes, over the {MAX_FRAME_BYTES} byte frame limit", + body.len() + ))); + } + let mut frame = Vec::with_capacity(body.len() + 4); + frame.extend_from_slice(&(body.len() as u32).to_be_bytes()); + frame.extend_from_slice(&body); + Ok(frame) +} + +impl FederationTransport for QuicTransport { + fn name(&self) -> &'static str { + "quic" + } + + /// Push one artifact on the stream for its class (ADR-269 §4.3). + fn announce<'a>( + &'a self, + peer: &'a PeerRef, + artifact: &'a FederationArtifact, + ) -> TransportFuture<'a, ()> { + Box::pin(async move { + let frame = encode_frame(artifact)?; + let stream = self.class_stream(peer, artifact.stream_class()).await?; + let mut send = stream.lock().await; + send.write_all(&frame) + .await + .map_err(|e| TransportError::Unreachable(format!("write artifact: {e}")))?; + Ok(()) + }) + } + + /// Drain everything peers pushed at us since the last call. + /// + /// The queue is endpoint-wide (see the module's honest limitations), so + /// `peer` is not used to filter — attribution comes from the signing key + /// during verification, not from the connection. + fn subscribe<'a>(&'a self, peer: &'a PeerRef) -> TransportFuture<'a, Vec> { + let _ = peer; + Box::pin(async move { + let mut queue = self + .inbound + .lock() + .map_err(|_| TransportError::Protocol("inbound queue poisoned".into()))?; + Ok(std::mem::take(&mut *queue)) + }) + } + + /// Ask the peer for everything from `since_ns` on, over a fresh + /// bidirectional stream so a backfill never shares fate with the push + /// streams (ADR-269 §3, §4.3). + fn sync_since<'a>( + &'a self, + peer: &'a PeerRef, + since_ns: u64, + ) -> TransportFuture<'a, Vec> { + Box::pin(async move { + let connection = { + let mut peers = self.peers.lock().await; + if let Some(existing) = peers.get(&peer.url) { + if existing.connection.close_reason().is_some() { + peers.remove(&peer.url); + } + } + match peers.get(&peer.url) { + Some(existing) => existing.connection.clone(), + None => { + let connection = self.dial(peer).await?; + peers.insert( + peer.url.clone(), + PeerConnection { + connection: connection.clone(), + streams: BTreeMap::new(), + }, + ); + connection + } + } + }; + let (mut send, mut recv) = connection + .open_bi() + .await + .map_err(|e| TransportError::Unreachable(format!("open_bi: {e}")))?; + send.write_all(&since_ns.to_be_bytes()) + .await + .map_err(|e| TransportError::Unreachable(format!("write backfill cursor: {e}")))?; + send.finish().map_err(|e| { + TransportError::Unreachable(format!("finish backfill request: {e}")) + })?; + let body = recv + .read_to_end(MAX_BACKFILL_BYTES) + .await + .map_err(|e| TransportError::Unreachable(format!("read backfill: {e}")))?; + if body.is_empty() { + return Ok(Vec::new()); + } + serde_json::from_slice(&body) + .map_err(|e| TransportError::Encoding(format!("decode backfill: {e}"))) + }) + } + + /// Over QUIC the peer's key is a *precondition* of connecting, not + /// something learned afterwards — the handshake already proved the peer + /// holds it (ADR-269 §4). Report it back so the gateway can bind + /// `biome_id → key` exactly as it does for HTTP. + fn identity<'a>(&'a self, peer: &'a PeerRef) -> TransportFuture<'a, Option> { + Box::pin(async move { + match (peer.biome_id.as_deref(), peer.pubkey_hex.as_deref()) { + (Some(biome_id), Some(pubkey_hex)) => Ok(Some(PeerIdentity { + biome_id: biome_id.to_string(), + pubkey_hex: pubkey_hex.to_string(), + })), + _ => Err(TransportError::Protocol(format!( + "peer {} has no pinned federation identity", + peer.url + ))), + } + }) + } +} + +/// Accept inbound connections forever, servicing each on its own task. +async fn accept_loop( + endpoint: quinn::Endpoint, + inbound: Arc>>, + backfill: Option, +) { + while let Some(incoming) = endpoint.accept().await { + let inbound = inbound.clone(); + let backfill = backfill.clone(); + tokio::spawn(async move { + let connection = match incoming.await { + Ok(c) => c, + Err(e) => { + // A refused pin shows up here as a handshake failure. + eprintln!("gateway: quic inbound handshake failed: {e}"); + return; + } + }; + serve_connection(connection, inbound, backfill).await; + }); + } +} + +/// Service one inbound connection: every stream on its own task, so a +/// stalled summary stream cannot delay a revocation stream (ADR-269 §4.3). +async fn serve_connection( + connection: quinn::Connection, + inbound: Arc>>, + backfill: Option, +) { + loop { + tokio::select! { + uni = connection.accept_uni() => match uni { + Ok(recv) => { + let inbound = inbound.clone(); + tokio::spawn(async move { read_class_stream(recv, inbound).await; }); + } + Err(_) => return, + }, + bi = connection.accept_bi() => match bi { + Ok((send, recv)) => { + let backfill = backfill.clone(); + tokio::spawn(async move { serve_backfill(send, recv, backfill).await; }); + } + Err(_) => return, + }, + } + } +} + +/// Read length-prefixed artifact frames off one class stream until it ends. +async fn read_class_stream( + mut recv: quinn::RecvStream, + inbound: Arc>>, +) { + let mut tag = [0u8; 1]; + if recv.read_exact(&mut tag).await.is_err() { + return; + } + if StreamClass::from_tag(tag[0]).is_none() { + eprintln!( + "gateway: quic peer opened a stream with unknown class {}", + tag[0] + ); + return; + } + loop { + let mut len = [0u8; 4]; + if recv.read_exact(&mut len).await.is_err() { + return; // stream ended (or the peer went away) + } + let len = u32::from_be_bytes(len) as usize; + if len > MAX_FRAME_BYTES { + eprintln!("gateway: quic frame of {len} bytes exceeds the limit; dropping stream"); + return; + } + let mut body = vec![0u8; len]; + if recv.read_exact(&mut body).await.is_err() { + return; + } + match serde_json::from_slice::(&body) { + Ok(artifact) => { + if let Ok(mut queue) = inbound.lock() { + queue.push(artifact); + } + } + Err(e) => eprintln!("gateway: undecodable quic artifact: {e}"), + } + } +} + +/// Answer one backfill request from the local [`BackfillSource`]. +async fn serve_backfill( + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + backfill: Option, +) { + let mut cursor = [0u8; 8]; + if recv.read_exact(&mut cursor).await.is_err() { + return; + } + let since_ns = u64::from_be_bytes(cursor); + let artifacts = backfill.map(|source| source(since_ns)).unwrap_or_default(); + let body = match serde_json::to_vec(&artifacts) { + Ok(b) => b, + Err(e) => { + eprintln!("gateway: encoding quic backfill failed: {e}"); + return; + } + }; + if send.write_all(&body).await.is_err() { + return; + } + let _ = send.finish(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkcs8_wrapper_is_the_rfc_8410_encoding_and_round_trips_through_ring() { + let seed = [7u8; 32]; + let der = ed25519_pkcs8(&seed); + assert_eq!(der.len(), 48); + assert_eq!(&der[..2], &[0x30, 0x2e]); + assert_eq!(&der[der.len() - 32..], &seed); + // ring accepts it, and the derived SPKI holds a 32-byte key. + let key = rustls::crypto::ring::sign::any_eddsa_type(&PrivatePkcs8KeyDer::from(der)) + .expect("ring parses the biome key"); + let spki = key.public_key().expect("ed25519 exposes an SPKI"); + assert_eq!(spki.as_ref().len(), 44); + assert_eq!(key_hex_from_spki(spki.as_ref()).len(), 64); + } + + #[test] + fn expected_spki_matches_what_a_server_would_present() { + let seed = [3u8; 32]; + let key = rustls::crypto::ring::sign::any_eddsa_type(&PrivatePkcs8KeyDer::from( + ed25519_pkcs8(&seed), + )) + .expect("ring parses the biome key"); + let served = key.public_key().expect("spki").as_ref().to_vec(); + let pinned = expected_spki(&key_hex_from_spki(&served)).expect("pin builds"); + assert_eq!( + pinned, served, + "the pinned SPKI must be byte-identical to the served one" + ); + } + + #[test] + fn malformed_peer_keys_are_encoding_errors_not_silent_accepts() { + assert!(matches!( + expected_spki("nothex!"), + Err(TransportError::Encoding(_)) + )); + assert!(matches!( + expected_spki("aabb"), + Err(TransportError::Encoding(_)) + )); + assert_eq!(hex_decode("abc"), None); + assert_eq!(hex_decode("zz"), None); + assert_eq!(hex_decode("00ff"), Some(vec![0x00, 0xff])); + } + + #[test] + fn hex_round_trips() { + let bytes: Vec = (0u8..=255).collect(); + assert_eq!(hex_decode(&hex_encode(&bytes)), Some(bytes)); + } +} diff --git a/crates/rucelium-gateway/tests/e2e.rs b/crates/rucelium-gateway/tests/e2e.rs new file mode 100644 index 0000000..ad781b6 --- /dev/null +++ b/crates/rucelium-gateway/tests/e2e.rs @@ -0,0 +1,220 @@ +//! End-to-end test of the running daemon stack (ADR-265 §4): two gateways +//! on ephemeral ports, real UDP envelopes, the real HTTP API, and network +//! federation of a device revocation from gateway A to gateway B. + +use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; +use rucelium_gateway::{spawn_gateway_with_state, GatewayConfig, GatewayState}; +use serde_json::Value; +use std::path::PathBuf; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::net::UdpSocket; + +const SEED: &[u8; 32] = b"rucelium-e2e-provision-seed-32b!"; +const NODE: u64 = 0x5CE2_0000_0000_0001; + +/// Unique temp data dir per gateway under the system temp dir. +fn temp_dir(tag: &str) -> PathBuf { + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("rucelium-gw-e2e-{tag}-{}-{t}", std::process::id())) +} + +/// A genuine signed v1 envelope from `NODE` with the given sequence. +fn envelope(sequence: u32) -> Vec { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() as u64; + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: 5, // weather + flags: 0, + node_id: NODE, + timestamp_ns: ts, + sequence, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: 16 * 65_536, + quality_q15: 0x7000, + battery_mv: 3_600, + calibration_id: 0, + }; + NodeSigner::for_node(SEED, NODE).sign_sample(&wire).encode() +} + +/// Fetch a JSON body. +async fn get_json(client: &reqwest::Client, url: &str) -> Value { + client + .get(url) + .send() + .await + .unwrap_or_else(|e| panic!("GET {url}: {e}")) + .json() + .await + .unwrap_or_else(|e| panic!("decode {url}: {e}")) +} + +/// Poll `url` until `pred` holds, panicking after `timeout`. +async fn wait_for_json(client: &reqwest::Client, url: &str, timeout: Duration, pred: F) -> Value +where + F: Fn(&Value) -> bool, +{ + let deadline = Instant::now() + timeout; + loop { + let v = get_json(client, url).await; + if pred(&v) { + return v; + } + assert!( + Instant::now() < deadline, + "timed out waiting on {url}; last body: {v}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn full_stack_ingest_api_and_peer_revocation_federation() { + let client = reqwest::Client::new(); + let signer = NodeSigner::for_node(SEED, NODE); + let sender = UdpSocket::bind("127.0.0.1:0").await.expect("bind sender"); + + // --- Gateway A: full stack on ephemeral ports, node provisioned before + // any traffic can race the registration. --- + let dir_a = temp_dir("a"); + let cfg_a = GatewayConfig { + biome_id: "biome/e2e-a".into(), + udp_port: 0, + http_port: 0, + data_dir: dir_a.clone(), + federation_poll_ms: 200, + ..GatewayConfig::default() + }; + let state_a = GatewayState::open(&cfg_a).expect("open state a"); + state_a.inner.lock().await.ingest.registry_mut().register( + NODE, + signer.public_key(), + "sha256:e2e-fw".into(), + ); + let a = spawn_gateway_with_state(state_a, cfg_a) + .await + .expect("spawn gateway a"); + let a_http = format!("http://127.0.0.1:{}", a.http_port); + + // (1) Health. + let health = get_json(&client, &format!("{a_http}/health")).await; + assert_eq!(health["ok"], true); + assert_eq!(health["biome_id"], "biome/e2e-a"); + + // (2) A genuine signed envelope over real UDP is accepted end to end. + sender + .send_to(&envelope(1), ("127.0.0.1", a.udp_port)) + .await + .expect("send envelope to a"); + let stats = wait_for_json( + &client, + &format!("{a_http}/api/stats"), + Duration::from_secs(5), + |v| v["ingest"]["accepted"] == 1, + ) + .await; + assert_eq!(stats["observations"]["records"], 1); + assert_eq!(stats["biome"]["accepted"], 1); + assert_eq!(stats["worldgraph"]["nodes"], 1); + + // (3) The SensorThings projection serves the observation. + let st = get_json(&client, &format!("{a_http}/api/sensorthings/Observations")).await; + let obs = st["value"].as_array().expect("value array"); + assert_eq!(obs.len(), 1); + assert_eq!(obs[0]["@iot.id"], format!("obs:{NODE}:1")); + let things = get_json(&client, &format!("{a_http}/api/sensorthings/Things")).await; + assert_eq!(things["value"].as_array().expect("things").len(), 1); + + // (4) Admin revocation produces one signed DeviceRevoked event. + client + .post(format!("{a_http}/api/admin/revoke/{NODE}")) + .send() + .await + .expect("revoke request") + .error_for_status() + .expect("revoke ok"); + let revs = get_json(&client, &format!("{a_http}/api/federation/revocations")).await; + let revs = revs.as_array().expect("revocations array"); + assert_eq!(revs.len(), 1); + let event: rucelium_core::EnvironmentalEvent = + serde_json::from_value(revs[0].clone()).expect("event decodes"); + assert!( + rucelium_federation::verify_event(&event), + "served revocation must verify" + ); + + // --- Gateway B: peers with A; the same node is provisioned before the + // federation task starts, so the peer revocation must land on it. --- + let dir_b = temp_dir("b"); + let cfg_b = GatewayConfig { + biome_id: "biome/e2e-b".into(), + udp_port: 0, + http_port: 0, + data_dir: dir_b.clone(), + peers: vec![a_http.clone()], + federation_poll_ms: 200, + ..GatewayConfig::default() + }; + let state_b = GatewayState::open(&cfg_b).expect("open state b"); + state_b.inner.lock().await.ingest.registry_mut().register( + NODE, + signer.public_key(), + "sha256:e2e-fw".into(), + ); + let b = spawn_gateway_with_state(state_b, cfg_b) + .await + .expect("spawn gateway b"); + let b_http = format!("http://127.0.0.1:{}", b.http_port); + + // (5) B applies A's verified revocation within a few poll ticks. + let b_stats = wait_for_json( + &client, + &format!("{b_http}/api/stats"), + Duration::from_secs(10), + |v| v["applied_peer_revocations"] == 1, + ) + .await; + assert_eq!(b_stats["applied_peer_revocations"], 1); + // ...and it verified A's summary too. + wait_for_json( + &client, + &format!("{b_http}/api/stats"), + Duration::from_secs(10), + |v| v["peer_summaries"] == 1, + ) + .await; + let peers = get_json(&client, &format!("{b_http}/api/federation/peers")).await; + assert_eq!(peers.as_array().expect("peers array").len(), 1); + assert_eq!(peers[0]["summary"]["biome_id"], "biome/e2e-a"); + + // (6) B's registry now rejects the revoked node's envelopes over UDP. + sender + .send_to(&envelope(2), ("127.0.0.1", b.udp_port)) + .await + .expect("send envelope to b"); + wait_for_json( + &client, + &format!("{b_http}/api/stats"), + Duration::from_secs(5), + |v| v["ingest"]["revoked_device"] == 1, + ) + .await; + let b_final = get_json(&client, &format!("{b_http}/api/stats")).await; + assert_eq!(b_final["ingest"]["accepted"], 0); + assert_eq!(b_final["observations"]["records"], 0); + + // Shut both gateways down and clean up. + for task in a.tasks.into_iter().chain(b.tasks) { + task.abort(); + } + std::fs::remove_dir_all(&dir_a).ok(); + std::fs::remove_dir_all(&dir_b).ok(); +} diff --git a/crates/rucelium-gateway/tests/push_federation.rs b/crates/rucelium-gateway/tests/push_federation.rs new file mode 100644 index 0000000..1b1ba8d --- /dev/null +++ b/crates/rucelium-gateway/tests/push_federation.rs @@ -0,0 +1,460 @@ +//! ADR-269 §3 acceptance: push federation over the default (HTTP) path. +//! +//! Everything here is transport-agnostic and runs with the `quic` feature +//! **off**, which is the point of ADR-269 §5's additive constraint. The +//! properties under test: +//! +//! 1. **push on revocation reaches a peer and is applied** without waiting +//! for a poll — the receiving gateway has no backstop running at all, so +//! a pass can only come from the push; +//! 2. a pushed artifact with a **bad signature** is rejected 4xx and not +//! applied; +//! 3. a pushed artifact whose **signer is not the claimed biome's registered +//! key** is rejected — the identity-binding path — even though its +//! signature is perfectly valid; +//! 4. a **duplicate** pushed event is applied exactly once; +//! 5. **backfill converges** a peer that never received a push, with the +//! push path unavailable — the ADR-269 §3 backstop doing its job; +//! 6. the `/api/stats` push counters move the way they claim to. + +use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; +use rucelium_core::EnvironmentalEvent; +use rucelium_federation::{Biome, BiomeConfig}; +use rucelium_gateway::federation::register_peer_identity; +use rucelium_gateway::state::biome_seed; +use rucelium_gateway::{spawn_gateway_with_state, GatewayConfig, GatewayHandle, GatewayState}; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Device provisioning seed shared by both gateways in a test. +const SEED: &[u8; 32] = b"rucelium-push-provision-seed-32!"; +/// The one device these tests revoke. +const NODE: u64 = 0x5CF0_0000_0000_0001; +/// A backstop interval far enough away that it cannot fire during a test +/// after the initial tick, so a pass can only come from the push path. +const NEVER_MS: u64 = 3_600_000; +/// A brisk backstop interval for the convergence test. +const FAST_MS: u64 = 100; + +/// Wall-clock nanoseconds. +fn now_ns() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() as u64 +} + +/// A unique temp data dir. +fn temp_dir(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rucelium-gw-push-{tag}-{}-{}", + std::process::id(), + now_ns() + )) +} + +/// The biome public key a gateway with this id (and the default seed) will +/// have. Biome identity is deterministic in `(biome_id, seed)`, so a peer's +/// federation key can be registered before that peer ever runs — which is +/// how these tests bind identity without a bootstrap poll. +fn biome_key_hex(biome_id: &str) -> String { + Biome::new( + BiomeConfig::new(biome_id), + &biome_seed(biome_id, GatewayConfig::default().seed), + ) + .public_key_hex() +} + +/// A genuine signed v1 envelope from `NODE`. +fn envelope(sequence: u32) -> Vec { + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: 5, // weather + flags: 0, + node_id: NODE, + timestamp_ns: now_ns(), + sequence, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: 16 * 65_536, + quality_q15: 0x7000, + battery_mv: 3_600, + calibration_id: 0, + }; + NodeSigner::for_node(SEED, NODE).sign_sample(&wire).encode() +} + +/// A running test gateway. +struct Gw { + /// Tasks and ports. + handle: GatewayHandle, + /// Base HTTP URL. + http: String, + /// Data directory, removed by [`Gw::shutdown`]. + dir: PathBuf, +} + +impl Gw { + /// Spawn a gateway with `NODE` provisioned, plus any peer federation + /// identities pre-registered, before any task can race them. + async fn spawn( + tag: &str, + biome_id: &str, + peers: Vec, + backfill_ms: u64, + known: &[(&str, &str)], + ) -> Self { + let dir = temp_dir(tag); + let config = GatewayConfig { + biome_id: biome_id.into(), + udp_port: 0, + http_port: 0, + data_dir: dir.clone(), + peers, + federation_poll_ms: backfill_ms, + federation_backfill_ms: Some(backfill_ms), + ..GatewayConfig::default() + }; + let state = GatewayState::open(&config).expect("open state"); + { + let mut inner = state.inner.lock().await; + inner.ingest.registry_mut().register( + NODE, + NodeSigner::for_node(SEED, NODE).public_key(), + "sha256:push-fw".into(), + ); + for (peer_biome, url) in known { + register_peer_identity(&mut inner, peer_biome, &biome_key_hex(peer_biome), url); + } + } + let handle = spawn_gateway_with_state(state, config) + .await + .expect("spawn gateway"); + let http = format!("http://127.0.0.1:{}", handle.http_port); + Gw { handle, http, dir } + } + + /// Stop every task and remove the data directory. + fn shutdown(self) { + for task in self.handle.tasks { + task.abort(); + } + std::fs::remove_dir_all(&self.dir).ok(); + } +} + +/// Fetch a JSON body. +async fn get_json(client: &reqwest::Client, url: &str) -> Value { + client + .get(url) + .send() + .await + .unwrap_or_else(|e| panic!("GET {url}: {e}")) + .json() + .await + .unwrap_or_else(|e| panic!("decode {url}: {e}")) +} + +/// Poll `url` until `pred` holds, panicking after `timeout`. +async fn wait_for_json(client: &reqwest::Client, url: &str, timeout: Duration, pred: F) -> Value +where + F: Fn(&Value) -> bool, +{ + let deadline = Instant::now() + timeout; + loop { + let v = get_json(client, url).await; + if pred(&v) { + return v; + } + assert!( + Instant::now() < deadline, + "timed out waiting on {url}; last body: {v}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// `POST {http}/api/federation/announce`, returning status and body. +async fn announce( + client: &reqwest::Client, + http: &str, + artifact: &Value, +) -> (reqwest::StatusCode, Value) { + let response = client + .post(format!("{http}/api/federation/announce")) + .json(artifact) + .send() + .await + .expect("announce request"); + let status = response.status(); + let body = response.json().await.unwrap_or(Value::Null); + (status, body) +} + +/// Wrap an event as the body `POST /api/federation/announce` accepts. +fn event_artifact(event: &EnvironmentalEvent) -> Value { + let mut value = serde_json::to_value(event).expect("event serializes"); + value["artifact"] = json!("event"); + value +} + +/// Revoke `NODE` on a gateway and return the response body. +async fn revoke(client: &reqwest::Client, http: &str) -> Value { + client + .post(format!("{http}/api/admin/revoke/{NODE}")) + .send() + .await + .expect("revoke request") + .json() + .await + .expect("revoke body") +} + +/// (1), (4) and (6): a revocation on A reaches B by push. B runs **no** +/// federation task at all — it has no peers — so nothing but the push can +/// possibly deliver it. +#[tokio::test(flavor = "multi_thread")] +async fn push_on_revocation_reaches_a_peer_without_waiting_for_a_poll() { + let client = reqwest::Client::new(); + + // B: no peers, therefore no poller and no backstop. It knows A's + // federation key (registered up front, as a real deployment would from + // a first contact) so it can identity-bind what A pushes. + let b = Gw::spawn( + "recv-b", + "biome/push-b", + Vec::new(), + NEVER_MS, + &[("biome/push-a", "http://a.invalid")], + ) + .await; + + // A: peers with B, backstop parked an hour out. + let a = Gw::spawn( + "send-a", + "biome/push-a", + vec![b.http.clone()], + NEVER_MS, + &[], + ) + .await; + + let before = get_json(&client, &format!("{}/api/stats", b.http)).await; + assert_eq!(before["applied_peer_revocations"], 0); + assert_eq!(before["pushes_received"], 0); + + let pushed_at = Instant::now(); + let response = revoke(&client, &a.http).await; + assert_eq!( + response["pushed"], true, + "revoke must queue an immediate push: {response}" + ); + + // B applies it. There is no timer on B that could have done this. + let b_stats = wait_for_json( + &client, + &format!("{}/api/stats", b.http), + Duration::from_secs(10), + |v| v["applied_peer_revocations"] == 1, + ) + .await; + assert!( + pushed_at.elapsed() < Duration::from_secs(10), + "push latency is link speed, not poll speed" + ); + assert_eq!(b_stats["pushes_received"], 1); + assert_eq!(b_stats["backfills"], 0, "B has no peers to back off"); + + // (6) A counted the push it sent, and nothing failed. + let a_stats = wait_for_json( + &client, + &format!("{}/api/stats", a.http), + Duration::from_secs(10), + |v| v["pushes_sent"] == 1, + ) + .await; + assert_eq!(a_stats["push_failures"], 0); + assert_eq!(a_stats["push"]["pushes_sent"], 1); + + // (4) Re-pushing the identical event is verified, accepted, applied + // once: `202 Accepted`, `applied: false`. + let event: EnvironmentalEvent = + serde_json::from_value(response["event"].clone()).expect("event decodes"); + let (status, body) = announce(&client, &b.http, &event_artifact(&event)).await; + assert_eq!(status, reqwest::StatusCode::ACCEPTED, "{body}"); + assert_eq!(body["applied"], false); + let after = get_json(&client, &format!("{}/api/stats", b.http)).await; + assert_eq!(after["applied_peer_revocations"], 1, "applied exactly once"); + assert_eq!(after["pushes_received"], 1, "a no-op is not a delivery"); + + // The revocation is real: B's registry rejects the node's traffic now. + let sender = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind sender"); + sender + .send_to(&envelope(1), ("127.0.0.1", b.handle.udp_port)) + .await + .expect("send envelope"); + wait_for_json( + &client, + &format!("{}/api/stats", b.http), + Duration::from_secs(5), + |v| v["ingest"]["revoked_device"] == 1, + ) + .await; + + a.shutdown(); + b.shutdown(); +} + +/// (2) and (3): a pushed artifact that does not verify — tampered, or signed +/// by a key that is not the claimed biome's registered key — gets a 4xx and +/// changes nothing. Being *pushed* buys an artifact no trust (ADR-269 §4). +#[tokio::test(flavor = "multi_thread")] +async fn pushed_artifacts_that_do_not_verify_are_refused_and_never_applied() { + let client = reqwest::Client::new(); + let a = Gw::spawn("bad-a", "biome/bad-a", Vec::new(), NEVER_MS, &[]).await; + let b = Gw::spawn( + "bad-b", + "biome/bad-b", + Vec::new(), + NEVER_MS, + &[("biome/bad-a", "http://a.invalid")], + ) + .await; + + // A genuine revocation from A, which B *would* accept. + let response = revoke(&client, &a.http).await; + let good: EnvironmentalEvent = + serde_json::from_value(response["event"].clone()).expect("event decodes"); + + // (2) Bad signature: one character added after signing. 400, not + // applied. + let mut tampered = good.clone(); + tampered.message.push('!'); + let (status, body) = announce(&client, &b.http, &event_artifact(&tampered)).await; + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(body["applied"], false); + + // (3) Identity mismatch: a *validly signed* revocation claiming A's + // biome id, signed by a key that is not A's registered key. 403, not + // applied — a valid signature is not an identity. + let mut impostor = Biome::new( + BiomeConfig::new("biome/bad-a"), + b"rucelium-impostor-seed-32-bytes!", + ); + let forged = impostor.revoke_device(NODE, now_ns(), "forged"); + assert!(rucelium_federation::verify_event(&forged)); + assert_ne!(forged.signer_pubkey_hex, good.signer_pubkey_hex); + let (status, body) = announce(&client, &b.http, &event_artifact(&forged)).await; + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{body}"); + assert!( + body["error"].as_str().unwrap_or_default().contains("bad-a"), + "identity-binding failure must name the biome: {body}" + ); + + // An artifact from an entirely unknown biome cannot be identity-bound + // at all, so it is refused too. + let mut stranger = Biome::new( + BiomeConfig::new("biome/stranger"), + b"rucelium-stranger-seed-32-bytes!", + ); + let strange = stranger.revoke_device(NODE, now_ns(), "who?"); + let (status, _) = announce(&client, &b.http, &event_artifact(&strange)).await; + assert_eq!(status, reqwest::StatusCode::FORBIDDEN); + + // Nothing landed. + let stats = get_json(&client, &format!("{}/api/stats", b.http)).await; + assert_eq!(stats["applied_peer_revocations"], 0); + assert_eq!(stats["pushes_received"], 0); + + // ...and the genuine one still works, proving the refusals were about + // the artifacts rather than a broken endpoint. + let (status, body) = announce(&client, &b.http, &event_artifact(&good)).await; + assert_eq!(status, reqwest::StatusCode::OK, "{body}"); + assert_eq!(body["applied"], true); + let stats = get_json(&client, &format!("{}/api/stats", b.http)).await; + assert_eq!(stats["applied_peer_revocations"], 1); + assert_eq!(stats["pushes_received"], 1); + + a.shutdown(); + b.shutdown(); +} + +/// (5): the mandatory backstop. A revokes while it has no peers at all, so +/// no `announce` is ever attempted; B still converges from `sync_since` +/// alone (ADR-269 §3: "a peer that missed a pushed event must still +/// converge"). +#[tokio::test(flavor = "multi_thread")] +async fn a_peer_that_missed_the_push_converges_through_backfill() { + let client = reqwest::Client::new(); + + // A federates with nobody: the revocation exists only in its own store. + let a = Gw::spawn("back-a", "biome/back-a", Vec::new(), NEVER_MS, &[]).await; + let response = revoke(&client, &a.http).await; + assert_eq!(response["pushed"], false, "A has no peers to push to"); + let a_stats = get_json(&client, &format!("{}/api/stats", a.http)).await; + assert_eq!(a_stats["pushes_sent"], 0); + + // B polls A on a brisk backstop and converges with no push involved. + let b = Gw::spawn("back-b", "biome/back-b", vec![a.http.clone()], FAST_MS, &[]).await; + let b_stats = wait_for_json( + &client, + &format!("{}/api/stats", b.http), + Duration::from_secs(10), + |v| v["applied_peer_revocations"] == 1, + ) + .await; + assert_eq!( + b_stats["pushes_received"], 0, + "convergence here is backfill, not push" + ); + assert!(b_stats["backfills"].as_u64().unwrap_or(0) >= 1); + // Identity was learned from A itself, over the same backfill pass. + assert_eq!(b_stats["known_peers"], 1); + // ...which also carried A's signed summary. + wait_for_json( + &client, + &format!("{}/api/stats", b.http), + Duration::from_secs(10), + |v| v["peer_summaries"] == 1, + ) + .await; + + a.shutdown(); + b.shutdown(); +} + +/// A push aimed at a peer that is not there is counted as a failure and is +/// never fatal — the gateway keeps federating (ADR-265 §4, ADR-269 §3). +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_push_is_counted_and_never_fatal() { + let client = reqwest::Client::new(); + // Port 1 on loopback: nothing listens there, ever. + let a = Gw::spawn( + "dead-peer", + "biome/dead-peer", + vec!["http://127.0.0.1:1".into()], + FAST_MS, + &[], + ) + .await; + + revoke(&client, &a.http).await; + + let stats = wait_for_json( + &client, + &format!("{}/api/stats", a.http), + Duration::from_secs(10), + |v| v["push_failures"].as_u64().unwrap_or(0) >= 1, + ) + .await; + assert_eq!(stats["pushes_sent"], 0); + // Still alive and serving. + let health = get_json(&client, &format!("{}/health", a.http)).await; + assert_eq!(health["ok"], true); + + a.shutdown(); +} diff --git a/crates/rucelium-gateway/tests/quic_federation.rs b/crates/rucelium-gateway/tests/quic_federation.rs new file mode 100644 index 0000000..daf0604 --- /dev/null +++ b/crates/rucelium-gateway/tests/quic_federation.rs @@ -0,0 +1,342 @@ +//! ADR-269 §4 acceptance for the optional QUIC transport (`--features quic`). +//! +//! The important test in this file is the second one. QUIC's job here is +//! defence in depth — connection migration, loss recovery, and closing the +//! alert-*timing* side channel — and the ADR is normative that it must never +//! become the trust boundary. So the properties are: +//! +//! 1. the biome's ed25519 identity really is the TLS identity, and an +//! announce round-trips and then **verifies through the same gate** as +//! anything polled over HTTP; +//! 2. **a peer whose TLS identity is not its registered federation key is +//! refused** — no connection, no artifact, and a precise +//! `IdentityRefused` naming both keys; +//! 3. summaries and events occupy independent streams, so a large summary +//! cannot stall a revocation (§4.3); +//! 4. `sync_since` backfills over QUIC as it does over HTTP (§3). + +#![cfg(feature = "quic")] + +use rucelium_abi::NodeSigner; +use rucelium_federation::ModalityStats; +use rucelium_federation::{Biome, BiomeConfig}; +use rucelium_gateway::federation::{accept_artifact, register_peer_identity, ArtifactEffect}; +use rucelium_gateway::transport::{ + FederationArtifact, FederationTransport, PeerRef, StreamClass, TransportError, +}; +use rucelium_gateway::transport_quic::{BackfillSource, QuicTransport}; +use rucelium_gateway::{GatewayConfig, Inner}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Biome signing seeds. Distinct seeds ⇒ distinct federation keys ⇒ +/// distinct pinned TLS identities. +const SEED_A: &[u8; 32] = b"rucelium-quic-biome-a-seed-32b!!"; +const SEED_B: &[u8; 32] = b"rucelium-quic-biome-b-seed-32b!!"; +const SEED_C: &[u8; 32] = b"rucelium-quic-biome-c-seed-32b!!"; +/// Device provisioning seed. +const NODE_SEED: &[u8; 32] = b"rucelium-quic-node-seed-32-byte!"; +/// The device the revocation targets. +const NODE: u64 = 0x5CFC_0000_0000_0001; + +/// Wall-clock nanoseconds. +fn now_ns() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() as u64 +} + +/// A unique temp data dir. +fn temp_dir(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rucelium-gw-quic-{tag}-{}-{}", + std::process::id(), + now_ns() + )) +} + +/// A local QUIC endpoint whose TLS identity is `biome_id`'s ed25519 key. +fn endpoint(biome_id: &str, seed: &[u8; 32], backfill: Option) -> QuicTransport { + QuicTransport::bind( + "127.0.0.1:0".parse().expect("loopback address"), + biome_id, + seed, + backfill, + ) + .expect("quic endpoint binds") +} + +/// A fresh gateway state with `NODE` provisioned and `peer`'s federation +/// identity registered, so a received revocation can actually apply. +fn inner_knowing(tag: &str, peer_biome: &str, peer_key: &str) -> Inner { + let config = GatewayConfig { + data_dir: temp_dir(tag), + ..GatewayConfig::default() + }; + let mut inner = Inner::open(&config).expect("inner opens"); + inner.ingest.registry_mut().register( + NODE, + NodeSigner::for_node(NODE_SEED, NODE).public_key(), + "sha256:quic-fw".into(), + ); + register_peer_identity(&mut inner, peer_biome, peer_key, "quic://peer"); + inner +} + +/// Drain `transport` until it has yielded `n` artifacts, or panic. +async fn collect( + transport: &QuicTransport, + peer: &PeerRef, + n: usize, + timeout: Duration, +) -> Vec { + let deadline = Instant::now() + timeout; + let mut out = Vec::new(); + while out.len() < n { + out.extend(transport.subscribe(peer).await.expect("subscribe")); + if out.len() >= n { + break; + } + assert!( + Instant::now() < deadline, + "timed out with {} of {n} artifacts", + out.len() + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + out +} + +/// A deliberately bulky signed summary: ~6000 modality buckets, several +/// hundred kilobytes on the wire. +fn bulky_summary(biome: &Biome) -> FederationArtifact { + let mut stats = BTreeMap::new(); + for i in 0..6_000u32 { + stats.insert( + format!("synthetic-modality-{i:05}"), + ModalityStats { + count: u64::from(i), + mean: f64::from(i), + min: 0.0, + max: f64::from(i), + mean_quality: 0.875, + }, + ); + } + let mut summary = rucelium_federation::RegionalSummary { + spec_version: rucelium_core::SPEC_VERSION.into(), + biome_id: biome.config().biome_id.clone(), + window_start_ns: 0, + window_end_ns: 1_000, + stats, + signature_hex: None, + signer_pubkey_hex: None, + }; + biome.sign_summary(&mut summary); + FederationArtifact::Summary(summary) +} + +/// (1) The QUIC TLS identity *is* the biome signing identity, an announce +/// round-trips, and what arrives is verified by the ordinary gate. +#[tokio::test(flavor = "multi_thread")] +async fn quic_announce_round_trips_and_the_artifact_still_has_to_verify() { + let a = endpoint("biome/quic-a", SEED_A, None); + let b = endpoint("biome/quic-b", SEED_B, None); + + // The endpoint's advertised key is exactly the biome's signing key — + // one key, not two provisioned together (ADR-269 §4). + let biome_a = Biome::new(BiomeConfig::new("biome/quic-a"), SEED_A); + assert_eq!( + a.local_identity().pubkey_hex, + biome_a.public_key_hex(), + "the QUIC identity must be the biome's ed25519 federation key" + ); + assert_eq!(a.name(), "quic"); + + let peer_b = b.peer_ref().expect("b advertises itself"); + let mut signer = Biome::new(BiomeConfig::new("biome/quic-a"), SEED_A); + let event = signer.revoke_device(NODE, now_ns(), "compromised"); + let artifact = FederationArtifact::Event(event.clone()); + + a.announce(&peer_b, &artifact) + .await + .expect("announce over quic"); + + let received = collect(&b, &peer_b, 1, Duration::from_secs(10)).await; + assert_eq!(received[0], artifact, "the artifact survives the wire"); + + // ADR-269 §4, normative: arriving over QUIC changes nothing about + // verification. It goes through the same gate as an HTTP poll. + let mut inner = inner_knowing("verify", "biome/quic-a", &biome_a.public_key_hex()); + assert_eq!( + accept_artifact(&mut inner, &received[0]), + Ok(ArtifactEffect::RevocationApplied) + ); + assert!(inner.ingest.registry().is_revoked(NODE)); + + // ...and a tampered copy that took the very same QUIC path is refused. + let mut tampered = event; + tampered.message.push('!'); + assert!(accept_artifact(&mut inner, &FederationArtifact::Event(tampered)).is_err()); +} + +/// **(2) The one that matters.** A peer's TLS identity must equal its +/// registered federation key or the connection is refused (ADR-269 §4, +/// normative). Here A dials B while pinning *C's* key: the handshake must +/// fail, the error must name both keys, and nothing may cross. +#[tokio::test(flavor = "multi_thread")] +async fn a_peer_presenting_the_wrong_key_is_refused_and_delivers_nothing() { + let a = endpoint("biome/quic-a", SEED_A, None); + let b = endpoint("biome/quic-b", SEED_B, None); + let c_key = Biome::new(BiomeConfig::new("biome/quic-c"), SEED_C).public_key_hex(); + let b_key = b.local_identity().pubkey_hex.clone(); + assert_ne!(b_key, c_key); + + // B's address, C's key. This is the impersonation the pin exists for: + // an attacker who controls the address but not the biome key. + let honest_b = b.peer_ref().expect("b advertises itself"); + let impersonated = PeerRef::with_identity(&honest_b.url, "biome/quic-c", &c_key); + + let mut signer = Biome::new(BiomeConfig::new("biome/quic-a"), SEED_A); + let artifact = FederationArtifact::Event(signer.revoke_device(NODE, now_ns(), "compromised")); + + let err = a + .announce(&impersonated, &artifact) + .await + .expect_err("a mismatched TLS identity must refuse the connection"); + match err { + TransportError::IdentityRefused { expected, got } => { + assert_eq!(expected, c_key, "expected key must be the pinned one"); + assert_eq!(got, b_key, "reported key must be what the peer presented"); + } + other => panic!("expected IdentityRefused, got {other}"), + } + + // Nothing crossed, and no stream was left open to the refused peer. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + b.subscribe(&honest_b).await.expect("subscribe").is_empty(), + "a refused connection must not deliver artifacts" + ); + assert!(a.open_stream_classes(&impersonated).await.is_empty()); + + // The same endpoint, dialled with the *correct* key, works — proving + // the refusal was the pin and not a broken endpoint. + a.announce(&honest_b, &artifact) + .await + .expect("the honest pin connects"); + let received = collect(&b, &honest_b, 1, Duration::from_secs(10)).await; + assert_eq!(received[0], artifact); +} + +/// (3) ADR-269 §4.3: separate streams per artifact class, so a stalled or +/// bulky summary cannot block a revocation. The structural assertion (two +/// distinct streams, each with its own lock) is deterministic; the latency +/// bound is deliberately loose. +#[tokio::test(flavor = "multi_thread")] +async fn summaries_and_revocations_travel_on_independent_streams() { + let a = endpoint("biome/quic-a", SEED_A, None); + let b = endpoint("biome/quic-b", SEED_B, None); + let peer_b = Arc::new(b.peer_ref().expect("b advertises itself")); + + let biome_a = Biome::new(BiomeConfig::new("biome/quic-a"), SEED_A); + let summary = bulky_summary(&biome_a); + let mut signer = Biome::new(BiomeConfig::new("biome/quic-a"), SEED_A); + let revocation = FederationArtifact::Event(signer.revoke_device(NODE, now_ns(), "urgent")); + + // The bulky summary goes first and keeps its own stream busy... + let summary_send = a.announce(&peer_b, &summary); + // ...while the revocation is announced on the event stream. If the two + // classes shared a stream (or a lock), this would have to wait. + let started = Instant::now(); + let (summary_result, revocation_result) = + tokio::join!(summary_send, a.announce(&peer_b, &revocation)); + summary_result.expect("summary announced"); + revocation_result.expect("revocation announced"); + assert!( + started.elapsed() < Duration::from_secs(5), + "revocation announce must not wait on the bulky summary" + ); + + // Structural: two open streams, one per class. + let mut classes = a.open_stream_classes(&peer_b).await; + classes.sort(); + assert_eq!(classes, vec![StreamClass::Summary, StreamClass::Event]); + + // Both arrive, and the revocation is not held hostage by the summary. + let received = collect(&b, &peer_b, 2, Duration::from_secs(20)).await; + assert!(received.contains(&revocation), "revocation must arrive"); + assert!(received.contains(&summary), "summary must arrive"); +} + +/// (4) ADR-269 §3: the mandatory backstop works over QUIC too — a fresh +/// bidirectional stream carries the peer's cursor and comes back with the +/// artifacts, which then face the same verification as everything else. +#[tokio::test(flavor = "multi_thread")] +async fn quic_backfill_serves_the_peers_cursor() { + let mut biome_b = Biome::new(BiomeConfig::new("biome/quic-b"), SEED_B); + let event = biome_b.revoke_device(NODE, now_ns(), "backfilled"); + let served = vec![FederationArtifact::Event(event)]; + let seen_cursor = Arc::new(std::sync::Mutex::new(Vec::::new())); + + let recorder = seen_cursor.clone(); + let answers = served.clone(); + let source: BackfillSource = Arc::new(move |since_ns| { + recorder.lock().expect("cursor lock").push(since_ns); + answers.clone() + }); + + let a = endpoint("biome/quic-a", SEED_A, None); + let b = endpoint("biome/quic-b", SEED_B, Some(source)); + let peer_b = b.peer_ref().expect("b advertises itself"); + + let got = a + .sync_since(&peer_b, 1_234_567_890) + .await + .expect("backfill over quic"); + assert_eq!(got, served); + assert_eq!( + seen_cursor.lock().expect("cursor lock").as_slice(), + &[1_234_567_890] + ); + + // Verified like anything else. + let mut inner = inner_knowing("backfill", "biome/quic-b", &b.local_identity().pubkey_hex); + assert_eq!( + accept_artifact(&mut inner, &got[0]), + Ok(ArtifactEffect::RevocationApplied) + ); + + // An endpoint with no backfill source answers empty rather than lying. + let c = endpoint("biome/quic-c", SEED_C, None); + let peer_c = c.peer_ref().expect("c advertises itself"); + assert!(a + .sync_since(&peer_c, 0) + .await + .expect("empty backfill") + .is_empty()); +} + +/// A QUIC peer with no pinned federation key cannot be dialled at all — +/// there is no "connect first, decide later" path (ADR-269 §4). +#[tokio::test(flavor = "multi_thread")] +async fn a_peer_without_a_pinned_key_is_never_dialled() { + let a = endpoint("biome/quic-a", SEED_A, None); + let b = endpoint("biome/quic-b", SEED_B, None); + let unpinned = PeerRef::new(b.peer_ref().expect("b advertises itself").url); + + let mut signer = Biome::new(BiomeConfig::new("biome/quic-a"), SEED_A); + let artifact = FederationArtifact::Event(signer.revoke_device(NODE, now_ns(), "x")); + let err = a + .announce(&unpinned, &artifact) + .await + .expect_err("an unpinned peer cannot be dialled"); + assert!( + matches!(err, TransportError::Protocol(_)), + "unexpected error: {err}" + ); + assert!(a.identity(&unpinned).await.is_err()); +} diff --git a/crates/rucelium-gateway/tests/restart.rs b/crates/rucelium-gateway/tests/restart.rs new file mode 100644 index 0000000..abc7bed --- /dev/null +++ b/crates/rucelium-gateway/tests/restart.rs @@ -0,0 +1,627 @@ +//! The security review's restart acceptance test. +//! +//! Everything here is about state that must **outlive the process**. A +//! gateway that forgets its anti-replay windows, its executed-command ids, or +//! its dedup index on restart offers replay protection only for as long as it +//! happens to stay up — which is not protection at all. Each test below kills +//! the in-memory copy of some security state and checks the durable copy +//! still refuses the attack. +//! +//! The seven criteria, in order: +//! +//! 1. a signed packet accepted before a restart is a **replay** after it; +//! 2. a command id executed before a restart is a **duplicate** after it; +//! 3. an observation whose segment retention has deleted is *still* deduped +//! after a restart (the dedup index outlives the payload); +//! 4. a serialized `EnvSample` claiming `verified: true` cannot enter a biome +//! — enforced by the type system, not a runtime check; +//! 5. a registered federation key may not publish under another biome's id; +//! 6. a replayed signed regional summary is rejected; +//! 7. a corrupted **complete** store record is an integrity failure, never +//! silent truncation. + +use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; +use rucelium_core::EnvSample; +use rucelium_federation::{ + verify_summary, AcceptOutcome, Biome, BiomeConfig, FederationBus, FederationError, +}; +use rucelium_gateway::{spawn_gateway_with_state, GatewayConfig, GatewayState}; +use rucelium_ingest::{DeviceRegistry, IngestPipeline, RejectReason}; +use rucelium_policy::verify_receipt; +use rucelium_store::{AppendOutcome, ObservationStore, StoreError}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::net::UdpSocket; + +/// Device provisioning seed for this test's spore node. +const SEED: &[u8; 32] = b"rucelium-restart-provision-seed!"; +/// The one device the restart gateway knows about. +const NODE: u64 = 0x5CDE_0000_0000_0001; +/// Firmware hash registered for `NODE`. +const FW: &str = "sha256:restart-fw"; +/// The sequence number criterion 1 replays. +const SEQUENCE: u32 = 100; +/// Agent the gateway grants actuator authority to at startup. +const AGENT: &str = "agent/flood"; +/// The default configured actuator. +const ACTUATOR: &str = "sluice-gate-1"; + +/// Wall-clock nanoseconds. +fn now_ns() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() as u64 +} + +/// A unique temp data dir. Each *test* gets its own directory; the two +/// gateway lifetimes inside a test deliberately share one. +fn temp_dir(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rucelium-gw-restart-{tag}-{}-{}", + std::process::id(), + now_ns() + )) +} + +/// The signer for `NODE`. +fn signer() -> NodeSigner { + NodeSigner::for_node(SEED, NODE) +} + +/// A genuine signed v1 envelope from `NODE`. Deterministic in its arguments, +/// so "the exact same packet" really is byte-identical. +fn envelope(sequence: u32, measured_ns: u64) -> Vec { + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: 5, // weather + flags: 0, + node_id: NODE, + timestamp_ns: measured_ns, + sequence, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: 16 * 65_536, + quality_q15: 0x7000, + battery_mv: 3_600, + calibration_id: 0, // uncalibrated: no calibration record needed + }; + signer().sign_sample(&wire).encode() +} + +/// A pipeline with `NODE` provisioned under its real key. +fn pipeline() -> IngestPipeline { + let mut registry = DeviceRegistry::new(); + registry.register(NODE, signer().public_key(), FW.to_string()); + IngestPipeline::new(registry) +} + +/// The plain `EnvSample` behind a freshly ingested envelope. Unwrapping the +/// seal is exactly what storage does — see criterion 4. +fn stored_sample(ingest: &mut IngestPipeline, sequence: u32, measured_ns: u64) -> EnvSample { + ingest + .ingest(&envelope(sequence, measured_ns), measured_ns + 1_000_000) + .expect("genuine envelope ingests") + .into_inner() +} + +/// The gateway config for a restart test: ephemeral ports, a fixed data dir, +/// fsync on so an accepted append really is durable. +fn config(dir: &Path) -> GatewayConfig { + GatewayConfig { + biome_id: "biome/restart".into(), + udp_port: 0, + http_port: 0, + data_dir: dir.to_path_buf(), + fsync: true, + ..GatewayConfig::default() + } +} + +/// Open a gateway on `dir` with `NODE` provisioned before any traffic can +/// race the registration. The device registry is in-memory provisioning +/// state, so it is re-supplied on each boot — unlike the replay and command +/// state, which must come off disk. +async fn boot(dir: &Path) -> rucelium_gateway::GatewayHandle { + let cfg = config(dir); + let state = GatewayState::open(&cfg).expect("open gateway state"); + state.inner.lock().await.ingest.registry_mut().register( + NODE, + signer().public_key(), + FW.to_string(), + ); + spawn_gateway_with_state(state, cfg) + .await + .expect("spawn gateway") +} + +/// Fetch a JSON body. +async fn get_json(client: &reqwest::Client, url: &str) -> Value { + client + .get(url) + .send() + .await + .unwrap_or_else(|e| panic!("GET {url}: {e}")) + .json() + .await + .unwrap_or_else(|e| panic!("decode {url}: {e}")) +} + +/// Poll `url` until `pred` holds, panicking after `timeout`. +async fn wait_for_json(client: &reqwest::Client, url: &str, timeout: Duration, pred: F) -> Value +where + F: Fn(&Value) -> bool, +{ + let deadline = Instant::now() + timeout; + loop { + let v = get_json(client, url).await; + if pred(&v) { + return v; + } + assert!( + Instant::now() < deadline, + "timed out waiting on {url}; last body: {v}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +/// POST the admin command body, returning `(status, json)`. +async fn post_command( + client: &reqwest::Client, + base: &str, + proposal_id: &str, +) -> (reqwest::StatusCode, Value) { + let resp = client + .post(format!("{base}/api/admin/command")) + .json(&json!({ + "proposal_id": proposal_id, + "agent_id": AGENT, + "actuator_id": ACTUATOR, + "action": "open_fraction", + "magnitude": 0.5, + })) + .send() + .await + .expect("POST /api/admin/command"); + let status = resp.status(); + let body = resp.json().await.expect("command response decodes"); + (status, body) +} + +/// Stop a gateway: abort every background task so its sockets close and its +/// in-memory state becomes unreachable. Only what reached disk survives. +fn shutdown(handle: rucelium_gateway::GatewayHandle) { + for task in handle.tasks { + task.abort(); + } + drop(handle.state); +} + +// --------------------------------------------------------------------------- +// Criteria 1 + 2 — replay protection and command dedup survive a restart +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn replay_and_command_dedup_survive_a_gateway_restart() { + let client = reqwest::Client::new(); + let dir = temp_dir("replay-command"); + let sender = UdpSocket::bind("127.0.0.1:0").await.expect("bind sender"); + + // The exact bytes both gateways will see. Measured a second ago so the + // reception timestamp is always later (`EnvSample` rejects inverted time). + let packet = envelope(SEQUENCE, now_ns() - 1_000_000_000); + + // --- First boot --------------------------------------------------- + let first = boot(&dir).await; + let first_http = format!("http://127.0.0.1:{}", first.http_port); + + // (1a) A genuine signed packet with sequence 100 is accepted. + sender + .send_to(&packet, ("127.0.0.1", first.udp_port)) + .await + .expect("send packet"); + let stats = wait_for_json( + &client, + &format!("{first_http}/api/stats"), + Duration::from_secs(5), + |v| v["ingest"]["accepted"] == 1, + ) + .await; + assert_eq!(stats["observations"]["records"], 1); + assert_eq!(stats["ingest"]["replay"], 0); + assert_eq!( + stats["observations"]["fsync"], true, + "the daemon must fsync accepted appends" + ); + + // (2a) Command "cmd-42" runs the whole governed path and yields a signed + // receipt. + let (status, body) = post_command(&client, &first_http, "42").await; + assert!(status.is_success(), "command rejected: {body}"); + let receipt: rucelium_policy::ExecutionReceipt = + serde_json::from_value(body["receipt"].clone()).expect("receipt decodes"); + assert_eq!(receipt.command_id, "cmd-42"); + assert!( + verify_receipt(&receipt), + "the receipt is a gateway-signed attestation" + ); + let stats = get_json(&client, &format!("{first_http}/api/stats")).await; + assert_eq!(stats["control"]["commands_executed"], 1); + assert_eq!(stats["control"]["receipts"], 1); + + // --- Restart on the SAME data dir --------------------------------- + shutdown(first); + let second = boot(&dir).await; + let second_http = format!("http://127.0.0.1:{}", second.http_port); + + // The fresh process starts with empty counters: everything asserted from + // here on is state that came back off disk, not state that never left. + let fresh = get_json(&client, &format!("{second_http}/api/stats")).await; + assert_eq!(fresh["ingest"]["accepted"], 0); + assert_eq!(fresh["ingest"]["replay"], 0); + assert_eq!(fresh["control"]["commands_executed"], 0); + assert_eq!(fresh["control"]["receipts"], 0); + assert_eq!( + fresh["observations"]["records"], 1, + "the durable observation survived the restart" + ); + + // (1b) The exact same packet is now REJECTED as a replay: the anti-replay + // window was primed from the store's durable dedup index. + sender + .send_to(&packet, ("127.0.0.1", second.udp_port)) + .await + .expect("resend packet"); + let stats = wait_for_json( + &client, + &format!("{second_http}/api/stats"), + Duration::from_secs(5), + |v| v["ingest"]["replay"] == 1, + ) + .await; + assert_eq!( + stats["ingest"]["accepted"], 0, + "a replayed packet must never be accepted after a restart" + ); + assert_eq!( + stats["observations"]["records"], 1, + "and it must not be stored a second time" + ); + + // (2b) Re-POSTing command id "cmd-42" is rejected as a duplicate: the + // phase table came back from `commands.jsonl`. + let (status, body) = post_command(&client, &second_http, "42").await; + assert_eq!( + status, + reqwest::StatusCode::CONFLICT, + "duplicate command must not be executed: {body}" + ); + assert_eq!(body["ok"], false); + assert_eq!(body["stage"], "gateway_duplicate"); + assert!( + body.get("receipt").is_none(), + "a duplicate command must produce no receipt: {body}" + ); + let stats = get_json(&client, &format!("{second_http}/api/stats")).await; + assert_eq!( + stats["control"]["receipts"], 0, + "no second receipt was produced" + ); + assert_eq!(stats["control"]["commands_executed"], 0); + assert_eq!(stats["control"]["proposals_rejected"], 1); + + // A *different* command id still works — the gateway is refusing the + // replay, not wedged shut. + let (status, body) = post_command(&client, &second_http, "43").await; + assert!(status.is_success(), "fresh command id rejected: {body}"); + + shutdown(second); + std::fs::remove_dir_all(&dir).ok(); +} + +// --------------------------------------------------------------------------- +// Criterion 3 — dedup outlives retention *and* restart +// --------------------------------------------------------------------------- + +/// Retention deletes whole segment *files*; the dedup index is deliberately +/// kept forever. So an observation whose payload is long gone is still enough +/// to reject its replayed envelope after a restart. +/// +/// Driven directly against `ObservationStore` + `IngestPipeline` because the +/// daemon's segment size (4096 records) makes segment rollover impractical to +/// reach over HTTP — but this is the exact pair of calls `Inner::open` makes. +#[test] +fn retention_deleted_records_are_still_replay_protected_after_restart() { + let dir = temp_dir("retention"); + let obs_dir = dir.join("obs"); + let base = now_ns() - 10_000_000_000; + + // One record per segment, so the first sample gets its own deletable file. + let mut ingest = pipeline(); + let mut store = ObservationStore::open(&obs_dir, 1, true).expect("open store"); + let first = stored_sample(&mut ingest, 1, base); + let second = stored_sample(&mut ingest, 2, base + 1_000_000_000); + assert_eq!(store.append(&first).unwrap(), AppendOutcome::Appended); + assert_eq!(store.append(&second).unwrap(), AppendOutcome::Appended); + assert_eq!(store.segments().len(), 2); + + // Retention with a 1 ns lifespan drops the first segment entirely (the + // current segment is never deleted). + let deleted = store + .enforce_retention(second.measured_ns + 1, 1) + .expect("retention runs"); + assert_eq!(deleted, 1, "the expired segment was deleted"); + assert_eq!(store.len(), 1, "its payload is gone"); + drop(store); + + // --- Restart: reopen the store and prime a brand-new pipeline. --- + let reopened = ObservationStore::open(&obs_dir, 1, true).expect("reopen store"); + assert_eq!( + reopened.len(), + 1, + "payload stayed deleted across the restart" + ); + assert!( + reopened.dedup_keys().contains(&(NODE, 1)), + "the dedup key outlives the segment that held its payload" + ); + + // Control: without priming, the restarted gateway would re-accept it. + let mut unprimed = pipeline(); + assert!( + unprimed.ingest(&envelope(1, base), now_ns()).is_ok(), + "sanity: an unprimed pipeline has no memory of the packet" + ); + + // With priming — what `Inner::open` actually does — it is a replay. + let mut primed = pipeline(); + primed.prime_from_dedup(reopened.dedup_keys()); + assert_eq!( + primed.ingest(&envelope(1, base), now_ns()), + Err(RejectReason::Replay { + node_id: NODE, + sequence: 1 + }) + ); + assert_eq!(primed.stats().accepted, 0); + assert_eq!(primed.stats().replay, 1); + + std::fs::remove_dir_all(&dir).ok(); +} + +// --------------------------------------------------------------------------- +// Criterion 4 — `"verified": true` in JSON is not a way into a biome +// --------------------------------------------------------------------------- + +/// A serialized `EnvSample` can claim anything it likes, including +/// `provenance.verified = true`. It still cannot reach `Biome::accept`, +/// because `accept` does not take an `EnvSample` at all — it takes a +/// `rucelium_ingest::VerifiedEnvSample`, which is not `Deserialize` and has no +/// public constructor. The only producers are `IngestPipeline::ingest` and +/// `IngestPipeline::reverify_stored`, both of which run the full registry + +/// signature verification. +/// +/// This is enforced at **compile time**, so the negative half of the property +/// cannot be written as a runtime assertion here. It lives as a `compile_fail` +/// doctest on `rucelium_federation::Biome::accept` (and is exercised by +/// `cargo test -p rucelium-federation --doc`); this test pins the positive +/// half: the one honest path in, and the fact that unwrapping for storage +/// really does drop the seal. +#[test] +fn a_deserialized_verified_sample_cannot_enter_the_biome() { + let mut ingest = pipeline(); + let base = now_ns() - 1_000_000_000; + + // Round-trip a genuine sample through JSON, exactly as an attacker with + // write access to the store (or a peer feeding us JSON) would have it. + let genuine = stored_sample(&mut ingest, 7, base); + let forged_json = serde_json::to_string(&genuine).expect("sample serializes"); + let forged: EnvSample = serde_json::from_str(&forged_json).expect("sample deserializes"); + assert!( + forged.provenance.verified, + "the JSON does claim verified = true" + ); + + // `biome.accept(forged)` is a compile error — see the doc comment above. + let mut biome = Biome::new( + BiomeConfig::new("biome/restart"), + b"rucelium-restart-biome-seed-32b!", + ); + + // The only path in: a real signed envelope through a real pipeline. + let sealed = ingest + .ingest(&envelope(8, base), now_ns()) + .expect("genuine envelope ingests"); + assert!(sealed.sample().provenance.verified); + assert_eq!(biome.accept(sealed), AcceptOutcome::Accepted); + assert_eq!( + biome.accepted_count(), + 1, + "exactly the one sample that came through ingest" + ); + + // And restoring a stored sample requires the original envelope bytes: the + // seal cannot be re-created from the JSON. Re-verifying the envelope of + // the sample the biome already holds yields a fresh seal — which the + // biome's dedup index then correctly refuses. + let restored = ingest + .reverify_stored(&envelope(8, base), now_ns()) + .expect("stored envelope re-verifies"); + assert_eq!(restored.sample().node_id, forged.node_id); + assert_eq!( + biome.accept(restored), + AcceptOutcome::Duplicate, + "the biome dedup index recognises it from the live path" + ); + + // Tampering with the stored bytes breaks re-verification outright, so no + // seal exists to hand to the biome at all. + let mut tampered = envelope(7, base); + tampered[3 + 36] ^= 0x01; // value_q16 inside the signed payload + assert!( + matches!( + ingest.reverify_stored(&tampered, now_ns()), + Err(RejectReason::BadSignature(NODE)) + ), + "a tampered stored envelope never re-earns the seal" + ); + assert_eq!(biome.accepted_count(), 1); +} + +// --------------------------------------------------------------------------- +// Criteria 5 + 6 — federation identity binding and summary replay +// --------------------------------------------------------------------------- + +/// Two honestly registered biomes. Signing a summary that *claims* the other +/// biome's id is an `IdentityMismatch`, even though the signature verifies and +/// the signing key is genuinely registered — just not for that id. +#[test] +fn a_registered_key_cannot_claim_another_biome_identity() { + let a = Biome::new( + BiomeConfig::new("biome/a"), + b"rucelium-restart-biome-a-seed!!!", + ); + let b = Biome::new( + BiomeConfig::new("biome/b"), + b"rucelium-restart-biome-b-seed!!!", + ); + let mut bus = FederationBus::new(); + bus.register_biome("biome/a", a.public_key_hex(), 1) + .expect("register a"); + bus.register_biome("biome/b", b.public_key_hex(), 1) + .expect("register b"); + + // A's key signs a summary stamped with B's identity. + let mut cross = a.summarize(0, 1_000); + cross.biome_id = "biome/b".into(); + a.sign_summary(&mut cross); + assert!( + verify_summary(&cross), + "the signature itself is perfectly valid" + ); + + assert_eq!( + bus.publish(cross), + Err(FederationError::IdentityMismatch { + biome_id: "biome/b".into() + }) + ); + assert!( + bus.summaries().is_empty(), + "nothing was published under the stolen identity" + ); +} + +/// A signed regional summary is accepted once per `(biome, window)`. Replays +/// — byte-identical or freshly re-signed — are `DuplicateSummary`. +#[test] +fn a_duplicated_signed_summary_is_rejected() { + let a = Biome::new( + BiomeConfig::new("biome/a"), + b"rucelium-restart-biome-a-seed!!!", + ); + let mut bus = FederationBus::new(); + bus.register_biome("biome/a", a.public_key_hex(), 1) + .expect("register a"); + + let summary = a.summarize(0, 1_000); + bus.publish(summary.clone()).expect("first publish"); + + assert_eq!( + bus.publish(summary), + Err(FederationError::DuplicateSummary), + "an exact replay is refused" + ); + let mut resigned = a.summarize(0, 1_000); + a.sign_summary(&mut resigned); + assert_eq!( + bus.publish(resigned), + Err(FederationError::DuplicateSummary), + "re-signing the same window does not launder the replay" + ); + + // A genuinely new window still publishes. + bus.publish(a.summarize(1_000, 2_000)) + .expect("new window publishes"); + assert_eq!(bus.summaries().len(), 2); +} + +// --------------------------------------------------------------------------- +// Criterion 7 — corruption is an error, not a silent truncation +// --------------------------------------------------------------------------- + +/// Torn-tail repair exists for a crash mid-write: a *final*, unterminated, +/// undecodable line. A **complete** record — newline-terminated, well-formed +/// framing — that no longer matches its CRC is corruption, and the store must +/// say so rather than quietly dropping it and everything after it. +#[test] +fn a_corrupted_complete_record_is_an_integrity_error_not_truncation() { + let dir = temp_dir("corrupt"); + let obs_dir = dir.join("obs"); + let base = now_ns() - 10_000_000_000; + + let mut ingest = pipeline(); + let mut store = ObservationStore::open(&obs_dir, 100, true).expect("open store"); + store + .append(&stored_sample(&mut ingest, 1, base)) + .expect("append first"); + store + .append(&stored_sample(&mut ingest, 2, base + 1_000_000_000)) + .expect("append second"); + assert_eq!(store.len(), 2); + drop(store); + + // Flip a byte in the MIDDLE of the file: inside the first record, which + // is complete and newline-terminated, with a valid record after it. The + // edit keeps the JSON parseable and the same length, so the only thing + // that gives it away is the CRC. + let path = obs_dir.join("obs-000000.jsonl"); + let text = std::fs::read_to_string(&path).expect("read segment"); + let newline = text.find('\n').expect("two records means a newline"); + assert!( + newline + 1 < text.len(), + "the corrupted record must not be the final line" + ); + let (first_line, rest) = text.split_at(newline); + assert!( + first_line.contains("air_temperature"), + "expected the observed property in the record" + ); + let tampered = format!( + "{}{rest}", + first_line.replace("air_temperature", "air_temperaturx") + ); + assert_eq!( + tampered.len(), + text.len(), + "same length: only the content changed" + ); + std::fs::write(&path, &tampered).expect("write tampered segment"); + + // Reopening reports the integrity failure, naming the segment and line. + match ObservationStore::open(&obs_dir, 100, true) { + Err(StoreError::Corrupt { + segment, + line, + reason, + }) => { + assert_eq!(segment, "obs-000000.jsonl"); + assert_eq!(line, 1); + assert_eq!(reason, "crc mismatch"); + } + Err(other) => panic!("expected a Corrupt error, got {other:?}"), + Ok(_) => panic!("a corrupted complete record must not open successfully"), + } + + // And nothing was truncated away in the attempt. + assert_eq!( + std::fs::read(&path).expect("re-read segment").len(), + tampered.len(), + "the failed open must not have rewritten the segment" + ); + + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/rucelium-ingest/Cargo.toml b/crates/rucelium-ingest/Cargo.toml new file mode 100644 index 0000000..1a2f0f4 --- /dev/null +++ b/crates/rucelium-ingest/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rucelium-ingest" +version.workspace = true +edition.workspace = true +description = "RuCelium rhizome-gateway ingest pipeline: envelope decode, signature + revocation + replay-window checks, normalization into EnvSample (ADR-264 §5)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "gateway", "ingest", "security"] +categories = ["science"] + +[dependencies] +rucelium-core = { workspace = true } +rucelium-abi = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-ingest/src/lib.rs b/crates/rucelium-ingest/src/lib.rs new file mode 100644 index 0000000..ad9c576 --- /dev/null +++ b/crates/rucelium-ingest/src/lib.rs @@ -0,0 +1,940 @@ +//! # rucelium-ingest +//! +//! The rhizome-gateway ingest pipeline (ADR-264 §5, responsibilities 1–3): +//! **decode** the signed wire envelope, **verify** signatures and sequence +//! numbers against the device registry and a per-device anti-replay window, +//! and **normalize** the payload into a [`rucelium_core::EnvSample`]. +//! +//! Trust posture (ADR-264 §12): every failure is a *rejection* — the gateway +//! never repairs, guesses, or forwards unverified data. Samples that reach +//! the domain model always carry `provenance.verified = true`; anything else +//! never leaves the gateway. Revocation is biome-local first: revoking a +//! device in the [`DeviceRegistry`] invalidates its key at this gateway +//! immediately while the audit record is kept. +//! +//! Everything here is deterministic — no RNG, no clocks. Callers pass the +//! reception timestamp (`received_ns`) explicitly, so the same envelope bytes +//! plus the same timestamp always produce the same result. + +#![doc(html_root_url = "https://docs.rs/rucelium-ingest/0.1.0")] + +use rucelium_abi::{verify_record, RvEnvSampleV1, SignedEnvRecordV1}; +use rucelium_core::EnvSample; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fmt; + +// --------------------------------------------------------------------------- +// Device registry +// --------------------------------------------------------------------------- + +/// A registered spore-node device: its provisioned ed25519 verifying key, +/// the firmware measurement implementation it attested at provisioning time, +/// and its revocation state (ADR-264 §12). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceRecord { + /// The device's ed25519 verifying key, as handed out at provisioning. + pub pubkey: [u8; 32], + /// `sha256:` hash of the firmware measurement implementation + /// (requirement 10 of ADR-264 §7.1); stamped into every accepted + /// sample's provenance. + pub firmware_hash: String, + /// Whether the device has been revoked. Revoked devices keep their + /// record for audit, but ingest rejects everything they send. + pub revoked: bool, +} + +/// The gateway's registry of provisioned spore-node devices, keyed by +/// `node_id`. Backed by a `BTreeMap` so iteration and behavior are fully +/// deterministic (no hasher randomness anywhere in the pipeline). +/// +/// Revocation follows ADR-264 §12: "revoking a device invalidates its key at +/// the biome's gateways immediately" — the record is retained for audit, but +/// [`IngestPipeline::ingest`] rejects revoked devices. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DeviceRegistry { + devices: BTreeMap, +} + +impl DeviceRegistry { + /// An empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Register (or re-provision) a device. Re-registering an existing + /// `node_id` replaces its record entirely — including clearing any + /// revocation — modelling a fresh provisioning ceremony. + pub fn register(&mut self, node_id: u64, pubkey: [u8; 32], firmware_hash: String) { + self.devices.insert( + node_id, + DeviceRecord { + pubkey, + firmware_hash, + revoked: false, + }, + ); + } + + /// Revoke a device's key. Returns `true` if the device was registered + /// and not already revoked. The record is kept for audit; ingest rejects + /// the device from this call onward. + pub fn revoke(&mut self, node_id: u64) -> bool { + match self.devices.get_mut(&node_id) { + Some(d) if !d.revoked => { + d.revoked = true; + true + } + _ => false, + } + } + + /// Whether the device is registered *and* revoked. + #[must_use] + pub fn is_revoked(&self, node_id: u64) -> bool { + self.devices.get(&node_id).is_some_and(|d| d.revoked) + } + + /// The device's record, if registered (revoked records are retained). + #[must_use] + pub fn get(&self, node_id: u64) -> Option<&DeviceRecord> { + self.devices.get(&node_id) + } +} + +// --------------------------------------------------------------------------- +// Anti-replay window +// --------------------------------------------------------------------------- + +/// Outcome of a failed [`ReplayWindow`] check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayCheck { + /// The sequence number was already accepted (exact duplicate). + Replay, + /// The sequence number fell below the sliding window + /// (`sequence < highest - 63`) and can no longer be deduplicated. + TooOld, +} + +impl fmt::Display for ReplayCheck { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReplayCheck::Replay => write!(f, "duplicate sequence (replay)"), + ReplayCheck::TooOld => write!(f, "sequence below replay window"), + } + } +} + +impl std::error::Error for ReplayCheck {} + +/// Per-device anti-replay window in the DTLS/IPsec style (ADR-264 §5 +/// responsibility 2): the highest sequence number accepted so far plus a +/// 64-bit bitmap of the 64 sequence numbers below it. Accepts each sequence +/// exactly once, tolerates out-of-order delivery within the window, and +/// rejects anything below it as [`ReplayCheck::TooOld`]. +/// +/// The `RV_ENV_FLAG_RETRANSMIT` flag marks store-and-forward ring-buffer +/// replay after an outage — it distinguishes honest retransmission from a +/// replay *attack*, but it **never** bypasses deduplication: a retransmit of +/// an already-accepted sequence is still dropped as [`ReplayCheck::Replay`] +/// (ADR-264 §11.2: "the sequence window still deduplicates"). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReplayWindow { + /// Highest sequence number accepted so far; `None` until the first + /// sequence from the device arrives (which is always accepted). + highest: Option, + /// Bit `i` set means sequence `highest - 1 - i` was accepted. + bitmap: u64, +} + +impl ReplayWindow { + /// A fresh window that will accept whatever sequence arrives first. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The highest sequence accepted so far, if any. + #[must_use] + pub fn highest(&self) -> Option { + self.highest + } + + /// Check `sequence` against the window and, if acceptable, record it. + /// + /// - First sequence from the device: always accepted. + /// - `sequence` already seen (highest or a set bitmap bit): + /// [`ReplayCheck::Replay`]. + /// - `sequence < highest - 63`: [`ReplayCheck::TooOld`]. + /// - Otherwise: accepted and recorded (advancing the window if + /// `sequence > highest`). + /// + /// Callers must only invoke this **after** all cryptographic checks pass + /// — otherwise an attacker could burn sequence numbers with forged + /// packets ([`IngestPipeline::ingest`] enforces this ordering). + pub fn check_and_update(&mut self, sequence: u32) -> Result<(), ReplayCheck> { + let Some(highest) = self.highest else { + self.highest = Some(sequence); + self.bitmap = 0; + return Ok(()); + }; + if sequence > highest { + // Advance: previous `highest` moves to bit `shift - 1`, old + // bitmap entries shift with it (falling off past 64). + let shift = sequence - highest; + self.bitmap = match shift { + 1..=63 => (self.bitmap << shift) | (1u64 << (shift - 1)), + 64 => 1u64 << 63, + _ => 0, + }; + self.highest = Some(sequence); + return Ok(()); + } + if sequence == highest { + return Err(ReplayCheck::Replay); + } + let diff = highest - sequence; // >= 1 + if diff > 63 { + return Err(ReplayCheck::TooOld); + } + let bit = 1u64 << (diff - 1); + if self.bitmap & bit != 0 { + return Err(ReplayCheck::Replay); + } + self.bitmap |= bit; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Reject reasons +// --------------------------------------------------------------------------- + +/// Why an envelope was rejected at ingest. Every variant is a hard rejection +/// — the boundary never repairs or forwards unverified data (ADR-264 §12). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RejectReason { + /// The CBOR envelope failed to decode (truncated, non-canonical, wrong + /// field lengths, trailing bytes). + BadEnvelope(String), + /// The 48-byte packed payload failed ABI parse or field validation + /// (ADR-264 §11.1). + BadPayload(String), + /// The payload's `node_id` is not in the [`DeviceRegistry`]. + UnknownDevice(u64), + /// The device is registered but revoked (ADR-264 §12). + RevokedDevice(u64), + /// The envelope's embedded public key differs from the key registered + /// for this `node_id`. + KeyMismatch(u64), + /// The ed25519 signature did not verify over the payload bytes. + BadSignature(u64), + /// The `(node_id, sequence)` pair was already accepted — an exact + /// duplicate, whether a replay attack or a store-and-forward retransmit. + Replay { + /// Producing device identity. + node_id: u64, + /// The duplicated sequence number. + sequence: u32, + }, + /// The sequence number fell below the device's replay window and can no + /// longer be deduplicated. + TooOld { + /// Producing device identity. + node_id: u64, + /// The stale sequence number. + sequence: u32, + }, + /// Domain conversion or `EnvSample` validation failed after all + /// cryptographic checks passed (ADR-264 §7.1: invalid samples are + /// rejected, never repaired). + Domain(String), +} + +impl fmt::Display for RejectReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RejectReason::BadEnvelope(m) => write!(f, "envelope decode failed: {m}"), + RejectReason::BadPayload(m) => write!(f, "payload parse/validate failed: {m}"), + RejectReason::UnknownDevice(id) => write!(f, "unknown device {id}"), + RejectReason::RevokedDevice(id) => write!(f, "revoked device {id}"), + RejectReason::KeyMismatch(id) => { + write!( + f, + "envelope key does not match registered key for device {id}" + ) + } + RejectReason::BadSignature(id) => { + write!(f, "signature verification failed for device {id}") + } + RejectReason::Replay { node_id, sequence } => { + write!(f, "replayed sequence {sequence} from device {node_id}") + } + RejectReason::TooOld { node_id, sequence } => { + write!( + f, + "sequence {sequence} from device {node_id} below replay window" + ) + } + RejectReason::Domain(m) => write!(f, "domain conversion failed: {m}"), + } + } +} + +impl std::error::Error for RejectReason {} + +// --------------------------------------------------------------------------- +// Ingest statistics +// --------------------------------------------------------------------------- + +/// Monotonic ingest counters: one for acceptance, one per reject category. +/// Serializable so gateways can publish them alongside signed regional +/// summaries (ADR-264 §5 responsibility 8, §12 public quality scores). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct IngestStats { + /// Envelopes fully accepted into the domain model. + pub accepted: u64, + /// Rejections: CBOR envelope decode failed. + pub bad_envelope: u64, + /// Rejections: ABI payload parse/validation failed. + pub bad_payload: u64, + /// Rejections: device not registered. + pub unknown_device: u64, + /// Rejections: device revoked. + pub revoked_device: u64, + /// Rejections: envelope key differed from the registered key. + pub key_mismatch: u64, + /// Rejections: signature verification failed. + pub bad_signature: u64, + /// Rejections: duplicate sequence number. + pub replay: u64, + /// Rejections: sequence below the replay window. + pub too_old: u64, + /// Rejections: domain conversion/validation failed. + pub domain: u64, + /// Stored envelopes successfully re-verified after restart/outage + /// restore ([`IngestPipeline::reverify_stored`]); counted separately + /// from `accepted` because they bypass the replay window by design. + pub restored: u64, +} + +impl IngestStats { + /// Bump the counter matching a reject reason. + fn note_reject(&mut self, reason: &RejectReason) { + match reason { + RejectReason::BadEnvelope(_) => self.bad_envelope += 1, + RejectReason::BadPayload(_) => self.bad_payload += 1, + RejectReason::UnknownDevice(_) => self.unknown_device += 1, + RejectReason::RevokedDevice(_) => self.revoked_device += 1, + RejectReason::KeyMismatch(_) => self.key_mismatch += 1, + RejectReason::BadSignature(_) => self.bad_signature += 1, + RejectReason::Replay { .. } => self.replay += 1, + RejectReason::TooOld { .. } => self.too_old += 1, + RejectReason::Domain(_) => self.domain += 1, + } + } +} + +// --------------------------------------------------------------------------- +// Ingest pipeline +// --------------------------------------------------------------------------- + +/// Hex-encode bytes (lowercase), the form `SampleProvenance` carries. +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// A cryptographically verified environmental sample — the ONLY type the +/// biome layer accepts (`rucelium_federation::Biome::accept`). +/// +/// This wrapper is the type-level fix for the "forgeable `verified` boolean" +/// problem: it is deliberately **not** `Serialize`/`Deserialize` and has no +/// public constructor, so it can only come out of [`IngestPipeline::ingest`] +/// or [`IngestPipeline::reverify_stored`] — both of which perform the full +/// registry + signature checks. A deserialized `EnvSample` with +/// `provenance.verified = true` cannot impersonate one. +/// +/// Serializing (storage, network) goes through [`Self::into_inner`] / +/// [`Self::sample`] and **loses** the seal; restoring verification requires +/// the original signed envelope bytes (`reverify_stored`). +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedEnvSample(EnvSample); + +impl VerifiedEnvSample { + /// Read access to the verified sample. + #[must_use] + pub fn sample(&self) -> &EnvSample { + &self.0 + } + + /// Unwrap for storage/serialization. The seal is lost — a round-trip + /// through disk or the network must re-verify via + /// [`IngestPipeline::reverify_stored`]. + #[must_use] + pub fn into_inner(self) -> EnvSample { + self.0 + } + + /// Apply a legitimate transformation (e.g. calibration) while keeping + /// the seal. The closure runs on a copy; the change is committed only if + /// the transformed sample still validates — so a buggy transformation + /// cannot corrupt a sealed sample. + pub fn modify( + &mut self, + f: impl FnOnce(&mut EnvSample) -> T, + ) -> Result { + let mut candidate = self.0.clone(); + let out = f(&mut candidate); + candidate.validate()?; + self.0 = candidate; + Ok(out) + } +} + +/// The rhizome-gateway ingest pipeline: parse → verify → replay-window → +/// normalize (ADR-264 §5.1). Owns the [`DeviceRegistry`], one +/// [`ReplayWindow`] per device, and running [`IngestStats`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IngestPipeline { + registry: DeviceRegistry, + windows: BTreeMap, + stats: IngestStats, +} + +impl IngestPipeline { + /// Build a pipeline over an already-provisioned registry. + #[must_use] + pub fn new(registry: DeviceRegistry) -> Self { + IngestPipeline { + registry, + windows: BTreeMap::new(), + stats: IngestStats::default(), + } + } + + /// The device registry (read-only). + #[must_use] + pub fn registry(&self) -> &DeviceRegistry { + &self.registry + } + + /// Mutable registry access — e.g. to revoke a device mid-run. Revocation + /// takes effect on the very next [`Self::ingest`] call (ADR-264 §12). + pub fn registry_mut(&mut self) -> &mut DeviceRegistry { + &mut self.registry + } + + /// Running acceptance/rejection counters. + #[must_use] + pub fn stats(&self) -> &IngestStats { + &self.stats + } + + /// Record a rejection in the stats and hand the reason back. + fn reject(&mut self, reason: RejectReason) -> RejectReason { + self.stats.note_reject(&reason); + reason + } + + /// Ingest one signed wire envelope received at `received_ns` + /// (nanoseconds since Unix epoch, supplied by the caller — the pipeline + /// holds no clock). + /// + /// Checks run strictly in this order: + /// + /// 1. CBOR envelope decode → [`RejectReason::BadEnvelope`] + /// 2. ABI payload parse + validation → [`RejectReason::BadPayload`] + /// 3. Registry lookup by payload `node_id` → + /// [`RejectReason::UnknownDevice`] + /// 4. Revocation check → [`RejectReason::RevokedDevice`] + /// 5. Envelope key vs registered key → [`RejectReason::KeyMismatch`] + /// 6. ed25519 signature verification → [`RejectReason::BadSignature`] + /// 7. Replay-window check → [`RejectReason::Replay`] / + /// [`RejectReason::TooOld`] + /// 8. Domain conversion + validation → [`RejectReason::Domain`] + /// + /// The replay window is only consulted (and updated) **after** all + /// cryptographic checks pass, so forged packets cannot burn sequence + /// numbers for a genuine device. Accepted samples carry + /// `provenance.verified = true` and the *registry's* firmware hash — + /// never anything self-reported over the wire. + pub fn ingest( + &mut self, + envelope_bytes: &[u8], + received_ns: u64, + ) -> Result { + // (1)–(6): decode + full cryptographic verification. + let (record, wire, firmware_hash) = match self.verify_envelope(envelope_bytes) { + Ok(v) => v, + Err(e) => return Err(self.reject(e)), + }; + let node_id = wire.node_id; + + // (7) Anti-replay — only now, after every cryptographic check, may + // the window advance. RV_ENV_FLAG_RETRANSMIT never bypasses dedup. + if let Err(check) = self + .windows + .entry(node_id) + .or_default() + .check_and_update(wire.sequence) + { + let reason = match check { + ReplayCheck::Replay => RejectReason::Replay { + node_id, + sequence: wire.sequence, + }, + ReplayCheck::TooOld => RejectReason::TooOld { + node_id, + sequence: wire.sequence, + }, + }; + return Err(self.reject(reason)); + } + + // (8) Normalize into the domain model. Identity comes from the + // verified envelope + registry, never from unverified wire fields. + match wire.to_env_sample( + received_ns, + &firmware_hash, + &hex_encode(&record.pubkey), + true, + ) { + Ok(sample) => { + self.stats.accepted += 1; + Ok(VerifiedEnvSample(sample)) + } + Err(e) => Err(self.reject(RejectReason::Domain(e.to_string()))), + } + } + + /// Re-verify a **stored** signed envelope (e.g. drained from an outage + /// buffer or restored after a crash) without touching the anti-replay + /// window: its sequence was already consumed when it was first accepted, + /// so a second window check would wrongly report `Replay`. All + /// cryptographic checks — registry, revocation, key match, signature, + /// payload validation — run in full; duplicate suppression is the biome + /// dedup index's job on this path. + pub fn reverify_stored( + &mut self, + envelope_bytes: &[u8], + received_ns: u64, + ) -> Result { + let (record, wire, firmware_hash) = match self.verify_envelope(envelope_bytes) { + Ok(v) => v, + Err(e) => return Err(self.reject(e)), + }; + match wire.to_env_sample( + received_ns, + &firmware_hash, + &hex_encode(&record.pubkey), + true, + ) { + Ok(sample) => { + self.stats.restored += 1; + Ok(VerifiedEnvSample(sample)) + } + Err(e) => Err(self.reject(RejectReason::Domain(e.to_string()))), + } + } + + /// Rebuild the anti-replay windows from a durable dedup index after a + /// process restart (ADR-265: the store's persistent `(node_id, sequence)` + /// index is the replay memory — without this call, a restarted gateway + /// would re-accept previously ingested signed packets). + /// + /// `keys` may arrive in any order; for each device the window is set to + /// the highest sequence seen with the in-window history bits populated. + pub fn prime_from_dedup(&mut self, keys: impl IntoIterator) { + let mut per_node: BTreeMap> = BTreeMap::new(); + for (node, seq) in keys { + per_node.entry(node).or_default().push(seq); + } + for (node, mut seqs) in per_node { + seqs.sort_unstable(); + let window = self.windows.entry(node).or_default(); + for seq in seqs { + // Errors here mean "already recorded" — harmless during + // priming. + let _ = window.check_and_update(seq); + } + } + } + + /// Steps (1)–(6) of the ingest contract, shared by [`Self::ingest`] and + /// [`Self::reverify_stored`]: envelope decode, payload validation, + /// registry + revocation lookup, key match, and signature verification. + fn verify_envelope( + &self, + envelope_bytes: &[u8], + ) -> Result<(SignedEnvRecordV1, RvEnvSampleV1, String), RejectReason> { + let record = SignedEnvRecordV1::decode(envelope_bytes) + .map_err(|e| RejectReason::BadEnvelope(e.to_string()))?; + let wire = RvEnvSampleV1::parse_validated(&record.payload) + .map_err(|e| RejectReason::BadPayload(e.to_string()))?; + let node_id = wire.node_id; + let (registered_pubkey, firmware_hash) = match self.registry.get(node_id) { + None => return Err(RejectReason::UnknownDevice(node_id)), + Some(d) if d.revoked => return Err(RejectReason::RevokedDevice(node_id)), + Some(d) => (d.pubkey, d.firmware_hash.clone()), + }; + if record.pubkey != registered_pubkey { + return Err(RejectReason::KeyMismatch(node_id)); + } + if verify_record(&record).is_err() { + return Err(RejectReason::BadSignature(node_id)); + } + Ok((record, wire, firmware_hash)) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_abi::{NodeSigner, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SCHEMA_V1}; + use rucelium_core::SensorModality; + + const SEED: &[u8; 32] = b"rucelium-provision-seed-32-byte!"; + const NODE_A: u64 = 0xDEAD_BEEF_0000_0007; + const NODE_B: u64 = 0xDEAD_BEEF_0000_0008; + const TS: u64 = 1_754_000_000_000_000_000; + const RECV: u64 = TS + 1_000_000; + const FW_A: &str = "sha256:firmware-a"; + const FW_B: &str = "sha256:firmware-b"; + + fn wire(node_id: u64, sequence: u32, flags: u16) -> RvEnvSampleV1 { + RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: SensorModality::SoilMoisture.code(), + flags, + node_id, + timestamp_ns: TS, + sequence, + latitude_e7: 514_778_216, + longitude_e7: -14_767, + altitude_mm: 46_000, + value_q16: 27 * 65_536 + 32_768, + quality_q15: 0x7000, + battery_mv: 3_612, + calibration_id: 3, + } + } + + /// Envelope bytes for `node_id` signed by that node's provisioned key. + fn signed_envelope(node_id: u64, sequence: u32, flags: u16) -> Vec { + NodeSigner::for_node(SEED, node_id) + .sign_sample(&wire(node_id, sequence, flags)) + .encode() + } + + /// A pipeline with NODE_A and NODE_B registered under their real keys. + fn pipeline() -> IngestPipeline { + let mut reg = DeviceRegistry::new(); + reg.register( + NODE_A, + NodeSigner::for_node(SEED, NODE_A).public_key(), + FW_A.to_string(), + ); + reg.register( + NODE_B, + NodeSigner::for_node(SEED, NODE_B).public_key(), + FW_B.to_string(), + ); + IngestPipeline::new(reg) + } + + #[test] + fn happy_path_ingests_verified_sample_with_registry_firmware() { + let mut p = pipeline(); + let sealed = p.ingest(&signed_envelope(NODE_A, 1, 0), RECV).unwrap(); + let sample = sealed.sample(); + sample.validate().unwrap(); + assert_eq!(sample.node_id, NODE_A); + assert_eq!(sample.sequence, 1); + assert_eq!(sample.received_ns, RECV); + assert!(sample.provenance.verified); + assert_eq!(sample.provenance.firmware_hash, FW_A); + assert_eq!( + sample.provenance.signer_pubkey_hex, + NodeSigner::for_node(SEED, NODE_A).public_key_hex() + ); + assert_eq!(p.stats().accepted, 1); + } + + #[test] + fn any_tampered_envelope_byte_is_rejected() { + let env = signed_envelope(NODE_A, 1, 0); + for i in 0..env.len() { + let mut p = pipeline(); + let mut tampered = env.clone(); + tampered[i] ^= 0x01; + assert!( + p.ingest(&tampered, RECV).is_err(), + "tampered byte {i} must be rejected" + ); + assert_eq!(p.stats().accepted, 0, "tampered byte {i} was accepted"); + } + // The pristine envelope still ingests fine. + assert!(pipeline().ingest(&env, RECV).is_ok()); + } + + #[test] + fn exact_replay_rejected_and_counted() { + let mut p = pipeline(); + let env = signed_envelope(NODE_A, 7, 0); + p.ingest(&env, RECV).unwrap(); + assert_eq!( + p.ingest(&env, RECV), + Err(RejectReason::Replay { + node_id: NODE_A, + sequence: 7 + }) + ); + assert_eq!(p.stats().accepted, 1); + assert_eq!(p.stats().replay, 1); + } + + #[test] + fn retransmit_flag_never_bypasses_dedup() { + let mut p = pipeline(); + let env = signed_envelope(NODE_A, 9, RV_ENV_FLAG_RETRANSMIT); + p.ingest(&env, RECV).unwrap(); + // Store-and-forward retransmit of an already-accepted sequence is + // still dropped as a replay. + assert!(matches!( + p.ingest(&env, RECV), + Err(RejectReason::Replay { sequence: 9, .. }) + )); + assert_eq!(p.stats().replay, 1); + } + + #[test] + fn out_of_order_within_window_accepted_exactly_once_each() { + let mut p = pipeline(); + p.ingest(&signed_envelope(NODE_A, 5, 0), RECV).unwrap(); + p.ingest(&signed_envelope(NODE_A, 3, 0), RECV).unwrap(); + p.ingest(&signed_envelope(NODE_A, 4, 0), RECV).unwrap(); + assert_eq!( + p.ingest(&signed_envelope(NODE_A, 3, 0), RECV), + Err(RejectReason::Replay { + node_id: NODE_A, + sequence: 3 + }) + ); + assert_eq!(p.stats().accepted, 3); + assert_eq!(p.stats().replay, 1); + } + + #[test] + fn very_old_sequence_below_window_is_too_old() { + let mut p = pipeline(); + p.ingest(&signed_envelope(NODE_A, 100, 0), RECV).unwrap(); + assert_eq!( + p.ingest(&signed_envelope(NODE_A, 10, 0), RECV), + Err(RejectReason::TooOld { + node_id: NODE_A, + sequence: 10 + }) + ); + assert_eq!(p.stats().too_old, 1); + } + + #[test] + fn unknown_device_rejected() { + let mut p = pipeline(); + let unknown: u64 = 0x9999; + assert_eq!( + p.ingest(&signed_envelope(unknown, 1, 0), RECV), + Err(RejectReason::UnknownDevice(unknown)) + ); + assert_eq!(p.stats().unknown_device, 1); + } + + #[test] + fn revoked_device_rejected_while_others_continue() { + let mut p = pipeline(); + p.ingest(&signed_envelope(NODE_A, 1, 0), RECV).unwrap(); + assert!(p.registry_mut().revoke(NODE_A)); + assert!(p.registry().is_revoked(NODE_A)); + // Audit record is kept. + assert!(p.registry().get(NODE_A).is_some()); + assert_eq!( + p.ingest(&signed_envelope(NODE_A, 2, 0), RECV), + Err(RejectReason::RevokedDevice(NODE_A)) + ); + // The second registered device is unaffected. + let s = p.ingest(&signed_envelope(NODE_B, 1, 0), RECV).unwrap(); + assert_eq!(s.sample().provenance.firmware_hash, FW_B); + assert_eq!(p.stats().accepted, 2); + assert_eq!(p.stats().revoked_device, 1); + } + + #[test] + fn revoke_is_true_once_then_false() { + let mut reg = DeviceRegistry::new(); + assert!(!reg.revoke(NODE_A), "unregistered device cannot be revoked"); + reg.register(NODE_A, [0xAA; 32], FW_A.to_string()); + assert!(!reg.is_revoked(NODE_A)); + assert!(reg.revoke(NODE_A)); + assert!(!reg.revoke(NODE_A), "second revoke must report false"); + assert!(reg.is_revoked(NODE_A)); + assert_eq!(reg.get(NODE_A).unwrap().firmware_hash, FW_A); + } + + #[test] + fn wrong_signer_claiming_node_a_is_rejected() { + let mut p = pipeline(); + // Node B's key signs a payload that claims to be from node A: the + // envelope carries B's pubkey, which differs from A's registration. + let env = NodeSigner::for_node(SEED, NODE_B) + .sign_sample(&wire(NODE_A, 1, 0)) + .encode(); + assert_eq!(p.ingest(&env, RECV), Err(RejectReason::KeyMismatch(NODE_A))); + assert_eq!(p.stats().key_mismatch, 1); + } + + #[test] + fn forged_packets_do_not_advance_the_replay_window() { + let mut p = pipeline(); + p.ingest(&signed_envelope(NODE_A, 1, 0), RECV).unwrap(); + // Forgery: node A's real registered pubkey, garbage signature, + // sequence 50. Passes the key-match check, fails verification. + let forged = SignedEnvRecordV1 { + payload: wire(NODE_A, 50, 0).encode(), + pubkey: NodeSigner::for_node(SEED, NODE_A).public_key(), + signature: [0u8; 64], + } + .encode(); + assert_eq!( + p.ingest(&forged, RECV), + Err(RejectReason::BadSignature(NODE_A)) + ); + // The genuine sequence 50 must still be accepted: the forgery did + // not burn the sequence number. + p.ingest(&signed_envelope(NODE_A, 50, 0), RECV).unwrap(); + assert_eq!(p.stats().accepted, 2); + assert_eq!(p.stats().bad_signature, 1); + assert_eq!(p.stats().replay, 0); + } + + #[test] + fn garbage_and_bad_payload_counted_in_stats() { + let mut p = pipeline(); + assert!(matches!( + p.ingest(b"not cbor at all", RECV), + Err(RejectReason::BadEnvelope(_)) + )); + // Structurally valid envelope, invalid payload (bad schema version), + // correctly signed — rejected at step 2 before any registry work. + let mut bad = wire(NODE_A, 1, 0); + bad.schema_version = 9; + let env = NodeSigner::for_node(SEED, NODE_A) + .sign_sample(&bad) + .encode(); + assert!(matches!( + p.ingest(&env, RECV), + Err(RejectReason::BadPayload(_)) + )); + assert_eq!(p.stats().bad_envelope, 1); + assert_eq!(p.stats().bad_payload, 1); + assert_eq!(p.stats().accepted, 0); + } + + #[test] + fn domain_failure_after_crypto_is_rejected_as_domain() { + let mut p = pipeline(); + // received_ns before measured_ns: passes every wire check, fails + // EnvSample::validate (TimeInverted) during conversion. + assert!(matches!( + p.ingest(&signed_envelope(NODE_A, 1, 0), TS - 1), + Err(RejectReason::Domain(_)) + )); + assert_eq!(p.stats().domain, 1); + assert_eq!(p.stats().accepted, 0); + } + + #[test] + fn replay_window_first_sequence_always_accepted() { + let mut w = ReplayWindow::new(); + w.check_and_update(0).unwrap(); + assert_eq!(w.highest(), Some(0)); + assert_eq!(w.check_and_update(0), Err(ReplayCheck::Replay)); + + let mut w = ReplayWindow::new(); + w.check_and_update(u32::MAX).unwrap(); + assert_eq!(w.check_and_update(u32::MAX), Err(ReplayCheck::Replay)); + } + + #[test] + fn replay_window_edges() { + let mut w = ReplayWindow::new(); + w.check_and_update(100).unwrap(); + // Exactly highest - 63 is still inside the window. + w.check_and_update(37).unwrap(); + assert_eq!(w.check_and_update(37), Err(ReplayCheck::Replay)); + // highest - 64 is below it. + assert_eq!(w.check_and_update(36), Err(ReplayCheck::TooOld)); + } + + #[test] + fn replay_window_large_jumps_shift_out_old_state() { + let mut w = ReplayWindow::new(); + w.check_and_update(1).unwrap(); + // Jump far ahead: 1 falls out of the window entirely. + w.check_and_update(1_000).unwrap(); + assert_eq!(w.check_and_update(1), Err(ReplayCheck::TooOld)); + assert_eq!(w.check_and_update(1_000), Err(ReplayCheck::Replay)); + // Advance by exactly 64: old highest lands on the last bitmap bit + // but is out of the acceptance window (diff 64 > 63). + let mut w = ReplayWindow::new(); + w.check_and_update(10).unwrap(); + w.check_and_update(74).unwrap(); + assert_eq!(w.check_and_update(10), Err(ReplayCheck::TooOld)); + } + + #[test] + fn windows_are_per_device() { + let mut p = pipeline(); + p.ingest(&signed_envelope(NODE_A, 5, 0), RECV).unwrap(); + // Node B reusing the same sequence number is not a replay. + p.ingest(&signed_envelope(NODE_B, 5, 0), RECV).unwrap(); + assert_eq!(p.stats().accepted, 2); + } + + #[test] + fn stats_serialize_to_json() { + let mut p = pipeline(); + p.ingest(&signed_envelope(NODE_A, 1, 0), RECV).unwrap(); + let env = signed_envelope(NODE_A, 1, 0); + let _ = p.ingest(&env, RECV); + let json = serde_json::to_value(p.stats()).unwrap(); + assert_eq!(json["accepted"], 1); + assert_eq!(json["replay"], 1); + assert_eq!(json["bad_signature"], 0); + } + + #[test] + fn reject_reasons_display() { + assert_eq!( + RejectReason::UnknownDevice(7).to_string(), + "unknown device 7" + ); + assert!(RejectReason::Replay { + node_id: 7, + sequence: 42 + } + .to_string() + .contains("42")); + // Error trait is implemented. + let e: Box = Box::new(RejectReason::BadSignature(7)); + assert!(e.to_string().contains("7")); + } +} diff --git a/crates/rucelium-notary/Cargo.toml b/crates/rucelium-notary/Cargo.toml new file mode 100644 index 0000000..66a2726 --- /dev/null +++ b/crates/rucelium-notary/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rucelium-notary" +version.workspace = true +edition.workspace = true +description = "RuCelium long-term provenance: domain-separated Merkle notarization with inclusion proofs and algorithm-agile root signing, amortizing post-quantum signature cost to bytes per observation (ADR-267)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["provenance", "merkle", "post-quantum", "archival", "environmental"] +categories = ["science", "cryptography"] + +[dependencies] +rucelium-core = { workspace = true } +sha2 = { workspace = true } +ed25519-dalek = { workspace = true } +serde = { workspace = true } +# `float_roundtrip` is load-bearing, not an optimization: without it serde_json +# parses a decimal like `23.470000000000002` to a f64 one ULP away from the +# value that printed it, so an archived evidence bundle would not rehash to its +# own leaf after a JSON round trip. ADR-267's whole claim is that bytes stored +# in 2026 still verify in 2040 — exact float round-tripping is a precondition. +serde_json = { workspace = true, features = ["float_roundtrip"] } + +[lints] +workspace = true diff --git a/crates/rucelium-notary/src/bundle.rs b/crates/rucelium-notary/src/bundle.rs new file mode 100644 index 0000000..3be0930 --- /dev/null +++ b/crates/rucelium-notary/src/bundle.rs @@ -0,0 +1,882 @@ +//! The gateway-side [`Notary`], the third-party [`EvidenceBundle`], and +//! forward chaining by [`renotarize`] (ADR-267 §3, shipped items 3 and 4). +//! +//! # The shape of the argument (ADR-267 §2) +//! +//! ```text +//! spore node ──ed25519(48-byte record)──► gateway +//! │ accepted observations +//! ▼ +//! Merkle accumulator (Notary) +//! │ every N records / T seconds +//! ▼ +//! signed NotaryRoot ──► biome ──► federation +//! +//! verification in 2040: observation + inclusion proof + signed root +//! └── recompute the root, check ONE signature ──┘ +//! ``` +//! +//! The gateway keeps the tree; the auditor needs none of it. An +//! [`EvidenceBundle`] is self-contained, and [`verify_bundle`] is the function +//! a regulator, insurer or reanalyst runs decades later with nothing but the +//! bundle and the public key they trust. +//! +//! # Batching latency is stated, not hidden (ADR-267 §4) +//! +//! A record is authentic the instant the node's ed25519 signature verifies, but +//! it is *notarized* only when its batch is sealed. Every bundle carries both +//! times ([`NotaryRoot::notarized_ns`] and the observation's `received_ns`), so +//! the gap is visible rather than implied; see +//! [`EvidenceBundle::notarization_lag_ns`]. + +use crate::root::{ + canonical_root_bytes, sign_root, verify_root, NotaryAlgorithm, NotaryRoot, RootSigner, + RootVerifier, +}; +use crate::tree::{leaf_hash, verify_inclusion, InclusionProof, MerkleTree}; +use crate::{canonical_json, hex_decode32, hex_encode}; +use rucelium_core::{EnvSample, EnvironmentalEvent, SPEC_VERSION}; +use serde::{Deserialize, Serialize}; + +/// Why a piece of long-term evidence failed to verify (ADR-267 §3). +/// +/// Every variant is a distinct, reportable failure: an auditor needs to say +/// *which* link of the chain broke, not merely that something did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotaryError { + /// The observation in the bundle does not hash to the bundle's `leaf_hex`: + /// the data was altered after notarization. + LeafMismatch, + /// The inclusion path does not carry the leaf to the signed root: the leaf + /// was not in this batch, or the path was tampered with. + ProofInvalid, + /// The root's signature does not verify under its declared algorithm and + /// key. + RootSignatureInvalid, + /// The signature is valid, but the signing key is not the key the auditor + /// trusts. + UntrustedSigner, + /// The root declares an algorithm the supplied verifier does not implement. + /// Never verify a root under an algorithm other than the one it claims. + AlgorithmMismatch, + /// The root carries no signature (or no signer key) at all. + MissingSignature, + /// Hex or JSON in the archived bundle was malformed. + Encoding(String), +} + +impl std::fmt::Display for NotaryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NotaryError::LeafMismatch => { + write!(f, "observation does not hash to the bundle's leaf") + } + NotaryError::ProofInvalid => write!(f, "merkle inclusion proof does not verify"), + NotaryError::RootSignatureInvalid => write!(f, "notary root signature is invalid"), + NotaryError::UntrustedSigner => write!(f, "root was signed by an untrusted key"), + NotaryError::AlgorithmMismatch => { + write!(f, "root algorithm differs from the verifier's algorithm") + } + NotaryError::MissingSignature => write!(f, "notary root carries no signature"), + NotaryError::Encoding(m) => write!(f, "bad encoding: {m}"), + } + } +} + +impl std::error::Error for NotaryError {} + +/// A sealed batch: the signed [`NotaryRoot`] that federates, plus the +/// [`MerkleTree`] the gateway keeps so it can serve inclusion proofs on demand +/// (ADR-267 §2 — proofs are served, not transmitted by default). +/// +/// The tree is *derived* state: ADR-267 §4 makes the durable store the source +/// of truth, so a gateway that loses its tree rebuilds it from stored +/// observations. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SealedBatch { + /// The signed root — small, data-free, federable. + pub root: NotaryRoot, + /// The tree over this batch's leaves, retained to answer proof requests. + pub tree: MerkleTree, +} + +impl SealedBatch { + /// Build the self-contained [`EvidenceBundle`] for one observation, or + /// `None` if this batch does not contain it. + /// + /// The observation is located by its leaf hash — the hash of its canonical + /// JSON — so a bundle can only be produced for bytes byte-identical to what + /// was notarized. (`EnvSample::dedup_key` identifies a sample in the + /// gateway's store; the leaf hash identifies it in the batch.) + #[must_use] + pub fn bundle_for(&self, sample: &EnvSample) -> Option { + let leaf = leaf_hash(&canonical_json(sample)); + let index = self.tree.index_of(&leaf)?; + let proof = self.tree.prove(index)?; + Some(EvidenceBundle { + observation: sample.clone(), + leaf_hex: hex_encode(&leaf), + proof, + root: self.root.clone(), + }) + } +} + +/// The gateway-side Merkle accumulator (ADR-267 §2). +/// +/// Accepted observations and events are hashed into leaves as they arrive; the +/// caller seals a batch when its own policy says so — after `batch_size` +/// records, or after an interval elapses. The notary itself reads no clock: +/// every timestamp is passed in by the caller, keeping the whole crate +/// deterministic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Notary { + biome_id: String, + batch_size: usize, + pending: Vec<[u8; 32]>, + next_batch_id: u64, + prev_root_hex: Option, +} + +impl Notary { + /// Create a notary for `biome_id` with a target `batch_size`. + /// + /// `batch_size` is advisory: it drives [`Notary::is_full`] so a caller can + /// implement the "every N records" half of ADR-267 §2's sealing policy. + /// Sealing is never automatic — [`Notary::seal`] is always explicit. + #[must_use] + pub fn new(biome_id: impl Into, batch_size: usize) -> Self { + Notary { + biome_id: biome_id.into(), + batch_size, + pending: Vec::new(), + next_batch_id: 0, + prev_root_hex: None, + } + } + + /// The biome this notary seals for. + #[must_use] + pub fn biome_id(&self) -> &str { + &self.biome_id + } + + /// The configured target batch size. + #[must_use] + pub fn batch_size(&self) -> usize { + self.batch_size + } + + /// The batch id the next [`Notary::seal`] will use. + #[must_use] + pub fn next_batch_id(&self) -> u64 { + self.next_batch_id + } + + /// Root hash of the most recently sealed batch, hex-encoded, which the next + /// batch will chain to via `prev_root_hex`. + #[must_use] + pub fn prev_root_hex(&self) -> Option<&str> { + self.prev_root_hex.as_deref() + } + + /// Number of leaves accumulated since the last seal. + #[must_use] + pub fn pending(&self) -> usize { + self.pending.len() + } + + /// Whether the target batch size has been reached (advisory sealing hint). + #[must_use] + pub fn is_full(&self) -> bool { + self.pending.len() >= self.batch_size + } + + /// Accumulate an accepted observation; returns its leaf hash. + /// + /// The leaf is `sha256(0x00 || canonical_json(sample))`, so it commits to + /// every one of ADR-264 §7.1's twelve mandatory attributes — value, units, + /// uncertainty, geo, calibration id, node signature provenance, lineage. + /// Change any byte of the sample and the leaf changes. + pub fn accept_observation(&mut self, sample: &EnvSample) -> [u8; 32] { + let leaf = leaf_hash(&canonical_json(sample)); + self.pending.push(leaf); + leaf + } + + /// Accumulate an accepted environmental event; returns its leaf hash. + /// + /// Events are `DataClass::FederatedEvent` (ADR-264 §10) and are notarized + /// exactly like observations, so a federated alert is as provable in 2040 + /// as the readings behind it. + pub fn accept_event(&mut self, event: &EnvironmentalEvent) -> [u8; 32] { + let leaf = leaf_hash(&canonical_json(event)); + self.pending.push(leaf); + leaf + } + + /// Seal the pending leaves into a signed batch (ADR-267 §2). + /// + /// Builds the Merkle tree over everything accumulated since the last seal, + /// chains `prev_root_hex` to the previous batch's root, signs the root with + /// `signer`, increments the batch id and clears the pending set. + /// + /// Sealing **zero** pending leaves is allowed and produces the documented + /// empty-batch sentinel root ([`crate::empty_root`]): a quiet interval must + /// still leave a signed, chained artifact, otherwise a gap in the chain is + /// indistinguishable from a deleted batch. + /// + /// All three timestamps are caller-supplied — the notary never reads a + /// clock. + pub fn seal( + &mut self, + signer: &dyn RootSigner, + window_start_ns: u64, + window_end_ns: u64, + notarized_ns: u64, + ) -> SealedBatch { + let tree = MerkleTree::build(std::mem::take(&mut self.pending)); + let root_hex = hex_encode(&tree.root()); + let mut root = NotaryRoot { + spec_version: SPEC_VERSION.to_string(), + biome_id: self.biome_id.clone(), + batch_id: self.next_batch_id, + root_hex: root_hex.clone(), + leaf_count: tree.len(), + window_start_ns, + window_end_ns, + notarized_ns, + prev_root_hex: self.prev_root_hex.clone(), + algorithm: signer.algorithm(), + signature_hex: None, + signer_pubkey_hex: None, + }; + sign_root(&mut root, signer); + self.prev_root_hex = Some(root_hex); + self.next_batch_id += 1; + SealedBatch { root, tree } + } +} + +/// Everything a third party needs to prove one observation existed, unaltered, +/// inside a signed batch — and nothing else (ADR-267 §2, §3 shipped item 3). +/// +/// This is the artifact handed to a regulator, an insurer or a court. It is +/// self-contained: [`verify_bundle`] needs no gateway, no database and no +/// network, only the bundle and the public key the auditor already trusts. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EvidenceBundle { + /// The observation being proven, exactly as notarized. + pub observation: EnvSample, + /// Hex-encoded leaf hash of `observation`. + pub leaf_hex: String, + /// Inclusion path from the leaf to the batch root. + pub proof: InclusionProof, + /// The signed root of the batch containing the leaf. + pub root: NotaryRoot, +} + +impl EvidenceBundle { + /// Batching latency: nanoseconds between the gateway receiving the + /// observation and the batch being notarized. + /// + /// ADR-267 §4 requires this distinction to be stated in any evidence + /// bundle: the record was *authentic* on receipt and *notarized* only at + /// seal time. Saturates at zero for a root sealed before reception (a clock + /// domain mismatch, itself worth reporting). + #[must_use] + pub fn notarization_lag_ns(&self) -> u64 { + self.root + .notarized_ns + .saturating_sub(self.observation.received_ns) + } +} + +/// **The 2040 auditor function** (ADR-267 §2, §3 shipped item 3). +/// +/// Given only a bundle, a verifier for the root's algorithm, and the public key +/// the auditor trusts, decide whether this observation provably existed, +/// unaltered, in the signed batch. In order: +/// +/// 1. recompute the leaf from the observation's canonical bytes and check it +/// against `leaf_hex` → [`NotaryError::LeafMismatch`]; +/// 2. verify the inclusion proof against the root's `root_hex`, requiring the +/// proof's `leaf_count` to equal the root's signed `leaf_count` — otherwise +/// a forger could re-declare the batch size to fit a path they invented +/// → [`NotaryError::ProofInvalid`]; +/// 3. check the root declares the verifier's algorithm +/// → [`NotaryError::AlgorithmMismatch`], and carries a signature +/// → [`NotaryError::MissingSignature`]; +/// 4. check the signer is the trusted key → [`NotaryError::UntrustedSigner`]; +/// 5. verify the root signature → [`NotaryError::RootSignatureInvalid`]. +/// +/// Exactly **one** signature check, for a whole batch — the asymmetry that +/// makes a 2,420-byte post-quantum signature affordable (ADR-267 §2). +pub fn verify_bundle( + bundle: &EvidenceBundle, + verifier: &dyn RootVerifier, + trusted_pubkey_hex: &str, +) -> Result<(), NotaryError> { + let claimed_leaf = hex_decode32(&bundle.leaf_hex) + .ok_or_else(|| NotaryError::Encoding(format!("leaf_hex {:?}", bundle.leaf_hex)))?; + let recomputed = leaf_hash(&canonical_json(&bundle.observation)); + if recomputed != claimed_leaf { + return Err(NotaryError::LeafMismatch); + } + + let root_hash = hex_decode32(&bundle.root.root_hex) + .ok_or_else(|| NotaryError::Encoding(format!("root_hex {:?}", bundle.root.root_hex)))?; + if bundle.proof.leaf_count != bundle.root.leaf_count + || !verify_inclusion(&recomputed, &bundle.proof, &root_hash) + { + return Err(NotaryError::ProofInvalid); + } + + if bundle.root.algorithm != verifier.algorithm() { + return Err(NotaryError::AlgorithmMismatch); + } + let signer_hex = match ( + bundle.root.signature_hex.as_ref(), + bundle.root.signer_pubkey_hex.as_ref(), + ) { + (Some(_), Some(pk)) => pk, + _ => return Err(NotaryError::MissingSignature), + }; + if signer_hex != trusted_pubkey_hex { + return Err(NotaryError::UntrustedSigner); + } + if !verify_root(&bundle.root, verifier) { + return Err(NotaryError::RootSignatureInvalid); + } + Ok(()) +} + +/// Re-notarization: chain existing roots forward under a new signer +/// (ADR-267 §3, shipped item 4). +/// +/// Each old root's [`canonical_root_bytes`] becomes a **leaf** of a new tree, +/// and only the new root is signed — potentially by a stronger algorithm. Data +/// already notarized under ed25519 therefore gains the new guarantee **without +/// re-signing a single observation**: an old inclusion proof still verifies +/// against its old root, and the old root now has its own inclusion proof in +/// the new, stronger tree. +/// +/// The synthesized root's window spans the earliest start and latest end of the +/// inputs, its `prev_root_hex` chains to the last input root, and `now_ns` is +/// supplied by the caller (no clock). Re-notarizing an empty slice yields the +/// documented empty-batch sentinel. +pub fn renotarize( + old_roots: &[NotaryRoot], + biome_id: impl Into, + batch_id: u64, + signer: &dyn RootSigner, + now_ns: u64, +) -> SealedBatch { + let leaves: Vec<[u8; 32]> = old_roots + .iter() + .map(|r| leaf_hash(&canonical_root_bytes(r))) + .collect(); + let tree = MerkleTree::build(leaves); + let window_start_ns = old_roots + .iter() + .map(|r| r.window_start_ns) + .min() + .unwrap_or(0); + let window_end_ns = old_roots.iter().map(|r| r.window_end_ns).max().unwrap_or(0); + let mut root = NotaryRoot { + spec_version: SPEC_VERSION.to_string(), + biome_id: biome_id.into(), + batch_id, + root_hex: hex_encode(&tree.root()), + leaf_count: tree.len(), + window_start_ns, + window_end_ns, + notarized_ns: now_ns, + prev_root_hex: old_roots.last().map(|r| r.root_hex.clone()), + algorithm: signer.algorithm(), + signature_hex: None, + signer_pubkey_hex: None, + }; + sign_root(&mut root, signer); + SealedBatch { root, tree } +} + +/// The leaf a re-notarized old root occupies in the new tree: the hash of its +/// canonical bytes (ADR-267 §3 item 4). Exposed so an auditor holding only an +/// old root can locate and check its own inclusion in the newer tree. +#[must_use] +pub fn renotarized_leaf(old_root: &NotaryRoot) -> [u8; 32] { + leaf_hash(&canonical_root_bytes(old_root)) +} + +/// Algorithm tag helper for callers assembling a hybrid transition: the tag a +/// dual ed25519 + ML-DSA-44 signer must declare (ADR-267 §3). +#[must_use] +pub fn hybrid_algorithm() -> NotaryAlgorithm { + NotaryAlgorithm::HybridEd25519MlDsa44 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::root::{Ed25519RootSigner, Ed25519RootVerifier, ML_DSA_44_SIGNATURE_BYTES}; + use crate::{empty_root, hex_decode, leaf_hash as lh}; + use rucelium_core::{ + EventKind, EvidenceRef, GeoPoint, SampleProvenance, SensorModality, Severity, Uncertainty, + }; + + const SEED: &[u8; 32] = b"rucelium-notary-test-seed-32byte"; + const ROGUE_SEED: &[u8; 32] = b"rucelium-notary-rogue-seed-32byt"; + + fn sample(i: u32) -> EnvSample { + let value = 20.0 + f64::from(i) * 0.01; + EnvSample { + node_id: 7, + sequence: i, + measured_ns: 1_000 + u64::from(i), + received_ns: 2_000 + u64::from(i), + geo: GeoPoint::new(514_778_216, -14_767, 46_000).unwrap(), + modality: SensorModality::Weather, + observed_property: "air_temperature".into(), + unit: "Cel".into(), + value, + quality: 0.98, + uncertainty: Uncertainty::symmetric(value, 0.3), + calibration_id: 3, + flags: 0, + battery_mv: 3600, + provenance: SampleProvenance { + firmware_hash: "sha256:abc".into(), + signer_pubkey_hex: "00ff".into(), + verified: true, + lineage: vec!["cal:3".into()], + }, + } + } + + fn event() -> EnvironmentalEvent { + EnvironmentalEvent { + evidence_digest: None, + spec_version: SPEC_VERSION.into(), + event_id: "evt-0001".into(), + biome_id: "biome/thames-estuary".into(), + kind: EventKind::FloodRisk, + severity: Severity::Warning, + modality: SensorModality::WaterQuality, + geo: GeoPoint::new(514_000_000, 500_000, 0).unwrap(), + window_start_ns: 1_000, + window_end_ns: 5_000, + detected_ns: 5_100, + evidence: vec![EvidenceRef { + node_id: 7, + sequence: 42, + }], + confidence: 0.9, + message: "water level rising across 3 nodes".into(), + signature_hex: None, + signer_pubkey_hex: None, + } + } + + #[test] + fn accept_returns_the_leaf_and_tracks_pending() { + let mut n = Notary::new("biome/thames-estuary", 4); + assert_eq!(n.biome_id(), "biome/thames-estuary"); + assert_eq!(n.batch_size(), 4); + assert_eq!(n.pending(), 0); + assert_eq!(n.next_batch_id(), 0); + assert_eq!(n.prev_root_hex(), None); + assert!(!n.is_full()); + + let s = sample(1); + let leaf = n.accept_observation(&s); + assert_eq!(leaf, lh(&serde_json::to_vec(&s).unwrap())); + assert_eq!(n.pending(), 1); + + let e = event(); + let ev_leaf = n.accept_event(&e); + assert_eq!(ev_leaf, lh(&serde_json::to_vec(&e).unwrap())); + assert_ne!(ev_leaf, leaf); + assert_eq!(n.pending(), 2); + + n.accept_observation(&sample(2)); + n.accept_observation(&sample(3)); + assert!(n.is_full()); + } + + #[test] + fn seal_signs_chains_and_clears() { + let signer = Ed25519RootSigner::from_seed(SEED); + let mut n = Notary::new("biome/thames-estuary", 2); + n.accept_observation(&sample(1)); + n.accept_observation(&sample(2)); + let b0 = n.seal(&signer, 1_000, 2_000, 2_100); + assert_eq!(n.pending(), 0); + assert_eq!(b0.root.batch_id, 0); + assert_eq!(b0.root.leaf_count, 2); + assert_eq!(b0.root.prev_root_hex, None); + assert_eq!(b0.root.spec_version, SPEC_VERSION); + assert_eq!(b0.root.algorithm, NotaryAlgorithm::Ed25519); + assert!(verify_root(&b0.root, &Ed25519RootVerifier::new())); + + n.accept_observation(&sample(3)); + let b1 = n.seal(&signer, 2_000, 3_000, 3_100); + assert_eq!(b1.root.batch_id, 1); + // Chaining: batch N+1's prev_root_hex is batch N's root_hex. + assert_eq!(b1.root.prev_root_hex.as_deref(), Some(&*b0.root.root_hex)); + assert!(verify_root(&b1.root, &Ed25519RootVerifier::new())); + + let b2 = n.seal(&signer, 3_000, 4_000, 4_100); + assert_eq!(b2.root.batch_id, 2); + assert_eq!(b2.root.prev_root_hex.as_deref(), Some(&*b1.root.root_hex)); + } + + #[test] + fn empty_batch_seals_to_the_sentinel_and_still_verifies() { + let signer = Ed25519RootSigner::from_seed(SEED); + let mut n = Notary::new("biome/quiet", 64); + let b = n.seal(&signer, 1_000, 2_000, 2_100); + assert_eq!(b.root.leaf_count, 0); + assert_eq!(b.root.root_hex, hex_encode(&empty_root())); + assert!(b.tree.is_empty()); + assert!(verify_root(&b.root, &Ed25519RootVerifier::new())); + assert!(b.bundle_for(&sample(1)).is_none()); + // The chain continues across the quiet interval. + n.accept_observation(&sample(1)); + let b1 = n.seal(&signer, 2_000, 3_000, 3_100); + assert_eq!(b1.root.prev_root_hex.as_deref(), Some(&*b.root.root_hex)); + } + + #[test] + fn sealing_is_deterministic() { + let build = || { + let signer = Ed25519RootSigner::from_seed(SEED); + let mut n = Notary::new("biome/thames-estuary", 8); + for i in 0..8 { + n.accept_observation(&sample(i)); + } + n.seal(&signer, 1_000, 2_000, 2_100) + }; + assert_eq!(build(), build()); + } + + #[test] + fn bundle_for_unknown_observation_is_none() { + let signer = Ed25519RootSigner::from_seed(SEED); + let mut n = Notary::new("biome/thames-estuary", 4); + for i in 0..4 { + n.accept_observation(&sample(i)); + } + let b = n.seal(&signer, 1_000, 2_000, 2_100); + assert!(b.bundle_for(&sample(0)).is_some()); + assert!(b.bundle_for(&sample(99)).is_none()); + } + + /// The headline: a stranger verifies one 2026 observation in 2040 holding + /// nothing but the bundle and the public key they trust — no Notary, no + /// tree, no gateway (ADR-267 §2). + #[test] + fn third_party_verifies_one_observation_from_the_bundle_alone() { + let signer = Ed25519RootSigner::from_seed(SEED); + let trusted = signer.public_hex(); + let mut n = Notary::new("biome/thames-estuary", 512); + for i in 0..500 { + n.accept_observation(&sample(i)); + } + assert_eq!(n.pending(), 500); + let batch = n.seal(&signer, 1_000, 500_000, 500_100); + assert_eq!(batch.root.leaf_count, 500); + + let target = sample(317); + let bundle = batch.bundle_for(&target).expect("observation is in batch"); + assert_eq!(bundle.proof.leaf_index, 317); + assert_eq!(bundle.proof.leaf_count, 500); + assert_eq!(bundle.notarization_lag_ns(), 500_100 - (2_000 + 317)); + + // Everything the auditor gets travels as bytes. + let wire = serde_json::to_string(&bundle).unwrap(); + let bundle: EvidenceBundle = serde_json::from_str(&wire).unwrap(); + let verifier = Ed25519RootVerifier::new(); + // The archived bundle carries no tree and no gateway state. + verify_bundle(&bundle, &verifier, &trusted).expect("honest bundle verifies"); + + // (a) the observation's value is altered + let mut tampered = bundle.clone(); + tampered.observation.value += 0.5; + assert_eq!( + verify_bundle(&tampered, &verifier, &trusted), + Err(NotaryError::LeafMismatch) + ); + + // (b) a sibling in the proof is altered + let mut tampered = bundle.clone(); + tampered.proof.siblings[0].0[0] ^= 0x01; + assert_eq!( + verify_bundle(&tampered, &verifier, &trusted), + Err(NotaryError::ProofInvalid) + ); + + // (c) the root signature is altered + let mut tampered = bundle.clone(); + let mut sig = hex_decode(tampered.root.signature_hex.as_ref().unwrap()).unwrap(); + sig[0] ^= 0x01; + tampered.root.signature_hex = Some(hex_encode(&sig)); + assert_eq!( + verify_bundle(&tampered, &verifier, &trusted), + Err(NotaryError::RootSignatureInvalid) + ); + + // (d) an untrusted key is supplied + let rogue = Ed25519RootSigner::from_seed(ROGUE_SEED); + assert_eq!( + verify_bundle(&bundle, &verifier, &rogue.public_hex()), + Err(NotaryError::UntrustedSigner) + ); + + // Bonus: a rogue notary re-signing the same tree is still untrusted. + let mut forged = bundle; + sign_root(&mut forged.root, &rogue); + assert!(verify_root(&forged.root, &verifier)); + assert_eq!( + verify_bundle(&forged, &verifier, &trusted), + Err(NotaryError::UntrustedSigner) + ); + } + + #[test] + fn verify_bundle_rejects_structural_forgeries() { + let signer = Ed25519RootSigner::from_seed(SEED); + let trusted = signer.public_hex(); + let verifier = Ed25519RootVerifier::new(); + let mut n = Notary::new("biome/thames-estuary", 16); + for i in 0..16 { + n.accept_observation(&sample(i)); + } + let batch = n.seal(&signer, 1_000, 2_000, 2_100); + let bundle = batch.bundle_for(&sample(9)).unwrap(); + verify_bundle(&bundle, &verifier, &trusted).unwrap(); + + // Unsigned root. + let mut t = bundle.clone(); + t.root.signature_hex = None; + assert_eq!( + verify_bundle(&t, &verifier, &trusted), + Err(NotaryError::MissingSignature) + ); + + // Root claiming another algorithm than the verifier implements. + let mut t = bundle.clone(); + t.root.algorithm = NotaryAlgorithm::MlDsa44; + assert_eq!( + verify_bundle(&t, &verifier, &trusted), + Err(NotaryError::AlgorithmMismatch) + ); + + // Malformed archived hex. + let mut t = bundle.clone(); + t.leaf_hex = "not-hex".into(); + assert!(matches!( + verify_bundle(&t, &verifier, &trusted), + Err(NotaryError::Encoding(_)) + )); + let mut t = bundle.clone(); + t.root.root_hex = "abc".into(); + assert!(matches!( + verify_bundle(&t, &verifier, &trusted), + Err(NotaryError::Encoding(_)) + )); + + // A proof re-pointed at another index. + let mut t = bundle.clone(); + t.proof.leaf_index = 8; + assert_eq!( + verify_bundle(&t, &verifier, &trusted), + Err(NotaryError::ProofInvalid) + ); + + // A proof that re-declares the batch size. `verify_inclusion` alone + // accepts a same-shape count (15 vs 16 at index 9); the bundle check + // rejects it because leaf_count is covered by the root signature. + let mut t = bundle.clone(); + t.proof.leaf_count = 15; + assert!(verify_inclusion( + &hex_decode32(&t.leaf_hex).unwrap(), + &t.proof, + &hex_decode32(&t.root.root_hex).unwrap() + )); + assert_eq!( + verify_bundle(&t, &verifier, &trusted), + Err(NotaryError::ProofInvalid) + ); + + // A leaf_hex that matches nothing. + let mut t = bundle; + t.leaf_hex = hex_encode(&[0u8; 32]); + assert_eq!( + verify_bundle(&t, &verifier, &trusted), + Err(NotaryError::LeafMismatch) + ); + } + + /// ADR-267 §2's economic claim, encoded so it cannot silently rot: one + /// signature per batch amortizes to a fraction of a byte per observation + /// even at ML-DSA-44 size. + #[test] + fn batch_signature_amortizes_below_one_byte_per_observation() { + let signer = Ed25519RootSigner::from_seed(SEED); + let mut n = Notary::new("biome/thames-estuary", 4096); + for i in 0..4096 { + n.accept_observation(&sample(i)); + } + assert!(n.is_full()); + let batch = n.seal(&signer, 1_000, 4_096_000, 4_096_100); + let leaf_count = batch.root.leaf_count; + assert_eq!(leaf_count, 4096); + + // 2,420 bytes is the NIST FIPS 204 ML-DSA-44 signature size (ADR-267 + // §1). v0.1 signs with ed25519, but the batch geometry is what makes + // the PQ swap affordable, so the assertion is written against the PQ + // number. + let bytes_per_observation = ML_DSA_44_SIGNATURE_BYTES as f64 / leaf_count as f64; + assert!( + bytes_per_observation < 1.0, + "ML-DSA-44 amortization regressed: {bytes_per_observation} B/observation" + ); + assert!((bytes_per_observation - 0.590_820_312_5).abs() < 1e-12); + + // Per-observation ML-DSA would instead cost 2,420 B each — ~38x + // ed25519 and ~49 LoRaWAN DR0 datagrams (ADR-267 §1). + assert!(bytes_per_observation < ML_DSA_44_SIGNATURE_BYTES as f64 / 100.0); + + // The proof an observation actually needs stays small: a 4,096-leaf + // batch gives a 12-hash (384-byte) path (ADR-267 §2). + let bundle = batch.bundle_for(&sample(4_095)).unwrap(); + assert_eq!(bundle.proof.siblings.len(), 12); + assert_eq!(bundle.proof.siblings.len() * 32, 384); + verify_bundle(&bundle, &Ed25519RootVerifier::new(), &signer.public_hex()).unwrap(); + } + + #[test] + fn renotarization_chains_history_forward_without_resigning_observations() { + let old_signer = Ed25519RootSigner::from_seed(SEED); + let verifier = Ed25519RootVerifier::new(); + let mut n = Notary::new("biome/thames-estuary", 4); + + let mut batches = Vec::new(); + for b in 0..3u32 { + for i in 0..4 { + n.accept_observation(&sample(b * 4 + i)); + } + let start = 1_000 + u64::from(b) * 1_000; + batches.push(n.seal(&old_signer, start, start + 999, start + 1_000)); + } + let old_roots: Vec = batches.iter().map(|b| b.root.clone()).collect(); + + // The "stronger" signer of the future. v0.1 has only ed25519, so this + // stands in for the ML-DSA key the same code path will carry. + let new_signer = Ed25519RootSigner::from_seed(ROGUE_SEED); + let renotarized = renotarize(&old_roots, "biome/thames-estuary", 100, &new_signer, 9_000); + + assert_eq!(renotarized.root.leaf_count, 3); + assert_eq!(renotarized.root.batch_id, 100); + assert_eq!(renotarized.root.window_start_ns, 1_000); + assert_eq!(renotarized.root.window_end_ns, 3_999); + assert_eq!(renotarized.root.notarized_ns, 9_000); + assert_eq!( + renotarized.root.prev_root_hex.as_deref(), + Some(&*old_roots[2].root_hex) + ); + assert!(verify_root(&renotarized.root, &verifier)); + + // An OLD root's inclusion in the NEW tree, proven and verified. + let old_leaf = renotarized_leaf(&old_roots[1]); + let idx = renotarized + .tree + .index_of(&old_leaf) + .expect("old root is a leaf"); + assert_eq!(idx, 1); + let proof = renotarized.tree.prove(idx).unwrap(); + let new_root_hash = hex_decode32(&renotarized.root.root_hex).unwrap(); + assert!(verify_inclusion(&old_leaf, &proof, &new_root_hash)); + + // Tampering with the old root changes its leaf, so it no longer proves. + let mut tampered = old_roots[1].clone(); + tampered.leaf_count += 1; + assert!(!verify_inclusion( + &renotarized_leaf(&tampered), + &proof, + &new_root_hash + )); + + // No observation was re-signed: the original bundles still verify + // against their original roots and the original key. + let bundle = batches[1].bundle_for(&sample(5)).unwrap(); + verify_bundle(&bundle, &verifier, &old_signer.public_hex()).unwrap(); + + // Re-notarizing nothing yields the documented sentinel. + let empty = renotarize(&[], "biome/quiet", 0, &new_signer, 9_000); + assert_eq!(empty.root.root_hex, hex_encode(&empty_root())); + assert_eq!(empty.root.prev_root_hex, None); + assert!(verify_root(&empty.root, &verifier)); + } + + #[test] + fn errors_display_distinctly_and_hybrid_tag_is_available() { + let all = [ + NotaryError::LeafMismatch, + NotaryError::ProofInvalid, + NotaryError::RootSignatureInvalid, + NotaryError::UntrustedSigner, + NotaryError::AlgorithmMismatch, + NotaryError::MissingSignature, + NotaryError::Encoding("leaf_hex".into()), + ]; + let mut seen: Vec = all.iter().map(ToString::to_string).collect(); + seen.sort(); + seen.dedup(); + assert_eq!(seen.len(), all.len()); + let as_err: &dyn std::error::Error = &NotaryError::ProofInvalid; + assert!(!as_err.to_string().is_empty()); + assert_eq!(hybrid_algorithm().as_str(), "hybrid-ed25519+ml-dsa-44"); + } + + /// Archival stability regression. A leaf commits to the canonical JSON of + /// an observation, so an archived bundle must rehash to its own leaf after + /// being parsed back out of storage. `serde_json`'s default float parser + /// lands one ULP away from the value that printed it (e.g. the decimal + /// `23.470000000000002` parses back as `23.47`), which would silently break + /// every float-bearing bundle on the way out of the archive; this crate + /// therefore enables serde_json's `float_roundtrip` feature. If that + /// feature is ever dropped, this test fails rather than the year-2040 + /// auditor. + #[test] + fn archived_observations_rehash_exactly_after_a_json_round_trip() { + for i in [0u32, 1, 7, 317, 4_095] { + let original = sample(i); + let leaf = lh(&serde_json::to_vec(&original).unwrap()); + let text = serde_json::to_string(&original).unwrap(); + let parsed: EnvSample = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed, original, "sample {i} lost precision"); + assert_eq!( + lh(&serde_json::to_vec(&parsed).unwrap()), + leaf, + "sample {i} rehashed differently after archival" + ); + } + } + + #[test] + fn sealed_batch_round_trips_as_json() { + let signer = Ed25519RootSigner::from_seed(SEED); + let mut n = Notary::new("biome/thames-estuary", 5); + for i in 0..5 { + n.accept_observation(&sample(i)); + } + let b = n.seal(&signer, 1_000, 2_000, 2_100); + let json = serde_json::to_string(&b).unwrap(); + let back: SealedBatch = serde_json::from_str(&json).unwrap(); + assert_eq!(b, back); + let bundle = back.bundle_for(&sample(3)).unwrap(); + verify_bundle(&bundle, &Ed25519RootVerifier::new(), &signer.public_hex()).unwrap(); + } +} diff --git a/crates/rucelium-notary/src/lib.rs b/crates/rucelium-notary/src/lib.rs new file mode 100644 index 0000000..cd2d925 --- /dev/null +++ b/crates/rucelium-notary/src/lib.rs @@ -0,0 +1,131 @@ +//! # rucelium-notary +//! +//! Long-term provenance for RuCelium: a gateway-side **Merkle notary** that +//! makes environmental evidence verifiable decades after it was collected +//! (ADR-267). +//! +//! ## Why this crate exists (ADR-267 §1–§2) +//! +//! A spore node signs every observation with ed25519 so the gateway can answer +//! *"is this packet from this device, unmodified?"* right now. That signature +//! is cheap enough for a duty-cycled sub-GHz radio (64 bytes, 3 LoRaWAN DR0 +//! datagrams) but it is **not durable**: a cryptographically relevant quantum +//! computer breaks ECC, and a signature that cannot be trusted in 2040 +//! retroactively destroys the evidentiary value of data collected in 2026. +//! +//! Signing each observation with ML-DSA-44 instead is infeasible at the sensor +//! boundary — 2,420-byte signatures plus a 1,312-byte public key would need +//! ~49 datagrams per reading (ADR-267 §1). So ADR-267 splits the two jobs a +//! signature does today: +//! +//! 1. **Authenticity now** stays ed25519, per observation, at the node. +//! 2. **Verifiability later** moves here: the gateway accumulates accepted +//! observations into a domain-separated Merkle tree and signs only the +//! **root**. One expensive signature amortizes across the whole batch — +//! a 4,096-leaf batch under a 2,420-byte ML-DSA-44 signature costs +//! **0.6 bytes per observation** (see the amortization test). +//! +//! ## What v0.1 ships (ADR-267 §3, implementation-status items 1–4) +//! +//! - [`tree`] — a binary Merkle tree over `sha256` with domain-separated leaf +//! (`0x00`) and interior (`0x01`) hashing, deterministic batch construction, +//! inclusion proofs, and a **stateless** [`verify_inclusion`] a third party +//! can run with no access to the gateway. +//! - [`root`] — the [`RootSigner`] / [`RootVerifier`] trait pair plus the +//! self-describing [`NotaryAlgorithm`] tag recorded *inside* the signed root, +//! so swapping in ML-DSA is a configuration change, not a protocol break. +//! - [`bundle`] — the [`Notary`] accumulator, [`Ed25519RootSigner`]-signed +//! batches, the third-party [`EvidenceBundle`] and its 2040 auditor function +//! [`verify_bundle`], and [`renotarize`] for chaining old roots forward into +//! a new (potentially stronger) tree. +//! +//! **Honest label (ADR-267 §3):** RuCelium is *post-quantum ready*, not +//! post-quantum. No ML-DSA implementation ships here — that needs a vetted, +//! ideally FIPS-validated implementation. What ships is the architecture that +//! makes the swap cheap, plus the amortization that makes it affordable. +//! +//! ## Determinism +//! +//! Nothing in this crate reads a clock or an RNG. Callers pass `now_ns` / +//! window bounds explicitly, and ed25519 (RFC 8032) is deterministic, so the +//! same inputs always produce byte-identical roots and signatures. + +#![doc(html_root_url = "https://docs.rs/rucelium-notary/0.1.0")] +#![deny(missing_docs)] + +pub mod bundle; +pub mod root; +pub mod tree; + +pub use bundle::{renotarize, verify_bundle, EvidenceBundle, Notary, NotaryError, SealedBatch}; +pub use root::{ + canonical_root_bytes, sign_root, verify_root, Ed25519RootSigner, Ed25519RootVerifier, + NotaryAlgorithm, NotaryRoot, RootSigner, RootVerifier, ED25519_SIGNATURE_BYTES, + ML_DSA_44_PUBLIC_KEY_BYTES, ML_DSA_44_SIGNATURE_BYTES, +}; +pub use tree::{ + empty_root, leaf_hash, node_hash, verify_inclusion, InclusionProof, MerkleTree, EMPTY_DOMAIN, + EMPTY_TREE_LABEL, LEAF_DOMAIN, NODE_DOMAIN, +}; + +/// Lowercase-hex encoding of arbitrary bytes — the encoding used by every +/// `*_hex` field in this crate (matching `rufield-provenance` house style). +#[must_use] +pub fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// Decode hex into bytes. Returns `None` for an odd length or a non-hex digit — +/// callers map that to an encoding error rather than panicking, because an +/// evidence bundle read in 2040 may be arbitrarily corrupt (ADR-267 §3). +#[must_use] +pub fn hex_decode(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} + +/// Decode exactly 32 hex-encoded bytes (a `sha256` digest: a leaf hash or a +/// Merkle root). Returns `None` unless the input is valid hex of exactly 64 +/// characters. +#[must_use] +pub fn hex_decode32(s: &str) -> Option<[u8; 32]> { + hex_decode(s).and_then(|b| b.try_into().ok()) +} + +/// Canonical JSON bytes of any serializable value — the byte string this crate +/// hashes into leaves and signs as roots (ADR-267 §3). +/// +/// The domain types involved (`EnvSample`, `EnvironmentalEvent`, [`NotaryRoot`]) +/// are plain data whose `serde_json` encoding cannot fail: `serde_json` encodes +/// a non-finite `f64` as `null` rather than erroring. Should an unrepresentable +/// value ever appear, this degrades to empty bytes — producing a leaf that +/// simply fails to verify — instead of panicking inside a notary that may be +/// running unattended on a gateway. +pub(crate) fn canonical_json(value: &T) -> Vec { + serde_json::to_vec(value).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_round_trips_and_rejects_malformed() { + let bytes = [0x00u8, 0x0f, 0xff, 0xa5]; + assert_eq!(hex_encode(&bytes), "000fffa5"); + assert_eq!(hex_decode("000fffa5").unwrap(), bytes); + assert!(hex_decode("abc").is_none()); // odd length + assert!(hex_decode("zz").is_none()); // not hex + assert!(hex_decode32("00").is_none()); // wrong length + assert_eq!(hex_decode32(&hex_encode(&[7u8; 32])).unwrap(), [7u8; 32]); + } +} diff --git a/crates/rucelium-notary/src/root.rs b/crates/rucelium-notary/src/root.rs new file mode 100644 index 0000000..6399416 --- /dev/null +++ b/crates/rucelium-notary/src/root.rs @@ -0,0 +1,484 @@ +//! Algorithm-agile signed notary roots (ADR-267 §3, shipped items 2 and 3). +//! +//! ADR-267's shipped feature is **algorithm agility, not ML-DSA itself**. The +//! root signature algorithm is reached only through the [`RootSigner`] / +//! [`RootVerifier`] trait pair, and the algorithm actually used is recorded +//! *inside* the signed structure as a [`NotaryAlgorithm`] tag. Two consequences +//! matter: +//! +//! - swapping ed25519 for ML-DSA-44 (or dual-signing during the hybrid +//! transition) is an implementation swap, not a protocol break; +//! - a verifier **never has to guess** which algorithm a root was signed under, +//! and a root minted in 2032 under ML-DSA is self-describing to a reader +//! written today. Because the tag is part of the canonical bytes, it is +//! covered by the signature and cannot be downgraded after the fact. +//! +//! **Honest label (ADR-267 §3):** only [`Ed25519RootSigner`] ships in v0.1. +//! Shipping a hand-rolled lattice implementation would be worse than shipping +//! none; the [`ML_DSA_44_SIGNATURE_BYTES`] constant records the size that +//! matters for the amortization argument until a vetted implementation exists. + +use crate::{canonical_json, hex_decode, hex_encode}; +use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +/// Ed25519 detached signature size, bytes (RFC 8032) — what v0.1 actually +/// signs roots with (ADR-267 §1 table). +pub const ED25519_SIGNATURE_BYTES: usize = 64; + +/// ML-DSA-44 signature size, bytes, per NIST FIPS 204 (ADR-267 §1 table). +/// +/// This constant is the whole economic argument of ADR-267 §2 in one number: +/// ~38× an ed25519 signature, infeasible per observation on a LoRaWAN DR0 link +/// (~49 datagrams), but negligible once amortized across a batch — 2,420 bytes +/// over a 4,096-leaf batch is **0.6 bytes per observation**. +pub const ML_DSA_44_SIGNATURE_BYTES: usize = 2420; + +/// ML-DSA-44 public key size, bytes, per NIST FIPS 204 (ADR-267 §1). +pub const ML_DSA_44_PUBLIC_KEY_BYTES: usize = 1312; + +/// Signature algorithm of a notary root, recorded inside the root itself so a +/// verifier never has to guess (ADR-267 §3). +/// +/// The serde names are the exact wire strings named in ADR-267 §3: +/// `"ed25519"`, `"ml-dsa-44"`, `"hybrid-ed25519+ml-dsa-44"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum NotaryAlgorithm { + /// Ed25519 (RFC 8032) — the v0.1 shipped algorithm. + #[serde(rename = "ed25519")] + Ed25519, + /// ML-DSA-44 (NIST FIPS 204). Tag reserved; no implementation ships in + /// v0.1 (ADR-267 §3 honest label). + #[serde(rename = "ml-dsa-44")] + MlDsa44, + /// Concurrent ed25519 **and** ML-DSA-44 signing — the deliberately + /// hybrid-first migration path of ADR-267 §3, where a root stays verifiable + /// by old and new verifiers alike and no historical data is re-signed. + #[serde(rename = "hybrid-ed25519+ml-dsa-44")] + HybridEd25519MlDsa44, +} + +impl NotaryAlgorithm { + /// The canonical wire string for this algorithm (ADR-267 §3). + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + NotaryAlgorithm::Ed25519 => "ed25519", + NotaryAlgorithm::MlDsa44 => "ml-dsa-44", + NotaryAlgorithm::HybridEd25519MlDsa44 => "hybrid-ed25519+ml-dsa-44", + } + } +} + +impl std::fmt::Display for NotaryAlgorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A signed commitment to one batch of observations — the small, data-free +/// artifact that leaves the biome and travels to the federation (ADR-267 §2, +/// §4). +/// +/// It carries no raw observations: exactly what ADR-264 §6 permits to cross a +/// biome boundary. Everything in it is covered by the signature except the +/// signature fields themselves (see [`canonical_root_bytes`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotaryRoot { + /// Wire spec version (`rucelium_core::SPEC_VERSION`). + pub spec_version: String, + /// Owning biome. + pub biome_id: String, + /// Monotonic batch number within this notary, starting at 0. + pub batch_id: u64, + /// Hex-encoded Merkle root of the batch (the empty-batch sentinel when + /// `leaf_count == 0`). + pub root_hex: String, + /// Number of leaves committed to by `root_hex`. + pub leaf_count: usize, + /// Start of the observation window this batch covers, ns since Unix epoch. + pub window_start_ns: u64, + /// End of the observation window this batch covers, ns since Unix epoch. + pub window_end_ns: u64, + /// When the batch was sealed and signed, ns since Unix epoch. The gap + /// between an observation's reception and this instant is the batching + /// latency ADR-267 §4 requires evidence to state honestly. + pub notarized_ns: u64, + /// Root hash of the previous batch, hex-encoded, or `None` for the first + /// batch. + /// + /// Two jobs: it **chains** consecutive batches into an append-only history + /// (a gap or a rewrite is detectable), and it is the hook for ADR-267 §3 + /// **re-notarization** — an old root becomes a leaf of a new, possibly + /// PQ-signed tree, carrying history forward without re-signing a single + /// observation. + pub prev_root_hex: Option, + /// Which algorithm signed this root — self-describing, and covered by the + /// signature so it cannot be downgraded after the fact. + pub algorithm: NotaryAlgorithm, + /// Hex-encoded signature over [`canonical_root_bytes`], if signed. + pub signature_hex: Option, + /// Hex-encoded public key of the signer, if signed. + pub signer_pubkey_hex: Option, +} + +/// Produces a signature over a root's canonical bytes. The indirection *is* the +/// shipped feature of ADR-267 §3: adding ML-DSA means adding an implementation +/// of this trait, not changing the notary or the wire format. +pub trait RootSigner { + /// Which algorithm this signer implements; written into the root's + /// `algorithm` tag by [`sign_root`]. + fn algorithm(&self) -> NotaryAlgorithm; + /// Hex-encoded public key, recorded in the root so a verifier can bind the + /// signature to a key it trusts. + fn public_hex(&self) -> String; + /// Sign canonical root bytes, returning a hex-encoded signature. + fn sign(&self, canonical: &[u8]) -> String; +} + +/// Checks a signature over a root's canonical bytes. The counterpart of +/// [`RootSigner`]; a 2040 auditor holds only this half (ADR-267 §3). +pub trait RootVerifier { + /// Which algorithm this verifier understands. [`verify_root`] refuses to + /// check a root that declares a different one. + fn algorithm(&self) -> NotaryAlgorithm; + /// Verify `sig_hex` over `canonical` under `pubkey_hex`. Malformed hex, + /// wrong lengths and bad keys all return `false` — a verifier reading + /// decades-old archived bytes must never panic. + fn verify(&self, canonical: &[u8], sig_hex: &str, pubkey_hex: &str) -> bool; +} + +/// Deterministic ed25519 root signer derived from a 32-byte seed — the v0.1 +/// implementation of [`RootSigner`] (ADR-267 §3, shipped item 3). +/// +/// Mirrors `rufield-provenance::Signer`: same seed ⇒ same key ⇒ same +/// signatures. No RNG is used anywhere. +pub struct Ed25519RootSigner { + key: SigningKey, +} + +impl Ed25519RootSigner { + /// Construct from a fixed 32-byte seed. + #[must_use] + pub fn from_seed(seed: &[u8; 32]) -> Self { + Ed25519RootSigner { + key: SigningKey::from_bytes(seed), + } + } + + /// Hex-encoded ed25519 public key of this signer. + #[must_use] + pub fn public_hex(&self) -> String { + hex_encode(self.key.verifying_key().as_bytes()) + } +} + +impl RootSigner for Ed25519RootSigner { + fn algorithm(&self) -> NotaryAlgorithm { + NotaryAlgorithm::Ed25519 + } + + fn public_hex(&self) -> String { + Ed25519RootSigner::public_hex(self) + } + + fn sign(&self, canonical: &[u8]) -> String { + let sig: Signature = self.key.sign(canonical); + hex_encode(&sig.to_bytes()) + } +} + +/// Stateless ed25519 [`RootVerifier`] — holds no key material; the trusted key +/// is supplied per verification (ADR-267 §3, shipped item 3). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Ed25519RootVerifier; + +impl Ed25519RootVerifier { + /// Construct the verifier. + #[must_use] + pub fn new() -> Self { + Ed25519RootVerifier + } +} + +impl RootVerifier for Ed25519RootVerifier { + fn algorithm(&self) -> NotaryAlgorithm { + NotaryAlgorithm::Ed25519 + } + + fn verify(&self, canonical: &[u8], sig_hex: &str, pubkey_hex: &str) -> bool { + let Some(pk_arr) = hex_decode(pubkey_hex).and_then(|b| <[u8; 32]>::try_from(b).ok()) else { + return false; + }; + let Ok(vk) = VerifyingKey::from_bytes(&pk_arr) else { + return false; + }; + let Some(sig_arr) = + hex_decode(sig_hex).and_then(|b| <[u8; ED25519_SIGNATURE_BYTES]>::try_from(b).ok()) + else { + return false; + }; + vk.verify(canonical, &Signature::from_bytes(&sig_arr)) + .is_ok() + } +} + +/// The exact bytes a root signature covers: the root serialized as JSON with +/// `signature_hex` and `signer_pubkey_hex` cleared. +/// +/// Every content field — including `algorithm`, `leaf_count`, the window, and +/// `prev_root_hex` — is therefore signed, but the signature never covers +/// itself. Same house rule as `rufield-provenance` and +/// `rucelium-calibration::authority`. +#[must_use] +pub fn canonical_root_bytes(root: &NotaryRoot) -> Vec { + let mut r = root.clone(); + r.signature_hex = None; + r.signer_pubkey_hex = None; + canonical_json(&r) +} + +/// Sign a root in place (ADR-267 §3). +/// +/// Clears any existing signature, stamps `algorithm` from the signer — the +/// signer is authoritative for the tag, so a root can never advertise an +/// algorithm other than the one that actually signed it — then fills in +/// `signature_hex` and `signer_pubkey_hex`. +pub fn sign_root(root: &mut NotaryRoot, signer: &dyn RootSigner) { + root.signature_hex = None; + root.signer_pubkey_hex = None; + root.algorithm = signer.algorithm(); + let canonical = canonical_root_bytes(root); + root.signature_hex = Some(signer.sign(&canonical)); + root.signer_pubkey_hex = Some(signer.public_hex()); +} + +/// Verify a root's signature (ADR-267 §3). +/// +/// Returns `false` unless **all** of: +/// +/// - the root declares the same [`NotaryAlgorithm`] the verifier implements — a +/// root must never be checked under an algorithm other than the one it +/// claims, or a future ML-DSA root could be silently "verified" by a +/// downgraded ed25519 path; +/// - both `signature_hex` and `signer_pubkey_hex` are present; +/// - the signature verifies over [`canonical_root_bytes`]. +/// +/// Binding the signature to a *trusted* key is a separate decision, made by +/// [`crate::verify_bundle`]. +#[must_use] +pub fn verify_root(root: &NotaryRoot, verifier: &dyn RootVerifier) -> bool { + if root.algorithm != verifier.algorithm() { + return false; + } + let (Some(sig), Some(pk)) = (root.signature_hex.as_ref(), root.signer_pubkey_hex.as_ref()) + else { + return false; + }; + verifier.verify(&canonical_root_bytes(root), sig, pk) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A named single-field tamper applied to a signed root. + type Mutation = (&'static str, fn(&mut NotaryRoot)); + + pub(crate) const SEED: &[u8; 32] = b"rucelium-notary-test-seed-32byte"; + pub(crate) const OTHER_SEED: &[u8; 32] = b"rucelium-notary-other-seed-32byt"; + + /// A stub verifier that declares ML-DSA-44 and accepts everything. It + /// exists solely to prove that [`verify_root`]'s algorithm check fires + /// *before* any signature math: if the check were missing, this verifier + /// would happily "verify" an ed25519 root. + struct AlwaysOkMlDsaVerifier; + impl RootVerifier for AlwaysOkMlDsaVerifier { + fn algorithm(&self) -> NotaryAlgorithm { + NotaryAlgorithm::MlDsa44 + } + fn verify(&self, _canonical: &[u8], _sig_hex: &str, _pubkey_hex: &str) -> bool { + true + } + } + + pub(crate) fn root() -> NotaryRoot { + NotaryRoot { + spec_version: rucelium_core::SPEC_VERSION.into(), + biome_id: "biome/thames-estuary".into(), + batch_id: 7, + root_hex: crate::hex_encode(&crate::leaf_hash(b"batch")), + leaf_count: 4096, + window_start_ns: 1_000, + window_end_ns: 2_000, + notarized_ns: 2_100, + prev_root_hex: Some(crate::hex_encode(&crate::leaf_hash(b"prev"))), + algorithm: NotaryAlgorithm::Ed25519, + signature_hex: None, + signer_pubkey_hex: None, + } + } + + #[test] + fn algorithm_wire_names_match_the_adr() { + assert_eq!(NotaryAlgorithm::Ed25519.as_str(), "ed25519"); + assert_eq!(NotaryAlgorithm::MlDsa44.as_str(), "ml-dsa-44"); + assert_eq!( + NotaryAlgorithm::HybridEd25519MlDsa44.as_str(), + "hybrid-ed25519+ml-dsa-44" + ); + for a in [ + NotaryAlgorithm::Ed25519, + NotaryAlgorithm::MlDsa44, + NotaryAlgorithm::HybridEd25519MlDsa44, + ] { + let json = serde_json::to_string(&a).unwrap(); + assert_eq!(json, format!("\"{}\"", a.as_str())); + assert_eq!(serde_json::from_str::(&json).unwrap(), a); + assert_eq!(a.to_string(), a.as_str()); + } + } + + #[test] + fn sign_verify_round_trip_and_serde() { + let signer = Ed25519RootSigner::from_seed(SEED); + let mut r = root(); + sign_root(&mut r, &signer); + assert_eq!(r.algorithm, NotaryAlgorithm::Ed25519); + assert_eq!(r.signer_pubkey_hex.as_deref(), Some(&*signer.public_hex())); + assert_eq!( + r.signature_hex.as_ref().map(String::len), + Some(ED25519_SIGNATURE_BYTES * 2) + ); + assert!(verify_root(&r, &Ed25519RootVerifier::new())); + + let json = serde_json::to_string(&r).unwrap(); + let back: NotaryRoot = serde_json::from_str(&json).unwrap(); + assert_eq!(r, back); + assert!(verify_root(&back, &Ed25519RootVerifier)); + } + + #[test] + fn signing_is_deterministic() { + let mut a = root(); + let mut b = root(); + sign_root(&mut a, &Ed25519RootSigner::from_seed(SEED)); + sign_root(&mut b, &Ed25519RootSigner::from_seed(SEED)); + assert_eq!(a, b); + // Re-signing clears the old fields first, so it is idempotent. + sign_root(&mut a, &Ed25519RootSigner::from_seed(SEED)); + assert_eq!(a, b); + } + + #[test] + fn every_content_field_is_covered_by_the_signature() { + let signer = Ed25519RootSigner::from_seed(SEED); + let v = Ed25519RootVerifier::new(); + let signed = { + let mut r = root(); + sign_root(&mut r, &signer); + r + }; + assert!(verify_root(&signed, &v)); + + let mutations: Vec = vec![ + ("root_hex", |r| r.root_hex = crate::hex_encode(&[9u8; 32])), + ("leaf_count", |r| r.leaf_count += 1), + ("biome_id", |r| r.biome_id = "biome/elsewhere".into()), + ("window_start_ns", |r| r.window_start_ns += 1), + ("window_end_ns", |r| r.window_end_ns += 1), + ("notarized_ns", |r| r.notarized_ns += 1), + ("batch_id", |r| r.batch_id += 1), + ("prev_root_hex", |r| r.prev_root_hex = None), + ("spec_version", |r| r.spec_version = "bogus.v9".into()), + ("signature_hex", |r| { + r.signature_hex = Some(crate::hex_encode(&[0u8; ED25519_SIGNATURE_BYTES])); + }), + ("signer_pubkey_hex", |r| { + r.signer_pubkey_hex = Some(crate::hex_encode(&[0u8; 32])); + }), + ]; + for (name, mutate) in mutations { + let mut tampered = signed.clone(); + mutate(&mut tampered); + assert!( + !verify_root(&tampered, &v), + "{name} mutation still verified" + ); + } + + // The algorithm tag is signed too: flipping it fails on the algorithm + // check *and* would fail on the bytes. + let mut tampered = signed; + tampered.algorithm = NotaryAlgorithm::MlDsa44; + assert!(!verify_root(&tampered, &v)); + } + + #[test] + fn verify_rejects_an_algorithm_mismatch_before_checking_bytes() { + let mut r = root(); + sign_root(&mut r, &Ed25519RootSigner::from_seed(SEED)); + // The stub verifier accepts any bytes, so only the algorithm check can + // reject this ed25519 root. + assert!(!verify_root(&r, &AlwaysOkMlDsaVerifier)); + // Same root, matching verifier: accepted. + assert!(verify_root(&r, &Ed25519RootVerifier)); + } + + #[test] + fn unsigned_or_half_signed_roots_do_not_verify() { + let v = Ed25519RootVerifier::new(); + assert!(!verify_root(&root(), &v)); // no signature at all + + let mut r = root(); + sign_root(&mut r, &Ed25519RootSigner::from_seed(SEED)); + let mut half = r.clone(); + half.signature_hex = None; + assert!(!verify_root(&half, &v)); + let mut half = r; + half.signer_pubkey_hex = None; + assert!(!verify_root(&half, &v)); + } + + #[test] + fn malformed_encodings_return_false_and_never_panic() { + let v = Ed25519RootVerifier::new(); + let mut r = root(); + sign_root(&mut r, &Ed25519RootSigner::from_seed(SEED)); + + for bad_sig in ["", "zz", "abc", &crate::hex_encode(&[0u8; 10])] { + let mut t = r.clone(); + t.signature_hex = Some(bad_sig.to_string()); + assert!(!verify_root(&t, &v)); + } + for bad_pk in ["", "zz", "00ff", &crate::hex_encode(&[0xffu8; 32])] { + let mut t = r.clone(); + t.signer_pubkey_hex = Some(bad_pk.to_string()); + assert!(!verify_root(&t, &v)); + } + } + + #[test] + fn a_different_key_does_not_verify() { + let mut r = root(); + sign_root(&mut r, &Ed25519RootSigner::from_seed(SEED)); + let other = Ed25519RootSigner::from_seed(OTHER_SEED); + assert_ne!(other.public_hex(), r.signer_pubkey_hex.clone().unwrap()); + let mut swapped = r; + swapped.signer_pubkey_hex = Some(other.public_hex()); + assert!(!verify_root(&swapped, &Ed25519RootVerifier::new())); + } + + #[test] + fn canonical_bytes_exclude_the_signature_fields() { + let signer = Ed25519RootSigner::from_seed(SEED); + let unsigned = root(); + let before = canonical_root_bytes(&unsigned); + let mut signed = unsigned; + sign_root(&mut signed, &signer); + assert_eq!(before, canonical_root_bytes(&signed)); + let text = String::from_utf8(before).unwrap(); + assert!(text.contains("\"algorithm\":\"ed25519\"")); + assert!(text.contains("\"signature_hex\":null")); + } +} diff --git a/crates/rucelium-notary/src/tree.rs b/crates/rucelium-notary/src/tree.rs new file mode 100644 index 0000000..9560e7a --- /dev/null +++ b/crates/rucelium-notary/src/tree.rs @@ -0,0 +1,547 @@ +//! Domain-separated binary Merkle tree over `sha256`, with inclusion proofs +//! and a stateless verifier (ADR-267 §3, shipped item 1). +//! +//! # Domain separation is a second-preimage defence +//! +//! Leaves are hashed as `sha256(0x00 || data)` and interior nodes as +//! `sha256(0x01 || left || right)`. The one-byte prefix is not decoration: it +//! is what stops a proof from being **re-interpreted at another depth**. +//! +//! Without it, an interior node's preimage is exactly 64 bytes of hash +//! material, and a leaf whose data happens to be those same 64 bytes hashes to +//! the *same* digest. An attacker who is allowed to choose leaf content could +//! therefore submit a 64-byte "observation" that is secretly an interior node, +//! and later present a shortened proof in which their leaf stands in for a +//! whole subtree — proving membership of data the notary never accepted. With +//! the prefixes, a leaf digest and a node digest are drawn from disjoint +//! preimage spaces, so a leaf can never be replayed as an interior node and a +//! proof only ever verifies at the exact depth it was issued for. +//! +//! # Odd levels: promotion, not duplication +//! +//! When a level has an odd number of nodes, this tree **promotes the last node +//! unchanged** to the next level. It does *not* duplicate it. +//! +//! Implications, both deliberate: +//! +//! - The tree is not perfectly balanced, so inclusion paths for different +//! leaves in the same batch may have different lengths (a promoted node +//! contributes no sibling at that level). [`verify_inclusion`] reconstructs +//! the exact same level geometry from `leaf_count` alone, so the shape is +//! never taken on trust from the proof. +//! - Promotion avoids the duplication ambiguity that bit Bitcoin's Merkle +//! construction (CVE-2012-2459), where duplicating the odd tail makes an +//! `n`-leaf tree and a specific `n+1`-leaf tree collide on the same root. +//! With promotion, the leaf multiset and its order determine the root +//! uniquely for a fixed `leaf_count`. +//! +//! # Empty batches +//! +//! A batch with zero leaves has no natural root, and a notary running on a +//! quiet gateway must still be able to seal an interval. The root of an empty +//! tree is therefore the fixed sentinel `sha256(0x02 || "rucelium.notary.empty")` +//! — a third domain, so the empty root can never equal any leaf or interior +//! digest. [`verify_inclusion`] always rejects proofs against it: nothing is +//! ever included in an empty batch. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Domain prefix byte for leaf hashing (ADR-267 §3: leaf = `0x00`). +pub const LEAF_DOMAIN: u8 = 0x00; + +/// Domain prefix byte for interior-node hashing (ADR-267 §3: interior = `0x01`). +pub const NODE_DOMAIN: u8 = 0x01; + +/// Domain prefix byte for the empty-tree sentinel — a third domain, disjoint +/// from both leaves and interior nodes. +pub const EMPTY_DOMAIN: u8 = 0x02; + +/// Label hashed under [`EMPTY_DOMAIN`] to form the empty-batch sentinel root. +pub const EMPTY_TREE_LABEL: &[u8] = b"rucelium.notary.empty"; + +/// Hash a leaf: `sha256(0x00 || data)`. +/// +/// The `0x00` prefix separates the leaf domain from the interior domain so a +/// proof cannot be re-interpreted at another depth (see the module docs and +/// ADR-267 §3). +#[must_use] +pub fn leaf_hash(data: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update([LEAF_DOMAIN]); + h.update(data); + h.finalize().into() +} + +/// Hash an interior node: `sha256(0x01 || left || right)`. +/// +/// The `0x01` prefix guarantees this digest can never coincide with the digest +/// of a 64-byte leaf (ADR-267 §3 second-preimage defence). +#[must_use] +pub fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update([NODE_DOMAIN]); + h.update(left); + h.update(right); + h.finalize().into() +} + +/// The sentinel root of a zero-leaf batch: `sha256(0x02 || "rucelium.notary.empty")`. +/// +/// A notary is allowed to seal an interval in which nothing was accepted; the +/// sealed root is still signed, chained and federated, it simply commits to the +/// empty set. Nothing verifies as included in it. +#[must_use] +pub fn empty_root() -> [u8; 32] { + let mut h = Sha256::new(); + h.update([EMPTY_DOMAIN]); + h.update(EMPTY_TREE_LABEL); + h.finalize().into() +} + +/// A Merkle inclusion path: everything a third party needs, together with the +/// leaf and the signed root, to prove membership without gateway access +/// (ADR-267 §3, shipped item 1). +/// +/// `siblings` is ordered bottom-up. Each entry pairs the sibling digest with a +/// flag saying whether that sibling sits on the **right** of the running hash +/// (`true`) or the left (`false`). Levels at which this leaf's ancestor was +/// promoted (odd tail) contribute no entry at all. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InclusionProof { + /// Zero-based index of the proven leaf within the batch. + pub leaf_index: usize, + /// Total number of leaves in the batch — fixes the tree geometry, so a + /// verifier never infers the shape from the proof itself. + pub leaf_count: usize, + /// Bottom-up sibling path: `(digest, sibling_is_right)`. + pub siblings: Vec<([u8; 32], bool)>, +} + +/// A deterministic, append-only binary Merkle tree over pre-hashed leaves. +/// +/// Construction is a pure function of the leaf vector: same leaves in the same +/// order ⇒ byte-identical root, on any machine, forever (ADR-267 §3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MerkleTree { + /// `levels[0]` is the leaf level; each subsequent level is the parent + /// level; the last level holds the single root (empty for a zero-leaf + /// tree). + levels: Vec>, +} + +impl MerkleTree { + /// Build a tree from already-hashed leaves (use [`leaf_hash`] to produce + /// them from canonical bytes). + /// + /// Deterministic. Odd levels promote their last node unchanged; see the + /// module docs for why duplication was rejected. + #[must_use] + pub fn build(leaves: Vec<[u8; 32]>) -> MerkleTree { + if leaves.is_empty() { + return MerkleTree { levels: Vec::new() }; + } + let mut levels: Vec> = vec![leaves]; + loop { + let top = levels.len() - 1; + if levels[top].len() <= 1 { + break; + } + let next = { + let current = &levels[top]; + let mut next = Vec::with_capacity(current.len().div_ceil(2)); + let mut i = 0; + while i + 1 < current.len() { + next.push(node_hash(¤t[i], ¤t[i + 1])); + i += 2; + } + if i < current.len() { + // Odd tail: promote unchanged. + next.push(current[i]); + } + next + }; + levels.push(next); + } + MerkleTree { levels } + } + + /// The Merkle root, or the documented [`empty_root`] sentinel when the tree + /// has no leaves. Never panics. + #[must_use] + pub fn root(&self) -> [u8; 32] { + self.levels + .last() + .and_then(|top| top.first().copied()) + .unwrap_or_else(empty_root) + } + + /// Number of leaves in the batch. + #[must_use] + pub fn len(&self) -> usize { + self.levels.first().map_or(0, Vec::len) + } + + /// Whether the batch contains no leaves. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The leaf digests, in insertion order. + #[must_use] + pub fn leaves(&self) -> &[[u8; 32]] { + self.levels.first().map_or(&[], Vec::as_slice) + } + + /// Index of the first leaf equal to `leaf`, if the batch contains it. + /// + /// Used by `SealedBatch::bundle_for` to locate an observation's leaf; a + /// duplicate observation (same canonical bytes) resolves to its first + /// occurrence, which is sufficient because either occurrence proves the + /// same membership fact. + #[must_use] + pub fn index_of(&self, leaf: &[u8; 32]) -> Option { + self.leaves().iter().position(|l| l == leaf) + } + + /// Produce an inclusion proof for `index`, or `None` if the index is out of + /// range (which includes every index of an empty tree). + #[must_use] + pub fn prove(&self, index: usize) -> Option { + if index >= self.len() { + return None; + } + let mut siblings = Vec::new(); + let mut idx = index; + for level in &self.levels { + if level.len() <= 1 { + break; + } + let is_promoted_tail = idx == level.len() - 1 && !level.len().is_multiple_of(2); + if !is_promoted_tail { + if idx.is_multiple_of(2) { + siblings.push((level[idx + 1], true)); + } else { + siblings.push((level[idx - 1], false)); + } + } + idx /= 2; + } + Some(InclusionProof { + leaf_index: index, + leaf_count: self.len(), + siblings, + }) + } +} + +/// **Stateless** inclusion verification — the function a third party runs in +/// 2040 with no access to the gateway, no tree, and no network (ADR-267 §3, +/// shipped item 1). +/// +/// Returns `true` only if *all* of the following hold: +/// +/// - `leaf_count > 0` and `leaf_index < leaf_count` (a self-inconsistent proof +/// is rejected before any hashing); +/// - the sibling path has **exactly** the length the declared `leaf_count` +/// requires — a truncated path (claiming a shallower tree) or an extended one +/// (extra siblings) is rejected; +/// - every `sibling_is_right` flag agrees with the side implied by the position +/// at that level, so a flipped flag cannot re-order a hash; +/// - the recomputed root equals `root`. +/// +/// The tree geometry is derived from `leaf_count` alone, never from the length +/// of the supplied path, so an attacker cannot choose a shape that makes their +/// path fit. +/// +/// **Scope of `leaf_count` here:** this function checks that the declared count +/// is *self-consistent* with the path, not that it is the true size of the +/// notarized batch — a forger who supplies their own siblings can always name +/// some count with the same path shape. Binding the count to reality is the +/// signed root's job: [`crate::NotaryRoot::leaf_count`] is covered by the root +/// signature, and [`crate::verify_bundle`] requires the proof's count to equal +/// it. +#[must_use] +pub fn verify_inclusion(leaf: &[u8; 32], proof: &InclusionProof, root: &[u8; 32]) -> bool { + if proof.leaf_count == 0 || proof.leaf_index >= proof.leaf_count { + return false; + } + let mut acc = *leaf; + let mut idx = proof.leaf_index; + let mut size = proof.leaf_count; + let mut consumed = 0usize; + while size > 1 { + let is_promoted_tail = idx == size - 1 && !size.is_multiple_of(2); + if !is_promoted_tail { + let Some(&(sibling, sibling_is_right)) = proof.siblings.get(consumed) else { + return false; // path truncated for this tree size + }; + if sibling_is_right != idx.is_multiple_of(2) { + return false; // flag disagrees with the position + } + acc = if sibling_is_right { + node_hash(&acc, &sibling) + } else { + node_hash(&sibling, &acc) + }; + consumed += 1; + } + idx /= 2; + size = size.div_ceil(2); + } + consumed == proof.siblings.len() && acc == *root +} + +#[cfg(test)] +mod tests { + use super::*; + + fn leaves(n: usize) -> Vec<[u8; 32]> { + (0..n) + .map(|i| leaf_hash(format!("observation-{i}").as_bytes())) + .collect() + } + + #[test] + fn hashes_are_real_sha256_and_domain_separated() { + // sha256(0x00) — the leaf hash of the empty byte string. + assert_eq!( + crate::hex_encode(&leaf_hash(b"")), + "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d" + ); + // A 64-byte leaf must not collide with the interior node over the same + // 64 bytes: this is the whole point of the prefixes. + let l = leaf_hash(b"left"); + let r = leaf_hash(b"right"); + let mut concat = Vec::with_capacity(64); + concat.extend_from_slice(&l); + concat.extend_from_slice(&r); + assert_eq!(concat.len(), 64); + assert_ne!(leaf_hash(&concat), node_hash(&l, &r)); + // And the empty sentinel lives in a third domain. + assert_ne!(empty_root(), leaf_hash(EMPTY_TREE_LABEL)); + assert_ne!(empty_root(), node_hash(&l, &r)); + assert_eq!(empty_root(), empty_root()); + } + + #[test] + fn empty_tree_uses_the_sentinel_and_proves_nothing() { + let t = MerkleTree::build(Vec::new()); + assert!(t.is_empty()); + assert_eq!(t.len(), 0); + assert_eq!(t.root(), empty_root()); + assert!(t.prove(0).is_none()); + // A hand-forged proof against the sentinel root is rejected. + let forged = InclusionProof { + leaf_index: 0, + leaf_count: 0, + siblings: Vec::new(), + }; + assert!(!verify_inclusion(&leaf_hash(b"x"), &forged, &empty_root())); + } + + #[test] + fn single_leaf_tree_is_its_own_root() { + let l = leaf_hash(b"only"); + let t = MerkleTree::build(vec![l]); + assert_eq!(t.len(), 1); + assert_eq!(t.root(), l); + let p = t.prove(0).unwrap(); + assert!(p.siblings.is_empty()); + assert!(verify_inclusion(&l, &p, &t.root())); + assert!(t.prove(1).is_none()); + } + + #[test] + fn build_prove_verify_across_sizes() { + for n in [1usize, 2, 3, 5, 8, 1000] { + let ls = leaves(n); + let t = MerkleTree::build(ls.clone()); + assert_eq!(t.len(), n); + let root = t.root(); + for (i, leaf) in ls.iter().enumerate() { + let p = t.prove(i).expect("index in range"); + assert_eq!(p.leaf_index, i); + assert_eq!(p.leaf_count, n); + assert!( + verify_inclusion(leaf, &p, &root), + "size {n} index {i} failed" + ); + } + assert!(t.prove(n).is_none()); + } + } + + #[test] + fn promotion_rule_is_what_verification_implements() { + // Three leaves: level0 = [a,b,c]; level1 = [H(a,b), c] (c promoted, + // NOT duplicated); root = H(H(a,b), c). + let ls = leaves(3); + let t = MerkleTree::build(ls.clone()); + let expected = node_hash(&node_hash(&ls[0], &ls[1]), &ls[2]); + assert_eq!(t.root(), expected); + // The duplication variant would give a different root — assert we did + // not implement it. + let duplicated = node_hash(&node_hash(&ls[0], &ls[1]), &node_hash(&ls[2], &ls[2])); + assert_ne!(t.root(), duplicated); + // The promoted leaf's path is one hash shorter than its siblings'. + assert_eq!(t.prove(2).unwrap().siblings.len(), 1); + assert_eq!(t.prove(0).unwrap().siblings.len(), 2); + // Five leaves exercise promotion at two levels. + let ls = leaves(5); + let t = MerkleTree::build(ls.clone()); + let l01 = node_hash(&ls[0], &ls[1]); + let l23 = node_hash(&ls[2], &ls[3]); + let expected = node_hash(&node_hash(&l01, &l23), &ls[4]); + assert_eq!(t.root(), expected); + assert!(verify_inclusion(&ls[4], &t.prove(4).unwrap(), &t.root())); + } + + #[test] + fn root_is_deterministic_and_order_sensitive() { + let ls = leaves(9); + let a = MerkleTree::build(ls.clone()); + let b = MerkleTree::build(ls.clone()); + assert_eq!(a.root(), b.root()); + assert_eq!(a, b); + + let mut swapped = ls.clone(); + swapped.swap(0, 1); + assert_ne!(MerkleTree::build(swapped).root(), a.root()); + + // A different leaf count is a different commitment. + let mut shorter = ls; + shorter.pop(); + assert_ne!(MerkleTree::build(shorter).root(), a.root()); + } + + #[test] + fn verify_rejects_tampered_leaf_sibling_and_root() { + let ls = leaves(8); + let t = MerkleTree::build(ls.clone()); + let root = t.root(); + let p = t.prove(3).unwrap(); + assert!(verify_inclusion(&ls[3], &p, &root)); + + // Tampered leaf. + assert!(!verify_inclusion(&leaf_hash(b"forged"), &p, &root)); + + // Tampered sibling. + let mut bad = p.clone(); + bad.siblings[0].0[0] ^= 0x01; + assert!(!verify_inclusion(&ls[3], &bad, &root)); + + // Flipped side flag. + let mut bad = p.clone(); + bad.siblings[0].1 = !bad.siblings[0].1; + assert!(!verify_inclusion(&ls[3], &bad, &root)); + + // Wrong root. + let mut wrong_root = root; + wrong_root[31] ^= 0xff; + assert!(!verify_inclusion(&ls[3], &p, &wrong_root)); + } + + #[test] + fn verify_rejects_inconsistent_index_and_count() { + let ls = leaves(8); + let t = MerkleTree::build(ls.clone()); + let root = t.root(); + let p = t.prove(3).unwrap(); + + // Wrong leaf_index (in range, but not this leaf's position). + let mut bad = p.clone(); + bad.leaf_index = 2; + assert!(!verify_inclusion(&ls[3], &bad, &root)); + + // leaf_index out of range for the declared count. + let mut bad = p.clone(); + bad.leaf_index = 8; + assert!(!verify_inclusion(&ls[3], &bad, &root)); + + // A leaf_count that changes the geometry is rejected: the path no + // longer has the right length for the claimed tree. + for count in [4usize, 9, 12, 0] { + let mut bad = p.clone(); + bad.leaf_count = count; + assert!( + !verify_inclusion(&ls[3], &bad, &root), + "leaf_count {count} still verified" + ); + } + // Documented limit: a count in the same shape class (7 vs 8 at index 3) + // recomputes the same path, so verify_inclusion alone cannot reject it. + // The count is bound to reality by the *signed* root, which is why + // verify_bundle cross-checks proof.leaf_count against root.leaf_count. + let mut same_shape = p; + same_shape.leaf_count = 7; + assert!(verify_inclusion(&ls[3], &same_shape, &root)); + } + + #[test] + fn verify_rejects_truncated_or_extended_paths() { + let ls = leaves(8); + let t = MerkleTree::build(ls.clone()); + let root = t.root(); + let p = t.prove(5).unwrap(); + assert_eq!(p.siblings.len(), 3); + + let mut truncated = p.clone(); + truncated.siblings.pop(); + assert!(!verify_inclusion(&ls[5], &truncated, &root)); + + let mut extended = p.clone(); + extended.siblings.push(([0u8; 32], true)); + assert!(!verify_inclusion(&ls[5], &extended, &root)); + + let empty_path = InclusionProof { + leaf_index: 5, + leaf_count: 8, + siblings: Vec::new(), + }; + assert!(!verify_inclusion(&ls[5], &empty_path, &root)); + } + + #[test] + fn a_leaf_cannot_be_replayed_as_an_interior_node() { + // The depth-confusion attack the domain prefixes exist to stop: an + // attacker submits the concatenation of two real leaves as their own + // "observation" and then presents a proof one level short. + let ls = leaves(4); + let t = MerkleTree::build(ls.clone()); + let root = t.root(); + let mut concat = Vec::with_capacity(64); + concat.extend_from_slice(&ls[0]); + concat.extend_from_slice(&ls[1]); + let malicious_leaf = leaf_hash(&concat); + // It is not the interior node, so no proof of the shallow shape works. + assert_ne!(malicious_leaf, node_hash(&ls[0], &ls[1])); + let shallow = InclusionProof { + leaf_index: 0, + leaf_count: 2, + siblings: vec![(node_hash(&ls[2], &ls[3]), true)], + }; + assert!(!verify_inclusion(&malicious_leaf, &shallow, &root)); + } + + #[test] + fn proof_serde_round_trips() { + let t = MerkleTree::build(leaves(5)); + let p = t.prove(1).unwrap(); + let json = serde_json::to_string(&p).unwrap(); + let back: InclusionProof = serde_json::from_str(&json).unwrap(); + assert_eq!(p, back); + assert!(verify_inclusion(&t.leaves()[1], &back, &t.root())); + } + + #[test] + fn index_of_and_leaves_expose_the_batch() { + let ls = leaves(6); + let t = MerkleTree::build(ls.clone()); + assert_eq!(t.leaves(), ls.as_slice()); + assert_eq!(t.index_of(&ls[4]), Some(4)); + assert_eq!(t.index_of(&leaf_hash(b"absent")), None); + assert_eq!(MerkleTree::build(Vec::new()).leaves(), &[] as &[[u8; 32]]); + } +} diff --git a/crates/rucelium-policy/Cargo.toml b/crates/rucelium-policy/Cargo.toml new file mode 100644 index 0000000..75f643f --- /dev/null +++ b/crates/rucelium-policy/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rucelium-policy" +version.workspace = true +edition.workspace = true +description = "RuCelium governed control path: agent proposal -> policy -> safety simulation -> authority -> signed command -> gateway validation -> execution receipt, typed so no stage can be skipped (ADR-264 §9)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "governance", "agents", "policy"] +categories = ["science"] + +[dependencies] +rucelium-core = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-policy/src/audit.rs b/crates/rucelium-policy/src/audit.rs new file mode 100644 index 0000000..8cea61f --- /dev/null +++ b/crates/rucelium-policy/src/audit.rs @@ -0,0 +1,73 @@ +//! Append-only audit trail for the governed control path (ADR-264 §9). +//! +//! Every stage of the pipeline appends an [`AuditEntry`] — acceptances **and** +//! rejections — so a completed happy path leaves exactly seven entries: +//! `"proposed"`, `"policy_evaluated"`, `"safety_simulated"`, `"authorized"`, +//! `"signed"`, `"gateway_validated"`, `"executed"`. +//! +//! Gateway failure outcomes are recorded too: a replayed command id leaves a +//! `"gateway_validated"` entry with verdict `"duplicate_rejected: …"`, and a +//! failed execution closure leaves an `"executed"` entry with verdict +//! `"execution_failed: …"`. + +use serde::Serialize; + +/// One audit record: which stage saw which proposal, when, with what verdict. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuditEntry { + /// Stage name (one of the seven fixed stage strings). + pub stage: &'static str, + /// Proposal this entry concerns. + pub proposal_id: String, + /// When the stage ran, ns since Unix epoch (caller-supplied — no clocks). + pub at_ns: u64, + /// Human-readable verdict, e.g. `"accepted"` or `"rejected: …"`. + pub verdict: String, +} + +/// Append-only trail threaded through every stage of the pipeline. Only this +/// crate's stage implementations can append (the recording method is +/// `pub(crate)`); callers get read-only access. +#[derive(Debug, Default)] +pub struct AuditTrail { + entries: Vec, +} + +impl AuditTrail { + /// New, empty trail. + #[must_use] + pub fn new() -> Self { + AuditTrail::default() + } + + /// Append an entry. Crate-private: only pipeline stages write the trail. + pub(crate) fn record( + &mut self, + stage: &'static str, + proposal_id: &str, + at_ns: u64, + verdict: impl Into, + ) { + self.entries.push(AuditEntry { + stage, + proposal_id: proposal_id.to_string(), + at_ns, + verdict: verdict.into(), + }); + } + + /// All entries, in append order. + #[must_use] + pub fn entries(&self) -> &[AuditEntry] { + &self.entries + } + + /// Entries for one proposal, in append order. + #[must_use] + pub fn for_proposal(&self, proposal_id: &str) -> Vec<&AuditEntry> { + self.entries + .iter() + .filter(|e| e.proposal_id == proposal_id) + .collect() + } +} diff --git a/crates/rucelium-policy/src/lib.rs b/crates/rucelium-policy/src/lib.rs new file mode 100644 index 0000000..098dbb9 --- /dev/null +++ b/crates/rucelium-policy/src/lib.rs @@ -0,0 +1,721 @@ +//! # rucelium-policy +//! +//! The **governed control path** of the RuCelium fabric (ADR-264 §9). +//! Agents propose; they can **never** execute. The only path from an agent's +//! idea to a physical effect is: +//! +//! ```text +//! AgentProposal +//! → deterministic policy evaluation (PolicyEngine) +//! → safety simulation (SafetySimulator) +//! → authority check (AuthorityRegistry) +//! → signed command (CommandSigner) +//! → gateway validation (GatewayValidator) +//! → local execution (caller-supplied closure) +//! → execution receipt (ExecutionReceipt) +//! ``` +//! +//! ## Enforced by construction — skipping a stage is a compile error +//! +//! Each stage's output type ([`EvaluatedProposal`], [`SimulatedProposal`], +//! [`AuthorizedProposal`]) has **private fields and no public constructor**, +//! and is the only accepted input to the next stage. The compiler is the +//! enforcement mechanism: there is no sequence of safe Rust outside this +//! crate that produces an [`AuthorizedProposal`] without passing policy and +//! safety first. The single exception is [`SignedCommand`], which crosses the +//! network to the gateway — at that hop the gate is **cryptography, not type +//! privacy**: a forged or tampered `SignedCommand` fails +//! [`GatewayValidator`]'s ed25519 verification against its trusted keys. +//! +//! For example, feeding a raw [`AgentProposal`] straight to the gateway does +//! not compile: +//! +//! ```compile_fail +//! use rucelium_policy::{AgentProposal, AuditTrail, GatewayValidator, ProposalKind}; +//! +//! let mut gateway = GatewayValidator::new(vec![], &[7u8; 32]); +//! let mut audit = AuditTrail::new(); +//! let proposal = AgentProposal { +//! proposal_id: "p-1".into(), +//! agent_id: "agent-1".into(), +//! biome_id: "biome-1".into(), +//! kind: ProposalKind::SetSamplingRate { node_id: 1, interval_s: 60 }, +//! justification: "denser sampling during storm".into(), +//! proposed_ns: 0, +//! }; +//! // ERROR: expected `&SignedCommand`, found `&AgentProposal`. +//! let _ = gateway.validate_and_execute(&proposal, 0, |_| Ok(String::new()), &mut audit); +//! ``` +//! +//! Likewise `AuthorityRegistry::authorize` only accepts a +//! [`SimulatedProposal`] (so authority cannot be checked before safety), and +//! `CommandSigner::sign` only accepts an [`AuthorizedProposal`] (so nothing +//! unauthorized can be signed). +//! +//! ## Determinism +//! +//! No clocks, no RNG: callers pass `now_ns` everywhere, and both command +//! signing and the gateway's receipt-signing identity are deterministic +//! ed25519 (RFC 8032) from fixed 32-byte seeds. Identical runs produce +//! identical signatures, receipts, and receipt hashes. +//! +//! ## Budgets: checked at safety, charged at execution +//! +//! [`SafetySimulator::simulate`] only **checks** the per-actuator command +//! budget — it never consumes it. The budget is charged only when a command +//! actually executes: the orchestrator calls +//! [`SafetySimulator::record_execution`] after the gateway confirms +//! execution. This means an unauthorized (or otherwise failing) proposal can +//! be replayed forever without draining another actuator's budget. As +//! defence in depth the gateway *also* enforces its own executed-command cap +//! per actuator ([`GatewayValidator::with_max_commands_per_actuator`]), +//! counted from commands it actually executed. +//! +//! ## Restart posture (§9 pipeline): journal + fail-closed `Executing` +//! +//! Execution at the gateway is **two-phase**. After all validation checks +//! pass, the command id is recorded as [`CommandPhase::Executing`] *before* +//! the execution closure runs; on success it becomes +//! [`CommandPhase::Executed`] (and a signed receipt is issued), on failure +//! [`CommandPhase::Failed`] (and [`ControlError::ExecutionFailed`] is +//! returned). A daemon that owns the gateway journals this table to disk via +//! [`GatewayValidator::export_phases`] and reloads it on restart via +//! [`GatewayValidator::restore_phases`]. Crash recovery is **fail-closed**: +//! a command id found in *any* phase — including an `Executing` entry left +//! behind by a crash mid-execution, whose physical effect is unknown — is +//! rejected as [`ControlError::DuplicateCommand`] and never re-executed. +//! Likewise a command replayed against a `Failed` entry is rejected; +//! retrying after failure deliberately requires a **new command id** (and +//! hence a fresh trip through the whole governed path). +//! +//! ## Receipts are attestations +//! +//! An [`ExecutionReceipt`] is signed by the gateway's own deterministic +//! ed25519 identity (seeded via [`GatewayValidator::new`]): it carries +//! `gateway_pubkey_hex` and `signature_hex` over the canonical receipt bytes +//! in addition to the receipt hash. Anyone can check it offline with +//! [`verify_receipt`]. +//! +//! ## Audit +//! +//! Every stage — acceptances and rejections alike — appends to an +//! [`AuditTrail`]. A completed happy path leaves exactly seven entries, in +//! order: `"proposed"`, `"policy_evaluated"`, `"safety_simulated"`, +//! `"authorized"`, `"signed"`, `"gateway_validated"`, `"executed"`. Gateway +//! failure outcomes are recorded too: a replayed command id leaves a +//! `"gateway_validated"` entry whose verdict starts with +//! `"duplicate_rejected:"`, and a failed execution closure leaves an +//! `"executed"` entry whose verdict starts with `"execution_failed:"`. + +#![doc(html_root_url = "https://docs.rs/rucelium-policy/0.1.0")] + +pub mod audit; +pub mod pipeline; +pub mod proposal; + +pub use audit::{AuditEntry, AuditTrail}; +pub use pipeline::{ + verify_receipt, AuthorityRegistry, AuthorizedProposal, CommandPayload, CommandPhase, + CommandSigner, EvaluatedProposal, ExecutionReceipt, GatewayValidator, PolicyConfig, + PolicyEngine, ProposalKindView, SafetyConfig, SafetySimulator, SignedCommand, + SimulatedProposal, +}; +pub use proposal::{AgentProposal, ProposalKind}; + +/// Everything that can stop a proposal on its way to execution. +#[derive(Debug, Clone, PartialEq)] +pub enum ControlError { + /// Rejected by deterministic policy evaluation (stage 1). + PolicyViolation(String), + /// Rejected by the safety simulation (stage 2) — possibly even though + /// policy allowed it; the gates are distinct. + Unsafe(String), + /// No `(biome, agent, actuator)` authority grant exists (stage 3). + NotAuthorized { + /// Biome the proposal targeted. + biome_id: String, + /// Agent that proposed. + agent_id: String, + /// Actuator it tried to command. + actuator_id: String, + }, + /// The command's signer key is not in the gateway's trusted set. + UntrustedKey(String), + /// The ed25519 signature did not verify over the canonical bytes. + BadSignature, + /// The command reached the gateway after its expiry. + Expired { + /// Command expiry, ns since Unix epoch. + expires_ns: u64, + /// Gateway's `now_ns` at validation time. + now_ns: u64, + }, + /// The command id is already known to the gateway in **any** phase + /// (`Executing`, `Executed`, or `Failed`) — replay protection, fail + /// closed. An `Executing` entry restored from a journal after a crash is + /// deliberately *not* retried, and a command that failed must be + /// re-issued under a new command id. + DuplicateCommand(String), + /// Gateway validation passed but the local execution closure reported + /// failure. The command id is recorded as `Failed` and can never be + /// replayed — recovery requires a new command id. + ExecutionFailed(String), + /// Malformed hex / key / signature material. + BadEncoding(String), +} + +impl std::fmt::Display for ControlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ControlError::PolicyViolation(m) => write!(f, "policy violation: {m}"), + ControlError::Unsafe(m) => write!(f, "unsafe: {m}"), + ControlError::NotAuthorized { + biome_id, + agent_id, + actuator_id, + } => write!( + f, + "not authorized: agent {agent_id} has no grant for actuator {actuator_id} in biome {biome_id}" + ), + ControlError::UntrustedKey(k) => write!(f, "untrusted signer key: {k}"), + ControlError::BadSignature => write!(f, "signature verification failed"), + ControlError::Expired { + expires_ns, + now_ns, + } => write!(f, "command expired: expires_ns={expires_ns}, now_ns={now_ns}"), + ControlError::DuplicateCommand(id) => { + write!(f, "duplicate command {id}: already in a recorded phase") + } + ControlError::ExecutionFailed(m) => write!(f, "execution failed: {m}"), + ControlError::BadEncoding(m) => write!(f, "bad encoding: {m}"), + } + } +} + +impl std::error::Error for ControlError {} + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_core::GeoPoint; + + const SEED: &[u8; 32] = b"rucelium-test-seed-32-bytes-ok!!"; + const OTHER_SEED: &[u8; 32] = b"rucelium-EVIL-seed-32-bytes-ok!!"; + const GATEWAY_SEED: &[u8; 32] = b"rucelium-gate-seed-32-bytes-ok!!"; + + fn actuator_proposal_with_id(proposal_id: &str, magnitude: f64) -> AgentProposal { + AgentProposal { + proposal_id: proposal_id.into(), + agent_id: "agent/flood".into(), + biome_id: "biome/thames-estuary".into(), + kind: ProposalKind::ActuatorCommand { + actuator_id: "sluice-7".into(), + action: "open".into(), + magnitude, + }, + justification: "water level rising across 3 nodes".into(), + proposed_ns: 1_000, + } + } + + fn actuator_proposal(magnitude: f64) -> AgentProposal { + actuator_proposal_with_id("p-1", magnitude) + } + + fn permissive_policy() -> PolicyEngine { + let mut config = PolicyConfig::default(); + config.allowed_actuators.insert("sluice-7".into()); + PolicyEngine::new(config) + } + + /// Run the whole pipeline from fresh state; returns the receipt and trail. + fn run_happy_path() -> (ExecutionReceipt, AuditTrail) { + let mut audit = AuditTrail::new(); + let engine = permissive_policy(); + let mut sim = SafetySimulator::new(SafetyConfig::default()); + let mut registry = AuthorityRegistry::new(); + registry.grant("biome/thames-estuary", "agent/flood", "sluice-7"); + let signer = CommandSigner::from_seed(SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + + let evaluated = engine + .evaluate(actuator_proposal(0.5), 2_000, &mut audit) + .unwrap(); + let simulated = sim.simulate(evaluated, 3_000, &mut audit).unwrap(); + let authorized = registry.authorize(simulated, 4_000, &mut audit).unwrap(); + let cmd = signer.sign(authorized, 5_000, 60_000_000_000, &mut audit); + let receipt = gateway + .validate_and_execute( + &cmd, + 6_000, + |kind| Ok(format!("executed {kind:?}")), + &mut audit, + ) + .unwrap(); + // The budget is charged only now, on confirmed execution. + sim.record_execution("sluice-7"); + (receipt, audit) + } + + #[test] + fn full_happy_path_yields_receipt_and_seven_audit_stages() { + let (receipt, audit) = run_happy_path(); + assert_eq!(receipt.command_id, "cmd-p-1"); + assert_eq!(receipt.executed_ns, 6_000); + assert!(receipt.gateway_receipt_hash.starts_with("sha256:")); + assert!(!receipt.gateway_pubkey_hex.is_empty()); + assert!(verify_receipt(&receipt)); + + let stages: Vec<&str> = audit.entries().iter().map(|e| e.stage).collect(); + assert_eq!( + stages, + [ + "proposed", + "policy_evaluated", + "safety_simulated", + "authorized", + "signed", + "gateway_validated", + "executed", + ] + ); + assert_eq!(audit.for_proposal("p-1").len(), 7); + assert!(audit.for_proposal("nope").is_empty()); + } + + #[test] + fn receipt_hash_is_deterministic_across_identical_runs() { + let (a, _) = run_happy_path(); + let (b, _) = run_happy_path(); + assert_eq!(a, b); + assert_eq!(a.gateway_receipt_hash, b.gateway_receipt_hash); + } + + #[test] + fn sampling_interval_below_min_is_policy_violation_and_audited() { + let mut audit = AuditTrail::new(); + let engine = PolicyEngine::default(); + let proposal = AgentProposal { + proposal_id: "p-2".into(), + agent_id: "agent/dq".into(), + biome_id: "biome/x".into(), + kind: ProposalKind::SetSamplingRate { + node_id: 9, + interval_s: 5, + }, + justification: "denser sampling".into(), + proposed_ns: 0, + }; + let err = engine.evaluate(proposal, 10, &mut audit).unwrap_err(); + assert!(matches!(err, ControlError::PolicyViolation(_))); + let last = audit.entries().last().unwrap(); + assert_eq!(last.stage, "policy_evaluated"); + assert!(last.verdict.starts_with("rejected:"), "{}", last.verdict); + } + + #[test] + fn actuator_not_in_allowed_set_is_policy_violation() { + let mut audit = AuditTrail::new(); + // Default policy: allowed_actuators is empty. + let engine = PolicyEngine::default(); + let err = engine + .evaluate(actuator_proposal(0.1), 10, &mut audit) + .unwrap_err(); + assert!(matches!(err, ControlError::PolicyViolation(_))); + } + + #[test] + fn policy_and_safety_are_distinct_gates() { + let mut audit = AuditTrail::new(); + let engine = permissive_policy(); + let mut sim = SafetySimulator::new(SafetyConfig::default()); + // Magnitude 0.9: within the policy maximum (1.0)… + let evaluated = engine + .evaluate(actuator_proposal(0.9), 10, &mut audit) + .unwrap(); + // …but beyond the safety envelope (0.8). + let err = sim.simulate(evaluated, 20, &mut audit).unwrap_err(); + assert!(matches!(err, ControlError::Unsafe(_))); + let last = audit.entries().last().unwrap(); + assert_eq!(last.stage, "safety_simulated"); + assert!(last.verdict.starts_with("rejected:")); + } + + #[test] + fn actuator_command_budget_binds_only_on_recorded_executions() { + let mut audit = AuditTrail::new(); + let engine = permissive_policy(); + let mut sim = SafetySimulator::new(SafetyConfig { + safe_magnitude: 0.8, + max_commands_per_actuator: 2, + }); + // Simulation alone never consumes the budget, no matter how often. + for _ in 0..20 { + let evaluated = engine + .evaluate(actuator_proposal(0.1), 10, &mut audit) + .unwrap(); + sim.simulate(evaluated, 20, &mut audit).unwrap(); + } + // Two confirmed executions exhaust the budget of 2… + sim.record_execution("sluice-7"); + sim.record_execution("sluice-7"); + let evaluated = engine + .evaluate(actuator_proposal(0.1), 10, &mut audit) + .unwrap(); + // …and only then does the check bind. + let err = sim.simulate(evaluated, 20, &mut audit).unwrap_err(); + assert!(matches!(err, ControlError::Unsafe(_))); + } + + #[test] + fn unauthorized_replays_do_not_consume_safety_budget() { + let mut audit = AuditTrail::new(); + let engine = permissive_policy(); + let mut sim = SafetySimulator::new(SafetyConfig { + safe_magnitude: 0.8, + max_commands_per_actuator: 2, + }); + let mut registry = AuthorityRegistry::new(); // rogue agent: no grant + for _ in 0..10 { + let evaluated = engine + .evaluate(actuator_proposal(0.1), 10, &mut audit) + .unwrap(); + let simulated = sim.simulate(evaluated, 20, &mut audit).unwrap(); + let err = registry.authorize(simulated, 30, &mut audit).unwrap_err(); + assert!(matches!(err, ControlError::NotAuthorized { .. })); + } + // A legitimately authorized proposal afterwards still executes: the + // rogue replays consumed none of sluice-7's budget. + registry.grant("biome/thames-estuary", "agent/flood", "sluice-7"); + let signer = CommandSigner::from_seed(SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + let evaluated = engine + .evaluate(actuator_proposal(0.1), 10, &mut audit) + .unwrap(); + let simulated = sim.simulate(evaluated, 20, &mut audit).unwrap(); + let authorized = registry.authorize(simulated, 30, &mut audit).unwrap(); + let cmd = signer.sign(authorized, 40, 1_000_000, &mut audit); + let receipt = gateway + .validate_and_execute(&cmd, 50, |_| Ok("opened".into()), &mut audit) + .unwrap(); + sim.record_execution("sluice-7"); + assert_eq!(receipt.outcome, "opened"); + } + + #[test] + fn missing_grant_is_not_authorized() { + let mut audit = AuditTrail::new(); + let engine = permissive_policy(); + let mut sim = SafetySimulator::default(); + let registry = AuthorityRegistry::new(); // no grants + let evaluated = engine + .evaluate(actuator_proposal(0.5), 10, &mut audit) + .unwrap(); + let simulated = sim.simulate(evaluated, 20, &mut audit).unwrap(); + let err = registry.authorize(simulated, 30, &mut audit).unwrap_err(); + assert_eq!( + err, + ControlError::NotAuthorized { + biome_id: "biome/thames-estuary".into(), + agent_id: "agent/flood".into(), + actuator_id: "sluice-7".into(), + } + ); + } + + #[test] + fn non_actuator_kinds_are_auto_authorized() { + let mut audit = AuditTrail::new(); + let engine = PolicyEngine::default(); + let mut sim = SafetySimulator::default(); + let registry = AuthorityRegistry::new(); // no grants needed + let proposal = AgentProposal { + proposal_id: "p-3".into(), + agent_id: "agent/deploy".into(), + biome_id: "biome/x".into(), + kind: ProposalKind::RepositionSensor { + node_id: 4, + to: GeoPoint::new(514_000_000, 500_000, 0).unwrap(), + }, + justification: "shade moved".into(), + proposed_ns: 0, + }; + let evaluated = engine.evaluate(proposal, 10, &mut audit).unwrap(); + let simulated = sim.simulate(evaluated, 20, &mut audit).unwrap(); + assert!(registry.authorize(simulated, 30, &mut audit).is_ok()); + } + + #[test] + fn invalid_reposition_geo_is_policy_violation() { + let mut audit = AuditTrail::new(); + let engine = PolicyEngine::default(); + let proposal = AgentProposal { + proposal_id: "p-4".into(), + agent_id: "agent/deploy".into(), + biome_id: "biome/x".into(), + kind: ProposalKind::RepositionSensor { + node_id: 4, + to: GeoPoint { + latitude_e7: 900_000_001, // out of range + longitude_e7: 0, + altitude_mm: 0, + }, + }, + justification: "move north".into(), + proposed_ns: 0, + }; + let err = engine.evaluate(proposal, 10, &mut audit).unwrap_err(); + assert!(matches!(err, ControlError::PolicyViolation(_))); + } + + /// Sign a valid command through the full front half of the pipeline. + fn signed_command_with_id( + signer: &CommandSigner, + proposal_id: &str, + ttl_ns: u64, + audit: &mut AuditTrail, + ) -> SignedCommand { + let engine = permissive_policy(); + let mut sim = SafetySimulator::default(); + let mut registry = AuthorityRegistry::new(); + registry.grant("biome/thames-estuary", "agent/flood", "sluice-7"); + let evaluated = engine + .evaluate(actuator_proposal_with_id(proposal_id, 0.5), 10, audit) + .unwrap(); + let simulated = sim.simulate(evaluated, 20, audit).unwrap(); + let authorized = registry.authorize(simulated, 30, audit).unwrap(); + signer.sign(authorized, 40, ttl_ns, audit) + } + + fn signed_command( + signer: &CommandSigner, + ttl_ns: u64, + audit: &mut AuditTrail, + ) -> SignedCommand { + signed_command_with_id(signer, "p-1", ttl_ns, audit) + } + + #[test] + fn expired_command_is_rejected() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + let cmd = signed_command(&signer, 1_000, &mut audit); // expires_ns = 1_040 + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + let err = gateway + .validate_and_execute(&cmd, 1_040, |_| Ok(String::new()), &mut audit) + .unwrap_err(); + assert_eq!( + err, + ControlError::Expired { + expires_ns: 1_040, + now_ns: 1_040, + } + ); + } + + #[test] + fn replaying_a_command_is_duplicate() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + let cmd = signed_command(&signer, 1_000_000, &mut audit); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + gateway + .validate_and_execute(&cmd, 50, |_| Ok("ok".into()), &mut audit) + .unwrap(); + let err = gateway + .validate_and_execute(&cmd, 60, |_| Ok("ok".into()), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::DuplicateCommand("cmd-p-1".into())); + // The replay is audited as a duplicate rejection at the gateway. + let last = audit.entries().last().unwrap(); + assert_eq!(last.stage, "gateway_validated"); + assert!( + last.verdict.starts_with("duplicate_rejected:"), + "{}", + last.verdict + ); + } + + #[test] + fn tampered_payload_fails_signature() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + let cmd = signed_command(&signer, 1_000_000, &mut audit); + // Serde round-trip mutation: bump the magnitude after signing. + let mut v: serde_json::Value = serde_json::to_value(&cmd).unwrap(); + v["payload"]["kind"]["ActuatorCommand"]["magnitude"] = serde_json::json!(0.95); + let forged: SignedCommand = serde_json::from_value(v).unwrap(); + assert_ne!(forged, cmd); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + let err = gateway + .validate_and_execute(&forged, 50, |_| Ok(String::new()), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::BadSignature); + } + + #[test] + fn untrusted_key_is_rejected() { + let mut audit = AuditTrail::new(); + let rogue = CommandSigner::from_seed(OTHER_SEED); + let cmd = signed_command(&rogue, 1_000_000, &mut audit); + // Gateway trusts only the legitimate key. + let trusted = CommandSigner::from_seed(SEED); + let mut gateway = GatewayValidator::new(vec![trusted.public_hex()], GATEWAY_SEED); + let err = gateway + .validate_and_execute(&cmd, 50, |_| Ok(String::new()), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::UntrustedKey(rogue.public_hex())); + } + + #[test] + fn signed_command_survives_serde_round_trip() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + let cmd = signed_command(&signer, 1_000_000, &mut audit); + let json = serde_json::to_string(&cmd).unwrap(); + let back: SignedCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(back, cmd); + // …and still validates after the round trip. + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + assert!(gateway + .validate_and_execute(&back, 50, |_| Ok("ok".into()), &mut audit) + .is_ok()); + } + + #[test] + fn gateway_executed_command_cap_binds_on_real_executions() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED) + .with_max_commands_per_actuator(2); + for id in ["p-a", "p-b"] { + let cmd = signed_command_with_id(&signer, id, 1_000_000, &mut audit); + gateway + .validate_and_execute(&cmd, 50, |_| Ok("ok".into()), &mut audit) + .unwrap(); + } + let cmd = signed_command_with_id(&signer, "p-c", 1_000_000, &mut audit); + let err = gateway + .validate_and_execute(&cmd, 50, |_| Ok("ok".into()), &mut audit) + .unwrap_err(); + assert!(matches!(err, ControlError::Unsafe(_)), "{err}"); + } + + #[test] + fn gateway_cap_is_not_consumed_by_failed_executions() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED) + .with_max_commands_per_actuator(1); + let failing = signed_command_with_id(&signer, "p-fail", 1_000_000, &mut audit); + let err = gateway + .validate_and_execute(&failing, 50, |_| Err("valve jammed".into()), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::ExecutionFailed("valve jammed".into())); + // The failure did not consume the executed-command cap of 1. + let cmd = signed_command_with_id(&signer, "p-good", 1_000_000, &mut audit); + assert!(gateway + .validate_and_execute(&cmd, 60, |_| Ok("ok".into()), &mut audit) + .is_ok()); + } + + #[test] + fn failed_execution_is_audited_and_never_retryable_under_same_id() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + let cmd = signed_command(&signer, 1_000_000, &mut audit); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + let err = gateway + .validate_and_execute(&cmd, 50, |_| Err("valve jammed".into()), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::ExecutionFailed("valve jammed".into())); + let last = audit.entries().last().unwrap(); + assert_eq!(last.stage, "executed"); + assert_eq!(last.verdict, "execution_failed: valve jammed"); + // The command id is burned: replaying it — even with a now-working + // closure — is a duplicate. Recovery needs a new command id. + let err = gateway + .validate_and_execute(&cmd, 60, |_| Ok("ok".into()), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::DuplicateCommand("cmd-p-1".into())); + assert_eq!( + gateway.export_phases(), + vec![("cmd-p-1".to_string(), "failed".to_string())] + ); + } + + #[test] + fn restored_executing_phase_is_fail_closed_and_unknown_phases_skipped() { + let mut audit = AuditTrail::new(); + let signer = CommandSigner::from_seed(SEED); + + // First gateway life: one success, one failure, then "crash". + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + let done = signed_command_with_id(&signer, "p-done", 1_000_000, &mut audit); + gateway + .validate_and_execute(&done, 50, |_| Ok("ok".into()), &mut audit) + .unwrap(); + let broke = signed_command_with_id(&signer, "p-broke", 1_000_000, &mut audit); + let _ = gateway.validate_and_execute(&broke, 50, |_| Err("boom".into()), &mut audit); + let mut journal = gateway.export_phases(); + assert_eq!( + journal, + vec![ + ("cmd-p-broke".to_string(), "failed".to_string()), + ("cmd-p-done".to_string(), "executed".to_string()), + ] + ); + // Simulate a crash mid-execution of a third command: the daemon's + // journal holds an Executing entry, plus a corrupt line. + journal.push(("cmd-p-crashed".to_string(), "executing".to_string())); + journal.push(("cmd-p-corrupt".to_string(), "banana".to_string())); + + // Restarted gateway restores the journal. + let mut restarted = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + restarted.restore_phases(journal); + + // Fail closed: every journaled phase — including Executing — rejects. + for id in ["p-done", "p-broke", "p-crashed"] { + let cmd = signed_command_with_id(&signer, id, 1_000_000, &mut audit); + let err = restarted + .validate_and_execute(&cmd, 60, |_| Ok("ok".into()), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::DuplicateCommand(format!("cmd-{id}"))); + } + // The unknown phase string was skipped, so that id still executes. + let cmd = signed_command_with_id(&signer, "p-corrupt", 1_000_000, &mut audit); + assert!(restarted + .validate_and_execute(&cmd, 60, |_| Ok("ok".into()), &mut audit) + .is_ok()); + } + + #[test] + fn receipt_attestation_verifies_and_tampering_breaks_it() { + let (receipt, _) = run_happy_path(); + assert_eq!(receipt.gateway_pubkey_hex.len(), 64); + assert_eq!(receipt.signature_hex.len(), 128); + assert!(verify_receipt(&receipt)); + + let mut tampered = receipt.clone(); + tampered.outcome = "did something else entirely".into(); + assert!(!verify_receipt(&tampered)); + + let mut tampered = receipt.clone(); + tampered.command_id = "cmd-p-666".into(); + assert!(!verify_receipt(&tampered)); + + let mut tampered = receipt.clone(); + tampered.executed_ns += 1; + assert!(!verify_receipt(&tampered)); + + let mut tampered = receipt.clone(); + tampered.signature_hex = "not-hex".into(); + assert!(!verify_receipt(&tampered)); + + // A different gateway identity cannot pass off the same receipt body. + let mut tampered = receipt; + tampered.gateway_pubkey_hex = + GatewayValidator::new(vec![], OTHER_SEED).gateway_pubkey_hex(); + assert!(!verify_receipt(&tampered)); + } +} diff --git a/crates/rucelium-policy/src/pipeline.rs b/crates/rucelium-policy/src/pipeline.rs new file mode 100644 index 0000000..6e93d52 --- /dev/null +++ b/crates/rucelium-policy/src/pipeline.rs @@ -0,0 +1,964 @@ +//! The governed control path itself (ADR-264 §9), stage by stage: +//! +//! ```text +//! AgentProposal → PolicyEngine::evaluate → EvaluatedProposal +//! → SafetySimulator::simulate → SimulatedProposal +//! → AuthorityRegistry::authorize → AuthorizedProposal +//! → CommandSigner::sign → SignedCommand +//! → GatewayValidator::validate_and_execute → ExecutionReceipt +//! ``` +//! +//! [`EvaluatedProposal`], [`SimulatedProposal`], and [`AuthorizedProposal`] +//! have private fields and **no public constructor** — the only way to obtain +//! one is to pass the previous gate, so skipping a stage is a compile error. +//! [`SignedCommand`] crosses the network, so at that hop the gate is +//! cryptography (an ed25519 signature by a key the gateway trusts), not type +//! privacy. + +use crate::audit::AuditTrail; +use crate::proposal::{AgentProposal, ProposalKind}; +use crate::ControlError; +use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; + +// --------------------------------------------------------------------------- +// hex / hash helpers (house style, matching rufield-provenance) +// --------------------------------------------------------------------------- + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +fn hex_decode(s: &str) -> Result, ControlError> { + if !s.len().is_multiple_of(2) { + return Err(ControlError::BadEncoding("odd hex length".into())); + } + (0..s.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&s[i..i + 2], 16) + .map_err(|e| ControlError::BadEncoding(e.to_string())) + }) + .collect() +} + +/// `sha256:` digest over arbitrary bytes. +fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + let mut s = String::from("sha256:"); + for b in h.finalize() { + s.push_str(&format!("{b:02x}")); + } + s +} + +// --------------------------------------------------------------------------- +// Stage 1 — deterministic policy evaluation +// --------------------------------------------------------------------------- + +/// Deterministic policy rules for [`PolicyEngine`]. +#[derive(Debug, Clone, PartialEq)] +pub struct PolicyConfig { + /// Minimum allowed sampling interval, seconds (default 10). + pub min_sampling_interval_s: u32, + /// Maximum allowed sampling interval, seconds (default 86 400 = 1 day). + pub max_sampling_interval_s: u32, + /// Maximum absolute actuator magnitude policy will ever accept + /// (default 1.0). The safety envelope may be tighter — that is a + /// separate gate. + pub max_actuator_magnitude: f64, + /// Actuators agents may target at all. Empty by default: no actuator + /// command passes policy until the biome owner lists its actuators. + pub allowed_actuators: BTreeSet, +} + +impl Default for PolicyConfig { + fn default() -> Self { + PolicyConfig { + min_sampling_interval_s: 10, + max_sampling_interval_s: 86_400, + max_actuator_magnitude: 1.0, + allowed_actuators: BTreeSet::new(), + } + } +} + +/// Stage 1: deterministic policy evaluation. The only entry point into the +/// governed control path — it is the sole producer of [`EvaluatedProposal`]. +#[derive(Debug, Default, Clone)] +pub struct PolicyEngine { + config: PolicyConfig, +} + +/// Witness that a proposal passed deterministic policy evaluation. +/// Private fields, no public constructor: the only producer is +/// [`PolicyEngine::evaluate`], and the only consumer is +/// [`SafetySimulator::simulate`]. +#[derive(Debug, Clone, PartialEq)] +pub struct EvaluatedProposal { + proposal: AgentProposal, + evaluated_ns: u64, +} + +impl EvaluatedProposal { + /// The underlying proposal (read-only). + #[must_use] + pub fn proposal(&self) -> &AgentProposal { + &self.proposal + } + + /// When policy evaluation ran, ns since Unix epoch. + #[must_use] + pub fn evaluated_ns(&self) -> u64 { + self.evaluated_ns + } +} + +impl PolicyEngine { + /// Engine with the given rules. + #[must_use] + pub fn new(config: PolicyConfig) -> Self { + PolicyEngine { config } + } + + /// Evaluate a raw agent proposal against deterministic policy rules. + /// + /// Records the `"proposed"` audit entry on entry and a + /// `"policy_evaluated"` entry with the verdict (accepted or rejected). + pub fn evaluate( + &self, + proposal: AgentProposal, + now_ns: u64, + audit: &mut AuditTrail, + ) -> Result { + audit.record( + "proposed", + &proposal.proposal_id, + now_ns, + format!( + "agent {} proposes in biome {}", + proposal.agent_id, proposal.biome_id + ), + ); + match self.check(&proposal) { + Ok(()) => { + audit.record( + "policy_evaluated", + &proposal.proposal_id, + now_ns, + "accepted", + ); + Ok(EvaluatedProposal { + proposal, + evaluated_ns: now_ns, + }) + } + Err(e) => { + audit.record( + "policy_evaluated", + &proposal.proposal_id, + now_ns, + format!("rejected: {e}"), + ); + Err(e) + } + } + } + + fn check(&self, p: &AgentProposal) -> Result<(), ControlError> { + for (name, value) in [ + ("proposal_id", &p.proposal_id), + ("agent_id", &p.agent_id), + ("biome_id", &p.biome_id), + ("justification", &p.justification), + ] { + if value.is_empty() { + return Err(ControlError::PolicyViolation(format!("empty {name}"))); + } + } + match &p.kind { + ProposalKind::SetSamplingRate { interval_s, .. } => { + if *interval_s < self.config.min_sampling_interval_s + || *interval_s > self.config.max_sampling_interval_s + { + return Err(ControlError::PolicyViolation(format!( + "sampling interval {interval_s}s outside [{}, {}]s", + self.config.min_sampling_interval_s, self.config.max_sampling_interval_s + ))); + } + } + ProposalKind::DeployModel { + model_id, + target_gateway, + } => { + if model_id.is_empty() { + return Err(ControlError::PolicyViolation("empty model_id".into())); + } + if target_gateway.is_empty() { + return Err(ControlError::PolicyViolation("empty target_gateway".into())); + } + } + ProposalKind::RepositionSensor { to, .. } => { + to.validate().map_err(|e| { + ControlError::PolicyViolation(format!("invalid target geo: {e}")) + })?; + } + ProposalKind::ActuatorCommand { + actuator_id, + magnitude, + .. + } => { + if !self.config.allowed_actuators.contains(actuator_id) { + return Err(ControlError::PolicyViolation(format!( + "actuator {actuator_id} is not in the allowed set" + ))); + } + if !magnitude.is_finite() || magnitude.abs() > self.config.max_actuator_magnitude { + return Err(ControlError::PolicyViolation(format!( + "actuator magnitude {magnitude} exceeds policy maximum {}", + self.config.max_actuator_magnitude + ))); + } + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Stage 2 — safety simulation +// --------------------------------------------------------------------------- + +/// Safety envelope for [`SafetySimulator`]. Deliberately tighter than policy: +/// something policy allows can still be unsafe. +#[derive(Debug, Clone, PartialEq)] +pub struct SafetyConfig { + /// Maximum absolute actuator magnitude the simulator considers safe + /// (default 0.8 — tighter than the policy default of 1.0). + pub safe_magnitude: f64, + /// Maximum number of **executed** commands per actuator (default 10) — + /// a deterministic stand-in for rate limiting. [`SafetySimulator::simulate`] + /// checks this budget; only [`SafetySimulator::record_execution`] + /// charges it. + pub max_commands_per_actuator: u32, +} + +impl Default for SafetyConfig { + fn default() -> Self { + SafetyConfig { + safe_magnitude: 0.8, + max_commands_per_actuator: 10, + } + } +} + +/// Witness that a proposal passed the safety simulation. Private fields, no +/// public constructor: produced only by [`SafetySimulator::simulate`], +/// consumed only by [`AuthorityRegistry::authorize`]. +#[derive(Debug, Clone, PartialEq)] +pub struct SimulatedProposal { + proposal: AgentProposal, + simulated_ns: u64, +} + +impl SimulatedProposal { + /// The underlying proposal (read-only). + #[must_use] + pub fn proposal(&self) -> &AgentProposal { + &self.proposal + } + + /// When the safety simulation ran, ns since Unix epoch. + #[must_use] + pub fn simulated_ns(&self) -> u64 { + self.simulated_ns + } +} + +/// Stage 2: deterministic safety simulation. +/// +/// Tracks how many commands each actuator has actually **executed** — but +/// only [`SafetySimulator::record_execution`] charges that budget. +/// [`SafetySimulator::simulate`] merely checks it, so a proposal that later +/// fails authority (or any later gate) cannot drain another actuator's +/// budget. +#[derive(Debug, Default, Clone)] +pub struct SafetySimulator { + config: SafetyConfig, + executed: BTreeMap, +} + +impl SafetySimulator { + /// Simulator with the given safety envelope. + #[must_use] + pub fn new(config: SafetyConfig) -> Self { + SafetySimulator { + config, + executed: BTreeMap::new(), + } + } + + /// Charge one executed command against `actuator_id`'s budget. + /// + /// Call this only after the gateway confirms a command actually executed + /// ([`GatewayValidator::validate_and_execute`] returned `Ok`). + /// [`SafetySimulator::simulate`] never consumes the budget itself — + /// checking is free, executing is what counts. + pub fn record_execution(&mut self, actuator_id: &str) { + *self.executed.entry(actuator_id.to_string()).or_insert(0) += 1; + } + + /// Run the safety simulation over a policy-evaluated proposal. + /// + /// An actuator magnitude beyond [`SafetyConfig::safe_magnitude`] fails + /// [`ControlError::Unsafe`] even when policy allowed it — policy and + /// safety are distinct gates. The per-actuator command budget is + /// **checked, not consumed**: only [`SafetySimulator::record_execution`] + /// charges it, so repeated simulations (e.g. by an agent that will never + /// pass authority) leave the budget untouched. Records a + /// `"safety_simulated"` audit entry either way. + pub fn simulate( + &mut self, + p: EvaluatedProposal, + now_ns: u64, + audit: &mut AuditTrail, + ) -> Result { + let proposal_id = p.proposal.proposal_id.clone(); + if let ProposalKind::ActuatorCommand { + actuator_id, + magnitude, + .. + } = &p.proposal.kind + { + if magnitude.abs() > self.config.safe_magnitude { + let e = ControlError::Unsafe(format!( + "magnitude {magnitude} exceeds safety envelope {}", + self.config.safe_magnitude + )); + audit.record( + "safety_simulated", + &proposal_id, + now_ns, + format!("rejected: {e}"), + ); + return Err(e); + } + let count = self.executed.get(actuator_id).copied().unwrap_or(0); + if count >= self.config.max_commands_per_actuator { + let e = ControlError::Unsafe(format!( + "actuator {actuator_id} command budget exhausted ({} max)", + self.config.max_commands_per_actuator + )); + audit.record( + "safety_simulated", + &proposal_id, + now_ns, + format!("rejected: {e}"), + ); + return Err(e); + } + } + audit.record( + "safety_simulated", + &proposal_id, + now_ns, + "within safety envelope", + ); + Ok(SimulatedProposal { + proposal: p.proposal, + simulated_ns: now_ns, + }) + } +} + +// --------------------------------------------------------------------------- +// Stage 3 — authority check +// --------------------------------------------------------------------------- + +/// Witness that a proposal is authorized for its biome. Private fields, no +/// public constructor: produced only by [`AuthorityRegistry::authorize`], +/// consumed only by [`CommandSigner::sign`]. +#[derive(Debug, Clone, PartialEq)] +pub struct AuthorizedProposal { + proposal: AgentProposal, + authorized_ns: u64, +} + +impl AuthorizedProposal { + /// The underlying proposal (read-only). + #[must_use] + pub fn proposal(&self) -> &AgentProposal { + &self.proposal + } + + /// When authorization was checked, ns since Unix epoch. + #[must_use] + pub fn authorized_ns(&self) -> u64 { + self.authorized_ns + } +} + +/// Stage 3: per-biome authority. Actuator authority never leaves the biome +/// owner (ADR-264 §6): an [`ProposalKind::ActuatorCommand`] requires an exact +/// `(biome, agent, actuator)` grant; all non-actuator kinds are +/// auto-authorized for the proposing biome. +#[derive(Debug, Default, Clone)] +pub struct AuthorityRegistry { + grants: BTreeSet<(String, String, String)>, +} + +impl AuthorityRegistry { + /// Empty registry: no actuator grants at all. + #[must_use] + pub fn new() -> Self { + AuthorityRegistry::default() + } + + /// Biome-owner grant: allow `agent_id` to command `actuator_id` inside + /// `biome_id`. + pub fn grant(&mut self, biome_id: &str, agent_id: &str, actuator_id: &str) { + self.grants.insert(( + biome_id.to_string(), + agent_id.to_string(), + actuator_id.to_string(), + )); + } + + /// Check authority for a safety-simulated proposal. Records an + /// `"authorized"` audit entry with the verdict. + pub fn authorize( + &self, + p: SimulatedProposal, + now_ns: u64, + audit: &mut AuditTrail, + ) -> Result { + let proposal_id = p.proposal.proposal_id.clone(); + if let ProposalKind::ActuatorCommand { actuator_id, .. } = &p.proposal.kind { + let key = ( + p.proposal.biome_id.clone(), + p.proposal.agent_id.clone(), + actuator_id.clone(), + ); + if !self.grants.contains(&key) { + let e = ControlError::NotAuthorized { + biome_id: p.proposal.biome_id.clone(), + agent_id: p.proposal.agent_id.clone(), + actuator_id: actuator_id.clone(), + }; + audit.record("authorized", &proposal_id, now_ns, format!("rejected: {e}")); + return Err(e); + } + } + audit.record("authorized", &proposal_id, now_ns, "granted"); + Ok(AuthorizedProposal { + proposal: p.proposal, + authorized_ns: now_ns, + }) + } +} + +// --------------------------------------------------------------------------- +// Stage 4 — signed command +// --------------------------------------------------------------------------- + +/// The exact material an ed25519 command signature covers, with a fixed, +/// derive-defined field order so `serde_json::to_vec` is canonical and +/// deterministic. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommandPayload { + /// Command id, `"cmd-{proposal_id}"`. + pub command_id: String, + /// Biome the command targets. + pub biome_id: String, + /// Agent that proposed it. + pub agent_id: String, + /// What to do. + pub kind: ProposalKind, + /// When the command was issued, ns since Unix epoch. + pub issued_ns: u64, + /// When the command expires, ns since Unix epoch. + pub expires_ns: u64, +} + +/// A signed, time-limited command — the only artifact a gateway will execute. +/// +/// Unlike the in-process stage witnesses, a `SignedCommand` crosses the +/// network, so it is `Serialize`/`Deserialize` with public fields. **Type +/// privacy is not the gate at this hop — cryptography is.** Anyone can +/// deserialize or hand-forge one of these, but [`GatewayValidator`] rejects +/// it unless the ed25519 signature verifies over the canonical payload bytes +/// under a key the gateway explicitly trusts. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SignedCommand { + /// The signed material (canonical bytes are `serde_json::to_vec` of this). + pub payload: CommandPayload, + /// Hex-encoded ed25519 signature over [`SignedCommand::canonical_bytes`]. + pub signature_hex: String, + /// Hex-encoded ed25519 public key of the signer. + pub signer_pubkey_hex: String, +} + +impl SignedCommand { + /// Command id (`"cmd-{proposal_id}"`). + #[must_use] + pub fn command_id(&self) -> &str { + &self.payload.command_id + } + + /// Issue time, ns since Unix epoch. + #[must_use] + pub fn issued_ns(&self) -> u64 { + self.payload.issued_ns + } + + /// Expiry time, ns since Unix epoch. + #[must_use] + pub fn expires_ns(&self) -> u64 { + self.payload.expires_ns + } + + /// Canonical JSON bytes of the payload — exactly what the signature + /// covers. Deterministic: derived `Serialize` emits fields in declaration + /// order. + #[must_use] + pub fn canonical_bytes(&self) -> Vec { + canonical_bytes(&self.payload) + } +} + +fn canonical_bytes(payload: &CommandPayload) -> Vec { + // Infallible for this struct: string keys only, and any non-finite + // magnitude was already rejected by the policy gate. + serde_json::to_vec(payload).expect("CommandPayload serialization cannot fail") +} + +/// Stage 4: deterministic ed25519 command signer, key derived from a 32-byte +/// seed (same house style as `rufield-provenance::Signer`). Same seed ⇒ same +/// key ⇒ same signatures — no RNG anywhere. +pub struct CommandSigner { + key: SigningKey, +} + +impl CommandSigner { + /// Signer from a fixed 32-byte seed. + #[must_use] + pub fn from_seed(seed: &[u8; 32]) -> Self { + CommandSigner { + key: SigningKey::from_bytes(seed), + } + } + + /// Hex-encoded public key — hand this to [`GatewayValidator::new`]. + #[must_use] + pub fn public_hex(&self) -> String { + hex_encode(self.key.verifying_key().as_bytes()) + } + + /// Sign an authorized proposal into a time-limited [`SignedCommand`] + /// (`expires_ns = issued_ns + ttl_ns`, saturating). Records a `"signed"` + /// audit entry. + #[must_use] + pub fn sign( + &self, + p: AuthorizedProposal, + issued_ns: u64, + ttl_ns: u64, + audit: &mut AuditTrail, + ) -> SignedCommand { + let expires_ns = issued_ns.saturating_add(ttl_ns); + let payload = CommandPayload { + command_id: format!("cmd-{}", p.proposal.proposal_id), + biome_id: p.proposal.biome_id.clone(), + agent_id: p.proposal.agent_id.clone(), + kind: p.proposal.kind.clone(), + issued_ns, + expires_ns, + }; + let sig: Signature = self.key.sign(&canonical_bytes(&payload)); + audit.record( + "signed", + &p.proposal.proposal_id, + issued_ns, + format!( + "command {} signed, expires_ns={expires_ns}", + payload.command_id + ), + ); + SignedCommand { + payload, + signature_hex: hex_encode(&sig.to_bytes()), + signer_pubkey_hex: self.public_hex(), + } + } +} + +// --------------------------------------------------------------------------- +// Stages 5 + 6 — gateway validation and local execution +// --------------------------------------------------------------------------- + +/// Read-only view of the command kind handed to the execution closure. +pub type ProposalKindView = ProposalKind; + +/// Lifecycle phase of a command id inside the gateway (two-phase execution). +/// +/// Recorded as `Executing` **before** the execution closure runs, then +/// promoted to `Executed` or `Failed`. A command id present in *any* phase is +/// rejected as [`ControlError::DuplicateCommand`] — fail closed. In +/// particular, an `Executing` entry restored from a journal after a crash is +/// never re-executed (its physical effect is unknown), and a `Failed` entry +/// can only be retried under a **new** command id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandPhase { + /// All checks passed; the execution closure has started (or the process + /// crashed while it was running). + Executing, + /// The execution closure returned success; a signed receipt was issued. + Executed, + /// The execution closure reported failure + /// ([`ControlError::ExecutionFailed`]). + Failed, +} + +impl CommandPhase { + /// Stable string form used by [`GatewayValidator::export_phases`] / + /// [`GatewayValidator::restore_phases`]. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + CommandPhase::Executing => "executing", + CommandPhase::Executed => "executed", + CommandPhase::Failed => "failed", + } + } + + fn parse(s: &str) -> Option { + match s { + "executing" => Some(CommandPhase::Executing), + "executed" => Some(CommandPhase::Executed), + "failed" => Some(CommandPhase::Failed), + _ => None, + } + } +} + +/// Terminal artifact of the governed control path: a signed **attestation** +/// that a command was validated and executed exactly once. +/// +/// Signed by the gateway's own deterministic ed25519 identity (see +/// [`GatewayValidator::new`]); verify offline with [`verify_receipt`]. The +/// signature covers the canonical receipt bytes: `serde_json` of the receipt +/// with `signature_hex` cleared to the empty string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionReceipt { + /// Executed command. + pub command_id: String, + /// Execution time, ns since Unix epoch (caller-supplied). + pub executed_ns: u64, + /// Outcome string returned by the local execution closure. + pub outcome: String, + /// `sha256:` over `"{command_id}|{executed_ns}|{outcome}"` — deterministic + /// for identical runs. + pub gateway_receipt_hash: String, + /// Hex-encoded ed25519 public key of the attesting gateway. + pub gateway_pubkey_hex: String, + /// Hex-encoded ed25519 signature over the canonical receipt bytes + /// (this receipt serialized with `signature_hex` set to `""`). + pub signature_hex: String, +} + +/// Canonical bytes a receipt signature covers: the receipt serialized with +/// its `signature_hex` field cleared. +fn receipt_canonical_bytes(receipt: &ExecutionReceipt) -> Vec { + let mut unsigned = receipt.clone(); + unsigned.signature_hex = String::new(); + // Infallible: string and integer fields only. + serde_json::to_vec(&unsigned).expect("ExecutionReceipt serialization cannot fail") +} + +/// Verify a receipt's gateway attestation: the ed25519 signature in +/// `signature_hex` must verify over the canonical receipt bytes under +/// `gateway_pubkey_hex`. Returns `false` on any tampering or malformed +/// key/signature material. +/// +/// Note this checks the receipt is *authentic and untampered*; whether the +/// attesting gateway key is one you trust is the caller's decision. +#[must_use] +pub fn verify_receipt(receipt: &ExecutionReceipt) -> bool { + let Ok(pk_bytes) = hex_decode(&receipt.gateway_pubkey_hex) else { + return false; + }; + let Ok(pk_arr) = <[u8; 32]>::try_from(pk_bytes) else { + return false; + }; + let Ok(vk) = VerifyingKey::from_bytes(&pk_arr) else { + return false; + }; + let Ok(sig_bytes) = hex_decode(&receipt.signature_hex) else { + return false; + }; + let Ok(sig_arr) = <[u8; 64]>::try_from(sig_bytes) else { + return false; + }; + let sig = Signature::from_bytes(&sig_arr); + vk.verify(&receipt_canonical_bytes(receipt), &sig).is_ok() +} + +/// Stages 5 and 6: gateway-side validation and two-phase local execution. +/// +/// Checks, in order: the signer key is trusted +/// ([`ControlError::UntrustedKey`]), the signature verifies over the +/// canonical bytes ([`ControlError::BadSignature`]), the command has not +/// expired ([`ControlError::Expired`]), the command id is not already known +/// in any [`CommandPhase`] ([`ControlError::DuplicateCommand`] — fail-closed +/// replay protection), and the target actuator is under its executed-command +/// cap ([`ControlError::Unsafe`]). Only then does the execution closure run, +/// at most once per command id, bracketed by phase records so a crash can +/// never leave a command silently marked complete. +/// +/// The gateway owns the disk: [`GatewayValidator::export_phases`] and +/// [`GatewayValidator::restore_phases`] let a daemon journal the phase table +/// and restore it across restarts. +#[derive(Clone)] +pub struct GatewayValidator { + trusted: BTreeSet, + phases: BTreeMap, + executed_per_actuator: BTreeMap, + max_commands_per_actuator: u32, + identity: SigningKey, +} + +impl std::fmt::Debug for GatewayValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewayValidator") + .field("trusted", &self.trusted) + .field("phases", &self.phases) + .field("executed_per_actuator", &self.executed_per_actuator) + .field("max_commands_per_actuator", &self.max_commands_per_actuator) + .field("gateway_pubkey_hex", &self.gateway_pubkey_hex()) + .finish() + } +} + +impl GatewayValidator { + /// Validator trusting the given hex-encoded ed25519 public keys, with a + /// deterministic ed25519 gateway identity derived from `gateway_seed` + /// (same seed ⇒ same identity ⇒ same receipt signatures). Every + /// [`ExecutionReceipt`] it issues is signed by this identity. + /// + /// The per-actuator executed-command cap defaults to unlimited + /// (`u32::MAX`); tighten it with + /// [`GatewayValidator::with_max_commands_per_actuator`]. + #[must_use] + pub fn new(trusted_keys: Vec, gateway_seed: &[u8; 32]) -> Self { + GatewayValidator { + trusted: trusted_keys.into_iter().collect(), + phases: BTreeMap::new(), + executed_per_actuator: BTreeMap::new(), + max_commands_per_actuator: u32::MAX, + identity: SigningKey::from_bytes(gateway_seed), + } + } + + /// Cap the number of commands this gateway will **execute** per actuator + /// (defence in depth alongside [`SafetyConfig::max_commands_per_actuator`] + /// — the gateway counts only commands it actually executed, so failed + /// validations and failed executions never consume the cap). + #[must_use] + pub fn with_max_commands_per_actuator(mut self, cap: u32) -> Self { + self.max_commands_per_actuator = cap; + self + } + + /// Hex-encoded ed25519 public key of this gateway's receipt-signing + /// identity — matches [`ExecutionReceipt::gateway_pubkey_hex`]. + #[must_use] + pub fn gateway_pubkey_hex(&self) -> String { + hex_encode(self.identity.verifying_key().as_bytes()) + } + + /// Snapshot of the command phase table as `(command_id, phase)` pairs + /// (phase strings: `"executing"`, `"executed"`, `"failed"`), in command-id + /// order. A daemon journals this to disk after every execution attempt + /// and feeds it back through [`GatewayValidator::restore_phases`] on + /// restart. + #[must_use] + pub fn export_phases(&self) -> Vec<(String, String)> { + self.phases + .iter() + .map(|(id, phase)| (id.clone(), phase.as_str().to_string())) + .collect() + } + + /// Restore a journaled phase table (see + /// [`GatewayValidator::export_phases`]). Entries with unknown phase + /// strings are skipped. Restored command ids are rejected as + /// [`ControlError::DuplicateCommand`] in **every** phase — including + /// `Executing`, the fail-closed crash-recovery posture: a command that + /// was mid-execution when the process died must not run again. + pub fn restore_phases(&mut self, phases: impl IntoIterator) { + for (command_id, phase) in phases { + if let Some(parsed) = CommandPhase::parse(&phase) { + self.phases.insert(command_id, parsed); + } + } + } + + /// Validate a signed command and, on success, run `execute` (the local + /// execution, returning outcome or failure reason) under two-phase + /// recording: + /// + /// 1. all checks pass → the command id is recorded as + /// [`CommandPhase::Executing`]; + /// 2. the closure runs; + /// 3. `Ok(outcome)` → phase [`CommandPhase::Executed`], signed + /// [`ExecutionReceipt`] returned; `Err(reason)` → phase + /// [`CommandPhase::Failed`], [`ControlError::ExecutionFailed`] + /// returned. + /// + /// Records `"gateway_validated"` (verdict pass, `"rejected: …"`, or + /// `"duplicate_rejected: …"` for replays) and, after the closure, an + /// `"executed"` entry (verdict `"outcome: …"` or `"execution_failed: …"`) + /// in the audit trail. + pub fn validate_and_execute Result>( + &mut self, + cmd: &SignedCommand, + now_ns: u64, + execute: F, + audit: &mut AuditTrail, + ) -> Result { + // Audit under the originating proposal id so the trail lines up. + let proposal_id = cmd + .payload + .command_id + .strip_prefix("cmd-") + .unwrap_or(&cmd.payload.command_id) + .to_string(); + if let Err(e) = self.check(cmd, now_ns) { + let verdict = match &e { + ControlError::DuplicateCommand(id) => format!("duplicate_rejected: {id}"), + _ => format!("rejected: {e}"), + }; + audit.record("gateway_validated", &proposal_id, now_ns, verdict); + return Err(e); + } + audit.record( + "gateway_validated", + &proposal_id, + now_ns, + "signature and freshness ok", + ); + // Phase 1: journal intent before any side effect, so a crash inside + // the closure leaves an `Executing` record — never a command silently + // marked complete without a receipt. + self.phases + .insert(cmd.payload.command_id.clone(), CommandPhase::Executing); + match execute(&cmd.payload.kind) { + Ok(outcome) => { + // Phase 2a: success. + self.phases + .insert(cmd.payload.command_id.clone(), CommandPhase::Executed); + if let ProposalKind::ActuatorCommand { actuator_id, .. } = &cmd.payload.kind { + *self + .executed_per_actuator + .entry(actuator_id.clone()) + .or_insert(0) += 1; + } + audit.record( + "executed", + &proposal_id, + now_ns, + format!("outcome: {outcome}"), + ); + Ok(self.build_receipt(cmd.payload.command_id.clone(), now_ns, outcome)) + } + Err(reason) => { + // Phase 2b: failure — recorded, audited, and never retryable + // under this command id. + self.phases + .insert(cmd.payload.command_id.clone(), CommandPhase::Failed); + audit.record( + "executed", + &proposal_id, + now_ns, + format!("execution_failed: {reason}"), + ); + Err(ControlError::ExecutionFailed(reason)) + } + } + } + + /// Build and sign the receipt for a successfully executed command. + fn build_receipt( + &self, + command_id: String, + executed_ns: u64, + outcome: String, + ) -> ExecutionReceipt { + let gateway_receipt_hash = + sha256_hex(format!("{command_id}|{executed_ns}|{outcome}").as_bytes()); + let mut receipt = ExecutionReceipt { + command_id, + executed_ns, + outcome, + gateway_receipt_hash, + gateway_pubkey_hex: self.gateway_pubkey_hex(), + signature_hex: String::new(), + }; + let sig: Signature = self.identity.sign(&receipt_canonical_bytes(&receipt)); + receipt.signature_hex = hex_encode(&sig.to_bytes()); + receipt + } + + fn check(&self, cmd: &SignedCommand, now_ns: u64) -> Result<(), ControlError> { + if !self.trusted.contains(&cmd.signer_pubkey_hex) { + return Err(ControlError::UntrustedKey(cmd.signer_pubkey_hex.clone())); + } + let pk_bytes = hex_decode(&cmd.signer_pubkey_hex)?; + let pk_arr: [u8; 32] = pk_bytes + .try_into() + .map_err(|_| ControlError::BadEncoding("pubkey not 32 bytes".into()))?; + let vk = VerifyingKey::from_bytes(&pk_arr) + .map_err(|e| ControlError::BadEncoding(e.to_string()))?; + let sig_bytes = hex_decode(&cmd.signature_hex)?; + let sig_arr: [u8; 64] = sig_bytes + .try_into() + .map_err(|_| ControlError::BadEncoding("signature not 64 bytes".into()))?; + let sig = Signature::from_bytes(&sig_arr); + vk.verify(&cmd.canonical_bytes(), &sig) + .map_err(|_| ControlError::BadSignature)?; + if now_ns >= cmd.payload.expires_ns { + return Err(ControlError::Expired { + expires_ns: cmd.payload.expires_ns, + now_ns, + }); + } + // Fail closed: a command id in ANY phase (Executing from a crashed + // run, Executed, or Failed) is never executed again. + if self.phases.contains_key(&cmd.payload.command_id) { + return Err(ControlError::DuplicateCommand( + cmd.payload.command_id.clone(), + )); + } + if let ProposalKind::ActuatorCommand { actuator_id, .. } = &cmd.payload.kind { + let executed = self + .executed_per_actuator + .get(actuator_id) + .copied() + .unwrap_or(0); + if executed >= self.max_commands_per_actuator { + return Err(ControlError::Unsafe(format!( + "actuator {actuator_id} executed-command cap exhausted at gateway ({} max)", + self.max_commands_per_actuator + ))); + } + } + Ok(()) + } +} diff --git a/crates/rucelium-policy/src/proposal.rs b/crates/rucelium-policy/src/proposal.rs new file mode 100644 index 0000000..1b8bb22 --- /dev/null +++ b/crates/rucelium-policy/src/proposal.rs @@ -0,0 +1,73 @@ +//! Agent proposal types — the **only** types agents ever construct +//! (ADR-264 §9). +//! +//! An agent's entire vocabulary is [`AgentProposal`] + [`ProposalKind`]. Every +//! other type in this crate is a stage output with private fields and no +//! public constructor, so an agent physically cannot fabricate something the +//! gateway would execute. + +use rucelium_core::GeoPoint; +use serde::{Deserialize, Serialize}; + +/// What an agent is asking the fabric to do. +/// +/// Agents may propose new sampling rates, model deployments, sensor +/// repositioning, or actuator commands — and nothing else. They never +/// directly control physical systems (ADR-264 §9). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ProposalKind { + /// Change the sampling interval of a spore node. + SetSamplingRate { + /// Target node. + node_id: u64, + /// Proposed sampling interval in seconds. + interval_s: u32, + }, + /// Deploy a model to a rhizome gateway. + DeployModel { + /// Model identifier. + model_id: String, + /// Gateway that should receive the model. + target_gateway: String, + }, + /// Physically reposition a sensor node. + RepositionSensor { + /// Node to move. + node_id: u64, + /// Proposed new location. + to: GeoPoint, + }, + /// Drive an actuator (valve, gate, pump, …). The most dangerous kind: + /// it must clear the policy gate, the safety envelope, **and** an exact + /// per-actuator authority grant before it can be signed. + ActuatorCommand { + /// Target actuator. + actuator_id: String, + /// Named action, e.g. `"open"`. + action: String, + /// Signed magnitude of the action, in actuator-native units. + magnitude: f64, + }, +} + +/// A raw, unevaluated proposal from an agent. All fields are public: agents +/// construct these freely. Constructing one grants **no** power — a proposal +/// only reaches execution by moving through every stage of the governed +/// control path, each of which returns a privately-constructed witness type. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentProposal { + /// Unique proposal id (deterministic in the simulator). + pub proposal_id: String, + /// Proposing agent. + pub agent_id: String, + /// Biome the proposal targets. Actuator authority never leaves the biome + /// owner (ADR-264 §6). + pub biome_id: String, + /// What is being proposed. + pub kind: ProposalKind, + /// Human-readable justification. Required — an empty justification is a + /// policy violation. + pub justification: String, + /// When the agent made the proposal, ns since Unix epoch. + pub proposed_ns: u64, +} diff --git a/crates/rucelium-store/Cargo.toml b/crates/rucelium-store/Cargo.toml new file mode 100644 index 0000000..78a08a0 --- /dev/null +++ b/crates/rucelium-store/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rucelium-store" +version.workspace = true +edition.workspace = true +description = "RuCelium durable gateway store: append-only segment log with dedup index, crash recovery, deterministic replay, and per-DataClass retention enforcement (ADR-265 §3)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "storage", "log", "retention"] +categories = ["science", "database-implementations"] + +[dependencies] +rucelium-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-store/src/events.rs b/crates/rucelium-store/src/events.rs new file mode 100644 index 0000000..f0ba7a5 --- /dev/null +++ b/crates/rucelium-store/src/events.rs @@ -0,0 +1,293 @@ +//! `EventStore` — the durable event log, mirroring +//! [`crate::ObservationStore`] for [`EnvironmentalEvent`]s (ADR-265 §3). +//! +//! Events are `DataClass::FederatedEvent` with a retention measured in +//! years (ADR-264 §10), so v0.1 has no retention enforcement here. + +use crate::segment::{ + append_dedup_lines, encode_line, list_segments, read_dedup_index, read_segment, + segment_file_name, +}; +use crate::{AppendOutcome, StoreError}; +use rucelium_core::EnvironmentalEvent; +use std::collections::BTreeSet; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Event segment file prefix (`evt-NNNNNN.jsonl`). +const PREFIX: &str = "evt"; + +/// One live event segment. +struct SegmentState { + name: String, + records: usize, +} + +/// Durable append-only store for [`EnvironmentalEvent`]s, deduped by +/// `event_id`. +/// +/// Same design as [`crate::ObservationStore`]: one CRC-framed JSON line +/// (` `) per event in zero-padded `evt-NNNNNN.jsonl` +/// segments, a persistent `dedup.idx` (one `event_id` per line) that is +/// authoritative on open, torn-tail repair on the final segment, and the +/// same two durability modes selected by the `sync` flag (crate docs). +pub struct EventStore { + dir: PathBuf, + segment_max_records: usize, + /// Fsync (`sync_data`) segment and dedup-index writes on every append. + sync: bool, + /// Every `event_id` ever appended. + seen: BTreeSet, + segments: Vec, + next_segment_index: u64, +} + +fn parse_event(line: &str) -> Result { + serde_json::from_str(line).map_err(|e| e.to_string()) +} + +/// Parse one `dedup.idx` line: a bare, non-empty `event_id`. +fn parse_dedup_id(line: &str) -> Result { + if line.is_empty() { + return Err("empty event_id".to_string()); + } + Ok(line.to_string()) +} + +impl EventStore { + /// Open (or create) an event store at `dir`. + /// + /// The dedup index is rebuilt from the persistent `dedup.idx` file + /// (authoritative), then existing `evt-*.jsonl` segments are scanned; + /// any `event_id` present in a segment but missing from `dedup.idx` + /// (legacy store) is merged in and appended back to the index. Recovery + /// and durability rules match [`crate::ObservationStore::open`] — + /// including the meaning of the `sync` flag; a `segment_max_records` of + /// `0` is treated as `1`. + pub fn open(dir: &Path, segment_max_records: usize, sync: bool) -> Result { + fs::create_dir_all(dir)?; + let mut seen: BTreeSet = + read_dedup_index(dir, parse_dedup_id)?.into_iter().collect(); + let listed = list_segments(dir, PREFIX)?; + let n = listed.len(); + let mut segments = Vec::with_capacity(n); + let mut next_segment_index = 0u64; + let mut missing_from_index = Vec::new(); + for (i, (name, index)) in listed.into_iter().enumerate() { + let repair_torn_tail = i + 1 == n; + let (records, _) = + read_segment(&dir.join(&name), &name, repair_torn_tail, parse_event)?; + for e in &records { + if seen.insert(e.event_id.clone()) { + missing_from_index.push(e.event_id.clone()); + } + } + segments.push(SegmentState { + name, + records: records.len(), + }); + next_segment_index = index + 1; + } + // Legacy upgrade / crash repair: persist ids the index was missing. + append_dedup_lines(dir, &missing_from_index, sync)?; + Ok(EventStore { + dir: dir.to_path_buf(), + segment_max_records: segment_max_records.max(1), + sync, + seen, + segments, + next_segment_index, + }) + } + + /// Append an event, deduplicating by `event_id`. The event is validated + /// first (invalid → [`StoreError::Core`]). The CRC-framed line is + /// written to the segment, then the `event_id` is appended to + /// `dedup.idx`; both writes are flushed to the OS, and additionally + /// `sync_data()`-fsynced when the store was opened with `sync = true` + /// (crate docs describe what each mode guarantees). + pub fn append(&mut self, event: &EnvironmentalEvent) -> Result { + event + .validate() + .map_err(|e| StoreError::Core(e.to_string()))?; + if self.seen.contains(&event.event_id) { + return Ok(AppendOutcome::Duplicate); + } + let roll = match self.segments.last() { + None => true, + Some(s) => s.records >= self.segment_max_records, + }; + if roll { + let name = segment_file_name(PREFIX, self.next_segment_index); + self.next_segment_index += 1; + self.segments.push(SegmentState { name, records: 0 }); + } + let json = serde_json::to_string(event).map_err(|e| StoreError::Core(e.to_string()))?; + let line = encode_line(&json); + let seg = self.segments.last_mut().expect("segment exists after roll"); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(self.dir.join(&seg.name))?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.flush()?; + if self.sync { + file.sync_data()?; + } + // Segment first, index second (see ObservationStore::append). + append_dedup_lines(&self.dir, std::slice::from_ref(&event.event_id), self.sync)?; + self.seen.insert(event.event_id.clone()); + seg.records += 1; + Ok(AppendOutcome::Appended) + } + + /// Number of unique events stored. + #[must_use] + pub fn len(&self) -> usize { + self.segments.iter().map(|s| s.records).sum() + } + + /// Whether no events are stored. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Live segment file names, sorted. + #[must_use] + pub fn segments(&self) -> Vec { + self.segments.iter().map(|s| s.name.clone()).collect() + } + + /// Full deterministic replay: every stored event in append order, read + /// back from disk. CRC prefixes are verified and stripped. + pub fn iter(&self) -> Result, StoreError> { + let mut out = Vec::with_capacity(self.len()); + for seg in &self.segments { + let (records, _) = + read_segment(&self.dir.join(&seg.name), &seg.name, false, parse_event)?; + out.extend(records); + } + Ok(out) + } + + /// The last `limit` events, in append order. + pub fn recent(&self, limit: usize) -> Result, StoreError> { + let mut all = self.iter()?; + let skip = all.len().saturating_sub(limit); + Ok(all.split_off(skip)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::{event, temp_dir}; + + #[test] + fn append_dedup_and_replay() { + let dir = temp_dir("evt"); + let mut store = EventStore::open(&dir, 2, false).unwrap(); + let events = [ + event("evt-0001", 5_000), + event("evt-0002", 6_000), + event("evt-0003", 7_000), + ]; + for e in &events { + assert_eq!(store.append(e).unwrap(), AppendOutcome::Appended); + } + assert_eq!( + store.append(&event("evt-0002", 9_999)).unwrap(), + AppendOutcome::Duplicate + ); + assert_eq!(store.len(), 3); + assert!(!store.is_empty()); + assert_eq!( + store.segments(), + vec!["evt-000000.jsonl", "evt-000001.jsonl"] + ); + assert_eq!(store.iter().unwrap(), events.to_vec()); + assert_eq!(store.recent(1).unwrap(), events[2..].to_vec()); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn reopen_preserves_dedup_and_order() { + let dir = temp_dir("evt-reopen"); + let mut store = EventStore::open(&dir, 2, false).unwrap(); + store.append(&event("evt-0001", 5_000)).unwrap(); + store.append(&event("evt-0002", 6_000)).unwrap(); + drop(store); + + // The event ids were persisted to dedup.idx. + assert_eq!( + fs::read_to_string(dir.join("dedup.idx")).unwrap(), + "evt-0001\nevt-0002\n" + ); + let mut reopened = EventStore::open(&dir, 2, false).unwrap(); + assert_eq!(reopened.len(), 2); + assert_eq!( + reopened.append(&event("evt-0001", 5_000)).unwrap(), + AppendOutcome::Duplicate + ); + assert_eq!( + reopened.append(&event("evt-0003", 7_000)).unwrap(), + AppendOutcome::Appended + ); + let ids: Vec = reopened + .iter() + .unwrap() + .into_iter() + .map(|e| e.event_id) + .collect(); + assert_eq!(ids, vec!["evt-0001", "evt-0002", "evt-0003"]); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn legacy_store_without_index_upgrades_on_open() { + let dir = temp_dir("evt-legacy"); + fs::create_dir_all(&dir).unwrap(); + // Pre-CRC, pre-index layout: bare JSON line, no dedup.idx. + let e = event("evt-legacy-1", 5_000); + let legacy = serde_json::to_string(&e).unwrap() + "\n"; + fs::write(dir.join("evt-000000.jsonl"), legacy).unwrap(); + + let mut store = EventStore::open(&dir, 10, false).unwrap(); + assert_eq!(store.len(), 1); + assert_eq!(store.iter().unwrap(), vec![e.clone()]); + assert_eq!( + fs::read_to_string(dir.join("dedup.idx")).unwrap(), + "evt-legacy-1\n" + ); + assert_eq!(store.append(&e).unwrap(), AppendOutcome::Duplicate); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn sync_mode_appends_and_reopens() { + let dir = temp_dir("evt-sync"); + let mut store = EventStore::open(&dir, 10, true).unwrap(); + store.append(&event("evt-0001", 5_000)).unwrap(); + drop(store); + let mut reopened = EventStore::open(&dir, 10, true).unwrap(); + assert_eq!( + reopened.append(&event("evt-0001", 5_000)).unwrap(), + AppendOutcome::Duplicate + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn invalid_event_is_a_core_error() { + let dir = temp_dir("evt-invalid"); + let mut store = EventStore::open(&dir, 10, false).unwrap(); + let mut bad = event("evt-0001", 5_000); + bad.evidence.clear(); + assert!(matches!(store.append(&bad), Err(StoreError::Core(_)))); + assert!(store.is_empty()); + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/rucelium-store/src/lib.rs b/crates/rucelium-store/src/lib.rs new file mode 100644 index 0000000..f27cd34 --- /dev/null +++ b/crates/rucelium-store/src/lib.rs @@ -0,0 +1,194 @@ +//! # rucelium-store +//! +//! Durable gateway store for **RuCelium** (ADR-265 §3): an append-only, +//! segmented, JSONL-on-disk log with a persistent dedup index, crash +//! recovery, deterministic replay, and retention enforcement. +//! +//! Two stores share the same segment machinery: +//! +//! * [`ObservationStore`] — normalized [`rucelium_core::EnvSample`]s, deduped +//! by the stable `(node_id, sequence)` key, files `obs-NNNNNN.jsonl`. +//! * [`EventStore`] — [`rucelium_core::EnvironmentalEvent`]s, deduped by +//! `event_id`, files `evt-NNNNNN.jsonl`. +//! +//! ## Design notes +//! +//! * **Durability** is a per-store choice: `open(dir, segment_max_records, +//! sync)`. With `sync = true`, every accepted append is +//! `sync_data()`-fsynced — segment file *and* dedup index — before +//! `append` returns, so an accepted record survives OS crash and power +//! loss (to the extent the storage stack honors fsync). With +//! `sync = false`, appends are only flushed to the OS page cache +//! (`File::flush`): a *process* crash loses nothing already flushed, but +//! a host power loss or kernel panic may lose the unsynced tail; crash +//! recovery then treats the partial last line as a torn tail, and any +//! fully-lost trailing records are simply absent (callers must be able +//! to replay them). "Durable" below means durable *for the chosen mode*. +//! * **Integrity**: each stored line is ` ` — CRC-32 +//! (IEEE) over the exact JSON bytes. On open a newline-terminated line +//! whose CRC does not match is [`StoreError::Corrupt`] with reason +//! `"crc mismatch"` — an integrity failure, never repaired. Legacy lines +//! of bare JSON (written before the CRC prefix existed) are still +//! accepted on read; new writes always carry a CRC. +//! * **Crash recovery**: on open, a *final* line of the *final* segment +//! that lacks its trailing newline and cannot be decoded (partial JSON +//! or an incomplete CRC prefix) is a torn write and is truncated away — +//! a crash mid-write must not poison the store. Malformed data anywhere +//! else is [`StoreError::Corrupt`]. +//! * **Retention** is segment-level: whole expired segment files are +//! deleted, never rewritten — cheap and O(1) per segment. The current +//! (last) segment is never deleted. +//! * **Dedup persistence**: every accepted key is also appended to a +//! per-store `dedup.idx` file (observations: `node_id sequence` per +//! line; events: one `event_id` per line). On open the index file is +//! authoritative; keys found in segments but missing from the index +//! (legacy directories) are merged in and written back. Dedup keys are +//! kept forever — in memory *and* in `dedup.idx` — even after retention +//! deletes their payload segments, so replaying an expired record after +//! a restart is still a duplicate. Keys are tiny (a `(u64, u32)` pair or +//! a short id string); retention frees payload bytes, not dedup state. +//! * **Determinism**: the library never reads a wall clock — callers pass +//! `now_ns` to [`ObservationStore::enforce_retention`]. + +#![doc(html_root_url = "https://docs.rs/rucelium-store/0.1.0")] + +mod events; +mod observations; +mod segment; + +pub use events::EventStore; +pub use observations::{ObservationStore, StoreStats}; +pub use segment::SegmentInfo; + +use std::fmt; + +/// Errors raised by the durable gateway store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoreError { + /// An underlying filesystem operation failed (carries the + /// `std::io::Error` message). + Io(String), + /// A segment file contains malformed data outside the tolerated + /// torn-tail position. + Corrupt { + /// Segment file name (e.g. `obs-000003.jsonl`). + segment: String, + /// 1-based line number of the malformed line. + line: usize, + /// Parser diagnostic. + reason: String, + }, + /// A core data-model rule was violated (invalid sample/event, or a + /// serialization failure). + Core(String), +} + +impl fmt::Display for StoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StoreError::Io(m) => write!(f, "storage I/O error: {m}"), + StoreError::Corrupt { + segment, + line, + reason, + } => write!(f, "corrupt segment {segment} at line {line}: {reason}"), + StoreError::Core(m) => write!(f, "core data error: {m}"), + } + } +} + +impl std::error::Error for StoreError {} + +impl From for StoreError { + fn from(e: std::io::Error) -> Self { + StoreError::Io(e.to_string()) + } +} + +/// Result of an append attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppendOutcome { + /// The record was new and was written to the current segment (fsynced + /// when the store was opened with `sync = true`; otherwise flushed to + /// the OS only — see the crate durability notes). + Appended, + /// The record's dedup key was already known; nothing was written. + Duplicate, +} + +#[cfg(test)] +pub(crate) mod testutil { + use rucelium_core::{ + EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SampleProvenance, + SensorModality, Severity, Uncertainty, + }; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + + /// Unique per-test temp dir under `std::env::temp_dir()`. `std::time` is + /// used only to make the *name* unique — never for store logic. + pub(crate) fn temp_dir(tag: &str) -> PathBuf { + let n = DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + let t = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "rucelium-store-{tag}-{}-{n}-{t}", + std::process::id() + )) + } + + /// A valid sample with the given identity, measurement time, and value. + pub(crate) fn sample(node_id: u64, sequence: u32, measured_ns: u64, value: f64) -> EnvSample { + EnvSample { + node_id, + sequence, + measured_ns, + received_ns: measured_ns + 1_000, + geo: GeoPoint::new(514_778_216, -14_767, 46_000).expect("valid geo"), + modality: SensorModality::Weather, + observed_property: "air_temperature".into(), + unit: "Cel".into(), + value, + quality: 0.98, + uncertainty: Uncertainty::symmetric(value, 0.3), + calibration_id: 3, + flags: 0, + battery_mv: 3600, + provenance: SampleProvenance { + firmware_hash: "sha256:abc".into(), + signer_pubkey_hex: "00ff".into(), + verified: true, + lineage: vec!["cal:3".into()], + }, + } + } + + /// A valid event with the given id and detection time. + pub(crate) fn event(event_id: &str, detected_ns: u64) -> EnvironmentalEvent { + EnvironmentalEvent { + evidence_digest: None, + spec_version: rucelium_core::SPEC_VERSION.into(), + event_id: event_id.into(), + biome_id: "biome/thames-estuary".into(), + kind: EventKind::FloodRisk, + severity: Severity::Warning, + modality: SensorModality::WaterQuality, + geo: GeoPoint::new(514_000_000, 500_000, 0).expect("valid geo"), + window_start_ns: detected_ns.saturating_sub(4_000), + window_end_ns: detected_ns, + detected_ns, + evidence: vec![EvidenceRef { + node_id: 7, + sequence: 42, + }], + confidence: 0.9, + message: "water level rising".into(), + signature_hex: None, + signer_pubkey_hex: None, + } + } +} diff --git a/crates/rucelium-store/src/observations.rs b/crates/rucelium-store/src/observations.rs new file mode 100644 index 0000000..2da0b68 --- /dev/null +++ b/crates/rucelium-store/src/observations.rs @@ -0,0 +1,798 @@ +//! `ObservationStore` — the durable, segmented, append-only sample log with +//! a persistent dedup index and retention enforcement (ADR-265 §3). + +use crate::segment::{ + append_dedup_lines, encode_line, list_segments, read_dedup_index, read_segment, + segment_file_name, SegmentInfo, +}; +use crate::{AppendOutcome, StoreError}; +use rucelium_core::EnvSample; +use serde::Serialize; +use std::collections::BTreeSet; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Observation segment file prefix (`obs-NNNNNN.jsonl`). +const PREFIX: &str = "obs"; + +/// Store health counters and sizes. +/// +/// `records` / `segments` / `bytes_on_disk` describe current on-disk state; +/// the `*_total` counters count operations since this handle was opened. +/// `bytes_on_disk` is approximate: it is the sum of segment sizes as +/// maintained at the last open-scan, append, or retention pass — the store +/// does not re-stat files on every call. `fsync` reports the durability +/// mode this handle was opened with (the `sync` flag of +/// [`ObservationStore::open`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct StoreStats { + /// Unique records currently stored on disk. + pub records: u64, + /// Number of live segment files. + pub segments: u64, + /// Samples appended since open. + pub appended_total: u64, + /// Duplicate appends rejected since open. + pub duplicates_total: u64, + /// Records deleted by retention since open. + pub retention_deleted_total: u64, + /// Approximate sum of segment file sizes in bytes. + pub bytes_on_disk: u64, + /// Whether every append is fsynced (`sync_data`) before returning. + pub fsync: bool, +} + +/// One live segment: public metadata plus its tracked byte size. +struct SegmentState { + info: SegmentInfo, + bytes: u64, +} + +/// Durable append-only store for [`EnvSample`]s. +/// +/// Samples live on disk as one CRC-framed JSON line each (` +/// `), in zero-padded segment files `obs-NNNNNN.jsonl` of at most +/// `segment_max_records` records. Dedup keys (`(node_id, sequence)`) are +/// persisted to an append-only `dedup.idx` file in the store directory and +/// survive both retention and restart. Only the dedup keys and per-segment +/// metadata are held in memory — replay always reads from disk, so it is +/// deterministic across restarts. See the crate docs for the durability, +/// integrity, torn-tail, and retention design notes. +pub struct ObservationStore { + dir: PathBuf, + segment_max_records: usize, + /// Fsync (`sync_data`) segment and dedup-index writes on every append. + sync: bool, + /// Every dedup key ever appended — kept even after retention (crate docs). + seen: BTreeSet<(u64, u32)>, + segments: Vec, + next_segment_index: u64, + appended_total: u64, + duplicates_total: u64, + retention_deleted_total: u64, +} + +fn parse_sample(line: &str) -> Result { + serde_json::from_str(line).map_err(|e| e.to_string()) +} + +/// Parse one `dedup.idx` line: ` `. +fn parse_dedup_key(line: &str) -> Result<(u64, u32), String> { + let (node, seq) = line + .split_once(' ') + .ok_or_else(|| "expected 'node_id sequence'".to_string())?; + let node_id: u64 = node.parse().map_err(|e| format!("bad node_id: {e}"))?; + let sequence: u32 = seq.parse().map_err(|e| format!("bad sequence: {e}"))?; + Ok((node_id, sequence)) +} + +/// Format one `dedup.idx` line (without the newline) for `key`. +fn dedup_line(key: (u64, u32)) -> String { + format!("{} {}", key.0, key.1) +} + +impl ObservationStore { + /// Open (or create) a store at `dir`. + /// + /// The in-memory dedup index is rebuilt from the persistent `dedup.idx` + /// file (authoritative — keys of retention-deleted segments live only + /// there), then existing `obs-*.jsonl` segments are scanned in + /// lexicographic order for segment metadata. Any key found in a segment + /// but missing from `dedup.idx` (a legacy pre-index store, or a crash + /// between the segment write and the index write) is merged in and + /// appended back to `dedup.idx`, so old directories upgrade cleanly. + /// + /// `sync` selects the durability mode: with `true`, every accepted + /// append is `sync_data()`-fsynced (segment file and `dedup.idx`) + /// before [`Self::append`] returns, so accepted records survive power + /// loss; with `false`, appends are only flushed to the OS page cache — + /// a process crash loses nothing, but a power loss or kernel panic may + /// lose the unsynced tail (recovered on reopen as a torn tail). + /// + /// Crash recovery: a **final** line of the **final** segment that lacks + /// its trailing newline and cannot be decoded is truncated away (torn + /// write). A newline-terminated line whose CRC does not match its JSON + /// bytes is [`StoreError::Corrupt`] (`"crc mismatch"`) and is never + /// truncated; any other malformed line is also `Corrupt`. A + /// `segment_max_records` of `0` is treated as `1`. + pub fn open(dir: &Path, segment_max_records: usize, sync: bool) -> Result { + fs::create_dir_all(dir)?; + let mut seen: BTreeSet<(u64, u32)> = read_dedup_index(dir, parse_dedup_key)? + .into_iter() + .collect(); + let listed = list_segments(dir, PREFIX)?; + let n = listed.len(); + let mut segments = Vec::with_capacity(n); + let mut next_segment_index = 0u64; + let mut missing_from_index = Vec::new(); + for (i, (name, index)) in listed.into_iter().enumerate() { + let repair_torn_tail = i + 1 == n; + let (records, bytes) = + read_segment(&dir.join(&name), &name, repair_torn_tail, parse_sample)?; + let mut info = SegmentInfo::empty(name); + for s in &records { + let key = s.dedup_key(); + if seen.insert(key) { + missing_from_index.push(dedup_line(key)); + } + info.records += 1; + info.min_measured_ns = info.min_measured_ns.min(s.measured_ns); + info.max_measured_ns = info.max_measured_ns.max(s.measured_ns); + } + segments.push(SegmentState { info, bytes }); + next_segment_index = index + 1; + } + // Legacy upgrade / crash repair: persist keys the index was missing. + append_dedup_lines(dir, &missing_from_index, sync)?; + Ok(ObservationStore { + dir: dir.to_path_buf(), + segment_max_records: segment_max_records.max(1), + sync, + seen, + segments, + next_segment_index, + appended_total: 0, + duplicates_total: 0, + retention_deleted_total: 0, + }) + } + + /// Append a sample, deduplicating by [`EnvSample::dedup_key`]. + /// + /// The sample is validated first (invalid → [`StoreError::Core`]). A new + /// segment starts when the current one holds `segment_max_records`. The + /// record line (` `) is written to the segment, then + /// the dedup key is appended to `dedup.idx`; both writes are flushed to + /// the OS, and additionally `sync_data()`-fsynced when the store was + /// opened with `sync = true` (see [`Self::open`] for what each mode + /// guarantees). + pub fn append(&mut self, sample: &EnvSample) -> Result { + sample + .validate() + .map_err(|e| StoreError::Core(e.to_string()))?; + let key = sample.dedup_key(); + if self.seen.contains(&key) { + self.duplicates_total += 1; + return Ok(AppendOutcome::Duplicate); + } + let roll = match self.segments.last() { + None => true, + Some(s) => s.info.records >= self.segment_max_records, + }; + if roll { + let name = segment_file_name(PREFIX, self.next_segment_index); + self.next_segment_index += 1; + self.segments.push(SegmentState { + info: SegmentInfo::empty(name), + bytes: 0, + }); + } + let json = serde_json::to_string(sample).map_err(|e| StoreError::Core(e.to_string()))?; + let line = encode_line(&json); + let seg = self.segments.last_mut().expect("segment exists after roll"); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(self.dir.join(&seg.info.name))?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.flush()?; + if self.sync { + file.sync_data()?; + } + // Segment first, index second: a crash in between leaves a record + // whose key is re-merged into dedup.idx on the next open. The + // reverse order could persist a key whose record was lost. + append_dedup_lines(&self.dir, &[dedup_line(key)], self.sync)?; + self.seen.insert(key); + seg.info.records += 1; + seg.info.min_measured_ns = seg.info.min_measured_ns.min(sample.measured_ns); + seg.info.max_measured_ns = seg.info.max_measured_ns.max(sample.measured_ns); + seg.bytes += line.len() as u64 + 1; + self.appended_total += 1; + Ok(AppendOutcome::Appended) + } + + /// Number of unique records currently stored on disk. After retention + /// this can be smaller than the dedup index, whose keys are kept forever. + #[must_use] + pub fn len(&self) -> usize { + self.segments.iter().map(|s| s.info.records).sum() + } + + /// Whether no records are currently stored. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Live segment file names, sorted. + #[must_use] + pub fn segments(&self) -> Vec { + self.segments.iter().map(|s| s.info.name.clone()).collect() + } + + /// Per-segment metadata, in segment order. + #[must_use] + pub fn segment_infos(&self) -> Vec { + self.segments.iter().map(|s| s.info.clone()).collect() + } + + /// Every dedup key the store has ever accepted, sorted ascending — + /// including keys whose payload segments retention has since deleted. + /// Used by the gateway to prime ingest replay windows after a restart. + #[must_use] + pub fn dedup_keys(&self) -> Vec<(u64, u32)> { + self.seen.iter().copied().collect() + } + + /// Full deterministic replay: every stored sample, in append order, + /// read back from disk (the store caches only dedup keys, never + /// payloads). CRC prefixes are verified and stripped. + pub fn iter(&self) -> Result, StoreError> { + let mut out = Vec::with_capacity(self.len()); + for seg in &self.segments { + let (records, _) = read_segment( + &self.dir.join(&seg.info.name), + &seg.info.name, + false, + parse_sample, + )?; + out.extend(records); + } + Ok(out) + } + + /// The last `limit` records, in append order. + pub fn recent(&self, limit: usize) -> Result, StoreError> { + let mut all = self.iter()?; + let skip = all.len().saturating_sub(limit); + Ok(all.split_off(skip)) + } + + /// Delete whole segments whose newest measurement has expired: + /// `max_measured_ns + retention_ns <= now_ns`. Returns the number of + /// records deleted. + /// + /// Segment-level deletion is the deliberate design: expired data is + /// dropped by removing whole files — cheap, and no segment is ever + /// rewritten. The current (last) segment is never deleted. Dedup keys of + /// deleted records are retained in memory **and** in `dedup.idx` (crate + /// docs), so an expired sample replayed later — even after a restart — + /// is still a duplicate. + pub fn enforce_retention(&mut self, now_ns: u64, retention_ns: u64) -> Result { + let mut deleted = 0u64; + let mut i = 0; + while i + 1 < self.segments.len() { + let seg = &self.segments[i]; + if seg.info.max_measured_ns.saturating_add(retention_ns) <= now_ns { + fs::remove_file(self.dir.join(&seg.info.name))?; + deleted += seg.info.records as u64; + self.segments.remove(i); + } else { + i += 1; + } + } + self.retention_deleted_total += deleted; + Ok(deleted) + } + + /// Current counters and sizes (see [`StoreStats`] for exact semantics). + #[must_use] + pub fn stats(&self) -> StoreStats { + StoreStats { + records: self.len() as u64, + segments: self.segments.len() as u64, + appended_total: self.appended_total, + duplicates_total: self.duplicates_total, + retention_deleted_total: self.retention_deleted_total, + bytes_on_disk: self.segments.iter().map(|s| s.bytes).sum(), + fsync: self.sync, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::{sample, temp_dir}; + + #[test] + fn append_iter_round_trips_in_order_and_rejects_duplicates() { + let dir = temp_dir("roundtrip"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + let samples = [ + sample(1, 1, 1_000, 20.0), + sample(2, 1, 2_000, 21.0), + sample(1, 2, 3_000, 22.0), + ]; + for s in &samples { + assert_eq!(store.append(s).unwrap(), AppendOutcome::Appended); + } + // Same key, different payload: still a duplicate. + assert_eq!( + store.append(&sample(1, 1, 9_000, 99.0)).unwrap(), + AppendOutcome::Duplicate + ); + assert_eq!(store.len(), 3); + assert!(!store.is_empty()); + assert_eq!(store.iter().unwrap(), samples.to_vec()); + assert_eq!(store.recent(2).unwrap(), samples[1..].to_vec()); + let stats = store.stats(); + assert_eq!(stats.appended_total, 3); + assert_eq!(stats.duplicates_total, 1); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn sync_mode_round_trips_too() { + let dir = temp_dir("sync-mode"); + let mut store = ObservationStore::open(&dir, 2, true).unwrap(); + for seq in 1..=3 { + assert_eq!( + store + .append(&sample(1, seq, u64::from(seq) * 1_000, 20.0)) + .unwrap(), + AppendOutcome::Appended + ); + } + assert!(store.stats().fsync); + assert_eq!(store.iter().unwrap().len(), 3); + drop(store); + let reopened = ObservationStore::open(&dir, 2, true).unwrap(); + assert_eq!(reopened.len(), 3); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn invalid_sample_is_a_core_error() { + let dir = temp_dir("invalid"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + let mut bad = sample(1, 1, 1_000, 20.0); + bad.quality = 2.0; + assert!(matches!(store.append(&bad), Err(StoreError::Core(_)))); + assert!(store.is_empty()); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn segments_roll_over_at_max_records() { + let dir = temp_dir("rollover"); + let mut store = ObservationStore::open(&dir, 3, false).unwrap(); + for seq in 1..=7 { + store + .append(&sample(1, seq, u64::from(seq) * 1_000, 20.0)) + .unwrap(); + } + assert_eq!( + store.segments(), + vec!["obs-000000.jsonl", "obs-000001.jsonl", "obs-000002.jsonl"] + ); + let infos = store.segment_infos(); + assert_eq!( + infos.iter().map(|i| i.records).collect::>(), + vec![3, 3, 1] + ); + assert_eq!(infos[0].min_measured_ns, 1_000); + assert_eq!(infos[0].max_measured_ns, 3_000); + assert_eq!(infos[2].min_measured_ns, 7_000); + assert_eq!(infos[2].max_measured_ns, 7_000); + // Replay stays ordered across segment boundaries. + let seqs: Vec = store.iter().unwrap().iter().map(|s| s.sequence).collect(); + assert_eq!(seqs, vec![1, 2, 3, 4, 5, 6, 7]); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn reopen_recovers_index_and_metadata() { + let dir = temp_dir("reopen"); + let mut store = ObservationStore::open(&dir, 3, false).unwrap(); + for seq in 1..=5 { + store + .append(&sample(1, seq, u64::from(seq) * 1_000, 20.0)) + .unwrap(); + } + let len = store.len(); + let segments = store.segments(); + let infos = store.segment_infos(); + drop(store); + + let mut reopened = ObservationStore::open(&dir, 3, false).unwrap(); + assert_eq!(reopened.len(), len); + assert_eq!(reopened.segments(), segments); + assert_eq!(reopened.segment_infos(), infos); + // Dedup survives restart. + assert_eq!( + reopened.append(&sample(1, 3, 3_000, 20.0)).unwrap(), + AppendOutcome::Duplicate + ); + // New keys still flow, into the correct next segment. + assert_eq!( + reopened.append(&sample(1, 6, 6_000, 20.0)).unwrap(), + AppendOutcome::Appended + ); + assert_eq!(reopened.len(), 6); + assert_eq!( + reopened.segments(), + vec!["obs-000000.jsonl", "obs-000001.jsonl"] + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn dedup_keys_are_sorted_and_persisted() { + let dir = temp_dir("dedup-keys"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + store.append(&sample(2, 1, 1_000, 20.0)).unwrap(); + store.append(&sample(1, 9, 2_000, 20.0)).unwrap(); + store.append(&sample(1, 2, 3_000, 20.0)).unwrap(); + assert_eq!(store.dedup_keys(), vec![(1, 2), (1, 9), (2, 1)]); + drop(store); + let idx = fs::read_to_string(dir.join("dedup.idx")).unwrap(); + assert_eq!(idx, "2 1\n1 9\n1 2\n"); + let reopened = ObservationStore::open(&dir, 100, false).unwrap(); + assert_eq!(reopened.dedup_keys(), vec![(1, 2), (1, 9), (2, 1)]); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn dedup_survives_retention_and_restart() { + let dir = temp_dir("retention-restart"); + let mut store = ObservationStore::open(&dir, 2, false).unwrap(); + // seg0: 1000, 2000 | seg1: 9000 + for (seq, measured) in [(1, 1_000), (2, 2_000), (3, 9_000)] { + store.append(&sample(1, seq, measured, 20.0)).unwrap(); + } + assert_eq!(store.enforce_retention(10_000, 1_000).unwrap(), 2); + assert!(!dir.join("obs-000000.jsonl").exists()); + drop(store); + + // The deleted segment's keys must still be duplicates after reopen. + let mut reopened = ObservationStore::open(&dir, 2, false).unwrap(); + assert_eq!(reopened.len(), 1); + assert_eq!( + reopened.append(&sample(1, 1, 1_000, 20.0)).unwrap(), + AppendOutcome::Duplicate + ); + assert_eq!( + reopened.append(&sample(1, 2, 2_000, 20.0)).unwrap(), + AppendOutcome::Duplicate + ); + assert_eq!(reopened.dedup_keys(), vec![(1, 1), (1, 2), (1, 3)]); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn written_lines_are_crc_framed_and_read_ok() { + let dir = temp_dir("crc-ok"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + store.append(&sample(1, 1, 1_000, 20.0)).unwrap(); + store.append(&sample(1, 2, 2_000, 21.0)).unwrap(); + drop(store); + let text = fs::read_to_string(dir.join("obs-000000.jsonl")).unwrap(); + assert!(text.ends_with('\n')); + for line in text.lines() { + let (crc_hex, json) = line.split_at(9); + assert!(crc_hex.ends_with(' ')); + assert_eq!(crc_hex.trim_end().len(), 8); + assert!(json.starts_with('{')); + } + // Newline-terminated, CRC-valid lines read back fine. + let reopened = ObservationStore::open(&dir, 100, false).unwrap(); + assert_eq!(reopened.iter().unwrap().len(), 2); + fs::remove_dir_all(&dir).unwrap(); + } + + /// Flip one JSON byte of line `line_idx` (0-based) in `name`, keeping + /// the line valid JSON and newline-terminated, so only the CRC breaks. + fn corrupt_json_of_line(dir: &Path, name: &str, line_idx: usize) { + let path = dir.join(name); + let mut lines: Vec = fs::read_to_string(&path) + .unwrap() + .lines() + .map(String::from) + .collect(); + let tampered = lines[line_idx].replacen("\"node_id\":1", "\"node_id\":9", 1); + assert_ne!(tampered, lines[line_idx], "tamper target must exist"); + lines[line_idx] = tampered; + fs::write(&path, lines.join("\n") + "\n").unwrap(); + } + + #[test] + fn middle_line_crc_mismatch_is_corrupt() { + let dir = temp_dir("crc-middle"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + for seq in 1..=3 { + store + .append(&sample(1, seq, u64::from(seq) * 1_000, 20.0)) + .unwrap(); + } + drop(store); + corrupt_json_of_line(&dir, "obs-000000.jsonl", 1); + let err = ObservationStore::open(&dir, 100, false) + .map(|_| ()) + .unwrap_err(); + match err { + StoreError::Corrupt { + segment, + line, + reason, + } => { + assert_eq!(segment, "obs-000000.jsonl"); + assert_eq!(line, 2); + assert_eq!(reason, "crc mismatch"); + } + other => panic!("expected Corrupt, got {other}"), + } + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn final_line_crc_mismatch_is_corrupt_never_truncated() { + let dir = temp_dir("crc-final"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + for seq in 1..=3 { + store + .append(&sample(1, seq, u64::from(seq) * 1_000, 20.0)) + .unwrap(); + } + drop(store); + corrupt_json_of_line(&dir, "obs-000000.jsonl", 2); + let path = dir.join("obs-000000.jsonl"); + let len_before = fs::metadata(&path).unwrap().len(); + let err = ObservationStore::open(&dir, 100, false) + .map(|_| ()) + .unwrap_err(); + match err { + StoreError::Corrupt { line, reason, .. } => { + assert_eq!(line, 3); + assert_eq!(reason, "crc mismatch"); + } + other => panic!("expected Corrupt, got {other}"), + } + // The corrupt line was NOT repaired away. + assert_eq!(fs::metadata(&path).unwrap().len(), len_before); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn torn_tail_is_truncated_on_open() { + let dir = temp_dir("torn"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + for seq in 1..=5 { + store + .append(&sample(1, seq, u64::from(seq) * 1_000, 20.0)) + .unwrap(); + } + drop(store); + // Simulate a crash mid-write: a partial CRC-framed line, no newline. + let path = dir.join("obs-000000.jsonl"); + let clean_len = fs::metadata(&path).unwrap().len(); + let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap(); + f.write_all(b"deadbeef {\"half").unwrap(); + drop(f); + + let mut reopened = ObservationStore::open(&dir, 100, false).unwrap(); + assert_eq!(reopened.len(), 5); + assert_eq!(reopened.iter().unwrap().len(), 5); + // The file was truncated back to the last complete record. + assert_eq!(fs::metadata(&path).unwrap().len(), clean_len); + assert!(!fs::read_to_string(&path).unwrap().contains("half")); + // The store keeps working after repair. + assert_eq!( + reopened.append(&sample(1, 6, 6_000, 20.0)).unwrap(), + AppendOutcome::Appended + ); + assert_eq!(reopened.iter().unwrap().len(), 6); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn torn_incomplete_crc_prefix_is_truncated_on_open() { + let dir = temp_dir("torn-prefix"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + store.append(&sample(1, 1, 1_000, 20.0)).unwrap(); + drop(store); + // Crash after only part of the CRC prefix hit the disk. + let path = dir.join("obs-000000.jsonl"); + let clean_len = fs::metadata(&path).unwrap().len(); + let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap(); + f.write_all(b"deadbe").unwrap(); + drop(f); + + let reopened = ObservationStore::open(&dir, 100, false).unwrap(); + assert_eq!(reopened.len(), 1); + assert_eq!(fs::metadata(&path).unwrap().len(), clean_len); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn legacy_bare_json_lines_are_readable_and_upgraded() { + let dir = temp_dir("legacy"); + fs::create_dir_all(&dir).unwrap(); + // A pre-CRC, pre-dedup.idx store: bare JSON lines, no index file. + let s1 = sample(1, 1, 1_000, 20.0); + let s2 = sample(1, 2, 2_000, 21.0); + let legacy = format!( + "{}\n{}\n", + serde_json::to_string(&s1).unwrap(), + serde_json::to_string(&s2).unwrap() + ); + fs::write(dir.join("obs-000000.jsonl"), legacy).unwrap(); + + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + assert_eq!(store.len(), 2); + assert_eq!(store.iter().unwrap(), vec![s1.clone(), s2.clone()]); + // Keys were merged into a freshly written dedup.idx. + assert_eq!( + fs::read_to_string(dir.join("dedup.idx")).unwrap(), + "1 1\n1 2\n" + ); + assert_eq!( + store.append(&s1).unwrap(), + AppendOutcome::Duplicate, + "legacy keys dedup" + ); + // New writes are CRC-framed even in an upgraded legacy store. + store.append(&sample(1, 3, 3_000, 22.0)).unwrap(); + let text = fs::read_to_string(dir.join("obs-000000.jsonl")).unwrap(); + let last = text.lines().last().unwrap(); + assert_eq!(last.as_bytes()[8], b' '); + // Mixed legacy + CRC-framed file still replays completely. + assert_eq!(store.iter().unwrap().len(), 3); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn corrupt_middle_line_names_segment_and_line() { + let dir = temp_dir("corrupt"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + for seq in 1..=3 { + store + .append(&sample(1, seq, u64::from(seq) * 1_000, 20.0)) + .unwrap(); + } + drop(store); + let path = dir.join("obs-000000.jsonl"); + let mut lines: Vec = fs::read_to_string(&path) + .unwrap() + .lines() + .map(String::from) + .collect(); + lines[1] = "not json".into(); + fs::write(&path, lines.join("\n") + "\n").unwrap(); + + let err = ObservationStore::open(&dir, 100, false) + .map(|_| ()) + .unwrap_err(); + match err { + StoreError::Corrupt { segment, line, .. } => { + assert_eq!(segment, "obs-000000.jsonl"); + assert_eq!(line, 2); + } + other => panic!("expected Corrupt, got {other}"), + } + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn torn_dedup_index_tail_is_truncated_on_open() { + let dir = temp_dir("torn-idx"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + store.append(&sample(1, 1, 1_000, 20.0)).unwrap(); + store.append(&sample(1, 2, 2_000, 20.0)).unwrap(); + drop(store); + // Crash mid-write of the "1 2" index line: the tail lost its + // newline, yet still parses — a torn prefix of a longer key would + // too. The un-terminated tail must be truncated regardless; the key + // is then recovered from the segment scan and appended back, + // properly terminated. + let idx_path = dir.join("dedup.idx"); + let idx = fs::read_to_string(&idx_path).unwrap(); + assert_eq!(idx, "1 1\n1 2\n"); + fs::write(&idx_path, "1 1\n1 2").unwrap(); // torn: no newline + let reopened = ObservationStore::open(&dir, 100, false).unwrap(); + assert_eq!(reopened.dedup_keys(), vec![(1, 1), (1, 2)]); + drop(reopened); + // The repaired index is fully newline-terminated again. + assert_eq!(fs::read_to_string(&idx_path).unwrap(), "1 1\n1 2\n"); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn corrupt_dedup_index_line_is_reported() { + let dir = temp_dir("bad-idx"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + store.append(&sample(1, 1, 1_000, 20.0)).unwrap(); + drop(store); + fs::write(dir.join("dedup.idx"), "garbage\n1 1\n").unwrap(); + let err = ObservationStore::open(&dir, 100, false) + .map(|_| ()) + .unwrap_err(); + match err { + StoreError::Corrupt { segment, line, .. } => { + assert_eq!(segment, "dedup.idx"); + assert_eq!(line, 1); + } + other => panic!("expected Corrupt, got {other}"), + } + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn retention_deletes_expired_segments_but_never_the_last() { + let dir = temp_dir("retention"); + let mut store = ObservationStore::open(&dir, 2, false).unwrap(); + // seg0: 1000, 2000 | seg1: 5000, 6000 | seg2: 9000 + for (seq, measured) in [(1, 1_000), (2, 2_000), (3, 5_000), (4, 6_000), (5, 9_000)] { + store.append(&sample(1, seq, measured, 20.0)).unwrap(); + } + assert_eq!(store.segments().len(), 3); + + // 2000 + 1000 <= 3000: only seg0 has expired. + assert_eq!(store.enforce_retention(3_000, 1_000).unwrap(), 2); + assert_eq!( + store.segments(), + vec!["obs-000001.jsonl", "obs-000002.jsonl"] + ); + let measured: Vec = store + .iter() + .unwrap() + .iter() + .map(|s| s.measured_ns) + .collect(); + assert_eq!(measured, vec![5_000, 6_000, 9_000]); + assert!(!dir.join("obs-000000.jsonl").exists()); + + // Far future: everything expired, but the last segment survives. + assert_eq!(store.enforce_retention(u64::MAX, 0).unwrap(), 2); + assert_eq!(store.segments(), vec!["obs-000002.jsonl"]); + assert_eq!(store.len(), 1); + assert_eq!(store.stats().retention_deleted_total, 4); + + // Dedup keys outlive retention: an expired sample is still a dup. + assert_eq!( + store.append(&sample(1, 1, 1_000, 20.0)).unwrap(), + AppendOutcome::Duplicate + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn stats_serialize_to_json() { + let dir = temp_dir("stats"); + let mut store = ObservationStore::open(&dir, 100, false).unwrap(); + store.append(&sample(1, 1, 1_000, 20.0)).unwrap(); + store.append(&sample(1, 1, 1_000, 20.0)).unwrap(); + let json = serde_json::to_value(store.stats()).unwrap(); + assert_eq!(json["records"], 1); + assert_eq!(json["segments"], 1); + assert_eq!(json["appended_total"], 1); + assert_eq!(json["duplicates_total"], 1); + assert_eq!(json["retention_deleted_total"], 0); + assert_eq!(json["fsync"], false); + assert!(json["bytes_on_disk"].as_u64().unwrap() > 0); + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/rucelium-store/src/segment.rs b/crates/rucelium-store/src/segment.rs new file mode 100644 index 0000000..3c00615 --- /dev/null +++ b/crates/rucelium-store/src/segment.rs @@ -0,0 +1,358 @@ +//! Segment file machinery shared by [`crate::ObservationStore`] and +//! [`crate::EventStore`]: naming, directory scan, per-line CRC framing, +//! line-oriented reads with torn-tail repair, and the persistent dedup +//! index file. +//! +//! ## On-disk line format +//! +//! Every record line written since v0.2 is ` ` — eight +//! lowercase hex digits of the CRC-32 (IEEE) of the exact JSON bytes, one +//! space, then the JSON, then `\n`. Legacy lines that are bare JSON (no CRC +//! prefix) are still accepted on read so pre-CRC store directories keep +//! working; new writes always carry a CRC. + +use crate::StoreError; +use serde::Serialize; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::sync::OnceLock; + +/// Name of the persistent append-only dedup index file, one per store +/// directory (each store owns its own directory). +pub(crate) const DEDUP_INDEX_FILE: &str = "dedup.idx"; + +/// In-memory metadata for one on-disk segment file, rebuilt on open and +/// updated on append. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SegmentInfo { + /// Segment file name (e.g. `obs-000002.jsonl`). + pub name: String, + /// Number of records in the segment. + pub records: usize, + /// Smallest `measured_ns` in the segment (`u64::MAX` while empty). + pub min_measured_ns: u64, + /// Largest `measured_ns` in the segment (`0` while empty). + pub max_measured_ns: u64, +} + +impl SegmentInfo { + /// An empty segment about to receive its first record. + pub(crate) fn empty(name: String) -> Self { + SegmentInfo { + name, + records: 0, + min_measured_ns: u64::MAX, + max_measured_ns: 0, + } + } +} + +/// CRC-32 lookup table (IEEE 802.3 reflected polynomial `0xEDB88320`), +/// built once. +fn crc32_table() -> &'static [u32; 256] { + static TABLE: OnceLock<[u32; 256]> = OnceLock::new(); + TABLE.get_or_init(|| { + let mut table = [0u32; 256]; + for (i, slot) in table.iter_mut().enumerate() { + let mut c = i as u32; + for _ in 0..8 { + c = if c & 1 != 0 { + 0xEDB8_8320 ^ (c >> 1) + } else { + c >> 1 + }; + } + *slot = c; + } + table + }) +} + +/// CRC-32 (IEEE) of `bytes` — the standard checksum used by Ethernet, gzip, +/// and zip (`crc32(b"123456789") == 0xCBF4_3926`). +pub(crate) fn crc32(bytes: &[u8]) -> u32 { + let table = crc32_table(); + let mut c = 0xFFFF_FFFFu32; + for &b in bytes { + c = table[((c ^ u32::from(b)) & 0xFF) as usize] ^ (c >> 8); + } + c ^ 0xFFFF_FFFF +} + +/// Encode one record line (without the trailing newline): ` `. +pub(crate) fn encode_line(json: &str) -> String { + format!("{:08x} {json}", crc32(json.as_bytes())) +} + +/// Split a stored line into its CRC prefix and JSON payload. Returns `None` +/// when the line does not have a complete `<8 hex digits>` prefix +/// (legacy bare-JSON line, or a torn/garbage line). +fn split_crc(line: &str) -> Option<(u32, &str)> { + let b = line.as_bytes(); + if b.len() < 9 || b[8] != b' ' || !b[..8].iter().all(u8::is_ascii_hexdigit) { + return None; + } + let crc = u32::from_str_radix(&line[..8], 16).ok()?; + Some((crc, &line[9..])) +} + +/// Why a stored line failed to decode. +enum LineFailure { + /// The line is well-formed ` ` and the JSON parses, but the + /// CRC does not match the JSON bytes: an integrity failure, never + /// repaired by torn-tail truncation. + CrcMismatch, + /// The line is not decodable at all (truncated JSON, garbage, invalid + /// UTF-8, ...). Repairable as a torn tail only in the final, + /// non-newline-terminated position. + Malformed(String), +} + +/// Decode one stored line: CRC-framed (` `) or legacy bare +/// JSON. +fn decode_line(line: &str, parse: &F) -> Result +where + F: Fn(&str) -> Result, +{ + if let Some((crc, json)) = split_crc(line) { + match parse(json) { + Ok(record) => { + if crc32(json.as_bytes()) == crc { + Ok(record) + } else { + Err(LineFailure::CrcMismatch) + } + } + Err(e) => Err(LineFailure::Malformed(e)), + } + } else { + // Legacy (pre-CRC) bare-JSON line — accepted for migration. + parse(line).map_err(LineFailure::Malformed) + } +} + +/// Segment file name for `index`: `{prefix}-{index:06}.jsonl`. Zero-padding +/// to six digits keeps lexicographic order equal to numeric order for up to +/// a million segments — far beyond any v0.1 deployment. +pub(crate) fn segment_file_name(prefix: &str, index: u64) -> String { + format!("{prefix}-{index:06}.jsonl") +} + +/// List `{prefix}-NNNNNN.jsonl` files in `dir` as `(name, index)`, sorted +/// lexicographically by name. Non-matching files are ignored. +pub(crate) fn list_segments(dir: &Path, prefix: &str) -> Result, StoreError> { + let mut out = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if let Some(index) = parse_segment_index(name, prefix) { + out.push((name.to_string(), index)); + } + } + out.sort(); + Ok(out) +} + +/// Parse the numeric index out of `{prefix}-NNNNNN.jsonl`; `None` when the +/// name does not match the pattern. +fn parse_segment_index(name: &str, prefix: &str) -> Option { + let digits = name + .strip_prefix(prefix)? + .strip_prefix('-')? + .strip_suffix(".jsonl")?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// Read one segment file, decoding each line (CRC-framed or legacy bare +/// JSON) with `parse` for the JSON payload. Returns the parsed records and +/// the file's size in bytes after any repair. +/// +/// With `repair_torn_tail` set (open-time recovery of the *last* segment +/// only), a **final** line that lacks its trailing newline and cannot be +/// decoded — a partial JSON body or an incomplete CRC prefix — is a +/// crash-torn write: the file is truncated to just before it and the scan +/// succeeds. Everything else malformed is [`StoreError::Corrupt`] with a +/// 1-based line number; in particular a newline-terminated, well-formed +/// ` ` line whose CRC does not match its JSON bytes is +/// `Corrupt` with reason `"crc mismatch"` and is **never** truncated. +pub(crate) fn read_segment( + path: &Path, + name: &str, + repair_torn_tail: bool, + parse: F, +) -> Result<(Vec, u64), StoreError> +where + F: Fn(&str) -> Result, +{ + let bytes = fs::read(path)?; + let mut records = Vec::new(); + let mut offset = 0usize; + let mut line_no = 0usize; + while offset < bytes.len() { + line_no += 1; + let newline_at = bytes[offset..].iter().position(|&b| b == b'\n'); + let end = newline_at.map_or(bytes.len(), |p| offset + p); + let decoded = match std::str::from_utf8(&bytes[offset..end]) { + Ok(line) => decode_line(line, &parse), + Err(e) => Err(LineFailure::Malformed(e.to_string())), + }; + match decoded { + Ok(record) => records.push(record), + Err(failure) => { + // Torn-tail repair applies only to the final line, only when + // it lacks its trailing newline (crash mid-write), and never + // to a CRC mismatch (that is corruption, not a torn write). + let is_final_line = end + 1 >= bytes.len(); + let has_newline = newline_at.is_some(); + if repair_torn_tail + && is_final_line + && !has_newline + && matches!(failure, LineFailure::Malformed(_)) + { + let file = fs::OpenOptions::new().write(true).open(path)?; + file.set_len(offset as u64)?; + return Ok((records, offset as u64)); + } + let reason = match failure { + LineFailure::CrcMismatch => "crc mismatch".to_string(), + LineFailure::Malformed(e) => e, + }; + return Err(StoreError::Corrupt { + segment: name.to_string(), + line: line_no, + reason, + }); + } + } + offset = end + 1; + } + Ok((records, bytes.len() as u64)) +} + +/// Read the persistent dedup index (`dedup.idx`) in `dir`, parsing each +/// line with `parse`. A missing file yields an empty list (fresh or legacy +/// store). A **final** line lacking its trailing newline is a torn write +/// and is truncated away *even if it parses* — a torn prefix of a longer +/// key can itself be parseable, and the writer always appends the newline +/// in the same write, so a complete index line is always terminated. (The +/// truncated key is not lost: its record is in the final segment, which is +/// written before the index, and the open-scan merge re-appends it.) Any +/// malformed newline-terminated line is [`StoreError::Corrupt`] (with +/// `segment: "dedup.idx"`). +pub(crate) fn read_dedup_index(dir: &Path, parse: F) -> Result, StoreError> +where + F: Fn(&str) -> Result, +{ + let path = dir.join(DEDUP_INDEX_FILE); + let bytes = match fs::read(&path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + let mut keys = Vec::new(); + let mut offset = 0usize; + let mut line_no = 0usize; + while offset < bytes.len() { + line_no += 1; + let newline_at = bytes[offset..].iter().position(|&b| b == b'\n'); + let Some(pos) = newline_at else { + // Torn final line (no trailing newline): truncate it away. + let file = fs::OpenOptions::new().write(true).open(&path)?; + file.set_len(offset as u64)?; + return Ok(keys); + }; + let end = offset + pos; + let parsed = std::str::from_utf8(&bytes[offset..end]) + .map_err(|e| e.to_string()) + .and_then(&parse); + match parsed { + Ok(key) => keys.push(key), + Err(reason) => { + return Err(StoreError::Corrupt { + segment: DEDUP_INDEX_FILE.to_string(), + line: line_no, + reason, + }); + } + } + offset = end + 1; + } + Ok(keys) +} + +/// Append `lines` (each without its newline) to the dedup index in `dir`, +/// flushing to the OS and — when `sync` is set — `sync_data()`-fsyncing +/// before returning. A no-op for an empty `lines`. +pub(crate) fn append_dedup_lines( + dir: &Path, + lines: &[String], + sync: bool, +) -> Result<(), StoreError> { + if lines.is_empty() { + return Ok(()); + } + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join(DEDUP_INDEX_FILE))?; + let mut buf = String::with_capacity(lines.iter().map(|l| l.len() + 1).sum()); + for line in lines { + buf.push_str(line); + buf.push('\n'); + } + file.write_all(buf.as_bytes())?; + file.flush()?; + if sync { + file.sync_data()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_names_are_zero_padded() { + assert_eq!(segment_file_name("obs", 0), "obs-000000.jsonl"); + assert_eq!(segment_file_name("evt", 42), "evt-000042.jsonl"); + } + + #[test] + fn index_parsing_rejects_foreign_names() { + assert_eq!(parse_segment_index("obs-000007.jsonl", "obs"), Some(7)); + assert_eq!(parse_segment_index("obs-000007.jsonl", "evt"), None); + assert_eq!(parse_segment_index("obs-x7.jsonl", "obs"), None); + assert_eq!(parse_segment_index("obs-.jsonl", "obs"), None); + assert_eq!(parse_segment_index("obs-000007.tmp", "obs"), None); + } + + #[test] + fn crc32_matches_the_ieee_check_value() { + // The canonical CRC-32 (IEEE) check value. + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + assert_eq!(crc32(b""), 0); + } + + #[test] + fn encode_line_prefixes_the_crc_of_the_exact_json_bytes() { + let json = r#"{"a":1}"#; + let line = encode_line(json); + let (crc, payload) = split_crc(&line).expect("framed line splits"); + assert_eq!(payload, json); + assert_eq!(crc, crc32(json.as_bytes())); + } + + #[test] + fn split_crc_rejects_bare_json_and_short_lines() { + assert!(split_crc(r#"{"a":1}"#).is_none()); + assert!(split_crc("deadbe").is_none()); + assert!(split_crc("nothexno {}").is_none()); + assert!(split_crc("deadbeef{}").is_none()); // no space + } +} diff --git a/crates/rucelium-transport/Cargo.toml b/crates/rucelium-transport/Cargo.toml new file mode 100644 index 0000000..4a537bd --- /dev/null +++ b/crates/rucelium-transport/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rucelium-transport" +version.workspace = true +edition.workspace = true +description = "RuCelium constrained-link transport: compact 114-byte envelope v2 (pubkey by reference) and MTU fragmentation/reassembly sized for LoRaWAN DR0's 51-byte payload cap (ADR-265 §2)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "lorawan", "fragmentation", "iot"] +categories = ["science", "embedded", "network-programming"] + +[dependencies] +rucelium-abi = { workspace = true } +ed25519-dalek = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-transport/src/envelope.rs b/crates/rucelium-transport/src/envelope.rs new file mode 100644 index 0000000..c890d11 --- /dev/null +++ b/crates/rucelium-transport/src/envelope.rs @@ -0,0 +1,272 @@ +//! Compact signed envelope v2: pubkey **by reference** (ADR-265 §2). +//! +//! The v1 envelope carries the signer's 32-byte ed25519 public key on every +//! message. On a constrained uplink that is pure overhead: the gateway +//! already holds the device registry keyed by the `node_id` embedded in the +//! 48-byte payload, so it can look the key up. Dropping the pubkey — and +//! replacing CBOR framing with a packed 2-byte header — shrinks the envelope +//! from 151 encoded bytes to a fixed **114**: +//! +//! ```text +//! [0] magic = 0xC2 +//! [1] version = 2 +//! [2..50] payload (48-byte packed rv_env_sample_v1 wire record) +//! [50..114] signature (64-byte ed25519 detached signature over payload) +//! ``` +//! +//! The signature is over the *exact same 48 payload bytes* as v1, so a +//! compact envelope rehydrated with the registry key ([`to_v1`]) verifies +//! under the unchanged v1 rules — the ingest pipeline downstream never +//! notices the wire format changed. Note 114 bytes still exceeds a single +//! LoRaWAN DR0 datagram (51 bytes); the [`crate::frag`] layer handles that. + +use crate::TransportError; +use ed25519_dalek::{Signature, Verifier as _, VerifyingKey}; +use rucelium_abi::{sign_payload, NodeSigner, SignedEnvRecordV1, RV_ENV_SAMPLE_V1_WIRE_LEN}; + +/// Magic byte identifying a compact envelope v2. +pub const COMPACT_ENV_MAGIC: u8 = 0xC2; + +/// Version byte of the compact envelope (2 — v1 is the CBOR envelope). +pub const COMPACT_ENV_VERSION: u8 = 2; + +/// Exact serialized length of a compact envelope v2: +/// `2 (header) + 48 (payload) + 64 (signature) = 114`. +pub const COMPACT_ENV_V2_LEN: usize = 2 + RV_ENV_SAMPLE_V1_WIRE_LEN + 64; + +/// Offset of the payload within the encoded envelope. +const PAYLOAD_OFF: usize = 2; +/// Offset of the signature within the encoded envelope. +const SIG_OFF: usize = PAYLOAD_OFF + RV_ENV_SAMPLE_V1_WIRE_LEN; + +/// Compact signed envelope v2: the 48-byte wire payload and the ed25519 +/// signature over exactly those bytes. The signer's public key is *not* +/// carried — verification requires the registry key for the payload's +/// `node_id` ([`verify_compact`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompactEnvV2 { + /// The 48-byte packed `rv_env_sample_v1` payload. + pub payload: [u8; RV_ENV_SAMPLE_V1_WIRE_LEN], + /// ed25519 detached signature over `payload` (64 bytes). + pub signature: [u8; 64], +} + +impl CompactEnvV2 { + /// Encode to the packed 114-byte wire layout. + #[must_use] + pub fn encode(&self) -> [u8; COMPACT_ENV_V2_LEN] { + let mut b = [0u8; COMPACT_ENV_V2_LEN]; + b[0] = COMPACT_ENV_MAGIC; + b[1] = COMPACT_ENV_VERSION; + b[PAYLOAD_OFF..SIG_OFF].copy_from_slice(&self.payload); + b[SIG_OFF..COMPACT_ENV_V2_LEN].copy_from_slice(&self.signature); + b + } + + /// Parse a packed compact envelope. Exactly one bounds check (the + /// length), then magic and version validation; never panics on any + /// input. Parsing does **not** verify the signature — call + /// [`verify_compact`] with the registry key before trusting the payload. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() != COMPACT_ENV_V2_LEN { + return Err(TransportError::WrongLength { + expected: COMPACT_ENV_V2_LEN, + actual: bytes.len(), + }); + } + if bytes[0] != COMPACT_ENV_MAGIC { + return Err(TransportError::BadMagic(bytes[0])); + } + if bytes[1] != COMPACT_ENV_VERSION { + return Err(TransportError::BadVersion(bytes[1])); + } + let mut payload = [0u8; RV_ENV_SAMPLE_V1_WIRE_LEN]; + payload.copy_from_slice(&bytes[PAYLOAD_OFF..SIG_OFF]); + let mut signature = [0u8; 64]; + signature.copy_from_slice(&bytes[SIG_OFF..COMPACT_ENV_V2_LEN]); + Ok(CompactEnvV2 { payload, signature }) + } +} + +/// Sign a 48-byte wire payload into a compact envelope. Reuses the v1 +/// signing path ([`rucelium_abi::sign_payload`]) — same key, same bytes, +/// same deterministic RFC 8032 signature — and drops the pubkey from the +/// result. +#[must_use] +pub fn sign_compact( + signer: &NodeSigner, + payload: &[u8; RV_ENV_SAMPLE_V1_WIRE_LEN], +) -> CompactEnvV2 { + let rec = sign_payload(signer, payload); + CompactEnvV2 { + payload: rec.payload, + signature: rec.signature, + } +} + +/// Verify the envelope's ed25519 signature over its 48 payload bytes using +/// a key supplied *by reference* — the gateway's registry entry for the +/// payload's `node_id`. Proves the payload is intact and was signed by that +/// key; whether the device is registered and unrevoked stays the ingest +/// pipeline's job. +pub fn verify_compact(env: &CompactEnvV2, pubkey: &[u8; 32]) -> Result<(), TransportError> { + let vk = VerifyingKey::from_bytes(pubkey).map_err(|_| TransportError::BadKey)?; + let sig = Signature::from_bytes(&env.signature); + vk.verify(&env.payload, &sig) + .map_err(|_| TransportError::BadSignature) +} + +/// Rehydrate a compact envelope into a v1 [`SignedEnvRecordV1`] by +/// re-attaching the registry public key, so the existing ingest pipeline is +/// unchanged downstream. This performs no verification itself — ingest +/// re-verifies the record ([`rucelium_abi::verify_record`]), so a wrong key +/// supplied here is caught there. +#[must_use] +pub fn to_v1(env: &CompactEnvV2, pubkey: [u8; 32]) -> SignedEnvRecordV1 { + SignedEnvRecordV1 { + payload: env.payload, + pubkey, + signature: env.signature, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_abi::verify_record; + + const SEED: &[u8; 32] = b"rucelium-provision-seed-32-byte!"; + + fn payload() -> [u8; RV_ENV_SAMPLE_V1_WIRE_LEN] { + let mut p = [0u8; RV_ENV_SAMPLE_V1_WIRE_LEN]; + for (i, b) in p.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(37).wrapping_add(11); + } + p + } + + #[test] + fn sign_encode_parse_verify_round_trip() { + let signer = NodeSigner::for_node(SEED, 7); + let env = sign_compact(&signer, &payload()); + let bytes = env.encode(); + assert_eq!(bytes.len(), COMPACT_ENV_V2_LEN); + assert_eq!(bytes[0], COMPACT_ENV_MAGIC); + assert_eq!(bytes[1], COMPACT_ENV_VERSION); + let back = CompactEnvV2::parse(&bytes).unwrap(); + assert_eq!(back, env); + verify_compact(&back, &signer.public_key()).unwrap(); + } + + #[test] + fn every_single_byte_tamper_breaks_parse_or_verify() { + let signer = NodeSigner::for_node(SEED, 7); + let env = sign_compact(&signer, &payload()); + let bytes = env.encode(); + let pk = signer.public_key(); + for i in 0..COMPACT_ENV_V2_LEN { + let mut t = bytes; + t[i] ^= 0x01; + let broken = match CompactEnvV2::parse(&t) { + Err(_) => true, + Ok(parsed) => verify_compact(&parsed, &pk).is_err(), + }; + assert!(broken, "tampered byte {i} must break parse or verify"); + } + } + + #[test] + fn wrong_length_magic_version_rejected() { + let signer = NodeSigner::for_node(SEED, 7); + let bytes = sign_compact(&signer, &payload()).encode(); + assert_eq!( + CompactEnvV2::parse(&bytes[..COMPACT_ENV_V2_LEN - 1]), + Err(TransportError::WrongLength { + expected: COMPACT_ENV_V2_LEN, + actual: COMPACT_ENV_V2_LEN - 1, + }) + ); + assert!(CompactEnvV2::parse(&[]).is_err()); + let mut bad = bytes; + bad[0] = 0xC3; + assert_eq!( + CompactEnvV2::parse(&bad), + Err(TransportError::BadMagic(0xC3)) + ); + let mut bad = bytes; + bad[1] = 1; + assert_eq!( + CompactEnvV2::parse(&bad), + Err(TransportError::BadVersion(1)) + ); + } + + #[test] + fn wrong_pubkey_is_bad_signature() { + let a = NodeSigner::for_node(SEED, 7); + let b = NodeSigner::for_node(SEED, 8); + let env = sign_compact(&a, &payload()); + assert_eq!( + verify_compact(&env, &b.public_key()), + Err(TransportError::BadSignature) + ); + } + + #[test] + fn invalid_pubkey_bytes_are_bad_key() { + // Roughly half of all 32-byte strings fail ed25519 point + // decompression. Sweep a deterministic family and require that at + // least one hits the BadKey path (probability of the sweep missing + // is ~2^-256) and that no other error kind ever appears for the + // remainder (a wrong-but-valid key must be BadSignature). + let signer = NodeSigner::for_node(SEED, 7); + let env = sign_compact(&signer, &payload()); + let mut bad_keys = 0u32; + for first in 0u8..=255 { + let mut key = [0x5Au8; 32]; + key[0] = first; + match verify_compact(&env, &key) { + Err(TransportError::BadKey) => bad_keys += 1, + Err(TransportError::BadSignature) => {} + other => panic!("unexpected result for key sweep: {other:?}"), + } + } + assert!(bad_keys > 0, "sweep must hit at least one invalid point"); + } + + #[test] + fn to_v1_rehydrates_a_record_the_v1_pipeline_verifies() { + let signer = NodeSigner::for_node(SEED, 7); + let env = sign_compact(&signer, &payload()); + let rec = to_v1(&env, signer.public_key()); + verify_record(&rec).unwrap(); + assert_eq!(rec.payload, env.payload); + assert_eq!(rec.signature, env.signature); + // And the size arithmetic that motivates v2: + assert_eq!(rec.encode().len(), 151); + assert_eq!(COMPACT_ENV_V2_LEN, 114); + } + + #[test] + fn to_v1_with_wrong_key_fails_downstream_verification() { + let a = NodeSigner::for_node(SEED, 7); + let b = NodeSigner::for_node(SEED, 8); + let env = sign_compact(&a, &payload()); + assert!(verify_record(&to_v1(&env, b.public_key())).is_err()); + } + + #[test] + fn parse_never_panics_on_arbitrary_bytes() { + // Deterministic LCG pseudo-fuzz over lengths 0..=130 (covers the + // exact 114-byte length too). + let mut x: u64 = 0xC2C2_0002_DEAD_BEEF; + for len in 0..=130usize { + let mut buf = vec![0u8; len]; + for b in &mut buf { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + *b = (x >> 56) as u8; + } + let _ = CompactEnvV2::parse(&buf); // must not panic + } + } +} diff --git a/crates/rucelium-transport/src/frag.rs b/crates/rucelium-transport/src/frag.rs new file mode 100644 index 0000000..edbbd38 --- /dev/null +++ b/crates/rucelium-transport/src/frag.rs @@ -0,0 +1,552 @@ +//! MTU fragmentation and reassembly for links that cannot carry a whole +//! envelope in one datagram (ADR-265 §2). +//! +//! Even the compact 114-byte envelope ([`crate::envelope`]) exceeds the +//! 51-byte LoRaWAN DR0 application payload cap, so messages are split into +//! frames with a fixed 6-byte header: +//! +//! ```text +//! [0] magic = 0xF7 +//! [1] version = 1 +//! [2..4] msg_id (u16, little-endian) +//! [4] frag_idx (0-based) +//! [5] frag_count (1..=255) +//! [6..] chunk (up to mtu - 6 bytes) +//! ``` +//! +//! Single-fragment messages still carry the header so receivers parse every +//! datagram uniformly. Reassembly ([`Reassembler`]) is keyed by +//! `(from, msg_id)` where `from` is a link-layer hint (e.g. a source address +//! hash), so `msg_id` collisions across senders never merge. All timing is +//! caller-driven: `offer` and `evict_older_than` take `now_ns`, keeping the +//! layer deterministic. + +use crate::envelope::CompactEnvV2; +use crate::TransportError; +use std::collections::HashMap; + +/// LoRaWAN DR0 (EU868 SF12/125 kHz) maximum application payload per datagram. +pub const LORAWAN_DR0_MTU: usize = 51; + +/// Magic byte identifying a fragment frame. +pub const FRAG_MAGIC: u8 = 0xF7; + +/// Fragment header version. +pub const FRAG_VERSION: u8 = 1; + +/// Fixed fragment header length in bytes. +pub const FRAG_HEADER_LEN: usize = 6; + +/// Maximum number of fragments per message (`frag_count` is one byte). +const MAX_FRAG_COUNT: usize = 255; + +/// A parsed fragment frame. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fragment { + /// Sender-chosen message identifier; scoped per sender, wraps freely. + pub msg_id: u16, + /// 0-based index of this fragment (`frag_idx < frag_count`). + pub frag_idx: u8, + /// Total fragments in the message (`1..=255`). + pub frag_count: u8, + /// The payload chunk carried by this frame. + pub chunk: Vec, +} + +impl Fragment { + /// Parse one datagram. Bounds-checked, never panics on any input; the + /// header magic, version, non-zero `frag_count`, and + /// `frag_idx < frag_count` are all enforced. + pub fn parse(datagram: &[u8]) -> Result { + if datagram.len() < FRAG_HEADER_LEN { + return Err(TransportError::WrongLength { + expected: FRAG_HEADER_LEN, + actual: datagram.len(), + }); + } + if datagram[0] != FRAG_MAGIC { + return Err(TransportError::BadMagic(datagram[0])); + } + if datagram[1] != FRAG_VERSION { + return Err(TransportError::BadVersion(datagram[1])); + } + let msg_id = u16::from_le_bytes([datagram[2], datagram[3]]); + let frag_idx = datagram[4]; + let frag_count = datagram[5]; + if frag_count == 0 { + return Err(TransportError::BadFragment( + "frag_count is zero".to_string(), + )); + } + if frag_idx >= frag_count { + return Err(TransportError::BadFragment(format!( + "frag_idx {frag_idx} not below frag_count {frag_count}" + ))); + } + Ok(Fragment { + msg_id, + frag_idx, + frag_count, + chunk: datagram[FRAG_HEADER_LEN..].to_vec(), + }) + } +} + +/// Split `message` into datagrams of at most `mtu` bytes, each with a 6-byte +/// header followed by up to `mtu - 6` chunk bytes. Errors with +/// [`TransportError::MtuTooSmall`] when `mtu < 7` (no room for even one +/// chunk byte) and [`TransportError::TooLarge`] when the message needs more +/// than 255 fragments. An empty message yields one header-only datagram; +/// single-fragment messages still carry the header (uniform parsing). +pub fn fragment(message: &[u8], msg_id: u16, mtu: usize) -> Result>, TransportError> { + if mtu < FRAG_HEADER_LEN + 1 { + return Err(TransportError::MtuTooSmall(mtu)); + } + let chunk_len = mtu - FRAG_HEADER_LEN; + let max = chunk_len * MAX_FRAG_COUNT; + if message.len() > max { + return Err(TransportError::TooLarge { + len: message.len(), + max, + }); + } + let frag_count = message.len().div_ceil(chunk_len).max(1); + let mut frames = Vec::with_capacity(frag_count); + for (idx, chunk) in message + .chunks(chunk_len) + .chain(std::iter::once(&[][..]).take(usize::from(message.is_empty()))) + .enumerate() + { + let mut frame = Vec::with_capacity(FRAG_HEADER_LEN + chunk.len()); + frame.push(FRAG_MAGIC); + frame.push(FRAG_VERSION); + frame.extend_from_slice(&msg_id.to_le_bytes()); + frame.push(idx as u8); + frame.push(frag_count as u8); + frame.extend_from_slice(chunk); + frames.push(frame); + } + debug_assert_eq!(frames.len(), frag_count); + Ok(frames) +} + +/// Fragment a compact envelope at [`LORAWAN_DR0_MTU`]. Infallible by +/// construction: 114 bytes split into 45-byte chunks is exactly 3 frames of +/// at most 51 bytes each (asserted in tests). +#[must_use] +pub fn fragment_compact(env: &CompactEnvV2, msg_id: u16) -> Vec> { + fragment(&env.encode(), msg_id, LORAWAN_DR0_MTU) + .expect("114-byte envelope always fits 3 DR0 frames") +} + +/// One in-flight partially reassembled message. +#[derive(Debug)] +struct Pending { + /// `frag_count` claimed by the first fragment seen for this key. + frag_count: u8, + /// Chunks received so far, indexed by `frag_idx`. + chunks: Vec>>, + /// How many distinct fragments have arrived. + received: usize, + /// `now_ns` when the first fragment for this key arrived. + first_seen_ns: u64, + /// Monotonic insertion counter, tie-breaker for deterministic eviction. + seq: u64, +} + +/// Reassembles fragment datagrams back into messages, keyed by +/// `(from, msg_id)`. +/// +/// Semantics: +/// +/// - **Duplicates** of an already-held fragment index are silently ignored. +/// - A fragment whose `frag_count` **conflicts** with the pending entry drops +/// that entry and returns [`TransportError::Inconsistent`]. +/// - [`Reassembler::offer`] returns `Some(message)` **exactly once**, when +/// the last missing fragment arrives; the completed state is then +/// forgotten. A later duplicate fragment of a completed message is +/// indistinguishable from a new message and starts a fresh pending entry — +/// callers wanting end-to-end deduplication use the payload's own sequence +/// number (the ingest sequence window), not this layer. +/// - **Capacity**: at most `max_pending` incomplete messages are held; when +/// full, the oldest pending entry (by first-seen `now_ns`, insertion order +/// breaking ties) is evicted, so a lost-fragment message can never leak +/// memory forever. +/// - **Timeout GC** is explicit and caller-driven: [`Reassembler::evict_older_than`]. +#[derive(Debug)] +pub struct Reassembler { + max_pending: usize, + pending: HashMap<(u64, u16), Pending>, + next_seq: u64, +} + +impl Reassembler { + /// Create a reassembler holding at most `max_pending` incomplete + /// messages (clamped to at least 1). + #[must_use] + pub fn new(max_pending: usize) -> Self { + Reassembler { + max_pending: max_pending.max(1), + pending: HashMap::new(), + next_seq: 0, + } + } + + /// Offer one received datagram. `from` is a link-layer sender hint + /// (e.g. a source address hash) scoping `msg_id`; `now_ns` is the + /// caller's clock, recorded when a key is first seen and used for + /// eviction ordering. Returns `Ok(Some(message))` when this datagram + /// completes a message, `Ok(None)` when more fragments are needed (or + /// the datagram was a duplicate), and an error for unparseable or + /// inconsistent fragments. + pub fn offer( + &mut self, + from: u64, + datagram: &[u8], + now_ns: u64, + ) -> Result>, TransportError> { + let frag = Fragment::parse(datagram)?; + let key = (from, frag.msg_id); + let count = usize::from(frag.frag_count); + let idx = usize::from(frag.frag_idx); + + if let Some(p) = self.pending.get_mut(&key) { + if p.frag_count != frag.frag_count { + self.pending.remove(&key); + return Err(TransportError::Inconsistent); + } + if p.chunks[idx].is_some() { + return Ok(None); // duplicate fragment: ignored + } + p.chunks[idx] = Some(frag.chunk); + p.received += 1; + if p.received == count { + let done = self.pending.remove(&key).expect("entry present"); + return Ok(Some(assemble(done.chunks))); + } + return Ok(None); + } + + if count == 1 { + // Complete in one datagram; nothing to store. + return Ok(Some(frag.chunk)); + } + if self.pending.len() >= self.max_pending { + self.evict_oldest(); + } + let mut chunks = vec![None; count]; + chunks[idx] = Some(frag.chunk); + self.pending.insert( + key, + Pending { + frag_count: frag.frag_count, + chunks, + received: 1, + first_seen_ns: now_ns, + seq: self.next_seq, + }, + ); + self.next_seq += 1; + Ok(None) + } + + /// Drop every pending entry first seen strictly before `cutoff_ns` and + /// return how many were evicted. Explicit, caller-driven timeout GC. + pub fn evict_older_than(&mut self, cutoff_ns: u64) -> usize { + let before = self.pending.len(); + self.pending.retain(|_, p| p.first_seen_ns >= cutoff_ns); + before - self.pending.len() + } + + /// Number of incomplete messages currently held. + #[must_use] + pub fn pending(&self) -> usize { + self.pending.len() + } + + /// Remove the oldest pending entry (deterministic: smallest + /// `(first_seen_ns, seq)`). + fn evict_oldest(&mut self) { + if let Some(&key) = self + .pending + .iter() + .min_by_key(|(_, p)| (p.first_seen_ns, p.seq)) + .map(|(k, _)| k) + { + self.pending.remove(&key); + } + } +} + +/// Concatenate a complete chunk vector into the reassembled message. +fn assemble(chunks: Vec>>) -> Vec { + let mut out = Vec::with_capacity(chunks.iter().flatten().map(Vec::len).sum()); + for c in chunks { + out.extend_from_slice(&c.expect("all fragments received")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::envelope::{sign_compact, verify_compact, CompactEnvV2, COMPACT_ENV_V2_LEN}; + use rucelium_abi::NodeSigner; + + const SEED: &[u8; 32] = b"rucelium-provision-seed-32-byte!"; + + fn signed_env() -> (CompactEnvV2, [u8; 32]) { + let signer = NodeSigner::for_node(SEED, 42); + let mut payload = [0u8; 48]; + for (i, b) in payload.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(29).wrapping_add(3); + } + (sign_compact(&signer, &payload), signer.public_key()) + } + + #[test] + fn header_layout_pinned() { + let frames = fragment(b"hello world", 0xBEEF, 10).unwrap(); + // chunk_len = 4 → ceil(11/4) = 3 frames. + assert_eq!(frames.len(), 3); + let f = &frames[0]; + assert_eq!(f[0], FRAG_MAGIC); + assert_eq!(f[1], FRAG_VERSION); + assert_eq!([f[2], f[3]], 0xBEEF_u16.to_le_bytes()); + assert_eq!(f[4], 0); // frag_idx + assert_eq!(f[5], 3); // frag_count + assert_eq!(&f[6..], b"hell"); + assert_eq!(&frames[2][6..], b"rld"); + } + + #[test] + fn compact_envelope_fits_dr0_in_exactly_three_frames() { + let (env, pk) = signed_env(); + let frames = fragment_compact(&env, 7); + assert_eq!(frames.len(), 3); + for f in &frames { + assert!(f.len() <= LORAWAN_DR0_MTU, "frame of {} bytes", f.len()); + } + // Reassemble and get the identical 114 bytes back. + let mut r = Reassembler::new(8); + assert_eq!(r.offer(1, &frames[0], 10).unwrap(), None); + assert_eq!(r.offer(1, &frames[1], 20).unwrap(), None); + let msg = r.offer(1, &frames[2], 30).unwrap().unwrap(); + assert_eq!(msg.len(), COMPACT_ENV_V2_LEN); + assert_eq!(msg, env.encode().to_vec()); + // ...and the result parses and verifies. + let back = CompactEnvV2::parse(&msg).unwrap(); + verify_compact(&back, &pk).unwrap(); + assert_eq!(r.pending(), 0); + } + + #[test] + fn out_of_order_reassembly_works() { + let frames = fragment(b"the quick brown fox jumps", 9, 16).unwrap(); + assert_eq!(frames.len(), 3); + let mut r = Reassembler::new(4); + assert_eq!(r.offer(5, &frames[2], 1).unwrap(), None); + assert_eq!(r.offer(5, &frames[0], 2).unwrap(), None); + let msg = r.offer(5, &frames[1], 3).unwrap().unwrap(); + assert_eq!(msg, b"the quick brown fox jumps"); + } + + #[test] + fn duplicate_fragments_ignored() { + let frames = fragment(&[7u8; 30], 1, 16).unwrap(); + let mut r = Reassembler::new(4); + assert_eq!(r.offer(1, &frames[0], 1).unwrap(), None); + assert_eq!(r.offer(1, &frames[0], 2).unwrap(), None); // dup + assert_eq!(r.offer(1, &frames[0], 3).unwrap(), None); // dup again + assert_eq!(r.pending(), 1); + assert_eq!(r.offer(1, &frames[1], 4).unwrap(), None); + assert_eq!(r.offer(1, &frames[2], 5).unwrap().unwrap(), vec![7u8; 30]); + } + + #[test] + fn same_msg_id_from_two_senders_does_not_merge() { + let msg_a = vec![0xAA; 30]; + let msg_b = vec![0xBB; 30]; + let fa = fragment(&msg_a, 77, 16).unwrap(); + let fb = fragment(&msg_b, 77, 16).unwrap(); + let mut r = Reassembler::new(8); + // Interleave fragments from senders 1 and 2 with the same msg_id. + assert_eq!(r.offer(1, &fa[0], 1).unwrap(), None); + assert_eq!(r.offer(2, &fb[0], 2).unwrap(), None); + assert_eq!(r.offer(1, &fa[1], 3).unwrap(), None); + assert_eq!(r.offer(2, &fb[1], 4).unwrap(), None); + assert_eq!(r.pending(), 2); + assert_eq!(r.offer(1, &fa[2], 5).unwrap().unwrap(), msg_a); + assert_eq!(r.offer(2, &fb[2], 6).unwrap().unwrap(), msg_b); + assert_eq!(r.pending(), 0); + } + + #[test] + fn interleaved_msg_ids_from_one_sender_both_complete() { + let msg_a = vec![1u8; 25]; + let msg_b = vec![2u8; 25]; + let fa = fragment(&msg_a, 10, 16).unwrap(); + let fb = fragment(&msg_b, 11, 16).unwrap(); + let mut r = Reassembler::new(8); + assert_eq!(r.offer(9, &fa[0], 1).unwrap(), None); + assert_eq!(r.offer(9, &fb[0], 2).unwrap(), None); + assert_eq!(r.offer(9, &fb[1], 3).unwrap(), None); + assert_eq!(r.offer(9, &fa[1], 4).unwrap(), None); + assert_eq!(r.offer(9, &fb[2], 5).unwrap().unwrap(), msg_b); + assert_eq!(r.offer(9, &fa[2], 6).unwrap().unwrap(), msg_a); + } + + #[test] + fn lost_fragment_stays_pending_until_explicit_eviction() { + let frames = fragment(&[3u8; 30], 5, 16).unwrap(); + let mut r = Reassembler::new(4); + assert_eq!(r.offer(1, &frames[0], 1_000).unwrap(), None); + assert_eq!(r.offer(1, &frames[2], 2_000).unwrap(), None); + // frames[1] is lost: never completes. + assert_eq!(r.pending(), 1); + // Cutoff at or before first-seen keeps it... + assert_eq!(r.evict_older_than(1_000), 0); + assert_eq!(r.pending(), 1); + // ...a later cutoff clears it. + assert_eq!(r.evict_older_than(1_001), 1); + assert_eq!(r.pending(), 0); + // The surviving fragment alone can no longer complete anything. + assert_eq!(r.offer(1, &frames[1], 3_000).unwrap(), None); + assert_eq!(r.pending(), 1); + } + + #[test] + fn max_pending_evicts_oldest_first() { + let mut r = Reassembler::new(2); + let fa = fragment(&[1u8; 20], 1, 16).unwrap(); + let fb = fragment(&[2u8; 20], 2, 16).unwrap(); + let fc = fragment(&[3u8; 20], 3, 16).unwrap(); + assert_eq!(r.offer(1, &fa[0], 100).unwrap(), None); // oldest + assert_eq!(r.offer(1, &fb[0], 200).unwrap(), None); + assert_eq!(r.pending(), 2); + // Third message evicts msg_id 1 (first seen at 100). + assert_eq!(r.offer(1, &fc[0], 300).unwrap(), None); + assert_eq!(r.pending(), 2); + // B and C still complete... + assert_eq!(r.offer(1, &fb[1], 400).unwrap().unwrap(), vec![2u8; 20]); + assert_eq!(r.offer(1, &fc[1], 500).unwrap().unwrap(), vec![3u8; 20]); + // ...A's remaining fragment starts over from nothing. + assert_eq!(r.offer(1, &fa[1], 600).unwrap(), None); + assert_eq!(r.pending(), 1); + } + + #[test] + fn completed_message_state_is_forgotten() { + let frames = fragment(&[9u8; 20], 4, 16).unwrap(); + let mut r = Reassembler::new(4); + assert_eq!(r.offer(1, &frames[0], 1).unwrap(), None); + assert!(r.offer(1, &frames[1], 2).unwrap().is_some()); + assert_eq!(r.pending(), 0); + // A late duplicate starts a fresh pending message. + assert_eq!(r.offer(1, &frames[0], 3).unwrap(), None); + assert_eq!(r.pending(), 1); + } + + #[test] + fn conflicting_frag_count_drops_entry_and_errors() { + let frames = fragment(&[8u8; 30], 6, 16).unwrap(); // frag_count 3 + let mut r = Reassembler::new(4); + assert_eq!(r.offer(1, &frames[0], 1).unwrap(), None); + let mut lying = frames[1].clone(); + lying[5] = 4; // claims frag_count 4 for the same (from, msg_id) + assert_eq!(r.offer(1, &lying, 2), Err(TransportError::Inconsistent)); + assert_eq!(r.pending(), 0, "conflicting entry must be dropped"); + } + + #[test] + fn single_fragment_and_empty_messages_round_trip() { + let mut r = Reassembler::new(1); + // Small message: one frame, still headered, completes immediately. + let frames = fragment(b"hi", 1, 51).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].len(), FRAG_HEADER_LEN + 2); + assert_eq!(r.offer(1, &frames[0], 1).unwrap().unwrap(), b"hi".to_vec()); + // Empty message: one header-only frame. + let frames = fragment(&[], 2, 51).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].len(), FRAG_HEADER_LEN); + assert_eq!( + r.offer(1, &frames[0], 2).unwrap().unwrap(), + Vec::::new() + ); + assert_eq!(r.pending(), 0); + } + + #[test] + fn mtu_edges() { + // mtu 7: 1-byte chunks, works. + let msg = [0xABu8; 5]; + let frames = fragment(&msg, 3, 7).unwrap(); + assert_eq!(frames.len(), 5); + let mut r = Reassembler::new(4); + let mut out = None; + for (i, f) in frames.iter().enumerate() { + out = r.offer(1, f, i as u64).unwrap(); + } + assert_eq!(out.unwrap(), msg.to_vec()); + // mtu 6: header only, no room for data. + assert_eq!(fragment(&msg, 3, 6), Err(TransportError::MtuTooSmall(6))); + assert_eq!(fragment(&msg, 3, 0), Err(TransportError::MtuTooSmall(0))); + } + + #[test] + fn oversized_message_rejected() { + // mtu 7 → chunk 1 byte → max 255 bytes. + assert!(fragment(&[0u8; 255], 1, 7).is_ok()); + assert_eq!( + fragment(&[0u8; 256], 1, 7), + Err(TransportError::TooLarge { len: 256, max: 255 }) + ); + } + + #[test] + fn parse_rejects_bad_headers() { + assert!(matches!( + Fragment::parse(&[]), + Err(TransportError::WrongLength { expected: 6, .. }) + )); + assert!(matches!( + Fragment::parse(&[FRAG_MAGIC, FRAG_VERSION, 0, 0, 0]), + Err(TransportError::WrongLength { .. }) + )); + assert_eq!( + Fragment::parse(&[0x00, FRAG_VERSION, 0, 0, 0, 1]), + Err(TransportError::BadMagic(0x00)) + ); + assert_eq!( + Fragment::parse(&[FRAG_MAGIC, 2, 0, 0, 0, 1]), + Err(TransportError::BadVersion(2)) + ); + assert!(matches!( + Fragment::parse(&[FRAG_MAGIC, FRAG_VERSION, 0, 0, 0, 0]), + Err(TransportError::BadFragment(_)) + )); + // frag_idx >= frag_count + assert!(matches!( + Fragment::parse(&[FRAG_MAGIC, FRAG_VERSION, 0, 0, 3, 3]), + Err(TransportError::BadFragment(_)) + )); + } + + #[test] + fn parse_never_panics_on_arbitrary_bytes() { + // Deterministic LCG pseudo-fuzz over lengths 0..80. + let mut x: u64 = 0xF701_F701_F701_F701; + for len in 0..80usize { + let mut buf = vec![0u8; len]; + for b in &mut buf { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + *b = (x >> 56) as u8; + } + let _ = Fragment::parse(&buf); // must not panic + // And a reassembler must swallow whatever parses. + let mut r = Reassembler::new(2); + let _ = r.offer(0, &buf, len as u64); + } + } +} diff --git a/crates/rucelium-transport/src/lib.rs b/crates/rucelium-transport/src/lib.rs new file mode 100644 index 0000000..d6e50b1 --- /dev/null +++ b/crates/rucelium-transport/src/lib.rs @@ -0,0 +1,155 @@ +//! # rucelium-transport +//! +//! Constrained-link transport for the RuCelium fabric: a compact signed +//! envelope and an MTU fragmentation layer (companion to the ADR-264 §11 +//! ABI boundary). +//! +//! ## Motivation: the LoRaWAN DR0 budget +//! +//! The v1 signed envelope ([`rucelium_abi::SignedEnvRecordV1`], deterministic +//! CBOR `[payload, pubkey, signature]`) encodes to **151 bytes** — but +//! LoRaWAN DR0 caps the application payload at **51 bytes** per datagram. +//! Two fixes, composable: +//! +//! - **(a) Compact envelope v2** ([`envelope`]): drop the 32-byte pubkey from +//! the wire — the gateway already holds the device registry keyed by the +//! `node_id` inside the 48-byte payload, so the key travels *by reference*. +//! A packed 2-byte header replaces the CBOR framing: +//! `2 + 48 + 64 = 114` bytes vs v1's 151. +//! - **(b) Fragmentation** ([`frag`]): 114 bytes still exceeds one DR0 +//! datagram, so a 6-byte-header fragment/reassembly layer splits any +//! message across up to 255 datagrams. A compact envelope at the DR0 MTU +//! is exactly **3 frames** ([`frag::fragment_compact`]). +//! +//! Rehydration via [`envelope::to_v1`] turns a verified compact envelope back +//! into a [`rucelium_abi::SignedEnvRecordV1`], so the existing ingest +//! pipeline downstream of the gateway is unchanged. +//! +//! The arithmetic, pinned: +//! +//! ``` +//! use rucelium_abi::SignedEnvRecordV1; +//! use rucelium_transport::{COMPACT_ENV_V2_LEN, LORAWAN_DR0_MTU}; +//! +//! let v1 = SignedEnvRecordV1 { payload: [0; 48], pubkey: [0; 32], signature: [0; 64] }; +//! assert_eq!(v1.encode().len(), 151); // v1: CBOR framing + embedded pubkey +//! assert_eq!(COMPACT_ENV_V2_LEN, 2 + 48 + 64); // v2: 114 bytes +//! // ...but 114 still exceeds one DR0 datagram — hence the frag layer. +//! assert!(COMPACT_ENV_V2_LEN > LORAWAN_DR0_MTU); +//! ``` +//! +//! Everything here is deterministic (callers pass `now_ns`), allocation-light, +//! bounds-checked, and free of `unsafe` and panics on untrusted input. + +#![doc(html_root_url = "https://docs.rs/rucelium-transport/0.1.0")] + +pub mod envelope; +pub mod frag; + +pub use envelope::{ + sign_compact, to_v1, verify_compact, CompactEnvV2, COMPACT_ENV_MAGIC, COMPACT_ENV_V2_LEN, + COMPACT_ENV_VERSION, +}; +pub use frag::{ + fragment, fragment_compact, Fragment, Reassembler, FRAG_HEADER_LEN, FRAG_MAGIC, FRAG_VERSION, + LORAWAN_DR0_MTU, +}; + +use std::fmt; + +/// Errors raised by the transport layer. Every failure is a rejection — the +/// transport never repairs or guesses (same posture as the ABI boundary). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportError { + /// A buffer had the wrong length for the structure being parsed. + WrongLength { + /// Expected length in bytes (for fragments: the minimum). + expected: usize, + /// Actual length received. + actual: usize, + }, + /// The leading magic byte did not match. + BadMagic(u8), + /// The version byte did not match. + BadVersion(u8), + /// The public key bytes were not a valid ed25519 point. + BadKey, + /// The ed25519 signature did not verify over the payload. + BadSignature, + /// The requested MTU cannot carry a fragment header plus one chunk byte. + MtuTooSmall(usize), + /// The message does not fit in 255 fragments at the requested MTU. + TooLarge { + /// Message length in bytes. + len: usize, + /// Maximum message length at this MTU. + max: usize, + }, + /// A fragment header was structurally invalid. + BadFragment(String), + /// Fragments for the same `(from, msg_id)` disagreed about the message + /// shape; the pending entry was dropped. + Inconsistent, +} + +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TransportError::WrongLength { expected, actual } => { + write!(f, "wrong length: expected {expected} bytes, got {actual}") + } + TransportError::BadMagic(b) => write!(f, "bad magic byte {b:#04x}"), + TransportError::BadVersion(v) => write!(f, "bad version byte {v}"), + TransportError::BadKey => write!(f, "invalid ed25519 public key"), + TransportError::BadSignature => write!(f, "signature verification failed"), + TransportError::MtuTooSmall(mtu) => { + write!(f, "mtu {mtu} too small: need header plus one chunk byte") + } + TransportError::TooLarge { len, max } => { + write!( + f, + "message of {len} bytes exceeds {max}-byte fragment limit" + ) + } + TransportError::BadFragment(m) => write!(f, "bad fragment: {m}"), + TransportError::Inconsistent => { + write!( + f, + "inconsistent fragments for message; pending entry dropped" + ) + } + } + } +} + +impl std::error::Error for TransportError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_is_informative() { + let cases: Vec<(TransportError, &str)> = vec![ + ( + TransportError::WrongLength { + expected: 114, + actual: 3, + }, + "expected 114", + ), + (TransportError::BadMagic(0x00), "0x00"), + (TransportError::BadVersion(9), "9"), + (TransportError::BadKey, "public key"), + (TransportError::BadSignature, "verification failed"), + (TransportError::MtuTooSmall(6), "6"), + (TransportError::TooLarge { len: 999, max: 45 }, "999"), + (TransportError::BadFragment("x".to_string()), "x"), + (TransportError::Inconsistent, "inconsistent"), + ]; + for (err, needle) in cases { + let s = err.to_string(); + assert!(s.contains(needle), "{s:?} should contain {needle:?}"); + } + } +} diff --git a/crates/rucelium-worldgraph/Cargo.toml b/crates/rucelium-worldgraph/Cargo.toml new file mode 100644 index 0000000..2203464 --- /dev/null +++ b/crates/rucelium-worldgraph/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rucelium-worldgraph" +version.workspace = true +edition.workspace = true +description = "RuCelium environmental WorldGraph: typed sensor/ecosystem nodes, geospatial registration, evidence + contradiction edges, RuView RF context bridge (ADR-264 §5.2/§8)" +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["environmental", "graph", "digital-twin", "fusion"] +categories = ["science"] + +[dependencies] +rucelium-core = { workspace = true } +rufield-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-worldgraph/src/graph.rs b/crates/rucelium-worldgraph/src/graph.rs new file mode 100644 index 0000000..8cfde28 --- /dev/null +++ b/crates/rucelium-worldgraph/src/graph.rs @@ -0,0 +1,505 @@ +//! The environmental WorldGraph (ADR-264 §5.2): typed nodes, geospatial +//! registration, typed evidence edges, contradiction tracking, and JSON +//! persistence (ADR-139 heritage). + +use rucelium_core::{EnvSample, GeoPoint, SensorModality}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; + +/// Mean Earth radius in metres, used by [`haversine_m`]. +pub const EARTH_RADIUS_M: f64 = 6_371_000.0; + +/// Errors from WorldGraph operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GraphError { + /// An edge endpoint referenced a node key that does not exist. + UnknownNode(String), + /// JSON persistence (serialize / deserialize) failed. + Persist(String), +} + +impl fmt::Display for GraphError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GraphError::UnknownNode(key) => write!(f, "unknown graph node: {key}"), + GraphError::Persist(msg) => write!(f, "worldgraph persistence error: {msg}"), + } + } +} + +impl std::error::Error for GraphError {} + +/// A typed WorldGraph node (ADR-264 §5.2). Extends the ADR-139 node set with +/// the environmental kinds of the fabric. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphNode { + /// A physical (or RF-context) sensor registered in the graph. + Sensor { + /// Producing device identity (0 for string-identified RF devices). + node_id: u64, + /// Sensor modality. + modality: SensorModality, + /// Geospatial registration of the sensor. + geo: GeoPoint, + /// Placement hint (e.g. `riverbank_post`, `auto_registered`). + placement: String, + }, + /// A named ecosystem feature (a wetland, a stand of oaks, a river reach). + Ecosystem { + /// Human-readable name. + name: String, + /// Ecosystem kind (e.g. `wetland`, `forest_stand`). + kind: String, + /// Geospatial registration. + geo: GeoPoint, + }, + /// A biome-scale region grouping sensors and ecosystems. + Region { + /// Owning biome id (e.g. `biome/thames-estuary`). + biome_id: String, + /// Human-readable region name. + name: String, + }, + /// A reference-grade calibration anchor station (ADR-264 §12). + Anchor { + /// Anchor station identifier. + station_id: String, + /// Modality the anchor references. + modality: SensorModality, + /// Geospatial registration. + geo: GeoPoint, + }, +} + +/// Typed edge kinds between WorldGraph nodes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EdgeKind { + /// Evidence from `from` supports the state of `to`. + Supports, + /// Evidence from `from` contradicts the state of `to` (tracked, never + /// silently resolved). + Contradicts, + /// The two nodes are physically co-located. + Colocated, + /// `from` lies within region `to`. + WithinRegion, + /// `from` was derived from `to`. + DerivedFrom, +} + +/// A directed, typed, weighted evidence edge. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Edge { + /// Source node key. + pub from: String, + /// Destination node key. + pub to: String, + /// Edge kind. + pub kind: EdgeKind, + /// Evidence weight, clamped to `0.0..=1.0`. + pub weight: f32, + /// Free-text annotation (e.g. a contradiction reason). + pub note: String, +} + +/// The environmental WorldGraph: typed nodes keyed by stable string keys, +/// adjacency-listed typed edges, and a contradiction counter. +/// +/// All maps are [`BTreeMap`]s so iteration (and therefore serialization and +/// every derived listing) is deterministic. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct WorldGraph { + /// Nodes by stable string key (e.g. `sensor/7`, `region/thames`). + nodes: BTreeMap, + /// Outgoing edges by source node key. + edges: BTreeMap>, + /// Number of contradictions recorded since the graph was created. + contradiction_count: u64, +} + +impl WorldGraph { + /// Empty graph. + #[must_use] + pub fn new() -> Self { + WorldGraph::default() + } + + /// Insert a node under `key`. Returns `false` (without overwriting) if a + /// node already exists under that key — registered topology is never + /// silently replaced. + pub fn add_node(&mut self, key: impl Into, node: GraphNode) -> bool { + let key = key.into(); + if self.nodes.contains_key(&key) { + return false; + } + self.nodes.insert(key, node); + true + } + + /// Look up a node by key. + #[must_use] + pub fn node(&self, key: &str) -> Option<&GraphNode> { + self.nodes.get(key) + } + + /// Number of nodes. + #[must_use] + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// True if the graph has no nodes. + #[must_use] + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Add a directed typed edge. Both endpoints must already exist; + /// `weight` is clamped to `0.0..=1.0` (non-finite weights clamp to 0). + pub fn add_edge( + &mut self, + from: &str, + to: &str, + kind: EdgeKind, + weight: f32, + note: impl Into, + ) -> Result<(), GraphError> { + if !self.nodes.contains_key(from) { + return Err(GraphError::UnknownNode(from.to_string())); + } + if !self.nodes.contains_key(to) { + return Err(GraphError::UnknownNode(to.to_string())); + } + let weight = if weight.is_finite() { + weight.clamp(0.0, 1.0) + } else { + 0.0 + }; + self.edges.entry(from.to_string()).or_default().push(Edge { + from: from.to_string(), + to: to.to_string(), + kind, + weight, + note: note.into(), + }); + Ok(()) + } + + /// Outgoing edges of a node (empty slice for unknown keys). + #[must_use] + pub fn edges_from(&self, key: &str) -> &[Edge] { + self.edges.get(key).map_or(&[], Vec::as_slice) + } + + /// All edges, in deterministic (source-key, insertion) order. + pub fn edges(&self) -> impl Iterator { + self.edges.values().flatten() + } + + /// Register an accepted observation into the graph, ensuring a `Sensor` + /// node exists at key `sensor/{node_id}` (created from the sample's + /// modality and geo with placement `"auto_registered"` if absent), and + /// return that key. + /// + /// This is what makes every accepted observation mappable into the graph + /// (ADR-264 §14 acceptance criterion 6) — registration is idempotent and + /// never overwrites an existing sensor node. + pub fn register_observation(&mut self, sample: &EnvSample) -> String { + let key = format!("sensor/{}", sample.node_id); + if !self.nodes.contains_key(&key) { + self.nodes.insert( + key.clone(), + GraphNode::Sensor { + node_id: sample.node_id, + modality: sample.modality, + geo: sample.geo, + placement: "auto_registered".to_string(), + }, + ); + } + key + } + + /// Record a contradiction between two nodes: adds a `Contradicts` edge + /// (weight 1.0) annotated with `reason` and increments the contradiction + /// counter. Contradictions are tracked, never silently resolved. + pub fn record_contradiction( + &mut self, + a_key: &str, + b_key: &str, + reason: impl Into, + ) -> Result<(), GraphError> { + self.add_edge(a_key, b_key, EdgeKind::Contradicts, 1.0, reason)?; + self.contradiction_count += 1; + Ok(()) + } + + /// All `Contradicts` edges, in deterministic order. + #[must_use] + pub fn contradictions(&self) -> Vec<&Edge> { + self.edges() + .filter(|e| e.kind == EdgeKind::Contradicts) + .collect() + } + + /// Number of contradictions recorded via [`WorldGraph::record_contradiction`] + /// (and the RF bridge's contradiction path) since the graph was created. + #[must_use] + pub fn contradiction_count(&self) -> u64 { + self.contradiction_count + } + + /// All `Sensor` nodes within `radius_m` metres of `center` (haversine + /// great-circle distance), as `(node key, node_id)` pairs sorted by key + /// for determinism. + #[must_use] + pub fn sensors_within_m(&self, center: GeoPoint, radius_m: f64) -> Vec<(String, u64)> { + // BTreeMap iteration is already key-sorted, so the result is too. + self.nodes + .iter() + .filter_map(|(key, node)| match node { + GraphNode::Sensor { node_id, geo, .. } if haversine_m(center, *geo) <= radius_m => { + Some((key.clone(), *node_id)) + } + _ => None, + }) + .collect() + } + + /// Convenience: link a sensor into a region with a `WithinRegion` edge + /// (weight 1.0). Both nodes must already exist. + pub fn link_within_region( + &mut self, + sensor_key: &str, + region_key: &str, + ) -> Result<(), GraphError> { + self.add_edge(sensor_key, region_key, EdgeKind::WithinRegion, 1.0, "") + } + + /// Serialize the full graph (persisted topology, ADR-139 heritage) to + /// JSON. Deterministic: `BTreeMap`s serialize in key order. + #[must_use] + pub fn to_json(&self) -> String { + // Serialization of this type cannot fail: all map keys are strings + // and all floats are finite by construction (weights are clamped). + serde_json::to_string(self).unwrap_or_default() + } + + /// Restore a graph from its [`WorldGraph::to_json`] form. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| GraphError::Persist(e.to_string())) + } +} + +/// Great-circle (haversine) distance between two points in metres, using a +/// mean Earth radius of [`EARTH_RADIUS_M`] (6 371 000 m). Altitude is ignored. +#[must_use] +pub fn haversine_m(a: GeoPoint, b: GeoPoint) -> f64 { + let lat_a = a.latitude_deg().to_radians(); + let lat_b = b.latitude_deg().to_radians(); + let d_lat = (b.latitude_deg() - a.latitude_deg()).to_radians(); + let d_lon = (b.longitude_deg() - a.longitude_deg()).to_radians(); + let h = (d_lat / 2.0).sin().powi(2) + lat_a.cos() * lat_b.cos() * (d_lon / 2.0).sin().powi(2); + 2.0 * EARTH_RADIUS_M * h.sqrt().min(1.0).asin() +} + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_core::{SampleProvenance, Uncertainty}; + + pub(crate) fn sample(node_id: u64) -> EnvSample { + EnvSample { + node_id, + sequence: 1, + measured_ns: 1_000, + received_ns: 2_000, + geo: GeoPoint::new(514_778_216, -14_767, 46_000).unwrap(), + modality: SensorModality::Weather, + observed_property: "air_temperature".into(), + unit: "Cel".into(), + value: 21.5, + quality: 0.98, + uncertainty: Uncertainty::symmetric(21.5, 0.3), + calibration_id: 3, + flags: 0, + battery_mv: 3600, + provenance: SampleProvenance { + firmware_hash: "sha256:abc".into(), + signer_pubkey_hex: "00ff".into(), + verified: true, + lineage: vec!["cal:3".into()], + }, + } + } + + fn region() -> GraphNode { + GraphNode::Region { + biome_id: "biome/thames-estuary".into(), + name: "Thames Estuary".into(), + } + } + + #[test] + fn register_observation_is_idempotent_with_stable_key() { + let mut g = WorldGraph::new(); + let s = sample(7); + let key = g.register_observation(&s); + assert_eq!(key, "sensor/7"); + assert_eq!(g.len(), 1); + + // Re-registering the same node is idempotent and never overwrites. + let key2 = g.register_observation(&s); + assert_eq!(key2, "sensor/7"); + assert_eq!(g.len(), 1); + match g.node("sensor/7").unwrap() { + GraphNode::Sensor { + node_id, placement, .. + } => { + assert_eq!(*node_id, 7); + assert_eq!(placement, "auto_registered"); + } + other => panic!("expected sensor node, got {other:?}"), + } + } + + #[test] + fn add_node_refuses_overwrite() { + let mut g = WorldGraph::new(); + assert!(g.add_node("region/thames", region())); + assert!(!g.add_node("region/thames", region())); + assert_eq!(g.len(), 1); + assert!(!g.is_empty()); + } + + #[test] + fn add_edge_unknown_node_fails_and_weights_clamp() { + let mut g = WorldGraph::new(); + g.register_observation(&sample(1)); + g.add_node("region/thames", region()); + + assert_eq!( + g.add_edge("sensor/1", "region/nowhere", EdgeKind::Supports, 0.5, ""), + Err(GraphError::UnknownNode("region/nowhere".into())) + ); + assert_eq!( + g.add_edge("sensor/99", "region/thames", EdgeKind::Supports, 0.5, ""), + Err(GraphError::UnknownNode("sensor/99".into())) + ); + + g.add_edge("sensor/1", "region/thames", EdgeKind::Supports, 3.5, "hi") + .unwrap(); + g.add_edge("sensor/1", "region/thames", EdgeKind::Supports, -1.0, "lo") + .unwrap(); + let weights: Vec = g.edges_from("sensor/1").iter().map(|e| e.weight).collect(); + assert_eq!(weights, vec![1.0, 0.0]); + assert_eq!(g.edges().count(), 2); + assert!(g.edges_from("sensor/none").is_empty()); + } + + #[test] + fn haversine_one_degree_latitude() { + let a = GeoPoint::new(0, 0, 0).unwrap(); + let b = GeoPoint::new(10_000_000, 0, 0).unwrap(); // +1 degree latitude + let d = haversine_m(a, b); + let expected = 111_190.0; + assert!( + (d - expected).abs() / expected < 0.01, + "expected ~{expected} m, got {d} m" + ); + // Symmetric and zero at identity. + assert!((haversine_m(b, a) - d).abs() < 1e-6); + assert_eq!(haversine_m(a, a), 0.0); + } + + #[test] + fn sensors_within_m_filters_and_sorts() { + let mut g = WorldGraph::new(); + let center = GeoPoint::new(514_778_216, -14_767, 0).unwrap(); + + let mut near_1 = sample(3); + near_1.geo = center; + let mut near_2 = sample(1); + near_2.geo = GeoPoint::new(514_779_000, -14_000, 0).unwrap(); // ~ 10s of m + let mut far = sample(2); + far.geo = GeoPoint::new(524_778_216, -14_767, 0).unwrap(); // ~111 km north + + g.register_observation(&near_1); + g.register_observation(&near_2); + g.register_observation(&far); + // Non-sensor nodes are never returned. + g.add_node("region/thames", region()); + + let hits = g.sensors_within_m(center, 500.0); + assert_eq!( + hits, + vec![("sensor/1".to_string(), 1), ("sensor/3".to_string(), 3)] + ); + } + + #[test] + fn contradiction_tracking() { + let mut g = WorldGraph::new(); + g.register_observation(&sample(1)); + g.register_observation(&sample(2)); + assert!(g.contradictions().is_empty()); + assert_eq!(g.contradiction_count(), 0); + + g.record_contradiction("sensor/1", "sensor/2", "disagreeing water level") + .unwrap(); + let c = g.contradictions(); + assert_eq!(c.len(), 1); + assert_eq!(c[0].kind, EdgeKind::Contradicts); + assert_eq!(c[0].weight, 1.0); + assert_eq!(c[0].note, "disagreeing water level"); + assert_eq!(g.contradiction_count(), 1); + + assert_eq!( + g.record_contradiction("sensor/1", "sensor/9", "x"), + Err(GraphError::UnknownNode("sensor/9".into())) + ); + assert_eq!(g.contradiction_count(), 1); + } + + #[test] + fn json_round_trip() { + let mut g = WorldGraph::new(); + g.register_observation(&sample(1)); + g.register_observation(&sample(2)); + g.add_node("region/thames", region()); + g.add_node( + "anchor/met-01", + GraphNode::Anchor { + station_id: "met-01".into(), + modality: SensorModality::Weather, + geo: GeoPoint::new(514_000_000, 0, 0).unwrap(), + }, + ); + g.add_node( + "eco/reedbed", + GraphNode::Ecosystem { + name: "North Reedbed".into(), + kind: "wetland".into(), + geo: GeoPoint::new(514_500_000, 100_000, 0).unwrap(), + }, + ); + g.link_within_region("sensor/1", "region/thames").unwrap(); + g.add_edge("sensor/1", "eco/reedbed", EdgeKind::Colocated, 0.9, "") + .unwrap(); + g.record_contradiction("sensor/1", "sensor/2", "drift") + .unwrap(); + + let json = g.to_json(); + let back = WorldGraph::from_json(&json).unwrap(); + assert_eq!(g, back); + assert_eq!(back.contradiction_count(), 1); + + assert!(matches!( + WorldGraph::from_json("not json"), + Err(GraphError::Persist(_)) + )); + } +} diff --git a/crates/rucelium-worldgraph/src/lib.rs b/crates/rucelium-worldgraph/src/lib.rs new file mode 100644 index 0000000..7215ff7 --- /dev/null +++ b/crates/rucelium-worldgraph/src/lib.rs @@ -0,0 +1,31 @@ +//! # rucelium-worldgraph +//! +//! The RuCelium environmental **WorldGraph** (ADR-264 §5.2) and the RuView +//! RF-context bridge (ADR-264 §8). +//! +//! The WorldGraph extends the ADR-139 concept — typed nodes, geospatial +//! registration, typed evidence edges, contradiction tracking — with the +//! environmental node kinds of the fabric: sensors, ecosystems, regions, and +//! calibration anchors. Every accepted observation must be mappable into the +//! graph (ADR-264 §14 acceptance criterion 6); [`WorldGraph::register_observation`] +//! guarantees that by auto-registering a sensor node for any accepted +//! [`rucelium_core::EnvSample`]. +//! +//! The [`rf`] module bridges RuField MFS [`rufield_core::FieldEvent`]s +//! (WiFi-CSI RF observations) into the graph under the §8 normative rule: +//! +//! > RuView outputs are supporting evidence. They may raise or lower +//! > confidence and create contradiction edges. They may **never** be the +//! > sole basis for an event above `Advisory` severity, and they are never +//! > ground truth. + +#![doc(html_root_url = "https://docs.rs/rucelium-worldgraph/0.1.0")] + +pub mod graph; +pub mod rf; + +pub use graph::{haversine_m, Edge, EdgeKind, GraphError, GraphNode, WorldGraph}; +pub use rf::{ + assess_plausibility, fuse_rf_context, rf_only_severity_cap, Plausibility, RfContext, + RF_MAX_EVIDENCE_WEIGHT, +}; diff --git a/crates/rucelium-worldgraph/src/rf.rs b/crates/rucelium-worldgraph/src/rf.rs new file mode 100644 index 0000000..a39b909 --- /dev/null +++ b/crates/rucelium-worldgraph/src/rf.rs @@ -0,0 +1,371 @@ +//! RuView RF context bridge (ADR-264 §8). +//! +//! **Normative rule (ADR-264 §8): RF is supporting evidence, NEVER ground +//! truth.** RuView outputs may raise or lower confidence and create +//! contradiction edges, but they may never be the sole basis for an +//! environmental event above [`rucelium_core::Severity::Advisory`], and +//! their evidence weight in the WorldGraph is capped at +//! [`RF_MAX_EVIDENCE_WEIGHT`]. + +use crate::graph::{EdgeKind, GraphError, GraphNode, WorldGraph}; +use rucelium_core::{GeoPoint, SensorModality, Severity}; +use serde::{Deserialize, Serialize}; + +/// Hard cap on the weight of any RF-derived evidence edge (ADR-264 §8). +/// RF context can nudge confidence; it can never dominate physical sensing. +pub const RF_MAX_EVIDENCE_WEIGHT: f32 = 0.3; + +/// Contextual RF evidence distilled from a RuField MFS WiFi-CSI +/// [`rufield_core::FieldEvent`]. This is context, not measurement: it never +/// enters the sample path, only the evidence-edge path. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RfContext { + /// The originating `FieldEvent` id. + pub source_event_id: String, + /// The RF device id (e.g. `sensor_room_01`). + pub device_id: String, + /// Observation confidence `0.0..=1.0` as reported by the RF stack. + pub confidence: f32, + /// The `motion_energy` derived feature, if the encoder produced one. + pub motion_energy: Option, + /// Labels attached to the RF observation. + pub labels: Vec, + /// Capture time, nanoseconds since Unix epoch. + pub timestamp_ns: u64, +} + +impl RfContext { + /// Distill RF context from a RuField MFS field event. Accepts only + /// events whose tensor modality is [`rufield_core::Modality::WifiCsi`] + /// (the RuView RF-context modality, ADR-264 §5.2); every other modality + /// yields `None` — this bridge never guesses. + #[must_use] + pub fn from_field_event(ev: &rufield_core::FieldEvent) -> Option { + if ev.tensor.modality != rufield_core::Modality::WifiCsi { + return None; + } + Some(RfContext { + source_event_id: ev.event_id.clone(), + device_id: ev.sensor.device_id.clone(), + confidence: ev.observation.confidence, + motion_energy: ev.observation.features.get("motion_energy").copied(), + labels: ev.observation.labels.clone(), + timestamp_ns: ev.timestamp_ns, + }) + } +} + +/// Outcome of checking an environmental observation against RF context +/// (ADR-264 §8 item 9: "validation that an observation is physically +/// plausible"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Plausibility { + /// The RF context agrees with the observation. + Supported, + /// The RF context disagrees with the observation. + Contradicted, + /// The RF context is outside the temporal window — it says nothing. + NoContext, +} + +/// Assess whether RF context supports or contradicts an environmental +/// observation. +/// +/// * `sample_indicates_change` — whether the environmental sample itself +/// indicates activity or an anomaly (decided by the caller's detector; +/// this function does not guess modality thresholds). +/// * `sample_measured_ns` — the sample's measurement time. +/// +/// Returns [`Plausibility::NoContext`] when the RF observation is more than +/// `window_ns` away from the sample in time. Otherwise the RF motion signal +/// (`motion_energy > 0.5`, absent treated as no motion) is compared with +/// `sample_indicates_change`: agreement is [`Plausibility::Supported`], +/// disagreement is [`Plausibility::Contradicted`]. Either way the RF verdict +/// is context only — never ground truth (ADR-264 §8). +#[must_use] +pub fn assess_plausibility( + sample_indicates_change: bool, + sample_measured_ns: u64, + rf: &RfContext, + window_ns: u64, +) -> Plausibility { + if rf.timestamp_ns.abs_diff(sample_measured_ns) > window_ns { + return Plausibility::NoContext; + } + let rf_indicates_motion = rf.motion_energy.unwrap_or(0.0) > 0.5; + if rf_indicates_motion == sample_indicates_change { + Plausibility::Supported + } else { + Plausibility::Contradicted + } +} + +/// Fuse RF context into the WorldGraph as a capped evidence edge. +/// +/// Ensures an RF node exists at key `rf/{device_id}` (a `Sensor` node with +/// modality [`SensorModality::WifiCsi`], zeroed geo, placement +/// `"rf_context"`), then: +/// +/// * [`Plausibility::Supported`] — adds a `Supports` edge from the RF node +/// to `sensor_key` with weight `rf.confidence.min(RF_MAX_EVIDENCE_WEIGHT)` +/// (the §8 cap: RF evidence can never exceed 0.3, regardless of how +/// confident the RF stack is). +/// * [`Plausibility::Contradicted`] — records a contradiction (a +/// `Contradicts` edge, counted by the graph's contradiction counter). +/// * [`Plausibility::NoContext`] — adds nothing and returns `Ok(None)`. +/// +/// On success returns `Ok(Some(rf_node_key))`. +pub fn fuse_rf_context( + graph: &mut WorldGraph, + sensor_key: &str, + rf: &RfContext, + plausibility: Plausibility, +) -> Result, GraphError> { + if plausibility == Plausibility::NoContext { + return Ok(None); + } + let rf_key = format!("rf/{}", rf.device_id); + graph.add_node( + rf_key.clone(), + GraphNode::Sensor { + node_id: 0, + modality: SensorModality::WifiCsi, + geo: GeoPoint { + latitude_e7: 0, + longitude_e7: 0, + altitude_mm: 0, + }, + placement: "rf_context".to_string(), + }, + ); + match plausibility { + Plausibility::Supported => { + let weight = rf.confidence.min(RF_MAX_EVIDENCE_WEIGHT); + graph.add_edge( + &rf_key, + sensor_key, + EdgeKind::Supports, + weight, + format!("rf:{}", rf.source_event_id), + )?; + } + Plausibility::Contradicted => { + graph.record_contradiction( + &rf_key, + sensor_key, + format!("rf context disagrees: rf:{}", rf.source_event_id), + )?; + } + Plausibility::NoContext => unreachable!("handled above"), + } + Ok(Some(rf_key)) +} + +/// Clamp a severity to at most [`Severity::Advisory`]. +/// +/// **This is the ADR-264 §8 normative rule, enforced:** an event whose only +/// evidence is RF context may NEVER exceed `Advisory` severity. RF is +/// supporting evidence — it raises or lowers confidence in physically +/// sensed events, but on its own it can only ever inform, never alarm. +/// Callers MUST route any RF-only event severity through this cap. +#[must_use] +pub fn rf_only_severity_cap(severity: Severity) -> Severity { + severity.min(Severity::Advisory) +} + +#[cfg(test)] +mod tests { + use super::*; + use rucelium_core::EnvSample; + use rucelium_core::SampleProvenance; + use rucelium_core::Uncertainty; + use rufield_core::{ + FieldAxis, FieldEvent, FieldTensor, Modality, Observation, PrivacyClass, ProvenanceRef, + SensorDescriptor, + }; + + fn field_event(modality: Modality, motion_energy: f32, confidence: f32) -> FieldEvent { + let tensor = FieldTensor::new( + 1_000, + modality, + vec![FieldAxis::Frequency], + vec![2], + vec![0.1, 0.2], + 0.9, + 0.01, + Some("cal".into()), + PrivacyClass::P2, + ) + .unwrap(); + let mut observation = Observation::occupancy(confidence, PrivacyClass::P2); + observation + .features + .insert("motion_energy".to_string(), motion_energy); + observation.labels = vec!["person_present".to_string()]; + FieldEvent::new( + "01JRF0000000000000000000EV", + 1_000, + SensorDescriptor { + modality: "wifi_csi".into(), + vendor: "esp32_c6".into(), + device_id: "rf_dev_01".into(), + placement: "ceiling_corner".into(), + clock_domain: "local_ptp".into(), + }, + tensor, + observation, + ProvenanceRef { + raw_hash: "sha256:raw".into(), + firmware_hash: "sha256:fw".into(), + model_id: "ruvector_field_encoder_v1".into(), + calibration_id: "cal".into(), + synthetic: true, + signature_hex: None, + signer_pubkey_hex: None, + }, + ) + } + + fn env_sample(node_id: u64) -> EnvSample { + EnvSample { + node_id, + sequence: 1, + measured_ns: 1_000, + received_ns: 2_000, + geo: GeoPoint::new(514_778_216, -14_767, 0).unwrap(), + modality: SensorModality::Acoustic, + observed_property: "acoustic_activity_index".into(), + unit: "1".into(), + value: 0.8, + quality: 0.95, + uncertainty: Uncertainty::symmetric(0.8, 0.05), + calibration_id: 1, + flags: 0, + battery_mv: 3600, + provenance: SampleProvenance { + firmware_hash: "sha256:abc".into(), + signer_pubkey_hex: "00ff".into(), + verified: true, + lineage: vec![], + }, + } + } + + #[test] + fn from_field_event_accepts_only_wifi_csi() { + let ev = field_event(Modality::WifiCsi, 0.8, 0.9); + let rf = RfContext::from_field_event(&ev).unwrap(); + assert_eq!(rf.source_event_id, "01JRF0000000000000000000EV"); + assert_eq!(rf.device_id, "rf_dev_01"); + assert_eq!(rf.confidence, 0.9); + assert_eq!(rf.motion_energy, Some(0.8)); + assert_eq!(rf.labels, vec!["person_present".to_string()]); + assert_eq!(rf.timestamp_ns, 1_000); + + let other = field_event(Modality::MmwaveRadar, 0.8, 0.9); + assert_eq!(RfContext::from_field_event(&other), None); + } + + #[test] + fn rf_evidence_weight_never_exceeds_cap() { + let ev = field_event(Modality::WifiCsi, 0.9, 0.99); + let rf = RfContext::from_field_event(&ev).unwrap(); + + let mut g = WorldGraph::new(); + let sensor_key = g.register_observation(&env_sample(7)); + let rf_key = fuse_rf_context(&mut g, &sensor_key, &rf, Plausibility::Supported) + .unwrap() + .unwrap(); + assert_eq!(rf_key, "rf/rf_dev_01"); + + let edges = g.edges_from(&rf_key); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, EdgeKind::Supports); + assert!(edges[0].weight <= RF_MAX_EVIDENCE_WEIGHT); + assert_eq!(edges[0].weight, RF_MAX_EVIDENCE_WEIGHT); + + // The rf node exists with the required shape. + match g.node(&rf_key).unwrap() { + GraphNode::Sensor { + modality, + placement, + .. + } => { + assert_eq!(*modality, SensorModality::WifiCsi); + assert_eq!(placement, "rf_context"); + } + other => panic!("expected sensor node, got {other:?}"), + } + } + + #[test] + fn plausibility_agreement_disagreement_and_window() { + let ev = field_event(Modality::WifiCsi, 0.8, 0.9); // motion present + let rf = RfContext::from_field_event(&ev).unwrap(); + + // Agreement: sample indicates change, RF sees motion. + assert_eq!( + assess_plausibility(true, 1_500, &rf, 1_000), + Plausibility::Supported + ); + // Disagreement: sample indicates change, RF sees no motion. + let still = field_event(Modality::WifiCsi, 0.1, 0.9); + let rf_still = RfContext::from_field_event(&still).unwrap(); + assert_eq!( + assess_plausibility(true, 1_500, &rf_still, 1_000), + Plausibility::Contradicted + ); + // No motion + no change agrees too. + assert_eq!( + assess_plausibility(false, 1_500, &rf_still, 1_000), + Plausibility::Supported + ); + // Outside window: no context, regardless of content. + assert_eq!( + assess_plausibility(true, 5_000_000, &rf, 1_000), + Plausibility::NoContext + ); + } + + #[test] + fn fuse_contradiction_adds_contradicts_edge_and_no_context_adds_nothing() { + let ev = field_event(Modality::WifiCsi, 0.1, 0.9); + let rf = RfContext::from_field_event(&ev).unwrap(); + + let mut g = WorldGraph::new(); + let sensor_key = g.register_observation(&env_sample(7)); + + // NoContext adds nothing at all. + assert_eq!( + fuse_rf_context(&mut g, &sensor_key, &rf, Plausibility::NoContext).unwrap(), + None + ); + assert_eq!(g.len(), 1); + assert_eq!(g.edges().count(), 0); + + // Contradicted records a tracked contradiction. + let rf_key = fuse_rf_context(&mut g, &sensor_key, &rf, Plausibility::Contradicted) + .unwrap() + .unwrap(); + let c = g.contradictions(); + assert_eq!(c.len(), 1); + assert_eq!(c[0].from, rf_key); + assert_eq!(c[0].to, sensor_key); + assert_eq!(g.contradiction_count(), 1); + + // Fusing against an unknown sensor fails cleanly. + assert!(matches!( + fuse_rf_context(&mut g, "sensor/none", &rf, Plausibility::Supported), + Err(GraphError::UnknownNode(_)) + )); + } + + #[test] + fn rf_only_severity_is_capped_at_advisory() { + assert_eq!(rf_only_severity_cap(Severity::Critical), Severity::Advisory); + assert_eq!(rf_only_severity_cap(Severity::Warning), Severity::Advisory); + assert_eq!(rf_only_severity_cap(Severity::Watch), Severity::Advisory); + assert_eq!(rf_only_severity_cap(Severity::Advisory), Severity::Advisory); + } +} diff --git a/docs/ADR-264-rucelium-federated-fabric.md b/docs/ADR-264-rucelium-federated-fabric.md new file mode 100644 index 0000000..eab4604 --- /dev/null +++ b/docs/ADR-264-rucelium-federated-fabric.md @@ -0,0 +1,443 @@ +# ADR 264: RuCelium Federated Environmental Intelligence Fabric + +Status: Accepted — v0.1 reference stack + +Date: 2026 08 02 + +Deciders: rUv + +Tags: environmental sensing, federation, biome, sovereignty, calibration, lorawan, cbor, cose, sensorthings, worldgraph, ruview, rufield, mycelium + +## 1. Context + +RuField MFS (ADR-260) normalized camera-free ambient sensing into one privacy +aware, provenance rich event model. RuView proved a safe architecture for +hostile hardware boundaries: ADR 096 confines vendor and firmware complexity +to a narrow, allocation free, bounds checked C boundary while keeping +validation, DSP, events, runtime composition, and memory integration in safe +Rust. ADR 139 defined WorldGraph — an environmental digital twin with typed +Rust graph nodes, geospatial registration, sensor placement, typed evidence +edges, contradiction tracking, privacy constraints, and persisted topology. + +The next opportunity is planetary-scale environmental intelligence: soil, +water, air, acoustic biodiversity, bioelectric, and RF-contextual sensing +across forests, watersheds, cities, farms, coastlines, and protected areas. + +A flat global peer mesh is the obvious design and the wrong one. It fails on: + +1. **Battery life** — battery nodes cannot participate in chatty mesh routing. +2. **Bandwidth** — raw RF/acoustic streams cannot cross constrained uplinks + (§10 quantifies this: one CSI link ≈ 2.2 GB/day). +3. **Routing** — global DHT-style routing over LoRaWAN-class links is fantasy. +4. **Calibration** — a measurement without calibration lineage is scientifically + worthless at aggregation time; a flat mesh has no calibration authority. +5. **Sovereignty** — a forest, a farm, and a city have different owners, + retention duties, and disclosure obligations. A flat mesh has one namespace. +6. **Compromised nodes** — a flat mesh gives one compromised node global blast + radius; revocation must be containable. + +The largest failure mode is not networking. It is **scientifically invalid +data** caused by sensor drift, inconsistent calibration, undocumented +placement, and model domain shift. + +## 2. Decision + +Create **RuCelium**, a federated environmental intelligence fabric — not a +global peer mesh. Four layers, each with a sovereignty boundary: + +```text +Layer 4 Planetary federation discovery + aggregate intelligence, no ownership +Layer 3 Biome regions sovereign owners of data, models, actuators +Layer 2 Rhizome gateways Rust: verify, normalize, fuse, buffer, govern +Layer 1 Spore nodes C: sense, calibrate (fixed point), sign, transmit +``` + +Language split follows ADR 096: **C at the sensor boundary only** (drivers, +interrupts, fixed point calibration, deterministic DSP, serialization, +transport). **Rust everywhere above it** (validation, ingestion, fusion, +WorldGraph, storage, policy, federation, agents). No policy engine, graph +logic, or large model executes on a spore node. + +RuView RF sensing joins as a **contextual environmental modality** — supporting +evidence, never ground truth (§8). Mycelium-style multi-agent coordination +sits **above** the biome layer behind a mandatory governed control path (§9). + +## 3. Name + +Mycelium: the underground fungal network that connects a forest — decentralized, +regional, resilient, and symbiotic rather than centrally owned. Spores (sensor +nodes) seed it; rhizomes (gateways) root it; biomes own it; the planetary layer +merely lets biomes find each other. + +## 4. Layer 1 — Spore nodes (C) + +Small environmental sensor nodes written primarily in C. + +Responsibilities (exhaustive — nothing else runs here): + +1. Sensor drivers and interrupt handling +2. Fixed point calibration +3. Basic filtering +4. Local anomaly thresholds +5. Offline ring buffer +6. Device signing +7. Transport: LoRaWAN 1.0.4, BLE, WiFi, 802.15.4, RS485, or SDI-12 + +Modalities (the v0.1 registry, §7.1): temperature/humidity, CO₂ and volatile +compounds, PM1/PM2.5/PM10, soil moisture and conductivity, water level and +flow, acoustic biodiversity, mycelial bioelectric potential, light/UV/IR, +leaf wetness and rainfall, and RuView-compatible RF observations. + +LoRaWAN 1.0.4 is appropriate for battery powered nodes with small periodic +payloads. It is **not** appropriate for raw RF or acoustic streams — those stay +on-gateway (§10). + +## 5. Layer 2 — Rhizome gateways (Rust) + +Rust services on CognitumWRT, Linux gateways, Raspberry Pi, industrial ARM, or +partner routers. + +Responsibilities: + +1. Decode all sensor protocols +2. Verify signatures and sequence numbers +3. Normalize observations +4. Run RuView DSP and local models +5. Fuse environmental and RF evidence +6. Maintain local WorldGraph state +7. Store data during network outages +8. Publish signed regional summaries +9. Execute governed actuator commands + +### 5.1 Crate map + +The specified crate family and where v0.1 implements each concern: + +| Specified crate | v0.1 home | Notes | +|---|---|---| +| `rucelium-core` | `rucelium-core` | domain model: `EnvSample`, `EnvFrame`, `CalibrationRecord`, `EnvironmentalEvent`, `SensorModality`, `GeoPoint`, `DataClass` | +| `rucelium-c-ffi` | `rucelium-abi` | versioned C ABI (`rv_env_sample_v1`), bounds-checked alloc-free parse, deterministic CBOR, signed record envelope, shipped C header | +| `rucelium-ingest` | `rucelium-ingest` | gateway pipeline: parse → verify → replay-window → normalize | +| `rucelium-calibration` | `rucelium-calibration` | lineage chains, drift detection, quarantine (never silent correction) | +| `rucelium-ruview` | `rucelium-worldgraph::rf` | RuField `FieldEvent` → contextual evidence bridge | +| `rucelium-fusion` | `rucelium-worldgraph` | evidence edges, plausibility checks, contradiction tracking | +| `rucelium-worldgraph` | `rucelium-worldgraph` | typed nodes, geospatial registration, evidence/contradiction edges | +| `rucelium-store` | `rucelium-federation::buffer` | outage buffer with deterministic duplicate-free replay | +| `rucelium-policy` | `rucelium-policy` | governed control path (§9), typed so steps cannot be skipped | +| `rucelium-federation` | `rucelium-federation` | biome sovereignty, signed summaries, revocation, SensorThings projection | +| `rucelium-agent` | `rucelium-policy::agent` | agent proposal types; agents never touch actuators directly | +| `rucelium-cli` | `rucelium-bench` (bin) | v0.1 CLI is the deterministic biome benchmark runner | + +Consolidation is deliberate: v0.1 keeps the crate count at the scale of the +existing workspace and splits later along the seams the table already draws. + +### 5.2 WorldGraph reuse + +ADR 139's WorldGraph is directly reusable. v0.1 **extends its sensor +modalities rather than creating a second graph**: + +```rust +pub enum SensorModality { + WifiCsi, + AirQuality, + SoilMoisture, + WaterQuality, + Acoustic, + Weather, + Bioelectric, + Radiation, + Optical, + Chemical, +} +``` + +## 6. Layer 3 — Biome regions + +Each forest, watershed, city, farm, coastline, or protected area is a +**sovereign biome**. A biome owns: + +1. Its raw observations +2. Its calibration records +3. Its WorldGraphs +4. Its local models +5. Its retention policy +6. Its disclosure policy +7. Its actuator authority + +Biomes exchange **signed events, statistical summaries, model updates, and +cross-boundary alerts**. They do not continuously replicate raw measurements. + +Sensitive biodiversity locations support coordinate coarsening, delayed +disclosure, and access-controlled raw data. Actuator permissions never leave +the biome owner. + +## 7. Layer 4 — Planetary federation + +The global layer provides **discovery and aggregate intelligence, not +centralized ownership**. It exposes: + +1. OGC SensorThings API 1.1 (Things, Sensors, Locations, Datastreams, + Observations, ObservedProperties, FeaturesOfInterest) for external + interoperability +2. Geospatial tiles +3. Regional event feeds +4. Environmental model registry +5. RuVector similarity search +6. WorldGraph query federation +7. Public research datasets +8. Sovereign private namespaces + +v0.1 implements the SensorThings **projection** (biome → SensorThings JSON +entities) in `rucelium-federation::sensorthings`; serving it over HTTP is a +follow-up. + +**Do not start with the global layer.** §13 requires one biome to prove 30 +days of operation without internet before the planetary service is designed. + +### 7.1 Observation requirements + +Every observation carries all twelve: + +1. Device identity +2. Sequence number +3. Measurement time +4. Reception time +5. Geospatial reference +6. Unit and observed property +7. Calibration identifier +8. Quality score +9. Uncertainty interval +10. Firmware measurement implementation +11. Signature +12. Derivation lineage + +An observation missing any of these is rejected at ingest, not repaired. + +## 8. RuView's contribution — RF as context, never ground truth + +RuView does not pretend RF replaces physical environmental sensors. RF becomes +a **contextual environmental modality** contributing: + +1. Movement of people and animals around protected areas +2. Canopy and vegetation motion signatures +3. Water surface and flood boundary changes +4. Precipitation related channel changes +5. Soil and vegetation moisture related RF features +6. Structural movement around cliffs, trees, bridges, and buildings +7. Detection of sensor displacement or tampering +8. Spatial localization of events reported by other sensors +9. Validation that an observation is physically plausible + +Current ISAC research covers environmental sensing through CSI, Doppler, and +signal statistics (rainfall, soil moisture, flood dynamics, water level), but +the **generalization problem remains unresolved**. Therefore, normatively: + +> RuView outputs are supporting evidence. They may raise or lower confidence +> and create contradiction edges. They may never be the sole basis for an +> environmental fact, an alert above advisory severity, or an actuator command. + +The bridge (`rucelium-worldgraph::rf`) ingests RuField `FieldEvent`s (which +already carry privacy class + provenance per ADR-260) and emits +`Supports` / `Contradicts` evidence edges against environmental observations. + +## 9. Mycelium agent layer and the governed control path + +Mycelium IO is a multi-agent coordination and persistent memory layer, not a +constrained sensor protocol. It sits **above** the biome layer. Agents include +calibration, sensor health, wildfire risk, flood, biodiversity, pollution +source, deployment optimization, data quality, scientific hypothesis, and +governance agents. + +Agents can propose new sampling rates, model deployments, sensor +repositioning, or actuator commands. **They never directly control physical +systems.** The only path to execution: + +```text +Agent proposal +→ deterministic policy evaluation +→ safety simulation +→ authority check +→ signed command +→ gateway validation +→ local execution +→ execution receipt +``` + +v0.1 enforces this **by construction**: `rucelium-policy` types each stage's +output as the only valid input to the next stage, so a proposal cannot reach +execution without passing every gate, and every stage appends to a signed audit +trail. + +The ThreeFold "Mycelium" project (an encrypted IPv6 overlay in Rust) is a +different technology. It may optionally connect gateways across unreliable +networks; it is not required by sensor nodes and is not embedded in the data +model. + +## 10. Data economics + +Raw RuView data cannot leave every site. One CSI link at 100 frames/s × +64 complex subcarriers × 4 bytes ≈ 25,600 B/s ≈ **2.2 GB/day per RF link**; +one million links ≈ 2.2 EB/day before metadata or replication. A normal +environmental node sending a 64-byte observation once per minute produces +≈ 92 KB/day; one million nodes ≈ 92 GB/day — manageable. + +Therefore three data classes with distinct residency and retention: + +| Class | Content | Residency | Retention | +|---|---|---|---| +| `RawSignal` | raw CSI/acoustic/waveform | gateway only | hours–days | +| `DerivedFeature` | DSP features, model outputs | biome | weeks–months | +| `FederatedEvent` | signed events + aggregates | global | years | + +Target latencies: + +1. Local safety event: **< 250 ms** +2. Gateway fusion: **< 2 s** +3. Biome alert: **< 30 s** +4. Global propagation: **< 5 min** +5. Scientific aggregate: hourly or daily + +## 11. The C ↔ Rust contract + +A versioned C ABI at ingestion, then deterministic CBOR above it. + +### 11.1 Wire struct (v1, little-endian, 48 bytes, no padding) + +```c +typedef struct { + uint8_t schema_version; /* == 1 */ + uint8_t sensor_type; /* SensorModality code */ + uint16_t flags; + uint64_t node_id; + uint64_t timestamp_ns; + uint32_t sequence; + int32_t latitude_e7; /* degrees × 1e7 */ + int32_t longitude_e7; /* degrees × 1e7 */ + int32_t altitude_mm; + int32_t value_q16; /* Q16.16 fixed point */ + uint16_t quality_q15; /* Q0.15: 0x0000..0x8000 → 0.0..1.0 */ + uint16_t battery_mv; + uint32_t calibration_id; +} rv_env_sample_v1; +``` + +The Rust mirror is `#[repr(C)]` and **every field is validated before +conversion into the domain model**. Because the workspace forbids `unsafe`, +the parser never transmutes: it performs bounds-checked little-endian field +reads over the byte slice — allocation free, panic free, exactly the ADR-096 +posture. The header of record is +[`crates/rucelium-abi/include/rucelium_env.h`](../crates/rucelium-abi/include/rucelium_env.h). + +### 11.2 Serialization and signing + +- **CBOR** (RFC 8949) for everything above the fixed struct, chosen for small + code and message sizes. v0.1 ships a dependency-free deterministic encoder: + definite lengths, fixed field order, shortest-form integers — same sample ⇒ + byte-identical encoding. +- **Signing**: ed25519 detached signatures over the exact wire payload, carried + in a COSE_Sign1-inspired deterministic CBOR envelope + (`[payload bstr, pubkey bstr, signature bstr]`). Honest label: this is + COSE-*inspired* deterministic framing, not a full RFC 9052 implementation — + upgrading the envelope to real COSE/CWT is a stated follow-up, and the + signature scheme (ed25519 over payload bytes) is forward-compatible with it. +- Flags bit 0 (`RV_ENV_FLAG_RETRANSMIT`) marks ring-buffer replay after an + outage so gateways can distinguish store-and-forward from replay attacks + (the sequence window still deduplicates). + +## 12. Trust and governance + +Countermeasures for the real failure mode (§1): + +1. Signed calibration lineage — every `CalibrationRecord` chains to a parent + up to a reference-grade anchor; broken chains are rejected +2. Reference grade anchor stations +3. Periodic co-location calibration +4. Measurement uncertainty on every observation +5. Automatic drift detection (EWMA residual vs anchor) +6. **Sensor quarantine rather than silent correction** — drifted sensors are + quarantined and their data flagged unusable; values are never rewritten +7. Contradiction edges in WorldGraph +8. Geographic and seasonal validation sets +9. Public quality scores +10. Reproducible transformation receipts + +Revocation is biome-local first: revoking a device invalidates its key at the +biome's gateways immediately and propagates outward as a signed event; the +biome keeps operating throughout. + +## 13. Implementation sequence + +1. Define `EnvSample`, `EnvFrame`, `CalibrationRecord`, `EnvironmentalEvent` +2. Add the stable C ABI and CBOR encoding +3. Implement temperature, humidity, soil, air quality, and acoustic adapters +4. Extend WorldGraph with environmental sensor and ecosystem nodes +5. Add RuView RF feature fusion +6. Implement local buffering and deterministic replay +7. Add OGC SensorThings projection +8. Add device signatures and revocation +9. Deploy one 64-node biome +10. Federate three biomes **before** designing the planetary service + +Do not start with the global layer. Prove that one biome can remain +operational for 30 days without internet. + +## 14. Acceptance test (v0.1) + +A 64-node pilot passes when it: + +1. operates for 30 days (simulated deterministically in v0.1 — labelled + **SYNTHETIC**, exactly as ADR-260 labels its benchmark), +2. survives seven consecutive offline days, +3. restores buffered data without duplicates, +4. rejects **every** modified or replayed packet, +5. produces local alerts within 500 ms (v0.1 measures in-process pipeline + latency, as rufield-bench does), +6. maps every accepted observation into SensorThings **and** WorldGraph, +7. revokes one compromised device without interrupting the biome, +8. maintains ≥ 95 % usable calibrated observations. + +`cargo run -p rucelium-bench` prints the scorecard; +`cargo test -p rucelium-bench` asserts all eight criteria plus determinism +(two runs at the same seed produce identical reports). + +## 15. Alternatives considered + +1. **Flat global peer mesh** — rejected for the six failures in §1. +2. **Cloud-centralized ingestion** — rejected: violates sovereignty, fails the + 30-day-offline requirement, and creates a single revocation/consent choke + point. +3. **MQTT + JSON everywhere** — rejected at the node boundary: JSON costs + 2–4× CBOR on constrained links and offers no deterministic encoding for + signatures; retained as an optional gateway-side projection. +4. **Protobuf instead of CBOR** — viable, but CBOR's self-description, + COSE alignment, and tiny encoder footprint fit spore nodes better. +5. **Requiring the ThreeFold Mycelium overlay** — rejected as a hard + dependency; optional gateway transport only. +6. **A second, environmental-specific graph** — rejected; extend WorldGraph + (ADR 139) modalities instead. + +## 16. Consequences + +Positive: sovereignty and revocation are containable; constrained links carry +only what they can; calibration is a first-class, auditable object; RF context +improves confidence without contaminating ground truth; the C surface stays +narrow and auditable. + +Negative / accepted costs: federation adds protocol surface (summaries, keys, +revocation feeds); consolidated v0.1 crates will need splitting as layers +mature; the COSE envelope is not yet full RFC 9052; v0.1 numbers are synthetic +until a real 64-node deployment exists. + +## Implementation status (v0.1) + +| # | Criterion | Status | +|---|---|---| +| 1 | Core domain model (§5.1) | shipped — `rucelium-core` | +| 2 | C ABI + deterministic CBOR + header (§11) | shipped — `rucelium-abi` | +| 3 | Gateway ingest: verify/replay-window/normalize (§5) | shipped — `rucelium-ingest` | +| 4 | Calibration lineage + drift + quarantine (§12) | shipped — `rucelium-calibration` | +| 5 | WorldGraph env nodes + RF context bridge (§5.2, §8) | shipped — `rucelium-worldgraph` | +| 6 | Governed control path, typed stages (§9) | shipped — `rucelium-policy` | +| 7 | Biome sovereignty, outage buffer, summaries, revocation, SensorThings projection (§6, §7, §10) | shipped — `rucelium-federation` | +| 8 | 64-node biome acceptance benchmark (§14) | shipped — `rucelium-bench` | +| 9 | Real spore-node firmware, LoRaWAN transport, HTTP SensorThings service, three-biome federation | honest follow-up — not in v0.1 | diff --git a/docs/ADR-265-rucelium-runtime.md b/docs/ADR-265-rucelium-runtime.md new file mode 100644 index 0000000..e760630 --- /dev/null +++ b/docs/ADR-265-rucelium-runtime.md @@ -0,0 +1,125 @@ +# ADR 265: RuCelium Runtime — Gateway Daemon, Constrained Transport, Durable Store + +Status: Accepted — v0.1 runtime layer + +Date: 2026 08 02 + +Deciders: rUv + +Tags: rucelium, gateway, daemon, lorawan, fragmentation, storage, retention, federation, no_std + +## 1. Context + +ADR-264 shipped the RuCelium data model, trust machinery, and a deterministic +64-node acceptance benchmark. What it deliberately did not ship is a runtime: +the crates were libraries, the federation bus was in-process, storage was an +in-memory buffer, and the signed envelope had never met a real radio budget. + +Auditing the gap surfaced one hard fact: the v1 envelope +(`[payload 48 B, pubkey 32 B, signature 64 B]` in CBOR ≈ **150 bytes**) does +not fit LoRaWAN DR0's ~51-byte application payload cap — and LoRaWAN 1.0.4 is +the ADR-264 primary spore transport. The transport problem is therefore not +hypothetical; it gates any real deployment. + +## 2. Decision — constrained transport (`rucelium-transport`) + +Two composable mechanisms: + +1. **Compact envelope v2, pubkey by reference.** The gateway already holds the + device registry keyed by the `node_id` inside the payload, so the envelope + does not need to carry the public key. v2 is a packed frame: + `magic 0xC2, version 2, payload[48], signature[64]` = **114 bytes** — + 24 % smaller than v1, with identical cryptographic strength (the gateway + verifies against the registered key; a forged node_id simply selects a key + the signature cannot match). `to_v1()` rehydrates a v1 record so the + ingest pipeline is unchanged downstream and re-verifies as before. +2. **MTU fragmentation.** A 6-byte header (`magic 0xF7, version, msg_id u16, + frag_idx, frag_count`) splits any message into ≤255 chunks of `mtu − 6` + bytes. At `LORAWAN_DR0_MTU = 51`, a compact envelope is exactly + **3 datagrams**. The `Reassembler` is keyed by `(sender, msg_id)`, + tolerates loss (caller-driven timeout eviction), duplication, and + reordering, and caps pending messages so lost fragments cannot leak memory. + +Rejected alternatives: truncated signatures (breaks ed25519), MAC-only links +with periodic signed checkpoints (weakens the per-observation provenance +requirement of ADR-264 §7.1 — may be revisited as an *addition* for +ultra-constrained duty cycles, never a replacement). + +## 3. Decision — durable store (`rucelium-store`) + +ADR-264 §13 named "SQLite or RVF buffering". v0.1 chooses an RVF-style +**append-only segmented JSONL log** over SQLite: zero new dependencies, no C +build, human-inspectable segments, and deletion-by-segment matches the +retention model (whole expired segments are unlinked; no rewrite, no vacuum). + +Properties: + +- **Dedup index** on `(node_id, sequence)` (observations) / `event_id` + (events), rebuilt by scanning segments at open — restart-safe. +- **Crash recovery**: a torn tail line (crash mid-write) is truncated on open; + corruption anywhere else is a hard, named error — never silently skipped. +- **Deterministic replay**: iteration is append order, always. +- **Retention enforcement** (`enforce_retention(now_ns, retention_ns)`) + deletes whole expired segments, never the active one, implementing the + ADR-264 §10 per-class lifespans (raw: days; derived: months; events: years). +- Dedup keys are retained after segment deletion (bytes are freed, keys are + tiny); documented trade-off. + +## 4. Decision — gateway daemon (`rucelium-gateway`) + +A single tokio/axum binary that composes the existing library crates into the +ADR-264 Layer-2 rhizome: + +```text +UDP :7464 ──► envelope detect (v1 CBOR / v2 compact / fragments) + ──► reassemble ──► registry + signature + anti-replay (ingest) + ──► calibration + drift quarantine + ──► ObservationStore (disk) + WorldGraph + local alert rules + ──► EventStore + biome-signed events +HTTP :7465 ──► /health /api/stats /api/observations/recent /api/events + ──► /api/sensorthings/{Things,Datastreams,Observations} + ──► /api/federation/{pubkey,summary,revocations,peers} +``` + +- **Federation over the network**: a background task polls each configured + peer's `/api/federation/summary` and `/revocations`, verifies the ed25519 + signatures with the peer's published biome key, stores verified summaries, + and applies verified `DeviceRevoked` events to the local registry. Biome + sovereignty is preserved: only signed summaries and events cross the wire, + exactly as ADR-264 §6 requires. +- **`--simulate N`**: the daemon can spawn N synthetic spore nodes that sign + real envelopes and send them over the loopback UDP socket — the full + pipeline demonstrable with zero hardware, honestly labelled SYNTHETIC. +- Retention enforcement runs on a timer with the ADR-264 §10 defaults. + +## 5. Decision — `no_std` ABI surface + +`rucelium-abi` gains a `std` default feature. With +`--no-default-features --features alloc`, the crate exposes the wire format +(`RvEnvSampleV1` parse/encode/validate) and deterministic CBOR — the exact +surface a Rust-based spore node (RP2040/ESP32 class) needs to produce +envelopes. Signing (`sign` module) and domain conversion (`to_env_sample`, +which requires `rucelium-core`) remain std-only in v0.1. + +## 6. Consequences + +Positive: the platform now *runs* — one command starts a gateway that ingests, +verifies, calibrates, stores, alerts, serves SensorThings, and federates +revocations with a peer. The LoRaWAN fit problem is solved at the framing +layer where it belongs. + +Negative / accepted: JSONL segments are larger than a binary store (revisit +with RVF proper); federation polling is pull-based (push/webhooks later); +retention deletes at segment granularity; `no_std` is compile-checked, not +yet CI-checked on an embedded target. + +## Implementation status (v0.1 runtime) + +| # | Item | Status | +|---|---|---| +| 1 | Compact envelope v2 + DR0 fragmentation | shipped — `rucelium-transport` | +| 2 | Durable segmented store + retention | shipped — `rucelium-store` | +| 3 | Gateway daemon (UDP ingest, HTTP/SensorThings API, simulate mode) | shipped — `rucelium-gateway` | +| 4 | Peer federation sync (summaries + revocations, verified) | shipped — `rucelium-gateway::federation` | +| 5 | `no_std` ABI feature | shipped — `rucelium-abi` | +| 6 | Reference C firmware, real LoRaWAN stack, secure time, key rotation, RVF binary store, embedded-target CI | honest follow-up | diff --git a/docs/ADR-266-rucelium-applications-and-wedges.md b/docs/ADR-266-rucelium-applications-and-wedges.md new file mode 100644 index 0000000..2f0b74f --- /dev/null +++ b/docs/ADR-266-rucelium-applications-and-wedges.md @@ -0,0 +1,162 @@ +# ADR 266: RuCelium Applications — Deployment Wedges and the Biological Frontier + +Status: Accepted — strategy of record for deployment sequencing + +Date: 2026 08 02 + +Deciders: rUv + +Tags: rucelium, deployment, commercialization, pilots, flood, agriculture, compliance, wildfire, biodiversity, bioelectronics, governance + +## 1. Context + +ADR-264 defined the fabric; ADR-265 built the runtime. Neither answers the +question that decides what gets engineered next: **what is the first thing +someone pays for, and what must be true for it to work in a field?** + +The honest status is that RuCelium is a working reference implementation and +simulation — roughly 70 % complete as an architectural specification, ~40 % as +a software platform, and ~15 % as a deployable physical system. Every claim +below is scoped against that. + +## 2. Decision — sequence: one paid biome, never a planetary launch + +**Do not begin with a planetary network. Begin with one paid biome.** + +The sellable unit is *one governed biome*: a watershed, a farm, a mine +boundary, a protected area. The planetary layer is the eventual network +effect, not the product. This mirrors the ADR-264 §13 engineering rule +("federate three biomes before designing the planetary service") and makes the +commercial and technical sequencing identical — which is the point. + +The fabric's strongest fit is regional environmental monitoring where +**connectivity is unreliable, data ownership matters, and decisions must be +made locally**. Those three conditions are exactly what the four-layer +sovereignty model buys, and exactly what a cloud-centralized competitor +cannot offer. + +## 3. Decision — flood and watershed intelligence is wedge #1 + +Chosen because the outcome is *measurable* and the cost of a missed event is +high — the two properties that make a pilot convertible. + +Deployment: water level, rainfall, soil saturation, flow, weather, plus +RuView motion/surface-change context across a watershed. + +Detections: rising water ahead of conventional gauge triggers; blocked +culverts and drainage channels; soil saturation preceding runoff; sensor +displacement during storms; contradictions between water sensors and +surrounding environmental evidence. + +Pilot shape: **16–40 nodes, 2–4 gateways, local alerts under 5 s**, roughly +$30k–$100k. Buyers: municipalities, conservation authorities, insurers, +utilities. + +Engineering implication (this is why it is an ADR, not a slide): a 5-second +local alert budget is 20× looser than the ADR-264 §10 250 ms local-safety +target, so the *existing* pipeline latency is not the risk. The risks are +storm-time sensor displacement (RuView tamper/displacement context becomes +load-bearing, not decorative) and multi-gateway agreement inside one biome — +which is the first feature gap this wedge exposes. + +### 3.1 The other four wedges, in priority order + +| # | Wedge | Core sensing | Commercial shape | What it demands of the fabric | +|---|---|---|---|---| +| 2 | Precision agriculture / irrigation | soil moisture + conductivity, temp, humidity, leaf wetness, rainfall, optical | $10–50/acre/yr; $15k–60k initial; 10–30 % water-saving target | **Governed actuation** (irrigation valves) — the ADR-264 §9 control path stops being theoretical | +| 3 | Industrial environmental compliance | PM, chemical emissions, noise, radiation, water discharge, boundary activity, tamper | $50k–250k/site; $2k–20k/month | **Signed provenance as the product**: device identity, calibration, location, quality, lineage — already core observation attributes | +| 4 | Wildfire risk and early detection | temp, humidity, wind, soil moisture, optical smoke, PM, acoustic, RF context | Buyers are forestry, utilities, insurers, resorts, landowners — often *not* the fire service | **RF severity cap holds**: RF may support or contradict, never independently raise a critical fire alert | +| 5 | Biodiversity and habitat monitoring | acoustic, eDNA interfaces, optical traps, soil, water quality, weather, RF | Grant- and compliance-funded; slower cycles | **Disclosure policy as a feature**: coordinate coarsening and delayed release for sensitive species | + +Note the pattern: each wedge stresses a *different* already-built subsystem. +Compliance monetizes provenance; agriculture monetizes governed actuation; +wildfire monetizes evidence discipline; biodiversity monetizes sovereignty. +That is the argument that the four-layer model was not over-engineering. + +## 4. Decision — the biological frontier is a research track, not a roadmap + +RuCelium's genuinely exotic opportunity is to be an **interface between +biological intelligence and machine intelligence** — biological sensors are +transducers the fabric already knows how to distrust properly (uncertainty, +calibration lineage, contradiction edges, quarantine). + +These are tracked as candidates with explicit risk, **not** committed +deliverables. The supporting literature referenced below comes from the +strategy brief and is recorded as *claimed prior art to verify before any +pilot commitment* — none of it has been independently reproduced by this +project, and no RuCelium claim may cite it as validation of RuCelium. + +| Track | Idea | Pilot cost | Time | Commercial | Sci. risk | +|---|---|---|---|---|---| +| B1 | **Living sentinel forests** — electrodes on trees/crops/fungal colonies; learn each organism's normal electrical signature, detect drought/heat/ozone/pest/damage deviation | $40k–120k | ~6 mo | 4/5 | 4/5 | +| B2 | **Ecosystem immune system** — electroactive microbial biofilms in waterways/discharge points; toxic exposure → electrical response → verification → source localization → governed intervention | $75k–250k | — | 5/5 | 3/5 | +| B3 | **Airborne DNA observatory** — anomaly-triggered air/water/soil DNA sampling enriching the WorldGraph with species, pathogens, invasives, AMR markers | $100k–300k | — | 5/5 | 2/5 | +| B4 | **Biohybrid pollinator nodes** — hives as biome nodes (acoustics, weight, vibration, electric field, air chemistry, pollen DNA) | $30k–80k | — | 4/5 | — | +| B5 | **Biohybrid chemical search** — biological olfaction on drones/ground robots; plume + wind model → probable source | — | 12–24 mo | 4/5 | eng. 4/5 | +| B6 | **Self-sensing living infrastructure** — mycelium composites reporting moisture, contamination, compression, viability, thermal stress | — | ~12 mo | 3/5 | — | +| B7 | **Autonomous bioremediation zones** — sensing organisms + remediation organisms under the governed control path | — | — | 5/5 | reg. 5/5 | +| B8 | **Ecosystem memory (RuVector)** — encode biome state; retrieve historically similar states ("91 % similar to three days before the 2028 bloom") | — | 6–9 mo | 5/5 | needs ≥1 seasonal cycle | +| B9 | **Ecosystem guardian agent** — a persistent, evidence-backed representative for a river/forest/watershed | — | — | 3/5 | gov. 5/5 | + +**Priority three:** B1 (makes the mycelium vision tangible), B2 (clear +industrial buyers), B3 (turns RuCelium into biodiversity intelligence rather +than another IoT network). + +### 4.1 Non-negotiable constraints on the biological track + +1. **Biological confounding is the dominant failure mode.** Temperature, + moisture, organism age, species, electrode placement, circadian rhythm and + season can all mimic the signal of interest. +2. **Paired experimental design is mandatory.** Every biological node requires + conventional reference sensors, local controls, organism-specific + baselines, causal stimulus experiments, and geographically separated + validation sites. +3. **Acceptance test (biological):** one biological signal predicts a + *confirmed* environmental condition **≥ 30 minutes earlier** than the + conventional sensor, at **> 90 % precision**, across **three independent + locations**, **without per-location retraining**. Until that passes, + biological modalities enter the WorldGraph as evidence with capped weight + — the same discipline ADR-264 §8 applies to RF. +4. **B3 privacy is a 5/5 risk**: airborne DNA may contain human genetic + material. No airborne-DNA pilot proceeds without an explicit human-DNA + handling policy, and the ADR-264 §6 disclosure controls (coarsening, + delay, access control) are minimum, not sufficient. +5. **B7 regulatory risk is 5/5**: agents may *recommend* remediation; only + deterministic local policy actuates. This is the ADR-264 §9 rule, + restated because the temptation is highest here. + +## 5. Decision — the primary risk is scientific trust, not software + +A cryptographically valid sensor can still produce meaningless data through +placement, drift, contamination, or seasonal change. Therefore calibration +authority, reference stations, stated uncertainty, drift detection, and field +validation are **product features with roadmap priority**, not internal +plumbing. ADR-265's calibration-authority work is the first payment against +this; field validation evidence is the outstanding one. + +## 6. Physical acceptance test (supersedes simulation claims) + +RuCelium crosses from architecture into product when **one physical biome of +8–16 nodes**: + +1. runs for 30 days, +2. survives a 7-day outage **and** a gateway restart, +3. rejects replayed packets after that restart, +4. detects one deliberately drifting sensor, +5. preserves signed calibration lineage end-to-end, +6. produces one independently verifiable environmental event. + +Until then, the deterministic 64-node result is labelled **fabric +reference-model acceptance** and never described as a field pilot. + +## 7. Consequences + +Positive: engineering priorities now derive from a buyer, not from +architectural symmetry. Multi-gateway agreement within a biome, actuation +safety, and field calibration evidence rise to the top precisely because +wedges 1–3 require them. + +Negative / accepted: focusing on one watershed defers the planetary layer +indefinitely (intended); the biological tracks risk becoming a distraction if +promoted before B-track acceptance passes; several cited studies remain +unverified by this project and must not be used as marketing support. diff --git a/docs/ADR-267-rucelium-long-term-provenance.md b/docs/ADR-267-rucelium-long-term-provenance.md new file mode 100644 index 0000000..d6335d4 --- /dev/null +++ b/docs/ADR-267-rucelium-long-term-provenance.md @@ -0,0 +1,149 @@ +# ADR 267: Long-Term Provenance — Merkle Notarization and Post-Quantum Readiness + +Status: Accepted — v0.1 notary layer + +Date: 2026 08 02 + +Deciders: rUv + +Tags: rucelium, provenance, post-quantum, merkle, notarization, compliance, archival, lorawan + +## 1. Context + +RuCelium signs every observation with ed25519 at the spore node (ADR-264 §11) +and keeps `FederatedEvent`-class data for **years** (§10). Two facts collide: + +1. **Environmental evidence is long-lived.** ADR-266's compliance wedge sells + *regulator-verifiable* evidence. A discharge measurement taken in 2026 may + need to be independently verified in 2040 — in a dispute, an insurance + claim, or a scientific reanalysis. Climate baselines are worse: their whole + value is decades of comparability. +2. **Ed25519 is not durable on that horizon.** A cryptographically relevant + quantum computer breaks ECC signatures. Unlike confidentiality, signatures + have no "harvest now" exposure — but they have a *verifiability* exposure: + a signature that cannot be trusted in 2040 retroactively destroys the + evidentiary value of data collected today. NIST standardized ML-DSA in + FIPS 204 (August 2024) precisely for this class of problem. + +The naive fix — sign each observation with ML-DSA — is **infeasible at the +sensor boundary**, and the numbers are not close: + +| Scheme | Signature | Envelope | LoRaWAN DR0 datagrams (51 B) | +|---|---:|---:|---:| +| ed25519 (today) | 64 B | 114 B | **3** | +| ML-DSA-44 | 2,420 B | ~2,470 B | **~49** | + +ML-DSA-44 also carries a 1,312-byte public key. A battery node on a duty-cycled +sub-GHz radio cannot send ~49 datagrams per reading; it would blow the airtime +budget, the battery, and the duty-cycle regulation simultaneously. ML-DSA's +*speed* is fine (≈0.65 ms signing, integer-only arithmetic, constant-time +friendly) — **size is the binding constraint**, exactly as it was for the +envelope-v2 work in ADR-265 §2. + +## 2. Decision — hybrid provenance: cheap per-observation, notarized in batch + +Split the two jobs that a signature is currently doing: + +1. **Authenticity now** (is this packet from this device, unmodified?) stays + **ed25519, per observation, at the node**. Unchanged. Fits the radio budget. +2. **Verifiability later** (can a stranger in 2040 prove this record existed, + unaltered, in 2026?) moves to a **gateway-side Merkle notary**: the gateway + accumulates accepted observations into an append-only Merkle tree and + periodically signs only the **root**. + +Because the expensive signature covers a whole batch, its cost amortizes to +near nothing per observation — the published figure for this pattern is +**≈2.4 bytes per event** even with a Dilithium-class signature ~38× the size +of ed25519. A 4,096-leaf batch signed with ML-DSA-44 costs 2,420 bytes total, +i.e. **0.6 bytes per observation**, and each observation's proof of membership +is a 12-hash (384-byte) inclusion path that the gateway can serve on demand +rather than transmit by default. + +```text +spore node ──ed25519(48-byte record)──► gateway + │ accepted observations + ▼ + Merkle accumulator + │ every N records / T seconds + ▼ + signed NotaryRoot ──► biome ──► federation + (algorithm-agnostic: + ed25519 today, ML-DSA when available) + +verification in 2040: observation + inclusion proof + signed root + └── recompute the root, check ONE signature ──┘ +``` + +This is the same structure production transparency logs and the IETF Merkle +Tree Certificates work use to make post-quantum PKI affordable; we are +applying it to environmental evidence, where the archival horizon is longer +than the web's. + +## 3. Decision — algorithm agility is the shipped feature, not ML-DSA itself + +v0.1 ships `rucelium-notary` with: + +- a binary Merkle tree over `sha256` with **domain-separated** leaf (`0x00`) + and interior (`0x01`) hashing — the standard second-preimage defence, so a + proof cannot be re-interpreted at another depth; +- deterministic, append-only batch construction with inclusion proofs and a + stateless `verify_inclusion` that a third party can run with no access to + the gateway; +- a `RootSigner` / `RootVerifier` trait pair, so the root signature algorithm + is a **swap, not a rewrite**; +- `Ed25519RootSigner` as the v0.1 implementation, and a `NotaryAlgorithm` tag + (`ed25519`, `ml-dsa-44`, `hybrid-ed25519+ml-dsa-44`) recorded **inside** the + signed root so a verifier never has to guess, and a future ML-DSA root is + self-describing to today's readers. + +**Honest label:** RuCelium is *post-quantum ready*, not post-quantum. No +ML-DSA implementation ships in v0.1 — that requires a vetted, ideally +FIPS-validated implementation, and shipping a hand-rolled lattice +implementation would be worse than shipping none. What ships is the +architecture that makes the swap a configuration change instead of a protocol +break, plus the amortization that makes it *affordable* when it happens. + +The migration is deliberately **hybrid-first**: sign roots with ed25519 **and** +ML-DSA concurrently during transition, so a root remains verifiable by old and +new verifiers alike, and no historical data needs re-signing. Data already +notarized under ed25519 gets its long-term guarantee by being *re-notarized*: +an old root is included as a leaf in a new PQ-signed tree, chaining the +history forward without touching a single stored observation. + +## 4. Consequences + +Positive: long-term evidentiary value stops depending on ed25519 surviving; +the compliance wedge (ADR-266 §3.1) gains a genuinely defensible claim; the +per-observation radio budget is untouched; verification is *cheaper* for a +third party (one signature check per batch, not per record); the notary root +is a natural federation artifact — it is small, signed, and carries no raw +data, exactly what ADR-264 §6 permits to leave a biome. + +Negative / accepted: a new crate and a new artifact to persist and federate; +inclusion proofs must be retrievable for the life of the data (a gateway that +loses its tree can rebuild it from the durable store — the store is the source +of truth, the tree is derived); the notary adds a batching latency (a record +is not notarized the instant it is accepted — it is authentic immediately, +notarized within the batch interval, and that distinction must be stated in +any evidence bundle). + +## Implementation status (v0.1) + +| # | Item | Status | +|---|---|---| +| 1 | Domain-separated sha256 Merkle tree, inclusion proofs, stateless verify | shipped — `rucelium-notary` | +| 2 | `RootSigner`/`RootVerifier` traits + self-describing `NotaryAlgorithm` | shipped | +| 3 | Ed25519 root signing + third-party verification of a single observation | shipped | +| 4 | Re-notarization (old root as a leaf of a new tree) | shipped | +| 5 | ML-DSA-44 root signer, hybrid dual-signing, FIPS-validated implementation | honest follow-up — architecture ready, algorithm not shipped | + +## Sources + +- NIST FIPS 204 (ML-DSA) sizes and performance: + +- Batch/Merkle amortization of post-quantum signatures (≈2.4 B/event): + +- IETF Merkle Tree Certificates: + +- Post-quantum audit evidence for long-lived regulated systems: + diff --git a/docs/ADR-268-rucelium-ruvnet-stack-integration.md b/docs/ADR-268-rucelium-ruvnet-stack-integration.md new file mode 100644 index 0000000..bac3398 --- /dev/null +++ b/docs/ADR-268-rucelium-ruvnet-stack-integration.md @@ -0,0 +1,117 @@ +# ADR 268: RuCelium and the ruvnet Stack — Integrate, Don't Reinvent + +Status: Accepted — integration strategy + +Date: 2026 08 02 + +Deciders: rUv + +Tags: rucelium, agentdb, agenticow, ruvector, rvf, agentic-flow, ruflo, ruv-swarm, integration, memory, harness + +## 1. Context + +RuCelium was built bottom-up from the sensing boundary and, in the process, +independently arrived at several capabilities the ruvnet stack already ships +as mature packages. Continuing to build them in-tree would be duplicated +effort and a worse result. A survey of the published packages: + +| Package | What it is | Overlap with RuCelium | +|---|---|---| +| `agentdb` (3.0.0-alpha) | Self-learning vector memory: **single-file `.rvf` cognitive container**, HNSW search, hybrid BM25+dense retrieval, causal graph + Cypher, episodic/Reflexion memory, provenance + audit log, offline-first, WASM/edge | ADR-264 §13 item 6 literally named "SQLite or **RVF** buffering"; §7 item 5 named "**RuVector similarity search**"; ADR-266 B8 (ecosystem memory) is a vector-similarity problem | +| `agenticow` (0.2.4) | "Git for agent memory": copy-on-write **vector branching**, branch a base memory in ~0.5 ms / 162 bytes regardless of base size; exact read-through queries; checkpoint/rollback; built on `@ruvector/rvf-node` | ADR-264 §9's **safety-simulation stage** needs exactly this: branch the biome state, simulate an actuation, discard or keep | +| `agentic-flow` (2.1.2) | "The agentic meta-harness — **freeze the model, evolve the harness**"; retrieve → judge → distill → consolidate; trajectory rewards; adaptive routing | `harness/` (ADR-266-era Darwin flywheel) converged on the same principle independently | +| `claude-flow` / ruflo (3.34) | Enterprise agent orchestration, 60+ specialized agents, swarm coordination, vector memory, MCP | ADR-264 §9's Mycelium agent layer (calibration, flood, biodiversity, governance agents) | +| `ruv-swarm` (1.0.20) | WASM neural swarm orchestration | Regional model execution at the gateway | + +## 2. Decision + +**Integrate at the seams RuCelium already defines; do not absorb, and do not +re-implement.** Three concrete bindings, one explicit non-binding. + +### 2.1 `agentdb` becomes the biome memory substrate (accepted) + +`rucelium-store` (ADR-265 §3) deliberately chose an append-only JSONL segment +log over SQLite for zero-dependency durability. That decision stands **for the +gateway's hot ingest path** — it is the write-ahead record of what was +accepted, and its guarantees (durable dedup index, CRC integrity, torn-tail +repair) are load-bearing for the restart-attack acceptance test. + +But `rucelium-store` is a *log*, not a *memory*. ADR-266 B8 (ecosystem +memory / "ecological déjà vu") needs vector similarity over encoded biome +states, with provenance on every retrieved case. That is `agentdb`'s exact +shape, including the `.rvf` container the original ADR named. + +Binding: a `rucelium-memory` adapter crate projects sealed observations and +biome-state vectors into an `.rvf` container, and answers "which historical +states resemble now?" with provenance references back to the observations +that produced each vector. The log remains the source of truth; the vector +container is derived and rebuildable. + +### 2.2 `agenticow` becomes the safety-simulation substrate (accepted) + +ADR-264 §9 requires a **safety simulation** between policy evaluation and +authority check. Today's `SafetySimulator` checks a static envelope. The +honest version simulates against *biome state* — and doing that safely means +branching the state, running the candidate action, and discarding the branch. + +`agenticow`'s copy-on-write branching (≈0.5 ms, 162 bytes, independent of base +size) makes per-proposal branching affordable enough to do on every actuator +command, which is precisely where it matters. It also gives the governance +story a feature it lacked: an auditor can be handed the *branch* a decision +was made on, not just the decision. + +Binding: the safety stage takes an optional branch handle; the ADR-264 §9 +typestate chain is unchanged — this deepens one stage, it does not add one. + +### 2.3 The agent layer runs on `ruflo` / `agentic-flow` (accepted) + +ADR-264 §9 lists ten agents (calibration, wildfire, flood, biodiversity, +pollution-source, deployment, data-quality, hypothesis, governance). RuCelium +should ship **none of them as an agent runtime**. It ships the thing that +makes agents safe: typed proposals, deterministic policy, safety simulation, +authority, signed commands, receipts. Agents are drivers of that interface and +belong on an orchestration platform built for them. + +Binding: publish the control path as an MCP tool surface so `ruflo`-hosted +agents can propose and read receipts, and can never actuate directly. + +### 2.4 `harness/` acknowledges convergence with `agentic-flow` (accepted) + +The Darwin flywheel in `harness/` and `agentic-flow`'s meta-harness are the +same idea — freeze the model, evolve the harness, select on measured fitness. +`harness/` stays, because its fitness function is *domain-specific* (workspace +tests + clippy + the ADR-264 §14 acceptance benchmark) and it must run with +zero network in CI. Its README now credits the convergence explicitly rather +than implying novelty. If `agentic-flow` exposes a pluggable fitness +interface, `harness/` should become a fitness provider for it rather than a +parallel loop. + +### 2.5 Non-binding: the ThreeFold "Mycelium" overlay (restated) + +Unchanged from ADR-264 §9: a different technology (encrypted IPv6 overlay), +optional gateway transport only, never a dependency of sensor nodes and never +in the data model. Recorded here because the name collision keeps resurfacing. + +## 3. Consequences + +Positive: RuCelium stops growing a vector database, a branching memory, and an +agent runtime it has no business owning, and concentrates on what nothing else +in the stack does — the signed sensing boundary, calibration authority, +sovereignty, and the governed control path. Each binding lands at a seam the +architecture already had, so none of them alters the ADR-264 layer model. + +Negative / accepted: three external dependencies on packages at +alpha/early-minor versions (`agentdb` 3.0.0-alpha, `agenticow` 0.2.x), so +adapters must be thin and the core must remain functional without them — +every binding above is *additive*, and the ADR-264 §14 acceptance path must +continue to pass with none of them installed. That constraint is normative. + +## Implementation status + +| # | Item | Status | +|---|---|---| +| 1 | Survey + decision to integrate rather than reinvent | shipped — this ADR | +| 2 | `harness/` credits the `agentic-flow` convergence | shipped | +| 3 | `rucelium-memory` adapter over `agentdb` `.rvf` | planned | +| 4 | Branch-backed safety simulation over `agenticow` | planned | +| 5 | MCP tool surface for the governed control path | planned | diff --git a/docs/ADR-269-rucelium-push-federation.md b/docs/ADR-269-rucelium-push-federation.md new file mode 100644 index 0000000..c6a7c4c --- /dev/null +++ b/docs/ADR-269-rucelium-push-federation.md @@ -0,0 +1,132 @@ +# ADR 269: Push Federation and the QUIC Question + +Status: Accepted — transport-agnostic push, QUIC as an optional transport + +Date: 2026 08 02 + +Deciders: rUv + +Tags: rucelium, federation, quic, transport, revocation, push, sovereignty, lorawan + +## 1. Context + +Two questions arrived together: *should the gateway use QUIC instead of UDP?* +and *should federation stay a poller?* They have different answers, and +conflating them would produce the wrong design. + +There is no `@ruvector/quic`: RuVector is a vector database and format family +(`ruvector`, `@ruvector/rvf`, `@ruvector/core`, `@ruvector/gnn`, +`@ruvector/graph-node`, WASM/napi bindings). It is a **memory** substrate +(ADR-268 §2.1), not a transport. So the transport question is plain QUIC. + +RuCelium has three network hops with genuinely different requirements, and the +current implementation gets one of them wrong. + +## 2. Decision — the sensor boundary keeps datagrams. Not QUIC. + +The gateway's UDP socket is **scaffolding standing in for a radio**. Real +spore transports are LoRaWAN, BLE, 802.15.4, RS-485, and SDI-12 — mostly not +IP at all. QUIC is a category error there: + +1. **The handshake dwarfs the payload.** RFC 9000 §14.1 requires a client + Initial packet be padded to **≥1200 bytes** for path validation. Our whole + envelope is 114 bytes (ADR-265 §2) and LoRaWAN DR0 MTU is 51. The handshake + alone is ~24× the message. +2. **Connection state versus sleep.** A node waking every 30 minutes to send + 48 bytes cannot amortize connection establishment and will have lost the + connection between wakeups regardless. Per-connection state × thousands of + nodes is a gateway memory problem for no gain. +3. **Duty cycle.** Under EU868's 1% budget, handshake round-trips are often + not merely expensive but unaffordable. +4. **Decisive: we do not need what it provides.** The envelope is + *object-secured* — ed25519 over the exact 48 payload bytes, with the + anti-replay window above it. Authenticity, integrity, and replay protection + travel **with the data, not the pipe**. That is what lets an untrusted + store-and-forward relay — precisely what a LoRaWAN network server is — sit + in the path harmlessly. Channel security would add cost without adding the + property the design depends on. + +The real work at this hop is a LoRaWAN network-server adapter, not a +transport swap. + +## 3. Decision — federation moves from poll to push (transport-agnostic) + +This is the weaker link, and it is a *security* problem before it is a +performance one. Federation currently polls each peer every 30 s +(ADR-265 §4). That means a revoked device stays valid at peer gateways for up +to a full polling interval after the biome owner revokes it. Revocation +latency is a security property; polling caps it at the interval. + +Therefore: **push first, transport second.** A `FederationTransport` trait +carries three verbs — `announce` (a signed summary or event), `subscribe` +(receive a peer's stream), and `sync_since` (backfill after a partition) — +with two implementations: + +- `HttpPollTransport` — the existing behaviour, kept as the always-available + default with no new dependencies. Backfill and correctness live here. +- `QuicTransport` — optional, behind the `quic` cargo feature. + +Push changes the revocation story from "within 30 s" to "as fast as the link +allows, with polling as the backstop". The backstop is not optional: a peer +that missed a pushed event must still converge, so `sync_since` runs on +reconnect and on a slow timer regardless of transport. + +## 4. Decision — QUIC is the optional transport, and never the trust boundary + +Where QUIC earns its place is exactly ADR-264 §1's founding premise — +unreliable connectivity: + +1. **Connection migration.** A watershed gateway failing over LTE → satellite + → wifi keeps its connection across IP changes. TCP breaks; QUIC survives. +2. **0-RTT resumption** after a partition — reconnect and drain backlog + without a full handshake. +3. **No head-of-line blocking.** A lost packet in the summary stream must not + stall the revocation stream. Separate QUIC streams per artifact class. +4. **Loss recovery** on satellite and rural cellular links. +5. **Traffic-analysis resistance.** This matters more than it sounds for the + biodiversity wedge (ADR-266 §3.1): alert *timing* leaks information about a + sensitive location even when the payload is signed and the coordinates are + coarsened. Channel encryption hides the pattern; disclosure policy alone + does not. + +Two constraints, both normative: + +- **QUIC is defence in depth, never the trust boundary.** Summaries and events + are already ed25519-signed and identity-bound (`biome_id → key + epoch`, + ADR-268-era hardening). If a QUIC session ever becomes the reason a peer is + trusted, that is a regression. Everything received over QUIC goes through + exactly the same verification as everything received over HTTP. +- **TLS identity is the biome's existing ed25519 key**, carried as a raw + public key (RFC 7250) rather than X.509. No certificate authority, no new + PKI, no name-based trust — the sovereignty model already says the biome owns + its key, and this makes the transport agree with it. A peer's TLS identity + must equal its registered federation key or the connection is refused. + +## 5. Consequences + +Positive: revocation propagates at link speed instead of polling speed; +federation survives IP changes and partitions; the alert-timing side channel +closes for sensitive-species deployments; the transport becomes swappable, so +the ThreeFold Mycelium overlay (ADR-264 §9) or anything else can be added +later without touching federation logic. + +Negative / accepted: `quinn` + `rustls` is a substantial dependency tree for a +deliberately lean workspace — hence the feature flag, default off. Push adds a +delivery-state concern (what if a push is dropped?) answered by keeping +`sync_since` mandatory. Raw-public-key TLS is less widely supported than +X.509; the HTTP transport remains the interoperable path. + +**Additive constraint (inherited from ADR-268 §3, restated as normative):** +the ADR-264 §14 acceptance path and the ADR-265 restart-attack tests must both +continue to pass with the `quic` feature **disabled**. + +## Implementation status + +| # | Item | Status | +|---|---|---| +| 1 | `FederationTransport` trait (announce / subscribe / sync_since) | shipped | +| 2 | `HttpPollTransport` — default, dependency-free, backfill of record | shipped | +| 3 | Push-on-revocation with polling backstop | shipped | +| 4 | `QuicTransport` behind the `quic` feature, raw-public-key identity bound to the biome key | shipped | +| 5 | Peer TLS identity ≠ registered federation key ⇒ connection refused | shipped | +| 6 | LoRaWAN network-server adapter (the actual sensor-boundary work) | honest follow-up | diff --git a/docs/ADR-270-rucelium-key-succession.md b/docs/ADR-270-rucelium-key-succession.md new file mode 100644 index 0000000..89a7603 --- /dev/null +++ b/docs/ADR-270-rucelium-key-succession.md @@ -0,0 +1,108 @@ +# ADR 270: Key Succession — Surviving the Institution + +Status: Accepted — closes an identity-takeover vector + +Date: 2026 08 02 + +Tags: rucelium, security, federation, key-rotation, succession, custodians, archival, tuf + +## 1. Context — a vulnerability found by asking a 20-year question + +The honest ledger asks: *could a stranger verify this record in 2046?* One row +came back amber — **"verify it after the institution that owned it is gone?"** +Chasing that row surfaced a live vulnerability in code already written. + +`FederationBus::register_biome(biome_id, pubkey, epoch)` accepted **any** +strictly-higher epoch and rebound the identity to the presented key. There was +no proof of continuity: the incoming key was not signed by the outgoing one. + +The attack is trivial. A peer — configured, malicious, or merely compromised — +announces `biome/thames-estuary` at epoch 999 with its own key. From that +moment the gateway accepts *the attacker's* summaries and revocations as +genuinely that biome's, and rejects the real biome's as an +`IdentityMismatch`. The identity-binding hardening added earlier +(`biome_id → key`) was doing real work, and rotation walked straight around +it. + +This is the same class of bug TUF's root-rotation design exists to prevent: +new root keys become trusted only through metadata signed by a **quorum of +the currently trusted keys**. + +## 2. Decision — genesis is trust-on-first-use; every rebinding is signed + +`register_biome` becomes **genesis only** — the single unauthenticated +binding, and idempotent for an unchanged key. Any attempt to rebind an +established identity returns `SuccessionRequired`. + +Rotation moves to `rotate_biome(&KeySuccession)`, a signed statement: + +```rust +KeySuccession { + biome_id, from_epoch, to_epoch, new_pubkey_hex, effective_ns, + new_custodians, new_custodian_threshold, + signatures: Vec<(signer_pubkey_hex, signature_hex)>, +} +``` + +Signatures cover the statement with the `signatures` list cleared, so they +commit to `from_epoch` — which is what stops a captured succession being +replayed onto a later state. `from_epoch` must equal the bus's current epoch +and `to_epoch` must strictly exceed it. + +## 3. Decision — two authorisation paths, and no third + +1. **Continuity.** The succession is signed by the key currently bound to the + biome. Ordinary rotation: the holder hands over to its successor. +2. **Recovery.** The succession carries signatures from at least + `custodian_threshold` **distinct** custodians declared at genesis. + +Path 2 is the one that answers the ledger's amber row. Over twenty years an +institution being restructured, defunded, merged, or simply losing its key is +not an edge case — it is the *expected* case. An m-of-n custodian quorum +(a regulator, a university, a downstream authority) declared in advance can +hand the identity to a successor **without the original key ever existing +again**. A succession may also rotate the custodian set itself, so governance +can evolve without breaking the chain. + +`custodian_threshold = 0` opts out: the identity dies with its key. That is a +legitimate choice for a short-lived deployment, and it is explicit rather than +accidental. + +Anything else is refused — including an epoch bump with no signatures at all, +which is precisely what used to succeed. + +## 4. Consequences + +Positive: the takeover vector is closed; environmental records stay +attributable across institutional change, which is the only way a 2026 +baseline is still citable in 2046; rotation and recovery are both auditable +artifacts rather than side effects of an API call. + +Negative / accepted: genesis remains trust-on-first-use (a first contact must +be trusted from somewhere — publishing genesis keys in a transparency log is +the follow-up); custodian key management is now a real operational +responsibility; and a biome that declares no custodians has no recovery path +by construction. + +Not addressed here: proving a *retired* key was validly retired to a verifier +who never saw the succession chain. Successions are Merkle-notarizable +(ADR-267) and chaining them into the notary is the natural next step. + +## Implementation status + +| # | Item | Status | +|---|---|---| +| 1 | `register_biome` is genesis-only; rebinding refused | shipped | +| 2 | `KeySuccession` + canonical bytes + `sign_succession` | shipped | +| 3 | Continuity path (outgoing key signs) | shipped | +| 4 | Recovery path (m-of-n custodian quorum) | shipped | +| 5 | Custodian-set handover; unreachable thresholds refused | shipped | +| 6 | Replay/rollback refusal bound to `from_epoch` | shipped | +| 7 | Genesis keys in a transparency log; successions notarized | follow-up | + +## Sources + +- TUF specification, root key rotation and thresholds: + +- TAP 8, generalised key rotation: + diff --git a/examples/Cargo.toml b/examples/Cargo.toml new file mode 100644 index 0000000..6b9d1e1 --- /dev/null +++ b/examples/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "rucelium-examples" +version.workspace = true +edition.workspace = true +description = "RuCelium worked applications: runnable, tested end-to-end scenarios for the practical deployment wedges and the biological research track (ADR-266)" +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false +keywords = ["environmental", "examples", "flood", "biodiversity"] +categories = ["science"] + +[dependencies] +rucelium-core = { workspace = true } +rucelium-abi = { workspace = true } +rucelium-transport = { workspace = true } +rucelium-ingest = { workspace = true } +rucelium-calibration = { workspace = true } +rucelium-worldgraph = { workspace = true } +rucelium-federation = { workspace = true } +rucelium-policy = { workspace = true } +rucelium-store = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/examples/src/bin/airborne-dna.rs b/examples/src/bin/airborne-dna.rs new file mode 100644 index 0000000..77fcc5f --- /dev/null +++ b/examples/src/bin/airborne-dna.rs @@ -0,0 +1,956 @@ +//! # airborne-dna — ADR-266 §4 track B3 (research track, NOT a product) +//! +//! An anomaly-triggered environmental-DNA observatory. An acoustic node and a +//! paired optical (illuminance) reference watch a river corridor; when the +//! acoustic anomaly survives the circadian cross-check, a DNA sampler is +//! triggered, and the metabarcoding result enriches the WorldGraph with taxon +//! nodes and typed evidence edges. +//! +//! Five episodes, each making one point: +//! +//! 1. **Confirmation** — an acoustic call is confirmed genetically. Two +//! independent modalities agree, so confidence rises and a `Supports` edge +//! is written from the sensor to the taxon. +//! 2. **Circadian confounder** — the dawn chorus spikes the acoustic activity +//! index. The paired optical reference shows civil twilight, the +//! circadian-adjusted detector refuses to call it an anomaly, and no +//! sampler cartridge is burned. A naive detector would have fired. +//! 3. **Contradiction** — an acoustic classifier calls *Myotis daubentonii*; +//! the DNA result contains zero *Myotis* reads. The disagreement is +//! recorded as a `Contradicts` edge and tracked, never silently resolved, +//! and **nothing escalates**. +//! 4. **Invasive detection** — DNA finds an invasive bivalve. An event is +//! raised, but its only evidence is molecular, so +//! [`bio_only_severity_cap`] holds it at `Advisory` until a conventional +//! survey confirms it. +//! 5. **The privacy gate (ADR-266 §4.1 item 4)** — a sample from a riverside +//! path contains human-classified reads. [`disclose`] refuses to release +//! *anything* from that sample: no taxa, no counts, no location. The +//! non-human taxa remain usable inside the biome, and every other sample +//! discloses normally. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin airborne-dna +//! ``` + +use rucelium_core::event::evidence_digest; +use rucelium_core::{ + EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, + SPEC_VERSION, +}; +use rucelium_examples::{banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S}; +use rucelium_federation::{verify_event, Biome, BiomeConfig}; +use rucelium_worldgraph::{EdgeKind, GraphNode, WorldGraph}; + +// --------------------------------------------------------------------------- +// The normative rules +// --------------------------------------------------------------------------- + +/// Hard cap on the weight of a biology-derived evidence edge, mirroring +/// `rucelium_worldgraph::RF_MAX_EVIDENCE_WEIGHT` (ADR-264 §8) as ADR-266 +/// §4.1 item 3 requires of every biological modality. +pub const BIO_MAX_EVIDENCE_WEIGHT: f32 = 0.3; + +/// Clamp a severity to at most [`Severity::Advisory`]. +/// +/// **The ADR-266 §4.1 item 3 rule, enforced.** A metabarcoding hit is +/// evidence that DNA was present in a water sample — not that a live +/// population is established, not where it came from, and not when it was +/// shed. Until a conventional survey agrees, it informs and does not alarm. +#[must_use] +pub fn bio_only_severity_cap(severity: Severity) -> Severity { + severity.min(Severity::Advisory) +} + +// --------------------------------------------------------------------------- +// DNA results and the privacy gate +// --------------------------------------------------------------------------- + +/// One taxon assignment from a metabarcoding run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaxonRead { + /// Binomial name as assigned by the reference database. + pub taxon: String, + /// Number of reads assigned to this taxon. + pub reads: u32, + /// Whether this taxon is on the biome's invasive-species list. + pub invasive: bool, + /// Whether this assignment is human (`Homo sapiens` or a human-classified + /// bin). Any `true` here arms the privacy gate. + pub human: bool, +} + +/// The result of one triggered DNA sample. +/// +/// Constructed only through [`DnaResult::new`], which derives +/// [`DnaResult::human_dna_present`] by scanning the assignments — the flag can +/// never be forgotten or set by hand. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DnaResult { + /// Laboratory sample identifier. + pub sample_id: String, + /// When the sampler fired, ns since Unix epoch. + pub sampled_ns: u64, + /// Sampler node identity. + pub node_id: u64, + /// Sequence number of the sampler's own observation. + pub sequence: u32, + /// Total reads in the run. + pub total_reads: u64, + /// Taxon assignments, highest read count first. + pub taxa: Vec, + /// **The privacy gate's input.** True when any assignment is human. + pub human_dna_present: bool, +} + +impl DnaResult { + /// Build a result, deriving the human-DNA flag from the assignments. + #[must_use] + pub fn new( + sample_id: &str, + sampled_ns: u64, + node_id: u64, + sequence: u32, + mut taxa: Vec, + ) -> Self { + taxa.sort_by(|a, b| b.reads.cmp(&a.reads).then_with(|| a.taxon.cmp(&b.taxon))); + let human_dna_present = taxa.iter().any(|t| t.human); + DnaResult { + sample_id: sample_id.to_string(), + sampled_ns, + node_id, + sequence, + total_reads: taxa.iter().map(|t| u64::from(t.reads)).sum(), + taxa, + human_dna_present, + } + } + + /// Non-human assignments. These stay usable **inside** the biome even + /// when the privacy gate blocks the sample from disclosure. + #[must_use] + pub fn non_human_taxa(&self) -> Vec<&TaxonRead> { + self.taxa.iter().filter(|t| !t.human).collect() + } + + /// Reads assigned to `taxon` (0 if absent). + #[must_use] + pub fn reads_for(&self, taxon: &str) -> u32 { + self.taxa + .iter() + .find(|t| t.taxon == taxon) + .map_or(0, |t| t.reads) + } +} + +/// What actually leaves the biome for a disclosed DNA sample. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisclosedPayload { + /// Sample identifier. + pub sample_id: String, + /// `(taxon, reads)` pairs — non-human assignments only. + pub taxa: Vec<(String, u32)>, + /// Location, coarsened per the biome disclosure policy (ADR-264 §6). + pub geo: GeoPoint, +} + +/// Outcome of the ADR-266 §4.1 item 4 privacy gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Disclosure { + /// The sample may be disclosed; here is exactly what leaves. + Released(Box), + /// The sample is blocked. Nothing derived from it leaves — not the taxa, + /// not the counts, not the location. + Refused { + /// Sample identifier (the only thing the refusal itself names). + sample_id: String, + /// Why disclosure was refused. + reason: String, + }, +} + +impl Disclosure { + /// The disclosed payload, if any. + #[must_use] + pub fn payload(&self) -> Option<&DisclosedPayload> { + match self { + Disclosure::Released(p) => Some(p), + Disclosure::Refused { .. } => None, + } + } + + /// Whether the gate refused. + #[must_use] + pub fn is_refused(&self) -> bool { + matches!(self, Disclosure::Refused { .. }) + } +} + +/// **The ADR-266 §4.1 item 4 privacy gate.** +/// +/// Airborne and waterborne DNA may contain human genetic material. If any +/// assignment in the run is human-classified, the *whole sample* is blocked +/// from disclosure — not filtered, not redacted, not coarsened. Filtering +/// would still disclose that a sample was taken at a place and time where a +/// person was present, which is the thing the rule exists to prevent. +/// +/// ADR-264 §6 coarsening (applied here to released samples) is the ADR-266 +/// §4.1 item 4 *minimum*, explicitly "not sufficient" — which is why this +/// gate sits in front of it. +#[must_use] +pub fn disclose(result: &DnaResult, geo: GeoPoint, coarsen_decimals: u32) -> Disclosure { + if result.human_dna_present { + return Disclosure::Refused { + sample_id: result.sample_id.clone(), + reason: "human-classified DNA present: disclosure blocked (ADR-266 §4.1 item 4)" + .to_string(), + }; + } + Disclosure::Released(Box::new(DisclosedPayload { + sample_id: result.sample_id.clone(), + taxa: result + .non_human_taxa() + .into_iter() + .map(|t| (t.taxon.clone(), t.reads)) + .collect(), + geo: geo.coarsen(coarsen_decimals), + })) +} + +// --------------------------------------------------------------------------- +// Episodes +// --------------------------------------------------------------------------- + +/// How the acoustic call and the DNA result relate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Agreement { + /// Both modalities name the same taxon. + Confirmed, + /// The DNA result contains no reads for the acoustically called taxon. + Contradicted, + /// No acoustic call to compare against (a DNA-first detection). + NoAcousticCall, + /// The sampler was never triggered. + NotSampled, +} + +/// One monitored episode from trigger to verdict. +#[derive(Debug, Clone, PartialEq)] +pub struct Episode { + /// Narrative label. + pub label: String, + /// Simulated time, ns since Unix epoch. + pub at_ns: u64, + /// Acoustic activity index as measured. + pub acoustic_index: f64, + /// Paired optical reference, lux. + pub illuminance_lx: f64, + /// Naive acoustic anomaly score (no circadian covariate). + pub naive_z: f64, + /// Circadian-adjusted anomaly score, using the optical reference. + pub adjusted_z: f64, + /// Whether the naive detector would have triggered the sampler. + pub naive_would_trigger: bool, + /// Whether the sampler was actually triggered. + pub sampler_triggered: bool, + /// Why, in one line. + pub trigger_note: String, + /// The acoustic classifier's species call, if it made one. + pub acoustic_call: Option, + /// The DNA result, if a sample was taken. + pub dna: Option, + /// Verdict of the acoustic/DNA cross-check. + pub agreement: Agreement, + /// Confidence in the acoustic call before the DNA result. + pub confidence_before: f32, + /// Confidence after the DNA result. + pub confidence_after: f32, + /// Disclosure outcome for this episode's sample. + pub disclosure: Option, + /// Event raised, if any. + pub event: Option, + /// The disclosed (coarsened, re-signed) form of that event, if any. + pub disclosed_event: Option, +} + +/// Everything one deterministic run produces. +#[derive(Debug, Clone, PartialEq)] +pub struct Report { + /// The five episodes, in time order. + pub episodes: Vec, + /// Contradictions recorded in the WorldGraph. + pub contradiction_count: u64, + /// Taxon nodes registered in the WorldGraph. + pub taxon_nodes: Vec, + /// WorldGraph JSON (deterministic). + pub graph_json: String, + /// Largest weight on any DNA-derived evidence edge. + pub max_bio_edge_weight: f32, + /// Envelopes the real ingest pipeline verified. + pub verified_samples: usize, +} + +/// Acoustic anomaly trigger threshold (in baseline standard deviations). +pub const TRIGGER_Z: f64 = 3.0; +/// Illuminance above which a rise in acoustic activity is attributed to the +/// dawn/dusk chorus rather than to an anomaly. +pub const TWILIGHT_LX: f64 = 6.0; +/// Deterministic biome identity seed (examples only). +pub const BIOME_SEED: &[u8; 32] = b"rucelium-b3-dna-biome-seed-32b!!"; + +/// Slug a taxon name into a WorldGraph key. +#[must_use] +pub fn taxon_key(taxon: &str) -> String { + format!("taxon/{}", taxon.to_lowercase().replace(' ', "-")) +} + +// --------------------------------------------------------------------------- +// The scenario +// --------------------------------------------------------------------------- + +/// Run the whole scenario deterministically. +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn run() -> Report { + let mut rng = Rng::new(0x00B3_D4A0_5EED_1234); + let station = + GeoPoint::new(512_384_100, -32_117_400, 8_000).expect("valid station coordinates"); + + let mut acoustic = Node::new( + 0x00B3_0000_0000_0001, + SensorModality::Acoustic, + station, + "corridor acoustic array", + ); + let mut optical = Node::new( + 0x00B3_0000_0000_0002, + SensorModality::Optical, + station, + "corridor illuminance reference", + ); + let mut sampler = Node::new( + 0x00B3_0000_0000_0003, + SensorModality::Chemical, + station, + "eDNA autosampler", + ); + let mut gw = Gateway::with_nodes(&[acoustic, optical, sampler]); + // `Gateway::with_nodes` only reads the nodes; rebuild the emitting copies + // so the signers keep their own sequence counters. + acoustic = Node::new( + 0x00B3_0000_0000_0001, + SensorModality::Acoustic, + station, + "corridor acoustic array", + ); + optical = Node::new( + 0x00B3_0000_0000_0002, + SensorModality::Optical, + station, + "corridor illuminance reference", + ); + sampler = Node::new( + 0x00B3_0000_0000_0003, + SensorModality::Chemical, + station, + "eDNA autosampler", + ); + + let mut graph = WorldGraph::new(); + graph.add_node( + "ecosystem/river-corridor", + GraphNode::Ecosystem { + name: "Corridor survey reach".into(), + kind: "river_corridor".into(), + geo: station, + }, + ); + let biome = Biome::new(BiomeConfig::new("biome/river-corridor"), BIOME_SEED); + let coarsen = biome + .config() + .disclosure + .coarsen_decimals + .expect("the default disclosure policy coarsens"); + + // Learn a nocturnal acoustic baseline over 40 dark quarter-hours. + let baseline_mean = 41.0; + let baseline_sd = 5.2; + let mut verified = 0usize; + for i in 0..40u64 { + let ns = EPOCH_NS + i * 900 * NS_PER_S; + let idx = baseline_mean + rng.noise(baseline_sd); + let env = acoustic.emit(idx, ns, 1); + gw.ingest(&env, ns + 1_000_000) + .expect("acoustic sample verifies"); + let env = optical.emit(0.4 + rng.noise(0.1), ns, 1); + gw.ingest(&env, ns + 1_000_000) + .expect("optical sample verifies"); + verified += 2; + } + + // Episode inputs: (label, hours after epoch, acoustic index, lux, + // acoustic call, DNA taxa). + struct Spec { + label: &'static str, + hour: u64, + index: f64, + lux: f64, + call: Option<&'static str>, + taxa: Vec, + } + let tr = |taxon: &str, reads: u32, invasive: bool, human: bool| TaxonRead { + taxon: taxon.to_string(), + reads, + invasive, + human, + }; + let specs = [ + Spec { + label: "22:10 roost pass — acoustic call, dark", + hour: 12, + index: 78.0, + lux: 0.3, + call: Some("Rhinolophus ferrumequinum"), + taxa: vec![ + tr("Rhinolophus ferrumequinum", 4_180, false, false), + tr("Pipistrellus pipistrellus", 611, false, false), + tr("Salmo trutta", 208, false, false), + ], + }, + Spec { + label: "05:05 dawn chorus — circadian confounder", + hour: 19, + index: 96.0, + lux: 21.0, + call: None, + taxa: Vec::new(), + }, + Spec { + label: "23:40 Myotis call — genetically contradicted", + hour: 25, + index: 71.0, + lux: 0.2, + call: Some("Myotis daubentonii"), + taxa: vec![ + tr("Pipistrellus pygmaeus", 2_905, false, false), + tr("Anguilla anguilla", 774, false, false), + tr("Gammarus pulex", 522, false, false), + ], + }, + Spec { + label: "01:20 pontoon anomaly — invasive bivalve", + hour: 27, + index: 69.0, + lux: 0.2, + call: None, + taxa: vec![ + tr("Dreissena polymorpha", 3_461, true, false), + tr("Gammarus pulex", 940, false, false), + tr("Salmo trutta", 305, false, false), + ], + }, + Spec { + label: "02:55 riverside path — HUMAN DNA PRESENT", + hour: 29, + index: 66.0, + lux: 0.5, + call: None, + taxa: vec![ + tr("Homo sapiens", 5_102, false, true), + tr("Rattus norvegicus", 1_188, false, false), + tr("Canis lupus familiaris", 640, false, false), + tr("Salmo trutta", 121, false, false), + ], + }, + ]; + + let mut episodes = Vec::new(); + let mut max_bio_edge_weight = 0.0_f32; + + for (i, spec) in specs.iter().enumerate() { + let ns = EPOCH_NS + spec.hour * 3_600 * NS_PER_S; + let env = acoustic.emit(spec.index, ns, 1); + let ac = gw + .ingest(&env, ns + 1_000_000) + .expect("acoustic sample verifies"); + let env = optical.emit(spec.lux, ns, 1); + let op = gw + .ingest(&env, ns + 1_000_000) + .expect("optical sample verifies"); + verified += 2; + let sensor_key = graph.register_observation(ac.sample()); + graph.register_observation(op.sample()); + + let naive_z = (ac.sample().value - baseline_mean) / baseline_sd; + // Circadian adjustment: above civil-twilight illuminance the dawn/dusk + // chorus explains a large slice of the activity index. The optical + // reference — a conventional sensor — supplies the covariate. + let circadian_lift = if op.sample().value > TWILIGHT_LX { + 42.0 + } else { + 0.0 + }; + let adjusted_z = (ac.sample().value - baseline_mean - circadian_lift) / baseline_sd; + let naive_would_trigger = naive_z >= TRIGGER_Z; + let sampler_triggered = adjusted_z >= TRIGGER_Z; + + let (dna, disclosure, sampler_obs): (_, _, Option) = if sampler_triggered { + // The sampler logs its own observation (eDNA yield, ng/L) through + // the same verified path as every other node. + let yield_ng = 18.0 + rng.noise(2.0); + let env = sampler.emit(yield_ng, ns + 60 * NS_PER_S, 1); + let sm = gw + .ingest(&env, ns + 61 * NS_PER_S) + .expect("sampler observation verifies"); + verified += 1; + let seq = sm.sample().sequence; + let result = DnaResult::new( + &format!("edna-{:02}", i + 1), + ns + 60 * NS_PER_S, + sm.sample().node_id, + seq, + spec.taxa.clone(), + ); + let d = disclose(&result, station, coarsen); + (Some(result), Some(d), Some(sm.sample().clone())) + } else { + (None, None, None) + }; + + // Cross-check the acoustic call against the genetics. + let agreement = match (&spec.call, &dna) { + (_, None) => Agreement::NotSampled, + (None, Some(_)) => Agreement::NoAcousticCall, + (Some(call), Some(d)) => { + if d.reads_for(call) > 0 { + Agreement::Confirmed + } else { + Agreement::Contradicted + } + } + }; + let confidence_before = if spec.call.is_some() { 0.58 } else { 0.0 }; + let confidence_after = match agreement { + Agreement::Confirmed => 0.91, + Agreement::Contradicted => 0.19, + _ => confidence_before, + }; + + // Enrich the graph with taxon nodes and typed evidence edges. + if let Some(d) = &dna { + for t in d.non_human_taxa() { + let key = taxon_key(&t.taxon); + graph.add_node( + key.clone(), + GraphNode::Ecosystem { + name: t.taxon.clone(), + kind: "taxon".into(), + geo: station, + }, + ); + let want = (t.reads as f32 / 10_000.0).min(1.0); + let weight = want.min(BIO_MAX_EVIDENCE_WEIGHT); + graph + .add_edge( + &sensor_key, + &key, + EdgeKind::Supports, + weight, + format!("edna {} reads (capped evidence)", t.reads), + ) + .expect("both endpoints registered"); + max_bio_edge_weight = max_bio_edge_weight.max(weight); + } + if agreement == Agreement::Contradicted { + let call = spec.call.expect("contradiction implies a call"); + let key = taxon_key(call); + graph.add_node( + key.clone(), + GraphNode::Ecosystem { + name: call.to_string(), + kind: "taxon".into(), + geo: station, + }, + ); + graph + .record_contradiction( + &sensor_key, + &key, + format!("acoustic called {call}; 0 reads in sample {}", d.sample_id), + ) + .expect("both endpoints registered"); + } + } + + // Invasive detection. Molecular evidence only, so the cap applies. + let invasive: Vec<&TaxonRead> = dna + .as_ref() + .map(|d| { + d.non_human_taxa() + .into_iter() + .filter(|t| t.invasive) + .collect() + }) + .unwrap_or_default(); + let event = if invasive.is_empty() { + None + } else { + let obs = sampler_obs.as_ref().expect("a sample was taken"); + Some(EnvironmentalEvent { + evidence_digest: Some(evidence_digest(&[obs])), + spec_version: SPEC_VERSION.into(), + event_id: format!("evt-b3-invasive-{:02}", i + 1), + biome_id: "biome/river-corridor".into(), + kind: EventKind::Anomaly, + severity: bio_only_severity_cap(Severity::Warning), + modality: SensorModality::Chemical, + geo: station, + window_start_ns: ns, + window_end_ns: ns + 60 * NS_PER_S, + detected_ns: ns + 60 * NS_PER_S, + evidence: vec![EvidenceRef { + node_id: obs.node_id, + sequence: obs.sequence, + }], + confidence: 0.74, + message: format!( + "invasive taxon {} detected in eDNA ({} reads); conventional survey required \ + before escalation", + invasive[0].taxon, invasive[0].reads + ), + signature_hex: None, + signer_pubkey_hex: None, + }) + }; + // Only samples that cleared the privacy gate may be disclosed. + let disclosed_event = match (&event, &disclosure) { + (Some(ev), Some(d)) if !d.is_refused() => { + let mut signed = ev.clone(); + biome.sign_event(&mut signed); + biome.disclose_event(&signed, ns + 120 * NS_PER_S) + } + _ => None, + }; + + episodes.push(Episode { + label: spec.label.to_string(), + at_ns: ns, + acoustic_index: ac.sample().value, + illuminance_lx: op.sample().value, + naive_z, + adjusted_z, + naive_would_trigger, + sampler_triggered, + trigger_note: if sampler_triggered { + "anomaly survives the circadian cross-check → sampler fired".into() + } else { + "activity explained by illuminance (dawn chorus) → NO sample taken".into() + }, + acoustic_call: spec.call.map(str::to_string), + dna, + agreement, + confidence_before, + confidence_after, + disclosure, + event, + disclosed_event, + }); + } + + let taxon_nodes = { + let mut v: Vec = graph + .edges() + .filter(|e| e.to.starts_with("taxon/")) + .map(|e| e.to.clone()) + .collect(); + v.sort(); + v.dedup(); + v + }; + + Report { + episodes, + contradiction_count: graph.contradiction_count(), + taxon_nodes, + graph_json: graph.to_json(), + max_bio_edge_weight, + verified_samples: verified, + } +} + +/// Print the ADR-266 §4.1 acceptance bar and disclaim this scenario. +fn print_not_validated() { + println!("\n NOT VALIDATED"); + println!(" ADR-266 §4 track B3 is a RESEARCH TRACK, not a roadmap item and not a"); + println!(" product claim. The §4.1 item 3 acceptance bar is: one biological signal"); + println!(" predicts a CONFIRMED environmental condition >= 30 MINUTES EARLIER than the"); + println!(" conventional sensor, at > 90% PRECISION, across 3 INDEPENDENT LOCATIONS,"); + println!(" with NO PER-LOCATION RETRAINING. This scenario is one simulated station"); + println!(" with hand-written metabarcoding results; it demonstrates the DISCIPLINE"); + println!(" (paired optical reference, circadian confounder rejection, contradiction"); + println!(" edges, capped evidence, the human-DNA gate) and is NO evidence toward any"); + println!(" part of that bar. §4.1 item 4 additionally rates B3 privacy risk 5/5: no"); + println!(" airborne-DNA pilot may proceed without an explicit human-DNA handling"); + println!(" policy, and ADR-264 §6 coarsening/delay/access control are the MINIMUM,"); + println!(" explicitly NOT sufficient."); +} + +fn main() { + banner( + "airborne-dna — ADR-266 B3 anomaly-triggered eDNA observatory", + "acoustic + paired optical reference → DNA sampler → WorldGraph taxa", + ); + let r = run(); + + for ep in &r.episodes { + println!("\n {}\n", ep.label); + line( + "acoustic activity index", + format!("{:.1}", ep.acoustic_index), + ); + line( + "paired optical reference", + format!("{:.1} lx", ep.illuminance_lx), + ); + line("naive anomaly z", format!("{:.2}", ep.naive_z)); + line( + "circadian-adjusted anomaly z", + format!("{:.2}", ep.adjusted_z), + ); + line("naive detector would have sampled", ep.naive_would_trigger); + line("sampler actually triggered", ep.sampler_triggered); + println!(" -> {}", ep.trigger_note); + if let Some(call) = &ep.acoustic_call { + line("acoustic classifier call", call); + } + if let Some(d) = &ep.dna { + line( + "sample id / total reads", + format!("{} / {}", d.sample_id, d.total_reads), + ); + for t in &d.taxa { + println!( + " {:<32} {:>7} reads{}{}", + t.taxon, + t.reads, + if t.invasive { " [INVASIVE]" } else { "" }, + if t.human { " [HUMAN]" } else { "" } + ); + } + line("human DNA present", d.human_dna_present); + } + line("acoustic/DNA agreement", format!("{:?}", ep.agreement)); + if ep.acoustic_call.is_some() { + line( + "confidence before → after DNA", + format!("{:.2} → {:.2}", ep.confidence_before, ep.confidence_after), + ); + } + match &ep.disclosure { + Some(Disclosure::Released(p)) => { + line("disclosure", "RELEASED (coarsened per ADR-264 §6)"); + line("disclosed taxa", p.taxa.len()); + line( + "disclosed location", + format!("{:.2}, {:.2}", p.geo.latitude_deg(), p.geo.longitude_deg()), + ); + } + Some(Disclosure::Refused { sample_id, reason }) => { + line("disclosure", "REFUSED"); + line("refused sample", sample_id); + println!(" !! {reason}"); + println!(" !! nothing from this sample leaves: no taxa, no counts, no location."); + let internal = ep.dna.as_ref().map_or(0, |d| d.non_human_taxa().len()); + line("non-human taxa still usable in-biome", internal); + } + None => line("disclosure", "n/a — no sample taken"), + } + if let Some(ev) = &ep.event { + ev.validate().expect("event is structurally valid"); + line( + "event severity (bio-only cap applied)", + format!("{:?}", ev.severity), + ); + line("event confidence", format!("{:.2}", ev.confidence)); + println!(" -> {}", ev.message); + } + if let Some(de) = &ep.disclosed_event { + line("federated event verifies", verify_event(de)); + line( + "federated event location (coarsened)", + format!( + "{:.2}, {:.2}", + de.geo.latitude_deg(), + de.geo.longitude_deg() + ), + ); + } + } + + println!("\n WORLDGRAPH\n"); + line("taxon nodes linked by evidence edges", r.taxon_nodes.len()); + for t in &r.taxon_nodes { + println!(" {t}"); + } + println!(" (the WorldGraph is biome-resident DerivedFeature data: the blocked"); + println!(" sample's non-human taxa live here and NEVER federate; `Homo sapiens`"); + println!(" is never registered as a node at all.)"); + line( + "contradictions recorded (never resolved)", + r.contradiction_count, + ); + line( + "max DNA evidence edge weight", + format!("{:.2}", r.max_bio_edge_weight), + ); + line( + "hard cap on that weight", + format!("{BIO_MAX_EVIDENCE_WEIGHT:.2}"), + ); + line("envelopes cryptographically verified", r.verified_samples); + line("WorldGraph JSON bytes (deterministic)", r.graph_json.len()); + + print_not_validated(); + synthetic_footer("Metabarcoding results here are hand-written, not sequenced."); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn episode<'a>(r: &'a Report, needle: &str) -> &'a Episode { + r.episodes + .iter() + .find(|e| e.label.contains(needle)) + .expect("episode present") + } + + #[test] + fn genetic_confirmation_raises_confidence() { + let r = run(); + let ep = episode(&r, "roost pass"); + assert_eq!(ep.agreement, Agreement::Confirmed); + assert!(ep.confidence_after > ep.confidence_before); + let d = ep.dna.as_ref().expect("sample taken"); + assert!(d.reads_for("Rhinolophus ferrumequinum") > 0); + // The confirmation is a Supports edge to the taxon node, capped. + assert!(r + .taxon_nodes + .contains(&taxon_key("Rhinolophus ferrumequinum"))); + assert!(r.max_bio_edge_weight <= BIO_MAX_EVIDENCE_WEIGHT); + } + + #[test] + fn contradiction_is_recorded_and_never_escalates() { + let r = run(); + let ep = episode(&r, "Myotis call"); + assert_eq!(ep.agreement, Agreement::Contradicted); + let d = ep.dna.as_ref().expect("sample taken"); + assert_eq!(d.reads_for("Myotis daubentonii"), 0); + // Confidence fell, no event was raised, and the disagreement is + // tracked in the graph rather than silently resolved. + assert!(ep.confidence_after < ep.confidence_before); + assert!(ep.event.is_none()); + assert!(ep.disclosed_event.is_none()); + assert_eq!(r.contradiction_count, 1); + assert!(r.graph_json.contains("acoustic called Myotis daubentonii")); + } + + #[test] + fn invasive_detection_raises_an_event_capped_at_advisory() { + assert_eq!( + bio_only_severity_cap(Severity::Critical), + Severity::Advisory + ); + assert_eq!(bio_only_severity_cap(Severity::Warning), Severity::Advisory); + let r = run(); + let ep = episode(&r, "pontoon anomaly"); + let ev = ep.event.as_ref().expect("invasive event raised"); + ev.validate().unwrap(); + assert_eq!(ev.severity, Severity::Advisory); + assert!(ev.message.contains("Dreissena polymorpha")); + assert!(ev + .evidence_digest + .as_ref() + .is_some_and(|d| d.starts_with("sha256:"))); + // It federates, coarsened and re-signed by the biome. + let de = ep.disclosed_event.as_ref().expect("event disclosed"); + assert!(verify_event(de)); + assert_ne!(de.geo, ev.geo, "disclosure must coarsen the location"); + // Exactly one event in the whole run. + assert_eq!(r.episodes.iter().filter(|e| e.event.is_some()).count(), 1); + } + + #[test] + fn human_dna_blocks_disclosure_while_non_human_results_still_flow() { + let r = run(); + let ep = episode(&r, "HUMAN DNA"); + let d = ep.dna.as_ref().expect("sample taken"); + assert!(d.human_dna_present); + let disc = ep.disclosure.as_ref().expect("gate ran"); + assert!(disc.is_refused()); + assert!(disc.payload().is_none()); + assert!(ep.disclosed_event.is_none()); + // The non-human taxa remain usable inside the biome. + assert_eq!(d.non_human_taxa().len(), 3); + assert!(d.non_human_taxa().iter().all(|t| !t.human)); + // NO TAXA LEAK: nothing from the blocked sample appears in any + // disclosed payload anywhere in the run. + let blocked: Vec<&str> = d.taxa.iter().map(|t| t.taxon.as_str()).collect(); + let released: Vec<&DisclosedPayload> = r + .episodes + .iter() + .filter_map(|e| e.disclosure.as_ref().and_then(Disclosure::payload)) + .collect(); + assert_eq!(released.len(), 3, "the three clean samples still flow"); + for p in &released { + assert_ne!(p.sample_id, d.sample_id); + for (taxon, _) in &p.taxa { + assert!( + !taxon.contains("Homo"), + "human assignment leaked into a disclosed payload" + ); + } + } + // The human assignment is never registered in the graph either. + assert!(!r.graph_json.contains("Homo sapiens")); + assert!(!r.taxon_nodes.contains(&taxon_key("Homo sapiens"))); + // Species unique to the blocked sample must appear in no payload. + for name in blocked + .iter() + .filter(|n| **n != "Salmo trutta" && **n != "Gammarus pulex") + { + assert!( + !released + .iter() + .any(|p| p.taxa.iter().any(|(t, _)| t == name)), + "{name} leaked from the blocked sample" + ); + } + } + + #[test] + fn circadian_confounder_is_rejected_and_no_sample_is_taken() { + let r = run(); + let ep = episode(&r, "dawn chorus"); + // The naive detector sees the largest anomaly of the whole run. + assert!(ep.naive_would_trigger); + assert!(ep.naive_z > r.episodes.iter().map(|e| e.naive_z).fold(0.0, f64::max) - 1e-9); + // The paired optical reference explains it: no trigger, no sample, + // no event, nothing escalated. + assert!(ep.illuminance_lx > TWILIGHT_LX); + assert!(!ep.sampler_triggered); + assert_eq!(ep.agreement, Agreement::NotSampled); + assert!(ep.dna.is_none()); + assert!(ep.disclosure.is_none()); + assert!(ep.event.is_none()); + } + + #[test] + fn scenario_is_fully_deterministic() { + let a = run(); + let b = run(); + assert_eq!(a, b); + assert!(a.verified_samples > 80); + } +} diff --git a/examples/src/bin/biodiversity-habitat.rs b/examples/src/bin/biodiversity-habitat.rs new file mode 100644 index 0000000..56f2e30 --- /dev/null +++ b/examples/src/bin/biodiversity-habitat.rs @@ -0,0 +1,626 @@ +//! # biodiversity-habitat — deployment wedge #5 (ADR-266 §3.1) +//! +//! Biodiversity and habitat monitoring is the wedge that **monetizes +//! sovereignty**. ADR-266 §3.1 states the demand on the fabric in one line: +//! +//! > **Disclosure policy as a feature**: coordinate coarsening and delayed +//! > release for sensitive species. +//! +//! That is not a privacy nicety — it is the reason a protected-area manager +//! can put a network in the ground at all. Publishing the exact location of a +//! nest in real time is how you get the nest robbed. So the biome here runs a +//! [`DisclosurePolicy`] that **withholds** an event until an embargo elapses +//! and then releases only a **coarsened** copy, and this example proves all +//! four halves of that: +//! +//! 1. internally the event keeps full precision — the reserve's own staff can +//! act on it; +//! 2. [`Biome::disclose_event`] returns `None` for the entire embargo; +//! 3. after the embargo the released copy sits on a grid cell whose real size +//! in metres is computed and printed — precision is genuinely destroyed, +//! and five distinct sensor locations collapse onto one disclosed point; +//! 4. the coarsened copy is **re-signed**, so it still verifies with +//! [`verify_event`]: sovereignty does not cost verifiability. +//! +//! Every accepted observation is also projected into *SensorThings-inspired* +//! entities (`rucelium_federation::project_sample`) — inspired by, not +//! conformant with, OGC SensorThings 1.1. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin biodiversity-habitat +//! cargo test -p rucelium-examples --bin biodiversity-habitat +//! ``` + +use rucelium_core::{ + EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, + SPEC_VERSION, +}; +use rucelium_examples::{banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S}; +use rucelium_federation::{ + project_sample, verify_event, AcceptOutcome, Biome, BiomeConfig, DisclosurePolicy, + SensorThingsBundle, +}; +use rucelium_worldgraph::haversine_m; + +// --------------------------------------------------------------------------- +// Scenario constants +// --------------------------------------------------------------------------- + +/// The protected area's biome. +pub const BIOME_ID: &str = "biome/glen-feshie-reserve"; + +/// Deterministic seed for the biome's federated identity key. +pub const BIOME_SEED: &[u8; 32] = b"rucelium-example-habitat-biome!!"; + +/// Decimal degrees kept when disclosing a sensitive location. +pub const COARSEN_DECIMALS: u32 = 2; + +/// Embargo before a sensitive detection may be disclosed (72 hours). +pub const DISCLOSURE_DELAY_NS: u64 = 72 * 3_600 * NS_PER_S; + +/// Simulated seconds between sampling rounds (30 minutes). +pub const ROUND_S: u64 = 1_800; + +/// Number of sampling rounds. +pub const ROUNDS: usize = 4; + +/// Provisioned spore nodes. +pub const NODE_COUNT: usize = 5; + +/// Acoustic activity index above which the classifier reports a sensitive +/// species call. +pub const SENSITIVE_CALL_INDEX: f64 = 0.75; + +/// The round in which the sensitive species calls. +pub const DETECTION_ROUND: usize = 2; + +/// Calibration record referenced by every node in the reserve. +pub const CALIBRATION_ID: u32 = 51; + +// Node-table indices. +/// Acoustic recorder, birch stand. +pub const ACO_A: usize = 0; +/// Acoustic recorder, crag — the one that hears the sensitive species. +pub const ACO_B: usize = 1; +/// Acoustic recorder, riparian corridor. +pub const ACO_C: usize = 2; +/// Water-quality / stage sensor in the burn. +pub const WATER: usize = 3; +/// Weather station. +pub const WEATHER: usize = 4; + +/// Build a geo point, panicking on a coordinate the example itself got wrong. +fn geo(latitude_e7: i32, longitude_e7: i32, altitude_mm: i32) -> GeoPoint { + GeoPoint::new(latitude_e7, longitude_e7, altitude_mm).expect("example coordinates are in range") +} + +/// Provision the five spore nodes of the reserve. +/// +/// All five sit inside a single `COARSEN_DECIMALS`-degree grid cell but at +/// genuinely different places — which is exactly what makes the coarsening +/// irreversible rather than decorative. +#[must_use] +pub fn provision() -> Vec { + vec![ + Node::new( + 0x00B5_0000_0000_0001, + SensorModality::Acoustic, + geo(570_834_120, -36_681_200, 512_000), + "AR-1 acoustic recorder, birch stand", + ), + Node::new( + 0x00B5_0000_0000_0002, + SensorModality::Acoustic, + geo(570_838_770, -36_688_400, 559_000), + "AR-2 acoustic recorder, crag", + ), + Node::new( + 0x00B5_0000_0000_0003, + SensorModality::Acoustic, + geo(570_831_050, -36_684_300, 487_000), + "AR-3 acoustic recorder, riparian corridor", + ), + Node::new( + 0x00B5_0000_0000_0004, + SensorModality::WaterQuality, + geo(570_833_400, -36_686_900, 481_000), + "WQ-1 burn stage and quality", + ), + Node::new( + 0x00B5_0000_0000_0005, + SensorModality::Weather, + geo(570_836_940, -36_683_100, 521_000), + "WX-1 reserve weather station", + ), + ] +} + +/// Noise-free truth for sensor `idx` at `round`, in that sensor's unit. +#[must_use] +pub fn truth(idx: usize, round: usize) -> f64 { + match idx { + ACO_A => 0.22, + ACO_B if round == DETECTION_ROUND => 0.91, + ACO_B => 0.18, + ACO_C => 0.25, + WATER => 1.42, + _ => 11.4, + } +} + +/// Per-sensor noise standard deviation. +#[must_use] +pub fn noise_sd(idx: usize) -> f64 { + match idx { + ACO_A | ACO_B | ACO_C => 0.01, + WATER => 0.004, + _ => 0.05, + } +} + +/// Measurement time of round `round`. +#[must_use] +pub fn round_ns(round: usize) -> u64 { + EPOCH_NS + (round as u64) * ROUND_S * NS_PER_S +} + +/// The grid step, in 1e-7 degree units, that `keep_decimals` coarsening snaps +/// to. +#[must_use] +pub fn grid_step_e7(keep_decimals: u32) -> i32 { + 10_i32.pow(7 - keep_decimals.min(7)) +} + +/// North–south and east–west extent, in metres, of the disclosure grid cell +/// whose south-west corner is `corner`. +#[must_use] +pub fn cell_size_m(corner: GeoPoint, keep_decimals: u32) -> (f64, f64) { + let step = grid_step_e7(keep_decimals); + let north = GeoPoint { + latitude_e7: corner.latitude_e7 + step, + longitude_e7: corner.longitude_e7, + altitude_mm: 0, + }; + let east = GeoPoint { + latitude_e7: corner.latitude_e7, + longitude_e7: corner.longitude_e7 + step, + altitude_mm: 0, + }; + (haversine_m(corner, north), haversine_m(corner, east)) +} + +// --------------------------------------------------------------------------- +// The run +// --------------------------------------------------------------------------- + +/// Everything one habitat-monitoring run produced. +#[derive(Debug)] +pub struct HabitatRun { + /// Observations the biome accepted, in arrival order. + pub observations: Vec, + /// SensorThings-inspired projections — one per accepted observation. + pub bundles: Vec, + /// The internal, full-precision, biome-signed detection event. + pub internal_event: EnvironmentalEvent, + /// Disclosure attempted the instant the event was detected. + pub at_detection: Option, + /// Disclosure attempted one nanosecond before the embargo lifts. + pub one_ns_early: Option, + /// Disclosure attempted the instant the embargo lifts. + pub at_release: Option, + /// When the embargo lifts, ns since Unix epoch. + pub release_ns: u64, + /// The biome's federated public key. + pub biome_pubkey_hex: String, + /// The reserve's five true sensor locations. + pub true_locations: Vec, + /// Those five locations after coarsening. + pub coarsened_locations: Vec, +} + +/// Run the reserve for four rounds and disclose the sensitive detection. +/// +/// # Panics +/// +/// Panics if the scenario's own signed envelopes fail to ingest, or if the +/// sensitive species is never heard — the example is the specification. +#[must_use] +pub fn run_reserve() -> HabitatRun { + let mut nodes = provision(); + let mut gateway = Gateway::with_nodes(&nodes); + + // Sovereignty configuration: coarsen to ~1 km, embargo for 72 hours, and + // keep the raw acoustic material access-controlled. + let mut config = BiomeConfig::new(BIOME_ID); + config.disclosure = DisclosurePolicy { + coarsen_decimals: Some(COARSEN_DECIMALS), + delay_ns: DISCLOSURE_DELAY_NS, + open_access: false, + }; + let mut biome = Biome::new(config, BIOME_SEED); + + let mut rng = Rng::new(0x00B5_0B10_0000_2026); + let mut observations = Vec::new(); + let mut detection: Option<(usize, EvidenceRef, GeoPoint, u64, f64)> = None; + + for round in 0..ROUNDS { + let measured = round_ns(round); + for (idx, node) in nodes.iter_mut().enumerate() { + let value = truth(idx, round) + rng.noise(noise_sd(idx)); + let envelope = node.emit(value, measured, CALIBRATION_ID); + let sealed = gateway + .ingest(&envelope, measured + 1_000_000) + .expect("a node's own signed envelope must ingest"); + let sample = sealed.sample().clone(); + if sample.modality == SensorModality::Acoustic + && sample.value > SENSITIVE_CALL_INDEX + && detection.is_none() + { + detection = Some(( + idx, + EvidenceRef { + node_id: sample.node_id, + sequence: sample.sequence, + }, + sample.geo, + measured, + sample.value, + )); + } + assert_eq!(biome.accept(sealed), AcceptOutcome::Accepted); + observations.push(sample); + } + } + + let (idx, evidence, at, measured, index) = + detection.expect("the sensitive species is heard in the reserve"); + + // The internal event carries the real location. Reserve staff need it. + let mut internal_event = EnvironmentalEvent { + evidence_digest: None, + spec_version: SPEC_VERSION.to_string(), + event_id: "habitat:sensitive-species:2026-001".to_string(), + biome_id: BIOME_ID.to_string(), + kind: EventKind::Anomaly, + severity: Severity::Watch, + modality: SensorModality::Acoustic, + geo: at, + window_start_ns: round_ns(0), + window_end_ns: measured, + detected_ns: measured, + evidence: vec![evidence], + confidence: 0.94, + message: format!( + "sensitive-species call classified on {} (acoustic activity index {index:.2}); \ + location withheld under the reserve's disclosure policy", + nodes[idx].label + ), + signature_hex: None, + signer_pubkey_hex: None, + }; + internal_event.validate().expect("the event is well-formed"); + biome.sign_event(&mut internal_event); + + let release_ns = measured + DISCLOSURE_DELAY_NS; + let bundles = observations.iter().map(project_sample).collect(); + let true_locations: Vec = nodes.iter().map(|n| n.geo).collect(); + let coarsened_locations = true_locations + .iter() + .map(|g| g.coarsen(COARSEN_DECIMALS)) + .collect(); + + HabitatRun { + observations, + bundles, + at_detection: biome.disclose_event(&internal_event, measured), + one_ns_early: biome.disclose_event(&internal_event, release_ns - 1), + at_release: biome.disclose_event(&internal_event, release_ns), + internal_event, + release_ns, + biome_pubkey_hex: biome.public_key_hex(), + true_locations, + coarsened_locations, + } +} + +// --------------------------------------------------------------------------- +// Narrative +// --------------------------------------------------------------------------- + +fn main() { + banner( + "BIODIVERSITY & HABITAT MONITORING — ADR-266 wedge #5", + "5 signed spore nodes in a protected area; disclosure policy is the product", + ); + + let run = run_reserve(); + + println!(" Reserve"); + for node in provision() { + line( + &format!(" {}", node.label), + format!( + "{} @ {:.6}, {:.6}", + node.modality.as_str(), + node.geo.latitude_deg(), + node.geo.longitude_deg() + ), + ); + } + line("observations accepted", run.observations.len()); + line( + "disclosure policy", + format!( + "coarsen to {COARSEN_DECIMALS} dp, embargo {} h, raw access controlled", + DISCLOSURE_DELAY_NS / NS_PER_S / 3_600 + ), + ); + + println!("\n 1. The detection, internally"); + let internal = &run.internal_event; + line("event", &internal.event_id); + line( + "kind / severity / confidence", + format!( + "{:?} / {:?} / {:.2}", + internal.kind, internal.severity, internal.confidence + ), + ); + line( + "location (full precision)", + format!( + "{:.7}, {:.7} (alt {} mm)", + internal.geo.latitude_deg(), + internal.geo.longitude_deg(), + internal.geo.altitude_mm + ), + ); + line("message", &internal.message); + line("signature verifies", verify_event(internal)); + + println!("\n 2. Disclosure during the embargo"); + line( + "disclose_event at detection", + if run.at_detection.is_none() { + "None — withheld" + } else { + "RELEASED — guarantee broken" + }, + ); + line( + "disclose_event 1 ns before release", + if run.one_ns_early.is_none() { + "None — withheld" + } else { + "RELEASED — guarantee broken" + }, + ); + line( + "embargo lifts at", + format!( + "T+{} h after detection", + (run.release_ns - internal.detected_ns) / NS_PER_S / 3_600 + ), + ); + + println!("\n 3. Disclosure after the embargo"); + let disclosed = run.at_release.as_ref().expect("the embargo lifts"); + line( + "location (disclosed)", + format!( + "{:.7}, {:.7} (alt {} mm)", + disclosed.geo.latitude_deg(), + disclosed.geo.longitude_deg(), + disclosed.geo.altitude_mm + ), + ); + line( + "matches GeoPoint::coarsen", + disclosed.geo == internal.geo.coarsen(COARSEN_DECIMALS), + ); + line( + "displacement from the true site", + format!("{:.0} m", haversine_m(internal.geo, disclosed.geo)), + ); + let (north_m, east_m) = cell_size_m(disclosed.geo, COARSEN_DECIMALS); + line( + "disclosure grid cell", + format!( + "{north_m:.0} m north-south x {east_m:.0} m east-west ({:.2} km^2)", + north_m * east_m / 1_000_000.0 + ), + ); + line( + "the reserve's 5 true locations collapse to", + format!("{} distinct disclosed point(s)", { + let mut cells = run.coarsened_locations.clone(); + cells.sort_by_key(|g| (g.latitude_e7, g.longitude_e7)); + cells.dedup(); + cells.len() + }), + ); + line( + "disclosed event still verifies", + if verify_event(disclosed) { + "yes — re-signed by the biome" + } else { + "NO — guarantee broken" + }, + ); + let mut tampered = disclosed.clone(); + tampered.geo = internal.geo; + line( + "geo restored by a third party", + if verify_event(&tampered) { + "verifies — guarantee broken" + } else { + "signature breaks — the coarsening is bound in" + }, + ); + + println!("\n 4. SensorThings-inspired projection"); + line("accepted observations", run.observations.len()); + line("entity bundles produced", run.bundles.len()); + let first = &run.bundles[0]; + line("thing", &first.thing.iot_id); + line("datastream", &first.datastream.iot_id); + line("observation", &first.observation.iot_id); + line( + "phenomenonTime / result", + format!( + "{} / {:.3}", + first.observation.phenomenon_time, first.observation.result + ), + ); + line( + "note", + "SensorThings-INSPIRED projection — not an OGC-conformant implementation", + ); + + synthetic_footer( + "Acoustic indices are simulated; the disclosure policy, coarsening, \ + embargo, re-signing, and entity projection are the production code.", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disclosure_is_withheld_for_the_whole_embargo() { + let run = run_reserve(); + assert!( + run.at_detection.is_none(), + "a sensitive location must not leave the biome at detection time" + ); + assert!( + run.one_ns_early.is_none(), + "the embargo must hold to its last nanosecond" + ); + assert_eq!( + run.release_ns, + run.internal_event.detected_ns + DISCLOSURE_DELAY_NS + ); + } + + #[test] + fn post_embargo_release_is_coarsened_and_still_verifies() { + let run = run_reserve(); + let disclosed = run.at_release.expect("the embargo lifts"); + assert_eq!( + disclosed.geo, + run.internal_event.geo.coarsen(COARSEN_DECIMALS), + "the disclosed geo must be exactly GeoPoint::coarsen of the true one" + ); + assert_ne!( + disclosed.geo, run.internal_event.geo, + "coarsening must actually change the coordinates" + ); + assert_eq!(disclosed.geo.altitude_mm, 0, "altitude is dropped"); + assert!( + verify_event(&disclosed), + "the disclosed copy is re-signed and must still verify" + ); + assert_eq!( + disclosed.signer_pubkey_hex.as_deref(), + Some(run.biome_pubkey_hex.as_str()) + ); + // Everything except the location is unchanged. + assert_eq!(disclosed.event_id, run.internal_event.event_id); + assert_eq!(disclosed.severity, run.internal_event.severity); + assert_eq!(disclosed.evidence, run.internal_event.evidence); + } + + #[test] + fn coarsening_destroys_precision_irreversibly() { + let run = run_reserve(); + let disclosed = run.at_release.expect("the embargo lifts"); + let step = grid_step_e7(COARSEN_DECIMALS); + + // The disclosed point is a grid corner, not a location. + assert_eq!(disclosed.geo.latitude_e7 % step, 0); + assert_eq!(disclosed.geo.longitude_e7 % step, 0); + + // It is hundreds of metres from the true site, inside a cell of real, + // computed size. + let displacement = haversine_m(run.internal_event.geo, disclosed.geo); + assert!( + displacement > 100.0, + "displacement was only {displacement} m" + ); + let (north_m, east_m) = cell_size_m(disclosed.geo, COARSEN_DECIMALS); + assert!( + (north_m - 1_112.0).abs() < 5.0, + "0.01 degrees of latitude is ~1112 m, got {north_m}" + ); + assert!(east_m > 500.0 && east_m < north_m, "got {east_m} m"); + assert!(displacement < north_m, "displacement stays inside the cell"); + + // Five genuinely different sensor sites, one disclosed point: the + // mapping is many-to-one, so it cannot be inverted. + let mut distinct = run.coarsened_locations.clone(); + distinct.sort_by_key(|g| (g.latitude_e7, g.longitude_e7)); + distinct.dedup(); + assert_eq!(distinct.len(), 1, "all five sites share one disclosed cell"); + let spread = haversine_m(run.true_locations[ACO_B], run.true_locations[ACO_C]); + assert!(spread > 50.0, "the true sites really are {spread} m apart"); + } + + #[test] + fn the_internal_copy_keeps_full_precision() { + let run = run_reserve(); + let nodes = provision(); + assert_eq!(run.internal_event.geo, nodes[ACO_B].geo); + assert_ne!( + run.internal_event.geo, + run.internal_event.geo.coarsen(COARSEN_DECIMALS) + ); + assert!(verify_event(&run.internal_event)); + assert_eq!( + run.internal_event.geo.altitude_mm, + nodes[ACO_B].geo.altitude_mm + ); + } + + #[test] + fn a_third_party_cannot_restore_the_true_location() { + let run = run_reserve(); + let disclosed = run.at_release.expect("the embargo lifts"); + for forged_geo in [run.internal_event.geo, run.true_locations[ACO_A]] { + let mut tampered = disclosed.clone(); + tampered.geo = forged_geo; + assert!( + !verify_event(&tampered), + "editing the disclosed location must break the biome signature" + ); + } + } + + #[test] + fn every_accepted_observation_projects_to_sensorthings_entities() { + let run = run_reserve(); + assert_eq!(run.observations.len(), ROUNDS * NODE_COUNT); + assert_eq!(run.bundles.len(), run.observations.len()); + for (sample, bundle) in run.observations.iter().zip(&run.bundles) { + assert_eq!( + bundle.thing.iot_id, + format!("thing:node:{}", sample.node_id) + ); + assert_eq!( + bundle.observation.iot_id, + format!("obs:{}:{}", sample.node_id, sample.sequence) + ); + assert_eq!(bundle.observation.result, sample.value); + assert_eq!(bundle.datastream.thing_id, bundle.thing.iot_id); + assert_eq!(bundle.datastream.sensor_id, bundle.sensor.iot_id); + // GeoJSON is longitude-first. + assert_eq!( + bundle.location.location.coordinates, + [sample.geo.longitude_deg(), sample.geo.latitude_deg()] + ); + // And the whole bundle serializes for an external consumer. + assert!(serde_json::to_string(bundle).is_ok()); + } + } +} diff --git a/examples/src/bin/ecosystem-immune.rs b/examples/src/bin/ecosystem-immune.rs new file mode 100644 index 0000000..8e99f47 --- /dev/null +++ b/examples/src/bin/ecosystem-immune.rs @@ -0,0 +1,974 @@ +//! # ecosystem-immune — ADR-266 §4 track B2 (research track, NOT a product) +//! +//! Electroactive microbial biofilm nodes at four points down a waterway, each +//! paired with a conventional chemical probe and a water-quality reference. +//! A toxic slug is released between the top two points. +//! +//! What the scenario is built to show — and what it refuses to show: +//! +//! * The biofilm current collapses **before** the chemical probe registers +//! anything. That head start is a *hand-set model parameter here*, not a +//! measurement; see the NOT VALIDATED block. +//! * A biofilm response on its own is routed through [`bio_only_severity_cap`] +//! and can only ever be `Advisory` — no matter how large. Only agreement +//! with the conventional chemical probes escalates to `Warning`, and only +//! agreement at two or more points reaches `Critical`. +//! * A cold front depresses biofilm current at **every** point, including the +//! one upstream of the release. Temperature-compensated detection rejects +//! it; the naive detector does not. ADR-266 §4.1 item 1 in one screen. +//! * **Source localization**: the most-upstream responding point is reported, +//! with the point upstream of it staying quiet as the control. +//! * **The governed control path** (ADR-264 §9 / ADR-266 §4.1 item 5): the +//! agent only ever *proposes*. `PolicyEngine → SafetySimulator → +//! AuthorityRegistry → CommandSigner → GatewayValidator` decides. An +//! unauthorized agent submitting a byte-identical proposal is refused at +//! the authority stage and no receipt is ever produced for it. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin ecosystem-immune +//! ``` + +use rucelium_core::event::evidence_digest; +use rucelium_core::{ + EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, + SPEC_VERSION, +}; +use rucelium_examples::{banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S}; +use rucelium_policy::{ + verify_receipt, AgentProposal, AuditTrail, AuthorityRegistry, CommandSigner, ControlError, + ExecutionReceipt, GatewayValidator, PolicyConfig, PolicyEngine, ProposalKind, SafetyConfig, + SafetySimulator, +}; +use rucelium_worldgraph::{EdgeKind, GraphNode, WorldGraph}; + +// --------------------------------------------------------------------------- +// The normative rule +// --------------------------------------------------------------------------- + +/// Hard cap on the weight of a biofilm-derived evidence edge, mirroring +/// `rucelium_worldgraph::RF_MAX_EVIDENCE_WEIGHT` (ADR-264 §8) as ADR-266 +/// §4.1 item 3 requires for every biological modality. +pub const BIO_MAX_EVIDENCE_WEIGHT: f32 = 0.3; + +/// Clamp a severity to at most [`Severity::Advisory`]. +/// +/// **The ADR-266 §4.1 item 3 rule, enforced.** A biofilm current collapse, +/// however dramatic, is one unverified transducer's opinion until a +/// conventional instrument agrees with it. +#[must_use] +pub fn bio_only_severity_cap(severity: Severity) -> Severity { + severity.min(Severity::Advisory) +} + +// --------------------------------------------------------------------------- +// Waterway model +// --------------------------------------------------------------------------- + +/// Seconds between measurements (10 minutes). +pub const STEP_S: u64 = 600; +/// Commissioning steps: a deliberate temperature stimulus ramp used to +/// measure each biofilm's own temperature coefficient (ADR-266 §4.1 item 2, +/// "causal stimulus experiments"). +pub const COMMISSION_STEPS: usize = 30; +/// Quiet baseline steps after commissioning. +pub const BASELINE_STEPS: usize = 30; +/// Step index at which the cold-front confounder is evaluated. +pub const COLD_FRONT_STEP: usize = 66; +/// Step index at which the toxic slug reaches the first downstream point. +pub const RELEASE_STEP: usize = 90; +/// Total simulated steps. +pub const TOTAL_STEPS: usize = 102; +/// Biofilm deviation (in baseline standard deviations) that counts as a +/// response. +pub const BIOFILM_TRIGGER_Z: f64 = 5.0; +/// Chemical concentration above baseline, µmol/L, that the conventional +/// probe treats as a detection. This is the *conventional* rule and owes +/// nothing to biology. +pub const CHEMICAL_TRIGGER_UMOL: f64 = 2.0; + +/// One monitoring point: a biofilm anode plus its paired conventional +/// chemical probe and water-quality reference. +#[derive(Debug, Clone)] +pub struct Point { + /// Short identifier used in the narrative and the WorldGraph. + pub label: &'static str, + /// Distance downstream from the top of the reach, kilometres. Ordering + /// on this field is what "most upstream" means. + pub river_km: f64, + /// This colony's own resting current density, µA/cm². + pub base_ua: f64, + /// This colony's own current noise, µA/cm². + pub sd_ua: f64, + /// This colony's true temperature coefficient, µA/cm² per °C. + pub temp_coeff: f64, + /// Step at which the slug arrives (`None` = upstream of the release, the + /// spatial control). + pub slug_step: Option, +} + +/// The four instrumented points, ordered upstream → downstream. +#[must_use] +pub fn reach() -> Vec { + vec![ + Point { + label: "P0 headwater-intake", + river_km: 0.0, + base_ua: 322.0, + sd_ua: 5.0, + temp_coeff: 6.4, + slug_step: None, + }, + Point { + label: "P1 bankside-weir", + river_km: 1.4, + base_ua: 281.0, + sd_ua: 4.1, + temp_coeff: 5.1, + slug_step: Some(RELEASE_STEP), + }, + Point { + label: "P2 mill-pool", + river_km: 3.1, + base_ua: 356.0, + sd_ua: 6.2, + temp_coeff: 7.9, + slug_step: Some(RELEASE_STEP + 2), + }, + Point { + label: "P3 tidal-limit", + river_km: 5.2, + base_ua: 299.0, + sd_ua: 5.5, + temp_coeff: 6.9, + slug_step: Some(RELEASE_STEP + 4), + }, + ] +} + +/// Water temperature at `step`, °C. +/// +/// Commissioning is a deliberate 8 → 22 °C ramp (the causal stimulus that +/// identifies each colony's temperature coefficient). After that the reach +/// runs at ~15 °C with a gentle diurnal, until a cold front drops it by 9 °C +/// — the confounder. +#[must_use] +pub fn water_temp_c(step: usize, rng: &mut Rng) -> f64 { + let base = if step < COMMISSION_STEPS { + 8.0 + 14.0 * (step as f64 / (COMMISSION_STEPS - 1) as f64) + } else { + 15.0 + 1.6 * ((step as f64) * 0.18).sin() + }; + let cold = if (COLD_FRONT_STEP - 6..COLD_FRONT_STEP + 6).contains(&step) { + -9.0 + } else { + 0.0 + }; + base + cold + rng.noise(0.25) +} + +/// Toxic-slug intensity at a point, `0.0..=1.0`, as a sharp arrival followed +/// by slow washout. +#[must_use] +pub fn slug_intensity(step: usize, arrival: Option) -> f64 { + match arrival { + Some(a) if step >= a => (1.0 - (step - a) as f64 * 0.04).max(0.55), + _ => 0.0, + } +} + +/// Full-strength biofilm current collapse under toxic exposure, µA/cm². +pub const TOXIC_CURRENT_DROP_UA: f64 = -74.0; +/// Full-strength chemical concentration once the plume is measurable, µmol/L. +pub const TOXIC_CHEMICAL_UMOL: f64 = 8.4; +/// Steps between the biofilm response and the chemical probe registering the +/// plume at the same point. **A model parameter, not a measurement.** +pub const CHEMICAL_LAG_STEPS: usize = 3; + +// --------------------------------------------------------------------------- +// Detection state +// --------------------------------------------------------------------------- + +/// Per-colony baseline: its own temperature coefficient (measured by the +/// commissioning stimulus) and its own compensated-current statistics. +#[derive(Debug, Clone, PartialEq)] +pub struct ColonyBaseline { + /// Point label. + pub label: String, + /// Measured temperature coefficient, µA/cm² per °C. + pub temp_coeff: f64, + /// Mean temperature-compensated current, µA/cm². + pub mean_comp_ua: f64, + /// Standard deviation of the compensated current, µA/cm². + pub sd_comp_ua: f64, + /// Mean raw (uncompensated) current, µA/cm². + pub mean_raw_ua: f64, + /// Standard deviation of the raw current, µA/cm². + pub sd_raw_ua: f64, + /// Mean baseline chemical concentration, µmol/L. + pub mean_chem_umol: f64, +} + +/// One point's state at one evaluated step. +#[derive(Debug, Clone, PartialEq)] +pub struct PointState { + /// Point label. + pub label: String, + /// Distance downstream, km. + pub river_km: f64, + /// Biofilm node id. + pub node_id: u64, + /// Sequence number of the evaluated biofilm sample. + pub sequence: u32, + /// Measured biofilm current, µA/cm². + pub current_ua: f64, + /// Naive z-score with no temperature compensation. + pub raw_z: f64, + /// Temperature-compensated z-score. + pub comp_z: f64, + /// Conventional chemical probe reading, µmol/L. + pub chem_umol: f64, + /// Whether the compensated biofilm detector responded. + pub biofilm_fired: bool, + /// Whether the naive (uncompensated) detector responded. + pub naive_fired: bool, + /// Whether the conventional chemical probe detected the analyte. + pub chemical_fired: bool, + /// The verified observation itself, retained so the event can bind the + /// *content* of its evidence and not merely its identity (ADR-266 §3.1). + pub sample: EnvSample, +} + +/// A cross-checked assessment at one moment in the incident. +#[derive(Debug, Clone, PartialEq)] +pub struct Assessment { + /// Narrative label for this moment. + pub moment: String, + /// Simulated step. + pub step: usize, + /// Per-point state. + pub points: Vec, + /// Severity the evidence would justify before the biological cap. + pub uncapped: Severity, + /// Severity actually emitted. + pub severity: Severity, + /// Whether biology was the only evidence. + pub bio_only: bool, + /// Most-upstream responding point, if any. + pub source_label: Option, + /// The emitted event, if one was raised. + pub event: Option, +} + +/// One trip (or attempted trip) through the governed control path. +#[derive(Debug, Clone, PartialEq)] +pub struct GovernanceOutcome { + /// Proposing agent. + pub agent_id: String, + /// Whether the biome owner had granted this agent the actuator. + pub granted: bool, + /// Stage at which the proposal stopped, or `"executed"`. + pub stopped_at: String, + /// The refusal, if it was refused. + pub error: Option, + /// The signed execution receipt, produced only on the authorized path. + pub receipt: Option, + /// Whether the receipt's gateway attestation verifies. + pub receipt_verifies: bool, + /// Audit entries recorded for this proposal. + pub audit_stages: Vec, +} + +/// Everything one deterministic run produces. +#[derive(Debug, Clone, PartialEq)] +pub struct Report { + /// Per-colony learned baselines. + pub baselines: Vec, + /// The cold-front confounder assessment. + pub cold_front: Assessment, + /// The biofilm-only moment of the toxic incident. + pub biofilm_only: Assessment, + /// The chemically corroborated moment of the same incident. + pub corroborated: Assessment, + /// Steps between the biofilm response and chemical corroboration. + pub lead_steps: usize, + /// Authorized agent's governed intervention. + pub authorized: GovernanceOutcome, + /// Unauthorized agent's byte-identical proposal. + pub unauthorized: GovernanceOutcome, + /// Envelopes the real ingest pipeline verified. + pub verified_samples: usize, + /// WorldGraph JSON (deterministic). + pub graph_json: String, + /// Largest weight on any biofilm-derived evidence edge. + pub max_bio_edge_weight: f32, +} + +/// Simulated measurement time for `step`, derived from `EPOCH_NS`. +#[must_use] +pub fn step_ns(step: usize) -> u64 { + EPOCH_NS + step as u64 * STEP_S * NS_PER_S +} + +/// Cross-check biofilm evidence against the conventional chemical probes and +/// return `(uncapped severity, emitted severity, bio_only)`. +/// +/// * biofilm alone → whatever the biology "wanted", clamped by +/// [`bio_only_severity_cap`] to `Advisory`; +/// * biofilm + chemical agreement at one point → `Warning`; +/// * agreement at two or more points → `Critical`. +#[must_use] +pub fn cross_check(points: &[PointState]) -> (Severity, Severity, bool) { + let bio = points.iter().filter(|p| p.biofilm_fired).count(); + let agree = points + .iter() + .filter(|p| p.biofilm_fired && p.chemical_fired) + .count(); + if bio == 0 { + return (Severity::Advisory, Severity::Advisory, false); + } + if agree == 0 { + // Biology alone. It wanted to shout; the cap says Advisory. + let wanted = Severity::Warning; + return (wanted, bio_only_severity_cap(wanted), true); + } + let sev = if agree >= 2 { + Severity::Critical + } else { + Severity::Warning + }; + (sev, sev, false) +} + +/// Most-upstream point whose biofilm responded — the localized source. +#[must_use] +pub fn localize_source(points: &[PointState]) -> Option { + points + .iter() + .filter(|p| p.biofilm_fired) + .min_by(|a, b| { + a.river_km + .partial_cmp(&b.river_km) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|p| p.label.clone()) +} + +// --------------------------------------------------------------------------- +// Governed control path +// --------------------------------------------------------------------------- + +/// The actuator the biome owner has installed at the localized source. +pub const ISOLATION_GATE: &str = "isolation-gate/reach-b"; +/// Deterministic command-signing seed (examples only). +pub const SIGNER_SEED: &[u8; 32] = b"rucelium-b2-immune-signer-seed!!"; +/// Deterministic gateway receipt-signing seed (examples only). +pub const GATEWAY_SEED: &[u8; 32] = b"rucelium-b2-immune-gateway-seed!"; + +/// Run one proposal through every stage of the governed control path. +/// +/// The agent's entire power is constructing the [`AgentProposal`]. Nothing in +/// this function lets it actuate: each stage consumes the previous stage's +/// privately-constructed witness, and a missing authority grant ends the +/// journey before any command is ever signed. +#[must_use] +pub fn govern(agent_id: &str, granted: bool, now_ns: u64) -> GovernanceOutcome { + let mut audit = AuditTrail::new(); + let proposal = AgentProposal { + proposal_id: format!("prop-b2-{}", agent_id.replace('/', "-")), + agent_id: agent_id.to_string(), + biome_id: "biome/reach-b".into(), + kind: ProposalKind::ActuatorCommand { + actuator_id: ISOLATION_GATE.into(), + action: "close".into(), + magnitude: 0.75, + }, + justification: "biofilm collapse at P1 corroborated by chemical probe; isolate reach" + .into(), + proposed_ns: now_ns, + }; + + let mut policy_cfg = PolicyConfig::default(); + policy_cfg.allowed_actuators.insert(ISOLATION_GATE.into()); + let engine = PolicyEngine::new(policy_cfg); + let mut safety = SafetySimulator::new(SafetyConfig::default()); + let mut authority = AuthorityRegistry::new(); + if granted { + authority.grant("biome/reach-b", agent_id, ISOLATION_GATE); + } + let signer = CommandSigner::from_seed(SIGNER_SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED) + .with_max_commands_per_actuator(2); + + let finish = |stopped_at: &str, + error: Option, + receipt: Option, + audit: &AuditTrail| GovernanceOutcome { + agent_id: agent_id.to_string(), + granted, + stopped_at: stopped_at.to_string(), + error: error.map(|e| e.to_string()), + receipt_verifies: receipt.as_ref().is_some_and(verify_receipt), + receipt, + audit_stages: audit + .entries() + .iter() + .map(|e| e.stage.to_string()) + .collect(), + }; + + let evaluated = match engine.evaluate(proposal, now_ns, &mut audit) { + Ok(v) => v, + Err(e) => return finish("policy", Some(e), None, &audit), + }; + let simulated = match safety.simulate(evaluated, now_ns, &mut audit) { + Ok(v) => v, + Err(e) => return finish("safety", Some(e), None, &audit), + }; + let authorized = match authority.authorize(simulated, now_ns, &mut audit) { + Ok(v) => v, + Err(e) => return finish("authority", Some(e), None, &audit), + }; + let signed = signer.sign(authorized, now_ns, 60 * NS_PER_S, &mut audit); + let receipt = match gateway.validate_and_execute( + &signed, + now_ns + NS_PER_S, + |kind| match kind { + ProposalKind::ActuatorCommand { action, .. } => { + Ok(format!("isolation gate {action}d locally")) + } + _ => Err("unexpected command kind".into()), + }, + &mut audit, + ) { + Ok(r) => r, + Err(e) => return finish("gateway", Some(e), None, &audit), + }; + safety.record_execution(ISOLATION_GATE); + finish("executed", None, Some(receipt), &audit) +} + +// --------------------------------------------------------------------------- +// The scenario +// --------------------------------------------------------------------------- + +/// Run the whole scenario deterministically. +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn run() -> Report { + let points = reach(); + let n = points.len(); + let mut rng = Rng::new(0x00B2_1F1E_1DEC_0DE1); + + let mut nodes: Vec = Vec::new(); + for (i, p) in points.iter().enumerate() { + let lon = -2_400_000 + (i as i32) * 21_000; + let geo = GeoPoint::new(535_400_000, lon, 11_000).expect("valid reach coordinates"); + nodes.push(Node::new( + 0x00B2_0000_0000_0001 + i as u64, + SensorModality::Bioelectric, + geo, + p.label, + )); + } + for (i, p) in points.iter().enumerate() { + let lon = -2_400_000 + (i as i32) * 21_000; + let geo = GeoPoint::new(535_400_000, lon, 11_000).expect("valid reach coordinates"); + nodes.push(Node::new( + 0x00B2_0000_0000_0101 + i as u64, + SensorModality::Chemical, + geo, + p.label, + )); + } + for (i, p) in points.iter().enumerate() { + let lon = -2_400_000 + (i as i32) * 21_000; + let geo = GeoPoint::new(535_400_000, lon, 11_000).expect("valid reach coordinates"); + nodes.push(Node::new( + 0x00B2_0000_0000_0201 + i as u64, + SensorModality::WaterQuality, + geo, + p.label, + )); + } + let mut gw = Gateway::with_nodes(&nodes); + let mut graph = WorldGraph::new(); + graph.add_node( + "ecosystem/reach-b", + GraphNode::Ecosystem { + name: "Reach B discharge corridor".into(), + kind: "river_reach".into(), + geo: GeoPoint::new(535_400_000, -2_368_000, 11_000).expect("valid reach centroid"), + }, + ); + + // Commissioning + baseline accumulation. + let mut commission: Vec> = vec![Vec::new(); n]; + let mut base_comp: Vec> = vec![Vec::new(); n]; + let mut base_raw: Vec> = vec![Vec::new(); n]; + let mut base_chem: Vec> = vec![Vec::new(); n]; + let mut verified = 0usize; + let mut assessments: Vec<(usize, Vec)> = Vec::new(); + let mut coeffs = vec![0.0_f64; n]; + let mut baselines: Vec = Vec::new(); + + for step in 0..TOTAL_STEPS { + let ns = step_ns(step); + let temp = water_temp_c(step, &mut rng); + let mut row: Vec = Vec::new(); + for (i, p) in points.iter().enumerate() { + let intensity = slug_intensity(step, p.slug_step); + let current = p.base_ua + + p.temp_coeff * (temp - 15.0) + + TOXIC_CURRENT_DROP_UA * intensity + + rng.noise(p.sd_ua); + let env = nodes[i].emit(current, ns, 1); + let bio = gw + .ingest(&env, ns + 1_000_000) + .expect("biofilm sample verifies"); + verified += 1; + + let chem_arrival = p.slug_step.map(|a| a + CHEMICAL_LAG_STEPS); + let chem_intensity = slug_intensity(step, chem_arrival); + let chem = 0.21 + TOXIC_CHEMICAL_UMOL * chem_intensity + rng.noise(0.06); + let env = nodes[n + i].emit(chem.max(0.0), ns, 1); + let ch = gw + .ingest(&env, ns + 1_000_000) + .expect("chemical sample verifies"); + verified += 1; + + let env = nodes[2 * n + i].emit(temp, ns, 1); + gw.ingest(&env, ns + 1_000_000) + .expect("water-quality sample verifies"); + verified += 1; + + let current = bio.sample().value; + let chem = ch.sample().value; + + if step < COMMISSION_STEPS { + commission[i].push((temp, current)); + continue; + } + let comp = current - coeffs[i] * (temp - 15.0); + if step < COMMISSION_STEPS + BASELINE_STEPS { + base_comp[i].push(comp); + base_raw[i].push(current); + base_chem[i].push(chem); + continue; + } + let b = &baselines[i]; + let raw_z = if b.sd_raw_ua > 0.0 { + (current - b.mean_raw_ua) / b.sd_raw_ua + } else { + 0.0 + }; + let comp_z = if b.sd_comp_ua > 0.0 { + (comp - b.mean_comp_ua) / b.sd_comp_ua + } else { + 0.0 + }; + graph.register_observation(bio.sample()); + graph.register_observation(ch.sample()); + row.push(PointState { + label: p.label.to_string(), + river_km: p.river_km, + node_id: bio.sample().node_id, + sequence: bio.sample().sequence, + current_ua: current, + raw_z, + comp_z, + chem_umol: chem, + biofilm_fired: comp_z.abs() >= BIOFILM_TRIGGER_Z, + naive_fired: raw_z.abs() >= BIOFILM_TRIGGER_Z, + chemical_fired: chem > b.mean_chem_umol + CHEMICAL_TRIGGER_UMOL, + sample: bio.sample().clone(), + }); + } + + // End of commissioning: fit each colony's own temperature coefficient + // from the deliberate stimulus ramp. + if step == COMMISSION_STEPS - 1 { + for (i, pairs) in commission.iter().enumerate() { + let len = pairs.len() as f64; + let mt = pairs.iter().map(|q| q.0).sum::() / len; + let mc = pairs.iter().map(|q| q.1).sum::() / len; + let sxy: f64 = pairs.iter().map(|q| (q.0 - mt) * (q.1 - mc)).sum(); + let sxx: f64 = pairs.iter().map(|q| (q.0 - mt).powi(2)).sum(); + coeffs[i] = if sxx > 0.0 { sxy / sxx } else { 0.0 }; + } + } + // End of baseline: freeze per-colony statistics. + if step == COMMISSION_STEPS + BASELINE_STEPS - 1 { + baselines = points + .iter() + .enumerate() + .map(|(i, p)| { + let mean = |v: &Vec| v.iter().sum::() / v.len() as f64; + let sd = |v: &Vec, m: f64| { + (v.iter().map(|x| (x - m).powi(2)).sum::() / (v.len() - 1) as f64) + .sqrt() + }; + let mc = mean(&base_comp[i]); + let mr = mean(&base_raw[i]); + ColonyBaseline { + label: p.label.to_string(), + temp_coeff: coeffs[i], + mean_comp_ua: mc, + sd_comp_ua: sd(&base_comp[i], mc), + mean_raw_ua: mr, + sd_raw_ua: sd(&base_raw[i], mr), + mean_chem_umol: mean(&base_chem[i]), + } + }) + .collect(); + } + if !row.is_empty() { + assessments.push((step, row)); + } + } + + let pick = |step: usize, moment: &str| -> Assessment { + let pts = assessments + .iter() + .find(|(s, _)| *s == step) + .map(|(_, r)| r.clone()) + .unwrap_or_default(); + let (uncapped, severity, bio_only) = cross_check(&pts); + let source_label = localize_source(&pts); + let fired: Vec<&PointState> = pts.iter().filter(|p| p.biofilm_fired).collect(); + let event = if fired.is_empty() { + None + } else { + Some(EnvironmentalEvent { + evidence_digest: Some(evidence_digest( + &fired.iter().map(|p| &p.sample).collect::>(), + )), + spec_version: SPEC_VERSION.into(), + event_id: format!("evt-b2-immune-{step:04}"), + biome_id: "biome/reach-b".into(), + kind: EventKind::ThresholdExceeded, + severity, + modality: SensorModality::Bioelectric, + geo: GeoPoint::new(535_400_000, -2_368_000, 11_000).expect("valid centroid"), + window_start_ns: step_ns(step.saturating_sub(3)), + window_end_ns: step_ns(step), + detected_ns: step_ns(step), + evidence: fired + .iter() + .map(|p| EvidenceRef { + node_id: p.node_id, + sequence: p.sequence, + }) + .collect(), + confidence: if bio_only { 0.55 } else { 0.93 }, + message: format!( + "{} biofilm node(s) responding; source localized to {}", + fired.len(), + source_label.clone().unwrap_or_else(|| "unknown".into()) + ), + signature_hex: None, + signer_pubkey_hex: None, + }) + }; + Assessment { + moment: moment.to_string(), + step, + points: pts, + uncapped, + severity, + bio_only, + source_label, + event, + } + }; + + let cold_front = pick(COLD_FRONT_STEP, "cold front — confounder, no toxin"); + let biofilm_only = pick(RELEASE_STEP + 1, "toxic slug — biofilm only"); + let corroborated = pick( + RELEASE_STEP + CHEMICAL_LAG_STEPS + 3, + "toxic slug — chemical probes agree", + ); + + // Capped biofilm evidence edges for the corroborated moment. + let mut max_bio_edge_weight = 0.0_f32; + for p in corroborated.points.iter().filter(|p| p.biofilm_fired) { + let key = format!("sensor/{}", p.node_id); + let want = (p.comp_z.abs() / 30.0) as f32; + let weight = want.min(BIO_MAX_EVIDENCE_WEIGHT); + graph + .add_edge( + &key, + "ecosystem/reach-b", + EdgeKind::Supports, + weight, + format!("biofilm response z={:.1} (capped evidence)", p.comp_z), + ) + .expect("both endpoints registered"); + max_bio_edge_weight = max_bio_edge_weight.max(weight); + } + + let now_ns = step_ns(RELEASE_STEP + CHEMICAL_LAG_STEPS + 4); + let authorized = govern("agent/water-guardian", true, now_ns); + let unauthorized = govern("agent/unbound-optimizer", false, now_ns); + + Report { + baselines, + cold_front, + biofilm_only, + corroborated, + lead_steps: CHEMICAL_LAG_STEPS, + authorized, + unauthorized, + verified_samples: verified, + graph_json: graph.to_json(), + max_bio_edge_weight, + } +} + +/// Print the ADR-266 §4.1 acceptance bar and disclaim this scenario. +fn print_not_validated() { + println!("\n NOT VALIDATED"); + println!(" ADR-266 §4 track B2 is a RESEARCH TRACK, not a roadmap item and not a"); + println!(" product claim. The §4.1 item 3 acceptance bar is: one biological signal"); + println!(" predicts a CONFIRMED environmental condition >= 30 MINUTES EARLIER than the"); + println!(" conventional sensor, at > 90% PRECISION, across 3 INDEPENDENT LOCATIONS,"); + println!(" with NO PER-LOCATION RETRAINING. The 30-minute head start printed above is"); + println!(" a HAND-SET SIMULATION PARAMETER (CHEMICAL_LAG_STEPS), not a measurement:"); + println!(" it demonstrates what the pipeline does WITH such a lead, and is NOT"); + println!(" evidence that any lead exists. One simulated waterway is also not three"); + println!(" independent locations, and precision is undefined for a single incident."); +} + +fn main() { + banner( + "ecosystem-immune — ADR-266 B2 electroactive biofilm sentinels", + "4 biofilm anodes + paired chemical probes and water-quality references", + ); + let r = run(); + + println!(" 1. COMMISSIONING — per-colony temperature coefficients\n"); + println!( + " {:<22} {:>12} {:>12} {:>10} {:>12}", + "point", "µA per °C", "mean comp µA", "sd comp", "mean chem" + ); + for b in &r.baselines { + println!( + " {:<22} {:>12.2} {:>12.1} {:>10.2} {:>12.2}", + b.label, b.temp_coeff, b.mean_comp_ua, b.sd_comp_ua, b.mean_chem_umol + ); + } + println!(" -> each colony has its own resting current and its own temperature"); + println!(" response, measured by a deliberate stimulus ramp (§4.1 item 2)."); + + for a in [&r.cold_front, &r.biofilm_only, &r.corroborated] { + println!("\n {}\n", a.moment.to_uppercase()); + println!( + " {:<22} {:>8} {:>10} {:>9} {:>9} {:>10} {:>10}", + "point", "km", "µA/cm²", "raw z", "comp z", "chem µM", "biofilm" + ); + for p in &a.points { + println!( + " {:<22} {:>8.1} {:>10.1} {:>9.2} {:>9.2} {:>10.2} {:>10}", + p.label, + p.river_km, + p.current_ua, + p.raw_z, + p.comp_z, + p.chem_umol, + if p.biofilm_fired { "RESPOND" } else { "quiet" } + ); + } + let naive = a.points.iter().filter(|p| p.naive_fired).count(); + let bio = a.points.iter().filter(|p| p.biofilm_fired).count(); + let chem = a.points.iter().filter(|p| p.chemical_fired).count(); + line( + "naive / compensated / chemical detections", + format!("{naive} / {bio} / {chem}"), + ); + if bio == 0 { + line("biological evidence", "none — nothing to cap"); + } else { + line("evidence is biology only", a.bio_only); + line( + "severity before the biological cap", + format!("{:?}", a.uncapped), + ); + line("severity emitted", format!("{:?}", a.severity)); + } + line( + "source localized (most upstream responder)", + a.source_label.clone().unwrap_or_else(|| "—".into()), + ); + if let Some(ev) = &a.event { + ev.validate().expect("event is structurally valid"); + line("event confidence", format!("{:.2}", ev.confidence)); + } + } + println!( + " -> the biofilm responded {} steps ({} min) before the chemical probe.", + r.lead_steps, + r.lead_steps as u64 * STEP_S / 60 + ); + println!(" Until corroborated, the fabric refused to say more than Advisory."); + + println!("\n GOVERNED INTERVENTION — the agent proposes, policy decides\n"); + for g in [&r.authorized, &r.unauthorized] { + println!(" agent {}", g.agent_id); + line(" biome owner granted the actuator", g.granted); + line(" journey ended at stage", &g.stopped_at); + line( + " refusal", + g.error.clone().unwrap_or_else(|| "none".into()), + ); + line(" audit stages recorded", g.audit_stages.join(" → ")); + match &g.receipt { + Some(rc) => { + line(" execution receipt", &rc.outcome); + line(" verify_receipt()", g.receipt_verifies); + } + None => line(" execution receipt", "NONE — nothing was actuated"), + } + println!(); + } + line( + "max biofilm evidence edge weight", + format!("{:.2}", r.max_bio_edge_weight), + ); + line( + "hard cap on that weight", + format!("{BIO_MAX_EVIDENCE_WEIGHT:.2}"), + ); + line("envelopes cryptographically verified", r.verified_samples); + line("WorldGraph JSON bytes (deterministic)", r.graph_json.len()); + + print_not_validated(); + synthetic_footer("The 30-minute biological lead is a model input, not a result."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn biofilm_only_evidence_is_capped_at_advisory() { + assert_eq!( + bio_only_severity_cap(Severity::Critical), + Severity::Advisory + ); + assert_eq!(bio_only_severity_cap(Severity::Warning), Severity::Advisory); + assert_eq!(bio_only_severity_cap(Severity::Watch), Severity::Advisory); + + let r = run(); + let a = &r.biofilm_only; + assert!(a.bio_only, "no chemical probe has fired yet"); + assert!(a.points.iter().any(|p| p.biofilm_fired)); + assert!(a.points.iter().all(|p| !p.chemical_fired)); + assert_eq!(a.uncapped, Severity::Warning); + assert_eq!(a.severity, Severity::Advisory); + let ev = a.event.as_ref().expect("an advisory event was raised"); + ev.validate().unwrap(); + assert_eq!(ev.severity, Severity::Advisory); + assert!(ev + .evidence_digest + .as_ref() + .is_some_and(|d| d.starts_with("sha256:"))); + } + + #[test] + fn corroboration_escalates_and_the_cold_front_never_does() { + let r = run(); + // Chemical agreement at two or more points reaches Critical. + let c = &r.corroborated; + assert!(!c.bio_only); + assert!( + c.points + .iter() + .filter(|p| p.biofilm_fired && p.chemical_fired) + .count() + >= 2 + ); + assert_eq!(c.severity, Severity::Critical); + + // The confounder: the naive detector fires everywhere, including the + // control point upstream of the release; the compensated detector + // fires nowhere, no chemical agrees, and nothing escalates. + let f = &r.cold_front; + assert_eq!(f.points.iter().filter(|p| p.naive_fired).count(), 4); + assert_eq!(f.points.iter().filter(|p| p.biofilm_fired).count(), 0); + assert_eq!(f.points.iter().filter(|p| p.chemical_fired).count(), 0); + assert_eq!(f.severity, Severity::Advisory); + assert!(f.event.is_none(), "a cold front is not an incident"); + } + + #[test] + fn source_localizes_to_the_most_upstream_responding_point() { + let r = run(); + for a in [&r.biofilm_only, &r.corroborated] { + assert_eq!(a.source_label.as_deref(), Some("P1 bankside-weir")); + // The spatial control upstream of the release stays quiet. + let p0 = &a.points[0]; + assert_eq!(p0.label, "P0 headwater-intake"); + assert!(!p0.biofilm_fired, "the upstream control must not respond"); + assert!(!p0.chemical_fired); + } + // Localization really is the minimum river_km among responders. + let responders: Vec = r + .corroborated + .points + .iter() + .filter(|p| p.biofilm_fired) + .map(|p| p.river_km) + .collect(); + assert!(responders.iter().all(|km| *km >= 1.4)); + } + + #[test] + fn unauthorized_proposal_never_executes_and_leaves_no_receipt() { + let r = run(); + assert_eq!(r.unauthorized.stopped_at, "authority"); + assert!(r.unauthorized.receipt.is_none()); + assert!(!r.unauthorized.receipt_verifies); + assert!(r + .unauthorized + .error + .as_ref() + .expect("a refusal was recorded") + .contains("not authorized")); + // It never even reached the signing stage. + assert!(!r.unauthorized.audit_stages.iter().any(|s| s == "signed")); + assert!(!r.unauthorized.audit_stages.iter().any(|s| s == "executed")); + } + + #[test] + fn authorized_path_produces_exactly_one_verifiable_receipt() { + let r = run(); + assert_eq!(r.authorized.stopped_at, "executed"); + let rc = r.authorized.receipt.as_ref().expect("receipt issued"); + assert!(verify_receipt(rc), "gateway attestation must verify"); + assert!(r.authorized.receipt_verifies); + // Tampering with the attested outcome breaks the signature. + let mut forged = rc.clone(); + forged.outcome.push('!'); + assert!(!verify_receipt(&forged)); + assert_eq!( + r.authorized.audit_stages, + vec![ + "proposed", + "policy_evaluated", + "safety_simulated", + "authorized", + "signed", + "gateway_validated", + "executed" + ] + ); + } + + #[test] + fn scenario_is_fully_deterministic() { + let a = run(); + let b = run(); + assert_eq!(a, b); + assert!(a.verified_samples > 1_000); + assert!(a.max_bio_edge_weight <= BIO_MAX_EVIDENCE_WEIGHT); + } +} diff --git a/examples/src/bin/ecosystem-memory.rs b/examples/src/bin/ecosystem-memory.rs new file mode 100644 index 0000000..2632d0a --- /dev/null +++ b/examples/src/bin/ecosystem-memory.rs @@ -0,0 +1,792 @@ +//! # ecosystem-memory — ADR-266 §4 track B8 (research track, NOT a product) +//! +//! RuVector-style case-based reasoning over a biome's own history. Every day +//! of a ninety-day record is encoded as a six-feature state vector — water +//! level, soil moisture, air temperature, an optical chlorophyll proxy, an +//! acoustic index and rainfall — normalized against the record's own +//! statistics. A new state is then matched against that archive by cosine +//! similarity, and the nearest historical cases are reported *with their +//! provenance*: exactly which observations produced each retrieved vector. +//! +//! The archive contains two algal-bloom episodes, each preceded by a +//! three-day precursor window. Three new states are presented: +//! +//! 1. **A precursor-like state.** Retrieval returns labelled precursor days +//! and reports "N % similar to conditions M days before the YYYY-MM-DD +//! bloom". +//! 2. **An ordinary state.** Retrieval returns ordinary days — the archive is +//! not simply attracted to the dramatic episodes. +//! 3. **A confounded state**: the optical chlorophyll proxy spikes as hard as +//! a real precursor, but it is turbidity after heavy rain. The +//! conventional references (rainfall, water level, soil moisture) put it +//! nowhere near the precursor windows — the archive instead recognises it +//! as the earlier turbidity event — and nothing escalates. +//! +//! Every verdict this file produces is routed through +//! [`bio_only_severity_cap`], because a retrieval similarity is not a +//! forecast: with two episodes in ninety days there is no seasonal cycle to +//! learn from, and ADR-266 §4 says B8 "needs ≥ 1 seasonal cycle" before any +//! predictive claim is admissible. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin ecosystem-memory +//! ``` + +use rucelium_core::{EvidenceRef, GeoPoint, SensorModality, Severity}; +use rucelium_examples::{ + banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S, S_PER_DAY, +}; + +// --------------------------------------------------------------------------- +// The normative rule +// --------------------------------------------------------------------------- + +/// Clamp a severity to at most [`Severity::Advisory`]. +/// +/// **The ADR-266 §4.1 item 3 rule, enforced.** A historical-similarity score +/// is evidence that today resembles a past day. It is not a prediction, it +/// has no measured precision, and ADR-266 §4 records that B8 needs at least +/// one full seasonal cycle before it may claim anything. Every verdict here +/// goes through this function. +#[must_use] +pub fn bio_only_severity_cap(severity: Severity) -> Severity { + severity.min(Severity::Advisory) +} + +// --------------------------------------------------------------------------- +// State vectors +// --------------------------------------------------------------------------- + +/// Number of features in a daily biome state vector. +pub const FEATURES: usize = 6; +/// Human-readable feature names, in vector order. +pub const FEATURE_NAMES: [&str; FEATURES] = [ + "water_level_m", + "soil_moisture_pct", + "air_temperature_c", + "chlorophyll_ug_l", + "acoustic_index", + "rainfall_mm", +]; +/// Days in the historical archive. +pub const HISTORY_DAYS: usize = 90; +/// Neighbours returned per query. +pub const TOP_K: usize = 4; +/// Days of the first bloom episode. +pub const BLOOM_A: usize = 27; +/// Days of the second bloom episode. +pub const BLOOM_B: usize = 64; +/// Length of the characteristic precursor window, days. +pub const PRECURSOR_DAYS: usize = 3; + +/// What a historical day is known to be. Labels are the ground truth the +/// retrieval is scored against — they are never an input to the similarity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DayLabel { + /// An ordinary day. + Normal, + /// One of the three days immediately preceding a bloom. + Precursor { + /// The bloom this window precedes. + bloom_day: usize, + }, + /// A day during a bloom. + Bloom { + /// First day of that bloom. + bloom_day: usize, + }, + /// A day whose chlorophyll proxy spiked for a non-bloom reason. + ConfoundedSpike, +} + +impl DayLabel { + /// Whether this day is a labelled precursor. + #[must_use] + pub fn is_precursor(self) -> bool { + matches!(self, DayLabel::Precursor { .. }) + } +} + +/// One day of biome state, with the evidence that produced it. +#[derive(Debug, Clone, PartialEq)] +pub struct StateVector { + /// Day index in the record. + pub day: usize, + /// Simulated calendar date, `YYYY-MM-DD`. + pub date: String, + /// Raw feature values in [`FEATURE_NAMES`] order. + pub raw: [f64; FEATURES], + /// Normalized (z-scored) features — what similarity is computed on. + pub norm: [f64; FEATURES], + /// Ground-truth label. + pub label: DayLabel, + /// **Provenance**: the accepted observations this vector was built from, + /// one per feature, as `(node_id, sequence)` dedup keys. + pub evidence: Vec, +} + +/// Cosine similarity between two feature vectors, in `[-1, 1]`. +/// +/// Returns `0.0` when either vector has zero magnitude (undefined direction), +/// and clamps to `[-1, 1]` so floating-point error can never leak a value +/// outside the mathematically valid range. +#[must_use] +pub fn cosine(a: &[f64; FEATURES], b: &[f64; FEATURES]) -> f64 { + let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na: f64 = a.iter().map(|x| x * x).sum::().sqrt(); + let nb: f64 = b.iter().map(|x| x * x).sum::().sqrt(); + if na == 0.0 || nb == 0.0 { + return 0.0; + } + (dot / (na * nb)).clamp(-1.0, 1.0) +} + +/// One retrieved historical case. +#[derive(Debug, Clone, PartialEq)] +pub struct Retrieved { + /// Day index of the retrieved case. + pub day: usize, + /// Its simulated date. + pub date: String, + /// Cosine similarity to the query. + pub similarity: f64, + /// Its ground-truth label. + pub label: DayLabel, + /// Days between this case and the bloom it preceded, if it was a + /// precursor. + pub days_before_bloom: Option, + /// The date of that bloom, if any. + pub bloom_date: Option, + /// **Provenance** carried through the retrieval, never dropped. + pub evidence: Vec, +} + +impl Retrieved { + /// The sentence a human actually reads. + #[must_use] + pub fn sentence(&self) -> String { + match (self.days_before_bloom, &self.bloom_date) { + (Some(m), Some(d)) => format!( + "{:.0}% similar to conditions {m} day(s) before the {d} bloom", + self.similarity * 100.0 + ), + _ => format!( + "{:.0}% similar to {} ({:?})", + self.similarity * 100.0, + self.date, + self.label + ), + } + } +} + +/// One presented state and what the archive said about it. +#[derive(Debug, Clone, PartialEq)] +pub struct Query { + /// Narrative name. + pub name: String, + /// The presented state. + pub state: StateVector, + /// The `TOP_K` nearest historical cases, most similar first. + pub neighbours: Vec, + /// How many of them are labelled precursors. + pub precursor_hits: usize, + /// Severity the retrieval "wanted" before the cap. + pub uncapped: Severity, + /// Severity actually emitted — always `Advisory`. + pub severity: Severity, +} + +/// Everything one deterministic run produces. +#[derive(Debug, Clone, PartialEq)] +pub struct Report { + /// The ninety-day archive. + pub history: Vec, + /// Per-feature normalization means. + pub means: [f64; FEATURES], + /// Per-feature normalization standard deviations. + pub sds: [f64; FEATURES], + /// The three presented states. + pub queries: Vec, + /// Envelopes the real ingest pipeline verified. + pub verified_samples: usize, + /// Days of record available, against the seasonal cycle B8 requires. + pub seasonal_cycles_available: f64, +} + +// --------------------------------------------------------------------------- +// Calendar +// --------------------------------------------------------------------------- + +/// Civil `(year, month, day)` from a days-since-Unix-epoch count +/// (Howard Hinnant's `civil_from_days`, exact integer arithmetic). +#[must_use] +pub fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Simulated calendar date for `day`, derived from `EPOCH_NS` — never a wall +/// clock. +#[must_use] +pub fn date_of(day: usize) -> String { + let epoch_days = (EPOCH_NS / NS_PER_S / S_PER_DAY) as i64; + let (y, m, d) = civil_from_days(epoch_days + day as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Simulated measurement time for `day`, derived from `EPOCH_NS`. +#[must_use] +pub fn day_ns(day: usize) -> u64 { + EPOCH_NS + day as u64 * S_PER_DAY * NS_PER_S +} + +// --------------------------------------------------------------------------- +// Environment model +// --------------------------------------------------------------------------- + +/// Ground-truth label for a day of the archive. +#[must_use] +pub fn label_of(day: usize) -> DayLabel { + for bloom in [BLOOM_A, BLOOM_B] { + if (bloom - PRECURSOR_DAYS..bloom).contains(&day) { + return DayLabel::Precursor { bloom_day: bloom }; + } + if (bloom..bloom + 4).contains(&day) { + return DayLabel::Bloom { bloom_day: bloom }; + } + } + if day == 78 { + return DayLabel::ConfoundedSpike; + } + DayLabel::Normal +} + +/// Raw feature values for `day`, in [`FEATURE_NAMES`] order. +/// +/// The precursor signature is deliberately *multivariate*: falling water +/// level, drying soil, rising temperature, rising chlorophyll, a slightly +/// quieter soundscape and no rain. A chlorophyll spike alone is not the +/// pattern — which is exactly what makes day 78 rejectable. +#[must_use] +pub fn features_for(day: usize, rng: &mut Rng) -> [f64; FEATURES] { + let seasonal = (day as f64 * 0.055).sin(); + let mut f = [ + 2.42 + 0.10 * seasonal + rng.noise(0.04), + 30.5 + 2.4 * seasonal + rng.noise(0.9), + 18.2 + 3.1 * seasonal + rng.noise(0.6), + 6.1 + 0.5 * seasonal + rng.noise(0.35), + 55.0 + 2.0 * seasonal + rng.noise(1.4), + 3.4 + rng.noise(1.1), + ]; + match label_of(day) { + DayLabel::Precursor { bloom_day } => { + let ramp = (PRECURSOR_DAYS - (bloom_day - day)) as f64 + 1.0; + f[0] -= 0.19 * ramp; + f[1] -= 3.1 * ramp; + f[2] += 1.7 * ramp; + f[3] += 4.6 * ramp; + f[4] -= 2.1 * ramp; + f[5] = 0.0; + } + DayLabel::Bloom { .. } => { + // The bloom itself is a different STATE, not just a bigger + // precursor: the water deficit has broken, rain has returned, and + // the chlorophyll proxy is saturated. Cosine similarity is + // scale-invariant, so the two must differ in DIRECTION for + // retrieval to tell them apart — and here they do. + f[0] -= 0.12; + f[1] += 2.2; + f[2] += 1.4; + f[3] += 37.0; + f[4] -= 8.5; + f[5] += 9.0; + } + DayLabel::ConfoundedSpike => { + // Turbidity after a downpour: the optical proxy reads like a + // precursor, every conventional reference says the opposite. + f[0] += 0.44; + f[1] += 7.2; + f[2] -= 2.0; + f[3] += 13.4; + f[4] += 1.1; + f[5] = 26.0; + } + DayLabel::Normal => {} + } + f[5] = f[5].max(0.0); + f +} + +// --------------------------------------------------------------------------- +// The scenario +// --------------------------------------------------------------------------- + +/// Run the whole scenario deterministically. +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn run() -> Report { + let mut rng = Rng::new(0x00B8_0E11_5EED_A17E); + let site = GeoPoint::new(524_110_000, 5_940_000, 3_000).expect("valid lagoon coordinates"); + + // One node per feature: four conventional references and two biological + // proxies (the optical chlorophyll estimate and the acoustic index). + let mut nodes = vec![ + Node::new( + 0x00B8_0000_0000_0001, + SensorModality::WaterQuality, + site, + "lagoon stage gauge", + ), + Node::new( + 0x00B8_0000_0000_0002, + SensorModality::SoilMoisture, + site, + "margin soil probe", + ), + Node::new( + 0x00B8_0000_0000_0003, + SensorModality::Weather, + site, + "shore air temperature", + ), + Node::new( + 0x00B8_0000_0000_0004, + SensorModality::Optical, + site, + "chlorophyll optical proxy", + ), + Node::new( + 0x00B8_0000_0000_0005, + SensorModality::Acoustic, + site, + "lagoon acoustic index", + ), + Node::new( + 0x00B8_0000_0000_0006, + SensorModality::Weather, + site, + "tipping-bucket rain gauge", + ), + ]; + let mut gw = Gateway::with_nodes(&nodes); + let mut verified = 0usize; + + // --- build the archive through the real verified ingest path --- + let mut raws: Vec<([f64; FEATURES], Vec)> = Vec::with_capacity(HISTORY_DAYS); + let collect = |day: usize, + rng: &mut Rng, + nodes: &mut Vec, + gw: &mut Gateway, + verified: &mut usize| + -> ([f64; FEATURES], Vec) { + let target = features_for(day, rng); + let ns = day_ns(day); + let mut raw = [0.0; FEATURES]; + let mut evidence = Vec::with_capacity(FEATURES); + for (i, node) in nodes.iter_mut().enumerate() { + let env = node.emit(target[i], ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("state sample verifies"); + raw[i] = s.sample().value; + evidence.push(EvidenceRef { + node_id: s.sample().node_id, + sequence: s.sample().sequence, + }); + *verified += 1; + } + (raw, evidence) + }; + + for day in 0..HISTORY_DAYS { + raws.push(collect(day, &mut rng, &mut nodes, &mut gw, &mut verified)); + } + + // Normalization statistics come from the archive itself, and the *same* + // statistics normalize every query — no per-query refitting. + let mut means = [0.0; FEATURES]; + let mut sds = [0.0; FEATURES]; + for i in 0..FEATURES { + let m = raws.iter().map(|(r, _)| r[i]).sum::() / HISTORY_DAYS as f64; + let v = + raws.iter().map(|(r, _)| (r[i] - m).powi(2)).sum::() / (HISTORY_DAYS - 1) as f64; + means[i] = m; + sds[i] = v.sqrt(); + } + let normalize = |raw: &[f64; FEATURES]| -> [f64; FEATURES] { + let mut out = [0.0; FEATURES]; + for i in 0..FEATURES { + out[i] = if sds[i] > 0.0 { + (raw[i] - means[i]) / sds[i] + } else { + 0.0 + }; + } + out + }; + + let history: Vec = raws + .iter() + .enumerate() + .map(|(day, (raw, evidence))| StateVector { + day, + date: date_of(day), + raw: *raw, + norm: normalize(raw), + label: label_of(day), + evidence: evidence.clone(), + }) + .collect(); + + // --- three new states, presented after the archive closes --- + let query_specs: [(&str, DayLabel); 3] = [ + ( + "precursor-like state", + DayLabel::Precursor { bloom_day: 93 }, + ), + ("ordinary state", DayLabel::Normal), + ( + "confounded state — chlorophyll spike after heavy rain", + DayLabel::ConfoundedSpike, + ), + ]; + let mut queries = Vec::new(); + for (qi, (name, label)) in query_specs.iter().enumerate() { + let day = HISTORY_DAYS + qi; + // Synthesize the presented state from the same generator, so a query + // is nothing more than "another day of the same instrument set". + let target = match label { + DayLabel::Precursor { .. } => features_for(BLOOM_A - 1, &mut rng), + DayLabel::ConfoundedSpike => features_for(78, &mut rng), + _ => features_for(44, &mut rng), + }; + let ns = day_ns(day); + let mut raw = [0.0; FEATURES]; + let mut evidence = Vec::with_capacity(FEATURES); + for (i, node) in nodes.iter_mut().enumerate() { + let env = node.emit(target[i], ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("query sample verifies"); + raw[i] = s.sample().value; + evidence.push(EvidenceRef { + node_id: s.sample().node_id, + sequence: s.sample().sequence, + }); + verified += 1; + } + let state = StateVector { + day, + date: date_of(day), + raw, + norm: normalize(&raw), + label: *label, + evidence, + }; + + let mut scored: Vec<(f64, &StateVector)> = history + .iter() + .map(|h| (cosine(&state.norm, &h.norm), h)) + .collect(); + // Deterministic ordering: similarity descending, then day ascending. + scored.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.1.day.cmp(&b.1.day)) + }); + let neighbours: Vec = scored + .iter() + .take(TOP_K) + .map(|(sim, h)| { + let (days_before_bloom, bloom_date) = match h.label { + DayLabel::Precursor { bloom_day } => { + (Some(bloom_day - h.day), Some(date_of(bloom_day))) + } + _ => (None, None), + }; + Retrieved { + day: h.day, + date: h.date.clone(), + similarity: *sim, + label: h.label, + days_before_bloom, + bloom_date, + // Provenance is carried, never dropped. + evidence: h.evidence.clone(), + } + }) + .collect(); + let precursor_hits = neighbours.iter().filter(|r| r.label.is_precursor()).count(); + // Even a perfect retrieval is capped: it is a resemblance, not a + // forecast. + let uncapped = if precursor_hits >= TOP_K { + Severity::Warning + } else if precursor_hits > 0 { + Severity::Watch + } else { + Severity::Advisory + }; + queries.push(Query { + name: (*name).to_string(), + state, + neighbours, + precursor_hits, + uncapped, + severity: bio_only_severity_cap(uncapped), + }); + } + + Report { + history, + means, + sds, + queries, + verified_samples: verified, + // 90 days against a 365-day cycle. + seasonal_cycles_available: HISTORY_DAYS as f64 / 365.0, + } +} + +/// Print the ADR-266 §4.1 acceptance bar and disclaim this scenario. +fn print_not_validated(cycles: f64) { + println!("\n NOT VALIDATED"); + println!(" ADR-266 §4 track B8 is a RESEARCH TRACK, not a roadmap item and not a"); + println!(" product claim. The §4.1 item 3 acceptance bar is: one biological signal"); + println!(" predicts a CONFIRMED environmental condition >= 30 MINUTES EARLIER than the"); + println!(" conventional sensor, at > 90% PRECISION, across 3 INDEPENDENT LOCATIONS,"); + println!(" with NO PER-LOCATION RETRAINING. ADR-266 §4 additionally records that B8"); + println!(" NEEDS AT LEAST ONE FULL SEASONAL CYCLE before any predictive claim."); + println!(" This archive holds {cycles:.2} of a seasonal cycle and TWO bloom episodes."); + println!(" Two episodes cannot establish precision, cannot separate seasonality from"); + println!(" causation, and cannot generalize to another site. A similarity score is a"); + println!(" RESEMBLANCE, not a forecast: every verdict here is capped at Advisory."); +} + +fn main() { + banner( + "ecosystem-memory — ADR-266 B8 RuVector-style ecosystem memory", + "90 days of 6-feature biome state; retrieve the most similar past conditions", + ); + let r = run(); + + println!(" ARCHIVE\n"); + line("days of record", r.history.len()); + line( + "labelled bloom episodes", + format!( + "{} ({} and {})", + r.history + .iter() + .filter(|h| matches!(h.label, DayLabel::Bloom { .. })) + .map(|h| match h.label { + DayLabel::Bloom { bloom_day } => bloom_day, + _ => 0, + }) + .collect::>() + .len(), + date_of(BLOOM_A), + date_of(BLOOM_B) + ), + ); + line( + "labelled precursor days", + r.history.iter().filter(|h| h.label.is_precursor()).count(), + ); + println!("\n {:<20} {:>10} {:>10}", "feature", "mean", "sd"); + for (i, name) in FEATURE_NAMES.iter().enumerate() { + println!(" {:<20} {:>10.2} {:>10.2}", name, r.means[i], r.sds[i]); + } + println!(" -> the same statistics normalize the archive AND every query:"); + println!(" no per-query refitting, which is the point of §4.1's"); + println!(" 'no per-location retraining' clause."); + + for q in &r.queries { + println!("\n QUERY — {}\n", q.name.to_uppercase()); + print!(" raw state:"); + for (i, name) in FEATURE_NAMES.iter().enumerate() { + print!( + " {}={:.1}", + name.split('_').next().unwrap_or(name), + q.state.raw[i] + ); + } + println!(); + for (rank, n) in q.neighbours.iter().enumerate() { + println!( + " #{} day {:>2} ({}) cos {:+.4} {:?}", + rank + 1, + n.day, + n.date, + n.similarity, + n.label + ); + println!(" {}", n.sentence()); + println!( + " provenance: {} observation(s), first = node {:#018x} seq {}", + n.evidence.len(), + n.evidence[0].node_id, + n.evidence[0].sequence + ); + } + line( + "labelled precursor days in top-k", + format!("{} of {TOP_K}", q.precursor_hits), + ); + line("severity before the cap", format!("{:?}", q.uncapped)); + line("severity emitted", format!("{:?}", q.severity)); + } + + println!("\n CONFOUNDER CHECK\n"); + let conf = &r.queries[2]; + line( + "chlorophyll in the confounded state", + format!("{:.1} µg/L", conf.state.raw[3]), + ); + line( + "chlorophyll in a real precursor state", + format!("{:.1} µg/L", r.queries[0].state.raw[3]), + ); + line( + "rainfall in the confounded state", + format!("{:.1} mm", conf.state.raw[5]), + ); + line( + "rainfall in a real precursor state", + format!("{:.1} mm", r.queries[0].state.raw[5]), + ); + println!(" -> the biological proxy alone looks like a precursor. The conventional"); + println!(" references (rainfall, stage, soil moisture) point the state vector in"); + println!(" a different direction entirely, so the archive returns the earlier"); + println!(" turbidity event and bloom days — and NOT ONE precursor window. No"); + println!(" precursor advisory is issued; nothing escalates."); + + line("envelopes cryptographically verified", r.verified_samples); + print_not_validated(r.seasonal_cycles_available); + synthetic_footer("Two synthetic bloom episodes are not a validated bloom predictor."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn precursor_query_retrieves_labelled_precursor_days_in_top_k() { + let r = run(); + let q = &r.queries[0]; + assert_eq!( + q.precursor_hits, TOP_K, + "every neighbour must be a precursor" + ); + for n in &q.neighbours { + assert!(n.label.is_precursor(), "day {} is {:?}", n.day, n.label); + // Precursor days really are the labelled windows. + let m = n.days_before_bloom.expect("precursor has a bloom"); + assert!((1..=PRECURSOR_DAYS).contains(&m)); + assert!(n.bloom_date.is_some()); + assert!(n.sentence().contains("before the")); + } + // Both episodes are represented, not just the nearest one. + let blooms: std::collections::BTreeSet<&str> = q + .neighbours + .iter() + .filter_map(|n| n.bloom_date.as_deref()) + .collect(); + assert_eq!(blooms.len(), 2, "retrieval spans both bloom episodes"); + // Similarity is high and strictly ordered. + assert!(q.neighbours[0].similarity > 0.9); + for w in q.neighbours.windows(2) { + assert!(w[0].similarity >= w[1].similarity); + } + } + + #[test] + fn ordinary_query_retrieves_non_precursor_neighbours() { + let r = run(); + let q = &r.queries[1]; + assert_eq!(q.precursor_hits, 0); + for n in &q.neighbours { + assert!(!n.label.is_precursor(), "day {} is {:?}", n.day, n.label); + assert!(!matches!(n.label, DayLabel::Bloom { .. })); + assert!(n.days_before_bloom.is_none()); + } + assert_eq!(q.severity, Severity::Advisory); + } + + #[test] + fn confounded_chlorophyll_spike_retrieves_no_precursors_and_never_escalates() { + let r = run(); + let q = &r.queries[2]; + // The biological proxy alone looks just like a precursor... + let precursor_chl = r.queries[0].state.raw[3]; + assert!(q.state.raw[3] > precursor_chl * 0.7); + // ...but the conventional references disagree, and the archive is not + // fooled. + assert!(q.state.raw[5] > 20.0, "heavy rain is the real cause"); + assert_eq!(q.precursor_hits, 0); + assert_eq!(q.uncapped, Severity::Advisory); + assert_eq!(q.severity, Severity::Advisory); + } + + #[test] + fn cosine_similarity_is_symmetric_and_bounded() { + let r = run(); + for a in r.history.iter().take(30) { + for b in r.history.iter().skip(40).take(30) { + let ab = cosine(&a.norm, &b.norm); + let ba = cosine(&b.norm, &a.norm); + assert!((ab - ba).abs() < 1e-12, "cosine must be symmetric"); + assert!((-1.0..=1.0).contains(&ab), "cosine {ab} out of range"); + } + // Self-similarity is 1. + assert!((cosine(&a.norm, &a.norm) - 1.0).abs() < 1e-9); + } + // A zero vector has no direction, and that is reported as 0, not NaN. + assert_eq!(cosine(&[0.0; FEATURES], &r.history[0].norm), 0.0); + } + + #[test] + fn every_retrieved_case_carries_provenance() { + let r = run(); + for q in &r.queries { + assert_eq!(q.neighbours.len(), TOP_K); + for n in &q.neighbours { + assert_eq!( + n.evidence.len(), + FEATURES, + "one evidence ref per feature, day {}", + n.day + ); + // The refs really point at the archive day they claim to. + let src = &r.history[n.day]; + assert_eq!(n.evidence, src.evidence); + // And they are the real dedup keys of accepted observations. + for (i, e) in n.evidence.iter().enumerate() { + assert_eq!(e.node_id, 0x00B8_0000_0000_0001 + i as u64); + assert_eq!(e.sequence as usize, n.day); + } + } + } + } + + #[test] + fn scenario_is_fully_deterministic() { + let a = run(); + let b = run(); + assert_eq!(a, b); + assert_eq!(a.verified_samples, (HISTORY_DAYS + 3) * FEATURES); + assert!(a.seasonal_cycles_available < 1.0); + // Every verdict in the whole run is capped. + for q in &a.queries { + assert_eq!(q.severity, Severity::Advisory); + } + } +} diff --git a/examples/src/bin/flood-watershed.rs b/examples/src/bin/flood-watershed.rs new file mode 100644 index 0000000..7204c3a --- /dev/null +++ b/examples/src/bin/flood-watershed.rs @@ -0,0 +1,799 @@ +//! # flood-watershed — deployment wedge #1 (ADR-266 §3) +//! +//! Flood and watershed intelligence is wedge #1 because the outcome is +//! *measurable* and the cost of a missed event is high. What this example has +//! to prove is therefore not "we can read a gauge" — it is the five things a +//! conservation authority actually buys: +//! +//! 1. **Lead time.** Rising water is detected *before* a fixed conventional +//! gauge trigger, and the lead is reported in minutes. +//! 2. **Inference, not thresholds.** A blocked culvert is inferred from the +//! *relationship* between two gauges (upstream rising, downstream flat) — +//! neither gauge alone crosses anything. +//! 3. **Storm-time sensor displacement.** A node that physically moves during +//! the storm is detected, quarantined, and — critically — its readings do +//! not drive the alert. ADR-266 §3 calls this out as the load-bearing risk +//! of the wedge. +//! 4. **Contradiction is recorded, never resolved silently.** RuView RF +//! context that disagrees with a gauge becomes a `Contradicts` edge in the +//! WorldGraph (ADR-264 §8: RF is context, never ground truth). +//! 5. **Latency budget.** ADR-266 §3 promises local alerts under 5 s. The +//! detection path is timed and asserted. +//! +//! Sensor values are simulated; the signing, ingest verification, WorldGraph, +//! and RF-cap machinery is the production code. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin flood-watershed +//! cargo test -p rucelium-examples --bin flood-watershed +//! ``` + +use rucelium_core::{ + EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, SPEC_VERSION, +}; +use rucelium_examples::{banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S}; +use rucelium_worldgraph::{ + assess_plausibility, fuse_rf_context, haversine_m, RfContext, WorldGraph, +}; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Scenario constants +// --------------------------------------------------------------------------- + +/// The biome this watershed belongs to. +pub const BIOME_ID: &str = "biome/avon-headwaters"; + +/// Simulated seconds between sampling rounds (5 minutes). +pub const STEP_S: u64 = 300; + +/// Number of sampling rounds: 72 × 5 min = 6 simulated hours. +pub const STEPS: usize = 72; + +/// Provisioned spore nodes. The RuView RF context source is the twelfth +/// evidence source in the watershed but is **not** a signed spore node — it +/// never enters the sample path (ADR-264 §8). +pub const NODE_COUNT: usize = 11; + +/// The fixed level (metres) at which a conventional telemetered gauge at the +/// outlet raises its alarm. This is the baseline RuCelium has to beat. +pub const CONVENTIONAL_TRIGGER_M: f64 = 2.50; + +/// Catchment rainfall (mm/h) above which the storm is considered active. +pub const RAIN_ALERT_MM_H: f64 = 15.0; + +/// Mean soil volumetric water content (%) above which the catchment is +/// considered saturated and runoff is imminent. +pub const SOIL_SATURATION_PCT: f64 = 44.5; + +/// Upstream stage rise (metres per 15 minutes) that counts as a flood ramp. +pub const STAGE_RISE_M_PER_15MIN: f64 = 0.06; + +/// Culvert-blockage rule: upstream stage rise (metres) over a 30-minute +/// window that must be matched by the downstream gauge. +pub const CULVERT_RISE_M: f64 = 0.30; + +/// Culvert-blockage rule: the maximum downstream rise (metres) over the same +/// window that still counts as "flat". +pub const CULVERT_FLAT_M: f64 = 0.05; + +/// Window length (sampling rounds) for the culvert comparison: 6 × 5 min. +pub const CULVERT_WINDOW: usize = 6; + +/// Window length (sampling rounds) for the stage-rise rate: 3 × 5 min. +pub const RISE_WINDOW: usize = 3; + +/// A node that has moved more than this far (metres) from its commissioned +/// position is treated as storm-displaced. +pub const DISPLACEMENT_LIMIT_M: f64 = 25.0; + +/// The sampling round at which the storm rips one soil probe off its post. +pub const DISPLACEMENT_STEP: usize = 18; + +/// Gateway reception delay applied to every envelope (1 ms). +pub const INGEST_LATENCY_NS: u64 = 1_000_000; + +/// Calibration record referenced by every node in this scenario. +pub const CALIBRATION_ID: u32 = 11; + +/// Temporal window (ns) within which RF context is considered to say anything +/// about a sample. +pub const RF_WINDOW_NS: u64 = 900 * NS_PER_S; + +/// The ADR-266 §3 promise: a local alert in under five seconds. +pub const ALERT_BUDGET_MS: u128 = 5_000; + +// Node-table indices. +/// Upstream reach water-level gauge. +pub const WL_UP: usize = 0; +/// Water-level gauge immediately upstream of the culvert. +pub const WL_CULVERT_IN: usize = 1; +/// Water-level gauge immediately downstream of the culvert. +pub const WL_CULVERT_OUT: usize = 2; +/// Outlet water-level gauge — co-located with the conventional gauge. +pub const WL_OUTLET: usize = 3; +/// Tipping-bucket rain gauge, north of the catchment. +pub const RAIN_A: usize = 4; +/// Tipping-bucket rain gauge, south of the catchment. +pub const RAIN_B: usize = 5; +/// Soil-moisture probe, north slope. +pub const SOIL_A: usize = 6; +/// Soil-moisture probe, valley floor. +pub const SOIL_B: usize = 7; +/// Soil-moisture probe, riverbank — the one the storm displaces. +pub const SOIL_C: usize = 8; +/// Weather station, catchment head. +pub const WX_A: usize = 9; +/// Weather station, outlet. +pub const WX_B: usize = 10; + +// --------------------------------------------------------------------------- +// Synthetic storm +// --------------------------------------------------------------------------- + +/// Storm intensity at `step`, ramping 0 → 1 between rounds 6 and 36. +#[must_use] +pub fn storm(step: usize) -> f64 { + let s = step as f64; + if s < 6.0 { + 0.0 + } else { + ((s - 6.0) / 30.0).min(1.0) + } +} + +/// Storm intensity `lag` rounds ago (catchment response lag). +#[must_use] +pub fn lagged(step: usize, lag: usize) -> f64 { + storm(step.saturating_sub(lag)) +} + +/// Noise-free truth for sensor `idx` at `step`, in that sensor's unit. +#[must_use] +pub fn truth(idx: usize, step: usize) -> f64 { + match idx { + WL_UP => 1.10 + 1.90 * lagged(step, 6), + WL_CULVERT_IN => 1.05 + 2.10 * lagged(step, 7), + // The culvert is blocked: almost nothing gets through it. + WL_CULVERT_OUT => 0.95 + 0.05 * lagged(step, 7), + WL_OUTLET => 1.00 + 1.90 * lagged(step, 14), + RAIN_A | RAIN_B => 45.0 * storm(step), + SOIL_C if step >= DISPLACEMENT_STEP => 98.0, // probe in the river + SOIL_A | SOIL_B | SOIL_C => 28.0 + 30.0 * lagged(step, 3), + // Weather stations: air temperature drops as the front arrives. + _ => 12.0 - 4.0 * storm(step), + } +} + +/// Per-sensor noise standard deviation. +#[must_use] +pub fn noise_sd(idx: usize) -> f64 { + match idx { + WL_UP | WL_CULVERT_IN | WL_CULVERT_OUT | WL_OUTLET => 0.004, + RAIN_A | RAIN_B => 0.30, + SOIL_A | SOIL_B | SOIL_C => 0.08, + _ => 0.05, + } +} + +/// Measurement time of sampling round `step`. +#[must_use] +pub fn step_ns(step: usize) -> u64 { + EPOCH_NS + (step as u64) * STEP_S * NS_PER_S +} + +/// Minutes into the storm at sampling round `step`. +#[must_use] +pub fn step_min(step: usize) -> u64 { + (step as u64) * STEP_S / 60 +} + +/// Build a geo point, panicking on a coordinate the example itself got wrong. +#[must_use] +fn geo(latitude_e7: i32, longitude_e7: i32, altitude_mm: i32) -> GeoPoint { + GeoPoint::new(latitude_e7, longitude_e7, altitude_mm).expect("example coordinates are in range") +} + +/// Provision the eleven spore nodes of the watershed, in node-table order. +#[must_use] +pub fn provision() -> Vec { + vec![ + Node::new( + 0x00F1_0000_0000_0001, + SensorModality::WaterQuality, + geo(513_820_000, -29_810_000, 74_000), + "WL-1 upstream reach", + ), + Node::new( + 0x00F1_0000_0000_0002, + SensorModality::WaterQuality, + geo(513_795_000, -29_795_000, 68_000), + "WL-2 culvert inlet", + ), + Node::new( + 0x00F1_0000_0000_0003, + SensorModality::WaterQuality, + geo(513_790_000, -29_788_000, 67_000), + "WL-3 culvert outfall", + ), + Node::new( + 0x00F1_0000_0000_0004, + SensorModality::WaterQuality, + geo(513_745_000, -29_760_000, 59_000), + "WL-4 outlet (conventional gauge site)", + ), + Node::new( + 0x00F1_0000_0000_0005, + SensorModality::Weather, + geo(513_860_000, -29_840_000, 91_000), + "RG-1 rain gauge, north ridge", + ), + Node::new( + 0x00F1_0000_0000_0006, + SensorModality::Weather, + geo(513_710_000, -29_730_000, 55_000), + "RG-2 rain gauge, south field", + ), + Node::new( + 0x00F1_0000_0000_0007, + SensorModality::SoilMoisture, + geo(513_845_000, -29_825_000, 88_000), + "SM-1 north slope", + ), + Node::new( + 0x00F1_0000_0000_0008, + SensorModality::SoilMoisture, + geo(513_780_000, -29_775_000, 64_000), + "SM-2 valley floor", + ), + Node::new( + 0x00F1_0000_0000_0009, + SensorModality::SoilMoisture, + geo(513_762_000, -29_768_000, 61_000), + "SM-3 riverbank post", + ), + Node::new( + 0x00F1_0000_0000_000A, + SensorModality::Weather, + geo(513_855_000, -29_835_000, 90_000), + "WX-1 catchment head", + ), + Node::new( + 0x00F1_0000_0000_000B, + SensorModality::Weather, + geo(513_740_000, -29_755_000, 57_000), + "WX-2 outlet", + ), + ] +} + +/// Where the storm dumps the riverbank soil probe: ~85 m downstream, in the +/// water. +#[must_use] +pub fn displaced_geo() -> GeoPoint { + geo(513_754_000, -29_762_000, 58_000) +} + +/// The single RuView RF context observation for this storm: the radio sees no +/// surface change in the outlet reach at all. +#[must_use] +pub fn rf_context(at_ns: u64) -> RfContext { + // Built directly rather than via `RfContext::from_field_event`: the + // examples package does not depend on `rufield-core`, so the + // `FieldEvent` type is not in scope here. Every field carries exactly + // what the RuField MFS WiFi-CSI encoder would have produced. + RfContext { + source_event_id: "rf-avon-storm-01".to_string(), + device_id: "rf-gw-01".to_string(), + confidence: 0.92, + motion_energy: Some(0.08), + labels: vec!["no_surface_change".to_string()], + timestamp_ns: at_ns, + } +} + +// --------------------------------------------------------------------------- +// Detection output +// --------------------------------------------------------------------------- + +/// Everything one 6-hour storm run produced. +#[derive(Debug, Default)] +pub struct StormRun { + /// The flood-risk alert, if raised. + pub alert: Option, + /// The sampling round at which the alert was raised. + pub alert_step: Option, + /// The blocked-culvert inference, if raised. + pub culvert: Option, + /// The sensor-displacement event, if raised. + pub displacement: Option, + /// The sampling round at which the conventional gauge would have fired. + pub conventional_step: Option, + /// Node ids quarantined for displacement. + pub quarantined: Vec, + /// The WorldGraph, including any contradiction edges. + pub graph: WorldGraph, + /// Worst observed ingest + detection wall time for a single round. + pub max_detect: Duration, + /// Envelopes accepted by the gateway. + pub accepted: u64, +} + +impl StormRun { + /// Lead time in minutes of the RuCelium alert over the conventional + /// gauge trigger, when both fired. + #[must_use] + pub fn lead_time_min(&self) -> Option { + let (a, c) = (self.alert_step?, self.conventional_step?); + (c > a).then(|| step_min(c) - step_min(a)) + } +} + +/// Mean of a slice; `0.0` for an empty slice. +#[must_use] +fn mean(values: &[f64]) -> f64 { + if values.is_empty() { + 0.0 + } else { + values.iter().sum::() / values.len() as f64 + } +} + +/// Assemble an environmental event for this watershed. +fn watershed_event( + id: &str, + kind: EventKind, + severity: Severity, + modality: SensorModality, + at: GeoPoint, + window: (u64, u64), + evidence: Vec, + confidence: f32, + message: String, +) -> EnvironmentalEvent { + let event = EnvironmentalEvent { + evidence_digest: None, + spec_version: SPEC_VERSION.to_string(), + event_id: id.to_string(), + biome_id: BIOME_ID.to_string(), + kind, + severity, + modality, + geo: at, + window_start_ns: window.0, + window_end_ns: window.1, + detected_ns: window.1, + evidence, + confidence, + message, + signature_hex: None, + signer_pubkey_hex: None, + }; + event.validate().expect("scenario events are well-formed"); + event +} + +/// Run the full 6-hour storm. +/// +/// When `honour_quarantine` is `true` the gateway excludes displaced sensors +/// from the alert logic — the shipped behaviour. Passing `false` reproduces +/// the naive pipeline that trusts a sensor no longer where it was +/// commissioned, and is used to show what that costs. +#[must_use] +pub fn run_storm(honour_quarantine: bool) -> StormRun { + let mut nodes = provision(); + let commissioned: Vec = nodes.iter().map(|n| n.geo).collect(); + let mut gateway = Gateway::with_nodes(&nodes); + let mut rng = Rng::new(0x00F1_00D5_EED0_2026); + let mut history: Vec> = vec![Vec::new(); NODE_COUNT]; + let mut run = StormRun::default(); + + for step in 0..STEPS { + if step == DISPLACEMENT_STEP { + nodes[SOIL_C].geo = displaced_geo(); + } + let measured = step_ns(step); + let received = measured + INGEST_LATENCY_NS; + let started = Instant::now(); + let mut sequences = [0u32; NODE_COUNT]; + + // --- ingest: every value is a real signed envelope --------------- + for idx in 0..NODE_COUNT { + let value = truth(idx, step) + rng.noise(noise_sd(idx)); + let envelope = nodes[idx].emit(value, measured, CALIBRATION_ID); + let sealed = gateway + .ingest(&envelope, received) + .expect("a node's own signed envelope must ingest"); + let sample = sealed.sample(); + sequences[idx] = sample.sequence; + let key = run.graph.register_observation(sample); + history[idx].push(sample.value); + run.accepted += 1; + + // Storm-time displacement: the geo the node signed no longer + // matches where it was commissioned. + let moved_m = haversine_m(commissioned[idx], sample.geo); + if moved_m > DISPLACEMENT_LIMIT_M && !run.quarantined.contains(&sample.node_id) { + run.quarantined.push(sample.node_id); + run.displacement = Some(watershed_event( + &format!("flood:displaced:{}", sample.node_id), + EventKind::SensorTampered, + Severity::Warning, + sample.modality, + sample.geo, + (measured, measured), + vec![EvidenceRef { + node_id: sample.node_id, + sequence: sample.sequence, + }], + 0.99, + format!( + "{key} moved {moved_m:.0} m from its commissioned position \ + (limit {DISPLACEMENT_LIMIT_M:.0} m); quarantined, readings excluded" + ), + )); + } + } + + // --- detection --------------------------------------------------- + let latest = |idx: usize| history[idx][step]; + let usable_soil: Vec = [SOIL_A, SOIL_B, SOIL_C] + .into_iter() + .filter(|&i| !(honour_quarantine && run.quarantined.contains(&nodes[i].node_id))) + .map(latest) + .collect(); + let mean_rain = mean(&[latest(RAIN_A), latest(RAIN_B)]); + let mean_soil = mean(&usable_soil); + let stage_rise = if step >= RISE_WINDOW { + latest(WL_UP) - history[WL_UP][step - RISE_WINDOW] + } else { + 0.0 + }; + + // Blocked culvert: upstream climbing while the outfall stays flat. + if run.culvert.is_none() && step >= CULVERT_WINDOW { + let rise_in = latest(WL_CULVERT_IN) - history[WL_CULVERT_IN][step - CULVERT_WINDOW]; + let rise_out = latest(WL_CULVERT_OUT) - history[WL_CULVERT_OUT][step - CULVERT_WINDOW]; + if rise_in >= CULVERT_RISE_M && rise_out <= CULVERT_FLAT_M { + run.culvert = Some(watershed_event( + "flood:blocked-culvert:01", + EventKind::Anomaly, + Severity::Warning, + SensorModality::WaterQuality, + nodes[WL_CULVERT_IN].geo, + (step_ns(step - CULVERT_WINDOW), measured), + vec![ + EvidenceRef { + node_id: nodes[WL_CULVERT_IN].node_id, + sequence: sequences[WL_CULVERT_IN], + }, + EvidenceRef { + node_id: nodes[WL_CULVERT_OUT].node_id, + sequence: sequences[WL_CULVERT_OUT], + }, + ], + 0.93, + format!( + "culvert inlet rose {rise_in:.2} m in 30 min while the outfall \ + moved {rise_out:.2} m — obstruction inferred, neither gauge \ + crosses a level threshold" + ), + )); + } + } + + // Flood risk: rain + saturation + stage ramp, from healthy nodes only. + if run.alert.is_none() + && mean_rain > RAIN_ALERT_MM_H + && mean_soil > SOIL_SATURATION_PCT + && stage_rise > STAGE_RISE_M_PER_15MIN + { + let evidence = [RAIN_A, RAIN_B, SOIL_A, SOIL_B, SOIL_C, WL_UP] + .into_iter() + .filter(|&i| !(honour_quarantine && run.quarantined.contains(&nodes[i].node_id))) + .map(|i| EvidenceRef { + node_id: nodes[i].node_id, + sequence: sequences[i], + }) + .collect(); + run.alert = Some(watershed_event( + "flood:risk:01", + EventKind::FloodRisk, + Severity::Warning, + SensorModality::WaterQuality, + nodes[WL_UP].geo, + (step_ns(step.saturating_sub(RISE_WINDOW)), measured), + evidence, + 0.91, + format!( + "catchment rainfall {mean_rain:.1} mm/h, soil {mean_soil:.1} % VWC, \ + upstream stage +{stage_rise:.2} m/15min — runoff imminent" + ), + )); + run.alert_step = Some(step); + + // RF context is consulted exactly once, at the alert, and it is + // context only: it can support, it can contradict, it can never + // raise the alert on its own (ADR-264 §8). + let rf = rf_context(measured); + let outlet_key = format!("sensor/{}", nodes[WL_OUTLET].node_id); + let outfall_key = format!("sensor/{}", nodes[WL_CULVERT_OUT].node_id); + // The outlet gauge says the water surface is moving; the radio + // says it is not. That disagreement is recorded, not resolved. + let disagree = assess_plausibility(true, measured, &rf, RF_WINDOW_NS); + let _ = fuse_rf_context(&mut run.graph, &outlet_key, &rf, disagree); + // The blocked outfall really is flat; the radio agrees. + let agree = assess_plausibility(false, measured, &rf, RF_WINDOW_NS); + let _ = fuse_rf_context(&mut run.graph, &outfall_key, &rf, agree); + } + + // Baseline: what a conventional fixed-threshold gauge would do. + if run.conventional_step.is_none() && latest(WL_OUTLET) >= CONVENTIONAL_TRIGGER_M { + run.conventional_step = Some(step); + } + + run.max_detect = run.max_detect.max(started.elapsed()); + } + run +} + +// --------------------------------------------------------------------------- +// Narrative +// --------------------------------------------------------------------------- + +fn main() { + banner( + "FLOOD & WATERSHED INTELLIGENCE — ADR-266 wedge #1", + "11 signed spore nodes + 1 RuView RF context source, 6-hour storm ramp", + ); + + let run = run_storm(true); + let naive = run_storm(false); + + println!(" Catchment"); + for (idx, node) in provision().iter().enumerate() { + line( + &format!(" [{idx:>2}] {}", node.label), + format!("{} / node {:#018x}", node.modality.as_str(), node.node_id), + ); + } + line( + " [11] rf-gw-01 (RuView context)", + "wifi_csi / not a spore node", + ); + println!(); + line("envelopes signed, verified, accepted", run.accepted); + line( + "simulated span", + format!("{} h", STEPS as u64 * STEP_S / 3600), + ); + + println!("\n 1. Lead time over the conventional gauge"); + let alert = run.alert.as_ref().expect("the storm raises a flood alert"); + let alert_step = run.alert_step.expect("alert step recorded"); + let conv = run + .conventional_step + .expect("the conventional gauge eventually fires"); + line( + "RuCelium flood-risk alert", + format!("T+{} min (round {alert_step})", step_min(alert_step)), + ); + line( + &format!("conventional {CONVENTIONAL_TRIGGER_M:.2} m gauge trigger"), + format!("T+{} min (round {conv})", step_min(conv)), + ); + line( + "LEAD TIME", + format!( + "{} minutes", + run.lead_time_min().expect("alert precedes the gauge") + ), + ); + line( + "alert severity / confidence", + format!("{:?} / {:.2}", alert.severity, alert.confidence), + ); + line("alert message", &alert.message); + + println!("\n 2. Blocked-culvert inference (no gauge crosses a threshold)"); + let culvert = run.culvert.as_ref().expect("the blockage is inferred"); + line( + "event kind / severity", + format!("{:?} / {:?}", culvert.kind, culvert.severity), + ); + line( + "detected at", + format!("T+{} min", (culvert.detected_ns - EPOCH_NS) / NS_PER_S / 60), + ); + line("evidence nodes", culvert.evidence.len()); + line("message", &culvert.message); + + println!("\n 3. Storm-displaced sensor"); + let displaced = run.displacement.as_ref().expect("the storm displaces SM-3"); + line( + "event kind / severity", + format!("{:?} / {:?}", displaced.kind, displaced.severity), + ); + line( + "quarantined node ids", + run.quarantined + .iter() + .map(|id| format!("{id:#018x}")) + .collect::>() + .join(", "), + ); + line("message", &displaced.message); + line( + "displaced node in alert evidence?", + if alert + .evidence + .iter() + .any(|e| run.quarantined.contains(&e.node_id)) + { + "YES — guarantee broken" + } else { + "no — excluded from the alert" + }, + ); + let naive_step = naive.alert_step.expect("the naive pipeline also alerts"); + line( + "same pipeline WITHOUT quarantine", + format!( + "alerts at T+{} min ({} min early, driven by a probe in the river)", + step_min(naive_step), + step_min(alert_step) - step_min(naive_step) + ), + ); + + println!("\n 4. Contradiction between RF context and a gauge"); + line("WorldGraph nodes", run.graph.len()); + line("contradictions recorded", run.graph.contradiction_count()); + for edge in run.graph.contradictions() { + line( + &format!(" {} -> {}", edge.from, edge.to), + format!("{:?} w={:.2} — {}", edge.kind, edge.weight, edge.note), + ); + } + for edge in run.graph.edges_from("rf/rf-gw-01") { + if edge.kind != rucelium_worldgraph::EdgeKind::Contradicts { + line( + &format!(" {} -> {}", edge.from, edge.to), + format!( + "{:?} w={:.2} (RF weight cap {:.2})", + edge.kind, + edge.weight, + rucelium_worldgraph::RF_MAX_EVIDENCE_WEIGHT + ), + ); + } + } + + println!("\n 5. Local alert latency"); + line( + "worst round: 11 verifications + detection", + format!("{} ms", run.max_detect.as_millis()), + ); + line("ADR-266 §3 budget", format!("{ALERT_BUDGET_MS} ms")); + line( + "verdict", + if run.max_detect.as_millis() < ALERT_BUDGET_MS { + "within budget" + } else { + "OVER BUDGET — guarantee broken" + }, + ); + + synthetic_footer( + "The storm hydrograph is synthetic; the 5 s budget is measured on the \ + real ingest + detection path.", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detection_leads_the_conventional_gauge() { + let run = run_storm(true); + let lead = run.lead_time_min().expect("both triggers fire"); + assert!(lead > 0, "alert must precede the conventional gauge"); + assert_eq!(lead, 90, "the storm's lead time is deterministic"); + let alert = run.alert.expect("alert raised"); + assert_eq!(alert.kind, EventKind::FloodRisk); + assert!(alert.severity >= Severity::Warning); + // A second run of the same seed is identical. + let again = run_storm(true); + assert_eq!(again.alert_step, run.alert_step); + assert_eq!(again.conventional_step, run.conventional_step); + } + + #[test] + fn blocked_culvert_is_inferred_from_the_gauge_relationship() { + let run = run_storm(true); + let culvert = run.culvert.expect("blockage inferred"); + assert_eq!(culvert.kind, EventKind::Anomaly); + assert_eq!(culvert.evidence.len(), 2, "inlet and outfall both cited"); + // Neither gauge individually crosses the conventional trigger at the + // moment the blockage is inferred — the inference is relational. + let step = ((culvert.detected_ns - EPOCH_NS) / NS_PER_S / STEP_S) as usize; + assert!(truth(WL_CULVERT_IN, step) < CONVENTIONAL_TRIGGER_M); + assert!(truth(WL_CULVERT_OUT, step) < CONVENTIONAL_TRIGGER_M); + } + + #[test] + fn displaced_sensor_is_quarantined_and_never_drives_the_alert() { + let run = run_storm(true); + let displaced = run.displacement.expect("displacement detected"); + assert_eq!(displaced.kind, EventKind::SensorTampered); + let soil_c = provision()[SOIL_C].node_id; + assert_eq!(run.quarantined, vec![soil_c]); + + let alert = run.alert.expect("alert raised"); + assert!( + !alert.evidence.iter().any(|e| e.node_id == soil_c), + "a displaced sensor must not appear in alert evidence" + ); + + // Without quarantine the same pipeline alerts earlier — on a probe + // that is in the river rather than in the soil. + let naive = run_storm(false); + let naive_step = naive.alert_step.expect("naive alert"); + assert!( + naive_step < run.alert_step.expect("governed alert"), + "the displaced probe would have triggered a spurious early alert" + ); + assert!(naive + .alert + .expect("naive alert") + .evidence + .iter() + .any(|e| e.node_id == soil_c)); + } + + #[test] + fn rf_contradiction_is_recorded_and_rf_weight_stays_capped() { + let run = run_storm(true); + assert_eq!(run.graph.contradiction_count(), 1); + let contradictions = run.graph.contradictions(); + assert_eq!(contradictions.len(), 1); + assert_eq!(contradictions[0].from, "rf/rf-gw-01"); + assert_eq!( + contradictions[0].to, + format!("sensor/{}", provision()[WL_OUTLET].node_id) + ); + // The supporting edge exists too, and RF evidence is capped. + let supports: Vec<_> = run + .graph + .edges_from("rf/rf-gw-01") + .iter() + .filter(|e| e.kind == rucelium_worldgraph::EdgeKind::Supports) + .collect(); + assert_eq!(supports.len(), 1); + assert!(supports[0].weight <= rucelium_worldgraph::RF_MAX_EVIDENCE_WEIGHT); + } + + #[test] + fn alert_latency_is_within_the_five_second_budget() { + let run = run_storm(true); + assert!( + run.max_detect.as_millis() < ALERT_BUDGET_MS, + "worst round took {} ms, budget is {ALERT_BUDGET_MS} ms", + run.max_detect.as_millis() + ); + assert_eq!(run.accepted, (NODE_COUNT * STEPS) as u64); + } + + #[test] + fn every_alert_is_a_valid_federable_event() { + let run = run_storm(true); + for event in [&run.alert, &run.culvert, &run.displacement] + .into_iter() + .flatten() + { + event.validate().expect("event validates"); + assert_eq!(event.biome_id, BIOME_ID); + assert!(!event.evidence.is_empty()); + } + } +} diff --git a/examples/src/bin/industrial-compliance.rs b/examples/src/bin/industrial-compliance.rs new file mode 100644 index 0000000..02757c2 --- /dev/null +++ b/examples/src/bin/industrial-compliance.rs @@ -0,0 +1,977 @@ +//! # industrial-compliance — deployment wedge #3 (ADR-266 §3.1) +//! +//! Industrial environmental compliance is the wedge that **monetizes signed +//! provenance**: device identity, calibration lineage, location, quality, and +//! transformation lineage are not internal plumbing here, they *are* the +//! product. A regulator does not want a dashboard — a regulator wants an +//! evidence bundle they can check themselves, months later, without trusting +//! the operator, the gateway, or this program. +//! +//! So this example builds one exceedance at a plant's discharge point and +//! packages it as a **regulator-verifiable evidence bundle**: +//! +//! * the accepted observations (each with `provenance.verified`, the signer +//! key, and the transformation lineage that produced the reported value), +//! * the **signed calibration lineage chain** resolving to a reference-grade +//! anchor — held in a STRICT [`CalibrationStore`] where an anchor cannot be +//! declared by writing a method string, +//! * the biome-signed [`EnvironmentalEvent`], whose signed `message` binds +//! the consent limit, the calibration head, and a digest of the exact +//! observations. +//! +//! Then it runs [`verify_bundle`] — an **independent verifier** that sees only +//! the bundle's JSON and the public keys a regulator would hold — and shows it +//! passing on the genuine bundle and failing on every field mutation tried. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin industrial-compliance +//! cargo test -p rucelium-examples --bin industrial-compliance +//! ``` + +use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; +use rucelium_calibration::{ + sha256_hex, verify_record_signature, AuthorityRegistry, CalibrationAuthority, CalibrationError, + CalibrationSigner, CalibrationStore, Calibrator, +}; +use rucelium_core::calibration::Q16_ONE; +use rucelium_core::{ + CalibrationRecord, EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, + SensorModality, Severity, SPEC_VERSION, +}; +use rucelium_examples::{banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S}; +use rucelium_federation::{verify_event, AcceptOutcome, Biome, BiomeConfig}; +use rucelium_ingest::RejectReason; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +// --------------------------------------------------------------------------- +// Site constants +// --------------------------------------------------------------------------- + +/// The regulated site's biome. +pub const BIOME_ID: &str = "biome/tees-works-outfall"; + +/// Deterministic seed for the biome's federated identity key. +pub const BIOME_SEED: &[u8; 32] = b"rucelium-example-compliance-bio!"; + +/// Deterministic seed for the accredited chemistry laboratory's key. +pub const CONSENT_LAB_SEED: &[u8; 32] = b"rucelium-example-consent-lab-key"; + +/// Deterministic seed for the site metrology team's key. +pub const SITE_METROLOGY_SEED: &[u8; 32] = b"rucelium-example-site-metrology!"; + +/// Deterministic seed for a key nobody registered as an authority. +pub const ROGUE_AUTHORITY_SEED: &[u8; 32] = b"rucelium-example-rogue-authority"; + +/// Deterministic seed for an attacker's *device* key. +pub const ROGUE_DEVICE_SEED: &[u8; 32] = b"rucelium-example-rogue-device-k!"; + +/// Discharge consent limit for the regulated analyte, µmol/L. +pub const CONSENT_LIMIT_UMOL_L: f64 = 250.0; + +/// Seconds between the three discharge grab samples (15 minutes). +pub const SAMPLE_INTERVAL_S: u64 = 900; + +/// One day in nanoseconds. +pub const NS_PER_DAY: u64 = 86_400 * NS_PER_S; + +// Node-table indices. +/// The consented discharge point. +pub const DISCHARGE: usize = 0; +/// Particulate-matter monitor at the site boundary. +pub const PM: usize = 1; +/// Noise monitor at the nearest receptor. +pub const NOISE: usize = 2; +/// Boundary-activity (optical) monitor. +pub const BOUNDARY: usize = 3; + +/// The four plant monitors and their calibration identities. +const SPEC: [(u64, SensorModality, &str, f64, u32, u32); 4] = [ + ( + 0x00C3_0000_0000_0001, + SensorModality::Chemical, + "discharge point DP-1 (consented outfall)", + 0.0, + 100, + 101, + ), + ( + 0x00C3_0000_0000_0002, + SensorModality::AirQuality, + "PM-1 boundary particulate monitor", + 38.0, + 102, + 103, + ), + ( + 0x00C3_0000_0000_0003, + SensorModality::Acoustic, + "NM-1 nearest-receptor noise monitor", + 0.62, + 104, + 105, + ), + ( + 0x00C3_0000_0000_0004, + SensorModality::Optical, + "BA-1 boundary activity monitor", + 780.0, + 106, + 107, + ), +]; + +/// The three raw discharge readings, µmol/L before calibration. +pub const DISCHARGE_RAW: [f64; 3] = [405.0, 418.0, 431.0]; + +// --------------------------------------------------------------------------- +// The evidence bundle and its independent verifier +// --------------------------------------------------------------------------- + +/// A regulator-verifiable evidence bundle for one exceedance. +/// +/// Everything a third party needs is inside: the observations, the signed +/// calibration lineage that produced their values, and the biome-signed event +/// that asserts the exceedance. Nothing in it has to be taken on trust — +/// [`verify_bundle`] re-derives every claim from the bytes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EvidenceBundle { + /// Owning biome. + pub biome_id: String, + /// Every observation in the evidence window, post-calibration. + pub observations: Vec, + /// The discharge point's calibration lineage, child first, anchor last. + pub calibration_chain: Vec, + /// The biome-signed exceedance event. + pub event: EnvironmentalEvent, +} + +/// What [`verify_bundle`] managed to prove from the bundle alone. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedClaim { + /// Biome that signed the event. + pub biome_id: String, + /// Number of cited observations above the consent limit. + pub exceedances: usize, + /// The consent limit carried inside the signed message. + pub limit: f64, + /// The calibration record the cited observations were produced with. + pub calibration_head: u32, + /// The anchor the lineage chain resolves to. + pub anchor_id: u32, + /// The anchor's method (`factory` or `anchor_reference`). + pub anchor_method: String, +} + +/// Extract a `name=value` token from the event's signed message. +fn signed_field<'a>(message: &'a str, name: &str) -> Result<&'a str, String> { + message + .split(" | ") + .find_map(|part| part.trim().strip_prefix(name)) + .ok_or_else(|| format!("signed message carries no `{name}` field")) +} + +/// **The independent verifier.** +/// +/// Takes only the serialized bundle and the public keys a regulator holds — +/// no access to this program's state, the gateway, or the operator — and +/// re-checks every signature and every binding: +/// +/// 1. the event is signed by the trusted biome key and the signature verifies; +/// 2. the observations hash to the digest inside the *signed* message, so no +/// observation can be edited, added, or removed; +/// 3. every calibration record's signature verifies under a trusted +/// calibration authority; +/// 4. the lineage chain is parent-linked and terminates at an anchored root; +/// 5. every cited observation is verified at ingest, carries the chain head in +/// both `calibration_id` and its transformation lineage, and really does +/// exceed the consent limit named in the signed message. +/// +/// Any failure returns `Err` with the reason. Nothing is repaired. +pub fn verify_bundle( + bundle_json: &str, + trusted_biome_pubkey_hex: &str, + trusted_calibration_authorities: &[String], +) -> Result { + let bundle: EvidenceBundle = + serde_json::from_str(bundle_json).map_err(|e| format!("bundle does not parse: {e}"))?; + + // (1) Event authenticity. + bundle + .event + .validate() + .map_err(|e| format!("event is not structurally valid: {e}"))?; + if bundle.event.signer_pubkey_hex.as_deref() != Some(trusted_biome_pubkey_hex) { + return Err("event was not signed by the trusted biome key".to_string()); + } + if !verify_event(&bundle.event) { + return Err("event signature does not verify over its canonical bytes".to_string()); + } + if bundle.event.biome_id != bundle.biome_id { + return Err("bundle biome_id does not match the signed event".to_string()); + } + + // (2) The signed message binds limit, calibration head, and observations. + let message = &bundle.event.message; + let limit: f64 = signed_field(message, "limit=")? + .parse() + .map_err(|e| format!("signed limit is not a number: {e}"))?; + let calibration_head: u32 = signed_field(message, "cal_head=")? + .parse() + .map_err(|e| format!("signed calibration head is not a number: {e}"))?; + // Prefer the structured, signed field; fall back to the legacy + // message-embedded digest for events minted before the schema gained it. + let (signed_digest, observed_digest) = match bundle.event.evidence_digest.as_deref() { + // Structured, signed, content-binding (rucelium_core::evidence_digest). + Some(d) => ( + d.to_string(), + rucelium_core::evidence_digest(&bundle.observations.iter().collect::>()), + ), + // Legacy events that predate the schema field carried the digest + // inside the signed message string. + None => ( + signed_field(message, "obs_digest=")?.to_string(), + sha256_hex( + &serde_json::to_vec(&bundle.observations) + .map_err(|e| format!("observations do not serialize: {e}"))?, + ), + ), + }; + if observed_digest != signed_digest { + return Err( + "observation digest mismatch: the observations are not the signed ones".to_string(), + ); + } + + // (3) + (4) Calibration lineage: signed, trusted, parent-linked, anchored. + if bundle.calibration_chain.is_empty() { + return Err("bundle carries no calibration lineage".to_string()); + } + for record in &bundle.calibration_chain { + verify_record_signature(record) + .map_err(|e| format!("calibration {} fails signature: {e}", record.calibration_id))?; + let signer = record.signer_pubkey_hex.as_deref().unwrap_or_default(); + if !trusted_calibration_authorities.iter().any(|k| k == signer) { + return Err(format!( + "calibration {} signed by an untrusted authority", + record.calibration_id + )); + } + } + if bundle.calibration_chain[0].calibration_id != calibration_head { + return Err("lineage head does not match the signed calibration head".to_string()); + } + for link in bundle.calibration_chain.windows(2) { + if link[0].parent_id != Some(link[1].calibration_id) { + return Err(format!( + "lineage break: calibration {} does not point at {}", + link[0].calibration_id, link[1].calibration_id + )); + } + } + let anchor = bundle + .calibration_chain + .last() + .expect("chain is non-empty here"); + if anchor.parent_id.is_some() { + return Err("lineage does not terminate: the root still has a parent".to_string()); + } + if anchor.method != "factory" && anchor.method != "anchor_reference" { + return Err(format!( + "lineage root uses unanchored method `{}`", + anchor.method + )); + } + + // (5) Every cited observation. + let mut exceedances = 0usize; + for cited in &bundle.event.evidence { + let observation = bundle + .observations + .iter() + .find(|o| o.node_id == cited.node_id && o.sequence == cited.sequence) + .ok_or_else(|| { + format!( + "event cites observation ({}, {}) that is not in the bundle", + cited.node_id, cited.sequence + ) + })?; + observation + .validate() + .map_err(|e| format!("cited observation is not valid: {e}"))?; + if !observation.provenance.verified { + return Err("cited observation was never verified at ingest".to_string()); + } + if observation.calibration_id != calibration_head { + return Err( + "cited observation was not produced with the signed calibration".to_string(), + ); + } + let expected_lineage = format!("cal:{calibration_head}"); + if !observation.provenance.lineage.contains(&expected_lineage) { + return Err("cited observation's lineage does not record the calibration".to_string()); + } + if observation.value <= limit { + return Err("cited observation does not exceed the consent limit".to_string()); + } + exceedances += 1; + } + + Ok(VerifiedClaim { + biome_id: bundle.biome_id, + exceedances, + limit, + calibration_head, + anchor_id: anchor.calibration_id, + anchor_method: anchor.method.clone(), + }) +} + +// --------------------------------------------------------------------------- +// Building the bundle +// --------------------------------------------------------------------------- + +/// Build a geo point, panicking on a coordinate the example itself got wrong. +fn geo(latitude_e7: i32, longitude_e7: i32, altitude_mm: i32) -> GeoPoint { + GeoPoint::new(latitude_e7, longitude_e7, altitude_mm).expect("example coordinates are in range") +} + +/// Provision the four plant monitors, in node-table order. +#[must_use] +pub fn provision() -> Vec { + SPEC.iter() + .enumerate() + .map(|(i, (node_id, modality, label, _, _, _))| { + Node::new( + *node_id, + *modality, + geo( + 546_000_000 + (i as i32) * 1_300, + -11_200_000 - (i as i32) * 900, + 8_000, + ), + label, + ) + }) + .collect() +} + +/// An unsigned calibration record template. +fn record( + calibration_id: u32, + node_id: u64, + modality: SensorModality, + method: &str, + parent_id: Option, + created_ns: u64, + expires_ns: u64, + scale_q16: i32, + offset_q16: i32, +) -> CalibrationRecord { + CalibrationRecord { + calibration_id, + node_id, + modality, + method: method.to_string(), + reference_station: Some("ukas-anchor-04".to_string()), + parent_id, + created_ns, + expires_ns, + scale_q16, + offset_q16, + uncertainty_q16: 6 * Q16_ONE, + data_hash: sha256_hex(format!("calibration-source-data:{calibration_id}").as_bytes()), + signature_hex: None, + signer_pubkey_hex: None, + } +} + +/// An envelope signed by a key the gateway never provisioned. +fn forged_envelope( + node_id: u64, + modality: SensorModality, + at: GeoPoint, + value: f64, + measured_ns: u64, + sequence: u32, +) -> Vec { + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: modality.code(), + flags: 0, + node_id, + timestamp_ns: measured_ns, + sequence, + latitude_e7: at.latitude_e7, + longitude_e7: at.longitude_e7, + altitude_mm: at.altitude_mm, + value_q16: (value * 65_536.0) as i32, + quality_q15: 32_112, + battery_mv: 3_600, + calibration_id: 0, + }; + NodeSigner::for_node(ROGUE_DEVICE_SEED, node_id) + .sign_sample(&wire) + .encode() +} + +/// Everything one compliance run produced. +#[derive(Debug)] +pub struct ComplianceRun { + /// The genuine evidence bundle. + pub bundle: EvidenceBundle, + /// Its serialized form — the only thing the verifier ever sees. + pub bundle_json: String, + /// The biome's federated public key (a regulator holds this). + pub biome_pubkey_hex: String, + /// Public keys of the registered calibration authorities. + pub trusted_authorities: Vec, + /// The discharge point's verified lineage, child first. + pub lineage: Vec, + /// Why an unsigned calibration record was refused. + pub unsigned_refusal: CalibrationError, + /// Why a record signed by an unregistered key was refused. + pub rogue_refusal: CalibrationError, + /// Why a registered authority signing outside its modality scope was + /// refused. + pub out_of_scope_refusal: CalibrationError, + /// Why an envelope signed with an unregistered device key was refused. + pub forged_sensor_refusal: RejectReason, + /// Observations the biome accepted. + pub accepted: usize, +} + +/// Run the exceedance and package the evidence. +/// +/// # Panics +/// +/// Panics if the scenario's own inputs are inconsistent (a signed envelope +/// that will not ingest, or a calibration the strict store refuses) — the +/// example is the specification of what must work. +#[must_use] +pub fn run_compliance() -> ComplianceRun { + // --- calibration authorities ------------------------------------------- + let consent_lab = CalibrationSigner::from_seed(CONSENT_LAB_SEED); + let site_metrology = CalibrationSigner::from_seed(SITE_METROLOGY_SEED); + let rogue = CalibrationSigner::from_seed(ROGUE_AUTHORITY_SEED); + + let mut registry = AuthorityRegistry::new(); + registry.add(CalibrationAuthority { + name: "UKAS-accredited discharge chemistry laboratory".to_string(), + pubkey_hex: consent_lab.public_hex(), + modalities: BTreeSet::from([SensorModality::Chemical]), + }); + registry.add(CalibrationAuthority { + name: "site metrology team".to_string(), + pubkey_hex: site_metrology.public_hex(), + modalities: BTreeSet::from([ + SensorModality::AirQuality, + SensorModality::Acoustic, + SensorModality::Optical, + ]), + }); + // STRICT mode: signatures required, signers checked per modality. + let mut store = CalibrationStore::with_authorities(registry); + + for (i, (node_id, modality, _, _, anchor_id, child_id)) in SPEC.iter().enumerate() { + let signer = if i == DISCHARGE { + &consent_lab + } else { + &site_metrology + }; + let mut anchor = record( + *anchor_id, + *node_id, + *modality, + "anchor_reference", + None, + EPOCH_NS - 90 * NS_PER_DAY, + EPOCH_NS + 275 * NS_PER_DAY, + Q16_ONE, + 0, + ); + signer + .sign_record(&mut anchor) + .expect("record canonicalizes"); + store.insert(anchor).expect("signed anchor is accepted"); + + let mut child = record( + *child_id, + *node_id, + *modality, + "colocation", + Some(*anchor_id), + EPOCH_NS - 7 * NS_PER_DAY, + EPOCH_NS + 358 * NS_PER_DAY, + 66_847, // ≈ 1.020 + -196_608, // -3.0 + ); + signer + .sign_record(&mut child) + .expect("record canonicalizes"); + store.insert(child).expect("signed child is accepted"); + } + + // What the strict store refuses. Each of these would silently succeed in a + // permissive store — which is exactly why compliance evidence needs one. + let unsigned_refusal = store + .insert(record( + 900, + SPEC[DISCHARGE].0, + SensorModality::Chemical, + "anchor_reference", + None, + EPOCH_NS, + EPOCH_NS + NS_PER_DAY, + Q16_ONE, + 0, + )) + .expect_err("an unsigned record must be refused"); + let rogue_refusal = { + let mut forged = record( + 901, + SPEC[DISCHARGE].0, + SensorModality::Chemical, + "anchor_reference", + None, + EPOCH_NS, + EPOCH_NS + NS_PER_DAY, + Q16_ONE, + 0, + ); + rogue.sign_record(&mut forged).expect("canonicalizes"); + store + .insert(forged) + .expect_err("an unregistered signer must be refused") + }; + let out_of_scope_refusal = { + let mut wrong_scope = record( + 902, + SPEC[DISCHARGE].0, + SensorModality::Chemical, + "anchor_reference", + None, + EPOCH_NS, + EPOCH_NS + NS_PER_DAY, + Q16_ONE, + 0, + ); + site_metrology + .sign_record(&mut wrong_scope) + .expect("canonicalizes"); + store + .insert(wrong_scope) + .expect_err("an authority may not sign outside its modality scope") + }; + + // --- sensing ------------------------------------------------------------ + let mut nodes = provision(); + let mut gateway = Gateway::with_nodes(&nodes); + let mut biome = Biome::new(BiomeConfig::new(BIOME_ID), BIOME_SEED); + let calibrator = Calibrator::default(); + let mut rng = Rng::new(0x00C3_0FF1_0000_2026); + let mut observations: Vec = Vec::new(); + let mut cited: Vec = Vec::new(); + let mut accepted = 0usize; + + for (k, raw_value) in DISCHARGE_RAW.iter().enumerate() { + let measured = EPOCH_NS + (k as u64) * SAMPLE_INTERVAL_S * NS_PER_S; + let raw = raw_value + rng.noise(0.4); + let envelope = nodes[DISCHARGE].emit(raw, measured, SPEC[DISCHARGE].5); + let mut sealed = gateway + .ingest(&envelope, measured + 1_000_000) + .expect("the plant's own signed envelope must ingest"); + // The calibration is a *transformation*: it is applied to the sealed + // sample and recorded in `provenance.lineage`, never applied silently. + sealed + .modify(|s| calibrator.apply(&store, s, measured)) + .expect("calibrated sample still validates") + .expect("the discharge calibration applies"); + let sample = sealed.sample().clone(); + cited.push(EvidenceRef { + node_id: sample.node_id, + sequence: sample.sequence, + }); + observations.push(sample); + assert_eq!(biome.accept(sealed), AcceptOutcome::Accepted); + accepted += 1; + } + + // Context monitors: one observation each, same calibration discipline. + let context_ns = EPOCH_NS + 2 * SAMPLE_INTERVAL_S * NS_PER_S; + for idx in [PM, NOISE, BOUNDARY] { + let (_, _, _, truth, _, child_id) = SPEC[idx]; + let envelope = nodes[idx].emit(truth + rng.noise(0.05), context_ns, child_id); + let mut sealed = gateway + .ingest(&envelope, context_ns + 1_000_000) + .expect("the plant's own signed envelope must ingest"); + sealed + .modify(|s| calibrator.apply(&store, s, context_ns)) + .expect("calibrated sample still validates") + .expect("the context calibration applies"); + observations.push(sealed.sample().clone()); + assert_eq!(biome.accept(sealed), AcceptOutcome::Accepted); + accepted += 1; + } + + // A tampering attempt on the sensor itself: same node id, attacker's key. + let forged_sensor_refusal = gateway + .ingest( + &forged_envelope( + SPEC[DISCHARGE].0, + SensorModality::Chemical, + nodes[DISCHARGE].geo, + 12.0, // "we were well inside consent, honest" + context_ns + SAMPLE_INTERVAL_S * NS_PER_S, + 99, + ), + context_ns + SAMPLE_INTERVAL_S * NS_PER_S + 1_000_000, + ) + .expect_err("an unregistered device key must be refused at ingest"); + + // --- the signed event --------------------------------------------------- + let calibration_head = SPEC[DISCHARGE].5; + let lineage = store + .verify_lineage(calibration_head) + .expect("the discharge lineage resolves to an anchor"); + let digest = sha256_hex(&serde_json::to_vec(&observations).expect("observations serialize")); + let mut event = EnvironmentalEvent { + // The schema now binds observation CONTENT into the signature + // (rucelium_core::evidence_digest). Previously this example had to + // smuggle a digest through the signed `message` string because + // EvidenceRef pins identity only — that gap is closed. + evidence_digest: Some(rucelium_core::evidence_digest( + &observations.iter().collect::>(), + )), + spec_version: SPEC_VERSION.to_string(), + event_id: "compliance:dp1-exceedance-2026-001".to_string(), + biome_id: BIOME_ID.to_string(), + kind: EventKind::ThresholdExceeded, + severity: Severity::Warning, + modality: SensorModality::Chemical, + geo: nodes[DISCHARGE].geo, + window_start_ns: observations[0].measured_ns, + window_end_ns: context_ns, + detected_ns: context_ns + NS_PER_S, + evidence: cited, + confidence: 0.99, + message: format!( + "discharge consent exceedance at DP-1 | limit={CONSENT_LIMIT_UMOL_L} \ + | cal_head={calibration_head} | obs_digest={digest}" + ), + signature_hex: None, + signer_pubkey_hex: None, + }; + event.validate().expect("the event is well-formed"); + biome.sign_event(&mut event); + + let bundle = EvidenceBundle { + biome_id: BIOME_ID.to_string(), + observations, + calibration_chain: lineage + .iter() + .map(|id| store.get(*id).expect("chain member is stored").clone()) + .collect(), + event, + }; + let bundle_json = serde_json::to_string(&bundle).expect("bundle serializes"); + + ComplianceRun { + bundle, + bundle_json, + biome_pubkey_hex: biome.public_key_hex(), + trusted_authorities: vec![consent_lab.public_hex(), site_metrology.public_hex()], + lineage, + unsigned_refusal, + rogue_refusal, + out_of_scope_refusal, + forged_sensor_refusal, + accepted, + } +} + +/// Re-serialize a bundle after applying `mutate`, so the verifier sees only +/// the altered bytes. +#[must_use] +pub fn mutated_json(bundle: &EvidenceBundle, mutate: impl FnOnce(&mut EvidenceBundle)) -> String { + let mut copy = bundle.clone(); + mutate(&mut copy); + serde_json::to_string(©).expect("bundle serializes") +} + +/// A named field mutation applied to a bundle before re-verification. +pub type TamperCase = (&'static str, fn(&mut EvidenceBundle)); + +/// The field mutations a regulator's verifier must catch. +#[must_use] +pub fn tamper_cases() -> Vec { + vec![ + ("observation value edited down", |b| { + b.observations[0].value = 12.0; + }), + ("calibration scale edited", |b| { + b.calibration_chain[0].scale_q16 += 1; + }), + ("anchor method downgraded", |b| { + b.calibration_chain[1].method = "self_declared".to_string(); + }), + ("event severity downgraded", |b| { + b.event.severity = Severity::Advisory; + }), + ("cited observation swapped out", |b| { + b.event.evidence[0].sequence = 42; + }), + ("consent limit raised in the message", |b| { + b.event.message = b.event.message.replace("limit=250", "limit=500"); + }), + ("an observation removed from the bundle", |b| { + b.observations.pop(); + }), + ] +} + +// --------------------------------------------------------------------------- +// Narrative +// --------------------------------------------------------------------------- + +fn main() { + banner( + "INDUSTRIAL ENVIRONMENTAL COMPLIANCE — ADR-266 wedge #3", + "one exceedance, packaged so a regulator can verify it without trusting us", + ); + + let run = run_compliance(); + + println!(" Site monitors"); + for node in provision() { + line( + &format!(" {}", node.label), + format!("{} / node {:#018x}", node.modality.as_str(), node.node_id), + ); + } + line("observations accepted by the biome", run.accepted); + line("consent limit", format!("{CONSENT_LIMIT_UMOL_L} umol/L")); + + println!("\n 1. Calibration lineage — STRICT store, signatures required"); + line( + "discharge lineage (child -> anchor)", + format!("{:?}", run.lineage), + ); + for record in &run.bundle.calibration_chain { + line( + &format!(" calibration {}", record.calibration_id), + format!( + "method={} parent={:?} signer={}…", + record.method, + record.parent_id, + &record.signer_pubkey_hex.as_deref().unwrap_or("")[..16] + ), + ); + } + line( + "unsigned record", + format!("REFUSED — {}", run.unsigned_refusal), + ); + line( + "record signed by an unregistered key", + format!("REFUSED — {}", run.rogue_refusal), + ); + line( + "authority signing outside its modality", + format!("REFUSED — {}", run.out_of_scope_refusal), + ); + + println!("\n 2. Transformation lineage on the evidence"); + let first = &run.bundle.observations[0]; + line( + "cited observation", + format!("node {:#018x} seq {}", first.node_id, first.sequence), + ); + line( + "reported value", + format!("{:.2} {}", first.value, first.unit), + ); + line( + "uncertainty", + format!("± {:.2}", first.uncertainty.width() / 2.0), + ); + line("verified at ingest", first.provenance.verified); + line("signer key", &first.provenance.signer_pubkey_hex); + line( + "provenance.lineage", + format!("{:?}", first.provenance.lineage), + ); + + println!("\n 3. Sensor tampering (attacker's device key)"); + line( + "forged envelope for node DP-1", + format!("REJECTED at ingest — {}", run.forged_sensor_refusal), + ); + + println!("\n 4. Independent verification of the bundle"); + line( + "bundle size", + format!("{} bytes of JSON", run.bundle_json.len()), + ); + match verify_bundle( + &run.bundle_json, + &run.biome_pubkey_hex, + &run.trusted_authorities, + ) { + Ok(claim) => { + line("verdict", "PASS"); + line( + "proved", + format!( + "{} observations above {} umol/L in biome {}", + claim.exceedances, claim.limit, claim.biome_id + ), + ); + line( + "lineage", + format!( + "calibration {} resolves to anchor {} ({})", + claim.calibration_head, claim.anchor_id, claim.anchor_method + ), + ); + } + Err(why) => line("verdict", format!("FAIL — guarantee broken: {why}")), + } + + println!("\n 5. The same verifier against tampered bundles"); + for (name, mutate) in tamper_cases() { + let json = mutated_json(&run.bundle, mutate); + let verdict = match verify_bundle(&json, &run.biome_pubkey_hex, &run.trusted_authorities) { + Ok(_) => "PASS — guarantee broken".to_string(), + Err(why) => format!("REJECTED — {why}"), + }; + line(&format!(" {name}"), verdict); + } + let verdict = match verify_bundle(&run.bundle_json, &"00".repeat(32), &run.trusted_authorities) + { + Ok(_) => "PASS — guarantee broken".to_string(), + Err(why) => format!("REJECTED — {why}"), + }; + line(" verified against the wrong biome key", verdict); + + synthetic_footer( + "Discharge chemistry is simulated; the calibration authority, lineage, \ + event signing, and the independent verifier are the production code.", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_genuine_bundle_verifies() { + let run = run_compliance(); + let claim = verify_bundle( + &run.bundle_json, + &run.biome_pubkey_hex, + &run.trusted_authorities, + ) + .expect("the genuine bundle must verify"); + assert_eq!(claim.biome_id, BIOME_ID); + assert_eq!(claim.exceedances, DISCHARGE_RAW.len()); + assert_eq!(claim.limit, CONSENT_LIMIT_UMOL_L); + assert_eq!(claim.calibration_head, SPEC[DISCHARGE].5); + assert_eq!(claim.anchor_id, SPEC[DISCHARGE].4); + assert_eq!(claim.anchor_method, "anchor_reference"); + } + + #[test] + fn every_field_mutation_breaks_verification() { + let run = run_compliance(); + let cases = tamper_cases(); + assert!(cases.len() >= 3, "at least three distinct mutations"); + for (name, mutate) in cases { + let json = mutated_json(&run.bundle, mutate); + assert!( + verify_bundle(&json, &run.biome_pubkey_hex, &run.trusted_authorities).is_err(), + "mutation `{name}` must break verification" + ); + } + } + + #[test] + fn verification_is_bound_to_the_trusted_keys() { + let run = run_compliance(); + // Wrong biome key. + assert!( + verify_bundle(&run.bundle_json, &"00".repeat(32), &run.trusted_authorities).is_err() + ); + // No trusted calibration authorities at all. + assert!(verify_bundle(&run.bundle_json, &run.biome_pubkey_hex, &[]).is_err()); + // Garbage in, error out — never a panic. + assert!(verify_bundle("{}", &run.biome_pubkey_hex, &run.trusted_authorities).is_err()); + } + + #[test] + fn the_strict_store_refuses_unsigned_and_untrusted_records() { + let run = run_compliance(); + assert_eq!( + run.unsigned_refusal, + CalibrationError::MissingSignature(900) + ); + assert!( + matches!( + run.rogue_refusal, + CalibrationError::UntrustedSigner { id: 901, .. } + ), + "got {:?}", + run.rogue_refusal + ); + // A registered authority is still refused outside its modality scope: + // a method string can never declare an anchor. + assert!( + matches!( + run.out_of_scope_refusal, + CalibrationError::UntrustedSigner { id: 902, .. } + ), + "got {:?}", + run.out_of_scope_refusal + ); + } + + #[test] + fn lineage_resolves_to_an_anchor_and_is_carried_on_the_sample() { + let run = run_compliance(); + assert_eq!(run.lineage, vec![SPEC[DISCHARGE].5, SPEC[DISCHARGE].4]); + let anchor = run + .bundle + .calibration_chain + .last() + .expect("chain is non-empty"); + assert_eq!(anchor.parent_id, None); + assert_eq!(anchor.method, "anchor_reference"); + for observation in run.bundle.observations.iter().take(DISCHARGE_RAW.len()) { + assert!(observation + .provenance + .lineage + .contains(&format!("cal:{}", SPEC[DISCHARGE].5))); + assert!(observation + .provenance + .lineage + .contains(&"abi:rv_env_sample_v1".to_string())); + assert!(observation.value > CONSENT_LIMIT_UMOL_L); + } + } + + #[test] + fn an_unregistered_device_key_never_reaches_the_evidence() { + let run = run_compliance(); + assert_eq!( + run.forged_sensor_refusal, + RejectReason::KeyMismatch(SPEC[DISCHARGE].0) + ); + // The forged reading is nowhere in the bundle. + assert!(!run + .bundle + .observations + .iter() + .any(|o| (o.value - 12.0).abs() < 1.0)); + assert_eq!(run.accepted, DISCHARGE_RAW.len() + 3); + } +} diff --git a/examples/src/bin/irrigation-agriculture.rs b/examples/src/bin/irrigation-agriculture.rs new file mode 100644 index 0000000..537ef52 --- /dev/null +++ b/examples/src/bin/irrigation-agriculture.rs @@ -0,0 +1,705 @@ +//! # irrigation-agriculture — deployment wedge #2 (ADR-266 §3.1) +//! +//! Precision agriculture is the wedge that **monetizes governed actuation**: +//! it is where the ADR-264 §9 control path stops being theoretical, because a +//! valve physically opens and water physically costs money. The thing the +//! grower is buying is not the irrigation decision — it is the guarantee that +//! nothing *else* can open that valve. +//! +//! Three irrigation zones, each with a soil-moisture probe, a temperature / +//! humidity station, and a leaf-wetness sensor. A per-zone water-stress index +//! drives valve commands through **every** stage of the governed path: +//! +//! ```text +//! PolicyEngine::evaluate → SafetySimulator::simulate → AuthorityRegistry::authorize +//! → CommandSigner::sign → GatewayValidator::validate_and_execute → ExecutionReceipt +//! ``` +//! +//! and four outcomes are demonstrated: +//! +//! | zone / actor | stopped at | why | +//! |-----------------------------|------------|-----| +//! | zone A, authorized planner | — executes | signed receipt, offline-verifiable | +//! | zone B, authorized planner | **safety** | policy allowed the magnitude; the envelope did not | +//! | zone A, unauthorized bot | **authority** | identical proposal, no `(biome, agent, actuator)` grant | +//! | zone A, replayed command id | **gateway** | fail-closed replay protection | +//! +//! ```bash +//! cargo run -p rucelium-examples --bin irrigation-agriculture +//! cargo test -p rucelium-examples --bin irrigation-agriculture +//! ``` + +use rucelium_core::SensorModality; +use rucelium_examples::{banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S}; +use rucelium_policy::{ + verify_receipt, AgentProposal, AuditTrail, AuthorityRegistry, CommandSigner, ControlError, + ExecutionReceipt, GatewayValidator, PolicyConfig, PolicyEngine, ProposalKind, SafetyConfig, + SafetySimulator, SignedCommand, +}; +use std::collections::BTreeSet; + +// --------------------------------------------------------------------------- +// Scenario constants +// --------------------------------------------------------------------------- + +/// The farm biome. Actuator authority never leaves its owner (ADR-264 §6). +pub const BIOME_ID: &str = "biome/wold-farm"; + +/// The agent the grower actually granted valve authority to. +pub const PLANNER_AGENT: &str = "agent/irrigation-planner"; + +/// A contractor's agent with no valve grant at all. +pub const CONTRACTOR_AGENT: &str = "agent/contractor-bot"; + +/// Deterministic seed for the command-signing key. +pub const COMMAND_SEED: &[u8; 32] = b"rucelium-example-irrigation-cmd!"; + +/// Deterministic seed for the gateway's receipt-signing identity. +pub const GATEWAY_SEED: &[u8; 32] = b"rucelium-example-irrigation-gw!!"; + +/// Target soil volumetric water content (%) for these crops. +pub const TARGET_VWC_PCT: f64 = 30.0; + +/// Water-stress index above which the planner asks for irrigation. +pub const STRESS_TRIGGER: f64 = 0.20; + +/// Valve opening requested per unit of water stress. +pub const MAGNITUDE_PER_STRESS: f64 = 1.7; + +/// Policy ceiling on any actuator magnitude. +pub const POLICY_MAX_MAGNITUDE: f64 = 1.0; + +/// Safety envelope — deliberately tighter than policy. +pub const SAFE_MAGNITUDE: f64 = 0.8; + +/// Command time-to-live (5 minutes). +pub const COMMAND_TTL_NS: u64 = 300 * NS_PER_S; + +/// Calibration record referenced by every node on the farm. +pub const CALIBRATION_ID: u32 = 21; + +/// The seven audit stages a completed governed command must leave behind. +pub const EXPECTED_STAGES: [&str; 7] = [ + "proposed", + "policy_evaluated", + "safety_simulated", + "authorized", + "signed", + "gateway_validated", + "executed", +]; + +/// One irrigation zone's identity and noise-free sensor truth. +pub struct ZoneSpec { + /// Human-readable zone name. + pub name: &'static str, + /// The zone's valve actuator id. + pub actuator_id: &'static str, + /// Soil volumetric water content, %. + pub vwc_pct: f64, + /// Canopy air temperature, °C. + pub temp_c: f64, + /// Leaf wetness index, `0.0..=1.0`. + pub leaf_wetness: f64, +} + +/// The three zones of the farm, in node-table order. +pub const ZONES: [ZoneSpec; 3] = [ + ZoneSpec { + name: "zone A — north block (winter wheat)", + actuator_id: "valve/zone-a", + vwc_pct: 20.0, + temp_c: 30.0, + leaf_wetness: 0.10, + }, + ZoneSpec { + name: "zone B — south block (potatoes, sandy)", + actuator_id: "valve/zone-b", + vwc_pct: 11.0, + temp_c: 36.0, + leaf_wetness: 0.03, + }, + ZoneSpec { + name: "zone C — riverside block (grass ley)", + actuator_id: "valve/zone-c", + vwc_pct: 32.0, + temp_c: 23.0, + leaf_wetness: 0.55, + }, +]; + +// --------------------------------------------------------------------------- +// Sensing +// --------------------------------------------------------------------------- + +/// Provision three sensors per zone: soil moisture, canopy climate, leaf +/// wetness. Nine signed spore nodes in total. +#[must_use] +pub fn provision() -> Vec { + let mut nodes = Vec::with_capacity(9); + for (z, zone) in ZONES.iter().enumerate() { + let base = 0x00A2_0000_0000_0000 | ((z as u64 + 1) << 8); + let lat = 533_100_000 + (z as i32) * 24_000; + let lon = -6_400_000 + (z as i32) * 31_000; + nodes.push(Node::new( + base | 1, + SensorModality::SoilMoisture, + geo(lat, lon, 42_000), + &format!("{} soil probe", zone.name), + )); + nodes.push(Node::new( + base | 2, + SensorModality::Weather, + geo(lat + 900, lon + 700, 44_000), + &format!("{} canopy climate", zone.name), + )); + nodes.push(Node::new( + base | 3, + SensorModality::Weather, + geo(lat - 800, lon + 500, 43_000), + &format!("{} leaf wetness", zone.name), + )); + } + nodes +} + +/// Build a geo point, panicking on a coordinate the example itself got wrong. +fn geo(latitude_e7: i32, longitude_e7: i32, altitude_mm: i32) -> rucelium_core::GeoPoint { + rucelium_core::GeoPoint::new(latitude_e7, longitude_e7, altitude_mm) + .expect("example coordinates are in range") +} + +/// One zone's fused water-stress assessment, computed from ingested samples. +#[derive(Debug, Clone, PartialEq)] +pub struct ZoneReading { + /// Zone name. + pub name: String, + /// Valve actuator id. + pub actuator_id: String, + /// Measured soil volumetric water content, %. + pub vwc_pct: f64, + /// Measured canopy temperature, °C. + pub temp_c: f64, + /// Measured leaf wetness index. + pub leaf_wetness: f64, + /// Water-stress index, `0.0..=1.0`. + pub stress: f64, + /// Valve opening the planner will request (0 when no irrigation is due). + pub magnitude: f64, +} + +/// Water-stress index: soil deficit dominates, heat adds to it, and a wet +/// canopy subtracts from it. Deliberately simple and deterministic — the +/// point of this example is what happens to the *command*, not the agronomy. +#[must_use] +pub fn water_stress(vwc_pct: f64, temp_c: f64, leaf_wetness: f64) -> f64 { + let deficit = 0.6 * (TARGET_VWC_PCT - vwc_pct) / TARGET_VWC_PCT; + let heat = 0.3 * (temp_c - 20.0) / 25.0; + let canopy = 0.2 * leaf_wetness; + (deficit + heat - canopy).clamp(0.0, 1.0) +} + +/// Ingest one sampling round from all nine nodes and fuse each zone. +#[must_use] +pub fn sense() -> Vec { + let mut nodes = provision(); + let mut gateway = Gateway::with_nodes(&nodes); + let mut rng = Rng::new(0x00A2_0FA1_0000_2026); + let measured = EPOCH_NS; + let mut readings = Vec::with_capacity(ZONES.len()); + + for (z, zone) in ZONES.iter().enumerate() { + let truth = [zone.vwc_pct, zone.temp_c, zone.leaf_wetness]; + let sd = [0.05, 0.05, 0.005]; + let mut measured_values = [0.0f64; 3]; + for k in 0..3 { + let idx = z * 3 + k; + let envelope = nodes[idx].emit(truth[k] + rng.noise(sd[k]), measured, CALIBRATION_ID); + let sealed = gateway + .ingest(&envelope, measured + 1_000_000) + .expect("a node's own signed envelope must ingest"); + measured_values[k] = sealed.sample().value; + } + let stress = water_stress(measured_values[0], measured_values[1], measured_values[2]); + let magnitude = if stress > STRESS_TRIGGER { + (stress * MAGNITUDE_PER_STRESS).min(POLICY_MAX_MAGNITUDE) + } else { + 0.0 + }; + readings.push(ZoneReading { + name: zone.name.to_string(), + actuator_id: zone.actuator_id.to_string(), + vwc_pct: measured_values[0], + temp_c: measured_values[1], + leaf_wetness: measured_values[2], + stress, + magnitude, + }); + } + readings +} + +// --------------------------------------------------------------------------- +// The governed control path +// --------------------------------------------------------------------------- + +/// Everything one governed irrigation cycle produced. +#[derive(Debug)] +pub struct IrrigationRun { + /// Fused per-zone water stress. + pub readings: Vec, + /// Zone A's signed execution receipt. + pub receipt: Option, + /// Zone A's signed command (kept so the replay can be attempted). + pub command: Option, + /// Why zone B's larger command was refused. + pub oversized: Option, + /// Why the contractor's identical proposal was refused. + pub unauthorized: Option, + /// Whether the contractor ever produced a receipt (it must not). + pub unauthorized_receipt: Option, + /// Why replaying zone A's command was refused. + pub replay: Option, + /// The full append-only audit trail across all four attempts. + pub audit: AuditTrail, + /// Executed-command budget charged against zone A's valve. + pub zone_a_executions: u32, +} + +impl IrrigationRun { + /// The audit stages recorded for one proposal, in order. + #[must_use] + pub fn stages_for(&self, proposal_id: &str) -> Vec<&'static str> { + self.audit + .for_proposal(proposal_id) + .iter() + .map(|e| e.stage) + .collect() + } + + /// The verdict recorded at `stage` for one proposal. + #[must_use] + pub fn verdict(&self, proposal_id: &str, stage: &str) -> Option { + self.audit + .for_proposal(proposal_id) + .iter() + .find(|e| e.stage == stage) + .map(|e| e.verdict.clone()) + } +} + +/// A valve proposal from `agent` for `zone`. +fn valve_proposal( + proposal_id: &str, + agent: &str, + zone: &ZoneReading, + now_ns: u64, +) -> AgentProposal { + AgentProposal { + proposal_id: proposal_id.to_string(), + agent_id: agent.to_string(), + biome_id: BIOME_ID.to_string(), + kind: ProposalKind::ActuatorCommand { + actuator_id: zone.actuator_id.clone(), + action: "open".to_string(), + magnitude: zone.magnitude, + }, + justification: format!( + "water stress {:.2} at {:.1} % VWC / {:.1} C — irrigate", + zone.stress, zone.vwc_pct, zone.temp_c + ), + proposed_ns: now_ns, + } +} + +/// Run one full governed irrigation cycle. +#[must_use] +pub fn run_cycle() -> IrrigationRun { + let readings = sense(); + let now = EPOCH_NS + 60 * NS_PER_S; + + // The grower's deterministic policy: only these three valves exist, and + // no magnitude above 1.0 is even discussable. + let policy = PolicyEngine::new(PolicyConfig { + min_sampling_interval_s: 10, + max_sampling_interval_s: 86_400, + max_actuator_magnitude: POLICY_MAX_MAGNITUDE, + allowed_actuators: ZONES + .iter() + .map(|z| z.actuator_id.to_string()) + .collect::>(), + }); + let mut safety = SafetySimulator::new(SafetyConfig { + safe_magnitude: SAFE_MAGNITUDE, + max_commands_per_actuator: 4, + }); + // The grower grants the planner both irrigable valves. Zone B will still + // be refused — by safety, which runs first and is a different gate. + let mut authority = AuthorityRegistry::new(); + authority.grant(BIOME_ID, PLANNER_AGENT, ZONES[0].actuator_id); + authority.grant(BIOME_ID, PLANNER_AGENT, ZONES[1].actuator_id); + + let signer = CommandSigner::from_seed(COMMAND_SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); + let mut audit = AuditTrail::new(); + + let mut run = IrrigationRun { + readings, + receipt: None, + command: None, + oversized: None, + unauthorized: None, + unauthorized_receipt: None, + replay: None, + audit: AuditTrail::new(), + zone_a_executions: 0, + }; + + // --- 1. Zone A: the authorized, in-envelope command ------------------- + let zone_a = &run.readings[0]; + let proposal = valve_proposal("irr-zone-a-001", PLANNER_AGENT, zone_a, now); + if let Ok(evaluated) = policy.evaluate(proposal, now, &mut audit) { + if let Ok(simulated) = safety.simulate(evaluated, now, &mut audit) { + if let Ok(authorized) = authority.authorize(simulated, now, &mut audit) { + let command = signer.sign(authorized, now, COMMAND_TTL_NS, &mut audit); + let result = gateway.validate_and_execute( + &command, + now, + |kind| match kind { + ProposalKind::ActuatorCommand { + actuator_id, + action, + magnitude, + } => Ok(format!("{actuator_id} {action} to {magnitude:.2}")), + other => Err(format!("gateway cannot execute {other:?}")), + }, + &mut audit, + ); + if let Ok(receipt) = result { + // Budgets are charged only when something really happened. + safety.record_execution(&zone_a.actuator_id); + run.zone_a_executions += 1; + run.receipt = Some(receipt); + } + run.command = Some(command); + } + } + } + + // --- 2. Zone B: policy says yes, the safety envelope says no ---------- + let zone_b = &run.readings[1]; + let proposal = valve_proposal("irr-zone-b-001", PLANNER_AGENT, zone_b, now); + match policy.evaluate(proposal, now, &mut audit) { + Ok(evaluated) => { + run.oversized = safety.simulate(evaluated, now, &mut audit).err(); + } + Err(e) => run.oversized = Some(e), + } + + // --- 3. The contractor's identical proposal --------------------------- + let proposal = valve_proposal("irr-zone-a-002", CONTRACTOR_AGENT, zone_a, now); + if let Ok(evaluated) = policy.evaluate(proposal, now, &mut audit) { + if let Ok(simulated) = safety.simulate(evaluated, now, &mut audit) { + match authority.authorize(simulated, now, &mut audit) { + Ok(authorized) => { + // Unreachable if the guarantee holds; if it ever is + // reached the receipt is recorded so the test fails loudly. + let command = signer.sign(authorized, now, COMMAND_TTL_NS, &mut audit); + run.unauthorized_receipt = gateway + .validate_and_execute(&command, now, |_| Ok("executed".into()), &mut audit) + .ok(); + } + Err(e) => run.unauthorized = Some(e), + } + } + } + + // --- 4. Replay of zone A's signed command ----------------------------- + if let Some(command) = &run.command { + run.replay = gateway + .validate_and_execute( + command, + now + NS_PER_S, + |_| Ok("replayed".into()), + &mut audit, + ) + .err(); + } + + run.audit = audit; + run +} + +// --------------------------------------------------------------------------- +// Narrative +// --------------------------------------------------------------------------- + +fn main() { + banner( + "PRECISION IRRIGATION — ADR-266 wedge #2", + "9 signed spore nodes, 3 zones, one governed valve command end to end", + ); + + let run = run_cycle(); + + println!(" Per-zone water stress (fused from verified observations)"); + for reading in &run.readings { + line( + &format!(" {}", reading.name), + format!( + "{:.1} % VWC, {:.1} C, leaf {:.2} -> stress {:.2}, request {:.2}", + reading.vwc_pct, + reading.temp_c, + reading.leaf_wetness, + reading.stress, + reading.magnitude + ), + ); + } + line( + "irrigation trigger", + format!("stress > {STRESS_TRIGGER:.2}"), + ); + line( + "policy ceiling / safety envelope", + format!("{POLICY_MAX_MAGNITUDE:.2} / {SAFE_MAGNITUDE:.2}"), + ); + + println!("\n 1. Zone A — authorized planner, inside the envelope"); + let receipt = run.receipt.as_ref().expect("zone A executes"); + line("command id", &receipt.command_id); + line("outcome", &receipt.outcome); + line("gateway receipt hash", &receipt.gateway_receipt_hash); + line("gateway attestation key", &receipt.gateway_pubkey_hex); + line( + "verify_receipt(receipt)", + if verify_receipt(receipt) { + "VALID — offline-verifiable attestation" + } else { + "INVALID — guarantee broken" + }, + ); + let mut tampered = receipt.clone(); + tampered.outcome.push_str(" (edited)"); + line( + "verify_receipt(tampered outcome)", + if verify_receipt(&tampered) { + "VALID — guarantee broken" + } else { + "INVALID — tampering detected" + }, + ); + line("safety budget charged", run.zone_a_executions); + + println!("\n 2. Zone B — policy allowed it, safety did not"); + let oversized = run.oversized.as_ref().expect("zone B is refused"); + line( + "policy verdict", + run.verdict("irr-zone-b-001", "policy_evaluated") + .unwrap_or_default(), + ); + line("safety verdict", format!("{oversized}")); + line( + "stopped at", + if matches!(oversized, ControlError::Unsafe(_)) { + "SafetySimulator (stage 2)" + } else { + "NOT safety — guarantee broken" + }, + ); + + println!("\n 3. The contractor's identical proposal"); + let unauthorized = run.unauthorized.as_ref().expect("contractor is refused"); + line("error", format!("{unauthorized}")); + line( + "stopped at", + if matches!(unauthorized, ControlError::NotAuthorized { .. }) { + "AuthorityRegistry (stage 3)" + } else { + "NOT authority — guarantee broken" + }, + ); + line( + "receipt issued to the contractor", + if run.unauthorized_receipt.is_none() { + "none — nothing was signed, nothing executed" + } else { + "ONE — guarantee broken" + }, + ); + + println!("\n 4. Replay of zone A's signed command"); + let replay = run.replay.as_ref().expect("the replay is refused"); + line("error", format!("{replay}")); + line( + "stopped at", + if matches!(replay, ControlError::DuplicateCommand(_)) { + "GatewayValidator (fail-closed, any recorded phase)" + } else { + "NOT the gateway — guarantee broken" + }, + ); + + println!("\n Audit trail — every stage, every verdict, append-only"); + for entry in run.audit.entries() { + line( + &format!(" [{}] {}", entry.proposal_id, entry.stage), + &entry.verdict, + ); + } + let zone_a_stages = run.stages_for("irr-zone-a-001"); + line( + "zone A stages (completed path)", + format!("{:?}", &zone_a_stages[..7]), + ); + line( + "zone A stages (after the replay attempt)", + format!("{:?}", &zone_a_stages[7..]), + ); + + synthetic_footer( + "Soil, canopy, and leaf-wetness values are simulated; the policy, \ + safety, authority, signing, and replay gates are the production path.", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authorized_zone_opens_with_a_verifiable_signed_receipt() { + let run = run_cycle(); + let receipt = run.receipt.expect("zone A executes"); + assert!(verify_receipt(&receipt), "the genuine receipt must verify"); + assert_eq!(receipt.command_id, "cmd-irr-zone-a-001"); + assert!(receipt.outcome.contains("valve/zone-a open")); + + // Any edit to the attestation breaks it. + for mutate in [ + (|r: &mut ExecutionReceipt| r.outcome.push('!')) as fn(&mut ExecutionReceipt), + |r: &mut ExecutionReceipt| r.executed_ns += 1, + |r: &mut ExecutionReceipt| r.command_id.push('x'), + |r: &mut ExecutionReceipt| r.gateway_receipt_hash.push('0'), + ] { + let mut tampered = receipt.clone(); + mutate(&mut tampered); + assert!( + !verify_receipt(&tampered), + "tampered receipt must not verify" + ); + } + assert_eq!(run.zone_a_executions, 1); + } + + #[test] + fn unauthorized_agent_is_stopped_at_authority_with_no_receipt() { + let run = run_cycle(); + let err = run.unauthorized.clone().expect("contractor refused"); + assert!( + matches!(&err, ControlError::NotAuthorized { agent_id, actuator_id, .. } + if agent_id == CONTRACTOR_AGENT && actuator_id == "valve/zone-a"), + "expected NotAuthorized, got {err:?}" + ); + assert!( + run.unauthorized_receipt.is_none(), + "an unauthorized agent must never obtain a receipt" + ); + // Policy and safety both passed — the proposal was identical. + assert_eq!( + run.verdict("irr-zone-a-002", "policy_evaluated").as_deref(), + Some("accepted") + ); + assert_eq!( + run.verdict("irr-zone-a-002", "safety_simulated").as_deref(), + Some("within safety envelope") + ); + // ...and the run stopped at "authorized" — it never reached "signed". + let stages = run.stages_for("irr-zone-a-002"); + assert_eq!( + stages, + vec![ + "proposed", + "policy_evaluated", + "safety_simulated", + "authorized" + ] + ); + } + + #[test] + fn over_magnitude_is_stopped_at_safety_not_policy() { + let run = run_cycle(); + let zone_b = &run.readings[1]; + assert!( + zone_b.magnitude > SAFE_MAGNITUDE && zone_b.magnitude <= POLICY_MAX_MAGNITUDE, + "zone B must sit between the safety envelope and the policy ceiling, got {}", + zone_b.magnitude + ); + let err = run.oversized.clone().expect("zone B refused"); + assert!(matches!(err, ControlError::Unsafe(_)), "got {err:?}"); + // Policy explicitly accepted it first. + assert_eq!( + run.verdict("irr-zone-b-001", "policy_evaluated").as_deref(), + Some("accepted") + ); + assert!(run + .verdict("irr-zone-b-001", "safety_simulated") + .expect("safety ran") + .starts_with("rejected:")); + } + + #[test] + fn replaying_the_same_command_id_is_refused() { + let run = run_cycle(); + let err = run.replay.clone().expect("replay refused"); + assert_eq!( + err, + ControlError::DuplicateCommand("cmd-irr-zone-a-001".to_string()) + ); + // The gateway recorded the duplicate rather than silently dropping it. + let duplicates: Vec<_> = run + .audit + .entries() + .iter() + .filter(|e| e.verdict.starts_with("duplicate_rejected:")) + .collect(); + assert_eq!(duplicates.len(), 1); + assert_eq!(duplicates[0].stage, "gateway_validated"); + // And the valve was still only ever opened once. + assert_eq!(run.zone_a_executions, 1); + } + + #[test] + fn the_executed_command_leaves_the_full_seven_stage_trail() { + let run = run_cycle(); + let stages = run.stages_for("irr-zone-a-001"); + assert_eq!( + stages[..7], + EXPECTED_STAGES, + "the completed governed path leaves exactly these seven stages, in order" + ); + // The replay attempt is audited under the same proposal id (the + // command id is derived from it), so it appends one more entry. + assert_eq!(stages.len(), 8); + assert_eq!(stages[7], "gateway_validated"); + } + + #[test] + fn well_watered_zone_proposes_nothing() { + let run = run_cycle(); + let zone_c = &run.readings[2]; + assert!(zone_c.stress <= STRESS_TRIGGER, "zone C is not stressed"); + assert_eq!(zone_c.magnitude, 0.0); + assert!( + run.audit + .entries() + .iter() + .all(|e| !e.verdict.contains("valve/zone-c")), + "no command should ever have been raised for zone C" + ); + // Stress ordering is deterministic: B (driest, hottest) > A > C. + assert!(run.readings[1].stress > run.readings[0].stress); + assert!(run.readings[0].stress > run.readings[2].stress); + } +} diff --git a/examples/src/bin/pollinator-hive.rs b/examples/src/bin/pollinator-hive.rs new file mode 100644 index 0000000..4c73c72 --- /dev/null +++ b/examples/src/bin/pollinator-hive.rs @@ -0,0 +1,884 @@ +//! # pollinator-hive — ADR-266 §4 track B4 (research track, NOT a product) +//! +//! Three honeybee colonies as biome nodes. Each hive emits an acoustic +//! activity index, an internal-temperature and internal-humidity reference +//! (conventional `Weather` sensors), and a colony electric-field reading +//! (`Bioelectric`); a hive weight series is derived locally from the same +//! observation window. +//! +//! Four things happen over sixty simulated days: +//! +//! 1. **A swarming precursor** builds on one hive: the acoustic index climbs +//! for five days while daily weight gain stalls. Only that hive fires. +//! 2. **A single hive collapses.** One colony failing is a beekeeping +//! problem, not a regional signal — so it is routed through +//! [`bio_only_severity_cap`] and stays `Advisory`, however dramatic. +//! 3. **A cold snap** chills a healthy hive. Its activity index craters. The +//! paired internal-temperature reference explains it, and nothing alarms — +//! ADR-266 §4.1 item 1, the dominant failure mode, in one day. +//! 4. **A correlated multi-hive collapse.** All three colonies crash within +//! the same window with normal internal temperatures. *That* is spatial +//! replication plus a conventional exclusion, and only that escalates. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin pollinator-hive +//! ``` + +use rucelium_core::event::evidence_digest; +use rucelium_core::{ + DataClass, EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, + Severity, SPEC_VERSION, +}; +use rucelium_examples::{ + banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S, S_PER_DAY, +}; + +// --------------------------------------------------------------------------- +// The normative rule +// --------------------------------------------------------------------------- + +/// Hard cap on the weight of a colony-derived evidence edge, mirroring +/// `rucelium_worldgraph::RF_MAX_EVIDENCE_WEIGHT` (ADR-264 §8) as ADR-266 +/// §4.1 item 3 requires of every biological modality. +pub const BIO_MAX_EVIDENCE_WEIGHT: f32 = 0.3; + +/// Clamp a severity to at most [`Severity::Advisory`]. +/// +/// **The ADR-266 §4.1 item 3 rule, enforced.** A single colony is one +/// organism. Colonies die of queen failure, varroa, starvation, robbing and +/// bad luck; one hive's collapse is never evidence of a landscape-scale +/// exposure, so on its own it can only ever be `Advisory`. +#[must_use] +pub fn bio_only_severity_cap(severity: Severity) -> Severity { + severity.min(Severity::Advisory) +} + +// --------------------------------------------------------------------------- +// Apiary model +// --------------------------------------------------------------------------- + +/// Observations per simulated day (6-hourly). +pub const SLOTS: usize = 4; +/// Days of undisturbed baseline before anything is injected. +pub const BASELINE_DAYS: usize = 20; +/// Total simulated days. +pub const TOTAL_DAYS: usize = 60; +/// Day on which the swarming precursor is evaluated. +pub const SWARM_EVAL_DAY: usize = 25; +/// Day on which the single-hive collapse is evaluated. +pub const SOLO_COLLAPSE_DAY: usize = 30; +/// Day on which the cold-snap confounder is evaluated. +pub const COLD_SNAP_DAY: usize = 38; +/// Day on which the correlated multi-hive collapse is evaluated. +pub const APIARY_COLLAPSE_DAY: usize = 45; +/// Acoustic drop (in baseline standard deviations) that counts as a collapse. +pub const COLLAPSE_ACOUSTIC_Z: f64 = -4.0; +/// Electric-field drop (in baseline standard deviations) that must agree. +pub const COLLAPSE_FIELD_Z: f64 = -3.0; +/// Internal-temperature deviation, °C, beyond which the *conventional* +/// reference explains the activity drop thermally and the biological detector +/// must stand down. +pub const THERMAL_EXPLAIN_C: f64 = 2.0; +/// Acoustic index rise, per day over five days, that counts as a swarming +/// precursor. +pub const SWARM_SLOPE: f64 = 1.5; +/// Fraction of baseline daily weight gain below which the gain has "stalled". +pub const SWARM_GAIN_FRACTION: f64 = 0.25; + +/// One instrumented colony. +#[derive(Debug, Clone)] +pub struct Hive { + /// Human-readable label. + pub label: &'static str, + /// This colony's own resting acoustic activity index. + pub base_acoustic: f64, + /// This colony's own acoustic noise. + pub sd_acoustic: f64, + /// This colony's own resting electric field, mV/m. + pub base_field: f64, + /// This colony's own field noise, mV/m. + pub sd_field: f64, + /// This colony's brood-nest set point, °C. + pub base_temp: f64, + /// This colony's starting weight, kg. + pub base_weight: f64, + /// Whether this colony builds a swarming precursor. + pub swarms: bool, + /// Whether this colony has the isolated (single-hive) collapse. + pub solo_collapse: bool, + /// Whether this colony gets the cold snap. + pub cold_snap: bool, +} + +/// The three colonies of the apiary. +#[must_use] +pub fn apiary() -> Vec { + vec![ + Hive { + label: "H1 orchard-east", + base_acoustic: 62.0, + sd_acoustic: 2.4, + base_field: 121.0, + sd_field: 4.1, + base_temp: 34.7, + base_weight: 41.5, + swarms: true, + solo_collapse: false, + cold_snap: false, + }, + Hive { + label: "H2 hedgerow", + base_acoustic: 48.0, + sd_acoustic: 1.9, + base_field: 96.0, + sd_field: 3.4, + base_temp: 34.9, + base_weight: 38.2, + swarms: false, + solo_collapse: true, + cold_snap: false, + }, + Hive { + label: "H3 heath-margin", + base_acoustic: 71.0, + sd_acoustic: 3.1, + base_field: 139.0, + sd_field: 4.8, + base_temp: 34.4, + base_weight: 44.9, + swarms: false, + solo_collapse: false, + cold_snap: true, + }, + ] +} + +/// Acoustic-index perturbation for `hive` on `day` (the biological signal). +#[must_use] +pub fn acoustic_effect(h: &Hive, day: usize) -> f64 { + let mut e = 0.0; + if h.swarms && (21..=26).contains(&day) { + // Pre-swarm piping and queen-cell activity: a steady climb. + e += (day - 20) as f64 * 3.0; + } + if h.swarms && day >= 27 { + // The prime swarm has left: a permanently smaller colony. + e -= 11.0; + } + if h.solo_collapse && (SOLO_COLLAPSE_DAY..SOLO_COLLAPSE_DAY + 4).contains(&day) { + e -= 34.0; + } + if h.cold_snap && (COLD_SNAP_DAY - 1..COLD_SNAP_DAY + 3).contains(&day) { + // Not a colony problem: a cold cluster simply flies less. + e -= 22.0; + } + if (APIARY_COLLAPSE_DAY..APIARY_COLLAPSE_DAY + 4).contains(&day) { + e -= 38.0; + } + e +} + +/// Electric-field perturbation for `hive` on `day`, mV/m. +#[must_use] +pub fn field_effect(h: &Hive, day: usize) -> f64 { + let mut e = 0.0; + if h.swarms && (21..=26).contains(&day) { + e += (day - 20) as f64 * 1.4; + } + if h.solo_collapse && (SOLO_COLLAPSE_DAY..SOLO_COLLAPSE_DAY + 4).contains(&day) { + e -= 41.0; + } + if h.cold_snap && (COLD_SNAP_DAY - 1..COLD_SNAP_DAY + 3).contains(&day) { + // A cold cluster is quieter but still very much alive: the field + // barely moves. This is what separates "cold" from "poisoned". + e -= 1.5; + } + if (APIARY_COLLAPSE_DAY..APIARY_COLLAPSE_DAY + 4).contains(&day) { + e -= 44.0; + } + e +} + +/// Internal-temperature perturbation for `hive` on `day`, °C. Only the cold +/// snap moves it — and it is a *conventional* sensor, so this is the +/// covariate that licenses the biological detector to stand down. +#[must_use] +pub fn temp_effect(h: &Hive, day: usize) -> f64 { + if h.cold_snap && (COLD_SNAP_DAY - 1..COLD_SNAP_DAY + 3).contains(&day) { + -6.4 + } else { + 0.0 + } +} + +/// Daily weight gain for `hive` on `day`, kg — the derived series. +#[must_use] +pub fn weight_gain_kg(h: &Hive, day: usize) -> f64 { + if h.swarms && day == 27 { + // The swarm departs with roughly 1.9 kg of bees. + return -1.9; + } + if h.swarms && (21..=26).contains(&day) { + // The precursor signature: the colony stops storing. + return 0.04; + } + let flow = (10..=40).contains(&day); + let base = if flow { 0.85 } else { 0.18 }; + if (APIARY_COLLAPSE_DAY..APIARY_COLLAPSE_DAY + 4).contains(&day) + || (h.solo_collapse && (SOLO_COLLAPSE_DAY..SOLO_COLLAPSE_DAY + 4).contains(&day)) + { + return base * 0.1; + } + if h.cold_snap && (COLD_SNAP_DAY - 1..COLD_SNAP_DAY + 3).contains(&day) { + return base * 0.3; + } + base +} + +// --------------------------------------------------------------------------- +// Detection state +// --------------------------------------------------------------------------- + +/// One colony's learned baseline. +#[derive(Debug, Clone, PartialEq)] +pub struct HiveBaseline { + /// Hive label. + pub label: String, + /// Mean daily acoustic index. + pub mean_acoustic: f64, + /// Standard deviation of the daily acoustic index. + pub sd_acoustic: f64, + /// Mean colony electric field, mV/m. + pub mean_field: f64, + /// Standard deviation of the field, mV/m. + pub sd_field: f64, + /// Mean internal temperature, °C. + pub mean_temp: f64, + /// Mean daily weight gain, kg. + pub mean_gain_kg: f64, +} + +/// One colony's daily aggregate. +#[derive(Debug, Clone, PartialEq)] +pub struct HiveDay { + /// Hive label. + pub label: String, + /// Simulated day index. + pub day: usize, + /// Acoustic node id. + pub node_id: u64, + /// Sequence number of the last acoustic sample of the day. + pub sequence: u32, + /// Daily mean acoustic activity index. + pub acoustic: f64, + /// Daily mean colony electric field, mV/m. + pub field: f64, + /// Daily mean internal temperature, °C. + pub temp: f64, + /// Daily mean internal relative humidity, percent. + pub humidity: f64, + /// Hive weight at end of day, kg (derived series). + pub weight_kg: f64, + /// Weight gained today, kg. + pub gain_kg: f64, + /// The last verified acoustic observation of the day, retained so events + /// can bind the *content* of their evidence (ADR-266 §3.1). + pub sample: EnvSample, +} + +/// A per-hive verdict on one evaluated day. +#[derive(Debug, Clone, PartialEq)] +pub struct HiveVerdict { + /// Hive label. + pub label: String, + /// Acoustic z-score against this colony's own baseline. + pub acoustic_z: f64, + /// Field z-score against this colony's own baseline. + pub field_z: f64, + /// Internal-temperature deviation from this colony's set point, °C. + pub temp_dev_c: f64, + /// Five-day acoustic slope, index per day. + pub acoustic_slope: f64, + /// Daily weight gain, kg. + pub gain_kg: f64, + /// Whether the naive detector (acoustic only) would have alarmed. + pub naive_alarm: bool, + /// Whether the conventional temperature reference explains the drop. + pub thermally_explained: bool, + /// Whether a genuine activity collapse was detected. + pub collapse: bool, + /// Whether a swarming precursor was detected. + pub swarm_precursor: bool, + /// Acoustic node id (evidence). + pub node_id: u64, + /// Sequence of the evidence sample. + pub sequence: u32, + /// The verified observation itself (content-binding evidence). + pub sample: EnvSample, +} + +/// A whole-apiary assessment on one evaluated day. +#[derive(Debug, Clone, PartialEq)] +pub struct Assessment { + /// Narrative label. + pub moment: String, + /// Simulated day. + pub day: usize, + /// Per-hive verdicts. + pub hives: Vec, + /// Hives collapsing within this window. + pub correlated_hives: usize, + /// Severity the evidence would justify before the biological cap. + pub uncapped: Severity, + /// Severity actually emitted. + pub severity: Severity, + /// Whether the evidence was biology alone (a single colony). + pub bio_only: bool, + /// Event raised, if any. + pub event: Option, +} + +/// Everything one deterministic run produces. +#[derive(Debug, Clone, PartialEq)] +pub struct Report { + /// Per-colony learned baselines. + pub baselines: Vec, + /// Swarming-precursor assessment. + pub swarm: Assessment, + /// Single-hive collapse assessment. + pub solo: Assessment, + /// Cold-snap confounder assessment. + pub cold: Assessment, + /// Correlated multi-hive collapse assessment. + pub apiary_wide: Assessment, + /// Residency class of the derived weight series. + pub weight_series_class: DataClass, + /// Envelopes the real ingest pipeline verified. + pub verified_samples: usize, + /// Final hive weights, kg. + pub final_weights: Vec, +} + +/// Severity for `n` colonies collapsing inside one window. +/// +/// * 1 colony — biology alone, so [`bio_only_severity_cap`] holds it at +/// `Advisory`. One hive failing is a beekeeping problem. +/// * 2 colonies — spatially replicated, thermally excluded: `Warning`. +/// * 3 or more — `Critical`. +#[must_use] +pub fn collapse_severity(n: usize) -> (Severity, Severity, bool) { + match n { + 0 => (Severity::Advisory, Severity::Advisory, false), + 1 => { + let wanted = Severity::Warning; + (wanted, bio_only_severity_cap(wanted), true) + } + 2 => (Severity::Warning, Severity::Warning, false), + _ => (Severity::Critical, Severity::Critical, false), + } +} + +/// Simulated measurement time for `(day, slot)`, derived from `EPOCH_NS`. +#[must_use] +pub fn slot_ns(day: usize, slot: usize) -> u64 { + EPOCH_NS + (day as u64 * S_PER_DAY + slot as u64 * 6 * 3_600) * NS_PER_S +} + +// --------------------------------------------------------------------------- +// The scenario +// --------------------------------------------------------------------------- + +/// Run the whole scenario deterministically. +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn run() -> Report { + let hives = apiary(); + let n = hives.len(); + let mut rng = Rng::new(0x00B4_BEE5_1A5E_C7E1); + + let mut nodes: Vec = Vec::new(); + // Layout: [acoustic ×3][field ×3][temp ×3][humidity ×3]. + for (kind, base_id, modality) in [ + ( + "acoustic", + 0x00B4_0000_0000_0001_u64, + SensorModality::Acoustic, + ), + ("field", 0x00B4_0000_0000_0101, SensorModality::Bioelectric), + ("temp", 0x00B4_0000_0000_0201, SensorModality::Weather), + ("humidity", 0x00B4_0000_0000_0301, SensorModality::Weather), + ] { + for (i, h) in hives.iter().enumerate() { + let geo = GeoPoint::new(508_812_000 + (i as i32) * 900, 4_411_000, 62_000) + .expect("valid apiary coordinates"); + nodes.push(Node::new( + base_id + i as u64, + modality, + geo, + &format!("{} {kind}", h.label), + )); + } + } + let mut gw = Gateway::with_nodes(&nodes); + + // Daily aggregation. + let mut days: Vec> = Vec::with_capacity(TOTAL_DAYS); + let mut weights: Vec = hives.iter().map(|h| h.base_weight).collect(); + let mut verified = 0usize; + + for day in 0..TOTAL_DAYS { + let mut row: Vec = Vec::with_capacity(n); + for (i, h) in hives.iter().enumerate() { + let (mut a, mut f, mut t, mut hu) = (0.0, 0.0, 0.0, 0.0); + let mut last_seq = 0; + let mut node_id = 0; + let mut last_sample: Option = None; + for slot in 0..SLOTS { + let ns = slot_ns(day, slot); + // Foraging is diurnal: the index peaks in the middle of the + // day. Aggregating over the whole day removes it. + let diurnal = [-9.0, 11.0, 7.0, -9.0][slot]; + let av = + h.base_acoustic + diurnal + acoustic_effect(h, day) + rng.noise(h.sd_acoustic); + let env = nodes[i].emit(av, ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("acoustic sample verifies"); + a += s.sample().value; + last_seq = s.sample().sequence; + node_id = s.sample().node_id; + last_sample = Some(s.sample().clone()); + + let fv = h.base_field + field_effect(h, day) + rng.noise(h.sd_field); + let env = nodes[n + i].emit(fv, ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("field sample verifies"); + f += s.sample().value; + + let tv = h.base_temp + temp_effect(h, day) + rng.noise(0.22); + let env = nodes[2 * n + i].emit(tv, ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("temperature sample verifies"); + t += s.sample().value; + + let hv = 58.0 + rng.noise(1.6) - temp_effect(h, day) * 0.8; + let env = nodes[3 * n + i].emit(hv, ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("humidity sample verifies"); + hu += s.sample().value; + verified += 4; + } + let gain = weight_gain_kg(h, day); + weights[i] += gain; + let d = SLOTS as f64; + row.push(HiveDay { + label: h.label.to_string(), + day, + node_id, + sequence: last_seq, + acoustic: a / d, + field: f / d, + temp: t / d, + humidity: hu / d, + weight_kg: weights[i], + gain_kg: gain, + sample: last_sample.expect("at least one slot per day"), + }); + } + days.push(row); + } + + // Per-colony baselines over the undisturbed window. + let baselines: Vec = (0..n) + .map(|i| { + let win: Vec<&HiveDay> = days[..BASELINE_DAYS].iter().map(|r| &r[i]).collect(); + let len = win.len() as f64; + let mean = + |f: fn(&HiveDay) -> f64, w: &[&HiveDay]| w.iter().map(|d| f(d)).sum::() / len; + let sd = |f: fn(&HiveDay) -> f64, w: &[&HiveDay], m: f64| { + (w.iter().map(|d| (f(d) - m).powi(2)).sum::() / (len - 1.0)).sqrt() + }; + let ma = mean(|d| d.acoustic, &win); + let mf = mean(|d| d.field, &win); + HiveBaseline { + label: hives[i].label.to_string(), + mean_acoustic: ma, + sd_acoustic: sd(|d| d.acoustic, &win, ma), + mean_field: mf, + sd_field: sd(|d| d.field, &win, mf), + mean_temp: mean(|d| d.temp, &win), + mean_gain_kg: mean(|d| d.gain_kg, &win), + } + }) + .collect(); + + let assess = |moment: &str, day: usize| -> Assessment { + let mut verdicts = Vec::with_capacity(n); + for i in 0..n { + let d = &days[day][i]; + let b = &baselines[i]; + let acoustic_z = (d.acoustic - b.mean_acoustic) / b.sd_acoustic; + let field_z = (d.field - b.mean_field) / b.sd_field; + let temp_dev_c = d.temp - b.mean_temp; + let slope = (d.acoustic - days[day - 5][i].acoustic) / 5.0; + let thermally_explained = temp_dev_c.abs() > THERMAL_EXPLAIN_C; + let naive_alarm = acoustic_z <= COLLAPSE_ACOUSTIC_Z; + let collapse = naive_alarm && field_z <= COLLAPSE_FIELD_Z && !thermally_explained; + let swarm_precursor = slope >= SWARM_SLOPE + && d.gain_kg < b.mean_gain_kg * SWARM_GAIN_FRACTION + && !collapse; + verdicts.push(HiveVerdict { + label: d.label.clone(), + acoustic_z, + field_z, + temp_dev_c, + acoustic_slope: slope, + gain_kg: d.gain_kg, + naive_alarm, + thermally_explained, + collapse, + swarm_precursor, + node_id: d.node_id, + sequence: d.sequence, + sample: d.sample.clone(), + }); + } + let correlated_hives = verdicts.iter().filter(|v| v.collapse).count(); + let (uncapped, severity, bio_only) = collapse_severity(correlated_hives); + let collapsing: Vec<&HiveVerdict> = verdicts.iter().filter(|v| v.collapse).collect(); + let swarming: Vec<&HiveVerdict> = verdicts.iter().filter(|v| v.swarm_precursor).collect(); + let event = if !collapsing.is_empty() { + Some(EnvironmentalEvent { + evidence_digest: Some(evidence_digest( + &collapsing.iter().map(|v| &v.sample).collect::>(), + )), + spec_version: SPEC_VERSION.into(), + event_id: format!("evt-b4-collapse-d{day:03}"), + biome_id: "biome/orchard-apiary".into(), + kind: EventKind::Anomaly, + severity, + modality: SensorModality::Acoustic, + geo: GeoPoint::new(508_812_900, 4_411_000, 62_000).expect("valid apiary centroid"), + window_start_ns: slot_ns(day, 0), + window_end_ns: slot_ns(day, SLOTS - 1), + detected_ns: slot_ns(day, SLOTS - 1), + evidence: collapsing + .iter() + .map(|v| EvidenceRef { + node_id: v.node_id, + sequence: v.sequence, + }) + .collect(), + confidence: if bio_only { 0.52 } else { 0.88 }, + message: format!( + "{} colony(ies) collapsed within one window, thermally excluded", + collapsing.len() + ), + signature_hex: None, + signer_pubkey_hex: None, + }) + } else if !swarming.is_empty() { + Some(EnvironmentalEvent { + evidence_digest: Some(evidence_digest( + &swarming.iter().map(|v| &v.sample).collect::>(), + )), + spec_version: SPEC_VERSION.into(), + event_id: format!("evt-b4-swarm-d{day:03}"), + biome_id: "biome/orchard-apiary".into(), + kind: EventKind::Anomaly, + // A swarm precursor is read purely from the colony: capped. + severity: bio_only_severity_cap(Severity::Watch), + modality: SensorModality::Acoustic, + geo: GeoPoint::new(508_812_900, 4_411_000, 62_000).expect("valid apiary centroid"), + window_start_ns: slot_ns(day - 5, 0), + window_end_ns: slot_ns(day, SLOTS - 1), + detected_ns: slot_ns(day, SLOTS - 1), + evidence: swarming + .iter() + .map(|v| EvidenceRef { + node_id: v.node_id, + sequence: v.sequence, + }) + .collect(), + confidence: 0.66, + message: format!( + "swarming precursor on {}: acoustic rising, weight gain stalled", + swarming[0].label + ), + signature_hex: None, + signer_pubkey_hex: None, + }) + } else { + None + }; + Assessment { + moment: moment.to_string(), + day, + hives: verdicts, + correlated_hives, + uncapped, + severity, + bio_only, + event, + } + }; + + let swarm = assess("swarming precursor", SWARM_EVAL_DAY); + let solo = assess("single-hive collapse", SOLO_COLLAPSE_DAY + 1); + let cold = assess("cold snap — confounder", COLD_SNAP_DAY); + let apiary_wide = assess("correlated apiary-wide collapse", APIARY_COLLAPSE_DAY + 1); + + Report { + baselines, + swarm, + solo, + cold, + apiary_wide, + // The weight series never leaves the biome: it is a derived feature. + weight_series_class: DataClass::DerivedFeature, + verified_samples: verified, + final_weights: weights, + } +} + +/// Print the ADR-266 §4.1 acceptance bar and disclaim this scenario. +fn print_not_validated() { + println!("\n NOT VALIDATED"); + println!(" ADR-266 §4 track B4 is a RESEARCH TRACK, not a roadmap item and not a"); + println!(" product claim. The §4.1 item 3 acceptance bar is: one biological signal"); + println!(" predicts a CONFIRMED environmental condition >= 30 MINUTES EARLIER than the"); + println!(" conventional sensor, at > 90% PRECISION, across 3 INDEPENDENT LOCATIONS,"); + println!(" with NO PER-LOCATION RETRAINING. Three hives in ONE apiary are three"); + println!(" organisms at one location — they are NOT three independent sites, and a"); + println!(" correlated collapse across them is not proof of a common cause. Nothing"); + println!(" here is evidence that hive acoustics detect pesticide exposure; the"); + println!(" scenario only shows what the fabric does with such a signal if it exists."); +} + +fn main() { + banner( + "pollinator-hive — ADR-266 B4 biohybrid pollinator nodes", + "3 colonies: acoustic + electric field, paired internal temp/humidity, derived weight", + ); + let r = run(); + + println!(" PER-COLONY BASELINES (20 undisturbed days, 6-hourly)\n"); + println!( + " {:<18} {:>10} {:>8} {:>10} {:>8} {:>9} {:>10}", + "hive", "acoustic", "sd", "field mV/m", "sd", "temp °C", "gain kg/d" + ); + for b in &r.baselines { + println!( + " {:<18} {:>10.1} {:>8.2} {:>10.1} {:>8.2} {:>9.2} {:>10.2}", + b.label, + b.mean_acoustic, + b.sd_acoustic, + b.mean_field, + b.sd_field, + b.mean_temp, + b.mean_gain_kg + ); + } + println!(" -> three colonies, three different normals. No global threshold."); + println!(" verdict legend: COLLAPSE = acoustic + electric field agree and the"); + println!(" internal-temperature reference does NOT explain it; thermal = the"); + println!(" conventional reference explains the drop; uncorrob. = the acoustic"); + println!(" index alone moved (e.g. against a stale post-swarm baseline) and the"); + println!(" colony electric field refused to confirm it. Only COLLAPSE counts."); + + for a in [&r.swarm, &r.solo, &r.cold, &r.apiary_wide] { + println!("\n DAY {} — {}\n", a.day, a.moment.to_uppercase()); + println!( + " {:<18} {:>9} {:>9} {:>9} {:>9} {:>9} {:>12}", + "hive", "acou z", "field z", "ΔT °C", "slope/d", "gain kg", "verdict" + ); + for v in &a.hives { + let verdict = if v.collapse { + "COLLAPSE" + } else if v.swarm_precursor { + "SWARM-PRE" + } else if v.naive_alarm && v.thermally_explained { + "thermal" + } else if v.naive_alarm { + "uncorrob." + } else { + "normal" + }; + println!( + " {:<18} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>12}", + v.label, + v.acoustic_z, + v.field_z, + v.temp_dev_c, + v.acoustic_slope, + v.gain_kg, + verdict + ); + } + let naive = a.hives.iter().filter(|v| v.naive_alarm).count(); + let thermal = a.hives.iter().filter(|v| v.thermally_explained).count(); + line("naive acoustic alarms", format!("{naive} of 3")); + line( + "thermally explained by the reference", + format!("{thermal} of 3"), + ); + line("colonies collapsing in this window", a.correlated_hives); + if a.correlated_hives == 0 { + line("collapse evidence", "none — nothing to cap"); + } else { + line("evidence is a single colony (biology only)", a.bio_only); + line( + "collapse severity before the biological cap", + format!("{:?}", a.uncapped), + ); + line("collapse severity emitted", format!("{:?}", a.severity)); + } + match &a.event { + Some(ev) => { + ev.validate().expect("event is structurally valid"); + line( + "event", + format!("{:?} / conf {:.2}", ev.severity, ev.confidence), + ); + println!(" -> {}", ev.message); + } + None => line("event", "NONE"), + } + } + + println!("\n DERIVED SERIES AND RESIDENCY\n"); + line( + "hive weight series data class", + format!("{:?}", r.weight_series_class), + ); + line( + "its residency", + format!("{:?}", r.weight_series_class.residency()), + ); + for (b, w) in r.baselines.iter().zip(&r.final_weights) { + line(&format!("final weight — {}", b.label), format!("{w:.2} kg")); + } + line( + "max colony evidence edge weight cap", + format!("{BIO_MAX_EVIDENCE_WEIGHT:.2}"), + ); + line("envelopes cryptographically verified", r.verified_samples); + + print_not_validated(); + synthetic_footer("Hive acoustics here are a hand-written model, not recordings."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn swarm_precursor_fires_on_the_right_hive_only() { + let r = run(); + let fired: Vec<&str> = r + .swarm + .hives + .iter() + .filter(|v| v.swarm_precursor) + .map(|v| v.label.as_str()) + .collect(); + assert_eq!(fired, vec!["H1 orchard-east"]); + let h1 = &r.swarm.hives[0]; + assert!( + h1.acoustic_slope >= SWARM_SLOPE, + "acoustic must be climbing" + ); + assert!(h1.gain_kg < r.baselines[0].mean_gain_kg * SWARM_GAIN_FRACTION); + // No collapse anywhere, and the event is a capped Watch → Advisory. + assert_eq!(r.swarm.correlated_hives, 0); + let ev = r.swarm.event.as_ref().expect("swarm precursor event"); + ev.validate().unwrap(); + assert_eq!(ev.severity, Severity::Advisory); + assert_eq!(ev.evidence.len(), 1); + } + + #[test] + fn a_single_hive_collapse_stays_advisory() { + assert_eq!( + collapse_severity(1), + (Severity::Warning, Severity::Advisory, true) + ); + let r = run(); + let a = &r.solo; + assert_eq!(a.correlated_hives, 1); + let collapsed: Vec<&str> = a + .hives + .iter() + .filter(|v| v.collapse) + .map(|v| v.label.as_str()) + .collect(); + assert_eq!(collapsed, vec!["H2 hedgerow"]); + assert!(a.bio_only); + assert_eq!(a.uncapped, Severity::Warning); + assert_eq!(a.severity, Severity::Advisory); + let ev = a.event.as_ref().expect("advisory event"); + assert_eq!(ev.severity, Severity::Advisory); + assert!(ev.confidence < 0.6); + } + + #[test] + fn correlated_multi_hive_collapse_escalates() { + assert_eq!(collapse_severity(2).1, Severity::Warning); + assert_eq!(collapse_severity(3).1, Severity::Critical); + let r = run(); + let a = &r.apiary_wide; + assert_eq!(a.correlated_hives, 3); + assert!(!a.bio_only); + assert_eq!(a.severity, Severity::Critical); + // Every collapsing colony had a NORMAL internal temperature — the + // conventional reference is what licenses the escalation. + for v in a.hives.iter().filter(|v| v.collapse) { + assert!(!v.thermally_explained); + assert!(v.temp_dev_c.abs() < THERMAL_EXPLAIN_C); + assert!(v.field_z <= COLLAPSE_FIELD_Z); + } + let ev = a.event.as_ref().expect("critical event"); + ev.validate().unwrap(); + assert_eq!(ev.evidence.len(), 3); + assert!(ev + .evidence_digest + .as_ref() + .is_some_and(|d| d.starts_with("sha256:"))); + assert!(ev.confidence > 0.8); + } + + #[test] + fn the_cold_confounded_hive_produces_no_event() { + let r = run(); + let a = &r.cold; + let h3 = a + .hives + .iter() + .find(|v| v.label == "H3 heath-margin") + .expect("H3 present"); + // The naive detector sees a huge activity drop... + assert!(h3.naive_alarm); + assert!(h3.acoustic_z < COLLAPSE_ACOUSTIC_Z); + // ...but the paired conventional temperature reference explains it, + // and the electric field says the colony is alive. + assert!(h3.thermally_explained); + assert!(h3.temp_dev_c < -THERMAL_EXPLAIN_C); + assert!(h3.field_z > COLLAPSE_FIELD_Z); + assert!(!h3.collapse); + assert_eq!(a.correlated_hives, 0); + assert!(a.event.is_none(), "a cold hive is not an incident"); + assert_eq!(a.severity, Severity::Advisory); + } + + #[test] + fn scenario_is_fully_deterministic() { + let a = run(); + let b = run(); + assert_eq!(a, b); + assert_eq!(a.verified_samples, TOTAL_DAYS * SLOTS * 3 * 4); + assert_eq!(a.weight_series_class, DataClass::DerivedFeature); + } +} diff --git a/examples/src/bin/sentinel-forest.rs b/examples/src/bin/sentinel-forest.rs new file mode 100644 index 0000000..0c37faa --- /dev/null +++ b/examples/src/bin/sentinel-forest.rs @@ -0,0 +1,924 @@ +//! # sentinel-forest — ADR-266 §4 track B1 (research track, NOT a product) +//! +//! Bioelectric electrodes on six trees, each paired with a conventional +//! soil-moisture reference sensor and a stand-level air-temperature sensor. +//! +//! The scenario demonstrates the three things ADR-266 §4.1 says must be true +//! before anyone is allowed to believe a plant-electrophysiology signal: +//! +//! 1. **Per-organism baselines.** Every tree's resting bioelectric potential +//! is different. A single global threshold is provably unable to separate +//! the stressed trees from the healthy ones — the scenario asserts it. +//! 2. **Confounder rejection.** A hot afternoon shifts *every* tree's +//! potential. Without the temperature covariate all six trees "detect +//! drought"; with the per-organism temperature slope learned during the +//! baseline period, exactly the two genuinely droughted trees deviate. +//! 3. **Capped evidence.** The resulting event is bioelectric-only, so it is +//! routed through [`bio_only_severity_cap`] and can never exceed +//! [`Severity::Advisory`], and its WorldGraph evidence edges are capped at +//! [`BIO_MAX_EVIDENCE_WEIGHT`]. The paired soil-moisture sensor may raise +//! *confidence*; it does not let biology raise *severity* on its own. +//! +//! Run it: +//! +//! ```bash +//! cargo run -p rucelium-examples --bin sentinel-forest +//! ``` +//! +//! **The sensor values are simulated. The verification, graph, and severity +//! machinery is the real production code.** Nothing here is evidence that +//! plant electrophysiology predicts drought. + +use rucelium_core::event::evidence_digest; +use rucelium_core::{ + EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, + SPEC_VERSION, +}; +use rucelium_examples::{ + banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S, S_PER_DAY, +}; +use rucelium_worldgraph::{EdgeKind, GraphNode, WorldGraph}; + +// --------------------------------------------------------------------------- +// The two normative rules this example exists to enforce +// --------------------------------------------------------------------------- + +/// Hard cap on the weight of any biology-derived evidence edge in the +/// WorldGraph. +/// +/// This mirrors `rucelium_worldgraph::RF_MAX_EVIDENCE_WEIGHT` (ADR-264 §8) +/// because ADR-266 §4.1 item 3 says biological modalities get *the same* +/// discipline as RF until the biological acceptance bar is met: they may +/// nudge confidence, they may never dominate physical sensing. +pub const BIO_MAX_EVIDENCE_WEIGHT: f32 = 0.3; + +/// Clamp a severity to at most [`Severity::Advisory`]. +/// +/// **The ADR-266 §4.1 item 3 rule, enforced:** an event whose only evidence +/// is a biological transducer may never exceed `Advisory`. Exact same +/// semantics as `rucelium_worldgraph::rf_only_severity_cap`, applied to the +/// biological frontier. Every biology-only severity in this file is routed +/// through this function. +#[must_use] +pub fn bio_only_severity_cap(severity: Severity) -> Severity { + severity.min(Severity::Advisory) +} + +// --------------------------------------------------------------------------- +// Simulation geometry +// --------------------------------------------------------------------------- + +/// Measurement slots per simulated day (4-hourly). +pub const SLOTS: usize = 6; +/// Days of undisturbed baseline learning before anything is injected. +pub const BASELINE_DAYS: u64 = 20; +/// The day the heatwave confounder arrives — all trees still healthy. +pub const CONFOUNDER_DAY: u64 = BASELINE_DAYS; +/// The day drought stress is evaluated (heatwave still present). +pub const DROUGHT_DAY: u64 = BASELINE_DAYS + 3; +/// Slot index of the hot afternoon reading used for every evaluation. +pub const AFTERNOON_SLOT: usize = 3; +/// Deviation (in baseline standard deviations) that counts as a detection. +pub const TRIGGER_Z: f64 = 4.0; +/// Air-temperature offset per slot, degrees Celsius (a fixed diurnal shape). +pub const DIURNAL_C: [f64; SLOTS] = [-2.5, -3.0, -0.5, 3.0, 2.0, -1.0]; +/// Extra degrees Celsius the heatwave adds to afternoon slots. +pub const HEATWAVE_C: f64 = 12.0; + +/// One instrumented tree: a bioelectric electrode plus its paired +/// conventional soil-moisture probe. +#[derive(Debug, Clone)] +pub struct Tree { + /// Human-readable label (appears in the WorldGraph). + pub label: &'static str, + /// Resting bioelectric potential of *this organism*, millivolts. + pub base_mv: f64, + /// This organism's own bioelectric noise, millivolts. + pub sd_mv: f64, + /// This organism's true bioelectric response to temperature, mV/°C. + pub temp_beta: f64, + /// Healthy soil volumetric water content, percent. + pub base_soil_pct: f64, + /// Whether this tree is genuinely droughted during the stress window. + pub droughted: bool, +} + +/// The six trees of the stand. Note the two droughted trees are also the two +/// with the *least negative* resting potentials — which is exactly why a +/// global millivolt threshold cannot work. +#[must_use] +pub fn stand() -> Vec { + vec![ + Tree { + label: "oak-north", + base_mv: -84.0, + sd_mv: 1.6, + temp_beta: 0.9, + base_soil_pct: 27.0, + droughted: false, + }, + Tree { + label: "oak-south", + base_mv: -131.0, + sd_mv: 2.4, + temp_beta: 1.4, + base_soil_pct: 29.0, + droughted: false, + }, + Tree { + label: "beech-ridge", + base_mv: -57.0, + sd_mv: 1.2, + temp_beta: 0.6, + base_soil_pct: 26.0, + droughted: true, + }, + Tree { + label: "birch-hollow", + base_mv: -102.0, + sd_mv: 2.0, + temp_beta: 1.1, + base_soil_pct: 31.0, + droughted: false, + }, + Tree { + label: "pine-east", + base_mv: -118.0, + sd_mv: 2.8, + temp_beta: 1.7, + base_soil_pct: 24.0, + droughted: false, + }, + Tree { + label: "beech-west", + base_mv: -66.0, + sd_mv: 1.4, + temp_beta: 0.8, + base_soil_pct: 28.0, + droughted: true, + }, + ] +} + +// --------------------------------------------------------------------------- +// Baseline statistics +// --------------------------------------------------------------------------- + +/// Streaming mean/variance (Welford), the same shape the calibration crate +/// uses for residual tracking. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Welford { + /// Number of samples seen. + pub n: u64, + /// Running mean. + pub mean: f64, + /// Running sum of squared deviations. + pub m2: f64, +} + +impl Welford { + /// Fold one observation in. + pub fn push(&mut self, x: f64) { + self.n += 1; + let d = x - self.mean; + self.mean += d / self.n as f64; + self.m2 += d * (x - self.mean); + } + + /// Sample standard deviation (0 for fewer than two observations). + #[must_use] + pub fn sd(&self) -> f64 { + if self.n < 2 { + 0.0 + } else { + (self.m2 / (self.n - 1) as f64).sqrt() + } + } +} + +/// A learned per-organism baseline: the tree's own resting statistics *and* +/// its own temperature response. ADR-266 §4.1 item 2 ("organism-specific +/// baselines") is this struct. +#[derive(Debug, Clone, PartialEq)] +pub struct Baseline { + /// Tree label. + pub label: String, + /// Mean resting potential, mV. + pub mean_mv: f64, + /// Standard deviation of the raw potential, mV. + pub sd_mv: f64, + /// Learned temperature slope, mV/°C. + pub slope_mv_per_c: f64, + /// Mean baseline air temperature, °C. + pub mean_temp_c: f64, + /// Standard deviation of the *temperature-adjusted* residual, mV. This is + /// the scale a real detection has to beat. + pub resid_sd_mv: f64, + /// Mean baseline soil moisture, percent (the conventional reference). + pub mean_soil_pct: f64, +} + +impl Baseline { + /// Raw z-score, ignoring the temperature covariate (the naive detector). + #[must_use] + pub fn raw_z(&self, value_mv: f64) -> f64 { + if self.sd_mv <= 0.0 { + 0.0 + } else { + (value_mv - self.mean_mv) / self.sd_mv + } + } + + /// Temperature-adjusted z-score: the residual against this organism's own + /// fitted temperature response, scaled by its own residual spread. + #[must_use] + pub fn adjusted_z(&self, value_mv: f64, temp_c: f64) -> f64 { + if self.resid_sd_mv <= 0.0 { + return 0.0; + } + let expected = self.mean_mv + self.slope_mv_per_c * (temp_c - self.mean_temp_c); + (value_mv - expected) / self.resid_sd_mv + } +} + +/// One tree's verdict at one evaluation time. +#[derive(Debug, Clone, PartialEq)] +pub struct Verdict { + /// Tree label. + pub label: String, + /// Bioelectric node id. + pub node_id: u64, + /// Sequence number of the evaluated bioelectric sample. + pub sequence: u32, + /// Measured potential, mV. + pub value_mv: f64, + /// Naive z-score (no temperature covariate). + pub raw_z: f64, + /// Temperature-adjusted z-score. + pub adj_z: f64, + /// Paired conventional soil-moisture reading, percent. + pub soil_pct: f64, + /// Whether the naive detector fired. + pub naive_fired: bool, + /// Whether the covariate-adjusted detector fired. + pub adjusted_fired: bool, + /// Whether the paired conventional sensor corroborates drought. + pub soil_corroborates: bool, + /// The verified observation itself, retained so the event can bind the + /// *content* of its evidence and not merely its identity (ADR-266 §3.1). + pub sample: EnvSample, +} + +/// Everything one deterministic run produces. `main` prints it; the tests +/// assert on it; two runs compare equal. +#[derive(Debug, Clone, PartialEq)] +pub struct Report { + /// Learned per-organism baselines, in stand order. + pub baselines: Vec, + /// Air temperature at the confounder evaluation, °C. + pub confounder_temp_c: f64, + /// Air temperature at the drought evaluation, °C. + pub drought_temp_c: f64, + /// Verdicts on the heatwave-only day (every tree is healthy). + pub confounder_only: Vec, + /// Verdicts on the drought day (heatwave still present). + pub drought_day: Vec, + /// The bioelectric-only event, if the adjusted detector fired. + pub event: Option, + /// Severity the detector *wanted* before the biological cap was applied. + pub uncapped_severity: Severity, + /// Confidence before conventional corroboration. + pub confidence_bio_only: f32, + /// Confidence after the paired soil probe agreed (severity unchanged). + pub confidence_corroborated: f32, + /// Total envelopes the real ingest pipeline verified. + pub verified_samples: usize, + /// WorldGraph JSON (deterministic; `BTreeMap`-ordered). + pub graph_json: String, + /// Largest evidence weight on any biology-derived edge. + pub max_bio_edge_weight: f32, +} + +// --------------------------------------------------------------------------- +// Deterministic environment model +// --------------------------------------------------------------------------- + +/// Simulated measurement time for `(day, slot)`, derived from `EPOCH_NS` — +/// never a wall clock. +#[must_use] +pub fn slot_ns(day: u64, slot: usize) -> u64 { + EPOCH_NS + (day * S_PER_DAY + slot as u64 * 4 * 3_600) * NS_PER_S +} + +/// Air temperature at `(day, slot)`: a seasonal drift, a fixed diurnal shape, +/// deterministic noise, and — from [`CONFOUNDER_DAY`] onwards — an afternoon +/// heatwave. The heatwave is the confounder of ADR-266 §4.1 item 1. +#[must_use] +pub fn air_temp_c(day: u64, slot: usize, rng: &mut Rng) -> f64 { + let seasonal = 2.0 * (day as f64 * 0.21).sin(); + let heat = if day >= CONFOUNDER_DAY && (slot == 3 || slot == 4) { + HEATWAVE_C + } else { + 0.0 + }; + 14.0 + seasonal + DIURNAL_C[slot] + heat + rng.noise(0.4) +} + +/// How far drought has progressed on `day`, `0.0..=1.0`. Zero on the +/// confounder day: on that day every tree is genuinely healthy. +#[must_use] +pub fn drought_progress(day: u64) -> f64 { + if day <= CONFOUNDER_DAY { + 0.0 + } else { + ((day - CONFOUNDER_DAY) as f64 / (DROUGHT_DAY - CONFOUNDER_DAY) as f64).min(1.0) + } +} + +/// Full drought depression of the bioelectric potential, millivolts. +pub const DROUGHT_DEPTH_MV: f64 = -18.0; +/// Full drought depletion of soil moisture, percentage points. +pub const DROUGHT_SOIL_DROP_PCT: f64 = -17.0; + +// --------------------------------------------------------------------------- +// The scenario +// --------------------------------------------------------------------------- + +/// Run the whole scenario deterministically and return its report. +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn run() -> Report { + let trees = stand(); + let mut rng = Rng::new(0x005E_171E_E1F0_2E57); + + // --- provision: one bioelectric electrode + one soil probe per tree, + // plus a single stand-level air-temperature reference. --- + let mut nodes: Vec = Vec::new(); + for (i, t) in trees.iter().enumerate() { + let lat = 514_100_000 + (i as i32) * 1_100; + let geo = GeoPoint::new(lat, -3_120_000, 96_000).expect("valid stand coordinates"); + nodes.push(Node::new( + 0x00B1_0000_0000_0001 + i as u64, + SensorModality::Bioelectric, + geo, + t.label, + )); + } + for (i, t) in trees.iter().enumerate() { + let lat = 514_100_000 + (i as i32) * 1_100; + let geo = GeoPoint::new(lat, -3_120_000, 96_000).expect("valid stand coordinates"); + nodes.push(Node::new( + 0x00B1_0000_0000_0101 + i as u64, + SensorModality::SoilMoisture, + geo, + t.label, + )); + } + nodes.push(Node::new( + 0x00B1_0000_0000_0201, + SensorModality::Weather, + GeoPoint::new(514_103_000, -3_120_000, 100_000).expect("valid mast coordinates"), + "stand mast", + )); + let n_trees = trees.len(); + let mut gw = Gateway::with_nodes(&nodes); + + // Baseline accumulators: raw statistics, an ordinary-least-squares fit of + // potential on temperature, and the paired soil reference. + let mut raw: Vec = vec![Welford::default(); n_trees]; + let mut soil: Vec = vec![Welford::default(); n_trees]; + let mut fit_pairs: Vec> = vec![Vec::new(); n_trees]; + let mut verified = 0usize; + + let mut graph = WorldGraph::new(); + graph.add_node( + "ecosystem/mixed-stand", + GraphNode::Ecosystem { + name: "Upland mixed stand".into(), + kind: "forest_stand".into(), + geo: GeoPoint::new(514_103_000, -3_120_000, 96_000).expect("valid stand centroid"), + }, + ); + + // --- 1. baseline learning: 12 undisturbed days --- + for day in 0..BASELINE_DAYS { + for slot in 0..SLOTS { + let ns = slot_ns(day, slot); + let temp = air_temp_c(day, slot, &mut rng); + let env = nodes[2 * n_trees].emit(temp, ns, 1); + gw.ingest(&env, ns + 1_000_000) + .expect("mast sample verifies"); + verified += 1; + for (i, t) in trees.iter().enumerate() { + let mv = t.base_mv + t.temp_beta * (temp - 14.0) + rng.noise(t.sd_mv); + let env = nodes[i].emit(mv, ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("bioelectric sample verifies"); + let v = s.sample().value; + raw[i].push(v); + fit_pairs[i].push((temp, v)); + graph.register_observation(s.sample()); + verified += 1; + + let pct = t.base_soil_pct + rng.noise(0.6); + let env = nodes[n_trees + i].emit(pct, ns, 1); + let s = gw + .ingest(&env, ns + 1_000_000) + .expect("soil sample verifies"); + soil[i].push(s.sample().value); + graph.register_observation(s.sample()); + verified += 1; + } + } + } + + // Fit each organism's own temperature response, then measure the spread + // of the residual around it. Both are per-organism: nothing global. + let mut baselines = Vec::with_capacity(n_trees); + for (i, t) in trees.iter().enumerate() { + let pairs = &fit_pairs[i]; + let n = pairs.len() as f64; + let mt = pairs.iter().map(|p| p.0).sum::() / n; + let mv = pairs.iter().map(|p| p.1).sum::() / n; + let sxy: f64 = pairs.iter().map(|p| (p.0 - mt) * (p.1 - mv)).sum(); + let sxx: f64 = pairs.iter().map(|p| (p.0 - mt).powi(2)).sum(); + let slope = if sxx > 0.0 { sxy / sxx } else { 0.0 }; + let mut resid = Welford::default(); + for (tc, val) in pairs { + resid.push(val - (mv + slope * (tc - mt))); + } + baselines.push(Baseline { + label: t.label.to_string(), + mean_mv: raw[i].mean, + sd_mv: raw[i].sd(), + slope_mv_per_c: slope, + mean_temp_c: mt, + resid_sd_mv: resid.sd(), + mean_soil_pct: soil[i].mean, + }); + } + + // --- 2. evaluate two days: heatwave-only, then heatwave + drought --- + let mut confounder_temp_c = 0.0; + let mut drought_temp_c = 0.0; + let mut confounder_only = Vec::new(); + let mut drought_day = Vec::new(); + + for day in CONFOUNDER_DAY..=DROUGHT_DAY { + for slot in 0..SLOTS { + let ns = slot_ns(day, slot); + let temp = air_temp_c(day, slot, &mut rng); + let env = nodes[2 * n_trees].emit(temp, ns, 1); + gw.ingest(&env, ns + 1_000_000) + .expect("mast sample verifies"); + verified += 1; + let progress = drought_progress(day); + let evaluating = + slot == AFTERNOON_SLOT && (day == CONFOUNDER_DAY || day == DROUGHT_DAY); + let mut row = Vec::new(); + for (i, t) in trees.iter().enumerate() { + let stress = if t.droughted { + DROUGHT_DEPTH_MV * progress + } else { + 0.0 + }; + let mv = t.base_mv + t.temp_beta * (temp - 14.0) + stress + rng.noise(t.sd_mv); + let env = nodes[i].emit(mv, ns, 1); + let bio = gw + .ingest(&env, ns + 1_000_000) + .expect("bioelectric sample verifies"); + verified += 1; + + let soil_stress = if t.droughted { + DROUGHT_SOIL_DROP_PCT * progress + } else { + 0.0 + }; + let pct = t.base_soil_pct + soil_stress + rng.noise(0.6); + let env = nodes[n_trees + i].emit(pct, ns, 1); + let sm = gw + .ingest(&env, ns + 1_000_000) + .expect("soil sample verifies"); + verified += 1; + + if !evaluating { + continue; + } + let b = &baselines[i]; + let value_mv = bio.sample().value; + let soil_pct = sm.sample().value; + let raw_z = b.raw_z(value_mv); + let adj_z = b.adjusted_z(value_mv, temp); + row.push(Verdict { + label: t.label.to_string(), + node_id: bio.sample().node_id, + sequence: bio.sample().sequence, + value_mv, + raw_z, + adj_z, + soil_pct, + naive_fired: raw_z.abs() >= TRIGGER_Z, + adjusted_fired: adj_z.abs() >= TRIGGER_Z, + // The conventional reference's own rule, independent of + // any biology: soil moisture 8 points below its baseline. + soil_corroborates: soil_pct < b.mean_soil_pct - 8.0, + sample: bio.sample().clone(), + }); + } + if evaluating { + if day == CONFOUNDER_DAY { + confounder_temp_c = temp; + confounder_only = row; + } else { + drought_temp_c = temp; + drought_day = row; + } + } + } + } + + // --- 3. build the (capped) bioelectric-only event --- + let fired: Vec<&Verdict> = drought_day.iter().filter(|v| v.adjusted_fired).collect(); + let uncapped_severity = if fired.len() >= 2 { + Severity::Warning + } else { + Severity::Watch + }; + let confidence_bio_only = 0.61_f32; + let corroborated = fired.iter().all(|v| v.soil_corroborates) && !fired.is_empty(); + let confidence_corroborated = if corroborated { + // Conventional agreement raises CONFIDENCE. It does not raise + // severity — this event's severity rests on biology alone. + (confidence_bio_only + 0.24).min(1.0) + } else { + confidence_bio_only + }; + + let event = if fired.is_empty() { + None + } else { + Some(EnvironmentalEvent { + evidence_digest: Some(evidence_digest( + &fired.iter().map(|v| &v.sample).collect::>(), + )), + spec_version: SPEC_VERSION.into(), + event_id: "evt-b1-sentinel-forest-0001".into(), + biome_id: "biome/upland-catchment".into(), + kind: EventKind::Anomaly, + // THE RULE: biology-only evidence, so the cap applies. + severity: bio_only_severity_cap(uncapped_severity), + modality: SensorModality::Bioelectric, + geo: GeoPoint::new(514_103_000, -3_120_000, 96_000).expect("valid event centroid"), + window_start_ns: slot_ns(CONFOUNDER_DAY, 0), + window_end_ns: slot_ns(DROUGHT_DAY, AFTERNOON_SLOT), + detected_ns: slot_ns(DROUGHT_DAY, AFTERNOON_SLOT), + evidence: fired + .iter() + .map(|v| EvidenceRef { + node_id: v.node_id, + sequence: v.sequence, + }) + .collect(), + confidence: confidence_corroborated, + message: format!( + "{} tree(s) deviate from their own temperature-adjusted baseline", + fired.len() + ), + signature_hex: None, + signer_pubkey_hex: None, + }) + }; + + // --- 4. capped evidence edges into the WorldGraph --- + let mut max_bio_edge_weight = 0.0_f32; + for v in &fired { + let key = format!("sensor/{}", v.node_id); + // Even a z-score of -13 buys at most BIO_MAX_EVIDENCE_WEIGHT. + let want = (v.adj_z.abs() / 20.0) as f32; + let weight = want.min(BIO_MAX_EVIDENCE_WEIGHT); + graph + .add_edge( + &key, + "ecosystem/mixed-stand", + EdgeKind::Supports, + weight, + format!( + "bioelectric drought deviation z={:.1} (capped evidence)", + v.adj_z + ), + ) + .expect("both endpoints registered"); + max_bio_edge_weight = max_bio_edge_weight.max(weight); + } + + Report { + baselines, + confounder_temp_c, + drought_temp_c, + confounder_only, + drought_day, + event, + uncapped_severity, + confidence_bio_only, + confidence_corroborated, + verified_samples: verified, + graph_json: graph.to_json(), + max_bio_edge_weight, + } +} + +/// True when no single global millivolt threshold separates the droughted +/// trees from the healthy ones on the drought day. +/// +/// A global "alarm below τ mV" rule works only if every droughted reading is +/// below every healthy reading. This returns `true` when that is impossible. +#[must_use] +pub fn no_global_threshold_works(trees: &[Tree], day: &[Verdict]) -> bool { + let worst_stressed = day + .iter() + .zip(trees) + .filter(|(_, t)| t.droughted) + .map(|(v, _)| v.value_mv) + .fold(f64::NEG_INFINITY, f64::max); + let calmest_healthy = day + .iter() + .zip(trees) + .filter(|(_, t)| !t.droughted) + .map(|(v, _)| v.value_mv) + .fold(f64::INFINITY, f64::min); + worst_stressed >= calmest_healthy +} + +/// Print the ADR-266 §4.1 acceptance bar and state plainly that this +/// scenario is not evidence toward it. +fn print_not_validated() { + println!("\n NOT VALIDATED"); + println!(" ADR-266 §4 track B1 is a RESEARCH TRACK, not a roadmap item and not a"); + println!(" product claim. The §4.1 item 3 acceptance bar is: one biological signal"); + println!(" predicts a CONFIRMED environmental condition >= 30 MINUTES EARLIER than the"); + println!(" conventional sensor, at > 90% PRECISION, across 3 INDEPENDENT LOCATIONS,"); + println!(" with NO PER-LOCATION RETRAINING. This scenario is one simulated stand with"); + println!(" synthetic data and a hand-written stress model; it demonstrates the"); + println!(" DISCIPLINE (per-organism baselines, paired conventional references,"); + println!(" confounder rejection, capped evidence) and constitutes NO evidence toward"); + println!(" any part of that bar. Until it passes, biological modalities enter the"); + println!(" WorldGraph with capped weight and can never alone exceed Advisory."); +} + +fn main() { + banner( + "sentinel-forest — ADR-266 B1 living sentinel forest", + "6 bioelectric electrodes + paired soil-moisture and air-temperature references", + ); + let r = run(); + let trees = stand(); + + println!(" 1. PER-ORGANISM BASELINES (20 undisturbed days, 4-hourly)\n"); + println!( + " {:<14} {:>10} {:>8} {:>12} {:>12} {:>10}", + "tree", "mean mV", "sd mV", "mV per °C", "resid sd", "soil %" + ); + for b in &r.baselines { + println!( + " {:<14} {:>10.1} {:>8.2} {:>12.2} {:>12.2} {:>10.1}", + b.label, b.mean_mv, b.sd_mv, b.slope_mv_per_c, b.resid_sd_mv, b.mean_soil_pct + ); + } + let lo = r + .baselines + .iter() + .map(|b| b.mean_mv) + .fold(f64::INFINITY, f64::min); + let hi = r + .baselines + .iter() + .map(|b| b.mean_mv) + .fold(f64::NEG_INFINITY, f64::max); + line( + "baseline mean spread across organisms", + format!("{:.1} mV", hi - lo), + ); + println!( + " -> no global threshold is defensible: the healthiest tree rests {:.0} mV", + hi - lo + ); + println!(" away from its neighbour before anything is wrong."); + + println!("\n 2. CONFOUNDER ONLY — hot afternoon, every tree healthy\n"); + line( + "air temperature at evaluation", + format!("{:.1} °C", r.confounder_temp_c), + ); + println!( + " {:<14} {:>10} {:>9} {:>9} {:>9} {:>10}", + "tree", "mV", "raw z", "adj z", "soil %", "verdict" + ); + for v in &r.confounder_only { + println!( + " {:<14} {:>10.1} {:>9.2} {:>9.2} {:>9.1} {:>10}", + v.label, + v.value_mv, + v.raw_z, + v.adj_z, + v.soil_pct, + if v.adjusted_fired { "FIRED" } else { "quiet" } + ); + } + let naive_fp = r.confounder_only.iter().filter(|v| v.naive_fired).count(); + let adj_fp = r + .confounder_only + .iter() + .filter(|v| v.adjusted_fired) + .count(); + line("naive detector false positives", format!("{naive_fp} of 6")); + line("covariate-adjusted detections", format!("{adj_fp} of 6")); + println!(" -> temperature alone mimics the signal of interest on EVERY tree."); + println!(" ADR-266 §4.1 item 1 is not a footnote; it is the dominant failure mode."); + + println!("\n 3. DROUGHT DAY — heatwave still present, 2 trees genuinely stressed\n"); + line( + "air temperature at evaluation", + format!("{:.1} °C", r.drought_temp_c), + ); + println!( + " {:<14} {:>10} {:>9} {:>9} {:>9} {:>10} {:>8}", + "tree", "mV", "raw z", "adj z", "soil %", "verdict", "truth" + ); + for (v, t) in r.drought_day.iter().zip(&trees) { + println!( + " {:<14} {:>10.1} {:>9.2} {:>9.2} {:>9.1} {:>10} {:>8}", + v.label, + v.value_mv, + v.raw_z, + v.adj_z, + v.soil_pct, + if v.adjusted_fired { "FIRED" } else { "quiet" }, + if t.droughted { "drought" } else { "healthy" } + ); + } + line( + "global-threshold rule provably impossible", + no_global_threshold_works(&trees, &r.drought_day), + ); + + println!("\n 4. THE CAP — biology may inform, never alarm\n"); + if let Some(ev) = &r.event { + ev.validate().expect("event is structurally valid"); + line( + "detector wanted severity", + format!("{:?}", r.uncapped_severity), + ); + line( + "bio_only_severity_cap() emitted", + format!("{:?}", ev.severity), + ); + line( + "event kind / modality", + format!("{:?} / {}", ev.kind, ev.modality.as_str()), + ); + line("evidence observations", ev.evidence.len()); + line( + "confidence, bioelectric only", + format!("{:.2}", r.confidence_bio_only), + ); + line( + "confidence, soil probe agreeing", + format!("{:.2} (severity UNCHANGED)", r.confidence_corroborated), + ); + line( + "max biology evidence edge weight", + format!("{:.2}", r.max_bio_edge_weight), + ); + line( + "hard cap on that weight", + format!("{BIO_MAX_EVIDENCE_WEIGHT:.2}"), + ); + println!(" -> the conventional soil probe raised CONFIDENCE. It did not, and could"); + println!(" not, raise SEVERITY: this event's evidence is bioelectric."); + } + line("envelopes cryptographically verified", r.verified_samples); + line("WorldGraph JSON bytes (deterministic)", r.graph_json.len()); + + print_not_validated(); + synthetic_footer("Nothing here is evidence that trees predict drought."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn baselines_differ_per_organism_so_no_global_threshold_exists() { + let r = run(); + let trees = stand(); + assert_eq!(r.baselines.len(), 6); + // Every organism's resting potential is materially different. + for i in 0..r.baselines.len() { + for j in (i + 1)..r.baselines.len() { + assert!( + (r.baselines[i].mean_mv - r.baselines[j].mean_mv).abs() > 5.0, + "{} and {} have indistinguishable baselines", + r.baselines[i].label, + r.baselines[j].label + ); + } + } + // And the learned temperature slopes are per-organism too. + for (b, t) in r.baselines.iter().zip(&trees) { + assert!( + (b.slope_mv_per_c - t.temp_beta).abs() < 0.4, + "{} slope {:.2} should recover {:.2}", + b.label, + b.slope_mv_per_c, + t.temp_beta + ); + } + // No single global millivolt threshold can separate stressed from + // healthy on the drought day. + assert!(no_global_threshold_works(&trees, &r.drought_day)); + } + + #[test] + fn drought_detected_on_exactly_the_two_stressed_trees() { + let r = run(); + let trees = stand(); + let fired: Vec<&str> = r + .drought_day + .iter() + .filter(|v| v.adjusted_fired) + .map(|v| v.label.as_str()) + .collect(); + assert_eq!(fired, vec!["beech-ridge", "beech-west"]); + for (v, t) in r.drought_day.iter().zip(&trees) { + assert_eq!( + v.adjusted_fired, t.droughted, + "{} misclassified (adj z {:.2})", + v.label, v.adj_z + ); + // The paired conventional reference agrees with the truth too. + assert_eq!(v.soil_corroborates, t.droughted, "{} soil probe", v.label); + } + } + + #[test] + fn confounder_alone_produces_zero_events_but_fools_the_naive_detector() { + let r = run(); + // Naive detector: every single healthy tree "detects drought". + assert_eq!( + r.confounder_only.iter().filter(|v| v.naive_fired).count(), + 6, + "the heatwave must be a genuine confounder for all six trees" + ); + // Covariate-adjusted detector: nothing fires, no event exists. + assert_eq!( + r.confounder_only + .iter() + .filter(|v| v.adjusted_fired) + .count(), + 0 + ); + for v in &r.confounder_only { + assert!(!v.soil_corroborates, "{} soil should look normal", v.label); + } + } + + #[test] + fn bioelectric_only_evidence_is_capped_at_advisory() { + // The cap function itself, over the whole ladder. + assert_eq!( + bio_only_severity_cap(Severity::Critical), + Severity::Advisory + ); + assert_eq!(bio_only_severity_cap(Severity::Warning), Severity::Advisory); + assert_eq!(bio_only_severity_cap(Severity::Watch), Severity::Advisory); + assert_eq!( + bio_only_severity_cap(Severity::Advisory), + Severity::Advisory + ); + + let r = run(); + let ev = r.event.as_ref().expect("drought event was raised"); + ev.validate().expect("valid event"); + assert_eq!(r.uncapped_severity, Severity::Warning); + assert_eq!(ev.severity, Severity::Advisory); + // The event binds the CONTENT of its evidence, not just its identity. + let digest = ev.evidence_digest.as_ref().expect("content-bound"); + assert!(digest.starts_with("sha256:")); + assert_eq!(ev.modality, SensorModality::Bioelectric); + // Corroboration moved confidence, never severity. + assert!(r.confidence_corroborated > r.confidence_bio_only); + assert_eq!(bio_only_severity_cap(ev.severity), ev.severity); + // Evidence weight in the graph is capped like RF evidence. + assert!(r.max_bio_edge_weight <= BIO_MAX_EVIDENCE_WEIGHT); + assert!(r.max_bio_edge_weight > 0.0); + } + + #[test] + fn scenario_is_fully_deterministic() { + let a = run(); + let b = run(); + assert_eq!(a, b); + assert!(a.verified_samples > 1_000); + } +} diff --git a/examples/src/bin/wildfire-risk.rs b/examples/src/bin/wildfire-risk.rs new file mode 100644 index 0000000..bc8cafc --- /dev/null +++ b/examples/src/bin/wildfire-risk.rs @@ -0,0 +1,835 @@ +//! # wildfire-risk — deployment wedge #4 (ADR-266 §3.1) +//! +//! Wildfire risk is the wedge that **monetizes evidence discipline**. The +//! buyers — forestry, utilities, insurers, resorts — are usually *not* the +//! fire service, which means the product is a defensible risk position, and a +//! single false Critical alert costs more credibility than a hundred correct +//! Advisories earn. +//! +//! So the guarantee this example exists to prove is the ADR-266 §3.1 line for +//! this wedge, verbatim: +//! +//! > **RF severity cap holds**: RF may support or contradict, never +//! > independently raise a critical fire alert. +//! +//! Concretely: +//! +//! * a composite risk index over temperature, humidity, wind, and soil +//! moisture escalates Advisory → Watch → Warning as the fuel dries; +//! * an **RF-only "detection"**, however confident the radio is, is routed +//! through [`rf_only_severity_cap`] and lands at +//! [`Severity::Advisory`] — never higher; +//! * only **physical** evidence — a PM spike *and* optical smoke together — +//! justifies [`Severity::Critical`]. A PM spike on its own (a harvester +//! raising dust) does not; +//! * a humidity sensor cooked by the heat has its quality collapse, is +//! excluded from the index, and its absence is **reported** as a signed +//! event rather than silently absorbed. +//! +//! ```bash +//! cargo run -p rucelium-examples --bin wildfire-risk +//! cargo test -p rucelium-examples --bin wildfire-risk +//! ``` + +use rucelium_core::{ + EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, SPEC_VERSION, +}; +use rucelium_examples::{banner, line, synthetic_footer, Gateway, Node, Rng, EPOCH_NS, NS_PER_S}; +use rucelium_worldgraph::{ + assess_plausibility, fuse_rf_context, rf_only_severity_cap, RfContext, WorldGraph, + RF_MAX_EVIDENCE_WEIGHT, +}; + +// --------------------------------------------------------------------------- +// Scenario constants +// --------------------------------------------------------------------------- + +/// The biome under fire watch. +pub const BIOME_ID: &str = "biome/ponderosa-ridge"; + +/// Simulated seconds between rounds (1 hour). +pub const ROUND_S: u64 = 3_600; + +/// Number of rounds: a 16-hour drying day. +pub const ROUNDS: usize = 16; + +/// Provisioned spore nodes. +pub const NODE_COUNT: usize = 7; + +/// Quality below which an observation is excluded from the risk index. +pub const QUALITY_FLOOR: f32 = 0.50; + +/// Composite risk at or above which an Advisory is raised. +pub const ADVISORY_RISK: f64 = 0.30; +/// Composite risk at or above which a Watch is raised. +pub const WATCH_RISK: f64 = 0.45; +/// Composite risk at or above which a Warning is raised — and the highest +/// severity any amount of *environmental* evidence can reach on its own. +pub const WARNING_RISK: f64 = 0.60; + +/// PM2.5 (µg/m³) that counts as physical combustion evidence. +pub const PM_CRITICAL_UG_M3: f64 = 120.0; +/// Optical smoke-obscuration index that counts as physical combustion +/// evidence. +pub const SMOKE_CRITICAL_INDEX: f64 = 0.60; + +/// Calibration record referenced by every node on the ridge. +pub const CALIBRATION_ID: u32 = 41; + +/// Temporal window (ns) within which RF context says anything about a sample. +pub const RF_WINDOW_NS: u64 = 3_600 * NS_PER_S; + +/// The round at which the humidity sensor at the exposed site cooks. +pub const SENSOR_FAILURE_ROUND: usize = 10; + +/// The round at which a harvester raises a dust plume — PM only, no smoke. +pub const DUST_ROUND: usize = 12; + +/// The round at which combustion actually starts — PM *and* optical smoke. +pub const IGNITION_ROUND: usize = 13; + +// Node-table indices. +/// Air-temperature station. +pub const TEMP: usize = 0; +/// Relative-humidity sensor, sheltered site. +pub const RH_A: usize = 1; +/// Relative-humidity sensor, exposed site — this one fails in the heat. +pub const RH_B: usize = 2; +/// Anemometer. +pub const WIND: usize = 3; +/// Soil-moisture probe (fuel dryness proxy). +pub const SOIL: usize = 4; +/// PM2.5 monitor. +pub const PM: usize = 5; +/// Optical smoke-obscuration sensor. +pub const SMOKE: usize = 6; + +// --------------------------------------------------------------------------- +// Synthetic drying day +// --------------------------------------------------------------------------- + +/// Noise-free truth for sensor `idx` at `round`, in that sensor's unit. +#[must_use] +pub fn truth(idx: usize, round: usize) -> f64 { + let t = round as f64; + match idx { + TEMP => 22.0 + 1.1 * t, + // The exposed hygrometer stops reporting anything physical once its + // element cooks — the value is nonsense and it says so via quality. + RH_B if round >= SENSOR_FAILURE_ROUND => 3.0, + RH_A | RH_B => 62.0 - 3.0 * t, + WIND => 4.0 + 0.9 * t, + SOIL => 24.0 - 1.2 * t, + PM if round >= IGNITION_ROUND => 165.0, + PM if round == DUST_ROUND => 130.0, + PM => 10.0 + 1.5 * t, + SMOKE if round >= IGNITION_ROUND => 0.84, + _ => 0.02, + } +} + +/// Reported quality for sensor `idx` at `round`. +#[must_use] +pub fn quality(idx: usize, round: usize) -> f64 { + if idx == RH_B && round >= SENSOR_FAILURE_ROUND { + 0.11 + } else { + 0.97 + } +} + +/// Per-sensor noise standard deviation. +#[must_use] +pub fn noise_sd(idx: usize) -> f64 { + match idx { + TEMP => 0.03, + RH_A | RH_B => 0.06, + WIND | SOIL => 0.03, + PM => 0.20, + _ => 0.002, + } +} + +/// Measurement time of round `round`. +#[must_use] +pub fn round_ns(round: usize) -> u64 { + EPOCH_NS + (round as u64) * ROUND_S * NS_PER_S +} + +/// Build a geo point, panicking on a coordinate the example itself got wrong. +fn geo(latitude_e7: i32, longitude_e7: i32, altitude_mm: i32) -> GeoPoint { + GeoPoint::new(latitude_e7, longitude_e7, altitude_mm).expect("example coordinates are in range") +} + +/// Provision the seven spore nodes of the fire-watch cluster. +#[must_use] +pub fn provision() -> Vec { + vec![ + Node::new( + 0x00F4_0000_0000_0001, + SensorModality::Weather, + geo(391_200_000, -1_064_000_000, 2_180_000), + "AT-1 air temperature", + ), + Node::new( + 0x00F4_0000_0000_0002, + SensorModality::Weather, + geo(391_206_000, -1_064_004_000, 2_178_000), + "RH-1 humidity, sheltered", + ), + Node::new( + 0x00F4_0000_0000_0003, + SensorModality::Weather, + geo(391_214_000, -1_063_988_000, 2_205_000), + "RH-2 humidity, exposed ridge", + ), + Node::new( + 0x00F4_0000_0000_0004, + SensorModality::Weather, + geo(391_219_000, -1_063_980_000, 2_211_000), + "AN-1 anemometer", + ), + Node::new( + 0x00F4_0000_0000_0005, + SensorModality::SoilMoisture, + geo(391_193_000, -1_064_012_000, 2_161_000), + "SM-1 fuel-bed soil moisture", + ), + Node::new( + 0x00F4_0000_0000_0006, + SensorModality::AirQuality, + geo(391_188_000, -1_064_020_000, 2_154_000), + "PM-1 particulate monitor", + ), + Node::new( + 0x00F4_0000_0000_0007, + SensorModality::Optical, + geo(391_190_000, -1_064_016_000, 2_158_000), + "OS-1 optical smoke obscuration", + ), + ] +} + +/// The RuView RF context observation for a given round. The radio is *very* +/// sure it sees a thermal plume moving — and it is still only context. +#[must_use] +pub fn rf_context(at_ns: u64, confidence: f32) -> RfContext { + // Built directly rather than via `RfContext::from_field_event`: the + // examples package does not depend on `rufield-core`. Every field is + // exactly what the RuField MFS WiFi-CSI encoder would have produced. + RfContext { + source_event_id: "rf-ridge-plume-01".to_string(), + device_id: "rf-ridge-01".to_string(), + confidence, + motion_energy: Some(0.90), + labels: vec!["thermal_plume_motion".to_string()], + timestamp_ns: at_ns, + } +} + +// --------------------------------------------------------------------------- +// Risk model +// --------------------------------------------------------------------------- + +/// Normalize a value onto `0.0..=1.0` between `low` and `high`. +fn norm(value: f64, low: f64, high: f64) -> f64 { + ((value - low) / (high - low)).clamp(0.0, 1.0) +} + +/// Composite fire-risk index from the *environmental* sensors only. +/// +/// Hotter, drier, windier, and drier-fuelled all push the index up, so a +/// drying day produces a monotonically rising index — which the tests assert. +#[must_use] +pub fn risk_index(temp_c: f64, humidity_pct: f64, wind_kmh: f64, soil_pct: f64) -> f64 { + 0.30 * norm(temp_c, 15.0, 45.0) + + 0.30 * norm(70.0 - humidity_pct, 0.0, 60.0) + + 0.20 * norm(wind_kmh, 0.0, 40.0) + + 0.20 * norm(30.0 - soil_pct, 0.0, 30.0) +} + +/// Severity for a risk index, given whether **physical** combustion evidence +/// is present. +/// +/// Environmental risk alone tops out at [`Severity::Warning`]: dry, hot, and +/// windy is not a fire. [`Severity::Critical`] requires physical evidence that +/// something is actually burning. +#[must_use] +pub fn severity_for(risk: f64, physical_evidence: bool) -> Option { + if risk >= WARNING_RISK { + Some(if physical_evidence { + Severity::Critical + } else { + Severity::Warning + }) + } else if risk >= WATCH_RISK { + Some(Severity::Watch) + } else if risk >= ADVISORY_RISK { + Some(Severity::Advisory) + } else { + None + } +} + +/// One hour of the fire-watch day. +#[derive(Debug, Clone, PartialEq)] +pub struct RiskRound { + /// Hour of the drying day. + pub hour: usize, + /// Composite environmental risk index. + pub risk: f64, + /// Severity raised this hour, if any. + pub severity: Option, + /// Environmental sensors that contributed (quality above the floor). + pub sensors_used: usize, + /// Node ids excluded this hour for collapsed quality. + pub excluded: Vec, + /// Whether PM *and* optical smoke both indicated combustion. + pub physical_evidence: bool, + /// Measured PM2.5, µg/m³. + pub pm_ug_m3: f64, + /// Measured optical smoke-obscuration index. + pub smoke_index: f64, +} + +/// Everything one fire-watch day produced. +#[derive(Debug, Default)] +pub struct WildfireRun { + /// Hour-by-hour risk assessment. + pub rounds: Vec, + /// Escalation events, one per severity step up. + pub events: Vec, + /// The sensor-degradation report — the absence is announced, not hidden. + pub degradation: Option, + /// The WorldGraph, including the capped RF evidence edge. + pub graph: WorldGraph, + /// Node ids excluded from the index at any point in the day. + pub excluded_nodes: Vec, +} + +impl WildfireRun { + /// The highest severity raised anywhere in the day. + #[must_use] + pub fn peak_severity(&self) -> Option { + self.events.iter().map(|e| e.severity).max() + } +} + +/// Assemble a fire event. +fn fire_event( + id: &str, + kind: EventKind, + severity: Severity, + modality: SensorModality, + at: GeoPoint, + window: (u64, u64), + evidence: Vec, + confidence: f32, + message: String, +) -> EnvironmentalEvent { + let event = EnvironmentalEvent { + evidence_digest: None, + spec_version: SPEC_VERSION.to_string(), + event_id: id.to_string(), + biome_id: BIOME_ID.to_string(), + kind, + severity, + modality, + geo: at, + window_start_ns: window.0, + window_end_ns: window.1, + detected_ns: window.1, + evidence, + confidence, + message, + signature_hex: None, + signer_pubkey_hex: None, + }; + event.validate().expect("scenario events are well-formed"); + event +} + +/// Build the event an **RF-only** "detection" would produce. +/// +/// The detector is allowed to *propose* whatever severity it likes; the +/// severity that reaches the event is whatever survives +/// [`rf_only_severity_cap`]. This is the ADR-264 §8 rule as a function call, +/// and it is the whole point of this example. +#[must_use] +pub fn rf_only_alert(rf: &RfContext, proposed: Severity, at: GeoPoint) -> EnvironmentalEvent { + let severity = rf_only_severity_cap(proposed); + fire_event( + &format!("wildfire:rf-only:{}", rf.source_event_id), + EventKind::WildfireRisk, + severity, + SensorModality::WifiCsi, + at, + (rf.timestamp_ns, rf.timestamp_ns), + // RF context is not a spore node: node id 0 is the graph's convention + // for a string-identified RF device (see `fuse_rf_context`). + vec![EvidenceRef { + node_id: 0, + sequence: 0, + }], + rf.confidence, + format!( + "RF context `{}` from {} proposed {proposed:?} at confidence {:.2}; \ + capped to {severity:?} — RF is never independently sufficient (ADR-264 §8)", + rf.labels.join(","), + rf.device_id, + rf.confidence + ), + ) +} + +/// Run the 16-hour fire-watch day. +#[must_use] +pub fn run_fire_watch() -> WildfireRun { + let mut nodes = provision(); + let mut gateway = Gateway::with_nodes(&nodes); + let mut rng = Rng::new(0x00F4_1FE0_0000_2026); + let mut run = WildfireRun::default(); + let mut highest: Option = None; + + for round in 0..ROUNDS { + let measured = round_ns(round); + let received = measured + 1_000_000; + let mut values = [0.0f64; NODE_COUNT]; + let mut qualities = [0.0f32; NODE_COUNT]; + let mut sequences = [0u32; NODE_COUNT]; + let mut excluded = Vec::new(); + + for idx in 0..NODE_COUNT { + let value = truth(idx, round) + rng.noise(noise_sd(idx)); + let envelope = + nodes[idx].emit_with_quality(value, measured, CALIBRATION_ID, quality(idx, round)); + let sealed = gateway + .ingest(&envelope, received) + .expect("a node's own signed envelope must ingest"); + let sample = sealed.sample(); + run.graph.register_observation(sample); + values[idx] = sample.value; + qualities[idx] = sample.quality; + sequences[idx] = sample.sequence; + if sample.quality < QUALITY_FLOOR { + excluded.push(sample.node_id); + if !run.excluded_nodes.contains(&sample.node_id) { + run.excluded_nodes.push(sample.node_id); + // The absence is announced. A quietly missing sensor is + // how a risk index silently becomes a lie. + run.degradation = Some(fire_event( + &format!("wildfire:sensor-degraded:{}", sample.node_id), + EventKind::SensorQuarantined, + Severity::Watch, + sample.modality, + sample.geo, + (measured, measured), + vec![EvidenceRef { + node_id: sample.node_id, + sequence: sample.sequence, + }], + 0.98, + format!( + "{} quality collapsed to {:.2} (floor {QUALITY_FLOOR:.2}) at {:.1} C — \ + excluded from the risk index and reported, not silently dropped", + nodes[idx].label, + sample.quality, + values[TEMP] + ), + )); + } + } + } + + // Humidity is the mean of whatever hygrometers are still trustworthy. + let humidity: Vec = [RH_A, RH_B] + .into_iter() + .filter(|&i| qualities[i] >= QUALITY_FLOOR) + .map(|i| values[i]) + .collect(); + let humidity_pct = if humidity.is_empty() { + 0.0 + } else { + humidity.iter().sum::() / humidity.len() as f64 + }; + let sensors_used = [TEMP, RH_A, RH_B, WIND, SOIL] + .into_iter() + .filter(|&i| qualities[i] >= QUALITY_FLOOR) + .count(); + + let risk = risk_index(values[TEMP], humidity_pct, values[WIND], values[SOIL]); + let physical_evidence = + values[PM] > PM_CRITICAL_UG_M3 && values[SMOKE] > SMOKE_CRITICAL_INDEX; + let severity = severity_for(risk, physical_evidence); + + run.rounds.push(RiskRound { + hour: round, + risk, + severity, + sensors_used, + excluded: excluded.clone(), + physical_evidence, + pm_ug_m3: values[PM], + smoke_index: values[SMOKE], + }); + + // Escalate only when the severity actually steps up. + if let Some(severity) = severity { + if highest.is_none_or(|h| severity > h) { + highest = Some(severity); + let mut evidence: Vec = [TEMP, RH_A, RH_B, WIND, SOIL] + .into_iter() + .filter(|&i| qualities[i] >= QUALITY_FLOOR) + .map(|i| EvidenceRef { + node_id: nodes[i].node_id, + sequence: sequences[i], + }) + .collect(); + if physical_evidence { + for i in [PM, SMOKE] { + evidence.push(EvidenceRef { + node_id: nodes[i].node_id, + sequence: sequences[i], + }); + } + } + run.events.push(fire_event( + &format!("wildfire:risk:h{round:02}"), + EventKind::WildfireRisk, + severity, + if physical_evidence { + SensorModality::AirQuality + } else { + SensorModality::Weather + }, + nodes[TEMP].geo, + (measured, measured), + evidence, + 0.85_f32.min(0.60 + risk as f32 * 0.4), + format!( + "risk {risk:.2} at hour {round} ({:.1} C, {humidity_pct:.0} % RH, \ + {:.1} km/h, soil {:.1} %); physical evidence: {}; \ + sensors_used={sensors_used}/5, excluded=[{}]", + values[TEMP], + values[WIND], + values[SOIL], + if physical_evidence { + format!("PM {:.0} ug/m3 AND smoke {:.2}", values[PM], values[SMOKE]) + } else { + format!( + "none (PM {:.0} ug/m3, smoke {:.2})", + values[PM], values[SMOKE] + ) + }, + run.excluded_nodes + .iter() + .map(|id| format!("{id:#018x}")) + .collect::>() + .join(", ") + ), + )); + } + } + + // RF context is fused as a capped evidence edge against the optical + // sensor — support or contradiction, never a severity of its own. + if round == IGNITION_ROUND { + let rf = rf_context(measured, 0.99); + let smoke_key = format!("sensor/{}", nodes[SMOKE].node_id); + let plausibility = assess_plausibility(true, measured, &rf, RF_WINDOW_NS); + let _ = fuse_rf_context(&mut run.graph, &smoke_key, &rf, plausibility); + } + } + run +} + +// --------------------------------------------------------------------------- +// Narrative +// --------------------------------------------------------------------------- + +fn main() { + banner( + "WILDFIRE RISK & EARLY DETECTION — ADR-266 wedge #4", + "7 signed spore nodes + RuView RF context; the RF severity cap is the product", + ); + + let run = run_fire_watch(); + + println!(" Fire-watch cluster"); + for node in provision() { + line( + &format!(" {}", node.label), + format!("{} / node {:#018x}", node.modality.as_str(), node.node_id), + ); + } + + println!("\n 1. The drying day, hour by hour"); + for round in &run.rounds { + line( + &format!(" hour {:>2}", round.hour), + format!( + "risk {:.3} {:<9} sensors {}/5 PM {:>5.0} smoke {:.2}{}", + round.risk, + round.severity.map_or("—".to_string(), |s| format!("{s:?}")), + round.sensors_used, + round.pm_ug_m3, + round.smoke_index, + if round.physical_evidence { + " <- physical combustion evidence" + } else { + "" + } + ), + ); + } + + println!("\n 2. Escalation ladder (one event per step up)"); + for event in &run.events { + line( + &format!(" {:?}", event.severity), + format!("{} — {}", event.event_id, event.message), + ); + } + line( + "peak severity reached", + format!("{:?}", run.peak_severity().expect("the day escalates")), + ); + + println!("\n 3. RF-only 'detection' — the cap that makes this wedge sellable"); + let rf = rf_context(round_ns(IGNITION_ROUND), 0.99); + for proposed in [ + Severity::Critical, + Severity::Warning, + Severity::Watch, + Severity::Advisory, + ] { + let event = rf_only_alert(&rf, proposed, provision()[SMOKE].geo); + line( + &format!( + " RF proposes {proposed:?} at confidence {:.2}", + rf.confidence + ), + format!( + "event severity {:?}{}", + event.severity, + if event.severity > Severity::Advisory { + " <- GUARANTEE BROKEN" + } else { + "" + } + ), + ); + } + for edge in run.graph.edges_from("rf/rf-ridge-01") { + line( + &format!(" graph edge {} -> optical sensor", edge.from), + format!( + "{:?} w={:.2} (RF weight cap {RF_MAX_EVIDENCE_WEIGHT:.2})", + edge.kind, edge.weight + ), + ); + } + + println!("\n 4. Physical evidence is what reaches Critical"); + let dust = &run.rounds[DUST_ROUND]; + let ignition = &run.rounds[IGNITION_ROUND]; + line( + &format!(" hour {DUST_ROUND}: PM spike, no smoke"), + format!( + "PM {:.0} ug/m3, smoke {:.2} -> {:?}", + dust.pm_ug_m3, + dust.smoke_index, + dust.severity.expect("a severity is raised") + ), + ); + line( + &format!(" hour {IGNITION_ROUND}: PM + optical smoke"), + format!( + "PM {:.0} ug/m3, smoke {:.2} -> {:?}", + ignition.pm_ug_m3, + ignition.smoke_index, + ignition.severity.expect("a severity is raised") + ), + ); + + println!("\n 5. The sensor that failed in the heat"); + let degradation = run + .degradation + .as_ref() + .expect("the exposed hygrometer fails"); + line( + "event kind / severity", + format!("{:?} / {:?}", degradation.kind, degradation.severity), + ); + line("message", °radation.message); + line( + "reported, not silently dropped", + format!( + "every event after hour {SENSOR_FAILURE_ROUND} names excluded=[{}]", + run.excluded_nodes + .iter() + .map(|id| format!("{id:#018x}")) + .collect::>() + .join(", ") + ), + ); + + synthetic_footer( + "Weather, PM, and smoke values are simulated; the severity cap, the \ + quality gate, and the WorldGraph RF weight cap are the production code.", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rf_only_detection_is_capped_at_advisory() { + let rf = rf_context(round_ns(IGNITION_ROUND), 0.99); + assert_eq!(rf.confidence, 0.99, "the radio is as sure as it can be"); + for proposed in [ + Severity::Critical, + Severity::Warning, + Severity::Watch, + Severity::Advisory, + ] { + let event = rf_only_alert(&rf, proposed, provision()[SMOKE].geo); + assert_eq!( + event.severity, + Severity::Advisory, + "RF-only evidence proposed {proposed:?} and must land at Advisory" + ); + assert!(event.severity <= Severity::Advisory); + event.validate().expect("the capped event is well-formed"); + } + } + + #[test] + fn physical_pm_plus_optical_smoke_reaches_critical() { + let run = run_fire_watch(); + let ignition = &run.rounds[IGNITION_ROUND]; + assert!(ignition.physical_evidence); + assert!(ignition.pm_ug_m3 > PM_CRITICAL_UG_M3); + assert!(ignition.smoke_index > SMOKE_CRITICAL_INDEX); + assert_eq!(ignition.severity, Some(Severity::Critical)); + assert_eq!(run.peak_severity(), Some(Severity::Critical)); + + // The Critical event cites both physical sensors. + let critical = run + .events + .iter() + .find(|e| e.severity == Severity::Critical) + .expect("a Critical event is raised"); + let nodes = provision(); + for idx in [PM, SMOKE] { + assert!( + critical + .evidence + .iter() + .any(|e| e.node_id == nodes[idx].node_id), + "the Critical event must cite the physical evidence" + ); + } + } + + #[test] + fn no_critical_without_physical_corroboration() { + let run = run_fire_watch(); + // A PM spike alone (harvester dust) does not reach Critical, even + // though the environmental risk is already in Warning territory. + let dust = &run.rounds[DUST_ROUND]; + assert!(dust.pm_ug_m3 > PM_CRITICAL_UG_M3, "PM really did spike"); + assert!(dust.smoke_index < SMOKE_CRITICAL_INDEX, "no smoke though"); + assert!(!dust.physical_evidence); + assert_eq!(dust.severity, Some(Severity::Warning)); + + // And no round before ignition ever reaches Critical. + for round in run.rounds.iter().take(IGNITION_ROUND) { + assert!( + round.severity < Some(Severity::Critical), + "hour {}", + round.hour + ); + } + // The severity function itself: environmental risk tops out at Warning. + assert_eq!(severity_for(0.99, false), Some(Severity::Warning)); + assert_eq!(severity_for(0.99, true), Some(Severity::Critical)); + } + + #[test] + fn degraded_sensor_is_excluded_and_its_absence_reported() { + let run = run_fire_watch(); + let rh_b = provision()[RH_B].node_id; + assert_eq!(run.excluded_nodes, vec![rh_b]); + + for round in &run.rounds { + if round.hour < SENSOR_FAILURE_ROUND { + assert_eq!(round.sensors_used, 5, "hour {}", round.hour); + assert!(round.excluded.is_empty()); + } else { + assert_eq!(round.sensors_used, 4, "hour {}", round.hour); + assert_eq!(round.excluded, vec![rh_b]); + } + } + + // The absence is announced as a signed-able event, not swallowed. + let report = run.degradation.expect("degradation reported"); + assert_eq!(report.kind, EventKind::SensorQuarantined); + assert_eq!(report.evidence[0].node_id, rh_b); + assert!(report.message.contains("excluded from the risk index")); + + // The failed sensor's nonsense reading never enters any event. + for event in &run.events { + assert!( + !event.evidence.iter().any(|e| e.node_id == rh_b) + || event.detected_ns < round_ns(SENSOR_FAILURE_ROUND), + "a degraded sensor must not back a post-failure event" + ); + } + } + + #[test] + fn risk_index_is_monotonic_through_the_drying_day() { + let run = run_fire_watch(); + for pair in run.rounds.windows(2) { + assert!( + pair[1].risk > pair[0].risk, + "risk must rise as the fuel dries: hour {} {:.4} -> hour {} {:.4}", + pair[0].hour, + pair[0].risk, + pair[1].hour, + pair[1].risk + ); + } + // Severity is likewise non-decreasing across the raised events. + for pair in run.events.windows(2) { + assert!(pair[1].severity > pair[0].severity); + } + // ...and the day really does traverse the whole ladder. + let severities: Vec = run.events.iter().map(|e| e.severity).collect(); + assert_eq!( + severities, + vec![ + Severity::Advisory, + Severity::Watch, + Severity::Warning, + Severity::Critical + ] + ); + } + + #[test] + fn rf_evidence_weight_is_capped_in_the_worldgraph() { + let run = run_fire_watch(); + let edges = run.graph.edges_from("rf/rf-ridge-01"); + assert_eq!(edges.len(), 1, "the RF context is fused exactly once"); + assert!( + edges[0].weight <= RF_MAX_EVIDENCE_WEIGHT, + "RF evidence weight {} exceeds the cap {RF_MAX_EVIDENCE_WEIGHT}", + edges[0].weight + ); + // Confidence 0.99 was clamped down to the cap, not honoured. + assert_eq!(edges[0].weight, RF_MAX_EVIDENCE_WEIGHT); + } +} diff --git a/examples/src/lib.rs b/examples/src/lib.rs new file mode 100644 index 0000000..3d7ab2e --- /dev/null +++ b/examples/src/lib.rs @@ -0,0 +1,255 @@ +//! # rucelium-examples +//! +//! Worked, runnable applications of the RuCelium fabric (ADR-266) — the +//! practical deployment wedges and the biological research track. +//! +//! Every example is a real end-to-end scenario: synthetic spore nodes sign +//! genuine 48-byte wire records, the gateway pipeline verifies them +//! cryptographically, calibration and drift logic run for real, and the +//! WorldGraph, biome, and governed control path behave exactly as they do in +//! the daemon. **The sensor data is simulated; the machinery is not.** +//! +//! Run them: +//! +//! ```bash +//! cargo run -p rucelium-examples --bin flood-watershed +//! cargo run -p rucelium-examples --bin sentinel-forest +//! cargo test -p rucelium-examples # every scenario is asserted +//! ``` +//! +//! This module holds the small amount of boilerplate every scenario shares: +//! deterministic node provisioning, envelope construction, and a one-call +//! ingest harness. Scenario logic lives in each `src/bin/*.rs`, so the +//! examples read top-to-bottom as the story they tell. + +#![doc(html_root_url = "https://docs.rs/rucelium-examples/0.1.0")] + +use rucelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_SCHEMA_V1}; +use rucelium_core::{GeoPoint, SensorModality}; +use rucelium_ingest::{DeviceRegistry, IngestPipeline, RejectReason, VerifiedEnvSample}; + +/// Nanoseconds per second. +pub const NS_PER_S: u64 = 1_000_000_000; +/// Seconds per day. +pub const S_PER_DAY: u64 = 86_400; +/// A fixed simulated epoch so every example is byte-reproducible. +pub const EPOCH_NS: u64 = 1_750_000_000 * NS_PER_S; +/// Provisioning seed for example device keys (examples only — a real +/// deployment provisions keys in a ceremony, never from a constant). +pub const PROVISION_SEED: &[u8; 32] = b"rucelium-examples-provision-key!"; + +/// SplitMix64 — the deterministic PRNG every RuCelium simulator uses. Same +/// seed, same story, every run. +#[derive(Debug, Clone)] +pub struct Rng(u64); + +impl Rng { + /// Seed the generator. + #[must_use] + pub fn new(seed: u64) -> Self { + Rng(seed) + } + + /// Next raw `u64`. + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform `f64` in `[0, 1)`. + pub fn unit(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + /// Approximately normal noise scaled by `sd`. + pub fn noise(&mut self, sd: f64) -> f64 { + (self.unit() + self.unit() + self.unit() + self.unit() - 2.0) * sd + } +} + +/// A provisioned example sensor node: identity, key, modality, placement. +pub struct Node { + /// Device identity. + pub node_id: u64, + /// What it measures. + pub modality: SensorModality, + /// Where it sits. + pub geo: GeoPoint, + /// Human-readable placement (appears in the WorldGraph). + pub label: String, + /// Firmware measurement implementation hash. + pub firmware_hash: String, + /// Per-device signer. + pub signer: NodeSigner, + /// Next sequence number to emit. + pub sequence: u32, +} + +impl Node { + /// Provision a node deterministically. + #[must_use] + pub fn new(node_id: u64, modality: SensorModality, geo: GeoPoint, label: &str) -> Self { + Node { + node_id, + modality, + geo, + label: label.to_string(), + firmware_hash: format!("sha256:example-fw-{}", modality.as_str()), + signer: NodeSigner::for_node(PROVISION_SEED, node_id), + sequence: 0, + } + } + + /// Build and sign one observation, returning the CBOR envelope bytes + /// exactly as they would leave the radio. + pub fn emit(&mut self, value: f64, measured_ns: u64, calibration_id: u32) -> Vec { + self.emit_with_quality(value, measured_ns, calibration_id, 0.98) + } + + /// As [`Self::emit`], with an explicit quality score (`0.0..=1.0`). + pub fn emit_with_quality( + &mut self, + value: f64, + measured_ns: u64, + calibration_id: u32, + quality: f64, + ) -> Vec { + let seq = self.sequence; + self.sequence = self.sequence.wrapping_add(1); + let wire = RvEnvSampleV1 { + schema_version: RV_ENV_SCHEMA_V1, + sensor_type: self.modality.code(), + flags: 0, + node_id: self.node_id, + timestamp_ns: measured_ns, + sequence: seq, + latitude_e7: self.geo.latitude_e7, + longitude_e7: self.geo.longitude_e7, + altitude_mm: self.geo.altitude_mm, + value_q16: (value * 65_536.0).clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32, + quality_q15: (quality.clamp(0.0, 1.0) * 32_768.0) as u16, + battery_mv: 3_600, + calibration_id, + }; + self.signer.sign_sample(&wire).encode() + } +} + +/// A minimal gateway harness: a device registry plus the real ingest +/// pipeline. Scenarios provision nodes into it and feed it envelopes. +pub struct Gateway { + /// The real ingest pipeline (signatures, revocation, anti-replay). + pub ingest: IngestPipeline, +} + +impl Gateway { + /// Build a gateway with the given nodes provisioned. + #[must_use] + pub fn with_nodes(nodes: &[Node]) -> Self { + let mut registry = DeviceRegistry::new(); + for n in nodes { + registry.register(n.node_id, n.signer.public_key(), n.firmware_hash.clone()); + } + Gateway { + ingest: IngestPipeline::new(registry), + } + } + + /// Ingest one envelope. The returned sample is *sealed*: it can only + /// exist because every cryptographic check passed. + pub fn ingest( + &mut self, + envelope: &[u8], + received_ns: u64, + ) -> Result { + self.ingest.ingest(envelope, received_ns) + } +} + +/// Render a labelled scenario banner (examples print a readable narrative, +/// not a wall of JSON). +pub fn banner(title: &str, subtitle: &str) { + println!("\n{}", "=".repeat(78)); + println!(" {title}"); + println!(" {subtitle}"); + println!("{}\n", "=".repeat(78)); +} + +/// Print a `key: value` line in the examples' consistent column layout. +pub fn line(key: &str, value: impl std::fmt::Display) { + println!(" {key:<44} {value}"); +} + +/// Print the SYNTHETIC honesty footer every example ends with. +pub fn synthetic_footer(extra: &str) { + println!("\n ---"); + println!(" SYNTHETIC: sensor values are simulated; the verification,"); + println!(" calibration, graph, and governance machinery is the real"); + println!(" production code. {extra}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node() -> Node { + Node::new( + 0x00E0_0000_0000_0001, + SensorModality::WaterQuality, + GeoPoint::new(515_000_000, -1_000_000, 25_000).unwrap(), + "test gauge", + ) + } + + #[test] + fn emitted_envelope_ingests_and_is_sealed() { + let mut n = node(); + let mut gw = Gateway::with_nodes(std::slice::from_ref(&n)); + let env = n.emit(1.25, EPOCH_NS, 0); + let sealed = gw.ingest(&env, EPOCH_NS + 1_000_000).unwrap(); + let s = sealed.sample(); + s.validate().unwrap(); + assert_eq!(s.node_id, n.node_id); + assert!((s.value - 1.25).abs() < 1e-4); + assert!(s.provenance.verified); + } + + #[test] + fn sequences_advance_and_replays_are_rejected() { + let mut n = node(); + let mut gw = Gateway::with_nodes(std::slice::from_ref(&n)); + let a = n.emit(1.0, EPOCH_NS, 0); + let b = n.emit(1.1, EPOCH_NS + NS_PER_S, 0); + // Reception always follows measurement (the domain model rejects an + // inverted pair — a real property, not test scaffolding). + assert!(gw.ingest(&a, EPOCH_NS + NS_PER_S).is_ok()); + assert!(gw.ingest(&b, EPOCH_NS + 2 * NS_PER_S).is_ok()); + // Exact replay of the first envelope. + assert!(matches!( + gw.ingest(&a, EPOCH_NS + 3 * NS_PER_S), + Err(RejectReason::Replay { .. }) + )); + } + + #[test] + fn tampered_envelope_never_ingests() { + let mut n = node(); + let mut gw = Gateway::with_nodes(std::slice::from_ref(&n)); + let mut env = n.emit(1.0, EPOCH_NS, 0); + let mid = env.len() / 2; + env[mid] ^= 0x01; + assert!(gw.ingest(&env, EPOCH_NS + NS_PER_S).is_err()); + } + + #[test] + fn rng_is_deterministic() { + let mut a = Rng::new(7); + let mut b = Rng::new(7); + for _ in 0..64 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } +} diff --git a/harness/README.md b/harness/README.md new file mode 100644 index 0000000..80fe5dd --- /dev/null +++ b/harness/README.md @@ -0,0 +1,96 @@ +# rucelium-harness + +The **RuCelium metaharness** — a Darwinian flywheel that drives the RuCelium +Rust implementation toward the [ADR-264](../docs/ADR-264-rucelium-federated-fabric.md) +§14 acceptance gate. + +It is a *meta*harness: it adds no new truth of its own. Every score is derived +from the same commands CI runs — `cargo test --workspace`, `cargo clippy`, +and the deterministic 64-node biome benchmark (`rucelium-bench`). What it adds +is the **loop**: + +```text + ┌────────────────────────────────────────────────┐ + │ │ + VARY │ you / an agent swarm change the Rust code │ + ▼ │ + EVALUATE rucelium fitness │ RETAIN + │ tests 45 + clippy 15 + §14 accept 30 │ survivors live in + ▼ + data quality 10 = fitness 0..100 │ .rucelium/ledger.json + SELECT rucelium evolve -m "what changed" │ (commit what survives) + │ fitness < parent → REJECTED (dies) │ + │ fitness >= parent → generation recorded ────┘ + ▼ + rucelium gate — the hard ADR-264 §14 acceptance gate (exit code) +``` + +Regressions don't get recorded — a change that lowers fitness is simply not +selected (override deliberately with `--allow-regression`, which records the +regression honestly rather than hiding it). Over iterations the ledger is the +lineage of surviving implementations: a fitness curve for the whole +implementation process, useful to humans and agent swarms alike. + +## Usage + +No install needed inside the repo: + +```bash +node harness/bin/rucelium.js doctor # environment sanity check +node harness/bin/rucelium.js fitness # score the working tree +node harness/bin/rucelium.js evolve -m "tighten replay window" +node harness/bin/rucelium.js ledger # show the generation lineage +node harness/bin/rucelium.js gate # strict acceptance gate (CI-friendly) +``` + +Or via npm from `harness/`: + +```bash +cd harness +npm run fitness +npm run gate +``` + +Flags: `--fast` skips clippy for quick inner-loop iterations (clippy score is +carried, not awarded); `--allow-regression` records a fitness regression +instead of rejecting it. + +## Fitness function + +| Component | Weight | Source | +|------------|-------:|--------| +| tests | 45 | fraction of workspace tests passing (0 if the build fails) | +| clippy | 15 | `15 − warnings`, floored at 0 | +| acceptance | 30 | ADR-264 §14 criteria passed (of 8) from `rucelium-bench --json` | +| quality | 10 | usable-calibrated-observation % from the biome benchmark, scaled from the 90–100 % band | + +`rucelium gate` is stricter than fitness: it requires **all** tests green, +**zero** clippy warnings, and **8/8** §14 criteria — the same bar a real +64-node pilot would have to clear (on synthetic data; see the honesty notes +in the ADR). + +## Prior art and convergence + +This harness and [`agentic-flow`](https://github.com/ruvnet/agentic-flow) +arrived at the same principle independently — **freeze the model, evolve the +harness**, and select on measured fitness rather than tuning the model. Credit +where it is due: `agentic-flow` states that principle explicitly and +implements it far more broadly (trajectory rewards, adaptive routing, +retrieve → judge → distill → consolidate). + +`rucelium-harness` stays narrow on purpose: its fitness function is +domain-specific (workspace tests + clippy + the ADR-264 §14 acceptance +benchmark) and it must run with **zero network** inside CI. If `agentic-flow` +exposes a pluggable fitness interface, this should become a fitness provider +for it rather than a parallel loop — see +[ADR-268](../docs/ADR-268-rucelium-ruvnet-stack-integration.md) §2.4. + +## Using it from an agent swarm + +The flywheel is designed to be driven by coding agents: after each edit +batch, run `evolve` with a one-line description; parse the JSON verdict. A +`"rejected"` verdict means the change must be fixed or abandoned before +continuing. The ledger gives the swarm (and its human) a shared, append-only +memory of which implementation lineages survived selection and why. + +The ledger lives in `.rucelium/ledger.json` (gitignored by default — commit +it if you want the lineage shared across machines). diff --git a/harness/bin/rucelium.js b/harness/bin/rucelium.js new file mode 100755 index 0000000..527a4cf --- /dev/null +++ b/harness/bin/rucelium.js @@ -0,0 +1,368 @@ +#!/usr/bin/env node +/** + * rucelium — the RuCelium metaharness. + * + * A Darwinian flywheel for the implementation process: + * + * VARY you (or an agent swarm) change the Rust implementation + * EVALUATE `rucelium fitness` scores the change against the repo's + * own selection pressure: workspace tests, clippy, and the + * ADR-264 §14 biome acceptance benchmark + * SELECT `rucelium evolve` compares fitness with the ledger head and + * only records non-regressing generations (survivors) + * RETAIN the generation ledger (.rucelium/ledger.json) keeps the + * lineage of surviving implementations; commit what survives + * + * Zero dependencies. Node >= 18. All scoring is derived from the same + * commands CI runs — the harness adds the loop, not new truth. + * + * Commands: + * rucelium fitness [--fast] score the working tree, print breakdown + * rucelium evolve -m "msg" score + select against the ledger head + * rucelium ledger show the generation lineage + * rucelium gate strict ADR-264 acceptance gate (exit code) + * rucelium doctor environment sanity check + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Repo + ledger plumbing +// --------------------------------------------------------------------------- + +function findRepoRoot() { + let dir = resolve(__dirname); + for (let i = 0; i < 8; i++) { + if (existsSync(join(dir, "Cargo.toml")) && existsSync(join(dir, "crates"))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + // Fall back to cwd (running via npx from the repo). + let cwd = process.cwd(); + for (let i = 0; i < 8; i++) { + if (existsSync(join(cwd, "Cargo.toml")) && existsSync(join(cwd, "crates"))) { + return cwd; + } + const parent = dirname(cwd); + if (parent === cwd) break; + cwd = parent; + } + fail("could not locate the rufield workspace root (Cargo.toml + crates/)"); +} + +const ROOT = findRepoRoot(); +const LEDGER_DIR = join(ROOT, ".rucelium"); +const LEDGER = join(LEDGER_DIR, "ledger.json"); + +function sh(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + timeout: opts.timeoutMs ?? 30 * 60 * 1000, + env: { ...process.env, CARGO_TERM_COLOR: "never" }, + }); + return { + ok: r.status === 0, + status: r.status, + out: (r.stdout ?? "") + (r.stderr ?? ""), + stdout: r.stdout ?? "", + }; +} + +function readLedger() { + if (!existsSync(LEDGER)) return { generations: [] }; + try { + return JSON.parse(readFileSync(LEDGER, "utf8")); + } catch { + fail(`ledger at ${LEDGER} is corrupt — fix or delete it`); + } +} + +function writeLedger(ledger) { + mkdirSync(LEDGER_DIR, { recursive: true }); + writeFileSync(LEDGER, JSON.stringify(ledger, null, 2) + "\n"); +} + +function gitInfo() { + const sha = sh("git", ["rev-parse", "--short", "HEAD"]); + const dirty = sh("git", ["status", "--porcelain"]); + return { + sha: sha.ok ? sha.stdout.trim() : "unknown", + dirty: dirty.ok ? dirty.stdout.trim().length > 0 : true, + }; +} + +function fail(msg) { + console.error(`rucelium: ${msg}`); + process.exit(2); +} + +// --------------------------------------------------------------------------- +// Fitness — the selection pressure +// --------------------------------------------------------------------------- + +/** + * Fitness is 0..100, from the same signals CI enforces: + * tests 45 fraction of workspace tests passing (0 on build failure) + * clippy 15 15 - warnings, floored at 0 + * accept 30 ADR-264 §14 criteria passed (of 8) + * quality 10 usable-calibrated % from the biome benchmark (scaled) + */ +function computeFitness({ fast = false } = {}) { + const t0 = Date.now(); + const metrics = { + tests_passed: 0, + tests_failed: 0, + build_ok: false, + clippy_warnings: null, + bench_criteria_passed: 0, + bench_criteria_total: 8, + usable_calibrated_pct: 0, + attacks_rejected: null, + attacks_injected: null, + bench_ok: false, + }; + + // 1. Tests. + process.stderr.write("→ cargo test --workspace\n"); + const test = sh("cargo", ["test", "--workspace"]); + metrics.build_ok = !/error\[|error: could not compile/.test(test.out); + for (const m of test.out.matchAll( + /test result: (ok|FAILED)\. (\d+) passed; (\d+) failed;/g + )) { + metrics.tests_passed += Number(m[2]); + metrics.tests_failed += Number(m[3]); + } + + // 2. Clippy (skipped with --fast). + if (!fast) { + process.stderr.write("→ cargo clippy --workspace --all-targets\n"); + const clippy = sh("cargo", ["clippy", "--workspace", "--all-targets"]); + const warnings = [...clippy.out.matchAll(/^warning: /gm)].length; + metrics.clippy_warnings = clippy.ok ? warnings : 99; + } + + // 3. The ADR-264 §14 biome acceptance benchmark. + process.stderr.write("→ cargo run -p rucelium-bench -- 2026 --json\n"); + const bench = sh("cargo", ["run", "-q", "-p", "rucelium-bench", "--", "2026", "--json"]); + try { + const report = JSON.parse(bench.stdout.trim()); + metrics.bench_ok = true; + metrics.bench_criteria_total = report.criteria.length; + metrics.bench_criteria_passed = report.criteria.filter((c) => c.pass).length; + metrics.usable_calibrated_pct = report.usable_calibrated_pct; + metrics.attacks_rejected = report.attacks_rejected; + metrics.attacks_injected = report.attacks_injected; + } catch { + metrics.bench_ok = false; + } + + const total_tests = metrics.tests_passed + metrics.tests_failed; + const testScore = + metrics.build_ok && total_tests > 0 + ? (metrics.tests_passed / total_tests) * 45 + : 0; + const clippyScore = + metrics.clippy_warnings === null + ? 15 // --fast: don't punish, don't reward — carry full marks forward + : Math.max(0, 15 - metrics.clippy_warnings); + const acceptScore = metrics.bench_ok + ? (metrics.bench_criteria_passed / metrics.bench_criteria_total) * 30 + : 0; + const qualityScore = metrics.bench_ok + ? Math.max(0, (metrics.usable_calibrated_pct - 90) / 10) * 10 + : 0; + + const fitness = + Math.round((testScore + clippyScore + acceptScore + Math.min(10, qualityScore)) * 100) / + 100; + + return { + fitness, + components: { + tests: Math.round(testScore * 100) / 100, + clippy: Math.round(clippyScore * 100) / 100, + acceptance: Math.round(acceptScore * 100) / 100, + quality: Math.round(Math.min(10, qualityScore) * 100) / 100, + }, + metrics, + elapsed_s: Math.round((Date.now() - t0) / 100) / 10, + }; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +function cmdFitness(args) { + const result = computeFitness({ fast: args.includes("--fast") }); + console.log(JSON.stringify(result, null, 2)); + process.exit(result.fitness >= 99.99 ? 0 : 1); +} + +function cmdEvolve(args) { + const mIdx = args.findIndex((a) => a === "-m" || a === "--message"); + const message = mIdx >= 0 ? args[mIdx + 1] : "(no message)"; + const allowRegression = args.includes("--allow-regression"); + const fast = args.includes("--fast"); + + const result = computeFitness({ fast }); + const ledger = readLedger(); + const head = ledger.generations.at(-1); + const parentFitness = head ? head.fitness : null; + const git = gitInfo(); + + let verdict; + if (parentFitness === null) { + verdict = "founder"; + } else if (result.fitness > parentFitness) { + verdict = "selected"; + } else if (result.fitness === parentFitness) { + verdict = "neutral-selected"; + } else if (allowRegression) { + verdict = "regression-allowed"; + } else { + verdict = "rejected"; + } + + const gen = { + gen: (head?.gen ?? 0) + 1, + at: new Date().toISOString(), + git_sha: git.sha, + dirty: git.dirty, + message, + fitness: result.fitness, + components: result.components, + metrics: result.metrics, + parent_fitness: parentFitness, + verdict, + elapsed_s: result.elapsed_s, + }; + + if (verdict !== "rejected") { + ledger.generations.push(gen); + writeLedger(ledger); + } + + const arrow = + parentFitness === null + ? "" + : ` (${parentFitness} → ${result.fitness})`; + console.log( + JSON.stringify( + { verdict, gen: gen.gen, fitness: result.fitness, components: result.components }, + null, + 2 + ) + ); + if (verdict === "rejected") { + console.error( + `rucelium: REJECTED — fitness regressed${arrow}. The change does not survive selection.\n` + + `Fix the regression, or record it deliberately with --allow-regression.` + ); + process.exit(1); + } + console.error(`rucelium: generation ${gen.gen} ${verdict}${arrow} — retained in ${LEDGER}`); +} + +function cmdLedger() { + const ledger = readLedger(); + if (ledger.generations.length === 0) { + console.log("ledger empty — run `rucelium evolve -m \"founding generation\"`"); + return; + } + const rows = ledger.generations.map((g) => + [ + String(g.gen).padStart(4), + g.at, + g.git_sha.padEnd(9), + g.dirty ? "dirty" : "clean", + String(g.fitness).padStart(7), + g.verdict.padEnd(18), + g.message, + ].join(" ") + ); + console.log( + [" gen timestamp sha tree fitness verdict message"] + .concat(rows) + .join("\n") + ); +} + +function cmdGate() { + // The strict acceptance gate: tests green, clippy silent, all §14 + // criteria pass. Exit 0 only when the implementation fully survives. + const result = computeFitness({}); + const pass = + result.metrics.tests_failed === 0 && + result.metrics.tests_passed > 0 && + result.metrics.build_ok && + (result.metrics.clippy_warnings ?? 99) === 0 && + result.metrics.bench_ok && + result.metrics.bench_criteria_passed === result.metrics.bench_criteria_total; + console.log(JSON.stringify({ gate: pass ? "PASS" : "FAIL", ...result }, null, 2)); + process.exit(pass ? 0 : 1); +} + +function cmdDoctor() { + const checks = []; + const node = process.versions.node.split(".").map(Number); + checks.push({ check: "node >= 18", ok: node[0] >= 18, detail: process.versions.node }); + const cargo = sh("cargo", ["--version"], { timeoutMs: 30_000 }); + checks.push({ check: "cargo available", ok: cargo.ok, detail: cargo.stdout.trim() }); + const git = sh("git", ["--version"], { timeoutMs: 30_000 }); + checks.push({ check: "git available", ok: git.ok, detail: git.stdout.trim() }); + checks.push({ check: "workspace root", ok: true, detail: ROOT }); + checks.push({ + check: "rucelium-bench present", + ok: existsSync(join(ROOT, "crates", "rucelium-bench", "Cargo.toml")), + detail: "crates/rucelium-bench", + }); + console.log(JSON.stringify(checks, null, 2)); + process.exit(checks.every((c) => c.ok) ? 0 : 1); +} + +// --------------------------------------------------------------------------- + +const [cmd, ...rest] = process.argv.slice(2); +switch (cmd) { + case "fitness": + cmdFitness(rest); + break; + case "evolve": + cmdEvolve(rest); + break; + case "ledger": + cmdLedger(); + break; + case "gate": + cmdGate(); + break; + case "doctor": + cmdDoctor(); + break; + default: + console.log( + [ + "rucelium — RuCelium metaharness (Darwin flywheel)", + "", + "usage:", + " rucelium fitness [--fast] score the working tree (tests/clippy/§14 bench)", + ' rucelium evolve -m "msg" [--fast] [--allow-regression]', + " score + select vs the ledger head, retain survivors", + " rucelium ledger show the generation lineage", + " rucelium gate strict ADR-264 §14 acceptance gate (exit code)", + " rucelium doctor environment sanity check", + ].join("\n") + ); + process.exit(cmd ? 2 : 0); +} diff --git a/harness/package.json b/harness/package.json new file mode 100644 index 0000000..2c32caa --- /dev/null +++ b/harness/package.json @@ -0,0 +1,38 @@ +{ + "name": "rucelium-harness", + "version": "0.1.0", + "description": "RuCelium metaharness — a Darwinian evolve/evaluate/select flywheel that drives the RuCelium Rust implementation toward the ADR-264 acceptance gate.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ruvnet/rufield.git", + "directory": "harness" + }, + "author": "rUv ", + "type": "module", + "bin": { + "rucelium": "./bin/rucelium.js" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "bin/", + "README.md" + ], + "scripts": { + "fitness": "node bin/rucelium.js fitness", + "evolve": "node bin/rucelium.js evolve", + "gate": "node bin/rucelium.js gate", + "doctor": "node bin/rucelium.js doctor" + }, + "keywords": [ + "rucelium", + "rufield", + "metaharness", + "darwin", + "flywheel", + "environmental-sensing", + "rust" + ] +}