Chio/Docs
LOGIN · JOIN

Scope Matching

Trace one request to the grant that covers it: match the target, check its constraints, and carry the grant's index into admission.

Concept chio@5f63e6e

On this page

The kernel resolves each request to one grant in its capability, and that grant's index travels with the call through the guards, the budget hold, and the receipt.

A capability can carry several grants, each with its own target, constraints, and limits. The portable matcher reads only the scope, server, tool, and arguments, so a matching grant is a candidate; later admission stages read revocation, budget, and approval state before anything is dispatched.

Scope resolutionChoose a scope.
rsrv-a / echoarguments {}targetconstraintsrankκ₀* / *no constraints2(0, 0, 0)κ₁srv-a / echono constraints1(1, 1, 0)index 1KgguardsBbudget holdρreceiptrsrv-a / echo{}κ₀* / *no constraintstargetconstraintsrank 2 (0,0,0)κ₁srv-a / echono constraintstargetconstraintsrank 1 (1,1,0)selected: κ₁, index 1KgguardsBbudget holdρreceipt

The exact grant is selected, whatever its position

Both grants cover the request. The exact grant ranks first with specificity (1, 1, 0) and keeps its index 1; the wildcard follows at (0, 0, 0) with index 0. Portable evaluation selects index 1, and the guards, the budget hold, and the receipt all carry it. Ranking and fallback.

One request resolved against two tool grants, in the shapes the matcher's own tests build; the arguments are illustrative. Resource and prompt grants and the hosted kernel's per-candidate fallback are left out.

Ranking reorders candidates without renumbering them. In the first scope the exact grant sits at index 1 and ranks first, so the guards, the budget hold, and the receipt all name index 1. The other three scopes end at the three ways resolution can stop short of that.

A grant covers a request only on an exact target with Invoke

A grant is considered only when three predicates hold: matches_pattern(grant.server_id, server_id), the same on the tool name, and grant.operations.contains(&Operation::Invoke).

matches_pattern is exactly pattern == "*" || pattern == candidate. A bare star matches everything and anything else is byte equality, so files-* is a literal name and server and tool names take no path-style globbing. The trailing-star prefix rule in pattern_covers belongs to resource and prompt subset checks during delegation.

The operations check is equally literal. A grant listing only ReadResult or Delegate never admits a tool invocation, whatever its constraints say.

Read more in Capability reference.

The most specific grant ranks first

Surviving grants are ranked by (server exact, tool exact, constraint count), descending, with the original grant-list index breaking ties. Exactness is measured against the request, so a */* grant scores (0, 0, 0). Ranking reorders the grants and leaves each one's authority and index as issued.

For pricing/quote, an exact server-and-tool grant ranks ahead of a */* grant when both include Invoke and their constraints pass; Capabilities shows a grant in full. Each caller uses the ranked list its own way:

  • Portable evaluation selects the first match and continues its decision checks.
  • A direct matcher caller receives every match and owns the selection. The AG-UI proxy also requires a grant to bind trusted event fields.
  • Hosted admission evaluates candidates in rank order with governed validation, guards, runtime admission, and budget checks. Governed-validation failures, guard denials, and some exhausted-budget outcomes advance to the next candidate; runtime-admission denials, pending approval, and other budget failures end the attempt.

Read the exact order in Ranking and fallback.

Every constraint on a grant must hold, and the matcher names any it cannot decide

After the target matches, the matcher reads the grant's constraints, and all of them must hold. The Constraint enum in crates/core/chio-core-types/src/capability/scope.rs carries 27 variants. The portable matcher decides 8 of them from arguments alone and returns ConstraintError for the other 19:

crates/kernel/chio-kernel-core/src/scope.rs250-271rust
Constraint::RegexMatch(_)
| Constraint::GovernedIntentRequired
| Constraint::RequireApprovalAbove { .. }
| Constraint::RequireCumulativeApprovalAbove { .. }
| Constraint::SellerExact(_)
| Constraint::MinimumRuntimeAssurance(_)
| Constraint::MinimumAutonomyTier(_)
| Constraint::TableAllowlist(_)
| Constraint::ColumnDenylist(_)
| Constraint::MaxRowsReturned(_)
| Constraint::OperationClass(_)
| Constraint::ContentReviewTier(_)
| Constraint::MaxTransactionAmountUsd(_)
| Constraint::RequireDualApproval(_)
| Constraint::ModelConstraint { .. }
| Constraint::MemoryWriteDenyPatterns(_)
| Constraint::OutputDigestSha256(_)
| Constraint::RequireFindingPurchase(_)
| Constraint::RequireFindingRecovery(_) => Err(ScopeMatchError::ConstraintError(format!(
    "portable kernel cannot safely evaluate {}",
    constraint_name(constraint)
))),

