Chio/Docs
LOGIN · JOIN

PlatformThe Guard Model

Kernel

Guard Evidence

What a guard hands back besides a verdict, who writes a row, and the two slots that carry those rows into a signed receipt.

Chio uses the bare word "evidence" for several unrelated things. The one below is the Vec<GuardEvidence> attached to a GuardDecision and folded into the evidence array of a signed ChioReceipt. Write "guard evidence" when you mean that array.

It is the Kernel half of an observability split. What a running node exports about guards as metrics, spans, and logs is Node Observability, and the two do not join. The receipt these rows land on, its id derivation, its store, and its checkpoint chain are Receipts & Audit.


A decision is a verdict plus an array

A guard returns one value, and that value carries two things. The verdict decides admission. The evidence decides what an auditor can reconstruct six months later. They are separate fields on the same struct, and they travel separate paths from there: the verdict becomes a deny reason and a response, the evidence becomes an array inside the canonical bytes the receipt id hashes.

crates/kernel/chio-kernel/src/kernel/mod.rs513-516rust
pub struct GuardDecision {
    pub verdict: Verdict,
    pub evidence: Vec<GuardEvidence>,
}
crates/kernel/chio-kernel/src/kernel/mod.rs518-559rust
impl GuardDecision {
    #[must_use]
    pub fn allow() -> Self {
        Self {
            verdict: Verdict::Allow,
            evidence: Vec::new(),
        }
    }

    #[must_use]
    pub fn allow_with_evidence(evidence: Vec<GuardEvidence>) -> Self {
        Self {
            verdict: Verdict::Allow,
            evidence,
        }
    }

    #[must_use]
    pub fn deny(evidence: Vec<GuardEvidence>) -> Self {
        Self {
            verdict: Verdict::Deny,
            evidence,
        }
    }

    #[must_use]
    pub fn pending_approval(evidence: Vec<GuardEvidence>) -> Self {
        Self {
            verdict: Verdict::PendingApproval,
            evidence,
        }
    }

    #[must_use]
    pub fn from_verdict(verdict: Verdict) -> Self {
        match verdict {
            Verdict::Allow => Self::allow(),
            Verdict::Deny => Self::deny(Vec::new()),
            Verdict::PendingApproval => Self::pending_approval(Vec::new()),
        }
    }
}

GuardDecision derives only Debug and Clone (kernel/mod.rs:512). It is not Serialize, never crosses a process boundary, and has no schema. The type that does cross is the row it carries.

crates/core/chio-core-types/src/receipt/metadata.rsrust
/// Evidence from a single guard's evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuardEvidence {
    /// Name of the guard (e.g. "ForbiddenPathGuard").
    pub guard_name: String,
    /// Whether the guard passed (true) or denied (false).
    pub verdict: bool,
    /// Optional details about the guard's decision.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub details: Option<String>,
}

Three fields, one of them optional, none of them structured. The wire contract is pinned in spec/schemas/chio-wire/v1/receipt/record.schema.json under the guardEvidence definition: additionalProperties: false, guard_name and verdict required, guard_name with minLength: 1, and details a plain string with no pattern and no maximum. Read that literally before you build anything on top of it. There is no severity, no code, no timestamp, no category, and no place to put a machine-readable field that is not guard_name. Everything a guard wants a downstream consumer to parse has to be encoded inside one free-form string.

The whole array is omitted from the wire when it is empty, because ChioReceipt::evidence carries skip_serializing_if = "Vec::is_empty". An absent evidence key and an empty one are the same receipt. A consumer that treats the missing key as a parse failure will reject the majority of real receipts, because the default runtime guard profile emits no rows at all on an allow.


guard_name is the join key, and it joins to less than it looks

guard_name is the only field that ties a row back to the code that wrote it. There is no guard id, no registry, no uniqueness constraint, and no validation beyond the one-character minimum. Whatever string a producer puts there is what a dashboard groups on forever, and four separate naming conventions are live in the tree at once.

