feat(grid-agent): enforce central action policy (#120)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m44s
CI / required (push) Failing after 2m42s

This commit is contained in:
2026-08-17 21:18:52 +00:00
parent a46bc42a8f
commit e3b9d575f9
13 changed files with 3288 additions and 33 deletions

1
Cargo.lock generated
View File

@@ -2203,6 +2203,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"tokio", "tokio",
"url", "url",
] ]

View File

@@ -14,6 +14,7 @@ libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.11"
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] } tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
url = "2.5.8" url = "2.5.8"

View File

@@ -40,4 +40,7 @@ cargo run --locked -p metacrate-grid-agent -- \
See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md) See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md)
for queue/task ownership, shutdown, and trust boundaries. See for queue/task ownership, shutdown, and trust boundaries. See
[`../../docs/grid-agent-llm.md`](../../docs/grid-agent-llm.md) for the LLM wire [`../../docs/grid-agent-llm.md`](../../docs/grid-agent-llm.md) for the LLM wire
compatibility envelope, retry rules, and tool-loop safety contract. compatibility envelope, retry rules, and tool-loop safety contract. The central
origin matrix, approval binding, budgets, prompt-data boundary, and opaque
backend authorization are specified in
[`../../docs/grid-agent-policy.md`](../../docs/grid-agent-policy.md).

View File

@@ -1,6 +1,7 @@
//! Narrow injected boundaries between orchestration and grid/world I/O. //! Narrow injected boundaries between orchestration and grid/world I/O.
use crate::types::{GridEvent, GridEventKind, PolicyDecision, ProposedToolCall, ToolCallOutcome}; use crate::policy::AuthorizedAction;
use crate::types::{GridEvent, GridEventKind, ToolCallOutcome};
use libremetaverse_types::compat::CancellationToken; use libremetaverse_types::compat::CancellationToken;
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
@@ -49,18 +50,21 @@ pub trait GridBackend: Send + Sync + 'static {
) -> BackendFuture<'_, Result<(), BackendError>>; ) -> BackendFuture<'_, Result<(), BackendError>>;
} }
/// The sole world-mutation boundary. Later tool implementations cannot bypass /// Backend for an already authorized tool action. `AuthorizedAction` has no
/// the policy decision passed to this trait, and fake/live implementations use /// public constructor and binds the exact name, arguments, principal, budget,
/// the same call shape. /// and one policy authorization.
pub trait WorldMutator: Send + Sync + 'static { pub trait AuthorizedToolBackend: Send + Sync + 'static {
fn apply( fn apply(
&self, &self,
call: ProposedToolCall, action: AuthorizedAction,
decision: PolicyDecision,
cancellation: CancellationToken, cancellation: CancellationToken,
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>>; ) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>>;
} }
/// Marker for world-mutating backends. Production mutations use the same
/// opaque authorization boundary as every other tool backend.
pub trait WorldMutator: AuthorizedToolBackend {}
/// Inert deterministic backend used by the foundational offline service. /// Inert deterministic backend used by the foundational offline service.
/// ///
/// It performs no login or network operation and is available without the /// It performs no login or network operation and is available without the

View File

