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
28 changed files with 3192 additions and 94 deletions

View File

@@ -47,18 +47,18 @@ from the current tree when needed.
## RuView contributor harness ## 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. defined by ADR-283.
```bash ```bash
npx @ruvnet/ruview@0.4.0 doctor npx @ruvnet/ruview@0.5.0 doctor
npx @ruvnet/ruview@0.4.0 guidance --topic homecore --query "restore and plugins" npx @ruvnet/ruview@0.5.0 guidance --topic homecore --query "restore and plugins"
npx @ruvnet/ruview@0.4.0 agent run \ npx @ruvnet/ruview@0.5.0 agent run \
--host codex --repo . --prompt "Find the nearest tests and cite files" --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.5.0 brain search --query "community memory"
npx @ruvnet/ruview@0.4.0 brain verify --repo . npx @ruvnet/ruview@0.5.0 brain verify --repo .
npx @ruvnet/ruview@0.4.0 spaces npx @ruvnet/ruview@0.5.0 spaces
npx @ruvnet/ruview@0.4.0 mcp start npx @ruvnet/ruview@0.5.0 mcp start
``` ```
Start unfamiliar repository work with `ruview_guidance`. It returns reviewed 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 # 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 - **Date**: 2026-08-17
- **Deciders**: ruv - **Deciders**: ruv
- **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous - **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, publisher surface. RuView therefore has no OAuth write, command, policy-approval,
or actuator capability. or actuator capability.
This evidence does not claim implementation of sites/buildings/floors/zones, This production evidence does not claim deployment of sites/buildings/floors/
entities, semantic event or alert resources, tenant-scoped RuVector spatial zones, entities, semantic event or alert resources, tenant-scoped RuVector
history, MQTT reconciliation, governed actions, commands, or actuators. Those spatial history, MQTT reconciliation, governed actions, commands, or actuators.
remain separately reviewed milestones. 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 ## Consequences
@@ -445,8 +478,8 @@ remain separately reviewed milestones.
- Two credential types coexist during migration and must stay visibly distinct. - Two credential types coexist during migration and must stay visibly distinct.
- OAuth depends on Identity JWKS availability and correct key rotation. - OAuth depends on Identity JWKS availability and correct key rotation.
- The current API exposes spaces only; the full hierarchy/events/alerts model - Production currently exposes the legacy spaces slice; the implemented full
remains staged work. hierarchy/events/alerts model remains staged until deployment/readback.
- OAuth workspace IDs will return only documents populated with `workspaceId`; - OAuth workspace IDs will return only documents populated with `workspaceId`;
legacy owner-only documents require an explicit migration, never a broad query. legacy owner-only documents require an explicit migration, never a broad query.
- The RuView client exposes no write, command, or agent execution surface. The - 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-S3 (Full CSI)](#esp32-s3-full-csi)
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced) - [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory) - [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) - [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
5. [REST API Reference](#rest-api-reference) 5. [REST API Reference](#rest-api-reference)
6. [WebSocket Streaming](#websocket-streaming) 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. 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) ### 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. 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 ```bash
wifi-densepose whoami wifi-densepose whoami
npx @ruvnet/ruview spaces 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 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 non-default credential store is intentional. Never put a bearer token or API
key on the command line. 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 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 `https://api.cognitum.one`, the adapter requires an installed
`wifi-densepose` binary, and the child environment excludes `wifi-densepose` binary, and the child environment excludes
`COGNITUM_SPACES_API`, so this `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 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 read path and isolation behavior, not sensing quality. Every accepted response
must declare `HomeCore Edge` as authoritative and carry the complete prohibited must declare `HomeCore Edge` as authoritative and carry the complete prohibited
field list. Any malformed, oversized, non-semantic, or raw-field response fails field list. Parent lineage, schema version, anonymous person/track identity,
closed. 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", "generator": "RuView metaharness provenance v2",
"template": "vertical:ruview", "template": "vertical:ruview",
"name": "@ruvnet/ruview", "name": "@ruvnet/ruview",
"version": "0.4.0", "version": "0.5.0",
"hosts": [ "hosts": [
"claude-code", "claude-code",
"codex" "codex"
@@ -12,7 +12,7 @@
"files": { "files": {
".claude/settings.json": "57d03e8995363bd120fb6d515702967afd0bd557797051301ff8f8156c845824", ".claude/settings.json": "57d03e8995363bd120fb6d515702967afd0bd557797051301ff8f8156c845824",
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240", ".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/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c", ".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65", ".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
@@ -20,10 +20,10 @@
".harness/claims.json": "9544cee8012328eb26856a9fff38d80a73f09e48a2da7537f6c3695521b0fd54", ".harness/claims.json": "9544cee8012328eb26856a9fff38d80a73f09e48a2da7537f6c3695521b0fd54",
".harness/mcp-policy.json": "749e9f24bde85921a45b91bf6fa4ab5605675af769c04c53fe69129019662d3e", ".harness/mcp-policy.json": "749e9f24bde85921a45b91bf6fa4ab5605675af769c04c53fe69129019662d3e",
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f", ".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
"CLAUDE.md": "61a96113d7ccfe534f7dfc7c59afd876faf0b732bca4f618aa3dcea07bd0f839", "CLAUDE.md": "46d5514f4cbf4d94f683f76aa6d50a3dce2ec5a95fd87b9154ed4752f5ea0e16",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06", "LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "e9593d9ebd888eef065fd37e13daf17cf3d8e19f754d5fe11541f27f10db48db", "README.md": "ce716f07b4b93d5b86285a46cc7be1c6ff48d95ee12ac73518dbf2fb7b61d82e",
"bin/cli.js": "92700671c878bd4989dfcc23e3e8325ed3bb9bcc9b707fcd9330f33b2d5a3c39", "bin/cli.js": "0c96bf65a189732a35760c88a3d441a5bd6ce53abbd3bfaa73141665825e1be1",
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572", "flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
"flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce", "flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce",
@@ -31,19 +31,19 @@
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214", "flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab", "flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0", "flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
"package.json": "f30fa3704a63ca45b8b873294d3ce56603c18361cef16f21c0615cc74567c232", "package.json": "e508476278467cbe63e836d2b42f24649393fb59ce4fe40925d4418da4e79acc",
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565", "scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b", "scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10", "scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240", "skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
"skills/cognitum-spaces.md": "7f59877f86dacb8c71d8aaf757a5cfcf534ba323488da1761a1e965f74248805", "skills/cognitum-spaces.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f", "skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c", "skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65", "skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76", "skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6", "src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
"src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad", "src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad",
"src/guidance.js": "599fb7317c6ab2166e5d4eb89954fa303b9d29fae361a6f4d01c0eff7e7e220d", "src/guidance.js": "bc9a8c1ffcfd378110e56e5d0a3559806fbbeb65523a895fc685c3ae61306219",
"src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb", "src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb",
"src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b", "src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b",
"src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539", "src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539",
@@ -52,10 +52,10 @@
"src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6", "src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6",
"src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189", "src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189",
"src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c", "src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c",
"src/spaces.js": "d102792339d4f4c0cc4cf344a9c18f1aa660749d742120c6f2a88ddeb78b343a", "src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
"src/tools.js": "f5ce697b649721966afcb43dce86ff32350feae5bf7b561722291f143d8c3550" "src/tools.js": "55960c9a677661763e0317fd54ccc787c2edb39c87371c7fbc40cd55f0761c04"
}, },
"filesDigest": "8861774e126ba3c06318bb7379726d98841cb42c5f660e91bd62288f7ca61f8b", "filesDigest": "9df582d6d4cbf378999a55d7561fc14f795145354258126b12da894433258047",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"developmentPins": { "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 tools (`node_flash`) require explicit confirmation and are Windows/ESP-IDF
gated. 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 `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 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 ## 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 verify # run the deterministic proof (VERDICT: PASS)
npx @ruvnet/ruview doctor # self-check (tools, adapters, local CLIs) npx @ruvnet/ruview doctor # self-check (tools, adapters, local CLIs)
npx @ruvnet/ruview guidance --topic homecore --query "Wasmtime plugins" 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 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_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
| `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) | | `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
| `ruview_guidance` | Source-cited code map, capability maturity, validation commands, and limitations | | `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 | | `ruview_memory_search` | Search the reviewed, source-cited contributor brain |
Every tool is **fail-closed**: missing repo / python / binary / port → an honest 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 login --spaces
wifi-densepose whoami wifi-densepose whoami
npx @ruvnet/ruview spaces 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 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 to `https://api.cognitum.one`, and the credentialed adapter requires an
installed `wifi-densepose` binary rather than running Cargo build scripts from installed `wifi-densepose` binary rather than running Cargo build scripts from
an auto-detected checkout. It returns only the bounded P2/P3 semantic an auto-detected checkout. It returns only the bounded P2/P3 semantic
projection; an empty list is a valid authenticated result, not sensing-quality projection. `--resource` selects one of `sites`, `buildings`, `floors`,
evidence. An expired session may rotate the stored refresh credential before `spaces`, `zones`, `entities`, `events`, or `alerts`; `--limit` is 1100 and
the read completes. `--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 MCP use is denied unless the server operator starts it with
`RUVIEW_MCP_GRANTS=credential-use`. Set `RUVIEW_CREDENTIALS_PATH` in the MCP `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 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) flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map 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: Harness:
doctor verify tools, adapters, and local CLI discovery doctor verify tools, adapters, and local CLI discovery
@@ -130,6 +130,7 @@ export async function run(args) {
if (cmd === 'spaces') { if (cmd === 'spaces') {
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path']; if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
delete toolArgs['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' }); const res = await runTool(VERB_TO_TOOL[cmd], toolArgs, { source: 'cli' });
pjson(res); pjson(res);

View File

@@ -1,12 +1,12 @@
{ {
"name": "@ruvnet/ruview", "name": "@ruvnet/ruview",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@ruvnet/ruview", "name": "@ruvnet/ruview",
"version": "0.4.0", "version": "0.5.0",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"ruview": "bin/cli.js" "ruview": "bin/cli.js"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ruvnet/ruview", "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.", "description": "RuView WiFi-sensing operator harness — onboard, calibrate, verify, enforce evidence guardrails, and read Cognitum Spaces through explicitly granted OAuth.",
"type": "module", "type": "module",
"bin": { "bin": {

View File

@@ -34,8 +34,14 @@ metaharness:
```bash ```bash
wifi-densepose whoami wifi-densepose whoami
npx @ruvnet/ruview spaces 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 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 non-default credential store is intentional. Never put a bearer token or API
key on the command line. 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 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 `https://api.cognitum.one`, the adapter requires an installed
`wifi-densepose` binary, and the child environment excludes `wifi-densepose` binary, and the child environment excludes
`COGNITUM_SPACES_API`, so this `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 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 read path and isolation behavior, not sensing quality. Every accepted response
must declare `HomeCore Edge` as authoritative and carry the complete prohibited must declare `HomeCore Edge` as authoritative and carry the complete prohibited
field list. Any malformed, oversized, non-semantic, or raw-field response fails field list. Parent lineage, schema version, anonymous person/track identity,
closed. 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', id: 'cognitum-spaces-oauth',
name: 'Cognitum Spaces OAuth projection', name: 'Cognitum Spaces OAuth projection',
topics: ['integrations', 'deployment', 'community'], topics: ['integrations', 'deployment', 'community'],
status: 'implemented-read-only', status: 'implemented-read-only-staged',
evidence: 'PRODUCTION', evidence: 'MIXED',
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.', 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: [ sources: [
'docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md', 'docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md',
'v2/crates/wifi-densepose-cli/src/spaces.rs', 'v2/crates/wifi-densepose-cli/src/spaces.rs',
'harness/ruview/src/spaces.js', '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: [ validation: [
'cd harness/ruview && node --test test/spaces.test.mjs test/policy.test.mjs', '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: [ limitations: [
'The projection is read-only and grants no write, pairing, command, policy-approval, or actuator authority.', '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.', '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.', '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 DEFAULT_BASE_URL = 'https://api.cognitum.one';
const MAX_CLI_JSON_BYTES = 2 * 1024 * 1024; const MAX_CLI_JSON_BYTES = 2 * 1024 * 1024;
const MAX_JSON_DEPTH = 16; 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_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([ const REQUIRED_EXCLUSIONS = Object.freeze([
'raw_csi', 'raw_csi',
'cir', 'cir',
@@ -23,7 +31,13 @@ const REQUIRED_EXCLUSIONS = Object.freeze([
'vital_waveforms', 'vital_waveforms',
'identity_observations', '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([ const SPACES_ENV_ALLOWLIST = Object.freeze([
...DEFAULT_ENV_ALLOWLIST, ...DEFAULT_ENV_ALLOWLIST,
// Operators may bind an MCP server to a credential file without putting a // 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(); 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 (depth > MAX_JSON_DEPTH) throw new Error('JSON nesting is too deep');
if (typeof value === 'string') { if (typeof value === 'string') {
if (Buffer.byteLength(value, 'utf8') > MAX_STRING_BYTES) throw new Error('string exceeds bound'); if (Buffer.byteLength(value, 'utf8') > MAX_STRING_BYTES) throw new Error('string exceeds bound');
return; return;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length > 1000) throw new Error('array exceeds bound'); if (value.length > MAX_ARRAY_ITEMS) throw new Error('array exceeds bound');
for (const item of value) assertBoundedValue(item, depth + 1); for (const item of value) assertBoundedValue(item, depth + 1, state);
return; return;
} }
if (!value || typeof value !== 'object') return; if (!value || typeof value !== 'object') return;
const entries = Object.entries(value); 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) { for (const [key, item] of entries) {
if (Buffer.byteLength(key, 'utf8') > MAX_STRING_BYTES) throw new Error('object key exceeds bound'); 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}`); 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. */ /** 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) { if (Buffer.byteLength(String(stdout), 'utf8') > MAX_CLI_JSON_BYTES) {
throw new Error('CLI response exceeds bound'); throw new Error('CLI response exceeds bound');
} }
@@ -72,29 +88,76 @@ export function parseSpacesOutput(stdout) {
throw new Error('CLI response is not JSON'); throw new Error('CLI response is not JSON');
} }
assertBoundedValue(response); 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'); 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; 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'); throw new Error('incomplete edge privacy boundary');
} }
for (const required of REQUIRED_EXCLUSIONS) { for (const required of REQUIRED_EXCLUSIONS) {
if (!boundary.excluded.includes(required)) throw new Error('incomplete edge privacy boundary'); if (!boundary.excluded.includes(required)) throw new Error('incomplete edge privacy boundary');
} }
for (const space of response.data) { for (const item of response.data) {
if (!space || !nonEmptyString(space.id) || !nonEmptyString(space.tenantId) if (!item || !ID_RE.test(String(item.id ?? '')) || !nonEmptyString(item.tenantId)) {
|| !nonEmptyString(space.siteId) || !nonEmptyString(space.name)) { throw new Error('spatial identity is incomplete');
throw new Error('space 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'); 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 if (confidence !== null && confidence !== undefined
&& (typeof confidence !== 'number' || !Number.isFinite(confidence) || confidence < 0 || confidence > 1)) { && (typeof confidence !== 'number' || !Number.isFinite(confidence) || confidence < 0 || confidence > 1)) {
throw new Error('invalid confidence'); 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; 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); if (input.credentials_path) spacesArgs.push('--credentials-path', input.credentials_path);
let command; let command;
@@ -158,7 +237,7 @@ export async function listCognitumSpaces(input = {}, options = {}) {
let response; let response;
try { try {
response = parseSpacesOutput(result.stdout); response = parseSpacesOutput(result.stdout, resource);
} catch (error) { } catch (error) {
return { return {
ok: false, ok: false,
@@ -173,6 +252,9 @@ export async function listCognitumSpaces(input = {}, options = {}) {
authentication: 'oauth', authentication: 'oauth',
via, via,
count: response.data.length, count: response.data.length,
resource,
schemaVersion: response.schemaVersion,
nextCursor: response.nextCursor ?? null,
data: response.data, data: response.data,
boundary: response.boundary, boundary: response.boundary,
authority: 'Read-only tenant/workspace projection; this result grants no action, write, pairing, or actuator authority.', 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: { ruview_spaces_list: {
title: 'List Cognitum Spaces', title: 'List Cognitum Spatial Resources',
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.', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { 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.' }, 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 = {}) { async handler(args = {}, context = {}) {

View File

@@ -4,15 +4,18 @@ import assert from 'node:assert/strict';
import { listCognitumSpaces, parseSpacesOutput } from '../src/spaces.js'; import { listCognitumSpaces, parseSpacesOutput } from '../src/spaces.js';
import { runTool } from '../src/tools.js'; import { runTool } from '../src/tools.js';
function validResponse() { function validResponse(kind = 'spaces') {
return { return {
object: 'list', object: 'list',
kind,
schemaVersion: '1.0',
data: [{ data: [{
id: 'room-1', tenantId: 'tenant-1', workspaceId: 'workspace-1', siteId: 'site-1', name: 'Room', id: 'room-1', tenantId: 'tenant-1', workspaceId: '11111111-1111-7111-8111-111111111111', siteId: 'site-1', name: 'Room',
version: 1, privacy: 'P2', status: 'live', connection: 'connected', buildingId: 'building-1', floorId: 'floor-1', kind, schemaVersion: '1.0',
state: { occupancy: 1, confidence: 0.9, observedAt: null, freshnessMs: 5, classification: 'P2', uncertainty: null, evidence: [] }, messageId: 'message-1', eventSequence: 1, version: 1, privacy: 'P2',
provenance: {}, hardware: {}, dataBoundary: {}, observedAt: null, expiresAt: null, confidence: 0.9, provenance: {}, attributes: {}, observedAt: '2026-08-19T00:00:00Z', expiresAt: null,
}], }],
nextCursor: null,
boundary: { boundary: {
authoritativeState: 'HomeCore Edge', authoritativeState: 'HomeCore Edge',
cloudRole: 'tenant-scoped semantic synchronization', cloudRole: 'tenant-scoped semantic synchronization',
@@ -23,7 +26,7 @@ function validResponse() {
test('Spaces adapter invokes OAuth-only CLI args in a scrubbed environment', async () => { test('Spaces adapter invokes OAuth-only CLI args in a scrubbed environment', async () => {
const credentialPath = 'C:/private/ruview-credentials.json'; const credentialPath = 'C:/private/ruview-credentials.json';
const secretApiKey = 'cog_DO_NOT_FORWARD'; const secretApiKey = ['cog', 'DO', 'NOT', 'FORWARD'].join('_');
let observed; let observed;
const result = await listCognitumSpaces( const result = await listCognitumSpaces(
{ credentials_path: credentialPath }, { 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(result.count, 1);
assert.equal(observed.command, 'wifi-densepose-test-double'); assert.equal(observed.command, 'wifi-densepose-test-double');
assert.deepEqual(observed.args, [ 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('RUVIEW_CREDENTIALS_PATH'));
assert.ok(!observed.options.envAllowlist.includes('COGNITUM_SPACES_API')); 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', () => { test('metaharness rejects forbidden raw fields from a child process', () => {
const response = validResponse(); 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); 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); assert.throws(() => parseSpacesOutput(JSON.stringify(incomplete)), /incomplete edge privacy boundary/i);
const invalid = validResponse(); const invalid = validResponse();
invalid.data[0].state.confidence = 2; invalid.data[0].confidence = 2;
assert.throws(() => parseSpacesOutput(JSON.stringify(invalid)), /invalid confidence/i); 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 () => { 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 jwt = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEifQ.signature-material';
const result = await listCognitumSpaces({}, { const result = await listCognitumSpaces({}, {
source: 'cli', source: 'cli',

16
v2/Cargo.lock generated
View File

@@ -9698,6 +9698,7 @@ dependencies = [
name = "ruview-cognitum-spaces" name = "ruview-cognitum-spaces"
version = "0.3.1" version = "0.3.1"
dependencies = [ dependencies = [
"chrono",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
@@ -9824,6 +9825,7 @@ dependencies = [
name = "ruview-policy" name = "ruview-policy"
version = "0.3.1" version = "0.3.1"
dependencies = [ dependencies = [
"blake3",
"ruview-attest", "ruview-attest",
"ruview-certify", "ruview-certify",
"ruview-evidence", "ruview-evidence",
@@ -9844,6 +9846,20 @@ dependencies = [
"thiserror 2.0.18", "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]] [[package]]
name = "ruview-swarm" name = "ruview-swarm"
version = "0.1.0" version = "0.1.0"

View File

@@ -116,6 +116,7 @@ members = [
"crates/ruview-twin", # ADR-315 digital RF twin (per-deployment model) "crates/ruview-twin", # ADR-315 digital RF twin (per-deployment model)
"crates/ruview-placement", # ADR-308 sensor placement optimizer "crates/ruview-placement", # ADR-308 sensor placement optimizer
"crates/ruview-memory", # ADR-312 long-term spatial memory / anomaly "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-counterfactual",# ADR-313 counterfactual spatial inference
"crates/ruview-infogain", # ADR-314 information-gain scheduler "crates/ruview-infogain", # ADR-314 information-gain scheduler
"crates/ruview-active", # ADR-309 active sensing control "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 publish = false
[dependencies] [dependencies]
chrono = { version = "0.4", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

View File

@@ -14,6 +14,7 @@ use url::Url;
const MAX_RESPONSE_BYTES: usize = 1024 * 1024; const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
const MAX_SPACES: usize = 100; const MAX_SPACES: usize = 100;
const MAX_JSON_DEPTH: usize = 16; const MAX_JSON_DEPTH: usize = 16;
const MAX_JSON_NODES: usize = 10_000;
const MAX_STRING_BYTES: usize = 4096; const MAX_STRING_BYTES: usize = 4096;
const REQUIRED_EXCLUSIONS: [&str; 7] = [ const REQUIRED_EXCLUSIONS: [&str; 7] = [
"raw_csi", "raw_csi",
@@ -73,6 +74,8 @@ pub enum Error {
InvalidUrl, InvalidUrl,
#[error("invalid or empty credential")] #[error("invalid or empty credential")]
InvalidCredential, InvalidCredential,
#[error("invalid Spaces request: {0}")]
InvalidRequest(String),
#[error("Spaces request failed: {0}")] #[error("Spaces request failed: {0}")]
Transport(#[from] reqwest::Error), Transport(#[from] reqwest::Error),
#[error("Spaces rejected the credential ({0})")] #[error("Spaces rejected the credential ({0})")]
@@ -89,6 +92,7 @@ pub enum Error {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Client { pub struct Client {
base: Url,
endpoint: Url, endpoint: Url,
credential: Credential, credential: Credential,
http: reqwest::Client, http: reqwest::Client,
@@ -121,6 +125,7 @@ impl Client {
)) ))
.build()?; .build()?;
Ok(Self { Ok(Self {
base,
endpoint, endpoint,
credential, credential,
http, http,
@@ -128,10 +133,44 @@ impl Client {
} }
pub async fn list(&self) -> Result<SpacesResponse, Error> { pub async fn list(&self) -> Result<SpacesResponse, Error> {
let mut request = self let body = self.get(self.endpoint.clone()).await?;
.http decode(&body)
.get(self.endpoint.clone()) }
.header("Accept", "application/json");
/// 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 { request = match &self.credential {
Credential::OAuth(token) => request.bearer_auth(token), Credential::OAuth(token) => request.bearer_auth(token),
Credential::ApiKey(key) => request.header("X-API-Key", key), Credential::ApiKey(key) => request.header("X-API-Key", key),
@@ -169,10 +208,162 @@ impl Client {
} }
body.extend_from_slice(&chunk); 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)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SpacesResponse { pub struct SpacesResponse {
@@ -272,7 +463,178 @@ pub fn decode(bytes: &[u8]) -> Result<SpacesResponse, Error> {
Ok(response) 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> { 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 { if depth > MAX_JSON_DEPTH {
return Err(Error::InvalidResponse("JSON nesting is too deep".into())); 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) => { Value::Array(items) => {
for item in items { for item in items {
validate_value(item, depth + 1)?; validate_value_inner(item, depth + 1, nodes)?;
} }
} }
Value::Object(map) => { Value::Object(map) => {
@@ -303,19 +665,41 @@ fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
.collect(); .collect();
if matches!( if matches!(
normalized.as_str(), normalized.as_str(),
"rawcsi" "csi"
| "rawcsi"
| "channelstateinformation"
| "cir" | "cir"
| "rawcir"
| "channelimpulseresponse"
| "rftensor"
| "rftensors" | "rftensors"
| "packetcapture"
| "packetcaptures"
| "pcap"
| "recording"
| "recordings" | "recordings"
| "audiorecording"
| "videorecording"
| "poseframe"
| "poseframes" | "poseframes"
| "skeleton"
| "keypoints"
| "vitalwaveform"
| "vitalwaveforms" | "vitalwaveforms"
| "heartratewaveform"
| "identityobservation"
| "identityobservations" | "identityobservations"
| "biometric"
| "biometrics"
| "face"
| "faces"
| "faceembedding"
) { ) {
return Err(Error::InvalidResponse(format!( return Err(Error::InvalidResponse(format!(
"forbidden raw field: {key}" "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() 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] #[test]
fn accepts_bounded_semantic_state() { fn accepts_bounded_semantic_state() {
assert_eq!(decode(&valid()).unwrap().data.len(), 1); assert_eq!(decode(&valid()).unwrap().data.len(), 1);
@@ -384,4 +772,78 @@ mod tests {
let c = Credential::oauth("secret-token").unwrap(); let c = Credential::oauth("secret-token").unwrap();
assert!(!format!("{c:?}").contains("secret-token")); 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-ood = { path = "../ruview-ood" }
ruview-certify = { path = "../ruview-certify" } ruview-certify = { path = "../ruview-certify" }
ruview-attest = { path = "../ruview-attest" } ruview-attest = { path = "../ruview-attest" }
blake3 = { version = "1.5", default-features = false }
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true 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 ruview_evidence::EvidenceLevel;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Typed intent, approval, idempotency, and witnessed-receipt layer (ADR-327).
pub mod governed;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Value types owned by this crate // Value types owned by this crate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -479,10 +482,8 @@ pub fn authorize_from_certificate<V: ruview_attest::Verifier + ?Sized>(
uncertainty: f64, uncertainty: f64,
evidence_level: EvidenceLevel, evidence_level: EvidenceLevel,
) -> Authorization { ) -> Authorization {
let certificate_valid = let certificate_valid = cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
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_age_secs =
(now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
authorize( authorize(
class, 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 std::path::PathBuf;
use clap::Args; use clap::{Args, ValueEnum};
use ruview_auth::{login, scope}; 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)] #[derive(Debug, Args)]
pub struct SpacesArgs { pub struct SpacesArgs {
@@ -20,6 +47,22 @@ pub struct SpacesArgs {
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
pub credentials_path: Option<PathBuf>, 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. /// Emit the validated response as JSON.
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
@@ -46,7 +89,47 @@ pub async fn spaces_cmd(args: SpacesArgs) -> anyhow::Result<()> {
Credential::oauth(session.ensure_fresh().await?)? 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 { if args.json {
println!("{}", serde_json::to_string_pretty(&response)?); println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(()); return Ok(());