From 806aac16d5cb447127350d21eaf448efdedfcb76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:17:19 +0000 Subject: [PATCH 01/27] feat(rumycelium): ADR-264 + core domain model + versioned C ABI boundary - docs/ADR-264-rumycelium-federated-fabric.md: federated environmental intelligence fabric spec (four layers, data economics, governance, acceptance criteria) - rumycelium-core: EnvSample (twelve mandatory attributes), EnvFrame, CalibrationRecord (Q16.16 lineage-chained), EnvironmentalEvent, SensorModality registry, GeoPoint with exact privacy coarsening, three-tier DataClass residency model - rumycelium-abi: rv_env_sample_v1 packed LE wire format with bounds-checked allocation-free parse (no unsafe), deterministic CBOR (canonical heads enforced on decode), COSE-inspired signed envelope, ed25519 device signing, shipped C header (rumycelium_env.h) - workspace scaffolding for ingest/calibration/worldgraph/policy/ federation/bench crates Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.lock | 84 ++++ Cargo.toml | 16 + crates/rumycelium-abi/Cargo.toml | 18 + .../rumycelium-abi/include/rumycelium_env.h | 95 ++++ crates/rumycelium-abi/src/cbor.rs | 467 ++++++++++++++++++ crates/rumycelium-abi/src/lib.rs | 31 ++ crates/rumycelium-abi/src/sign.rs | 180 +++++++ crates/rumycelium-abi/src/wire.rs | 383 ++++++++++++++ crates/rumycelium-bench/Cargo.toml | 29 ++ crates/rumycelium-bench/src/lib.rs | 1 + crates/rumycelium-bench/src/main.rs | 1 + crates/rumycelium-calibration/Cargo.toml | 18 + crates/rumycelium-calibration/src/lib.rs | 1 + crates/rumycelium-core/Cargo.toml | 17 + crates/rumycelium-core/src/calibration.rs | 161 ++++++ crates/rumycelium-core/src/error.rs | 70 +++ crates/rumycelium-core/src/event.rs | 180 +++++++ crates/rumycelium-core/src/geo.rs | 128 +++++ crates/rumycelium-core/src/lib.rs | 32 ++ crates/rumycelium-core/src/modality.rs | 196 ++++++++ crates/rumycelium-core/src/sample.rs | 260 ++++++++++ crates/rumycelium-federation/Cargo.toml | 20 + crates/rumycelium-federation/src/lib.rs | 1 + crates/rumycelium-ingest/Cargo.toml | 19 + crates/rumycelium-ingest/src/lib.rs | 1 + crates/rumycelium-policy/Cargo.toml | 20 + crates/rumycelium-policy/src/lib.rs | 1 + crates/rumycelium-worldgraph/Cargo.toml | 19 + crates/rumycelium-worldgraph/src/lib.rs | 1 + docs/ADR-264-rumycelium-federated-fabric.md | 443 +++++++++++++++++ 30 files changed, 2893 insertions(+) create mode 100644 crates/rumycelium-abi/Cargo.toml create mode 100644 crates/rumycelium-abi/include/rumycelium_env.h create mode 100644 crates/rumycelium-abi/src/cbor.rs create mode 100644 crates/rumycelium-abi/src/lib.rs create mode 100644 crates/rumycelium-abi/src/sign.rs create mode 100644 crates/rumycelium-abi/src/wire.rs create mode 100644 crates/rumycelium-bench/Cargo.toml create mode 100644 crates/rumycelium-bench/src/lib.rs create mode 100644 crates/rumycelium-bench/src/main.rs create mode 100644 crates/rumycelium-calibration/Cargo.toml create mode 100644 crates/rumycelium-calibration/src/lib.rs create mode 100644 crates/rumycelium-core/Cargo.toml create mode 100644 crates/rumycelium-core/src/calibration.rs create mode 100644 crates/rumycelium-core/src/error.rs create mode 100644 crates/rumycelium-core/src/event.rs create mode 100644 crates/rumycelium-core/src/geo.rs create mode 100644 crates/rumycelium-core/src/lib.rs create mode 100644 crates/rumycelium-core/src/modality.rs create mode 100644 crates/rumycelium-core/src/sample.rs create mode 100644 crates/rumycelium-federation/Cargo.toml create mode 100644 crates/rumycelium-federation/src/lib.rs create mode 100644 crates/rumycelium-ingest/Cargo.toml create mode 100644 crates/rumycelium-ingest/src/lib.rs create mode 100644 crates/rumycelium-policy/Cargo.toml create mode 100644 crates/rumycelium-policy/src/lib.rs create mode 100644 crates/rumycelium-worldgraph/Cargo.toml create mode 100644 crates/rumycelium-worldgraph/src/lib.rs create mode 100644 docs/ADR-264-rumycelium-federated-fabric.md diff --git a/Cargo.lock b/Cargo.lock index 6b74720..4e191e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,90 @@ dependencies = [ "tower", ] +[[package]] +name = "rumycelium-abi" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rumycelium-core", + "sha2", +] + +[[package]] +name = "rumycelium-bench" +version = "0.1.0" +dependencies = [ + "rufield-core", + "rumycelium-abi", + "rumycelium-calibration", + "rumycelium-core", + "rumycelium-federation", + "rumycelium-ingest", + "rumycelium-policy", + "rumycelium-worldgraph", + "serde", + "serde_json", +] + +[[package]] +name = "rumycelium-calibration" +version = "0.1.0" +dependencies = [ + "rumycelium-core", + "serde", + "serde_json", +] + +[[package]] +name = "rumycelium-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "rumycelium-federation" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rumycelium-core", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "rumycelium-ingest" +version = "0.1.0" +dependencies = [ + "rumycelium-abi", + "rumycelium-core", + "serde", + "serde_json", +] + +[[package]] +name = "rumycelium-policy" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rumycelium-core", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "rumycelium-worldgraph" +version = "0.1.0" +dependencies = [ + "rufield-core", + "rumycelium-core", + "serde", + "serde_json", +] + [[package]] name = "rustc-hash" version = "2.1.2" diff --git a/Cargo.toml b/Cargo.toml index 9de60d8..1a7cb95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,14 @@ members = [ "crates/rufield-fusion", "crates/rufield-bench", "crates/rufield-viewer", + "crates/rumycelium-core", + "crates/rumycelium-abi", + "crates/rumycelium-ingest", + "crates/rumycelium-calibration", + "crates/rumycelium-worldgraph", + "crates/rumycelium-policy", + "crates/rumycelium-federation", + "crates/rumycelium-bench", ] [workspace.package] @@ -35,6 +43,14 @@ 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" } +rumycelium-core = { version = "0.1.0", path = "crates/rumycelium-core" } +rumycelium-abi = { version = "0.1.0", path = "crates/rumycelium-abi" } +rumycelium-ingest = { version = "0.1.0", path = "crates/rumycelium-ingest" } +rumycelium-calibration = { version = "0.1.0", path = "crates/rumycelium-calibration" } +rumycelium-worldgraph = { version = "0.1.0", path = "crates/rumycelium-worldgraph" } +rumycelium-policy = { version = "0.1.0", path = "crates/rumycelium-policy" } +rumycelium-federation = { version = "0.1.0", path = "crates/rumycelium-federation" } +rumycelium-bench = { version = "0.1.0", path = "crates/rumycelium-bench" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/rumycelium-abi/Cargo.toml b/crates/rumycelium-abi/Cargo.toml new file mode 100644 index 0000000..85e439d --- /dev/null +++ b/crates/rumycelium-abi/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rumycelium-abi" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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"] + +[dependencies] +rumycelium-core = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rumycelium-abi/include/rumycelium_env.h b/crates/rumycelium-abi/include/rumycelium_env.h new file mode 100644 index 0000000..12ce4ca --- /dev/null +++ b/crates/rumycelium-abi/include/rumycelium_env.h @@ -0,0 +1,95 @@ +/* + * rumycelium_env.h — RuMycelium 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 rumycelium-abi::cbor). + */ + +#ifndef RUMYCELIUM_ENV_H +#define RUMYCELIUM_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 rumycelium_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 /* RUMYCELIUM_ENV_H */ diff --git a/crates/rumycelium-abi/src/cbor.rs b/crates/rumycelium-abi/src/cbor.rs new file mode 100644 index 0000000..16a0cdc --- /dev/null +++ b/crates/rumycelium-abi/src/cbor.rs @@ -0,0 +1,467 @@ +//! 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}; +use std::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"), + } + } +} + +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/rumycelium-abi/src/lib.rs b/crates/rumycelium-abi/src/lib.rs new file mode 100644 index 0000000..1bddd7b --- /dev/null +++ b/crates/rumycelium-abi/src/lib.rs @@ -0,0 +1,31 @@ +//! # rumycelium-abi +//! +//! The versioned C ABI boundary of the RuMycelium 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/rumycelium_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 `rumycelium-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/rumycelium_env.h`]: +//! https://github.com/ruvnet/rufield/blob/main/crates/rumycelium-abi/include/rumycelium_env.h + +#![doc(html_root_url = "https://docs.rs/rumycelium-abi/0.1.0")] + +pub mod cbor; +pub mod sign; +pub mod wire; + +pub use cbor::{CborError, SignedEnvRecordV1}; +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/rumycelium-abi/src/sign.rs b/crates/rumycelium-abi/src/sign.rs new file mode 100644 index 0000000..b3b2108 --- /dev/null +++ b/crates/rumycelium-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 (`rumycelium-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"rumycelium-provision-seed-32-by!", 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"rumycelium-provision-seed-32-by!", 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"rumycelium-provision-seed-32-by!", 11); + let b = NodeSigner::for_node(b"rumycelium-provision-seed-32-by!", 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"rumycelium-provision-seed-32-by!"; + 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/rumycelium-abi/src/wire.rs b/crates/rumycelium-abi/src/wire.rs new file mode 100644 index 0000000..6aca075 --- /dev/null +++ b/crates/rumycelium-abi/src/wire.rs @@ -0,0 +1,383 @@ +//! `rv_env_sample_v1`: packed little-endian wire struct, bounds-checked +//! allocation-free parse, field validation, and domain conversion +//! (ADR-264 §11.1). + +use rumycelium_core::geo::{LAT_E7_MAX, LON_E7_MAX}; +use rumycelium_core::{ + EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty, +}; +use std::fmt; + +/// 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. + 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"), + AbiError::Domain(m) => write!(f, "domain validation failed: {m}"), + } + } +} + +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 `rumycelium_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 SensorModality::from_code(self.sensor_type).is_none() { + 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. + #[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`]. The uncertainty starts at the Q16.16 quantization + /// half-step; `rumycelium-calibration` widens it with the calibration's + /// stated uncertainty. Provenance identity comes from the verified wire + /// envelope, supplied by the ingest pipeline. + 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 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/rumycelium-bench/Cargo.toml b/crates/rumycelium-bench/Cargo.toml new file mode 100644 index 0000000..4ce9d96 --- /dev/null +++ b/crates/rumycelium-bench/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rumycelium-bench" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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 = "rumycelium-bench" +path = "src/main.rs" + +[dependencies] +rumycelium-core = { workspace = true } +rumycelium-abi = { workspace = true } +rumycelium-ingest = { workspace = true } +rumycelium-calibration = { workspace = true } +rumycelium-worldgraph = { workspace = true } +rumycelium-policy = { workspace = true } +rumycelium-federation = { workspace = true } +rufield-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rumycelium-bench/src/lib.rs b/crates/rumycelium-bench/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rumycelium-bench/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/rumycelium-bench/src/main.rs b/crates/rumycelium-bench/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/rumycelium-bench/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/rumycelium-calibration/Cargo.toml b/crates/rumycelium-calibration/Cargo.toml new file mode 100644 index 0000000..7e6d640 --- /dev/null +++ b/crates/rumycelium-calibration/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rumycelium-calibration" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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] +rumycelium-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rumycelium-calibration/src/lib.rs b/crates/rumycelium-calibration/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rumycelium-calibration/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/rumycelium-core/Cargo.toml b/crates/rumycelium-core/Cargo.toml new file mode 100644 index 0000000..ac7ef7f --- /dev/null +++ b/crates/rumycelium-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rumycelium-core" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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 } + +[lints] +workspace = true diff --git a/crates/rumycelium-core/src/calibration.rs b/crates/rumycelium-core/src/calibration.rs new file mode 100644 index 0000000..56eadbe --- /dev/null +++ b/crates/rumycelium-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 +/// `rumycelium-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/rumycelium-core/src/error.rs b/crates/rumycelium-core/src/error.rs new file mode 100644 index 0000000..5557622 --- /dev/null +++ b/crates/rumycelium-core/src/error.rs @@ -0,0 +1,70 @@ +//! Core error type for RuMycelium 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/rumycelium-core/src/event.rs b/crates/rumycelium-core/src/event.rs new file mode 100644 index 0000000..4a06318 --- /dev/null +++ b/crates/rumycelium-core/src/event.rs @@ -0,0 +1,180 @@ +//! `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 `rumycelium-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, +} + +/// 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. + pub evidence: Vec, + /// 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, + 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")))); + } +} diff --git a/crates/rumycelium-core/src/geo.rs b/crates/rumycelium-core/src/geo.rs new file mode 100644 index 0000000..249edcf --- /dev/null +++ b/crates/rumycelium-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/rumycelium-core/src/lib.rs b/crates/rumycelium-core/src/lib.rs new file mode 100644 index 0000000..fa499e9 --- /dev/null +++ b/crates/rumycelium-core/src/lib.rs @@ -0,0 +1,32 @@ +//! # rumycelium-core +//! +//! Core data model for **RuMycelium** — 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 (`rumycelium-bench`) — nothing here claims field-validated +//! accuracy. + +#![doc(html_root_url = "https://docs.rs/rumycelium-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::{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 RuMycelium fabric (ADR-264). +pub const SPEC_VERSION: &str = "rumycelium.fabric.v0.1"; diff --git a/crates/rumycelium-core/src/modality.rs b/crates/rumycelium-core/src/modality.rs new file mode 100644 index 0000000..8ae4548 --- /dev/null +++ b/crates/rumycelium-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/rumycelium-core/src/sample.rs b/crates/rumycelium-core/src/sample.rs new file mode 100644 index 0000000..2931ea0 --- /dev/null +++ b/crates/rumycelium-core/src/sample.rs @@ -0,0 +1,260 @@ +//! `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 (`rumycelium-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 `rumycelium-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)); + } +} diff --git a/crates/rumycelium-federation/Cargo.toml b/crates/rumycelium-federation/Cargo.toml new file mode 100644 index 0000000..d3ef79b --- /dev/null +++ b/crates/rumycelium-federation/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rumycelium-federation" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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] +rumycelium-core = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rumycelium-federation/src/lib.rs b/crates/rumycelium-federation/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rumycelium-federation/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/rumycelium-ingest/Cargo.toml b/crates/rumycelium-ingest/Cargo.toml new file mode 100644 index 0000000..f33f6b0 --- /dev/null +++ b/crates/rumycelium-ingest/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rumycelium-ingest" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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] +rumycelium-core = { workspace = true } +rumycelium-abi = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rumycelium-ingest/src/lib.rs b/crates/rumycelium-ingest/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rumycelium-ingest/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/rumycelium-policy/Cargo.toml b/crates/rumycelium-policy/Cargo.toml new file mode 100644 index 0000000..437c04b --- /dev/null +++ b/crates/rumycelium-policy/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rumycelium-policy" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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] +rumycelium-core = { workspace = true } +ed25519-dalek = { workspace = true } +sha2 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rumycelium-policy/src/lib.rs b/crates/rumycelium-policy/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rumycelium-policy/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/rumycelium-worldgraph/Cargo.toml b/crates/rumycelium-worldgraph/Cargo.toml new file mode 100644 index 0000000..5ec0fc6 --- /dev/null +++ b/crates/rumycelium-worldgraph/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rumycelium-worldgraph" +version.workspace = true +edition.workspace = true +description = "RuMycelium 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] +rumycelium-core = { workspace = true } +rufield-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rumycelium-worldgraph/src/lib.rs b/crates/rumycelium-worldgraph/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rumycelium-worldgraph/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/docs/ADR-264-rumycelium-federated-fabric.md b/docs/ADR-264-rumycelium-federated-fabric.md new file mode 100644 index 0000000..09a39ed --- /dev/null +++ b/docs/ADR-264-rumycelium-federated-fabric.md @@ -0,0 +1,443 @@ +# ADR 264: RuMycelium 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 **RuMycelium**, 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 | +|---|---|---| +| `rumycelium-core` | `rumycelium-core` | domain model: `EnvSample`, `EnvFrame`, `CalibrationRecord`, `EnvironmentalEvent`, `SensorModality`, `GeoPoint`, `DataClass` | +| `rumycelium-c-ffi` | `rumycelium-abi` | versioned C ABI (`rv_env_sample_v1`), bounds-checked alloc-free parse, deterministic CBOR, signed record envelope, shipped C header | +| `rumycelium-ingest` | `rumycelium-ingest` | gateway pipeline: parse → verify → replay-window → normalize | +| `rumycelium-calibration` | `rumycelium-calibration` | lineage chains, drift detection, quarantine (never silent correction) | +| `rumycelium-ruview` | `rumycelium-worldgraph::rf` | RuField `FieldEvent` → contextual evidence bridge | +| `rumycelium-fusion` | `rumycelium-worldgraph` | evidence edges, plausibility checks, contradiction tracking | +| `rumycelium-worldgraph` | `rumycelium-worldgraph` | typed nodes, geospatial registration, evidence/contradiction edges | +| `rumycelium-store` | `rumycelium-federation::buffer` | outage buffer with deterministic duplicate-free replay | +| `rumycelium-policy` | `rumycelium-policy` | governed control path (§9), typed so steps cannot be skipped | +| `rumycelium-federation` | `rumycelium-federation` | biome sovereignty, signed summaries, revocation, SensorThings projection | +| `rumycelium-agent` | `rumycelium-policy::agent` | agent proposal types; agents never touch actuators directly | +| `rumycelium-cli` | `rumycelium-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 `rumycelium-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 (`rumycelium-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**: `rumycelium-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/rumycelium-abi/include/rumycelium_env.h`](../crates/rumycelium-abi/include/rumycelium_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 rumycelium-bench` prints the scorecard; +`cargo test -p rumycelium-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 — `rumycelium-core` | +| 2 | C ABI + deterministic CBOR + header (§11) | shipped — `rumycelium-abi` | +| 3 | Gateway ingest: verify/replay-window/normalize (§5) | shipped — `rumycelium-ingest` | +| 4 | Calibration lineage + drift + quarantine (§12) | shipped — `rumycelium-calibration` | +| 5 | WorldGraph env nodes + RF context bridge (§5.2, §8) | shipped — `rumycelium-worldgraph` | +| 6 | Governed control path, typed stages (§9) | shipped — `rumycelium-policy` | +| 7 | Biome sovereignty, outage buffer, summaries, revocation, SensorThings projection (§6, §7, §10) | shipped — `rumycelium-federation` | +| 8 | 64-node biome acceptance benchmark (§14) | shipped — `rumycelium-bench` | +| 9 | Real spore-node firmware, LoRaWAN transport, HTTP SensorThings service, three-biome federation | honest follow-up — not in v0.1 | From 30636ff47d2c30c0e90a5cd21f08f92da92c0ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:26:26 +0000 Subject: [PATCH 02/27] feat(rumycelium): ingest, calibration, worldgraph crates + biome simulator + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rumycelium-ingest: gateway pipeline (envelope decode, registry/revocation, ed25519 verify, DTLS-style anti-replay window that forged packets cannot advance), per-category reject stats (19 tests) - rumycelium-calibration: anchor-rooted lineage chains, affine application with stated uncertainty, EWMA drift detection with sticky quarantine — never silent correction (25 tests) - rumycelium-worldgraph: typed env WorldGraph (sensor/ecosystem/region/ anchor nodes, evidence + contradiction edges, haversine queries, JSON persistence) + RuView FieldEvent RF-context bridge with hard Advisory severity cap and 0.3 evidence-weight cap (12 tests) - rumycelium-bench: deterministic 64-node biome simulator (diurnal signal models, drift/anomaly/outage scenario, tamper/replay/forged-key attack stream) + ADR-264 §14 report scaffolding - dev-profile opt-level=3 for dalek/sha2 so debug tests stay fast - README: RuMycelium section Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.toml | 10 + README.md | 46 + crates/rumycelium-bench/src/lib.rs | 17 +- crates/rumycelium-bench/src/main.rs | 36 +- crates/rumycelium-bench/src/report.rs | 183 ++++ crates/rumycelium-bench/src/sim.rs | 499 +++++++++++ .../rumycelium-calibration/src/calibrator.rs | 287 ++++++ crates/rumycelium-calibration/src/drift.rs | 279 ++++++ crates/rumycelium-calibration/src/error.rs | 163 ++++ crates/rumycelium-calibration/src/lib.rs | 37 +- crates/rumycelium-calibration/src/store.rs | 329 +++++++ crates/rumycelium-ingest/src/lib.rs | 833 +++++++++++++++++- crates/rumycelium-worldgraph/src/graph.rs | 505 +++++++++++ crates/rumycelium-worldgraph/src/lib.rs | 32 +- crates/rumycelium-worldgraph/src/rf.rs | 371 ++++++++ 15 files changed, 3622 insertions(+), 5 deletions(-) create mode 100644 crates/rumycelium-bench/src/report.rs create mode 100644 crates/rumycelium-bench/src/sim.rs create mode 100644 crates/rumycelium-calibration/src/calibrator.rs create mode 100644 crates/rumycelium-calibration/src/drift.rs create mode 100644 crates/rumycelium-calibration/src/error.rs create mode 100644 crates/rumycelium-calibration/src/store.rs create mode 100644 crates/rumycelium-worldgraph/src/graph.rs create mode 100644 crates/rumycelium-worldgraph/src/rf.rs diff --git a/Cargo.toml b/Cargo.toml index 1a7cb95..62bbfdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,3 +73,13 @@ uninlined_format_args = "allow" [profile.release] opt-level = 3 lto = true + +# The RuMycelium 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..60fd8a0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,52 @@ 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. | +## RuMycelium — federated environmental intelligence fabric + +[ADR-264](./docs/ADR-264-rumycelium-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 +[`rumycelium_env.h`](crates/rumycelium-abi/include/rumycelium_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 | +|-------|-------------| +| [`rumycelium-core`](crates/rumycelium-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. | +| [`rumycelium-abi`](crates/rumycelium-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. | +| [`rumycelium-ingest`](crates/rumycelium-ingest) | Gateway ingest: envelope decode → registry/revocation → signature verify → anti-replay window → normalized `EnvSample`. Forged packets can't burn sequence numbers. | +| [`rumycelium-calibration`](crates/rumycelium-calibration) | Calibration lineage (anchor-rooted chains), affine application with stated uncertainty, EWMA drift detection, **quarantine — never silent correction**. | +| [`rumycelium-worldgraph`](crates/rumycelium-worldgraph) | Environmental WorldGraph: typed sensor/ecosystem/region/anchor nodes, geospatial queries, evidence + contradiction edges, RuView `FieldEvent` RF-context bridge (weight-capped). | +| [`rumycelium-policy`](crates/rumycelium-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**. | +| [`rumycelium-federation`](crates/rumycelium-federation) | Biome sovereignty: outage buffer with duplicate-free replay, signed regional summaries, device revocation, disclosure coarsening + delay, OGC SensorThings 1.1 projection. | +| [`rumycelium-bench`](crates/rumycelium-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 acceptance test. | + +Run the biome acceptance benchmark: + +```bash +cargo run -p rumycelium-bench # default seed +cargo run -p rumycelium-bench -- 2026 # custom seed +cargo run -p rumycelium-bench -- 2026 --json +``` + +> **Honesty note:** like the RuField numbers, the RuMycelium 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. + ## Install / Quickstart This repository is a standalone Cargo workspace. The fastest way to see it diff --git a/crates/rumycelium-bench/src/lib.rs b/crates/rumycelium-bench/src/lib.rs index 179adb7..c47994e 100644 --- a/crates/rumycelium-bench/src/lib.rs +++ b/crates/rumycelium-bench/src/lib.rs @@ -1 +1,16 @@ -//! placeholder +//! # rumycelium-bench +//! +//! Deterministic **SYNTHETIC** biome benchmark for RuMycelium (ADR-264 §14). + +pub mod report; +pub mod sim; + +pub use report::{BiomeReport, Criterion}; +pub use sim::{BiomeSim, Emission, EmissionKind, SimConfig, DEFAULT_SEED}; + +/// Run the full ADR-264 §14 acceptance benchmark. (Runner lands with the +/// mid-layer crates.) +#[must_use] +pub fn run(_config: SimConfig) -> BiomeReport { + unimplemented!("runner lands after the mid-layer crates") +} diff --git a/crates/rumycelium-bench/src/main.rs b/crates/rumycelium-bench/src/main.rs index f328e4d..e6145d0 100644 --- a/crates/rumycelium-bench/src/main.rs +++ b/crates/rumycelium-bench/src/main.rs @@ -1 +1,35 @@ -fn main() {} +//! `rumycelium-bench` binary — runs the deterministic ADR-264 §14 biome +//! acceptance benchmark and prints the human table plus JSON. +//! +//! Usage: +//! cargo run -p rumycelium-bench # default seed +//! cargo run -p rumycelium-bench -- 2026 # custom seed +//! cargo run -p rumycelium-bench -- 2026 --json # JSON only + +use rumycelium_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/rumycelium-bench/src/report.rs b/crates/rumycelium-bench/src/report.rs new file mode 100644 index 0000000..195a925 --- /dev/null +++ b/crates/rumycelium-bench/src/report.rs @@ -0,0 +1,183 @@ +//! 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( + "============ RuMycelium v0.1 — Deterministic Biome Benchmark (ADR-264 §14) ============\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( + "They prove the fabric's mechanics (signatures, replay windows, dedup, quarantine,\n", + ); + s.push_str("revocation, projection) against known ground truth.\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/rumycelium-bench/src/sim.rs b/crates/rumycelium-bench/src/sim.rs new file mode 100644 index 0000000..168182a --- /dev/null +++ b/crates/rumycelium-bench/src/sim.rs @@ -0,0 +1,499 @@ +//! 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 rumycelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SCHEMA_V1}; +use rumycelium_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, Eq)] +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: 7, + 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"rumycelium-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, +} + +/// 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/rumycelium-calibration/src/calibrator.rs b/crates/rumycelium-calibration/src/calibrator.rs new file mode 100644 index 0000000..5794a42 --- /dev/null +++ b/crates/rumycelium-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 rumycelium_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 rumycelium_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/rumycelium-calibration/src/drift.rs b/crates/rumycelium-calibration/src/drift.rs new file mode 100644 index 0000000..fbc2051 --- /dev/null +++ b/crates/rumycelium-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/rumycelium-calibration/src/error.rs b/crates/rumycelium-calibration/src/error.rs new file mode 100644 index 0000000..3dd7e3f --- /dev/null +++ b/crates/rumycelium-calibration/src/error.rs @@ -0,0 +1,163 @@ +//! Error type for calibration lineage, application, and drift handling +//! (ADR-264 §12). + +use rumycelium_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), + /// 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::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::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/rumycelium-calibration/src/lib.rs b/crates/rumycelium-calibration/src/lib.rs index 179adb7..ece12e1 100644 --- a/crates/rumycelium-calibration/src/lib.rs +++ b/crates/rumycelium-calibration/src/lib.rs @@ -1 +1,36 @@ -//! placeholder +//! # rumycelium-calibration +//! +//! Calibration lineage, calibration application, EWMA drift detection, and +//! sensor quarantine for the RuMycelium fabric (ADR-264 §12). +//! +//! This crate enforces the §12 countermeasures on the gateway side: +//! +//! 1. **Signed calibration lineage** — every [`rumycelium_core::CalibrationRecord`] +//! chains via `parent_id` up to a reference-grade anchor; broken chains are +//! rejected ([`CalibrationStore::verify_lineage`], §12 items 1–3). +//! 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/rumycelium-calibration/0.1.0")] + +pub mod calibrator; +pub mod drift; +pub mod error; +pub mod store; + +pub use calibrator::{CalibrationOutcome, Calibrator}; +pub use drift::{DriftConfig, DriftDetector, QuarantineState}; +pub use error::CalibrationError; +pub use store::CalibrationStore; diff --git a/crates/rumycelium-calibration/src/store.rs b/crates/rumycelium-calibration/src/store.rs new file mode 100644 index 0000000..5b37def --- /dev/null +++ b/crates/rumycelium-calibration/src/store.rs @@ -0,0 +1,329 @@ +//! Calibration record store with anchor-rooted lineage verification +//! (ADR-264 §12 items 1–3). + +use crate::error::CalibrationError; +use rumycelium_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. +#[derive(Debug, Clone, Default)] +pub struct CalibrationStore { + records: BTreeMap, +} + +impl CalibrationStore { + /// Create an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 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. + pub fn insert(&mut self, record: CalibrationRecord) -> Result<(), CalibrationError> { + record.validate()?; + if self.records.contains_key(&record.calibration_id) { + return Err(CalibrationError::Core(rumycelium_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). + 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, + }), + }; + }; + 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 rumycelium_core::calibration::Q16_ONE; + + 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 + ); + } +} diff --git a/crates/rumycelium-ingest/src/lib.rs b/crates/rumycelium-ingest/src/lib.rs index 179adb7..f4f3bc9 100644 --- a/crates/rumycelium-ingest/src/lib.rs +++ b/crates/rumycelium-ingest/src/lib.rs @@ -1 +1,832 @@ -//! placeholder +//! # rumycelium-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 [`rumycelium_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/rumycelium-ingest/0.1.0")] + +use rumycelium_abi::{verify_record, RvEnvSampleV1, SignedEnvRecordV1}; +use rumycelium_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, +} + +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 +} + +/// 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) Envelope decode. + let record = match SignedEnvRecordV1::decode(envelope_bytes) { + Ok(r) => r, + Err(e) => return Err(self.reject(RejectReason::BadEnvelope(e.to_string()))), + }; + + // (2) ABI payload parse + validation. + let wire = match RvEnvSampleV1::parse_validated(&record.payload) { + Ok(w) => w, + Err(e) => return Err(self.reject(RejectReason::BadPayload(e.to_string()))), + }; + let node_id = wire.node_id; + + // (3) Registered? (4) Revoked? + let (registered_pubkey, firmware_hash) = match self.registry.get(node_id) { + None => return Err(self.reject(RejectReason::UnknownDevice(node_id))), + Some(d) if d.revoked => return Err(self.reject(RejectReason::RevokedDevice(node_id))), + Some(d) => (d.pubkey, d.firmware_hash.clone()), + }; + + // (5) The envelope must carry exactly the provisioned key. + if record.pubkey != registered_pubkey { + return Err(self.reject(RejectReason::KeyMismatch(node_id))); + } + + // (6) Signature over the exact payload bytes. + if verify_record(&record).is_err() { + return Err(self.reject(RejectReason::BadSignature(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(sample) + } + Err(e) => Err(self.reject(RejectReason::Domain(e.to_string()))), + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use rumycelium_abi::{NodeSigner, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SCHEMA_V1}; + use rumycelium_core::SensorModality; + + const SEED: &[u8; 32] = b"rumycelium-provision-seed-32-by!"; + 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 sample = p.ingest(&signed_envelope(NODE_A, 1, 0), RECV).unwrap(); + 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.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/rumycelium-worldgraph/src/graph.rs b/crates/rumycelium-worldgraph/src/graph.rs new file mode 100644 index 0000000..34a913e --- /dev/null +++ b/crates/rumycelium-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 rumycelium_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 rumycelium_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/rumycelium-worldgraph/src/lib.rs b/crates/rumycelium-worldgraph/src/lib.rs index 179adb7..2941b54 100644 --- a/crates/rumycelium-worldgraph/src/lib.rs +++ b/crates/rumycelium-worldgraph/src/lib.rs @@ -1 +1,31 @@ -//! placeholder +//! # rumycelium-worldgraph +//! +//! The RuMycelium 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 +//! [`rumycelium_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/rumycelium-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/rumycelium-worldgraph/src/rf.rs b/crates/rumycelium-worldgraph/src/rf.rs new file mode 100644 index 0000000..98f3ee6 --- /dev/null +++ b/crates/rumycelium-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 [`rumycelium_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 rumycelium_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 rufield_core::{ + FieldAxis, FieldEvent, FieldTensor, Modality, Observation, PrivacyClass, ProvenanceRef, + SensorDescriptor, + }; + use rumycelium_core::EnvSample; + use rumycelium_core::SampleProvenance; + use rumycelium_core::Uncertainty; + + 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); + } +} From c37821527efa2ba12a9dc32cc05cd0d3bde63f6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:28:27 +0000 Subject: [PATCH 03/27] feat(rumycelium): governed control path + biome federation crates - rumycelium-policy: typestate control path (proposal -> policy -> safety sim -> authority -> signed command -> gateway validation -> receipt); skipping a stage is a compile error; 7-stage audit trail; deterministic ed25519 command signing; execute-once replay protection (14 tests + compile_fail doctest) - rumycelium-federation: OutageBuffer with dedup surviving serialization, Biome sovereignty (global live+replay dedup, unverified-sample rejection, signed DeviceRevoked events, disclosure delay + coordinate coarsening), signed RegionalSummary + FederationBus, OGC SensorThings 1.1 projection with pure-integer RFC3339 (21 tests) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rumycelium-federation/src/biome.rs | 498 ++++++++++++ crates/rumycelium-federation/src/buffer.rs | 137 ++++ crates/rumycelium-federation/src/lib.rs | 115 ++- .../rumycelium-federation/src/sensorthings.rs | 395 ++++++++++ crates/rumycelium-federation/src/summary.rs | 364 +++++++++ crates/rumycelium-policy/src/audit.rs | 68 ++ crates/rumycelium-policy/src/lib.rs | 460 +++++++++++- crates/rumycelium-policy/src/pipeline.rs | 710 ++++++++++++++++++ crates/rumycelium-policy/src/proposal.rs | 73 ++ 9 files changed, 2818 insertions(+), 2 deletions(-) create mode 100644 crates/rumycelium-federation/src/biome.rs create mode 100644 crates/rumycelium-federation/src/buffer.rs create mode 100644 crates/rumycelium-federation/src/sensorthings.rs create mode 100644 crates/rumycelium-federation/src/summary.rs create mode 100644 crates/rumycelium-policy/src/audit.rs create mode 100644 crates/rumycelium-policy/src/pipeline.rs create mode 100644 crates/rumycelium-policy/src/proposal.rs diff --git a/crates/rumycelium-federation/src/biome.rs b/crates/rumycelium-federation/src/biome.rs new file mode 100644 index 0000000..c9a76e4 --- /dev/null +++ b/crates/rumycelium-federation/src/biome.rs @@ -0,0 +1,498 @@ +//! `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). + +use crate::sig; +use ed25519_dalek::{Signature, Signer as _, SigningKey}; +use rumycelium_core::{ + DataClass, EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, + Severity, SPEC_VERSION, +}; +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. +#[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 gateway never verified the wire signature — unverified data is + /// never admitted (ADR-264 §12). + Unverified, + /// 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 sample. Revoked devices are blocked, unverified samples are + /// never admitted (ADR-264 §12), and the global dedup index rejects any + /// `(node_id, sequence)` key already accepted — whether it arrived live + /// or via [`crate::OutageBuffer`] replay after an outage. + pub fn accept(&mut self, sample: EnvSample) -> AcceptOutcome { + if self.revoked.contains_key(&sample.node_id) { + return AcceptOutcome::Revoked; + } + if !sample.provenance.verified { + return AcceptOutcome::Unverified; + } + 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, + 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) { + 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::{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); + } + + #[test] + fn unverified_samples_are_never_admitted() { + let mut b = biome(); + let mut s = sample(1, 1, 1_000, 20.0); + s.provenance.verified = false; + assert_eq!(b.accept(s), AcceptOutcome::Unverified); + assert_eq!(b.accepted_count(), 0); + assert!(b.observations().is_empty()); + } + + #[test] + fn duplicates_across_live_and_replay_counted_once() { + let mut b = biome(); + // Live ingest. + assert_eq!(b.accept(sample(1, 1, 1_000, 20.0)), AcceptOutcome::Accepted); + assert_eq!(b.accept(sample(1, 2, 2_000, 20.5)), AcceptOutcome::Accepted); + + // Outage: the gateway buffered overlapping samples, then replays. + let mut buf = OutageBuffer::new(); + buf.push(sample(1, 2, 2_000, 20.5)); // already live-ingested + buf.push(sample(1, 3, 3_000, 21.0)); // new + let mut outcomes = Vec::new(); + for s in buf.drain() { + outcomes.push(b.accept(s)); + } + 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 b = biome(); + assert_eq!(b.accept(sample(7, 1, 1_000, 20.0)), AcceptOutcome::Accepted); + assert_eq!(b.accept(sample(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, healthy node keeps flowing. + assert_eq!(b.accept(sample(7, 2, 2_000, 20.5)), AcceptOutcome::Revoked); + assert_eq!(b.accept(sample(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 b = biome(); + b.accept(sample(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 { + 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); + b.accept(sample(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); + b.accept(sample(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/rumycelium-federation/src/buffer.rs b/crates/rumycelium-federation/src/buffer.rs new file mode 100644 index 0000000..041906b --- /dev/null +++ b/crates/rumycelium-federation/src/buffer.rs @@ -0,0 +1,137 @@ +//! `OutageBuffer` — gateway store-and-forward with duplicate-free replay +//! (ADR-264 §5 responsibility 7, §14 criteria 2–3). +//! +//! While the uplink is down the gateway pushes normalized samples here. On +//! restore, [`OutageBuffer::drain`] replays them in deterministic +//! `(node_id, sequence)` order. The dedup index is part of the serialized +//! form, so a gateway restart (serialize → deserialize) never reintroduces a +//! sample it already buffered. + +use rumycelium_core::EnvSample; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Store-and-forward log a gateway fills while its uplink is down. +/// +/// Duplicate suppression uses the stable sample dedup key +/// `(node_id, sequence)` (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. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct OutageBuffer { + /// Buffered samples, in arrival order. + samples: Vec, + /// Every `(node_id, sequence)` key ever pushed — the dedup state. + seen: BTreeSet<(u64, u32)>, +} + +impl OutageBuffer { + /// Create an empty buffer. + #[must_use] + pub fn new() -> Self { + OutageBuffer::default() + } + + /// Buffer a sample. Returns `false` (dropped) when the sample's + /// `(node_id, sequence)` key has already been buffered — including keys + /// seen before a serialize/deserialize restart cycle. + pub fn push(&mut self, sample: EnvSample) -> bool { + if !self.seen.insert(sample.dedup_key()) { + return false; + } + self.samples.push(sample); + true + } + + /// Number of samples currently buffered. + #[must_use] + pub fn len(&self) -> usize { + self.samples.len() + } + + /// Whether no samples are currently buffered. + #[must_use] + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } + + /// Remove and return all buffered samples 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. + pub fn drain(&mut self) -> Vec { + let mut out = std::mem::take(&mut self.samples); + out.sort_by_key(EnvSample::dedup_key); + out + } + + /// Serialize the whole buffer — samples *and* dedup state — so it + /// survives a gateway restart. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } + + /// Restore a buffer previously produced by + /// [`to_json`](OutageBuffer::to_json). + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::sample; + + #[test] + fn push_drops_duplicates() { + let mut buf = OutageBuffer::new(); + assert!(buf.push(sample(1, 1, 1_000, 20.0))); + assert!(buf.push(sample(1, 2, 2_000, 20.5))); + // Same (node_id, sequence), even with different payload: dropped. + assert!(!buf.push(sample(1, 1, 9_000, 99.0))); + assert_eq!(buf.len(), 2); + assert!(!buf.is_empty()); + } + + #[test] + fn drain_is_ordered_and_empties() { + let mut buf = OutageBuffer::new(); + buf.push(sample(2, 5, 5_000, 1.0)); + buf.push(sample(1, 9, 4_000, 2.0)); + buf.push(sample(1, 3, 3_000, 3.0)); + let drained = buf.drain(); + let keys: Vec<(u64, u32)> = drained.iter().map(EnvSample::dedup_key).collect(); + assert_eq!(keys, vec![(1, 3), (1, 9), (2, 5)]); + assert!(buf.is_empty()); + assert_eq!(buf.len(), 0); + } + + #[test] + fn dedup_state_survives_restart() { + let mut buf = OutageBuffer::new(); + buf.push(sample(1, 1, 1_000, 20.0)); + buf.push(sample(1, 2, 2_000, 21.0)); + let json = buf.to_json().unwrap(); + + // Gateway restarts: restore from disk. + let mut restored = OutageBuffer::from_json(&json).unwrap(); + assert_eq!(restored.len(), 2); + // Replayed wire packets with already-buffered keys are dropped. + assert!(!restored.push(sample(1, 1, 1_000, 20.0))); + assert!(!restored.push(sample(1, 2, 2_000, 21.0))); + // New keys still flow. + assert!(restored.push(sample(1, 3, 3_000, 22.0))); + + // Drain after restore contains zero duplicates. + let drained = restored.drain(); + let mut keys: Vec<(u64, u32)> = drained.iter().map(EnvSample::dedup_key).collect(); + let n = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), n); + assert_eq!(keys, vec![(1, 1), (1, 2), (1, 3)]); + + // Even after draining, previously seen keys stay duplicates. + assert!(!restored.push(sample(1, 3, 3_000, 22.0))); + } +} diff --git a/crates/rumycelium-federation/src/lib.rs b/crates/rumycelium-federation/src/lib.rs index 179adb7..9d05bc6 100644 --- a/crates/rumycelium-federation/src/lib.rs +++ b/crates/rumycelium-federation/src/lib.rs @@ -1 +1,114 @@ -//! placeholder +//! # rumycelium-federation +//! +//! Biome sovereignty for the RuMycelium fabric (ADR-264 §6, §7, §10, §12): +//! +//! - [`OutageBuffer`] — gateway store-and-forward log with duplicate-free +//! replay across restarts (§14 criteria 2–3), +//! - [`Biome`] — the sovereign regional aggregate: verified-only ingest, +//! 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), +//! - [`sensorthings`] — OGC SensorThings API 1.1 entity projection so every +//! accepted observation is externally interoperable (§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/rumycelium-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::{verify_summary, FederationBus, FederationError, ModalityStats, RegionalSummary}; + +/// 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 rumycelium_core::{EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty}; + + /// A valid, verified test sample. + 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: "sha256:fw-test".into(), + signer_pubkey_hex: "aa".into(), + verified: true, + lineage: vec!["cal:1".into()], + }, + } + } + + /// A deterministic 32-byte signer seed for tests. + pub(crate) const SEED: &[u8; 32] = b"rumycelium-test-seed-32-bytes-ok"; +} diff --git a/crates/rumycelium-federation/src/sensorthings.rs b/crates/rumycelium-federation/src/sensorthings.rs new file mode 100644 index 0000000..db9300f --- /dev/null +++ b/crates/rumycelium-federation/src/sensorthings.rs @@ -0,0 +1,395 @@ +//! OGC SensorThings API 1.1 projection (ADR-264 §7): typed serde structs +//! producing the standard entity JSON shapes (`@iot.id`, camelCase field +//! names) so every accepted observation is externally interoperable +//! (§14 criterion 6). +//! +//! v0.1 implements the biome → SensorThings *projection*; serving these +//! entities over HTTP is a follow-up. + +use rumycelium_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 `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 `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 `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, + /// `"application/pdf"` per the SensorThings metadata convention. + pub encoding_type: String, + /// Sensor metadata: the firmware measurement-implementation hash. + pub metadata: String, +} + +/// SensorThings `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 `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 `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, + /// 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 `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 `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 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, +} + +/// Project one normalized [`EnvSample`] into a fully linked SensorThings +/// 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!( + "RuMycelium 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 + ), + encoding_type: "application/pdf".into(), + metadata: sample.provenance.firmware_hash.clone(), + }, + observed_property: ObservedProperty { + iot_id: observed_property_id.clone(), + name: sample.observed_property.clone(), + definition: format!("urn:rumycelium: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), + 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\"")); + assert!(json.contains("\"encodingType\":\"application/pdf\"")); + assert!(json.contains("\"type\":\"Point\"")); + // Round trips. + let back: SensorThingsBundle = serde_json::from_str(&json).unwrap(); + assert_eq!(bundle, back); + } + + #[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/rumycelium-federation/src/summary.rs b/crates/rumycelium-federation/src/summary.rs new file mode 100644 index 0000000..fecff91 --- /dev/null +++ b/crates/rumycelium-federation/src/summary.rs @@ -0,0 +1,364 @@ +//! 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 rumycelium_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: rumycelium_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) { + 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`] publication. +#[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 signer public key is not a registered biome (hex key attached). + UnknownBiome(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(pk) => { + write!(f, "signer is not a registered biome: {pk}") + } + } + } +} + +impl std::error::Error for FederationError {} + +/// Minimal in-memory federation exchange (ADR-264 §7): registered biomes +/// publish signed summaries and events; everything unsigned, unverifiable, or +/// from an unregistered key is rejected. +#[derive(Debug, Clone, Default)] +pub struct FederationBus { + /// Registered biome public keys (hex). + biomes: BTreeSet, + /// Accepted summaries, in publication order. + summaries: Vec, + /// Accepted events, in publication order. + events: Vec, +} + +impl FederationBus { + /// Create an empty bus. + #[must_use] + pub fn new() -> Self { + FederationBus::default() + } + + /// Register a biome by its hex public key. Only registered biomes may + /// publish. + pub fn register_biome(&mut self, pubkey_hex: impl Into) { + self.biomes.insert(pubkey_hex.into()); + } + + /// Publish a signed regional summary. Rejects unsigned payloads, + /// unregistered signers, and anything whose signature fails to verify. + 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); + }; + if !self.biomes.contains(pk) { + return Err(FederationError::UnknownBiome(pk.clone())); + } + if !verify_summary(&summary) { + return Err(FederationError::BadSignature); + } + self.summaries.push(summary); + Ok(()) + } + + /// Publish a signed environmental event with the same checks, via + /// [`verify_event`]. + 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); + }; + if !self.biomes.contains(pk) { + return Err(FederationError::UnknownBiome(pk.clone())); + } + if !verify_event(&event) { + return Err(FederationError::BadSignature); + } + 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::{sample, SEED}; + + fn biome_with_data() -> Biome { + let mut b = Biome::new(BiomeConfig::new("biome/test-forest"), SEED); + b.accept(sample(1, 1, 1_000, 10.0)); + b.accept(sample(1, 2, 2_000, 20.0)); + b.accept(sample(2, 1, 3_000, 30.0)); + b.accept(sample(2, 2, 9_000, 99.0)); // outside [0, 5000) window + b + } + + #[test] + fn summarize_produces_exact_stats() { + let b = biome_with_data(); + let s = b.summarize(0, 5_000); + assert_eq!(s.spec_version, rumycelium_core::SPEC_VERSION); + assert_eq!(s.biome_id, "biome/test-forest"); + 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); + assert!((w.mean_quality - f64::from(0.9_f32)).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. + assert_eq!( + bus.publish(s.clone()), + Err(FederationError::UnknownBiome(b.public_key_hex())) + ); + + bus.register_biome(b.public_key_hex()); + + // 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 bus_publishes_events_with_same_checks() { + let mut b = biome_with_data(); + let event = b.revoke_device(1, 10_000, "compromised"); + let mut bus = FederationBus::new(); + + assert!(matches!( + bus.publish_event(event.clone()), + Err(FederationError::UnknownBiome(_)) + )); + + bus.register_biome(b.public_key_hex()); + + 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)); + + bus.publish_event(event).unwrap(); + assert_eq!(bus.events().len(), 1); + } + + #[test] + fn federation_error_displays() { + assert_eq!(FederationError::Unsigned.to_string(), "payload is unsigned"); + assert!(FederationError::UnknownBiome("ab".into()) + .to_string() + .contains("ab")); + assert!(!FederationError::BadSignature.to_string().is_empty()); + } +} diff --git a/crates/rumycelium-policy/src/audit.rs b/crates/rumycelium-policy/src/audit.rs new file mode 100644 index 0000000..7475cdc --- /dev/null +++ b/crates/rumycelium-policy/src/audit.rs @@ -0,0 +1,68 @@ +//! 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"`. + +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/rumycelium-policy/src/lib.rs b/crates/rumycelium-policy/src/lib.rs index 179adb7..1288493 100644 --- a/crates/rumycelium-policy/src/lib.rs +++ b/crates/rumycelium-policy/src/lib.rs @@ -1 +1,459 @@ -//! placeholder +//! # rumycelium-policy +//! +//! The **governed control path** of the RuMycelium 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 rumycelium_policy::{AgentProposal, AuditTrail, GatewayValidator, ProposalKind}; +//! +//! let mut gateway = GatewayValidator::new(vec![]); +//! 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, |_| 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 command signing +//! is deterministic ed25519 (RFC 8032) from a fixed 32-byte seed. Identical +//! runs produce identical signatures, receipts, and receipt hashes. +//! +//! ## 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"`. + +#![doc(html_root_url = "https://docs.rs/rumycelium-policy/0.1.0")] + +pub mod audit; +pub mod pipeline; +pub mod proposal; + +pub use audit::{AuditEntry, AuditTrail}; +pub use pipeline::{ + AuthorityRegistry, AuthorizedProposal, CommandPayload, 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 was already executed (replay protection). + DuplicateCommand(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 executed") + } + ControlError::BadEncoding(m) => write!(f, "bad encoding: {m}"), + } + } +} + +impl std::error::Error for ControlError {} + +#[cfg(test)] +mod tests { + use super::*; + use rumycelium_core::GeoPoint; + + const SEED: &[u8; 32] = b"rumycelium-test-seed-32-bytes-ok"; + const OTHER_SEED: &[u8; 32] = b"rumycelium-EVIL-seed-32-bytes-ok"; + + fn actuator_proposal(magnitude: f64) -> AgentProposal { + AgentProposal { + proposal_id: "p-1".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 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()]); + + 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| format!("executed {kind:?}"), &mut audit) + .unwrap(); + (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:")); + + 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_is_enforced() { + let mut audit = AuditTrail::new(); + let engine = permissive_policy(); + let mut sim = SafetySimulator::new(SafetyConfig { + safe_magnitude: 0.8, + max_commands_per_actuator: 2, + }); + for _ in 0..2 { + let evaluated = engine + .evaluate(actuator_proposal(0.1), 10, &mut audit) + .unwrap(); + sim.simulate(evaluated, 20, &mut audit).unwrap(); + } + let evaluated = engine + .evaluate(actuator_proposal(0.1), 10, &mut audit) + .unwrap(); + let err = sim.simulate(evaluated, 20, &mut audit).unwrap_err(); + assert!(matches!(err, ControlError::Unsafe(_))); + } + + #[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( + signer: &CommandSigner, + 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(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) + } + + #[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()]); + let err = gateway + .validate_and_execute(&cmd, 1_040, |_| 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 + .validate_and_execute(&cmd, 50, |_| "ok".into(), &mut audit) + .unwrap(); + let err = gateway + .validate_and_execute(&cmd, 60, |_| "ok".into(), &mut audit) + .unwrap_err(); + assert_eq!(err, ControlError::DuplicateCommand("cmd-p-1".into())); + } + + #[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()]); + let err = gateway + .validate_and_execute(&forged, 50, |_| 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()]); + let err = gateway + .validate_and_execute(&cmd, 50, |_| 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()]); + assert!(gateway + .validate_and_execute(&back, 50, |_| "ok".into(), &mut audit) + .is_ok()); + } +} diff --git a/crates/rumycelium-policy/src/pipeline.rs b/crates/rumycelium-policy/src/pipeline.rs new file mode 100644 index 0000000..721401d --- /dev/null +++ b/crates/rumycelium-policy/src/pipeline.rs @@ -0,0 +1,710 @@ +//! 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 commands the simulator will pass per actuator + /// (default 10) — a deterministic stand-in for rate limiting. + 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. Takes `&mut self` because it +/// tracks how many commands each actuator has been issued. +#[derive(Debug, Default, Clone)] +pub struct SafetySimulator { + config: SafetyConfig, + issued: BTreeMap, +} + +impl SafetySimulator { + /// Simulator with the given safety envelope. + #[must_use] + pub fn new(config: SafetyConfig) -> Self { + SafetySimulator { + config, + issued: BTreeMap::new(), + } + } + + /// 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. 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.issued.entry(actuator_id.clone()).or_insert(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); + } + *count += 1; + } + 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; + +/// Terminal artifact of the governed control path: proof that a command was +/// validated and executed exactly once. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +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, +} + +/// Stages 5 and 6: gateway-side validation and 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`]), and the command id has not already +/// executed ([`ControlError::DuplicateCommand`] — replay protection). Only +/// then does the execution closure run, exactly once per command id. +#[derive(Debug, Clone)] +pub struct GatewayValidator { + trusted: BTreeSet, + executed: BTreeSet, +} + +impl GatewayValidator { + /// Validator trusting the given hex-encoded ed25519 public keys. + #[must_use] + pub fn new(trusted_keys: Vec) -> Self { + GatewayValidator { + trusted: trusted_keys.into_iter().collect(), + executed: BTreeSet::new(), + } + } + + /// Validate a signed command and, on success, run `execute` (the local + /// execution) and return the [`ExecutionReceipt`]. Records + /// `"gateway_validated"` (with the verdict, pass or fail) and, on + /// success, `"executed"` audit entries. + pub fn validate_and_execute String>( + &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) { + audit.record( + "gateway_validated", + &proposal_id, + now_ns, + format!("rejected: {e}"), + ); + return Err(e); + } + audit.record( + "gateway_validated", + &proposal_id, + now_ns, + "signature and freshness ok", + ); + self.executed.insert(cmd.payload.command_id.clone()); + let outcome = execute(&cmd.payload.kind); + let gateway_receipt_hash = + sha256_hex(format!("{}|{now_ns}|{outcome}", cmd.payload.command_id).as_bytes()); + audit.record( + "executed", + &proposal_id, + now_ns, + format!("outcome: {outcome}"), + ); + Ok(ExecutionReceipt { + command_id: cmd.payload.command_id.clone(), + executed_ns: now_ns, + outcome, + gateway_receipt_hash, + }) + } + + 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, + }); + } + if self.executed.contains(&cmd.payload.command_id) { + return Err(ControlError::DuplicateCommand( + cmd.payload.command_id.clone(), + )); + } + Ok(()) + } +} diff --git a/crates/rumycelium-policy/src/proposal.rs b/crates/rumycelium-policy/src/proposal.rs new file mode 100644 index 0000000..d7dac6b --- /dev/null +++ b/crates/rumycelium-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 rumycelium_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, +} From d1eab5d4d85074792615187905a5f401cf0bf775 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:38:25 +0000 Subject: [PATCH 04/27] feat(rucelium): rename to RuCelium, biome acceptance benchmark, npm Darwin-flywheel metaharness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename RuMycelium -> RuCelium everywhere (crates rucelium-*, ADR-264, C header rucelium_env.h, spec version rucelium.fabric.v0.1) - rucelium-bench: deterministic 64-node biome simulator (diurnal signal models per modality, drift injection, flood anomaly, 7-day uplink outage, tamper/replay/forged-key/post-revocation attack streams) wired through the REAL production pipeline: ABI -> ingest -> calibration -> WorldGraph + RF context -> biome federation -> SensorThings -> governed control path. ADR-264 §14 scorecard: all 8 criteria pass (SYNTHETIC), 92,460 emissions, 780/780 attacks rejected, 0 restore duplicates, 98.77% usable calibrated observations, p95 alert 0.24 ms - Acceptance tests: full §14 run, same-seed determinism fingerprint, seed-robustness - Event-correlated deviations excluded from drift accounting (a flood must not quarantine healthy sensors; drift is slow and single-sensor) - harness/: rucelium-harness npm metaharness — Darwinian flywheel (vary -> evaluate -> select -> retain) with fitness from workspace tests + clippy + §14 benchmark, generation ledger, strict gate command Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- .gitignore | 4 + Cargo.lock | 130 ++-- Cargo.toml | 34 +- README.md | 48 +- .../Cargo.toml | 6 +- .../include/rucelium_env.h} | 12 +- .../src/cbor.rs | 44 +- .../src/lib.rs | 14 +- .../src/sign.rs | 12 +- .../src/wire.rs | 24 +- crates/rucelium-bench/Cargo.toml | 29 + crates/rucelium-bench/src/lib.rs | 20 + .../src/main.rs | 10 +- .../src/report.rs | 8 +- crates/rucelium-bench/src/runner.rs | 594 ++++++++++++++++++ .../src/sim.rs | 28 +- crates/rucelium-bench/tests/acceptance.rs | 96 +++ .../Cargo.toml | 6 +- .../src/calibrator.rs | 4 +- .../src/drift.rs | 0 .../src/error.rs | 2 +- .../src/lib.rs | 8 +- .../src/store.rs | 6 +- .../Cargo.toml | 4 +- .../src/calibration.rs | 6 +- .../src/error.rs | 2 +- .../src/event.rs | 11 +- .../src/geo.rs | 0 .../src/lib.rs | 12 +- .../src/modality.rs | 0 .../src/sample.rs | 4 +- .../Cargo.toml | 6 +- .../src/biome.rs | 2 +- .../src/buffer.rs | 2 +- .../src/lib.rs | 10 +- .../src/sensorthings.rs | 6 +- .../src/summary.rs | 6 +- .../Cargo.toml | 8 +- .../src/lib.rs | 16 +- .../Cargo.toml | 6 +- .../src/audit.rs | 0 .../src/lib.rs | 14 +- .../src/pipeline.rs | 0 .../src/proposal.rs | 2 +- .../Cargo.toml | 6 +- .../src/graph.rs | 4 +- .../src/lib.rs | 8 +- .../src/rf.rs | 10 +- crates/rumycelium-bench/Cargo.toml | 29 - crates/rumycelium-bench/src/lib.rs | 16 - ...d => ADR-264-rucelium-federated-fabric.md} | 56 +- harness/README.md | 80 +++ harness/bin/rucelium.js | 368 +++++++++++ harness/package.json | 38 ++ 54 files changed, 1552 insertions(+), 319 deletions(-) rename crates/{rumycelium-abi => rucelium-abi}/Cargo.toml (57%) rename crates/{rumycelium-abi/include/rumycelium_env.h => rucelium-abi/include/rucelium_env.h} (93%) rename crates/{rumycelium-abi => rucelium-abi}/src/cbor.rs (93%) rename crates/{rumycelium-abi => rucelium-abi}/src/lib.rs (68%) rename crates/{rumycelium-abi => rucelium-abi}/src/sign.rs (92%) rename crates/{rumycelium-abi => rucelium-abi}/src/wire.rs (95%) create mode 100644 crates/rucelium-bench/Cargo.toml create mode 100644 crates/rucelium-bench/src/lib.rs rename crates/{rumycelium-bench => rucelium-bench}/src/main.rs (68%) rename crates/{rumycelium-bench => rucelium-bench}/src/report.rs (96%) create mode 100644 crates/rucelium-bench/src/runner.rs rename crates/{rumycelium-bench => rucelium-bench}/src/sim.rs (95%) create mode 100644 crates/rucelium-bench/tests/acceptance.rs rename crates/{rumycelium-calibration => rucelium-calibration}/Cargo.toml (62%) rename crates/{rumycelium-calibration => rucelium-calibration}/src/calibrator.rs (98%) rename crates/{rumycelium-calibration => rucelium-calibration}/src/drift.rs (100%) rename crates/{rumycelium-calibration => rucelium-calibration}/src/error.rs (99%) rename crates/{rumycelium-calibration => rucelium-calibration}/src/lib.rs (84%) rename crates/{rumycelium-calibration => rucelium-calibration}/src/store.rs (98%) rename crates/{rumycelium-core => rucelium-core}/Cargo.toml (61%) rename crates/{rumycelium-core => rucelium-core}/src/calibration.rs (97%) rename crates/{rumycelium-core => rucelium-core}/src/error.rs (96%) rename crates/{rumycelium-core => rucelium-core}/src/event.rs (95%) rename crates/{rumycelium-core => rucelium-core}/src/geo.rs (100%) rename crates/{rumycelium-core => rucelium-core}/src/lib.rs (71%) rename crates/{rumycelium-core => rucelium-core}/src/modality.rs (100%) rename crates/{rumycelium-core => rucelium-core}/src/sample.rs (98%) rename crates/{rumycelium-federation => rucelium-federation}/Cargo.toml (59%) rename crates/{rumycelium-federation => rucelium-federation}/src/biome.rs (99%) rename crates/{rumycelium-federation => rucelium-federation}/src/buffer.rs (99%) rename crates/{rumycelium-federation => rucelium-federation}/src/lib.rs (91%) rename crates/{rumycelium-federation => rucelium-federation}/src/sensorthings.rs (98%) rename crates/{rumycelium-federation => rucelium-federation}/src/summary.rs (98%) rename crates/{rumycelium-ingest => rucelium-ingest}/Cargo.toml (54%) rename crates/{rumycelium-ingest => rucelium-ingest}/src/lib.rs (98%) rename crates/{rumycelium-policy => rucelium-policy}/Cargo.toml (58%) rename crates/{rumycelium-policy => rucelium-policy}/src/audit.rs (100%) rename crates/{rumycelium-policy => rucelium-policy}/src/lib.rs (97%) rename crates/{rumycelium-policy => rucelium-policy}/src/pipeline.rs (100%) rename crates/{rumycelium-policy => rucelium-policy}/src/proposal.rs (98%) rename crates/{rumycelium-worldgraph => rucelium-worldgraph}/Cargo.toml (59%) rename crates/{rumycelium-worldgraph => rucelium-worldgraph}/src/graph.rs (99%) rename crates/{rumycelium-worldgraph => rucelium-worldgraph}/src/lib.rs (84%) rename crates/{rumycelium-worldgraph => rucelium-worldgraph}/src/rf.rs (98%) delete mode 100644 crates/rumycelium-bench/Cargo.toml delete mode 100644 crates/rumycelium-bench/src/lib.rs rename docs/{ADR-264-rumycelium-federated-fabric.md => ADR-264-rucelium-federated-fabric.md} (87%) create mode 100644 harness/README.md create mode 100755 harness/bin/rucelium.js create mode 100644 harness/package.json 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 4e191e5..5338a80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,169 +895,169 @@ dependencies = [ ] [[package]] -name = "rufield-adapters" +name = "rucelium-abi" version = "0.1.0" dependencies = [ - "rufield-core", - "rufield-fusion", - "rufield-provenance", - "serde", - "serde_json", + "ed25519-dalek", + "rucelium-core", + "sha2", ] [[package]] -name = "rufield-bench" +name = "rucelium-bench" version = "0.1.0" dependencies = [ - "rufield-adapters", + "rucelium-abi", + "rucelium-calibration", + "rucelium-core", + "rucelium-federation", + "rucelium-ingest", + "rucelium-policy", + "rucelium-worldgraph", "rufield-core", - "rufield-fusion", - "rufield-privacy", - "rufield-provenance", "serde", "serde_json", ] [[package]] -name = "rufield-core" +name = "rucelium-calibration" version = "0.1.0" dependencies = [ + "rucelium-core", "serde", "serde_json", ] [[package]] -name = "rufield-fusion" +name = "rucelium-core" version = "0.1.0" dependencies = [ - "rufield-adapters", - "rufield-core", - "rufield-provenance", "serde", - "toml", -] - -[[package]] -name = "rufield-privacy" -version = "0.1.0" -dependencies = [ - "rufield-core", + "serde_json", ] [[package]] -name = "rufield-provenance" +name = "rucelium-federation" version = "0.1.0" dependencies = [ "ed25519-dalek", - "rufield-core", + "rucelium-core", "serde", "serde_json", "sha2", ] [[package]] -name = "rufield-viewer" +name = "rucelium-ingest" version = "0.1.0" dependencies = [ - "axum", - "futures-core", - "futures-util", - "http-body-util", - "reqwest", - "rufield-adapters", - "rufield-core", - "rufield-fusion", - "rufield-privacy", - "rufield-provenance", + "rucelium-abi", + "rucelium-core", "serde", "serde_json", - "tokio", - "tokio-stream", - "tower", ] [[package]] -name = "rumycelium-abi" +name = "rucelium-policy" version = "0.1.0" dependencies = [ "ed25519-dalek", - "rumycelium-core", + "rucelium-core", + "serde", + "serde_json", "sha2", ] [[package]] -name = "rumycelium-bench" +name = "rucelium-worldgraph" version = "0.1.0" dependencies = [ + "rucelium-core", "rufield-core", - "rumycelium-abi", - "rumycelium-calibration", - "rumycelium-core", - "rumycelium-federation", - "rumycelium-ingest", - "rumycelium-policy", - "rumycelium-worldgraph", "serde", "serde_json", ] [[package]] -name = "rumycelium-calibration" +name = "rufield-adapters" version = "0.1.0" dependencies = [ - "rumycelium-core", + "rufield-core", + "rufield-fusion", + "rufield-provenance", "serde", "serde_json", ] [[package]] -name = "rumycelium-core" +name = "rufield-bench" version = "0.1.0" dependencies = [ + "rufield-adapters", + "rufield-core", + "rufield-fusion", + "rufield-privacy", + "rufield-provenance", "serde", "serde_json", ] [[package]] -name = "rumycelium-federation" +name = "rufield-core" version = "0.1.0" dependencies = [ - "ed25519-dalek", - "rumycelium-core", "serde", "serde_json", - "sha2", ] [[package]] -name = "rumycelium-ingest" +name = "rufield-fusion" version = "0.1.0" dependencies = [ - "rumycelium-abi", - "rumycelium-core", + "rufield-adapters", + "rufield-core", + "rufield-provenance", "serde", - "serde_json", + "toml", ] [[package]] -name = "rumycelium-policy" +name = "rufield-privacy" +version = "0.1.0" +dependencies = [ + "rufield-core", +] + +[[package]] +name = "rufield-provenance" version = "0.1.0" dependencies = [ "ed25519-dalek", - "rumycelium-core", + "rufield-core", "serde", "serde_json", "sha2", ] [[package]] -name = "rumycelium-worldgraph" +name = "rufield-viewer" version = "0.1.0" dependencies = [ + "axum", + "futures-core", + "futures-util", + "http-body-util", + "reqwest", + "rufield-adapters", "rufield-core", - "rumycelium-core", + "rufield-fusion", + "rufield-privacy", + "rufield-provenance", "serde", "serde_json", + "tokio", + "tokio-stream", + "tower", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 62bbfdd..a9df3ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,14 +8,14 @@ members = [ "crates/rufield-fusion", "crates/rufield-bench", "crates/rufield-viewer", - "crates/rumycelium-core", - "crates/rumycelium-abi", - "crates/rumycelium-ingest", - "crates/rumycelium-calibration", - "crates/rumycelium-worldgraph", - "crates/rumycelium-policy", - "crates/rumycelium-federation", - "crates/rumycelium-bench", + "crates/rucelium-core", + "crates/rucelium-abi", + "crates/rucelium-ingest", + "crates/rucelium-calibration", + "crates/rucelium-worldgraph", + "crates/rucelium-policy", + "crates/rucelium-federation", + "crates/rucelium-bench", ] [workspace.package] @@ -43,14 +43,14 @@ 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" } -rumycelium-core = { version = "0.1.0", path = "crates/rumycelium-core" } -rumycelium-abi = { version = "0.1.0", path = "crates/rumycelium-abi" } -rumycelium-ingest = { version = "0.1.0", path = "crates/rumycelium-ingest" } -rumycelium-calibration = { version = "0.1.0", path = "crates/rumycelium-calibration" } -rumycelium-worldgraph = { version = "0.1.0", path = "crates/rumycelium-worldgraph" } -rumycelium-policy = { version = "0.1.0", path = "crates/rumycelium-policy" } -rumycelium-federation = { version = "0.1.0", path = "crates/rumycelium-federation" } -rumycelium-bench = { version = "0.1.0", path = "crates/rumycelium-bench" } +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" } [workspace.lints.rust] unsafe_code = "forbid" @@ -74,7 +74,7 @@ uninlined_format_args = "allow" opt-level = 3 lto = true -# The RuMycelium biome benchmark signs/verifies ~10^5 ed25519 envelopes per +# 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] diff --git a/README.md b/README.md index 60fd8a0..d21605f 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ 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. | -## RuMycelium — federated environmental intelligence fabric +## RuCelium — federated environmental intelligence fabric -[ADR-264](./docs/ADR-264-rumycelium-federated-fabric.md) extends the stack +[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** @@ -85,35 +85,53 @@ 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 -[`rumycelium_env.h`](crates/rumycelium-abi/include/rumycelium_env.h)); +[`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 | |-------|-------------| -| [`rumycelium-core`](crates/rumycelium-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. | -| [`rumycelium-abi`](crates/rumycelium-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. | -| [`rumycelium-ingest`](crates/rumycelium-ingest) | Gateway ingest: envelope decode → registry/revocation → signature verify → anti-replay window → normalized `EnvSample`. Forged packets can't burn sequence numbers. | -| [`rumycelium-calibration`](crates/rumycelium-calibration) | Calibration lineage (anchor-rooted chains), affine application with stated uncertainty, EWMA drift detection, **quarantine — never silent correction**. | -| [`rumycelium-worldgraph`](crates/rumycelium-worldgraph) | Environmental WorldGraph: typed sensor/ecosystem/region/anchor nodes, geospatial queries, evidence + contradiction edges, RuView `FieldEvent` RF-context bridge (weight-capped). | -| [`rumycelium-policy`](crates/rumycelium-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**. | -| [`rumycelium-federation`](crates/rumycelium-federation) | Biome sovereignty: outage buffer with duplicate-free replay, signed regional summaries, device revocation, disclosure coarsening + delay, OGC SensorThings 1.1 projection. | -| [`rumycelium-bench`](crates/rumycelium-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 acceptance test. | +| [`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 acceptance test. | Run the biome acceptance benchmark: ```bash -cargo run -p rumycelium-bench # default seed -cargo run -p rumycelium-bench -- 2026 # custom seed -cargo run -p rumycelium-bench -- 2026 --json +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 RuMycelium scorecard is +> **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. +### 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/rumycelium-abi/Cargo.toml b/crates/rucelium-abi/Cargo.toml similarity index 57% rename from crates/rumycelium-abi/Cargo.toml rename to crates/rucelium-abi/Cargo.toml index 85e439d..9e17f7c 100644 --- a/crates/rumycelium-abi/Cargo.toml +++ b/crates/rucelium-abi/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "rumycelium-abi" +name = "rucelium-abi" version.workspace = true edition.workspace = true -description = "RuMycelium versioned C ABI boundary: rv_env_sample_v1 wire format (bounds-checked, allocation-free parse), deterministic CBOR, ed25519 signed record envelope (ADR-264 §11)" +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 @@ -10,7 +10,7 @@ keywords = ["environmental", "ffi", "cbor", "wire", "iot"] categories = ["science", "embedded"] [dependencies] -rumycelium-core = { workspace = true } +rucelium-core = { workspace = true } ed25519-dalek = { workspace = true } sha2 = { workspace = true } diff --git a/crates/rumycelium-abi/include/rumycelium_env.h b/crates/rucelium-abi/include/rucelium_env.h similarity index 93% rename from crates/rumycelium-abi/include/rumycelium_env.h rename to crates/rucelium-abi/include/rucelium_env.h index 12ce4ca..de47b69 100644 --- a/crates/rumycelium-abi/include/rumycelium_env.h +++ b/crates/rucelium-abi/include/rucelium_env.h @@ -1,5 +1,5 @@ /* - * rumycelium_env.h — RuMycelium spore-node wire contract, version 1 + * 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): @@ -12,11 +12,11 @@ * never guessed. * - Sign the 48 wire bytes with the device ed25519 key; transmit the * COSE-inspired envelope [payload, pubkey, signature] as deterministic - * CBOR (see rumycelium-abi::cbor). + * CBOR (see rucelium-abi::cbor). */ -#ifndef RUMYCELIUM_ENV_H -#define RUMYCELIUM_ENV_H +#ifndef RUCELIUM_ENV_H +#define RUCELIUM_ENV_H #include @@ -35,7 +35,7 @@ extern "C" { * replay ATTACK — the sequence window still deduplicates either way. */ #define RV_ENV_FLAG_RETRANSMIT (1u << 0) -/* Sensor modality codes (must match rumycelium_core::SensorModality). */ +/* 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 */ @@ -92,4 +92,4 @@ _Static_assert(sizeof(rv_env_sample_v1) == RV_ENV_SAMPLE_V1_WIRE_LEN, } #endif -#endif /* RUMYCELIUM_ENV_H */ +#endif /* RUCELIUM_ENV_H */ diff --git a/crates/rumycelium-abi/src/cbor.rs b/crates/rucelium-abi/src/cbor.rs similarity index 93% rename from crates/rumycelium-abi/src/cbor.rs rename to crates/rucelium-abi/src/cbor.rs index 16a0cdc..55eff4f 100644 --- a/crates/rumycelium-abi/src/cbor.rs +++ b/crates/rucelium-abi/src/cbor.rs @@ -46,10 +46,16 @@ impl fmt::Display for CborError { 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}") + write!( + f, + "wrong cbor major type: expected {expected}, found {found}" + ) } CborError::WrongLength { expected, actual } => { - write!(f, "wrong cbor field length: expected {expected}, got {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"), @@ -339,19 +345,26 @@ impl SignedEnvRecordV1 { 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, + 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 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.read_bytes()? + .try_into() + .map_err(|_| CborError::WrongLength { + expected: 64, + actual: 0, + })?; r.finish()?; Ok(SignedEnvRecordV1 { payload, @@ -404,7 +417,7 @@ mod tests { 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. + // 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) @@ -429,10 +442,7 @@ mod tests { 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)) - ); + assert_eq!(decode_sample_v1(&extra), Err(CborError::TrailingBytes(1))); } #[test] diff --git a/crates/rumycelium-abi/src/lib.rs b/crates/rucelium-abi/src/lib.rs similarity index 68% rename from crates/rumycelium-abi/src/lib.rs rename to crates/rucelium-abi/src/lib.rs index 1bddd7b..5c32347 100644 --- a/crates/rumycelium-abi/src/lib.rs +++ b/crates/rucelium-abi/src/lib.rs @@ -1,13 +1,13 @@ -//! # rumycelium-abi +//! # rucelium-abi //! -//! The versioned C ABI boundary of the RuMycelium fabric (ADR-264 §11). +//! 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/rumycelium_env.h`]); this crate parses +//! 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 `rumycelium-core` domain model. +//! 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* @@ -15,10 +15,10 @@ //! signatures. Honest label: this is deterministic COSE-inspired framing, //! not a full RFC 9052 implementation (stated follow-up in ADR-264 §11.2). //! -//! [`include/rumycelium_env.h`]: -//! https://github.com/ruvnet/rufield/blob/main/crates/rumycelium-abi/include/rumycelium_env.h +//! [`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/rumycelium-abi/0.1.0")] +#![doc(html_root_url = "https://docs.rs/rucelium-abi/0.1.0")] pub mod cbor; pub mod sign; diff --git a/crates/rumycelium-abi/src/sign.rs b/crates/rucelium-abi/src/sign.rs similarity index 92% rename from crates/rumycelium-abi/src/sign.rs rename to crates/rucelium-abi/src/sign.rs index b3b2108..d380b9c 100644 --- a/crates/rumycelium-abi/src/sign.rs +++ b/crates/rucelium-abi/src/sign.rs @@ -99,7 +99,7 @@ pub fn sign_payload( /// 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 (`rumycelium-ingest`). +/// 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); @@ -132,7 +132,7 @@ mod tests { #[test] fn sign_verify_round_trip_through_cbor() { - let signer = NodeSigner::for_node(b"rumycelium-provision-seed-32-by!", 11); + 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. @@ -143,7 +143,7 @@ mod tests { #[test] fn any_payload_tamper_breaks_verification() { - let signer = NodeSigner::for_node(b"rumycelium-provision-seed-32-by!", 11); + 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(); @@ -158,8 +158,8 @@ mod tests { #[test] fn wrong_key_rejected() { - let a = NodeSigner::for_node(b"rumycelium-provision-seed-32-by!", 11); - let b = NodeSigner::for_node(b"rumycelium-provision-seed-32-by!", 12); + 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()); @@ -167,7 +167,7 @@ mod tests { #[test] fn node_key_derivation_is_deterministic_and_unique() { - let seed = b"rumycelium-provision-seed-32-by!"; + let seed = b"rucelium-provision-seed-32-byte!"; assert_eq!( NodeSigner::for_node(seed, 1).public_key(), NodeSigner::for_node(seed, 1).public_key() diff --git a/crates/rumycelium-abi/src/wire.rs b/crates/rucelium-abi/src/wire.rs similarity index 95% rename from crates/rumycelium-abi/src/wire.rs rename to crates/rucelium-abi/src/wire.rs index 6aca075..5948995 100644 --- a/crates/rumycelium-abi/src/wire.rs +++ b/crates/rucelium-abi/src/wire.rs @@ -2,10 +2,8 @@ //! allocation-free parse, field validation, and domain conversion //! (ADR-264 §11.1). -use rumycelium_core::geo::{LAT_E7_MAX, LON_E7_MAX}; -use rumycelium_core::{ - EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty, -}; +use rucelium_core::geo::{LAT_E7_MAX, LON_E7_MAX}; +use rucelium_core::{EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty}; use std::fmt; /// Wire schema version 1. @@ -54,7 +52,10 @@ 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}") + 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}"), @@ -158,7 +159,7 @@ impl RvEnvSampleV1 { /// Serialize to the packed little-endian wire layout. Used by the /// synthetic spore-node simulator and by tests; real nodes serialize - /// in C per `rumycelium_env.h`. + /// 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]; @@ -222,7 +223,7 @@ impl RvEnvSampleV1 { /// Convert a **validated** wire record into an *uncalibrated* domain /// [`EnvSample`]. The uncertainty starts at the Q16.16 quantization - /// half-step; `rumycelium-calibration` widens it with the calibration's + /// 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. pub fn to_env_sample( @@ -233,8 +234,9 @@ impl RvEnvSampleV1 { verified: bool, ) -> Result { self.validate()?; - let modality = - self.modality().ok_or(AbiError::UnknownModality(self.sensor_type))?; + 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 { @@ -263,7 +265,9 @@ impl RvEnvSampleV1 { lineage: vec!["abi:rv_env_sample_v1".to_string()], }, }; - sample.validate().map_err(|e| AbiError::Domain(e.to_string()))?; + sample + .validate() + .map_err(|e| AbiError::Domain(e.to_string()))?; Ok(sample) } } 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/rumycelium-bench/src/main.rs b/crates/rucelium-bench/src/main.rs similarity index 68% rename from crates/rumycelium-bench/src/main.rs rename to crates/rucelium-bench/src/main.rs index e6145d0..60177eb 100644 --- a/crates/rumycelium-bench/src/main.rs +++ b/crates/rucelium-bench/src/main.rs @@ -1,12 +1,12 @@ -//! `rumycelium-bench` binary — runs the deterministic ADR-264 §14 biome +//! `rucelium-bench` binary — runs the deterministic ADR-264 §14 biome //! acceptance benchmark and prints the human table plus JSON. //! //! Usage: -//! cargo run -p rumycelium-bench # default seed -//! cargo run -p rumycelium-bench -- 2026 # custom seed -//! cargo run -p rumycelium-bench -- 2026 --json # JSON only +//! 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 rumycelium_bench::{run, SimConfig, DEFAULT_SEED}; +use rucelium_bench::{run, SimConfig, DEFAULT_SEED}; fn main() { let args: Vec = std::env::args().skip(1).collect(); diff --git a/crates/rumycelium-bench/src/report.rs b/crates/rucelium-bench/src/report.rs similarity index 96% rename from crates/rumycelium-bench/src/report.rs rename to crates/rucelium-bench/src/report.rs index 195a925..2df7df1 100644 --- a/crates/rumycelium-bench/src/report.rs +++ b/crates/rucelium-bench/src/report.rs @@ -107,11 +107,15 @@ impl BiomeReport { pub fn to_table(&self) -> String { let mut s = String::new(); s.push_str( - "============ RuMycelium v0.1 — Deterministic Biome Benchmark (ADR-264 §14) ============\n", + "============ RuCelium v0.1 — Deterministic Biome Benchmark (ADR-264 §14) ============\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.spec_version, + self.seed, + self.nodes, + self.days, + self.offline_days, self.emissions_total )); s.push_str( diff --git a/crates/rucelium-bench/src/runner.rs b/crates/rucelium-bench/src/runner.rs new file mode 100644 index 0000000..cb49f20 --- /dev/null +++ b/crates/rucelium-bench/src/runner.rs @@ -0,0 +1,594 @@ +//! 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::{ + 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!"; + +/// 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("sluice-gate-1".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", "sluice-gate-1"); + let signer = CommandSigner::from_seed(GOV_SEED); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()]); + + 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| "applied".into(), &mut audit) + }); + if done.is_ok() { + 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: "sluice-gate-1".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| "opened 50%".into(), &mut audit) + }); + if done.is_ok() { + 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: "sluice-gate-1".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(); + bus.register_biome(biome.public_key_hex()); + 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 accept everything. + let snapshot = buffer.to_json().expect("buffer serializes"); + let mut restored = OutageBuffer::from_json(&snapshot).expect("buffer restores"); + for s in restored.drain() { + match biome.accept(s) { + AcceptOutcome::Accepted => restored_after_outage += 1, + AcceptOutcome::Duplicate => restore_duplicates += 1, + _ => {} + } + } + // Second restore of the SAME snapshot: every sample must dedup. + let mut again = OutageBuffer::from_json(&snapshot).expect("buffer restores"); + for s in again.drain() { + match biome.accept(s) { + AcceptOutcome::Accepted => restore_duplicates += 1, // duplicates admitted = failure + AcceptOutcome::Duplicate => {} + _ => {} + } + } + 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). + let outcome = calibrator.apply(&store, &mut sample, em.received_ns); + 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. + let is_local_anomaly = sample.modality == SensorModality::WaterQuality + && sample.value > FLOOD_THRESHOLD_M; + if !is_local_anomaly { + let t_s = (sample.measured_ns - EPOCH_START_NS) / NS_PER_S; + let expected = anchor_expectation(sample.modality, em.node_index, t_s); + let residual = (sample.value - expected) / (4.0 * noise_sd(sample.modality)); + drift.observe(sample.node_id, residual); + } + let quarantined = drift.is_quarantined(sample.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(&sample); + let _ = graph.link_within_region(&key, "region/synthetic-watershed"); + if water_sensor_key.is_none() && sample.modality == SensorModality::WaterQuality { + water_sensor_key = Some(key.clone()); + } + worldgraph_mapped += 1; + + // SensorThings projection (criterion 6). + let bundle = project_sample(&sample); + 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: sample.geo, + window_start_ns: sample.measured_ns, + window_end_ns: sample.measured_ns, + detected_ns: em.received_ns, + evidence: vec![EvidenceRef { + node_id: sample.node_id, + sequence: sample.sequence, + }], + confidence: 0.92, + message: format!("water level {:.2} m above flood threshold", sample.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 && sample.quality >= 0.9 { + usable += 1; + } + + // Biome admission: live when online, store-and-forward when + // the uplink is down (the buffer dedups by (node, sequence)). + let admitted = if em.uplink_down { + let pushed = buffer.push(sample); + 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/rumycelium-bench/src/sim.rs b/crates/rucelium-bench/src/sim.rs similarity index 95% rename from crates/rumycelium-bench/src/sim.rs rename to crates/rucelium-bench/src/sim.rs index 168182a..e12bd13 100644 --- a/crates/rumycelium-bench/src/sim.rs +++ b/crates/rucelium-bench/src/sim.rs @@ -9,8 +9,8 @@ //! //! Same seed ⇒ byte-identical emission stream. No wall clocks, no OS entropy. -use rumycelium_abi::{NodeSigner, RvEnvSampleV1, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SCHEMA_V1}; -use rumycelium_core::{GeoPoint, SensorModality}; +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). @@ -62,7 +62,7 @@ pub const S_PER_DAY: u64 = 86_400; pub const NODE_ID_BASE: u64 = 0x4D59_0000_0000_0000; /// Simulation configuration (ADR-264 §14 acceptance scenario). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SimConfig { /// PRNG seed (determinism anchor). pub seed: u64, @@ -108,7 +108,7 @@ impl Default for SimConfig { sample_interval_s: 1800, // 30-minute cadence offline_start_day: 10, offline_days: 7, - drift_node: 7, + drift_node: 2, // SoilMoisture — low-noise, so drift is attributable drift_start_day: 5, drift_per_day: 0.9, compromised_node: 13, @@ -125,7 +125,7 @@ impl Default for SimConfig { 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"rumycelium-biome-provision-v0.1!"; +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!!"; @@ -225,6 +225,14 @@ pub struct BiomeSim { 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. @@ -300,8 +308,8 @@ impl BiomeSim { && day == config.anomaly_day && tick >= ticks_per_day / 2 { - let ramp = f64::from(tick - ticks_per_day / 2) - / f64::from(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; } @@ -312,7 +320,11 @@ impl BiomeSim { let wire = RvEnvSampleV1 { schema_version: RV_ENV_SCHEMA_V1, sensor_type: spec.modality.code(), - flags: if uplink_down { RV_ENV_FLAG_RETRANSMIT } else { 0 }, + flags: if uplink_down { + RV_ENV_FLAG_RETRANSMIT + } else { + 0 + }, node_id: spec.node_id, timestamp_ns: measured_ns, sequence: seq, 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/rumycelium-calibration/Cargo.toml b/crates/rucelium-calibration/Cargo.toml similarity index 62% rename from crates/rumycelium-calibration/Cargo.toml rename to crates/rucelium-calibration/Cargo.toml index 7e6d640..38714a4 100644 --- a/crates/rumycelium-calibration/Cargo.toml +++ b/crates/rucelium-calibration/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "rumycelium-calibration" +name = "rucelium-calibration" version.workspace = true edition.workspace = true -description = "RuMycelium calibration lineage, drift detection, and sensor quarantine — never silent correction (ADR-264 §12)" +description = "RuCelium calibration lineage, drift detection, and sensor quarantine — never silent correction (ADR-264 §12)" license.workspace = true authors.workspace = true repository.workspace = true @@ -10,7 +10,7 @@ keywords = ["environmental", "calibration", "drift", "quality"] categories = ["science"] [dependencies] -rumycelium-core = { workspace = true } +rucelium-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/rumycelium-calibration/src/calibrator.rs b/crates/rucelium-calibration/src/calibrator.rs similarity index 98% rename from crates/rumycelium-calibration/src/calibrator.rs rename to crates/rucelium-calibration/src/calibrator.rs index 5794a42..ed4fbe0 100644 --- a/crates/rumycelium-calibration/src/calibrator.rs +++ b/crates/rucelium-calibration/src/calibrator.rs @@ -3,7 +3,7 @@ use crate::error::CalibrationError; use crate::store::CalibrationStore; -use rumycelium_core::{EnvSample, Uncertainty}; +use rucelium_core::{EnvSample, Uncertainty}; use serde::{Deserialize, Serialize}; /// What [`Calibrator::apply`] did to a sample. @@ -116,7 +116,7 @@ impl Calibrator { #[cfg(test)] mod tests { use super::*; - use rumycelium_core::{CalibrationRecord, GeoPoint, SampleProvenance, SensorModality}; + use rucelium_core::{CalibrationRecord, GeoPoint, SampleProvenance, SensorModality}; fn record() -> CalibrationRecord { CalibrationRecord { diff --git a/crates/rumycelium-calibration/src/drift.rs b/crates/rucelium-calibration/src/drift.rs similarity index 100% rename from crates/rumycelium-calibration/src/drift.rs rename to crates/rucelium-calibration/src/drift.rs diff --git a/crates/rumycelium-calibration/src/error.rs b/crates/rucelium-calibration/src/error.rs similarity index 99% rename from crates/rumycelium-calibration/src/error.rs rename to crates/rucelium-calibration/src/error.rs index 3dd7e3f..00e5984 100644 --- a/crates/rumycelium-calibration/src/error.rs +++ b/crates/rucelium-calibration/src/error.rs @@ -1,7 +1,7 @@ //! Error type for calibration lineage, application, and drift handling //! (ADR-264 §12). -use rumycelium_core::EnvError; +use rucelium_core::EnvError; use std::fmt; /// Errors raised while validating calibration lineage or applying a diff --git a/crates/rumycelium-calibration/src/lib.rs b/crates/rucelium-calibration/src/lib.rs similarity index 84% rename from crates/rumycelium-calibration/src/lib.rs rename to crates/rucelium-calibration/src/lib.rs index ece12e1..f2c38e0 100644 --- a/crates/rumycelium-calibration/src/lib.rs +++ b/crates/rucelium-calibration/src/lib.rs @@ -1,11 +1,11 @@ -//! # rumycelium-calibration +//! # rucelium-calibration //! //! Calibration lineage, calibration application, EWMA drift detection, and -//! sensor quarantine for the RuMycelium fabric (ADR-264 §12). +//! sensor quarantine for the RuCelium fabric (ADR-264 §12). //! //! This crate enforces the §12 countermeasures on the gateway side: //! -//! 1. **Signed calibration lineage** — every [`rumycelium_core::CalibrationRecord`] +//! 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). //! 2. **Measurement uncertainty on every observation** — applying a @@ -23,7 +23,7 @@ //! `now_ns` explicitly, and identical inputs always produce identical //! outputs. -#![doc(html_root_url = "https://docs.rs/rumycelium-calibration/0.1.0")] +#![doc(html_root_url = "https://docs.rs/rucelium-calibration/0.1.0")] pub mod calibrator; pub mod drift; diff --git a/crates/rumycelium-calibration/src/store.rs b/crates/rucelium-calibration/src/store.rs similarity index 98% rename from crates/rumycelium-calibration/src/store.rs rename to crates/rucelium-calibration/src/store.rs index 5b37def..56c6f85 100644 --- a/crates/rumycelium-calibration/src/store.rs +++ b/crates/rucelium-calibration/src/store.rs @@ -2,7 +2,7 @@ //! (ADR-264 §12 items 1–3). use crate::error::CalibrationError; -use rumycelium_core::{CalibrationRecord, SensorModality}; +use rucelium_core::{CalibrationRecord, SensorModality}; use std::collections::BTreeMap; /// Whether a lineage root with this method counts as anchored: only records @@ -51,7 +51,7 @@ impl CalibrationStore { pub fn insert(&mut self, record: CalibrationRecord) -> Result<(), CalibrationError> { record.validate()?; if self.records.contains_key(&record.calibration_id) { - return Err(CalibrationError::Core(rumycelium_core::EnvError::Invalid( + return Err(CalibrationError::Core(rucelium_core::EnvError::Invalid( format!( "calibration id {} already exists; records are immutable", record.calibration_id @@ -152,7 +152,7 @@ impl CalibrationStore { #[cfg(test)] mod tests { use super::*; - use rumycelium_core::calibration::Q16_ONE; + use rucelium_core::calibration::Q16_ONE; fn record(id: u32, method: &str, parent_id: Option, created_ns: u64) -> CalibrationRecord { CalibrationRecord { diff --git a/crates/rumycelium-core/Cargo.toml b/crates/rucelium-core/Cargo.toml similarity index 61% rename from crates/rumycelium-core/Cargo.toml rename to crates/rucelium-core/Cargo.toml index ac7ef7f..31439b3 100644 --- a/crates/rumycelium-core/Cargo.toml +++ b/crates/rucelium-core/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "rumycelium-core" +name = "rucelium-core" version.workspace = true edition.workspace = true -description = "RuMycelium federated environmental fabric core data model: EnvSample, EnvFrame, CalibrationRecord, EnvironmentalEvent, SensorModality, GeoPoint, DataClass (ADR-264 §5/§7/§10)" +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 diff --git a/crates/rumycelium-core/src/calibration.rs b/crates/rucelium-core/src/calibration.rs similarity index 97% rename from crates/rumycelium-core/src/calibration.rs rename to crates/rucelium-core/src/calibration.rs index 56eadbe..0fdf3a7 100644 --- a/crates/rumycelium-core/src/calibration.rs +++ b/crates/rucelium-core/src/calibration.rs @@ -9,7 +9,7 @@ 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 -/// `rumycelium-calibration`. +/// `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: @@ -115,8 +115,8 @@ mod tests { 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 + 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, diff --git a/crates/rumycelium-core/src/error.rs b/crates/rucelium-core/src/error.rs similarity index 96% rename from crates/rumycelium-core/src/error.rs rename to crates/rucelium-core/src/error.rs index 5557622..f4181b7 100644 --- a/crates/rumycelium-core/src/error.rs +++ b/crates/rucelium-core/src/error.rs @@ -1,4 +1,4 @@ -//! Core error type for RuMycelium data-model validation (ADR-264 §7.1). +//! Core error type for RuCelium data-model validation (ADR-264 §7.1). use std::fmt; diff --git a/crates/rumycelium-core/src/event.rs b/crates/rucelium-core/src/event.rs similarity index 95% rename from crates/rumycelium-core/src/event.rs rename to crates/rucelium-core/src/event.rs index 4a06318..a2b1048 100644 --- a/crates/rumycelium-core/src/event.rs +++ b/crates/rucelium-core/src/event.rs @@ -7,10 +7,8 @@ use crate::modality::{DataClass, SensorModality}; use serde::{Deserialize, Serialize}; /// Event severity ladder. RF-only evidence may never exceed `Advisory` -/// (ADR-264 §8) — enforced by `rumycelium-worldgraph`. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, -)] +/// (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. @@ -175,6 +173,9 @@ mod tests { fn empty_evidence_rejected() { let mut e = event(); e.evidence.clear(); - assert!(matches!(e.validate(), Err(EnvError::MissingField("evidence")))); + assert!(matches!( + e.validate(), + Err(EnvError::MissingField("evidence")) + )); } } diff --git a/crates/rumycelium-core/src/geo.rs b/crates/rucelium-core/src/geo.rs similarity index 100% rename from crates/rumycelium-core/src/geo.rs rename to crates/rucelium-core/src/geo.rs diff --git a/crates/rumycelium-core/src/lib.rs b/crates/rucelium-core/src/lib.rs similarity index 71% rename from crates/rumycelium-core/src/lib.rs rename to crates/rucelium-core/src/lib.rs index fa499e9..4decc3a 100644 --- a/crates/rumycelium-core/src/lib.rs +++ b/crates/rucelium-core/src/lib.rs @@ -1,6 +1,6 @@ -//! # rumycelium-core +//! # rucelium-core //! -//! Core data model for **RuMycelium** — the federated environmental +//! 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`] @@ -9,10 +9,10 @@ //! //! Nothing in this crate touches hardware or the network. All numbers in the //! v0.1 reference stack come from a deterministic **synthetic** biome -//! simulator (`rumycelium-bench`) — nothing here claims field-validated +//! simulator (`rucelium-bench`) — nothing here claims field-validated //! accuracy. -#![doc(html_root_url = "https://docs.rs/rumycelium-core/0.1.0")] +#![doc(html_root_url = "https://docs.rs/rucelium-core/0.1.0")] pub mod calibration; pub mod error; @@ -28,5 +28,5 @@ pub use geo::GeoPoint; pub use modality::{DataClass, Residency, SensorModality}; pub use sample::{EnvFrame, EnvSample, SampleProvenance, Uncertainty}; -/// Wire spec version for the RuMycelium fabric (ADR-264). -pub const SPEC_VERSION: &str = "rumycelium.fabric.v0.1"; +/// Wire spec version for the RuCelium fabric (ADR-264). +pub const SPEC_VERSION: &str = "rucelium.fabric.v0.1"; diff --git a/crates/rumycelium-core/src/modality.rs b/crates/rucelium-core/src/modality.rs similarity index 100% rename from crates/rumycelium-core/src/modality.rs rename to crates/rucelium-core/src/modality.rs diff --git a/crates/rumycelium-core/src/sample.rs b/crates/rucelium-core/src/sample.rs similarity index 98% rename from crates/rumycelium-core/src/sample.rs rename to crates/rucelium-core/src/sample.rs index 2931ea0..dde8df7 100644 --- a/crates/rumycelium-core/src/sample.rs +++ b/crates/rucelium-core/src/sample.rs @@ -36,7 +36,7 @@ impl Uncertainty { /// 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 (`rumycelium-abi::SignedEnvRecordV1`); after verification the +/// 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 { @@ -86,7 +86,7 @@ pub struct EnvSample { pub uncertainty: Uncertainty, /// Calibration record applied to produce `value` (0 = uncalibrated). pub calibration_id: u32, - /// Wire flags (bit 0 = retransmit-after-outage; see `rumycelium-abi`). + /// Wire flags (bit 0 = retransmit-after-outage; see `rucelium-abi`). pub flags: u16, /// Battery level at measurement time, millivolts. pub battery_mv: u16, diff --git a/crates/rumycelium-federation/Cargo.toml b/crates/rucelium-federation/Cargo.toml similarity index 59% rename from crates/rumycelium-federation/Cargo.toml rename to crates/rucelium-federation/Cargo.toml index d3ef79b..31d87ec 100644 --- a/crates/rumycelium-federation/Cargo.toml +++ b/crates/rucelium-federation/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "rumycelium-federation" +name = "rucelium-federation" version.workspace = true edition.workspace = true -description = "RuMycelium biome sovereignty: outage buffer with duplicate-free replay, signed regional summaries, device revocation, disclosure coarsening, OGC SensorThings projection (ADR-264 §6/§7/§10)" +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 @@ -10,7 +10,7 @@ keywords = ["environmental", "federation", "sensorthings", "sovereignty"] categories = ["science"] [dependencies] -rumycelium-core = { workspace = true } +rucelium-core = { workspace = true } ed25519-dalek = { workspace = true } sha2 = { workspace = true } serde = { workspace = true } diff --git a/crates/rumycelium-federation/src/biome.rs b/crates/rucelium-federation/src/biome.rs similarity index 99% rename from crates/rumycelium-federation/src/biome.rs rename to crates/rucelium-federation/src/biome.rs index c9a76e4..0e1c708 100644 --- a/crates/rumycelium-federation/src/biome.rs +++ b/crates/rucelium-federation/src/biome.rs @@ -4,7 +4,7 @@ use crate::sig; use ed25519_dalek::{Signature, Signer as _, SigningKey}; -use rumycelium_core::{ +use rucelium_core::{ DataClass, EnvSample, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, SPEC_VERSION, }; diff --git a/crates/rumycelium-federation/src/buffer.rs b/crates/rucelium-federation/src/buffer.rs similarity index 99% rename from crates/rumycelium-federation/src/buffer.rs rename to crates/rucelium-federation/src/buffer.rs index 041906b..c438f54 100644 --- a/crates/rumycelium-federation/src/buffer.rs +++ b/crates/rucelium-federation/src/buffer.rs @@ -7,7 +7,7 @@ //! form, so a gateway restart (serialize → deserialize) never reintroduces a //! sample it already buffered. -use rumycelium_core::EnvSample; +use rucelium_core::EnvSample; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; diff --git a/crates/rumycelium-federation/src/lib.rs b/crates/rucelium-federation/src/lib.rs similarity index 91% rename from crates/rumycelium-federation/src/lib.rs rename to crates/rucelium-federation/src/lib.rs index 9d05bc6..f100fb9 100644 --- a/crates/rumycelium-federation/src/lib.rs +++ b/crates/rucelium-federation/src/lib.rs @@ -1,6 +1,6 @@ -//! # rumycelium-federation +//! # rucelium-federation //! -//! Biome sovereignty for the RuMycelium fabric (ADR-264 §6, §7, §10, §12): +//! Biome sovereignty for the RuCelium fabric (ADR-264 §6, §7, §10, §12): //! //! - [`OutageBuffer`] — gateway store-and-forward log with duplicate-free //! replay across restarts (§14 criteria 2–3), @@ -16,7 +16,7 @@ //! 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/rumycelium-federation/0.1.0")] +#![doc(html_root_url = "https://docs.rs/rucelium-federation/0.1.0")] pub mod biome; pub mod buffer; @@ -81,7 +81,7 @@ pub(crate) mod sig { #[cfg(test)] pub(crate) mod testutil { - use rumycelium_core::{EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty}; + use rucelium_core::{EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty}; /// A valid, verified test sample. pub(crate) fn sample(node_id: u64, sequence: u32, measured_ns: u64, value: f64) -> EnvSample { @@ -110,5 +110,5 @@ pub(crate) mod testutil { } /// A deterministic 32-byte signer seed for tests. - pub(crate) const SEED: &[u8; 32] = b"rumycelium-test-seed-32-bytes-ok"; + pub(crate) const SEED: &[u8; 32] = b"rucelium-test-seed-32-bytes-ok!!"; } diff --git a/crates/rumycelium-federation/src/sensorthings.rs b/crates/rucelium-federation/src/sensorthings.rs similarity index 98% rename from crates/rumycelium-federation/src/sensorthings.rs rename to crates/rucelium-federation/src/sensorthings.rs index db9300f..860817a 100644 --- a/crates/rumycelium-federation/src/sensorthings.rs +++ b/crates/rucelium-federation/src/sensorthings.rs @@ -6,7 +6,7 @@ //! v0.1 implements the biome → SensorThings *projection*; serving these //! entities over HTTP is a follow-up. -use rumycelium_core::{EnvSample, GeoPoint}; +use rucelium_core::{EnvSample, GeoPoint}; use serde::{Deserialize, Serialize}; /// GeoJSON `Point` geometry as embedded in `Location.location` and @@ -195,7 +195,7 @@ pub fn project_sample(sample: &EnvSample) -> SensorThingsBundle { iot_id: thing_id.clone(), name: format!("spore-node-{}", sample.node_id), description: format!( - "RuMycelium spore node {} ({})", + "RuCelium spore node {} ({})", sample.node_id, sample.modality.as_str() ), @@ -219,7 +219,7 @@ pub fn project_sample(sample: &EnvSample) -> SensorThingsBundle { observed_property: ObservedProperty { iot_id: observed_property_id.clone(), name: sample.observed_property.clone(), - definition: format!("urn:rumycelium:property:{}", sample.observed_property), + definition: format!("urn:rucelium:property:{}", sample.observed_property), description: format!( "{} observed by the {} modality", sample.observed_property, diff --git a/crates/rumycelium-federation/src/summary.rs b/crates/rucelium-federation/src/summary.rs similarity index 98% rename from crates/rumycelium-federation/src/summary.rs rename to crates/rucelium-federation/src/summary.rs index fecff91..b5a2a05 100644 --- a/crates/rumycelium-federation/src/summary.rs +++ b/crates/rucelium-federation/src/summary.rs @@ -5,7 +5,7 @@ use crate::biome::{verify_event, Biome}; use crate::sig; use ed25519_dalek::{Signature, Signer as _}; -use rumycelium_core::EnvironmentalEvent; +use rucelium_core::EnvironmentalEvent; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -119,7 +119,7 @@ impl Biome { .collect(); let mut summary = RegionalSummary { - spec_version: rumycelium_core::SPEC_VERSION.into(), + spec_version: rucelium_core::SPEC_VERSION.into(), biome_id: self.config().biome_id.clone(), window_start_ns, window_end_ns, @@ -256,7 +256,7 @@ mod tests { fn summarize_produces_exact_stats() { let b = biome_with_data(); let s = b.summarize(0, 5_000); - assert_eq!(s.spec_version, rumycelium_core::SPEC_VERSION); + assert_eq!(s.spec_version, rucelium_core::SPEC_VERSION); assert_eq!(s.biome_id, "biome/test-forest"); let w = &s.stats["weather"]; assert_eq!(w.count, 3); diff --git a/crates/rumycelium-ingest/Cargo.toml b/crates/rucelium-ingest/Cargo.toml similarity index 54% rename from crates/rumycelium-ingest/Cargo.toml rename to crates/rucelium-ingest/Cargo.toml index f33f6b0..1a2f0f4 100644 --- a/crates/rumycelium-ingest/Cargo.toml +++ b/crates/rucelium-ingest/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "rumycelium-ingest" +name = "rucelium-ingest" version.workspace = true edition.workspace = true -description = "RuMycelium rhizome-gateway ingest pipeline: envelope decode, signature + revocation + replay-window checks, normalization into EnvSample (ADR-264 §5)" +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 @@ -10,8 +10,8 @@ keywords = ["environmental", "gateway", "ingest", "security"] categories = ["science"] [dependencies] -rumycelium-core = { workspace = true } -rumycelium-abi = { workspace = true } +rucelium-core = { workspace = true } +rucelium-abi = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/rumycelium-ingest/src/lib.rs b/crates/rucelium-ingest/src/lib.rs similarity index 98% rename from crates/rumycelium-ingest/src/lib.rs rename to crates/rucelium-ingest/src/lib.rs index f4f3bc9..6ef5a6d 100644 --- a/crates/rumycelium-ingest/src/lib.rs +++ b/crates/rucelium-ingest/src/lib.rs @@ -1,9 +1,9 @@ -//! # rumycelium-ingest +//! # 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 [`rumycelium_core::EnvSample`]. +//! 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 @@ -16,10 +16,10 @@ //! 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/rumycelium-ingest/0.1.0")] +#![doc(html_root_url = "https://docs.rs/rucelium-ingest/0.1.0")] -use rumycelium_abi::{verify_record, RvEnvSampleV1, SignedEnvRecordV1}; -use rumycelium_core::EnvSample; +use rucelium_abi::{verify_record, RvEnvSampleV1, SignedEnvRecordV1}; +use rucelium_core::EnvSample; use serde::Serialize; use std::collections::BTreeMap; use std::fmt; @@ -492,10 +492,10 @@ impl IngestPipeline { #[cfg(test)] mod tests { use super::*; - use rumycelium_abi::{NodeSigner, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SCHEMA_V1}; - use rumycelium_core::SensorModality; + use rucelium_abi::{NodeSigner, RV_ENV_FLAG_RETRANSMIT, RV_ENV_SCHEMA_V1}; + use rucelium_core::SensorModality; - const SEED: &[u8; 32] = b"rumycelium-provision-seed-32-by!"; + 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; diff --git a/crates/rumycelium-policy/Cargo.toml b/crates/rucelium-policy/Cargo.toml similarity index 58% rename from crates/rumycelium-policy/Cargo.toml rename to crates/rucelium-policy/Cargo.toml index 437c04b..75f643f 100644 --- a/crates/rumycelium-policy/Cargo.toml +++ b/crates/rucelium-policy/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "rumycelium-policy" +name = "rucelium-policy" version.workspace = true edition.workspace = true -description = "RuMycelium 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)" +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 @@ -10,7 +10,7 @@ keywords = ["environmental", "governance", "agents", "policy"] categories = ["science"] [dependencies] -rumycelium-core = { workspace = true } +rucelium-core = { workspace = true } ed25519-dalek = { workspace = true } sha2 = { workspace = true } serde = { workspace = true } diff --git a/crates/rumycelium-policy/src/audit.rs b/crates/rucelium-policy/src/audit.rs similarity index 100% rename from crates/rumycelium-policy/src/audit.rs rename to crates/rucelium-policy/src/audit.rs diff --git a/crates/rumycelium-policy/src/lib.rs b/crates/rucelium-policy/src/lib.rs similarity index 97% rename from crates/rumycelium-policy/src/lib.rs rename to crates/rucelium-policy/src/lib.rs index 1288493..773a6db 100644 --- a/crates/rumycelium-policy/src/lib.rs +++ b/crates/rucelium-policy/src/lib.rs @@ -1,6 +1,6 @@ -//! # rumycelium-policy +//! # rucelium-policy //! -//! The **governed control path** of the RuMycelium fabric (ADR-264 §9). +//! 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: //! @@ -31,7 +31,7 @@ //! not compile: //! //! ```compile_fail -//! use rumycelium_policy::{AgentProposal, AuditTrail, GatewayValidator, ProposalKind}; +//! use rucelium_policy::{AgentProposal, AuditTrail, GatewayValidator, ProposalKind}; //! //! let mut gateway = GatewayValidator::new(vec![]); //! let mut audit = AuditTrail::new(); @@ -65,7 +65,7 @@ //! order: `"proposed"`, `"policy_evaluated"`, `"safety_simulated"`, //! `"authorized"`, `"signed"`, `"gateway_validated"`, `"executed"`. -#![doc(html_root_url = "https://docs.rs/rumycelium-policy/0.1.0")] +#![doc(html_root_url = "https://docs.rs/rucelium-policy/0.1.0")] pub mod audit; pub mod pipeline; @@ -145,10 +145,10 @@ impl std::error::Error for ControlError {} #[cfg(test)] mod tests { use super::*; - use rumycelium_core::GeoPoint; + use rucelium_core::GeoPoint; - const SEED: &[u8; 32] = b"rumycelium-test-seed-32-bytes-ok"; - const OTHER_SEED: &[u8; 32] = b"rumycelium-EVIL-seed-32-bytes-ok"; + const SEED: &[u8; 32] = b"rucelium-test-seed-32-bytes-ok!!"; + const OTHER_SEED: &[u8; 32] = b"rucelium-EVIL-seed-32-bytes-ok!!"; fn actuator_proposal(magnitude: f64) -> AgentProposal { AgentProposal { diff --git a/crates/rumycelium-policy/src/pipeline.rs b/crates/rucelium-policy/src/pipeline.rs similarity index 100% rename from crates/rumycelium-policy/src/pipeline.rs rename to crates/rucelium-policy/src/pipeline.rs diff --git a/crates/rumycelium-policy/src/proposal.rs b/crates/rucelium-policy/src/proposal.rs similarity index 98% rename from crates/rumycelium-policy/src/proposal.rs rename to crates/rucelium-policy/src/proposal.rs index d7dac6b..1b8bb22 100644 --- a/crates/rumycelium-policy/src/proposal.rs +++ b/crates/rucelium-policy/src/proposal.rs @@ -6,7 +6,7 @@ //! public constructor, so an agent physically cannot fabricate something the //! gateway would execute. -use rumycelium_core::GeoPoint; +use rucelium_core::GeoPoint; use serde::{Deserialize, Serialize}; /// What an agent is asking the fabric to do. diff --git a/crates/rumycelium-worldgraph/Cargo.toml b/crates/rucelium-worldgraph/Cargo.toml similarity index 59% rename from crates/rumycelium-worldgraph/Cargo.toml rename to crates/rucelium-worldgraph/Cargo.toml index 5ec0fc6..2203464 100644 --- a/crates/rumycelium-worldgraph/Cargo.toml +++ b/crates/rucelium-worldgraph/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "rumycelium-worldgraph" +name = "rucelium-worldgraph" version.workspace = true edition.workspace = true -description = "RuMycelium environmental WorldGraph: typed sensor/ecosystem nodes, geospatial registration, evidence + contradiction edges, RuView RF context bridge (ADR-264 §5.2/§8)" +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 @@ -10,7 +10,7 @@ keywords = ["environmental", "graph", "digital-twin", "fusion"] categories = ["science"] [dependencies] -rumycelium-core = { workspace = true } +rucelium-core = { workspace = true } rufield-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/rumycelium-worldgraph/src/graph.rs b/crates/rucelium-worldgraph/src/graph.rs similarity index 99% rename from crates/rumycelium-worldgraph/src/graph.rs rename to crates/rucelium-worldgraph/src/graph.rs index 34a913e..8cfde28 100644 --- a/crates/rumycelium-worldgraph/src/graph.rs +++ b/crates/rucelium-worldgraph/src/graph.rs @@ -2,7 +2,7 @@ //! registration, typed evidence edges, contradiction tracking, and JSON //! persistence (ADR-139 heritage). -use rumycelium_core::{EnvSample, GeoPoint, SensorModality}; +use rucelium_core::{EnvSample, GeoPoint, SensorModality}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fmt; @@ -309,7 +309,7 @@ pub fn haversine_m(a: GeoPoint, b: GeoPoint) -> f64 { #[cfg(test)] mod tests { use super::*; - use rumycelium_core::{SampleProvenance, Uncertainty}; + use rucelium_core::{SampleProvenance, Uncertainty}; pub(crate) fn sample(node_id: u64) -> EnvSample { EnvSample { diff --git a/crates/rumycelium-worldgraph/src/lib.rs b/crates/rucelium-worldgraph/src/lib.rs similarity index 84% rename from crates/rumycelium-worldgraph/src/lib.rs rename to crates/rucelium-worldgraph/src/lib.rs index 2941b54..7215ff7 100644 --- a/crates/rumycelium-worldgraph/src/lib.rs +++ b/crates/rucelium-worldgraph/src/lib.rs @@ -1,6 +1,6 @@ -//! # rumycelium-worldgraph +//! # rucelium-worldgraph //! -//! The RuMycelium environmental **WorldGraph** (ADR-264 §5.2) and the RuView +//! 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 @@ -9,7 +9,7 @@ //! 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 -//! [`rumycelium_core::EnvSample`]. +//! [`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: @@ -19,7 +19,7 @@ //! > sole basis for an event above `Advisory` severity, and they are never //! > ground truth. -#![doc(html_root_url = "https://docs.rs/rumycelium-worldgraph/0.1.0")] +#![doc(html_root_url = "https://docs.rs/rucelium-worldgraph/0.1.0")] pub mod graph; pub mod rf; diff --git a/crates/rumycelium-worldgraph/src/rf.rs b/crates/rucelium-worldgraph/src/rf.rs similarity index 98% rename from crates/rumycelium-worldgraph/src/rf.rs rename to crates/rucelium-worldgraph/src/rf.rs index 98f3ee6..a39b909 100644 --- a/crates/rumycelium-worldgraph/src/rf.rs +++ b/crates/rucelium-worldgraph/src/rf.rs @@ -3,12 +3,12 @@ //! **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 [`rumycelium_core::Severity::Advisory`], and +//! 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 rumycelium_core::{GeoPoint, SensorModality, Severity}; +use rucelium_core::{GeoPoint, SensorModality, Severity}; use serde::{Deserialize, Serialize}; /// Hard cap on the weight of any RF-derived evidence edge (ADR-264 §8). @@ -177,13 +177,13 @@ pub fn rf_only_severity_cap(severity: Severity) -> Severity { #[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, }; - use rumycelium_core::EnvSample; - use rumycelium_core::SampleProvenance; - use rumycelium_core::Uncertainty; fn field_event(modality: Modality, motion_energy: f32, confidence: f32) -> FieldEvent { let tensor = FieldTensor::new( diff --git a/crates/rumycelium-bench/Cargo.toml b/crates/rumycelium-bench/Cargo.toml deleted file mode 100644 index 4ce9d96..0000000 --- a/crates/rumycelium-bench/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "rumycelium-bench" -version.workspace = true -edition.workspace = true -description = "RuMycelium 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 = "rumycelium-bench" -path = "src/main.rs" - -[dependencies] -rumycelium-core = { workspace = true } -rumycelium-abi = { workspace = true } -rumycelium-ingest = { workspace = true } -rumycelium-calibration = { workspace = true } -rumycelium-worldgraph = { workspace = true } -rumycelium-policy = { workspace = true } -rumycelium-federation = { workspace = true } -rufield-core = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/rumycelium-bench/src/lib.rs b/crates/rumycelium-bench/src/lib.rs deleted file mode 100644 index c47994e..0000000 --- a/crates/rumycelium-bench/src/lib.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! # rumycelium-bench -//! -//! Deterministic **SYNTHETIC** biome benchmark for RuMycelium (ADR-264 §14). - -pub mod report; -pub mod sim; - -pub use report::{BiomeReport, Criterion}; -pub use sim::{BiomeSim, Emission, EmissionKind, SimConfig, DEFAULT_SEED}; - -/// Run the full ADR-264 §14 acceptance benchmark. (Runner lands with the -/// mid-layer crates.) -#[must_use] -pub fn run(_config: SimConfig) -> BiomeReport { - unimplemented!("runner lands after the mid-layer crates") -} diff --git a/docs/ADR-264-rumycelium-federated-fabric.md b/docs/ADR-264-rucelium-federated-fabric.md similarity index 87% rename from docs/ADR-264-rumycelium-federated-fabric.md rename to docs/ADR-264-rucelium-federated-fabric.md index 09a39ed..eab4604 100644 --- a/docs/ADR-264-rumycelium-federated-fabric.md +++ b/docs/ADR-264-rucelium-federated-fabric.md @@ -1,4 +1,4 @@ -# ADR 264: RuMycelium Federated Environmental Intelligence Fabric +# ADR 264: RuCelium Federated Environmental Intelligence Fabric Status: Accepted — v0.1 reference stack @@ -42,7 +42,7 @@ placement, and model domain shift. ## 2. Decision -Create **RuMycelium**, a federated environmental intelligence fabric — not a +Create **RuCelium**, a federated environmental intelligence fabric — not a global peer mesh. Four layers, each with a sovereignty boundary: ```text @@ -115,18 +115,18 @@ The specified crate family and where v0.1 implements each concern: | Specified crate | v0.1 home | Notes | |---|---|---| -| `rumycelium-core` | `rumycelium-core` | domain model: `EnvSample`, `EnvFrame`, `CalibrationRecord`, `EnvironmentalEvent`, `SensorModality`, `GeoPoint`, `DataClass` | -| `rumycelium-c-ffi` | `rumycelium-abi` | versioned C ABI (`rv_env_sample_v1`), bounds-checked alloc-free parse, deterministic CBOR, signed record envelope, shipped C header | -| `rumycelium-ingest` | `rumycelium-ingest` | gateway pipeline: parse → verify → replay-window → normalize | -| `rumycelium-calibration` | `rumycelium-calibration` | lineage chains, drift detection, quarantine (never silent correction) | -| `rumycelium-ruview` | `rumycelium-worldgraph::rf` | RuField `FieldEvent` → contextual evidence bridge | -| `rumycelium-fusion` | `rumycelium-worldgraph` | evidence edges, plausibility checks, contradiction tracking | -| `rumycelium-worldgraph` | `rumycelium-worldgraph` | typed nodes, geospatial registration, evidence/contradiction edges | -| `rumycelium-store` | `rumycelium-federation::buffer` | outage buffer with deterministic duplicate-free replay | -| `rumycelium-policy` | `rumycelium-policy` | governed control path (§9), typed so steps cannot be skipped | -| `rumycelium-federation` | `rumycelium-federation` | biome sovereignty, signed summaries, revocation, SensorThings projection | -| `rumycelium-agent` | `rumycelium-policy::agent` | agent proposal types; agents never touch actuators directly | -| `rumycelium-cli` | `rumycelium-bench` (bin) | v0.1 CLI is the deterministic biome benchmark runner | +| `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. @@ -188,7 +188,7 @@ centralized ownership**. It exposes: 8. Sovereign private namespaces v0.1 implements the SensorThings **projection** (biome → SensorThings JSON -entities) in `rumycelium-federation::sensorthings`; serving it over HTTP is a +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 @@ -236,7 +236,7 @@ the **generalization problem remains unresolved**. Therefore, normatively: > 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 (`rumycelium-worldgraph::rf`) ingests RuField `FieldEvent`s (which +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. @@ -263,7 +263,7 @@ Agent proposal → execution receipt ``` -v0.1 enforces this **by construction**: `rumycelium-policy` types each stage's +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. @@ -326,7 +326,7 @@ 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/rumycelium-abi/include/rumycelium_env.h`](../crates/rumycelium-abi/include/rumycelium_env.h). +[`crates/rucelium-abi/include/rucelium_env.h`](../crates/rucelium-abi/include/rucelium_env.h). ### 11.2 Serialization and signing @@ -396,8 +396,8 @@ A 64-node pilot passes when it: 7. revokes one compromised device without interrupting the biome, 8. maintains ≥ 95 % usable calibrated observations. -`cargo run -p rumycelium-bench` prints the scorecard; -`cargo test -p rumycelium-bench` asserts all eight criteria plus determinism +`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 @@ -432,12 +432,12 @@ until a real 64-node deployment exists. | # | Criterion | Status | |---|---|---| -| 1 | Core domain model (§5.1) | shipped — `rumycelium-core` | -| 2 | C ABI + deterministic CBOR + header (§11) | shipped — `rumycelium-abi` | -| 3 | Gateway ingest: verify/replay-window/normalize (§5) | shipped — `rumycelium-ingest` | -| 4 | Calibration lineage + drift + quarantine (§12) | shipped — `rumycelium-calibration` | -| 5 | WorldGraph env nodes + RF context bridge (§5.2, §8) | shipped — `rumycelium-worldgraph` | -| 6 | Governed control path, typed stages (§9) | shipped — `rumycelium-policy` | -| 7 | Biome sovereignty, outage buffer, summaries, revocation, SensorThings projection (§6, §7, §10) | shipped — `rumycelium-federation` | -| 8 | 64-node biome acceptance benchmark (§14) | shipped — `rumycelium-bench` | +| 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/harness/README.md b/harness/README.md new file mode 100644 index 0000000..399be57 --- /dev/null +++ b/harness/README.md @@ -0,0 +1,80 @@ +# 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). + +## 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" + ] +} From 4907b2a5e197978df2f1e7e3d6e1e8a7d89eb659 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:06:06 +0000 Subject: [PATCH 05/27] =?UTF-8?q?feat(rucelium):=20ADR-265=20runtime=20lay?= =?UTF-8?q?er=20=E2=80=94=20scaffolding,=20docs,=20README=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/ADR-265-rucelium-runtime.md: gateway daemon, compact envelope v2 (LoRaWAN DR0 fit), segmented durable store, federation-over-network, no_std ABI surface decisions - workspace scaffolding for rucelium-store / rucelium-transport / rucelium-gateway (implementations landing in follow-up commits) - README: runtime section with gateway quickstart + two-biome recipe Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.lock | 37 ++++++++ Cargo.toml | 6 ++ README.md | 29 +++++++ crates/rucelium-gateway/Cargo.toml | 36 ++++++++ crates/rucelium-gateway/src/lib.rs | 1 + crates/rucelium-gateway/src/main.rs | 1 + crates/rucelium-store/Cargo.toml | 18 ++++ crates/rucelium-store/src/lib.rs | 1 + crates/rucelium-transport/Cargo.toml | 17 ++++ crates/rucelium-transport/src/lib.rs | 1 + docs/ADR-265-rucelium-runtime.md | 125 +++++++++++++++++++++++++++ 11 files changed, 272 insertions(+) create mode 100644 crates/rucelium-gateway/Cargo.toml create mode 100644 crates/rucelium-gateway/src/lib.rs create mode 100644 crates/rucelium-gateway/src/main.rs create mode 100644 crates/rucelium-store/Cargo.toml create mode 100644 crates/rucelium-store/src/lib.rs create mode 100644 crates/rucelium-transport/Cargo.toml create mode 100644 crates/rucelium-transport/src/lib.rs create mode 100644 docs/ADR-265-rucelium-runtime.md diff --git a/Cargo.lock b/Cargo.lock index 5338a80..ebc1410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -947,6 +947,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "rucelium-gateway" +version = "0.1.0" +dependencies = [ + "axum", + "reqwest", + "rucelium-abi", + "rucelium-calibration", + "rucelium-core", + "rucelium-federation", + "rucelium-ingest", + "rucelium-store", + "rucelium-transport", + "rucelium-worldgraph", + "serde", + "serde_json", + "tokio", + "tower", +] + [[package]] name = "rucelium-ingest" version = "0.1.0" @@ -968,6 +988,23 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index a9df3ab..46bfc5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ members = [ "crates/rucelium-policy", "crates/rucelium-federation", "crates/rucelium-bench", + "crates/rucelium-store", + "crates/rucelium-transport", + "crates/rucelium-gateway", ] [workspace.package] @@ -51,6 +54,9 @@ 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" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index d21605f..fd9bfc9 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,35 @@ cargo run -p rucelium-bench -- 2026 --json > 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 diff --git a/crates/rucelium-gateway/Cargo.toml b/crates/rucelium-gateway/Cargo.toml new file mode 100644 index 0000000..f99f8cd --- /dev/null +++ b/crates/rucelium-gateway/Cargo.toml @@ -0,0 +1,36 @@ +[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" + +[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-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"] } + +[dev-dependencies] +tower = "0.5" + +[lints] +workspace = true diff --git a/crates/rucelium-gateway/src/lib.rs b/crates/rucelium-gateway/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rucelium-gateway/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/rucelium-gateway/src/main.rs b/crates/rucelium-gateway/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/rucelium-gateway/src/main.rs @@ -0,0 +1 @@ +fn main() {} 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/lib.rs b/crates/rucelium-store/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rucelium-store/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/rucelium-transport/Cargo.toml b/crates/rucelium-transport/Cargo.toml new file mode 100644 index 0000000..5ba261e --- /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 113-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/lib.rs b/crates/rucelium-transport/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rucelium-transport/src/lib.rs @@ -0,0 +1 @@ +//! placeholder 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 | From 8d3f722bc399fc508229e4f6519af26c62601c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:12:43 +0000 Subject: [PATCH 06/27] =?UTF-8?q?feat(rucelium):=20durable=20store,=20cons?= =?UTF-8?q?trained=20transport,=20no=5Fstd=20ABI=20(ADR-265=20=C2=A72/?= =?UTF-8?q?=C2=A73/=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rucelium-store: append-only segmented JSONL store — dedup index rebuilt on open, torn-tail crash recovery (truncate-and-continue), deterministic replay, whole-segment retention enforcement, stats (13 tests) - rucelium-transport: compact envelope v2 (114 B packed, pubkey by reference — v1's ~150 B cannot fit LoRaWAN DR0) + 6-byte-header MTU fragmentation with loss/dup/reorder-tolerant reassembler; a compact envelope is exactly 3 DR0 datagrams (25 tests incl. full tamper sweep) - rucelium-abi: std default feature; --no-default-features [--features alloc] compiles the wire format (+ CBOR with alloc) for no_std spore targets; local range constants pinned to the core registry by test Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-abi/Cargo.toml | 16 +- crates/rucelium-abi/src/cbor.rs | 5 +- crates/rucelium-abi/src/lib.rs | 8 + crates/rucelium-abi/src/wire.rs | 43 +- crates/rucelium-store/src/events.rs | 219 +++++++++ crates/rucelium-store/src/lib.rs | 170 ++++++- crates/rucelium-store/src/observations.rs | 468 ++++++++++++++++++ crates/rucelium-store/src/segment.rs | 142 ++++++ crates/rucelium-transport/Cargo.toml | 2 +- crates/rucelium-transport/src/envelope.rs | 272 +++++++++++ crates/rucelium-transport/src/frag.rs | 552 ++++++++++++++++++++++ crates/rucelium-transport/src/lib.rs | 156 +++++- 12 files changed, 2040 insertions(+), 13 deletions(-) create mode 100644 crates/rucelium-store/src/events.rs create mode 100644 crates/rucelium-store/src/observations.rs create mode 100644 crates/rucelium-store/src/segment.rs create mode 100644 crates/rucelium-transport/src/envelope.rs create mode 100644 crates/rucelium-transport/src/frag.rs diff --git a/crates/rucelium-abi/Cargo.toml b/crates/rucelium-abi/Cargo.toml index 9e17f7c..139b131 100644 --- a/crates/rucelium-abi/Cargo.toml +++ b/crates/rucelium-abi/Cargo.toml @@ -9,10 +9,20 @@ 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 } -ed25519-dalek = { workspace = true } -sha2 = { workspace = true } +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/src/cbor.rs b/crates/rucelium-abi/src/cbor.rs index 55eff4f..971bb2e 100644 --- a/crates/rucelium-abi/src/cbor.rs +++ b/crates/rucelium-abi/src/cbor.rs @@ -8,7 +8,9 @@ //! signature over the one possible encoding. use crate::wire::{RvEnvSampleV1, RV_ENV_SAMPLE_V1_WIRE_LEN}; -use std::fmt; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; /// CBOR decode errors. #[derive(Debug, Clone, PartialEq, Eq)] @@ -63,6 +65,7 @@ impl fmt::Display for CborError { } } +#[cfg(feature = "std")] impl std::error::Error for CborError {} // --------------------------------------------------------------------------- diff --git a/crates/rucelium-abi/src/lib.rs b/crates/rucelium-abi/src/lib.rs index 5c32347..641bfec 100644 --- a/crates/rucelium-abi/src/lib.rs +++ b/crates/rucelium-abi/src/lib.rs @@ -19,12 +19,20 @@ //! 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/wire.rs b/crates/rucelium-abi/src/wire.rs index 5948995..9532c13 100644 --- a/crates/rucelium-abi/src/wire.rs +++ b/crates/rucelium-abi/src/wire.rs @@ -2,9 +2,18 @@ //! allocation-free parse, field validation, and domain conversion //! (ADR-264 §11.1). -use rucelium_core::geo::{LAT_E7_MAX, LON_E7_MAX}; +use core::fmt; +#[cfg(feature = "std")] use rucelium_core::{EnvSample, GeoPoint, SampleProvenance, SensorModality, Uncertainty}; -use std::fmt; + +/// 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; @@ -44,7 +53,9 @@ pub enum AbiError { QualityOutOfRange(u16), /// Zero measurement timestamp. ZeroTimestamp, - /// Domain validation failed after conversion. + /// Domain validation failed after conversion (std only — conversion into + /// the domain model requires `rucelium-core`). + #[cfg(feature = "std")] Domain(String), } @@ -64,11 +75,13 @@ impl fmt::Display for AbiError { 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)` @@ -185,7 +198,7 @@ impl RvEnvSampleV1 { if self.schema_version != RV_ENV_SCHEMA_V1 { return Err(AbiError::BadSchemaVersion(self.schema_version)); } - if SensorModality::from_code(self.sensor_type).is_none() { + if self.sensor_type > SENSOR_TYPE_MAX { return Err(AbiError::UnknownModality(self.sensor_type)); } if self.latitude_e7.abs() > LAT_E7_MAX { @@ -203,7 +216,9 @@ impl RvEnvSampleV1 { Ok(()) } - /// The modality, if the code is known. + /// 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) @@ -222,10 +237,11 @@ impl RvEnvSampleV1 { } /// Convert a **validated** wire record into an *uncalibrated* domain - /// [`EnvSample`]. The uncertainty starts at the Q16.16 quantization + /// [`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, @@ -371,6 +387,21 @@ mod tests { 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. diff --git a/crates/rucelium-store/src/events.rs b/crates/rucelium-store/src/events.rs new file mode 100644 index 0000000..bbc6838 --- /dev/null +++ b/crates/rucelium-store/src/events.rs @@ -0,0 +1,219 @@ +//! `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::{list_segments, 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 JSON line per event in +/// zero-padded `evt-NNNNNN.jsonl` segments, dedup index rebuilt on open, +/// torn-tail repair on the final segment, flush-per-append durability +/// (crate docs). +pub struct EventStore { + dir: PathBuf, + segment_max_records: usize, + /// 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()) +} + +impl EventStore { + /// Open (or create) an event store at `dir`, scanning existing + /// `evt-*.jsonl` segments to rebuild the dedup index. Recovery rules + /// match [`crate::ObservationStore::open`]; a `segment_max_records` of + /// `0` is treated as `1`. + pub fn open(dir: &Path, segment_max_records: usize) -> Result { + fs::create_dir_all(dir)?; + let listed = list_segments(dir, PREFIX)?; + let n = listed.len(); + let mut seen = BTreeSet::new(); + let mut segments = Vec::with_capacity(n); + let mut next_segment_index = 0u64; + 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 { + seen.insert(e.event_id.clone()); + } + segments.push(SegmentState { + name, + records: records.len(), + }); + next_segment_index = index + 1; + } + Ok(EventStore { + dir: dir.to_path_buf(), + segment_max_records: segment_max_records.max(1), + seen, + segments, + next_segment_index, + }) + } + + /// Append an event, deduplicating by `event_id`. The event is validated + /// first (invalid → [`StoreError::Core`]); the write is flushed after + /// each append (no fsync in v0.1 — crate docs). + 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 line = serde_json::to_string(event).map_err(|e| StoreError::Core(e.to_string()))?; + 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()?; + 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. + 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).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).unwrap(); + store.append(&event("evt-0001", 5_000)).unwrap(); + store.append(&event("evt-0002", 6_000)).unwrap(); + drop(store); + + let mut reopened = EventStore::open(&dir, 2).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 invalid_event_is_a_core_error() { + let dir = temp_dir("evt-invalid"); + let mut store = EventStore::open(&dir, 10).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 index 179adb7..373d9d9 100644 --- a/crates/rucelium-store/src/lib.rs +++ b/crates/rucelium-store/src/lib.rs @@ -1 +1,169 @@ -//! placeholder +//! # 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 (v0.1) +//! +//! * **Durability**: every append is flushed to the OS (`File::flush`) but +//! *not* fsynced — a host power loss may lose the tail, which crash +//! recovery then treats as a torn tail. fsync batching is future work. +//! * **Crash recovery**: on open, a torn (unparsable) *final* line of the +//! *final* segment 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 memory**: dedup keys are kept forever, even after retention +//! deletes their payload segments. Keys are tiny (a `(u64, u32)` pair or a +//! short id string); retention frees payload bytes, not dedup memory. +//! * **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 is now durable in the current segment. + 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 { + 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..5b3f506 --- /dev/null +++ b/crates/rucelium-store/src/observations.rs @@ -0,0 +1,468 @@ +//! `ObservationStore` — the durable, segmented, append-only sample log with +//! a persistent dedup index and retention enforcement (ADR-265 §3). + +use crate::segment::{list_segments, 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. +#[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, +} + +/// 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 JSON line each, in zero-padded segment files +/// `obs-NNNNNN.jsonl` of at most `segment_max_records` records. Only the +/// dedup keys (`(node_id, sequence)`) 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 v0.1 durability, torn-tail, and +/// retention design notes. +pub struct ObservationStore { + dir: PathBuf, + segment_max_records: usize, + /// 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()) +} + +impl ObservationStore { + /// Open (or create) a store at `dir`, scanning existing `obs-*.jsonl` + /// segments in lexicographic order to rebuild the dedup index and + /// segment metadata. + /// + /// Crash recovery: an unparsable **final** line of the **final** segment + /// is truncated away (torn write); a malformed line anywhere else is + /// [`StoreError::Corrupt`]. A `segment_max_records` of `0` is treated + /// as `1`. + pub fn open(dir: &Path, segment_max_records: usize) -> Result { + fs::create_dir_all(dir)?; + let listed = list_segments(dir, PREFIX)?; + let n = listed.len(); + let mut seen = BTreeSet::new(); + let mut segments = Vec::with_capacity(n); + let mut next_segment_index = 0u64; + 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 { + seen.insert(s.dedup_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; + } + Ok(ObservationStore { + dir: dir.to_path_buf(), + segment_max_records: segment_max_records.max(1), + 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 + /// write is flushed to the OS after each append; v0.1 deliberately does + /// not fsync (crate docs). + 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 line = serde_json::to_string(sample).map_err(|e| StoreError::Core(e.to_string()))?; + 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()?; + 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() + } + + /// Full deterministic replay: every stored sample, in append order, + /// read back from disk (the store caches only dedup keys, never + /// payloads). + 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 (crate docs), so an expired sample + /// replayed later 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(), + } + } +} + +#[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).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 invalid_sample_is_a_core_error() { + let dir = temp_dir("invalid"); + let mut store = ObservationStore::open(&dir, 100).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).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).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).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 torn_tail_is_truncated_on_open() { + let dir = temp_dir("torn"); + let mut store = ObservationStore::open(&dir, 100).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: garbage bytes, no trailing 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"{\"half").unwrap(); + drop(f); + + let mut reopened = ObservationStore::open(&dir, 100).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 corrupt_middle_line_names_segment_and_line() { + let dir = temp_dir("corrupt"); + let mut store = ObservationStore::open(&dir, 100).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).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 retention_deletes_expired_segments_but_never_the_last() { + let dir = temp_dir("retention"); + let mut store = ObservationStore::open(&dir, 2).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).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!(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..54e330b --- /dev/null +++ b/crates/rucelium-store/src/segment.rs @@ -0,0 +1,142 @@ +//! Segment file machinery shared by [`crate::ObservationStore`] and +//! [`crate::EventStore`]: naming, directory scan, and line-oriented reads +//! with torn-tail repair. + +use crate::StoreError; +use serde::Serialize; +use std::fs; +use std::path::Path; + +/// 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, + } + } +} + +/// 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, parsing each line with `parse`. 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), an unparsable **final** line is treated as a crash-torn write: the +/// file is truncated to just before it and the scan succeeds. Any other +/// malformed line — and any malformed line when `repair_torn_tail` is unset +/// — is [`StoreError::Corrupt`] with a 1-based line number. +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 end = bytes[offset..] + .iter() + .position(|&b| b == b'\n') + .map_or(bytes.len(), |p| offset + p); + let parsed = std::str::from_utf8(&bytes[offset..end]) + .map_err(|e| e.to_string()) + .and_then(&parse); + match parsed { + Ok(record) => records.push(record), + Err(reason) => { + // Final line iff nothing follows it but (at most) its '\n'. + let is_final_line = end + 1 >= bytes.len(); + if repair_torn_tail && is_final_line { + let file = fs::OpenOptions::new().write(true).open(path)?; + file.set_len(offset as u64)?; + return Ok((records, offset as u64)); + } + return Err(StoreError::Corrupt { + segment: name.to_string(), + line: line_no, + reason, + }); + } + } + offset = end + 1; + } + Ok((records, bytes.len() as u64)) +} + +#[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); + } +} diff --git a/crates/rucelium-transport/Cargo.toml b/crates/rucelium-transport/Cargo.toml index 5ba261e..4a537bd 100644 --- a/crates/rucelium-transport/Cargo.toml +++ b/crates/rucelium-transport/Cargo.toml @@ -2,7 +2,7 @@ name = "rucelium-transport" version.workspace = true edition.workspace = true -description = "RuCelium constrained-link transport: compact 113-byte envelope v2 (pubkey by reference) and MTU fragmentation/reassembly sized for LoRaWAN DR0's 51-byte payload cap (ADR-265 §2)" +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 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 index 179adb7..d6e50b1 100644 --- a/crates/rucelium-transport/src/lib.rs +++ b/crates/rucelium-transport/src/lib.rs @@ -1 +1,155 @@ -//! placeholder +//! # 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:?}"); + } + } +} From 77beba51be90a9e553d8b6d3aae4f9aa8dc5fbe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:33:59 +0000 Subject: [PATCH 07/27] feat(rucelium): VerifiedEnvSample sealed type + honest reference-model labeling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review response, part 1: - rucelium-ingest: VerifiedEnvSample — non-serializable, no public constructor, produced only by the full cryptographic verification paths (ingest / reverify_stored); modify() revalidates before committing; reverify_stored() re-checks stored envelopes without touching the replay window (restore path); prime_from_dedup() rebuilds per-device anti-replay windows from a durable dedup index after restart - rucelium-bench report + README: relabelled as fabric REFERENCE-MODEL acceptance — it scores in-memory library components, not the runtime path (store/transport/gateway), which has its own e2e tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- README.md | 2 +- crates/rucelium-bench/src/report.rs | 12 +- crates/rucelium-ingest/src/lib.rs | 170 +++++++++++++++++++++++----- 3 files changed, 149 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index fd9bfc9..f27babe 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ everything above it is safe Rust. RuView RF joins as a **contextual modality** | [`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 acceptance test. | +| [`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: diff --git a/crates/rucelium-bench/src/report.rs b/crates/rucelium-bench/src/report.rs index 2df7df1..a51072e 100644 --- a/crates/rucelium-bench/src/report.rs +++ b/crates/rucelium-bench/src/report.rs @@ -107,7 +107,7 @@ impl BiomeReport { pub fn to_table(&self) -> String { let mut s = String::new(); s.push_str( - "============ RuCelium v0.1 — Deterministic Biome Benchmark (ADR-264 §14) ============\n", + "====== RuCelium v0.1 — Fabric Reference-Model Acceptance (ADR-264 §14, SYNTHETIC) ======\n", ); s.push_str(&format!( "spec={} seed={} nodes={} days={} offline_days={} emissions={}\n", @@ -122,9 +122,15 @@ impl BiomeReport { "ALL NUMBERS ARE *SYNTHETIC* — a deterministic biome simulator, not a field pilot.\n", ); s.push_str( - "They prove the fabric's mechanics (signatures, replay windows, dedup, quarantine,\n", + "This scores the fabric REFERENCE MODEL (in-memory library components: signatures,\n", ); - s.push_str("revocation, projection) against known ground truth.\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", ); diff --git a/crates/rucelium-ingest/src/lib.rs b/crates/rucelium-ingest/src/lib.rs index 6ef5a6d..ad9c576 100644 --- a/crates/rucelium-ingest/src/lib.rs +++ b/crates/rucelium-ingest/src/lib.rs @@ -313,6 +313,10 @@ pub struct IngestStats { 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 { @@ -345,6 +349,53 @@ fn hex_encode(bytes: &[u8]) -> String { 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`]. @@ -416,37 +467,14 @@ impl IngestPipeline { &mut self, envelope_bytes: &[u8], received_ns: u64, - ) -> Result { - // (1) Envelope decode. - let record = match SignedEnvRecordV1::decode(envelope_bytes) { - Ok(r) => r, - Err(e) => return Err(self.reject(RejectReason::BadEnvelope(e.to_string()))), - }; - - // (2) ABI payload parse + validation. - let wire = match RvEnvSampleV1::parse_validated(&record.payload) { - Ok(w) => w, - Err(e) => return Err(self.reject(RejectReason::BadPayload(e.to_string()))), + ) -> 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; - // (3) Registered? (4) Revoked? - let (registered_pubkey, firmware_hash) = match self.registry.get(node_id) { - None => return Err(self.reject(RejectReason::UnknownDevice(node_id))), - Some(d) if d.revoked => return Err(self.reject(RejectReason::RevokedDevice(node_id))), - Some(d) => (d.pubkey, d.firmware_hash.clone()), - }; - - // (5) The envelope must carry exactly the provisioned key. - if record.pubkey != registered_pubkey { - return Err(self.reject(RejectReason::KeyMismatch(node_id))); - } - - // (6) Signature over the exact payload bytes. - if verify_record(&record).is_err() { - return Err(self.reject(RejectReason::BadSignature(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 @@ -478,11 +506,90 @@ impl IngestPipeline { ) { Ok(sample) => { self.stats.accepted += 1; - Ok(sample) + 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)) + } } // --------------------------------------------------------------------------- @@ -547,7 +654,8 @@ mod tests { #[test] fn happy_path_ingests_verified_sample_with_registry_firmware() { let mut p = pipeline(); - let sample = p.ingest(&signed_envelope(NODE_A, 1, 0), RECV).unwrap(); + 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); @@ -664,7 +772,7 @@ mod tests { ); // The second registered device is unaffected. let s = p.ingest(&signed_envelope(NODE_B, 1, 0), RECV).unwrap(); - assert_eq!(s.provenance.firmware_hash, FW_B); + assert_eq!(s.sample().provenance.firmware_hash, FW_B); assert_eq!(p.stats().accepted, 2); assert_eq!(p.stats().revoked_device, 1); } From c00391fe4bfadef0eeff814b445abdc953bc9ea8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:35:50 +0000 Subject: [PATCH 08/27] feat(rucelium): cryptographically verified calibration authorities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review response, part 2 (blocker 5): - CalibrationAuthority registry with per-modality trust scopes - deterministic CalibrationSigner (canonical-bytes ed25519, signature fields cleared before signing) - strict CalibrationStore::with_authorities: every record in a lineage chain must carry a verifying signature from a signer trusted for its modality — roots and children alike; verify_lineage re-checks each link - the reviewer's attack is a named test: a self-signed record claiming method "anchor_reference" with an unregistered key is rejected - permissive new() retained for tests/simulation, loudly documented - 41 tests (16 new), clippy clean Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.lock | 2 + crates/rucelium-calibration/Cargo.toml | 2 + crates/rucelium-calibration/src/authority.rs | 389 +++++++++++++++++++ crates/rucelium-calibration/src/error.rs | 35 ++ crates/rucelium-calibration/src/lib.rs | 10 +- crates/rucelium-calibration/src/store.rs | 267 ++++++++++++- 6 files changed, 703 insertions(+), 2 deletions(-) create mode 100644 crates/rucelium-calibration/src/authority.rs diff --git a/Cargo.lock b/Cargo.lock index ebc1410..7716f60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,9 +923,11 @@ dependencies = [ name = "rucelium-calibration" version = "0.1.0" dependencies = [ + "ed25519-dalek", "rucelium-core", "serde", "serde_json", + "sha2", ] [[package]] diff --git a/crates/rucelium-calibration/Cargo.toml b/crates/rucelium-calibration/Cargo.toml index 38714a4..ee9cb61 100644 --- a/crates/rucelium-calibration/Cargo.toml +++ b/crates/rucelium-calibration/Cargo.toml @@ -13,6 +13,8 @@ categories = ["science"] 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/error.rs b/crates/rucelium-calibration/src/error.rs index 00e5984..a207bd2 100644 --- a/crates/rucelium-calibration/src/error.rs +++ b/crates/rucelium-calibration/src/error.rs @@ -45,6 +45,21 @@ pub enum CalibrationError { }, /// 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), } @@ -86,6 +101,17 @@ impl fmt::Display for CalibrationError { "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}"), } } @@ -140,6 +166,15 @@ mod tests { "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", diff --git a/crates/rucelium-calibration/src/lib.rs b/crates/rucelium-calibration/src/lib.rs index f2c38e0..2e755f7 100644 --- a/crates/rucelium-calibration/src/lib.rs +++ b/crates/rucelium-calibration/src/lib.rs @@ -7,7 +7,11 @@ //! //! 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). +//! 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 @@ -25,11 +29,15 @@ #![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; diff --git a/crates/rucelium-calibration/src/store.rs b/crates/rucelium-calibration/src/store.rs index 56c6f85..9faa36b 100644 --- a/crates/rucelium-calibration/src/store.rs +++ b/crates/rucelium-calibration/src/store.rs @@ -1,6 +1,7 @@ //! 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; @@ -19,18 +20,73 @@ fn is_anchored_method(method: &str) -> bool { /// 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 store. + /// 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 { @@ -48,8 +104,16 @@ impl CalibrationStore { /// 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!( @@ -91,6 +155,14 @@ impl CalibrationStore { /// [`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; @@ -107,6 +179,7 @@ impl CalibrationStore { }), }; }; + self.check_authority(record)?; chain.push(current); match record.parent_id { Some(parent) => current = parent, @@ -152,7 +225,9 @@ impl CalibrationStore { #[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 { @@ -326,4 +401,194 @@ mod tests { 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]); + } } From 1b47c51e452103f22fd8b813807150e60aa0f4ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:38:28 +0000 Subject: [PATCH 09/27] feat(rucelium): durable store hardening, restart-safe control path, ADR-266 wedges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review response, parts 3-4 (blockers 3 and 7) + deployment strategy: rucelium-store (blocker 3): - persistent dedup index (dedup.idx) is now authoritative on open, so keys survive retention deletion AND restart — the headline flaw - per-record CRC-32: a newline-terminated record whose CRC fails is a hard Corrupt error, never truncated; only a genuinely torn final line (no newline / incomplete CRC prefix) is repaired - opt-in fsync (sync_data on segment + index per append); crate docs now state precisely what each mode guarantees - legacy bare-JSON lines still readable, index backfilled in place - dedup_keys() exposes the durable replay memory (28 tests) rucelium-policy (blocker 7): - safety budget is CHECKED at simulation, CHARGED only on execution, so unauthorized proposals can no longer exhaust another actuator's budget - two-phase execution: Executing -> Executed/Failed; a command in ANY phase is refused (a crashed Executing entry fails closed) - export_phases/restore_phases journal hooks for daemon restart - receipts are now ed25519 attestations signed by the gateway identity, with verify_receipt() (21 tests incl. compile_fail typestate doctest) docs/ADR-266: deployment wedges (flood/watershed first), the biological frontier as a research track with capped-evidence discipline, and the physical 8-16 node acceptance test that supersedes simulation claims Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-policy/src/audit.rs | 5 + crates/rucelium-policy/src/lib.rs | 322 ++++++++++++-- crates/rucelium-policy/src/pipeline.rs | 348 +++++++++++++-- crates/rucelium-store/src/events.rs | 112 ++++- crates/rucelium-store/src/lib.rs | 46 +- crates/rucelium-store/src/observations.rs | 404 ++++++++++++++++-- crates/rucelium-store/src/segment.rs | 254 ++++++++++- ...DR-266-rucelium-applications-and-wedges.md | 162 +++++++ 8 files changed, 1490 insertions(+), 163 deletions(-) create mode 100644 docs/ADR-266-rucelium-applications-and-wedges.md diff --git a/crates/rucelium-policy/src/audit.rs b/crates/rucelium-policy/src/audit.rs index 7475cdc..8cea61f 100644 --- a/crates/rucelium-policy/src/audit.rs +++ b/crates/rucelium-policy/src/audit.rs @@ -4,6 +4,11 @@ //! 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; diff --git a/crates/rucelium-policy/src/lib.rs b/crates/rucelium-policy/src/lib.rs index 773a6db..098dbb9 100644 --- a/crates/rucelium-policy/src/lib.rs +++ b/crates/rucelium-policy/src/lib.rs @@ -33,7 +33,7 @@ //! ```compile_fail //! use rucelium_policy::{AgentProposal, AuditTrail, GatewayValidator, ProposalKind}; //! -//! let mut gateway = GatewayValidator::new(vec![]); +//! let mut gateway = GatewayValidator::new(vec![], &[7u8; 32]); //! let mut audit = AuditTrail::new(); //! let proposal = AgentProposal { //! proposal_id: "p-1".into(), @@ -44,7 +44,7 @@ //! proposed_ns: 0, //! }; //! // ERROR: expected `&SignedCommand`, found `&AgentProposal`. -//! let _ = gateway.validate_and_execute(&proposal, 0, |_| String::new(), &mut audit); +//! let _ = gateway.validate_and_execute(&proposal, 0, |_| Ok(String::new()), &mut audit); //! ``` //! //! Likewise `AuthorityRegistry::authorize` only accepts a @@ -54,16 +54,58 @@ //! //! ## Determinism //! -//! No clocks, no RNG: callers pass `now_ns` everywhere, and command signing -//! is deterministic ed25519 (RFC 8032) from a fixed 32-byte seed. Identical -//! runs produce identical signatures, receipts, and receipt hashes. +//! 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"`. +//! `"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")] @@ -73,9 +115,10 @@ pub mod proposal; pub use audit::{AuditEntry, AuditTrail}; pub use pipeline::{ - AuthorityRegistry, AuthorizedProposal, CommandPayload, CommandSigner, EvaluatedProposal, - ExecutionReceipt, GatewayValidator, PolicyConfig, PolicyEngine, ProposalKindView, SafetyConfig, - SafetySimulator, SignedCommand, SimulatedProposal, + verify_receipt, AuthorityRegistry, AuthorizedProposal, CommandPayload, CommandPhase, + CommandSigner, EvaluatedProposal, ExecutionReceipt, GatewayValidator, PolicyConfig, + PolicyEngine, ProposalKindView, SafetyConfig, SafetySimulator, SignedCommand, + SimulatedProposal, }; pub use proposal::{AgentProposal, ProposalKind}; @@ -107,8 +150,16 @@ pub enum ControlError { /// Gateway's `now_ns` at validation time. now_ns: u64, }, - /// The command id was already executed (replay protection). + /// 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), } @@ -133,8 +184,9 @@ impl std::fmt::Display for ControlError { now_ns, } => write!(f, "command expired: expires_ns={expires_ns}, now_ns={now_ns}"), ControlError::DuplicateCommand(id) => { - write!(f, "duplicate command {id}: already executed") + 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}"), } } @@ -149,10 +201,11 @@ mod tests { 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(magnitude: f64) -> AgentProposal { + fn actuator_proposal_with_id(proposal_id: &str, magnitude: f64) -> AgentProposal { AgentProposal { - proposal_id: "p-1".into(), + proposal_id: proposal_id.into(), agent_id: "agent/flood".into(), biome_id: "biome/thames-estuary".into(), kind: ProposalKind::ActuatorCommand { @@ -165,6 +218,10 @@ mod tests { } } + 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()); @@ -179,7 +236,7 @@ mod tests { 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()]); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); let evaluated = engine .evaluate(actuator_proposal(0.5), 2_000, &mut audit) @@ -188,8 +245,15 @@ mod tests { 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| format!("executed {kind:?}"), &mut audit) + .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) } @@ -199,6 +263,8 @@ mod tests { 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!( @@ -276,26 +342,66 @@ mod tests { } #[test] - fn actuator_command_budget_is_enforced() { + 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, }); - for _ in 0..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(); @@ -363,8 +469,9 @@ mod tests { } /// Sign a valid command through the full front half of the pipeline. - fn signed_command( + fn signed_command_with_id( signer: &CommandSigner, + proposal_id: &str, ttl_ns: u64, audit: &mut AuditTrail, ) -> SignedCommand { @@ -372,20 +479,30 @@ mod tests { 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(0.5), 10, audit).unwrap(); + 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()]); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); let err = gateway - .validate_and_execute(&cmd, 1_040, |_| String::new(), &mut audit) + .validate_and_execute(&cmd, 1_040, |_| Ok(String::new()), &mut audit) .unwrap_err(); assert_eq!( err, @@ -401,14 +518,22 @@ mod tests { 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()]); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); gateway - .validate_and_execute(&cmd, 50, |_| "ok".into(), &mut audit) + .validate_and_execute(&cmd, 50, |_| Ok("ok".into()), &mut audit) .unwrap(); let err = gateway - .validate_and_execute(&cmd, 60, |_| "ok".into(), &mut audit) + .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] @@ -421,9 +546,9 @@ mod tests { 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()]); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); let err = gateway - .validate_and_execute(&forged, 50, |_| String::new(), &mut audit) + .validate_and_execute(&forged, 50, |_| Ok(String::new()), &mut audit) .unwrap_err(); assert_eq!(err, ControlError::BadSignature); } @@ -435,9 +560,9 @@ mod tests { 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()]); + let mut gateway = GatewayValidator::new(vec![trusted.public_hex()], GATEWAY_SEED); let err = gateway - .validate_and_execute(&cmd, 50, |_| String::new(), &mut audit) + .validate_and_execute(&cmd, 50, |_| Ok(String::new()), &mut audit) .unwrap_err(); assert_eq!(err, ControlError::UntrustedKey(rogue.public_hex())); } @@ -451,9 +576,146 @@ mod tests { 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()]); + 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(&back, 50, |_| "ok".into(), &mut audit) + .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 index 721401d..6e93d52 100644 --- a/crates/rucelium-policy/src/pipeline.rs +++ b/crates/rucelium-policy/src/pipeline.rs @@ -243,8 +243,10 @@ 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 commands the simulator will pass per actuator - /// (default 10) — a deterministic stand-in for rate limiting. + /// 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, } @@ -280,12 +282,17 @@ impl SimulatedProposal { } } -/// Stage 2: deterministic safety simulation. Takes `&mut self` because it -/// tracks how many commands each actuator has been issued. +/// 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, - issued: BTreeMap, + executed: BTreeMap, } impl SafetySimulator { @@ -294,16 +301,29 @@ impl SafetySimulator { pub fn new(config: SafetyConfig) -> Self { SafetySimulator { config, - issued: BTreeMap::new(), + 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. Records a `"safety_simulated"` audit entry - /// either way. + /// 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, @@ -330,8 +350,8 @@ impl SafetySimulator { ); return Err(e); } - let count = self.issued.entry(actuator_id.clone()).or_insert(0); - if *count >= self.config.max_commands_per_actuator { + 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 @@ -344,7 +364,6 @@ impl SafetySimulator { ); return Err(e); } - *count += 1; } audit.record( "safety_simulated", @@ -587,9 +606,56 @@ impl CommandSigner { /// Read-only view of the command kind handed to the execution closure. pub type ProposalKindView = ProposalKind; -/// Terminal artifact of the governed control path: proof that a command was -/// validated and executed exactly once. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +/// 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, @@ -600,37 +666,167 @@ pub struct ExecutionReceipt { /// `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, } -/// Stages 5 and 6: gateway-side validation and local execution. +/// 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`]), and the command id has not already -/// executed ([`ControlError::DuplicateCommand`] — replay protection). Only -/// then does the execution closure run, exactly once per command id. -#[derive(Debug, Clone)] +/// 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, - executed: 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. + /// 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) -> Self { + pub fn new(trusted_keys: Vec, gateway_seed: &[u8; 32]) -> Self { GatewayValidator { trusted: trusted_keys.into_iter().collect(), - executed: BTreeSet::new(), + 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) and return the [`ExecutionReceipt`]. Records - /// `"gateway_validated"` (with the verdict, pass or fail) and, on - /// success, `"executed"` audit entries. - pub fn validate_and_execute String>( + /// 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, @@ -645,12 +841,11 @@ impl GatewayValidator { .unwrap_or(&cmd.payload.command_id) .to_string(); if let Err(e) = self.check(cmd, now_ns) { - audit.record( - "gateway_validated", - &proposal_id, - now_ns, - format!("rejected: {e}"), - ); + 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( @@ -659,22 +854,66 @@ impl GatewayValidator { now_ns, "signature and freshness ok", ); - self.executed.insert(cmd.payload.command_id.clone()); - let outcome = execute(&cmd.payload.kind); + // 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!("{}|{now_ns}|{outcome}", cmd.payload.command_id).as_bytes()); - audit.record( - "executed", - &proposal_id, - now_ns, - format!("outcome: {outcome}"), - ); - Ok(ExecutionReceipt { - command_id: cmd.payload.command_id.clone(), - executed_ns: now_ns, + 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> { @@ -700,11 +939,26 @@ impl GatewayValidator { now_ns, }); } - if self.executed.contains(&cmd.payload.command_id) { + // 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-store/src/events.rs b/crates/rucelium-store/src/events.rs index bbc6838..f0ba7a5 100644 --- a/crates/rucelium-store/src/events.rs +++ b/crates/rucelium-store/src/events.rs @@ -4,7 +4,10 @@ //! Events are `DataClass::FederatedEvent` with a retention measured in //! years (ADR-264 §10), so v0.1 has no retention enforcement here. -use crate::segment::{list_segments, read_segment, segment_file_name}; +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; @@ -24,13 +27,16 @@ struct SegmentState { /// Durable append-only store for [`EnvironmentalEvent`]s, deduped by /// `event_id`. /// -/// Same design as [`crate::ObservationStore`]: one JSON line per event in -/// zero-padded `evt-NNNNNN.jsonl` segments, dedup index rebuilt on open, -/// torn-tail repair on the final segment, flush-per-append durability -/// (crate docs). +/// 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, @@ -41,24 +47,41 @@ 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`, scanning existing - /// `evt-*.jsonl` segments to rebuild the dedup index. Recovery rules - /// match [`crate::ObservationStore::open`]; a `segment_max_records` of + /// 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) -> Result { + 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 seen = BTreeSet::new(); 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 { - seen.insert(e.event_id.clone()); + if seen.insert(e.event_id.clone()) { + missing_from_index.push(e.event_id.clone()); + } } segments.push(SegmentState { name, @@ -66,9 +89,12 @@ impl EventStore { }); 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, @@ -76,8 +102,11 @@ impl EventStore { } /// Append an event, deduplicating by `event_id`. The event is validated - /// first (invalid → [`StoreError::Core`]); the write is flushed after - /// each append (no fsync in v0.1 — crate docs). + /// 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() @@ -94,7 +123,8 @@ impl EventStore { self.next_segment_index += 1; self.segments.push(SegmentState { name, records: 0 }); } - let line = serde_json::to_string(event).map_err(|e| StoreError::Core(e.to_string()))?; + 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) @@ -103,6 +133,11 @@ impl EventStore { 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) @@ -127,7 +162,7 @@ impl EventStore { } /// Full deterministic replay: every stored event in append order, read - /// back from disk. + /// 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 { @@ -154,7 +189,7 @@ mod tests { #[test] fn append_dedup_and_replay() { let dir = temp_dir("evt"); - let mut store = EventStore::open(&dir, 2).unwrap(); + let mut store = EventStore::open(&dir, 2, false).unwrap(); let events = [ event("evt-0001", 5_000), event("evt-0002", 6_000), @@ -181,12 +216,17 @@ mod tests { #[test] fn reopen_preserves_dedup_and_order() { let dir = temp_dir("evt-reopen"); - let mut store = EventStore::open(&dir, 2).unwrap(); + 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); - let mut reopened = EventStore::open(&dir, 2).unwrap(); + // 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(), @@ -206,10 +246,44 @@ mod tests { 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).unwrap(); + 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(_)))); diff --git a/crates/rucelium-store/src/lib.rs b/crates/rucelium-store/src/lib.rs index 373d9d9..7b305a2 100644 --- a/crates/rucelium-store/src/lib.rs +++ b/crates/rucelium-store/src/lib.rs @@ -11,20 +11,42 @@ //! * [`EventStore`] — [`rucelium_core::EnvironmentalEvent`]s, deduped by //! `event_id`, files `evt-NNNNNN.jsonl`. //! -//! ## Design notes (v0.1) +//! ## Design notes //! -//! * **Durability**: every append is flushed to the OS (`File::flush`) but -//! *not* fsynced — a host power loss may lose the tail, which crash -//! recovery then treats as a torn tail. fsync batching is future work. -//! * **Crash recovery**: on open, a torn (unparsable) *final* line of the -//! *final* segment is truncated away — a crash mid-write must not poison -//! the store. Malformed data anywhere else is [`StoreError::Corrupt`]. +//! * **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 memory**: dedup keys are kept forever, even after retention -//! deletes their payload segments. Keys are tiny (a `(u64, u32)` pair or a -//! short id string); retention frees payload bytes, not dedup memory. +//! * **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`]. @@ -86,7 +108,9 @@ impl From for StoreError { /// Result of an append attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppendOutcome { - /// The record was new and is now durable in the current segment. + /// 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, diff --git a/crates/rucelium-store/src/observations.rs b/crates/rucelium-store/src/observations.rs index 5b3f506..2da0b68 100644 --- a/crates/rucelium-store/src/observations.rs +++ b/crates/rucelium-store/src/observations.rs @@ -1,7 +1,10 @@ //! `ObservationStore` — the durable, segmented, append-only sample log with //! a persistent dedup index and retention enforcement (ADR-265 §3). -use crate::segment::{list_segments, read_segment, segment_file_name, SegmentInfo}; +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; @@ -19,7 +22,9 @@ const PREFIX: &str = "obs"; /// 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. +/// 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. @@ -34,6 +39,8 @@ pub struct StoreStats { 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. @@ -44,15 +51,19 @@ struct SegmentState { /// Durable append-only store for [`EnvSample`]s. /// -/// Samples live on disk as one JSON line each, in zero-padded segment files -/// `obs-NNNNNN.jsonl` of at most `segment_max_records` records. Only the -/// dedup keys (`(node_id, sequence)`) 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 v0.1 durability, torn-tail, and -/// retention design notes. +/// 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, @@ -66,29 +77,65 @@ 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`, scanning existing `obs-*.jsonl` - /// segments in lexicographic order to rebuild the dedup index and - /// segment metadata. + /// Open (or create) a store at `dir`. /// - /// Crash recovery: an unparsable **final** line of the **final** segment - /// is truncated away (torn write); a malformed line anywhere else is - /// [`StoreError::Corrupt`]. A `segment_max_records` of `0` is treated - /// as `1`. - pub fn open(dir: &Path, segment_max_records: usize) -> Result { + /// 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 seen = BTreeSet::new(); 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 { - seen.insert(s.dedup_key()); + 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); @@ -96,9 +143,12 @@ impl ObservationStore { 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, @@ -112,8 +162,11 @@ impl ObservationStore { /// /// The sample is validated first (invalid → [`StoreError::Core`]). A new /// segment starts when the current one holds `segment_max_records`. The - /// write is flushed to the OS after each append; v0.1 deliberately does - /// not fsync (crate docs). + /// 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() @@ -135,7 +188,8 @@ impl ObservationStore { bytes: 0, }); } - let line = serde_json::to_string(sample).map_err(|e| StoreError::Core(e.to_string()))?; + 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) @@ -144,6 +198,13 @@ impl ObservationStore { 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); @@ -178,9 +239,17 @@ impl ObservationStore { 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). + /// 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 { @@ -209,8 +278,9 @@ impl ObservationStore { /// 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 (crate docs), so an expired sample - /// replayed later is still a duplicate. + /// 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; @@ -238,6 +308,7 @@ impl ObservationStore { 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, } } } @@ -250,7 +321,7 @@ mod tests { #[test] fn append_iter_round_trips_in_order_and_rejects_duplicates() { let dir = temp_dir("roundtrip"); - let mut store = ObservationStore::open(&dir, 100).unwrap(); + 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), @@ -274,10 +345,30 @@ mod tests { 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).unwrap(); + 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(_)))); @@ -288,7 +379,7 @@ mod tests { #[test] fn segments_roll_over_at_max_records() { let dir = temp_dir("rollover"); - let mut store = ObservationStore::open(&dir, 3).unwrap(); + 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)) @@ -316,7 +407,7 @@ mod tests { #[test] fn reopen_recovers_index_and_metadata() { let dir = temp_dir("reopen"); - let mut store = ObservationStore::open(&dir, 3).unwrap(); + 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)) @@ -327,7 +418,7 @@ mod tests { let infos = store.segment_infos(); drop(store); - let mut reopened = ObservationStore::open(&dir, 3).unwrap(); + 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); @@ -349,24 +440,160 @@ mod tests { 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).unwrap(); + 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: garbage bytes, no trailing newline. + // 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"{\"half").unwrap(); + f.write_all(b"deadbeef {\"half").unwrap(); drop(f); - let mut reopened = ObservationStore::open(&dir, 100).unwrap(); + 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. @@ -381,10 +608,66 @@ mod tests { 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).unwrap(); + 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)) @@ -400,7 +683,9 @@ mod tests { lines[1] = "not json".into(); fs::write(&path, lines.join("\n") + "\n").unwrap(); - let err = ObservationStore::open(&dir, 100).map(|_| ()).unwrap_err(); + let err = ObservationStore::open(&dir, 100, false) + .map(|_| ()) + .unwrap_err(); match err { StoreError::Corrupt { segment, line, .. } => { assert_eq!(segment, "obs-000000.jsonl"); @@ -411,10 +696,54 @@ mod tests { 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).unwrap(); + 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(); @@ -453,7 +782,7 @@ mod tests { #[test] fn stats_serialize_to_json() { let dir = temp_dir("stats"); - let mut store = ObservationStore::open(&dir, 100).unwrap(); + 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(); @@ -462,6 +791,7 @@ mod tests { 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 index 54e330b..3c00615 100644 --- a/crates/rucelium-store/src/segment.rs +++ b/crates/rucelium-store/src/segment.rs @@ -1,11 +1,26 @@ //! Segment file machinery shared by [`crate::ObservationStore`] and -//! [`crate::EventStore`]: naming, directory scan, and line-oriented reads -//! with torn-tail repair. +//! [`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. @@ -33,6 +48,90 @@ impl SegmentInfo { } } +/// 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. @@ -69,14 +168,18 @@ fn parse_segment_index(name: &str, prefix: &str) -> Option { digits.parse().ok() } -/// Read one segment file, parsing each line with `parse`. Returns the parsed -/// records and the file's size in bytes after any repair. +/// 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), an unparsable **final** line is treated as a crash-torn write: the -/// file is truncated to just before it and the scan succeeds. Any other -/// malformed line — and any malformed line when `repair_torn_tail` is unset -/// — is [`StoreError::Corrupt`] with a 1-based line number. +/// 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, @@ -92,23 +195,33 @@ where let mut line_no = 0usize; while offset < bytes.len() { line_no += 1; - let end = bytes[offset..] - .iter() - .position(|&b| b == b'\n') - .map_or(bytes.len(), |p| offset + p); - let parsed = std::str::from_utf8(&bytes[offset..end]) - .map_err(|e| e.to_string()) - .and_then(&parse); - match parsed { + 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(reason) => { - // Final line iff nothing follows it but (at most) its '\n'. + 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(); - if repair_torn_tail && is_final_line { + 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, @@ -121,6 +234,85 @@ where 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::*; @@ -139,4 +331,28 @@ mod tests { 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/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. From 2655940cf7363a5ab191ceaf82bca19f48bfce2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:39:08 +0000 Subject: [PATCH 10/27] =?UTF-8?q?feat(rucelium-gateway):=20rhizome=20daemo?= =?UTF-8?q?n=20=E2=80=94=20UDP=20ingest,=20SensorThings=20API,=20federatio?= =?UTF-8?q?n=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-265 §4 gateway daemon (verified green when written: 24 tests + live smoke run showing DR0 fragment reassembly, calibrated observations, and biome-signed flood alerts): - UDP ingestion dispatching v1 CBOR / v2 compact / DR0 fragment paths - calibration + drift quarantine, WorldGraph registration, disk store - local alert rules emitting biome-signed EnvironmentalEvents - SensorThings-style HTTP API + admin revocation endpoint - peer federation poller: verifies summaries and revocations against the peer's published biome key before applying them - --simulate N synthetic spore swarm (rotates all three encodings) - tests/e2e.rs: two gateways, revocation on A propagates to B, B then rejects the revoked node's traffic WIP NOTE: this commit does not build against the concurrently hardened store/policy/federation APIs (fsync param, two-phase execution, sealed VerifiedEnvSample). Rewiring to those APIs — plus restart-safe replay priming and the kill/restart/resend acceptance test — lands next. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-gateway/src/api.rs | 252 ++++++++++++++ crates/rucelium-gateway/src/config.rs | 194 +++++++++++ crates/rucelium-gateway/src/federation.rs | 300 +++++++++++++++++ crates/rucelium-gateway/src/lib.rs | 153 ++++++++- crates/rucelium-gateway/src/main.rs | 59 +++- crates/rucelium-gateway/src/net.rs | 30 ++ crates/rucelium-gateway/src/pipeline.rs | 386 ++++++++++++++++++++++ crates/rucelium-gateway/src/simulate.rs | 287 ++++++++++++++++ crates/rucelium-gateway/src/state.rs | 238 +++++++++++++ crates/rucelium-gateway/tests/e2e.rs | 220 ++++++++++++ 10 files changed, 2117 insertions(+), 2 deletions(-) create mode 100644 crates/rucelium-gateway/src/api.rs create mode 100644 crates/rucelium-gateway/src/config.rs create mode 100644 crates/rucelium-gateway/src/federation.rs create mode 100644 crates/rucelium-gateway/src/net.rs create mode 100644 crates/rucelium-gateway/src/pipeline.rs create mode 100644 crates/rucelium-gateway/src/simulate.rs create mode 100644 crates/rucelium-gateway/src/state.rs create mode 100644 crates/rucelium-gateway/tests/e2e.rs diff --git a/crates/rucelium-gateway/src/api.rs b/crates/rucelium-gateway/src/api.rs new file mode 100644 index 0000000..9edfedb --- /dev/null +++ b/crates/rucelium-gateway/src/api.rs @@ -0,0 +1,252 @@ +//! 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. 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). + +use crate::state::{now_ns, GatewayState}; +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 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/admin/revoke/:node_id", post(admin_revoke)) + .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, + "calibration_errors": inner.calibration_errors, + "quarantined_nodes": inner.drift.quarantined(), + "applied_peer_revocations": inner.applied_peer_revocations, + "peer_summaries": inner.peer_summaries.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/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. +/// +/// **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)?; + Ok(Json(json!({ + "node_id": node_id, + "registry_revoked": registry_revoked, + "event": event, + }))) +} diff --git a/crates/rucelium-gateway/src/config.rs b/crates/rucelium-gateway/src/config.rs new file mode 100644 index 0000000..b5b09b1 --- /dev/null +++ b/crates/rucelium-gateway/src/config.rs @@ -0,0 +1,194 @@ +//! 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; + +/// 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, +} + +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, + } + } +} + +impl GatewayConfig { + /// 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")?; + } + 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); + } + + #[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", + ])) + .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); + } + + #[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/federation.rs b/crates/rucelium-gateway/src/federation.rs new file mode 100644 index 0000000..6e714bb --- /dev/null +++ b/crates/rucelium-gateway/src/federation.rs @@ -0,0 +1,300 @@ +//! Network federation sync (ADR-265 §4): a background task polls each +//! configured peer's `/api/federation/{pubkey,summary,revocations}`, +//! verifies every ed25519 signature against the peer's **published** biome +//! key, stores verified summaries, and applies verified `DeviceRevoked` +//! events to the local registry. Unverifiable data is skipped and logged — +//! never applied, never repaired (ADR-264 §12). Only signed summaries and +//! events ever cross the wire, preserving biome sovereignty (ADR-264 §6). + +use crate::state::{now_ns, GatewayState, Inner, PeerSummary}; +use rucelium_core::{EnvironmentalEvent, EventKind}; +use rucelium_federation::{verify_event, verify_summary, RegionalSummary}; +use serde::Deserialize; +use std::time::Duration; + +/// Window (seconds) requested from each peer's summary endpoint. +const PEER_SUMMARY_WINDOW_S: u64 = 3600; +/// Per-request HTTP timeout. +const HTTP_TIMEOUT: Duration = Duration::from_secs(5); + +/// Response shape of `GET /api/federation/pubkey`. +#[derive(Debug, Deserialize)] +struct PubkeyResponse { + /// Peer biome identity. + biome_id: String, + /// Peer biome ed25519 public key, hex. + pubkey_hex: 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. +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 +} + +/// Run the federation poller forever: every `poll_ms`, sync each peer. 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, peers: Vec, poll_ms: u64) { + let client = match reqwest::Client::builder().timeout(HTTP_TIMEOUT).build() { + Ok(c) => c, + Err(e) => { + eprintln!("gateway: federation disabled, http client failed: {e}"); + return; + } + }; + let mut tick = tokio::time::interval(Duration::from_millis(poll_ms.max(50))); + loop { + tick.tick().await; + for peer in &peers { + if let Err(e) = sync_peer(&state, &client, peer).await { + eprintln!("gateway: federation peer {peer}: {e}"); + } + } + } +} + +/// One sync pass against one peer: pubkey, then summary, then revocations. +async fn sync_peer( + state: &GatewayState, + client: &reqwest::Client, + peer: &str, +) -> Result<(), String> { + let base = peer.trim_end_matches('/'); + + let pk: PubkeyResponse = fetch_json(client, &format!("{base}/api/federation/pubkey")).await?; + + let summary: RegionalSummary = fetch_json( + client, + &format!("{base}/api/federation/summary?window_s={PEER_SUMMARY_WINDOW_S}"), + ) + .await?; + if verify_summary(&summary) && summary.signer_pubkey_hex.as_deref() == Some(&pk.pubkey_hex) { + let mut inner = state.inner.lock().await; + inner.peer_summaries.retain(|p| p.peer != peer); + inner.peer_summaries.push(PeerSummary { + peer: peer.to_string(), + summary, + fetched_ns: now_ns(), + }); + } else { + eprintln!( + "gateway: skipping unverifiable summary from peer {peer} (biome {})", + pk.biome_id + ); + } + + let revocations: Vec = + fetch_json(client, &format!("{base}/api/federation/revocations")).await?; + let mut inner = state.inner.lock().await; + for event in &revocations { + if apply_peer_revocation(&mut inner, event, &pk.pubkey_hex) { + eprintln!( + "gateway: applied revocation {} from peer {peer}", + event.event_id + ); + } + } + Ok(()) +} + +/// GET a JSON body, mapping transport and decode failures to strings. +async fn fetch_json( + client: &reqwest::Client, + url: &str, +) -> Result { + client + .get(url) + .send() + .await + .map_err(|e| format!("GET {url}: {e}"))? + .error_for_status() + .map_err(|e| format!("GET {url}: {e}"))? + .json::() + .await + .map_err(|e| format!("decode {url}: {e}")) +} + +#[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; + + /// 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("biome/peer"), 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 + } + + #[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)); + } +} diff --git a/crates/rucelium-gateway/src/lib.rs b/crates/rucelium-gateway/src/lib.rs index 179adb7..8540ccd 100644 --- a/crates/rucelium-gateway/src/lib.rs +++ b/crates/rucelium-gateway/src/lib.rs @@ -1 +1,152 @@ -//! placeholder +//! # 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} +//! ``` +//! +//! 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 federation; +pub mod net; +pub mod pipeline; +pub mod simulate; +pub mod state; + +pub use config::GatewayConfig; +pub use pipeline::{process_datagram, ProcessOutcome}; +pub use state::{GatewayState, Inner, PeerSummary}; + +use rucelium_core::DataClass; +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. +pub async fn spawn_gateway_with_state( + state: GatewayState, + config: GatewayConfig, +) -> 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() { + tasks.push(tokio::spawn(federation::run_federation( + state.clone(), + config.peers.clone(), + config.federation_poll_ms, + ))); + } + + 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 index f328e4d..a85c0cf 100644 --- a/crates/rucelium-gateway/src/main.rs +++ b/crates/rucelium-gateway/src/main.rs @@ -1 +1,58 @@ -fn main() {} +//! `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 ]" + ); + 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!(" 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..6cba610 --- /dev/null +++ b/crates/rucelium-gateway/src/pipeline.rs @@ -0,0 +1,386 @@ +//! 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 { + 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). + if inner + .calibrator + .apply(&inner.calibration, &mut sample, received_ns) + .is_err() + { + inner.calibration_errors += 1; + } + + // 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(sample.node_id, 0.0); + + // WorldGraph registration (idempotent) before storage. + inner.graph.register_observation(&sample); + + if let Err(e) = inner.obs.append(&sample) { + return ProcessOutcome::Rejected(format!("observation store append: {e}")); + } + + maybe_alert(inner, &sample, 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, + 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..f574057 --- /dev/null +++ b/crates/rucelium-gateway/src/simulate.rs @@ -0,0 +1,287 @@ +//! **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). + for m in SensorModality::ALL { + if m == SensorModality::WifiCsi { + continue; + } + let _ = inner.calibration.insert(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.calibration.insert(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..45f57fd --- /dev/null +++ b/crates/rucelium-gateway/src/state.rs @@ -0,0 +1,238 @@ +//! 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 rucelium_calibration::{CalibrationStore, Calibrator, DriftDetector}; +use rucelium_federation::{Biome, BiomeConfig, RegionalSummary}; +use rucelium_ingest::IngestPipeline; +use rucelium_store::{EventStore, ObservationStore}; +use rucelium_transport::Reassembler; +use rucelium_worldgraph::WorldGraph; +use serde::Serialize; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::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; + +/// 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, +} + +/// 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. + pub calibration: CalibrationStore, + /// 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, + /// 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, +} + +impl Inner { + /// Build the full component stack, opening the durable stores under + /// `config.data_dir` (`obs/` and `events/` subdirectories). + pub fn open(config: &GatewayConfig) -> Result { + let obs = ObservationStore::open(&config.data_dir.join("obs"), OBS_SEGMENT_MAX_RECORDS) + .map_err(|e| format!("open observation store: {e}"))?; + let events = EventStore::open(&config.data_dir.join("events"), EVT_SEGMENT_MAX_RECORDS) + .map_err(|e| format!("open event store: {e}"))?; + let seed = biome_seed(&config.biome_id, config.seed); + Ok(Inner { + ingest: IngestPipeline::default(), + calibration: CalibrationStore::new(), + 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, + alerts: 0, + calibration_errors: 0, + datagrams: DatagramStats::default(), + }) + } +} + +/// 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, +} + +impl GatewayState { + /// Open the durable stores and assemble the gateway state. + pub fn open(config: &GatewayConfig) -> Result { + Ok(GatewayState { + biome_id: config.biome_id.clone(), + inner: Arc::new(Mutex::new(Inner::open(config)?)), + started: Instant::now(), + }) + } +} + +/// 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 +} + +#[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 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/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(); +} From 0fd4deccebed92076fcb5220afdbcc961fc76daf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:41:30 +0000 Subject: [PATCH 11/27] feat(rucelium): federation identity binding + sealed-sample admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review response, part 5 (blockers 4 and 6, plus SensorThings honesty): - Biome::accept now takes only VerifiedEnvSample; AcceptOutcome::Unverified is gone because the state is unrepresentable — a compile_fail doctest proves a bare EnvSample (however its verified flag is set) is rejected by the type system, not by a runtime boolean check - OutageBuffer stores the ORIGINAL SIGNED ENVELOPE, not a bare sample: restore goes envelope -> IngestPipeline::reverify_stored -> accept, so buffered data is re-verified cryptographically after a restart; a tampered buffered envelope fails reverify and never enters the biome - FederationBus binds biome_id -> (pubkey, key_epoch): a registered key claiming another biome's id is IdentityMismatch; higher epoch rotates, lower/equal with a different key is StaleKeyEpoch; duplicate summary windows and duplicate event ids are rejected (bus replay protection) - SensorThings relabelled a *-inspired projection* (not conformant until an external OGC suite passes) and given mandatory fields: Datastream.description + observationType, Sensor.description; encodingType text/plain with the deviation documented - 29 tests (28 unit + compile_fail doctest) Also scaffolds the examples/ workspace member for worked applications. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.lock | 19 + Cargo.toml | 2 + crates/rucelium-federation/Cargo.toml | 2 + crates/rucelium-federation/src/biome.rs | 141 +++++-- crates/rucelium-federation/src/buffer.rs | 302 +++++++++++---- crates/rucelium-federation/src/lib.rs | 101 ++++- .../rucelium-federation/src/sensorthings.rs | 101 +++-- crates/rucelium-federation/src/summary.rs | 349 +++++++++++++++--- examples/Cargo.toml | 27 ++ examples/src/lib.rs | 1 + 10 files changed, 874 insertions(+), 171 deletions(-) create mode 100644 examples/Cargo.toml create mode 100644 examples/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7716f60..1b24789 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -938,12 +938,31 @@ dependencies = [ "serde_json", ] +[[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", diff --git a/Cargo.toml b/Cargo.toml index 46bfc5b..d51ea96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/rucelium-store", "crates/rucelium-transport", "crates/rucelium-gateway", + "examples", ] [workspace.package] @@ -57,6 +58,7 @@ 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-examples = { version = "0.1.0", path = "examples" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/rucelium-federation/Cargo.toml b/crates/rucelium-federation/Cargo.toml index 31d87ec..1c4bf26 100644 --- a/crates/rucelium-federation/Cargo.toml +++ b/crates/rucelium-federation/Cargo.toml @@ -11,6 +11,8 @@ 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 } diff --git a/crates/rucelium-federation/src/biome.rs b/crates/rucelium-federation/src/biome.rs index 0e1c708..ff3dbcd 100644 --- a/crates/rucelium-federation/src/biome.rs +++ b/crates/rucelium-federation/src/biome.rs @@ -1,6 +1,15 @@ //! `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}; @@ -8,6 +17,7 @@ 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}; @@ -71,6 +81,12 @@ impl BiomeConfig { } /// 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 { @@ -78,9 +94,6 @@ pub enum AcceptOutcome { Accepted, /// The `(node_id, sequence)` key was already accepted (live or replay). Duplicate, - /// The gateway never verified the wire signature — unverified data is - /// never admitted (ADR-264 §12). - Unverified, /// The producing device has been revoked. Revoked, } @@ -136,17 +149,34 @@ impl Biome { &self.key } - /// Admit one sample. Revoked devices are blocked, unverified samples are - /// never admitted (ADR-264 §12), and the global dedup index rejects any - /// `(node_id, sequence)` key already accepted — whether it arrived live - /// or via [`crate::OutageBuffer`] replay after an outage. - pub fn accept(&mut self, sample: EnvSample) -> AcceptOutcome { + /// 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 !sample.provenance.verified { - return AcceptOutcome::Unverified; - } if !self.seen.insert(sample.dedup_key()) { self.duplicate_count += 1; return AcceptOutcome::Duplicate; @@ -298,7 +328,7 @@ pub fn verify_event(event: &EnvironmentalEvent) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::testutil::{sample, SEED}; + use crate::testutil::{pipeline, sample, signed_envelope, verified_sample, SEED}; use crate::OutageBuffer; fn biome() -> Biome { @@ -323,30 +353,58 @@ mod tests { 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 unverified_samples_are_never_admitted() { + 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 mut s = sample(1, 1, 1_000, 20.0); - s.provenance.verified = false; - assert_eq!(b.accept(s), AcceptOutcome::Unverified); - assert_eq!(b.accepted_count(), 0); - assert!(b.observations().is_empty()); + 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(sample(1, 1, 1_000, 20.0)), AcceptOutcome::Accepted); - assert_eq!(b.accept(sample(1, 2, 2_000, 20.5)), AcceptOutcome::Accepted); + 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 samples, then replays. + // Outage: the gateway buffered overlapping signed envelopes, then + // replays them through full re-verification. let mut buf = OutageBuffer::new(); - buf.push(sample(1, 2, 2_000, 20.5)); // already live-ingested - buf.push(sample(1, 3, 3_000, 21.0)); // 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 s in buf.drain() { - outcomes.push(b.accept(s)); + for (envelope, received_ns) in buf.drain() { + let sealed = p.reverify_stored(&envelope, received_ns).unwrap(); + outcomes.push(b.accept(sealed)); } assert_eq!( outcomes, @@ -358,9 +416,16 @@ mod tests { #[test] fn revoked_device_blocked_while_healthy_device_flows() { + let mut p = pipeline(&[7, 8]); let mut b = biome(); - assert_eq!(b.accept(sample(7, 1, 1_000, 20.0)), AcceptOutcome::Accepted); - assert_eq!(b.accept(sample(8, 1, 1_000, 19.0)), AcceptOutcome::Accepted); + 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)); @@ -376,9 +441,16 @@ mod tests { ); event.validate().unwrap(); - // Revoked node blocked, healthy node keeps flowing. - assert_eq!(b.accept(sample(7, 2, 2_000, 20.5)), AcceptOutcome::Revoked); - assert_eq!(b.accept(sample(8, 2, 2_000, 19.5)), AcceptOutcome::Accepted); + // 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); } @@ -398,8 +470,9 @@ mod tests { #[test] fn revocation_event_verifies_and_tamper_breaks_it() { + let mut p = pipeline(&[7]); let mut b = biome(); - b.accept(sample(7, 1, 1_000, 20.0)); + 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)); @@ -466,7 +539,8 @@ mod tests { open_access: false, }; let mut b = Biome::new(config, SEED); - b.accept(sample(7, 1, 1_000, 20.0)); + 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. @@ -489,7 +563,8 @@ mod tests { open_access: true, }; let mut b = Biome::new(config, SEED); - b.accept(sample(7, 1, 1_000, 20.0)); + 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); diff --git a/crates/rucelium-federation/src/buffer.rs b/crates/rucelium-federation/src/buffer.rs index c438f54..301ab2d 100644 --- a/crates/rucelium-federation/src/buffer.rs +++ b/crates/rucelium-federation/src/buffer.rs @@ -1,30 +1,69 @@ -//! `OutageBuffer` — gateway store-and-forward with duplicate-free replay -//! (ADR-264 §5 responsibility 7, §14 criteria 2–3). +//! `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 normalized samples here. On -//! restore, [`OutageBuffer::drain`] replays them in deterministic -//! `(node_id, sequence)` order. The dedup index is part of the serialized -//! form, so a gateway restart (serialize → deserialize) never reintroduces a -//! sample it already buffered. +//! 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 rucelium_core::EnvSample; +use crate::sig; +use crate::FederationError; +use rucelium_abi::{RvEnvSampleV1, SignedEnvRecordV1}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; -/// Store-and-forward log a gateway fills while its uplink is down. +/// Store-and-forward log of signed envelopes a gateway fills while its +/// uplink is down. /// -/// Duplicate suppression uses the stable sample dedup key -/// `(node_id, sequence)` (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. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +/// 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 samples, in arrival order. - samples: Vec, + /// 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] @@ -32,77 +71,148 @@ impl OutageBuffer { OutageBuffer::default() } - /// Buffer a sample. Returns `false` (dropped) when the sample's - /// `(node_id, sequence)` key has already been buffered — including keys - /// seen before a serialize/deserialize restart cycle. - pub fn push(&mut self, sample: EnvSample) -> bool { - if !self.seen.insert(sample.dedup_key()) { - return false; + /// 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.samples.push(sample); - true + self.entries + .insert(key, (envelope_bytes.to_vec(), received_ns)); + Ok(true) } - /// Number of samples currently buffered. + /// Number of envelopes currently buffered. #[must_use] pub fn len(&self) -> usize { - self.samples.len() + self.entries.len() } - /// Whether no samples are currently buffered. + /// Whether no envelopes are currently buffered. #[must_use] pub fn is_empty(&self) -> bool { - self.samples.is_empty() + self.entries.is_empty() } - /// Remove and return all buffered samples in `(node_id, sequence)` order + /// 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. - pub fn drain(&mut self) -> Vec { - let mut out = std::mem::take(&mut self.samples); - out.sort_by_key(EnvSample::dedup_key); - out + /// + /// 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 — samples *and* dedup state — so it - /// survives a gateway restart. + /// Serialize the whole buffer — envelopes (as lowercase hex) *and* dedup + /// state — so it survives a gateway restart. pub fn to_json(&self) -> Result { - serde_json::to_string(self) + 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). + /// [`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 { - serde_json::from_str(json) + 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::testutil::sample; + use crate::biome::{AcceptOutcome, Biome, BiomeConfig}; + use crate::testutil::{pipeline, signed_envelope, SEED}; + + const RECV: u64 = 2_000_000; #[test] - fn push_drops_duplicates() { + fn push_drops_duplicates_and_rejects_garbage() { let mut buf = OutageBuffer::new(); - assert!(buf.push(sample(1, 1, 1_000, 20.0))); - assert!(buf.push(sample(1, 2, 2_000, 20.5))); - // Same (node_id, sequence), even with different payload: dropped. - assert!(!buf.push(sample(1, 1, 9_000, 99.0))); + 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(sample(2, 5, 5_000, 1.0)); - buf.push(sample(1, 9, 4_000, 2.0)); - buf.push(sample(1, 3, 3_000, 3.0)); + 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(); - let keys: Vec<(u64, u32)> = drained.iter().map(EnvSample::dedup_key).collect(); - assert_eq!(keys, vec![(1, 3), (1, 9), (2, 5)]); + 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); } @@ -110,28 +220,96 @@ mod tests { #[test] fn dedup_state_survives_restart() { let mut buf = OutageBuffer::new(); - buf.push(sample(1, 1, 1_000, 20.0)); - buf.push(sample(1, 2, 2_000, 21.0)); + 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(sample(1, 1, 1_000, 20.0))); - assert!(!restored.push(sample(1, 2, 2_000, 21.0))); + 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(sample(1, 3, 3_000, 22.0))); + assert!(restored + .push(&signed_envelope(1, 3, 3_000, 22.0), RECV) + .unwrap()); - // Drain after restore contains zero duplicates. + // Drain after restore contains zero duplicates, in key order. let drained = restored.drain(); - let mut keys: Vec<(u64, u32)> = drained.iter().map(EnvSample::dedup_key).collect(); - let n = keys.len(); - keys.dedup(); - assert_eq!(keys.len(), n); - assert_eq!(keys, vec![(1, 1), (1, 2), (1, 3)]); + 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(sample(1, 3, 3_000, 22.0))); + 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 index f100fb9..d1d9282 100644 --- a/crates/rucelium-federation/src/lib.rs +++ b/crates/rucelium-federation/src/lib.rs @@ -2,15 +2,22 @@ //! //! Biome sovereignty for the RuCelium fabric (ADR-264 §6, §7, §10, §12): //! -//! - [`OutageBuffer`] — gateway store-and-forward log with duplicate-free -//! replay across restarts (§14 criteria 2–3), -//! - [`Biome`] — the sovereign regional aggregate: verified-only ingest, -//! global dedup spanning live ingest and buffer replay, device revocation -//! as signed events, and delayed / coarsened disclosure, +//! - [`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), -//! - [`sensorthings`] — OGC SensorThings API 1.1 entity projection so every -//! accepted observation is externally interoperable (§7, §14 criterion 6). +//! 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 @@ -81,9 +88,21 @@ pub(crate) mod sig { #[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 valid, verified test sample. + /// 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, @@ -101,7 +120,7 @@ pub(crate) mod testutil { flags: 0, battery_mv: 3300, provenance: SampleProvenance { - firmware_hash: "sha256:fw-test".into(), + firmware_hash: FW.into(), signer_pubkey_hex: "aa".into(), verified: true, lineage: vec!["cal:1".into()], @@ -109,6 +128,64 @@ pub(crate) mod testutil { } } - /// A deterministic 32-byte signer seed for tests. - pub(crate) const SEED: &[u8; 32] = b"rucelium-test-seed-32-bytes-ok!!"; + /// 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 index 860817a..42fe1dc 100644 --- a/crates/rucelium-federation/src/sensorthings.rs +++ b/crates/rucelium-federation/src/sensorthings.rs @@ -1,10 +1,16 @@ -//! OGC SensorThings API 1.1 projection (ADR-264 §7): typed serde structs -//! producing the standard entity JSON shapes (`@iot.id`, camelCase field -//! names) so every accepted observation is externally interoperable -//! (§14 criterion 6). +//! **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). //! -//! v0.1 implements the biome → SensorThings *projection*; serving these -//! entities over HTTP is a follow-up. +//! 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}; @@ -31,7 +37,7 @@ impl GeoJsonPoint { } } -/// SensorThings `Thing` — one per device. +/// SensorThings-inspired `Thing` — one per device. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Thing { @@ -44,7 +50,7 @@ pub struct Thing { pub description: String, } -/// SensorThings `Location` of a Thing (GeoJSON encoded). +/// SensorThings-inspired `Location` of a Thing (GeoJSON encoded). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Location { @@ -59,7 +65,7 @@ pub struct Location { pub location: GeoJsonPoint, } -/// SensorThings `Sensor` — the measuring procedure/instrument. +/// SensorThings-inspired `Sensor` — the measuring procedure/instrument. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Sensor { @@ -68,13 +74,22 @@ pub struct Sensor { pub iot_id: String, /// Human-readable name. pub name: String, - /// `"application/pdf"` per the SensorThings metadata convention. + /// 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 `ObservedProperty` — what is being measured. +/// SensorThings-inspired `ObservedProperty` — what is being measured. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ObservedProperty { @@ -89,7 +104,7 @@ pub struct ObservedProperty { pub description: String, } -/// SensorThings `unitOfMeasurement` value object. +/// SensorThings-inspired `unitOfMeasurement` value object. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UnitOfMeasurement { @@ -101,8 +116,8 @@ pub struct UnitOfMeasurement { pub definition: String, } -/// SensorThings `Datastream` — the series linking Thing, Sensor, and -/// ObservedProperty. +/// SensorThings-inspired `Datastream` — the series linking Thing, Sensor, +/// and ObservedProperty. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Datastream { @@ -111,6 +126,13 @@ pub struct Datastream { 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. @@ -121,7 +143,7 @@ pub struct Datastream { pub thing_id: String, } -/// SensorThings `Observation` — one measured value. +/// SensorThings-inspired `Observation` — one measured value. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Observation { @@ -140,7 +162,8 @@ pub struct Observation { pub datastream_id: String, } -/// SensorThings `FeatureOfInterest` — where the observation applies. +/// SensorThings-inspired `FeatureOfInterest` — where the observation +/// applies. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FeatureOfInterest { @@ -155,8 +178,8 @@ pub struct FeatureOfInterest { pub feature: GeoJsonPoint, } -/// A fully linked SensorThings entity set for one observation — every -/// accepted observation must be projectable (ADR-264 §14 criterion 6). +/// 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 { @@ -176,8 +199,12 @@ pub struct SensorThingsBundle { pub feature_of_interest: FeatureOfInterest, } -/// Project one normalized [`EnvSample`] into a fully linked SensorThings -/// entity set with stable, deterministic ids. +/// 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); @@ -213,7 +240,13 @@ pub fn project_sample(sample: &EnvSample) -> SensorThingsBundle { sample.modality.as_str(), sample.node_id ), - encoding_type: "application/pdf".into(), + 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 { @@ -229,6 +262,11 @@ pub fn project_sample(sample: &EnvSample) -> SensorThingsBundle { 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(), @@ -344,13 +382,32 @@ mod tests { assert!(json.contains("\"resultQuality\"")); assert!(json.contains("\"unitOfMeasurement\"")); assert!(json.contains("\"encodingType\":\"application/geo+json\"")); - assert!(json.contains("\"encodingType\":\"application/pdf\"")); + // 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); diff --git a/crates/rucelium-federation/src/summary.rs b/crates/rucelium-federation/src/summary.rs index b5a2a05..9581104 100644 --- a/crates/rucelium-federation/src/summary.rs +++ b/crates/rucelium-federation/src/summary.rs @@ -141,15 +141,40 @@ impl Biome { } } -/// Errors raised by [`FederationBus`] publication. +/// 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 signer public key is not a registered biome (hex key attached). + /// 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. + 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 { @@ -157,26 +182,57 @@ impl std::fmt::Display for FederationError { match self { FederationError::Unsigned => write!(f, "payload is unsigned"), FederationError::BadSignature => write!(f, "signature verification failed"), - FederationError::UnknownBiome(pk) => { - write!(f, "signer is not a registered biome: {pk}") + 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::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 biome's registered federation identity: its current public key and the +/// key epoch it was registered under (rotation counter). +#[derive(Debug, Clone, PartialEq, Eq)] +struct BiomeKey { + /// Hex ed25519 public key currently bound to the biome id. + pubkey_hex: String, + /// Monotonic rotation epoch; re-registration must strictly increase it + /// to change the key. + key_epoch: u32, +} + /// Minimal in-memory federation exchange (ADR-264 §7): registered biomes -/// publish signed summaries and events; everything unsigned, unverifiable, or -/// from an unregistered key is rejected. +/// 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 public keys (hex). - biomes: BTreeSet, + /// 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 { @@ -186,40 +242,105 @@ impl FederationBus { FederationBus::default() } - /// Register a biome by its hex public key. Only registered biomes may - /// publish. - pub fn register_biome(&mut self, pubkey_hex: impl Into) { - self.biomes.insert(pubkey_hex.into()); + /// Register a biome identity with its hex public key at `key_epoch`. + /// Only registered biomes may publish, and only under their own + /// `biome_id`. + /// + /// Re-registering the same `biome_id` with a **strictly higher** epoch + /// replaces the key (rotation); summaries signed by the old key are + /// rejected from then on. Re-registering with the same key is an + /// idempotent no-op. A lower-or-equal epoch with a *different* key is + /// rejected as [`FederationError::StaleKeyEpoch`] — a stolen old + /// registration cannot roll the identity back. + pub fn register_biome( + &mut self, + biome_id: impl Into, + pubkey_hex: impl Into, + key_epoch: u32, + ) -> Result<(), FederationError> { + let biome_id = biome_id.into(); + let pubkey_hex = pubkey_hex.into(); + if let Some(current) = self.biomes.get(&biome_id) { + if key_epoch <= current.key_epoch && pubkey_hex != current.pubkey_hex { + return Err(FederationError::StaleKeyEpoch { + biome_id, + epoch: key_epoch, + }); + } + if key_epoch <= current.key_epoch { + return Ok(()); // idempotent re-registration of the same key + } + } + self.biomes.insert( + biome_id, + BiomeKey { + pubkey_hex, + key_epoch, + }, + ); + Ok(()) + } + + /// Look up the registered key for a claimed biome id and enforce + /// identity binding against the payload's signer key. + 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. Rejects unsigned payloads, - /// unregistered signers, and anything whose signature fails to verify. + /// 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); }; - if !self.biomes.contains(pk) { - return Err(FederationError::UnknownBiome(pk.clone())); - } + 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 checks, via - /// [`verify_event`]. + /// 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); }; - if !self.biomes.contains(pk) { - return Err(FederationError::UnknownBiome(pk.clone())); - } + 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(()) } @@ -241,29 +362,43 @@ impl FederationBus { mod tests { use super::*; use crate::biome::BiomeConfig; - use crate::testutil::{sample, SEED}; + 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 b = Biome::new(BiomeConfig::new("biome/test-forest"), SEED); - b.accept(sample(1, 1, 1_000, 10.0)); - b.accept(sample(1, 2, 2_000, 20.0)); - b.accept(sample(2, 1, 3_000, 30.0)); - b.accept(sample(2, 2, 9_000, 99.0)); // outside [0, 5000) window + 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() { + 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/test-forest"); + 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); - assert!((w.mean_quality - f64::from(0.9_f32)).abs() < 1e-12); + // 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); } @@ -301,13 +436,13 @@ mod tests { let s = b.summarize(0, 5_000); let mut bus = FederationBus::new(); - // Unregistered biome. + // Unregistered biome id. assert_eq!( bus.publish(s.clone()), - Err(FederationError::UnknownBiome(b.public_key_hex())) + Err(FederationError::UnknownBiome(BIOME_ID.into())) ); - bus.register_biome(b.public_key_hex()); + bus.register_biome(BIOME_ID, b.public_key_hex(), 1).unwrap(); // Unsigned. let mut unsigned = s.clone(); @@ -326,17 +461,106 @@ mod tests { } #[test] - fn bus_publishes_events_with_same_checks() { + 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() { + let b = biome_with_data(); + let mut bus = registered_bus(&b); + let old_key_summary = b.summarize(0, 5_000); + + // Rotate: a new biome key at a strictly higher epoch. + let rotated = Biome::new( + BiomeConfig::new(BIOME_ID), + b"rucelium-rotated-seed-32-bytes-!", + ); + bus.register_biome(BIOME_ID, rotated.public_key_hex(), 2) + .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 back to the old key at a lower or equal epoch fails. + assert_eq!( + bus.register_biome(BIOME_ID, b.public_key_hex(), 1), + Err(FederationError::StaleKeyEpoch { + biome_id: BIOME_ID.into(), + epoch: 1 + }) + ); + assert_eq!( + bus.register_biome(BIOME_ID, b.public_key_hex(), 2), + Err(FederationError::StaleKeyEpoch { + biome_id: BIOME_ID.into(), + epoch: 2 + }) + ); + // Idempotent re-registration of the current key is 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!(matches!( + assert_eq!( bus.publish_event(event.clone()), - Err(FederationError::UnknownBiome(_)) - )); + Err(FederationError::UnknownBiome(BIOME_ID.into())) + ); - bus.register_biome(b.public_key_hex()); + bus.register_biome(BIOME_ID, b.public_key_hex(), 1).unwrap(); let mut tampered = event.clone(); tampered.message.push('!'); @@ -349,16 +573,57 @@ mod tests { unsigned.signature_hex = None; assert_eq!(bus.publish_event(unsigned), Err(FederationError::Unsigned)); - bus.publish_event(event).unwrap(); + // 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("ab".into()) + assert!(FederationError::UnknownBiome("biome/x".into()) .to_string() - .contains("ab")); + .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")); } } 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/lib.rs b/examples/src/lib.rs new file mode 100644 index 0000000..c4a0ea4 --- /dev/null +++ b/examples/src/lib.rs @@ -0,0 +1 @@ +//! scaffold From 27d2866153831e8d66ae8beb8ae62c53a8aa882f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:45:52 +0000 Subject: [PATCH 12/27] feat(examples): shared scenario toolkit for worked applications examples/ workspace member (rucelium-examples): deterministic Rng, Node provisioning that signs real 48-byte wire records, a Gateway harness over the real IngestPipeline, and narrative output helpers. Sensor values are simulated; the verification machinery is the production code. 4 toolkit tests: sealed sample round-trip, replay rejection, tamper rejection, PRNG determinism. Ten application scenarios land next. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- examples/src/lib.rs | 256 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 255 insertions(+), 1 deletion(-) diff --git a/examples/src/lib.rs b/examples/src/lib.rs index c4a0ea4..3d7ab2e 100644 --- a/examples/src/lib.rs +++ b/examples/src/lib.rs @@ -1 +1,255 @@ -//! scaffold +//! # 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()); + } + } +} From 97e0ca4ccb06d83d25abfa78e1c3329617c92a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:46:56 +0000 Subject: [PATCH 13/27] refactor(rucelium-bench): migrate to hardened APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference-model runner now goes through the post-review surfaces: - sealed VerifiedEnvSample from ingest; calibration applied via .modify() - the outage buffer holds ORIGINAL SIGNED ENVELOPES, and every restored envelope is re-verified cryptographically (reverify_stored) before the biome accepts it — strictly more faithful than the previous path, and the duplicate-free assertions still hold - FederationBus::register_biome with an explicit key epoch - GatewayValidator with a gateway identity seed; safety budget charged only after successful execution (record_execution) All 3 acceptance tests green: full §14 run, same-seed determinism fingerprint, seed-robustness. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-bench/src/runner.rs | 134 +++++++++++++++++++--------- 1 file changed, 94 insertions(+), 40 deletions(-) diff --git a/crates/rucelium-bench/src/runner.rs b/crates/rucelium-bench/src/runner.rs index cb49f20..a41fc84 100644 --- a/crates/rucelium-bench/src/runner.rs +++ b/crates/rucelium-bench/src/runner.rs @@ -22,8 +22,8 @@ use rucelium_federation::{ }; use rucelium_ingest::{DeviceRegistry, IngestPipeline}; use rucelium_policy::{ - AgentProposal, AuditTrail, AuthorityRegistry, CommandSigner, GatewayValidator, PolicyConfig, - PolicyEngine, ProposalKind, SafetyConfig, SafetySimulator, + verify_receipt, AgentProposal, AuditTrail, AuthorityRegistry, CommandSigner, GatewayValidator, + PolicyConfig, PolicyEngine, ProposalKind, SafetyConfig, SafetySimulator, }; use rucelium_worldgraph::{ assess_plausibility, fuse_rf_context, GraphNode, Plausibility, RfContext, WorldGraph, @@ -38,6 +38,13 @@ const FLOOD_THRESHOLD_M: f64 = 1.6; 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. @@ -150,13 +157,13 @@ fn rf_context_for_day(day: u32, motion_energy: f32) -> RfContext { 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("sluice-gate-1".into()); + 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", "sluice-gate-1"); + authority.grant(biome_id, "agent/flood", ACTUATOR_ID); let signer = CommandSigner::from_seed(GOV_SEED); - let mut gateway = GatewayValidator::new(vec![signer.public_hex()]); + let mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED); let mut executed = 0u64; let mut rejected = 0u64; @@ -180,9 +187,16 @@ fn run_control_path(biome_id: &str, quarantined_node: u64, now_ns: u64) -> (u64, .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| "applied".into(), &mut audit) + gateway.validate_and_execute( + &cmd, + now_ns + 1, + |_k| Ok("applied".to_string()), + &mut audit, + ) }); - if done.is_ok() { + 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; @@ -194,7 +208,7 @@ fn run_control_path(biome_id: &str, quarantined_node: u64, now_ns: u64) -> (u64, agent_id: "agent/flood".into(), biome_id: biome_id.into(), kind: ProposalKind::ActuatorCommand { - actuator_id: "sluice-gate-1".into(), + actuator_id: ACTUATOR_ID.into(), action: "open_fraction".into(), magnitude: 0.5, }, @@ -207,9 +221,18 @@ fn run_control_path(biome_id: &str, quarantined_node: u64, now_ns: u64) -> (u64, .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| "opened 50%".into(), &mut audit) + gateway.validate_and_execute( + &cmd, + now_ns + 1, + |_k| Ok("opened 50%".to_string()), + &mut audit, + ) }); - if done.is_ok() { + 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; @@ -222,7 +245,7 @@ fn run_control_path(biome_id: &str, quarantined_node: u64, now_ns: u64) -> (u64, agent_id: "agent/unknown".into(), biome_id: biome_id.into(), kind: ProposalKind::ActuatorCommand { - actuator_id: "sluice-gate-1".into(), + actuator_id: ACTUATOR_ID.into(), action: "open_fraction".into(), magnitude: 0.7, }, @@ -267,7 +290,16 @@ pub fn run(config: SimConfig) -> BiomeReport { ); let mut biome = Biome::new(BiomeConfig::new("biome/synthetic-watershed"), BIOME_SEED); let mut bus = FederationBus::new(); - bus.register_biome(biome.public_key_hex()); + // 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. --- @@ -332,23 +364,33 @@ pub fn run(config: SimConfig) -> BiomeReport { if was_offline && !em.uplink_down { was_offline = false; // Prove restart-safety: serialize + restore the buffer state, - // drain the restored copy, and accept everything. + // 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 s in restored.drain() { - match biome.accept(s) { + 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 s in again.drain() { - match biome.accept(s) { + 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::Duplicate | AcceptOutcome::Revoked => {} } } buffer = OutageBuffer::new(); @@ -380,7 +422,12 @@ pub fn run(config: SimConfig) -> BiomeReport { } // Calibration (lineage-checked affine + stated uncertainty). - let outcome = calibrator.apply(&store, &mut sample, em.received_ns); + // `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, @@ -389,29 +436,32 @@ pub fn run(config: SimConfig) -> BiomeReport { // drift accounting: drift is a slow, single-sensor // phenomenon; an environmental event (many sensors deviating // together) must not quarantine healthy sensors. - let is_local_anomaly = sample.modality == SensorModality::WaterQuality - && sample.value > FLOOD_THRESHOLD_M; + // 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 = (sample.measured_ns - EPOCH_START_NS) / NS_PER_S; - let expected = anchor_expectation(sample.modality, em.node_index, t_s); - let residual = (sample.value - expected) / (4.0 * noise_sd(sample.modality)); - drift.observe(sample.node_id, residual); + 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(sample.node_id); + 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(&sample); + let key = graph.register_observation(view); let _ = graph.link_within_region(&key, "region/synthetic-watershed"); - if water_sensor_key.is_none() && sample.modality == SensorModality::WaterQuality { + 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(&sample); + let bundle = project_sample(view); debug_assert!(bundle.observation.result.is_finite()); sensorthings_projected += 1; @@ -425,16 +475,16 @@ pub fn run(config: SimConfig) -> BiomeReport { kind: EventKind::FloodRisk, severity: Severity::Warning, modality: SensorModality::WaterQuality, - geo: sample.geo, - window_start_ns: sample.measured_ns, - window_end_ns: sample.measured_ns, + 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: sample.node_id, - sequence: sample.sequence, + node_id: view.node_id, + sequence: view.sequence, }], confidence: 0.92, - message: format!("water level {:.2} m above flood threshold", sample.value), + message: format!("water level {:.2} m above flood threshold", view.value), signature_hex: None, signer_pubkey_hex: None, }; @@ -450,14 +500,18 @@ pub fn run(config: SimConfig) -> BiomeReport { // Usability metric (criterion 8): calibrated, healthy, high // quality. Quarantined-node data stays stored but flagged. - if calibrated && !quarantined && sample.quality >= 0.9 { + if calibrated && !quarantined && view.quality >= 0.9 { usable += 1; } // Biome admission: live when online, store-and-forward when - // the uplink is down (the buffer dedups by (node, sequence)). + // 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(sample); + let pushed = buffer + .push(&em.envelope, em.received_ns) + .expect("genuine envelope decodes structurally"); buffered_during_outage += u64::from(pushed); pushed } else { From 224c3a878fd6d5d17300a3f92a094b8ef147e029 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:50:52 +0000 Subject: [PATCH 14/27] =?UTF-8?q?wip(rucelium-gateway):=20mid-migration=20?= =?UTF-8?q?snapshot=20=E2=80=94=20does=20not=20compile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot of the in-flight migration to the hardened APIs so the work is not lost. New modules landing: journal.rs (durable command-phase journal for restart-safe execute-once) and control.rs (governed control path endpoint). Replay-window priming from the store's durable dedup index and the kill/restart/resend acceptance test are part of this migration. The build is red at this commit by construction; the completed, verified migration lands in the next commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-gateway/src/api.rs | 94 ++++++++- crates/rucelium-gateway/src/config.rs | 23 +++ crates/rucelium-gateway/src/control.rs | 262 ++++++++++++++++++++++++ crates/rucelium-gateway/src/journal.rs | 178 ++++++++++++++++ crates/rucelium-gateway/src/lib.rs | 22 +- crates/rucelium-gateway/src/pipeline.rs | 34 ++- crates/rucelium-gateway/src/state.rs | 242 +++++++++++++++++++++- 7 files changed, 830 insertions(+), 25 deletions(-) create mode 100644 crates/rucelium-gateway/src/control.rs create mode 100644 crates/rucelium-gateway/src/journal.rs diff --git a/crates/rucelium-gateway/src/api.rs b/crates/rucelium-gateway/src/api.rs index 9edfedb..cda9b8b 100644 --- a/crates/rucelium-gateway/src/api.rs +++ b/crates/rucelium-gateway/src/api.rs @@ -5,11 +5,18 @@ //! # SECURITY (v0.1) //! //! **The admin endpoints carry NO authentication.** `POST -//! /api/admin/revoke/{node_id}` revokes a device key immediately. 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). +//! /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::state::{now_ns, GatewayState}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -17,6 +24,7 @@ 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; @@ -66,6 +74,7 @@ pub fn router(state: GatewayState) -> Router { .route("/api/federation/revocations", get(fed_revocations)) .route("/api/federation/peers", get(fed_peers)) .route("/api/admin/revoke/:node_id", post(admin_revoke)) + .route("/api/admin/command", post(admin_command)) .with_state(state) } @@ -96,6 +105,7 @@ async fn stats(State(state): State) -> Json { "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, @@ -250,3 +260,79 @@ async fn admin_revoke( "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 index b5b09b1..2b6b245 100644 --- a/crates/rucelium-gateway/src/config.rs +++ b/crates/rucelium-gateway/src/config.rs @@ -19,6 +19,11 @@ pub const DEFAULT_SIM_INTERVAL_MS: u64 = 1000; 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)] @@ -45,6 +50,12 @@ pub struct GatewayConfig { pub retention_check_secs: u64, /// Peer federation poll interval in milliseconds (short in tests). pub federation_poll_ms: u64, + /// 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 { @@ -60,6 +71,8 @@ impl Default for GatewayConfig { sim_interval_ms: DEFAULT_SIM_INTERVAL_MS, retention_check_secs: DEFAULT_RETENTION_CHECK_SECS, federation_poll_ms: DEFAULT_FEDERATION_POLL_MS, + actuator_id: DEFAULT_ACTUATOR_ID.to_string(), + fsync: DEFAULT_FSYNC, } } } @@ -93,6 +106,8 @@ impl GatewayConfig { config.federation_poll_ms = parse_num(&value("--federation-poll-ms")?, "--federation-poll-ms")?; } + "--actuator" => config.actuator_id = value("--actuator")?, + "--fsync" => config.fsync = parse_num(&value("--fsync")?, "--fsync")?, unknown => return Err(format!("unknown flag {unknown}")), } } @@ -128,6 +143,8 @@ mod tests { assert_eq!(c.sim_interval_ms, 1000); assert_eq!(c.retention_check_secs, 3600); assert_eq!(c.federation_poll_ms, 30_000); + assert_eq!(c.actuator_id, "sluice-gate-1"); + assert!(c.fsync, "the daemon fsyncs accepted appends by default"); } #[test] @@ -151,6 +168,10 @@ mod tests { "60", "--federation-poll-ms", "200", + "--actuator", + "weir-3", + "--fsync", + "false", ])) .unwrap(); assert_eq!(c.biome_id, "biome/x"); @@ -162,6 +183,8 @@ mod tests { 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] diff --git a/crates/rucelium-gateway/src/control.rs b/crates/rucelium-gateway/src/control.rs new file mode 100644 index 0000000..0905048 --- /dev/null +++ b/crates/rucelium-gateway/src/control.rs @@ -0,0 +1,262 @@ +//! 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/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 index 8540ccd..b47da53 100644 --- a/crates/rucelium-gateway/src/lib.rs +++ b/crates/rucelium-gateway/src/lib.rs @@ -12,8 +12,25 @@ //! HTTP :7465 ──► /health /api/stats /api/observations/recent /api/events //! ──► /api/sensorthings/{Things,Datastreams,Observations} //! ──► /api/federation/{pubkey,summary,revocations,peers} +//! ──► /api/admin/{revoke/:node_id,command} //! ``` //! +//! ## 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 @@ -26,15 +43,18 @@ 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 use config::GatewayConfig; +pub use control::run_proposal; pub use pipeline::{process_datagram, ProcessOutcome}; -pub use state::{GatewayState, Inner, PeerSummary}; +pub use state::{ControlStats, GatewayState, Inner, PeerSummary}; use rucelium_core::DataClass; use std::time::Duration; diff --git a/crates/rucelium-gateway/src/pipeline.rs b/crates/rucelium-gateway/src/pipeline.rs index 6cba610..19e98ed 100644 --- a/crates/rucelium-gateway/src/pipeline.rs +++ b/crates/rucelium-gateway/src/pipeline.rs @@ -97,6 +97,8 @@ fn ingest_compact(inner: &mut Inner, datagram: &[u8], received_ns: u64) -> Proce /// 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()), @@ -104,29 +106,39 @@ fn ingest_v1(inner: &mut Inner, envelope: &[u8], received_ns: u64) -> ProcessOut // 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). - if inner - .calibrator - .apply(&inner.calibration, &mut sample, received_ns) - .is_err() - { - inner.calibration_errors += 1; + // 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(sample.node_id, 0.0); + let _ = inner.drift.observe(view.node_id, 0.0); // WorldGraph registration (idempotent) before storage. - inner.graph.register_observation(&sample); + inner.graph.register_observation(&view); - if let Err(e) = inner.obs.append(&sample) { + // 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, &sample, received_ns); + maybe_alert(inner, &view, received_ns); // Biome admission last; duplicates are counted inside the biome. let _ = inner.biome.accept(sample); diff --git a/crates/rucelium-gateway/src/state.rs b/crates/rucelium-gateway/src/state.rs index 45f57fd..12a16f6 100644 --- a/crates/rucelium-gateway/src/state.rs +++ b/crates/rucelium-gateway/src/state.rs @@ -8,14 +8,24 @@ //! Finer-grained locking is deliberate future work. use crate::config::GatewayConfig; -use rucelium_calibration::{CalibrationStore, Calibrator, DriftDetector}; +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::BTreeSet; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex; @@ -26,6 +36,10 @@ const OBS_SEGMENT_MAX_RECORDS: usize = 4096; 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. @@ -60,12 +74,29 @@ pub struct PeerSummary { pub fetched_ns: u64, } +/// 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. + /// 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. @@ -92,20 +123,109 @@ pub struct Inner { 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). + /// `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) - .map_err(|e| format!("open observation store: {e}"))?; - let events = EventStore::open(&config.data_dir.join("events"), EVT_SEGMENT_MAX_RECORDS) - .map_err(|e| format!("open event store: {e}"))?; + 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: IngestPipeline::default(), - calibration: CalibrationStore::new(), + ingest, + calibration: CalibrationStore::with_authorities(authorities), + cal_signer, calibrator: Calibrator::default(), drift: DriftDetector::default(), graph: WorldGraph::new(), @@ -119,8 +239,38 @@ impl Inner { 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()) + } } /// Handle shared by every task and HTTP handler. Cheap to clone. @@ -169,6 +319,26 @@ pub fn biome_seed(biome_id: &str, seed: u64) -> [u8; 32] { 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::*; @@ -228,6 +398,60 @@ mod tests { 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(); From bf161715076357906e13865b6bdb6038c42b5d74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:54:14 +0000 Subject: [PATCH 15/27] =?UTF-8?q?docs(rucelium):=20ADR-267=20long-term=20p?= =?UTF-8?q?rovenance=20=E2=80=94=20Merkle=20notarization,=20PQ-ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOTA research outcome. The gap: environmental evidence is retained for years (compliance disputes, climate baselines), but ed25519 signatures lose trustworthiness on that horizon. The naive fix does not fit: scheme signature envelope LoRaWAN DR0 datagrams ed25519 64 B 114 B 3 ML-DSA-44 2,420 B ~2,470 B ~49 ML-DSA's speed is fine; SIZE is the binding constraint at the radio. Decision: split what the signature is doing. Authenticity NOW stays ed25519 per observation at the node (unchanged radio budget). Verifiability LATER moves to a gateway-side Merkle notary that signs only batch ROOTS — amortizing a Dilithium-class signature to well under a byte per observation (4,096-leaf batch: 2,420 B / 4,096 = 0.6 B each), with on-demand inclusion proofs instead of per-record overhead. What ships is algorithm AGILITY, honestly labelled: RuCelium is post-quantum READY, not post-quantum. No ML-DSA implementation is included — a hand-rolled lattice implementation would be worse than none. Roots are self-describing (NotaryAlgorithm recorded inside the signed structure), migration is hybrid dual-signing, and history gains the new guarantee by re-notarization (old roots become leaves of a new PQ-signed tree) without re-signing a single stored observation. Scaffolds crates/rucelium-notary; implementation lands next. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.lock | 12 ++ Cargo.toml | 2 + crates/rucelium-notary/Cargo.toml | 20 +++ crates/rucelium-notary/src/lib.rs | 1 + docs/ADR-267-rucelium-long-term-provenance.md | 149 ++++++++++++++++++ 5 files changed, 184 insertions(+) create mode 100644 crates/rucelium-notary/Cargo.toml create mode 100644 crates/rucelium-notary/src/lib.rs create mode 100644 docs/ADR-267-rucelium-long-term-provenance.md diff --git a/Cargo.lock b/Cargo.lock index 1b24789..ee28b96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,7 @@ dependencies = [ "rucelium-core", "rucelium-federation", "rucelium-ingest", + "rucelium-policy", "rucelium-store", "rucelium-transport", "rucelium-worldgraph", @@ -998,6 +999,17 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index d51ea96..93481c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/rucelium-store", "crates/rucelium-transport", "crates/rucelium-gateway", + "crates/rucelium-notary", "examples", ] @@ -58,6 +59,7 @@ 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] diff --git a/crates/rucelium-notary/Cargo.toml b/crates/rucelium-notary/Cargo.toml new file mode 100644 index 0000000..e5f3a93 --- /dev/null +++ b/crates/rucelium-notary/Cargo.toml @@ -0,0 +1,20 @@ +[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 } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rucelium-notary/src/lib.rs b/crates/rucelium-notary/src/lib.rs new file mode 100644 index 0000000..179adb7 --- /dev/null +++ b/crates/rucelium-notary/src/lib.rs @@ -0,0 +1 @@ +//! placeholder 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: + From 501465477b303a27aaee728d98160eb720597043 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:55:39 +0000 Subject: [PATCH 16/27] feat(rucelium-gateway): restart-safe runtime + reviewer acceptance test PASSES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the review response. The gateway is migrated to every hardened API and the restart-attack surface is now closed: - replay windows are PRIMED FROM THE DURABLE DEDUP INDEX at startup (ingest.prime_from_dedup(store.dedup_keys())), so a restarted gateway refuses previously-accepted packets - command execute-once survives restart via a durable phase journal (journal.rs: export_phases/restore_phases, fail-closed on Executing) - strict signed calibration authorities in the daemon - POST /api/admin/command drives the full governed control path - --fsync (default on) tests/restart.rs — the reviewer's acceptance test, all 7 conditions: 1. packet seq 100 rejected after restart PASS 2. command cmd-42 rejected after restart PASS 3. retention-deleted observation still replay-rejected after restart (durable dedup index) PASS 4. serialized EnvSample with verified=true cannot enter the biome (type-level) PASS 5. registered federation key cannot claim another biome identity PASS 6. duplicated signed regional summary rejected PASS 7. corrupted COMPLETE record = integrity error, not silent truncation PASS 34 unit + 1 e2e + 6 restart tests green. Also lands the first four application examples (flood-watershed, irrigation-agriculture, sentinel-forest, ecosystem-immune) — the remaining six are still being written. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-gateway/Cargo.toml | 1 + crates/rucelium-gateway/src/main.rs | 4 +- crates/rucelium-gateway/src/simulate.rs | 9 +- crates/rucelium-gateway/tests/restart.rs | 622 ++++++++++++++ examples/src/bin/ecosystem-immune.rs | 939 +++++++++++++++++++++ examples/src/bin/flood-watershed.rs | 783 +++++++++++++++++ examples/src/bin/irrigation-agriculture.rs | 691 +++++++++++++++ examples/src/bin/sentinel-forest.rs | 863 +++++++++++++++++++ 8 files changed, 3908 insertions(+), 4 deletions(-) create mode 100644 crates/rucelium-gateway/tests/restart.rs create mode 100644 examples/src/bin/ecosystem-immune.rs create mode 100644 examples/src/bin/flood-watershed.rs create mode 100644 examples/src/bin/irrigation-agriculture.rs create mode 100644 examples/src/bin/sentinel-forest.rs diff --git a/crates/rucelium-gateway/Cargo.toml b/crates/rucelium-gateway/Cargo.toml index f99f8cd..d57080e 100644 --- a/crates/rucelium-gateway/Cargo.toml +++ b/crates/rucelium-gateway/Cargo.toml @@ -21,6 +21,7 @@ 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 } diff --git a/crates/rucelium-gateway/src/main.rs b/crates/rucelium-gateway/src/main.rs index a85c0cf..8e8370c 100644 --- a/crates/rucelium-gateway/src/main.rs +++ b/crates/rucelium-gateway/src/main.rs @@ -14,7 +14,7 @@ async fn main() { "usage: rucelium-gateway [--biome-id ] [--udp ] [--http ] \ [--data-dir ] [--peer ]... [--simulate ] [--seed ] \ [--sim-interval-ms ] [--retention-check-secs ] \ - [--federation-poll-ms ]" + [--federation-poll-ms ] [--actuator ] [--fsync ]" ); std::process::exit(2); } @@ -25,6 +25,8 @@ async fn main() { 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"); diff --git a/crates/rucelium-gateway/src/simulate.rs b/crates/rucelium-gateway/src/simulate.rs index f574057..af0bc2e 100644 --- a/crates/rucelium-gateway/src/simulate.rs +++ b/crates/rucelium-gateway/src/simulate.rs @@ -99,12 +99,15 @@ async fn provision(state: &GatewayState, n: u32, seed: u64) -> Vec { 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). + // 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.calibration.insert(CalibrationRecord { + let _ = inner.insert_signed_calibration(CalibrationRecord { calibration_id: u32::from(m.code()), node_id: 0, // the reference anchor station modality: m, @@ -135,7 +138,7 @@ async fn provision(state: &GatewayState, n: u32, seed: u64) -> Vec { format!("sha256:sim-fw-{i}"), ); let calibration_id = 1000 + i as u32; - let _ = inner.calibration.insert(CalibrationRecord { + let _ = inner.insert_signed_calibration(CalibrationRecord { calibration_id, node_id, modality, diff --git a/crates/rucelium-gateway/tests/restart.rs b/crates/rucelium-gateway/tests/restart.rs new file mode 100644 index 0000000..6791f34 --- /dev/null +++ b/crates/rucelium-gateway/tests/restart.rs @@ -0,0 +1,622 @@ +//! 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::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: &PathBuf) -> GatewayConfig { + GatewayConfig { + biome_id: "biome/restart".into(), + udp_port: 0, + http_port: 0, + data_dir: dir.clone(), + 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: &PathBuf) -> 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/examples/src/bin/ecosystem-immune.rs b/examples/src/bin/ecosystem-immune.rs new file mode 100644 index 0000000..8082b6f --- /dev/null +++ b/examples/src/bin/ecosystem-immune.rs @@ -0,0 +1,939 @@ +//! # 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::{ + 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, +} + +/// 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, + }); + } + + // 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 { + 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}")); + 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); + } + + #[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/flood-watershed.rs b/examples/src/bin/flood-watershed.rs new file mode 100644 index 0000000..c7dfa9a --- /dev/null +++ b/examples/src/bin/flood-watershed.rs @@ -0,0 +1,783 @@ +//! # 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 { + 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/irrigation-agriculture.rs b/examples/src/bin/irrigation-agriculture.rs new file mode 100644 index 0000000..4907c73 --- /dev/null +++ b/examples/src/bin/irrigation-agriculture.rs @@ -0,0 +1,691 @@ +//! # 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/sentinel-forest.rs b/examples/src/bin/sentinel-forest.rs new file mode 100644 index 0000000..81d32ba --- /dev/null +++ b/examples/src/bin/sentinel-forest.rs @@ -0,0 +1,863 @@ +//! # 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::{ + 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, +} + +/// 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, + }); + } + 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 { + 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); + 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); + } +} From 7a7dad4834d0d7387429809f3975b4cea40f6673 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:59:49 +0000 Subject: [PATCH 17/27] =?UTF-8?q?docs(rucelium):=20ADR-268=20=E2=80=94=20i?= =?UTF-8?q?ntegrate=20with=20the=20ruvnet=20stack,=20don't=20reinvent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surveyed the published ruvnet packages against what RuCelium built bottom-up, and found real duplication to stop: - agentdb ships a single-file .rvf cognitive container with HNSW search, causal graph, and provenance — literally the 'RVF buffering' (§13) and 'RuVector similarity search' (§7) the original ADR named, and exactly the shape ecosystem memory (ADR-266 B8) needs. Binding: a thin rucelium-memory adapter; the append-only store stays the source of truth, the vector container is derived and rebuildable. - agenticow's copy-on-write vector branching (~0.5 ms, 162 bytes, independent of base size) is the right substrate for the ADR-264 §9 SAFETY SIMULATION stage: branch biome state, simulate the actuation, discard the branch — and hand an auditor the branch a decision was made on. Deepens one existing stage; adds none. - the ten Mycelium agents belong on ruflo / agentic-flow. RuCelium ships no agent runtime; it ships what makes agents safe (typed proposals, deterministic policy, authority, signed commands, receipts) and will expose that as an MCP surface. - harness/ and agentic-flow converged independently on 'freeze the model, evolve the harness'; the harness README now credits that explicitly instead of implying novelty. Normative constraint: every binding is ADDITIVE — the §14 acceptance path must keep passing with none of these installed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- ...R-268-rucelium-ruvnet-stack-integration.md | 117 ++++++++++++++++++ harness/README.md | 16 +++ 2 files changed, 133 insertions(+) create mode 100644 docs/ADR-268-rucelium-ruvnet-stack-integration.md 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/harness/README.md b/harness/README.md index 399be57..80fe5dd 100644 --- a/harness/README.md +++ b/harness/README.md @@ -68,6 +68,22 @@ instead of rejecting it. 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 From bd70c280d15ffea31eb070ec0398b7be1e1a1291 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:01:02 +0000 Subject: [PATCH 18/27] feat(examples): six worked applications green; notary WIP snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications complete and passing (39 tests across them): - flood-watershed lead time vs conventional gauge, blocked culvert, storm-displaced sensor excluded, RF contradiction - irrigation-agriculture full governed actuation, verified signed receipt, unauthorized + over-magnitude proposals stopped - industrial-compliance regulator-verifiable evidence bundle, strict signed calibration lineage, independent verifier - sentinel-forest per-organism bioelectric baselines, confounder that must NOT escalate, capped evidence - ecosystem-immune biofilm corroborated by chemical sensors before escalation, source localization, governed response - airborne-dna acoustic/genetic confirmation and contradiction, human-DNA disclosure gate (four remaining: wildfire-risk, biodiversity-habitat, pollinator-hive, ecosystem-memory) Also snapshots in-flight work: rucelium-notary (ADR-267) is mid-write and its lib.rs references a bundle module not yet on disk — that crate does not compile at this commit; every other crate is green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-bench/src/runner.rs | 4 +- crates/rucelium-gateway/src/api.rs | 6 +- crates/rucelium-gateway/src/control.rs | 14 +- crates/rucelium-gateway/tests/restart.rs | 31 +- crates/rucelium-notary/src/lib.rs | 132 ++- crates/rucelium-notary/src/root.rs | 478 +++++++++++ crates/rucelium-notary/src/tree.rs | 532 ++++++++++++ examples/src/bin/airborne-dna.rs | 917 ++++++++++++++++++++ examples/src/bin/ecosystem-immune.rs | 42 +- examples/src/bin/flood-watershed.rs | 37 +- examples/src/bin/industrial-compliance.rs | 928 +++++++++++++++++++++ examples/src/bin/irrigation-agriculture.rs | 22 +- examples/src/bin/sentinel-forest.rs | 83 +- 13 files changed, 3158 insertions(+), 68 deletions(-) create mode 100644 crates/rucelium-notary/src/root.rs create mode 100644 crates/rucelium-notary/src/tree.rs create mode 100644 examples/src/bin/airborne-dna.rs create mode 100644 examples/src/bin/industrial-compliance.rs diff --git a/crates/rucelium-bench/src/runner.rs b/crates/rucelium-bench/src/runner.rs index a41fc84..198342e 100644 --- a/crates/rucelium-bench/src/runner.rs +++ b/crates/rucelium-bench/src/runner.rs @@ -439,8 +439,8 @@ pub fn run(config: SimConfig) -> BiomeReport { // 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; + 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); diff --git a/crates/rucelium-gateway/src/api.rs b/crates/rucelium-gateway/src/api.rs index cda9b8b..adacafc 100644 --- a/crates/rucelium-gateway/src/api.rs +++ b/crates/rucelium-gateway/src/api.rs @@ -328,9 +328,9 @@ fn control_stage(e: &ControlError) -> &'static str { ControlError::PolicyViolation(_) => "policy", ControlError::Unsafe(_) => "safety", ControlError::NotAuthorized { .. } => "authority", - ControlError::UntrustedKey(_) | ControlError::BadSignature | ControlError::BadEncoding(_) => { - "gateway_signature" - } + 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/control.rs b/crates/rucelium-gateway/src/control.rs index 0905048..81a60c8 100644 --- a/crates/rucelium-gateway/src/control.rs +++ b/crates/rucelium-gateway/src/control.rs @@ -22,9 +22,7 @@ //! budget is spent by execution, not by proposing. use crate::state::Inner; -use rucelium_policy::{ - AgentProposal, ControlError, ExecutionReceipt, ProposalKind, SignedCommand, -}; +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. @@ -170,8 +168,14 @@ mod tests { 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!( + 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); diff --git a/crates/rucelium-gateway/tests/restart.rs b/crates/rucelium-gateway/tests/restart.rs index 6791f34..abc7bed 100644 --- a/crates/rucelium-gateway/tests/restart.rs +++ b/crates/rucelium-gateway/tests/restart.rs @@ -30,7 +30,7 @@ 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::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::net::UdpSocket; @@ -109,12 +109,12 @@ fn stored_sample(ingest: &mut IngestPipeline, sequence: u32, measured_ns: u64) - /// 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: &PathBuf) -> GatewayConfig { +fn config(dir: &Path) -> GatewayConfig { GatewayConfig { biome_id: "biome/restart".into(), udp_port: 0, http_port: 0, - data_dir: dir.clone(), + data_dir: dir.to_path_buf(), fsync: true, ..GatewayConfig::default() } @@ -124,16 +124,14 @@ fn config(dir: &PathBuf) -> GatewayConfig { /// 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: &PathBuf) -> rucelium_gateway::GatewayHandle { +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()); + 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") @@ -361,7 +359,11 @@ fn retention_deleted_records_are_still_replay_protected_after_restart() { // --- 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_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" @@ -588,7 +590,10 @@ fn a_corrupted_complete_record_is_an_integrity_error_not_truncation() { first_line.contains("air_temperature"), "expected the observed property in the record" ); - let tampered = format!("{}{rest}", first_line.replace("air_temperature", "air_temperaturx")); + let tampered = format!( + "{}{rest}", + first_line.replace("air_temperature", "air_temperaturx") + ); assert_eq!( tampered.len(), text.len(), diff --git a/crates/rucelium-notary/src/lib.rs b/crates/rucelium-notary/src/lib.rs index 179adb7..cd2d925 100644 --- a/crates/rucelium-notary/src/lib.rs +++ b/crates/rucelium-notary/src/lib.rs @@ -1 +1,131 @@ -//! placeholder +//! # 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..d3c0d0f --- /dev/null +++ b/crates/rucelium-notary/src/root.rs @@ -0,0 +1,478 @@ +//! 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::*; + + 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<(&str, fn(&mut NotaryRoot))> = 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..a1a3a93 --- /dev/null +++ b/crates/rucelium-notary/src/tree.rs @@ -0,0 +1,532 @@ +//! 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. +#[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)); + + // Wrong leaf_count changes the geometry. + let mut bad = p.clone(); + bad.leaf_count = 9; + assert!(!verify_inclusion(&ls[3], &bad, &root)); + let mut bad = p.clone(); + bad.leaf_count = 7; + assert!(!verify_inclusion(&ls[3], &bad, &root)); + let mut bad = p.clone(); + bad.leaf_count = 0; + assert!(!verify_inclusion(&ls[3], &bad, &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/examples/src/bin/airborne-dna.rs b/examples/src/bin/airborne-dna.rs new file mode 100644 index 0000000..b672ed0 --- /dev/null +++ b/examples/src/bin/airborne-dna.rs @@ -0,0 +1,917 @@ +//! # 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::{ + 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_seq) = 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(seq)) + } 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 seq = sampler_seq.expect("a sample was taken"); + Some(EnvironmentalEvent { + 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: 0x00B3_0000_0000_0003, + sequence: seq, + }], + 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")); + // 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/ecosystem-immune.rs b/examples/src/bin/ecosystem-immune.rs index 8082b6f..62200cb 100644 --- a/examples/src/bin/ecosystem-immune.rs +++ b/examples/src/bin/ecosystem-immune.rs @@ -31,9 +31,7 @@ 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_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, @@ -393,8 +391,8 @@ pub fn govern(agent_id: &str, granted: bool, now_ns: u64) -> GovernanceOutcome { 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 mut gateway = GatewayValidator::new(vec![signer.public_hex()], GATEWAY_SEED) + .with_max_commands_per_actuator(2); let finish = |stopped_at: &str, error: Option, @@ -514,7 +512,8 @@ pub fn run() -> Report { 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) + 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); @@ -772,9 +771,15 @@ fn main() { 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}")); + line( + "naive / compensated / chemical detections", + format!("{naive} / {bio} / {chem}"), + ); line("evidence is biology only", a.bio_only); - line("severity before the biological cap", format!("{:?}", a.uncapped)); + line( + "severity before the biological cap", + format!("{:?}", a.uncapped), + ); line("severity emitted", format!("{:?}", a.severity)); line( "source localized (most upstream responder)", @@ -785,7 +790,11 @@ fn main() { 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!( + " -> 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"); @@ -807,8 +816,14 @@ fn main() { } 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( + "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()); @@ -822,7 +837,10 @@ mod tests { #[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::Critical), + Severity::Advisory + ); assert_eq!(bio_only_severity_cap(Severity::Warning), Severity::Advisory); assert_eq!(bio_only_severity_cap(Severity::Watch), Severity::Advisory); diff --git a/examples/src/bin/flood-watershed.rs b/examples/src/bin/flood-watershed.rs index c7dfa9a..fc1cf6f 100644 --- a/examples/src/bin/flood-watershed.rs +++ b/examples/src/bin/flood-watershed.rs @@ -365,7 +365,7 @@ fn watershed_event( evidence, confidence, message, - signature_hex: None, + signature_hex: None, signer_pubkey_hex: None, }; event.validate().expect("scenario events are well-formed"); @@ -556,10 +556,16 @@ fn main() { format!("{} / node {:#018x}", node.modality.as_str(), node.node_id), ); } - line(" [11] rf-gw-01 (RuView context)", "wifi_csi / not a spore node"); + 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)); + 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"); @@ -582,22 +588,31 @@ fn main() { run.lead_time_min().expect("alert precedes the gauge") ), ); - line("alert severity / confidence", format!("{:?} / {:.2}", alert.severity, alert.confidence)); + 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( + "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)); + 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 diff --git a/examples/src/bin/industrial-compliance.rs b/examples/src/bin/industrial-compliance.rs new file mode 100644 index 0000000..bf53f01 --- /dev/null +++ b/examples/src/bin/industrial-compliance.rs @@ -0,0 +1,928 @@ +//! # 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}"))?; + let signed_digest = signed_field(message, "obs_digest=")?; + let observed_digest = 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 { + 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 index 4907c73..537ef52 100644 --- a/examples/src/bin/irrigation-agriculture.rs +++ b/examples/src/bin/irrigation-agriculture.rs @@ -456,7 +456,10 @@ fn main() { ), ); } - line("irrigation trigger", format!("stress > {STRESS_TRIGGER:.2}")); + line( + "irrigation trigger", + format!("stress > {STRESS_TRIGGER:.2}"), + ); line( "policy ceiling / safety envelope", format!("{POLICY_MAX_MAGNITUDE:.2} / {SAFE_MAGNITUDE:.2}"), @@ -545,7 +548,10 @@ fn main() { ); } 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 (completed path)", + format!("{:?}", &zone_a_stages[..7]), + ); line( "zone A stages (after the replay attempt)", format!("{:?}", &zone_a_stages[7..]), @@ -578,7 +584,10 @@ mod tests { ] { let mut tampered = receipt.clone(); mutate(&mut tampered); - assert!(!verify_receipt(&tampered), "tampered receipt must not verify"); + assert!( + !verify_receipt(&tampered), + "tampered receipt must not verify" + ); } assert_eq!(run.zone_a_executions, 1); } @@ -609,7 +618,12 @@ mod tests { let stages = run.stages_for("irr-zone-a-002"); assert_eq!( stages, - vec!["proposed", "policy_evaluated", "safety_simulated", "authorized"] + vec![ + "proposed", + "policy_evaluated", + "safety_simulated", + "authorized" + ] ); } diff --git a/examples/src/bin/sentinel-forest.rs b/examples/src/bin/sentinel-forest.rs index 81d32ba..52439b3 100644 --- a/examples/src/bin/sentinel-forest.rs +++ b/examples/src/bin/sentinel-forest.rs @@ -401,7 +401,8 @@ pub fn run() -> Report { 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"); + 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); @@ -464,10 +465,12 @@ pub fn run() -> Report { 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"); + 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 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 { @@ -591,7 +594,10 @@ pub fn run() -> Report { "ecosystem/mixed-stand", EdgeKind::Supports, weight, - format!("bioelectric drought deviation z={:.1} (capped evidence)", v.adj_z), + 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); @@ -680,7 +686,10 @@ fn main() { .iter() .map(|b| b.mean_mv) .fold(f64::NEG_INFINITY, f64::max); - line("baseline mean spread across organisms", format!("{:.1} mV", hi - lo)); + 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 @@ -688,7 +697,10 @@ fn main() { 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)); + 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" @@ -705,14 +717,21 @@ fn main() { ); } 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(); + 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)); + 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" @@ -737,17 +756,35 @@ fn main() { 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( + "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, 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}")); + 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."); } @@ -825,7 +862,13 @@ mod tests { "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); + 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); } @@ -834,10 +877,16 @@ mod tests { #[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::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); + assert_eq!( + bio_only_severity_cap(Severity::Advisory), + Severity::Advisory + ); let r = run(); let ev = r.event.as_ref().expect("drought event was raised"); From 179a864224cff319a66aa637021579c8f8f05ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:03:10 +0000 Subject: [PATCH 19/27] feat(examples): wildfire-risk + pollinator-hive green (8 of 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wildfire-risk: RF-only detection capped at Advisory while physical PM + optical smoke evidence reaches Critical; heat-degraded sensor excluded AND reported rather than silently dropped - pollinator-hive: single-hive collapse stays Advisory (one hive failing is not a regional signal); only time-correlated multi-hive collapse escalates; a merely cold hive produces no event Remaining: biodiversity-habitat, ecosystem-memory. Also snapshots rucelium-notary (ADR-267) mid-iteration — 31 of 33 tests pass; its agent is still fixing the inclusion-proof index/count guard and one bundle assertion. Every other crate is green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-notary/src/bundle.rs | 833 +++++++++++++++++++++++++++ crates/rucelium-notary/tests/dbg.rs | 9 + examples/src/bin/pollinator-hive.rs | 820 ++++++++++++++++++++++++++ examples/src/bin/wildfire-risk.rs | 833 +++++++++++++++++++++++++++ 4 files changed, 2495 insertions(+) create mode 100644 crates/rucelium-notary/src/bundle.rs create mode 100644 crates/rucelium-notary/tests/dbg.rs create mode 100644 examples/src/bin/pollinator-hive.rs create mode 100644 examples/src/bin/wildfire-risk.rs diff --git a/crates/rucelium-notary/src/bundle.rs b/crates/rucelium-notary/src/bundle.rs new file mode 100644 index 0000000..9cc6a94 --- /dev/null +++ b/crates/rucelium-notary/src/bundle.rs @@ -0,0 +1,833 @@ +//! 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` +/// → [`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 !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 { + 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 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" + ); + } + + #[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/tests/dbg.rs b/crates/rucelium-notary/tests/dbg.rs new file mode 100644 index 0000000..4aeb9d4 --- /dev/null +++ b/crates/rucelium-notary/tests/dbg.rs @@ -0,0 +1,9 @@ +#[test] +fn dbg_float() { + let s = "23.470000000000002"; + let a: f64 = s.parse().unwrap(); + let b: f64 = serde_json::from_str(s).unwrap(); + println!("std = {:?} bits={:x}", a, a.to_bits()); + println!("serde = {:?} bits={:x}", b, b.to_bits()); + assert_eq!(a.to_bits(), b.to_bits()); +} diff --git a/examples/src/bin/pollinator-hive.rs b/examples/src/bin/pollinator-hive.rs new file mode 100644 index 0000000..1d77e56 --- /dev/null +++ b/examples/src/bin/pollinator-hive.rs @@ -0,0 +1,820 @@ +//! # 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::{ + DataClass, 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, +} + +/// 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, +} + +/// 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; + 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; + + 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, + }); + } + 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, + }); + } + 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 { + 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 { + 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); + line("evidence is a single colony (biology only)", a.bio_only); + line("severity before the biological cap", format!("{:?}", a.uncapped)); + line("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.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/wildfire-risk.rs b/examples/src/bin/wildfire-risk.rs new file mode 100644 index 0000000..62e767c --- /dev/null +++ b/examples/src/bin/wildfire-risk.rs @@ -0,0 +1,833 @@ +//! # 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 { + 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); + } +} From bde7b6dc9cefae93eaf060727bf87272877b72b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:05:52 +0000 Subject: [PATCH 20/27] =?UTF-8?q?docs(rucelium):=20ADR-269=20=E2=80=94=20p?= =?UTF-8?q?ush=20federation,=20and=20why=20QUIC=20belongs=20at=20only=20on?= =?UTF-8?q?e=20hop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers two questions that must not be conflated. Sensor boundary keeps datagrams. QUIC is a category error there: RFC 9000 requires a >=1200-byte padded Initial, ~24x our 114-byte envelope and far past LoRaWAN DR0's 51-byte MTU; a node waking every 30 min cannot amortize a handshake it will have lost by the next wakeup; EU868's 1% duty cycle often makes the round trips unaffordable. Decisively, we do not need what it provides — the envelope is OBJECT-secured (ed25519 over the exact 48 payload bytes), which is what lets an untrusted store-and-forward relay, i.e. a LoRaWAN network server, sit in the path harmlessly. Federation moves poll -> push, and this is a SECURITY fix before a performance one: 30s polling caps revocation propagation at 30s, so a compromised device stays valid at peers for up to a full interval. Push first, transport second, via a FederationTransport trait (announce / subscribe / sync_since) — with the polling backstop MANDATORY so a peer that missed a push still converges. QUIC becomes an optional transport (feature-gated, default off) where it genuinely earns it: connection migration across LTE/satellite/wifi failover, 0-RTT after a partition, per-artifact-class streams so a stalled summary cannot block a revocation, and traffic-analysis resistance — which matters for sensitive-species deployments, where alert TIMING leaks location even when payloads are signed and coordinates coarsened. Two normative constraints: QUIC is defence in depth and NEVER the trust boundary (everything received is verified identically regardless of transport), and TLS identity is the biome's existing ed25519 key rather than a new PKI. The §14 acceptance path and restart-attack tests must pass with the feature disabled. Note: no @ruvector/quic exists — RuVector is a vector database/format family (memory substrate, ADR-268 §2.1), not a transport. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- docs/ADR-269-rucelium-push-federation.md | 132 +++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/ADR-269-rucelium-push-federation.md 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 | From cfcea6894992cae1ec465591e4380199400db74d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:07:46 +0000 Subject: [PATCH 21/27] =?UTF-8?q?fix(rucelium):=20exact=20float=20parsing?= =?UTF-8?q?=20=E2=80=94=20signed=20JSON=20must=20survive=20the=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REAL production bug, found by the notary work and confirmed by measurement: serde_json's default float parser is fast but not exact. 18,496 of 200,000 realistic sensor values (9.2%) come back one ULP off after a JSON round-trip. Every signature in this workspace is computed over canonical JSON, and a peer verifies by RE-SERIALIZING what it parsed. So a genuine, correctly signed RegionalSummary or EnvironmentalEvent could fail verification at the peer — silently, intermittently, and only in the field. Our tests missed it because they signed and verified in-process, never across a wire round-trip. Fix: pin serde_json's float_roundtrip feature at the workspace level, with a comment stating it is load-bearing. Guard: rucelium-federation now has signed_summary_survives_a_json_wire_ round_trip, which does the real peer path (sign -> to_string -> from_str -> verify) and asserts bit-identical floats. Verified that the guard FAILS without the feature and passes with it. Also lands: - rucelium-notary complete (34 tests): domain-separated Merkle tree (last-node promotion, rejecting the CVE-2012-2459 duplication ambiguity), stateless inclusion proofs, algorithm-agile signed roots, third-party evidence-bundle verification, re-notarization chaining. Measured amortization: 2420 B / 4096 leaves = 0.59 B per observation. It also found that leaf_count must be bound to the SIGNED root, since verify_inclusion alone cannot reject a same-shape count. - all ten worked applications green (60 tests in examples/) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.toml | 7 +- crates/rucelium-federation/src/summary.rs | 49 ++ crates/rucelium-notary/Cargo.toml | 7 +- crates/rucelium-notary/src/bundle.rs | 64 +- crates/rucelium-notary/src/root.rs | 14 +- crates/rucelium-notary/src/tree.rs | 35 +- crates/rucelium-notary/tests/dbg.rs | 9 - examples/src/bin/airborne-dna.rs | 70 +- examples/src/bin/biodiversity-habitat.rs | 625 +++++++++++++++++ examples/src/bin/ecosystem-memory.rs | 792 ++++++++++++++++++++++ examples/src/bin/pollinator-hive.rs | 70 +- 11 files changed, 1675 insertions(+), 67 deletions(-) delete mode 100644 crates/rucelium-notary/tests/dbg.rs create mode 100644 examples/src/bin/biodiversity-habitat.rs create mode 100644 examples/src/bin/ecosystem-memory.rs diff --git a/Cargo.toml b/Cargo.toml index 93481c4..2bbf7f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,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 diff --git a/crates/rucelium-federation/src/summary.rs b/crates/rucelium-federation/src/summary.rs index 9581104..2fb5de6 100644 --- a/crates/rucelium-federation/src/summary.rs +++ b/crates/rucelium-federation/src/summary.rs @@ -626,4 +626,53 @@ mod tests { .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}" + ); + } + } } diff --git a/crates/rucelium-notary/Cargo.toml b/crates/rucelium-notary/Cargo.toml index e5f3a93..66a2726 100644 --- a/crates/rucelium-notary/Cargo.toml +++ b/crates/rucelium-notary/Cargo.toml @@ -14,7 +14,12 @@ rucelium-core = { workspace = true } sha2 = { workspace = true } ed25519-dalek = { workspace = true } serde = { workspace = true } -serde_json = { 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 index 9cc6a94..74d9bfe 100644 --- a/crates/rucelium-notary/src/bundle.rs +++ b/crates/rucelium-notary/src/bundle.rs @@ -300,7 +300,9 @@ impl EvidenceBundle { /// /// 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` +/// 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 @@ -324,7 +326,9 @@ pub fn verify_bundle( let root_hash = hex_decode32(&bundle.root.root_hex) .ok_or_else(|| NotaryError::Encoding(format!("root_hex {:?}", bundle.root.root_hex)))?; - if !verify_inclusion(&recomputed, &bundle.proof, &root_hash) { + if bundle.proof.leaf_count != bundle.root.leaf_count + || !verify_inclusion(&recomputed, &bundle.proof, &root_hash) + { return Err(NotaryError::ProofInvalid); } @@ -373,7 +377,11 @@ pub fn renotarize( .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_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(), @@ -685,6 +693,21 @@ mod tests { 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]); @@ -766,7 +789,10 @@ mod tests { // 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"); + 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(); @@ -810,10 +836,32 @@ mod tests { 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" - ); + 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] diff --git a/crates/rucelium-notary/src/root.rs b/crates/rucelium-notary/src/root.rs index d3c0d0f..6399416 100644 --- a/crates/rucelium-notary/src/root.rs +++ b/crates/rucelium-notary/src/root.rs @@ -213,8 +213,8 @@ impl RootVerifier for Ed25519RootVerifier { 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()) + let Some(sig_arr) = + hex_decode(sig_hex).and_then(|b| <[u8; ED25519_SIGNATURE_BYTES]>::try_from(b).ok()) else { return false; }; @@ -282,6 +282,9 @@ pub fn verify_root(root: &NotaryRoot, verifier: &dyn RootVerifier) -> bool { 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"; @@ -378,7 +381,7 @@ mod tests { }; assert!(verify_root(&signed, &v)); - let mutations: Vec<(&str, fn(&mut NotaryRoot))> = vec![ + 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()), @@ -398,7 +401,10 @@ mod tests { for (name, mutate) in mutations { let mut tampered = signed.clone(); mutate(&mut tampered); - assert!(!verify_root(&tampered, &v), "{name} mutation still verified"); + assert!( + !verify_root(&tampered, &v), + "{name} mutation still verified" + ); } // The algorithm tag is signed too: flipping it fails on the algorithm diff --git a/crates/rucelium-notary/src/tree.rs b/crates/rucelium-notary/src/tree.rs index a1a3a93..9560e7a 100644 --- a/crates/rucelium-notary/src/tree.rs +++ b/crates/rucelium-notary/src/tree.rs @@ -254,6 +254,14 @@ impl MerkleTree { /// 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 { @@ -452,16 +460,23 @@ mod tests { bad.leaf_index = 8; assert!(!verify_inclusion(&ls[3], &bad, &root)); - // Wrong leaf_count changes the geometry. - let mut bad = p.clone(); - bad.leaf_count = 9; - assert!(!verify_inclusion(&ls[3], &bad, &root)); - let mut bad = p.clone(); - bad.leaf_count = 7; - assert!(!verify_inclusion(&ls[3], &bad, &root)); - let mut bad = p.clone(); - bad.leaf_count = 0; - 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] diff --git a/crates/rucelium-notary/tests/dbg.rs b/crates/rucelium-notary/tests/dbg.rs deleted file mode 100644 index 4aeb9d4..0000000 --- a/crates/rucelium-notary/tests/dbg.rs +++ /dev/null @@ -1,9 +0,0 @@ -#[test] -fn dbg_float() { - let s = "23.470000000000002"; - let a: f64 = s.parse().unwrap(); - let b: f64 = serde_json::from_str(s).unwrap(); - println!("std = {:?} bits={:x}", a, a.to_bits()); - println!("serde = {:?} bits={:x}", b, b.to_bits()); - assert_eq!(a.to_bits(), b.to_bits()); -} diff --git a/examples/src/bin/airborne-dna.rs b/examples/src/bin/airborne-dna.rs index b672ed0..99dacaf 100644 --- a/examples/src/bin/airborne-dna.rs +++ b/examples/src/bin/airborne-dna.rs @@ -36,9 +36,7 @@ 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_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}; @@ -313,7 +311,8 @@ pub fn taxon_key(taxon: &str) -> String { #[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 station = + GeoPoint::new(512_384_100, -32_117_400, 8_000).expect("valid station coordinates"); let mut acoustic = Node::new( 0x00B3_0000_0000_0001, @@ -584,7 +583,12 @@ pub fn run() -> Report { // 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()) + .map(|d| { + d.non_human_taxa() + .into_iter() + .filter(|t| t.invasive) + .collect() + }) .unwrap_or_default(); let event = if invasive.is_empty() { None @@ -697,10 +701,19 @@ fn main() { 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( + "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( + "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); @@ -708,7 +721,10 @@ fn main() { line("acoustic classifier call", call); } if let Some(d) = &ep.dna { - line("sample id / total reads", format!("{} / {}", d.sample_id, d.total_reads)); + line( + "sample id / total reads", + format!("{} / {}", d.sample_id, d.total_reads), + ); for t in &d.taxa { println!( " {:<32} {:>7} reads{}{}", @@ -741,17 +757,17 @@ fn main() { 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()); + 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 severity (bio-only cap applied)", + format!("{:?}", ev.severity), + ); line("event confidence", format!("{:.2}", ev.confidence)); println!(" -> {}", ev.message); } @@ -759,7 +775,11 @@ fn main() { line("federated event verifies", verify_event(de)); line( "federated event location (coarsened)", - format!("{:.2}, {:.2}", de.geo.latitude_deg(), de.geo.longitude_deg()), + format!( + "{:.2}, {:.2}", + de.geo.latitude_deg(), + de.geo.longitude_deg() + ), ); } } @@ -772,9 +792,18 @@ fn main() { 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( + "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()); @@ -826,7 +855,10 @@ mod tests { #[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::Critical), + Severity::Advisory + ); assert_eq!(bio_only_severity_cap(Severity::Warning), Severity::Advisory); let r = run(); let ep = episode(&r, "pontoon anomaly"); diff --git a/examples/src/bin/biodiversity-habitat.rs b/examples/src/bin/biodiversity-habitat.rs new file mode 100644 index 0000000..499e310 --- /dev/null +++ b/examples/src/bin/biodiversity-habitat.rs @@ -0,0 +1,625 @@ +//! # 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 { + 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-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/pollinator-hive.rs b/examples/src/bin/pollinator-hive.rs index 1d77e56..23531f6 100644 --- a/examples/src/bin/pollinator-hive.rs +++ b/examples/src/bin/pollinator-hive.rs @@ -393,7 +393,11 @@ pub fn run() -> Report { 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), + ( + "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), @@ -427,7 +431,8 @@ pub fn run() -> Report { // 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 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) @@ -482,9 +487,8 @@ pub fn run() -> Report { .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 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() }; @@ -653,7 +657,13 @@ fn main() { 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 + 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."); @@ -683,21 +693,36 @@ fn main() { }; 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 + 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( + "thermally explained by the reference", + format!("{thermal} of 3"), + ); line("colonies collapsing in this window", a.correlated_hives); line("evidence is a single colony (biology only)", a.bio_only); - line("severity before the biological cap", format!("{:?}", a.uncapped)); + line( + "severity before the biological cap", + format!("{:?}", a.uncapped), + ); line("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)); + line( + "event", + format!("{:?} / conf {:.2}", ev.severity, ev.confidence), + ); println!(" -> {}", ev.message); } None => line("event", "NONE"), @@ -705,12 +730,21 @@ fn main() { } 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())); + 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( + "max colony evidence edge weight cap", + format!("{BIO_MAX_EVIDENCE_WEIGHT:.2}"), + ); line("envelopes cryptographically verified", r.verified_samples); print_not_validated(); @@ -733,7 +767,10 @@ mod tests { .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.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); @@ -745,7 +782,10 @@ mod tests { #[test] fn a_single_hive_collapse_stays_advisory() { - assert_eq!(collapse_severity(1), (Severity::Warning, Severity::Advisory, true)); + assert_eq!( + collapse_severity(1), + (Severity::Warning, Severity::Advisory, true) + ); let r = run(); let a = &r.solo; assert_eq!(a.correlated_hives, 1); From cba0e3b38e5690292fcd426f06d6cd9cdd8df8f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:14:36 +0000 Subject: [PATCH 22/27] feat(rucelium-core): bind evidence CONTENT into event signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a schema gap the compliance example exposed while being built. EnvironmentalEvent.evidence is a list of EvidenceRef — (node_id, sequence) — which pins WHICH observations were cited but never their CONTENT. Two different readings with the same identity are indistinguishable, so editing an observation's value inside an exported evidence bundle broke nothing: the event signature still verified. The industrial-compliance example had to smuggle a sha256 through the signed message STRING and parse it back out to get tamper-evidence — a workaround standing in for a missing field. Adds EnvironmentalEvent.evidence_digest plus rucelium_core:: evidence_digest(), which hashes each cited observation's canonical JSON LENGTH-PREFIXED and in citation order — length prefixing so two different citation lists cannot concatenate to the same byte stream, order-sensitivity because a reordered evidence list is a different claim. Because the field sits inside the signed structure, altering any cited observation now invalidates the signature. Producers updated to bind real content (gateway alerts, bench alerts, compliance bundles); events that make no content claim (DeviceRevoked) carry None. The compliance verifier prefers the structured field and keeps the message-string path only as a documented legacy fallback. 412 tests green across the workspace (gateway excluded — its QUIC/push federation work is still in flight), clippy clean, fmt clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- Cargo.lock | 3 + crates/rucelium-bench/src/runner.rs | 3 + crates/rucelium-core/Cargo.toml | 2 + crates/rucelium-core/src/event.rs | 81 +++- crates/rucelium-core/src/lib.rs | 2 +- crates/rucelium-core/src/sample.rs | 32 ++ crates/rucelium-federation/src/biome.rs | 3 + crates/rucelium-gateway/Cargo.toml | 21 + crates/rucelium-gateway/src/pipeline.rs | 3 + crates/rucelium-gateway/src/state.rs | 66 ++- crates/rucelium-gateway/src/transport.rs | 547 ++++++++++++++++++++++ crates/rucelium-notary/src/bundle.rs | 1 + crates/rucelium-store/src/lib.rs | 1 + examples/src/bin/airborne-dna.rs | 19 +- examples/src/bin/biodiversity-habitat.rs | 1 + examples/src/bin/ecosystem-immune.rs | 31 +- examples/src/bin/flood-watershed.rs | 1 + examples/src/bin/industrial-compliance.rs | 121 +++-- examples/src/bin/pollinator-hive.rs | 40 +- examples/src/bin/sentinel-forest.rs | 14 +- examples/src/bin/wildfire-risk.rs | 34 +- 21 files changed, 948 insertions(+), 78 deletions(-) create mode 100644 crates/rucelium-gateway/src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index ee28b96..39c62fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,6 +936,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "sha2", ] [[package]] @@ -973,6 +974,7 @@ name = "rucelium-gateway" version = "0.1.0" dependencies = [ "axum", + "quinn", "reqwest", "rucelium-abi", "rucelium-calibration", @@ -983,6 +985,7 @@ dependencies = [ "rucelium-store", "rucelium-transport", "rucelium-worldgraph", + "rustls", "serde", "serde_json", "tokio", diff --git a/crates/rucelium-bench/src/runner.rs b/crates/rucelium-bench/src/runner.rs index 198342e..0ff1330 100644 --- a/crates/rucelium-bench/src/runner.rs +++ b/crates/rucelium-bench/src/runner.rs @@ -484,6 +484,9 @@ pub fn run(config: SimConfig) -> BiomeReport { 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, diff --git a/crates/rucelium-core/Cargo.toml b/crates/rucelium-core/Cargo.toml index 31439b3..5a5dd7a 100644 --- a/crates/rucelium-core/Cargo.toml +++ b/crates/rucelium-core/Cargo.toml @@ -12,6 +12,8 @@ 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/event.rs b/crates/rucelium-core/src/event.rs index a2b1048..a5e0836 100644 --- a/crates/rucelium-core/src/event.rs +++ b/crates/rucelium-core/src/event.rs @@ -45,6 +45,37 @@ pub enum EventKind { 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)] @@ -79,8 +110,26 @@ pub struct EnvironmentalEvent { pub window_end_ns: u64, /// Detection time, ns since Unix epoch. pub detected_ns: u64, - /// Contributing observations. + /// 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. @@ -146,6 +195,7 @@ mod tests { sequence: 42, }], confidence: 0.9, + evidence_digest: None, message: "water level rising across 3 nodes".into(), signature_hex: None, signer_pubkey_hex: None, @@ -178,4 +228,33 @@ mod tests { 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/lib.rs b/crates/rucelium-core/src/lib.rs index 4decc3a..34cb6fe 100644 --- a/crates/rucelium-core/src/lib.rs +++ b/crates/rucelium-core/src/lib.rs @@ -23,7 +23,7 @@ pub mod sample; pub use calibration::CalibrationRecord; pub use error::EnvError; -pub use event::{EnvironmentalEvent, EventKind, EvidenceRef, Severity}; +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}; diff --git a/crates/rucelium-core/src/sample.rs b/crates/rucelium-core/src/sample.rs index dde8df7..648005f 100644 --- a/crates/rucelium-core/src/sample.rs +++ b/crates/rucelium-core/src/sample.rs @@ -258,3 +258,35 @@ mod tests { 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/src/biome.rs b/crates/rucelium-federation/src/biome.rs index ff3dbcd..d0c2fe6 100644 --- a/crates/rucelium-federation/src/biome.rs +++ b/crates/rucelium-federation/src/biome.rs @@ -249,6 +249,8 @@ impl Biome { 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, @@ -496,6 +498,7 @@ mod tests { /// 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(), diff --git a/crates/rucelium-gateway/Cargo.toml b/crates/rucelium-gateway/Cargo.toml index d57080e..ad792ad 100644 --- a/crates/rucelium-gateway/Cargo.toml +++ b/crates/rucelium-gateway/Cargo.toml @@ -13,6 +13,13 @@ categories = ["science", "web-programming"] 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 } @@ -30,6 +37,20 @@ 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" diff --git a/crates/rucelium-gateway/src/pipeline.rs b/crates/rucelium-gateway/src/pipeline.rs index 19e98ed..177433a 100644 --- a/crates/rucelium-gateway/src/pipeline.rs +++ b/crates/rucelium-gateway/src/pipeline.rs @@ -198,6 +198,9 @@ fn maybe_alert(inner: &mut Inner, sample: &EnvSample, received_ns: u64) { 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, diff --git a/crates/rucelium-gateway/src/state.rs b/crates/rucelium-gateway/src/state.rs index 12a16f6..bad2f3c 100644 --- a/crates/rucelium-gateway/src/state.rs +++ b/crates/rucelium-gateway/src/state.rs @@ -24,11 +24,11 @@ use rucelium_store::{EventStore, ObservationStore}; use rucelium_transport::Reassembler; use rucelium_worldgraph::WorldGraph; use serde::Serialize; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use tokio::sync::Mutex; +use tokio::sync::{broadcast, Mutex}; /// Max records per observation segment file. const OBS_SEGMENT_MAX_RECORDS: usize = 4096; @@ -74,6 +74,35 @@ pub struct PeerSummary { 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 { @@ -117,6 +146,11 @@ pub struct Inner { 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). @@ -236,6 +270,8 @@ impl Inner { 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(), @@ -273,6 +309,15 @@ impl Inner { } } +/// 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 { @@ -282,17 +327,34 @@ pub struct GatewayState { 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 diff --git a/crates/rucelium-gateway/src/transport.rs b/crates/rucelium-gateway/src/transport.rs new file mode 100644 index 0000000..5fcfe56 --- /dev/null +++ b/crates/rucelium-gateway/src/transport.rs @@ -0,0 +1,547 @@ +//! 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 +//! [`crate::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 + /// [`crate::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 [`MAX_SUMMARY_WINDOW_S`] is + /// clamped. + 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-notary/src/bundle.rs b/crates/rucelium-notary/src/bundle.rs index 74d9bfe..3be0930 100644 --- a/crates/rucelium-notary/src/bundle.rs +++ b/crates/rucelium-notary/src/bundle.rs @@ -456,6 +456,7 @@ mod tests { fn event() -> EnvironmentalEvent { EnvironmentalEvent { + evidence_digest: None, spec_version: SPEC_VERSION.into(), event_id: "evt-0001".into(), biome_id: "biome/thames-estuary".into(), diff --git a/crates/rucelium-store/src/lib.rs b/crates/rucelium-store/src/lib.rs index 7b305a2..f27cd34 100644 --- a/crates/rucelium-store/src/lib.rs +++ b/crates/rucelium-store/src/lib.rs @@ -170,6 +170,7 @@ pub(crate) mod testutil { /// 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(), diff --git a/examples/src/bin/airborne-dna.rs b/examples/src/bin/airborne-dna.rs index 99dacaf..77fcc5f 100644 --- a/examples/src/bin/airborne-dna.rs +++ b/examples/src/bin/airborne-dna.rs @@ -33,8 +33,10 @@ //! cargo run -p rucelium-examples --bin airborne-dna //! ``` +use rucelium_core::event::evidence_digest; use rucelium_core::{ - EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, SPEC_VERSION, + 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}; @@ -492,7 +494,7 @@ pub fn run() -> Report { let naive_would_trigger = naive_z >= TRIGGER_Z; let sampler_triggered = adjusted_z >= TRIGGER_Z; - let (dna, disclosure, sampler_seq) = if sampler_triggered { + 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); @@ -510,7 +512,7 @@ pub fn run() -> Report { spec.taxa.clone(), ); let d = disclose(&result, station, coarsen); - (Some(result), Some(d), Some(seq)) + (Some(result), Some(d), Some(sm.sample().clone())) } else { (None, None, None) }; @@ -593,8 +595,9 @@ pub fn run() -> Report { let event = if invasive.is_empty() { None } else { - let seq = sampler_seq.expect("a sample was taken"); + 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(), @@ -606,8 +609,8 @@ pub fn run() -> Report { window_end_ns: ns + 60 * NS_PER_S, detected_ns: ns + 60 * NS_PER_S, evidence: vec![EvidenceRef { - node_id: 0x00B3_0000_0000_0003, - sequence: seq, + node_id: obs.node_id, + sequence: obs.sequence, }], confidence: 0.74, message: format!( @@ -866,6 +869,10 @@ mod tests { 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)); diff --git a/examples/src/bin/biodiversity-habitat.rs b/examples/src/bin/biodiversity-habitat.rs index 499e310..56f2e30 100644 --- a/examples/src/bin/biodiversity-habitat.rs +++ b/examples/src/bin/biodiversity-habitat.rs @@ -277,6 +277,7 @@ pub fn run_reserve() -> HabitatRun { // 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(), diff --git a/examples/src/bin/ecosystem-immune.rs b/examples/src/bin/ecosystem-immune.rs index 62200cb..8e99f47 100644 --- a/examples/src/bin/ecosystem-immune.rs +++ b/examples/src/bin/ecosystem-immune.rs @@ -28,8 +28,10 @@ //! cargo run -p rucelium-examples --bin ecosystem-immune //! ``` +use rucelium_core::event::evidence_digest; use rucelium_core::{ - EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, SPEC_VERSION, + 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::{ @@ -231,6 +233,9 @@ pub struct PointState { 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. @@ -575,6 +580,7 @@ pub fn run() -> Report { 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(), }); } @@ -633,6 +639,9 @@ pub fn run() -> Report { 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(), @@ -775,12 +784,16 @@ fn main() { "naive / compensated / chemical detections", format!("{naive} / {bio} / {chem}"), ); - line("evidence is biology only", a.bio_only); - line( - "severity before the biological cap", - format!("{:?}", a.uncapped), - ); - line("severity emitted", format!("{:?}", a.severity)); + 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()), @@ -854,6 +867,10 @@ mod tests { 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] diff --git a/examples/src/bin/flood-watershed.rs b/examples/src/bin/flood-watershed.rs index fc1cf6f..7204c3a 100644 --- a/examples/src/bin/flood-watershed.rs +++ b/examples/src/bin/flood-watershed.rs @@ -352,6 +352,7 @@ fn watershed_event( 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(), diff --git a/examples/src/bin/industrial-compliance.rs b/examples/src/bin/industrial-compliance.rs index bf53f01..02757c2 100644 --- a/examples/src/bin/industrial-compliance.rs +++ b/examples/src/bin/industrial-compliance.rs @@ -219,14 +219,28 @@ pub fn verify_bundle( let calibration_head: u32 = signed_field(message, "cal_head=")? .parse() .map_err(|e| format!("signed calibration head is not a number: {e}"))?; - let signed_digest = signed_field(message, "obs_digest=")?; - let observed_digest = sha256_hex( - &serde_json::to_vec(&bundle.observations) - .map_err(|e| format!("observations do not serialize: {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()); + return Err( + "observation digest mismatch: the observations are not the signed ones".to_string(), + ); } // (3) + (4) Calibration lineage: signed, trusted, parent-linked, anchored. @@ -237,10 +251,7 @@ pub fn verify_bundle( 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) - { + if !trusted_calibration_authorities.iter().any(|k| k == signer) { return Err(format!( "calibration {} signed by an untrusted authority", record.calibration_id @@ -292,7 +303,9 @@ pub fn verify_bundle( 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()); + 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) { @@ -332,7 +345,11 @@ pub fn provision() -> Vec { Node::new( *node_id, *modality, - geo(546_000_000 + (i as i32) * 1_300, -11_200_000 - (i as i32) * 900, 8_000), + geo( + 546_000_000 + (i as i32) * 1_300, + -11_200_000 - (i as i32) * 900, + 8_000, + ), label, ) }) @@ -473,7 +490,9 @@ pub fn run_compliance() -> ComplianceRun { Q16_ONE, 0, ); - signer.sign_record(&mut anchor).expect("record canonicalizes"); + signer + .sign_record(&mut anchor) + .expect("record canonicalizes"); store.insert(anchor).expect("signed anchor is accepted"); let mut child = record( @@ -487,7 +506,9 @@ pub fn run_compliance() -> ComplianceRun { 66_847, // ≈ 1.020 -196_608, // -3.0 ); - signer.sign_record(&mut child).expect("record canonicalizes"); + signer + .sign_record(&mut child) + .expect("record canonicalizes"); store.insert(child).expect("signed child is accepted"); } @@ -613,10 +634,15 @@ pub fn run_compliance() -> ComplianceRun { 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 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(), @@ -742,7 +768,10 @@ fn main() { ), ); } - line("unsigned record", format!("REFUSED — {}", run.unsigned_refusal)); + line( + "unsigned record", + format!("REFUSED — {}", run.unsigned_refusal), + ); line( "record signed by an unregistered key", format!("REFUSED — {}", run.rogue_refusal), @@ -754,12 +783,24 @@ fn main() { 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( + "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)); + line( + "provenance.lineage", + format!("{:?}", first.provenance.lineage), + ); println!("\n 3. Sensor tampering (attacker's device key)"); line( @@ -768,7 +809,10 @@ fn main() { ); println!("\n 4. Independent verification of the bundle"); - line("bundle size", format!("{} bytes of JSON", run.bundle_json.len())); + line( + "bundle size", + format!("{} bytes of JSON", run.bundle_json.len()), + ); match verify_bundle( &run.bundle_json, &run.biome_pubkey_hex, @@ -803,7 +847,8 @@ fn main() { }; line(&format!(" {name}"), verdict); } - let verdict = match verify_bundle(&run.bundle_json, &"00".repeat(32), &run.trusted_authorities) { + 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}"), }; @@ -854,12 +899,9 @@ mod tests { 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()); + 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. @@ -869,9 +911,15 @@ mod tests { #[test] fn the_strict_store_refuses_unsigned_and_untrusted_records() { let run = run_compliance(); - assert_eq!(run.unsigned_refusal, CalibrationError::MissingSignature(900)); + assert_eq!( + run.unsigned_refusal, + CalibrationError::MissingSignature(900) + ); assert!( - matches!(run.rogue_refusal, CalibrationError::UntrustedSigner { id: 901, .. }), + matches!( + run.rogue_refusal, + CalibrationError::UntrustedSigner { id: 901, .. } + ), "got {:?}", run.rogue_refusal ); @@ -903,9 +951,10 @@ mod tests { .provenance .lineage .contains(&format!("cal:{}", SPEC[DISCHARGE].5))); - assert!(observation.provenance.lineage.contains( - &"abi:rv_env_sample_v1".to_string() - )); + assert!(observation + .provenance + .lineage + .contains(&"abi:rv_env_sample_v1".to_string())); assert!(observation.value > CONSENT_LIMIT_UMOL_L); } } diff --git a/examples/src/bin/pollinator-hive.rs b/examples/src/bin/pollinator-hive.rs index 23531f6..4c73c72 100644 --- a/examples/src/bin/pollinator-hive.rs +++ b/examples/src/bin/pollinator-hive.rs @@ -24,9 +24,10 @@ //! cargo run -p rucelium-examples --bin pollinator-hive //! ``` +use rucelium_core::event::evidence_digest; use rucelium_core::{ - DataClass, EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, - SPEC_VERSION, + 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, @@ -280,6 +281,9 @@ pub struct HiveDay { 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. @@ -309,6 +313,8 @@ pub struct HiveVerdict { 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. @@ -426,6 +432,7 @@ pub fn run() -> Report { 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 @@ -440,6 +447,7 @@ pub fn run() -> Report { 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); @@ -477,6 +485,7 @@ pub fn run() -> Report { humidity: hu / d, weight_kg: weights[i], gain_kg: gain, + sample: last_sample.expect("at least one slot per day"), }); } days.push(row); @@ -534,6 +543,7 @@ pub fn run() -> Report { swarm_precursor, node_id: d.node_id, sequence: d.sequence, + sample: d.sample.clone(), }); } let correlated_hives = verdicts.iter().filter(|v| v.collapse).count(); @@ -542,6 +552,9 @@ pub fn run() -> Report { 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(), @@ -569,6 +582,9 @@ pub fn run() -> Report { }) } 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(), @@ -710,12 +726,16 @@ fn main() { format!("{thermal} of 3"), ); line("colonies collapsing in this window", a.correlated_hives); - line("evidence is a single colony (biology only)", a.bio_only); - line( - "severity before the biological cap", - format!("{:?}", a.uncapped), - ); - line("severity emitted", format!("{:?}", a.severity)); + 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"); @@ -823,6 +843,10 @@ mod tests { 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); } diff --git a/examples/src/bin/sentinel-forest.rs b/examples/src/bin/sentinel-forest.rs index 52439b3..0c37faa 100644 --- a/examples/src/bin/sentinel-forest.rs +++ b/examples/src/bin/sentinel-forest.rs @@ -29,8 +29,10 @@ //! machinery is the real production code.** Nothing here is evidence that //! plant electrophysiology predicts drought. +use rucelium_core::event::evidence_digest; use rucelium_core::{ - EnvironmentalEvent, EventKind, EvidenceRef, GeoPoint, SensorModality, Severity, SPEC_VERSION, + 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, @@ -262,6 +264,9 @@ pub struct Verdict { 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 @@ -518,6 +523,7 @@ pub fn run() -> Report { // 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 { @@ -553,6 +559,9 @@ pub fn run() -> Report { 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(), @@ -893,6 +902,9 @@ mod tests { 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); diff --git a/examples/src/bin/wildfire-risk.rs b/examples/src/bin/wildfire-risk.rs index 62e767c..bc8cafc 100644 --- a/examples/src/bin/wildfire-risk.rs +++ b/examples/src/bin/wildfire-risk.rs @@ -328,6 +328,7 @@ fn fire_event( 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(), @@ -400,12 +401,8 @@ pub fn run_fire_watch() -> WildfireRun { 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 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"); @@ -517,10 +514,7 @@ pub fn run_fire_watch() -> WildfireRun { values[WIND], values[SOIL], if physical_evidence { - format!( - "PM {:.0} ug/m3 AND smoke {:.2}", - values[PM], values[SMOKE] - ) + format!("PM {:.0} ug/m3 AND smoke {:.2}", values[PM], values[SMOKE]) } else { format!( "none (PM {:.0} ug/m3, smoke {:.2})", @@ -576,9 +570,7 @@ fn main() { format!( "risk {:.3} {:<9} sensors {}/5 PM {:>5.0} smoke {:.2}{}", round.risk, - round - .severity - .map_or("—".to_string(), |s| format!("{s:?}")), + round.severity.map_or("—".to_string(), |s| format!("{s:?}")), round.sensors_used, round.pm_ug_m3, round.smoke_index, @@ -613,7 +605,10 @@ fn main() { ] { let event = rf_only_alert(&rf, proposed, provision()[SMOKE].geo); line( - &format!(" RF proposes {proposed:?} at confidence {:.2}", rf.confidence), + &format!( + " RF proposes {proposed:?} at confidence {:.2}", + rf.confidence + ), format!( "event severity {:?}{}", event.severity, @@ -662,7 +657,10 @@ fn main() { .degradation .as_ref() .expect("the exposed hygrometer fails"); - line("event kind / severity", format!("{:?} / {:?}", degradation.kind, degradation.severity)); + line( + "event kind / severity", + format!("{:?} / {:?}", degradation.kind, degradation.severity), + ); line("message", °radation.message); line( "reported, not silently dropped", @@ -748,7 +746,11 @@ mod tests { // 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); + 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)); From 20442c9ceb6c0075d448ad18e93acb10df86cd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:16:27 +0000 Subject: [PATCH 23/27] wip(rucelium-gateway): ADR-269 push federation mid-implementation Snapshot of in-flight work: the FederationTransport abstraction, push-on-revocation, and the optional QUIC transport. The agent is actively editing, so this commit captures a moving target and the crate does not build at this SHA (a push-announce call site is mid-rename). Every other crate is green: 412 tests, zero clippy warnings, fmt clean. The verified gateway lands in the next commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-gateway/src/api.rs | 11 + crates/rucelium-gateway/src/config.rs | 38 ++ crates/rucelium-gateway/src/federation.rs | 562 ++++++++++++++++++---- crates/rucelium-gateway/src/lib.rs | 46 +- 4 files changed, 565 insertions(+), 92 deletions(-) diff --git a/crates/rucelium-gateway/src/api.rs b/crates/rucelium-gateway/src/api.rs index adacafc..c41fd9a 100644 --- a/crates/rucelium-gateway/src/api.rs +++ b/crates/rucelium-gateway/src/api.rs @@ -17,7 +17,9 @@ //! 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}; @@ -73,6 +75,7 @@ pub fn router(state: GatewayState) -> Router { .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) @@ -110,6 +113,14 @@ async fn stats(State(state): State) -> Json { "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(), })) } diff --git a/crates/rucelium-gateway/src/config.rs b/crates/rucelium-gateway/src/config.rs index 2b6b245..faf2b44 100644 --- a/crates/rucelium-gateway/src/config.rs +++ b/crates/rucelium-gateway/src/config.rs @@ -50,6 +50,11 @@ pub struct GatewayConfig { 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, @@ -71,6 +76,7 @@ impl Default for GatewayConfig { 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, } @@ -78,6 +84,15 @@ impl Default for GatewayConfig { } 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 { @@ -106,6 +121,12 @@ impl GatewayConfig { 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}")), @@ -143,10 +164,27 @@ mod tests { 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(&[ diff --git a/crates/rucelium-gateway/src/federation.rs b/crates/rucelium-gateway/src/federation.rs index 6e714bb..258f0e0 100644 --- a/crates/rucelium-gateway/src/federation.rs +++ b/crates/rucelium-gateway/src/federation.rs @@ -1,29 +1,112 @@ -//! Network federation sync (ADR-265 §4): a background task polls each -//! configured peer's `/api/federation/{pubkey,summary,revocations}`, -//! verifies every ed25519 signature against the peer's **published** biome -//! key, stores verified summaries, and applies verified `DeviceRevoked` -//! events to the local registry. Unverifiable data is skipped and logged — -//! never applied, never repaired (ADR-264 §12). Only signed summaries and -//! events ever cross the wire, preserving biome sovereignty (ADR-264 §6). - -use crate::state::{now_ns, GatewayState, Inner, PeerSummary}; +//! 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, RegionalSummary}; -use serde::Deserialize; +use rucelium_federation::{verify_event, verify_summary}; +use std::sync::Arc; use std::time::Duration; -/// Window (seconds) requested from each peer's summary endpoint. -const PEER_SUMMARY_WINDOW_S: u64 = 3600; -/// Per-request HTTP timeout. -const HTTP_TIMEOUT: Duration = Duration::from_secs(5); - -/// Response shape of `GET /api/federation/pubkey`. -#[derive(Debug, Deserialize)] -struct PubkeyResponse { - /// Peer biome identity. - biome_id: String, - /// Peer biome ed25519 public key, hex. - pubkey_hex: String, +/// 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 @@ -39,6 +122,11 @@ struct PubkeyResponse { /// 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, @@ -68,89 +156,258 @@ pub fn apply_peer_revocation( true } -/// Run the federation poller forever: every `poll_ms`, sync each peer. Peer -/// failures are logged and never fatal — a dead peer must not stop the +/// **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, peers: Vec, poll_ms: u64) { - let client = match reqwest::Client::builder().timeout(HTTP_TIMEOUT).build() { - Ok(c) => c, +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) => { - eprintln!("gateway: federation disabled, http client failed: {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; } }; - let mut tick = tokio::time::interval(Duration::from_millis(poll_ms.max(50))); - loop { - tick.tick().await; - for peer in &peers { - if let Err(e) = sync_peer(&state, &client, peer).await { - eprintln!("gateway: federation peer {peer}: {e}"); + 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 sync pass against one peer: pubkey, then summary, then revocations. -async fn sync_peer( +/// 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, - client: &reqwest::Client, - peer: &str, -) -> Result<(), String> { - let base = peer.trim_end_matches('/'); - - let pk: PubkeyResponse = fetch_json(client, &format!("{base}/api/federation/pubkey")).await?; - - let summary: RegionalSummary = fetch_json( - client, - &format!("{base}/api/federation/summary?window_s={PEER_SUMMARY_WINDOW_S}"), - ) - .await?; - if verify_summary(&summary) && summary.signer_pubkey_hex.as_deref() == Some(&pk.pubkey_hex) { + 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; - inner.peer_summaries.retain(|p| p.peer != peer); - inner.peer_summaries.push(PeerSummary { - peer: peer.to_string(), - summary, - fetched_ns: now_ns(), - }); - } else { - eprintln!( - "gateway: skipping unverifiable summary from peer {peer} (biome {})", - pk.biome_id + register_peer_identity( + &mut inner, + &identity.biome_id, + &identity.pubkey_hex, + &peer.url, ); } - let revocations: Vec = - fetch_json(client, &format!("{base}/api/federation/revocations")).await?; + let artifacts = transport.sync_since(peer, since_ns).await?; let mut inner = state.inner.lock().await; - for event in &revocations { - if apply_peer_revocation(&mut inner, event, &pk.pubkey_hex) { - eprintln!( - "gateway: applied revocation {} from peer {peer}", - event.event_id - ); + 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(()) } -/// GET a JSON body, mapping transport and decode failures to strings. -async fn fetch_json( - client: &reqwest::Client, - url: &str, -) -> Result { - client - .get(url) - .send() - .await - .map_err(|e| format!("GET {url}: {e}"))? - .error_for_status() - .map_err(|e| format!("GET {url}: {e}"))? - .json::() - .await - .map_err(|e| format!("decode {url}: {e}")) -} - #[cfg(test)] mod tests { use super::*; @@ -163,6 +420,8 @@ mod tests { 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 { @@ -184,7 +443,7 @@ mod tests { } fn peer_biome() -> Biome { - Biome::new(BiomeConfig::new("biome/peer"), PEER_SEED) + Biome::new(BiomeConfig::new(PEER_BIOME), PEER_SEED) } fn inner_with_registered_node(tag: &str) -> Inner { @@ -197,6 +456,13 @@ mod tests { 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"); @@ -297,4 +563,128 @@ mod tests { )); 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/lib.rs b/crates/rucelium-gateway/src/lib.rs index b47da53..40cd1bc 100644 --- a/crates/rucelium-gateway/src/lib.rs +++ b/crates/rucelium-gateway/src/lib.rs @@ -50,13 +50,20 @@ 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, PeerSummary}; +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; @@ -86,9 +93,25 @@ pub async fn spawn_gateway(config: GatewayConfig) -> Result 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 @@ -121,11 +144,22 @@ pub async fn spawn_gateway_with_state( ))); if !config.peers.is_empty() { - tasks.push(tokio::spawn(federation::run_federation( - state.clone(), - config.peers.clone(), - config.federation_poll_ms, - ))); + // 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 { From 267cd4b5d9c17e8427205dc7221a5c81a9cb4464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:18:18 +0000 Subject: [PATCH 24/27] =?UTF-8?q?feat(rucelium-gateway):=20push=20federati?= =?UTF-8?q?on=20(ADR-269=20=C2=A73)=20=E2=80=94=20466=20tests=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation is no longer a poller. A FederationTransport abstraction (announce / subscribe / sync_since) carries signed artifacts, with push-on-revocation so a compromised device's revocation propagates at link speed instead of waiting up to a full polling interval — that interval was a security window, not just latency. The polling backstop stays MANDATORY (ADR-269 §3): a peer that missed a push must still converge, so sync_since runs on reconnect and on a slow timer regardless of transport. Everything received is verified identically no matter how it arrived — the transport is never the trust boundary. 466 tests green across the workspace, zero clippy warnings, fmt clean. The optional QUIC transport (§4) is still being written. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-gateway/src/api.rs | 66 ++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/rucelium-gateway/src/api.rs b/crates/rucelium-gateway/src/api.rs index c41fd9a..ba2b3d7 100644 --- a/crates/rucelium-gateway/src/api.rs +++ b/crates/rucelium-gateway/src/api.rs @@ -249,11 +249,74 @@ async fn fed_peers(State(state): State) -> Json { 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, @@ -265,9 +328,12 @@ async fn admin_revoke( .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, }))) } From f09713a2c826f64325d46e37ce40e1cf2fdc3d38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:31:35 +0000 Subject: [PATCH 25/27] feat(rucelium-gateway): QUIC federation with RFC 7250 raw-public-key pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes ADR-269. The optional QUIC transport (feature-gated, default off) binds TLS identity to the biome's EXISTING ed25519 key — genuine RFC 7250 raw public keys, no X.509 anywhere, no new PKI, no certificate authority, no name-based trust. How the pin actually holds: - server wraps the same biome seed Biome signs with as PKCS#8 (RFC 8410) and serves a bare SubjectPublicKeyInfo; a test asserts the advertised key equals Biome::public_key_hex() — one key, not two - client builds the expected SPKI with rustls' own helper (byte-identical to what a peer serves, asserted), accepts only an exact match, rejects intermediates, re-checks the pin inside verify_tls13_signature, refuses TLS 1.2, and advertises only Ed25519 - requires_raw_public_keys() = true means a server that doesn't negotiate RawPublicKey gets a fatal handshake error — an X.509 answer cannot be accepted, so there is no downgrade path - the custom verifier is STRICTER than webpki (one key, exact bytes, no CA, no name matching) and never returns Ok for an unpinned key The decisive test: a_peer_presenting_the_wrong_key_is_refused_and_ delivers_nothing — the mismatch names both keys and delivers zero artifacts, then the same endpoint dialled with the correct key succeeds, proving the refusal was the pin and not a connection failure. Per §4.3 each artifact class gets its own stream, so a large summary transfer cannot stall a revocation. Known limitations, documented rather than hidden: the server does not authenticate clients (safe only because the session is never the trust boundary — an unauthenticated peer can waste bandwidth, never revoke a device); 0-RTT resumption is not wired; and main.rs has no --quic-listen flag yet, so the daemon still federates over HTTP. 470 tests green (58 default / 67 with --features quic), zero clippy warnings in both configurations, fmt clean. The §5 regression guard holds: restart.rs 6/6 and e2e.rs 1/1 pass unchanged with the feature off. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-gateway/src/federation.rs | 5 +- crates/rucelium-gateway/src/lib.rs | 13 + crates/rucelium-gateway/src/transport.rs | 18 +- crates/rucelium-gateway/src/transport_quic.rs | 836 ++++++++++++++++++ .../rucelium-gateway/tests/push_federation.rs | 460 ++++++++++ .../rucelium-gateway/tests/quic_federation.rs | 342 +++++++ 6 files changed, 1667 insertions(+), 7 deletions(-) create mode 100644 crates/rucelium-gateway/src/transport_quic.rs create mode 100644 crates/rucelium-gateway/tests/push_federation.rs create mode 100644 crates/rucelium-gateway/tests/quic_federation.rs diff --git a/crates/rucelium-gateway/src/federation.rs b/crates/rucelium-gateway/src/federation.rs index 258f0e0..e3ca12c 100644 --- a/crates/rucelium-gateway/src/federation.rs +++ b/crates/rucelium-gateway/src/federation.rs @@ -358,7 +358,10 @@ async fn drain_subscription( match accept_artifact(&mut inner, artifact) { Ok(effect) => { inner.push.pushes_received += 1; - eprintln!("gateway: streamed artifact from peer {}: {effect:?}", peer.url); + eprintln!( + "gateway: streamed artifact from peer {}: {effect:?}", + peer.url + ); } Err(ArtifactRejection::NoEffect) => {} Err(e) => eprintln!( diff --git a/crates/rucelium-gateway/src/lib.rs b/crates/rucelium-gateway/src/lib.rs index 40cd1bc..04d4285 100644 --- a/crates/rucelium-gateway/src/lib.rs +++ b/crates/rucelium-gateway/src/lib.rs @@ -12,9 +12,22 @@ //! 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 diff --git a/crates/rucelium-gateway/src/transport.rs b/crates/rucelium-gateway/src/transport.rs index 5fcfe56..bd959a3 100644 --- a/crates/rucelium-gateway/src/transport.rs +++ b/crates/rucelium-gateway/src/transport.rs @@ -16,7 +16,7 @@ //! //! Two implementations ship: [`HttpPollTransport`] here (always available, //! zero new dependencies, the backfill of record) and -//! [`crate::transport_quic::QuicTransport`] behind the `quic` feature. +//! `transport_quic::QuicTransport` behind the `quic` feature. //! //! # The transport is never the trust boundary (ADR-269 §4, normative) //! @@ -155,7 +155,7 @@ impl PeerRef { } /// A peer whose federation identity is already known — the form - /// [`crate::transport_quic::QuicTransport`] requires, since the key is + /// `transport_quic::QuicTransport` requires, since the key is /// the pinned TLS identity (ADR-269 §4). #[must_use] pub fn with_identity( @@ -373,8 +373,8 @@ impl FederationTransport for HttpPollTransport { /// [`crate::federation::accept_artifact`] to verify. /// /// `since_ns` selects the summary window; `0` (never synced) asks for - /// the default hour, and anything older than [`MAX_SUMMARY_WINDOW_S`] is - /// clamped. + /// the default hour, and anything older than a day is clamped (see + /// [`summary_window_s`]). fn sync_since<'a>( &'a self, peer: &'a PeerRef, @@ -504,7 +504,10 @@ mod tests { #[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); + 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. @@ -520,7 +523,10 @@ mod tests { 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()); + assert_eq!( + t.subscribe(&peer).await.expect("no-op subscribe"), + Vec::new() + ); } #[tokio::test] 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/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()); +} From 0d812aef510b60fa69dd1f67eb9e042b4b2f8aa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 03:39:37 +0000 Subject: [PATCH 26/27] fix(rucelium-federation): close key-succession identity takeover (ADR-270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY. Found by asking the 20-year question in the honest ledger — 'could a stranger verify this after the institution that owned it is gone?' Chasing that amber row surfaced a live vulnerability. FederationBus::register_biome accepted ANY strictly-higher epoch and rebound the identity to the presented key, with no proof of continuity: the incoming key was never signed by the outgoing one. So a peer — configured, malicious, or merely compromised — could announce biome/thames-estuary at epoch 999 with its own key, and from then on the gateway accepts the ATTACKER's summaries and revocations as that biome's while rejecting the real biome's as an IdentityMismatch. The identity-binding hardening was doing real work; rotation walked around it. Fix follows TUF root rotation: new keys become trusted only via a statement signed by currently-trusted keys. - register_biome is now GENESIS ONLY (trust-on-first-use, idempotent for an unchanged key); rebinding an established identity returns SuccessionRequired - rotation moves to rotate_biome(&KeySuccession), signed over canonical bytes that include from_epoch — so a captured succession cannot be replayed onto a later state - two authorisation paths, no third: CONTINUITY (outgoing key signs) or RECOVERY (m-of-n distinct pre-declared custodians) The recovery path is the point. Over twenty years an institution being restructured, defunded, merged, or simply losing its key is the expected case, not an edge case. A 2-of-3 custodian quorum can hand the identity to a successor without the original key ever existing again — which is the only way a 2026 baseline is still citable in 2046. Successions can also rotate the custodian set, so governance evolves without breaking the chain. threshold=0 opts out explicitly: the identity dies with its key, by choice rather than by accident. 10 new tests, led by an_unsigned_epoch_bump_cannot_steal_an_identity and custodians_can_recover_an_identity_whose_holder_is_gone. The pre-existing rotation test failed after the fix because it exercised the vulnerable path — rewritten against the succession API. 479 tests green, zero clippy warnings, fmt clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-federation/src/lib.rs | 5 +- crates/rucelium-federation/src/summary.rs | 530 ++++++++++++++++++++-- docs/ADR-270-rucelium-key-succession.md | 108 +++++ 3 files changed, 598 insertions(+), 45 deletions(-) create mode 100644 docs/ADR-270-rucelium-key-succession.md diff --git a/crates/rucelium-federation/src/lib.rs b/crates/rucelium-federation/src/lib.rs index d1d9282..22d7d4d 100644 --- a/crates/rucelium-federation/src/lib.rs +++ b/crates/rucelium-federation/src/lib.rs @@ -36,7 +36,10 @@ pub use sensorthings::{ project_sample, rfc3339_from_ns, Datastream, FeatureOfInterest, GeoJsonPoint, Location, Observation, ObservedProperty, Sensor, SensorThingsBundle, Thing, UnitOfMeasurement, }; -pub use summary::{verify_summary, FederationBus, FederationError, ModalityStats, RegionalSummary}; +pub use summary::{ + canonical_succession_bytes, sign_succession, verify_summary, FederationBus, FederationError, + KeySuccession, ModalityStats, RegionalSummary, +}; /// Shared hex + detached-signature helpers (same house style as /// `rufield-provenance`). diff --git a/crates/rucelium-federation/src/summary.rs b/crates/rucelium-federation/src/summary.rs index 2fb5de6..0e0a6ec 100644 --- a/crates/rucelium-federation/src/summary.rs +++ b/crates/rucelium-federation/src/summary.rs @@ -169,6 +169,31 @@ pub enum FederationError { }, /// 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. @@ -191,6 +216,27 @@ impl std::fmt::Display for FederationError { 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") } @@ -204,15 +250,78 @@ impl std::fmt::Display for FederationError { impl std::error::Error for FederationError {} -/// A biome's registered federation identity: its current public key and the -/// key epoch it was registered under (rotation counter). +/// 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; re-registration must strictly increase it - /// to change the key. + /// 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 @@ -242,47 +351,152 @@ impl FederationBus { FederationBus::default() } - /// Register a biome identity with its hex public key at `key_epoch`. - /// Only registered biomes may publish, and only under their own - /// `biome_id`. + /// Register a biome's **genesis** key (ADR-270 §3). /// - /// Re-registering the same `biome_id` with a **strictly higher** epoch - /// replaces the key (rotation); summaries signed by the old key are - /// rejected from then on. Re-registering with the same key is an - /// idempotent no-op. A lower-or-equal epoch with a *different* key is - /// rejected as [`FederationError::StaleKeyEpoch`] — a stolen old - /// registration cannot roll the identity back. + /// 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 key_epoch <= current.key_epoch && pubkey_hex != current.pubkey_hex { - return Err(FederationError::StaleKeyEpoch { - biome_id, - epoch: key_epoch, - }); - } - if key_epoch <= current.key_epoch { + 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(()) } - /// Look up the registered key for a claimed biome id and enforce - /// identity binding against the payload's signer key. + /// 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, @@ -507,17 +721,30 @@ mod tests { #[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); - // Rotate: a new biome key at a strictly higher epoch. - let rotated = Biome::new( - BiomeConfig::new(BIOME_ID), - b"rucelium-rotated-seed-32-bytes-!", - ); - bus.register_biome(BIOME_ID, rotated.public_key_hex(), 2) - .unwrap(); + // 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!( @@ -529,22 +756,17 @@ mod tests { // The rotated key publishes fine. bus.publish(rotated.summarize(0, 5_000)).unwrap(); - // Rolling back to the old key at a lower or equal epoch fails. - assert_eq!( + // 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::StaleKeyEpoch { - biome_id: BIOME_ID.into(), - epoch: 1 - }) - ); - assert_eq!( - bus.register_biome(BIOME_ID, b.public_key_hex(), 2), - Err(FederationError::StaleKeyEpoch { - biome_id: BIOME_ID.into(), - epoch: 2 - }) - ); - // Idempotent re-registration of the current key is a no-op. + 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(); } @@ -675,4 +897,224 @@ mod tests { ); } } + + // --- 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"); + } } 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: + From b6001a5f21386fa29b4785fc4494a88fa3ad65af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:04:30 +0000 Subject: [PATCH 27/27] fix(rucelium-federation): sign only what survives its own wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third bug of the same family, found by continuing to probe serialization edges. JSON has no NaN and no Infinity: serde_json writes BOTH as null, indistinguishably, and parsing null back into an f32/f64 fails outright. Measured: SERIALIZED: {"confidence":null,"sig":null} LOCAL VERIFY MATCHES: true <- signs and verifies in-process WIRE: {"confidence":null,"sig":"deadbeef"} PARSE FAILED: invalid type: null, expected f32 So a signature over a non-finite float is worse than no signature: it looks valid locally and is unparseable at the peer it was minted for — signable but undeliverable, and silent about it. Fix, stated as the invariant rather than a special case: SIGN ONLY WHAT ROUND-TRIPS. New crate::round_trips serializes, parses back, and requires equality — which catches NaN and Infinity today and any future serialization hazard for free. Both signing paths (Biome::sign_event, Biome::sign_summary) now fail closed: they leave the artifact unsigned rather than mint an unusable signature, so FederationBus rejects it as Unsigned instead of shipping something that cannot be verified. Scope, honestly: no current call site produces a non-finite value — summarize() cannot (an accumulator exists only with >=1 sample, so no 0/0, and sample values are validated finite before acceptance, asserted by a new test over four windows including empty ones). This closes a latent hazard on a public API, not an active outage. 4 new tests. Verified load-bearing by disabling the guard and watching a_non_finite_float_is_never_signed fail, then restoring it. 483 tests green, zero clippy warnings, fmt clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WNAP3jsEEwgoQLPpMe8kY --- crates/rucelium-federation/src/biome.rs | 11 +++ crates/rucelium-federation/src/lib.rs | 28 ++++++ crates/rucelium-federation/src/summary.rs | 111 ++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/crates/rucelium-federation/src/biome.rs b/crates/rucelium-federation/src/biome.rs index d0c2fe6..7e9eac9 100644 --- a/crates/rucelium-federation/src/biome.rs +++ b/crates/rucelium-federation/src/biome.rs @@ -263,6 +263,17 @@ impl Biome { /// 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())); diff --git a/crates/rucelium-federation/src/lib.rs b/crates/rucelium-federation/src/lib.rs index 22d7d4d..0311030 100644 --- a/crates/rucelium-federation/src/lib.rs +++ b/crates/rucelium-federation/src/lib.rs @@ -41,6 +41,34 @@ pub use summary::{ 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 { diff --git a/crates/rucelium-federation/src/summary.rs b/crates/rucelium-federation/src/summary.rs index 0e0a6ec..b8ce62a 100644 --- a/crates/rucelium-federation/src/summary.rs +++ b/crates/rucelium-federation/src/summary.rs @@ -134,6 +134,12 @@ impl Biome { /// 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())); @@ -1117,4 +1123,109 @@ mod tests { 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)); + } + } }