<!-- HushSpec 1.0.0; guide; stable
releaseCommit: e771ec647b7f26a0a09ff852886eb8a91093bc58
docsCommit: 5a252dbf7eb46b507ac9493dff8a695abf5b5e9a
sourcePath: docs/src/guides/runtime-integration.md
canonical: https://www.hushspec.org/docs/runtime-integration/
Relative links in this unmodified source body are relative to sourcePath.
-->

# Runtime Integration

A policy document only becomes a control when something asks it before acting.
This guide is about that something: the **enforcement point** at an agent
runtime's tool boundary.

The shape is the same in every SDK:

1. Load a policy and compile it once.
2. Wrap it in a **guard** that knows the enforcement mode, the confirmation
   channel for `warn`, where receipts go, and who is acting.
3. Call the guard before every tool call, file access, egress or shell command.
4. Let the guard follow the policy file so a change takes effect without a
   restart.

This page explains the runtime contract using Rust. The [Rust](sdks/rust.md),
[TypeScript](sdks/typescript.md), [Python](sdks/python.md), and [Go](sdks/go.md)
guides each provide a complete executable dispatch example. Go calls its guard
`Guard`. TypeScript/Python `check` returns a boolean and `gate` returns a richer
outcome; Rust/Go `check`/`Check` returns a structured decision. Do not assume
identical return shapes from similar names.

## The guard

The Rust blocks on this page are API fragments: they assume the surrounding
application's imports, action and error handling. For copy-and-run programs
with tested side effects, use the four language guides linked above.

`HushGuard` is the enforcement point. It holds a compiled policy and adds
everything a bare `CompiledPolicy` deliberately does not know about.

```rust
use hushspec::{EvaluationAction, HushGuard, Policy};

let guard = HushGuard::from_policy(Policy::from_path("policy.yaml")?)?;

let action = EvaluationAction {
    action_type: "egress".to_string(),
    target: Some("api.example.com".to_string()),
    ..Default::default()
};

let decision = guard.check(&action);
if !decision.allowed() {
    // Refuse the tool call. `decision.receipt` is the evidence that it
    // happened and why.
}
```

`check()` returns a `GuardDecision`:

| Field | Meaning |
|---|---|
| `result` | The policy's decision: `allow`, `warn` or `deny`, with `matched_rule` and `reason` |
| `receipt` | The decision receipt (receipt spec 0.2), unless auditing is switched off |
| `enforced` | `true` exactly when the action was **stopped** |
| `enforcement` | Mode and disposition, as the receipt records them |
| `duration_us` | Wall time of the evaluation |

`allowed()` is the complement of `enforced`, and is what a call site branches
on. Two convenience wrappers exist for the common shapes:

```rust
if guard.allows(&action) { /* ... */ }         // just the go/no-go
guard.enforce(&action)?;                        // `Denied` error on refusal
```

`evaluate()` scores an action without enforcing anything -- the receipt still
reaches the sink, stamped with the disposition the decision *implies*. Use it
to shadow a policy the runtime is not yet acting on.

A Rust guard is `Send + Sync` and takes `&self` everywhere, so share one `Arc`
across an agent's worker threads. Rust and Go allow concurrent evaluations;
Python serializes guard operations. In each threaded SDK, reload waits through
in-flight evaluations, confirmation callbacks and receipt delivery. The new
policy event is written before new-policy evaluations proceed. This keeps each
successfully written receipt associated with its nearest preceding policy event.

Confirmation handlers, custom sinks and Go's configurable receipt clock must
not call the same guard's evaluation or reload methods, or wait for work that
requires them. Observer callbacks run after the ordering gate releases and may
re-enter the guard. TypeScript's synchronous guard rejects callback reentry with
an error before mutation; defer such work until the current call returns.
Use one guard per ordered evidence stream: sharing a sink between independent
guards does not coordinate their policy transitions. Custom sinks must preserve
the order of completed writes. Storage failures remain reported evidence gaps,
not a guarantee that the log is complete.

## Configuring the guard

```rust
use hushspec::{Actor, AuditConfig, EnforcementMode, HushGuard, Policy, StderrObserver, TimeSource};
use std::sync::Arc;

let guard = HushGuard::builder()
    .actor(Actor {
        agent_id: Some("code-assistant".to_string()),
        session_id: Some(session_id.clone()),
        principal: Some("alice@example.com".to_string()),
        runtime: Some("my-agent/1.4".to_string()),
    })
    .time_source(TimeSource::System)
    .sink(Box::new(sink))
    .observer(Arc::new(StderrObserver::deny_only()))
    .enforcement_mode(EnforcementMode::Enforce)
    .enforcement_override("rules.secret_patterns", EnforcementMode::Monitor)
    .on_warn(|result, action| ask_the_operator(result, action))
    .audit(AuditConfig::default())
    .build_from_policy(Policy::from_path("policy.yaml")?)?;
```