Where the name comes fromShapeExamples
Guard::name() on a registered guardlowercase kebab-caseforbidden-path, shell-command, internal-network, agent-velocity, advisory-pipeline, guard-pipeline
SanitizerHook::hook_name, the post-invocation defaultlowercase kebab-case, overridable per instance with with_nameoutput-sanitizer
projected_evidence on the HTTP mediation edgePascalCase, two hard-coded literals, not a registered guardCapabilityGuard, DefaultPolicyGuard
Doc comments, the JSON schema example, and test fixturesPascalCase Rust type namesForbiddenPathGuard, SecretLeakGuard, PolicyGuard

The last row is the trap. The schema’s own field description reads Name of the guard (e.g. `ForbiddenPathGuard`), and no guard in crates/guards/chio-guards/ ever emits that string. The names a kernel receipt actually carries are the Guard::name() values, which are kebab-case. Build your grouping on those and treat the PascalCase forms as documentation prose.

The receipt’s decision does not name the guard

Decision::Deny carries a guard: String field whose doc comment says "the guard or validation step that triggered the denial". On the kernel path it never holds a guard name. Every construction in crates/kernel/chio-kernel writes a fixed literal naming the kernel scope that refused, and the literals a reader will meet are these.

ValueWritten byThe refusal it marks
kernelresponses/deny_responses.rs, responses/pending_responses.rsEvery ordinary deny, including one raised by a guard, and the emergency-stop gate.
kernel.negotiationresponses/deny_responses.rsA capability-negotiation failure, recorded locally and deliberately not federated.
kernel.overloadresponses/deny_responses.rsAn RSS or allocation load-shed, recorded so the shed appears in the receipt trail.
kernel.receipt_persistenceresponses/deny_responses.rsA federated dispatch with no durable local receipt store to write to first.
session_rootskernel/session_ops.rsThe session filesystem-root refusal.
delivery_contractkernel/delivery_contract.rs, through admission_coordinator/terminal.rsA delivered output whose digest does not match the committed one, or a stream where a digest commitment admits only a single value.
finding_deliverySame pairA delivered output that is not a canonical reveal envelope, or whose media type does not match the advertised one.
finding_statusSame pair, under the finding-market featureA finding whose status changed before durable output release.

The four kernel-scoped values and session_roots are what a general deployment sees, and a deny raised by forbidden-path is signed with guard: "kernel". The three delivery values come from the durable delivery gate rather than from the guard pipeline, and they name the gate rather than a registered Guard too. Treat the table as the values in the tree rather than as a closed enumeration: nothing in the workspace constrains the field, so a new refusal path adds a new string with no schema change and no test to notice.

A guard name survives in two other places on that same receipt, and only one of them is reliably the guard that denied. The first is Decision::Deny.reason. The kernel’s guard loop builds KernelError::GuardDenied(format!("guard \"{}\" denied the request", guard.name())), and what lands on the receipt is that error’s Display, declared as guard denied the request: {0}, so the sentence arrives doubled. Note also which name the loop interpolates: the guard registered on the kernel, not the guard that actually denied. Compose through one GuardPipeline, as the next section recommends, and every deny reason names guard-pipeline, which leaves the evidence row as the only place the denying child is named.

The second place is a guard_name row, and only when something wrote one. Parsing a guard name out of a human-readable reason string is the fallback, not the contract. If you need the denying guard as data, make sure a producer from the next section is in the pipeline.

The metrics families are not a second copy of this

chio_guard_verdict_total{guard_id,verdict}, chio_guard_deny_total{guard_id,reason_class}, and chio_guard_eval_duration_seconds{guard_id,verdict} are declared in crates/observability/chio-metrics-spec/src/runtime.rs with a guard_id label, not guard_name. The only non-test emitter in the workspace is chio-wasm-guards, in runtime/guard.rs. A native Rust guard that denies increments none of the three. So the metric series and the receipt array cover disjoint guard populations and cannot be reconciled by joining the label to the field. Node Observability covers what a node does export.

Who actually writes a row

