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.
pub struct GuardDecision {
pub verdict: Verdict,
pub evidence: Vec<GuardEvidence>,
}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.
/// 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 from | Shape | Examples |
|---|---|---|
Guard::name() on a registered guard | lowercase kebab-case | forbidden-path, shell-command, internal-network, agent-velocity, advisory-pipeline, guard-pipeline |
SanitizerHook::hook_name, the post-invocation default | lowercase kebab-case, overridable per instance with with_name | output-sanitizer |
projected_evidence on the HTTP mediation edge | PascalCase, two hard-coded literals, not a registered guard | CapabilityGuard, DefaultPolicyGuard |
| Doc comments, the JSON schema example, and test fixtures | PascalCase Rust type names | ForbiddenPathGuard, 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.
| Value | Written by | The refusal it marks |
|---|---|---|
kernel | responses/deny_responses.rs, responses/pending_responses.rs | Every ordinary deny, including one raised by a guard, and the emergency-stop gate. |
kernel.negotiation | responses/deny_responses.rs | A capability-negotiation failure, recorded locally and deliberately not federated. |
kernel.overload | responses/deny_responses.rs | An RSS or allocation load-shed, recorded so the shed appears in the receipt trail. |
kernel.receipt_persistence | responses/deny_responses.rs | A federated dispatch with no durable local receipt store to write to first. |
session_roots | kernel/session_ops.rs | The session filesystem-root refusal. |
delivery_contract | kernel/delivery_contract.rs, through admission_coordinator/terminal.rs | A delivered output whose digest does not match the committed one, or a stream where a digest commitment admits only a single value. |
finding_delivery | Same pair | A delivered output that is not a canonical reveal envelope, or whose media type does not match the advertised one. |
finding_status | Same pair, under the finding-market feature | A 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.
| Producer | File | Rows per request | What it writes |
|---|---|---|---|
GuardPipeline::evaluate, deny arm | crates/guards/chio-guards/src/pipeline.rs | At most 1 | The denying child’s name, verdict: false, and the fixed string action=deny; reason=guard denied request |
GuardPipeline::evaluate, error arm | crates/guards/chio-guards/src/pipeline.rs | At most 1 | The erroring child’s name and action=error; reason=fail-closed; error={e} |
signal_evidence, reached from AdvisoryPipeline::evaluate | crates/guards/chio-guards/src/advisory.rs | One per emitted signal, promoted or not | The advisory guard’s name, verdict: !promoted, and a five-key details string |
SanitizerHook::inspect, redact arm | crates/guards/chio-guards/src/post_invocation.rs | At most 1 | output-sanitizer, verdict: true, and a finding census |
| Any guard you write | Yours | Unbounded | Whatever 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.
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
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.
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.
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.
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_receipthard-codesreceipt_kind: ReceiptKind::MediatedDecisionandboundary_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.evidencefrom theGuardRunErrorbefore 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.
evidenceis a field ofChioReceiptIdInput, andchio_receipt_idissha256_hexover 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
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.
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.
| Producer | details |
|---|---|
| Pipeline deny | action=deny; reason=guard denied request |
| Pipeline fail-closed error | action=error; reason=fail-closed; error=<KernelError Display> |
| Advisory signal | action=advisory; description=...; severity=...; promoted=...; metadata=... |
| Output sanitizer | sanitizer detected N findings (id:count,...), no action key |
| HTTP mediation edge | Bare 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:
[
{
"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.
[
{
"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
| Status | Claim | Evidence |
|---|---|---|
| Shipped | A 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 |
| Shipped | The 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 |
| Shipped | The 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 test | Evidence 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 test | GuardPipeline 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 test | The 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 |
| Limit | Almost 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 |
| Limit | A 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 |
| Limit | Decision::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 |
| Limit | Nothing 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 |
| Limit | No 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() |
| Limit | verdict: 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 wired | GuardOutput 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 wired | WASM 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 wired | External 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 |
| Unsupported | Joining 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 |
| Unsupported | Structured 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