From 7927839f4fd8bbeae7c91b9a61b3d3be4b185c41 Mon Sep 17 00:00:00 2001 From: rUv Date: Tue, 18 Aug 2026 15:21:48 -0400 Subject: [PATCH] feat: add governed Cognitum Spaces activation (#1631) Add explicit spaces:read consent, a bounded read client and CLI surface, and ADR-325's privacy, memory, and action-governance contract. Closes #1630. --- ...ctivation-and-governed-spatial-exchange.md | 422 ++++++++++++++++++ v2/Cargo.lock | 14 + v2/Cargo.toml | 1 + v2/crates/ruview-auth/src/principal.rs | 4 + v2/crates/ruview-cognitum-spaces/Cargo.toml | 19 + v2/crates/ruview-cognitum-spaces/src/lib.rs | 387 ++++++++++++++++ v2/crates/wifi-densepose-cli/Cargo.toml | 2 + v2/crates/wifi-densepose-cli/src/auth.rs | 47 +- v2/crates/wifi-densepose-cli/src/lib.rs | 6 +- v2/crates/wifi-densepose-cli/src/main.rs | 3 + v2/crates/wifi-densepose-cli/src/spaces.rs | 74 +++ 11 files changed, 967 insertions(+), 12 deletions(-) create mode 100644 docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md create mode 100644 v2/crates/ruview-cognitum-spaces/Cargo.toml create mode 100644 v2/crates/ruview-cognitum-spaces/src/lib.rs create mode 100644 v2/crates/wifi-densepose-cli/src/spaces.rs diff --git a/docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md b/docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md new file mode 100644 index 00000000..df88905d --- /dev/null +++ b/docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md @@ -0,0 +1,422 @@ +# ADR-325: Cognitum Spaces activation and governed spatial exchange + +- **Status**: Accepted — read path implemented; write path remains policy-gated +- **Date**: 2026-08-17 +- **Deciders**: ruv +- **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous +- **Relates to**: ADR-271, ADR-277, ADR-304, ADR-306, ADR-312, ADR-318, ADR-319, ADR-321; Cognitum API ADR-094; Autogenous ADR-402 + +## Context + +RuView produces camera-free RF perception locally. Cognitum Spaces provides a +tenant-scoped cloud projection of physical places. Autogenous ADR-402 proposes +using that projection as a spatial-intelligence input for agent coordination. +The useful product is not another sensor dashboard: it is a governed chain from +local perception to spatial state, persistent memory, explanation, and action. + +Four product pillars define the requested integration: + +1. **Spatial state** — sites, buildings, floors, rooms/spaces, zones, entities, + semantic events, and alerts. +2. **RuView perception** — camera-free sensing is normalized locally before any + permitted P2/P3 semantic event synchronizes. +3. **Persistent memory** — RuVector grounds anomaly explanations in + tenant-scoped spatial history. +4. **Governed action** — agents observe or recommend by default; consequential + execution requires explicit policy authorization. + +The live API audit on 2026-08-17 established the current production boundary: + +- `GET https://api.cognitum.one/v1/spaces` exists and returns a bounded list; +- an unauthenticated request is rejected; +- the current account has no paired sites, so the authenticated result is an + empty list rather than fabricated sample state; +- the projection declares HomeCore Edge authoritative and excludes raw CSI, + CIR, RF tensors, recordings, pose frames, vital waveforms, and identity + observations; +- the first deployed Function revision accepted only legacy `cog_` API keys; +- the gateway was configured to authenticate private Function hops, but the + direct Function endpoint was still publicly invokable; that bypass has now + been closed and the exact gateway runtime service account is the only + invoker; +- OAuth protected-resource metadata and a RuView-scoped OAuth accept path were + absent. + +The Autogenous review at commit +`f7fa308b261bac89a8909edae8a3fdbbfb8ce66c` found additional integration risks: + +- its Spaces client only listed spaces; no governed ingest contract existed; +- it trusted a loose TypeScript cast, with no response-size, timeout, redirect, + or strict semantic-boundary validation; +- its observation conversion dropped tenant/message/sequence identity; +- missing confidence became zero but could still enter fusion; +- provenance could be substituted for calibration identity; +- a Spaces-derived belief could be converted back into an observation and + counted as independent corroboration, laundering one source into two; +- its API-key exchange returns a `cognitum-cli` OAuth token, but the live Spaces + endpoint accepted only a `cog_` key. Calling this “OAuth Spaces access” was a + contract mismatch. + +## Decision + +Adopt a one-way-by-default, typed spatial exchange with separate activation, +data, memory, and action authorities. + +```text +RuView RF capture (P0/P1, local) + -> calibrated/OOD-gated semantic observation + -> ontology + evidence + witness envelope (P2/P3) + -> HomeCore authoritative edge state + -> Cognitum Spaces tenant/workspace projection + -> RuView bounded read client / Autogenous spatial context + -> RuVector tenant-scoped memory and explanation + -> recommendation + -> ruvview-policy authorization + approval + receipt + -> optional consequential action +``` + +Cloud state is a projection of edge state, not a second sensor and not an +independent corroborating modality. + +### 1. Activation and data-plane credentials are distinct + +RuView uses Cognitum's existing Authorization Code + PKCE flow with the public +`ruview` client. A user explicitly requests `spaces:read` with +`wifi-densepose login --spaces`. The authorization-server registration is a +ceiling; ordinary sensing login does not silently gain cloud access. + +The Spaces resource server accepts either: + +- a legacy API key carrying `spaces:read` (or the migration-compatible + predecessor `devices:manage`); or +- a Cognitum OAuth access token that passes every condition below. + +OAuth acceptance is conjunctive: + +| Check | Required value | +|---|---| +| Signature | ES256 against `https://auth.cognitum.one/.well-known/jwks.json` | +| Issuer | exact `https://auth.cognitum.one` | +| Audience | exact `ruview` | +| Client claim | exact `ruview` | +| Token type | ordinary `access`; setup/workload tokens denied | +| Lifetime | current `exp`/`nbf`, five-second clock tolerance only | +| Scope | exact token `spaces:read` member | +| Tenant binding | valid non-empty UUID `org_id` and `workspace_id` | + +An API key is not called OAuth. An OAuth token is not stored in +`COGNITUM_SPACES_API`. The compatibility environment variable contains an API +key only and is never printed, logged, or committed. + +OAuth consent grants identity-bound read access. It does **not** grant device +pairing, data publication, deployment, billing, spending, leases, learning +promotion, automation installation, commands, or actuator authority. + +### 2. The gateway owns the private credential relay + +The public gateway strips inbound `X-Cognitum-User-Authorization` and +`X-Serverless-Authorization`. For a locked Function upstream it then: + +1. retains a legacy `cog_` credential in `X-API-Key`, or, for the exact Spaces + route only, retains a non-key bearer in a gateway-owned internal header; +2. replaces `Authorization` with the gateway's Google invoker ID token; +3. fails closed with `503` if it cannot mint that hop identity; +4. forwards only to the configured Function origin. + +The Function's Cloud Run invoker check is enabled. `allUsers` has no invoker +binding; only the exact `apigateway-sa` service account may invoke it. This is +required because otherwise a caller could bypass Cloud Armor and spoof an +internal relay header. + +The API publishes RFC 9728 protected-resource metadata naming the authorization +server and `spaces:read` scope. Discovery describes capability; it does not +grant it. + +### 3. Tenant isolation is part of authentication + +Legacy API-key documents are queried by their existing owner-bound `tenantId`. +OAuth requests are conjunctively queried by both signed `org_id` and +`workspace_id` using stored `tenantId` and `workspaceId` fields. The public +tenant identifier is projected from signed `org_id`. A request cannot supply +either selector in a query string. + +No cross-tenant aggregation exists on this path. Pagination, search, memory, +and event endpoints added later must carry the same authoritative principal; +client-provided tenant filters may only narrow within it, never replace it. + +### 4. Spatial model and ownership + +The canonical RuView vocabulary remains ADR-306: + +```text +Site -> Building -> Floor -> Space -> Zone + -> Sensor / Person / Object / Track + -> Observation -> Event -> Alert +``` + +Cognitum may call a bounded room a “space”; RuView does not create a second +room type. Stable external IDs are namespaced and validated before entering the +ontology. HomeCore remains authoritative for local registry state and local +automation. Cognitum owns tenant/workspace projection and activation. RuVector +owns indexed spatial history, not tenancy or authorization. + +The current live endpoint exposes the first `Space` slice only. Sites, floors, +zones, entities, events, and alerts are contract milestones, not inferred from +missing fields. A client must represent absence as unknown/unavailable and must +not fabricate parents, coordinates, people, alerts, or provenance. + +### 5. Privacy boundary and synchronization eligibility + +Only allow-listed P2/P3 semantic projections may cross the cloud boundary. + +| Class | Examples | Cloud default | +|---|---|---| +| P0 | raw CSI, CIR, RF tensors, packet captures | prohibited | +| P1 | pose frames, vital waveforms, identity observations, recordings | prohibited | +| P2 | occupancy count, bounded activity/fall possibility, anomaly score | permitted when policy allows | +| P3 | versions, connection health, signed capability metadata | permitted | + +The client independently rejects forbidden raw-field names anywhere in the +response. This is defense in depth, not a substitute for server-side +projection. It also enforces HTTPS except for loopback tests, refuses redirects, +uses bounded connect/total timeouts, caps responses at 1 MiB, caps the list at +100 spaces, bounds nesting/arrays/strings, validates confidence, and rejects +non-P2/P3 space records. + +Cloud-bound envelopes must preserve, when available: + +- tenant/workspace/site/space/device identity; +- `messageId` and monotonic `eventSequence`; +- `observedAt`, `expiresAt`, freshness, and connection state; +- privacy class and semantic schema version; +- calibrated confidence and explicit uncertainty/abstention; +- model, HomeCore, hardware-manifest, calibration, evidence, and witness + provenance. + +Provenance is never used as a calibration identifier. Missing confidence, +calibration, timestamp, or tenant identity stays missing and cannot satisfy an +admission rule. + +### 6. No feedback laundering or false corroboration + +A Spaces record derived from RuView evidence carries derivation lineage. If it +returns to RuView or Autogenous, it is a **projection/recollection** of that +lineage, not a new observation. It cannot: + +- increment corroborating-sensor count; +- raise evidence level; +- be fused as an independent modality; +- reset freshness to retrieval time; +- erase abstention, contradiction, or uncertainty; +- generate a second belief that cites the first as support. + +Deduplication keys include tenant, source/witness identity, message ID, and +sequence. Cycles are detected and rejected. Independent corroboration requires +a distinct authenticated source and evidence chain. + +### 7. Persistent memory is tenant-scoped and explanation-oriented + +RuVector indexes accepted semantic state under at least: + +```text +(tenant_id, workspace_id, site_id, space_id, schema_version, time_bucket) +``` + +It stores bounded semantic features, uncertainty, evidence references, and +witness digests. It does not store OAuth/API credentials or prohibited raw +payloads. Retrieval always applies the authenticated tenant/workspace filter +before similarity ranking. + +An anomaly explanation names: + +- the current semantic state and its uncertainty; +- the relevant learned baseline/window from ADR-312; +- comparable tenant-local history; +- the measured deviation and contradictory evidence; +- the provenance/witness chain; +- the evidence label (`MEASURED`, `SYNTHETIC`, or `CLAIMED`). + +Memory supplies context, not permission. A historically common action is not +automatically authorized. + +### 8. Agents observe and recommend; policy authorizes action + +Autogenous and other agents receive read-only spatial context by default. Their +normal outputs are observations, explanations, proposals, and recommendations. + +Any consequential action must cross the ADR-321 `ruview-policy` gate with: + +- an exact action class and target; +- a fresh capability certificate; +- KNOWN/DEGRADED/UNKNOWN domain state; +- bounded uncertainty and sufficient evidence; +- tenant/workspace authorization; +- expiry, nonce, idempotency key, and replay protection; +- required human/policy approval; +- a terminal witness receipt for allow or deny. + +Missing policy, unknown action class, stale state, incomplete provenance, or an +unavailable approval service denies. OAuth `spaces:read` can never authorize an +action. This ADR adds no actuator method to the Spaces client. + +## Implementation + +### RuView + +- `ruview-cognitum-spaces` is a reusable, read-only client with typed/redacted + credentials and a bounded response decoder. +- `wifi-densepose login --spaces` explicitly requests `spaces:read` through the + existing PKCE flow and credential store. +- `wifi-densepose spaces` refreshes OAuth through the existing single-flight, + persist-before-return mechanism, verifies that the stored grant contains + `spaces:read`, and lists validated state. `COGNITUM_SPACES_API` remains an + explicit compatibility path. + +### Cognitum Identity + +- the `ruview` public client allow-list includes `spaces:read`; +- RFC 8414 metadata advertises it; +- refresh preserves the originally granted scope; +- no new client secret or password grant is introduced. + +### Cognitum API + +- the gateway preserves caller OAuth through an internal, spoof-resistant + relay while authenticating the private Function hop; +- Spaces verifies the signed OAuth principal and queries by tenant + workspace; +- legacy API-key behavior remains available; +- OpenAPI documents both alternatives and RFC 9728 metadata supports discovery; +- the Function remains gateway-only at Cloud Run IAM. + +### Autogenous + +Autogenous must consume an explicitly typed credential. It must not imply that +`/v1/cli/session/exchange` produces a RuView-audience token: that exchange +currently produces `client_id=cognitum-cli` and cannot pass the Spaces policy. +An external RuView PKCE token may be supplied after activation, or a scoped API +key may be used as the compatibility path. Response validation and lineage +rules in this ADR apply before agent belief formation. + +## Threat model + +| Threat | Required control | +|---|---| +| Direct Function bypass | invoker IAM check; gateway SA only; no `allUsers` | +| Forged internal OAuth header | strip inbound relay headers; gateway writes after route classification | +| Token substitution | ES256/JWKS plus exact issuer, audience, client, type, scope, and tenant claims | +| Cross-tenant enumeration | principal-derived Firestore selector; bounded non-enumerating errors | +| Redirect/token exfiltration | redirects disabled; HTTPS required; fixed path | +| Oversized/malformed response | byte/depth/count/string bounds before use | +| Raw-data regression | server allow-list plus client forbidden-field rejection | +| Secret disclosure | redacting types; no token logs/URLs; `.env` untracked | +| Feedback amplification | lineage preservation, dedupe, cycle rejection, no independent corroboration | +| Memory leakage | tenant filter before vector search; no global nearest-neighbor pass | +| Agent overreach | observe/recommend default; ADR-321 fail-closed action gate | +| Stale/replayed state | expiry, sequence, message ID, freshness, witness receipt | +| JWKS outage/rotation | bounded cache; fail closed; refresh after unknown `kid`; no algorithm fallback | + +## Deployment and rollback + +Rollout order is dependency-safe: + +1. merge and deploy Identity scope/metadata; +2. deploy the Spaces Function with OAuth verification while API-key behavior + remains unchanged; +3. deploy the gateway relay and protected-resource metadata; +4. verify gateway API-key access, OAuth denial matrices, direct-URL `403`, and + tenant isolation; +5. merge/release the RuView client and CLI activation; +6. enable Autogenous consumption only after its strict validation/lineage gates + pass. + +Rollback disables OAuth advertisement/relay and returns clients to scoped API +keys. It must not restore public Function invocation. Revoking an OAuth session +or API key must not alter paired-site state. + +## Validation and acceptance + +Required automated gates: + +- Identity: metadata test, migration application, PKCE authorize/token/refresh + scope preservation, cross-client scope denial; +- API Function: valid claim matrix and rejection for wrong issuer/audience/ + client/type/scope/tenant, API-key regression, tenant query assertion, bounded + projection tests, build and dependency audit; +- gateway: spoofed relay stripped, caller OAuth preserved, Google hop identity + substituted, OpenAPI security alternatives, RFC 9728 metadata, build and + dependency audit; +- RuView: semantic decoder bounds/privacy tests, redaction tests, login scope + tests, CLI compile, and live empty/non-empty response tests without fixtures + masquerading as production; +- policy: no Spaces read can invoke an actuator; denial receipts are witnessed. + +Production readback must prove: + +- unauthenticated gateway request returns `401`; +- legacy scoped API key returns the authenticated tenant list; +- valid RuView OAuth returns only its workspace; +- wrong client, missing `spaces:read`, setup/workload token, and second-tenant + token are denied; +- direct Function URL returns `403` even with a valid application credential; +- response remains `no-store` and excludes P0/P1; +- no secret appears in logs, diffs, artifacts, or issue/PR text. + +Performance, detection quality, and action-safety numbers are not claimed by +this decision. Any such number requires a named reproducer and the repository's +evidence labels. An empty production tenant is a successful isolation/read-path +test, not sensing-quality evidence. + +## Consequences + +### Positive + +- One Cognitum identity can explicitly activate RuView's cloud spatial read + capability without sharing a long-lived static bearer. +- Tenant and workspace become cryptographically bound inputs to the data query. +- RuView and Autogenous gain useful spatial context without importing raw RF or + inventing independent evidence. +- RuVector can ground explanations in local/tenant history while action remains + separately governed. +- The direct-origin bypass is closed permanently, independent of OAuth rollout. + +### Costs and limitations + +- Two credential types coexist during migration and must stay visibly distinct. +- OAuth depends on Identity JWKS availability and correct key rotation. +- The current API exposes spaces only; the full hierarchy/events/alerts model + remains staged work. +- OAuth workspace IDs will return only documents populated with `workspaceId`; + legacy owner-only documents require an explicit migration, never a broad query. +- No write, command, or agent execution surface is implemented by this ADR. + +## Alternatives considered + +**Keep API keys only.** Rejected as the target: keys are useful for service +compatibility but do not provide user activation, consent, short lifetime, or +refresh/revocation semantics. + +**Treat the CLI API-key exchange token as a Spaces OAuth token.** Rejected: it +is minted for `cognitum-cli`, not `ruview`, and accepting it would remove the +audience/client boundary. + +**Trust the gateway without verifying OAuth in Spaces.** Rejected: hop identity +and user authorization are distinct, and authorization must remain valid if the +route topology changes. + +**Make Spaces state independent corroboration.** Rejected: it is derived from +the same RuView/HomeCore lineage and would double-count evidence. + +**Allow agents to execute from `spaces:read`.** Rejected: read consent is not +action authority, and perception confidence alone cannot authorize consequence. + +**Synchronize raw RF for better cloud models.** Rejected by default: it violates +the edge privacy boundary and is unnecessary for the semantic product. + +## References + +- Autogenous ADR-402, `docs/adr/ADR-402-ruview-cognitum-spaces-spatial-intelligence.md` +- Cognitum API ADR-094, `docs/adr/ADR-094-cognitum-spaces-homecore-edge-boundary.md` +- RFC 7636, Proof Key for Code Exchange +- RFC 8414, OAuth 2.0 Authorization Server Metadata +- RFC 9700, OAuth 2.0 Security Best Current Practice +- RFC 9728, OAuth 2.0 Protected Resource Metadata diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 94421948..33a39c3a 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -9694,6 +9694,18 @@ dependencies = [ "wifi-densepose-calibration", ] +[[package]] +name = "ruview-cognitum-spaces" +version = "0.3.1" +dependencies = [ + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "url", +] + [[package]] name = "ruview-counterfactual" version = "0.3.1" @@ -13734,6 +13746,7 @@ dependencies = [ "predicates", "reqwest 0.12.28", "ruview-auth", + "ruview-cognitum-spaces", "serde", "serde_json", "tabled", @@ -13744,6 +13757,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "uuid", "wifi-densepose-calibration", "wifi-densepose-core", diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 35cc6237..ac825316 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -37,6 +37,7 @@ members = [ # their Cognitum identity instead of a shared static bearer. No login flow # and no outbound Cognitum API calls live here — verification only. "crates/ruview-auth", + "crates/ruview-cognitum-spaces", # ADR-325 Cognitum Spaces read client "crates/nvsim", "crates/nvsim-server", "crates/homecore", # ADR-127 — HOMECORE state machine diff --git a/v2/crates/ruview-auth/src/principal.rs b/v2/crates/ruview-auth/src/principal.rs index 32d99bad..298b369b 100644 --- a/v2/crates/ruview-auth/src/principal.rs +++ b/v2/crates/ruview-auth/src/principal.rs @@ -19,6 +19,10 @@ pub mod scope { /// Irreversible: a deleted model or labelled capture may represent days of /// collection, and a training run burns hours of CPU on a Pi. pub const SENSING_ADMIN: &str = "sensing:admin"; + + /// Read tenant-scoped P2/P3 semantic state from Cognitum Spaces. + /// This grants no raw sensing access and no action authority. + pub const SPACES_READ: &str = "spaces:read"; } /// A verified caller. Constructed only by diff --git a/v2/crates/ruview-cognitum-spaces/Cargo.toml b/v2/crates/ruview-cognitum-spaces/Cargo.toml new file mode 100644 index 00000000..953e5a05 --- /dev/null +++ b/v2/crates/ruview-cognitum-spaces/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ruview-cognitum-spaces" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Bounded, privacy-preserving Cognitum Spaces client for RuView" +publish = false + +[dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +url = "2" + +[dev-dependencies] +tokio.workspace = true diff --git a/v2/crates/ruview-cognitum-spaces/src/lib.rs b/v2/crates/ruview-cognitum-spaces/src/lib.rs new file mode 100644 index 00000000..7a8fdc40 --- /dev/null +++ b/v2/crates/ruview-cognitum-spaces/src/lib.rs @@ -0,0 +1,387 @@ +//! Cognitum Spaces read client (ADR-325). +//! +//! This crate consumes tenant-scoped semantic P2/P3 state only. It never +//! uploads raw CSI/CIR, RF tensors, pose frames, vital waveforms, recordings, +//! or identity observations, and it exposes no action method. + +use std::time::Duration; + +use reqwest::redirect::Policy; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use url::Url; + +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const MAX_SPACES: usize = 100; +const MAX_JSON_DEPTH: usize = 16; +const MAX_STRING_BYTES: usize = 4096; +const REQUIRED_EXCLUSIONS: [&str; 7] = [ + "raw_csi", + "cir", + "rf_tensors", + "recordings", + "pose_frames", + "vital_waveforms", + "identity_observations", +]; + +#[derive(Clone)] +pub enum Credential { + OAuth(String), + ApiKey(String), +} + +impl std::fmt::Debug for Credential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::OAuth(_) => f.write_str("OAuth()"), + Self::ApiKey(_) => f.write_str("ApiKey()"), + } + } +} + +impl Credential { + pub fn oauth(token: impl Into) -> Result { + secret(Self::OAuth, token.into()) + } + + pub fn api_key(key: impl Into) -> Result { + let key = key.into(); + if !key.starts_with("cog_") || key.len() == 4 { + return Err(Error::InvalidCredential); + } + secret(Self::ApiKey, key) + } +} + +fn secret(make: impl FnOnce(String) -> Credential, value: String) -> Result { + if value.is_empty() + || value.len() > 16_384 + || value.chars().any(char::is_whitespace) + || value.chars().any(char::is_control) + { + return Err(Error::InvalidCredential); + } + Ok(make(value)) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Spaces base URL must be HTTPS (HTTP is allowed only on loopback)")] + InsecureUrl, + #[error("invalid Spaces base URL")] + InvalidUrl, + #[error("invalid or empty credential")] + InvalidCredential, + #[error("Spaces request failed: {0}")] + Transport(#[from] reqwest::Error), + #[error("Spaces rejected the credential ({0})")] + Authentication(u16), + #[error("Spaces returned HTTP {0}")] + Http(u16), + #[error("Spaces response is too large")] + ResponseTooLarge, + #[error("Spaces response is not JSON")] + ContentType, + #[error("Spaces response violates the semantic boundary: {0}")] + InvalidResponse(String), +} + +#[derive(Clone, Debug)] +pub struct Client { + endpoint: Url, + credential: Credential, + http: reqwest::Client, +} + +impl Client { + pub fn new(base_url: &str, credential: Credential) -> Result { + let base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?; + let loopback = base + .host_str() + .is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "::1"); + if base.scheme() != "https" && !(base.scheme() == "http" && loopback) { + return Err(Error::InsecureUrl); + } + if !base.username().is_empty() + || base.password().is_some() + || base.query().is_some() + || base.fragment().is_some() + { + return Err(Error::InvalidUrl); + } + let endpoint = base.join("/v1/spaces").map_err(|_| Error::InvalidUrl)?; + let http = reqwest::Client::builder() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .user_agent(concat!( + "ruview-cognitum-spaces/", + env!("CARGO_PKG_VERSION") + )) + .build()?; + Ok(Self { + endpoint, + credential, + http, + }) + } + + pub async fn list(&self) -> Result { + let mut request = self + .http + .get(self.endpoint.clone()) + .header("Accept", "application/json"); + request = match &self.credential { + Credential::OAuth(token) => request.bearer_auth(token), + Credential::ApiKey(key) => request.header("X-API-Key", key), + }; + let mut response = request.send().await?; + let status = response.status(); + if status.as_u16() == 401 || status.as_u16() == 403 { + return Err(Error::Authentication(status.as_u16())); + } + if !status.is_success() { + return Err(Error::Http(status.as_u16())); + } + let is_json = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| { + v.split(';') + .next() + .is_some_and(|m| m.trim().eq_ignore_ascii_case("application/json")) + }); + if !is_json { + return Err(Error::ContentType); + } + if response + .content_length() + .is_some_and(|n| n > MAX_RESPONSE_BYTES as u64) + { + return Err(Error::ResponseTooLarge); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(Error::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + decode(&body) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpacesResponse { + pub object: String, + pub data: Vec, + pub boundary: DataBoundary, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Space { + pub id: String, + pub tenant_id: String, + pub workspace_id: Option, + pub site_id: String, + pub name: String, + pub version: u64, + pub privacy: String, + pub status: String, + pub connection: String, + pub state: SemanticState, + pub provenance: Value, + pub hardware: Value, + pub data_boundary: Value, + #[serde(default)] + pub observed_at: Option, + #[serde(default)] + pub expires_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SemanticState { + pub occupancy: Option, + pub confidence: Option, + pub observed_at: Option, + pub freshness_ms: Option, + pub classification: String, + pub uncertainty: Value, + pub evidence: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataBoundary { + pub authoritative_state: String, + pub cloud_role: String, + pub excluded: Vec, +} + +pub fn decode(bytes: &[u8]) -> Result { + 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: SpacesResponse = serde_json::from_value(value) + .map_err(|e| Error::InvalidResponse(format!("schema mismatch: {e}")))?; + if response.object != "list" || response.data.len() > MAX_SPACES { + return Err(Error::InvalidResponse("invalid 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(), + )); + } + for space in &response.data { + if space.id.is_empty() + || space.tenant_id.is_empty() + || space.site_id.is_empty() + || space.name.is_empty() + { + return Err(Error::InvalidResponse( + "space identity is incomplete".into(), + )); + } + if !matches!(space.privacy.as_str(), "P2" | "P3") || space.state.classification != "P2" { + return Err(Error::InvalidResponse("non-semantic privacy class".into())); + } + if space + .state + .confidence + .is_some_and(|v| !v.is_finite() || !(0.0..=1.0).contains(&v)) + { + return Err(Error::InvalidResponse("invalid confidence".into())); + } + } + Ok(response) +} + +fn validate_value(value: &Value, depth: usize) -> Result<(), Error> { + if depth > MAX_JSON_DEPTH { + return Err(Error::InvalidResponse("JSON nesting is too deep".into())); + } + match value { + Value::String(s) if s.len() > MAX_STRING_BYTES => { + return Err(Error::InvalidResponse("string exceeds bound".into())); + } + Value::Array(items) if items.len() > 1000 => { + return Err(Error::InvalidResponse("array exceeds bound".into())); + } + Value::Array(items) => { + for item in items { + validate_value(item, depth + 1)?; + } + } + Value::Object(map) => { + if map.len() > 128 { + return Err(Error::InvalidResponse("object exceeds bound".into())); + } + for (key, item) in map { + if key.len() > MAX_STRING_BYTES { + return Err(Error::InvalidResponse("object key exceeds bound".into())); + } + let normalized: String = key + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + if matches!( + normalized.as_str(), + "rawcsi" + | "cir" + | "rftensors" + | "recordings" + | "poseframes" + | "vitalwaveforms" + | "identityobservations" + ) { + return Err(Error::InvalidResponse(format!( + "forbidden raw field: {key}" + ))); + } + validate_value(item, depth + 1)?; + } + } + _ => {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid() -> Vec { + 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() + } + + #[test] + fn accepts_bounded_semantic_state() { + assert_eq!(decode(&valid()).unwrap().data.len(), 1); + } + + #[test] + fn rejects_raw_fields_anywhere() { + let mut value: Value = serde_json::from_slice(&valid()).unwrap(); + value["data"][0]["state"]["raw_csi"] = Value::String("secret".into()); + assert!(matches!( + decode(&serde_json::to_vec(&value).unwrap()), + Err(Error::InvalidResponse(_)) + )); + } + + #[test] + fn rejects_missing_confidence_as_a_number_outside_bounds() { + let mut value: Value = serde_json::from_slice(&valid()).unwrap(); + value["data"][0]["state"]["confidence"] = Value::from(2.0); + assert!(matches!( + decode(&serde_json::to_vec(&value).unwrap()), + Err(Error::InvalidResponse(_)) + )); + } + + #[test] + fn rejects_incomplete_privacy_boundary() { + let mut value: Value = serde_json::from_slice(&valid()).unwrap(); + value["boundary"]["excluded"] = serde_json::json!(["raw_csi"]); + assert!(matches!( + decode(&serde_json::to_vec(&value).unwrap()), + Err(Error::InvalidResponse(_)) + )); + } + + #[test] + fn rejects_credential_bearing_urls_and_whitespace_secrets() { + let credential = Credential::oauth("token").unwrap(); + assert!(matches!( + Client::new("https://user:pass@api.cognitum.one", credential), + Err(Error::InvalidUrl) + )); + assert!(matches!( + Credential::oauth("token with spaces"), + Err(Error::InvalidCredential) + )); + } + + #[test] + fn credentials_are_redacted() { + let c = Credential::oauth("secret-token").unwrap(); + assert!(!format!("{c:?}").contains("secret-token")); + } +} diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index d28d1e50..96bd92c9 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -62,9 +62,11 @@ anyhow = "1.0" # the sensing server depends on this same crate with default features and # gets only the verifier. ruview-auth = { path = "../ruview-auth", features = ["login"] } +ruview-cognitum-spaces = { path = "../ruview-cognitum-spaces" } # Only for constructing the HTTP client hands to Session. reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } thiserror = "2.0" +url = "2" # Time chrono = { version = "0.4", features = ["serde"] } diff --git a/v2/crates/wifi-densepose-cli/src/auth.rs b/v2/crates/wifi-densepose-cli/src/auth.rs index eb97b4b8..95316295 100644 --- a/v2/crates/wifi-densepose-cli/src/auth.rs +++ b/v2/crates/wifi-densepose-cli/src/auth.rs @@ -24,6 +24,10 @@ pub struct LoginArgs { #[arg(long)] pub admin: bool, + /// Also activate read-only Cognitum Spaces access (`spaces:read`). + #[arg(long)] + pub spaces: bool, + /// Skip the browser and use the paste-a-code flow. /// /// Detected automatically over SSH and inside containers; this forces it. @@ -66,18 +70,21 @@ fn path_or_default(p: Option) -> PathBuf { /// least-privilege test in the library, but this command does NOT go through /// that default — it builds the scope string itself, so the library test says /// nothing about what the CLI actually requests. -fn requested_scope(admin: bool) -> String { +fn requested_scope(admin: bool, spaces: bool) -> String { + let mut scopes = vec![scope::SENSING_READ]; if admin { // Admin implies read: there is no scope hierarchy server-side, so a // session that needs both must consent to both explicitly. - format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN) - } else { - scope::SENSING_READ.to_string() + scopes.push(scope::SENSING_ADMIN); } + if spaces { + scopes.push(scope::SPACES_READ); + } + scopes.join(" ") } pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> { - let scope = requested_scope(args.admin); + let scope = requested_scope(args.admin, args.spaces); let opts = LoginOptions { credentials_path: path_or_default(args.credentials_path), @@ -102,7 +109,9 @@ pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> { } // Deliberately local-only. This makes the machine unable to act as you; // revoking the session for every device is an account-level action. - println!("Note: this forgets the local credential only. It does not revoke the session server-side."); + println!( + "Note: this forgets the local credential only. It does not revoke the session server-side." + ); Ok(()) } @@ -157,9 +166,12 @@ mod tests { // poses must not carry the capability to delete recordings. If this // ever returns admin by default, every session silently becomes // destructive-capable and nothing else in the suite would notice. - let s = requested_scope(false); + let s = requested_scope(false, false); assert_eq!(s, scope::SENSING_READ); - assert!(!s.contains(scope::SENSING_ADMIN), "read-only login leaked admin: {s}"); + assert!( + !s.contains(scope::SENSING_ADMIN), + "read-only login leaked admin: {s}" + ); } #[test] @@ -167,9 +179,22 @@ mod tests { // The authorization server grants exactly what is requested; admin does // not imply read. Asking for admin alone would produce a session that // cannot stream. - let s = requested_scope(true); - assert!(s.split_whitespace().any(|x| x == scope::SENSING_READ), "{s}"); - assert!(s.split_whitespace().any(|x| x == scope::SENSING_ADMIN), "{s}"); + let s = requested_scope(true, false); + assert!( + s.split_whitespace().any(|x| x == scope::SENSING_READ), + "{s}" + ); + assert!( + s.split_whitespace().any(|x| x == scope::SENSING_ADMIN), + "{s}" + ); + } + + #[test] + fn spaces_activation_is_explicit_and_read_only() { + let s = requested_scope(false, true); + assert!(s.split_whitespace().any(|x| x == scope::SPACES_READ)); + assert!(!s.split_whitespace().any(|x| x == scope::SENSING_ADMIN)); } #[test] diff --git a/v2/crates/wifi-densepose-cli/src/lib.rs b/v2/crates/wifi-densepose-cli/src/lib.rs index 79366f04..b813eaaf 100644 --- a/v2/crates/wifi-densepose-cli/src/lib.rs +++ b/v2/crates/wifi-densepose-cli/src/lib.rs @@ -29,9 +29,10 @@ use clap::{Parser, Subcommand}; pub mod auth; pub mod calibrate; pub mod calibrate_api; -pub mod room; #[cfg(feature = "mat")] pub mod mat; +pub mod room; +pub mod spaces; /// WiFi-DensePose Command Line Interface #[derive(Parser, Debug)] @@ -61,6 +62,9 @@ pub enum Commands { /// Show the stored Cognitum session: account, scope, and whether it is live. Whoami(auth::WhoamiArgs), + /// Read tenant-scoped semantic state from Cognitum Spaces (ADR-325). + Spaces(spaces::SpacesArgs), + /// Empty-room baseline calibration (ADR-135). /// Captures CSI frames via UDP and saves a per-subcarrier statistical /// baseline used for real-time motion z-scoring and CIR reference. diff --git a/v2/crates/wifi-densepose-cli/src/main.rs b/v2/crates/wifi-densepose-cli/src/main.rs index ad468586..5ae84632 100644 --- a/v2/crates/wifi-densepose-cli/src/main.rs +++ b/v2/crates/wifi-densepose-cli/src/main.rs @@ -27,6 +27,9 @@ async fn main() -> anyhow::Result<()> { Commands::Whoami(args) => { wifi_densepose_cli::auth::whoami_cmd(args).await?; } + Commands::Spaces(args) => { + wifi_densepose_cli::spaces::spaces_cmd(args).await?; + } Commands::Calibrate(args) => { wifi_densepose_cli::calibrate::execute(args).await?; } diff --git a/v2/crates/wifi-densepose-cli/src/spaces.rs b/v2/crates/wifi-densepose-cli/src/spaces.rs new file mode 100644 index 00000000..cf6bd0cd --- /dev/null +++ b/v2/crates/wifi-densepose-cli/src/spaces.rs @@ -0,0 +1,74 @@ +//! `wifi-densepose spaces` — Cognitum Spaces activation and read access. + +use std::path::PathBuf; + +use clap::Args; +use ruview_auth::{login, scope}; +use ruview_cognitum_spaces::{Client, Credential}; + +#[derive(Debug, Args)] +pub struct SpacesArgs { + /// Cognitum Spaces API origin. + #[arg(long, default_value = "https://api.cognitum.one")] + pub base_url: String, + + /// Compatibility API key. If omitted, use the stored OAuth session. + #[arg(long, env = "COGNITUM_SPACES_API", hide_env_values = true)] + pub api_key: Option, + + /// OAuth credential file used when no API key is supplied. + #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] + pub credentials_path: Option, + + /// Emit the validated response as JSON. + #[arg(long)] + pub json: bool, +} + +pub async fn spaces_cmd(args: SpacesArgs) -> anyhow::Result<()> { + let credential = match args.api_key { + Some(key) => Credential::api_key(key)?, + None => { + let path = args + .credentials_path + .unwrap_or_else(login::default_credentials_path); + let session = login::Session::load_from(path, reqwest::Client::new())?; + let snapshot = session.snapshot().await; + let granted = snapshot.effective_scope().unwrap_or_default(); + if !granted + .split_whitespace() + .any(|item| item == scope::SPACES_READ) + { + anyhow::bail!( + "stored OAuth session lacks spaces:read; run `wifi-densepose login --spaces`" + ); + } + Credential::oauth(session.ensure_fresh().await?)? + } + }; + let response = Client::new(&args.base_url, credential)?.list().await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&response)?); + return Ok(()); + } + println!("Cognitum Spaces: {}", response.data.len()); + println!( + "Boundary: {} / {}", + response.boundary.authoritative_state, response.boundary.cloud_role + ); + for space in response.data { + let occupancy = space + .state + .occupancy + .map_or_else(|| "unknown".into(), |v| v.to_string()); + let confidence = space + .state + .confidence + .map_or_else(|| "unknown".into(), |v| format!("{v:.3}")); + println!( + "{}\t{}\toccupancy={}\tconfidence={}\t{}", + space.id, space.name, occupancy, confidence, space.status + ); + } + Ok(()) +}