@@ -7,13 +7,20 @@
pub mod backend; pub mod backend;
pub mod config; pub mod config;
pub mod llm; pub mod llm;
pub mod policy;
pub mod service; pub mod service;
pub mod tool_loop; pub mod tool_loop;
pub mod types; pub mod types;
#[cfg(test)]
mod policy_tests;
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
pub use backend::LibremetaverseClientOwner; pub use backend::LibremetaverseClientOwner;
pub use backend::{BackendError, BackendFuture, GridBackend, OfflineGridBackend, WorldMutator}; pub use backend::{
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
WorldMutator,
};
pub use config::{ pub use config::{
AgentConfig, BehaviorSettings, ConfigError, ConfigLoader, EndpointUrl, Environment, AgentConfig, BehaviorSettings, ConfigError, ConfigLoader, EndpointUrl, Environment,
GridConnection, Limits, LlmConnection, MapEnvironment, OperatingMode, SecretString, GridConnection, Limits, LlmConnection, MapEnvironment, OperatingMode, SecretString,
@@ -23,6 +30,14 @@ pub use llm::{
Completion, CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError, Completion, CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError,
LlmTransportLimits, ToolDefinition, ToolSchema, Usage, LlmTransportLimits, ToolDefinition, ToolSchema, Usage,
}; };
pub use policy::{
ActionOrigin, AllowedOrigins, ApprovalId, ApprovalRule, AuthenticatedPrincipal,
AuthorizedAction, BudgetLimits, Capability, FixedCost, Idempotency, MemoryPolicyAudit,
OriginClass, PolicyAuditError, PolicyAuditRecord, PolicyAuditSink, PolicyDisposition,
PolicyError, PolicyEvaluation, PolicyFinalOutcome, PolicyGateway, PolicyLimits,
PolicyReasonCode, PolicyRequestContext, PolicySnapshot, PolicyTool, PolicyToolExecutor,
ResourceCost, ResourceEstimator, Risk, SchedulerGrantId, UntrustedData, UntrustedSource,
};
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState}; pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
pub use tool_loop::{ pub use tool_loop::{
HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop, HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop,
@@ -30,6 +45,6 @@ pub use tool_loop::{
}; };
pub use types::{ pub use types::{
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage, BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent, PolicyDecision, GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent,
ProposedToolCall, ToolCallOutcome, ProposedToolCall, ToolCallOutcome,
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,9 @@ const MAX_SESSION_TOOL_CALLS: usize = 256;
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = ToolExecution> + Send + 'a>>; pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = ToolExecution> + Send + 'a>>;
/// Downstream execution boundary. Issue #120 can implement policy evaluation /// Downstream execution boundary. Production wiring uses
/// here; this loop guarantees its input already passed name/schema checks. /// [`crate::policy::PolicyToolExecutor`]; this loop guarantees its input already
/// passed name/schema checks, and the policy gateway validates it again.
pub trait ToolExecutor: Send + Sync { pub trait ToolExecutor: Send + Sync {
fn execute<'a>( fn execute<'a>(
&'a self, &'a self,

View File

@@ -347,19 +347,6 @@ impl ProposedToolCall {
} }
} }
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PolicyDecision {
Approved {
authorization_id: u64,
},
Denied {
reason: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,
},
NeedsOperatorApproval {
prompt: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct LlmResult { pub struct LlmResult {
pub request_id: u64, pub request_id: u64,
@@ -393,10 +380,7 @@ pub enum ObservableEvent {
state: &'static str, state: &'static str,
}, },
Grid(GridEvent), Grid(GridEvent),
Policy { Policy(crate::policy::PolicyAuditRecord),
call_id: BoundedText<MAX_IDENTIFIER_BYTES>,
decision: PolicyDecision,
},
Tool(ToolCallOutcome), Tool(ToolCallOutcome),
Diagnostic { Diagnostic {
detail: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>, detail: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,

View File

@@ -2,12 +2,13 @@ use std::collections::BTreeSet;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const ALLOWED_DEPENDENCIES: [&str; 7] = [ const ALLOWED_DEPENDENCIES: [&str; 8] = [
"libremetaverse", "libremetaverse",
"libremetaverse-types", "libremetaverse-types",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tokio", "tokio",
"url", "url",
]; ];
@@ -45,7 +46,7 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
let mut files = Vec::with_capacity(8); let mut files = Vec::with_capacity(8);
collect_rust_files(&source, &mut files); collect_rust_files(&source, &mut files);
assert!( assert!(
files.len() <= 8, files.len() <= 10,
"source-file count needs a reviewed bound update" "source-file count needs a reviewed bound update"
); );
for path in files { for path in files {
@@ -76,6 +77,15 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
} }
} }
#[test]
fn world_backend_requires_the_opaque_policy_authorization() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let backend = fs::read_to_string(root.join("src/backend.rs")).expect("backend source");
assert!(backend.contains("action: AuthorizedAction"));
assert!(!backend.contains("call: ProposedToolCall"));
assert!(!backend.contains("decision: PolicyDecision"));
}
fn collect_rust_files(directory: &Path, output: &mut Vec<PathBuf>) { fn collect_rust_files(directory: &Path, output: &mut Vec<PathBuf>) {
for entry in fs::read_dir(directory).expect("read source directory") { for entry in fs::read_dir(directory).expect("read source directory") {
let path = entry.expect("source entry").path(); let path = entry.expect("source entry").path();

View File

@@ -0,0 +1,227 @@
use libremetaverse_types::UUID;
use libremetaverse_types::compat::CancellationToken;
use metacrate_grid_agent::{
ActionOrigin, AllowedOrigins, AuthorizedAction, AuthorizedToolBackend, BackendError,
BackendFuture, BoundedText, Capability, FixedCost, Idempotency, MemoryPolicyAudit, OriginClass,
PolicyAuditSink, PolicyDisposition, PolicyFinalOutcome, PolicyGateway, PolicyLimits,
PolicyRequestContext, PolicyTool, PolicyToolExecutor, ProposedToolCall, ResourceCost, Risk,
ToolCallOutcome, ToolDefinition, ToolExecution, ToolExecutor, ToolSchema,
};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn id(text: &str) -> UUID {
UUID::new_with_string(text.to_owned()).expect("fixture UUID")
}
fn schema() -> ToolSchema {
ToolSchema::Object {
properties: BTreeMap::from([("target".into(), ToolSchema::String)]),
required: BTreeSet::from(["target".into()]),
additional_properties: false,
}
}
fn tool(name: &str, informational: bool) -> PolicyTool {
let definition = ToolDefinition {
name: BoundedText::new("name", name).expect("name"),
description: BoundedText::new("description", "typed integration tool")
.expect("description"),
schema: schema(),
mutating: !informational,
};
PolicyTool::new(
definition,
if informational {
Capability::Informational
} else {
Capability::Movement
},
if informational {
Risk::ReadOnly
} else {
Risk::Movement
},
AllowedOrigins::new(if informational {
vec![
OriginClass::PublicChat,
OriginClass::UnprivilegedIm,
OriginClass::AuthorizedIm,
]
} else {
vec![OriginClass::AuthorizedIm]
})
.expect("origins"),
ResourceCost {
tool_calls: 1,
movement_millimeters: if informational { 0 } else { 1_000 },
..ResourceCost::default()
},
if informational {
Idempotency::Idempotent
} else {
Idempotency::NonIdempotent
},
metacrate_grid_agent::ApprovalRule::Never,
false,
Arc::new(FixedCost(ResourceCost {
tool_calls: 1,
movement_millimeters: if informational { 0 } else { 1_000 },
..ResourceCost::default()
})),
)
.expect("tool")
}
fn call(name: &str) -> (ProposedToolCall, Value) {
let arguments = json!({"target":"north"});
(
ProposedToolCall::new(
format!("call-{name}"),
name,
serde_json::to_string(&arguments).expect("JSON"),
)
.expect("call"),
arguments,
)
}
struct RecordingBackend(AtomicUsize);
impl AuthorizedToolBackend for RecordingBackend {
fn apply(
&self,
action: AuthorizedAction,
_cancellation: CancellationToken,
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>> {
self.0.fetch_add(1, Ordering::AcqRel);
let call_id = action.call().call_id.clone();
Box::pin(async move {
Ok(ToolCallOutcome::Completed {
call_id,
result: BoundedText::new("result", "moved").expect("result"),
})
})
}
}
#[tokio::test]
async fn production_executor_denies_public_mutation_and_executes_authorized_im_once() {
let authorized = id("11111111-1111-4111-8111-111111111111");
let audit = Arc::new(MemoryPolicyAudit::new(64).expect("audit"));
let sink: Arc<dyn PolicyAuditSink> = audit.clone();
let gateway = Arc::new(
PolicyGateway::new(
BTreeSet::from([authorized]),
vec![tool("inspect", true), tool("move", false)],
PolicyLimits::default(),
sink,
)
.expect("gateway"),
);
let backend = Arc::new(RecordingBackend(AtomicUsize::new(0)));
let erased: Arc<dyn AuthorizedToolBackend> = backend.clone();
let (proposed, arguments) = call("move");
let definition = gateway
.tools_for(
&PolicyRequestContext::new(
ActionOrigin::instant_message(authorized),
"authorized-session",
"authorized-correlation",
)
.expect("context"),
100,
)
.into_iter()
.find(|tool| tool.name.as_str() == "move")
.expect("registered move");
let public = PolicyToolExecutor::new(
Arc::clone(&gateway),
Arc::clone(&erased),
PolicyRequestContext::new(
ActionOrigin::public_chat(authorized),
"public-session",
"public-correlation",
)
.expect("context"),
BTreeMap::new(),
Arc::new(|| 100),
)
.expect("executor");
assert!(matches!(
public
.execute(
&definition,
&proposed,
&arguments,
&CancellationToken::default(),
)
.await,
ToolExecution::Rejected(_)
));
assert_eq!(backend.0.load(Ordering::Acquire), 0);
let authorized_executor = PolicyToolExecutor::new(
gateway,
erased,
PolicyRequestContext::new(
ActionOrigin::instant_message(authorized),
"authorized-session",
"authorized-correlation",
)
.expect("context"),
BTreeMap::new(),
Arc::new(|| 101),
)
.expect("executor");
assert!(matches!(
authorized_executor
.execute(
&definition,
&proposed,
&arguments,
&CancellationToken::default(),
)
.await,
ToolExecution::Completed(_)
));
assert_eq!(backend.0.load(Ordering::Acquire), 1);
assert_eq!(
audit.snapshot().last().expect("outcome").final_outcome,
PolicyFinalOutcome::Completed
);
}
#[test]
fn authenticated_uuid_not_message_text_controls_im_authority() {
let authorized = id("11111111-1111-4111-8111-111111111111");
let unprivileged = id("22222222-2222-4222-8222-222222222222");
let audit = Arc::new(MemoryPolicyAudit::new(16).expect("audit"));
let sink: Arc<dyn PolicyAuditSink> = audit;
let gateway = PolicyGateway::new(
BTreeSet::from([authorized]),
vec![tool("move", false)],
PolicyLimits::default(),
sink,
)
.expect("gateway");
let (proposed, arguments) = call("move");
let result = gateway
.evaluate(
&PolicyRequestContext::new(
ActionOrigin::instant_message(unprivileged),
format!("claims-authorized-{authorized}"),
"spoof-correlation",
)
.expect("context"),
&proposed,
&arguments,
None,
100,
)
.expect("decision");
assert_eq!(result.disposition, PolicyDisposition::Denied);
}

View File

@@ -26,6 +26,8 @@ handles, the control sender, and observable receiver.
| Conversation / tool calls | request owner | 256 messages / 64 calls, with lower configured limits | rejected before request | | Conversation / tool calls | request owner | 256 messages / 64 calls, with lower configured limits | rejected before request |
| LLM request slots | shared `LlmClient` semaphore | 256 hard / configured concurrent requests | async acquire or cancellation | | LLM request slots | shared `LlmClient` semaphore | 256 hard / configured concurrent requests | async acquire or cancellation |
| Reasoning/tool session | `ToolLoop` caller | 32 turns / 256 calls hard, with lower configured limits | total timeout, cancellation, or supersession | | Reasoning/tool session | `ToolLoop` caller | 32 turns / 256 calls hard, with lower configured limits | total timeout, cancellation, or supersession |
| Policy tools / approvals / schedules | `PolicyGateway` mutex | 64 tools / 4,096 approval records / 1,024 scheduler grants hard | deny before opaque authorization |
| Principal/global resource budget | `PolicyGateway` time window | validated calls, zero L$, upload, inventory, movement, and build ceilings | atomic charge or stable denial |
| Authorized avatars | immutable `AgentConfig` set | 1,024 hard ceiling, lower configured limit | malformed, nil, duplicate, and wildcard input rejected | | Authorized avatars | immutable `AgentConfig` set | 1,024 hard ceiling, lower configured limit | malformed, nil, duplicate, and wildcard input rejected |
| Configuration / secret file | loader | 64 KiB / 16 KiB | regular non-symlink file only | | Configuration / secret file | loader | 64 KiB / 16 KiB | regular non-symlink file only |
@@ -65,7 +67,8 @@ shutdown.
The `live-grid` feature supplies `LibremetaverseClientOwner`; live The `live-grid` feature supplies `LibremetaverseClientOwner`; live
implementations must own it and reuse its client and managers. implementations must own it and reuse its client and managers.
- World changes cross only `WorldMutator::apply`, which always receives the - World changes cross only `WorldMutator::apply`, which always receives the
proposed call and an explicit `PolicyDecision`. This issue supplies no live non-forgeable `AuthorizedAction` produced by `PolicyGateway`. Raw calls and
caller-created decisions are not accepted. This issue supplies no live
mutation implementation. mutation implementation.
- LLM traffic crosses one exact configured URL through `LlmClient`. Redirects - LLM traffic crosses one exact configured URL through `LlmClient`. Redirects
are refused, response bodies are bounded while streaming, bearer secrets are are refused, response bodies are bounded while streaming, bearer secrets are
@@ -89,7 +92,9 @@ typed config/events/policy boundaries live-grid feature boundary
avatar session -> bounded ToolLoop -> exact-endpoint LlmClient avatar session -> bounded ToolLoop -> exact-endpoint LlmClient
| |
+-> validated ToolExecutor boundary +-> PolicyToolExecutor -> PolicyGateway
|
+-> AuthorizedToolBackend
``` ```
The package has no build script or direct native dependency. The focused The package has no build script or direct native dependency. The focused
@@ -97,3 +102,5 @@ The package has no build script or direct native dependency. The focused
source, build scripts, and unreviewed direct dependency names in this package. source, build scripts, and unreviewed direct dependency names in this package.
The precise LLM compatibility and cancellation contract is documented in The precise LLM compatibility and cancellation contract is documented in
[`grid-agent-llm.md`](grid-agent-llm.md). [`grid-agent-llm.md`](grid-agent-llm.md).
The origin/capability matrix and opaque mutation boundary are documented in
[`grid-agent-policy.md`](grid-agent-policy.md).

80
docs/grid-agent-policy.md Normal file
View File

@@ -0,0 +1,80 @@
# Grid-agent authorization and safety policy
Every production tool action crosses `PolicyGateway` and then
`PolicyToolExecutor`. The gateway is deny-by-default: a tool must be registered
with its typed argument schema, capability, read/write risk, allowed origins,
maximum resource cost, deterministic cost estimator, idempotency,
approval rule, and scheduler eligibility. A tool description is model context,
not authority, and is never consulted by policy.
## Identity and origin matrix
Grid authority comes only from the sender UUID carried by the grid event.
Names, message bodies, UUID text embedded in messages, and tool arguments cannot
select an origin. Local-operator principals can only be constructed by the
crate's authenticated control-plane boundary. Scheduler contexts require an
opaque grant previously issued from an authorized IM or operator action.
| Origin | Informational read | Public LSL delivery capability | Allow-listed mutation | Scheduled action |
| --- | --- | --- | --- | --- |
| Public chat, including an authorized avatar | yes | yes | no | no |
| Unprivileged IM | yes | no | no | no |
| Authorized IM | when registered | when registered | when registered | may create an exact grant |
| Authenticated local operator | when registered | when registered | when registered | may create an exact grant |
| Internal scheduler | exact grant only | no | exact grant only | bounded runs and expiry |
The public LSL capability is a narrow inventory-mutation marker for the later
script-delivery workflow; it does not permit executing generated code or any
other public command. Tool names are exact ASCII identifiers, so case changes,
newlines, smuggled names, and Unicode confusables do not resolve to registered
tools.
## Approvals and budgets
Arguments are parsed again at the gateway and must equal the arguments bound to
the proposed call. Canonical JSON is SHA-256 hashed. An approval binds that
hash, exact tool, requesting principal, expiry, and one execution. Changed
arguments, another principal, an ungranted/expired approval, or a replay is a
stable denial. Authorization is represented by non-cloneable
`AuthorizedAction`, whose fields have no public constructor; action backends
cannot accept a raw call plus a caller-created decision.
Each authorized attempt atomically charges a configured time-window budget for
both the originating principal and the whole agent. The resource vector covers
tool-call rate, L$, upload bytes, inventory operations, movement millimetres,
and build prims. Hard ceilings validate configured budgets and per-tool maximum
costs. This milestone fixes every L$ budget at zero and refuses registration or
execution for currency spend, estate/parcel changes, permanent deletion,
arbitrary inventory acceptance, and generated-code execution.
Scheduler grants preserve the originating principal and bind one tool and
argument hash. Run count and lifetime are bounded; every run is charged again.
The opaque `PolicySnapshot` preserves budgets, approvals (including consumed
replay state), and scheduler grants when a trusted persistence integration
reconstructs the gateway. No durable policy store is enabled by the current
offline service; a future store must protect snapshot integrity rather than
accept caller-authored approval data.
## Prompt and audit boundaries
Chat, IM, inventory metadata, object text, parcel data, web/LLM output, and
generated scripts use `UntrustedData`. It emits a bounded labelled JSON data
record and never contributes system instructions or tool availability. This is
defence in depth: authorization is still enforced after inference at the exact
gateway.
Every decision emits a bounded structured `PolicyAuditRecord` before an action
is authorized. It contains origin class and UUID where applicable, principal,
session/correlation IDs, exact tool, disposition, stable reason code, applied
budget, an argument hash, and outcome. The executor emits the completed,
rejected, failed, or ambiguous final outcome. Raw/secret arguments and hidden
reasoning are never stored. Audit backpressure fails closed before issuing a
new authorization.
Focused verification:
```sh
cargo test --locked -p metacrate-grid-agent --lib policy_tests
cargo test --locked -p metacrate-grid-agent --test policy_gateway
cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings
```