Most guards write nothing. Every shipped filesystem, shell, egress, tool-access, secret, patch, velocity, data-flow, sequence, injection, computer-use, memory, and data-layer guard denies with GuardDecision::deny(Vec::new()), and across the whole of crates/guards/ there is exactly one call to allow_with_evidence, in AdvisoryPipeline::evaluate. The rows that reach a receipt are written by a small, enumerable set of call sites, and every one of them sits outside the guard that made the decision.

ProducerFileRows per requestWhat it writes
GuardPipeline::evaluate, deny armcrates/guards/chio-guards/src/pipeline.rsAt most 1The denying child’s name, verdict: false, and the fixed string action=deny; reason=guard denied request
GuardPipeline::evaluate, error armcrates/guards/chio-guards/src/pipeline.rsAt most 1The erroring child’s name and action=error; reason=fail-closed; error={e}
signal_evidence, reached from AdvisoryPipeline::evaluatecrates/guards/chio-guards/src/advisory.rsOne per emitted signal, promoted or notThe advisory guard’s name, verdict: !promoted, and a five-key details string
SanitizerHook::inspect, redact armcrates/guards/chio-guards/src/post_invocation.rsAt most 1output-sanitizer, verdict: true, and a finding census
Any guard you writeYoursUnboundedWhatever you pass to deny or allow_with_evidence

The pipeline synthesizes what its children omit

GuardPipeline is the reason the empty-vector denials are survivable. It runs its children in registration order, accumulates whatever they attached, and on a deny or an error appends one row it composes itself before short-circuiting.

crates/guards/chio-guards/src/pipeline.rsrust
Verdict::Deny => {
    evidence.push(GuardEvidence {
        guard_name: guard.name().to_string(),
        verdict: false,
        details: Some(
        "action=deny; reason=guard denied request".to_string(),
    ),
    });
    return Ok(GuardDecision::deny(evidence));
}
// ...
Err(e) => {
    // Fail closed: guard errors are treated as denials.
    evidence.push(GuardEvidence {
        guard_name: guard.name().to_string(),
        verdict: false,
        details: Some(format!("action=error; reason=fail-closed; error={e}")),
    });
    return Ok(GuardDecision::deny(evidence));
}

Two tests in the same file pin exactly this. one_deny_means_pipeline_denies asserts one row naming deny-all, and error_treated_as_deny asserts one row naming error-guard whose details contain fail-closed. Both start from a child that attached nothing.

Registering guards directly on the kernel loses the synthesized row

The kernel has its own sequential loop, evaluate_guards_sequential in kernel/dispatch.rs, and it is not the same code. It extends the accumulator with decision.evidence and then matches the verdict, but it appends nothing of its own. It names the guard only inside the KernelError::GuardDenied message. So a guard added with kernel.add_guard(Box::new(MyGuard)) that denies with an empty vector produces a deny receipt with evidence absent from the wire and guard: "kernel" on the decision. Compose through GuardPipeline and register the pipeline as the single kernel-level guard, or attach the row yourself.

One divergence between the two loops is worth holding. GuardPipeline treats Verdict::PendingApproval as a sticky escalation, keeps iterating so a later child can still deny, and propagates the pending verdict if nothing does. The kernel loop refuses it outright with guard "..." returned an unsupported approval verdict. The evidence accumulated up to that point is still carried, because the extend runs before the match. The verdict semantics are The Three Verdicts.

Post-invocation hooks contribute at most one row each

A post-invocation hook returns a verdict, not a decision, so its evidence travels a side channel. The trait method take_evidence defaults to None; a hook that wants a row stores one during inspect and the pipeline drains it immediately after. SanitizerHook is the one shipped implementation that opts in, and it stores a row only on the redact path, clearing any stale row when nothing matched.

crates/guards/chio-guards/src/post_invocation.rsrust
let details = summarize_findings(&sanitized.findings, &sanitized.redactions);
self.store_evidence(GuardEvidence {
    guard_name: self.hook_name.clone(),
    verdict: true, // sanitized: still allowed but redacted
    details: Some(details),
});
PostInvocationVerdict::Redact(sanitized.value)

