Chio/Docs
LOGIN · JOIN

Delegation Witnesses

At every hand-off you can check that a child task received a narrower scope than its parent, under a key you trust.

Concept chio@5f63e6e

On this page

Each hand-off in a delegation carries its own signed proof that the child’s scope is a subset of the parent’s, so a receiving operator can check the derivation one hop at a time.

A witness chain is one record in the swarm authority bundle. It covers the derivation of scope; routes, pooled budgets, revocation, and continuation admission have their own checks in the same bundle.

Every delegation edge carries exactly one witness chain

A chain names the graph, the parent task, and the child task, then carries one or more signed hops. Each hop holds the parent and child scope text, their hashes, capability digests, an expiry, and an issuer.

Signed task graphChoose a view.
depth 0depth 13 of maxFanout 3maxDepth 2joinpool · 10,000 usd_minornext task: task-rootσ1σ2A · 3B · 2C · 1commerce/reserve_budgetκtask-roota1task-child-a2,500a2task-child-b2,500a3task-child-c2,500ρjoin-child-resultsmaxDepth 2 · maxFanout 3pool 10,000σ1σ23 → 22 → 1κtask-roota12,500a22,500a32,500ρjoin-child-resultsnext task: task-root

a1task-child-aa2task-child-ba3task-child-c

A chain hands authority across an interior scope

In the two-hop example, commerce/reserve_budget narrows from three invocations at scope A to two at the interior scope B, then one at C. The second hop starts from the scope hash and capability digest D that the first hop handed over, and each hop signs both scopes with the graph and chain identity. Delegation Witnesses.

The signed task graph and budget pool from the valid-recursive-delegation fixture, with widths drawn to the pool’s units; the overdrawn pool is the budget-allocations-exceed-pool fixture and the two-hop chain is the verifier’s source test. Route plans, continuations, and the revocation epoch appear by name only.

The verifier anchors the first parent scope to the parent graph node and the last child scope to the child node. Between them, each hop starts from the scope hash and capability digest the previous hop handed over. In the two-hop test example, commerce/reserve_budget narrows from three invocations at scope A to two at the interior scope B, then one at C. A and C are graph-node anchors; B is an interior scope with the same capability digest D on both sides, and it adds no third node to the graph.

Each hop’s signature covers its complete signed body, including the chain and graph identity. The in-token delegation chain is a separate contract with a one-link limit; both paths apply the same scope-subset rules.

One hop narrows three invocations to one, and the verifier recomputes the subset

The fixture witness-child-b.json is a single hop from task-root to task-child-b. Both scopes name server commerce, tool reserve_budget, and operation invoke; max_invocations drops from 3 to 1. The chain is anchored at the root’s scope hash 07bedb… and the child’s 582bb5….

The hop carries both normalized JSON strings in scopeSubsetProof, with a declared relation that child tool grant 0 is a subset of parent tool grant 0. validate_attenuation_proof rebuilds that relation from the text:

  1. Hash. It hashes the exact supplied parent and child strings and compares them with the hop’s scope hashes.
  2. Declared relations. It refuses any declared relation whose subset is false.
  3. Marker. It validates any cumulative-approval marker.
  4. Subset. It parses both scopes and checks child.is_subset_of(parent), then checks that the marker agrees with the child scope.

Each child tool, resource, or prompt grant is covered by a parent grant. For a tool grant, coverage spans the target, operations, invocation and monetary caps, required constraints, and the DPoP requirement. Coverage is per grant; Swarm Budgets debits the shared pool across sibling tasks.

Read more in Delegation & Attenuation, which defines the underlying subset rules.

Graph depth and witness length are separate limits

multiHopWitnessChains belongs to the signed task graph. A chain with more than one hop is refused when that field is false, and changing it takes a new valid graph signature.

Once enabled, the permission sets no maximum hop count. The graph’s maxDepth limits task-node depth, and interior witness scopes on one edge sit outside that count. The transaction-passport claim path also requires maxDepth of at least two when multi-hop is enabled, and that check leaves chain length open as well.

The trusted key set decides who may sign a hop

The hop’s canonical signature body uses chio.swarm.delegation-witness-hop-signature.v1. It includes graphId, chainId, both task ids, and all nine hop fields other than the signature, which binds the hop to its named edge and chain. The hop’s index and the chain length are unsigned; scope and capability continuity fix the order.

An issuer of the form did:chio:<64 lowercase hex characters> identifies an Ed25519 public key directly, with no DID-document resolution. PublicKey::from_hex parses the other accepted encodings, including the p256, p384, and hybrid forms. The parsed key must be in trusted_witness_issuer_keys, the same set that pins the graph, route and join receipts, revocation epoch, terminal receipts, and continuation tokens; a key pinned as a witness can sign any of those classes.

Revocation compares the raw issuer string. A did:chio issuer and the same public key as bare hex are different strings, so publish revocations in the exact representation the issuer uses. Continuation Tokens explains the remaining admission bindings.

The prepared fixture stops at the child anchor, and a missing key list stops sooner

Both runs read the negatives that chio proof fixture generate recursive-runtime-swarm --with-negatives --out ./swarm writes. The first passes the fixture’s trust file and reaches the child-scope anchor check.

Child scope anchor mismatchtranscript
$ chio proof verify ./swarm/negatives/witness-child-scope-mismatch \
  --trust ./swarm/trust.toml
REFUSED   witness-child-scope-mismatch  swarm witness child scope mismatch: witness-child-a
trust     ./swarm/trust.toml (fixture keys)
exit 1

The second runs with no key pinned for swarm.witness. The CLI loads the bundle, then stops before any check with trust:role-unpinned, a different refusal from the library’s empty-slice error, trusted swarm witness keys missing.

Trusted witness keys absenttranscript
$ chio proof verify ./swarm/negatives/witness-child-scope-mismatch
error[CHIO-TRUST-ROLE-UNPINNED]: no key is pinned for swarm.witness, so the witness chain cannot be checked
  help: pass --trust ./swarm/trust.toml, or pin the witness key with chio trust pin swarm.witness <KEY>
  code: urn:chio:error:trust:role-unpinned
  docs: https://chio.computer/docs/reference/errors#error-trust-role-unpinned
exit 2

You should see swarm witness child scope mismatch: witness-child-a with exit status 1 on the first: a result about the bundle. The second exits 2, an error about your configuration, and its help line names the fix. The worked example issues a narrowing witness from Rust.

Worked Rust example: Carry a signed narrowing witness

Carry a signed narrowing witness

Give a child one invocation from a parent scope allowing three. The complete constructor binds the witness to the graph edge, signs its hop, and binds the chain digest into the continuation.

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

bash
cargo run --locked --bin witness

Implementation

witness.rsrust
use chio_core_types::capability::attenuation::compute_attenuation_witness;
use chio_swarm_authority::verify_swarm_authority_bundle;
use swarm_authority_examples::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let parent = scope_for("commerce", "reserve_budget", 3);
    let child = scope_for("commerce", "reserve_budget", 1);
    let proof = compute_attenuation_witness(&parent, &child)?;
    println!("narrowing witness: {}", serde_json::to_string(&proof)?);
    let bundle = build_bundle()?;
    let report = verify_swarm_authority_bundle(&bundle, &trusted_witness_keys())?;
    assert!(report
        .hop_reports
        .iter()
        .all(|hop| hop.attenuation_verified));
    println!("signed hops verified: {}", report.hop_reports.len());
    let wider = scope_for("commerce", "reserve_budget", 4);
    println!(
        "widening: {}",
        compute_attenuation_witness(&parent, &wider).expect_err("child cannot increase quota")
    );
    Ok(())
}

Open src/bin/witness.rs

lib.rs · scope_forrust
pub fn scope_for(server_id: &str, tool_name: &str, max_invocations: u32) -> ChioScope {
    ChioScope {
        grants: vec![ToolGrant {
            server_id: server_id.to_string(),
            tool_name: tool_name.to_string(),
            operations: vec![Operation::Invoke],
            constraints: Vec::new(),
            max_invocations: Some(max_invocations),
            max_cost_per_invocation: None,
            max_total_cost: None,
            dpop_required: None,
        }],
        ..ChioScope::default()
    }
}

Open src/lib.rs

lib.rs · witness_chainrust
pub fn witness_chain(
    chain_id: &str,
    parent_task_id: &str,
    child_task_id: &str,
    parent_scope_hash: &str,
    child_scope_hash: &str,
    scope_subset_proof: chio_core_types::capability::attenuation::AttenuationWitness,
) -> Result<SwarmDelegationWitnessChain, Box<dyn Error>> {
    let mut chain = SwarmDelegationWitnessChain {
        schema: CHIO_SWARM_DELEGATION_WITNESS_CHAIN_SCHEMA.to_string(),
        chain_id: chain_id.to_string(),
        graph_id: "analysis-graph".to_string(),
        parent_task_id: parent_task_id.to_string(),
        child_task_id: child_task_id.to_string(),
        hops: vec![SwarmDelegationWitnessHop {
            parent_capability_digest: sha256_hex(b"parent-capability"),
            child_capability_digest: sha256_hex(child_task_id.as_bytes()),
            parent_scope_hash: parent_scope_hash.to_string(),
            child_scope_hash: child_scope_hash.to_string(),
            attenuation_rule_id: "rule-subset-tool-invocation".to_string(),
            scope_subset_proof,
            expires_at_unix_ms: now_ms() + 60_000,
            issuer: witness_issuer(),
            policy_digest: sha256_hex(b"swarm-policy"),
            witness_signature: String::new(),
        }],
    };
    sign_witness_chain(&mut chain)?;
    Ok(chain)
}

Open src/lib.rs

lib.rs · sign_witness_chainrust
pub fn sign_witness_chain(chain: &mut SwarmDelegationWitnessChain) -> Result<(), Box<dyn Error>> {
    let keypair = witness_keypair();
    for index in 0..chain.hops.len() {
        let signature = sign_swarm_delegation_witness_hop(chain, &chain.hops[index], &keypair)?;
        chain.hops[index].witness_signature = signature;
    }
    Ok(())
}

Open src/lib.rs

Captured result and refusal

