Chio/Docs
LOGIN · JOIN

PlatformSession & Approval Guards

Kernel

Memory Governance

What an agent may write into memory, and what it may read back out, decided by store, size, lifetime, entry count, and finding status.

MemoryGovernanceGuard lives in chio-guards, in crates/guards/chio-guards/src/memory_governance.rs, and registers under the name memory-governance. It reads its policy from two independent surfaces: a guard-level MemoryGovernanceConfig that an operator sets once for the deployment, and the per-grant constraint Constraint::MemoryStoreAllowlist that an issuer mints onto one capability. Neither overrides the other. The store allowlist is their union, and every other ceiling comes from the config alone.

The guard is an application-level control over logical memory entries: how many, how large, how long they live, and whether the finding a stored value came from is still live. It does not cap process RSS, CPU time, or syscall budget. Those sit one layer down, in the WASM host, where the kernel meters fuel per invocation.


The actions it claims

Evaluation starts by running extract_action_checked over the tool name and arguments, and branches three ways.

  • ToolAction::MemoryWrite { store, .. } runs the write path.
  • ToolAction::MemoryRead { store, key } runs the read path, which applies the same store allowlist. Blocking an agent from writing to a store does not implicitly leave it free to read from one.
  • Every other action returns GuardDecision::allow(). The guard passes what is not its domain rather than widening its own.

An extraction that fails is not a fourth case that passes. When extract_action_checked returns a MalformedAction, the guard returns GuardDecision::deny(Vec::new()) before it reaches the branch, so a malformed argument shape refuses rather than escaping classification.

The store is whatever the extractor found. It probes collection, index, namespace, and store in that order and, when none is present, falls back to the tool name itself through unwrap_or_else(|| self.tool.clone()). A bare vector_upsert call with no store argument therefore arrives at the guard as store == "vector_upsert", which is then matched against the configured patterns like any other store name. A pattern that happens to cover that tool name admits the write. The extractor decides what counts as a store key, not the guard.


Configuration

MemoryGovernanceConfig is deny_unknown_fields, so a misspelled key fails to deserialize rather than silently disabling a ceiling.

FieldTypeDefaultBehavior
enabledbooltrueWhen false, every evaluation returns allow before any action is extracted.
store_allowlistVec<String>emptyStores the agent may write or read, unioned with Constraint::MemoryStoreAllowlist on the matched grant.
max_memory_entriesOption<u64>NoneWrite cap per (agent_id, capability_id). With Some(n), the write that takes the counter past n denies.
max_retention_ttl_secsOption<u64>NoneCeiling on the retention TTL a write may declare. A write that declares none denies under a set ceiling.
max_content_size_bytesOption<u64>NoneByte ceiling on one write's content. A write whose size cannot be determined denies under a set ceiling.
deny_patternsVec<String>emptyRegexes compiled once at construction. A write whose content text matches any of them denies.
finding_retractionOption<FindingRetractionGuardConfig>NoneTurns on the retraction profile. Carries resolver_id and feed_id, and changes both the write and the read path.

An empty allowlist is not a closed one

The effective store allowlist is the union of MemoryGovernanceConfig::store_allowlist and every Constraint::MemoryStoreAllowlist on the matched grant. When that union is empty, effective_store_allowlist returns None and the check is skipped rather than failing everything. An operator who wants to forbid a store sets a non-empty list that excludes it.

The write path