Read verdict: true carefully. A redaction is recorded as a pass, because the response was still delivered. A consumer counting verdict == false to find interventions will miss every sanitizer redaction on the node. summarize_findings builds aBTreeMap census of detector ids and renders sanitizer detected N findings (id:count,...), so the row carries counts and detector ids and never the matched text. That discipline is deliberate and is why the sanitizer can put anything in the row at all; Response Sanitization covers the detector set.

Because the row lives in a mutex on the hook rather than in the return value, the pipeline serializes whole evaluations behind lock_evaluation so two concurrent requests cannot swap each other’s rows. pipeline_serializes_hook_evaluation_and_evidence_side_channels drives two threads through one pipeline and asserts no overlap.

Three sources that look like producers and are not

WASM guards write no evidence. A denying module’s reason string is logged and classified into a bounded reason_class metric label, then discarded: runtime/guard.rs returns GuardDecision::deny(Vec::new()) on every deny arm. The guard does keep the last evaluation’s epoch_id, fuel_consumed, and manifest_sha256, exposed as JSON by guard_evidence_metadata(), and nothing on the receipt path calls that accessor. Its only callers in the tree are tests. See Custom WASM Guards.

External provider guards build rich rows on request and no one requests them. Each of the six providers in chio-external-guards(azure-content-safety, vertex-safety, bedrock-guardrail, safe-browsing, snyk, virustotal) exposes evidence_from_decision(verdict, details) that serializes a structured detail type into the string field. The only call sites in the workspace are two assertions in tests/cloud_guardrails.rs; every other reference to the method is prose. The adapter that fronts these providers, AsyncGuardAdapter::evaluate, returns a bare Verdict with no evidence channel at all, so an integrator who wants provider evidence on a receipt has to call the helper and attach the result. See External Guard Adapters.

Dispatch revalidation adds nothing. Guards with mutable state can be re-checked immediately before dispatch through revalidate_before_dispatch, whose signature is Result<(), KernelError> and therefore has nowhere to put a row. The shared helper revalidate_non_consuming_guard in chio-guards/src/lib.rs re-runs evaluate and reads only .verdict, dropping the decision and its evidence. AdvisoryPipeline overrides the hook to a no-op precisely so its admission-time signals are preserved rather than recomputed. The receipt therefore reflects admission-time evidence, never a second look.


The contract that carries a row onto a receipt

Guard evidence does not travel in a function argument. It travels in two thread-local slots that the evaluate path installs around the code that builds a receipt, and the receipt builder reads them at the moment it composes the body.

crates/kernel/chio-kernel/src/receipt_support/receipt_scopes.rsrust
thread_local! {
    static PRE_INVOCATION_GUARD_EVIDENCE: RefCell<Vec<GuardEvidence>> =
        const { RefCell::new(Vec::new()) };
    static POST_INVOCATION_GUARD_EVIDENCE: RefCell<Vec<GuardEvidence>> =
        const { RefCell::new(Vec::new()) };
}

Each slot has an RAII scope guard whose Drop restores the previous contents, so the installs nest correctly and a panic or an early return cannot leak one request’s rows into the next. The pre-invocation slot is installed by with_pre_invocation_guard_evidence, a small wrapper in evaluation/evaluation_helpers.rs, once at every terminal outcome that signs a receipt. Those calls are dense and mechanical: two files, evaluation/async_evaluation_core.rs and evaluation/nested_flow_evaluation.rs, hold every one of them, and no other module in the workspace calls the wrapper at all.

Four further sites install the slot directly through scope_pre_invocation_guard_evidence, skipping the wrapper. Three are in kernel_drop_guard.rs: persist_url_elicitation_cancellation for the terminal ambiguity receipt on a URL-error return, record_pre_dispatch_cleanup_fault_receipt for a cleanup fault raised from Drop, and the post-dispatch arm of Drop itself, where the tool invoke was in flight and a side effect may have executed. The fourth is finalize_durable_tool_return in admission_coordinator/terminal.rs. Cancellation receipts and recovered receipts carry the rows too.