The builder also accepts an already-resolved policy
(`build_from_resolution`), an already-compiled one (`build_from_compiled`), or
one with a tenant-scoped kill switch (`build_from_resolution_with`).

### `warn` needs a confirmation channel

A `warn` decision means "ask a human". Without an `on_warn` handler a guard has
nobody to ask, so the action is **denied** (core spec 6). There is no
"warn means proceed" default in any SDK.

A handler that returns `true` records the outcome as `confirmed` -- distinct
from `allowed` in the receipt, so an auditor can tell a policy allow from a
human override.

If an ordinary confirmation callback throws, the guard attempts to record the
existing warn receipt with outcome `blocked`, then propagates the original
failure. A failing sink cannot replace that failure or authorize the action.
Rust unwinding panics and recoverable Go panics use the same boundary; Python
`BaseException` termination paths, Go `runtime.Goexit()`, Rust abort-mode panic
and fatal process errors are outside the recording guarantee. Goexit retains
goroutine-termination behavior and is not converted into a panic. Unavailable
storage means no receipt can be promised. Monitor mode does not invoke confirmation callbacks.

### Enforcement modes

`EnforcementMode::Monitor` records what the policy *would* have done and lets
the action through; the receipt's `enforcement.outcome` is `would_block`. It is
how a new policy gets rolled out without breaking a running agent.

Monitor mode is only accepted when a sink or an observer is configured. A
shadow decision nobody records is indistinguishable from having no policy at
all, so the guard refuses to build.

Per-rule-path overrides let one block run in shadow while the rest enforces:

```rust
.enforcement_override("rules.secret_patterns", EnforcementMode::Monitor)
```

An override key is a rule path prefix and the **longest** match wins. A prefix
only matches at a segment boundary, so `rules.egress` covers
`rules.egress.default` but not `rules.egress_extra`. Keys must start with
`rules.` or `extensions.` and name a real block -- a typo is a build error, not
a silently inert override.

Two things always enforce, whatever the mode says:

- **Panic mode.** A kill switch monitor mode could wave through would not be a
  kill switch.
- **A refused policy.** See below.

### A policy that will not verify

Under `require_signature`, a policy whose chain does not verify does not make
the guard fail to build. It makes the guard **refuse**: it holds the identity of
what it was handed, and every action is denied with
`matched_rule: __hushspec_policy_unverified__` and an unverified-policy receipt
recording `policy.signature.verified: false` and the verifier's reason (signing
spec 6.5).

That is deliberate. A guard that never came into existence emits nothing --
no receipt, no observer event, no record that an agent tried to act under an
unverified policy. Refusing keeps the attempt on the record.

```rust
let guard = HushGuard::from_policy(
    Policy::from_path("policy.yaml")?.verify(keyring),
)?;
assert!(guard.refused());
let (document, status) = guard.refusal().expect("what would not verify");
```

Every other load failure -- a base that will not load, a chain that will not
merge, a document that will not validate -- is still an error: there is no
document to refuse against.

## Observers

Receipts are the *evidence* channel: specified records that can be stored in
signed, hash-linked logs. Durability depends on the sink and its successful
storage, not merely on configuring a guard.
Observers are the *telemetry* channel: best-effort, structured, cheap. A guard
drives both from the same evaluation.

These best-effort hooks are not a no-durable-permit/no-dispatch boundary. A
successful effect check from an automatic tool mapper also does not establish
MCP server identity or tool authorization: allowed egress can coexist with a
denied tool call. Complete tool-plus-effect authorization and controlled
dispatch remain separate integration work. See [Evidence Verification](evidence-verification.md)
for what authenticated records can and cannot establish.

```rust
pub trait EvaluationObserver: Send + Sync {
    fn on_policy_loaded(&self, event: &PolicyLoadedEvent) {}
    fn on_evaluation(&self, event: &EvaluationCompletedEvent) {}
    fn on_error(&self, event: &ErrorEvent) {}
}
```

Every method has a no-op default, so an observer implements only what it cares
about. Batteries included:

| Observer | What it does |
|---|---|
| `JsonLineObserver` | One JSON object per line to any `Write` (the SDKs' shared event shape) |
| `StderrObserver` | A human line per event; `deny_only()` for just the refusals |
| `MetricsCollector` | Counters and a latency histogram, rendered as Prometheus text |
| `WebhookObserver` | POSTs each event to an HTTP endpoint off-thread (`http` feature) |

An action's `content` is stripped before it reaches an observer, exactly as a
receipt records only its hash and size (receipt spec 4.4); the event's
`content_redacted` flag records that it happened. Observers run inline on the
evaluation thread, so do fallible or slow work off-thread.

A receipt sink that refuses a receipt or a policy event never changes a
decision and never reaches the caller -- a full disk is no reason to let an
action through, nor to stop one -- but it is never silent either: every SDK
reports it here as a `sink.error` event carrying the failure and the name of
the sink that refused.

`ObservableEvaluator` is the fan-out, usable on its own when you want telemetry
without enforcement.

### Metrics

`MetricsCollector::render_prometheus()` emits the series the observability
spec names -- the same series from every SDK, so an existing dashboard or
recording rule works unchanged whichever one is running:

```
hushspec_evaluate_total{decision="deny",action_type="egress"} 89
hushspec_evaluate_duration_us_bucket{action_type="tool_call",le="100"} 9050
hushspec_evaluate_duration_us_sum{action_type="tool_call"} 182340
hushspec_evaluate_duration_us_count{action_type="tool_call"} 9091
hushspec_rule_match_total{rule_block="forbidden_paths",decision="deny"} 45
hushspec_policy_load_total{status="success"} 3
```

`snapshot()` returns the same counters as data, for a handler that renders its
own format.

## Policy providers and hot reload

A `PolicyProvider` answers "what is the policy right now?" -- and answers it
with a fully resolved, verified `Resolution`, never a bare document. That
matters: a leaf policy whose `extends` chain has not been merged silently drops
every rule its base declares.

| Provider | Source |
|---|---|
| `FileProvider` | A file; relative `extends` resolve against its directory, and its detached signature is looked for next to it |
| `HttpProvider` | HTTPS, through the SDK's own loader: HTTPS-only, SSRF protection, a size cap, and ETag revalidation with a cache directory (`http` feature) |

Two drivers reload on an interval and hand each new resolution to a callback --
typically the guard:

```rust
use hushspec::{HushGuard, PolicyWatcher};
use std::{sync::Arc, time::Duration};

let guard = Arc::new(HushGuard::from_path("policy.yaml")?);

let watch = PolicyWatcher::new("policy.yaml")
    .every(Duration::from_secs(2))
    .swapping_into(guard.clone())
    .on_error(|error| eprintln!("policy reload failed: {error}"))
    .start()?;
```

`PolicyWatcher` stats the file each tick and reloads only when its mtime or
size moved, so an unchanged policy costs one `stat`. `PolicyPoller` does the
same for any provider, delivering only when the resolved `content_hash`
actually changed:

```rust
use hushspec::{HttpProvider, PolicyPoller};

let handle = PolicyPoller::new(Arc::new(
    HttpProvider::new("https://policies.example.com/agents/default.yaml")
        .with_cache_dir("/var/cache/hushspec"),
))
.every(Duration::from_secs(60))
.swapping_into(guard.clone())
.start()?;
```

The initial load is synchronous and its failure is returned -- a driver that
never had a policy has nothing to keep. After that, **a reload that will not
load, resolve, verify, validate or compile is an error, not a policy**: the
previous resolution stays in force, the error goes to `on_error`, and the
driver keeps ticking. An unverified file must never become the policy in effect
just because it arrived second.

The returned `PolicyHandle` reports `current()`, `generation()`, `errors()` and
`last_error()`. Dropping it stops the thread and joins it.

### Panic sentinel

Both drivers can check a sentinel file on the same tick as the reload and arm
the guard's kill switch when it appears:

```rust
let watch = PolicyWatcher::new("policy.yaml")
    .every(Duration::from_secs(2))
    .swapping_into(guard.clone())
    .panic_sentinel(".hushspec_panic", guard.panic_state().clone())
    .start()?;
```

Checking fails closed: an I/O error that cannot prove the file absent arms the
latch. `h2h panic` writes the same sentinel.

## Exporting to OpenTelemetry

`OtlpSink` is a receipt sink that batches entries onto a background thread and
POSTs them to a collector as OTLP/HTTP JSON at `<endpoint>/v1/logs`
(`otlp` feature, which implies `http`).

```rust
use hushspec::{ChainedFileSink, HushGuard, MultiSink, OtlpConfig, OtlpSink, Policy};

let sink = MultiSink::new(vec![
    Box::new(ChainedFileSink::open("receipts.log.jsonl")?),
    Box::new(OtlpSink::with_config(
        OtlpConfig::new("http://localhost:4318")
            .with_service_name("agent-gateway")
            .with_header("x-api-key", api_key),
    )?),
]);

let guard = HushGuard::builder()
    .sink(Box::new(sink))
    .build_from_policy(Policy::from_path("policy.yaml")?)?;
```

Evidence and telemetry are different jobs. Fan out through a `MultiSink` so a
collector outage never costs an audit entry: OTLP export is best-effort, the
hash-linked log is not.

### Wire mapping

Normative for every HushSpec SDK -- the four ports emit the same records, so
one collector pipeline works whichever SDK wrote them. One `logRecord` per
entry, in a single `resourceLogs` → `scopeLogs` envelope per request.

| Log record field | Value |
|---|---|
| `timeUnixNano` | The receipt's (or policy event's) `timestamp`, in nanoseconds since the epoch, as a decimal string |
| `observedTimeUnixNano` | When the sink took the entry |
| `severityText` / `severityNumber` | `INFO`/9 for `allow`, `WARN`/13 for `warn`, `ERROR`/17 for `deny`; a policy event is `INFO` |
| `body.stringValue` | The canonical JSON (RFC 8785) of the receipt, or of the policy event |