evaluate_write runs the gates in a fixed order and returns at the first one that refuses. The counter is bumped last, so a denial never spends quota.

  1. Finding provenance, only when the retraction profile is on. The arguments must carry a non-empty FINDING_DELIVERY_RECEIPT_ID_ARGUMENT string, or the write denies before any ceiling is read.
  2. Store allowlist. store_matches takes * as any store, prefix* as a prefix match, and anything else as an exact comparison.
  3. Retention TTL ceiling. extract_retention_ttl reads the first of retention_ttl, retentionTtl, retention_ttl_secs, retentionTtlSecs, ttl, ttl_secs, expires_in, or expiresIn that parses as a u64. A missing TTL reads as a request for indefinite retention and denies.
  4. Content size. extract_content_size_bytes reads content_size, contentSize, content_bytes, or size, and falls back to the byte length of the content text. When neither yields a size, the write denies.
  5. Deny patterns. extract_content_text takes the first non-empty string among content, text, value, vector_text, and payload, and any pattern that matches it denies. A write carrying none of those keys is not scanned.
  6. Per-session entry limit. bump_counter increments the entry for (agent_id, capability_id) and denies once the new value passes max_memory_entries.

The read path

Without the retraction profile, evaluate_read runs the store allowlist and nothing else. With it configured, three further gates run on every read, each of them a deny.

  1. The read must name an exact key. A MemoryRead whose key is None, which is what a similarity search over a collection produces, denies.
  2. The bound resolver must still be the one the policy named. When either resolver_id() or feed_id() no longer matches what was pinned at construction, the read denies rather than resolving against a substituted feed.
  3. resolve_live_finding must return a resolution whose feed_id is the pinned one and whose value is FindingStatusValue::Live. Anything else, including Pending, Retracted, and a resolver error, denies.

A key that fails to resolve live is written into a sticky quarantine set on the guard, so the second read of the same (store, key) does not depend on the resolver answering again. A later live resolution removes the marker. The set is bounded by MAX_QUARANTINED_FINDING_KEYS, and once the bound is reached the guard sets saturated and denies every subsequent resolution with "Finding memory quarantine state is saturated", which no live answer clears. A store or key longer than MAX_QUARANTINE_COMPONENT_BYTES is treated as quarantined without being stored, so an oversized key cannot be used to grow the set.


The retraction profile

Setting finding_retraction alone does not build a guard. The profile needs a synchronous, deployment-pinned FindingRetractionResolver handed in at construction through MemoryGovernanceGuard::with_config_and_retraction_resolver; the trait boundary is synchronous because guard evaluation cannot park a memory read while a network lookup completes. Both constructors route through the same private build, which checks configuration and resolver against each other and refuses four distinct ways.

MemoryGovernanceErrorRaised when
MissingFindingRetractionResolverThe config names a policy and no resolver was supplied: "finding retraction policy requires an injected resolver".
UnexpectedFindingRetractionResolverA resolver was supplied while the profile stayed off: "finding retraction resolver supplied without an enabled policy".
FindingRetractionResolverIdentityThe resolver's resolver_id() is not the one the policy pinned.
FindingRetractionResolverFeedThe resolver's feed_id() serves a different status feed than the policy pinned.

The profile also reaches past evaluate into three further trait methods, so a status that changed between admission and dispatch does not slip through a stale verdict. requires_dispatch_revalidation is true unconditionally, and revalidate_before_dispatch re-runs the read evaluation just before the call goes out, turning a fresh deny into KernelError::GuardDenied("memory-governance dispatch revalidation denied"). required_finding_status_feed_id names the feed a governed write must be admitted against, and returns "Finding memory write resolver identity changed" rather than a feed id when the resolver has drifted off the pin. validate_output_before_release resolves the finding once more and refuses to release output whose canonical SHA-256 differs from the memory_content_sha256 the verified write provenance recorded, so a store that returned something other than what was governed on the way in does not reach the agent.

Quarantine is read-side, not retroactive

The profile denies a read whose provenance traces to a finding that is no longer live. It does not walk memory and rewrite entries derived from that finding before the retraction landed, and it does not touch conclusions an agent already acted on. See The cognition market for the purchase path and Finding revocation for the retraction and status-feed mechanics.