The post-invocation slot is installed at two sites. The first is finalize_tool_output_with_metadata_and_payee_binding in responses/finalization.rs, immediately after apply_post_invocation_pipeline returns and before any allow, block, or incomplete response is built. The second is finalize_durable_tool_return, which installs both slots before rebuilding a recovered receipt.

crates/kernel/chio-kernel/src/kernel/responses/receipt_persistence.rsrust
let mut evidence = current_pre_invocation_guard_evidence();
evidence.extend(current_post_invocation_guard_evidence());

let body = ChioReceiptBody {
    id: next_receipt_id("rcpt"),
    // ...
    content_hash: params.content_hash,
    policy_hash: self.config.policy_hash.clone(),
    evidence,
    metadata,
    trust_level: params.trust_level,
    // ...
};

Four consequences follow from those two lines, and they are the whole contract.

  • Order is fixed. Pre-invocation rows come first, in guard registration order, then post-invocation rows in hook registration order. Index 0 is the earliest guard that wrote something, never the denying one unless it was also the first.
  • Mediated decisions only. build_and_sign_receipt hard-codes receipt_kind: ReceiptKind::MediatedDecision and boundary_class: BoundaryClass::Prevent, and it is the only reader of either slot in the whole crate. A trace-observation or advisory-evaluation receipt minted elsewhere carries no guard evidence from this path.
  • Deny receipts carry it too. The deny arm of the evaluate path installs denial.evidence from the GuardRunError before building the response, so rows accumulated before the short-circuit are signed into the refusal, not lost with it.
  • The array is inside the id. evidence is a field of ChioReceiptIdInput, and chio_receipt_id is sha256_hex over the canonical JSON of that struct. Adding, removing, reordering, or editing a single row changes the receipt id and invalidates the signature.

Nothing verifies that an evidence row came from the guard that owns it

Field coupling is stated as a bounded model, not as a runtime check. receipt_fields_coupled in crates/kernel/chio-kernel-core/src/formal_core.rs takes five booleans (capability, request, verdict, policy hash, evidence class) and its callers are the Kani harnesses in kani_harnesses.rs and kani_public_harnesses.rs. The signing path does not compare the body it is about to sign against the admitted decision inputs.

The evidence array is bound to the receipt by the id hash, so it is tamper-evident: editing a row after signing invalidates the signature. What that does not give you is provenance. A guard that writes a row under a name it does not own produces a valid, correctly signed receipt, and a verifier holding only the public key cannot tell.

Three tests in kernel/tests/guard_pipeline.rs pin the end-to-end path, and all three drive a real kernel with a real signature. allowing_guard_evidence_is_signed_into_success_receipt registers a guard returning allow_with_evidence and asserts the allow receipt carries one row with the expected name, a verdict of true, and the exact details string. async_allowing_guard_evidence_is_signed_into_success_receipt repeats it on a multi-thread Tokio runtime against a tool server that yields mid-invocation, which is what proves the thread-local slot survives the async dispatch rather than being dropped at the first await point. denying_guard_evidence_is_signed_into_deny_receipt asserts the deny receipt carries the row with verdict: false. No test in the tree asserts a post-invocation row on a signed ChioReceipt; that half is covered at the pipeline level only.


Advisory signals arrive flattened, and GuardOutput does not arrive

AdvisoryPipeline is the one shipped component that calls allow_with_evidence. It collects AdvisorySignal values from its child guards, marks any that a PromotionPolicy rule matches, and then maps every signal, promoted or not, through one function.

crates/guards/chio-guards/src/advisory.rsrust
fn signal_evidence(signal: &AdvisorySignal) -> GuardEvidence {
    let metadata = match &signal.metadata {
        Some(metadata) => metadata.to_string(),
        None => "null".to_string(),
    };
    GuardEvidence {
        guard_name: signal.guard_name.clone(),
        verdict: !signal.promoted,
        details: Some(format!(
            "action=advisory; description={}; severity={}; promoted={}; metadata={metadata}",
            signal.description,
            severity_label(signal.severity),
            signal.promoted
        )),
    }
}

