Chio/Docs
LOGIN · JOIN

PlatformFan-Out & Fan-In

Swarm

Continuation Tokens

One signed token authorizes one child task, and the runtime burns its id before dispatch rather than after.

The verifier half lives next door

Swarm Authority owns the pure function that reads a whole delegation bundle in a fixed order and returns a verdict or an error, with no state and no clock of its own. Inside that pass, continuation tokens are validated like every other artifact family.

Source

This page reflects spec/schemas/chio-swarm/v1/continuation-token.schema.json and spec/PROTOCOL.md section 6.4.2 in the chio repository. The Swarm Protocol reference carries the full set of artifacts and the verification order.


One id, burned once across bundles

The token carries the one piece of state the verifier cannot hold. A continuation id is single-use across bundles, and nothing in a stateless function can know that. So the runtime takes a reservation on the id after the bundle verifies and before the admission policy evaluation runs, keeps it when the request is admitted, and hands it back when that evaluation denies. Both halves are shipped: crates/kernel/chio-swarm-authority defines and checks the artifact, crates/kernel/chio-runtime-core/src/admission_hook.rs runs the reservation, and spec/schemas/chio-swarm/v1/continuation-token.schema.json pins the wire form.


The token

A continuation token authorizes exactly one child task. Every field is inside the signature: the signature body is the canonical JSON of the whole token with the signature field removed, so editing any binding invalidates the token rather than retargeting it. That includes schema, which must equal chio.swarm.continuation-token.v1, and mode, so the issuer picks the mode and a presenter cannot change it.

FieldBinds the child task toChecked against
graphId, graphSha256The signed plan it descends fromString equality against the bundle graph id, plus a canonical SHA-256 recomputed over the bundle’s task graph, signature field included
childTaskIdThe one node it authorizesMust resolve in the graph node index. A node that declares a parentTaskId must carry a continuationTokenRef, and any node that carries one must be that token’s childTaskId. Nothing forbids a second token naming the same child
parentTaskId or joinReceiptIdWhere the authority came fromExactly one of the two. On the parent branch: the child node’s own parentTaskId and a declared graph edge. On the join branch: a join receipt whose nextTaskId is this child
parentReceiptIdsThe work that already happenedEntries unique and individually non-empty. The list must be non-empty on the parent branch; equal as sorted lists to the join’s actualParentReceiptIds on the join branch
witnessChainRef, witnessChainSha256The proof that scope narrowed on this hopCanonical digest of the named chain, whose parent and child task ids must match the token. Required on the parent branch, forbidden on the join branch
routePlanReceiptIdThe egress it may takeThe child node’s routePlanRef, and a receipt whose taskId is this child
budgetAllocationIdThe units it may spendThe child node’s allocation ref, whose state must be Active
revocationEpochRef, revocationEpochRootHashThe revocation view it was minted underThe bundle epoch id and its root hash, itself recomputed from the sorted revoked lists
nonceFreshness within the bundleA BTreeSet insert across every token in the bundle
sessionAnchorRefA session, by referenceRequired non-empty. Nothing resolves it. See Guarantees and limits
issuedAtUnixMs, expiresAtUnixMsA validity windowThe bundle’s now_unix_ms, which the runtime overwrites with the request clock. Issued after that instant is swarm continuation token is from the future: {tokenId}; expiry at or before it is swarm continuation token is stale: {tokenId}
issuer, signatureA pinned witness keyThe issuer parses to a public key, which must be a member of the caller-supplied trusted slice, then verifies the signature. Membership, not parseability, is the gate; an empty slice rejects everything

Because the epoch root hash is recomputed from the epoch’s own sorted revoked-subject and revoked-task lists, revoking one task changes that root, and every token minted against the old epoch now fails either the epoch-id equality check or the root-hash equality check. The digest is the part the tokens commit to. The lists are read directly as well, and earlier: validate_revocation_epoch runs before any token is looked at and rejects the whole bundle if the graph issuer, the planner subject, or any witness hop issuer is a revoked subject, or if the graph contains a revoked task id. The epoch itself, its validity window, and what a rotation costs every token in the bundle are Revocation Epochs.

Schema and verifier disagree, in both directions

The pinned JSON Schema carries additionalProperties: false and one conditional, mirrored in Rust by deny_unknown_fields:

spec/schemas/chio-swarm/v1/continuation-token.schema.json7-16json
"allOf": [
  {
    "if": {
      "required": ["parentTaskId"]
    },
    "then": {
      "required": ["witnessChainRef", "witnessChainSha256"]
    }
  }
],

That is the only cross-field rule the schema states. A token naming both parentTaskId and joinReceiptId, or neither, is schema-valid and is rejected by validate_continuation_parent with swarm continuation must choose parent task or join receipt and swarm continuation missing parent context. Validate against the schema to reject malformed input early; do not read a schema pass as an authorization result.

The schema is stricter in exactly one place, and it is the place that fixes the algorithm. signature is pinned to ^[0-9a-f]{128}$, the bare Ed25519 encoding. The Rust path parses through Signature::from_hex and PublicKey::from_hex, which also accept p256:, p384:, and hybrid: prefixes, so the crate is not Ed25519-only on its own. Validating against the schema therefore narrows what a token may carry past what the verifier would accept. The issuer-string rules are the same everywhere in the bundle; Delegation Witnesses works them through.


Two modes, one branch

mode is the field that decides whether an id gets burned. The runtime reads it once, in the function that verifies a swarm reference against the store:

crates/kernel/chio-runtime-core/src/admission_hook/swarm_authority.rs44-50rust
Ok(VerifiedSwarmAuthorityReference {
    continuation_id_to_consume: match continuation.mode {
        SwarmContinuationMode::SingleUse => Some(continuation.token_id.clone()),
        SwarmContinuationMode::Resumable => None,
    },
    route_metadata,
})
ModeReservation takenSecond presentationReceipt metadata
single_useYes, on the token idDenied, chio_swarm_continuation_replayCarries reserved_swarm_continuation_id
resumableNoRe-verified in full and admitted againNo reserved id at all

Resumable does not relax any other check. All of them still run on every presentation: expiry against the current request clock, the graph digest, the epoch root, the witness chain, the route plan, the allocation state. What resumable drops is exactly one property, consume-once, and chio_runtime_hook_revalidates_resumable_swarm_continuation_without_replay_denial pins the difference by admitting the same request twice and asserting no reserved id was ever recorded.

It is still a strictly weaker token, and the weakness has no ceiling on it. Nothing counts presentations of a resumable token or rate-limits them. The only things that stop one are its own expiresAtUnixMs, a revocation-epoch change, and whatever the policy pipeline decides on each pass. If you need a bound on how many times a child hop can run, mint single_use tokens and count them.

Two of the 11 shipped negative bundles are continuation cases: a token past its expiry, and two tokens in one bundle carrying the same nonce. Both are offline refusals, decided by the verifier from the bundle alone with no consume store involved.

swarm-authority · continuation-negativestranscript
$ source scripts/proof-room-quickstart-env.sh
$ for n in stale-continuation replayed-continuation-nonce; do
$   chio proof verify \
    "fixtures/proof-room/swarm-authority/$n/transaction-passport.json" 2>&1 | head -1
$ done
error [urn:chio:error:cli:other]: proof verify: swarm continuation token is stale: continuation-child-a
error [urn:chio:error:cli:other]: proof verify: swarm continuation nonce replay: continuation-child-b
exit 1

Neither is the chio_swarm_continuation_replay in the table above. That code is the runtime denying a second presentation of an id the store has already burned, and it needs two requests to happen. The nonce-replay refusal here is a different property: one bundle, two tokens, one nonce, refused without any request having run.


One child dispatch, in order

The request names evidence; it never carries it. swarm_ref_from_request reads governed_intent.context.chioSwarm and requires seven id-plus-digest pairs: task graph, continuation token, route-plan receipt, delegation witness, join receipt, revocation epoch, and budget pool. Any missing pair is missing_chio_swarm_evidence_ref; a pair that is present but malformed, meaning a non-object ref, a blank id, or a digest that is not 64 hex characters, is invalid_chio_swarm_evidence_ref; and a chioSwarm value that is not an object at all is invalid_chio_swarm_context. A request that tries to supply its own trust is refused by name, on the presence of the key alone. Six deny with request_smuggled_trust_root: trustRoot, trustRoots, trustBundle, authorityBundle, signingKey, witnessKeys. Three deny with request_smuggled_dynamic_trust: dynamicTrust, dynamicTrustBundle, peerDiscovery. That is a fixed list, not a pattern, so it screens the named escape hatches and nothing else.

From there the order is fixed. The store lookup is by task-graph evidence id; now_unix_ms is overwritten with the request clock, so a token that was fresh when the bundle was stored and stale by dispatch is stale. Each of the seven references must match the stored artifact by id and by canonical SHA-256. Live route metadata is compared against the route-plan receipt for bridge, protocol target, and selected route, which Route Plans works through spelling by spelling. Only then does verify_swarm_authority_bundle run. One check follows it: the named continuation’s routePlanReceiptId must equal the route-plan evidence id the request named, or the request denies with chio_swarm_authority_ref_mismatch. Only after all of that does the id get touched.

Which of those steps denies first depends on what the deployment actually has. On an embedder that never pinned a witness key but did store a bundle, the verifier call is where it stops, with chio_swarm_authority_rejected. On one that pinned keys and stored nothing, it stops three steps earlier, on the lookup itself, and the code is different:

crates/kernel/chio-runtime-core/src/admission_hook/swarm_authority.rs21-26rust
let Some(mut bundle) = store.swarm_authority_bundle(&reference.task_graph.evidence_id)? else {
    return rejected(
        "missing_chio_swarm_authority_bundle",
        "swarm-bound request referenced authority evidence that is not in the verifier-owned store",
    );
};

Either way no id is touched, because the consume is the last thing that happens. But the two are not the same refusal, and an operator reading one for the other will look in the wrong place: one says the keys are not pinned, the other says the evidence the request named is not in the verifier-owned store. The unpinned-key refusal is stated with its bound in Swarm Authority, and both, with the other two admission codes, are indexed in Swarm Denial Codes.

The consume itself sits in ChioRuntimeAdmissionHook::evaluate, inside a std::panic::catch_unwind whose four arms are a clean success, a store rejection, a store error, and a panic. The three failing arms each release the treaty continuation and return a denial. Only past that match is the reservation tracker built and evaluate_runtime_admission_tracked called, itself wrapped the same way, with both its error and panic arms releasing what the consume took.

The id is consumed before the admission evaluation, not after it. That ordering is the whole point: two concurrent presentations of the same token race on the store, not on a policy decision, and exactly one wins. A call contract keeps it there: cargo xtask check adapter-no-bypass requires at least one self.store.consume_swarm_continuation inside the hook’s evaluate, alongside the contracts requiring the route-metadata comparison and the verifier call. The same table pins the seven evidence pairs from the other end: swarm_ref_from_request must call required_swarm_evidence_ref at least seven times. Deleting the consume, or dropping a required reference, to make something else pass fails that check.

rendering
The states of one single-use continuation id. Presenting a Reserved id again denies with chio_swarm_continuation_replay and changes nothing.
sourcecrates/kernel/chio-runtime-core/src/admission_hook.rs:877-1004crates/kernel/chio-kernel/src/kernel/dispatch.rs:1587-1642at fe56570

When the admission evaluation then denies, the hook releases every reservation it took and strips the released ids back out of the receipt metadata, so a denial receipt does not claim to hold an id it gave back. When the evaluation accepts, the id stays consumed and the metadata carries reserved_swarm_continuation_id plus the verified route metadata. Before the tool is reached, the kernel calls back into the hook, which returns true from requires_dispatch_revalidation and re-runs the entire verification. Revalidation recomputes which id would be consumed and compares it to the recorded reservation; it does not consume a second time. Two boundaries apply to that pass. It feeds the verifier the verified_swarm_route_metadata recorded on the admission receipt rather than fresh live metadata, so it re-proves the bundle against the request clock but not the route against the current call. And a mismatch is a KernelError, not a policy verdict: the message is runtime admission reservation reserved_swarm_continuation_id changed before dispatch, and the dispatch fails closed.


Where the id is kept

RuntimeAdmissionStore ships a default implementation of consume_swarm_continuation that refuses rather than succeeds, and every store in the workspace overrides it with a real single-consume. Both codes below, and every other swarm-specific refusal, are indexed in Swarm Denial Codes.

