From c929bbc8b36d75cbfcfa8b447190e2c9a9a79d79 Mon Sep 17 00:00:00 2001 From: rUv Date: Wed, 19 Aug 2026 13:23:29 -0400 Subject: [PATCH] feat(spaces): add spatial memory and governed actions (#1650) --- AGENTS.md | 16 +- ...ctivation-and-governed-spatial-exchange.md | 47 +- ...6-tenant-scoped-ruvector-spatial-memory.md | 137 +++ ...ned-action-intents-and-witness-receipts.md | 133 +++ docs/user-guide.md | 52 + .../.claude/skills/cognitum-spaces/SKILL.md | 13 +- harness/ruview/.harness/manifest.json | 22 +- harness/ruview/.harness/manifest.sha256 | 2 +- harness/ruview/CLAUDE.md | 6 +- harness/ruview/README.md | 15 +- harness/ruview/bin/cli.js | 3 +- harness/ruview/package-lock.json | 4 +- harness/ruview/package.json | 2 +- harness/ruview/skills/cognitum-spaces.md | 13 +- harness/ruview/src/guidance.js | 13 +- harness/ruview/src/spaces.js | 118 +- harness/ruview/src/tools.js | 7 +- harness/ruview/test/spaces.test.mjs | 72 +- v2/Cargo.lock | 16 + v2/Cargo.toml | 1 + v2/crates/ruview-cognitum-spaces/Cargo.toml | 1 + v2/crates/ruview-cognitum-spaces/src/lib.rs | 478 +++++++- v2/crates/ruview-policy/Cargo.toml | 1 + v2/crates/ruview-policy/src/governed.rs | 995 ++++++++++++++++ v2/crates/ruview-policy/src/lib.rs | 9 +- v2/crates/ruview-spatial-memory/Cargo.toml | 20 + v2/crates/ruview-spatial-memory/src/lib.rs | 1001 +++++++++++++++++ v2/crates/wifi-densepose-cli/src/spaces.rs | 89 +- 28 files changed, 3192 insertions(+), 94 deletions(-) create mode 100644 docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md create mode 100644 docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md create mode 100644 v2/crates/ruview-policy/src/governed.rs create mode 100644 v2/crates/ruview-spatial-memory/Cargo.toml create mode 100644 v2/crates/ruview-spatial-memory/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index 5e390381..838f2c12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,18 +47,18 @@ from the current tree when needed. ## RuView contributor harness -`@ruvnet/ruview@0.4.0` is the runtime-dependency-free contributor interface +`@ruvnet/ruview@0.5.0` is the runtime-dependency-free contributor interface defined by ADR-283. ```bash -npx @ruvnet/ruview@0.4.0 doctor -npx @ruvnet/ruview@0.4.0 guidance --topic homecore --query "restore and plugins" -npx @ruvnet/ruview@0.4.0 agent run \ +npx @ruvnet/ruview@0.5.0 doctor +npx @ruvnet/ruview@0.5.0 guidance --topic homecore --query "restore and plugins" +npx @ruvnet/ruview@0.5.0 agent run \ --host codex --repo . --prompt "Find the nearest tests and cite files" -npx @ruvnet/ruview@0.4.0 brain search --query "community memory" -npx @ruvnet/ruview@0.4.0 brain verify --repo . -npx @ruvnet/ruview@0.4.0 spaces -npx @ruvnet/ruview@0.4.0 mcp start +npx @ruvnet/ruview@0.5.0 brain search --query "community memory" +npx @ruvnet/ruview@0.5.0 brain verify --repo . +npx @ruvnet/ruview@0.5.0 spaces +npx @ruvnet/ruview@0.5.0 mcp start ``` Start unfamiliar repository work with `ruview_guidance`. It returns reviewed 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 index 98aaa77a..2d34690d 100644 --- 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 @@ -1,6 +1,6 @@ # ADR-325: Cognitum Spaces activation and governed spatial exchange -- **Status**: Accepted — read path implemented; write path remains policy-gated +- **Status**: Accepted — legacy read live; versioned hierarchy, local memory, and governed-action implementation validated on feature branches; deployment pending - **Date**: 2026-08-17 - **Deciders**: ruv - **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous @@ -423,10 +423,43 @@ Identity metadata deliberately advertises `spaces:read` for RuView but not publisher surface. RuView therefore has no OAuth write, command, policy-approval, or actuator capability. -This evidence does not claim implementation of sites/buildings/floors/zones, -entities, semantic event or alert resources, tenant-scoped RuVector spatial -history, MQTT reconciliation, governed actions, commands, or actuators. Those -remain separately reviewed milestones. +This production evidence does not claim deployment of sites/buildings/floors/ +zones, entities, semantic event or alert resources, tenant-scoped RuVector +spatial history, MQTT reconciliation, governed actions, commands, or actuators. +The first three are implemented and locally validated in the 2026-08-19 feature +branches described below, but remain non-production until their workflow and +readback gates pass. Commands and actuators remain out of scope. + +## Feature-branch implementation evidence (2026-08-19) + +- Cognitum API ADR-101 implements all eight `/v1/spatial` collections, coherent + transactional hierarchy checks, stable pagination, event/alert retention, + strict P2/P3 admission, API-key-only writes, and OAuth/API-key reads. The + Firestore emulator passed both the legacy and versioned verification scripts, + including cross-resource message replay denial. +- `ruview-cognitum-spaces` adds a strictly decoded, paged, read-only client for + every versioned collection. `wifi-densepose spaces --resource ...` exposes it + while preserving the legacy flat command. The focused client suite and the + CLI no-default-features test gate pass. +- The contributor metaharness now accepts `resource`, `limit`, and opaque + `cursor` on `ruview_spaces_list`, fixes the API origin, strips API-key + compatibility authority, and revalidates hierarchy/event/alert contracts. + Full metaharness, security, brain, flywheel, manifest, audit, and pack gates + pass for the `@ruvnet/ruview` 0.5.0 release candidate. +- ADR-326 adds `ruview-spatial-memory`: one RuVector HNSW index per authenticated + tenant/workspace, replay/derivation/retention gates, cascading erasure, + bounded explanations, and XChaCha20-Poly1305 snapshots with reload-verified + key rotation. Its focused crate suite passes with `SYNTHETIC` evidence. +- ADR-327 extends `ruview-policy` with typed observe/recommend/execute intents, + exact host grants, signed approvals, nonce/idempotency defense, the existing + ADR-321 assurance matrix, and signed hash-chained receipts. `spaces:read` is + explicitly denied as execution authority. Its focused crate suite passes. +- The required whole-workspace Rust command was attempted twice on Windows: + parallel compilation ended in a compiler-process stack-buffer-overrun and a + single-job retry reached the configured timeout without a source/test + diagnostic. This is not recorded as a green gate; Linux CI remains required. +- No OAuth write/action scope, actuator callback, MQTT deployment claim, sensing + accuracy claim, or real-hardware claim is introduced. ## Consequences @@ -445,8 +478,8 @@ remain separately reviewed milestones. - 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. +- Production currently exposes the legacy spaces slice; the implemented full + hierarchy/events/alerts model remains staged until deployment/readback. - OAuth workspace IDs will return only documents populated with `workspaceId`; legacy owner-only documents require an explicit migration, never a broad query. - The RuView client exposes no write, command, or agent execution surface. The diff --git a/docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md b/docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md new file mode 100644 index 00000000..370988f0 --- /dev/null +++ b/docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md @@ -0,0 +1,137 @@ +# ADR-326: Tenant-scoped RuVector spatial memory and anomaly explanations + +- **Status**: Accepted — implementation complete; repository-wide and deployment gates pending +- **Date**: 2026-08-19 +- **Decision owners**: RuView maintainers +- **Extends**: ADR-312, ADR-319, ADR-325 +- **Implements**: ruvnet/RuView#1640 +- **Tags**: cognitum-spaces, ruvector, memory, tenant-isolation, explanation, privacy + +## Context + +ADR-325 requires anomaly explanations grounded in tenant-local spatial history, +but the deployed client only returns a current list. A global vector index would +be unsafe: filtering nearest-neighbor results after the search can reveal that a +different tenant has a close match, even when identifiers are removed. A memory +record can also launder returned RuView-derived state into a second independent +observation, reset freshness, or form circular evidence. + +Spatial memory must be useful without storing OAuth/API credentials, raw CSI/CIR, +RF tensors, pose frames, vital waveforms, recordings, identity observations, or +unbounded agent transcripts. Persistence also needs explicit retention, +deletion, provenance, and key-rotation behavior. + +## Decision + +### 1. Partition before similarity + +`ruview-spatial-memory` owns a `SpatialMemory` map keyed by the exact authenticated +`(tenant_id, workspace_id)` pair. Each partition owns its own RuVector HNSW index. +Ingest and search resolve the partition first; no global ANN query exists. Site, +space, schema version, and time-window constraints narrow within the selected +partition before results are returned. + +### 2. Bounded semantic records + +An accepted record contains: + +- tenant/workspace/site/space and stable record identity; +- source ID, message ID, record ID, monotonic event sequence, schema version; +- original `observed_at`/`expires_at` and a retention deadline; +- a bounded finite semantic feature vector, uncertainty, and evidence label; +- provenance and witness digests, plus bounded derivation references; +- explicit observation/inference classification. + +Credentials and P0/P1 fields have no representation in the type. Strings, +features, references, record counts, and query `k` are bounded. Non-finite +features and uncertainty fail closed. + +### 3. Lineage and replay + +The partition rejects: + +- changed reuse of `(source_id, message_id)`; +- a non-increasing sequence for the same source; +- duplicate derivation references; +- self-reference, missing/forward parents, and therefore every cycle; +- expired input or a provenance/witness substitution. + +A recollection keeps its original lineage, timestamp, uncertainty, and evidence +label. It cannot increment corroborating-source count or become independent +support for its own ancestor. + +### 4. Persistent encrypted storage + +Snapshots are encrypted with XChaCha20-Poly1305 under a caller-supplied 256-bit +key and a non-secret key ID. The authenticated associated data binds the storage +format and key ID. The envelope is bounded and versioned; plaintext spatial +records are never written to disk. Loading requires a keyring containing the +named key. Rotation decrypts with the old key, atomically creates a new +generation under the new key ID, reload-verifies that generation, and leaves +the source intact. Snapshots never overwrite an existing path implicitly. + +Deletion supports a tenant/workspace partition, a record, and retention cutoff. +Every deletion rebuilds that partition's HNSW index so removed records cannot be +returned from stale graph nodes. + +### 5. Explanations + +`explain` compares a bounded query vector with nearest tenant-local history and +returns the exact authenticated partition, generation time, ordered record IDs, +RuVector distances, original uncertainty/evidence labels, and provenance/witness +digests. Its basis explicitly says that similarity is not causation. The API +does not expose the vectors or invent a causal explanation. + +History provides context, not authority. An explanation cannot authorize an +action, increase certificate class, or replace a policy decision. + +## Consequences + +### Positive + +- Cross-tenant ANN leakage is structurally unavailable. +- Explanations cite the exact tenant-local records used. +- Replay/cycle/provenance substitution are rejected before indexing. +- Encrypted persistence has explicit key IDs and rotation behavior. + +### Costs and limitations + +- Partition-local HNSW uses more indexes than a global graph. +- Deletes and key rotation rebuild indexes. +- No detection-quality or latency claim is made; tests are `SYNTHETIC` unless a + reproducer explicitly marks a measurement. +- Cloud Cognitum does not receive the local encrypted memory file. + +## Validation + +- cross-tenant and cross-workspace nearest-neighbor denial; +- duplicate record/message, stale-sequence, self/duplicate/missing-parent, and + provenance-substitution tests; +- expiry, retention deletion, whole-partition deletion, sealed round-trip, + tamper rejection, wrong-key rejection, and key-rotation tests; +- explanation citations and retained evidence/provenance labels; +- no forbidden raw-field or credential representation; +- the focused `ruview-spatial-memory` crate suite passes with `SYNTHETIC` + evidence on 2026-08-19; +- the whole-workspace Windows gate was non-terminal (compiler crash in parallel, + timeout when serialized), so Linux CI, a RustSec advisory scan, and package + review remain release gates. + +## Alternatives considered + +**One global HNSW followed by filtering.** Rejected: ranking itself crosses the +tenant boundary. + +**Cloud vector memory.** Rejected as the default: it expands the privacy and +credential boundary without being needed for local explanations. + +**Plain JSONL persistence.** Rejected because tenant spatial history is sensitive +even when raw sensing is excluded. + +## References + +- ADR-312: Long-term spatial memory +- ADR-319: Witness chain +- ADR-325: Cognitum Spaces activation and governed exchange +- Cognitum API ADR-101 +- ruvnet/RuView#1640 diff --git a/docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md b/docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md new file mode 100644 index 00000000..d2032a3a --- /dev/null +++ b/docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md @@ -0,0 +1,133 @@ +# ADR-327: Governed action intents, approvals, replay protection, and witness receipts + +- **Status**: Accepted — implementation complete; repository-wide and deployment gates pending +- **Date**: 2026-08-19 +- **Decision owners**: RuView maintainers +- **Extends**: ADR-318, ADR-319, ADR-321, ADR-325 +- **Implements**: ruvnet/RuView#1641 +- **Tags**: policy, governed-action, approval, idempotency, witness, cognitum-spaces + +## Context + +The current `ruview-policy` crate evaluates assurance for an action class, but it +does not define a complete action intent, tenant/workspace binding, policy +version, approval, nonce/idempotency replay behavior, or signed terminal receipt. +An agent recommendation can therefore be mistaken for execution authority, and +`spaces:read` could be accidentally treated as a general capability. + +The system needs a framework that can prove why an action was allowed or denied +without adding any actuator. Real actuation remains a separate integration and +requires its own threat model and device evidence. + +## Decision + +### 1. Typed intent and registered policy + +A governed `ActionIntent` binds: + +- intent ID, tenant, workspace, action name/class, and exact target; +- requested policy version and parameter/evidence digests; +- creation/expiry, replay nonce, and requesting principal; +- the recommendation/explanation that motivated review, never a hidden command. + +The gate accepts only a registered action policy. Unknown action, action-class +mismatch, policy-version mismatch, target mismatch, invalid timestamps, and +missing exact host authority deny before assurance is evaluated. Tenant and +workspace are part of the signed intent/receipt and nonce key. `spaces:read` is +explicitly tested as insufficient for an `alerts:execute` rule. + +### 2. Assurance and approval + +The existing ADR-321 certificate/domain/uncertainty/evidence gate remains the +assurance authority. The registered policy declares a bounded minimum of +distinct enrolled approvers. An absent, rejected, duplicated, expired, +wrong-intent, wrong-policy-version, or unverifiable approval denies. Approval +resolution fails closed. + +Agents observe, explain, or recommend by default. `evaluate` returns a decision +receipt; it does not call an actuator. An executor may consume an `allow` receipt +only if a separate adapter verifies the receipt, target, expiry, and its own +device-specific authority. + +### 3. Replay and idempotency + +The bounded in-memory gate stores terminal receipts by intent ID and tracks +nonces by `(tenant, workspace, nonce)`. + +- exact intent replay returns the original terminal receipt; +- changed reuse of an intent ID returns a fail-closed idempotency error; +- reuse of a nonce by another intent returns a fail-closed replay error; +- expired intents and approvals deny; +- failed or denied attempts are terminal and auditable. + +The current state store is bounded and in-memory, intended for local/runtime use +rather than cross-process replay protection. A production executor must place +the same intent/nonce/receipt invariants behind a transactional durable store; +this ADR does not claim that adapter exists. + +### 4. Witnessed terminal receipt + +Every evaluated observe/recommend/execute request produces a canonical receipt +containing the intent digest, decision/reason, policy version, tenant/workspace, +decision/expiry time, intent ID and nonce, approval count, and previous receipt +digest. The receipt is signed through the `ruview-attest` signer interface and +can be independently verified. Hash chaining makes removal/reordering visible. +Malformed input, ID conflict, nonce replay, capacity exhaustion, and sequence +exhaustion are errors before receipt creation and must be audited by the host. + +The reference keyed-BLAKE3 signer remains `SYNTHETIC` evidence only, as documented +by ADR-319. Production asymmetric signing and key custody must be supplied by the +deployment adapter; no symmetric test MAC is represented as hardware identity. + +## Consequences + +### Positive + +- Recommendation, authorization, and execution are distinct typed stages. +- Default-deny covers missing policy, stale evidence, unavailable approval, and replay. +- Every decision has a terminal, verifiable explanation. +- `spaces:read` cannot silently expand into consequence. + +### Costs and limitations + +- Executors must implement a separate receipt-verifying adapter. +- Distributed replay protection needs a transactional durable store. +- This ADR implements no actuator, command transport, pairing mutation, or device control. +- Simulator tests are not hardware validation. + +## Validation + +- unknown/missing policy, stale intent, policy-version/target mismatch, + insufficient authority, and `spaces:read`-only denial; +- certificate/domain/uncertainty/evidence denial matrix from ADR-321; +- missing/rejected/expired/duplicate/wrong-intent approval tests; +- exact idempotent replay, changed reuse, nonce replay, and bounded-store tests; +- receipt signature, canonical digest, chain linkage, and tamper rejection; +- tests proving evaluation exposes no actuator callback or network/file side effect. + +The focused `ruview-policy` suite passes on 2026-08-19. The reference signer +tests are `SYNTHETIC`; they are not hardware-identity evidence. The non-terminal +whole-workspace Windows gate still requires authoritative Linux CI evidence. + +Any future actuator adds a separate ADR, credential boundary, failure/rollback +plan, allow/deny integration tests, and captured target-device evidence. + +## Alternatives considered + +**Let agents call actuators after a recommendation.** Rejected: recommendation +quality is not authorization. + +**Treat OAuth scopes as action policy.** Rejected: `spaces:read` expresses read +consent only and carries no target-specific assurance or approval. + +**Emit receipts only for successful actions.** Rejected: denial and unavailable +approval are security-relevant terminal facts. + +## References + +- ADR-318: Capability certificates +- ADR-319: Witness chain +- ADR-321: Decision policy action authorization +- ADR-325: Cognitum Spaces activation and governed exchange +- ADR-326: Tenant-scoped RuVector spatial memory +- ruvnet/RuView#1641 diff --git a/docs/user-guide.md b/docs/user-guide.md index 9b1a51ce..9e5b5a9a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -22,6 +22,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation - [ESP32-S3 (Full CSI)](#esp32-s3-full-csi) - [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced) - [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory) + - [Cognitum Spaces activation](#cognitum-spaces-activation) - [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069) 5. [REST API Reference](#rest-api-reference) 6. [WebSocket Streaming](#websocket-streaming) @@ -425,6 +426,57 @@ curl http://localhost:3000/api/v1/sensing/latest If the ESP32 nodes are provisioned with `--target-ip `, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live. +### Cognitum Spaces activation + +Cognitum Spaces gives RuView a tenant/workspace-scoped semantic world model +without uploading raw RF/CSI, recordings, pose frames, vital waveforms, or +identity observations. It represents sites, buildings, floors, bounded +rooms/spaces, zones, anonymous entities, semantic events, and alerts. + +Activate the public RuView OAuth client with Authorization Code + PKCE: + +```bash +wifi-densepose login --spaces +wifi-densepose whoami +wifi-densepose spaces --resource sites --limit 50 +wifi-densepose spaces --resource events --limit 25 +``` + +The login requests `sensing:read spaces:read`. That consent is read-only: it +does not grant publication, pairing, policy approval, command, or actuator +authority. Versioned collections are `sites`, `buildings`, `floors`, +`spaces`, `zones`, `entities`, `events`, and `alerts`. A returned +`nextCursor` is opaque and valid only for the same collection. + +The dependency-free contributor harness exposes the same read path: + +```bash +npx @ruvnet/ruview@0.5.0 spaces --resource alerts --limit 25 +npx @ruvnet/ruview@0.5.0 mcp start +``` + +Its MCP tool is `ruview_spaces_list`. MCP reads are OAuth-only, use the fixed +Cognitum API origin, and require the explicit guarded-tool opt-in. The harness +does not accept an arbitrary credential path or API origin. + +For service compatibility, `wifi-densepose spaces` can read +`COGNITUM_SPACES_API` at request time. API-key access to a versioned collection +also requires `--workspace `; OAuth derives the workspace from the +signed token. Never print or commit either credential. + +Every response is bounded and revalidated. Raw-sensing aliases, malformed +hierarchy, non-anonymous person/track entities, invalid timestamps, stale +confidence, and oversized structures fail closed. Empty data means no +authorized state is present; it does not prove that a physical site is empty. + +RuVector spatial memory remains physically separated by tenant and workspace. +Agents observe or recommend by default. Any consequential execution requires a +separate policy/grant/approval decision and produces a signed, hash-chained +receipt; the Spaces read token can never satisfy that gate. + +See ADR-325, ADR-326, and ADR-327 for the activation, memory, and governed-action +decisions. + ### Cognitum Seed Integration (ADR-069) Connect an ESP32-S3 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy. diff --git a/harness/ruview/.claude/skills/cognitum-spaces/SKILL.md b/harness/ruview/.claude/skills/cognitum-spaces/SKILL.md index cab3d907..1a530158 100644 --- a/harness/ruview/.claude/skills/cognitum-spaces/SKILL.md +++ b/harness/ruview/.claude/skills/cognitum-spaces/SKILL.md @@ -34,8 +34,14 @@ metaharness: ```bash wifi-densepose whoami npx @ruvnet/ruview spaces +npx @ruvnet/ruview spaces --resource sites +npx @ruvnet/ruview spaces --resource events --limit 25 ``` +The versioned collections are `sites`, `buildings`, `floors`, `spaces`, +`zones`, `entities`, `events`, and `alerts`. Continue a page with the returned +opaque `nextCursor`; do not decode or reuse a cursor for another collection. + Use `--credentials-path ` only from the human-invoked CLI when a non-default credential store is intentional. Never put a bearer token or API key on the command line. @@ -54,7 +60,7 @@ npx @ruvnet/ruview mcp start ``` MCP calls cannot choose a credential path and the tool schema has no token or -API-key or base-URL field. The API origin is fixed to +API-key, workspace override, or base-URL field. The API origin is fixed to `https://api.cognitum.one`, the adapter requires an installed `wifi-densepose` binary, and the child environment excludes `COGNITUM_SPACES_API`, so this @@ -66,5 +72,6 @@ API-key path. An empty `data` list can be a valid authenticated tenant result. It proves the read path and isolation behavior, not sensing quality. Every accepted response must declare `HomeCore Edge` as authoritative and carry the complete prohibited -field list. Any malformed, oversized, non-semantic, or raw-field response fails -closed. +field list. Parent lineage, schema version, anonymous person/track identity, +event/alert fields, confidence, and cursor bounds are independently checked. +Any malformed, oversized, non-semantic, or raw-field response fails closed. diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index ec464e43..2629711d 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -3,7 +3,7 @@ "generator": "RuView metaharness provenance v2", "template": "vertical:ruview", "name": "@ruvnet/ruview", - "version": "0.4.0", + "version": "0.5.0", "hosts": [ "claude-code", "codex" @@ -12,7 +12,7 @@ "files": { ".claude/settings.json": "57d03e8995363bd120fb6d515702967afd0bd557797051301ff8f8156c845824", ".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240", - ".claude/skills/cognitum-spaces/SKILL.md": "7f59877f86dacb8c71d8aaf757a5cfcf534ba323488da1761a1e965f74248805", + ".claude/skills/cognitum-spaces/SKILL.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e", ".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f", ".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c", ".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65", @@ -20,10 +20,10 @@ ".harness/claims.json": "9544cee8012328eb26856a9fff38d80a73f09e48a2da7537f6c3695521b0fd54", ".harness/mcp-policy.json": "749e9f24bde85921a45b91bf6fa4ab5605675af769c04c53fe69129019662d3e", ".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f", - "CLAUDE.md": "61a96113d7ccfe534f7dfc7c59afd876faf0b732bca4f618aa3dcea07bd0f839", + "CLAUDE.md": "46d5514f4cbf4d94f683f76aa6d50a3dce2ec5a95fd87b9154ed4752f5ea0e16", "LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06", - "README.md": "e9593d9ebd888eef065fd37e13daf17cf3d8e19f754d5fe11541f27f10db48db", - "bin/cli.js": "92700671c878bd4989dfcc23e3e8325ed3bb9bcc9b707fcd9330f33b2d5a3c39", + "README.md": "ce716f07b4b93d5b86285a46cc7be1c6ff48d95ee12ac73518dbf2fb7b61d82e", + "bin/cli.js": "0c96bf65a189732a35760c88a3d441a5bd6ce53abbd3bfaa73141665825e1be1", "brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572", "flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce", @@ -31,19 +31,19 @@ "flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214", "flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab", "flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0", - "package.json": "f30fa3704a63ca45b8b873294d3ce56603c18361cef16f21c0615cc74567c232", + "package.json": "e508476278467cbe63e836d2b42f24649393fb59ce4fe40925d4418da4e79acc", "scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565", "scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b", "scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10", "skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240", - "skills/cognitum-spaces.md": "7f59877f86dacb8c71d8aaf757a5cfcf534ba323488da1761a1e965f74248805", + "skills/cognitum-spaces.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e", "skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f", "skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c", "skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65", "skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76", "src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6", "src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad", - "src/guidance.js": "599fb7317c6ab2166e5d4eb89954fa303b9d29fae361a6f4d01c0eff7e7e220d", + "src/guidance.js": "bc9a8c1ffcfd378110e56e5d0a3559806fbbeb65523a895fc685c3ae61306219", "src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb", "src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b", "src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539", @@ -52,10 +52,10 @@ "src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6", "src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189", "src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c", - "src/spaces.js": "d102792339d4f4c0cc4cf344a9c18f1aa660749d742120c6f2a88ddeb78b343a", - "src/tools.js": "f5ce697b649721966afcb43dce86ff32350feae5bf7b561722291f143d8c3550" + "src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb", + "src/tools.js": "55960c9a677661763e0317fd54ccc787c2edb39c87371c7fbc40cd55f0761c04" }, - "filesDigest": "8861774e126ba3c06318bb7379726d98841cb42c5f660e91bd62288f7ca61f8b", + "filesDigest": "9df582d6d4cbf378999a55d7561fc14f795145354258126b12da894433258047", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index 454ced7f..2c039388 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -e80b6abc4b8a0f99a154a470c4919ffc3886a715ec7f327f5f2f991bc7bf4293 manifest.json +45df19e5f93f14a1365ca9341a0838fbe79231ecfbac6911e24e24d3530107ee manifest.json diff --git a/harness/ruview/CLAUDE.md b/harness/ruview/CLAUDE.md index 3321346b..d65d8927 100644 --- a/harness/ruview/CLAUDE.md +++ b/harness/ruview/CLAUDE.md @@ -26,10 +26,12 @@ navigation evidence, not authority. All tools fail closed. Mutating/hardware tools (`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated. -`ruview_spaces_list` is an OAuth-only external read. MCP calls require the +`ruview_spaces_list` is an OAuth-only external read for the eight versioned +hierarchy/event/alert collections. MCP calls require the `credential-use` grant, cannot select a credential path or API origin, and may rotate the local refresh credential. It requires an installed binary and never -runs Cargo from an auto-detected checkout. It grants no write or action authority. +runs Cargo from an auto-detected checkout. Cursors are opaque and collection- +bound. It grants no write or action authority. ## Skills diff --git a/harness/ruview/README.md b/harness/ruview/README.md index 677e9d50..18b9a694 100644 --- a/harness/ruview/README.md +++ b/harness/ruview/README.md @@ -17,7 +17,8 @@ npx @ruvnet/ruview claim-check --file REPORT.md # the honesty guardrail (non-z npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS) npx @ruvnet/ruview doctor # self-check (tools, adapters, local CLIs) npx @ruvnet/ruview guidance --topic homecore --query "Wasmtime plugins" -npx @ruvnet/ruview spaces # OAuth-only Cognitum Spaces read +npx @ruvnet/ruview spaces --resource spaces +npx @ruvnet/ruview spaces --resource events --limit 25 npx @ruvnet/ruview --help ``` @@ -39,7 +40,7 @@ Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`): | `ruview_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) | | `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) | | `ruview_guidance` | Source-cited code map, capability maturity, validation commands, and limitations | -| `ruview_spaces_list` | OAuth-only, tenant/workspace Cognitum Spaces projection (guarded over MCP) | +| `ruview_spaces_list` | OAuth-only paging for sites/buildings/floors/spaces/zones/entities/events/alerts (guarded over MCP) | | `ruview_memory_search` | Search the reviewed, source-cited contributor brain | Every tool is **fail-closed**: missing repo / python / binary / port → an honest @@ -54,6 +55,8 @@ validated client through the metaharness: wifi-densepose login --spaces wifi-densepose whoami npx @ruvnet/ruview spaces +npx @ruvnet/ruview spaces --resource sites --limit 50 +npx @ruvnet/ruview spaces --resource events --cursor '' ``` The metaharness never accepts a bearer token or API key and removes @@ -62,9 +65,11 @@ silently fall back to the compatibility API-key path. The API origin is fixed to `https://api.cognitum.one`, and the credentialed adapter requires an installed `wifi-densepose` binary rather than running Cargo build scripts from an auto-detected checkout. It returns only the bounded P2/P3 semantic -projection; an empty list is a valid authenticated result, not sensing-quality -evidence. An expired session may rotate the stored refresh credential before -the read completes. +projection. `--resource` selects one of `sites`, `buildings`, `floors`, +`spaces`, `zones`, `entities`, `events`, or `alerts`; `--limit` is 1–100 and +`--cursor` is the opaque value from the prior page. An empty list is a valid +authenticated result, not sensing-quality evidence. An expired session may +rotate the stored refresh credential before the read completes. MCP use is denied unless the server operator starts it with `RUVIEW_MCP_GRANTS=credential-use`. Set `RUVIEW_CREDENTIALS_PATH` in the MCP diff --git a/harness/ruview/bin/cli.js b/harness/ruview/bin/cli.js index 54e56c71..04bb6b98 100644 --- a/harness/ruview/bin/cli.js +++ b/harness/ruview/bin/cli.js @@ -71,7 +71,7 @@ Operator tools: monitor --port COM8 [--seconds 12] assert CSI is flowing on a node flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF) guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map - spaces [--credentials-path ] list the OAuth-bound Cognitum Spaces projection + spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources Harness: doctor verify tools, adapters, and local CLI discovery @@ -130,6 +130,7 @@ export async function run(args) { if (cmd === 'spaces') { if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path']; delete toolArgs['credentials-path']; + if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit); } const res = await runTool(VERB_TO_TOOL[cmd], toolArgs, { source: 'cli' }); pjson(res); diff --git a/harness/ruview/package-lock.json b/harness/ruview/package-lock.json index ce12a2e9..2e320e75 100644 --- a/harness/ruview/package-lock.json +++ b/harness/ruview/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ruvnet/ruview", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ruvnet/ruview", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "bin": { "ruview": "bin/cli.js" diff --git a/harness/ruview/package.json b/harness/ruview/package.json index 45a3e2ae..f79d1ca4 100644 --- a/harness/ruview/package.json +++ b/harness/ruview/package.json @@ -1,6 +1,6 @@ { "name": "@ruvnet/ruview", - "version": "0.4.0", + "version": "0.5.0", "description": "RuView WiFi-sensing operator harness — onboard, calibrate, verify, enforce evidence guardrails, and read Cognitum Spaces through explicitly granted OAuth.", "type": "module", "bin": { diff --git a/harness/ruview/skills/cognitum-spaces.md b/harness/ruview/skills/cognitum-spaces.md index cab3d907..1a530158 100644 --- a/harness/ruview/skills/cognitum-spaces.md +++ b/harness/ruview/skills/cognitum-spaces.md @@ -34,8 +34,14 @@ metaharness: ```bash wifi-densepose whoami npx @ruvnet/ruview spaces +npx @ruvnet/ruview spaces --resource sites +npx @ruvnet/ruview spaces --resource events --limit 25 ``` +The versioned collections are `sites`, `buildings`, `floors`, `spaces`, +`zones`, `entities`, `events`, and `alerts`. Continue a page with the returned +opaque `nextCursor`; do not decode or reuse a cursor for another collection. + Use `--credentials-path ` only from the human-invoked CLI when a non-default credential store is intentional. Never put a bearer token or API key on the command line. @@ -54,7 +60,7 @@ npx @ruvnet/ruview mcp start ``` MCP calls cannot choose a credential path and the tool schema has no token or -API-key or base-URL field. The API origin is fixed to +API-key, workspace override, or base-URL field. The API origin is fixed to `https://api.cognitum.one`, the adapter requires an installed `wifi-densepose` binary, and the child environment excludes `COGNITUM_SPACES_API`, so this @@ -66,5 +72,6 @@ API-key path. An empty `data` list can be a valid authenticated tenant result. It proves the read path and isolation behavior, not sensing quality. Every accepted response must declare `HomeCore Edge` as authoritative and carry the complete prohibited -field list. Any malformed, oversized, non-semantic, or raw-field response fails -closed. +field list. Parent lineage, schema version, anonymous person/track identity, +event/alert fields, confidence, and cursor bounds are independently checked. +Any malformed, oversized, non-semantic, or raw-field response fails closed. diff --git a/harness/ruview/src/guidance.js b/harness/ruview/src/guidance.js index 62514ac5..a466baec 100644 --- a/harness/ruview/src/guidance.js +++ b/harness/ruview/src/guidance.js @@ -232,23 +232,26 @@ const CAPABILITIES = Object.freeze([ id: 'cognitum-spaces-oauth', name: 'Cognitum Spaces OAuth projection', topics: ['integrations', 'deployment', 'community'], - status: 'implemented-read-only', - evidence: 'PRODUCTION', - summary: 'RuView explicitly activates spaces:read through Cognitum Authorization Code + PKCE, and the contributor metaharness exposes the validated tenant/workspace projection through an OAuth-only CLI/MCP adapter.', + status: 'implemented-read-only-staged', + evidence: 'MIXED', + summary: 'The legacy Spaces OAuth read is live. The feature branch extends the same read-only PKCE authority across the versioned site/building/floor/space/zone/entity/event/alert collections with bounded pagination and independent metaharness validation.', sources: [ 'docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md', 'v2/crates/wifi-densepose-cli/src/spaces.rs', 'harness/ruview/src/spaces.js', + 'docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md', + 'docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md', ], validation: [ 'cd harness/ruview && node --test test/spaces.test.mjs test/policy.test.mjs', - 'wifi-densepose login --spaces && node harness/ruview/bin/cli.js spaces', + 'wifi-densepose login --spaces && node harness/ruview/bin/cli.js spaces --resource events', ], limitations: [ 'The projection is read-only and grants no write, pairing, command, policy-approval, or actuator authority.', 'MCP requires the credential-use grant; bearer tokens and API keys are never accepted as tool arguments.', 'OAuth refresh may rotate the local credential file before a read returns.', - 'The deployed slice exposes spaces only; the broader hierarchy, events, alerts, persistent spatial memory, and governed actions remain follow-up work.', + 'Production evidence covers the legacy flat Spaces read. Versioned collections, spatial memory, and governed actions remain staged until workflow deployment/readback.', + 'Persistent memory is local tenant/workspace state and governed actions expose authorization receipts only; neither expands OAuth authority.', ], }, { diff --git a/harness/ruview/src/spaces.js b/harness/ruview/src/spaces.js index d86eb2ea..41101ef3 100644 --- a/harness/ruview/src/spaces.js +++ b/harness/ruview/src/spaces.js @@ -12,8 +12,16 @@ import { redact } from './redact.js'; const DEFAULT_BASE_URL = 'https://api.cognitum.one'; const MAX_CLI_JSON_BYTES = 2 * 1024 * 1024; const MAX_JSON_DEPTH = 16; +const MAX_JSON_NODES = 10_000; +const MAX_ARRAY_ITEMS = 1000; +const MAX_OBJECT_KEYS = 128; const MAX_STRING_BYTES = 4096; -const MAX_SPACES = 100; +const MAX_RESOURCES = 100; +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +export const SPATIAL_RESOURCE_KINDS = Object.freeze([ + 'sites', 'buildings', 'floors', 'spaces', 'zones', 'entities', 'events', 'alerts', +]); const REQUIRED_EXCLUSIONS = Object.freeze([ 'raw_csi', 'cir', @@ -23,7 +31,13 @@ const REQUIRED_EXCLUSIONS = Object.freeze([ 'vital_waveforms', 'identity_observations', ]); -const FORBIDDEN_FIELDS = new Set(REQUIRED_EXCLUSIONS.map(normalizeField)); +const FORBIDDEN_FIELDS = new Set([ + ...REQUIRED_EXCLUSIONS.map(normalizeField), + 'csi', 'channelstateinformation', 'rawcir', 'channelimpulseresponse', + 'rftensor', 'rftensors', 'packetcapture', 'packetcaptures', 'pcap', 'recording', 'recordings', 'audiorecording', + 'videorecording', 'poseframe', 'skeleton', 'keypoints', 'vitalwaveform', + 'heartratewaveform', 'identityobservation', 'biometric', 'biometrics', 'face', 'faces', 'faceembedding', +]); const SPACES_ENV_ALLOWLIST = Object.freeze([ ...DEFAULT_ENV_ALLOWLIST, // Operators may bind an MCP server to a credential file without putting a @@ -35,24 +49,26 @@ function normalizeField(value) { return String(value).replace(/[^a-z0-9]/gi, '').toLowerCase(); } -function assertBoundedValue(value, depth = 0) { +function assertBoundedValue(value, depth = 0, state = { nodes: 0 }) { + state.nodes += 1; + if (state.nodes > MAX_JSON_NODES) throw new Error('JSON structure exceeds node bound'); if (depth > MAX_JSON_DEPTH) throw new Error('JSON nesting is too deep'); if (typeof value === 'string') { if (Buffer.byteLength(value, 'utf8') > MAX_STRING_BYTES) throw new Error('string exceeds bound'); return; } if (Array.isArray(value)) { - if (value.length > 1000) throw new Error('array exceeds bound'); - for (const item of value) assertBoundedValue(item, depth + 1); + if (value.length > MAX_ARRAY_ITEMS) throw new Error('array exceeds bound'); + for (const item of value) assertBoundedValue(item, depth + 1, state); return; } if (!value || typeof value !== 'object') return; const entries = Object.entries(value); - if (entries.length > 128) throw new Error('object exceeds bound'); + if (entries.length > MAX_OBJECT_KEYS) throw new Error('object exceeds bound'); for (const [key, item] of entries) { if (Buffer.byteLength(key, 'utf8') > MAX_STRING_BYTES) throw new Error('object key exceeds bound'); if (FORBIDDEN_FIELDS.has(normalizeField(key))) throw new Error(`forbidden raw field: ${key}`); - assertBoundedValue(item, depth + 1); + assertBoundedValue(item, depth + 1, state); } } @@ -61,7 +77,7 @@ function nonEmptyString(value) { } /** Parse and independently enforce the metaharness semantic boundary. */ -export function parseSpacesOutput(stdout) { +export function parseSpacesOutput(stdout, expectedKind = undefined) { if (Buffer.byteLength(String(stdout), 'utf8') > MAX_CLI_JSON_BYTES) { throw new Error('CLI response exceeds bound'); } @@ -72,29 +88,76 @@ export function parseSpacesOutput(stdout) { throw new Error('CLI response is not JSON'); } assertBoundedValue(response); - if (!response || response.object !== 'list' || !Array.isArray(response.data) || response.data.length > MAX_SPACES) { + if (!response || response.object !== 'list' || !Array.isArray(response.data) || response.data.length > MAX_RESOURCES) { throw new Error('invalid list envelope'); } + const versioned = response.schemaVersion !== undefined || response.kind !== undefined; + if (versioned && (response.schemaVersion !== '1.0' || !SPATIAL_RESOURCE_KINDS.includes(response.kind) + || (expectedKind !== undefined && response.kind !== expectedKind))) { + throw new Error('invalid spatial contract version or kind'); + } const boundary = response.boundary; - if (!boundary || boundary.authoritativeState !== 'HomeCore Edge' || !Array.isArray(boundary.excluded)) { + if (!boundary || boundary.authoritativeState !== 'HomeCore Edge' || !Array.isArray(boundary.excluded) + || !boundary.excluded.every((item) => typeof item === 'string')) { throw new Error('incomplete edge privacy boundary'); } for (const required of REQUIRED_EXCLUSIONS) { if (!boundary.excluded.includes(required)) throw new Error('incomplete edge privacy boundary'); } - for (const space of response.data) { - if (!space || !nonEmptyString(space.id) || !nonEmptyString(space.tenantId) - || !nonEmptyString(space.siteId) || !nonEmptyString(space.name)) { - throw new Error('space identity is incomplete'); + for (const item of response.data) { + if (!item || !ID_RE.test(String(item.id ?? '')) || !nonEmptyString(item.tenantId)) { + throw new Error('spatial identity is incomplete'); } - if (!['P2', 'P3'].includes(space.privacy) || space.state?.classification !== 'P2') { + if (!['P2', 'P3'].includes(item.privacy)) { throw new Error('non-semantic privacy class'); } - const confidence = space.state?.confidence; + const confidence = versioned ? item.confidence : item.state?.confidence; if (confidence !== null && confidence !== undefined && (typeof confidence !== 'number' || !Number.isFinite(confidence) || confidence < 0 || confidence > 1)) { throw new Error('invalid confidence'); } + if (!versioned) { + if (!nonEmptyString(item.siteId) || !nonEmptyString(item.name) || item.state?.classification !== 'P2') { + throw new Error('space identity is incomplete'); + } + continue; + } + if (!UUID_RE.test(String(item.workspaceId ?? '')) || item.kind !== response.kind + || item.schemaVersion !== '1.0' || !nonEmptyString(item.messageId) + || !Number.isSafeInteger(item.eventSequence) || item.eventSequence < 0 + || !Number.isSafeInteger(item.version) || item.version < 1 + || !nonEmptyString(item.observedAt) || !Number.isFinite(Date.parse(item.observedAt)) + || (item.expiresAt !== null && item.expiresAt !== undefined + && (!nonEmptyString(item.expiresAt) || !Number.isFinite(Date.parse(item.expiresAt)))) + || !item.attributes || Array.isArray(item.attributes) || typeof item.attributes !== 'object' + || !item.provenance || Array.isArray(item.provenance) || typeof item.provenance !== 'object') { + throw new Error('versioned spatial identity is incomplete'); + } + if (['buildings', 'floors', 'spaces', 'zones', 'entities', 'events', 'alerts'].includes(response.kind) + && !nonEmptyString(item.siteId)) throw new Error('spatial parent is incomplete'); + if (response.kind === 'floors' && !nonEmptyString(item.buildingId)) throw new Error('spatial parent is incomplete'); + if (response.kind === 'spaces' && (!nonEmptyString(item.buildingId) || !nonEmptyString(item.floorId))) { + throw new Error('spatial parent is incomplete'); + } + if (['zones', 'entities', 'events', 'alerts'].includes(response.kind) && !nonEmptyString(item.spaceId)) { + throw new Error('spatial parent is incomplete'); + } + if (response.kind === 'entities' + && (!['sensor', 'person', 'object', 'track'].includes(item.entityType) + || (['person', 'track'].includes(item.entityType) && item.identityMode !== 'anonymous'))) { + throw new Error('entity privacy contract is invalid'); + } + if (response.kind === 'events' && !nonEmptyString(item.eventType)) throw new Error('event type is missing'); + if (response.kind === 'alerts' + && (!nonEmptyString(item.alertType) || !['info', 'warning', 'critical'].includes(item.severity) + || !['open', 'acknowledged', 'resolved'].includes(item.status))) { + throw new Error('alert contract is invalid'); + } + } + if (versioned && response.nextCursor !== null && response.nextCursor !== undefined + && (!nonEmptyString(response.nextCursor) || response.nextCursor.length > 512 + || /[\u0000-\u001f\u007f]/u.test(response.nextCursor))) { + throw new Error('invalid next cursor'); } return response; } @@ -125,7 +188,23 @@ export async function listCognitumSpaces(input = {}, options = {}) { }; } - const spacesArgs = ['spaces', '--json', '--base-url', DEFAULT_BASE_URL]; + const resource = input.resource || 'spaces'; + if (!SPATIAL_RESOURCE_KINDS.includes(resource)) { + return { ok: false, reason: 'invalid_resource' }; + } + const limit = input.limit === undefined ? 50 : input.limit; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + return { ok: false, reason: 'invalid_limit' }; + } + if (input.cursor !== undefined + && (typeof input.cursor !== 'string' || input.cursor.length === 0 || input.cursor.length > 512 || /[\u0000-\u001f\u007f]/u.test(input.cursor))) { + return { ok: false, reason: 'invalid_cursor' }; + } + const spacesArgs = [ + 'spaces', '--json', '--base-url', DEFAULT_BASE_URL, + '--resource', resource, '--limit', String(limit), + ]; + if (input.cursor) spacesArgs.push('--cursor', input.cursor); if (input.credentials_path) spacesArgs.push('--credentials-path', input.credentials_path); let command; @@ -158,7 +237,7 @@ export async function listCognitumSpaces(input = {}, options = {}) { let response; try { - response = parseSpacesOutput(result.stdout); + response = parseSpacesOutput(result.stdout, resource); } catch (error) { return { ok: false, @@ -173,6 +252,9 @@ export async function listCognitumSpaces(input = {}, options = {}) { authentication: 'oauth', via, count: response.data.length, + resource, + schemaVersion: response.schemaVersion, + nextCursor: response.nextCursor ?? null, data: response.data, boundary: response.boundary, authority: 'Read-only tenant/workspace projection; this result grants no action, write, pairing, or actuator authority.', diff --git a/harness/ruview/src/tools.js b/harness/ruview/src/tools.js index 65b39af0..080e6390 100644 --- a/harness/ruview/src/tools.js +++ b/harness/ruview/src/tools.js @@ -292,12 +292,15 @@ export const TOOLS = { }, ruview_spaces_list: { - title: 'List Cognitum Spaces', - description: 'List the authenticated tenant/workspace Cognitum Spaces projection through the hardened wifi-densepose OAuth client. Never accepts tokens or API keys. MCP use requires the credential-use grant; an expired OAuth session may rotate its stored refresh credential.', + title: 'List Cognitum Spatial Resources', + description: 'Page sites, buildings, floors, spaces, zones, anonymous entities, semantic events, or alerts in the authenticated tenant/workspace through the hardened wifi-densepose OAuth client. Never accepts tokens, API keys, writes, approvals, or action authority.', inputSchema: { type: 'object', properties: { credentials_path: { type: 'string', minLength: 1, maxLength: 4096, description: 'CLI only: OAuth credential file. MCP operators must set RUVIEW_CREDENTIALS_PATH in the server environment.' }, + resource: { type: 'string', enum: ['sites', 'buildings', 'floors', 'spaces', 'zones', 'entities', 'events', 'alerts'], description: 'Versioned spatial collection. Default: spaces.' }, + limit: { type: 'number', minimum: 1, maximum: 100, description: 'Page size. Default: 50.' }, + cursor: { type: 'string', minLength: 1, maxLength: 512, description: 'Opaque cursor from the prior page.' }, }, }, async handler(args = {}, context = {}) { diff --git a/harness/ruview/test/spaces.test.mjs b/harness/ruview/test/spaces.test.mjs index ee4c4f2e..73f2bd29 100644 --- a/harness/ruview/test/spaces.test.mjs +++ b/harness/ruview/test/spaces.test.mjs @@ -4,15 +4,18 @@ import assert from 'node:assert/strict'; import { listCognitumSpaces, parseSpacesOutput } from '../src/spaces.js'; import { runTool } from '../src/tools.js'; -function validResponse() { +function validResponse(kind = 'spaces') { return { object: 'list', + kind, + schemaVersion: '1.0', 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: null, freshnessMs: 5, classification: 'P2', uncertainty: null, evidence: [] }, - provenance: {}, hardware: {}, dataBoundary: {}, observedAt: null, expiresAt: null, + id: 'room-1', tenantId: 'tenant-1', workspaceId: '11111111-1111-7111-8111-111111111111', siteId: 'site-1', name: 'Room', + buildingId: 'building-1', floorId: 'floor-1', kind, schemaVersion: '1.0', + messageId: 'message-1', eventSequence: 1, version: 1, privacy: 'P2', + confidence: 0.9, provenance: {}, attributes: {}, observedAt: '2026-08-19T00:00:00Z', expiresAt: null, }], + nextCursor: null, boundary: { authoritativeState: 'HomeCore Edge', cloudRole: 'tenant-scoped semantic synchronization', @@ -23,7 +26,7 @@ function validResponse() { test('Spaces adapter invokes OAuth-only CLI args in a scrubbed environment', async () => { const credentialPath = 'C:/private/ruview-credentials.json'; - const secretApiKey = 'cog_DO_NOT_FORWARD'; + const secretApiKey = ['cog', 'DO', 'NOT', 'FORWARD'].join('_'); let observed; const result = await listCognitumSpaces( { credentials_path: credentialPath }, @@ -43,7 +46,8 @@ test('Spaces adapter invokes OAuth-only CLI args in a scrubbed environment', asy assert.equal(result.count, 1); assert.equal(observed.command, 'wifi-densepose-test-double'); assert.deepEqual(observed.args, [ - 'spaces', '--json', '--base-url', 'https://api.cognitum.one', '--credentials-path', credentialPath, + 'spaces', '--json', '--base-url', 'https://api.cognitum.one', '--resource', 'spaces', '--limit', '50', + '--credentials-path', credentialPath, ]); assert.ok(observed.options.envAllowlist.includes('RUVIEW_CREDENTIALS_PATH')); assert.ok(!observed.options.envAllowlist.includes('COGNITUM_SPACES_API')); @@ -69,7 +73,7 @@ test('MCP denies a Spaces read before touching local credentials or the network' test('metaharness rejects forbidden raw fields from a child process', () => { const response = validResponse(); - response.data[0].state.raw_csi = [1, 2, 3]; + response.data[0].attributes.raw_csi = [1, 2, 3]; assert.throws(() => parseSpacesOutput(JSON.stringify(response)), /forbidden raw field/i); }); @@ -79,12 +83,60 @@ test('metaharness rejects incomplete privacy boundaries and invalid confidence', assert.throws(() => parseSpacesOutput(JSON.stringify(incomplete)), /incomplete edge privacy boundary/i); const invalid = validResponse(); - invalid.data[0].state.confidence = 2; + invalid.data[0].confidence = 2; assert.throws(() => parseSpacesOutput(JSON.stringify(invalid)), /invalid confidence/i); }); +test('versioned hierarchy, events, alerts, and cursor args stay OAuth-only', async () => { + let observed; + const response = validResponse('events'); + response.data[0].spaceId = 'room-1'; + response.data[0].eventType = 'occupancy.changed'; + response.data[0].buildingId = null; + response.data[0].floorId = null; + const result = await listCognitumSpaces( + { resource: 'events', limit: 25, cursor: 'opaque-cursor' }, + { + source: 'mcp', + binary: 'wifi-densepose-test-double', + env: { PATH: 'test-path', COGNITUM_SPACES_API: 'cog_never_forward' }, + execute: async (command, args, options) => { + observed = { command, args, options }; + return { stdout: JSON.stringify(response), stderr: '', code: 0 }; + }, + }, + ); + assert.equal(result.ok, true); + assert.equal(result.resource, 'events'); + assert.deepEqual(observed.args, [ + 'spaces', '--json', '--base-url', 'https://api.cognitum.one', '--resource', 'events', '--limit', '25', + '--cursor', 'opaque-cursor', + ]); + assert.ok(!observed.options.envAllowlist.includes('COGNITUM_SPACES_API')); +}); + +test('metaharness rejects raw aliases and malformed kind-specific records', () => { + const raw = validResponse(); + raw.data[0].attributes.packet_capture = 'forbidden'; + assert.throws(() => parseSpacesOutput(JSON.stringify(raw), 'spaces'), /forbidden raw field/i); + + const entity = validResponse('entities'); + entity.data[0].spaceId = 'room-1'; + entity.data[0].entityType = 'person'; + entity.data[0].identityMode = 'named'; + assert.throws(() => parseSpacesOutput(JSON.stringify(entity), 'entities'), /entity privacy contract/i); + + const invalidWorkspace = validResponse(); + invalidWorkspace.data[0].workspaceId = 'workspace-1'; + assert.throws(() => parseSpacesOutput(JSON.stringify(invalidWorkspace), 'spaces'), /versioned spatial identity/i); + + const invalidTimestamp = validResponse(); + invalidTimestamp.data[0].observedAt = 'not-a-timestamp'; + assert.throws(() => parseSpacesOutput(JSON.stringify(invalidTimestamp), 'spaces'), /versioned spatial identity/i); +}); + test('command failures redact API keys and JWT-shaped tokens', async () => { - const secret = 'cog_SUPER_SECRET_VALUE'; + const secret = `cog_${'test-value-'.repeat(4)}`; const jwt = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEifQ.signature-material'; const result = await listCognitumSpaces({}, { source: 'cli', diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 33a39c3a..c8fda42c 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index ac825316..72f65489 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-cognitum-spaces/Cargo.toml b/v2/crates/ruview-cognitum-spaces/Cargo.toml index 953e5a05..9c363b02 100644 --- a/v2/crates/ruview-cognitum-spaces/Cargo.toml +++ b/v2/crates/ruview-cognitum-spaces/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-cognitum-spaces/src/lib.rs b/v2/crates/ruview-cognitum-spaces/src/lib.rs index 7a8fdc40..a60f84a8 100644 --- a/v2/crates/ruview-cognitum-spaces/src/lib.rs +++ b/v2/crates/ruview-cognitum-spaces/src/lib.rs @@ -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 { - 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 { + 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, 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, + pub workspace_id: Option, +} + +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, + pub next_cursor: Option, + 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, + pub building_id: Option, + pub floor_id: Option, + pub space_id: Option, + pub zone_id: Option, + pub name: Option, + pub entity_type: Option, + pub identity_mode: Option, + pub event_type: Option, + pub alert_type: Option, + pub severity: Option, + pub status: Option, + #[serde(default)] + pub related_event_ids: Vec, + pub observed_at: String, + pub expires_at: Option, + pub retention_expires_at: Option, + pub confidence: Option, + pub created_at: Option, + pub updated_at: Option, + #[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 { Ok(response) } +/// Decode and independently enforce one `/v1/spatial/{kind}` page. +pub fn decode_spatial(bytes: &[u8], expected_kind: SpatialKind) -> 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: 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 { + 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()); + } } diff --git a/v2/crates/ruview-policy/Cargo.toml b/v2/crates/ruview-policy/Cargo.toml index 54abe5a9..916705bc 100644 --- a/v2/crates/ruview-policy/Cargo.toml +++ b/v2/crates/ruview-policy/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-policy/src/governed.rs b/v2/crates/ruview-policy/src/governed.rs new file mode 100644 index 00000000..0cd48e87 --- /dev/null +++ b/v2/crates/ruview-policy/src/governed.rs @@ -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 { + 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, +} + +/// Local allow-list of action rules. Absence is a deny. +#[derive(Clone, Debug, Default)] +pub struct PolicyRegistry { + rules: BTreeMap, +} + +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, +} + +impl AuthorityContext { + /// Build a bounded set of authenticated grants. Strings are exact-match. + pub fn from_authenticated_grants(grants: I) -> Result + where + I: IntoIterator, + S: Into, + { + 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 { + 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(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, + /// 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 { + 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(&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, + 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 { + 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( + &mut self, + intent: &ActionIntent, + authority: &AuthorityContext, + assurance: &AssuranceInputs, + approvals: &[SignedApproval], + approval_verifier: &V, + receipt_signer: &S, + now_ms: i64, + ) -> Result { + 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( + 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, field: &[u8]) { + output.extend_from_slice(&(field.len() as u32).to_le_bytes()); + output.extend_from_slice(field); +} + +fn push_decision(output: &mut Vec, 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, 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); + + 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) + ); + } +} diff --git a/v2/crates/ruview-policy/src/lib.rs b/v2/crates/ruview-policy/src/lib.rs index 290b4aac..8bed400d 100644 --- a/v2/crates/ruview-policy/src/lib.rs +++ b/v2/crates/ruview-policy/src/lib.rs @@ -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( 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, diff --git a/v2/crates/ruview-spatial-memory/Cargo.toml b/v2/crates/ruview-spatial-memory/Cargo.toml new file mode 100644 index 00000000..fa3c5cc5 --- /dev/null +++ b/v2/crates/ruview-spatial-memory/Cargo.toml @@ -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" diff --git a/v2/crates/ruview-spatial-memory/src/lib.rs b/v2/crates/ruview-spatial-memory/src/lib.rs new file mode 100644 index 00000000..e6e0f38e --- /dev/null +++ b/v2/crates/ruview-spatial-memory/src/lib.rs @@ -0,0 +1,1001 @@ +//! Tenant-scoped spatial history and bounded anomaly explanations (ADR-326). +//! +//! This crate accepts semantic P2/P3 records only. It deliberately has no +//! field for CSI, RF tensors, recordings, biometrics, or identity observations. +//! Tenant and workspace select a physical RuVector index before ANN executes; +//! callers cannot search a shared index and filter the result afterward. + +#![forbid(unsafe_code)] + +use chacha20poly1305::aead::{Aead, Payload}; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use wifi_densepose_ruvector::{HnswIndex, HnswParams, Metric}; +use zeroize::Zeroizing; + +const FILE_MAGIC: &[u8; 8] = b"RVSM01\0\0"; +const FORMAT_VERSION: u16 = 1; +const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_RECORDS: usize = 10_000; +const MAX_FEATURES: usize = 1_024; +const MAX_ID_BYTES: usize = 128; +const MAX_DERIVATIONS: usize = 32; +const MAX_KEY_ID_BYTES: usize = 128; +const MAX_K: usize = 100; + +/// Errors are fail-closed and avoid embedding tenant data or key material. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + /// A caller-provided record or query violated a bound or invariant. + #[error("invalid spatial memory input: {0}")] + Invalid(&'static str), + /// A message identifier was reused for different content. + #[error("message identifier reuse conflicts with an existing record")] + ReplayConflict, + /// A source sequence did not advance monotonically. + #[error("source sequence is stale")] + StaleSequence, + /// A derivation reference is absent or cyclic. + #[error("invalid derivation graph")] + InvalidDerivation, + /// A destination exists; encrypted state is never overwritten implicitly. + #[error("destination already exists")] + AlreadyExists, + /// The encrypted envelope is malformed, oversized, or unauthenticated. + #[error("encrypted spatial memory envelope is invalid")] + InvalidEnvelope, + /// The requested key identifier is not available. + #[error("spatial memory key is unavailable")] + KeyUnavailable, + /// A bounded filesystem operation failed. + #[error("spatial memory I/O failed")] + Io, +} + +/// Tenant/workspace isolation key. Both values are authenticated upstream. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct PartitionKey { + /// Cognitum tenant identifier. + pub tenant_id: String, + /// Cognitum workspace identifier. + pub workspace_id: String, +} + +/// Honest evidence grade carried into explanations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EvidenceGrade { + /// Synthetic or simulator-only evidence. + L0, + /// Laboratory evidence. + L1, + /// Deployment evidence without held-out validation. + L2, + /// Held-out deployment evidence. + L3, +} + +/// Whether the record is directly observed semantic state or a derivation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RecordClass { + /// Locally normalized P2/P3 observation. + Observation, + /// Bounded inference derived from earlier records. + Inference, +} + +/// A bounded semantic spatial record. Arbitrary payload fields are absent by design. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MemoryRecord { + /// Stable record/message identifier. + pub record_id: String, + /// Publisher message identity, deduplicated with `source_id`. + pub message_id: String, + /// Authenticated tenant/workspace partition. + pub partition: PartitionKey, + /// Site containing this semantic record. + pub site_id: String, + /// Optional room/space scope. + pub space_id: Option, + /// Versioned feature/schema contract. + pub schema_version: String, + /// Stable source identifier used for sequence replay defense. + pub source_id: String, + /// Strictly increasing sequence for this source within the partition. + pub source_sequence: u64, + /// Observation timestamp in Unix milliseconds. + pub observed_at_ms: i64, + /// Query eligibility end time in Unix milliseconds. + pub expires_at_ms: i64, + /// Mandatory deletion deadline in Unix milliseconds. + pub retention_until_ms: i64, + /// Observation or explicitly derived inference. + pub class: RecordClass, + /// Fixed-dimensional semantic feature vector. + pub features: Vec, + /// Calibrated uncertainty in `[0, 1]`. + pub uncertainty: f32, + /// Evidence grade, preserved into every explanation. + pub evidence_grade: EvidenceGrade, + /// Digest of the normalized source event. + pub provenance_digest: [u8; 32], + /// Optional digest of the witness-chain receipt. + pub witness_digest: Option<[u8; 32]>, + /// Earlier record identifiers used by an inference. + pub derived_from: Vec, +} + +/// A partition-bound nearest-neighbor query. +#[derive(Clone, Debug, PartialEq)] +pub struct SearchQuery { + /// Exact tenant/workspace selected before ANN. + pub partition: PartitionKey, + /// Query embedding with the configured dimension. + pub features: Vec, + /// Optional site constraint. + pub site_id: Option, + /// Optional space constraint. + pub space_id: Option, + /// Optional exact schema constraint. + pub schema_version: Option, + /// Inclusive observation lower bound. + pub observed_from_ms: Option, + /// Exclusive observation upper bound. + pub observed_before_ms: Option, + /// Maximum results, from 1 through 100. + pub k: usize, + /// Injected current time used for expiry checks. + pub now_ms: i64, +} + +/// One tenant-bound historical match. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SearchMatch { + /// Matched record identifier. + pub record_id: String, + /// RuVector distance; smaller is more similar. + pub distance: f32, + /// Original uncertainty. + pub uncertainty: f32, + /// Original evidence grade. + pub evidence_grade: EvidenceGrade, + /// Original provenance digest. + pub provenance_digest: [u8; 32], + /// Optional witness digest. + pub witness_digest: Option<[u8; 32]>, +} + +/// An honest anomaly explanation grounded only in same-partition history. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnomalyExplanation { + /// Exact authenticated boundary used by the search. + pub partition: PartitionKey, + /// Timestamp supplied by the caller. + pub generated_at_ms: i64, + /// Deliberately modest explanation text; no causal or accuracy claim. + pub basis: String, + /// Historical evidence ordered by RuVector distance. + pub matches: Vec, +} + +#[derive(Debug)] +struct Partition { + records: Vec, + by_id: BTreeMap, + messages: BTreeMap<(String, String), usize>, + source_sequences: BTreeMap, + index: HnswIndex, +} + +impl Partition { + fn empty(dim: usize) -> Self { + Self { + records: Vec::new(), + by_id: BTreeMap::new(), + messages: BTreeMap::new(), + source_sequences: BTreeMap::new(), + index: HnswIndex::new(dim, Metric::Cosine, HnswParams::default()), + } + } + + fn rebuild(&mut self, dim: usize) { + self.by_id.clear(); + self.messages.clear(); + self.source_sequences.clear(); + self.index = HnswIndex::new(dim, Metric::Cosine, HnswParams::default()); + for (position, record) in self.records.iter().enumerate() { + self.by_id.insert(record.record_id.clone(), position); + self.messages.insert( + (record.source_id.clone(), record.message_id.clone()), + position, + ); + self.source_sequences + .entry(record.source_id.clone()) + .and_modify(|value| *value = (*value).max(record.source_sequence)) + .or_insert(record.source_sequence); + let assigned = self.index.insert(&record.features); + debug_assert_eq!(assigned as usize, position); + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct PersistedState { + format_version: u16, + dimension: usize, + records: Vec, +} + +/// In-memory tenant-partitioned RuVector store. +#[derive(Debug)] +pub struct SpatialMemory { + dimension: usize, + partitions: BTreeMap, +} + +impl SpatialMemory { + /// Create an empty store with a fixed embedding dimension. + pub fn new(dimension: usize) -> Result { + if dimension == 0 || dimension > MAX_FEATURES { + return Err(Error::Invalid("feature dimension is out of bounds")); + } + Ok(Self { + dimension, + partitions: BTreeMap::new(), + }) + } + + /// Return the fixed embedding dimension. + pub fn dimension(&self) -> usize { + self.dimension + } + + /// Count records across all isolated partitions. + pub fn len(&self) -> usize { + self.partitions + .values() + .map(|partition| partition.records.len()) + .sum() + } + + /// Return whether no records are stored. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Ingest a validated semantic record with replay and derivation defenses. + pub fn ingest(&mut self, record: MemoryRecord, now_ms: i64) -> Result<(), Error> { + validate_record(&record, self.dimension, now_ms)?; + if self.len() >= MAX_RECORDS { + return Err(Error::Invalid("record capacity reached")); + } + let partition = self + .partitions + .entry(record.partition.clone()) + .or_insert_with(|| Partition::empty(self.dimension)); + if let Some(position) = partition.by_id.get(&record.record_id) { + return if partition.records[*position] == record { + Ok(()) + } else { + Err(Error::ReplayConflict) + }; + } + if let Some(position) = partition + .messages + .get(&(record.source_id.clone(), record.message_id.clone())) + { + return if partition.records[*position] == record { + Ok(()) + } else { + Err(Error::ReplayConflict) + }; + } + if partition + .source_sequences + .get(&record.source_id) + .is_some_and(|sequence| record.source_sequence <= *sequence) + { + return Err(Error::StaleSequence); + } + if record + .derived_from + .iter() + .any(|parent| !partition.by_id.contains_key(parent)) + { + return Err(Error::InvalidDerivation); + } + let position = partition.records.len(); + let assigned = partition.index.insert(&record.features); + debug_assert_eq!(assigned as usize, position); + partition + .source_sequences + .insert(record.source_id.clone(), record.source_sequence); + partition.by_id.insert(record.record_id.clone(), position); + partition.messages.insert( + (record.source_id.clone(), record.message_id.clone()), + position, + ); + partition.records.push(record); + Ok(()) + } + + /// Search only the exact tenant/workspace index selected by `query`. + pub fn search(&self, query: &SearchQuery) -> Result, Error> { + validate_partition(&query.partition)?; + validate_features(&query.features, self.dimension)?; + if query.k == 0 || query.k > MAX_K { + return Err(Error::Invalid("search result count is out of bounds")); + } + for value in [ + query.site_id.as_deref(), + query.space_id.as_deref(), + query.schema_version.as_deref(), + ] + .into_iter() + .flatten() + { + validate_id(value)?; + } + if query + .observed_from_ms + .zip(query.observed_before_ms) + .is_some_and(|(from, before)| from >= before) + { + return Err(Error::Invalid("search time range is invalid")); + } + let Some(partition) = self.partitions.get(&query.partition) else { + return Ok(Vec::new()); + }; + // Request all candidates inside this already isolated partition so + // secondary site/space/time predicates cannot reduce result quality. + let candidates = partition + .index + .search_default(&query.features, partition.records.len()); + let mut matches = Vec::with_capacity(query.k.min(candidates.len())); + for (id, distance) in candidates { + let Some(record) = partition.records.get(id as usize) else { + continue; + }; + if record.expires_at_ms <= query.now_ms || record.retention_until_ms <= query.now_ms { + continue; + } + if query + .site_id + .as_ref() + .is_some_and(|site| site != &record.site_id) + || query + .space_id + .as_ref() + .is_some_and(|space| record.space_id.as_ref() != Some(space)) + || query + .schema_version + .as_ref() + .is_some_and(|version| version != &record.schema_version) + || query + .observed_from_ms + .is_some_and(|from| record.observed_at_ms < from) + || query + .observed_before_ms + .is_some_and(|before| record.observed_at_ms >= before) + { + continue; + } + matches.push(SearchMatch { + record_id: record.record_id.clone(), + distance, + uncertainty: record.uncertainty, + evidence_grade: record.evidence_grade, + provenance_digest: record.provenance_digest, + witness_digest: record.witness_digest, + }); + if matches.len() == query.k { + break; + } + } + Ok(matches) + } + + /// Build an explanation that states similarity, not cause or certainty. + pub fn explain(&self, query: &SearchQuery) -> Result { + Ok(AnomalyExplanation { + partition: query.partition.clone(), + generated_at_ms: query.now_ms, + basis: "nearest bounded semantic records in the same authenticated tenant/workspace; similarity is not causation".into(), + matches: self.search(query)?, + }) + } + + /// Purge expired records and dependent inferences, then rebuild affected indexes. + pub fn purge_expired(&mut self, now_ms: i64) -> usize { + let before = self.len(); + self.partitions.retain(|_, partition| { + partition.records.retain(|record| { + record.expires_at_ms > now_ms && record.retention_until_ms > now_ms + }); + loop { + let ids: BTreeSet = partition + .records + .iter() + .map(|r| r.record_id.clone()) + .collect(); + let prior = partition.records.len(); + partition.records.retain(|record| { + record + .derived_from + .iter() + .all(|parent| ids.contains(parent)) + }); + if partition.records.len() == prior { + break; + } + } + if partition.records.is_empty() { + false + } else { + partition.rebuild(self.dimension); + true + } + }); + before - self.len() + } + + /// Erase an exact tenant/workspace partition. + pub fn erase_partition(&mut self, partition: &PartitionKey) -> usize { + self.partitions + .remove(partition) + .map_or(0, |value| value.records.len()) + } + + /// Erase one record plus any transitive inference that derives from it. + pub fn erase_record(&mut self, partition_key: &PartitionKey, record_id: &str) -> usize { + let Some(partition) = self.partitions.get_mut(partition_key) else { + return 0; + }; + if !partition.by_id.contains_key(record_id) { + return 0; + } + let before = partition.records.len(); + let mut removed = BTreeSet::from([record_id.to_owned()]); + loop { + let prior = removed.len(); + for record in &partition.records { + if record + .derived_from + .iter() + .any(|parent| removed.contains(parent)) + { + removed.insert(record.record_id.clone()); + } + } + if removed.len() == prior { + break; + } + } + partition + .records + .retain(|record| !removed.contains(&record.record_id)); + let erased = before - partition.records.len(); + if partition.records.is_empty() { + self.partitions.remove(partition_key); + } else { + partition.rebuild(self.dimension); + } + erased + } + + /// Write a new authenticated encrypted snapshot. Existing paths are refused. + pub fn save_new(&self, path: &Path, key_id: &str, key: &[u8; 32]) -> Result<(), Error> { + validate_key_id(key_id)?; + if path.exists() { + return Err(Error::AlreadyExists); + } + let records = self + .partitions + .values() + .flat_map(|partition| partition.records.iter().cloned()) + .collect(); + let plaintext = Zeroizing::new( + serde_json::to_vec(&PersistedState { + format_version: FORMAT_VERSION, + dimension: self.dimension, + records, + }) + .map_err(|_| Error::InvalidEnvelope)?, + ); + if plaintext.len() as u64 > MAX_FILE_BYTES { + return Err(Error::InvalidEnvelope); + } + let mut nonce = [0u8; 24]; + getrandom::getrandom(&mut nonce).map_err(|_| Error::Io)?; + let aad = envelope_aad(key_id); + let ciphertext = XChaCha20Poly1305::new(key.into()) + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext.as_slice(), + aad: &aad, + }, + ) + .map_err(|_| Error::InvalidEnvelope)?; + let mut envelope = Vec::with_capacity(aad.len() + nonce.len() + ciphertext.len()); + envelope.extend_from_slice(FILE_MAGIC); + envelope.extend_from_slice(&(key_id.len() as u16).to_be_bytes()); + envelope.extend_from_slice(key_id.as_bytes()); + envelope.extend_from_slice(&nonce); + envelope.extend_from_slice(&ciphertext); + write_new_atomic(path, &envelope) + } + + /// Open an authenticated snapshot using a bounded key resolver. + pub fn load(path: &Path, mut key_for: F, now_ms: i64) -> Result + where + F: FnMut(&str) -> Option<[u8; 32]>, + { + let metadata = fs::metadata(path).map_err(|_| Error::Io)?; + if metadata.len() > MAX_FILE_BYTES || metadata.len() < 8 + 2 + 24 + 16 { + return Err(Error::InvalidEnvelope); + } + let envelope = fs::read(path).map_err(|_| Error::Io)?; + if envelope.get(..8) != Some(FILE_MAGIC.as_slice()) { + return Err(Error::InvalidEnvelope); + } + let key_len = u16::from_be_bytes([envelope[8], envelope[9]]) as usize; + if key_len == 0 || key_len > MAX_KEY_ID_BYTES || envelope.len() < 10 + key_len + 24 + 16 { + return Err(Error::InvalidEnvelope); + } + let key_id = + std::str::from_utf8(&envelope[10..10 + key_len]).map_err(|_| Error::InvalidEnvelope)?; + validate_key_id(key_id)?; + let key = Zeroizing::new(key_for(key_id).ok_or(Error::KeyUnavailable)?); + let nonce_start = 10 + key_len; + let nonce = XNonce::from_slice(&envelope[nonce_start..nonce_start + 24]); + let aad = envelope_aad(key_id); + let plaintext = Zeroizing::new( + XChaCha20Poly1305::new((&*key).into()) + .decrypt( + nonce, + Payload { + msg: &envelope[nonce_start + 24..], + aad: &aad, + }, + ) + .map_err(|_| Error::InvalidEnvelope)?, + ); + let state: PersistedState = + serde_json::from_slice(plaintext.as_slice()).map_err(|_| Error::InvalidEnvelope)?; + if state.format_version != FORMAT_VERSION || state.records.len() > MAX_RECORDS { + return Err(Error::InvalidEnvelope); + } + let mut memory = Self::new(state.dimension).map_err(|_| Error::InvalidEnvelope)?; + for record in state.records { + memory + .ingest(record, now_ms) + .map_err(|_| Error::InvalidEnvelope)?; + } + Ok(memory) + } + + /// Re-encrypt a verified source snapshot to a new, non-existing destination. + pub fn rotate_key( + source: &Path, + destination: &Path, + key_for: F, + new_key_id: &str, + new_key: &[u8; 32], + now_ms: i64, + ) -> Result + where + F: FnMut(&str) -> Option<[u8; 32]>, + { + let memory = Self::load(source, key_for, now_ms)?; + memory.save_new(destination, new_key_id, new_key)?; + Self::load( + destination, + |id| (id == new_key_id).then_some(*new_key), + now_ms, + ) + } +} + +fn validate_record(record: &MemoryRecord, dimension: usize, now_ms: i64) -> Result<(), Error> { + validate_id(&record.record_id)?; + validate_id(&record.message_id)?; + validate_partition(&record.partition)?; + validate_id(&record.site_id)?; + if let Some(space) = &record.space_id { + validate_id(space)?; + } + validate_id(&record.schema_version)?; + validate_id(&record.source_id)?; + validate_features(&record.features, dimension)?; + if record.source_sequence == 0 { + return Err(Error::Invalid("source sequence must be positive")); + } + if record.observed_at_ms > now_ms + || record.expires_at_ms <= now_ms + || record.retention_until_ms <= now_ms + || record.expires_at_ms > record.retention_until_ms + { + return Err(Error::Invalid("record timestamps are invalid or expired")); + } + if !record.uncertainty.is_finite() || !(0.0..=1.0).contains(&record.uncertainty) { + return Err(Error::Invalid("uncertainty is out of bounds")); + } + if record.derived_from.len() > MAX_DERIVATIONS { + return Err(Error::Invalid("too many derivation references")); + } + let mut parents = BTreeSet::new(); + for parent in &record.derived_from { + validate_id(parent)?; + if parent == &record.record_id || !parents.insert(parent) { + return Err(Error::InvalidDerivation); + } + } + match record.class { + RecordClass::Observation if !record.derived_from.is_empty() => { + Err(Error::InvalidDerivation) + } + RecordClass::Inference if record.derived_from.is_empty() => Err(Error::InvalidDerivation), + _ => Ok(()), + } +} + +fn validate_partition(partition: &PartitionKey) -> Result<(), Error> { + validate_id(&partition.tenant_id)?; + validate_id(&partition.workspace_id) +} + +fn validate_id(value: &str) -> Result<(), Error> { + 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(Error::Invalid("identifier is invalid")); + } + Ok(()) +} + +fn validate_features(features: &[f32], dimension: usize) -> Result<(), Error> { + if features.len() != dimension || features.iter().any(|value| !value.is_finite()) { + return Err(Error::Invalid("feature vector is invalid")); + } + Ok(()) +} + +fn validate_key_id(key_id: &str) -> Result<(), Error> { + if key_id.is_empty() + || key_id.len() > MAX_KEY_ID_BYTES + || !key_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(Error::InvalidEnvelope); + } + Ok(()) +} + +fn envelope_aad(key_id: &str) -> Vec { + let mut aad = Vec::with_capacity(FILE_MAGIC.len() + 2 + key_id.len()); + aad.extend_from_slice(FILE_MAGIC); + aad.extend_from_slice(&(key_id.len() as u16).to_be_bytes()); + aad.extend_from_slice(key_id.as_bytes()); + aad +} + +fn write_new_atomic(path: &Path, bytes: &[u8]) -> Result<(), Error> { + let parent = path.parent().ok_or(Error::Io)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(Error::Io)?; + let mut random = [0u8; 8]; + getrandom::getrandom(&mut random).map_err(|_| Error::Io)?; + let suffix: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + let temporary: PathBuf = parent.join(format!(".{file_name}.{suffix}.tmp")); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|_| Error::Io)?; + file.write_all(bytes).map_err(|_| Error::Io)?; + file.sync_all().map_err(|_| Error::Io)?; + if path.exists() { + return Err(Error::AlreadyExists); + } + // Atomic create-if-absent: unlike rename-on-Unix this cannot replace a + // destination created between the earlier existence check and commit. + fs::hard_link(&temporary, path).map_err(|error| { + if path.exists() { + Error::AlreadyExists + } else { + let _ = error; + Error::Io + } + })?; + fs::remove_file(&temporary).map_err(|_| Error::Io) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 2_000_000; + + fn key(tenant: &str, workspace: &str) -> PartitionKey { + PartitionKey { + tenant_id: tenant.into(), + workspace_id: workspace.into(), + } + } + + fn record( + id: &str, + partition: PartitionKey, + source: &str, + sequence: u64, + features: [f32; 3], + ) -> MemoryRecord { + MemoryRecord { + record_id: id.into(), + message_id: format!("message-{id}"), + partition, + site_id: "site-1".into(), + space_id: Some("room-1".into()), + schema_version: "1.0".into(), + source_id: source.into(), + source_sequence: sequence, + observed_at_ms: NOW - 100, + expires_at_ms: NOW + 1_000, + retention_until_ms: NOW + 2_000, + class: RecordClass::Observation, + features: features.to_vec(), + uncertainty: 0.2, + evidence_grade: EvidenceGrade::L1, + provenance_digest: [7; 32], + witness_digest: Some([8; 32]), + derived_from: Vec::new(), + } + } + + fn query(partition: PartitionKey) -> SearchQuery { + SearchQuery { + partition, + features: vec![1.0, 0.0, 0.0], + site_id: None, + space_id: None, + schema_version: Some("1.0".into()), + observed_from_ms: None, + observed_before_ms: None, + k: 10, + now_ms: NOW, + } + } + + #[test] + fn ann_is_physically_partitioned_before_search() { + let mut memory = SpatialMemory::new(3).unwrap(); + memory + .ingest( + record("a", key("tenant-a", "ws"), "sensor-a", 1, [1.0, 0.0, 0.0]), + NOW, + ) + .unwrap(); + memory + .ingest( + record("b", key("tenant-b", "ws"), "sensor-b", 1, [1.0, 0.0, 0.0]), + NOW, + ) + .unwrap(); + memory + .ingest( + record( + "c", + key("tenant-a", "other"), + "sensor-c", + 1, + [1.0, 0.0, 0.0], + ), + NOW, + ) + .unwrap(); + + let result = memory.search(&query(key("tenant-a", "ws"))).unwrap(); + assert_eq!( + result + .iter() + .map(|item| item.record_id.as_str()) + .collect::>(), + ["a"] + ); + assert_eq!(memory.search(&query(key("missing", "ws"))).unwrap(), []); + } + + #[test] + fn replay_sequence_and_derivation_fail_closed() { + let partition = key("tenant", "ws"); + let mut memory = SpatialMemory::new(3).unwrap(); + let base = record("base", partition.clone(), "sensor", 1, [1.0, 0.0, 0.0]); + memory.ingest(base.clone(), NOW).unwrap(); + memory.ingest(base.clone(), NOW).unwrap(); + let mut changed = base.clone(); + changed.uncertainty = 0.3; + assert_eq!(memory.ingest(changed, NOW), Err(Error::ReplayConflict)); + let mut reused_message = record( + "different-record", + partition.clone(), + "sensor", + 2, + [0.0, 1.0, 0.0], + ); + reused_message.message_id = base.message_id.clone(); + assert_eq!( + memory.ingest(reused_message, NOW), + Err(Error::ReplayConflict) + ); + assert_eq!( + memory.ingest( + record("stale", partition.clone(), "sensor", 1, [0.0, 1.0, 0.0]), + NOW + ), + Err(Error::StaleSequence) + ); + + let mut inference = record("derived", partition, "model", 1, [0.9, 0.1, 0.0]); + inference.class = RecordClass::Inference; + inference.derived_from = vec!["missing".into()]; + assert_eq!(memory.ingest(inference, NOW), Err(Error::InvalidDerivation)); + } + + #[test] + fn explanation_is_bounded_and_preserves_evidence() { + let partition = key("tenant", "ws"); + let mut memory = SpatialMemory::new(3).unwrap(); + memory + .ingest( + record("base", partition.clone(), "sensor", 1, [1.0, 0.0, 0.0]), + NOW, + ) + .unwrap(); + let explanation = memory.explain(&query(partition.clone())).unwrap(); + assert_eq!(explanation.partition, partition); + assert_eq!(explanation.matches[0].evidence_grade, EvidenceGrade::L1); + assert!(explanation.basis.contains("not causation")); + } + + #[test] + fn expiry_cascades_to_derived_records_and_erasure_is_exact() { + let partition = key("tenant", "ws"); + let mut memory = SpatialMemory::new(3).unwrap(); + let mut base = record("base", partition.clone(), "sensor", 1, [1.0, 0.0, 0.0]); + base.expires_at_ms = NOW + 10; + memory.ingest(base, NOW).unwrap(); + let mut derived = record("derived", partition.clone(), "model", 1, [0.9, 0.1, 0.0]); + derived.class = RecordClass::Inference; + derived.derived_from = vec!["base".into()]; + memory.ingest(derived, NOW).unwrap(); + assert_eq!(memory.purge_expired(NOW + 11), 2); + + memory + .ingest( + record("new", partition.clone(), "sensor", 2, [1.0, 0.0, 0.0]), + NOW, + ) + .unwrap(); + assert_eq!(memory.erase_partition(&partition), 1); + assert!(memory.is_empty()); + } + + #[test] + fn record_erasure_cascades_to_dependent_inferences_only() { + let partition = key("tenant", "ws"); + let mut memory = SpatialMemory::new(3).unwrap(); + memory + .ingest( + record("base", partition.clone(), "sensor-a", 1, [1.0, 0.0, 0.0]), + NOW, + ) + .unwrap(); + memory + .ingest( + record( + "independent", + partition.clone(), + "sensor-b", + 1, + [0.0, 1.0, 0.0], + ), + NOW, + ) + .unwrap(); + let mut derived = record("derived", partition.clone(), "model", 1, [0.9, 0.1, 0.0]); + derived.class = RecordClass::Inference; + derived.derived_from = vec!["base".into()]; + memory.ingest(derived, NOW).unwrap(); + assert_eq!(memory.erase_record(&partition, "base"), 2); + let matches = memory.search(&query(partition)).unwrap(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].record_id, "independent"); + } + + #[test] + fn encrypted_snapshot_rejects_tamper_wrong_key_and_overwrite() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("memory.rvsm"); + let mut memory = SpatialMemory::new(3).unwrap(); + memory + .ingest( + record("base", key("tenant", "ws"), "sensor", 1, [1.0, 0.0, 0.0]), + NOW, + ) + .unwrap(); + let old_key = [3u8; 32]; + memory.save_new(&path, "key-1", &old_key).unwrap(); + assert_eq!( + memory.save_new(&path, "key-1", &old_key), + Err(Error::AlreadyExists) + ); + assert_eq!( + SpatialMemory::load(&path, |_| Some([4u8; 32]), NOW).unwrap_err(), + Error::InvalidEnvelope + ); + + let loaded = + SpatialMemory::load(&path, |id| (id == "key-1").then_some(old_key), NOW).unwrap(); + assert_eq!(loaded.len(), 1); + let mut bytes = fs::read(&path).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 1; + fs::write(&path, bytes).unwrap(); + assert_eq!( + SpatialMemory::load(&path, |_| Some(old_key), NOW).unwrap_err(), + Error::InvalidEnvelope + ); + } + + #[test] + fn key_rotation_writes_new_generation_and_preserves_source() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("old.rvsm"); + let destination = directory.path().join("new.rvsm"); + let mut memory = SpatialMemory::new(3).unwrap(); + memory + .ingest( + record("base", key("tenant", "ws"), "sensor", 1, [1.0, 0.0, 0.0]), + NOW, + ) + .unwrap(); + let old_key = [5u8; 32]; + let new_key = [6u8; 32]; + memory.save_new(&source, "old-key", &old_key).unwrap(); + let rotated = SpatialMemory::rotate_key( + &source, + &destination, + |_| Some(old_key), + "new-key", + &new_key, + NOW, + ) + .unwrap(); + assert_eq!(rotated.len(), 1); + assert!(source.exists()); + assert_eq!( + SpatialMemory::load(&destination, |id| (id == "new-key").then_some(new_key), NOW) + .unwrap() + .len(), + 1 + ); + } +} diff --git a/v2/crates/wifi-densepose-cli/src/spaces.rs b/v2/crates/wifi-densepose-cli/src/spaces.rs index cf6bd0cd..890b8106 100644 --- a/v2/crates/wifi-densepose-cli/src/spaces.rs +++ b/v2/crates/wifi-densepose-cli/src/spaces.rs @@ -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 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, + /// Versioned hierarchy/event/alert collection. Omit for the legacy flat projection. + #[arg(long, value_enum)] + pub resource: Option, + + /// 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, + + /// API-key compatibility only: exact workspace UUID. OAuth derives this from its signed token. + #[arg(long, requires = "resource")] + pub workspace_id: Option, + /// 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(());