Failure modes

  • A poisoned counter denies. bump_counter maps a poisoned mutex to KernelError::Internal("memory-governance guard counter mutex poisoned"). The kernel's guard loop treats any guard error as a denial and reports guard "memory-governance" error (fail-closed), so every later write that reaches the counter on that guard instance refuses too.
  • A bad regex never runs. A deny_patterns entry that does not compile fails construction with MemoryGovernanceError::InvalidPattern, which carries the offending pattern and the underlying regex::Error. Traffic never reaches a half-configured guard.
  • A poisoned quarantine map reports quarantined. is_finding_quarantined returns true when the lock cannot be taken, so observability never upgrades an indeterminate state to live.

What the denial records

Every gate returns GuardDecision::deny(Vec::new()), and that Vec::new() is the evidence array. The guard attaches no structured reason label and records neither the matched store, the requested TTL, the byte size, nor the counter value on the decision. A caller that needs to know which gate refused reads the kernel's own Deny reason, which names the guard, not a per-guard evidence block. The retraction paths are the exception: they raise KernelError::GuardDenied with a specific message, so a refusal at dispatch revalidation or output release is distinguishable from a refusal at evaluation.

Counters do not survive a restart

The counter map lives inside the guard struct, so a kernel restart resets every per-session count and the quarantine set with it. A deployment that needs durable caps enforces them in the memory store rather than in the guard's in-process state.

Wiring

HushSpec does not configure memory governance. The Rules type in chio-policy has no memory field, so there is nothing to write in a policy document. The two surfaces are the guard config, wired directly or through the guard section of chio.yaml, and the capability constraint, minted onto a ToolGrant inside a ChioScope.

rust
use chio_guards::{MemoryGovernanceConfig, MemoryGovernanceGuard};
use chio_core::capability::scope::Constraint;

// Deployment-wide ceilings. A struct literal must name every field, so
// spread the default and set only the ones this deployment changes.
let guard = MemoryGovernanceGuard::with_config(MemoryGovernanceConfig {
    store_allowlist: vec!["agent-notes".to_string(), "vector-*".to_string()],
    max_memory_entries: Some(500),
    max_retention_ttl_secs: Some(86_400),
    max_content_size_bytes: Some(64 * 1024),
    deny_patterns: vec![
        r"(?i)\bssn\b".to_string(),
        r"AKIA[0-9A-Z]{16}".to_string(),
    ],
    ..MemoryGovernanceConfig::default()
})?;

// The per-grant surface. The effective store allowlist is the union of
// this list and the one in the config above.
grant.constraints.push(Constraint::MemoryStoreAllowlist(vec![
    "agent-notes".to_string(),
]));

Under that configuration a write to agent-notes carrying {"ttl": 3600, "content": "..."} is admitted while the session stays under 500 entries, the content fits in 64 KiB, and the body matches neither pattern. A write to incident-log denies, because it is in neither allowlist. A write with no TTL key denies, because an unstated lifetime under a 24-hour ceiling reads as a request for indefinite retention.


What it costs to run

The hot path is a handful of serde_json lookups over the argument object, one HashMap bump behind a mutex, and an optional regex sweep over the content text. A disabled guard returns before the extractor runs, and an action that is neither a memory read nor a memory write returns from the match arm. Regex cost is bounded by the operator-supplied pattern set: the guard ships no built-in detector. A deployment that wants cross-cutting redaction on results reaches for QueryResultGuard in chio-data-guards instead, on Data-layer guards.

The retraction profile changes that shape. Every governed read makes a synchronous resolver call, and the resolver composes a provenance lookup, a verified lineage edge, and an authenticated status cache. Its latency, not the guard's, sets the read path's cost.


See also

  • Scope matching · how the matched grant is chosen, and which of the 27 constraint variants the portable matcher decides from arguments
  • Data-layer guards · the vector and SQL surfaces a memory tool sits on top of
  • The default pipeline · where a guard sits in the conjunction, and what the default profile installs
  • Kernel architecture · where the application-level line sits against host-level sandboxing
Memory Governance · Chio Docs