Chio/Docs
LOGIN · JOIN

PlatformFan-Out & Fan-In

Swarm

Join & Terminal Receipts

Fan-in proved against an expected parent set, and a closing receipt whose completed, join, route, and budget sets must equal the bundle.

Signing lives next door, the rollup lives across the hall

Receipts & Audit owns what a receipt is and how one is signed for a single mediated call: the canonical-JSON rule, the SigningBackend trait, the store, and the Merkle checkpoints over it. This page owns graph closure, and it never re-explains a signature. Sub-Agent Budgets owns the pool and the per-dimension rollup arithmetic; this page states only where that reconciliation sits in the closing sequence.

Source

This page reflects spec/schemas/chio-swarm/v1/join-receipt.schema.json, spec/schemas/chio-swarm/v1/terminal-graph-receipt.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.


Two receipts a graph cannot close without

A join receipt is a signed claim that a declared fan-in happened: which parent tasks were expected to report, which actually did, under what predicate, and what the merged result digests to. A terminal graph receipt is a signed claim that a whole delegation graph is finished, and it carries the four sets and the budget rollup that claim has to reconcile against.

ArtifactSchema tagRust typeClaim emitted
Join receiptchio.swarm.join-receipt.v1SwarmJoinReceiptclaim.swarm.join_receipt_bound, only when the bundle carries at least one
Terminal graph receiptchio.swarm.terminal-graph-receipt.v1SwarmTerminalGraphReceiptclaim.swarm.terminal_graph_receipt_bound, unconditionally, because the artifact is mandatory

Status. Verification is shipped and pure. Both artifacts are checked inside verify_swarm_authority_bundle (crates/kernel/chio-swarm-authority/src/verifier.rs), which performs no I/O and reads the clock only as bundle.now_unix_ms. Issuance is not shipped: every workspace caller of mint_swarm_join_receipt, sign_swarm_join_receipt, and sign_swarm_terminal_graph_receipt lives under a tests/ directory or in the crate’s own examples/agent_os.rs, which signs a demonstration bundle. Both wire shapes are published as JSON Schema and registered in spec/schemas/registry.json under swarm-authority-v1. No crate loads either file and no test validates a fixture against one. Parsing is what rejects a malformed receipt: both Rust types deserialize with deny_unknown_fields, and both schemas set additionalProperties: false with every field required.

One precondition sits ahead of every check on this page. The first thing verify_swarm_authority_bundle does is reject an empty trusted-key slice, with trusted swarm witness keys missing: CHIO_SWARM_TRUSTED_WITNESS_KEYS must pin trusted swarm witness keys. The crate never reads that environment variable, or any other. The name is only the convention callers use when they populate the slice.

The closing receipt is mandatory everywhere the verifier runs, including the pre-dispatch admission hook at crates/kernel/chio-runtime-core/src/admission_hook/swarm_authority.rs. An absent field and an empty list both reject with missing swarm terminal graph receipt. A bundle therefore carries its own closing evidence from the moment it is first admitted, which is a real design consequence rather than a deployment detail. There is no in-flight graph in this verifier.

On that path the kernel loads the bundle from its own store, overwrites now_unix_ms with its own clock, and runs the same entry point twice: once at admission and again before dispatch. What the request itself pins is narrower. The chioSwarm context carries seven evidence references, each an id plus a sha256 over the stored artifact: task graph, continuation token, route plan receipt, delegation witness, join receipt, revocation epoch, budget pool. A terminal receipt is not among them, so a caller can require closure but cannot pin which closing receipt it got.


The two objects

Field names below are the wire spellings. The Rust structs use snake case.

fixtures/proof-room/swarm-authority/valid-recursive-delegation/join-receipt.jsonjson
{
  "schema": "chio.swarm.join-receipt.v1",
  "joinId": "join-child-results",
  "graphId": "swarm-graph-proof-valid",
  "chainId": "swarm-chain-swarm-graph-proof-valid",
  "parentSetHash": "e23cfc5d097cbce5f0ec16529869e57ddeaadb67f8fb3e561ae1c3125993657e",
  "dagOrdinal": 2,
  "hlcUnixMs": 1700000000000,
  "parentTaskReceipts": [
    { "taskId": "task-child-a", "receiptId": "receipt-child-a" },
    { "taskId": "task-child-b", "receiptId": "receipt-child-b" },
    { "taskId": "task-child-c", "receiptId": "receipt-child-c" }
  ],
  "expectedParentReceiptIds": ["receipt-child-a", "receipt-child-b", "receipt-child-c"],
  "actualParentReceiptIds": ["receipt-child-a", "receipt-child-b", "receipt-child-c"],
  "joinPredicate": "all_success",
  "resultDigest": "cd81227938a623d33e100a0198a8e844b9545075e6583d48a6b059a68d4cff39",
  "nextTaskId": "task-root",
  "issuer": "did:chio:43046bfe4092b3e94994eada15dcc20d8aaa07b658fd3954eb8e0efb8bdca5de",
  "signature": "9cd2e4083bc981c190942f75b3af8b899d99c616e5dd096f400977e61fbf7da4..."
}
FieldWhat the verifier does with it
joinIdMust name a join declared in the signed task graph. Unknown ids reject; duplicate receipts for one join reject; a declared join with no receipt rejects with missing swarm join receipt.
graphIdMust equal the task graph’s, checked by require_same_graph.
chainIdNon-empty, and hashed into parentSetHash. Otherwise uninterpreted. Nothing compares it to any other artifact’s chain id.
parentSetHashRecomputed from the receipt’s own contents and compared. See below.
dagOrdinalRejected at zero, and nothing else. No comparison against any parent ordinal happens in this crate.
hlcUnixMsRejected when greater than now_unix_ms. No lower bound and no ordering against the graph or its parents.
expectedParentReceiptIdsUnique, and its length must equal the graph join’s parent count, which the graph check floors at two. Its order is load bearing.
actualParentReceiptIdsUnique, and a subset of the expected set. That check runs after the predicate and independently of it.
parentTaskReceiptsThe explicit task-to-receipt pairing, which must reproduce the positional one. Order within the array is free.
joinPredicateOne of three forms. Anything else is swarm join receipt predicate unsupported.
resultDigestMust be a lowercase 64-character SHA-256 digest. Nothing recomputes it; there is no merged payload in the bundle to hash.
nextTaskIdMust equal the graph join’s nextTaskId and resolve to a task in the graph.
issuer / signatureThe issuer is self-certifying: did:chio: plus 64 lowercase hex characters, or a bare hex key. It must resolve to a key in the caller-supplied trusted set, and the signature must verify over the canonical JSON of the whole receipt with the signature field removed.

The terminal receipt is shorter to describe and stricter to satisfy. receiptId must be unique within the bundle, graphId must match, chainId must be non-empty, budgetPoolId must equal the bundle pool’s id, revocationEpochRef must equal the epoch’s id, resultDigest must be a SHA-256 digest, and completedAtUnixMs may not be in the future. Its four id arrays must each be free of duplicates. What makes it a closure artifact is what those arrays are then compared against.


How a join proves fan-in

The parent set hash is recomputed

parentSetHash is not accepted as declared. The verifier recomputes it from the receipt’s own chain id and the sorted list of parents that actually reported, then compares:

crates/kernel/chio-swarm-authority/src/verifier.rsrust
fn join_parent_set_hash_from_parts(
    chain_id: &str,
    receipt_ids: &[String],
) -> Result<String, SwarmAuthorityError> {
    let mut receipt_ids = receipt_ids.to_vec();
    receipt_ids.sort();
    let body = serde_json::json!({
        "chainId": chain_id,
        "parentReceiptIds": receipt_ids,
    });
    let canonical = canonical_json_bytes(&body)
        .map_err(|error| SwarmAuthorityError::Canonical(error.to_string()))?;
    Ok(sha256_hex(&canonical))
}

Three things follow. The digest covers the actual set, not the expected one, so it identifies the fan-in that happened rather than the one that was planned. Sorting means the order a producer happens to emit parents in cannot change the digest. And the chain id is inside the hashed body, so the same parent receipt ids under a different chain produce a different digest. Everything else in the receipt is covered by the signature instead, which is computed over the whole artifact minus its own signature field.

Two functions named parent_set_hash, two different digests

chio_core_types::receipt::lineage::parent_set_hash hashes the canonical JSON of the sorted, deduplicated id array on its own. The swarm function above hashes an object carrying both the chain id and that array. The two never produce the same digest for the same parents, and they belong to different artifacts: kernel receipt lineage on one side, swarm join receipts on the other. Do not carry a digest across.

Three predicates, one shared rejection

The predicate runs first and decides how much of the expected set had to report. validate_actual_parent_receipt_subset runs immediately after it and rejects any actual id that is not in the expected set, whatever the predicate said. A predicate rejection returns first, so the subset check runs on every receipt whose predicate passed and on no receipt whose predicate did not.

PredicateConditionTest
all_successThe sorted actual set must equal the sorted expected set.swarm_authority_stage0_rejects_join_parent_set_mismatch
any_successAt least one parent reported. This is the only length check.swarm_authority_stage0_accepts_any_success_join_subset
quorum:NN must parse as an integer with 0 < N <= expected.len(), and at least N parents must have reported.swarm_authority_stage0_accepts_quorum_join_subset, swarm_authority_stage0_rejects_unmet_quorum_join_subset

The expected set is never smaller than two. A graph join declaring one parent rejects at graph validation with swarm join requires at least two parents, and the count check ties expectedParentReceiptIds to that length, so N is always drawn from a set of two or more.

An unparseable N, a zero quorum, and a quorum larger than the expected set all reject as an unsupported predicate rather than as an unmet one. Everything else lands on one string, swarm join receipt parent set mismatch: an all_success shortfall, an unmet quorum, an empty any_success, and a subset violation are indistinguishable from the rejection text. Match on the claim, not the sentence.

The pairing is declared twice and must agree

expectedParentReceiptIds has no task ids in it. The correspondence comes from position: index i of that array is the receipt expected from index i of the graph join’s parentTaskIds, which the planner signed. The verifier zips the two, keeps the pairs whose receipt actually reported, and requires parentTaskReceipts to be exactly that set:

crates/kernel/chio-swarm-authority/src/verifier.rsrust
graph_join
    .parent_task_ids
    .iter()
    .zip(receipt.expected_parent_receipt_ids.iter())
    .filter_map(|(task_id, receipt_id)| {
        if actual_receipts.contains(receipt_id.as_str()) {
            Some((task_id.as_str(), receipt_id.as_str()))
        } else {
            None
        }
    })
    .collect()

validate_join_parent_task_receipts then requires the arrays to be the same length, every declared pair to be a member of that computed set, task ids and receipt ids each to be unique, and the three sorted projections to agree. Order inside parentTaskReceipts is free, and swarm_authority_stage0_accepts_reordered_join_parent_task_receipts swaps two entries and still verifies. Repointing one entry at an unrelated receipt id does not: swarm_authority_stage0_rejects_join_parent_task_receipt_mapping_mismatch lands on swarm join receipt parent task receipts mismatch.

A join receipt does not by itself authorize the task it names. It records that fan-in occurred. Authorization arrives only if a continuation token chooses join mode, names the join (the token field is spelled joinReceiptId, and the index it resolves against is keyed by joinId), targets the same next task, and carries exactly the same actual parent receipt ids; Continuation Tokens owns that check. Nothing requires such a token to exist. In the shipped positive fixture the join over three depth-1 children names task-root, which has no parent and therefore no continuation token at all.


What closure actually checks

rendering
Closure in the shipped positive fixture. Each arrow is a comparison the verifier makes against something it built earlier in the same call: three set equalities, and one per-dimension sum over the live pool.
sourcecrates/kernel/chio-swarm-authority/src/verifier.rs:1636-1681crates/kernel/chio-swarm-authority/src/verifier.rs:1731-1789at fe56570

Set equality, not containment. A terminal receipt cannot close part of a graph, cannot omit a route plan the bundle carries, and cannot name a join the graph does not declare. The three id comparisons run in validate_terminal_graph_receipt_refs against indexes the verifier built while validating routes, joins, and the graph itself, so a mismatch reports the receipt id rather than the offending element.

terminalTaskIds is the exception, and it is worth reading carefully. It is required only to name tasks that exist. It is not derived from the edge set, not required to be the graph’s sinks, and not compared to anything else. In the fixture it holds a single entry, task-root, which is the graph’s depth-0 root and the target of the join. Which tasks were terminal is a producer’s declaration, not a verified fact.

The published schema and the crate disagree about emptiness. The schema sets minItems: 1 on all four id arrays and on budgetRollups. A graph with no joins, no route plans, or no allocations forces the matching array empty by set equality, so such a bundle passes the crate and fails the schema. Nothing loads the schema, so the crate is what runs.

The fourth comparison is the budget rollup, and it is the reason this receipt has to be reconciled before a graph counts as closed. The verifier groups the live allocations by dimensionId, sums their reserved, active, consumed, released, and reversed columns, and compares the whole map against the receipt’s. Every rollup’s declared totalUnits must equal the sum of its own five columns first, and a repeated dimension rejects. Because the comparison is between maps, a rollup naming a dimension with no allocations and a missing dimension both fail the same way. Sub-Agent Budgets carries that arithmetic in full, including why the final exceeds-the-pool comparison is unreachable while the earlier checks hold.

fixtures/proof-room/swarm-authority/valid-recursive-delegation/terminal-graph-receipt.jsonjson
{
  "schema": "chio.swarm.terminal-graph-receipt.v1",
  "receiptId": "terminal-swarm-proof-valid",
  "graphId": "swarm-graph-proof-valid",
  "chainId": "swarm-chain-proof-valid",
  "terminalTaskIds": ["task-root"],
  "completedTaskIds": ["task-root", "task-child-a", "task-child-b", "task-child-c"],
  "joinReceiptIds": ["join-child-results"],
  "routePlanReceiptIds": ["route-child-a", "route-child-b", "route-child-c"],
  "budgetPoolId": "budget-pool-swarm-valid",
  "budgetRollups": [
    {
      "dimensionId": "usd_minor",
      "reservedUnits": 0, "activeUnits": 7500, "consumedUnits": 0,
      "releasedUnits": 0, "reversedUnits": 0, "totalUnits": 7500
    }
  ],
  "revocationEpochRef": "revocation-epoch-swarm-valid",
  "resultDigest": "cd81227938a623d33e100a0198a8e844b9545075e6583d48a6b059a68d4cff39",
  "completedAtUnixMs": 1700000000000,
  "issuer": "did:chio:43046bfe4092b3e94994eada15dcc20d8aaa07b658fd3954eb8e0efb8bdca5de",
  "signature": "1416795d6d3e9a0472b54961abf09ad2c7148d11aee4776b4ac346c530106b3b..."
}

Closed means reconciled, not settled

Read the rollup above against the pool it closes: three allocations of 2,500 units, each in state active, so the reconciled total is 7,500 units still active and nothing consumed. The receipt declares a completedAtUnixMs anyway. The check is that the closing receipt agrees with the pool as the bundle presents it, not that the work finished or that any spend was realized. The two fixture chain ids also disagree (swarm-chain-swarm-graph-proof-valid on the join, swarm-chain-proof-valid on the terminal receipt) and the bundle verifies, because nothing cross-checks them.

The parent-set-mismatch negative

The join claim is the one of the two with a frozen, replayable counterexample. The descriptor names the claim it breaks, the valid receipt it mutates, and the failure code the proof room must produce:

fixtures/proof-room/swarm-authority/negatives/join-parent-set-mismatch.jsonjson
{
  "schema": "chio.swarm-authority.negative-fixture.v1",
  "id": "join-parent-set-mismatch",
  "claim_ref": "claim.swarm.join_receipt_bound",
  "base_fixture": "fixtures/proof-room/swarm-authority/valid-recursive-delegation/join-receipt.json",
  "case": "JoinParentSetMismatch",
  "expected_failure_code": "proof-room.negative.swarm-join-receipt-parent-set-mismatch"
}

The materialized fixture beside it is a two-child graph whose join receipt keeps joinPredicate: "all_success" while listing two expected parents and one actual, with parentTaskReceipts trimmed to match and parentSetHash recomputed over the shortened set so the digest check still passes. The rejection has to come from the predicate, and it does. The proof-room bundle for the recursive-runtime-swarm stage replays the case and records observed_failure_code equal to the expected one; that code is the slugified rejection text, which is why swarm join receipt parent set mismatch becomes the string above. The unit test takes the shorter road: swarm_authority_stage0_rejects_join_parent_set_mismatch pops one actual id, refreshes the hash, and re-signs.

swarm-authority · join-parent-set-mismatchtranscript
$ source scripts/proof-room-quickstart-env.sh
$ chio proof verify \
  fixtures/proof-room/swarm-authority/join-parent-set-mismatch/transaction-passport.json
error [urn:chio:error:cli:other]: proof verify: swarm join receipt parent set mismatch: join-child-results
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.
exit 1

The refusal names the join id, not the missing parent. A predicate over a set can say the set was wrong; it cannot say which member the issuer meant to include.

The terminal claim has no negative fixture. spec/registries/proof-manifest.v1.json lists manifest.swarm.terminal_graph_receipt_bound with two Rust tests, a positive fixture, and a schema, and no negative_fixture entry, while the join claim carries one. Closure is pinned on the rollup arm only, by two tests from opposite directions: swarm_authority_stage0_rejects_terminal_budget_rollup_mismatch edits the receipt, and case 4 of r_t03_recursive_swarm_conformance_rejects_generated_malformed_cases edits the live pool under an unchanged receipt. Neither is in the manifest. No test in the workspace exercises the completed-task, join, or route set equalities as a negative.

Read the bundle’s own caveat too. Both swarm claims are recorded there at proof level deterministic-verifier-report, with the note that the claim is emitted by the verifier report and the Proof Room renders it rather than independently proving it. The replayable counterexample is the part a third party checks for itself, and only the join claim has one.


A second checker, with different rules

A join receipt is also validated on the transaction-passport execution-lease path, in crates/platform/chio-transaction-passport/src/runtime_security/artifacts.rs. That path parses the same wire shape and verifies the same signature body, and then applies a different rule set for one leased child hop.

Checkchio-swarm-authorityExecution lease path
parentSetHashRecomputed and comparedValidated as a well-formed digest only
joinPredicateThree forms, each with its own conditionRequired non-empty and otherwise ignored; expected and actual must be equal element for element. That is stricter than all_success, which sorts both sides first, so this comparison is order sensitive as well as set sensitive
dagOrdinal / hlcUnixMsOrdinal must be non-zero; the timestamp may not be in the futureBoth must be non-zero; neither is compared to a clock
Issuer trustA caller-supplied pinned key sliceTrusted runtime roots carried by the passport
Terminal receiptMandatory, four comparisonsNot checked. The evidence graph has a swarm-terminal-graph-receipt node kind, and the lease claim never reads one

That path also binds the receipt to one hop rather than to a graph. Exactly one join receipt node may hang off the lease, and both zero and two reject. nextTaskId must equal the lease’s child task. The lease’s parent receipt must appear in actualParentReceiptIds and again in parentTaskReceipts, paired with the child’s declared parent task.

Neither is a superset of the other, so a receipt accepted by one is not thereby accepted by the other. Point an integration at the checker whose rules it means, and name it explicitly.


Guarantees and limits

