Output release
Follow a tool result from return to caller, and name the checkpoint that makes its release final and replayable.
On this page
Retrying an operation after a lost reply returns the same guarded result, and the tool runs exactly once. Before a result leaves the kernel, its output guards run, the restrictions it reveals are committed, and a checkpoint binds the exact guarded bytes to the operation that produced them.
Three states stay separate: the tool's effect, its retained raw result, and its authorized release. The checkpoint authorizes replay of one guarded response under the release checks that apply at the time; delivery to the client is a separate observation, and a new invocation needs new authority. The running case is the fixer's read of Harbor Goods' terms in the pricing lab.
After the checkpoint, a retry replays the same bytes
SecurityReleaseRecordV1 binds the operation, dispatch commitment, outcome, evaluation, and resolved-output digests. Retrying the same operation returns the original guarded response and receipt without invoking the tool again. Replay protection.
The return context is frozen when dispatch commits
At dispatch commit the kernel freezes the context a result will return under, from the matched grant and the guard evidence already produced. It carries the original security, caller-delivery, and federation bindings, so fields of the request that change later cannot select a different authority for the result.
When a result comes back, validate_binding compares the frozen operation ID and request-binding hash with the admission being finalized. Two operations can share a caller-supplied request ID and still carry different commitments; output from one cannot satisfy the other, and a rejected substitution leaves the legitimate operation available for recovery.
The frozen context and its cross-operation test
pub(crate) struct DurableToolReturnContext {
operation_id: crate::admission_operation::AdmissionOperationId,
request_binding_hash: AdmissionDigest,
request_id: String,
request_material_digest: AdmissionDigest,
pub(super) matched_grant_index: usize,
pub(super) stream_limits: InvocationStreamLimitsV1,
admitted_metadata: Option<serde_json::Value>,
purchase_replay_metadata: Option<serde_json::Value>,
recovery_replay_metadata: Option<serde_json::Value>,
pub(super) pre_invocation_guard_evidence: Vec<chio_core::receipt::metadata::GuardEvidence>,
pub(super) security_invocation_context: Option<SecurityInvocationContext>,
pub(super) security_release_required: bool,
// Set only after authenticating a returned caller report. Never frozen as
// pre-dispatch evidence or serialized into the caller admission snapshot.
pub(super) caller_delivery_evidence:
Option<Box<crate::caller_delivery::CallerDeliveryEvidenceV1>>,
pub(super) federation_context: Option<FrozenFederationContext>,
pub(super) receipt_signing_identity:
Option<crate::tool_outcome::FrozenReceiptSigningIdentityV1>,
// Legacy caller frames stay explicitly unbound. New live contexts always
// retain the original selection, including absent participants.
participants: Option<Box<FrozenDispatchParticipants>>,
// None on native dispatch and on older caller formats. Historical frames
// cannot acquire missing custody by reading a newer current projection.
caller_participant_custody: Option<caller::CallerParticipantCustody>,
}Nothing in the context is re-derived from the request when the result returns. validate_binding (return_context.rs:87) checks it against the admission a return is recorded against. The test frozen_return_context_cannot_cross_operations_with_the_same_request_id (kernel/tests/durable_admission/return_context.rs:350) builds that collision and shows the second operation's attempt fails the binding check while its own admission state and outcome stay untouched.
Guards and the flow join see the bytes the caller would receive
Permission to run a tool approves none of the bytes it returns. validate_guarded_output reruns every registered guard's release check against the exact output value about to leave the kernel, under the grant that admitted the call.
- A guard refuses.
KernelError::GuardDeniedreading "guard output validation failed: …", with the guard's own text after the colon. - A guard panics. The panic is caught and refused fail-closed as
KernelError::GuardDeniedreading "guard output validation panicked (fail-closed)". - Flow evaluation runs last. On a kernel built with
build_kernel_with_active_defense, the post-invocation order is a watermark tripwire, then the output sanitizer, then flow evaluation, pinned byactive_defense_builder_installs_exact_boundary_order.
A caller reads either guard refusal after the variant's "guard denied the request: " prefix. evaluate_post_invocation validates the tool's manifest, checks that the classified result names the tenant, request, and payload being finalized, and joins its label with the retained principal, lineage, and session labels, the operator's output floor, and any declared output label. A join that cannot be represented raises the label to Top; a failed check refuses as a FlowDenial, which reaches the caller as a signed Deny such as flow blocked: tool flow declaration is invalid, or, under durable admission, as KernelError::DurableAdmission("durable post-invocation pipeline returned an unsupported blocking verdict").
The order protects recovery. A host that sent restricted bytes and then crashed before saving their label would leave a caller knowing something the recovered host failed to account for; committing the restrictions before any observable delivery makes the next request depend on what was released, whichever worker or host serves it. A withheld output still leaves the tool's effect in place, so keep the original result and operation when diagnosing a refusal. The native process host installs no post-invocation flow hook and classifies the delivered output inside its release checkpoint.
The checkpoint binds one guarded response to its operation
A release checkpoint commits the operation, the dispatch commitment, the outcome, the evaluation, and the resolved-output digests. Creating its live acknowledgement requires every one of those artifacts to be durable.
pub struct SecurityReleaseRecordV1 {
schema: String,
operation_id: AdmissionOperationId,
request_binding_hash: AdmissionDigest,
dispatch_commitment_id: chio_security_types::ports::RecordId,
outcome_id: AdmissionDigest,
raw_output_digest: AdmissionDigest,
evaluation_id: AdmissionDigest,
evaluation_lifecycle_digest: AdmissionDigest,
resolved_output_digest: AdmissionDigest,
acknowledged_at_unix_ms: u64,
store_fence: StoreMutationFence,
}AcknowledgedSecurityReleaseV1::acknowledge is the only way to produce a value proving that binding, and the value is neither serializable nor cloneable, so a stored record cannot recreate the call-scoped authority behind a release. When the artifacts are missing, acknowledge refuses with KernelError::SecurityDispatchOutcomeRecoveryRequired in place of assembling a record from what is available.
Before the checkpoint, stored output stays withheld and recovery must restore the original release custody. The test crash_before_release_cannot_publish_resolved_output kills the process at the cutpoint before the checkpoint: on restart, startup reconciliation and a replayed request both fail, and the operation keeps its retained state, neither completing on a second attempt nor losing the original outcome. After the checkpoint, the synchronous retry path returns the original guarded response and receipt without invoking the tool. That is narrower than declassification: a checkpoint replays one evaluated response and grants no exception for another destination or purpose.
Release and replay recheck authority before the bytes leave
A retained response stays bound to its original operation and caller context, and a grant that was live during execution can be withdrawn before a later delivery. Each return path rechecks in its own way:
- Caller-delivered returns.
require_caller_output_releaserefuses under an active emergency stop and rechecks the capability's current revocation status. It applies to returns carrying a caller-delivery report;completed_durable_tool_responsecalls it right after loading the retained return, before building the response and ahead of the check that the release was durably recorded. - Native returns. The output join runs under
NativeSecurityOutputJoinAuthority, a call-scoped value borrowed from kernel finalization that accepts exactly one join attempt; an old output record cannot manufacture it. The enclosing release then rechecks revocation and the emergency stop on every native return.
A successful native join records learned restrictions, which is taint persistence; durable delivery to an external destination is a separate contract layered above it. Missing release custody after a restart is a fail-closed recovery requirement: keep the operation for recovery, rerun neither the tool nor its guards under a new owner, and treat the outcome as unknown outcomes describes. Replay protection covers the same recheck from the retry side.
A released read of the terms replays without reading the document again
In the host/ directory of the security-research project, read Harbor Goods' terms, inspect the checkpoint, then repeat the read with the same operation key in a new host process:
research init --state .release
research read terms --as fixer --operation terms-v1 --state .release --json > first.json
research release terms-v1 --state .release --json > checkpoint.json
research read terms --as fixer --operation terms-v1 --state .release --json > replay.json
jq '{state, raw_recorded, evaluated, schema: .release.schema}' checkpoint.json
cmp <(jq -S .receipt first.json) <(jq -S .receipt replay.json)
wc -l .release/resource-events.jsonl{
"state": "completed",
"raw_recorded": true,
"evaluated": true,
"schema": "chio.security-release-checkpoint.v1"
}
1 .release/resource-events.jsonlYou should see a completed checkpoint, matching receipts, and one entry in the read journal: the replay returned the original guarded result without reading the document again. If the first caller lost its output file, retrying the same operation recovers those bytes, and a new operation key requests new work.
The release command loads the operation by its retained request, retrieves the raw invocation, the outcome, the post-return evaluation, and the release record from the original outcome store, and runs validate_against on their binding before reporting success. It prints checkpoint metadata without the raw response bytes and creates no release or dispatch authority.
Integration reference: inspect one original release
Invoke and inspect one original release
A configured durable host keeps the original host-bound operation ID, the request, and the current authenticated context. This integration evaluates through the kernel and inspects that operation's checkpoint even when finalization returns an error:
use chio_kernel::admission_operation::{AdmissionOperationId, AdmissionOperationStore};
use chio_kernel::tool_outcome::{SecurityReleaseRecordV1, ToolOutcomeStore};
/// The host supplies its original ID and qualified stores. A lookup does not
/// acquire a live lifecycle owner, authorize dispatch or release private bytes.
pub fn inspect_original_release(
admission: &dyn AdmissionOperationStore,
outcomes: &dyn ToolOutcomeStore,
original_id: &AdmissionOperationId,
) -> Result<Option<SecurityReleaseRecordV1>, Box<dyn std::error::Error>> {
let Some(checkpoint) = outcomes.lookup_security_release(original_id)? else {
return Ok(None); // Raw output or evaluation alone is not a release.
};
let operation = admission.load_by_operation_id(original_id)?.ok_or("missing original operation")?;
let raw = outcomes.load_raw_invocation_by_operation(original_id)?.ok_or("missing original raw outcome")?;
let outcome = outcomes.lookup_by_operation(original_id)?.ok_or("missing original outcome")?;
let evaluation = outcomes.lookup_post_return_evaluation(original_id)?.ok_or("missing original evaluation")?;
checkpoint.validate_against(&operation, &raw, &outcome, &evaluation)?;
Ok(Some(checkpoint)) // Matching historical evidence, not permission to resend.
}
/// Invoke through the configured kernel, then inspect even if finalization errs.
/// Never substitute raw stored output for a response the kernel did not release.
pub fn evaluate_and_inspect_original_release(
kernel: &chio_kernel::ChioKernel,
admission: &dyn AdmissionOperationStore,
outcomes: &dyn ToolOutcomeStore,
original_id: &AdmissionOperationId,
original: &chio_kernel::ToolCallRequest,
current_context: &chio_kernel::SecurityInvocationContext,
) -> (
Result<chio_kernel::ToolCallResponse, chio_kernel::KernelError>,
Result<Option<SecurityReleaseRecordV1>, Box<dyn std::error::Error>>,
) {
let response = kernel.evaluate_tool_call_blocking_with_security_context(original, current_context);
let release = inspect_original_release(admission, outcomes, original_id);
(response, release)
}- Allowed response and a matching checkpoint. Use the exact guarded response the kernel returned, and keep evidence inspection separate from authority.
- Signed refusal. Return the refusal and withhold any output it did not release; the original receipt and any retained tool outcome stay with the operation.
- Finalization error with a raw outcome and no checkpoint. Keep the error and recover the original operation, which still owns the tool effect and the unresolved release; stored raw bytes never stand in for a response.
- Checkpoint lookup or binding check fails. Keep the inspection failure visible and investigate the qualified store; a checkpoint is never synthesized.
The helper's lookups are independent reads and grant no live ownership. Its full binding check needs the retained raw payload; after authorized compaction, use the retained validation contract. A store without release checkpoints returns an error, never an empty snapshot.
An interrupted send stays unknown, and every release failure waits for recovery
With the tutorial receiver running and summary.txt present, this Bash block stops one posting host after the receiver accepts its request and before the callback returns, then retries the same operation. The connector's operator-controlled cutpoint writes effect-reached only after the receiver acknowledges.
research init --state .interrupted
research read terms --as fixer --operation terms-v1 --state .interrupted --json > interrupted-terms.json
before=$(wc -l < .receiver/internal.jsonl)
touch .interrupted/pause-after-effect
research publish summary.txt \
--as fixer --operation send-v1 \
--to internal --state .interrupted --json > interrupted.json &
call_pid=$!
while test ! -f .interrupted/effect-reached; do
kill -0 "$call_pid" || exit 1
sleep 0.05
done
kill -KILL "$call_pid"
wait "$call_pid" || test "$?" -eq 137
rm .interrupted/pause-after-effect
if research publish summary.txt \
--as fixer --operation send-v1 \
--to internal --state .interrupted --json > interrupted-retry.json; then
echo 'Unexpected retry completion' >&2
exit 1
else
test "$?" -eq 2
fi
test "$(wc -l < .receiver/internal.jsonl)" -eq "$((before + 1))"
jq '{verdict, release}' interrupted-retry.json{
"verdict": "Deny",
"release": {
"evaluated": false,
"found": true,
"raw_recorded": false,
"release": null,
"state": "outcome_unknown_after_dispatch"
}
}You should see one more summary at the receiver, while the kernel holds neither a raw return nor a checkpoint for the operation. The retry is denied with OutcomeUnknownAfterDispatch and creates no second HTTP effect. The completed replay above and this interrupted send are different recovery states, and neither permits a new operation that makes an uncertain send look complete.
| Refusal | Read as | Cause |
|---|---|---|
Kernel | "security dispatch outcome requires reconciliation: …" | Outcome must be reconciled before release |
Kernel | "guard denied the request: guard output validation failed: …" or "… panicked (fail-closed)" | An output guard refused or panicked |
Kernel | "durable admission failed: frozen return context belongs to another operation" | A result matched to the wrong operation |
Reconciliation is required when the checkpoint or its evaluation is absent, when the original release owner is unavailable, or when native output classification or its join fails, which reads "security native output preparation callback failed; authoritative recovery required". Under durable admission a finalization failure propagates as Err(KernelError) from evaluation; without it, the failure becomes Ok(ToolCallResponse) carrying a signed Deny with the error's display text. The configured mode, the effect class, and the qualified store decide which applies, whatever the entry point.
Keep the original operation, the returned bytes, and the checkpoint when release fails. Correct the classifier or restore the original release owner, then reconcile the operation through recovery. A caller that received nothing may still be looking at a tool that ran, and an ordinary retry cannot replace committed output.
Source reference: durable and non-durable error propagation
evaluate_tool_call and evaluate_tool_call_blocking produce both shapes. begin_durable_tool_admission_for_transport (admission_coordinator.rs:467) decides once per call, from the configured durable admission mode, the call's effect class, and whether a qualified admission store is configured. Under durable admission, a record_durable_tool_return failure returns unwrapped from the durable branch of tool-return handling (async_evaluation_core.rs:1907-1943). Without it, a validate_guarded_output failure becomes a Deny response with error.to_string() as its reason (finalization.rs:58-74).