A five-field structured signal becomes one row with a name, a boolean, and a semicolon-delimited string. The severity survives as the text after severity=, taking one of info, low, medium, high, or critical. The whole metadata object is stringified with Value::to_string and inlined after metadata=, which is the largest uncapped input to a receipt id anywhere on this path. An advisory guard that attaches a megabyte of structured context puts a megabyte inside the canonical bytes that get hashed and signed.

verdict: !signal.promoted is the field to internalize. A non-promoted signal is recorded as a pass, which is correct (it did not block) and misleading if you read verdict as "nothing was observed". A critical-severity anomaly with no matching promotion rule lands as verdict: true. Split advisory rows out by the action=advisory prefix before counting anything. Advisory Guards owns the signal types and the promotion rules.

GuardOutput is a type, not a wire shape

GuardOutput is a serde-tagged enum in chio-guards/src/advisory.rs with a deterministic variant mirroring the three GuardEvidence fields and an advisory variant wrapping a whole AdvisorySignal. It is re-exported from the crate root. Its only non-test constructor is AdvisoryPipeline::last_outputs, which maps stored signals into the Advisory variant, and the only callers of that method in the workspace are three call sites across two tests in the module’s own test block. No production path constructs the Deterministic variant; its one construction in the tree is a serde round-trip test, guard_output_distinguishes_types. No receipt field is typed as GuardOutput: both ChioReceipt::evidence and HttpReceipt::evidence are Vec<GuardEvidence>, and the wire schema has no type discriminator. Section 5.6 of spec/GUARDS.md describes GuardOutput as the representation "in the evidence array"; the code flattens through signal_evidence instead, and the code is the authority. Do not write a consumer that branches on a type key.

The details string, and reading it downstream

details is the only place a producer can put anything beyond a name and a boolean. It has no schema, no length limit, and no required encoding. The shipped producers nevertheless converge on one convention: semicolon-space-delimited key=value pairs whose first key is always action.

Producerdetails
Pipeline denyaction=deny; reason=guard denied request
Pipeline fail-closed erroraction=error; reason=fail-closed; error=<KernelError Display>
Advisory signalaction=advisory; description=...; severity=...; promoted=...; metadata=...
Output sanitizersanitizer detected N findings (id:count,...), no action key
HTTP mediation edgeBare prose, for example safe method, session-scoped allow

Two of the five break the convention, so it is a convention and not a contract. Treat the string as opaque unless you own the guard that wrote it. If you are writing a guard, follow the action= prefix, keep it short, and keep it free of the request content: the string is hashed into the receipt id and stored for the whole retention window in Receipt Retention, so anything sensitive you put there is durable.

Several shipped consumers read the array, and the OCSF export is the most structured of them. Each row becomes one OCSF enrichment object named chio.guard.evidence.{index} whose value is the guard name and whose data object carries guard_name, verdict, and details when present. The enrichment name embeds the array index, so a row’s enrichment key is stable only for as long as the guard ordering is.

The rest read it more coarsely, and one of them makes a routing decision on it. derive_severity in chio-siem/src/alerting.rs downgrades an allow receipt to AlertSeverity::Low as soon as any row carries verdict == false, and on a deny it passes the whole array to severity_for_guard, which folds the row names into the token set it substring-matches. The Datadog exporter pushes one evidence_guard:<name> tag per row. The webhook exporter builds its guard filter from the same names. And chio receipt explain renders the array verbatim under a guards key. A guard name is therefore an alerting input, not only a label: renaming a guard moves rows across severity buckets and out of existing webhook filters.

Two receipt families carry a guard_name field

The HTTP mediation edge signs its own record. HttpReceipt in chio-http-core/src/receipt.rs has its own evidence: Vec<GuardEvidence>, its own id derivation, and its own signature, and to_chio_receipt refuses the conversion with cannot convert HttpReceipt into signed ChioReceipt without the kernel keypair. The keypair-carrying variant, to_chio_receipt_with_keypair, does convert, but it builds a fresh ChioReceiptBody, recomputes content_hash over the canonical bytes, and re-signs. The HTTP signature does not cross with it, so the two records are never the same attestation.

