feat(spaces): add spatial memory and governed actions (#1650)

This commit is contained in:
rUv 2026-08-19 13:23:29 -04:00 committed by GitHub
parent d36f346bba
commit c929bbc8b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 3192 additions and 94 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <AGGREGATOR_HOST>`, 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 <uuid>`; 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.

View file

@ -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 <private-file>` 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.

View file

@ -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": {

View file

@ -1 +1 @@
e80b6abc4b8a0f99a154a470c4919ffc3886a715ec7f327f5f2f991bc7bf4293 manifest.json
45df19e5f93f14a1365ca9341a0838fbe79231ecfbac6911e24e24d3530107ee manifest.json

View file

@ -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

View file

@ -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 '<opaque-next-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 1100 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

View file

@ -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 <file>] 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);

View file

@ -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"

View file

@ -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": {

View file

@ -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 <private-file>` 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.

View file

@ -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.',
],
},
{

View file

@ -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.',

View file

@ -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 = {}) {

View file

@ -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',

16
v2/Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -14,6 +14,7 @@ use url::Url;
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
const MAX_SPACES: usize = 100;
const MAX_JSON_DEPTH: usize = 16;
const MAX_JSON_NODES: usize = 10_000;
const MAX_STRING_BYTES: usize = 4096;
const REQUIRED_EXCLUSIONS: [&str; 7] = [
"raw_csi",
@ -73,6 +74,8 @@ pub enum Error {
InvalidUrl,
#[error("invalid or empty credential")]
InvalidCredential,
#[error("invalid Spaces request: {0}")]
InvalidRequest(String),
#[error("Spaces request failed: {0}")]
Transport(#[from] reqwest::Error),
#[error("Spaces rejected the credential ({0})")]
@ -89,6 +92,7 @@ pub enum Error {
#[derive(Clone, Debug)]
pub struct Client {
base: Url,
endpoint: Url,
credential: Credential,
http: reqwest::Client,
@ -121,6 +125,7 @@ impl Client {
))
.build()?;
Ok(Self {
base,
endpoint,
credential,
http,
@ -128,10 +133,44 @@ impl Client {
}
pub async fn list(&self) -> Result<SpacesResponse, Error> {
let mut request = self
.http
.get(self.endpoint.clone())
.header("Accept", "application/json");
let body = self.get(self.endpoint.clone()).await?;
decode(&body)
}
/// Read one stable page from the versioned Cognitum spatial hierarchy.
/// This is a read-only method; the client exposes no publisher, approval,
/// command, or actuator operation.
pub async fn list_spatial(
&self,
kind: SpatialKind,
page: &PageRequest,
) -> Result<SpatialResponse, Error> {
page.validate()?;
if matches!(self.credential, Credential::ApiKey(_)) && page.workspace_id.is_none() {
return Err(Error::InvalidRequest(
"API-key spatial reads require a workspace id".into(),
));
}
let mut endpoint = self
.base
.join(&format!("/v1/spatial/{}", kind.as_str()))
.map_err(|_| Error::InvalidUrl)?;
{
let mut query = endpoint.query_pairs_mut();
query.append_pair("limit", &page.limit.to_string());
if let Some(cursor) = &page.cursor {
query.append_pair("cursor", cursor);
}
if let Some(workspace_id) = &page.workspace_id {
query.append_pair("workspaceId", workspace_id);
}
}
let body = self.get(endpoint).await?;
decode_spatial(&body, kind)
}
async fn get(&self, endpoint: Url) -> Result<Vec<u8>, Error> {
let mut request = self.http.get(endpoint).header("Accept", "application/json");
request = match &self.credential {
Credential::OAuth(token) => request.bearer_auth(token),
Credential::ApiKey(key) => request.header("X-API-Key", key),
@ -169,10 +208,162 @@ impl Client {
}
body.extend_from_slice(&chunk);
}
decode(&body)
Ok(body)
}
}
/// Versioned resource collections available from `/v1/spatial`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpatialKind {
Sites,
Buildings,
Floors,
Spaces,
Zones,
Entities,
Events,
Alerts,
}
impl SpatialKind {
/// Stable wire path segment.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Sites => "sites",
Self::Buildings => "buildings",
Self::Floors => "floors",
Self::Spaces => "spaces",
Self::Zones => "zones",
Self::Entities => "entities",
Self::Events => "events",
Self::Alerts => "alerts",
}
}
}
/// Bounded stable-page request. OAuth derives its workspace from the signed
/// token; the optional workspace id exists only for the legacy API-key path.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PageRequest {
pub limit: u8,
pub cursor: Option<String>,
pub workspace_id: Option<String>,
}
impl Default for PageRequest {
fn default() -> Self {
Self {
limit: 50,
cursor: None,
workspace_id: None,
}
}
}
impl PageRequest {
fn validate(&self) -> Result<(), Error> {
if self.limit == 0 || self.limit > 100 {
return Err(Error::InvalidRequest("limit must be from 1 to 100".into()));
}
if self.cursor.as_ref().is_some_and(|value| {
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
}) {
return Err(Error::InvalidRequest("cursor is invalid".into()));
}
if self
.workspace_id
.as_ref()
.is_some_and(|value| !is_uuid(value))
{
return Err(Error::InvalidRequest("workspace id must be a UUID".into()));
}
Ok(())
}
}
fn is_uuid(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 36
&& [8, 13, 18, 23].iter().all(|&index| bytes[index] == b'-')
&& matches!(bytes[14], b'1'..=b'8')
&& matches!(bytes[19].to_ascii_lowercase(), b'8' | b'9' | b'a' | b'b')
&& bytes
.iter()
.enumerate()
.all(|(index, byte)| [8, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit())
}
fn valid_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 120
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b':' | b'-'))
}
fn valid_timestamp(value: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(value).is_ok()
}
fn optional_id_valid(value: Option<&str>) -> bool {
value.is_none_or(valid_id)
}
/// One versioned P2/P3 hierarchy/event/alert page.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpatialResponse {
pub object: String,
pub kind: SpatialKind,
pub schema_version: String,
pub data: Vec<SpatialResource>,
pub next_cursor: Option<String>,
pub boundary: DataBoundary,
}
/// Common bounded spatial resource. Kind-specific fields stay in `attributes`;
/// tenant/workspace and lineage fields remain typed and independently checked.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpatialResource {
pub id: String,
pub tenant_id: String,
pub workspace_id: String,
pub kind: SpatialKind,
pub schema_version: String,
pub privacy: String,
pub message_id: String,
pub event_sequence: u64,
pub version: u64,
pub site_id: Option<String>,
pub building_id: Option<String>,
pub floor_id: Option<String>,
pub space_id: Option<String>,
pub zone_id: Option<String>,
pub name: Option<String>,
pub entity_type: Option<String>,
pub identity_mode: Option<String>,
pub event_type: Option<String>,
pub alert_type: Option<String>,
pub severity: Option<String>,
pub status: Option<String>,
#[serde(default)]
pub related_event_ids: Vec<String>,
pub observed_at: String,
pub expires_at: Option<String>,
pub retention_expires_at: Option<String>,
pub confidence: Option<f64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(default)]
pub attributes: Value,
#[serde(default)]
pub provenance: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpacesResponse {
@ -272,7 +463,178 @@ pub fn decode(bytes: &[u8]) -> Result<SpacesResponse, Error> {
Ok(response)
}
/// Decode and independently enforce one `/v1/spatial/{kind}` page.
pub fn decode_spatial(bytes: &[u8], expected_kind: SpatialKind) -> Result<SpatialResponse, Error> {
if bytes.len() > MAX_RESPONSE_BYTES {
return Err(Error::ResponseTooLarge);
}
let value: Value = serde_json::from_slice(bytes)
.map_err(|_| Error::InvalidResponse("malformed JSON".into()))?;
validate_value(&value, 0)?;
let response: SpatialResponse = serde_json::from_value(value)
.map_err(|error| Error::InvalidResponse(format!("spatial schema mismatch: {error}")))?;
if response.object != "list"
|| response.kind != expected_kind
|| response.schema_version != "1.0"
|| response.data.len() > MAX_SPACES
{
return Err(Error::InvalidResponse(
"invalid spatial list envelope".into(),
));
}
if response.boundary.authoritative_state != "HomeCore Edge"
|| REQUIRED_EXCLUSIONS.iter().any(|required| {
!response
.boundary
.excluded
.iter()
.any(|excluded| excluded == required)
})
{
return Err(Error::InvalidResponse(
"incomplete edge privacy boundary".into(),
));
}
if response.next_cursor.as_ref().is_some_and(|cursor| {
cursor.is_empty() || cursor.len() > 512 || cursor.chars().any(char::is_control)
}) {
return Err(Error::InvalidResponse("invalid next cursor".into()));
}
for record in &response.data {
if !valid_id(&record.id)
|| record.tenant_id.is_empty()
|| !is_uuid(&record.workspace_id)
|| record.kind != expected_kind
|| record.schema_version != "1.0"
|| !valid_id(&record.message_id)
|| record.version == 0
|| !valid_timestamp(&record.observed_at)
|| record
.expires_at
.as_deref()
.is_some_and(|value| !valid_timestamp(value))
|| record
.retention_expires_at
.as_deref()
.is_some_and(|value| !valid_timestamp(value))
|| record
.created_at
.as_deref()
.is_some_and(|value| !valid_timestamp(value))
|| record
.updated_at
.as_deref()
.is_some_and(|value| !valid_timestamp(value))
|| !optional_id_valid(record.site_id.as_deref())
|| !optional_id_valid(record.building_id.as_deref())
|| !optional_id_valid(record.floor_id.as_deref())
|| !optional_id_valid(record.space_id.as_deref())
|| !optional_id_valid(record.zone_id.as_deref())
|| record.related_event_ids.len() > 32
|| record.related_event_ids.iter().any(|id| !valid_id(id))
|| record
.related_event_ids
.iter()
.enumerate()
.any(|(index, id)| record.related_event_ids[..index].contains(id))
|| !record.attributes.is_object()
|| !record.provenance.is_object()
{
return Err(Error::InvalidResponse(
"spatial resource identity is incomplete".into(),
));
}
if let Some(expires_at) = record.expires_at.as_deref() {
let observed = chrono::DateTime::parse_from_rfc3339(&record.observed_at)
.map_err(|_| Error::InvalidResponse("invalid observed timestamp".into()))?;
let expires = chrono::DateTime::parse_from_rfc3339(expires_at)
.map_err(|_| Error::InvalidResponse("invalid expiry timestamp".into()))?;
if expires <= observed {
return Err(Error::InvalidResponse(
"expiry must follow observation".into(),
));
}
}
if !matches!(record.privacy.as_str(), "P2" | "P3") {
return Err(Error::InvalidResponse("non-semantic privacy class".into()));
}
if record
.confidence
.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
{
return Err(Error::InvalidResponse("invalid confidence".into()));
}
if matches!(record.kind, SpatialKind::Buildings | SpatialKind::Floors)
&& record.site_id.as_deref().is_none_or(str::is_empty)
{
return Err(Error::InvalidResponse(
"spatial parent is incomplete".into(),
));
}
if matches!(record.kind, SpatialKind::Spaces)
&& (record.site_id.as_deref().is_none_or(str::is_empty)
|| record.building_id.as_deref().is_none_or(str::is_empty)
|| record.floor_id.as_deref().is_none_or(str::is_empty))
{
return Err(Error::InvalidResponse(
"spatial parent is incomplete".into(),
));
}
if matches!(
record.kind,
SpatialKind::Zones | SpatialKind::Entities | SpatialKind::Events | SpatialKind::Alerts
) && (record.site_id.as_deref().is_none_or(str::is_empty)
|| record.space_id.as_deref().is_none_or(str::is_empty))
{
return Err(Error::InvalidResponse(
"spatial parent is incomplete".into(),
));
}
if record.kind == SpatialKind::Entities
&& (!matches!(
record.entity_type.as_deref(),
Some("sensor" | "person" | "object" | "track")
) || matches!(record.entity_type.as_deref(), Some("person" | "track"))
&& record.identity_mode.as_deref() != Some("anonymous"))
{
return Err(Error::InvalidResponse(
"entity privacy contract is invalid".into(),
));
}
if record.kind == SpatialKind::Events
&& record.event_type.as_deref().is_none_or(str::is_empty)
{
return Err(Error::InvalidResponse("event type is missing".into()));
}
if record.kind == SpatialKind::Alerts
&& (record.alert_type.as_deref().is_none_or(str::is_empty)
|| !matches!(
record.severity.as_deref(),
Some("info" | "warning" | "critical")
)
|| !matches!(
record.status.as_deref(),
Some("open" | "acknowledged" | "resolved")
))
{
return Err(Error::InvalidResponse("alert contract is invalid".into()));
}
}
Ok(response)
}
fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
let mut nodes = 0;
validate_value_inner(value, depth, &mut nodes)
}
fn validate_value_inner(value: &Value, depth: usize, nodes: &mut usize) -> Result<(), Error> {
*nodes = nodes.saturating_add(1);
if *nodes > MAX_JSON_NODES {
return Err(Error::InvalidResponse(
"JSON structure exceeds node bound".into(),
));
}
if depth > MAX_JSON_DEPTH {
return Err(Error::InvalidResponse("JSON nesting is too deep".into()));
}
@ -285,7 +647,7 @@ fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
}
Value::Array(items) => {
for item in items {
validate_value(item, depth + 1)?;
validate_value_inner(item, depth + 1, nodes)?;
}
}
Value::Object(map) => {
@ -303,19 +665,41 @@ fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
.collect();
if matches!(
normalized.as_str(),
"rawcsi"
"csi"
| "rawcsi"
| "channelstateinformation"
| "cir"
| "rawcir"
| "channelimpulseresponse"
| "rftensor"
| "rftensors"
| "packetcapture"
| "packetcaptures"
| "pcap"
| "recording"
| "recordings"
| "audiorecording"
| "videorecording"
| "poseframe"
| "poseframes"
| "skeleton"
| "keypoints"
| "vitalwaveform"
| "vitalwaveforms"
| "heartratewaveform"
| "identityobservation"
| "identityobservations"
| "biometric"
| "biometrics"
| "face"
| "faces"
| "faceembedding"
) {
return Err(Error::InvalidResponse(format!(
"forbidden raw field: {key}"
)));
}
validate_value(item, depth + 1)?;
validate_value_inner(item, depth + 1, nodes)?;
}
}
_ => {}
@ -331,6 +715,10 @@ mod tests {
br#"{"object":"list","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"workspace-1","siteId":"site-1","name":"Room","version":1,"privacy":"P2","status":"live","connection":"connected","state":{"occupancy":1,"confidence":0.9,"observedAt":"2026-08-17T00:00:00Z","freshnessMs":5,"classification":"P2","uncertainty":null,"evidence":[]},"provenance":{},"hardware":{},"dataBoundary":{},"observedAt":"2026-08-17T00:00:00Z","expiresAt":null}],"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
}
fn valid_spatial() -> Vec<u8> {
br#"{"object":"list","kind":"spaces","schemaVersion":"1.0","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"22222222-2222-4222-8222-222222222222","kind":"spaces","schemaVersion":"1.0","privacy":"P2","messageId":"message-1","eventSequence":7,"version":1,"siteId":"site-1","buildingId":"building-1","floorId":"floor-1","spaceId":null,"zoneId":null,"name":"Room","observedAt":"2026-08-19T12:00:00Z","expiresAt":null,"retentionExpiresAt":null,"confidence":0.8,"attributes":{"occupancy":2},"provenance":{"witnessDigest":"abc"}}],"nextCursor":null,"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant/workspace-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
}
#[test]
fn accepts_bounded_semantic_state() {
assert_eq!(decode(&valid()).unwrap().data.len(), 1);
@ -384,4 +772,78 @@ mod tests {
let c = Credential::oauth("secret-token").unwrap();
assert!(!format!("{c:?}").contains("secret-token"));
}
#[test]
fn accepts_versioned_spatial_pages() {
let response = decode_spatial(&valid_spatial(), SpatialKind::Spaces).unwrap();
assert_eq!(response.data.len(), 1);
assert_eq!(response.data[0].event_sequence, 7);
}
#[test]
fn spatial_page_is_bound_to_requested_kind_and_parents() {
assert!(decode_spatial(&valid_spatial(), SpatialKind::Events).is_err());
let mut value: Value = serde_json::from_slice(&valid_spatial()).unwrap();
value["data"][0]["floorId"] = Value::Null;
assert!(matches!(
decode_spatial(&serde_json::to_vec(&value).unwrap(), SpatialKind::Spaces),
Err(Error::InvalidResponse(_))
));
}
#[test]
fn spatial_page_rejects_cross_boundary_payload_and_bad_workspace() {
let mut raw: Value = serde_json::from_slice(&valid_spatial()).unwrap();
raw["data"][0]["attributes"]["pose_frames"] = serde_json::json!([1]);
assert!(decode_spatial(&serde_json::to_vec(&raw).unwrap(), SpatialKind::Spaces).is_err());
let mut workspace: Value = serde_json::from_slice(&valid_spatial()).unwrap();
workspace["data"][0]["workspaceId"] = Value::String("not-a-uuid".into());
assert!(decode_spatial(
&serde_json::to_vec(&workspace).unwrap(),
SpatialKind::Spaces
)
.is_err());
let mut timestamp: Value = serde_json::from_slice(&valid_spatial()).unwrap();
timestamp["data"][0]["observedAt"] = Value::String("not-a-timestamp".into());
assert!(decode_spatial(
&serde_json::to_vec(&timestamp).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());
}
}

View file

@ -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

View file

@ -0,0 +1,995 @@
//! Governed action intents and witnessed authorization receipts (ADR-327).
//!
//! This module never touches an actuator. Its strongest outcome is an
//! `Authorized` receipt that a separate, explicitly configured adapter may
//! consume. Observe and recommend are the default modes; execute fails closed
//! unless a registered policy, live assurance, and signed approvals all pass.
use crate::{authorize, ActionClass, AssuranceInputs, Authorization, FailedCondition};
use ruview_attest::{Signature, Signer, Verifier};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
const INTENT_DOMAIN: &[u8] = b"ruview.governed-intent.v1\0";
const APPROVAL_DOMAIN: &[u8] = b"ruview.governed-approval.v1\0";
const RECEIPT_DOMAIN: &[u8] = b"ruview.governed-receipt.v1\0";
const MAX_ID_BYTES: usize = 128;
const MAX_APPROVALS: usize = 16;
const MAX_TARGET_PREFIXES: usize = 32;
const MAX_INTENT_LIFETIME_MS: i64 = 86_400_000;
const MAX_RECEIPTS: usize = 10_000;
/// Requested governance mode. Automation should default to `Recommend`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum IntentMode {
/// Record a governed observation without proposing a consequence.
Observe,
/// Produce a recommendation for human/policy review.
Recommend,
/// Request an authorization receipt for a separately configured adapter.
Execute,
}
impl Default for IntentMode {
fn default() -> Self {
Self::Recommend
}
}
/// A typed, bounded request. Parameters are represented only by a digest.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionIntent {
/// Idempotency key for this exact attempt.
pub intent_id: String,
/// Authenticated tenant identifier.
pub tenant_id: String,
/// Authenticated workspace identifier.
pub workspace_id: String,
/// Registered action kind, such as `alert.raise`.
pub action_kind: String,
/// Exact registered policy version requested by this intent.
pub policy_version: String,
/// Bounded target identifier.
pub target_id: String,
/// Consequence/assurance class.
pub class: ActionClass,
/// Observe, recommend, or explicitly request authorization.
#[serde(default)]
pub mode: IntentMode,
/// Authenticated requesting principal or agent.
pub requested_by: String,
/// Intent creation time in Unix milliseconds.
pub issued_at_ms: i64,
/// Hard expiry in Unix milliseconds.
pub expires_at_ms: i64,
/// Caller-generated replay nonce. All zeroes are invalid.
pub nonce: [u8; 16],
/// Digest of canonical adapter parameters; raw values are not logged here.
pub parameters_digest: [u8; 32],
/// Digest of the governed perception/evidence input.
pub evidence_digest: [u8; 32],
}
impl ActionIntent {
/// Deterministic bytes bound into approvals and receipts.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(512);
out.extend_from_slice(INTENT_DOMAIN);
for value in [
self.intent_id.as_str(),
self.tenant_id.as_str(),
self.workspace_id.as_str(),
self.action_kind.as_str(),
self.policy_version.as_str(),
self.target_id.as_str(),
self.requested_by.as_str(),
] {
push_field(&mut out, value.as_bytes());
}
out.push(self.class as u8);
out.push(self.mode as u8);
out.extend_from_slice(&self.issued_at_ms.to_le_bytes());
out.extend_from_slice(&self.expires_at_ms.to_le_bytes());
out.extend_from_slice(&self.nonce);
out.extend_from_slice(&self.parameters_digest);
out.extend_from_slice(&self.evidence_digest);
out
}
/// Digest used as the immutable idempotency fingerprint.
pub fn digest(&self) -> [u8; 32] {
*blake3::hash(&self.canonical_bytes()).as_bytes()
}
}
/// A versioned, locally registered execution rule.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionRule {
/// Exact action kind this rule governs.
pub action_kind: String,
/// Monotonic/configuration version included in approvals and receipts.
pub policy_version: String,
/// Required assurance class. Intent class must match exactly.
pub class: ActionClass,
/// Number of distinct valid human/service approvals (at least one).
pub minimum_approvals: usize,
/// Trusted execution grant required in addition to perception assurance.
pub required_grant: String,
/// At least one prefix must match the target identifier.
pub target_prefixes: Vec<String>,
}
/// Local allow-list of action rules. Absence is a deny.
#[derive(Clone, Debug, Default)]
pub struct PolicyRegistry {
rules: BTreeMap<String, ActionRule>,
}
impl PolicyRegistry {
/// Register one valid rule; duplicate action kinds are refused.
pub fn register(&mut self, rule: ActionRule) -> Result<(), GovernanceError> {
validate_id(&rule.action_kind)?;
validate_id(&rule.policy_version)?;
validate_id(&rule.required_grant)?;
if rule.minimum_approvals == 0 || rule.minimum_approvals > MAX_APPROVALS {
return Err(GovernanceError::InvalidInput(
"approval threshold is out of bounds",
));
}
if rule.target_prefixes.is_empty() || rule.target_prefixes.len() > MAX_TARGET_PREFIXES {
return Err(GovernanceError::InvalidInput(
"target prefix list is out of bounds",
));
}
for prefix in &rule.target_prefixes {
validate_id(prefix)?;
}
if self.rules.contains_key(&rule.action_kind) {
return Err(GovernanceError::PolicyConflict);
}
self.rules.insert(rule.action_kind.clone(), rule);
Ok(())
}
fn get(&self, action_kind: &str) -> Option<&ActionRule> {
self.rules.get(action_kind)
}
}
/// Trusted grants established by the host authorization adapter.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AuthorityContext {
grants: BTreeSet<String>,
}
impl AuthorityContext {
/// Build a bounded set of authenticated grants. Strings are exact-match.
pub fn from_authenticated_grants<I, S>(grants: I) -> Result<Self, GovernanceError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut values = BTreeSet::new();
for (index, grant) in grants.into_iter().enumerate() {
if index >= MAX_APPROVALS {
return Err(GovernanceError::InvalidInput(
"authority grant set is out of bounds",
));
}
let grant = grant.into();
validate_id(&grant)?;
values.insert(grant);
}
Ok(Self { grants: values })
}
fn contains(&self, grant: &str) -> bool {
self.grants.contains(grant)
}
}
/// Human or service approval decision.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ApprovalDecision {
/// Explicit approval.
Approve,
/// Explicit rejection; any valid rejection denies this attempt.
Reject,
}
/// Content signed by one registered approver.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApprovalContent {
/// Intent digest prevents approval substitution.
pub intent_digest: [u8; 32],
/// Exact policy version reviewed by the approver.
pub policy_version: String,
/// Registered approver identity.
pub approver_id: String,
/// Explicit approve/reject decision.
pub decision: ApprovalDecision,
/// Approval timestamp in Unix milliseconds.
pub approved_at_ms: i64,
}
impl ApprovalContent {
/// Deterministic signing bytes.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
out.extend_from_slice(APPROVAL_DOMAIN);
out.extend_from_slice(&self.intent_digest);
push_field(&mut out, self.policy_version.as_bytes());
push_field(&mut out, self.approver_id.as_bytes());
out.push(self.decision as u8);
out.extend_from_slice(&self.approved_at_ms.to_le_bytes());
out
}
}
/// Signed approval envelope.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedApproval {
/// Signed approval content.
pub content: ApprovalContent,
/// Attestation signature/MAC.
pub signature: Signature,
}
impl SignedApproval {
/// Sign approval content with an enrolled signer.
pub fn sign<S: Signer + ?Sized>(content: ApprovalContent, signer: &S) -> Self {
let signature = signer.sign(&content.canonical_bytes());
Self { content, signature }
}
}
/// Resolves approver identities to enrolled verification keys.
pub trait ApprovalVerifier {
/// Return true only for a registered identity and valid signature.
fn verify(&self, approver_id: &str, message: &[u8], signature: &Signature) -> bool;
}
/// Stable terminal reason for a non-authorized receipt.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DenialReason {
/// Intent was expired or not yet valid.
IntentExpired,
/// Action kind has no registered policy.
NoPolicy,
/// Intent class does not match the registered policy.
ClassMismatch,
/// Intent names a policy version other than the registered version.
PolicyVersionMismatch,
/// Trusted host authority lacks the exact policy grant.
MissingAuthority,
/// Target is outside the registered allow-list.
TargetNotAllowed,
/// Too few distinct, valid, explicit approvals.
InsufficientApprovals,
/// An approval was malformed, rejected, duplicated, or unauthenticated.
InvalidApproval,
/// Existing assurance policy denied the requested class.
AssuranceDenied(FailedCondition),
}
/// Terminal governance decision. `Authorized` is not proof of actuation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GovernedDecision {
/// Observation was witnessed only.
Observed,
/// Recommendation was witnessed and awaits a new execute intent.
Recommended,
/// A separate configured adapter may execute this exact intent.
Authorized,
/// Authorization failed closed.
Denied(DenialReason),
}
/// Signed, hash-chained receipt content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReceiptContent {
/// Monotonic sequence within this engine instance.
pub sequence: u64,
/// Engine/service identity issuing the receipt.
pub issuer_id: String,
/// Exact intent digest.
pub intent_digest: [u8; 32],
/// Intent idempotency key for lookup.
pub intent_id: String,
/// Authenticated tenant/workspace copied from the intent.
pub tenant_id: String,
/// Authenticated tenant/workspace copied from the intent.
pub workspace_id: String,
/// Registered policy version, if a policy was found.
pub policy_version: Option<String>,
/// Terminal governance decision.
pub decision: GovernedDecision,
/// Number of distinct verified approvals used.
pub verified_approvals: usize,
/// Decision timestamp supplied by the caller.
pub decided_at_ms: i64,
/// Intent expiry copied into the receipt for adapter-side checks.
pub expires_at_ms: i64,
/// Non-secret replay nonce copied into the signed receipt.
pub nonce: [u8; 16],
/// Previous receipt digest; zeroes start a chain.
pub previous_receipt_digest: [u8; 32],
}
impl ReceiptContent {
/// Deterministic bytes for signing and chain hashing.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(512);
out.extend_from_slice(RECEIPT_DOMAIN);
out.extend_from_slice(&self.sequence.to_le_bytes());
for value in [
self.issuer_id.as_str(),
self.intent_id.as_str(),
self.tenant_id.as_str(),
self.workspace_id.as_str(),
] {
push_field(&mut out, value.as_bytes());
}
out.extend_from_slice(&self.intent_digest);
match &self.policy_version {
Some(version) => {
out.push(1);
push_field(&mut out, version.as_bytes());
}
None => out.push(0),
}
push_decision(&mut out, &self.decision);
out.extend_from_slice(&(self.verified_approvals as u64).to_le_bytes());
out.extend_from_slice(&self.decided_at_ms.to_le_bytes());
out.extend_from_slice(&self.expires_at_ms.to_le_bytes());
out.extend_from_slice(&self.nonce);
out.extend_from_slice(&self.previous_receipt_digest);
out
}
}
/// Signed receipt. It authorizes at most; it never asserts physical execution.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ActionReceipt {
/// Signed content.
pub content: ReceiptContent,
/// Signature over canonical content bytes.
pub signature: Signature,
}
impl ActionReceipt {
/// Verify the issuer signature.
pub fn verify<V: Verifier + ?Sized>(&self, verifier: &V) -> bool {
verifier.verify(&self.content.canonical_bytes(), &self.signature)
}
/// Digest used by the next receipt's chain link.
pub fn digest(&self) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&self.content.canonical_bytes());
hasher.update(&self.signature.0);
*hasher.finalize().as_bytes()
}
}
#[derive(Clone, Debug)]
struct StoredReceipt {
intent_digest: [u8; 32],
receipt: ActionReceipt,
}
/// Stateful governance boundary providing idempotency and receipt chaining.
#[derive(Clone, Debug)]
pub struct GovernanceEngine {
issuer_id: String,
policies: PolicyRegistry,
receipts: BTreeMap<String, StoredReceipt>,
nonces: BTreeMap<(String, String, [u8; 16]), [u8; 32]>,
next_sequence: u64,
previous_receipt_digest: [u8; 32],
}
impl GovernanceEngine {
/// Create an engine with an explicit local policy registry.
pub fn new(issuer_id: String, policies: PolicyRegistry) -> Result<Self, GovernanceError> {
validate_id(&issuer_id)?;
Ok(Self {
issuer_id,
policies,
receipts: BTreeMap::new(),
nonces: BTreeMap::new(),
next_sequence: 1,
previous_receipt_digest: [0; 32],
})
}
/// Evaluate and witness one intent. A repeated identical intent returns the
/// exact prior receipt; changed reuse of its idempotency key is rejected.
pub fn evaluate<S: Signer + ?Sized, V: ApprovalVerifier + ?Sized>(
&mut self,
intent: &ActionIntent,
authority: &AuthorityContext,
assurance: &AssuranceInputs,
approvals: &[SignedApproval],
approval_verifier: &V,
receipt_signer: &S,
now_ms: i64,
) -> Result<ActionReceipt, GovernanceError> {
validate_intent(intent)?;
let intent_digest = intent.digest();
if let Some(stored) = self.receipts.get(&intent.intent_id) {
return if stored.intent_digest == intent_digest {
Ok(stored.receipt.clone())
} else {
Err(GovernanceError::IdempotencyConflict)
};
}
if self.receipts.len() >= MAX_RECEIPTS {
return Err(GovernanceError::CapacityReached);
}
let nonce_key = (
intent.tenant_id.clone(),
intent.workspace_id.clone(),
intent.nonce,
);
if self.nonces.contains_key(&nonce_key) {
return Err(GovernanceError::NonceReplay);
}
let rule = self.policies.get(&intent.action_kind);
let (decision, verified_approvals) =
if now_ms < intent.issued_at_ms || now_ms >= intent.expires_at_ms {
(GovernedDecision::Denied(DenialReason::IntentExpired), 0)
} else {
match intent.mode {
IntentMode::Observe => (GovernedDecision::Observed, 0),
IntentMode::Recommend => (GovernedDecision::Recommended, 0),
IntentMode::Execute => evaluate_execution(
intent,
intent_digest,
authority,
assurance,
approvals,
approval_verifier,
rule,
now_ms,
),
}
};
let policy_version = rule.map(|value| value.policy_version.clone());
let content = ReceiptContent {
sequence: self.next_sequence,
issuer_id: self.issuer_id.clone(),
intent_digest,
intent_id: intent.intent_id.clone(),
tenant_id: intent.tenant_id.clone(),
workspace_id: intent.workspace_id.clone(),
policy_version,
decision,
verified_approvals,
decided_at_ms: now_ms,
expires_at_ms: intent.expires_at_ms,
nonce: intent.nonce,
previous_receipt_digest: self.previous_receipt_digest,
};
let receipt = ActionReceipt {
signature: receipt_signer.sign(&content.canonical_bytes()),
content,
};
self.next_sequence = self
.next_sequence
.checked_add(1)
.ok_or(GovernanceError::SequenceExhausted)?;
self.previous_receipt_digest = receipt.digest();
self.receipts.insert(
intent.intent_id.clone(),
StoredReceipt {
intent_digest,
receipt: receipt.clone(),
},
);
self.nonces.insert(nonce_key, intent_digest);
Ok(receipt)
}
}
fn evaluate_execution<V: ApprovalVerifier + ?Sized>(
intent: &ActionIntent,
intent_digest: [u8; 32],
authority: &AuthorityContext,
assurance: &AssuranceInputs,
approvals: &[SignedApproval],
verifier: &V,
rule: Option<&ActionRule>,
now_ms: i64,
) -> (GovernedDecision, usize) {
if now_ms < intent.issued_at_ms || now_ms >= intent.expires_at_ms {
return (GovernedDecision::Denied(DenialReason::IntentExpired), 0);
}
let Some(rule) = rule else {
return (GovernedDecision::Denied(DenialReason::NoPolicy), 0);
};
if intent.class != rule.class {
return (GovernedDecision::Denied(DenialReason::ClassMismatch), 0);
}
if intent.policy_version != rule.policy_version {
return (
GovernedDecision::Denied(DenialReason::PolicyVersionMismatch),
0,
);
}
if !authority.contains(&rule.required_grant) {
return (GovernedDecision::Denied(DenialReason::MissingAuthority), 0);
}
if !rule
.target_prefixes
.iter()
.any(|prefix| intent.target_id.starts_with(prefix))
{
return (GovernedDecision::Denied(DenialReason::TargetNotAllowed), 0);
}
if approvals.len() > MAX_APPROVALS {
return (GovernedDecision::Denied(DenialReason::InvalidApproval), 0);
}
let mut distinct = BTreeSet::new();
for approval in approvals {
let content = &approval.content;
if validate_id(&content.approver_id).is_err()
|| content.intent_digest != intent_digest
|| content.policy_version != rule.policy_version
|| content.approved_at_ms < intent.issued_at_ms
|| content.approved_at_ms >= intent.expires_at_ms
|| content.approved_at_ms > now_ms
|| content.decision != ApprovalDecision::Approve
|| !distinct.insert(content.approver_id.as_str())
|| !verifier.verify(
&content.approver_id,
&content.canonical_bytes(),
&approval.signature,
)
{
return (
GovernedDecision::Denied(DenialReason::InvalidApproval),
distinct.len(),
);
}
}
if distinct.len() < rule.minimum_approvals {
return (
GovernedDecision::Denied(DenialReason::InsufficientApprovals),
distinct.len(),
);
}
match authorize(intent.class, assurance) {
Authorization::Allow { .. } => (GovernedDecision::Authorized, distinct.len()),
Authorization::Deny { failed_condition } => (
GovernedDecision::Denied(DenialReason::AssuranceDenied(failed_condition)),
distinct.len(),
),
}
}
/// Engine/configuration errors. Policy denials are signed receipts, not errors.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum GovernanceError {
/// Malformed caller/configuration input.
#[error("invalid governed-action input: {0}")]
InvalidInput(&'static str),
/// Duplicate action rule.
#[error("action policy already registered")]
PolicyConflict,
/// An intent idempotency key was reused with different content.
#[error("intent idempotency conflict")]
IdempotencyConflict,
/// A nonce was already bound to a different intent id.
#[error("intent nonce replay")]
NonceReplay,
/// The bounded in-memory replay store reached capacity.
#[error("governance receipt capacity reached")]
CapacityReached,
/// Receipt sequence exhausted.
#[error("receipt sequence exhausted")]
SequenceExhausted,
}
fn validate_intent(intent: &ActionIntent) -> Result<(), GovernanceError> {
for value in [
intent.intent_id.as_str(),
intent.tenant_id.as_str(),
intent.workspace_id.as_str(),
intent.action_kind.as_str(),
intent.policy_version.as_str(),
intent.target_id.as_str(),
intent.requested_by.as_str(),
] {
validate_id(value)?;
}
if intent.issued_at_ms >= intent.expires_at_ms
|| intent.expires_at_ms - intent.issued_at_ms > MAX_INTENT_LIFETIME_MS
|| intent.nonce == [0; 16]
{
return Err(GovernanceError::InvalidInput("intent lifetime is invalid"));
}
Ok(())
}
fn validate_id(value: &str) -> Result<(), GovernanceError> {
if value.is_empty()
|| value.len() > MAX_ID_BYTES
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
{
return Err(GovernanceError::InvalidInput("identifier is invalid"));
}
Ok(())
}
fn push_field(output: &mut Vec<u8>, field: &[u8]) {
output.extend_from_slice(&(field.len() as u32).to_le_bytes());
output.extend_from_slice(field);
}
fn push_decision(output: &mut Vec<u8>, decision: &GovernedDecision) {
match decision {
GovernedDecision::Observed => output.push(0),
GovernedDecision::Recommended => output.push(1),
GovernedDecision::Authorized => output.push(2),
GovernedDecision::Denied(reason) => {
output.push(3);
push_denial(output, reason);
}
}
}
fn push_denial(output: &mut Vec<u8>, reason: &DenialReason) {
match reason {
DenialReason::IntentExpired => output.push(0),
DenialReason::NoPolicy => output.push(1),
DenialReason::ClassMismatch => output.push(2),
DenialReason::PolicyVersionMismatch => output.push(3),
DenialReason::MissingAuthority => output.push(4),
DenialReason::TargetNotAllowed => output.push(5),
DenialReason::InsufficientApprovals => output.push(6),
DenialReason::InvalidApproval => output.push(7),
DenialReason::AssuranceDenied(condition) => {
output.push(8);
match condition {
FailedCondition::NoPolicy => output.push(0),
FailedCondition::CertificateInvalid => output.push(1),
FailedCondition::CertificateClassTooLow { required, actual } => {
output.extend_from_slice(&[2, *required as u8, *actual as u8]);
}
FailedCondition::CertificateStale { age_secs, max_secs } => {
output.push(3);
output.extend_from_slice(&age_secs.to_le_bytes());
output.extend_from_slice(&max_secs.to_le_bytes());
}
FailedCondition::DomainDegraded => output.push(4),
FailedCondition::DomainNotKnown => output.push(5),
FailedCondition::UncertaintyOverCeiling { max_uncertainty } => {
output.push(6);
output.extend_from_slice(&max_uncertainty.to_bits().to_le_bytes());
}
FailedCondition::EvidenceBelowFloor { required, actual } => {
output.extend_from_slice(&[7, *required as u8, *actual as u8]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CertificateClass, DomainState};
use ruview_attest::Blake3MacSigner;
use ruview_evidence::EvidenceLevel;
const NOW: i64 = 10_000;
struct Approvers(BTreeMap<String, Blake3MacSigner>);
impl ApprovalVerifier for Approvers {
fn verify(&self, approver_id: &str, message: &[u8], signature: &Signature) -> bool {
self.0
.get(approver_id)
.is_some_and(|key| Verifier::verify(key, message, signature))
}
}
fn registry() -> PolicyRegistry {
let mut registry = PolicyRegistry::default();
registry
.register(ActionRule {
action_kind: "alert.raise".into(),
policy_version: "v1".into(),
class: ActionClass::Security,
minimum_approvals: 1,
required_grant: "alerts:execute".into(),
target_prefixes: vec!["alert:".into()],
})
.unwrap();
registry
}
fn intent(mode: IntentMode) -> ActionIntent {
ActionIntent {
intent_id: "intent-1".into(),
tenant_id: "tenant-1".into(),
workspace_id: "workspace-1".into(),
action_kind: "alert.raise".into(),
policy_version: "v1".into(),
target_id: "alert:room-1".into(),
class: ActionClass::Security,
mode,
requested_by: "agent-1".into(),
issued_at_ms: NOW - 100,
expires_at_ms: NOW + 100,
nonce: [1; 16],
parameters_digest: [1; 32],
evidence_digest: [2; 32],
}
}
fn assurance() -> AssuranceInputs {
AssuranceInputs {
certificate_class: CertificateClass::Standard,
certificate_valid: true,
certificate_age_secs: 1,
domain_state: DomainState::Known,
uncertainty: 0.1,
evidence_level: EvidenceLevel::L2,
}
}
fn approvers() -> Approvers {
Approvers(BTreeMap::from([(
"human-1".into(),
Blake3MacSigner::new([3; 32]),
)]))
}
fn authority() -> AuthorityContext {
AuthorityContext::from_authenticated_grants(["alerts:execute"]).unwrap()
}
fn approval(intent: &ActionIntent) -> SignedApproval {
SignedApproval::sign(
ApprovalContent {
intent_digest: intent.digest(),
policy_version: "v1".into(),
approver_id: "human-1".into(),
decision: ApprovalDecision::Approve,
approved_at_ms: NOW - 1,
},
&Blake3MacSigner::new([3; 32]),
)
}
#[test]
fn observe_and_recommend_are_non_executing_defaults() {
let signer = Blake3MacSigner::new([9; 32]);
for (mode, expected) in [
(IntentMode::Observe, GovernedDecision::Observed),
(IntentMode::Recommend, GovernedDecision::Recommended),
] {
let mut engine =
GovernanceEngine::new("issuer".into(), PolicyRegistry::default()).unwrap();
let receipt = engine
.evaluate(
&intent(mode),
&authority(),
&assurance(),
&[],
&approvers(),
&signer,
NOW,
)
.unwrap();
assert_eq!(receipt.content.decision, expected);
assert!(receipt.verify(&signer));
}
}
#[test]
fn expired_observation_and_recommendation_intents_are_denied() {
let signer = Blake3MacSigner::new([9; 32]);
for mode in [IntentMode::Observe, IntentMode::Recommend] {
let mut request = intent(mode);
request.issued_at_ms = NOW - 200;
request.expires_at_ms = NOW - 1;
let mut engine =
GovernanceEngine::new("issuer".into(), PolicyRegistry::default()).unwrap();
let receipt = engine
.evaluate(
&request,
&authority(),
&assurance(),
&[],
&approvers(),
&signer,
NOW,
)
.unwrap();
assert_eq!(
receipt.content.decision,
GovernedDecision::Denied(DenialReason::IntentExpired)
);
assert!(receipt.verify(&signer));
}
}
#[test]
fn execute_requires_policy_signed_approval_and_assurance() {
let receipt_signer = Blake3MacSigner::new([9; 32]);
let request = intent(IntentMode::Execute);
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
let denied = engine
.evaluate(
&request,
&authority(),
&assurance(),
&[],
&approvers(),
&receipt_signer,
NOW,
)
.unwrap();
assert_eq!(
denied.content.decision,
GovernedDecision::Denied(DenialReason::InsufficientApprovals)
);
let mut second = request.clone();
second.intent_id = "intent-2".into();
second.nonce = [2; 16];
let authorized = engine
.evaluate(
&second,
&authority(),
&assurance(),
&[approval(&second)],
&approvers(),
&receipt_signer,
NOW,
)
.unwrap();
assert_eq!(authorized.content.decision, GovernedDecision::Authorized);
assert_eq!(authorized.content.previous_receipt_digest, denied.digest());
assert!(authorized.verify(&receipt_signer));
}
#[test]
fn invalid_approval_and_unknown_domain_fail_closed() {
let signer = Blake3MacSigner::new([9; 32]);
let request = intent(IntentMode::Execute);
let mut bad = approval(&request);
bad.signature.0[0] ^= 1;
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
let receipt = engine
.evaluate(
&request,
&authority(),
&assurance(),
&[bad],
&approvers(),
&signer,
NOW,
)
.unwrap();
assert_eq!(
receipt.content.decision,
GovernedDecision::Denied(DenialReason::InvalidApproval)
);
let mut second = request.clone();
second.intent_id = "intent-2".into();
second.nonce = [2; 16];
let mut weak = assurance();
weak.domain_state = DomainState::Unknown;
let receipt = engine
.evaluate(
&second,
&authority(),
&weak,
&[approval(&second)],
&approvers(),
&signer,
NOW,
)
.unwrap();
assert_eq!(
receipt.content.decision,
GovernedDecision::Denied(DenialReason::AssuranceDenied(
FailedCondition::DomainNotKnown
))
);
}
#[test]
fn spaces_read_is_not_execution_authority_and_nonce_reuse_is_rejected() {
let signer = Blake3MacSigner::new([9; 32]);
let request = intent(IntentMode::Execute);
let read_only = AuthorityContext::from_authenticated_grants(["spaces:read"]).unwrap();
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
let receipt = engine
.evaluate(
&request,
&read_only,
&assurance(),
&[approval(&request)],
&approvers(),
&signer,
NOW,
)
.unwrap();
assert_eq!(
receipt.content.decision,
GovernedDecision::Denied(DenialReason::MissingAuthority)
);
let mut changed_id = request;
changed_id.intent_id = "intent-other".into();
assert_eq!(
engine.evaluate(
&changed_id,
&authority(),
&assurance(),
&[approval(&changed_id)],
&approvers(),
&signer,
NOW,
),
Err(GovernanceError::NonceReplay)
);
}
#[test]
fn idempotency_is_exact_and_changed_reuse_is_rejected() {
let signer = Blake3MacSigner::new([9; 32]);
let request = intent(IntentMode::Recommend);
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
let first = engine
.evaluate(
&request,
&authority(),
&assurance(),
&[],
&approvers(),
&signer,
NOW,
)
.unwrap();
let replay = engine
.evaluate(
&request,
&authority(),
&assurance(),
&[],
&approvers(),
&signer,
NOW + 1,
)
.unwrap();
assert_eq!(first, replay);
let mut changed = request;
changed.parameters_digest = [0xAA; 32];
assert_eq!(
engine.evaluate(
&changed,
&authority(),
&assurance(),
&[],
&approvers(),
&signer,
NOW,
),
Err(GovernanceError::IdempotencyConflict)
);
}
}

View file

@ -63,6 +63,9 @@
use ruview_evidence::EvidenceLevel;
use serde::{Deserialize, Serialize};
/// Typed intent, approval, idempotency, and witnessed-receipt layer (ADR-327).
pub mod governed;
// ---------------------------------------------------------------------------
// Value types owned by this crate
// ---------------------------------------------------------------------------
@ -479,10 +482,8 @@ pub fn authorize_from_certificate<V: ruview_attest::Verifier + ?Sized>(
uncertainty: f64,
evidence_level: EvidenceLevel,
) -> Authorization {
let certificate_valid =
cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
let certificate_age_secs =
(now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
let certificate_valid = cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
let certificate_age_secs = (now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
authorize(
class,

View file

@ -0,0 +1,20 @@
[package]
name = "ruview-spatial-memory"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Tenant-scoped encrypted RuVector spatial memory for Cognitum Spaces"
[dependencies]
chacha20poly1305 = "0.10"
getrandom.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
wifi-densepose-ruvector = { path = "../wifi-densepose-ruvector" }
zeroize = "1"
[dev-dependencies]
tempfile = "3"

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,36 @@
use std::path::PathBuf;
use clap::Args;
use clap::{Args, ValueEnum};
use ruview_auth::{login, scope};
use ruview_cognitum_spaces::{Client, Credential};
use ruview_cognitum_spaces::{Client, Credential, PageRequest, SpatialKind};
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum SpatialResourceKind {
Sites,
Buildings,
Floors,
Spaces,
Zones,
Entities,
Events,
Alerts,
}
impl From<SpatialResourceKind> for SpatialKind {
fn from(value: SpatialResourceKind) -> Self {
match value {
SpatialResourceKind::Sites => Self::Sites,
SpatialResourceKind::Buildings => Self::Buildings,
SpatialResourceKind::Floors => Self::Floors,
SpatialResourceKind::Spaces => Self::Spaces,
SpatialResourceKind::Zones => Self::Zones,
SpatialResourceKind::Entities => Self::Entities,
SpatialResourceKind::Events => Self::Events,
SpatialResourceKind::Alerts => Self::Alerts,
}
}
}
#[derive(Debug, Args)]
pub struct SpacesArgs {
@ -20,6 +47,22 @@ pub struct SpacesArgs {
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
pub credentials_path: Option<PathBuf>,
/// Versioned hierarchy/event/alert collection. Omit for the legacy flat projection.
#[arg(long, value_enum)]
pub resource: Option<SpatialResourceKind>,
/// Page size for a versioned resource collection (1..=100).
#[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u8).range(1..=100), requires = "resource")]
pub limit: u8,
/// Opaque next-page cursor returned by a prior versioned read.
#[arg(long, requires = "resource")]
pub cursor: Option<String>,
/// API-key compatibility only: exact workspace UUID. OAuth derives this from its signed token.
#[arg(long, requires = "resource")]
pub workspace_id: Option<String>,
/// Emit the validated response as JSON.
#[arg(long)]
pub json: bool,
@ -46,7 +89,47 @@ pub async fn spaces_cmd(args: SpacesArgs) -> anyhow::Result<()> {
Credential::oauth(session.ensure_fresh().await?)?
}
};
let response = Client::new(&args.base_url, credential)?.list().await?;
let client = Client::new(&args.base_url, credential)?;
if let Some(resource) = args.resource {
let response = client
.list_spatial(
resource.into(),
&PageRequest {
limit: args.limit,
cursor: args.cursor,
workspace_id: args.workspace_id,
},
)
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
println!(
"Cognitum Spatial {}: {}",
response.kind.as_str(),
response.data.len()
);
println!(
"Boundary: {} / {}",
response.boundary.authoritative_state, response.boundary.cloud_role
);
for item in response.data {
println!(
"{}\tkind={}\tprivacy={}\tsite={}\tspace={}",
item.id,
item.kind.as_str(),
item.privacy,
item.site_id.as_deref().unwrap_or("-"),
item.space_id.as_deref().unwrap_or("-")
);
}
if let Some(cursor) = response.next_cursor {
println!("Next cursor: {cursor}");
}
return Ok(());
}
let response = client.list().await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());