PlatformThe Delegation Graph
Swarm
Delegation Witnesses
Every graph edge carries a per-hop attenuation proof the verifier recomputes, so narrowing is checked rather than asserted.
The in-token half stops at one hop
attenuation_proof, its inline delegation_chain, and the chain-binding rule. That half stops at one link deliberately. validate_delegation_chain_with_trust_root rejects anything longer, and the source gives the reason: with only one scope_hash per link, a verifier cannot prove that link N’s advertised parent scope was actually the scope link N-1 handed over. The subset rule every hop below is checked against is the Kernel contract in Delegation & Attenuation.Source
This page reflects spec/schemas/chio-swarm/v1/delegation-witness-chain.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 chain per edge
This is what the swarm graph carries instead. Every edge in a signed chio.swarm.task-graph.v1 is covered by exactly one delegation witness chain, a separate artifact whose hops each ship the normalized parent and child scope text. The verifier re-derives the subset relation from that text rather than trusting a hash or a declared relation list. The chain is validated against the graph, not against any one token.
Verification is shipped. crates/kernel/chio-swarm-authority/src/verifier/witness.rs runs inside verify_swarm_authority_bundle, which three consumers call: the runtime admission hook, the proof room, and the swarm arm of chio proof verify. The proof room reaches it from two places, so there are four call sites outside tests. Two of the three consumers are wired to a key source. The runtime hook is the third, and an unpinned verifier verifies nothing: Swarm Authority states that claim and its bound. sign_swarm_delegation_witness_hop is a fixture helper; the crate’s ARCHITECTURE.md records that no production code path in the workspace calls it. Minting witnesses is the operator’s problem. Refusing bad ones is the crate’s.
The artifact
A chain names one edge and carries an ordered list of hops. The registered wire contract is spec/schemas/chio-swarm/v1/delegation-witness-chain.schema.json. The verifier does not consult it: chio-swarm-authority takes no JSON Schema dependency and deserializes into SwarmDelegationWitnessChain, which carries deny_unknown_fields at both the chain and the hop level.
{
"schema": "chio.swarm.delegation-witness-chain.v1",
"chainId": "witness-child-b",
"graphId": "swarm-graph-proof-valid",
"parentTaskId": "task-root",
"childTaskId": "task-child-b",
"hops": [
{
"parentCapabilityDigest": "20fb8e54e2ac5774...",
"childCapabilityDigest": "a024e804a25f592d...",
"parentScopeHash": "07bedb735c306ca7a683b841f1eda95730170dcd1af6367c4c3446c7754edf73",
"childScopeHash": "582bb5a477c13ed42aa0c8dcc9d49dbb7b982408c98245045f425f22b38560cf",
"attenuationRuleId": "rule-subset-tool-invocation",
"scopeSubsetProof": {
"normalizedParentScope": "{\"grants\":[{\"max_invocations\":3,\"operations\":[\"invoke\"],\"server_id\":\"commerce\",\"tool_name\":\"reserve_budget\"}]}",
"normalizedChildScope": "{\"grants\":[{\"max_invocations\":1,\"operations\":[\"invoke\"],\"server_id\":\"commerce\",\"tool_name\":\"reserve_budget\"}]}",
"subsetRelations": [
{ "grantKind": "tool", "childIndex": 0, "parentIndex": 0, "subset": true }
]
},
"expiresAtUnixMs": 1800000061000,
"issuer": "did:chio:43046bfe4092b3e9...",
"policyDigest": "d8495bc1178cab58...",
"witnessSignature": "eeca6bf0a7b7b570..."
}
]
}Ten fields per hop, each checked differently. The last column is the one that matters: several fields are bound by the signature and by continuity but never interpreted.
| Hop field | Shape | What the verifier does with it |
|---|---|---|
parentCapabilityDigest | lowercase SHA-256 hex | Must equal the previous hop’s childCapabilityDigest. Never resolved to a token. |
childCapabilityDigest | lowercase SHA-256 hex | Shape, then continuity into the next hop. |
parentScopeHash | lowercase SHA-256 hex | First hop must equal the parent graph node’s scopeHash; must equal the previous hop’s child hash; must equal SHA-256 of normalizedParentScope. |
childScopeHash | lowercase SHA-256 hex | Last hop must equal the child graph node’s scopeHash; must equal SHA-256 of normalizedChildScope. |
attenuationRuleId | non-empty string | Non-emptiness only. Signed, so it cannot be swapped, but the verifier attaches no meaning to it. |
scopeSubsetProof | AttenuationWitness | Recomputed in full. See below. |
expiresAtUnixMs | u64 | Must be strictly greater than the bundle’s nowUnixMs. |
issuer | did:chio:<64 lowercase hex>, or anything PublicKey::from_hex parses | Parsed to a public key, then required to be present in the caller-supplied trusted key slice. |
policyDigest | lowercase SHA-256 hex | Shape only. The policy it names is not fetched or evaluated here. |
witnessSignature | hex, algorithm following the issuer key | Verified over a canonical body carrying the chain identity and every field above. |
The JSON Schema is strict about the envelope and loose about the proof. It sets additionalProperties: false on the chain and the hop, pins every digest field to ^[0-9a-f]{64}$, and requires minItems: 1 on hops. Then it declares scopeSubsetProof as {"type": "object"} and nothing more. Schema validation alone tells you a chain is well formed. It does not tell you the delegation narrowed.
What the verifier checks, in order
validate_witness_chains walks the bundle’s chains, then walks the graph’s edge set. Both directions are enforced, so neither a missing chain nor a second chain for the same edge survives.
- Schema tag, non-empty
chainId, and agraphIdequal to the signed graph’s. - The
(parentTaskId, childTaskId)pair must be an edge in the graph, and both task ids must resolve to nodes. - At least one hop. The first hop’s
parentScopeHashis anchored to the parent node’s scope hash, the last hop’schildScopeHashto the child node’s. - More than one hop requires
multiHopWitnessChainson the signed graph. - One pass over the hops. Per hop: digest shapes, expiry against bundle time, the attenuation proof, and the signature against a pinned issuer. Then, against the hop before it, the previous child scope hash must equal this hop’s parent scope hash and the previous child capability digest must equal this hop’s parent capability digest.
- Chain-level: one chain per edge, and every edge covered.
crates/kernel/chio-swarm-authority/src/verifier/witness.rs:112-148at fe56570The subset proof is recomputed, not read
Each hop hands its proof to validate_attenuation_proof in chio-core-types, the same function the Kernel rung uses for a single token. The ordering is the point:
pub fn validate_attenuation_proof(
parent_hash: &ScopeHash,
child_hash: &ScopeHash,
witness: &AttenuationWitness,
) -> Result<()> {
let computed_parent_hash = sha256_hex(witness.normalized_parent_scope.as_bytes());
if &computed_parent_hash != parent_hash {
return Err(Error::AttenuationViolation {
reason: "attenuation witness parent_scope_hash mismatch".to_string(),
});
}
let computed_child_hash = sha256_hex(witness.normalized_child_scope.as_bytes());
if &computed_child_hash != child_hash {
return Err(Error::AttenuationViolation {
reason: "attenuation witness child_scope_hash mismatch".to_string(),
});
}
if witness
.subset_relations
.iter()
.any(|relation| !relation.subset)
{
return Err(Error::AttenuationViolation {
reason: "attenuation witness carries a non-subset relation".to_string(),
});
}
if let Some(marker) = witness.cumulative_approval.as_ref() {
marker.validate()?;
}
let parent_scope: ChioScope =
serde_json::from_str(&witness.normalized_parent_scope).map_err(|err| {
Error::AttenuationViolation {
reason: format!("attenuation witness parent scope is invalid: {err}"),
}
})?;
let child_scope: ChioScope =
serde_json::from_str(&witness.normalized_child_scope).map_err(|err| {
Error::AttenuationViolation {
reason: format!("attenuation witness child scope is invalid: {err}"),
}
})?;
validate_attenuation(&parent_scope, &child_scope)?;
if witness.cumulative_approval != cumulative_approval_delegation_marker(&child_scope)? {
return Err(Error::AttenuationViolation {
reason: "attenuation witness changed or omitted cumulative approval markers"
.to_string(),
});
}
Ok(())
}The hashes bind the scope text to the hop. The scope text is then parsed back into a ChioScope and run through child.is_subset_of(parent), which is a per-grant subset test over the tool, resource, and prompt grant lists and nothing else. The declared subsetRelations array is not the proof. It is rejected if any entry claims subset: false, and otherwise ignored in favour of the recomputed relation. A forged relation list buys an attacker nothing. Two of the six AttenuationWitness fields go unexamined here. restrictedPredicates is computed by compute_attenuation_witness and read by no verifier in the workspace. aggregateBudget is consulted only on the capability-token path, through AttenuationProof.normalized_subset_proof, never on a witness hop. The signature covers both, so neither can be swapped, but no check on this path depends on either.
What the proptest actually proves
swarm_authority_stage0_rejects_generated_recursive_scope_widening generates a parent scope and a strictly larger child scope, differing only in max_invocations, points both the child graph node and the hop’s childScopeHash at the widened scope, and attaches a proof computed for the permitted narrow scope. The task graph, the continuation tokens, and the chain are all re-signed, so every signature is valid. It still rejects with swarm attenuation witness invalid, because the child hash no longer matches the scope text the proof carries.Chain length is a signed field
A planner that could lengthen a chain after issuance could insert an intermediate scope of its own choosing. So the permission to have more than one hop lives inside the graph signature, not on the chain:
if chain.hops.len() > 1 && !bundle.task_graph.multi_hop_witness_chains {
return Err(rejected(format!(
"swarm multi-hop witness chain feature gate missing: {}",
chain.chain_id
)));
}multiHopWitnessChains is a field of SwarmTaskGraph, which means it is inside the body the graph issuer signed. Flipping it invalidates the graph. The paired tests are swarm_authority_stage0_rejects_multi_hop_without_feature_gate and swarm_authority_stage0_accepts_multi_hop_with_feature_gate, the second asserting a witnessHopCount of 2 in the emitted hop report. A second check sits upstream in chio-transaction-passport: a graph that enables multi-hop while declaring maxDepth < 2 fails its runtime-security claim with task graph multi-hop setting requires depth.
The gate is binary, not a bound
multiHopWitnessChains is true, nothing caps hop count. The only comparison the verifier makes on chain.hops.len() is > 1, and maxDepth bounds graph node depth, not hops inside a chain. A graph issuer who sets the flag has authorized chains of arbitrary length on every edge of that graph, each interior scope chosen by whoever mints the hops.Issuer pinning and the signed body
The hop signature covers its own schema tag, chio.swarm.delegation-witness-hop-signature.v1, which is distinct from the chain’s schema tag, plus the chain’s graphId, chainId, and both task ids, plus the nine hop fields other than the signature itself. A hop signed for one edge cannot be replayed onto another. The signature does not cover the hop’s index within the chain; ordering is constrained by the continuity equalities and the two end anchors rather than by the signature.
An issuer is a key, not a name. A did:chio: issuer must be exactly 64 lowercase hex characters, rejected as “not self-certifying” otherwise, and the hex is an Ed25519 public key. There is no DID document, no resolution step, and no registry lookup. Anything without that prefix goes straight to PublicKey::from_hex, which also accepts a 0x prefix and the p256:, p384:, and hybrid: forms, so a witness issuer is not necessarily Ed25519 and the lowercase constraint applies only to the did:chio: form. The parsed key must then appear in the trusted_witness_issuer_keys slice the caller passed. An empty slice rejects the bundle before any artifact is read, with rejection text naming the environment variable callers use to populate it, CHIO_SWARM_TRUSTED_WITNESS_KEYS. The crate itself reads no environment.
That slice is not witness-specific. The same trusted_witness_issuer_keys argument pins the task-graph issuer, every continuation token, every route-plan and join receipt, the revocation epoch, the terminal receipts, and the witness hops, and membership is the only test at any of those sites. A key pinned so a partner can witness one delegation is equally a key that can sign the task graph that partner runs under.
Binding into the rest of the bundle
Continuation tokens tie a verified chain to the child task that ran under it. A token with a parentTaskId must carry both witnessChainRef and witnessChainSha256; a token without one must carry neither. The referenced chain must agree on both task ids, and its canonical SHA-256 must equal the digest the token committed to. A bundle carrying no witness chains at all does not slip through this: require_signed_swarm_delegation_evidence rejects any bundle with an empty witnessChains or continuationTokens list.
The revocation epoch is checked against every witness-hop issuer, and a hit rejects the whole bundle, not the one chain. The comparison is string equality between the raw issuer field and the epoch’s revokedSubjects list, not equality of parsed keys, so an entry written as did:chio:<hex> does not match the same key presented as bare hex. Publish revocations in the exact form your issuers use.
Rejections
Validation failures are SwarmAuthorityError::Rejected and return immediately; no partial report is produced. The one other variant is SwarmAuthorityError::Canonical, raised when canonical JSON serialization fails while building a signature body. Most messages end in the chain id, shown here as {chainId}.
| Message | Trigger |
|---|---|
missing swarm delegation witness chain: a -> b | A graph edge no chain covers. |
duplicate swarm delegation witness chain: a -> b | Two chains, different ids, same edge. |
swarm witness chain edge mismatch: {chainId} | A chain naming a pair that is not an edge. |
swarm witness parent scope mismatch: {chainId} | First hop’s parent scope hash is not the parent node’s. |
swarm witness child scope mismatch: {chainId} | Last hop’s child scope hash is not the child node’s. |
swarm multi-hop witness chain feature gate missing: {chainId} | More than one hop without multiHopWitnessChains. |
swarm witness hop scope discontinuity: {chainId} | A gap between one hop’s child scope and the next hop’s parent scope. |
swarm witness hop capability discontinuity: {chainId} | The same gap in capability digests. |
swarm witness hop is stale: {chainId} | expiresAtUnixMs at or before bundle time. |
swarm attenuation witness invalid: {error} | Hash mismatch, unparseable scope, a declared non-subset relation, a changed cumulative-approval marker, or a child scope that is not a subset. |
swarm witness issuer is not trusted: {chainId} | A parsed key outside the pinned slice. |
swarm witness signature invalid: {chainId} | Unparseable or non-verifying witnessSignature. |
trusted swarm witness keys missing: CHIO_SWARM_TRUSTED_WITNESS_KEYS must pin trusted swarm witness keys | An empty trusted key slice, before any artifact is read. Reached from an embedder that passes an empty vector; the CLI refuses an absent or blank variable with its own error first. |
One of these is a shipped negative fixture. fixtures/proof-room/swarm-authority/negatives/witness-child-scope-mismatch.json mutates the valid witness-child-a chain, names claim.swarm.attenuation_witness_chain_bound as the claim it breaks, and pins proof-room.negative.swarm-witness-child-scope-mismatch as the failure code the proof room must produce. The generated bundle sets the hop’s child scope hash to 64 lowercase a characters. That is a well-formed digest of nothing, and the last-hop anchor is compared before any per-hop check runs, so the chain never reaches the shape test, the attenuation proof, or the signature.
$ source scripts/proof-room-quickstart-env.sh
$ chio proof verify \
fixtures/proof-room/swarm-authority/witness-child-scope-mismatch/transaction-passport.jsonerror [urn:chio:error:cli:other]: proof verify: swarm witness child scope mismatch: witness-child-a
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.Unset the key variable and the same bundle stops earlier, with a different error from a different crate. The last row of the table is the verifier’s, raised by require_trusted_witness_issuer_keys on an empty slice and carrying a trusted swarm witness keys missing: prefix. What the CLI prints instead is its own, raised by swarm_trusted_witness_keys_from_env on the environment variable being absent:
$ env -u CHIO_SWARM_TRUSTED_WITNESS_KEYS chio proof verify \
fixtures/proof-room/swarm-authority/witness-child-scope-mismatch/transaction-passport.jsonerror [urn:chio:error:cli:other]: CHIO_SWARM_TRUSTED_WITNESS_KEYS must pin trusted swarm witness keys
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.The CLI does not raise it before it reads an artifact. It loads the whole bundle off disk on the line above the one that reads the variable. It also never hands the verifier an empty slice: an absent variable, a blank one, and one holding an empty entry are each their own CLI error, so the verifier’s own refusal is reachable from an embedder that passes an empty vector rather than from chio proof verify.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | Narrowing is recomputed from the scope text carried on the hop, not inferred from hashes or from the declared relation list. | validate_attenuation_proof in capability/attenuation.rs, called from validate_witness_hop |
| Shipped | A chain cannot go from one hop to many without the graph issuer’s signature over the permission. | multi_hop_witness_chains on SwarmTaskGraph, inside the signed body; the gate in validate_witness_chain |
| Shipped | Edge coverage is exact. A missing chain and a duplicate chain both reject. | validate_witness_chains, the witnessed_edges set and the edge-set loop after it |
| Tested | Swapping a legal narrowing proof onto a widened child is rejected under generated inputs. | swarm_authority_stage0_rejects_generated_recursive_scope_widening, 32 proptest cases |
| Tested | Tampered signatures, disconnected hops, and ungated multi-hop chains each have a named rejection test. | crates/kernel/chio-swarm-authority/tests/swarm_authority_stage0.rs |
| Limit | Capability digests are opaque. Continuity between hops is checked; that a digest is the digest of a real capability token holding the stated scope is not. | validate_witness_hop applies only require_sha256 to both digest fields |
| Limit | attenuationRuleId and policyDigest are labels. Covered by the hop signature, so a third party cannot swap them, but never resolved or evaluated on this path. | require_non_empty and require_sha256 respectively, and nothing else |
| Limit | Expiry is only as good as the caller’s clock. The crate reads no clock; freshness is judged against bundle.now_unix_ms. | hop.expires_at_unix_ms <= bundle.now_unix_ms; the bundle field is caller-supplied |
| Limit | Hop count is unbounded once the gate is on. The only comparison the verifier makes on chain.hops.len() is > 1; the crate’s other length comparisons are on the edge set, on join-receipt parent counts, and on 64-character hex shapes. | validate_witness_chain; maxDepth bounds node depth, not hops |
| Limit | One trust slice covers every artifact class. A pinned witness key can equally sign the task graph, continuation tokens, receipts, and the revocation epoch. | The same trusted_witness_issuer_keys argument threads through every ensure_*_is_pinned in verifier.rs |
| Limit | Revocation matches the issuer string, not the key. The same key in a different accepted encoding is not caught. | revoked_subjects.contains(hop.issuer.as_str()) in validate_revocation_epoch |
| Limit | The Proof Room renders the witness-chain claim; it does not independently prove it. | The claim caveat in fixtures/proof-room/public-stages/recursive-runtime-swarm/proof-room-bundle/manifest.json |
| Not configured | The runtime admission call site exists but nothing in the workspace hands it keys. Two functions share the name with_swarm_witness_keys and they are not the same one. chio-runtime’s facade builder holds a vector that starts empty, and no non-test caller ever fills it; core_hook() then forwards that empty vector into chio-runtime-core’s same-named method on every hook call, which is production code. Embedders wire it themselves; the proof room and the CLI read the environment variable. | chio-runtime/src/lib.rs:151,188,226; chio-runtime-core/src/admission_hook.rs:240; swarm_trusted_witness_keys_from_env in chio-proof-room and chio-cli |
This checks structure, never conduct
Next Steps
- Swarm Authority · the one function these chains are verified inside, and the eight other artifacts checked with them
- Task Graphs · the edge set every chain is matched against, one chain per edge
- Capabilities · the Kernel half:
AttenuationProof,DelegationLink, and why the in-token chain stops at one hop - Swarm Overview · the rung, the nine
chio.swarm.*schemas, and the other seven artifact classes a bundle carries - 3-Vendor Walkthrough · one delegation carried end to end, witnesses included
- Proof Room · where the negative fixture above is replayed against a published bundle