Its rows come from projected_evidence in authority.rs, which returns exactly one row for every evaluation that reaches the kernel and picks among four constructions keyed on the route policy and whether a capability token was presented and valid. Three of the four carry a fixed details string; the invalid-capability branch clones the validation reason instead, which is how a string like HTTP authority projection does not support authorization field approval_tokens ends up in the field. A transport-level refusal never reaches that function at all: sign_transport_deny_receipt signs an HttpReceipt with evidence: Vec::new() for requests rejected before the kernel ran, an oversized body among them.

Those names, CapabilityGuard and DefaultPolicyGuard, correspond to no registered Guard. They describe the edge’s own admission logic. The join between the two families is not the guard name: it is the kernel receipt id, which the edge writes into the HTTP receipt’s metadata under the constant CHIO_KERNEL_RECEIPT_ID_KEY, chio_kernel_receipt_id. Join on that, then read the kernel receipt’s array for the guards that actually ran.


A worked array

Start with a captured one. Denying a read of /workspace/.env under examples/policies/canonical-hushspec.yaml produces a receipt whose evidence array holds exactly one row. The commands that produce it are on Testing Guards & Policies:

ChioReceipt.evidence, from chio receipt list --admin-alljson
[
  {
    "guard_name": "forbidden-path",
    "verdict": false,
    "details": "action=deny; reason=guard denied request"
  }
]

One guard ran and refused, so one row. Add an advisory pipeline ahead of the denying guard, both inside the same GuardPipeline, and the array gains a row above it. The two-row payload below is composed rather than captured: every field name is from GuardEvidence and every details string is the corresponding format! literal, but the advisory guard's name and description text are illustrative.

ChioReceipt.evidence (advisory row composed)json
[
  {
    "guard_name": "anomaly-advisory",
    "verdict": true,
    "details": "action=advisory; description=tool 'read_file' invoked 12 times (threshold: 5); severity=high; promoted=false; metadata={\"count\":12,\"threshold\":5}"
  },
  {
    "guard_name": "forbidden-path",
    "verdict": false,
    "details": "action=deny; reason=guard denied request"
  }
]

Note what is not in it. The advisory guard’s severity is inside a string, not a field. The denying guard’s actual reason, the path it objected to, is nowhere: ForbiddenPathGuard returns deny(Vec::new()) and the pipeline’s synthesized row carries a fixed sentence. The row that proves which guard denied is present; the row that would tell you why is the guard author’s job. Alongside this array the same receipt carries decision: { verdict: "deny", reason: "guard denied the request: guard \"guard-pipeline\" denied the request", guard: "kernel" }, which is where the human-readable half lives and where the name is the pipeline’s rather than the child’s. The kernel loop interpolates the guard registered on it, so forbidden-path appears nowhere on the receipt outside its evidence row.


Guarantees and limits