StatusClaimEvidence
ShippedA join receipt’s parentSetHash is recomputed from its chain id and sorted actual parents rather than trusted, and its whole body is signed by a key the caller pinned.join_parent_set_hash_from_parts, verify_join_receipt_signature, ensure_join_issuer_is_pinned
ShippedJoins and their receipts correspond one to one in both directions, and the receipt’s expected-parent count must equal the count the planner signed into the graph.validate_join_receipts
ShippedA terminal receipt’s completed-task, join, and route sets must equal the bundle’s exactly, and its per-dimension rollup must equal a map the verifier builds from the live pool.validate_terminal_graph_receipt_refs, validate_terminal_budget_rollups
Proved by testOne unit of drift between a rollup and the live allocations rejects, even when the rollup’s own declared total is adjusted to stay self-consistent and the receipt is re-signed.swarm_authority_stage0_rejects_terminal_budget_rollup_mismatch
Proved by testDropping a parent from an all_success join rejects with the digest recomputed and the receipt re-signed, and the same case replays in the proof-room bundle with a matching failure code.swarm_authority_stage0_rejects_join_parent_set_mismatch; fixtures/proof-room/public-stages/recursive-runtime-swarm/proof-room-bundle/manifest.json
Not claimedDAG ordering. dagOrdinal is only rejected at zero. spec/PROTOCOL.md states the rule child.dagOrdinal > max(parent.dagOrdinal) for kernel receipt lineage, together with an HLC triple; the swarm join receipt is a different artifact carrying a flat hlcUnixMs, and no crate compares two swarm ordinals.validate_join_receipt_schema; a workspace grep for dag_ordinal outside the crate finds only non-zero checks
Not claimedThat chainId ties artifacts together. It is hashed into the join digest and otherwise only required non-empty. The shipped positive fixture’s join and terminal receipts carry different chain ids and verify.valid-recursive-delegation/join-receipt.json against terminal-graph-receipt.json
Not claimedThat resultDigest commits to a result. Both receipts carry one, both are checked for digest shape, and neither is recomputed from anything. The join and terminal receipts in the positive fixture carry the same digest value.require_sha256 at both call sites
Weaker than it looksA graph may carry more than one terminal receipt. The verifier iterates, requires unique receipt ids, and applies the same four comparisons to each. Nothing requires exactly one closing receipt, nothing distinguishes them, and the pre-dispatch chioSwarm reference has no field that pins one.The loop in validate_terminal_graph_receipts; swarm_ref_from_request
Weaker than it looksterminalTaskIds is producer-declared. It must name known tasks and nothing more; the crate does not even require it non-empty. The published schema sets minItems: 1 on it and on the other three arrays and on budgetRollups, which a joinless or routeless graph cannot satisfy. Nothing loads that schema.validate_terminal_graph_receipt_refs; spec/schemas/chio-swarm/v1/terminal-graph-receipt.schema.json
GapThe terminal claim has no negative fixture in the proof manifest, so the three set equalities have no frozen counterexample a third party can replay. Only the rollup arm is covered, by a stage-0 unit test and one arm of a conformance proptest, neither of which the manifest cites.manifest.swarm.terminal_graph_receipt_bound in spec/registries/proof-manifest.v1.json; r_t03_recursive_swarm_conformance.rs case 4
UnsupportedIssuance. mint_swarm_join_receipt refuses empty parent lists and a zero ordinal and computes the digest for you, and its only workspace call site is a test. The two signing helpers are also called by the crate’s runnable example.swarm_authority_stage0.rs, runtime_admission.rs, chio-swarm-authority/examples/agent_os.rs; the crate’s ARCHITECTURE.md puts it as no production code path calling them

Next Steps

  • Sub-Agent Budgets · the pool the closing rollup reconciles against, and the conservation law behind it
  • Continuation Tokens · the only artifact that turns a join receipt into authority for the next task
  • Task Graphs · where a join is declared, why it is not an edge, and the parent order the pairing depends on
  • Swarm Authority · the one function these checks run inside, and the order it runs them in
  • Receipts & Audit · the Kernel receipt for a single mediated call, its canonical JSON, and its checkpoints
  • Proof Room · how a negative descriptor becomes a failure code a third party can replay
Join & Terminal Receipts · Chio Docs