constraint_name is an exhaustive match that returns the snake_case wire name, so the error names the token the grant author wrote: portable kernel cannot safely evaluate minimum_runtime_assurance. Through evaluate it arrives as constraint evaluation failed: portable kernel cannot safely evaluate minimum_runtime_assurance, which evaluate_fails_closed_on_unsupported_constraint asserts.

The hosted kernel decides more of them. It evaluates regex, model, and memory-write constraints against the request, accepts the governed-transaction and data constraints at this stage for enforcement later in the same request, and drops a grant carrying any of three constraints whose enforcement needs a dedicated runtime path.

Read every variant in Constraint behavior by runtime.

One unsupported constraint fails the whole resolution

A supported constraint that returns false drops its grant, and the matcher moves to the next one. An unsupported constraint on a grant that targets the request returns ConstraintError for the entire resolution, including grants already matched and grants never reached.

The matcher's tests build this case: a pricing/quote grant carrying MinimumRuntimeAssurance beside an unconstrained */* grant. The call errors, and resolution ends before the wildcard is ranked (resolve_matching_grants_fails_closed_when_target_match_has_unsupported_constraint). Move the constraint onto a pricing/refund grant and the request is admitted, because constraints are read only after the target matches (resolve_matching_grants_ignores_unsupported_constraints_on_unrelated_grants).

constraints_match walks a grant's constraints in author order and returns Ok(false) at the first one that fails. An unsupported constraint listed after a failing supported one goes unread, so the same grant can drop silently on one request and error on another. Check a grant's constraint list directly to learn whether it carries an unsupported constraint.

The selected index becomes part of durable state

The winning index reaches GuardContext.matched_grant_index before any guard runs. It crosses the WASM guard ABI as GuardRequest.matched_grant_index, defined in chio-wasm-guards/src/abi.rs and mirrored field for field in chio-guard-sdk.

It lands in receipt attribution metadata as grant_index, beside the subject key, issuer key, and delegation depth. It is persisted on the tool-outcome record, where a restore validates it against the JSON safe-integer bound and rejects raw.matched_grant_index otherwise.

The hosted kernel derives the durable budget hold id from it, admission-budget:{operation_id}:{grant_index}, so a resumed admission permits only the grant index it originally authorized. Renumbering the grants in a scope changes an identifier that durable state has already committed to.

Matching is pure computation, and admission decides the call

The portable matcher is pure computation over supplied inputs and is included in the bounded verified-core manifest, which covers the matcher and leaves host stores, attestation, and later guards to their own evidence. Matching reads no revocation list, charges no budget, verifies no DPoP proof, and consults no approval record: each is a later step of evaluate or a hosted service, fenced as Core & Shell describes.

Resource and prompt grants take their own path. resolve_matching_grants walks scope.grants only, and the hosted kernel matches resource and prompt requests through capability_matches_resource_request and capability_matches_prompt_request. Use the hosted pipeline when a constraint needs host state.

Matching rules and entry points

Ranking and fallback

Follow the selected grant from the ranking into admission. Surviving grants are ranked, never filtered further:

crates/kernel/chio-kernel-core/src/scope.rsrust
matches.push(MatchedGrant {
    index,
    grant,
    specificity: (
        u8::from(grant.server_id == server_id),
        u8::from(grant.tool_name == tool_name),
        grant.constraints.len(),
    ),
});
// ...
matches.sort_by(|left, right| {
    right
        .specificity
        .cmp(&left.specificity)
        .then_with(|| left.index.cmp(&right.index))
});

The first two keys test string equality against the request, so exactness is per request. The third ranks a more constrained grant higher, which is safe in the portable core because a grant reaches the ranking only after every one of its constraints held. The hosted matcher reuses the tuple while ten of its variants return Ok(true) at this stage, so a hosted grant can outrank a sibling on constraints that are checked later.

exact_grants_sort_ahead_of_wildcard_fallbacks pins the order against a two-grant scope written wildcard-first: the exact grant lands at position 0 carrying grant index 1 and specificity (1, 1, 0), and the wildcard follows at position 1 carrying index 0. Portable evaluate takes the first match, matches.first(); adapters that call resolve_matching_grants directly receive the whole ranked list, which is how the AG-UI proxy iterates it.

  • A resumed durable admission skips any candidate permits_matching_grant rejects: a grant index the recorded budget hold id does not name, or a grant whose cost caps do not match the operation's payment requirement.
  • For each remaining candidate the order is governed-transaction validation, the guard pipeline under its time budget, the runtime admission hook, and last check_and_increment_budget. Guards run before the monetary hold.
  • Governed-validation errors, guard denials, and BudgetExhausted with a released runtime reservation continue the loop. A runtime-admission denial, a pending-approval outcome, and any other budget error return on the candidate that produced them.
  • When the loop ends with no selection, a recorded budget error outranks a guard denial, which outranks a governed-validation error. A guard denial against the most specific grant decides the request only after every later candidate has also failed and none recorded a budget error.

