mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-20 22:14:56 +00:00
feat(spaces): add spatial memory and governed actions (#1650)
This commit is contained in:
parent
d36f346bba
commit
c929bbc8b3
28 changed files with 3192 additions and 94 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -47,18 +47,18 @@ from the current tree when needed.
|
|||
|
||||
## RuView contributor harness
|
||||
|
||||
`@ruvnet/ruview@0.4.0` is the runtime-dependency-free contributor interface
|
||||
`@ruvnet/ruview@0.5.0` is the runtime-dependency-free contributor interface
|
||||
defined by ADR-283.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.4.0 doctor
|
||||
npx @ruvnet/ruview@0.4.0 guidance --topic homecore --query "restore and plugins"
|
||||
npx @ruvnet/ruview@0.4.0 agent run \
|
||||
npx @ruvnet/ruview@0.5.0 doctor
|
||||
npx @ruvnet/ruview@0.5.0 guidance --topic homecore --query "restore and plugins"
|
||||
npx @ruvnet/ruview@0.5.0 agent run \
|
||||
--host codex --repo . --prompt "Find the nearest tests and cite files"
|
||||
npx @ruvnet/ruview@0.4.0 brain search --query "community memory"
|
||||
npx @ruvnet/ruview@0.4.0 brain verify --repo .
|
||||
npx @ruvnet/ruview@0.4.0 spaces
|
||||
npx @ruvnet/ruview@0.4.0 mcp start
|
||||
npx @ruvnet/ruview@0.5.0 brain search --query "community memory"
|
||||
npx @ruvnet/ruview@0.5.0 brain verify --repo .
|
||||
npx @ruvnet/ruview@0.5.0 spaces
|
||||
npx @ruvnet/ruview@0.5.0 mcp start
|
||||
```
|
||||
|
||||
Start unfamiliar repository work with `ruview_guidance`. It returns reviewed
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# ADR-325: Cognitum Spaces activation and governed spatial exchange
|
||||
|
||||
- **Status**: Accepted — read path implemented; write path remains policy-gated
|
||||
- **Status**: Accepted — legacy read live; versioned hierarchy, local memory, and governed-action implementation validated on feature branches; deployment pending
|
||||
- **Date**: 2026-08-17
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous
|
||||
|
|
@ -423,10 +423,43 @@ Identity metadata deliberately advertises `spaces:read` for RuView but not
|
|||
publisher surface. RuView therefore has no OAuth write, command, policy-approval,
|
||||
or actuator capability.
|
||||
|
||||
This evidence does not claim implementation of sites/buildings/floors/zones,
|
||||
entities, semantic event or alert resources, tenant-scoped RuVector spatial
|
||||
history, MQTT reconciliation, governed actions, commands, or actuators. Those
|
||||
remain separately reviewed milestones.
|
||||
This production evidence does not claim deployment of sites/buildings/floors/
|
||||
zones, entities, semantic event or alert resources, tenant-scoped RuVector
|
||||
spatial history, MQTT reconciliation, governed actions, commands, or actuators.
|
||||
The first three are implemented and locally validated in the 2026-08-19 feature
|
||||
branches described below, but remain non-production until their workflow and
|
||||
readback gates pass. Commands and actuators remain out of scope.
|
||||
|
||||
## Feature-branch implementation evidence (2026-08-19)
|
||||
|
||||
- Cognitum API ADR-101 implements all eight `/v1/spatial` collections, coherent
|
||||
transactional hierarchy checks, stable pagination, event/alert retention,
|
||||
strict P2/P3 admission, API-key-only writes, and OAuth/API-key reads. The
|
||||
Firestore emulator passed both the legacy and versioned verification scripts,
|
||||
including cross-resource message replay denial.
|
||||
- `ruview-cognitum-spaces` adds a strictly decoded, paged, read-only client for
|
||||
every versioned collection. `wifi-densepose spaces --resource ...` exposes it
|
||||
while preserving the legacy flat command. The focused client suite and the
|
||||
CLI no-default-features test gate pass.
|
||||
- The contributor metaharness now accepts `resource`, `limit`, and opaque
|
||||
`cursor` on `ruview_spaces_list`, fixes the API origin, strips API-key
|
||||
compatibility authority, and revalidates hierarchy/event/alert contracts.
|
||||
Full metaharness, security, brain, flywheel, manifest, audit, and pack gates
|
||||
pass for the `@ruvnet/ruview` 0.5.0 release candidate.
|
||||
- ADR-326 adds `ruview-spatial-memory`: one RuVector HNSW index per authenticated
|
||||
tenant/workspace, replay/derivation/retention gates, cascading erasure,
|
||||
bounded explanations, and XChaCha20-Poly1305 snapshots with reload-verified
|
||||
key rotation. Its focused crate suite passes with `SYNTHETIC` evidence.
|
||||
- ADR-327 extends `ruview-policy` with typed observe/recommend/execute intents,
|
||||
exact host grants, signed approvals, nonce/idempotency defense, the existing
|
||||
ADR-321 assurance matrix, and signed hash-chained receipts. `spaces:read` is
|
||||
explicitly denied as execution authority. Its focused crate suite passes.
|
||||
- The required whole-workspace Rust command was attempted twice on Windows:
|
||||
parallel compilation ended in a compiler-process stack-buffer-overrun and a
|
||||
single-job retry reached the configured timeout without a source/test
|
||||
diagnostic. This is not recorded as a green gate; Linux CI remains required.
|
||||
- No OAuth write/action scope, actuator callback, MQTT deployment claim, sensing
|
||||
accuracy claim, or real-hardware claim is introduced.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
@ -445,8 +478,8 @@ remain separately reviewed milestones.
|
|||
|
||||
- Two credential types coexist during migration and must stay visibly distinct.
|
||||
- OAuth depends on Identity JWKS availability and correct key rotation.
|
||||
- The current API exposes spaces only; the full hierarchy/events/alerts model
|
||||
remains staged work.
|
||||
- Production currently exposes the legacy spaces slice; the implemented full
|
||||
hierarchy/events/alerts model remains staged until deployment/readback.
|
||||
- OAuth workspace IDs will return only documents populated with `workspaceId`;
|
||||
legacy owner-only documents require an explicit migration, never a broad query.
|
||||
- The RuView client exposes no write, command, or agent execution surface. The
|
||||
|
|
|
|||
137
docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md
Normal file
137
docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md
Normal 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
|
||||
133
docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md
Normal file
133
docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md
Normal 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
|
||||
|
|
@ -22,6 +22,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
|||
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
|
||||
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
|
||||
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
|
||||
- [Cognitum Spaces activation](#cognitum-spaces-activation)
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
5. [REST API Reference](#rest-api-reference)
|
||||
6. [WebSocket Streaming](#websocket-streaming)
|
||||
|
|
@ -425,6 +426,57 @@ curl http://localhost:3000/api/v1/sensing/latest
|
|||
|
||||
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
|
||||
|
||||
### Cognitum Spaces activation
|
||||
|
||||
Cognitum Spaces gives RuView a tenant/workspace-scoped semantic world model
|
||||
without uploading raw RF/CSI, recordings, pose frames, vital waveforms, or
|
||||
identity observations. It represents sites, buildings, floors, bounded
|
||||
rooms/spaces, zones, anonymous entities, semantic events, and alerts.
|
||||
|
||||
Activate the public RuView OAuth client with Authorization Code + PKCE:
|
||||
|
||||
```bash
|
||||
wifi-densepose login --spaces
|
||||
wifi-densepose whoami
|
||||
wifi-densepose spaces --resource sites --limit 50
|
||||
wifi-densepose spaces --resource events --limit 25
|
||||
```
|
||||
|
||||
The login requests `sensing:read spaces:read`. That consent is read-only: it
|
||||
does not grant publication, pairing, policy approval, command, or actuator
|
||||
authority. Versioned collections are `sites`, `buildings`, `floors`,
|
||||
`spaces`, `zones`, `entities`, `events`, and `alerts`. A returned
|
||||
`nextCursor` is opaque and valid only for the same collection.
|
||||
|
||||
The dependency-free contributor harness exposes the same read path:
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.5.0 spaces --resource alerts --limit 25
|
||||
npx @ruvnet/ruview@0.5.0 mcp start
|
||||
```
|
||||
|
||||
Its MCP tool is `ruview_spaces_list`. MCP reads are OAuth-only, use the fixed
|
||||
Cognitum API origin, and require the explicit guarded-tool opt-in. The harness
|
||||
does not accept an arbitrary credential path or API origin.
|
||||
|
||||
For service compatibility, `wifi-densepose spaces` can read
|
||||
`COGNITUM_SPACES_API` at request time. API-key access to a versioned collection
|
||||
also requires `--workspace <uuid>`; OAuth derives the workspace from the
|
||||
signed token. Never print or commit either credential.
|
||||
|
||||
Every response is bounded and revalidated. Raw-sensing aliases, malformed
|
||||
hierarchy, non-anonymous person/track entities, invalid timestamps, stale
|
||||
confidence, and oversized structures fail closed. Empty data means no
|
||||
authorized state is present; it does not prove that a physical site is empty.
|
||||
|
||||
RuVector spatial memory remains physically separated by tenant and workspace.
|
||||
Agents observe or recommend by default. Any consequential execution requires a
|
||||
separate policy/grant/approval decision and produces a signed, hash-chained
|
||||
receipt; the Spaces read token can never satisfy that gate.
|
||||
|
||||
See ADR-325, ADR-326, and ADR-327 for the activation, memory, and governed-action
|
||||
decisions.
|
||||
|
||||
### Cognitum Seed Integration (ADR-069)
|
||||
|
||||
Connect an ESP32-S3 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy.
|
||||
|
|
|
|||
|
|
@ -34,8 +34,14 @@ metaharness:
|
|||
```bash
|
||||
wifi-densepose whoami
|
||||
npx @ruvnet/ruview spaces
|
||||
npx @ruvnet/ruview spaces --resource sites
|
||||
npx @ruvnet/ruview spaces --resource events --limit 25
|
||||
```
|
||||
|
||||
The versioned collections are `sites`, `buildings`, `floors`, `spaces`,
|
||||
`zones`, `entities`, `events`, and `alerts`. Continue a page with the returned
|
||||
opaque `nextCursor`; do not decode or reuse a cursor for another collection.
|
||||
|
||||
Use `--credentials-path <private-file>` only from the human-invoked CLI when a
|
||||
non-default credential store is intentional. Never put a bearer token or API
|
||||
key on the command line.
|
||||
|
|
@ -54,7 +60,7 @@ npx @ruvnet/ruview mcp start
|
|||
```
|
||||
|
||||
MCP calls cannot choose a credential path and the tool schema has no token or
|
||||
API-key or base-URL field. The API origin is fixed to
|
||||
API-key, workspace override, or base-URL field. The API origin is fixed to
|
||||
`https://api.cognitum.one`, the adapter requires an installed
|
||||
`wifi-densepose` binary, and the child environment excludes
|
||||
`COGNITUM_SPACES_API`, so this
|
||||
|
|
@ -66,5 +72,6 @@ API-key path.
|
|||
An empty `data` list can be a valid authenticated tenant result. It proves the
|
||||
read path and isolation behavior, not sensing quality. Every accepted response
|
||||
must declare `HomeCore Edge` as authoritative and carry the complete prohibited
|
||||
field list. Any malformed, oversized, non-semantic, or raw-field response fails
|
||||
closed.
|
||||
field list. Parent lineage, schema version, anonymous person/track identity,
|
||||
event/alert fields, confidence, and cursor bounds are independently checked.
|
||||
Any malformed, oversized, non-semantic, or raw-field response fails closed.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"generator": "RuView metaharness provenance v2",
|
||||
"template": "vertical:ruview",
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"hosts": [
|
||||
"claude-code",
|
||||
"codex"
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"files": {
|
||||
".claude/settings.json": "57d03e8995363bd120fb6d515702967afd0bd557797051301ff8f8156c845824",
|
||||
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
||||
".claude/skills/cognitum-spaces/SKILL.md": "7f59877f86dacb8c71d8aaf757a5cfcf534ba323488da1761a1e965f74248805",
|
||||
".claude/skills/cognitum-spaces/SKILL.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
|
||||
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
||||
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
||||
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
||||
|
|
@ -20,10 +20,10 @@
|
|||
".harness/claims.json": "9544cee8012328eb26856a9fff38d80a73f09e48a2da7537f6c3695521b0fd54",
|
||||
".harness/mcp-policy.json": "749e9f24bde85921a45b91bf6fa4ab5605675af769c04c53fe69129019662d3e",
|
||||
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
|
||||
"CLAUDE.md": "61a96113d7ccfe534f7dfc7c59afd876faf0b732bca4f618aa3dcea07bd0f839",
|
||||
"CLAUDE.md": "46d5514f4cbf4d94f683f76aa6d50a3dce2ec5a95fd87b9154ed4752f5ea0e16",
|
||||
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
|
||||
"README.md": "e9593d9ebd888eef065fd37e13daf17cf3d8e19f754d5fe11541f27f10db48db",
|
||||
"bin/cli.js": "92700671c878bd4989dfcc23e3e8325ed3bb9bcc9b707fcd9330f33b2d5a3c39",
|
||||
"README.md": "ce716f07b4b93d5b86285a46cc7be1c6ff48d95ee12ac73518dbf2fb7b61d82e",
|
||||
"bin/cli.js": "0c96bf65a189732a35760c88a3d441a5bd6ce53abbd3bfaa73141665825e1be1",
|
||||
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
|
||||
"flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce",
|
||||
|
|
@ -31,19 +31,19 @@
|
|||
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
|
||||
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
|
||||
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
|
||||
"package.json": "f30fa3704a63ca45b8b873294d3ce56603c18361cef16f21c0615cc74567c232",
|
||||
"package.json": "e508476278467cbe63e836d2b42f24649393fb59ce4fe40925d4418da4e79acc",
|
||||
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
|
||||
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
|
||||
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
|
||||
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
||||
"skills/cognitum-spaces.md": "7f59877f86dacb8c71d8aaf757a5cfcf534ba323488da1761a1e965f74248805",
|
||||
"skills/cognitum-spaces.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
|
||||
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
||||
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
||||
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
||||
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
|
||||
"src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad",
|
||||
"src/guidance.js": "599fb7317c6ab2166e5d4eb89954fa303b9d29fae361a6f4d01c0eff7e7e220d",
|
||||
"src/guidance.js": "bc9a8c1ffcfd378110e56e5d0a3559806fbbeb65523a895fc685c3ae61306219",
|
||||
"src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb",
|
||||
"src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b",
|
||||
"src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539",
|
||||
|
|
@ -52,10 +52,10 @@
|
|||
"src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6",
|
||||
"src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189",
|
||||
"src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c",
|
||||
"src/spaces.js": "d102792339d4f4c0cc4cf344a9c18f1aa660749d742120c6f2a88ddeb78b343a",
|
||||
"src/tools.js": "f5ce697b649721966afcb43dce86ff32350feae5bf7b561722291f143d8c3550"
|
||||
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
|
||||
"src/tools.js": "55960c9a677661763e0317fd54ccc787c2edb39c87371c7fbc40cd55f0761c04"
|
||||
},
|
||||
"filesDigest": "8861774e126ba3c06318bb7379726d98841cb42c5f660e91bd62288f7ca61f8b",
|
||||
"filesDigest": "9df582d6d4cbf378999a55d7561fc14f795145354258126b12da894433258047",
|
||||
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
|
||||
"developmentPins": {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
e80b6abc4b8a0f99a154a470c4919ffc3886a715ec7f327f5f2f991bc7bf4293 manifest.json
|
||||
45df19e5f93f14a1365ca9341a0838fbe79231ecfbac6911e24e24d3530107ee manifest.json
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ navigation evidence, not authority. All tools fail closed. Mutating/hardware
|
|||
tools (`node_flash`) require explicit confirmation and are Windows/ESP-IDF
|
||||
gated.
|
||||
|
||||
`ruview_spaces_list` is an OAuth-only external read. MCP calls require the
|
||||
`ruview_spaces_list` is an OAuth-only external read for the eight versioned
|
||||
hierarchy/event/alert collections. MCP calls require the
|
||||
`credential-use` grant, cannot select a credential path or API origin, and may
|
||||
rotate the local refresh credential. It requires an installed binary and never
|
||||
runs Cargo from an auto-detected checkout. It grants no write or action authority.
|
||||
runs Cargo from an auto-detected checkout. Cursors are opaque and collection-
|
||||
bound. It grants no write or action authority.
|
||||
|
||||
## Skills
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ npx @ruvnet/ruview claim-check --file REPORT.md # the honesty guardrail (non-z
|
|||
npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS)
|
||||
npx @ruvnet/ruview doctor # self-check (tools, adapters, local CLIs)
|
||||
npx @ruvnet/ruview guidance --topic homecore --query "Wasmtime plugins"
|
||||
npx @ruvnet/ruview spaces # OAuth-only Cognitum Spaces read
|
||||
npx @ruvnet/ruview spaces --resource spaces
|
||||
npx @ruvnet/ruview spaces --resource events --limit 25
|
||||
npx @ruvnet/ruview --help
|
||||
```
|
||||
|
||||
|
|
@ -39,7 +40,7 @@ Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
|
|||
| `ruview_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
|
||||
| `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
|
||||
| `ruview_guidance` | Source-cited code map, capability maturity, validation commands, and limitations |
|
||||
| `ruview_spaces_list` | OAuth-only, tenant/workspace Cognitum Spaces projection (guarded over MCP) |
|
||||
| `ruview_spaces_list` | OAuth-only paging for sites/buildings/floors/spaces/zones/entities/events/alerts (guarded over MCP) |
|
||||
| `ruview_memory_search` | Search the reviewed, source-cited contributor brain |
|
||||
|
||||
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
|
||||
|
|
@ -54,6 +55,8 @@ validated client through the metaharness:
|
|||
wifi-densepose login --spaces
|
||||
wifi-densepose whoami
|
||||
npx @ruvnet/ruview spaces
|
||||
npx @ruvnet/ruview spaces --resource sites --limit 50
|
||||
npx @ruvnet/ruview spaces --resource events --cursor '<opaque-next-cursor>'
|
||||
```
|
||||
|
||||
The metaharness never accepts a bearer token or API key and removes
|
||||
|
|
@ -62,9 +65,11 @@ silently fall back to the compatibility API-key path. The API origin is fixed
|
|||
to `https://api.cognitum.one`, and the credentialed adapter requires an
|
||||
installed `wifi-densepose` binary rather than running Cargo build scripts from
|
||||
an auto-detected checkout. It returns only the bounded P2/P3 semantic
|
||||
projection; an empty list is a valid authenticated result, not sensing-quality
|
||||
evidence. An expired session may rotate the stored refresh credential before
|
||||
the read completes.
|
||||
projection. `--resource` selects one of `sites`, `buildings`, `floors`,
|
||||
`spaces`, `zones`, `entities`, `events`, or `alerts`; `--limit` is 1–100 and
|
||||
`--cursor` is the opaque value from the prior page. An empty list is a valid
|
||||
authenticated result, not sensing-quality evidence. An expired session may
|
||||
rotate the stored refresh credential before the read completes.
|
||||
|
||||
MCP use is denied unless the server operator starts it with
|
||||
`RUVIEW_MCP_GRANTS=credential-use`. Set `RUVIEW_CREDENTIALS_PATH` in the MCP
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ Operator tools:
|
|||
monitor --port COM8 [--seconds 12] assert CSI is flowing on a node
|
||||
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
|
||||
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
|
||||
spaces [--credentials-path <file>] list the OAuth-bound Cognitum Spaces projection
|
||||
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
|
||||
|
||||
Harness:
|
||||
doctor verify tools, adapters, and local CLI discovery
|
||||
|
|
@ -130,6 +130,7 @@ export async function run(args) {
|
|||
if (cmd === 'spaces') {
|
||||
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
|
||||
delete toolArgs['credentials-path'];
|
||||
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
|
||||
}
|
||||
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs, { source: 'cli' });
|
||||
pjson(res);
|
||||
|
|
|
|||
4
harness/ruview/package-lock.json
generated
4
harness/ruview/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ruview": "bin/cli.js"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "RuView WiFi-sensing operator harness — onboard, calibrate, verify, enforce evidence guardrails, and read Cognitum Spaces through explicitly granted OAuth.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -34,8 +34,14 @@ metaharness:
|
|||
```bash
|
||||
wifi-densepose whoami
|
||||
npx @ruvnet/ruview spaces
|
||||
npx @ruvnet/ruview spaces --resource sites
|
||||
npx @ruvnet/ruview spaces --resource events --limit 25
|
||||
```
|
||||
|
||||
The versioned collections are `sites`, `buildings`, `floors`, `spaces`,
|
||||
`zones`, `entities`, `events`, and `alerts`. Continue a page with the returned
|
||||
opaque `nextCursor`; do not decode or reuse a cursor for another collection.
|
||||
|
||||
Use `--credentials-path <private-file>` only from the human-invoked CLI when a
|
||||
non-default credential store is intentional. Never put a bearer token or API
|
||||
key on the command line.
|
||||
|
|
@ -54,7 +60,7 @@ npx @ruvnet/ruview mcp start
|
|||
```
|
||||
|
||||
MCP calls cannot choose a credential path and the tool schema has no token or
|
||||
API-key or base-URL field. The API origin is fixed to
|
||||
API-key, workspace override, or base-URL field. The API origin is fixed to
|
||||
`https://api.cognitum.one`, the adapter requires an installed
|
||||
`wifi-densepose` binary, and the child environment excludes
|
||||
`COGNITUM_SPACES_API`, so this
|
||||
|
|
@ -66,5 +72,6 @@ API-key path.
|
|||
An empty `data` list can be a valid authenticated tenant result. It proves the
|
||||
read path and isolation behavior, not sensing quality. Every accepted response
|
||||
must declare `HomeCore Edge` as authoritative and carry the complete prohibited
|
||||
field list. Any malformed, oversized, non-semantic, or raw-field response fails
|
||||
closed.
|
||||
field list. Parent lineage, schema version, anonymous person/track identity,
|
||||
event/alert fields, confidence, and cursor bounds are independently checked.
|
||||
Any malformed, oversized, non-semantic, or raw-field response fails closed.
|
||||
|
|
|
|||
|
|
@ -232,23 +232,26 @@ const CAPABILITIES = Object.freeze([
|
|||
id: 'cognitum-spaces-oauth',
|
||||
name: 'Cognitum Spaces OAuth projection',
|
||||
topics: ['integrations', 'deployment', 'community'],
|
||||
status: 'implemented-read-only',
|
||||
evidence: 'PRODUCTION',
|
||||
summary: 'RuView explicitly activates spaces:read through Cognitum Authorization Code + PKCE, and the contributor metaharness exposes the validated tenant/workspace projection through an OAuth-only CLI/MCP adapter.',
|
||||
status: 'implemented-read-only-staged',
|
||||
evidence: 'MIXED',
|
||||
summary: 'The legacy Spaces OAuth read is live. The feature branch extends the same read-only PKCE authority across the versioned site/building/floor/space/zone/entity/event/alert collections with bounded pagination and independent metaharness validation.',
|
||||
sources: [
|
||||
'docs/adr/ADR-325-cognitum-spaces-activation-and-governed-spatial-exchange.md',
|
||||
'v2/crates/wifi-densepose-cli/src/spaces.rs',
|
||||
'harness/ruview/src/spaces.js',
|
||||
'docs/adr/ADR-326-tenant-scoped-ruvector-spatial-memory.md',
|
||||
'docs/adr/ADR-327-governed-action-intents-and-witness-receipts.md',
|
||||
],
|
||||
validation: [
|
||||
'cd harness/ruview && node --test test/spaces.test.mjs test/policy.test.mjs',
|
||||
'wifi-densepose login --spaces && node harness/ruview/bin/cli.js spaces',
|
||||
'wifi-densepose login --spaces && node harness/ruview/bin/cli.js spaces --resource events',
|
||||
],
|
||||
limitations: [
|
||||
'The projection is read-only and grants no write, pairing, command, policy-approval, or actuator authority.',
|
||||
'MCP requires the credential-use grant; bearer tokens and API keys are never accepted as tool arguments.',
|
||||
'OAuth refresh may rotate the local credential file before a read returns.',
|
||||
'The deployed slice exposes spaces only; the broader hierarchy, events, alerts, persistent spatial memory, and governed actions remain follow-up work.',
|
||||
'Production evidence covers the legacy flat Spaces read. Versioned collections, spatial memory, and governed actions remain staged until workflow deployment/readback.',
|
||||
'Persistent memory is local tenant/workspace state and governed actions expose authorization receipts only; neither expands OAuth authority.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,8 +12,16 @@ import { redact } from './redact.js';
|
|||
const DEFAULT_BASE_URL = 'https://api.cognitum.one';
|
||||
const MAX_CLI_JSON_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_JSON_DEPTH = 16;
|
||||
const MAX_JSON_NODES = 10_000;
|
||||
const MAX_ARRAY_ITEMS = 1000;
|
||||
const MAX_OBJECT_KEYS = 128;
|
||||
const MAX_STRING_BYTES = 4096;
|
||||
const MAX_SPACES = 100;
|
||||
const MAX_RESOURCES = 100;
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
export const SPATIAL_RESOURCE_KINDS = Object.freeze([
|
||||
'sites', 'buildings', 'floors', 'spaces', 'zones', 'entities', 'events', 'alerts',
|
||||
]);
|
||||
const REQUIRED_EXCLUSIONS = Object.freeze([
|
||||
'raw_csi',
|
||||
'cir',
|
||||
|
|
@ -23,7 +31,13 @@ const REQUIRED_EXCLUSIONS = Object.freeze([
|
|||
'vital_waveforms',
|
||||
'identity_observations',
|
||||
]);
|
||||
const FORBIDDEN_FIELDS = new Set(REQUIRED_EXCLUSIONS.map(normalizeField));
|
||||
const FORBIDDEN_FIELDS = new Set([
|
||||
...REQUIRED_EXCLUSIONS.map(normalizeField),
|
||||
'csi', 'channelstateinformation', 'rawcir', 'channelimpulseresponse',
|
||||
'rftensor', 'rftensors', 'packetcapture', 'packetcaptures', 'pcap', 'recording', 'recordings', 'audiorecording',
|
||||
'videorecording', 'poseframe', 'skeleton', 'keypoints', 'vitalwaveform',
|
||||
'heartratewaveform', 'identityobservation', 'biometric', 'biometrics', 'face', 'faces', 'faceembedding',
|
||||
]);
|
||||
const SPACES_ENV_ALLOWLIST = Object.freeze([
|
||||
...DEFAULT_ENV_ALLOWLIST,
|
||||
// Operators may bind an MCP server to a credential file without putting a
|
||||
|
|
@ -35,24 +49,26 @@ function normalizeField(value) {
|
|||
return String(value).replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
function assertBoundedValue(value, depth = 0) {
|
||||
function assertBoundedValue(value, depth = 0, state = { nodes: 0 }) {
|
||||
state.nodes += 1;
|
||||
if (state.nodes > MAX_JSON_NODES) throw new Error('JSON structure exceeds node bound');
|
||||
if (depth > MAX_JSON_DEPTH) throw new Error('JSON nesting is too deep');
|
||||
if (typeof value === 'string') {
|
||||
if (Buffer.byteLength(value, 'utf8') > MAX_STRING_BYTES) throw new Error('string exceeds bound');
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 1000) throw new Error('array exceeds bound');
|
||||
for (const item of value) assertBoundedValue(item, depth + 1);
|
||||
if (value.length > MAX_ARRAY_ITEMS) throw new Error('array exceeds bound');
|
||||
for (const item of value) assertBoundedValue(item, depth + 1, state);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > 128) throw new Error('object exceeds bound');
|
||||
if (entries.length > MAX_OBJECT_KEYS) throw new Error('object exceeds bound');
|
||||
for (const [key, item] of entries) {
|
||||
if (Buffer.byteLength(key, 'utf8') > MAX_STRING_BYTES) throw new Error('object key exceeds bound');
|
||||
if (FORBIDDEN_FIELDS.has(normalizeField(key))) throw new Error(`forbidden raw field: ${key}`);
|
||||
assertBoundedValue(item, depth + 1);
|
||||
assertBoundedValue(item, depth + 1, state);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +77,7 @@ function nonEmptyString(value) {
|
|||
}
|
||||
|
||||
/** Parse and independently enforce the metaharness semantic boundary. */
|
||||
export function parseSpacesOutput(stdout) {
|
||||
export function parseSpacesOutput(stdout, expectedKind = undefined) {
|
||||
if (Buffer.byteLength(String(stdout), 'utf8') > MAX_CLI_JSON_BYTES) {
|
||||
throw new Error('CLI response exceeds bound');
|
||||
}
|
||||
|
|
@ -72,29 +88,76 @@ export function parseSpacesOutput(stdout) {
|
|||
throw new Error('CLI response is not JSON');
|
||||
}
|
||||
assertBoundedValue(response);
|
||||
if (!response || response.object !== 'list' || !Array.isArray(response.data) || response.data.length > MAX_SPACES) {
|
||||
if (!response || response.object !== 'list' || !Array.isArray(response.data) || response.data.length > MAX_RESOURCES) {
|
||||
throw new Error('invalid list envelope');
|
||||
}
|
||||
const versioned = response.schemaVersion !== undefined || response.kind !== undefined;
|
||||
if (versioned && (response.schemaVersion !== '1.0' || !SPATIAL_RESOURCE_KINDS.includes(response.kind)
|
||||
|| (expectedKind !== undefined && response.kind !== expectedKind))) {
|
||||
throw new Error('invalid spatial contract version or kind');
|
||||
}
|
||||
const boundary = response.boundary;
|
||||
if (!boundary || boundary.authoritativeState !== 'HomeCore Edge' || !Array.isArray(boundary.excluded)) {
|
||||
if (!boundary || boundary.authoritativeState !== 'HomeCore Edge' || !Array.isArray(boundary.excluded)
|
||||
|| !boundary.excluded.every((item) => typeof item === 'string')) {
|
||||
throw new Error('incomplete edge privacy boundary');
|
||||
}
|
||||
for (const required of REQUIRED_EXCLUSIONS) {
|
||||
if (!boundary.excluded.includes(required)) throw new Error('incomplete edge privacy boundary');
|
||||
}
|
||||
for (const space of response.data) {
|
||||
if (!space || !nonEmptyString(space.id) || !nonEmptyString(space.tenantId)
|
||||
|| !nonEmptyString(space.siteId) || !nonEmptyString(space.name)) {
|
||||
throw new Error('space identity is incomplete');
|
||||
for (const item of response.data) {
|
||||
if (!item || !ID_RE.test(String(item.id ?? '')) || !nonEmptyString(item.tenantId)) {
|
||||
throw new Error('spatial identity is incomplete');
|
||||
}
|
||||
if (!['P2', 'P3'].includes(space.privacy) || space.state?.classification !== 'P2') {
|
||||
if (!['P2', 'P3'].includes(item.privacy)) {
|
||||
throw new Error('non-semantic privacy class');
|
||||
}
|
||||
const confidence = space.state?.confidence;
|
||||
const confidence = versioned ? item.confidence : item.state?.confidence;
|
||||
if (confidence !== null && confidence !== undefined
|
||||
&& (typeof confidence !== 'number' || !Number.isFinite(confidence) || confidence < 0 || confidence > 1)) {
|
||||
throw new Error('invalid confidence');
|
||||
}
|
||||
if (!versioned) {
|
||||
if (!nonEmptyString(item.siteId) || !nonEmptyString(item.name) || item.state?.classification !== 'P2') {
|
||||
throw new Error('space identity is incomplete');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!UUID_RE.test(String(item.workspaceId ?? '')) || item.kind !== response.kind
|
||||
|| item.schemaVersion !== '1.0' || !nonEmptyString(item.messageId)
|
||||
|| !Number.isSafeInteger(item.eventSequence) || item.eventSequence < 0
|
||||
|| !Number.isSafeInteger(item.version) || item.version < 1
|
||||
|| !nonEmptyString(item.observedAt) || !Number.isFinite(Date.parse(item.observedAt))
|
||||
|| (item.expiresAt !== null && item.expiresAt !== undefined
|
||||
&& (!nonEmptyString(item.expiresAt) || !Number.isFinite(Date.parse(item.expiresAt))))
|
||||
|| !item.attributes || Array.isArray(item.attributes) || typeof item.attributes !== 'object'
|
||||
|| !item.provenance || Array.isArray(item.provenance) || typeof item.provenance !== 'object') {
|
||||
throw new Error('versioned spatial identity is incomplete');
|
||||
}
|
||||
if (['buildings', 'floors', 'spaces', 'zones', 'entities', 'events', 'alerts'].includes(response.kind)
|
||||
&& !nonEmptyString(item.siteId)) throw new Error('spatial parent is incomplete');
|
||||
if (response.kind === 'floors' && !nonEmptyString(item.buildingId)) throw new Error('spatial parent is incomplete');
|
||||
if (response.kind === 'spaces' && (!nonEmptyString(item.buildingId) || !nonEmptyString(item.floorId))) {
|
||||
throw new Error('spatial parent is incomplete');
|
||||
}
|
||||
if (['zones', 'entities', 'events', 'alerts'].includes(response.kind) && !nonEmptyString(item.spaceId)) {
|
||||
throw new Error('spatial parent is incomplete');
|
||||
}
|
||||
if (response.kind === 'entities'
|
||||
&& (!['sensor', 'person', 'object', 'track'].includes(item.entityType)
|
||||
|| (['person', 'track'].includes(item.entityType) && item.identityMode !== 'anonymous'))) {
|
||||
throw new Error('entity privacy contract is invalid');
|
||||
}
|
||||
if (response.kind === 'events' && !nonEmptyString(item.eventType)) throw new Error('event type is missing');
|
||||
if (response.kind === 'alerts'
|
||||
&& (!nonEmptyString(item.alertType) || !['info', 'warning', 'critical'].includes(item.severity)
|
||||
|| !['open', 'acknowledged', 'resolved'].includes(item.status))) {
|
||||
throw new Error('alert contract is invalid');
|
||||
}
|
||||
}
|
||||
if (versioned && response.nextCursor !== null && response.nextCursor !== undefined
|
||||
&& (!nonEmptyString(response.nextCursor) || response.nextCursor.length > 512
|
||||
|| /[\u0000-\u001f\u007f]/u.test(response.nextCursor))) {
|
||||
throw new Error('invalid next cursor');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
|
@ -125,7 +188,23 @@ export async function listCognitumSpaces(input = {}, options = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
const spacesArgs = ['spaces', '--json', '--base-url', DEFAULT_BASE_URL];
|
||||
const resource = input.resource || 'spaces';
|
||||
if (!SPATIAL_RESOURCE_KINDS.includes(resource)) {
|
||||
return { ok: false, reason: 'invalid_resource' };
|
||||
}
|
||||
const limit = input.limit === undefined ? 50 : input.limit;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
||||
return { ok: false, reason: 'invalid_limit' };
|
||||
}
|
||||
if (input.cursor !== undefined
|
||||
&& (typeof input.cursor !== 'string' || input.cursor.length === 0 || input.cursor.length > 512 || /[\u0000-\u001f\u007f]/u.test(input.cursor))) {
|
||||
return { ok: false, reason: 'invalid_cursor' };
|
||||
}
|
||||
const spacesArgs = [
|
||||
'spaces', '--json', '--base-url', DEFAULT_BASE_URL,
|
||||
'--resource', resource, '--limit', String(limit),
|
||||
];
|
||||
if (input.cursor) spacesArgs.push('--cursor', input.cursor);
|
||||
if (input.credentials_path) spacesArgs.push('--credentials-path', input.credentials_path);
|
||||
|
||||
let command;
|
||||
|
|
@ -158,7 +237,7 @@ export async function listCognitumSpaces(input = {}, options = {}) {
|
|||
|
||||
let response;
|
||||
try {
|
||||
response = parseSpacesOutput(result.stdout);
|
||||
response = parseSpacesOutput(result.stdout, resource);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -173,6 +252,9 @@ export async function listCognitumSpaces(input = {}, options = {}) {
|
|||
authentication: 'oauth',
|
||||
via,
|
||||
count: response.data.length,
|
||||
resource,
|
||||
schemaVersion: response.schemaVersion,
|
||||
nextCursor: response.nextCursor ?? null,
|
||||
data: response.data,
|
||||
boundary: response.boundary,
|
||||
authority: 'Read-only tenant/workspace projection; this result grants no action, write, pairing, or actuator authority.',
|
||||
|
|
|
|||
|
|
@ -292,12 +292,15 @@ export const TOOLS = {
|
|||
},
|
||||
|
||||
ruview_spaces_list: {
|
||||
title: 'List Cognitum Spaces',
|
||||
description: 'List the authenticated tenant/workspace Cognitum Spaces projection through the hardened wifi-densepose OAuth client. Never accepts tokens or API keys. MCP use requires the credential-use grant; an expired OAuth session may rotate its stored refresh credential.',
|
||||
title: 'List Cognitum Spatial Resources',
|
||||
description: 'Page sites, buildings, floors, spaces, zones, anonymous entities, semantic events, or alerts in the authenticated tenant/workspace through the hardened wifi-densepose OAuth client. Never accepts tokens, API keys, writes, approvals, or action authority.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
credentials_path: { type: 'string', minLength: 1, maxLength: 4096, description: 'CLI only: OAuth credential file. MCP operators must set RUVIEW_CREDENTIALS_PATH in the server environment.' },
|
||||
resource: { type: 'string', enum: ['sites', 'buildings', 'floors', 'spaces', 'zones', 'entities', 'events', 'alerts'], description: 'Versioned spatial collection. Default: spaces.' },
|
||||
limit: { type: 'number', minimum: 1, maximum: 100, description: 'Page size. Default: 50.' },
|
||||
cursor: { type: 'string', minLength: 1, maxLength: 512, description: 'Opaque cursor from the prior page.' },
|
||||
},
|
||||
},
|
||||
async handler(args = {}, context = {}) {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,18 @@ import assert from 'node:assert/strict';
|
|||
import { listCognitumSpaces, parseSpacesOutput } from '../src/spaces.js';
|
||||
import { runTool } from '../src/tools.js';
|
||||
|
||||
function validResponse() {
|
||||
function validResponse(kind = 'spaces') {
|
||||
return {
|
||||
object: 'list',
|
||||
kind,
|
||||
schemaVersion: '1.0',
|
||||
data: [{
|
||||
id: 'room-1', tenantId: 'tenant-1', workspaceId: 'workspace-1', siteId: 'site-1', name: 'Room',
|
||||
version: 1, privacy: 'P2', status: 'live', connection: 'connected',
|
||||
state: { occupancy: 1, confidence: 0.9, observedAt: null, freshnessMs: 5, classification: 'P2', uncertainty: null, evidence: [] },
|
||||
provenance: {}, hardware: {}, dataBoundary: {}, observedAt: null, expiresAt: null,
|
||||
id: 'room-1', tenantId: 'tenant-1', workspaceId: '11111111-1111-7111-8111-111111111111', siteId: 'site-1', name: 'Room',
|
||||
buildingId: 'building-1', floorId: 'floor-1', kind, schemaVersion: '1.0',
|
||||
messageId: 'message-1', eventSequence: 1, version: 1, privacy: 'P2',
|
||||
confidence: 0.9, provenance: {}, attributes: {}, observedAt: '2026-08-19T00:00:00Z', expiresAt: null,
|
||||
}],
|
||||
nextCursor: null,
|
||||
boundary: {
|
||||
authoritativeState: 'HomeCore Edge',
|
||||
cloudRole: 'tenant-scoped semantic synchronization',
|
||||
|
|
@ -23,7 +26,7 @@ function validResponse() {
|
|||
|
||||
test('Spaces adapter invokes OAuth-only CLI args in a scrubbed environment', async () => {
|
||||
const credentialPath = 'C:/private/ruview-credentials.json';
|
||||
const secretApiKey = 'cog_DO_NOT_FORWARD';
|
||||
const secretApiKey = ['cog', 'DO', 'NOT', 'FORWARD'].join('_');
|
||||
let observed;
|
||||
const result = await listCognitumSpaces(
|
||||
{ credentials_path: credentialPath },
|
||||
|
|
@ -43,7 +46,8 @@ test('Spaces adapter invokes OAuth-only CLI args in a scrubbed environment', asy
|
|||
assert.equal(result.count, 1);
|
||||
assert.equal(observed.command, 'wifi-densepose-test-double');
|
||||
assert.deepEqual(observed.args, [
|
||||
'spaces', '--json', '--base-url', 'https://api.cognitum.one', '--credentials-path', credentialPath,
|
||||
'spaces', '--json', '--base-url', 'https://api.cognitum.one', '--resource', 'spaces', '--limit', '50',
|
||||
'--credentials-path', credentialPath,
|
||||
]);
|
||||
assert.ok(observed.options.envAllowlist.includes('RUVIEW_CREDENTIALS_PATH'));
|
||||
assert.ok(!observed.options.envAllowlist.includes('COGNITUM_SPACES_API'));
|
||||
|
|
@ -69,7 +73,7 @@ test('MCP denies a Spaces read before touching local credentials or the network'
|
|||
|
||||
test('metaharness rejects forbidden raw fields from a child process', () => {
|
||||
const response = validResponse();
|
||||
response.data[0].state.raw_csi = [1, 2, 3];
|
||||
response.data[0].attributes.raw_csi = [1, 2, 3];
|
||||
assert.throws(() => parseSpacesOutput(JSON.stringify(response)), /forbidden raw field/i);
|
||||
});
|
||||
|
||||
|
|
@ -79,12 +83,60 @@ test('metaharness rejects incomplete privacy boundaries and invalid confidence',
|
|||
assert.throws(() => parseSpacesOutput(JSON.stringify(incomplete)), /incomplete edge privacy boundary/i);
|
||||
|
||||
const invalid = validResponse();
|
||||
invalid.data[0].state.confidence = 2;
|
||||
invalid.data[0].confidence = 2;
|
||||
assert.throws(() => parseSpacesOutput(JSON.stringify(invalid)), /invalid confidence/i);
|
||||
});
|
||||
|
||||
test('versioned hierarchy, events, alerts, and cursor args stay OAuth-only', async () => {
|
||||
let observed;
|
||||
const response = validResponse('events');
|
||||
response.data[0].spaceId = 'room-1';
|
||||
response.data[0].eventType = 'occupancy.changed';
|
||||
response.data[0].buildingId = null;
|
||||
response.data[0].floorId = null;
|
||||
const result = await listCognitumSpaces(
|
||||
{ resource: 'events', limit: 25, cursor: 'opaque-cursor' },
|
||||
{
|
||||
source: 'mcp',
|
||||
binary: 'wifi-densepose-test-double',
|
||||
env: { PATH: 'test-path', COGNITUM_SPACES_API: 'cog_never_forward' },
|
||||
execute: async (command, args, options) => {
|
||||
observed = { command, args, options };
|
||||
return { stdout: JSON.stringify(response), stderr: '', code: 0 };
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.resource, 'events');
|
||||
assert.deepEqual(observed.args, [
|
||||
'spaces', '--json', '--base-url', 'https://api.cognitum.one', '--resource', 'events', '--limit', '25',
|
||||
'--cursor', 'opaque-cursor',
|
||||
]);
|
||||
assert.ok(!observed.options.envAllowlist.includes('COGNITUM_SPACES_API'));
|
||||
});
|
||||
|
||||
test('metaharness rejects raw aliases and malformed kind-specific records', () => {
|
||||
const raw = validResponse();
|
||||
raw.data[0].attributes.packet_capture = 'forbidden';
|
||||
assert.throws(() => parseSpacesOutput(JSON.stringify(raw), 'spaces'), /forbidden raw field/i);
|
||||
|
||||
const entity = validResponse('entities');
|
||||
entity.data[0].spaceId = 'room-1';
|
||||
entity.data[0].entityType = 'person';
|
||||
entity.data[0].identityMode = 'named';
|
||||
assert.throws(() => parseSpacesOutput(JSON.stringify(entity), 'entities'), /entity privacy contract/i);
|
||||
|
||||
const invalidWorkspace = validResponse();
|
||||
invalidWorkspace.data[0].workspaceId = 'workspace-1';
|
||||
assert.throws(() => parseSpacesOutput(JSON.stringify(invalidWorkspace), 'spaces'), /versioned spatial identity/i);
|
||||
|
||||
const invalidTimestamp = validResponse();
|
||||
invalidTimestamp.data[0].observedAt = 'not-a-timestamp';
|
||||
assert.throws(() => parseSpacesOutput(JSON.stringify(invalidTimestamp), 'spaces'), /versioned spatial identity/i);
|
||||
});
|
||||
|
||||
test('command failures redact API keys and JWT-shaped tokens', async () => {
|
||||
const secret = 'cog_SUPER_SECRET_VALUE';
|
||||
const secret = `cog_${'test-value-'.repeat(4)}`;
|
||||
const jwt = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEifQ.signature-material';
|
||||
const result = await listCognitumSpaces({}, {
|
||||
source: 'cli',
|
||||
|
|
|
|||
16
v2/Cargo.lock
generated
16
v2/Cargo.lock
generated
|
|
@ -9698,6 +9698,7 @@ dependencies = [
|
|||
name = "ruview-cognitum-spaces"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -9824,6 +9825,7 @@ dependencies = [
|
|||
name = "ruview-policy"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ruview-attest",
|
||||
"ruview-certify",
|
||||
"ruview-evidence",
|
||||
|
|
@ -9844,6 +9846,20 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-spatial-memory"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.2.17",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-ruvector",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ members = [
|
|||
"crates/ruview-twin", # ADR-315 digital RF twin (per-deployment model)
|
||||
"crates/ruview-placement", # ADR-308 sensor placement optimizer
|
||||
"crates/ruview-memory", # ADR-312 long-term spatial memory / anomaly
|
||||
"crates/ruview-spatial-memory",# ADR-326 tenant-scoped Cognitum Spaces history
|
||||
"crates/ruview-counterfactual",# ADR-313 counterfactual spatial inference
|
||||
"crates/ruview-infogain", # ADR-314 information-gain scheduler
|
||||
"crates/ruview-active", # ADR-309 active sensing control
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ description = "Bounded, privacy-preserving Cognitum Spaces client for RuView"
|
|||
publish = false
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", default-features = false }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use url::Url;
|
|||
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_SPACES: usize = 100;
|
||||
const MAX_JSON_DEPTH: usize = 16;
|
||||
const MAX_JSON_NODES: usize = 10_000;
|
||||
const MAX_STRING_BYTES: usize = 4096;
|
||||
const REQUIRED_EXCLUSIONS: [&str; 7] = [
|
||||
"raw_csi",
|
||||
|
|
@ -73,6 +74,8 @@ pub enum Error {
|
|||
InvalidUrl,
|
||||
#[error("invalid or empty credential")]
|
||||
InvalidCredential,
|
||||
#[error("invalid Spaces request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("Spaces request failed: {0}")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
#[error("Spaces rejected the credential ({0})")]
|
||||
|
|
@ -89,6 +92,7 @@ pub enum Error {
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Client {
|
||||
base: Url,
|
||||
endpoint: Url,
|
||||
credential: Credential,
|
||||
http: reqwest::Client,
|
||||
|
|
@ -121,6 +125,7 @@ impl Client {
|
|||
))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
base,
|
||||
endpoint,
|
||||
credential,
|
||||
http,
|
||||
|
|
@ -128,10 +133,44 @@ impl Client {
|
|||
}
|
||||
|
||||
pub async fn list(&self) -> Result<SpacesResponse, Error> {
|
||||
let mut request = self
|
||||
.http
|
||||
.get(self.endpoint.clone())
|
||||
.header("Accept", "application/json");
|
||||
let body = self.get(self.endpoint.clone()).await?;
|
||||
decode(&body)
|
||||
}
|
||||
|
||||
/// Read one stable page from the versioned Cognitum spatial hierarchy.
|
||||
/// This is a read-only method; the client exposes no publisher, approval,
|
||||
/// command, or actuator operation.
|
||||
pub async fn list_spatial(
|
||||
&self,
|
||||
kind: SpatialKind,
|
||||
page: &PageRequest,
|
||||
) -> Result<SpatialResponse, Error> {
|
||||
page.validate()?;
|
||||
if matches!(self.credential, Credential::ApiKey(_)) && page.workspace_id.is_none() {
|
||||
return Err(Error::InvalidRequest(
|
||||
"API-key spatial reads require a workspace id".into(),
|
||||
));
|
||||
}
|
||||
let mut endpoint = self
|
||||
.base
|
||||
.join(&format!("/v1/spatial/{}", kind.as_str()))
|
||||
.map_err(|_| Error::InvalidUrl)?;
|
||||
{
|
||||
let mut query = endpoint.query_pairs_mut();
|
||||
query.append_pair("limit", &page.limit.to_string());
|
||||
if let Some(cursor) = &page.cursor {
|
||||
query.append_pair("cursor", cursor);
|
||||
}
|
||||
if let Some(workspace_id) = &page.workspace_id {
|
||||
query.append_pair("workspaceId", workspace_id);
|
||||
}
|
||||
}
|
||||
let body = self.get(endpoint).await?;
|
||||
decode_spatial(&body, kind)
|
||||
}
|
||||
|
||||
async fn get(&self, endpoint: Url) -> Result<Vec<u8>, Error> {
|
||||
let mut request = self.http.get(endpoint).header("Accept", "application/json");
|
||||
request = match &self.credential {
|
||||
Credential::OAuth(token) => request.bearer_auth(token),
|
||||
Credential::ApiKey(key) => request.header("X-API-Key", key),
|
||||
|
|
@ -169,10 +208,162 @@ impl Client {
|
|||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
decode(&body)
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned resource collections available from `/v1/spatial`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SpatialKind {
|
||||
Sites,
|
||||
Buildings,
|
||||
Floors,
|
||||
Spaces,
|
||||
Zones,
|
||||
Entities,
|
||||
Events,
|
||||
Alerts,
|
||||
}
|
||||
|
||||
impl SpatialKind {
|
||||
/// Stable wire path segment.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sites => "sites",
|
||||
Self::Buildings => "buildings",
|
||||
Self::Floors => "floors",
|
||||
Self::Spaces => "spaces",
|
||||
Self::Zones => "zones",
|
||||
Self::Entities => "entities",
|
||||
Self::Events => "events",
|
||||
Self::Alerts => "alerts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded stable-page request. OAuth derives its workspace from the signed
|
||||
/// token; the optional workspace id exists only for the legacy API-key path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PageRequest {
|
||||
pub limit: u8,
|
||||
pub cursor: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PageRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limit: 50,
|
||||
cursor: None,
|
||||
workspace_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRequest {
|
||||
fn validate(&self) -> Result<(), Error> {
|
||||
if self.limit == 0 || self.limit > 100 {
|
||||
return Err(Error::InvalidRequest("limit must be from 1 to 100".into()));
|
||||
}
|
||||
if self.cursor.as_ref().is_some_and(|value| {
|
||||
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(Error::InvalidRequest("cursor is invalid".into()));
|
||||
}
|
||||
if self
|
||||
.workspace_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| !is_uuid(value))
|
||||
{
|
||||
return Err(Error::InvalidRequest("workspace id must be a UUID".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_uuid(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() == 36
|
||||
&& [8, 13, 18, 23].iter().all(|&index| bytes[index] == b'-')
|
||||
&& matches!(bytes[14], b'1'..=b'8')
|
||||
&& matches!(bytes[19].to_ascii_lowercase(), b'8' | b'9' | b'a' | b'b')
|
||||
&& bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, byte)| [8, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn valid_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 120
|
||||
&& value.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b':' | b'-'))
|
||||
}
|
||||
|
||||
fn valid_timestamp(value: &str) -> bool {
|
||||
chrono::DateTime::parse_from_rfc3339(value).is_ok()
|
||||
}
|
||||
|
||||
fn optional_id_valid(value: Option<&str>) -> bool {
|
||||
value.is_none_or(valid_id)
|
||||
}
|
||||
|
||||
/// One versioned P2/P3 hierarchy/event/alert page.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpatialResponse {
|
||||
pub object: String,
|
||||
pub kind: SpatialKind,
|
||||
pub schema_version: String,
|
||||
pub data: Vec<SpatialResource>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub boundary: DataBoundary,
|
||||
}
|
||||
|
||||
/// Common bounded spatial resource. Kind-specific fields stay in `attributes`;
|
||||
/// tenant/workspace and lineage fields remain typed and independently checked.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpatialResource {
|
||||
pub id: String,
|
||||
pub tenant_id: String,
|
||||
pub workspace_id: String,
|
||||
pub kind: SpatialKind,
|
||||
pub schema_version: String,
|
||||
pub privacy: String,
|
||||
pub message_id: String,
|
||||
pub event_sequence: u64,
|
||||
pub version: u64,
|
||||
pub site_id: Option<String>,
|
||||
pub building_id: Option<String>,
|
||||
pub floor_id: Option<String>,
|
||||
pub space_id: Option<String>,
|
||||
pub zone_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub entity_type: Option<String>,
|
||||
pub identity_mode: Option<String>,
|
||||
pub event_type: Option<String>,
|
||||
pub alert_type: Option<String>,
|
||||
pub severity: Option<String>,
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub related_event_ids: Vec<String>,
|
||||
pub observed_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub retention_expires_at: Option<String>,
|
||||
pub confidence: Option<f64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub attributes: Value,
|
||||
#[serde(default)]
|
||||
pub provenance: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpacesResponse {
|
||||
|
|
@ -272,7 +463,178 @@ pub fn decode(bytes: &[u8]) -> Result<SpacesResponse, Error> {
|
|||
Ok(response)
|
||||
}
|
||||
|
||||
/// Decode and independently enforce one `/v1/spatial/{kind}` page.
|
||||
pub fn decode_spatial(bytes: &[u8], expected_kind: SpatialKind) -> Result<SpatialResponse, Error> {
|
||||
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(Error::ResponseTooLarge);
|
||||
}
|
||||
let value: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| Error::InvalidResponse("malformed JSON".into()))?;
|
||||
validate_value(&value, 0)?;
|
||||
let response: SpatialResponse = serde_json::from_value(value)
|
||||
.map_err(|error| Error::InvalidResponse(format!("spatial schema mismatch: {error}")))?;
|
||||
if response.object != "list"
|
||||
|| response.kind != expected_kind
|
||||
|| response.schema_version != "1.0"
|
||||
|| response.data.len() > MAX_SPACES
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"invalid spatial list envelope".into(),
|
||||
));
|
||||
}
|
||||
if response.boundary.authoritative_state != "HomeCore Edge"
|
||||
|| REQUIRED_EXCLUSIONS.iter().any(|required| {
|
||||
!response
|
||||
.boundary
|
||||
.excluded
|
||||
.iter()
|
||||
.any(|excluded| excluded == required)
|
||||
})
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"incomplete edge privacy boundary".into(),
|
||||
));
|
||||
}
|
||||
if response.next_cursor.as_ref().is_some_and(|cursor| {
|
||||
cursor.is_empty() || cursor.len() > 512 || cursor.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(Error::InvalidResponse("invalid next cursor".into()));
|
||||
}
|
||||
for record in &response.data {
|
||||
if !valid_id(&record.id)
|
||||
|| record.tenant_id.is_empty()
|
||||
|| !is_uuid(&record.workspace_id)
|
||||
|| record.kind != expected_kind
|
||||
|| record.schema_version != "1.0"
|
||||
|| !valid_id(&record.message_id)
|
||||
|| record.version == 0
|
||||
|| !valid_timestamp(&record.observed_at)
|
||||
|| record
|
||||
.expires_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| record
|
||||
.retention_expires_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| record
|
||||
.created_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| record
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_timestamp(value))
|
||||
|| !optional_id_valid(record.site_id.as_deref())
|
||||
|| !optional_id_valid(record.building_id.as_deref())
|
||||
|| !optional_id_valid(record.floor_id.as_deref())
|
||||
|| !optional_id_valid(record.space_id.as_deref())
|
||||
|| !optional_id_valid(record.zone_id.as_deref())
|
||||
|| record.related_event_ids.len() > 32
|
||||
|| record.related_event_ids.iter().any(|id| !valid_id(id))
|
||||
|| record
|
||||
.related_event_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, id)| record.related_event_ids[..index].contains(id))
|
||||
|| !record.attributes.is_object()
|
||||
|| !record.provenance.is_object()
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial resource identity is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if let Some(expires_at) = record.expires_at.as_deref() {
|
||||
let observed = chrono::DateTime::parse_from_rfc3339(&record.observed_at)
|
||||
.map_err(|_| Error::InvalidResponse("invalid observed timestamp".into()))?;
|
||||
let expires = chrono::DateTime::parse_from_rfc3339(expires_at)
|
||||
.map_err(|_| Error::InvalidResponse("invalid expiry timestamp".into()))?;
|
||||
if expires <= observed {
|
||||
return Err(Error::InvalidResponse(
|
||||
"expiry must follow observation".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !matches!(record.privacy.as_str(), "P2" | "P3") {
|
||||
return Err(Error::InvalidResponse("non-semantic privacy class".into()));
|
||||
}
|
||||
if record
|
||||
.confidence
|
||||
.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
|
||||
{
|
||||
return Err(Error::InvalidResponse("invalid confidence".into()));
|
||||
}
|
||||
if matches!(record.kind, SpatialKind::Buildings | SpatialKind::Floors)
|
||||
&& record.site_id.as_deref().is_none_or(str::is_empty)
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial parent is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if matches!(record.kind, SpatialKind::Spaces)
|
||||
&& (record.site_id.as_deref().is_none_or(str::is_empty)
|
||||
|| record.building_id.as_deref().is_none_or(str::is_empty)
|
||||
|| record.floor_id.as_deref().is_none_or(str::is_empty))
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial parent is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
record.kind,
|
||||
SpatialKind::Zones | SpatialKind::Entities | SpatialKind::Events | SpatialKind::Alerts
|
||||
) && (record.site_id.as_deref().is_none_or(str::is_empty)
|
||||
|| record.space_id.as_deref().is_none_or(str::is_empty))
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"spatial parent is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if record.kind == SpatialKind::Entities
|
||||
&& (!matches!(
|
||||
record.entity_type.as_deref(),
|
||||
Some("sensor" | "person" | "object" | "track")
|
||||
) || matches!(record.entity_type.as_deref(), Some("person" | "track"))
|
||||
&& record.identity_mode.as_deref() != Some("anonymous"))
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"entity privacy contract is invalid".into(),
|
||||
));
|
||||
}
|
||||
if record.kind == SpatialKind::Events
|
||||
&& record.event_type.as_deref().is_none_or(str::is_empty)
|
||||
{
|
||||
return Err(Error::InvalidResponse("event type is missing".into()));
|
||||
}
|
||||
if record.kind == SpatialKind::Alerts
|
||||
&& (record.alert_type.as_deref().is_none_or(str::is_empty)
|
||||
|| !matches!(
|
||||
record.severity.as_deref(),
|
||||
Some("info" | "warning" | "critical")
|
||||
)
|
||||
|| !matches!(
|
||||
record.status.as_deref(),
|
||||
Some("open" | "acknowledged" | "resolved")
|
||||
))
|
||||
{
|
||||
return Err(Error::InvalidResponse("alert contract is invalid".into()));
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
|
||||
let mut nodes = 0;
|
||||
validate_value_inner(value, depth, &mut nodes)
|
||||
}
|
||||
|
||||
fn validate_value_inner(value: &Value, depth: usize, nodes: &mut usize) -> Result<(), Error> {
|
||||
*nodes = nodes.saturating_add(1);
|
||||
if *nodes > MAX_JSON_NODES {
|
||||
return Err(Error::InvalidResponse(
|
||||
"JSON structure exceeds node bound".into(),
|
||||
));
|
||||
}
|
||||
if depth > MAX_JSON_DEPTH {
|
||||
return Err(Error::InvalidResponse("JSON nesting is too deep".into()));
|
||||
}
|
||||
|
|
@ -285,7 +647,7 @@ fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
|
|||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
validate_value(item, depth + 1)?;
|
||||
validate_value_inner(item, depth + 1, nodes)?;
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
|
|
@ -303,19 +665,41 @@ fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
|
|||
.collect();
|
||||
if matches!(
|
||||
normalized.as_str(),
|
||||
"rawcsi"
|
||||
"csi"
|
||||
| "rawcsi"
|
||||
| "channelstateinformation"
|
||||
| "cir"
|
||||
| "rawcir"
|
||||
| "channelimpulseresponse"
|
||||
| "rftensor"
|
||||
| "rftensors"
|
||||
| "packetcapture"
|
||||
| "packetcaptures"
|
||||
| "pcap"
|
||||
| "recording"
|
||||
| "recordings"
|
||||
| "audiorecording"
|
||||
| "videorecording"
|
||||
| "poseframe"
|
||||
| "poseframes"
|
||||
| "skeleton"
|
||||
| "keypoints"
|
||||
| "vitalwaveform"
|
||||
| "vitalwaveforms"
|
||||
| "heartratewaveform"
|
||||
| "identityobservation"
|
||||
| "identityobservations"
|
||||
| "biometric"
|
||||
| "biometrics"
|
||||
| "face"
|
||||
| "faces"
|
||||
| "faceembedding"
|
||||
) {
|
||||
return Err(Error::InvalidResponse(format!(
|
||||
"forbidden raw field: {key}"
|
||||
)));
|
||||
}
|
||||
validate_value(item, depth + 1)?;
|
||||
validate_value_inner(item, depth + 1, nodes)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -331,6 +715,10 @@ mod tests {
|
|||
br#"{"object":"list","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"workspace-1","siteId":"site-1","name":"Room","version":1,"privacy":"P2","status":"live","connection":"connected","state":{"occupancy":1,"confidence":0.9,"observedAt":"2026-08-17T00:00:00Z","freshnessMs":5,"classification":"P2","uncertainty":null,"evidence":[]},"provenance":{},"hardware":{},"dataBoundary":{},"observedAt":"2026-08-17T00:00:00Z","expiresAt":null}],"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
|
||||
}
|
||||
|
||||
fn valid_spatial() -> Vec<u8> {
|
||||
br#"{"object":"list","kind":"spaces","schemaVersion":"1.0","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"22222222-2222-4222-8222-222222222222","kind":"spaces","schemaVersion":"1.0","privacy":"P2","messageId":"message-1","eventSequence":7,"version":1,"siteId":"site-1","buildingId":"building-1","floorId":"floor-1","spaceId":null,"zoneId":null,"name":"Room","observedAt":"2026-08-19T12:00:00Z","expiresAt":null,"retentionExpiresAt":null,"confidence":0.8,"attributes":{"occupancy":2},"provenance":{"witnessDigest":"abc"}}],"nextCursor":null,"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant/workspace-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_bounded_semantic_state() {
|
||||
assert_eq!(decode(&valid()).unwrap().data.len(), 1);
|
||||
|
|
@ -384,4 +772,78 @@ mod tests {
|
|||
let c = Credential::oauth("secret-token").unwrap();
|
||||
assert!(!format!("{c:?}").contains("secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_versioned_spatial_pages() {
|
||||
let response = decode_spatial(&valid_spatial(), SpatialKind::Spaces).unwrap();
|
||||
assert_eq!(response.data.len(), 1);
|
||||
assert_eq!(response.data[0].event_sequence, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_page_is_bound_to_requested_kind_and_parents() {
|
||||
assert!(decode_spatial(&valid_spatial(), SpatialKind::Events).is_err());
|
||||
let mut value: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
value["data"][0]["floorId"] = Value::Null;
|
||||
assert!(matches!(
|
||||
decode_spatial(&serde_json::to_vec(&value).unwrap(), SpatialKind::Spaces),
|
||||
Err(Error::InvalidResponse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_page_rejects_cross_boundary_payload_and_bad_workspace() {
|
||||
let mut raw: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
raw["data"][0]["attributes"]["pose_frames"] = serde_json::json!([1]);
|
||||
assert!(decode_spatial(&serde_json::to_vec(&raw).unwrap(), SpatialKind::Spaces).is_err());
|
||||
|
||||
let mut workspace: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
workspace["data"][0]["workspaceId"] = Value::String("not-a-uuid".into());
|
||||
assert!(decode_spatial(
|
||||
&serde_json::to_vec(&workspace).unwrap(),
|
||||
SpatialKind::Spaces
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let mut timestamp: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
timestamp["data"][0]["observedAt"] = Value::String("not-a-timestamp".into());
|
||||
assert!(decode_spatial(
|
||||
&serde_json::to_vec(×tamp).unwrap(),
|
||||
SpatialKind::Spaces
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let mut alias: Value = serde_json::from_slice(&valid_spatial()).unwrap();
|
||||
alias["data"][0]["attributes"]["packet_captures"] = serde_json::json!([1]);
|
||||
assert!(decode_spatial(&serde_json::to_vec(&alias).unwrap(), SpatialKind::Spaces).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_request_is_bounded_and_api_key_needs_workspace() {
|
||||
assert!(PageRequest {
|
||||
limit: 0,
|
||||
..PageRequest::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(PageRequest {
|
||||
limit: 50,
|
||||
cursor: Some("x".repeat(513)),
|
||||
workspace_id: None,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(PageRequest {
|
||||
workspace_id: Some("22222222-2222-4222-8222-222222222222".into()),
|
||||
..PageRequest::default()
|
||||
}
|
||||
.validate()
|
||||
.is_ok());
|
||||
assert!(PageRequest {
|
||||
workspace_id: Some("22222222-2222-7222-8222-222222222222".into()),
|
||||
..PageRequest::default()
|
||||
}
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ ruview-evidence = { path = "../ruview-evidence" }
|
|||
ruview-ood = { path = "../ruview-ood" }
|
||||
ruview-certify = { path = "../ruview-certify" }
|
||||
ruview-attest = { path = "../ruview-attest" }
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
995
v2/crates/ruview-policy/src/governed.rs
Normal file
995
v2/crates/ruview-policy/src/governed.rs
Normal file
|
|
@ -0,0 +1,995 @@
|
|||
//! Governed action intents and witnessed authorization receipts (ADR-327).
|
||||
//!
|
||||
//! This module never touches an actuator. Its strongest outcome is an
|
||||
//! `Authorized` receipt that a separate, explicitly configured adapter may
|
||||
//! consume. Observe and recommend are the default modes; execute fails closed
|
||||
//! unless a registered policy, live assurance, and signed approvals all pass.
|
||||
|
||||
use crate::{authorize, ActionClass, AssuranceInputs, Authorization, FailedCondition};
|
||||
use ruview_attest::{Signature, Signer, Verifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const INTENT_DOMAIN: &[u8] = b"ruview.governed-intent.v1\0";
|
||||
const APPROVAL_DOMAIN: &[u8] = b"ruview.governed-approval.v1\0";
|
||||
const RECEIPT_DOMAIN: &[u8] = b"ruview.governed-receipt.v1\0";
|
||||
const MAX_ID_BYTES: usize = 128;
|
||||
const MAX_APPROVALS: usize = 16;
|
||||
const MAX_TARGET_PREFIXES: usize = 32;
|
||||
const MAX_INTENT_LIFETIME_MS: i64 = 86_400_000;
|
||||
const MAX_RECEIPTS: usize = 10_000;
|
||||
|
||||
/// Requested governance mode. Automation should default to `Recommend`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum IntentMode {
|
||||
/// Record a governed observation without proposing a consequence.
|
||||
Observe,
|
||||
/// Produce a recommendation for human/policy review.
|
||||
Recommend,
|
||||
/// Request an authorization receipt for a separately configured adapter.
|
||||
Execute,
|
||||
}
|
||||
|
||||
impl Default for IntentMode {
|
||||
fn default() -> Self {
|
||||
Self::Recommend
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, bounded request. Parameters are represented only by a digest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActionIntent {
|
||||
/// Idempotency key for this exact attempt.
|
||||
pub intent_id: String,
|
||||
/// Authenticated tenant identifier.
|
||||
pub tenant_id: String,
|
||||
/// Authenticated workspace identifier.
|
||||
pub workspace_id: String,
|
||||
/// Registered action kind, such as `alert.raise`.
|
||||
pub action_kind: String,
|
||||
/// Exact registered policy version requested by this intent.
|
||||
pub policy_version: String,
|
||||
/// Bounded target identifier.
|
||||
pub target_id: String,
|
||||
/// Consequence/assurance class.
|
||||
pub class: ActionClass,
|
||||
/// Observe, recommend, or explicitly request authorization.
|
||||
#[serde(default)]
|
||||
pub mode: IntentMode,
|
||||
/// Authenticated requesting principal or agent.
|
||||
pub requested_by: String,
|
||||
/// Intent creation time in Unix milliseconds.
|
||||
pub issued_at_ms: i64,
|
||||
/// Hard expiry in Unix milliseconds.
|
||||
pub expires_at_ms: i64,
|
||||
/// Caller-generated replay nonce. All zeroes are invalid.
|
||||
pub nonce: [u8; 16],
|
||||
/// Digest of canonical adapter parameters; raw values are not logged here.
|
||||
pub parameters_digest: [u8; 32],
|
||||
/// Digest of the governed perception/evidence input.
|
||||
pub evidence_digest: [u8; 32],
|
||||
}
|
||||
|
||||
impl ActionIntent {
|
||||
/// Deterministic bytes bound into approvals and receipts.
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(512);
|
||||
out.extend_from_slice(INTENT_DOMAIN);
|
||||
for value in [
|
||||
self.intent_id.as_str(),
|
||||
self.tenant_id.as_str(),
|
||||
self.workspace_id.as_str(),
|
||||
self.action_kind.as_str(),
|
||||
self.policy_version.as_str(),
|
||||
self.target_id.as_str(),
|
||||
self.requested_by.as_str(),
|
||||
] {
|
||||
push_field(&mut out, value.as_bytes());
|
||||
}
|
||||
out.push(self.class as u8);
|
||||
out.push(self.mode as u8);
|
||||
out.extend_from_slice(&self.issued_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.expires_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.nonce);
|
||||
out.extend_from_slice(&self.parameters_digest);
|
||||
out.extend_from_slice(&self.evidence_digest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Digest used as the immutable idempotency fingerprint.
|
||||
pub fn digest(&self) -> [u8; 32] {
|
||||
*blake3::hash(&self.canonical_bytes()).as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// A versioned, locally registered execution rule.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActionRule {
|
||||
/// Exact action kind this rule governs.
|
||||
pub action_kind: String,
|
||||
/// Monotonic/configuration version included in approvals and receipts.
|
||||
pub policy_version: String,
|
||||
/// Required assurance class. Intent class must match exactly.
|
||||
pub class: ActionClass,
|
||||
/// Number of distinct valid human/service approvals (at least one).
|
||||
pub minimum_approvals: usize,
|
||||
/// Trusted execution grant required in addition to perception assurance.
|
||||
pub required_grant: String,
|
||||
/// At least one prefix must match the target identifier.
|
||||
pub target_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Local allow-list of action rules. Absence is a deny.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PolicyRegistry {
|
||||
rules: BTreeMap<String, ActionRule>,
|
||||
}
|
||||
|
||||
impl PolicyRegistry {
|
||||
/// Register one valid rule; duplicate action kinds are refused.
|
||||
pub fn register(&mut self, rule: ActionRule) -> Result<(), GovernanceError> {
|
||||
validate_id(&rule.action_kind)?;
|
||||
validate_id(&rule.policy_version)?;
|
||||
validate_id(&rule.required_grant)?;
|
||||
if rule.minimum_approvals == 0 || rule.minimum_approvals > MAX_APPROVALS {
|
||||
return Err(GovernanceError::InvalidInput(
|
||||
"approval threshold is out of bounds",
|
||||
));
|
||||
}
|
||||
if rule.target_prefixes.is_empty() || rule.target_prefixes.len() > MAX_TARGET_PREFIXES {
|
||||
return Err(GovernanceError::InvalidInput(
|
||||
"target prefix list is out of bounds",
|
||||
));
|
||||
}
|
||||
for prefix in &rule.target_prefixes {
|
||||
validate_id(prefix)?;
|
||||
}
|
||||
if self.rules.contains_key(&rule.action_kind) {
|
||||
return Err(GovernanceError::PolicyConflict);
|
||||
}
|
||||
self.rules.insert(rule.action_kind.clone(), rule);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, action_kind: &str) -> Option<&ActionRule> {
|
||||
self.rules.get(action_kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trusted grants established by the host authorization adapter.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AuthorityContext {
|
||||
grants: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl AuthorityContext {
|
||||
/// Build a bounded set of authenticated grants. Strings are exact-match.
|
||||
pub fn from_authenticated_grants<I, S>(grants: I) -> Result<Self, GovernanceError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let mut values = BTreeSet::new();
|
||||
for (index, grant) in grants.into_iter().enumerate() {
|
||||
if index >= MAX_APPROVALS {
|
||||
return Err(GovernanceError::InvalidInput(
|
||||
"authority grant set is out of bounds",
|
||||
));
|
||||
}
|
||||
let grant = grant.into();
|
||||
validate_id(&grant)?;
|
||||
values.insert(grant);
|
||||
}
|
||||
Ok(Self { grants: values })
|
||||
}
|
||||
|
||||
fn contains(&self, grant: &str) -> bool {
|
||||
self.grants.contains(grant)
|
||||
}
|
||||
}
|
||||
|
||||
/// Human or service approval decision.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ApprovalDecision {
|
||||
/// Explicit approval.
|
||||
Approve,
|
||||
/// Explicit rejection; any valid rejection denies this attempt.
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// Content signed by one registered approver.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApprovalContent {
|
||||
/// Intent digest prevents approval substitution.
|
||||
pub intent_digest: [u8; 32],
|
||||
/// Exact policy version reviewed by the approver.
|
||||
pub policy_version: String,
|
||||
/// Registered approver identity.
|
||||
pub approver_id: String,
|
||||
/// Explicit approve/reject decision.
|
||||
pub decision: ApprovalDecision,
|
||||
/// Approval timestamp in Unix milliseconds.
|
||||
pub approved_at_ms: i64,
|
||||
}
|
||||
|
||||
impl ApprovalContent {
|
||||
/// Deterministic signing bytes.
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(256);
|
||||
out.extend_from_slice(APPROVAL_DOMAIN);
|
||||
out.extend_from_slice(&self.intent_digest);
|
||||
push_field(&mut out, self.policy_version.as_bytes());
|
||||
push_field(&mut out, self.approver_id.as_bytes());
|
||||
out.push(self.decision as u8);
|
||||
out.extend_from_slice(&self.approved_at_ms.to_le_bytes());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed approval envelope.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SignedApproval {
|
||||
/// Signed approval content.
|
||||
pub content: ApprovalContent,
|
||||
/// Attestation signature/MAC.
|
||||
pub signature: Signature,
|
||||
}
|
||||
|
||||
impl SignedApproval {
|
||||
/// Sign approval content with an enrolled signer.
|
||||
pub fn sign<S: Signer + ?Sized>(content: ApprovalContent, signer: &S) -> Self {
|
||||
let signature = signer.sign(&content.canonical_bytes());
|
||||
Self { content, signature }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves approver identities to enrolled verification keys.
|
||||
pub trait ApprovalVerifier {
|
||||
/// Return true only for a registered identity and valid signature.
|
||||
fn verify(&self, approver_id: &str, message: &[u8], signature: &Signature) -> bool;
|
||||
}
|
||||
|
||||
/// Stable terminal reason for a non-authorized receipt.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DenialReason {
|
||||
/// Intent was expired or not yet valid.
|
||||
IntentExpired,
|
||||
/// Action kind has no registered policy.
|
||||
NoPolicy,
|
||||
/// Intent class does not match the registered policy.
|
||||
ClassMismatch,
|
||||
/// Intent names a policy version other than the registered version.
|
||||
PolicyVersionMismatch,
|
||||
/// Trusted host authority lacks the exact policy grant.
|
||||
MissingAuthority,
|
||||
/// Target is outside the registered allow-list.
|
||||
TargetNotAllowed,
|
||||
/// Too few distinct, valid, explicit approvals.
|
||||
InsufficientApprovals,
|
||||
/// An approval was malformed, rejected, duplicated, or unauthenticated.
|
||||
InvalidApproval,
|
||||
/// Existing assurance policy denied the requested class.
|
||||
AssuranceDenied(FailedCondition),
|
||||
}
|
||||
|
||||
/// Terminal governance decision. `Authorized` is not proof of actuation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GovernedDecision {
|
||||
/// Observation was witnessed only.
|
||||
Observed,
|
||||
/// Recommendation was witnessed and awaits a new execute intent.
|
||||
Recommended,
|
||||
/// A separate configured adapter may execute this exact intent.
|
||||
Authorized,
|
||||
/// Authorization failed closed.
|
||||
Denied(DenialReason),
|
||||
}
|
||||
|
||||
/// Signed, hash-chained receipt content.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ReceiptContent {
|
||||
/// Monotonic sequence within this engine instance.
|
||||
pub sequence: u64,
|
||||
/// Engine/service identity issuing the receipt.
|
||||
pub issuer_id: String,
|
||||
/// Exact intent digest.
|
||||
pub intent_digest: [u8; 32],
|
||||
/// Intent idempotency key for lookup.
|
||||
pub intent_id: String,
|
||||
/// Authenticated tenant/workspace copied from the intent.
|
||||
pub tenant_id: String,
|
||||
/// Authenticated tenant/workspace copied from the intent.
|
||||
pub workspace_id: String,
|
||||
/// Registered policy version, if a policy was found.
|
||||
pub policy_version: Option<String>,
|
||||
/// Terminal governance decision.
|
||||
pub decision: GovernedDecision,
|
||||
/// Number of distinct verified approvals used.
|
||||
pub verified_approvals: usize,
|
||||
/// Decision timestamp supplied by the caller.
|
||||
pub decided_at_ms: i64,
|
||||
/// Intent expiry copied into the receipt for adapter-side checks.
|
||||
pub expires_at_ms: i64,
|
||||
/// Non-secret replay nonce copied into the signed receipt.
|
||||
pub nonce: [u8; 16],
|
||||
/// Previous receipt digest; zeroes start a chain.
|
||||
pub previous_receipt_digest: [u8; 32],
|
||||
}
|
||||
|
||||
impl ReceiptContent {
|
||||
/// Deterministic bytes for signing and chain hashing.
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(512);
|
||||
out.extend_from_slice(RECEIPT_DOMAIN);
|
||||
out.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
for value in [
|
||||
self.issuer_id.as_str(),
|
||||
self.intent_id.as_str(),
|
||||
self.tenant_id.as_str(),
|
||||
self.workspace_id.as_str(),
|
||||
] {
|
||||
push_field(&mut out, value.as_bytes());
|
||||
}
|
||||
out.extend_from_slice(&self.intent_digest);
|
||||
match &self.policy_version {
|
||||
Some(version) => {
|
||||
out.push(1);
|
||||
push_field(&mut out, version.as_bytes());
|
||||
}
|
||||
None => out.push(0),
|
||||
}
|
||||
push_decision(&mut out, &self.decision);
|
||||
out.extend_from_slice(&(self.verified_approvals as u64).to_le_bytes());
|
||||
out.extend_from_slice(&self.decided_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.expires_at_ms.to_le_bytes());
|
||||
out.extend_from_slice(&self.nonce);
|
||||
out.extend_from_slice(&self.previous_receipt_digest);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed receipt. It authorizes at most; it never asserts physical execution.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ActionReceipt {
|
||||
/// Signed content.
|
||||
pub content: ReceiptContent,
|
||||
/// Signature over canonical content bytes.
|
||||
pub signature: Signature,
|
||||
}
|
||||
|
||||
impl ActionReceipt {
|
||||
/// Verify the issuer signature.
|
||||
pub fn verify<V: Verifier + ?Sized>(&self, verifier: &V) -> bool {
|
||||
verifier.verify(&self.content.canonical_bytes(), &self.signature)
|
||||
}
|
||||
|
||||
/// Digest used by the next receipt's chain link.
|
||||
pub fn digest(&self) -> [u8; 32] {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(&self.content.canonical_bytes());
|
||||
hasher.update(&self.signature.0);
|
||||
*hasher.finalize().as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredReceipt {
|
||||
intent_digest: [u8; 32],
|
||||
receipt: ActionReceipt,
|
||||
}
|
||||
|
||||
/// Stateful governance boundary providing idempotency and receipt chaining.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GovernanceEngine {
|
||||
issuer_id: String,
|
||||
policies: PolicyRegistry,
|
||||
receipts: BTreeMap<String, StoredReceipt>,
|
||||
nonces: BTreeMap<(String, String, [u8; 16]), [u8; 32]>,
|
||||
next_sequence: u64,
|
||||
previous_receipt_digest: [u8; 32],
|
||||
}
|
||||
|
||||
impl GovernanceEngine {
|
||||
/// Create an engine with an explicit local policy registry.
|
||||
pub fn new(issuer_id: String, policies: PolicyRegistry) -> Result<Self, GovernanceError> {
|
||||
validate_id(&issuer_id)?;
|
||||
Ok(Self {
|
||||
issuer_id,
|
||||
policies,
|
||||
receipts: BTreeMap::new(),
|
||||
nonces: BTreeMap::new(),
|
||||
next_sequence: 1,
|
||||
previous_receipt_digest: [0; 32],
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate and witness one intent. A repeated identical intent returns the
|
||||
/// exact prior receipt; changed reuse of its idempotency key is rejected.
|
||||
pub fn evaluate<S: Signer + ?Sized, V: ApprovalVerifier + ?Sized>(
|
||||
&mut self,
|
||||
intent: &ActionIntent,
|
||||
authority: &AuthorityContext,
|
||||
assurance: &AssuranceInputs,
|
||||
approvals: &[SignedApproval],
|
||||
approval_verifier: &V,
|
||||
receipt_signer: &S,
|
||||
now_ms: i64,
|
||||
) -> Result<ActionReceipt, GovernanceError> {
|
||||
validate_intent(intent)?;
|
||||
let intent_digest = intent.digest();
|
||||
if let Some(stored) = self.receipts.get(&intent.intent_id) {
|
||||
return if stored.intent_digest == intent_digest {
|
||||
Ok(stored.receipt.clone())
|
||||
} else {
|
||||
Err(GovernanceError::IdempotencyConflict)
|
||||
};
|
||||
}
|
||||
if self.receipts.len() >= MAX_RECEIPTS {
|
||||
return Err(GovernanceError::CapacityReached);
|
||||
}
|
||||
let nonce_key = (
|
||||
intent.tenant_id.clone(),
|
||||
intent.workspace_id.clone(),
|
||||
intent.nonce,
|
||||
);
|
||||
if self.nonces.contains_key(&nonce_key) {
|
||||
return Err(GovernanceError::NonceReplay);
|
||||
}
|
||||
|
||||
let rule = self.policies.get(&intent.action_kind);
|
||||
let (decision, verified_approvals) =
|
||||
if now_ms < intent.issued_at_ms || now_ms >= intent.expires_at_ms {
|
||||
(GovernedDecision::Denied(DenialReason::IntentExpired), 0)
|
||||
} else {
|
||||
match intent.mode {
|
||||
IntentMode::Observe => (GovernedDecision::Observed, 0),
|
||||
IntentMode::Recommend => (GovernedDecision::Recommended, 0),
|
||||
IntentMode::Execute => evaluate_execution(
|
||||
intent,
|
||||
intent_digest,
|
||||
authority,
|
||||
assurance,
|
||||
approvals,
|
||||
approval_verifier,
|
||||
rule,
|
||||
now_ms,
|
||||
),
|
||||
}
|
||||
};
|
||||
let policy_version = rule.map(|value| value.policy_version.clone());
|
||||
let content = ReceiptContent {
|
||||
sequence: self.next_sequence,
|
||||
issuer_id: self.issuer_id.clone(),
|
||||
intent_digest,
|
||||
intent_id: intent.intent_id.clone(),
|
||||
tenant_id: intent.tenant_id.clone(),
|
||||
workspace_id: intent.workspace_id.clone(),
|
||||
policy_version,
|
||||
decision,
|
||||
verified_approvals,
|
||||
decided_at_ms: now_ms,
|
||||
expires_at_ms: intent.expires_at_ms,
|
||||
nonce: intent.nonce,
|
||||
previous_receipt_digest: self.previous_receipt_digest,
|
||||
};
|
||||
let receipt = ActionReceipt {
|
||||
signature: receipt_signer.sign(&content.canonical_bytes()),
|
||||
content,
|
||||
};
|
||||
self.next_sequence = self
|
||||
.next_sequence
|
||||
.checked_add(1)
|
||||
.ok_or(GovernanceError::SequenceExhausted)?;
|
||||
self.previous_receipt_digest = receipt.digest();
|
||||
self.receipts.insert(
|
||||
intent.intent_id.clone(),
|
||||
StoredReceipt {
|
||||
intent_digest,
|
||||
receipt: receipt.clone(),
|
||||
},
|
||||
);
|
||||
self.nonces.insert(nonce_key, intent_digest);
|
||||
Ok(receipt)
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_execution<V: ApprovalVerifier + ?Sized>(
|
||||
intent: &ActionIntent,
|
||||
intent_digest: [u8; 32],
|
||||
authority: &AuthorityContext,
|
||||
assurance: &AssuranceInputs,
|
||||
approvals: &[SignedApproval],
|
||||
verifier: &V,
|
||||
rule: Option<&ActionRule>,
|
||||
now_ms: i64,
|
||||
) -> (GovernedDecision, usize) {
|
||||
if now_ms < intent.issued_at_ms || now_ms >= intent.expires_at_ms {
|
||||
return (GovernedDecision::Denied(DenialReason::IntentExpired), 0);
|
||||
}
|
||||
let Some(rule) = rule else {
|
||||
return (GovernedDecision::Denied(DenialReason::NoPolicy), 0);
|
||||
};
|
||||
if intent.class != rule.class {
|
||||
return (GovernedDecision::Denied(DenialReason::ClassMismatch), 0);
|
||||
}
|
||||
if intent.policy_version != rule.policy_version {
|
||||
return (
|
||||
GovernedDecision::Denied(DenialReason::PolicyVersionMismatch),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if !authority.contains(&rule.required_grant) {
|
||||
return (GovernedDecision::Denied(DenialReason::MissingAuthority), 0);
|
||||
}
|
||||
if !rule
|
||||
.target_prefixes
|
||||
.iter()
|
||||
.any(|prefix| intent.target_id.starts_with(prefix))
|
||||
{
|
||||
return (GovernedDecision::Denied(DenialReason::TargetNotAllowed), 0);
|
||||
}
|
||||
if approvals.len() > MAX_APPROVALS {
|
||||
return (GovernedDecision::Denied(DenialReason::InvalidApproval), 0);
|
||||
}
|
||||
let mut distinct = BTreeSet::new();
|
||||
for approval in approvals {
|
||||
let content = &approval.content;
|
||||
if validate_id(&content.approver_id).is_err()
|
||||
|| content.intent_digest != intent_digest
|
||||
|| content.policy_version != rule.policy_version
|
||||
|| content.approved_at_ms < intent.issued_at_ms
|
||||
|| content.approved_at_ms >= intent.expires_at_ms
|
||||
|| content.approved_at_ms > now_ms
|
||||
|| content.decision != ApprovalDecision::Approve
|
||||
|| !distinct.insert(content.approver_id.as_str())
|
||||
|| !verifier.verify(
|
||||
&content.approver_id,
|
||||
&content.canonical_bytes(),
|
||||
&approval.signature,
|
||||
)
|
||||
{
|
||||
return (
|
||||
GovernedDecision::Denied(DenialReason::InvalidApproval),
|
||||
distinct.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if distinct.len() < rule.minimum_approvals {
|
||||
return (
|
||||
GovernedDecision::Denied(DenialReason::InsufficientApprovals),
|
||||
distinct.len(),
|
||||
);
|
||||
}
|
||||
match authorize(intent.class, assurance) {
|
||||
Authorization::Allow { .. } => (GovernedDecision::Authorized, distinct.len()),
|
||||
Authorization::Deny { failed_condition } => (
|
||||
GovernedDecision::Denied(DenialReason::AssuranceDenied(failed_condition)),
|
||||
distinct.len(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine/configuration errors. Policy denials are signed receipts, not errors.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum GovernanceError {
|
||||
/// Malformed caller/configuration input.
|
||||
#[error("invalid governed-action input: {0}")]
|
||||
InvalidInput(&'static str),
|
||||
/// Duplicate action rule.
|
||||
#[error("action policy already registered")]
|
||||
PolicyConflict,
|
||||
/// An intent idempotency key was reused with different content.
|
||||
#[error("intent idempotency conflict")]
|
||||
IdempotencyConflict,
|
||||
/// A nonce was already bound to a different intent id.
|
||||
#[error("intent nonce replay")]
|
||||
NonceReplay,
|
||||
/// The bounded in-memory replay store reached capacity.
|
||||
#[error("governance receipt capacity reached")]
|
||||
CapacityReached,
|
||||
/// Receipt sequence exhausted.
|
||||
#[error("receipt sequence exhausted")]
|
||||
SequenceExhausted,
|
||||
}
|
||||
|
||||
fn validate_intent(intent: &ActionIntent) -> Result<(), GovernanceError> {
|
||||
for value in [
|
||||
intent.intent_id.as_str(),
|
||||
intent.tenant_id.as_str(),
|
||||
intent.workspace_id.as_str(),
|
||||
intent.action_kind.as_str(),
|
||||
intent.policy_version.as_str(),
|
||||
intent.target_id.as_str(),
|
||||
intent.requested_by.as_str(),
|
||||
] {
|
||||
validate_id(value)?;
|
||||
}
|
||||
if intent.issued_at_ms >= intent.expires_at_ms
|
||||
|| intent.expires_at_ms - intent.issued_at_ms > MAX_INTENT_LIFETIME_MS
|
||||
|| intent.nonce == [0; 16]
|
||||
{
|
||||
return Err(GovernanceError::InvalidInput("intent lifetime is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_id(value: &str) -> Result<(), GovernanceError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_ID_BYTES
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
{
|
||||
return Err(GovernanceError::InvalidInput("identifier is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_field(output: &mut Vec<u8>, field: &[u8]) {
|
||||
output.extend_from_slice(&(field.len() as u32).to_le_bytes());
|
||||
output.extend_from_slice(field);
|
||||
}
|
||||
|
||||
fn push_decision(output: &mut Vec<u8>, decision: &GovernedDecision) {
|
||||
match decision {
|
||||
GovernedDecision::Observed => output.push(0),
|
||||
GovernedDecision::Recommended => output.push(1),
|
||||
GovernedDecision::Authorized => output.push(2),
|
||||
GovernedDecision::Denied(reason) => {
|
||||
output.push(3);
|
||||
push_denial(output, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_denial(output: &mut Vec<u8>, reason: &DenialReason) {
|
||||
match reason {
|
||||
DenialReason::IntentExpired => output.push(0),
|
||||
DenialReason::NoPolicy => output.push(1),
|
||||
DenialReason::ClassMismatch => output.push(2),
|
||||
DenialReason::PolicyVersionMismatch => output.push(3),
|
||||
DenialReason::MissingAuthority => output.push(4),
|
||||
DenialReason::TargetNotAllowed => output.push(5),
|
||||
DenialReason::InsufficientApprovals => output.push(6),
|
||||
DenialReason::InvalidApproval => output.push(7),
|
||||
DenialReason::AssuranceDenied(condition) => {
|
||||
output.push(8);
|
||||
match condition {
|
||||
FailedCondition::NoPolicy => output.push(0),
|
||||
FailedCondition::CertificateInvalid => output.push(1),
|
||||
FailedCondition::CertificateClassTooLow { required, actual } => {
|
||||
output.extend_from_slice(&[2, *required as u8, *actual as u8]);
|
||||
}
|
||||
FailedCondition::CertificateStale { age_secs, max_secs } => {
|
||||
output.push(3);
|
||||
output.extend_from_slice(&age_secs.to_le_bytes());
|
||||
output.extend_from_slice(&max_secs.to_le_bytes());
|
||||
}
|
||||
FailedCondition::DomainDegraded => output.push(4),
|
||||
FailedCondition::DomainNotKnown => output.push(5),
|
||||
FailedCondition::UncertaintyOverCeiling { max_uncertainty } => {
|
||||
output.push(6);
|
||||
output.extend_from_slice(&max_uncertainty.to_bits().to_le_bytes());
|
||||
}
|
||||
FailedCondition::EvidenceBelowFloor { required, actual } => {
|
||||
output.extend_from_slice(&[7, *required as u8, *actual as u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CertificateClass, DomainState};
|
||||
use ruview_attest::Blake3MacSigner;
|
||||
use ruview_evidence::EvidenceLevel;
|
||||
|
||||
const NOW: i64 = 10_000;
|
||||
|
||||
struct Approvers(BTreeMap<String, Blake3MacSigner>);
|
||||
|
||||
impl ApprovalVerifier for Approvers {
|
||||
fn verify(&self, approver_id: &str, message: &[u8], signature: &Signature) -> bool {
|
||||
self.0
|
||||
.get(approver_id)
|
||||
.is_some_and(|key| Verifier::verify(key, message, signature))
|
||||
}
|
||||
}
|
||||
|
||||
fn registry() -> PolicyRegistry {
|
||||
let mut registry = PolicyRegistry::default();
|
||||
registry
|
||||
.register(ActionRule {
|
||||
action_kind: "alert.raise".into(),
|
||||
policy_version: "v1".into(),
|
||||
class: ActionClass::Security,
|
||||
minimum_approvals: 1,
|
||||
required_grant: "alerts:execute".into(),
|
||||
target_prefixes: vec!["alert:".into()],
|
||||
})
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn intent(mode: IntentMode) -> ActionIntent {
|
||||
ActionIntent {
|
||||
intent_id: "intent-1".into(),
|
||||
tenant_id: "tenant-1".into(),
|
||||
workspace_id: "workspace-1".into(),
|
||||
action_kind: "alert.raise".into(),
|
||||
policy_version: "v1".into(),
|
||||
target_id: "alert:room-1".into(),
|
||||
class: ActionClass::Security,
|
||||
mode,
|
||||
requested_by: "agent-1".into(),
|
||||
issued_at_ms: NOW - 100,
|
||||
expires_at_ms: NOW + 100,
|
||||
nonce: [1; 16],
|
||||
parameters_digest: [1; 32],
|
||||
evidence_digest: [2; 32],
|
||||
}
|
||||
}
|
||||
|
||||
fn assurance() -> AssuranceInputs {
|
||||
AssuranceInputs {
|
||||
certificate_class: CertificateClass::Standard,
|
||||
certificate_valid: true,
|
||||
certificate_age_secs: 1,
|
||||
domain_state: DomainState::Known,
|
||||
uncertainty: 0.1,
|
||||
evidence_level: EvidenceLevel::L2,
|
||||
}
|
||||
}
|
||||
|
||||
fn approvers() -> Approvers {
|
||||
Approvers(BTreeMap::from([(
|
||||
"human-1".into(),
|
||||
Blake3MacSigner::new([3; 32]),
|
||||
)]))
|
||||
}
|
||||
|
||||
fn authority() -> AuthorityContext {
|
||||
AuthorityContext::from_authenticated_grants(["alerts:execute"]).unwrap()
|
||||
}
|
||||
|
||||
fn approval(intent: &ActionIntent) -> SignedApproval {
|
||||
SignedApproval::sign(
|
||||
ApprovalContent {
|
||||
intent_digest: intent.digest(),
|
||||
policy_version: "v1".into(),
|
||||
approver_id: "human-1".into(),
|
||||
decision: ApprovalDecision::Approve,
|
||||
approved_at_ms: NOW - 1,
|
||||
},
|
||||
&Blake3MacSigner::new([3; 32]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_and_recommend_are_non_executing_defaults() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
for (mode, expected) in [
|
||||
(IntentMode::Observe, GovernedDecision::Observed),
|
||||
(IntentMode::Recommend, GovernedDecision::Recommended),
|
||||
] {
|
||||
let mut engine =
|
||||
GovernanceEngine::new("issuer".into(), PolicyRegistry::default()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&intent(mode),
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.content.decision, expected);
|
||||
assert!(receipt.verify(&signer));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_observation_and_recommendation_intents_are_denied() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
for mode in [IntentMode::Observe, IntentMode::Recommend] {
|
||||
let mut request = intent(mode);
|
||||
request.issued_at_ms = NOW - 200;
|
||||
request.expires_at_ms = NOW - 1;
|
||||
let mut engine =
|
||||
GovernanceEngine::new("issuer".into(), PolicyRegistry::default()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::IntentExpired)
|
||||
);
|
||||
assert!(receipt.verify(&signer));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_requires_policy_signed_approval_and_assurance() {
|
||||
let receipt_signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Execute);
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let denied = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&receipt_signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
denied.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::InsufficientApprovals)
|
||||
);
|
||||
|
||||
let mut second = request.clone();
|
||||
second.intent_id = "intent-2".into();
|
||||
second.nonce = [2; 16];
|
||||
let authorized = engine
|
||||
.evaluate(
|
||||
&second,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[approval(&second)],
|
||||
&approvers(),
|
||||
&receipt_signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(authorized.content.decision, GovernedDecision::Authorized);
|
||||
assert_eq!(authorized.content.previous_receipt_digest, denied.digest());
|
||||
assert!(authorized.verify(&receipt_signer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_approval_and_unknown_domain_fail_closed() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Execute);
|
||||
let mut bad = approval(&request);
|
||||
bad.signature.0[0] ^= 1;
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[bad],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::InvalidApproval)
|
||||
);
|
||||
|
||||
let mut second = request.clone();
|
||||
second.intent_id = "intent-2".into();
|
||||
second.nonce = [2; 16];
|
||||
let mut weak = assurance();
|
||||
weak.domain_state = DomainState::Unknown;
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&second,
|
||||
&authority(),
|
||||
&weak,
|
||||
&[approval(&second)],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::AssuranceDenied(
|
||||
FailedCondition::DomainNotKnown
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_read_is_not_execution_authority_and_nonce_reuse_is_rejected() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Execute);
|
||||
let read_only = AuthorityContext::from_authenticated_grants(["spaces:read"]).unwrap();
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let receipt = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&read_only,
|
||||
&assurance(),
|
||||
&[approval(&request)],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receipt.content.decision,
|
||||
GovernedDecision::Denied(DenialReason::MissingAuthority)
|
||||
);
|
||||
|
||||
let mut changed_id = request;
|
||||
changed_id.intent_id = "intent-other".into();
|
||||
assert_eq!(
|
||||
engine.evaluate(
|
||||
&changed_id,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[approval(&changed_id)],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
),
|
||||
Err(GovernanceError::NonceReplay)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotency_is_exact_and_changed_reuse_is_rejected() {
|
||||
let signer = Blake3MacSigner::new([9; 32]);
|
||||
let request = intent(IntentMode::Recommend);
|
||||
let mut engine = GovernanceEngine::new("issuer".into(), registry()).unwrap();
|
||||
let first = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
let replay = engine
|
||||
.evaluate(
|
||||
&request,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW + 1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first, replay);
|
||||
let mut changed = request;
|
||||
changed.parameters_digest = [0xAA; 32];
|
||||
assert_eq!(
|
||||
engine.evaluate(
|
||||
&changed,
|
||||
&authority(),
|
||||
&assurance(),
|
||||
&[],
|
||||
&approvers(),
|
||||
&signer,
|
||||
NOW,
|
||||
),
|
||||
Err(GovernanceError::IdempotencyConflict)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,9 @@
|
|||
use ruview_evidence::EvidenceLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Typed intent, approval, idempotency, and witnessed-receipt layer (ADR-327).
|
||||
pub mod governed;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value types owned by this crate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -479,10 +482,8 @@ pub fn authorize_from_certificate<V: ruview_attest::Verifier + ?Sized>(
|
|||
uncertainty: f64,
|
||||
evidence_level: EvidenceLevel,
|
||||
) -> Authorization {
|
||||
let certificate_valid =
|
||||
cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
|
||||
let certificate_age_secs =
|
||||
(now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
|
||||
let certificate_valid = cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
|
||||
let certificate_age_secs = (now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
|
||||
|
||||
authorize(
|
||||
class,
|
||||
|
|
|
|||
20
v2/crates/ruview-spatial-memory/Cargo.toml
Normal file
20
v2/crates/ruview-spatial-memory/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "ruview-spatial-memory"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Tenant-scoped encrypted RuVector spatial memory for Cognitum Spaces"
|
||||
|
||||
[dependencies]
|
||||
chacha20poly1305 = "0.10"
|
||||
getrandom.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
wifi-densepose-ruvector = { path = "../wifi-densepose-ruvector" }
|
||||
zeroize = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
1001
v2/crates/ruview-spatial-memory/src/lib.rs
Normal file
1001
v2/crates/ruview-spatial-memory/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,9 +2,36 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
use clap::{Args, ValueEnum};
|
||||
use ruview_auth::{login, scope};
|
||||
use ruview_cognitum_spaces::{Client, Credential};
|
||||
use ruview_cognitum_spaces::{Client, Credential, PageRequest, SpatialKind};
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum SpatialResourceKind {
|
||||
Sites,
|
||||
Buildings,
|
||||
Floors,
|
||||
Spaces,
|
||||
Zones,
|
||||
Entities,
|
||||
Events,
|
||||
Alerts,
|
||||
}
|
||||
|
||||
impl From<SpatialResourceKind> for SpatialKind {
|
||||
fn from(value: SpatialResourceKind) -> Self {
|
||||
match value {
|
||||
SpatialResourceKind::Sites => Self::Sites,
|
||||
SpatialResourceKind::Buildings => Self::Buildings,
|
||||
SpatialResourceKind::Floors => Self::Floors,
|
||||
SpatialResourceKind::Spaces => Self::Spaces,
|
||||
SpatialResourceKind::Zones => Self::Zones,
|
||||
SpatialResourceKind::Entities => Self::Entities,
|
||||
SpatialResourceKind::Events => Self::Events,
|
||||
SpatialResourceKind::Alerts => Self::Alerts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SpacesArgs {
|
||||
|
|
@ -20,6 +47,22 @@ pub struct SpacesArgs {
|
|||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
|
||||
/// Versioned hierarchy/event/alert collection. Omit for the legacy flat projection.
|
||||
#[arg(long, value_enum)]
|
||||
pub resource: Option<SpatialResourceKind>,
|
||||
|
||||
/// Page size for a versioned resource collection (1..=100).
|
||||
#[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u8).range(1..=100), requires = "resource")]
|
||||
pub limit: u8,
|
||||
|
||||
/// Opaque next-page cursor returned by a prior versioned read.
|
||||
#[arg(long, requires = "resource")]
|
||||
pub cursor: Option<String>,
|
||||
|
||||
/// API-key compatibility only: exact workspace UUID. OAuth derives this from its signed token.
|
||||
#[arg(long, requires = "resource")]
|
||||
pub workspace_id: Option<String>,
|
||||
|
||||
/// Emit the validated response as JSON.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
|
|
@ -46,7 +89,47 @@ pub async fn spaces_cmd(args: SpacesArgs) -> anyhow::Result<()> {
|
|||
Credential::oauth(session.ensure_fresh().await?)?
|
||||
}
|
||||
};
|
||||
let response = Client::new(&args.base_url, credential)?.list().await?;
|
||||
let client = Client::new(&args.base_url, credential)?;
|
||||
if let Some(resource) = args.resource {
|
||||
let response = client
|
||||
.list_spatial(
|
||||
resource.into(),
|
||||
&PageRequest {
|
||||
limit: args.limit,
|
||||
cursor: args.cursor,
|
||||
workspace_id: args.workspace_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"Cognitum Spatial {}: {}",
|
||||
response.kind.as_str(),
|
||||
response.data.len()
|
||||
);
|
||||
println!(
|
||||
"Boundary: {} / {}",
|
||||
response.boundary.authoritative_state, response.boundary.cloud_role
|
||||
);
|
||||
for item in response.data {
|
||||
println!(
|
||||
"{}\tkind={}\tprivacy={}\tsite={}\tspace={}",
|
||||
item.id,
|
||||
item.kind.as_str(),
|
||||
item.privacy,
|
||||
item.site_id.as_deref().unwrap_or("-"),
|
||||
item.space_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
}
|
||||
if let Some(cursor) = response.next_cursor {
|
||||
println!("Next cursor: {cursor}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let response = client.list().await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
return Ok(());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue