PlatformCapabilities & Receipts
Kernel
Delegation & Attenuation
The subset prover: what makes a child capability a strict narrowing, what binds it to a real parent, and what the kernel refuses instead.
The contract a swarm witness chain proves against
scopeSubsetProof to validate_attenuation_proof, the same function documented here, so the narrowing rule below is what a witness chain is checked against, once per hop. That page owns multiple hops, edge anchoring, and issuer pinning. Capabilities owns the token shape and the grant catalog these rules operate over.A prover, not a policy
Attenuation is the only way authority moves in Chio. A holder cannot grant what it does not hold, and the check is arithmetic over two scopes rather than a rule an operator writes. chio-core-types/src/capability/attenuation.rs is the prover, chio-kernel-core/src/capability_verify.rs wraps it in issuer trust, the crypto floor, the time window, and chain binding, and chio-kernel-core/src/budget_split.rs settles the one field two scopes cannot.
Only budget_split.rs claims purity in its own module doc: no clock, no I/O, no revocation state. capability_verify.rs takes a &dyn Clock, threaded explicitly, for the time window, and fences out revocation, the lineage join, scope match, and DPoP by name.
This is Kernel content: it decides one mediated call and nothing about it changes when you run two. The lineage join does change, and evaluate.rs excludes it by name, "Delegation-chain ancestor inspection against the receipt store". ChioKernel::validate_delegation_admission performs it, one CapabilitySnapshot per ancestor off local disk. Everything below runs on material the token carries.
The wire primitive ships unconditionally: chio-core-types’ delegation feature is a no-op by its manifest comment, though delegate’s doc comment still claims the feature gates it. The kernel’s same-named feature is real, gates the RevocationView consultation on delegated dispatch, and sits in default despite a comment further down that manifest reading "Default OFF".
Three objects
| Object | Signed by | Carries |
|---|---|---|
DelegationLink | The delegator, over the canonical DelegationLinkBody | Ancestor capability_id, delegator and delegatee keys, the declared attenuations, a timestamp, and scope_hash: the SHA-256 of the canonical scope authorized at this hop, optional on the wire and required by chain binding. Optional aggregate-budget and cumulative-approval markers ride alongside. |
AttenuationProof | The token issuer, as part of the token body | parent_scope_hash, child_scope_hash, and the witness below, under the field name normalized_subset_proof. |
AttenuationWitness | Covered by the token signature | The normalized parent and child scope as text, plus subset_relations, restricted_predicates, and two optional markers for the aggregate-invocation and cumulative-approval families. |
Carrying scope text rather than only hashes is what makes the proof checkable offline. validate_attenuation_proof re-derives both hashes from that text, parses it into two ChioScope values, and runs the subset relation itself. The rest of the witness carries less than it looks:
subset_relationsis not the proof. An entry claimingsubset: falserejects the witness; otherwise the recomputed relation decides.restricted_predicatesis written at mint and never read by the verifier. Diagnostics, not evidence.cumulative_approvalis recomputed from the parsed child scope and must match exactly;aggregate_budgetis checked separately, invalidate_schema.
validate_schema also pins child_scope_hash to scope_hash(&self.scope), so the witness cannot describe one child while the token grants another.
Seven declared steps
Attenuation is a closed enum. Every variant except ShortenExpiry is addressed by a (server_id, tool_name) pair, and delegate checks each one twice before signing a link.
| Step | Narrows the parent when | Is reflected in the child when |
|---|---|---|
RemoveTool | Any parent grant covers the target. | No child grant still covers it. |
RemoveOperation | Some covering parent grant holds the operation. | No covering child grant still holds it. |
AddConstraint | Any parent grant covers the target. | Every covering child grant carries the constraint. |
ReduceBudget | Some covering parent grant is uncapped, or capped at or above the declared max_invocations. | Every covering child grant is capped at or below it. An uncapped child contradicts the step. |
ReduceCostPerInvocation | Some covering parent grant is uncapped, or capped in the same currency at or above the ceiling. | Every covering child grant is capped, same-currency, at or below it. |
ReduceTotalCost | Same rule, against max_total_cost. | Same rule. |
ShortenExpiry | new_expires_at <= parent.expires_at. | The child expiry is at or before the declared bound. |
The middle column is asymmetric on purpose: an uncapped parent accepts any finite child cap, because introducing a ceiling is a reduction. The child column has no such escape, and child_cost_within returns false for an absent child cap. Admission re-runs the right-hand column only, against the stored child snapshot at intermediate hops and the leaf token at the last; the reduce-only column is mint-time.
What counts as a strict narrowing
Underneath every step sits one relation. ChioScope::is_subset_of holds when every child grant is covered by some parent grant, checked independently across the tool, resource, and prompt lists. Tool grants carry the substance:
// If parent has an invocation cap, child must too and it must be <= parent
if let Some(parent_max) = parent.max_invocations {
match self.max_invocations {
Some(child_max) if child_max <= parent_max => {}
None => return false, // child is uncapped but parent is capped
Some(_) => return false, // child exceeds parent
}
}The same shape repeats for both monetary caps, currency matched exactly rather than converted. Operations must be a subset and dpop_required is monotone upward. Constraints run both ways: every parent constraint must be preserved by some child constraint, and every cumulative-approval constraint the child carries must be preserved by a parent one.
Preservation is equality for every variant except RequireCumulativeApprovalAbove, where budget id, epoch, and currency must match, root bindings must be canonically equal or both absent, and the child threshold must be at or below the parent’s. Equality elsewhere is strict: a child that replaces PathPrefix("/srv") with the tighter PathPrefix("/srv/reports") has dropped the parent constraint and fails. Tightening means carrying both.
A parent grant whose server_id or tool_name is * covers a concrete child. Resource and prompt grants use pattern_covers, which knows three forms: bare *, a trailing * compared with starts_with, and literal equality. No path-segment awareness, so file:///srv* covers file:///srvfoo as readily as file:///srv/report.csv.
Overlapping parent grants are order-independent
A parent can hold a broad *:* grant and a concrete grant for the same tool, with only one holding the operation a step targets. Step validation walks every covering grant and accepts if any one makes the step a true narrowing:
/// Accept a step when at least one covering parent grant satisfies the
/// variant-specific narrowing `predicate`, fail-closed otherwise.
///
/// Overlapping parent grants are order-independent: a broad `*:*` grant that
/// lacks the targeted operation (or cost cap) must not mask a later concrete
/// grant that holds it. Subset validation already accepts a child grant covered
/// by *any* parent grant, so step validation mirrors that by checking every
/// covering grant and accepting if any one of them makes the step a true
/// narrowing. When no covering grant exists at all, the step targets a tool the
/// parent never held: reject as a widening.
fn step_matches_any_covering_grant<F>(
parent_scope: &ChioScope,
server_id: &str,
tool_name: &str,
predicate: F,
reason: impl FnOnce() -> String,
) -> Result<()>
where
F: Fn(&super::scope::ToolGrant) -> bool,
{
let mut covered = false;
for grant in covering_parent_grants(parent_scope, server_id, tool_name) {
covered = true;
if predicate(grant) {
return Ok(());
}
}
if !covered {
return Err(Error::AttenuationViolation {
reason: format!(
"attenuation step targets tool {server_id}:{tool_name} not present in parent scope"
),
});
}
Err(Error::AttenuationViolation { reason: reason() })
}The two failure modes stay distinct: no covering grant means the step asserts authority the parent never held; a covering grant that fails the predicate produces the variant-specific message. The reflection check uses a wider matcher, step_target_covers_child_grant, firing when either side is a wildcard, while the kernel’s tool_grant_covers_target matches only on the grant side. Reading the two truth tables, mint rejects a superset of what the kernel rejects, which is the safe direction. The source gives the rationale for the wider matcher but never draws that comparison, so treat it as a direction, not a proved property.
Delegation is a per-grant operation, not a token flag
validate_delegable_attenuation first requires the parent scope to hold Operation::Delegate anywhere at all (parent capability scope does not authorize delegation), then requires every child grant to be a subset of a parent grant that itself lists Delegate, checked separately per grant kind. A parent holding invoke on one tool and delegate on another cannot pass on the first. The kernel repeats this against stored ancestor scopes in validate_delegatable_subset.Binding the claimed parent to a real one
A self-consistent proof proves nothing on its own. An issuer holding scope X can compute a valid witness for a narrowing of some larger scope Y it never held, unless something ties parent_scope_hash to authority the verifier already recognizes. The source names that hole parent-scope inflation.
The rule fires on shape, not lineage. requires_chain_binding() is true for an attenuation_proof, a non-empty scope_attenuations list, or any budget_share_bps. A non-empty delegation_chain alone is not a trigger: a pass-through delegation introduces no narrowing to bind, and each link already carries its own signature.
| Token shape | parent_scope_hash must equal |
|---|---|
| Attenuated, empty chain (direct issue) | The issuer’s trust-root scope hash, resolved through TrustRootResolver. |
| Attenuated, one link | That link’s scope_hash, which the delegator signed. The link’s own scope_hash must equal the trust root. |
| Attenuated, two or more links | Nothing. Rejected outright. |
| Not attenuated | Not checked. Signature, connectivity, timestamp, subject continuity, and depth checks still apply. |
The third row is the boundary this page will not paper over. validate_delegation_chain_with_trust_root rejects any attenuated chain longer than one hop with multi-hop attenuated delegation chains require per-hop child-scope witnesses. A link carries one scope_hash, so a verifier can confirm what link N claims to have authorized but not that link N-1 handed it that scope. Per-hop child-scope witnesses are what a swarm witness chain carries and a token does not; full_verifier_rejects_multi_hop_attenuation_without_child_scope_witnesses signs every artifact correctly and still gets a deny.
Three more refusals sit alongside. A link omitting scope_hash is rejected rather than skipped, so an older unbound chain cannot ride through as compatibility. A resolver returning None for the issuer denies with chain-binding: no trust-root scope hash registered for issuer. A peer that clears the delegation_chain_binding negotiation flag does not turn chain binding off; every attenuated token is denied with chain-binding: peer disabled delegation_chain_binding; attenuated tokens are rejected. That flag defaults to enabled when absent, the opposite of the unwrap_or(false) that CapabilityNegotiation::supports applies to optional features. The per-link rules run only on the resolver path; the fixed-hash entry point verify_capability_with_floor_and_trust_root checks the leaf binding alone.
The portable chain walk that runs first is narrower than it looks. validate_delegation_chain checks each link’s signature against its delegator key, that each delegator is the previous link’s delegatee, that timestamps do not go backwards, and depth against a maximum when the caller supplies one. Its doc comment states that it does not enforce chain binding, and it never compares two scopes: the ancestor scopes are not on the token. Its caller verify_delegation_chain_shape adds one rule and drops another, requiring the final link’s delegatee to equal the token subject but passing None for depth, so the portable verifier bounds chain length not at all. Only the hosted validate_delegation_admission passes max_delegation_depth and compares scopes, over capability snapshots, which belongs to Node State on Disk.
The crypto floor underneath
Every check above assumes the signature means something. CapabilityCryptoFloor is the minimum posture the validator accepts. It lives in chio-core-types so a no-std or edge verifier can branch on it without depending on chio-policy or chio-kernel. Operators set policy.crypto_floor; capability_crypto_floor translates it at the kernel boundary.
| Floor | Classical only | Hybrid |
|---|---|---|
allow_classical (default) | Accepted | Rejected |
allow_hybrid | Accepted | Accepted |
pq_required | Rejected | Accepted |
verify_signature_with_floor runs three steps in fixed order. If the optional algorithm envelope field is present it must match what the signature material self-describes; a mismatch is a downgrade attempt and is rejected, not resolved either way. The floor is applied before any cryptography runs, so a refusal costs no verification work and gets its own error variant. Then schema validation, which is where the subset prover runs, and canonical-JSON verification. The threat-model row this guards is named in the source: pq_signature_downgrade.
Hybrid verification is conjunctive: the declared algorithm set must agree between key and signature, must be the canonical label for the classical algorithm in use, and both branches must verify. A legacy plain-body signature fallback exists, but permits_plain_body_signature excludes any token carrying caveats, scope attenuations, an attenuation proof, a budget share, an aggregate budget, or a cumulative-approval constraint. That is every token this page is about.
Boundary: the floor is portable, the algorithms are not
CapabilityCryptoFloor compiles into every target. The primitives it names do not. Without the pq cargo feature, verify_mldsa65_signature is a stub returning false; without fips, so are the P-256 and P-384 verifiers. Raising the floor is a rebuild, not a config edit: pq is default-off on chio-kernel and the ML-DSA-65 backend does not exist without it.Two gates fire before the verifier does.
chio-policy refuses the configuration at load (policy.crypto_floor=pq_required requires a post-quantum (ML-DSA-65) signing key but none was provisioned at kernel boot), and the boot path derives the PQ backend only after a verified TEE self-quote. A deployment that reaches the verifier anyway with pq_required and no working ML-DSA-65 denies every capability: classical envelopes refused at the floor, hybrid ones failing the ML-DSA branch. That outcome composes two behaviours asserted separately; no single test pins it.Sibling splits, and why they go last
budget_share_bps is the field two scopes cannot settle between them. Per-token validation caps it at 10_000 basis points and mint enforces it parent-relative. Neither sees siblings: a parent at 5000 bps can mint two children at 5000 each, both passing in isolation while jointly claiming twice its authority. Admission reads an omitted share as MAX_BUDGET_SHARE_BPS, which is why mint rejects omission whenever the parent holds less than the ceiling.
BudgetSplit closes that: one record per parent, holding the parent share and a map from child capability id to an admitted share plus a holder count. The running sum is compared in u32, so two u16::MAX siblings cannot wrap past the cap. Rejections are typed: ChildShareExceedsCap, OversubscribedSiblings carrying the running sum and the parent share, DuplicateChild for a child returning with a different share, and UnknownParent. Accounting fires only for a non-empty delegation_chain and charges the last link’s capability_id, so a token with a share but no chain is never charged at all.
UnknownParent is a deliberate fail-closed default: the registry will not invent a parent at the full ceiling. ChioKernel::register_budget_parent and evict_budget_parent are the embedder’s handles; nothing auto-registers. Conformance pins that (unregistered_parent_rejects_first_sibling_fail_closed) and the composition case (parent_5000_child_4000_two_grandchildren_3000_each_second_rejected), where the second grandchild is rejected for oversubscribing the child, not the root.
Ordered last, on both paths
Admission mutates shared state, so a token that is valid for the wrong request must not touch it. The portable core states the rule as a comment on the step itself:
// Step 5: mutate delegated sibling budget only after every deny-capable
// local check has passed.
if let Err(error) = admit_delegated_budget(input.capability, budgets) {
let core_err = KernelCoreError::InvalidCapability(error);
return deny(core_err, Some(matched_grant_index), Some(verified));
}Steps one through four are capability verification, subject binding, scope match, and the guard pipeline; the inner verification call is handed a NoopBudgetRegistry, so signature checking cannot charge anything. The hosted kernel mirrors that through a different seam: verify_capability_full_pre_admit runs the production verifier against a no-op registry, and admit_capability_budget is deferred past time bounds, revocation, the lineage join, subject binding, scope resolution, DPoP, and the guards. Otherwise, its comment says, a denied call would consume the parent’s share and starve later valid siblings. budget_admission_waits_until_subject_scope_and_guards_allow proves it negatively: after a wrong-agent deny the registered parent’s split still has zero children and a zero running total.
Two admit modes, one refcount
The registry stores exactly one edge per (parent, child) pair, but overlapping evaluations of the same delegated capability depend on it at once. A boolean "who inserted it" owner is unsound: the inserting evaluation cancelling would return a share another live evaluation is still using.
| Mode | Fresh child | Child already present, same share | Used by |
|---|---|---|---|
Lease | Insert with holders = 1. | holders += 1. | Hosted dispatch, which owns the matching release on its cleanup path. |
VerifyOnly | Insert with holders = 0. The share is charged for sibling accounting; no releasable holder exists. | Untouched. | Portable and preflight verdicts, adapter one-shot evaluations. |
The edge is freed only when the last holder drops, which overlapping_holders_release_frees_only_on_last pins by asserting the sibling stays denied in between. The split exists because a verify-only path has no cleanup: a lease taken there could never be released. Release is idempotent for a missing child, so cleanup can run after either an admission failure or a later denial, and a share mismatch is a hard ReleaseShareMismatch that drops no lease.
The gate that reads node state
admit_capability_budget calls enforce_restart_reserved_hold_gate before it touches the registry, for delegated capabilities only. While reserved holds from a prior process are open in the budget store, admission is denied with delegated reserved holds from a prior process remain open (N), or, when the pending set cannot be enumerated, open budget holds from a prior process remain (N) and cannot be enumerated. A poisoned registry lock fails closed the same way. The gate is Kernel; the store it reads is Budget Store.Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | A child scope is admitted only when every grant is covered by a parent grant, recomputed from the witness scope text rather than the declared relation list, with the child text pinned to the token’s canonical scope. | validate_attenuation_proof, ChioScope::is_subset_of, the child_scope_hash check in validate_schema |
| Shipped | A declared step is checked reduce-only against the parent and reflected in the child before a link is signed. A step targeting a tool no parent grant covers is itself a widening. | Mint: validate_attenuation_steps, validate_steps_reflected_in_child. Admission re-runs the reflection half only: validate_declared_attenuations |
| Shipped | An attenuated token’s claimed parent scope is bound to the issuer’s trust root or a predecessor’s signed scope_hash. Parent-scope inflation is not accepted with an internally consistent witness. | CapabilityToken::validate_chain_binding; full_verifier_rejects_delegated_attenuation_unbound_from_trust_root |
| Shipped | Sibling shares under one parent sum to at most the parent’s share, across hops, with the running sum computed in u32. | BudgetSplit::admit_child; current_total_avoids_u16_overflow; the cross-hop conformance test parent_5000_child_4000_two_grandchildren_3000_each_second_rejected |
| Shipped | A denied request does not consume the parent’s share. Budget admission is the last mutating step on both the portable and the hosted path. | Step 5 in finish_verified_evaluation; the deferred admit_capability_budget; budget_admission_waits_until_subject_scope_and_guards_allow |
| Proved (P1) | Capability attenuation is a required property with a Lean root, a differential test lane, a Rust projection, Aeneas equivalence, and public Kani harnesses. Named proofs include scope subset implying grant subset, reduced budget as subset, and added constraint as subset. | property_matrix row P1. The covered symbols are the Normalized* mirrors in chio-kernel-core; the chio-core-types originals are pinned as hash-tracked Lean transliterations, not verified refinements |
| Presented, not proved | Delegation-chain semantic validity is row P5, labelled "presented" in the manifest itself. Its lanes are a Lean root and a SQLite projection, with no Rust refinement lane. Do not read P1 coverage onto the chain. | property_matrix row P5 |
| Limit | Proof coverage names the compatibility verifier: capability_verify.rs is a covered module but its covered symbols are only verify_capability and verify_capability_with_trusted. Of the functions on this page only validate_delegation_chain has a mirror; validate_attenuation_proof, validate_chain_binding, verify_capability_full, and budget_split.rs appear in no lane. | covered_rust_modules, covered_rust_symbols, the [[mirror]] table |
| Boundary | Attenuated delegation chains stop at one hop, refused rather than approximated. Per-hop semantic attenuation from scope hashes alone is explicitly not claimed: the full parent and child scopes are not carried on every link, so callers needing it must carry explicit witnesses. | validate_delegation_chain_with_trust_root and its doc comment; full_verifier_rejects_multi_hop_attenuation_without_child_scope_witnesses |
| Boundary | A build without pq or fips denies the algorithms it cannot verify instead of accepting them. Configuring pq_required without a provisioned PQ key is refused at policy load, before the verifier sees a token. | The #[cfg(not(feature = ...))] stubs in chio-core-types/src/crypto.rs; CryptoFloorLoadError::HybridFloorRequiresPqKey |
| Boundary | Capability caveats are parsed but never enforced, so a token carrying any is rejected at schema validation rather than admitted with the caveat ignored. | validate_schema: capability caveats are not enforced by admission and are rejected fail-closed |
| Boundary | The portable evaluator denies any token carrying an aggregate invocation budget or a cumulative-approval constraint outright, even once the witness markers check out. Enforcement for both families lives above the pure core. | KernelCoreError::UnsupportedCapabilityFeature in evaluate_with_full_floor_and_root |
| Limit | The split registry is in-process memory keyed by capability id: not durable, not replicated, single-process enforcement only. A verify-only admission also commits a fresh child’s share with no holder, so preflight traffic holds headroom against later siblings until a real dispatch releases the edge or the parent is evicted. | InMemoryBudgetRegistry behind the kernel’s mutex (the only other implementation is NoopBudgetRegistry); verify_only_fresh_admit_commits_share_without_a_holder |
| Limit | Constraint preservation is equality outside the cumulative-approval variant, so a child that tightens a constraint value instead of adding one fails the subset check. Resource and prompt patterns match by starts_with, not by path segment. | Constraint::is_preserved_by, pattern_covers |
Next steps
- Delegation Witnesses · the multi-hop artifact that carries what a token cannot, verified against this same subset prover once per hop
- Capabilities · the token, the grant variants, and the constraint catalog these rules operate over
- Core & Shell · why the prover is pure and what the shell adds around it
- Budget Store · authorize, capture, release, reconcile, and the restart gate that blocks delegated admission
- Sub-Agent Budgets · what happens to a share once it crosses to a party you do not run
- P1 Tour · the attenuation property end to end, from the Lean root to the Rust projection