Constraint behavior by runtime

VariantPortable coreHosted kernel
PathPrefix, DomainExact, DomainGlob, MaxLength
Decides
Decides, identically
MaxArgsSize, Custom, AudienceAllowlist, MemoryStoreAllowlist
Decides
Decides, identically
RegexMatch
ConstraintError
Any string leaf must match; a bad pattern is KernelError::InvalidConstraint
ModelConstraint
ConstraintError
Decides against request.model_metadata; absent metadata under a non-vacuous constraint denies
MemoryWriteDenyPatterns
ConstraintError
Ok(false) when any string leaf matches any pattern; a bad pattern is KernelError::InvalidConstraint
GovernedIntentRequired, RequireApprovalAbove, RequireCumulativeApprovalAbove, SellerExact, MinimumRuntimeAssurance, MinimumAutonomyTier
ConstraintError
Ok(true) here; the governed-transaction path enforces them later in the request
TableAllowlist, ColumnDenylist, MaxRowsReturned, OperationClass
ConstraintError
Ok(true) here; chio-data-guards and post-invocation result shaping enforce them
ContentReviewTier, MaxTransactionAmountUsd, RequireDualApproval
ConstraintError
Ok(false) for every value: the grant drops at this stage

Read the hosted column as three behaviors. Eleven variants are decided here. Ten return Ok(true) and are enforced elsewhere, so a grant carrying only those passes the matcher and can still be refused later. Three return Ok(false) without reading the value, even ContentReviewTier::None or RequireDualApproval(false), because their enforcement needs runtime context from a dedicated path; that is a candidate drop, and a sibling grant may still admit the call.

Argument reading

PathPrefix, DomainExact, DomainGlob, and MaxLength scan collect_string_leaves, a recursive walk that flattens the arguments into (key, value) pairs for every string in the tree. Arrays inherit the enclosing key; numbers, booleans, and nulls are not leaves. These four apply to whatever in the arguments looks like their subject, unbound to any named parameter. The other four walk the raw arguments value: MaxArgsSize re-serializes it, Custom recurses for one entry, and the two allowlists recurse with collect_string_values_strict.

PathPrefixNo candidate: does not match
Every string leaf whose lowercased key contains path or is one of file, filepath, dir, directory, root, cwd, plus every value that carries a slash or backslash and no ://. All must sit under the prefix, compared segment by segment after lexical normalization.
DomainExact, DomainGlobNo candidate: does not match
Every string leaf that parse_domain accepts: scheme, userinfo, port, path, query, and fragment are stripped, then the host is lowercased and dot-trimmed. All must equal the expected domain, or match the glob through the backtracking wildcard_matches.
MaxLengthNo candidate: matches vacuously
Every string leaf value, compared by byte length, not character count. An argument object with no strings satisfies any length cap.
MaxArgsSizeAlways evaluable
arguments.to_string().len(), the compact re-serialization, so whitespace in the caller's JSON never counts against the cap.
Custom(key, expected)No candidate: does not match
A recursive search for any object entry under key, compared case-sensitively, whose value is the exact string expected. One matching entry anywhere in the arguments satisfies it.
AudienceAllowlistKey absent: matches. Present but empty or non-string: does not match
Values under recipient, recipients, audience, to, channel, channels, collected strictly.
MemoryStoreAllowlistSame three-way split as the audience allowlist
Values under store, memory_store, collection, namespace, matched case-insensitively at any depth.

parse_domain accepts any non-empty string that contains a dot and consists only of ASCII alphanumerics, hyphens, and dots, plus the literal localhost. Under that rule report.txt is a domain candidate, and because the domain constraints require every candidate to match, a request carrying a URL and one unrelated dotted token drops the grant. looks_like_path treats any value with a slash and no :// the same way, so an incidental application/json has to sit under the prefix too.

AudienceAllowlist claims the key to at any depth, so a date range written as {"to": "2026-07-28"} is an observed audience value and fails the allowlist. Scope these constraints to tools whose arguments are the constrained value and little else.

  • Path matching is lexical. normalize_path treats \ as a separator, resolves . and .., and compares byte-equal segments with no filesystem access, so it resolves no symlink, folds no case, and expands no ~. A .. that pops above the root returns None, which makes path_has_prefix false. A symlink inside an allowed prefix that points outside it satisfies PathPrefix; the Filesystem Guard checks that case.
  • DomainGlob globs the whole hostname string, not label by label. * crosses dots, so *.larkfield.example also admits a.b.larkfield.example, and a trailing star is a raw prefix match: larkfield.example* admits larkfield.example.example.net. The pattern is lowercased without normalize_domain, so a pattern written with a trailing dot never matches.

The strict collector returns false on any non-string, non-array leaf, so {"audience": null}, {"audience": 42}, {"audience": []}, and {"audience": ["security", null]} all fail closed while an absent key allows. Treating an explicit null as absence would let anyone who controls a nullable field switch the allowlist off. The portable core copies these semantics from the hosted kernel case for case; resolve_matching_grants_audience_null_fails_closed_and_missing_allows, resolve_matching_grants_audience_allowlist_rejects_non_string_values, and resolve_matching_grants_audience_mixed_null_array_fails_closed pin them.

Matcher API

A match borrows a grant with its position and its rank. Nothing is cloned or normalized into a new shape.

crates/kernel/chio-kernel-core/src/scope.rsrust
pub struct MatchedGrant<'a> {
    /// Index of this grant inside the scope's grant vector.
    pub index: usize,
    /// The matched grant itself.
    pub grant: &'a ToolGrant,
    /// Specificity tuple: `(server-exact, tool-exact, constraint-count)`.
    pub specificity: (u8, u8, usize),
}

pub enum ScopeMatchError {
    /// No grant in the scope covers the requested `(server, tool, Invoke)`.
    OutOfScope,
    /// The portable kernel cannot safely evaluate a constraint carried by a
    /// target-matching grant.
    ConstraintError(String),
}
Entry pointNo grant matchedUsed by
resolve_matching_grants(scope, tool, server, args)
Ok(vec![]); the caller handles emptiness
evaluate, through resolve_matched_grant_index, which maps an empty vector to KernelCoreError::OutOfScope
resolve_capability_grants(capability, tool, server, args)
Err(ScopeMatchError::OutOfScope); never an empty Ok
The AG-UI proxy

The proxy treats a match as a candidate. It calls resolve_capability_grants with a synthesized argument object, then requires matches.iter().any(|matched| grant_binds_event(matched.grant, event)), a second predicate that re-reads the grant's Custom constraints against trusted event fields (event_id, session_id, target_component_type, target_component_id). A non-empty match set where nothing binds falls into the same capability scope does not authorize this AG-UI event block as OutOfScope, and ConstraintError blocks with capability scope constraint failed: {reason}. Because the proxy uses any(), it computes the specificity order and then ignores it.

Cumulative approval before matching

RequireCumulativeApprovalAbove is on the portable refusal list, and every evaluate entry point refuses it before matching. On the plain path, verify_capability_with_floor passes cumulative_approval_enabled = false, so verification rejects the token with cumulative_approval_budget was not negotiated. On the full-floor path a peer may negotiate the feature, and evaluate_with_full_floor_and_root checks scope.has_cumulative_approval() and denies with UnsupportedCapabilityFeature before scope matching runs.

The AG-UI proxy runs the same has_cumulative_approval() check before it calls resolve_capability_grants. The matcher's own ConstraintError for the variant is reachable only through a direct resolve_matching_grants call, which resolve_matching_grants_rejects_cumulative_approval_until_enforced makes. Negotiated features in capability/features.rs decide whether a token is admissible at all, and the matcher is the last check in that sequence.

Tests and model checking

  • constraint_name is an exhaustive match, so a new Constraint variant fails to compile until it is placed among the decided or refused variants in constraint_matches.
  • Four tests in tests/mutation_boundaries.rs pin the strict allowlist shapes for both allowlists.
  • tests/scope_proptest.rs runs four proptest properties at 96 cases by default, overridable through PROPTEST_CASES: a normalized descendant of a PathPrefix matches and a sibling directory does not; domain matching normalizes case and URL authority; a domain glob admits a subdomain and not the base domain; MaxLength and MaxArgsSize are inclusive at the boundary.
  • Kani checks public_resolve_matching_grants_rejects_out_of_scope_request and public_resolve_matching_grants_preserves_wildcard_matching, both listed in .kani/harnesses.toml: a grant that does not cover the request yields an empty match set, and a */* grant yields exactly one match with specificity (0, 0, 0).

The Kani harnesses cover their fixtures: fixed server and tool strings, Value::Null arguments, and kani::assume pinning the scope to one unconstrained Invoke grant through assume_single_unconstrained_invoke_grant. The #[cfg(kani)] shortcut at the top of resolve_matching_grants refuses a constrained one-grant fixture outright, so constraint semantics rest on the proptest and unit suites.

The hosted kernel decides the 8 shared variants with the same semantics in crates/kernel/chio-kernel/src/request_matching.rs, pinned by tests that mirror those for crates/kernel/chio-kernel-core/src/scope.rs.

Next: read the fields a grant carries