StatusClaimEvidence
ShippedA guard returns one GuardDecision carrying a verdict and a Vec<GuardEvidence>. The row is three fields: guard_name, a boolean verdict, and an optional free-form details.GuardDecision in kernel/mod.rs; GuardEvidence in chio-core-types/src/receipt/metadata.rs
ShippedThe array reaches a receipt through two thread-local slots read once by build_and_sign_receipt, pre-invocation first then post-invocation. That function is the only reader of either slot in the crate.receipt_support/receipt_scopes.rs; the two-line concatenation in responses/receipt_persistence.rs
ShippedThe array is bound into the content-addressed receipt id, so a row cannot be added, dropped, reordered, or edited after signing without breaking the id and the signature.evidence is a field of ChioReceiptIdInput; chio_receipt_id hashes its canonical JSON
Proved by testEvidence attached to an allow decision is signed into the success receipt, survives an async dispatch across an await point, and evidence attached to a deny is signed into the deny receipt.The three named tests in kernel/tests/guard_pipeline.rs
Proved by testGuardPipeline appends exactly one row on a child deny and one on a child error, naming the child and marking verdict: false, even when the child attached nothing.one_deny_means_pipeline_denies, error_treated_as_deny
Proved by testThe post-invocation take-evidence side channel is serialized, so two concurrent evaluations cannot swap rows.pipeline_serializes_hook_evaluation_and_evidence_side_channels in chio-kernel/src/post_invocation.rs
LimitAlmost no guard writes its own row. Every shipped guard denies with GuardDecision::deny(Vec::new()), and allow_with_evidence is called once in the whole of crates/guards/. Row content on a deny is the pipeline’s fixed sentence, not the guard’s reason.The single allow_with_evidence call is chio-guards/src/advisory.rs, in AdvisoryPipeline::evaluate
LimitA guard registered directly on the kernel gets no synthesized row. The kernel loop appends nothing and names the guard only in the GuardDenied message, so its deny receipt can omit evidence entirely.evaluate_guards_sequential in kernel/dispatch.rs versus GuardPipeline::evaluate
LimitDecision::Deny.guard does not name the denying guard. Every value the kernel writes is a fixed literal naming a kernel scope or a delivery gate, and nothing in the workspace constrains the field, so the set is open.Every Decision::Deny construction in responses/deny_responses.rs, responses/pending_responses.rs, kernel/session_ops.rs, and admission_coordinator/terminal.rs
LimitNothing validates that a row corresponds to a guard that ran. Field coupling exists as a bounded model exercised by the Kani harnesses, not as a check on the signing path.receipt_fields_coupled in crates/kernel/chio-kernel-core/src/formal_core.rs
LimitNo cap exists on array length or on details length, and the advisory producer inlines a stringified JSON object into the string. Every byte is hashed into the receipt id and retained.No length check anywhere on the path; signal_evidence’s metadata.to_string()
Limitverdict: true does not mean nothing happened. A sanitizer redaction and a non-promoted critical advisory signal both record true.SanitizerHook::inspect; verdict: !signal.promoted in signal_evidence
Not wiredGuardOutput never reaches a receipt. Its only non-test constructor is last_outputs, whose only callers are that module’s tests, and the Deterministic variant is constructed only in a serde round-trip test. No receipt field is typed as it and the wire schema has no type discriminator.Repo-wide grep for GuardOutput returns advisory.rs, the crate re-export, and two paragraphs of spec/GUARDS.md
Not wiredWASM guard evidence. A denying module’s reason string is logged and classified into a metric label, never written as a row, and guard_evidence_metadata() has no caller outside tests.Every deny arm in chio-wasm-guards/src/runtime/guard.rs returns deny(Vec::new())
Not wiredExternal provider evidence. Six provider guards expose evidence_from_decision; the only callers in the workspace are tests. AsyncGuardAdapter::evaluate returns a bare Verdict with no evidence channel.chio-external-guards/src/external/; chio-guards/src/external/mod.rs
UnsupportedJoining the guard metric families to the receipt array. The families are labeled guard_id and are emitted only by chio-wasm-guards, which writes no rows. The two cover disjoint guard populations.chio-metrics-spec/src/runtime.rs; the only non-test GUARD_VERDICT and GUARD_DENY emitters
UnsupportedStructured evidence. There is no field for a severity, a code, a category, or a timestamp, and additionalProperties: false on the wire schema forbids adding one. Structure has to be encoded into the details string.The guardEvidence definition in spec/schemas/chio-wire/v1/receipt/record.schema.json

Next steps

  • The Guard Trait · the signature that returns a decision, its context, and the narrower portable-core variant that has no evidence channel
  • Pipelines & Composition · the composition that synthesizes the rows a bare guard omits, and where each pipeline sits relative to dispatch
  • Receipts & Audit · the record the array lands on: canonical JSON, id derivation, signing backends, and the checkpoint chain
  • Advisory Guards · the signal types that flatten into rows, and the promotion rules that flip a row’s boolean
  • Node Observability · the node-scoped half: what a running node exports about guards, and why it does not join to this