Task graphs
A plan signed before any child runs bounds how deep delegation recurses and how wide each task fans out.
On this page
Before any child agent starts, a signed task graph fixes how deep delegation may recurse and how many tasks each parent may hand off.
Each task carries an identity, a delegation depth, and a scope hash. The running example is the signed fixture chio proof fixture generate recursive-runtime-swarm writes: one root, three children, and a join that returns their results to the root, the shape of the fixer splitting #417 across three helpers.
The graph fixes depth and fan-out before any child runs
Delegation edges carry authority outward. Joins carry results back. The verifier checks the two relationships separately.
a1task-child-aa2task-child-ba3task-child-c
One signed graph bounds how far work can branch
task-root delegates to three depth-1 children. The graph signs maxDepth 2 and maxFanout 3, so every node stays at or below depth 2, each edge advances one level, and no parent has more than three children. The results meet at join-child-results, whose next task is task-root; a join adds no delegation edge. Task Graphs.
task-root delegates to three depth-1 children. The graph signs maxDepth 2 and maxFanout 3, so every node stays at or below depth 2, each edge advances one level, and no parent has more than three children; the fixture itself uses one delegation level. The results meet at join-child-results, whose next task is task-root, without adding a delegation edge.
Read more in Swarm authority, which verifies this graph together with every record it names.
An application sets the ceilings on the grant it hands a coordinator
A coordinator receives its ceilings with its grant. Here the fixer gets room to split issue #417 two levels deep and three wide, on a fork of the pricing project, with a USD 2 pool every helper draws from.
import { Computer, USD } from "@chio-protocol/sdk";
const project = await Computer.open("quickstart");
const fixer = await project.agent("fixer");
const fork = await project.fork();
const authority = fork.grant({
to: fixer,
resources: { "/workspace": ["read", "write"] },
delegation: { depth: 2, fanout: 3 },
budget: USD(2),
expiresIn: "20m",
});
console.log(authority.describe());grant cap-01b3…6d20, signed by your kernel to fixer, delegating up to 2 deep and 3 wide on quickstart, fork 51c07ae allows /workspace read, write budget USD 2.00 pool shared by all of its agents, charged at your door expires in 20 minutes
from chio import Computer, Delegation, USD
project = Computer.open("quickstart")
fixer = project.agent("fixer")
fork = project.fork()
authority = fork.grant(
to=fixer,
resources={"/workspace": ["read", "write"]},
delegation=Delegation(depth=2, fanout=3),
budget=USD(2),
expires_in="20m",
)
print(authority.describe())grant cap-01b3…6d20, signed by your kernel to fixer, delegating up to 2 deep and 3 wide on quickstart, fork 51c07ae allows /workspace read, write budget USD 2.00 pool shared by all of its agents, charged at your door expires in 20 minutes
use chio::{Computer, Delegation, Grant, USD};
let project = Computer::open("quickstart").await?;
let fixer = project.agent("fixer").await?;
let fork = project.fork().await?;
let authority = fork.grant(
Grant::to(&fixer)
.resource("/workspace", ["read", "write"])
.delegation(Delegation::depth(2).fanout(3))
.budget(USD(2))
.expires_in("20m"),
)?;
println!("{}", authority.describe());grant cap-01b3…6d20, signed by your kernel to fixer, delegating up to 2 deep and 3 wide on quickstart, fork 51c07ae allows /workspace read, write budget USD 2.00 pool shared by all of its agents, charged at your door expires in 20 minutes
The signed graph carries the same ceilings as maxDepth and maxFanout. It holds one depth-0 root, uniquely identified tasks, directed delegation edges, and separately declared joins. A parented task references its continuation token, which agrees with the task’s route and budget allocation, and scope hashes let a witness bind parent and child at each edge.
A malformed graph stops at the first structural rule
verify_swarm_authority_bundle refuses an empty trusted key set, then runs validate_task_graph, then verify_task_graph_signature, then require_signed_swarm_delegation_evidence. Inside the first, once the header fields are checked, the structural rules run in a fixed order.
| Rule | Checked by | Rejection |
|---|---|---|
| Task ids unique, scope hashes well formed | task_index | duplicate swarm task id |
| Exactly one root; depth 0 means no parent | validate_roots | swarm task graph requires exactly one root task, swarm root-depth task has parent, swarm non-root task missing parent |
| Edges resolve, no duplicates, every lineage edge present | validate_edges | swarm task parent edge missing: A -> B |
| Joins well formed | validate_joins | swarm join requires at least two parents |
| Route-plan refs unique and non-empty | validate_route_refs | duplicate swarm route plan ref: {value}, swarm route plan ref must not be empty |
| Acyclic over the edge set | validate_acyclic | swarm task graph cycle at <task> |
| Every edge target at parent depth plus one | validate_edge_depths | swarm task depth mismatch: A -> B, swarm task depth overflow |
| Both ceilings | validate_graph_limits | swarm task exceeds max depth, swarm task exceeds max fanout |
Structure comes before the signature, so a graph whose ceiling was edited without being re-signed is refused by the structural check. Changing a signed ceiling takes a fresh signature from a trusted issuer.
The structural sequence in Rust
let task_by_id = task_index(graph)?;
let edge_set = edge_set(&graph.edges);
validate_roots(graph)?;
validate_edges(graph, &task_by_id, &edge_set)?;
validate_joins(graph, &task_by_id)?;
validate_route_refs(graph)?;
validate_acyclic(graph, &task_by_id)?;
validate_edge_depths(graph, &task_by_id)?;
validate_graph_limits(graph)?;Each delegation edge advances exactly one level
The declared depth is a claim the edge set has to corroborate. The verifier compares it with the parent’s depth plus one.
let expected_child_depth = parent
.depth
.checked_add(1)
.ok_or_else(|| rejected("swarm task depth overflow"))?;
if child.depth != expected_child_depth {
return Err(rejected(format!(
"swarm task depth mismatch: {} -> {}",
edge.from_task_id, edge.to_task_id
)));
}Overflow is a rejection. Depth is a u32, so a wrap needs a parent declared at u32::MAX, and the depth-0 child it would produce is one validate_roots already refuses; the arithmetic refuses on its own as well. swarm_authority_stage0_rejects_edge_depth_bypass lowers max_depth to 1, reparents the second child under the first at depth 1, rebuilds the witness chain, and refreshes the continuation digests. It is refused with swarm task depth mismatch: understating depth fails on the arithmetic before it reaches the ceiling.
A planner cannot widen a ceiling it already signed
validate_graph_limits refuses any node whose depth exceeds max_depth, then counts edges per from_task_id and refuses the first source over max_fanout. The counter keys on the edge source, so the ceiling bounds outgoing edges: a dependency edge draws on the same allowance as a lineage edge.
The signature body, built by task_graph_signature_body, is the canonical JSON of the whole graph with the signature field removed, and both ceilings sit inside it. Each continuation token also carries graphSha256, which the verifier recomputes. swarm_authority_stage0_rejects_task_graph_tampering_after_continuations_are_resealed adds one to max_fanout and re-signs every continuation against the new digest; the bundle is still refused with swarm task graph signature invalid.
A ceiling is a commitment by whoever signed the graph. Any holder of a trusted key can sign a different graph with different ceilings, so the trusted set is the caller’s decision, upstream of everything here; Swarm authority states what an empty set costs. Together, one root, the parent-edge rule, and depth plus one make the lineage a tree, so a full tree at both ceilings grows exponentially in maxDepth. Swarm Scarcity carries the closed form, and the budget pool bounds what the graph may spend.
Joins gather results, and every other record hangs off the graph
Joins are declared in the graph and stay out of the edge set, so they take no part in depth arithmetic, fan-out accounting, the acyclicity walk, or witness coverage. validate_joins checks that join ids and parents are unique, parents and the next task resolve, the next task is not one of its own parents, and there are at least two parents. In the fixture the join names task-root, the depth-0 node, as its next task: the ceilings bound the delegation tree, and the join adds no edge they count.
- Continuation token. Same
graphId, recomputedgraphSha256, and a child task in the graph. A parented node without acontinuationTokenRef, or a token naming a different child, is refused. - Witness chain. Its
(parentTaskId, childTaskId)pair is an edge, and each edge gets exactly one chain. - Route-plan receipt.
routePlanRefscovers the receipts both ways, the node’s ref equals the token’s, and the receipt’staskIdis the token’s child. - Budget allocation. Every allocation’s
taskIdis a node, the node’s ref equals the token’s, each unit rollup sums tomaxUnits, and themaxUnitsvalues sum within the pool total. The allocation a token points at isActive. - Revocation epoch. No revoked task id names a node, and the graph issuer, the planner, and every witness-hop issuer are absent from the revoked subjects.
- Terminal graph receipt.
completedTaskIdsequals the node set exactly, and the join and route id sets match the bundle.
In the fixture, task-child-a names continuation-child-a, route-child-a, and budget-child-a, witness-child-a covers its edge, and the graph names budget-pool-swarm-valid and revocation-epoch-swarm-valid. The terminal receipt lists all four tasks, the one join, and the three route plans, with a rollup of 7,500 active usd_minor units under the 10,000-unit pool. Join and terminal receipts are signed declarations checked against the graph; the verifier schedules and executes no task.
The prepared negatives refuse a lowered ceiling and a cycle
The max-depth-exceeded negative is the valid four-task graph with maxDepth lowered from 2 to 0, carrying the valid graph’s signature byte for byte. A coordinator that tried to widen its own ceiling after signing would produce exactly this file. Read the field from the graph the generator wrote:
jq '.maxDepth' ./swarm/negatives/max-depth-exceeded/task-graph.json0
Verify it beside the graph-cycle negative. Both must be refused:
$ chio proof verify \
./swarm/negatives/max-depth-exceeded \
./swarm/negatives/graph-cycle \
--trust ./swarm/trust.toml --expect refuseREFUSED max-depth-exceeded swarm task exceeds max depth: task-child-a REFUSED graph-cycle swarm task graph cycle at task-child-a 2 of 2 refused, as expected trust ./swarm/trust.toml (fixture keys)
You should see both refusals name the node, and the summary line confirm that each input was refused as expected. Each code is a slug of the verifier’s message: the collected bundle for the recursive-runtime-swarm stage records the same string under observed_failure_code, and the cycle case is recorded as proof-room.negative.swarm-task-graph-cycle-at-task-child-a. In the worked example below, you sign a branching graph and change its fan-out.
Worked Rust example: Sign a branching graph and change its limits
Sign a branching graph and change its limits
The constructor defines a root, two depth-one children and a join. Change the fan-out argument, sign the graph, and refresh each continuation's graph digest before verification.
Use the Rust example setup, or download the complete source project. The files below belong to that project; shared constructors and configuration are included in the download.
Run it
cargo run --locked --bin task-graph -- 2Implementation
use chio_swarm_authority::verify_swarm_authority_bundle;
use swarm_authority_examples::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let fanout: u32 = std::env::args()
.nth(1)
.unwrap_or_else(|| "2".into())
.parse()?;
let mut bundle = build_bundle()?;
bundle.task_graph.max_fanout = fanout;
// Re-sign the graph, then rebind and sign the continuations to its new digest.
refresh_continuation_graph_digests(&mut bundle)?;
let report = verify_swarm_authority_bundle(&bundle, &trusted_witness_keys())?;
println!(
"graph: {} tasks; max fanout {fanout}; {} join",
report.task_count, report.join_count
);
bundle.task_graph.max_depth = 0;
refresh_continuation_graph_digests(&mut bundle)?;
let error = verify_swarm_authority_bundle(&bundle, &trusted_witness_keys())
.expect_err("depth-1 children must refuse at depth 0");
println!("depth mutation: {error}");
Ok(())
}let mut task_graph = SwarmTaskGraph {
schema: CHIO_SWARM_TASK_GRAPH_SCHEMA.to_string(),
graph_id: "analysis-graph".to_string(),
root_transaction_ref: "passport-analysis".to_string(),
planner_subject: "did:chio:planner".to_string(),
issuer: witness_issuer(),
signature: String::new(),
created_at_unix_ms: now_ms() - 1_000,
expires_at_unix_ms: now_ms() + 60_000,
max_depth: 2,
max_fanout: 2,
multi_hop_witness_chains: false,
nodes: vec![
SwarmGraphNode {
task_id: "task-root".to_string(),
parent_task_id: None,
route_plan_ref: None,
continuation_token_ref: None,
budget_allocation_ref: None,
scope_hash: parent_scope_hash.clone(),
depth: 0,
},
SwarmGraphNode {
task_id: "task-child-a".to_string(),
parent_task_id: Some("task-root".to_string()),
route_plan_ref: Some("route-child-a".to_string()),
continuation_token_ref: Some("continuation-child-a".to_string()),
budget_allocation_ref: Some("budget-child-a".to_string()),
scope_hash: child_scope_hash.clone(),
depth: 1,
},
SwarmGraphNode {
task_id: "task-child-b".to_string(),
parent_task_id: Some("task-root".to_string()),
route_plan_ref: Some("route-child-b".to_string()),
continuation_token_ref: Some("continuation-child-b".to_string()),
budget_allocation_ref: Some("budget-child-b".to_string()),
scope_hash: child_scope_hash.clone(),
depth: 1,
},
],
edges: vec![
SwarmGraphEdge {
from_task_id: "task-root".to_string(),
to_task_id: "task-child-a".to_string(),
edge_type: "delegates".to_string(),
},
SwarmGraphEdge {
from_task_id: "task-root".to_string(),
to_task_id: "task-child-b".to_string(),
edge_type: "delegates".to_string(),
},
],
joins: vec![SwarmGraphJoin {
join_id: "join-child-results".to_string(),
parent_task_ids: vec!["task-child-a".to_string(), "task-child-b".to_string()],
next_task_id: "task-root".to_string(),
}],
budget_pool_ref: "budget-pool-analysis".to_string(),
revocation_epoch_ref: "revocation-epoch-analysis".to_string(),
route_plan_refs: vec!["route-child-a".to_string(), "route-child-b".to_string()],
};
sign_task_graph(&mut task_graph)?;
let graph_sha256 = canonical_hash(&task_graph)?;Captured result and refusal
graph: 3 tasks; max fanout 2; 1 join
depth mutation: swarm task exceeds max depth: task-child-aRun with fan-out 1 to reject two outgoing edges. The built-in depth mutation is re-signed so it exercises graph validation rather than an obsolete signature.
Exit 0 indicates a successful example run. Expected refusals shown in the output are checked by the program; invalid command inputs exit nonzero. Keys, digests, timestamps and run-directory names vary.
Task graph fields and rules
Search for maxDepth, plannerSubject, scope_hash, or lease.
Field names are the wire spellings. The Rust struct uses snake case and deserializes with deny_unknown_fields, so an unrecognized key is a parse failure.
schema
Equals chio.swarm.task-graph.v1. Any other value is refused before a second field is read.
Example"chio.swarm.task-graph.v1"
graphId
Every bundle artifact with a graphId of its own matches it, checked by require_same_graph: continuation tokens, witness chains, route-plan receipts, join receipts, the budget pool, and terminal receipts. The revocation epoch carries no graphId and is bound by id.
rootTransactionRef
Names the transaction the graph descends from.
plannerSubject
Checked against the revocation epoch. A revoked planner refuses the whole bundle, as does a revoked graph issuer or a revoked witness-hop issuer.
issuer / signature
The issuer resolves to a chio_core_types::crypto::PublicKey. A did:chio: prefix is self-certifying: exactly 64 lowercase hex characters, which forces Ed25519. A bare issuer goes to PublicKey::from_hex, which also accepts the self-describing p256:, p384:, and hybrid: forms. The resolved key appears in the caller-supplied trusted set.
createdAtUnixMs / expiresAtUnixMs
Compared against now_unix_ms. A graph from the future and an expired graph are both refused.
maxDepth
Ceiling on node depth. The schema (minimum: 0) and validate_task_graph both admit 0 for a root-only graph, and require_signed_swarm_delegation_evidence refuses any bundle without continuation tokens and witness chains. Every chain names an edge and every edge puts its target at parent depth plus one, so maxDepth is floored at 1 in any verified bundle. The root-only bundle fails with signed swarm delegation evidence missing, pinned by swarm_authority_stage0_rejects_root_only_bundle_without_signed_swarm_evidence.
Example2
maxFanout
Ceiling on outgoing edges per task. Zero is refused by the verifier and excluded by the schema (minimum: 1).
Example3
multiHopWitnessChains
Feature gate. A witness chain carrying more than one hop is refused unless the graph sets it.
nodes / edges / joins
The structure. validate_task_graph requires at least one node; the bundle verifier requires at least two nodes and one edge.
budgetPoolRef / revocationEpochRef
Equal to the bundle's pool id and epoch id exactly.
routePlanRefs
Every ref has a receipt in the bundle, and every receipt in the bundle is declared here.
Node, edge, and join types
pub struct SwarmGraphNode {
pub task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_plan_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continuation_token_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget_allocation_ref: Option<String>,
pub scope_hash: String,
pub depth: u32,
}pub struct SwarmGraphEdge {
pub from_task_id: String,
pub to_task_id: String,
pub edge_type: String,
}pub struct SwarmGraphJoin {
pub join_id: String,
pub parent_task_ids: Vec<String>,
pub next_task_id: String,
}task_id is unique across the graph, and scope_hash is a lowercase 64-character SHA-256 digest. The witness chain for an edge lands on it at both ends: the first hop declares the parent node’s hash, the last hop the child node’s. A node with a parent_task_id also carries a continuation_token_ref, and its route and budget refs agree with the ones inside that token. edge_type is non-empty and otherwise uninterpreted; every fixture uses delegates.
Schema and verifier
The verifier is the enforcement path, and spec/schemas/chio-swarm/v1/task-graph.schema.json describes the envelope. The schema allows a join with minItems: 1 parents and the verifier requires two, asserted by swarm_authority_stage0_rejects_single_parent_join. The schema expresses none of the single-root, depth-plus-one, or ceiling rules. spec/PROTOCOL.md section 6.4.2 states the two ceilings, the depth-plus-one rule, and overflow as a rejection; that text is the contract a third-party implementation is held to.
Each ceiling is local: maxDepth bounds depth per node and maxFanout bounds outgoing edges per edge source, and no field states a total. The acyclicity check, visit_task, is a recursive walk that runs before the ceilings in validate_task_graph, so its recursion follows the submitted node count.
Lease-path graph check
The passport path runs a narrower check in validate_execution_lease_context (crates/platform/chio-transaction-passport/src/runtime_security/artifacts.rs). It validates the graph for one leased child hop: schema tag, expiry ordering, positive fan-out, a zero maxDepth against child depths, multi-hop witness chains requiring maxDepth of at least 2, the graph digest bound to the lease, the graph signature against a trusted runtime root, and the leased child’s own depth against maxDepth. Per-edge depth arithmetic, the fan-out count, the one-root rule, and the acyclicity walk belong to the bundle verifier.
Tests and fixtures
swarm_authority_stage0_rejects_graph_cycle: a back edge from a child to the root is refused as a cycle, frozen as thegraph-cyclenegative.crates/tooling/chio-conformance/tests/r_t03_recursive_swarm_conformance.rs: the signed positive fixture verifies, and a proptest draws 48 random cases over a six-mutation space, each asserting the expected rejection message.sign_swarm_task_graphsigns a graph; the crate’sexamples/agent_os.rsbinary builds and signs a demonstration bundle.chio proof fixture generate recursive-runtime-swarm(generate_recursive_runtime_swarm_fixture) writes the signed fixture built into the binary, and--with-negativesadds its eleven negatives.
Sources and wire contract
The wire schema is spec/schemas/chio-swarm/v1/task-graph.schema.json and the verification order is spec/PROTOCOL.md section 6.4.2 in the public Chio repository. The fixture graph is ./swarm/proof-room-bundle/task-graph.json after chio proof fixture generate recursive-runtime-swarm --out ./swarm. The Swarm Protocol reference carries every artifact in the bundle.