mirror of
https://github.com/ruvnet/RuView.git
synced 2026-09-01 04:55:54 +00:00
feat(spaces): add spatial memory and governed actions (#1650)
This commit is contained in:
16
v2/Cargo.lock
generated
16
v2/Cargo.lock
generated
@@ -9698,6 +9698,7 @@ dependencies = [
|
||||
name = "ruview-cognitum-spaces"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9824,6 +9825,7 @@ dependencies = [
|
||||
name = "ruview-policy"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ruview-attest",
|
||||
"ruview-certify",
|
||||
"ruview-evidence",
|
||||
@@ -9844,6 +9846,20 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-spatial-memory"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.2.17",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-ruvector",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -116,6 +116,7 @@ members = [
|
||||
"crates/ruview-twin", # ADR-315 digital RF twin (per-deployment model)
|
||||
"crates/ruview-placement", # ADR-308 sensor placement optimizer
|
||||
"crates/ruview-memory", # ADR-312 long-term spatial memory / anomaly
|
||||
"crates/ruview-spatial-memory",# ADR-326 tenant-scoped Cognitum Spaces history
|
||||
"crates/ruview-counterfactual",# ADR-313 counterfactual spatial inference
|
||||
"crates/ruview-infogain", # ADR-314 information-gain scheduler
|
||||
"crates/ruview-active", # ADR-309 active sensing control
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "Bounded, privacy-preserving Cognitum Spaces client for RuView"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", default-features = false }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -14,6 +14,7 @@ use url::Url;
|
||||
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_SPACES: usize = 100;
|
||||
const MAX_JSON_DEPTH: usize = 16;
|
||||
const MAX_JSON_NODES: usize = 10_000;
|
||||
const MAX_STRING_BYTES: usize = 4096;
|
||||
const REQUIRED_EXCLUSIONS: [&str; 7] = [
|
||||
"raw_csi",
|
||||
@@ -73,6 +74,8 @@ pub enum Error {
|
||||
InvalidUrl,
|
||||
#[error("invalid or empty credential")]
|
||||
InvalidCredential,
|
||||
#[error("invalid Spaces request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("Spaces request failed: {0}")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
#[error("Spaces rejected the credential ({0})")]
|
||||
@@ -89,6 +92,7 @@ pub enum Error {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Client {
|
||||
base: Url,
|
||||
endpoint: Url,
|
||||
credential: Credential,
|
||||
http: reqwest::Client,
|
||||
@@ -121,6 +125,7 @@ impl Client {
|
||||
))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
base,
|
||||
endpoint,
|
||||
credential,
|
||||
http,
|
||||
@@ -128,10 +133,44 @@ impl Client {
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<SpacesResponse, Error> {
|
||||
let mut request = self
|
||||
.http
|
||||
.get(self.endpoint.clone())
|
||||
.header("Accept", "application/json");
|
||||
let body = self.get(self.endpoint.clone()).await?;
|
||||
decode(&body)
|
||||
}
|
||||
|
||||
/// Read one stable page from the versioned Cognitum spatial hierarchy.
|
||||
/// This is a read-only method; the client exposes no publisher, approval,
|
||||
/// command, or actuator operation.
|
||||
pub async fn list_spatial(
|
||||
&self,
|
||||
kind: SpatialKind,
|
||||
page: &PageRequest,
|
||||
) -> Result<SpatialResponse, Error> {
|
||||
page.validate()?;
|
||||
if matches!(self.credential, Credential::ApiKey(_)) && page.workspace_id.is_none() {
|
||||
return Err(Error::InvalidRequest(
|
||||
"API-key spatial reads require a workspace id".into(),
|
||||
));
|
||||
}
|
||||
let mut endpoint = self
|
||||
.base
|
||||
.join(&format!("/v1/spatial/{}", kind.as_str()))
|
||||
.map_err(|_| Error::InvalidUrl)?;
|
||||
{
|
||||
let mut query = endpoint.query_pairs_mut();
|
||||
query.append_pair("limit", &page.limit.to_string());
|
||||
if let Some(cursor) = &page.cursor {
|
||||
query.append_pair("cursor", cursor);
|
||||
}
|
||||
if let Some(workspace_id) = &page.workspace_id {
|
||||
query.append_pair("workspaceId", workspace_id);
|
||||
}
|
||||
}
|
||||
let body = self.get(endpoint).await?;
|
||||
decode_spatial(&body, kind)
|
||||
}
|
||||
|
||||
async fn get(&self, endpoint: Url) -> Result<Vec<u8>, Error> {
|
||||
let mut request = self.http.get(endpoint).header("Accept", "application/json");
|
||||
request = match &self.credential {
|
||||
Credential::OAuth(token) => request.bearer_auth(token),
|
||||
Credential::ApiKey(key) => request.header("X-API-Key", key),
|
||||
@@ -169,10 +208,162 @@ impl Client {
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
decode(&body)
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned resource collections available from `/v1/spatial`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SpatialKind {
|
||||
Sites,
|
||||
Buildings,
|
||||
Floors,
|
||||
Spaces,
|
||||
Zones,
|
||||
Entities,
|
||||
Events,
|
||||
Alerts,
|
||||
}
|
||||
|
||||
impl SpatialKind {
|
||||
/// Stable wire path segment.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sites => "sites",
|
||||
Self::Buildings => "buildings",
|
||||
Self::Floors => "floors",
|
||||
Self::Spaces => "spaces",
|
||||
Self::Zones => "zones",
|
||||
Self::Entities => "entities",
|
||||
Self::Events => "events",
|
||||
Self::Alerts => "alerts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded stable-page request. OAuth derives its workspace from the signed
|
||||
/// token; the optional workspace id exists only for the legacy API-key path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PageRequest {
|
||||
pub limit: u8,
|
||||
pub cursor: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PageRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limit: 50,
|
||||
cursor: None,
|
||||
workspace_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRequest {
|
||||
fn validate(&self) -> Result<(), Error> {
|
||||
if self.limit == 0 || self.limit > 100 {
|
||||
return Err(Error::InvalidRequest("limit must be from 1 to 100".into()));
|
||||
}
|
||||
if self.cursor.as_ref().is_some_and(|value| {
|
||||
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(Error::InvalidRequest("cursor is invalid".into()));
|
||||
}
|
||||
if self
|
||||
.workspace_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| !is_uuid(value))
|
||||
{
|
||||
return Err(Error::InvalidRequest("workspace id must be a UUID".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_uuid(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() == 36
|
||||
&& [8, 13, 18, 23].iter().all(|&index| bytes[index] == b'-')
|
||||
&& matches!(bytes[14], b'1'..=b'8')
|
||||
&& matches!(bytes[19].to_ascii_lowercase(), b'8' | b'9' | b'a' | b'b')
|
||||
&& bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, byte)| [8, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn valid_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 120
|
||||
&& value.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b':' | b'-'))
|
||||
}
|
||||
|
||||
fn valid_timestamp(value: &str) -> bool {
|
||||
chrono::DateTime::parse_from_rfc3339(value).is_ok()
|
||||
}
|
||||
|
||||
fn optional_id_valid(value: Option<&str>) -> bool {
|
||||
value.is_none_or(valid_id)
|
||||
}
|
||||
|
||||
/// One versioned P2/P3 hierarchy/event/alert page.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpatialResponse {
|
||||
pub object: String,
|
||||
pub kind: SpatialKind,
|
||||
pub schema_version: String,
|
||||
pub data: Vec<SpatialResource>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub boundary: DataBoundary,
|
||||
}
|
||||
|
||||
/// Common bounded spatial resource. Kind-specific fields stay in `attributes`;
|
||||
/// tenant/workspace and lineage fields remain typed and independently checked.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpatialResource {
|
||||
pub id: String,
|
||||
pub tenant_id: String,
|
||||
pub workspace_id: String,
|
||||
pub kind: SpatialKind,
|
||||
pub schema_version: String,
|
||||
pub privacy: String,
|
||||
pub message_id: String,
|
||||
pub event_sequence: u64,
|
||||
pub version: u64,
|
||||
pub site_id: Option<String>,
|
||||
pub building_id: Option<String>,
|
||||
pub floor_id: Option<String>,
|
||||
pub space_id: Option<String>,
|
||||
pub zone_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub entity_type: Option<String>,
|
||||
pub identity_mode: Option<String>,
|
||||
pub event_type: Option<String>,
|
||||
pub alert_type: Option<String>,
|
||||
pub severity: Option<String>,
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub related_event_ids: Vec<String>,
|
||||
pub observed_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub retention_expires_at: Option<String>,
|
||||
pub confidence: Option<f64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub attributes: Value,
|
||||
#[serde(default)]
|
||||
pub provenance: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpacesResponse {
|
||||
@@ -272,7 +463,178 @@ pub fn decode(bytes: &[u8]) -> Result<SpacesResponse, Error> {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Decode and independently enforce one `/v1/spatial/{kind}` page.
|
||||
pub fn decode_spatial(bytes: &[u8], expected_kind: SpatialKind) -> Result<SpatialResponse, Error> {
|
||||
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(Error::ResponseTooLarge);
|
||||
}
|
||||
let value: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| Error::InvalidResponse("malformed JSON".into()))?;
|
||||
validate_value(&value, 0)?;
|
||||
let response: SpatialResponse = serde_json::from_value(value)
|
||||
.map_err(|error| Error::InvalidResponse(format!("spatial schema mismatch: {error}")))?;
|
||||
if response.object != "list"
|
||||
|| response.kind != expected_kind
|
||||
|| response.schema_version != "1.0"
|
||||
|| response.data.len() > MAX_SPACES
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"invalid spatial list envelope".into(),
|
||||
));
|
||||
}
|
||||
if response.boundary.authoritative_state != "HomeCore Edge"
|
||||
|| REQUIRED_EXCLUSIONS.iter().any(|required| {
|
||||
!response
|
||||
.boundary
|
||||
.excluded
|
||||
.iter()
|
||||
.any(|excluded| excluded == required)
|
||||
})
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"incomplete edge privacy boundary".into(),
|
||||
));
|
||||
}
|
||||
if response.next_cursor.as_ref().is_some_and(|cursor| {
|
||||
cursor.is_empty() || cursor.len() > 512 || cursor.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(Error::InvalidResponse("invalid next cursor".into()));
|
||||
}
|
||||
for record in &response.data {
|
||||
if !valid_id(&record.id)
|
||||
|| record.tenant_id.is_empty()
|
||||
|| !is_uuid(&record.workspace_id)
|
||||
|| record.kind != expected_kind
|
||||
|| record.schema_version != "1.0"
|
||||
|| !valid_id(&record.message_id)
|
||||
|| record.version == 0
|
||||
|| !valid_timestamp(&record.observed_at)
|
||||
|| record
|
||||
.expires_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| record
|
||||
.retention_expires_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| record
|
||||
.created_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| record
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| !optional_id_valid(record.site_id.as_deref())
|
||||
|| !optional_id_valid(record.building_id.as_deref())
|
||||
|| !optional_id_valid(record.floor_id.as_deref())
|
||||
|| !optional_id_valid(record.space_id.as_deref())
|
||||
|| !optional_id_valid(record.zone_id.as_deref())
|
||||
|| record.related_event_ids.len() > 32
|
||||
|| record.related_event_ids.iter().any(|id| !valid_id(id))
|
||||
|| record
|
||||
.related_event_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, id)| record.related_event_ids[..index].contains(id))
|
||||
|| !record.attributes.is_object()
|
||||
|| !record.provenance.is_object()
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial resource identity is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if let Some(expires_at) = record.expires_at.as_deref() {
|
||||
let observed = chrono::DateTime::parse_from_rfc3339(&record.observed_at)
|
||||
.map_err(|_| Error::InvalidResponse("invalid observed timestamp".into()))?;
|
||||
let expires = chrono::DateTime::parse_from_rfc3339(expires_at)
|
||||
.map_err(|_| Error::InvalidResponse("invalid expiry timestamp".into()))?;
|
||||
if expires <= observed {
|
||||
return Err(Error::InvalidResponse(
|
||||
"expiry must follow observation".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !matches!(record.privacy.as_str(), "P2" | "P3") {
|
||||
return Err(Error::InvalidResponse("non-semantic privacy class".into()));
|
||||
}
|
||||
if record
|
||||
.confidence
|
||||
.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
|
||||
{
|
||||
return Err(Error::InvalidResponse("invalid confidence".into()));
|
||||
}
|
||||
if matches!(record.kind, SpatialKind::Buildings | SpatialKind::Floors)
|
||||
&& record.site_id.as_deref().is_none_or(str::is_empty)
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial parent is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if matches!(record.kind, SpatialKind::Spaces)
|
||||
&& (record.site_id.as_deref().is_none_or(str::is_empty)
|
||||
|| record.building_id.as_deref().is_none_or(str::is_empty)
|
||||
|| record.floor_id.as_deref().is_none_or(str::is_empty))
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial parent is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
record.kind,
|
||||
SpatialKind::Zones | SpatialKind::Entities | SpatialKind::Events | SpatialKind::Alerts
|
||||
) && (record.site_id.as_deref().is_none_or(str::is_empty)
|
||||
|| record.space_id.as_deref().is_none_or(str::is_empty))
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial parent is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if record.kind == SpatialKind::Entities
|
||||
&& (!matches!(
|
||||
record.entity_type.as_deref(),
|
||||
Some("sensor" | "person" | "object" | "track")
|
||||
) || matches!(record.entity_type.as_deref(), Some("person" | "track"))
|
||||
&& record.identity_mode.as_deref() != Some("anonymous"))
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"entity privacy contract is invalid".into(),
|
||||
));
|
||||
}
|
||||
if record.kind == SpatialKind::Events
|
||||
&& record.event_type.as_deref().is_none_or(str::is_empty)
|
||||
{
|
||||
return Err(Error::InvalidResponse("event type is missing".into()));
|
||||
}
|
||||
if record.kind == SpatialKind::Alerts
|
||||
&& (record.alert_type.as_deref().is_none_or(str::is_empty)
|
||||
|| !matches!(
|
||||
record.severity.as_deref(),
|
||||
Some("info" | "warning" | "critical")
|
||||
)
|
||||
|| !matches!(
|
||||
record.status.as_deref(),
|
||||
Some("open" | "acknowledged" | "resolved")
|
||||
))
|
||||
{
|
||||
return Err(Error::InvalidResponse("alert contract is invalid".into()));
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
|
||||
let mut nodes = 0;
|
||||
validate_value_inner(value, depth, &mut nodes)
|
||||
}
|
||||
|
||||
fn validate_value_inner(value: &Value, depth: usize, nodes: &mut usize) -> Result<(), Error> {
|
||||
*nodes = nodes.saturating_add(1);
|
||||
if *nodes > MAX_JSON_NODES {
|
||||
return Err(Error::InvalidResponse(
|
||||
"JSON structure exceeds node bound".into(),
|
||||
));
|
||||
}
|
||||
if depth > MAX_JSON_DEPTH {
|
||||
return Err(Error::InvalidResponse("JSON nesting is too deep".into()));
|
||||
}
|
||||
@@ -285,7 +647,7 @@ fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
validate_value(item, depth + 1)?;
|
||||
validate_value_inner(item, depth + 1, nodes)?;
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
@@ -303,19 +665,41 @@ fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
|
||||
.collect();
|
||||
if matches!(
|
||||
normalized.as_str(),
|
||||
"rawcsi"
|
||||
"csi"
|
||||
| "rawcsi"
|
||||
| "channelstateinformation"
|
||||
| "cir"
|
||||
| "rawcir"
|
||||
| "channelimpulseresponse"
|
||||
| "rftensor"
|
||||
| "rftensors"
|
||||
| "packetcapture"
|
||||
| "packetcaptures"
|
||||
| "pcap"
|
||||
| "recording"
|
||||
| "recordings"
|
||||
| "audiorecording"
|
||||
| "videorecording"
|
||||
| "poseframe"
|
||||
| "poseframes"
|
||||
| "skeleton"
|
||||
| "keypoints"
|
||||
| "vitalwaveform"
|
||||
| "vitalwaveforms"
|
||||
| "heartratewaveform"
|
||||
| "identityobservation"
|
||||
| "identityobservations"
|
||||
| "biometric"
|
||||
| "biometrics"
|
||||
| "face"
|
||||
| "faces"
|
||||
| "faceembedding"
|
||||
) {
|
||||
return Err(Error::InvalidResponse(format!(
|
||||
"forbidden raw field: {key}"
|
||||
)));
|
||||
}
|
||||
validate_value(item, depth + 1)?;
|
||||
validate_value_inner(item, depth + 1, nodes)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -331,6 +715,10 @@ mod tests {
|
||||
br#"{"object":"list","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"workspace-1","siteId":"site-1","name":"Room","version":1,"privacy":"P2","status":"live","connection":"connected","state":{"occupancy":1,"confidence":0.9,"observedAt":"2026-08-17T00:00:00Z","freshnessMs":5,"classification":"P2","uncertainty":null,"evidence":[]},"provenance":{},"hardware":{},"dataBoundary":{},"observedAt":"2026-08-17T00:00:00Z","expiresAt":null}],"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
|
||||
}
|
||||
|
||||
fn valid_spatial() -> Vec<u8> {
|
||||
br#"{"object":"list","kind":"spaces","schemaVersion":"1.0","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"22222222-2222-4222-8222-222222222222","kind":"spaces","schemaVersion":"1.0","privacy":"P2","messageId":"message-1","eventSequence":7,"version":1,"siteId":"site-1","buildingId":"building-1","floorId":"floor-1","spaceId":null,"zoneId":null,"name":"Room","observedAt":"2026-08-19T12:00:00Z","expiresAt":null,"retentionExpiresAt":null,"confidence":0.8,"attributes":{"occupancy":2},"provenance":{"witnessDigest":"abc"}}],"nextCursor":null,"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant/workspace-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_bounded_semantic_state() {
|
||||
assert_eq!(decode(&valid()).unwrap().data.len(), 1);
|
||||
@@ -384,4 +772,78 @@ mod tests {
|
||||
let c = Credential::oauth("secret-token").unwrap();
|
||||
assert!(!format!("{c:?}").contains("secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_versioned_spatial_pages() {
|
||||
let response = decode_spatial(&valid_spatial(), SpatialKind::Spaces).unwrap();
|
||||
assert_eq!(response.data.len(), 1);
|
||||
assert_eq!(response.data[0].event_sequence, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_page_is_bound_to_requested_kind_and_parents() {
|
||||
assert!(decode_spatial(&valid_spatial(), SpatialKind::Events).is_err());
|
||||
let mut value: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
value["data"][0]["floorId"] = Value::Null;
|
||||
assert!(matches!(
|
||||
decode_spatial(&serde_json::to_vec(&value).unwrap(), SpatialKind::Spaces),
|
||||
Err(Error::InvalidResponse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_page_rejects_cross_boundary_payload_and_bad_workspace() {
|
||||
let mut raw: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
raw["data"][0]["attributes"]["pose_frames"] = serde_json::json!([1]);
|
||||
assert!(decode_spatial(&serde_json::to_vec(&raw).unwrap(), SpatialKind::Spaces).is_err());
|
||||
|
||||
let mut workspace: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
workspace["data"][0]["workspaceId"] = Value::String("not-a-uuid".into());
|
||||
assert!(decode_spatial(
|
||||
&serde_json::to_vec(&workspace).unwrap(),
|
||||
SpatialKind::Spaces
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let mut timestamp: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
timestamp["data"][0]["observedAt"] = Value::String("not-a-timestamp".into());
|
||||
assert!(decode_spatial(
|
||||
&serde_json::to_vec(×tamp).unwrap(),
|
||||
SpatialKind::Spaces
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let mut alias: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
alias["data"][0]["attributes"]["packet_captures"] = serde_json::json!([1]);
|
||||
assert!(decode_spatial(&serde_json::to_vec(&alias).unwrap(), SpatialKind::Spaces).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_request_is_bounded_and_api_key_needs_workspace() {
|
||||
assert!(PageRequest {
|
||||
limit: 0,
|
||||
..PageRequest::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(PageRequest {
|
||||
limit: 50,
|
||||
cursor: Some("x".repeat(513)),
|
||||
workspace_id: None,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(PageRequest {
|
||||
workspace_id: Some("22222222-2222-4222-8222-222222222222".into()),
|
||||
..PageRequest::default()
|
||||
}
|
||||
.validate()
|
||||
.is_ok());
|
||||
assert!(PageRequest {
|
||||
workspace_id: Some("22222222-2222-7222-8222-222222222222".into()),
|
||||
..PageRequest::default()
|
||||
}
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ ruview-evidence = { path = "../ruview-evidence" }
|
||||
ruview-ood = { path = "../ruview-ood" }
|
||||
ruview-certify = { path = "../ruview-certify" }
|
||||
ruview-attest = { path = "../ruview-attest" }
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
995
v2/crates/ruview-policy/src/governed.rs
Normal file
995
v2/crates/ruview-policy/src/governed.rs
Normal file
@@ -0,0 +1,995 @@
|
||||
//! Governed action intents and witnessed authorization receipts (ADR-327).
|
||||
//!
|
||||
//! This module never touches an actuator. Its strongest outcome is an
|
||||
//! `Authorized` receipt that a separate, explicitly configured adapter may
|
||||
//! consume. Observe and recommend are the default modes; execute fails closed
|
||||
//! unless a registered policy, live assurance, and signed approvals all pass.
|
||||
|
||||
use crate::{authorize, ActionClass, AssuranceInputs, Authorization, FailedCondition};
|
||||
use ruview_attest::{Signature, Signer, Verifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const INTENT_DOMAIN: &[u8] = b"ruview.governed-intent.v1\0";
|
||||
const APPROVAL_DOMAIN: &[u8] = b"ruview.governed-approval.v1\0";
|
||||
const RECEIPT_DOMAIN: &[u8] = b"ruview.governed-receipt.v1\0";
|
||||
const MAX_ID_BYTES: usize = 128;
|
||||
const MAX_APPROVALS: usize = 16;
|
||||
const MAX_TARGET_PREFIXES: usize = 32;
|
||||
const MAX_INTENT_LIFETIME_MS: i64 = 86_400_000;
|
||||
const MAX_RECEIPTS: usize = 10_000;
|
||||
|
||||
/// Requested governance mode. Automation should default to `Recommend`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum IntentMode {
|
||||
/// Record a governed observation without proposing a consequence.
|
||||
Observe,
|
||||
/// Produce a recommendation for human/policy review.
|
||||
Recommend,
|
||||
/// Request an authorization receipt for a separately configured adapter.
|
||||
Execute,
|
||||
}
|
||||
|
||||
impl Default for IntentMode {
|
||||
fn default() -> Self {
|
||||
Self::Recommend
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, bounded request. Parameters are represented only by a digest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActionIntent {
|
||||
/// Idempotency key for this exact attempt.
|
||||
pub intent_id: String,
|
||||
/// Authenticated tenant identifier.
|
||||
pub tenant_id: String,
|
||||
/// Authenticated workspace identifier.
|
||||
pub workspace_id: String,
|
||||
/// Registered action kind, such as `alert.raise`.
|
||||
pub action_kind: String,
|
||||
/// Exact registered policy version requested by this intent.
|
||||
pub policy_version: String,
|
||||
/// Bounded target identifier.
|
||||
pub target_id: String,
|
||||
/// Consequence/assurance class.
|
||||
pub class: ActionClass,
|
||||
/// Observe, recommend, or explicitly request authorization.
|
||||
#[serde(default)]
|
||||
pub mode: IntentMode,
|
||||
/// Authenticated requesting principal or agent.
|
||||
pub requested_by: String,
|
||||
/// Intent creation time in Unix milliseconds.
|
||||
pub issued_at_ms: i64,
|
||||
/// Hard expiry in Unix milliseconds.
|
||||
pub expires_at_ms: i64,
|
||||
/// Caller-generated replay nonce. All zeroes are invalid.
|
||||
pub nonce: [u8; 16],
|
||||
/// Digest of canonical adapter parameters; raw values are not logged here.
|
||||
pub parameters_digest: [u8; 32],
|
||||
/// Digest of the governed perception/evidence input.
|
||||
pub evidence_digest: [u8; 32],
|
||||
}
|
||||
|
||||
impl ActionIntent {
|
||||
/// Deterministic bytes bound into approvals and receipts.
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(512);
|
||||
out.extend_from_slice(INTENT_DOMAIN);
|
||||
for value in [
|
||||
self.intent_id.as_str(),
|
||||
self.tenant_id.as_str(),
|
||||
self.workspace_id.as_str(),
|
||||
self.action_kind.as_str(),
|
||||
self.policy_version.as_str(),
|
||||
self.target_id.as_str(),
|
||||
self.requested_by.as_str(),
|
||||
] {
|
||||
push_field(&mut out, value.as_bytes());
|
||||
}
|
||||
out.push(self.class as u8);
|
||||
out.push(self.mode as u8);
|
||||
out.extend_from_slice(&self.issued_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.expires_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.nonce);
|
||||
out.extend_from_slice(&self.parameters_digest);
|
||||
out.extend_from_slice(&self.evidence_digest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Digest used as the immutable idempotency fingerprint.
|
||||
pub fn digest(&self) -> [u8; 32] {
|
||||
*blake3::hash(&self.canonical_bytes()).as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// A versioned, locally registered execution rule.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActionRule {
|
||||
/// Exact action kind this rule governs.
|
||||
pub action_kind: String,
|
||||
/// Monotonic/configuration version included in approvals and receipts.
|
||||
pub policy_version: String,
|
||||
/// Required assurance class. Intent class must match exactly.
|
||||
pub class: ActionClass,
|
||||
/// Number of distinct valid human/service approvals (at least one).
|
||||
pub minimum_approvals: usize,
|
||||
/// Trusted execution grant required in addition to perception assurance.
|
||||
pub required_grant: String,
|
||||
/// At least one prefix must match the target identifier.
|
||||
pub target_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Local allow-list of action rules. Absence is a deny.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PolicyRegistry {
|
||||
rules: BTreeMap<String, ActionRule>,
|
||||
}
|
||||
|
||||
impl PolicyRegistry {
|
||||
/// Register one valid rule; duplicate action kinds are refused.
|
||||
pub fn register(&mut self, rule: ActionRule) -> Result<(), GovernanceError> {
|
||||
validate_id(&rule.action_kind)?;
|
||||
validate_id(&rule.policy_version)?;
|
||||
validate_id(&rule.required_grant)?;
|
||||
if rule.minimum_approvals == 0 || rule.minimum_approvals > MAX_APPROVALS {
|
||||
return Err(GovernanceError::InvalidInput(
|
||||
"approval threshold is out of bounds",
|
||||
));
|
||||
}
|
||||
if rule.target_prefixes.is_empty() || rule.target_prefixes.len() > MAX_TARGET_PREFIXES {
|
||||
return Err(GovernanceError::InvalidInput(
|
||||
"target prefix list is out of bounds",
|
||||
));
|
||||
}
|
||||
for prefix in &rule.target_prefixes {
|
||||
validate_id(prefix)?;
|
||||
}
|
||||
if self.rules.contains_key(&rule.action_kind) {
|
||||
return Err(GovernanceError::PolicyConflict);
|
||||
}
|
||||
self.rules.insert(rule.action_kind.clone(), rule);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, action_kind: &str) -> Option<&ActionRule> {
|
||||
self.rules.get(action_kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trusted grants established by the host authorization adapter.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AuthorityContext {
|
||||
grants: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl AuthorityContext {
|
||||
/// Build a bounded set of authenticated grants. Strings are exact-match.
|
||||
pub fn from_authenticated_grants<I, S>(grants: I) -> Result<Self, GovernanceError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let mut values = BTreeSet::new();
|
||||
for (index, grant) in grants.into_iter().enumerate() {
|
||||
if index >= MAX_APPROVALS {
|
||||
return Err(GovernanceError::InvalidInput(
|
||||
"authority grant set is out of bounds",
|
||||
));
|
||||
}
|
||||
let grant = grant.into();
|
||||
validate_id(&grant)?;
|
||||
values.insert(grant);
|
||||
}
|
||||
Ok(Self { grants: values })
|
||||
}
|
||||
|
||||
fn contains(&self, grant: &str) -> bool {
|
||||
self.grants.contains(grant)
|
||||
}
|
||||
}
|
||||
|
||||
/// Human or service approval decision.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ApprovalDecision {
|
||||
/// Explicit approval.
|
||||
Approve,
|
||||
/// Explicit rejection; any valid rejection denies this attempt.
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// Content signed by one registered approver.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApprovalContent {
|
||||
/// Intent digest prevents approval substitution.
|
||||
pub intent_digest: [u8; 32],
|
||||
/// Exact policy version reviewed by the approver.
|
||||
pub policy_version: String,
|
||||
/// Registered approver identity.
|
||||
pub approver_id: String,
|
||||
/// Explicit approve/reject decision.
|
||||
pub decision: ApprovalDecision,
|
||||
/// Approval timestamp in Unix milliseconds.
|
||||
pub approved_at_ms: i64,
|
||||
}
|
||||
|
||||
impl ApprovalContent {
|
||||
/// Deterministic signing bytes.
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(256);
|
||||
out.extend_from_slice(APPROVAL_DOMAIN);
|
||||
out.extend_from_slice(&self.intent_digest);
|
||||
push_field(&mut out, self.policy_version.as_bytes());
|
||||
push_field(&mut out, self.approver_id.as_bytes());
|
||||
out.push(self.decision as u8);
|
||||
out.extend_from_slice(&self.approved_at_ms.to_le_bytes());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed approval envelope.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SignedApproval {
|
||||
/// Signed approval content.
|
||||
pub content: ApprovalContent,
|
||||
/// Attestation signature/MAC.
|
||||
pub signature: Signature,
|
||||
}
|
||||
|
||||
impl SignedApproval {
|
||||
/// Sign approval content with an enrolled signer.
|
||||
pub fn sign<S: Signer + ?Sized>(content: ApprovalContent, signer: &S) -> Self {
|
||||
let signature = signer.sign(&content.canonical_bytes());
|
||||
Self { content, signature }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves approver identities to enrolled verification keys.
|
||||
pub trait ApprovalVerifier {
|
||||
/// Return true only for a registered identity and valid signature.
|
||||
fn verify(&self, approver_id: &str, message: &[u8], signature: &Signature) -> bool;
|
||||
}
|
||||
|
||||
/// Stable terminal reason for a non-authorized receipt.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DenialReason {
|
||||
/// Intent was expired or not yet valid.
|
||||
IntentExpired,
|
||||
/// Action kind has no registered policy.
|
||||
NoPolicy,
|
||||
/// Intent class does not match the registered policy.
|
||||
ClassMismatch,
|
||||
/// Intent names a policy version other than the registered version.
|
||||
PolicyVersionMismatch,
|
||||
/// Trusted host authority lacks the exact policy grant.
|
||||
MissingAuthority,
|
||||
/// Target is outside the registered allow-list.
|
||||
TargetNotAllowed,
|
||||
/// Too few distinct, valid, explicit approvals.
|
||||
InsufficientApprovals,
|
||||
/// An approval was malformed, rejected, duplicated, or unauthenticated.
|
||||
InvalidApproval,
|
||||
/// Existing assurance policy denied the requested class.
|
||||
AssuranceDenied(FailedCondition),
|
||||
}
|
||||
|
||||
/// Terminal governance decision. `Authorized` is not proof of actuation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GovernedDecision {
|
||||
/// Observation was witnessed only.
|
||||
Observed,
|
||||
/// Recommendation was witnessed and awaits a new execute intent.
|
||||
Recommended,
|
||||
/// A separate configured adapter may execute this exact intent.
|
||||
Authorized,
|
||||
/// Authorization failed closed.
|
||||
Denied(DenialReason),
|
||||
}
|
||||
|
||||
/// Signed, hash-chained receipt content.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ReceiptContent {
|
||||
/// Monotonic sequence within this engine instance.
|
||||
pub sequence: u64,
|
||||
/// Engine/service identity issuing the receipt.
|
||||
pub issuer_id: String,
|
||||
/// Exact intent digest.
|
||||
pub intent_digest: [u8; 32],
|
||||
/// Intent idempotency key for lookup.
|
||||
pub intent_id: String,
|
||||
/// Authenticated tenant/workspace copied from the intent.
|
||||
pub tenant_id: String,
|
||||
/// Authenticated tenant/workspace copied from the intent.
|
||||
pub workspace_id: String,
|
||||
/// Registered policy version, if a policy was found.
|
||||
pub policy_version: Option<String>,
|
||||
/// Terminal governance decision.
|
||||
pub decision: GovernedDecision,
|
||||
/// Number of distinct verified approvals used.
|
||||
pub verified_approvals: usize,
|
||||
/// Decision timestamp supplied by the caller.
|
||||
pub decided_at_ms: i64,
|
||||
/// Intent expiry copied into the receipt for adapter-side checks.
|
||||
pub expires_at_ms: i64,
|
||||
/// Non-secret replay nonce copied into the signed receipt.
|
||||
pub nonce: [u8; 16],
|
||||
/// Previous receipt digest; zeroes start a chain.
|
||||
pub previous_receipt_digest: [u8; 32],
|
||||
}
|
||||
|
||||
impl ReceiptContent {
|
||||
/// Deterministic bytes for signing and chain hashing.
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(512);
|
||||
out.extend_from_slice(RECEIPT_DOMAIN);
|
||||
out.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
for value in [
|
||||
self.issuer_id.as_str(),
|
||||
self.intent_id.as_str(),
|
||||
self.tenant_id.as_str(),
|
||||
self.workspace_id.as_str(),
|
||||
] {
|
||||
push_field(&mut out, value.as_bytes());
|
||||
}
|
||||
out.extend_from_slice(&self.intent_digest);
|
||||
match &self.policy_version {
|
||||
Some(version) => {
|
||||
out.push(1);
|
||||
push_field(&mut out, version.as_bytes());
|
||||
}
|
||||
None => out.push(0),
|
||||
}
|
||||
push_decision(&mut out, &self.decision);
|
||||
out.extend_from_slice(&(self.verified_approvals as u64).to_le_bytes());
|
||||
out.extend_from_slice(&self.decided_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.expires_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.nonce);
|
||||
out.extend_from_slice(&self.previous_receipt_digest);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed receipt. It authorizes at most; it never asserts physical execution.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ActionReceipt {
|
||||
/// Signed content.
|
||||
pub content: ReceiptContent,
|
||||
/// Signature over canonical content bytes.
|
||||
pub signature: Signature,
|
||||
}
|
||||
|
||||
impl ActionReceipt {
|
||||
/// Verify the issuer signature.
|
||||
pub fn verify<V: Verifier + ?Sized>(&self, verifier: &V) -> bool {
|
||||
verifier.verify(&self.content.canonical_bytes(), &self.signature)
|
||||
}
|
||||
|
||||
/// Digest used by the next receipt's chain link.
|
||||
pub fn digest(&self) -> [u8; 32] {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(&self.content.canonical_bytes());
|
||||
hasher.update(&self.signature.0);
|
||||
*hasher.finalize().as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredReceipt {
|
||||
intent_digest: [u8; 32],
|
||||
receipt: ActionReceipt,
|
||||
}
|
||||
|
||||
/// Stateful governance boundary providing idempotency and receipt chaining.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GovernanceEngine {
|
||||
issuer_id: String,
|
||||
policies: PolicyRegistry,
|
||||
receipts: BTreeMap<String, StoredReceipt>,
|
||||
nonces: BTreeMap<(String, String, [u8; 16]), [u8; 32]>,
|
||||
next_sequence: u64,
|
||||
previous_receipt_digest: [u8; 32],
|
||||
}
|
||||
|
||||
impl GovernanceEngine {
|
||||
/// Create an engine with an explicit local policy registry.
|
||||
pub fn new(issuer_id: String, policies: PolicyRegistry) -> Result<Self, GovernanceError> {
|
||||
validate_id(&issuer_id)?;
|
||||
Ok(Self {
|
||||
issuer_id,
|
||||
policies,
|
||||
receipts: BTreeMap::new(),
|
||||
nonces: BTreeMap::new(),
|
||||
next_sequence: 1,
|
||||
previous_receipt_digest: [0; 32],
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate and witness one intent. A repeated identical intent returns the
|
||||
/// exact prior receipt; changed reuse of its idempotency key is rejected.
|
||||
pub fn evaluate<S: Signer + ?Sized, V: ApprovalVerifier + ?Sized>(
|
||||
&mut self,
|
||||
intent: &ActionIntent,
|
||||
authority: &AuthorityContext,
|
||||
assurance: &AssuranceInputs,
|
||||
approvals: &[SignedApproval],
|
||||
approval_verifier: &V,
|
||||
receipt_signer: &S,
|
||||
now_ms: i64,
|
||||
) -> Result<ActionReceipt, GovernanceError> {
|
||||
validate_intent(intent)?;
|
||||
let intent_digest = intent.digest();
|
||||
if let Some(stored) = self.receipts.get(&intent.intent_id) {
|
||||
return if stored.intent_digest == intent_digest {
|
||||
Ok(stored.receipt.clone())
|
||||
} else {
|
||||
Err(GovernanceError::IdempotencyConflict)
|
||||
};
|
||||
}
|
||||
if self.receipts.len() >= MAX_RECEIPTS {
|
||||
return Err(GovernanceError::CapacityReached);
|
||||
}
|
||||
let nonce_key = (
|
||||
intent.tenant_id.clone(),
|
||||
intent.workspace_id.clone(),
|
||||
intent.nonce,
|
||||
);
|
||||
if self.nonces.contains_key(&nonce_key) {
|
||||
return Err(GovernanceError::NonceReplay);
|
||||
}
|
||||
|
||||
let rule = self.policies.get(&intent.action_kind);
|
||||
let (decision, verified_approvals) =
|
||||
if now_ms < intent.issued_at_ms || now_ms >= intent.expires_at_ms {
|
||||
(GovernedDecision::Denied(DenialReason::IntentExpired), 0)
|
||||
} else {
|
||||
match intent.mode {
|
||||
IntentMode::Observe => (GovernedDecision::Observed, 0),
|
||||
IntentMode::Recommend => (GovernedDecision::Recommended, 0),
|
||||
IntentMode::Execute => evaluate_execution(
|
||||
intent,
|
||||
intent_digest,
|
||||
authority,
|
||||
assurance,
|
||||
approvals,
|
||||
approval_verifier,
|
||||
rule,
|
||||
now_ms,
|
||||
),
|
||||
}
|
||||
};
|
||||
let policy_version = rule.map(|value| value.policy_version.clone());
|
||||
let content = ReceiptContent {
|
||||
sequence: self.next_sequence,
|
||||
issuer_id: self.issuer_id.clone(),
|
||||
intent_digest,
|
||||
intent_id: intent.intent_id.clone(),
|
||||
tenant_id: intent.tenant_id.clone(),
|
||||
workspace_id: intent.workspace_id.clone(),
|
||||
policy_version,
|
||||
decision,
|
||||
verified_approvals,
|
||||
decided_at_ms: now_ms,
|
||||
expires_at_ms: intent.expires_at_ms,
|
||||
nonce: intent.nonce,
|
||||
previous_receipt_digest: self.previous_receipt_digest,
|
||||
};
|
||||
let receipt = ActionReceipt {
|
||||
signature: receipt_signer.sign(&content.canonical_bytes()),
|
||||
content,
|
||||
};
|
||||
self.next_sequence = self
|
||||
.next_sequence
|
||||
.checked_add(1)
|
||||
.ok_or(GovernanceError::SequenceExhausted)?;
|
||||
self.previous_receipt_digest = receipt.digest();
|
||||
self.receipts.insert(
|
||||
intent.intent_id.clone(),
|
||||
StoredReceipt {
|
||||
intent_digest,
|
||||
receipt: receipt.clone(),
|
||||
},
|
||||
);
|
||||
self.nonces.insert(nonce_key, intent_digest);
|
||||
Ok(receipt)
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_execution<V: ApprovalVerifier + ?Sized>(
|
||||
intent: &ActionIntent,
|
||||
intent_digest: [u8; 32],
|
||||
authority: &AuthorityContext,
|
||||
assurance: &AssuranceInputs,
|
||||
approvals: &[SignedApproval],
|
||||
verifier: &V,
|
||||
rule: Option<&ActionRule>,
|
||||
now_ms: i64,
|
||||
) -> (GovernedDecision, usize) {
|
||||
if now_ms < intent.issued_at_ms || now_ms >= intent.expires_at_ms {
|
||||
return (GovernedDecision::Denied(DenialReason::IntentExpired), 0);
|
||||
}
|
||||
let Some(rule) = rule else {
|
||||
return (GovernedDecision::Denied(DenialReason::NoPolicy), 0);
|
||||
};
|
||||
if intent.class != rule.class {
|
||||
return (GovernedDecision::Denied(DenialReason::ClassMismatch), 0);
|
||||
}
|
||||
if intent.policy_version != rule.policy_version {
|
||||
return (
|
||||
GovernedDecision::Denied(DenialReason::PolicyVersionMismatch),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if !authority.contains(&rule.required_grant) {
|
||||
return (GovernedDecision::Denied(DenialReason::MissingAuthority), 0);
|
||||
}
|
||||
if !rule
|
||||
.target_prefixes
|
||||
.iter()
|
||||
.any(|prefix| intent.target_id.starts_with(prefix))
|
||||
{
|
||||
return (GovernedDecision::Denied(DenialReason::TargetNotAllowed), 0);
|
||||
}
|
||||
if approvals.len() > MAX_APPROVALS {
|
||||
return (GovernedDecision::Denied(DenialReason::InvalidApproval), 0);
|
||||
}
|
||||
let mut distinct = BTreeSet::new();
|
||||
for approval in approvals {
|
||||
let content = &approval.content;
|
||||
if validate_id(&content.approver_id).is_err()
|
||||
|| content.intent_digest != intent_digest
|
||||
|| content.policy_version != rule.policy_version
|
||||
|| content.approved_at_ms < intent.issued_at_ms
|
||||
|| content.approved_at_ms >= intent.expires_at_ms
|
||||
|| content.approved_at_ms > now_ms
|
||||
|| content.decision != ApprovalDecision::Approve
|
||||
|| !distinct.insert(content.approver_id.as_str())
|
||||
|| !verifier.verify(
|
||||
&content.approver_id,
|
||||
&content.canonical_bytes(),
|
||||
&approval.signature,
|
||||
)
|
||||
{
|
||||
return (
|
||||
GovernedDecision::Denied(DenialReason::InvalidApproval),
|
||||
distinct.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if distinct.len() < rule.minimum_approvals {
|
||||
return (
|
||||
GovernedDecision::Denied(DenialReason::InsufficientApprovals),
|
||||
distinct.len(),
|
||||
);
|
||||
}
|
||||
match authorize(intent.class, assurance) {
|
||||
Authorization::Allow { .. } => (GovernedDecision::Authorized, distinct.len()),
|
||||
Authorization::Deny { failed_condition } => (
|
||||
GovernedDecision::Denied(DenialReason::AssuranceDenied(failed_condition)),
|
||||
distinct.len(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine/configuration errors. Policy denials are signed receipts, not errors.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum GovernanceError {
|
||||
/// Malformed caller/configuration input.
|
||||
#[error("invalid governed-action input: {0}")]
|
||||
InvalidInput(&'static str),
|
||||
/// Duplicate action rule.
|
||||
#[error("action policy already registered")]
|
||||
PolicyConflict,
|
||||
/// An intent idempotency key was reused with different content.
|
||||
#[error("intent idempotency conflict")]
|
||||
IdempotencyConflict,
|
||||
/// A nonce was already bound to a different intent id.
|
||||
#[error("intent nonce replay")]
|
||||
NonceReplay,
|
||||
/// The bounded in-memory replay store reached capacity.
|
||||
#[error("governance receipt capacity reached")]
|
||||
CapacityReached,
|
||||
/// Receipt sequence exhausted.
|
||||
#[error("receipt sequence exhausted")]
|
||||
SequenceExhausted,
|
||||
}
|
||||
|
||||
fn validate_intent(intent: &ActionIntent) -> Result<(), GovernanceError> {
|
||||
for value in [
|
||||
intent.intent_id.as_str(),
|
||||
intent.tenant_id.as_str(),
|
||||
intent.workspace_id.as_str(),
|
||||
intent.action_kind.as_str(),
|
||||
intent.policy_version.as_str(),
|
||||
intent.target_id.as_str(),
|
||||
intent.requested_by.as_str(),
|
||||
] {
|
||||
validate_id(value)?;
|
||||
}
|
||||
if intent.issued_at_ms >= intent.expires_at_ms
|
||||
|| intent.expires_at_ms - intent.issued_at_ms > MAX_INTENT_LIFETIME_MS
|
||||
|| intent.nonce == [0; 16]
|
||||
{
|
||||
return Err(GovernanceError::InvalidInput("intent lifetime is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_id(value: &str) -> Result<(), GovernanceError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_ID_BYTES
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
{
|
||||
return Err(GovernanceError::InvalidInput("identifier is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_field(output: &mut Vec<u8>, field: &[u8]) {
|
||||
output.extend_from_slice(&(field.len() as u32).to_le_bytes());
|
||||
output.extend_from_slice(field);
|
||||
}
|
||||
|
||||
fn push_decision(output: &mut Vec<u8>, decision: &GovernedDecision) {
|
||||
match decision {
|
||||
GovernedDecision::Observed => output.push(0),
|
||||
GovernedDecision::Recommended => output.push(1),
|
||||
GovernedDecision::Authorized => output.push(2),
|
||||
GovernedDecision::Denied(reason) => {
|
||||
output.push(3);
|
||||
push_denial(output, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_denial(output: &mut Vec<u8>, reason: &DenialReason) {
|
||||
match reason {
|
||||
DenialReason::IntentExpired => output.push(0),
|
||||
DenialReason::NoPolicy => output.push(1),
|
||||
DenialReason::ClassMismatch => output.push(2),
|
||||
DenialReason::PolicyVersionMismatch => output.push(3),
|
||||
DenialReason::MissingAuthority => output.push(4),
|
||||
DenialReason::TargetNotAllowed => output.push(5),
|
||||
DenialReason::InsufficientApprovals => output.push(6),
|
||||
DenialReason::InvalidApproval => output.push(7),
|
||||
DenialReason::AssuranceDenied(condition) => {
|
||||
output.push(8);
|
||||
match condition {
|
||||
FailedCondition::NoPolicy => output.push(0),
|
||||
FailedCondition::CertificateInvalid => output.push(1),
|
||||
FailedCondition::CertificateClassTooLow { required, actual } => {
|
||||
output.extend_from_slice(&[2, *required as u8, *actual as u8]);
|
||||
}
|
||||
FailedCondition::CertificateStale { age_secs, max_secs } => {
|
||||
output.push(3);
|
||||
output.extend_from_slice(&age_secs.to_le_bytes());
|
||||
output.extend_from_slice(&max_secs.to_le_bytes());
|
||||
}
|
||||
FailedCondition::DomainDegraded => output.push(4),
|
||||
FailedCondition::DomainNotKnown => output.push(5),
|
||||
FailedCondition::UncertaintyOverCeiling { max_uncertainty } => {
|
||||
output.push(6);
|
||||
output.extend_from_slice(&max_uncertainty.to_bits().to_le_bytes());
|
||||
}
|
||||
FailedCondition::EvidenceBelowFloor { required, actual } => {
|
||||
output.extend_from_slice(&[7, *required as u8, *actual as u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CertificateClass, DomainState};
|
||||
use ruview_attest::Blake3MacSigner;
|
||||
use ruview_evidence::EvidenceLevel;
|
||||
|
||||
const NOW: i64 = 10_000;
|
||||
|
||||
struct Approvers(BTreeMap<String, Blake3MacSigner>);
|
||||
|
||||
impl ApprovalVerifier for Approvers {
|
||||
fn verify(&self, approver_id: &str, message: &[u8], signature: &Signature) -> bool {
|
||||
self.0
|
||||
.get(approver_id)
|
||||
.is_some_and(|key| Verifier::verify(key, message, signature))
|
||||
}
|
||||
}
|
||||
|
||||
fn registry() -> PolicyRegistry {
|
||||
let mut registry = PolicyRegistry::default();
|
||||
registry
|
||||
.register(ActionRule {
|
||||
action_kind: "alert.raise".into(),
|
||||
policy_version: "v1".into(),
|
||||
class: ActionClass::Security,
|
||||
minimum_approvals: 1,
|
||||
required_grant: "alerts:execute".into(),
|
||||
target_prefixes: vec!["alert:".into()],
|
||||
})
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn intent(mode: IntentMode) -> ActionIntent {
|
||||
ActionIntent {
|
||||
intent_id: "intent-1".into(),
|
||||
tenant_id: "tenant-1".into(),
|
||||
workspace_id: "workspace-1".into(),
|
||||
action_kind: "alert.raise".into(),
|
||||
policy_version: "v1".into(),
|
||||
target_id: "alert:room-1".into(),
|
||||
class: ActionClass::Security,
|
||||
mode,
|
||||
requested_by: "agent-1".into(),
|
||||
issued_at_ms: NOW - 100,
|
||||
expires_at_ms: NOW + 100,
|
||||
nonce: [1; 16],
|
||||
parameters_digest: [1; 32],
|
||||
evidence_digest: [2; 32],
|
||||
}
|
||||
}
|
||||
|
||||
fn assurance() -> AssuranceInputs {
|
||||
AssuranceInputs {
|
||||
certificate_class: CertificateClass::Standard,
|
||||
certificate_valid: true,
|
||||
certificate_age_secs: 1,
|
||||
domain_state: DomainState::Known,
|
||||
uncertainty: 0.1,
|
||||
evidence_level: EvidenceLevel::L2,
|
||||
}
|
||||
}
|
||||
|
||||
fn approvers() -> Approvers {
|
||||
Approvers(BTreeMap::from([(
|
||||
"human-1".into(),
|
||||
Blake3MacSigner::new([3; 32]),
|
||||
)]))
|
||||
}
|
||||
|
||||
fn authority() -> AuthorityContext {
|
||||
AuthorityContext::from_authenticated_grants(["alerts:execute"]).unwrap()
|
||||
}
|
||||
|
||||
fn approval(intent: &ActionIntent) -> SignedApproval {
|
||||
SignedApproval::sign(
|
||||
ApprovalContent {
|
||||
intent_digest: intent.digest(),
|
||||
policy_version: "v1".into(),
|
||||
approver_id: "human-1".into(),
|
||||
decision: ApprovalDecision::Approve,
|
||||
approved_at_ms: NOW - 1,
|
||||
},
|
||||
&Blake3MacSigner::new([3; 32]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_and_recommend_are_non_executing_defaults() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
for (mode, expected) in [
|
||||
(IntentMode::Observe, GovernedDecision::Observed),
|
||||
(IntentMode::Recommend, GovernedDecision::Recommended),
|
||||
] {
|
||||
let mut engine =
|
||||
GovernanceEngine::new("issuer".into(), PolicyRegistry::default()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&intent(mode),
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.content.decision, expected);
|
||||
assert!(receipt.verify(&signer));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_observation_and_recommendation_intents_are_denied() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
for mode in [IntentMode::Observe, IntentMode::Recommend] {
|
||||
let mut request = intent(mode);
|
||||
request.issued_at_ms = NOW - 200;
|
||||
request.expires_at_ms = NOW - 1;
|
||||
let mut engine =
|
||||
GovernanceEngine::new("issuer".into(), PolicyRegistry::default()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::IntentExpired)
|
||||
);
|
||||
assert!(receipt.verify(&signer));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_requires_policy_signed_approval_and_assurance() {
|
||||
let receipt_signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Execute);
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let denied = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&receipt_signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
denied.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::InsufficientApprovals)
|
||||
);
|
||||
|
||||
let mut second = request.clone();
|
||||
second.intent_id = "intent-2".into();
|
||||
second.nonce = [2; 16];
|
||||
let authorized = engine
|
||||
.evaluate(
|
||||
&second,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[approval(&second)],
|
||||
&approvers(),
|
||||
&receipt_signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(authorized.content.decision, GovernedDecision::Authorized);
|
||||
assert_eq!(authorized.content.previous_receipt_digest, denied.digest());
|
||||
assert!(authorized.verify(&receipt_signer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_approval_and_unknown_domain_fail_closed() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Execute);
|
||||
let mut bad = approval(&request);
|
||||
bad.signature.0[0] ^= 1;
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[bad],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::InvalidApproval)
|
||||
);
|
||||
|
||||
let mut second = request.clone();
|
||||
second.intent_id = "intent-2".into();
|
||||
second.nonce = [2; 16];
|
||||
let mut weak = assurance();
|
||||
weak.domain_state = DomainState::Unknown;
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&second,
|
||||
&authority(),
|
||||
&weak,
|
||||
&[approval(&second)],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::AssuranceDenied(
|
||||
FailedCondition::DomainNotKnown
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_read_is_not_execution_authority_and_nonce_reuse_is_rejected() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Execute);
|
||||
let read_only = AuthorityContext::from_authenticated_grants(["spaces:read"]).unwrap();
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&read_only,
|
||||
&assurance(),
|
||||
&[approval(&request)],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::MissingAuthority)
|
||||
);
|
||||
|
||||
let mut changed_id = request;
|
||||
changed_id.intent_id = "intent-other".into();
|
||||
assert_eq!(
|
||||
engine.evaluate(
|
||||
&changed_id,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[approval(&changed_id)],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
),
|
||||
Err(GovernanceError::NonceReplay)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotency_is_exact_and_changed_reuse_is_rejected() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Recommend);
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let first = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
let replay = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW + 1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first, replay);
|
||||
let mut changed = request;
|
||||
changed.parameters_digest = [0xAA; 32];
|
||||
assert_eq!(
|
||||
engine.evaluate(
|
||||
&changed,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
),
|
||||
Err(GovernanceError::IdempotencyConflict)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@
|
||||
use ruview_evidence::EvidenceLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Typed intent, approval, idempotency, and witnessed-receipt layer (ADR-327).
|
||||
pub mod governed;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value types owned by this crate
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -479,10 +482,8 @@ pub fn authorize_from_certificate<V: ruview_attest::Verifier + ?Sized>(
|
||||
uncertainty: f64,
|
||||
evidence_level: EvidenceLevel,
|
||||
) -> Authorization {
|
||||
let certificate_valid =
|
||||
cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
|
||||
let certificate_age_secs =
|
||||
(now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
|
||||
let certificate_valid = cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
|
||||
let certificate_age_secs = (now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
|
||||
|
||||
authorize(
|
||||
class,
|
||||
|
||||
20
v2/crates/ruview-spatial-memory/Cargo.toml
Normal file
20
v2/crates/ruview-spatial-memory/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "ruview-spatial-memory"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Tenant-scoped encrypted RuVector spatial memory for Cognitum Spaces"
|
||||
|
||||
[dependencies]
|
||||
chacha20poly1305 = "0.10"
|
||||
getrandom.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
wifi-densepose-ruvector = { path = "../wifi-densepose-ruvector" }
|
||||
zeroize = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
1001
v2/crates/ruview-spatial-memory/src/lib.rs
Normal file
1001
v2/crates/ruview-spatial-memory/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,36 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
use clap::{Args, ValueEnum};
|
||||
use ruview_auth::{login, scope};
|
||||
use ruview_cognitum_spaces::{Client, Credential};
|
||||
use ruview_cognitum_spaces::{Client, Credential, PageRequest, SpatialKind};
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum SpatialResourceKind {
|
||||
Sites,
|
||||
Buildings,
|
||||
Floors,
|
||||
Spaces,
|
||||
Zones,
|
||||
Entities,
|
||||
Events,
|
||||
Alerts,
|
||||
}
|
||||
|
||||
impl From<SpatialResourceKind> for SpatialKind {
|
||||
fn from(value: SpatialResourceKind) -> Self {
|
||||
match value {
|
||||
SpatialResourceKind::Sites => Self::Sites,
|
||||
SpatialResourceKind::Buildings => Self::Buildings,
|
||||
SpatialResourceKind::Floors => Self::Floors,
|
||||
SpatialResourceKind::Spaces => Self::Spaces,
|
||||
SpatialResourceKind::Zones => Self::Zones,
|
||||
SpatialResourceKind::Entities => Self::Entities,
|
||||
SpatialResourceKind::Events => Self::Events,
|
||||
SpatialResourceKind::Alerts => Self::Alerts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SpacesArgs {
|
||||
@@ -20,6 +47,22 @@ pub struct SpacesArgs {
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
|
||||
/// Versioned hierarchy/event/alert collection. Omit for the legacy flat projection.
|
||||
#[arg(long, value_enum)]
|
||||
pub resource: Option<SpatialResourceKind>,
|
||||
|
||||
/// Page size for a versioned resource collection (1..=100).
|
||||
#[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u8).range(1..=100), requires = "resource")]
|
||||
pub limit: u8,
|
||||
|
||||
/// Opaque next-page cursor returned by a prior versioned read.
|
||||
#[arg(long, requires = "resource")]
|
||||
pub cursor: Option<String>,
|
||||
|
||||
/// API-key compatibility only: exact workspace UUID. OAuth derives this from its signed token.
|
||||
#[arg(long, requires = "resource")]
|
||||
pub workspace_id: Option<String>,
|
||||
|
||||
/// Emit the validated response as JSON.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
@@ -46,7 +89,47 @@ pub async fn spaces_cmd(args: SpacesArgs) -> anyhow::Result<()> {
|
||||
Credential::oauth(session.ensure_fresh().await?)?
|
||||
}
|
||||
};
|
||||
let response = Client::new(&args.base_url, credential)?.list().await?;
|
||||
let client = Client::new(&args.base_url, credential)?;
|
||||
if let Some(resource) = args.resource {
|
||||
let response = client
|
||||
.list_spatial(
|
||||
resource.into(),
|
||||
&PageRequest {
|
||||
limit: args.limit,
|
||||
cursor: args.cursor,
|
||||
workspace_id: args.workspace_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"Cognitum Spatial {}: {}",
|
||||
response.kind.as_str(),
|
||||
response.data.len()
|
||||
);
|
||||
println!(
|
||||
"Boundary: {} / {}",
|
||||
response.boundary.authoritative_state, response.boundary.cloud_role
|
||||
);
|
||||
for item in response.data {
|
||||
println!(
|
||||
"{}\tkind={}\tprivacy={}\tsite={}\tspace={}",
|
||||
item.id,
|
||||
item.kind.as_str(),
|
||||
item.privacy,
|
||||
item.site_id.as_deref().unwrap_or("-"),
|
||||
item.space_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
}
|
||||
if let Some(cursor) = response.next_cursor {
|
||||
println!("Next cursor: {cursor}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let response = client.list().await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
return Ok(());
|
||||
|
||||
Reference in New Issue
Block a user