Log record attributes -- absent members are omitted, never sent as null:

| Attribute | Value |
|---|---|
| `hushspec.entry_type` | `receipt`, `policy_loaded` or `policy_swapped` |
| `hushspec.receipt_version` | `receipt.receipt_version` (receipts only) |
| `hushspec.decision` | `allow` / `warn` / `deny` (receipts only) |
| `hushspec.action_type` | `receipt.action.type` (receipts only) |
| `hushspec.matched_rule` | `receipt.matched_rule` (receipts only, when set) |
| `hushspec.policy.content_hash` | `policy.content_hash` |
| `hushspec.receipt_hash` | `sha256:` over the receipt's canonical form (receipts only) |
| `hushspec.enforcement.mode` | `enforce` / `monitor` |
| `hushspec.enforcement.outcome` | `allowed` / `confirmed` / `blocked` / `would_block` (receipts only) |

Resource attributes:

| Attribute | Value |
|---|---|
| `service.name` | Configurable, default `hushspec` |
| `hushspec.sdk` | `hushspec-rust` (each SDK reports its own) |
| `hushspec.sdk.version` | The SDK's version |
| `hushspec.spec_version` | The HushSpec version the engine implements |

Because the body is the receipt's canonical form byte for byte, a downstream
consumer can recompute `receipt_hash` from the log record and check it against
the attribute.

### Delivery

- **Bounded queue.** When the collector is slower than the agent, the newest
  entry is dropped rather than blocking the evaluation that produced it. Every
  drop increments `OtlpSink::dropped()`, returns an error from `send()`, and --
  with `with_observer()` -- raises a `sink.error` observer event.
- **Retries.** A transport error and the four statuses OTLP/HTTP names as
  retryable -- `429`, `502`, `503` and `504` -- are retried with exponential
  backoff. Every other status is final: the collector will answer the same
  bytes the same way, so resending them only delays the report. The same rule
  in every SDK.
- **Flush.** The worker exports when the batch fills or the flush interval
  elapses. `flush()` blocks until the queue is exported, and dropping the sink
  flushes and joins the worker -- so a process exiting right after a denial
  still exports the receipt for it.

## Adapters

The Rust SDK ships no framework adapters, by design. `HushGuard::check` **is**
the integration point, and a Rust host calls it from whatever tool-dispatch
function it already has. The TypeScript, Python and Go SDKs ship adapters
(Anthropic, OpenAI, MCP) because their ecosystems have a dominant client
worth adapting to.

## A complete program

`crates/hushspec/examples/guarded_agent.rs` wires all of the above together --
guard, confirmation channel, monitored rule block, hash-linked log, OTLP
export, metrics and hot reload -- and runs a handful of tool calls through it:

```bash
cargo run --example guarded_agent --features otlp
```

```text
ALLOW   egress       api.example.com          Enforce/Allowed     rules.egress.allow
BLOCK   egress       exfil.evil.test          Enforce/Blocked     rules.egress.default
BLOCK   file_read    /home/agent/.ssh/id_rsa  Enforce/Blocked     rules.forbidden_paths.patterns
BLOCK   tool_call    rm_rf                    Enforce/Blocked     rules.tool_access.block
ALLOW   tool_call    deploy                   Enforce/Confirmed   rules.tool_access.require_confirmation
ALLOW   file_write   /home/agent/.config      Monitor/WouldBlock  rules.secret_patterns.patterns.aws_access_key
```