Captured executable outputtext
narrowing witness: {"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}]}
signed hops verified: 2
widening: attenuation violation: child scope is not a subset of parent scope

The final call asks for four invocations and must fail before a witness can be issued. A signature cannot make a wider scope an attenuation.

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.

A broken hop refuses the whole bundle at the first failing contract

Witness checking runs inside verify_swarm_authority_bundle, after the other bundle checks, continuation bindings included. Each chain is checked in this order:

  1. The schema, a non-empty chain id, a matching graph id, and a parent and child pair that exists as a graph edge.
  2. At least one hop, with the first parent scope and last child scope anchored to their graph nodes.
  3. The graph’s signed multi-hop permission, when the chain has more than one hop.
  4. For each hop, field shapes, expiry, the scope-subset proof, issuer trust, and signature, then its input hashes against the preceding hop’s output hashes.
  5. A second chain for the same edge is refused; after all chains, so is any graph edge left without one.

The verifier returns its first error, never a partial success report, so a malformed continuation binding can stop the bundle before its chain reaches this stage. Each diagnostic names the relationship that failed, and most append the chain id; the diagnostic lookup lists them.

A valid witness proves the scope narrowed at every hop

The verifier is a pure library. The caller supplies the bundle, the evaluation time, and the trusted keys, and it reads neither a clock nor an environment variable. On the serving path, the runtime admission hook loads the bundle from its store and runs the same verification before dispatch.

Capability and policy digests are checked for shape, continuity, and inclusion in the signature; the owning paths resolve and enforce the tokens and policies they name. What a child task did afterwards is recorded in its own receipts, within the mediation boundary.

Witness hop fields and diagnostics

The wire schema is chio.swarm.delegation-witness-chain.v1. Chain and hop types reject unknown fields. The JSON Schema describes the envelope and leaves scopeSubsetProof as an object, so narrowing is established by the verifier.

Fixture scope comparison

Scope fieldParentChild
server_id
commerce
commerce
tool_name
reserve_budget
reserve_budget
operations
invoke
invoke
max_invocations
3
1

parentCapabilityDigest / childCapabilityDigest

stringSigned

Lowercase SHA-256 hex shape and continuity between adjacent hops. The witness verifier treats them as digests and leaves token resolution to the capability path.

parentScopeHash / childScopeHash

stringSigned

Hex shape, endpoint anchors, hop continuity, and hashes recomputed from the supplied scope strings.

attenuationRuleId

stringSigned, non-empty

Carried and signed as an identifier. Narrowing is decided by recomputation from the scope text.

scopeSubsetProof

objectSigned

Scope hashes, parsed scope narrowing, declared non-subset refusals, and cumulative-approval marker preservation. The relation list never substitutes for recomputation. The witness's restrictedPredicates and aggregateBudget fields are signed, and the witness-hop validation path reads neither; the cumulative-approval marker is checked and preserved.

expiresAtUnixMs

integerSigned

Strictly after the caller-supplied bundle time.

issuer

stringSigned

A parseable public key that is a member of the caller's trusted key set.

policyDigest

stringSigned

Lowercase SHA-256 hex shape and inclusion in the signature. The policy itself is resolved and executed by the policy path.

witnessSignature

stringRequired

A valid signature over the canonical hop body, including graph, chain, and task identity.

Continuation and revocation bindings

  • Continuation with a parent task. Carries witnessChainRef and witnessChainSha256; the referenced chain matches both task ids and the token’s canonical chain digest.
  • Continuation without a parent task. Carries neither witness-chain field.
  • Bundle evidence. The full authority verifier refuses an empty witness-chain or continuation-token list.
  • Revoked hop issuer. A match in the supplied epoch’s revokedSubjects refuses the entire bundle.

Diagnostic fragments

Each fragment names the relationship to inspect. Most append the chain id. They are verifier diagnostics, distinct from the stable CLI registry codes.

  • missing swarm delegation witness chain: a graph edge with no chain.
  • duplicate swarm delegation witness chain: more than one chain covering the same edge.
  • swarm witness chain edge mismatch: the parent and child pair against the graph’s edges.
  • swarm witness parent scope mismatch: the first hop’s parent hash against the parent node.
  • swarm witness child scope mismatch: the last hop’s child hash against the child node.
  • swarm multi-hop witness chain feature gate missing: the signed graph’s multiHopWitnessChains field.
  • swarm witness hop scope discontinuity: the preceding child scope hash and the current parent scope hash.
  • swarm witness hop capability discontinuity: the corresponding capability digests at that junction.
  • swarm witness hop is stale: expiry against bundle.now_unix_ms.
  • swarm attenuation witness invalid: scope text and hashes, parsed subset rules, declared relations, and cumulative-approval markers.
  • swarm witness issuer is not trusted: the parsed issuer key against the caller’s pins.
  • swarm witness signature invalid: signature encoding and the complete canonical signed body.
  • trusted swarm witness keys missing: an empty key slice supplied directly to the verifier.

Issuing a witness

An issuer builds normalized scopes, keeps chain continuity, and signs each hop with a trusted key. The worked example’s witness_chain and sign_witness_chain constructors bind the witness to its graph edge, sign the hop, and bind the chain digest into the continuation. A request for a wider scope fails before a witness exists: attenuation violation: child scope is not a subset of parent scope.

What the source tests exercise

The suite covers child-anchor mismatch, a broken continuation-to-chain binding, ungated and enabled two-hop chains, a tampered signature, and disconnected hops. The enabled two-hop test checks a reported witnessHopCount of two.

The generated widening test runs 32 cases. It re-signs a graph and chain with a widened child hash and keeps the proof for the permitted scope; the expected attenuation refusal shows that signed but inconsistent scope text fails. Its coverage is the generated widening space.

Sources and wire contract

Next: assemble the bundle these chains belong to