StoreConsume mechanismOn a second consume
Trait defaultNonechio_swarm_continuation_store_unsupported, on the first call. A store that cannot track replay denies instead of passing the request through
InMemoryRuntimeAdmissionStoreInsert into a mutex-guarded setchio_swarm_continuation_replay
JsonRuntimeAdmissionStoreScan consumed_swarm_continuation_ids for the id first, then push, validate, and persistchio_swarm_continuation_replay
SqliteRuntimeOrchestrationStoreINSERT OR IGNORE INTO runtime_consumed_swarm_continuationsZero rows inserted, so chio_swarm_continuation_replay
LayeredRuntimeAdmissionStoreDelegates to its admission storeWhatever that store returns

Release is the inverse in each case: remove from the set, retain the vector without the id, or DELETE ... WHERE continuation_id = ?1 AND admission_id = ?2. The scoping is not uniform, and the difference matters. SQLite is the only store that records which admission consumed an id and the only one that scopes the delete by it, so a release there can clear only a marker the same admission wrote. The in-memory and JSON stores take admission_id and ignore it on both consume and release, so any release call clears the id unconditionally. Treat that as a development-store property, not a guarantee to build on.


What happens when the store cannot answer

Claim: an ambiguous consume is never rolled back

FieldValue
StatusShipped, proved by test.
ClaimWhen consume_swarm_continuation fails with a store error or panics, the runtime denies, releases the treaty continuation it already took, and leaves the swarm id alone. Releasing an id whose consume outcome is unknown could erase a marker the store did write, or one a different admission reacquired after the callback returned.
SubjectOne swarm-bound child dispatch whose store call neither cleanly succeeded nor cleanly rejected.
EvidenceThe denial metadata carries failure_code: swarm_continuation_consume_error, ambiguous_swarm_continuation_id, reservation_ownership_ambiguous: true, and reservation_consumption_failure_reason. Both the store-error and the panic arm produce the same shape. swarm_consume_error_releases_treaty_and_preserves_same_admission_swarm_marker and its panic twin assert one treaty release, zero swarm releases, and that a later consume of the same id still returns chio_swarm_continuation_replay.
LimitThe id is stranded until an operator intervenes. The runtime records enough to find it and deliberately does not retry.

The three keys that mark an ambiguous consume are written by one helper, and it is the same helper both the store-error arm and the panic arm call. The reservation key is its argument, which is why the treaty and swarm cases produce the same shape under different names:

crates/kernel/chio-runtime-core/src/admission_hook.rs106-131rust
fn ambiguous_consumption_metadata(
    mut metadata: serde_json::Value,
    reservation_key: &str,
    reservation_id: &str,
    failure_reason: &str,
) -> serde_json::Value {
    let runtime = metadata
        .as_object_mut()
        .and_then(|metadata| metadata.get_mut("chio_runtime"))
        .and_then(serde_json::Value::as_object_mut);
    if let Some(runtime) = runtime {
        runtime.insert(
            reservation_key.to_string(),
            serde_json::Value::String(reservation_id.to_string()),
        );
        runtime.insert(
            "reservation_ownership_ambiguous".to_string(),
            serde_json::Value::Bool(true),
        );
        runtime.insert(
            "reservation_consumption_failure_reason".to_string(),
            serde_json::Value::String(failure_reason.to_string()),
        );
    }
    metadata
}

Claim: once the tool may have run, the id is not returned

The kernel releases reservations on a pre-dispatch denial and on a dropped request. It stops doing so the moment a side effect could have happened. Instead it copies the reserved ids into the receipt under new names and marks them:

crates/kernel/chio-kernel/src/kernel/dispatch.rs1587-1642rust
pub(crate) fn mark_runtime_admission_reservations_retained_fail_closed(
    &self,
    metadata: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
    let mut retained = serde_json::Map::new();
    {
        let Some(runtime) = metadata
            .as_ref()
            .and_then(|value| value.get("chio_runtime"))
            .and_then(serde_json::Value::as_object)
        else {
            return metadata;
        };
        // Copy across only the ids that name a REAL reservation: a present,
        // non-empty reserved lease/continuation id. A `chio_runtime` route
        // block that merely carries the key with no (or an empty) value had
        // nothing to burn.
        for (source, target) in [
            (
                "reserved_destructive_lease_id",
                "retained_destructive_lease_id",
            ),
            (
                "reserved_treaty_continuation_id",
                "retained_treaty_continuation_id",
            ),
            (
                "reserved_swarm_continuation_id",
                "retained_swarm_continuation_id",
            ),
        ] {
            if let Some(id) = runtime
                .get(source)
                .and_then(serde_json::Value::as_str)
                .filter(|id| !id.is_empty())
            {
                retained.insert(target.to_string(), serde_json::json!(id));
            }
        }
        // Only mark retained when at least one real reservation was actually
        // retained. An observe-only admission or a metadata-only
        // `chio_runtime` route block has no `reserved_*` id to recover, so
        // it must not carry the fail-closed marker.
        if retained.is_empty() {
            return metadata;
        }
        retained.insert(
            "reservations_retained_fail_closed".to_string(),
            serde_json::Value::Bool(true),
        );
    }
    merge_metadata_objects(
        metadata,
        Some(serde_json::json!({ "chio_runtime": retained })),
    )
}

The copy is deliberately conservative, and the source says so in its own comments. Metadata with no chio_runtime block, or with a block carrying no present and non-empty reserved id, is returned unchanged: marking it retained would tell an operator a continuation was burned when there was nothing to recover. A burned single-use continuation is not recoverable by the runtime. It is recoverable by whoever can mint another one, from the signed receipt that names it.

A release failure is recorded, not retried

If a release call itself fails, the hook writes reservation_release_failed: true and a joined failure reason into the receipt, keeps the reserved ids visible, and returns. Subsequent code paths check that flag and skip re-releasing, because a retry could erase a marker reacquired between the failed call and the retry. Treat that flag as an operator alert: the reservation may still be held.

Guarantees and limits

StatusClaimEvidence
ShippedA single-use id is consumed at admission, before the admission evaluation runs, and released if that evaluation denies.ChioRuntimeAdmissionHook::evaluate and release_reservations in admission_hook.rs
Proved by testReplaying an admitted single-use token denies with chio_swarm_continuation_replay, in memory and on disk, and dispatch revalidation of the first admission still passes.chio_runtime_hook_revalidates_reserved_swarm_continuation_then_denies_replay, sqlite_runtime_hook_denies_replayed_swarm_continuation_before_dispatch
Proved by testA token that expires between storage and dispatch is denied, because the runtime evaluates against the request clock rather than the stored bundle’s.chio_runtime_hook_denies_swarm_continuation_expired_since_storage, denial code chio_swarm_authority_rejected
Proved by fixtureTwo tokens sharing a nonce inside one bundle are rejected. The negative is not a byte flip: the nonce check runs after the signature check and the graph-digest check, so reaching it at all proves the fixture is internally well signed and only the duplicate nonce is wrong.fixtures/proof-room/swarm-authority/negatives/replayed-continuation-nonce.json, claim claim.swarm.continuation_fresh, failure code proof-room.negative.swarm-continuation-nonce-replay, doctor spec swarm_replayed_continuation_nonce expecting swarm continuation nonce replay
Not claimedSession binding. sessionAnchorRef is required non-empty and covered by the signature, and nothing in the workspace resolves it or compares it to the kernel’s own session anchor state. It is an auditable label today, not an enforced link.validate_continuation_token checks only non-emptiness; the kernel’s session_anchor_id in chio-kernel/src/session.rs is a separate mechanism
UnsupportedCross-bundle nonce replay detection in the verifier. Nonce and token-id uniqueness hold within one bundle. A second bundle reusing a nonce is caught only if it reuses the token id, and then by the store, not by the verifier.validate_continuation_tokens; the store denial code chio_swarm_continuation_replay
UnsupportedIssuance in production. mint_swarm_continuation_token and sign_swarm_continuation_token are public and callable, and every call site in the repo is a test. No product crate, CLI command, or service path mints a token.Call sites of both functions: chio-swarm-authority/tests/swarm_authority_stage0.rs and chio-runtime-core/tests/runtime_admission.rs only
UnsupportedAny bound on how often a resumable token may be presented. Nothing counts or throttles presentations. Expiry and the revocation epoch are the only limits.continuation_id_to_consume is None for that mode, so no store row is ever written
Operator-configuredThe trusted witness key slice. It defaults to empty on the admission hook and is populated only by an in-process builder call, so a deployment that forgets it fails closed on every swarm-bound request rather than falling back to anything.ChioRuntimeAdmissionHook::with_swarm_witness_keys; require_trusted_witness_issuer_keys in verifier.rs

Next Steps