PlatformCapabilities & Receipts
Kernel
Scope Matching
Which grant admits a call, which constraints the portable matcher can decide, and why the rest deny instead of widening.
The enum lives next door
Constraint variants as data. This page owns what happens when one of them meets a request. Core & Shell owns the wider split this matcher sits inside, and Delegation owns the separate question of whether a child scope is a subset of its parent, which is a comparison between two tokens and never reads a request.One function, two callers, no state
chio_kernel_core::scope::resolve_matching_grants answers a single question: which grants in this scope authorize an Invoke of (server_id, tool_name) with these arguments. It is shipped, it is step 3 of evaluate, and formal/proof-manifest.toml names it and resolve_capability_grants as covered Rust symbols inside the bounded verified core.
The module opens by saying what it will not do:
//! The hosted kernel carries the richest matcher in
//! `chio-kernel::request_matching`, but the portable core must never
//! silently drop a grant constraint. Constraints that can be evaluated
//! from request arguments are enforced here; constraints that require
//! richer kernel state (governed intent, runtime attestation, SQL result
//! inspection, regex compilation, etc.) fail closed with an explicit
//! error instead of widening scope.Two matchers exist because two runtimes exist. The hosted kernel has a regex engine, a governed-transaction evaluator, and the request’s model metadata. The portable core is no_std plus alloc, and the matcher takes four values: the scope, the tool name, the server id, and the arguments. Everything below follows from that signature. There is no clock, no store, no session, and no attestation record on this path, so any constraint whose meaning depends on one of those is not evaluable here, and the matcher says so by name rather than passing the grant through.
What comes back
A match is a borrow of a grant plus its position and its rank. Nothing is cloned and nothing is normalized into a new shape.
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),
}The two public entry points differ only in how they treat an empty result, and the difference is load-bearing for callers.
| Entry point | No grant matched | Used by |
|---|---|---|
resolve_matching_grants(scope, tool, server, args) | Ok(vec![]). Emptiness is the caller’s problem. | evaluate, through resolve_matched_grant_index, which maps the empty vector onto KernelCoreError::OutOfScope itself. |
resolve_capability_grants(capability, tool, server, args) | Err(ScopeMatchError::OutOfScope). It can never return Ok with an empty vector. | The AG-UI proxy, its only in-tree caller. |
The proxy does not treat a match as an answer. It calls resolve_capability_grants with a synthesized argument object and 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) rather than against the arguments blob the matcher saw. A non-empty match set where nothing binds falls into the same capability scope does not authorize this AG-UI event block as OutOfScope; ConstraintError blocks with capability scope constraint failed: {reason}. Because the proxy uses any(), the specificity order described below is computed and then ignored on that path.
The function walks scope.grants only. resource_grants and prompt_grants are never consulted here; the hosted kernel matches those through separate helpers (capability_matches_resource_request, capability_matches_prompt_request) that have no portable equivalent.
Target match first, and it is not a glob
A grant is considered at all 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; anything else is byte equality. There is no files-* prefix form and no path-style globbing on server or tool names. The trailing-star prefix rule that does exist in pattern_covers applies to resource and prompt subset checks during delegation, not to matching a request.
The operations check is equally literal. A grant listing only ReadResult or Delegate never admits a tool invocation, whatever its constraints say.
Decided and refused constraints
Only after the target matches does the matcher read constraints, and every constraint on a grant must hold for that grant to cover the request. 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. The other 19 land in one match arm:
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 returning the snake_case wire name, so the error text an operator sees is the same token they wrote in the grant: portable kernel cannot safely evaluate minimum_runtime_assurance. Through evaluate it arrives wrapped as constraint evaluation failed: portable kernel cannot safely evaluate minimum_runtime_assurance, which is what evaluate_fails_closed_on_unsupported_constraint asserts on.
The table an author needs before writing a grant
| Variant | Portable core | Hosted kernel |
|---|---|---|
PathPrefix | Decides | Decides, identically |
DomainExact | Decides | Decides, identically |
DomainGlob | Decides | Decides, identically |
MaxLength | Decides | Decides, identically |
MaxArgsSize | Decides | Decides, identically |
Custom | Decides | Decides, identically |
AudienceAllowlist | Decides | Decides, identically |
MemoryStoreAllowlist | Decides | Decides, identically |
RegexMatch | ConstraintError | Compiles the pattern and requires any string leaf to match; a pattern that fails to compile is KernelError::InvalidConstraint |
ModelConstraint | ConstraintError | Decides against request.model_metadata; absent metadata under a non-vacuous constraint denies |
MemoryWriteDenyPatterns | ConstraintError | Compiles each pattern and returns Ok(false) when any string leaf matches any pattern; a pattern that fails to compile is KernelError::InvalidConstraint |
GovernedIntentRequired, RequireApprovalAbove, RequireCumulativeApprovalAbove, SellerExact, MinimumRuntimeAssurance, MinimumAutonomyTier | ConstraintError | Ok(true) at this stage. Enforcement is the governed-transaction path, later in the same request |
TableAllowlist, ColumnDenylist, MaxRowsReturned, OperationClass | ConstraintError | Ok(true) at this stage. Enforcement is chio-data-guards and post-invocation result shaping |
ContentReviewTier, MaxTransactionAmountUsd, RequireDualApproval | ConstraintError | Ok(false), unconditionally. The value is never read, so even ContentReviewTier::None or RequireDualApproval(false) drops the grant |
Read the hosted column as three behaviors, not one. Eleven variants are decided. Ten return Ok(true) here and are enforced somewhere else, so a grant carrying only those is admitted by the matcher and still subject to a later refusal. Three return Ok(false), which is not an error: the grant drops out of the candidate list and a sibling grant may still admit the call.
How the eight read a request
Four of them work over 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. The other four ignore that flattening and walk the raw arguments value themselves: MaxArgsSize re-serializes it, Custom recurses looking for one entry, and the two allowlists recurse with collect_string_values_strict.
The most surprising property of the group is that the four leaf-scanning constraints are not bound to a named parameter. PathPrefix, DomainExact, DomainGlob, and MaxLength apply to whatever in the arguments happens to look like their subject. The other four are not: MaxArgsSize measures the whole payload, Custom names its own key (compared case-sensitively, unlike the path and allowlist key predicates, which lowercase first), and the allowlists match a fixed key list.
| Variant | Candidate set | Verdict when the request carries no candidate |
|---|---|---|
PathPrefix | 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. | Does not match. An empty candidate set is a miss, not a pass. |
DomainExact / DomainGlob | 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. | Does not match. |
MaxLength | Every string leaf value, compared by byte length, not character count. | Matches vacuously. An argument object with no strings satisfies any length cap. |
MaxArgsSize | arguments.to_string().len(), the compact re-serialization, not the bytes that arrived on the wire. | Always evaluable. Whitespace in the caller’s JSON never counts against the cap. |
Custom(key, expected) | A recursive search for any object entry under key whose value is the exact string expected. Existence, not universality. | Does not match. |
AudienceAllowlist | Values under recipient, recipients, audience, to, channel, channels, collected strictly. | Key absent: matches, because the constraint cannot apply. Key present but holding a non-string leaf, or holding nothing: does not match. |
MemoryStoreAllowlist | Values under store, memory_store, collection, namespace, matched case-insensitively at any depth. | Same three-way split as above. |
Incidental values poison these constraints
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 both a URL and one unrelated dotted token drops the grant. That is a reading of the acceptance predicate; no in-tree test constructs the case. looks_like_path behaves the same way for any value with a slash and no ://, 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 thing being constrained and little else.
The strict allowlist collector is the one place the portable core deliberately copies hosted semantics rather than simplifying. collect_string_values_strict returns false on any non-string, non-array leaf, so {"audience": null}, {"audience": 42}, {"audience": []}, and the mixed array {"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. resolve_matching_grants_audience_null_fails_closed_and_missing_allows pins the null, empty-string, and missing shapes for both allowlists; resolve_matching_grants_audience_allowlist_rejects_non_string_values and resolve_matching_grants_audience_mixed_null_array_fails_closed cover the rest.
A failed constraint drops one grant; an unevaluable one ends the call
These two outcomes look similar and are not. When an evaluable constraint returns false, the loop does continue and the next grant gets its turn. When any target-matching grant carries an unevaluable constraint, the error propagates out of the loop immediately and the whole resolution fails, including grants that had already matched and grants that were never reached.
resolve_matching_grants_fails_closed_when_target_match_has_unsupported_constraint builds exactly that trap: a srv-a/echo grant carrying MinimumRuntimeAssurance and an unconstrained */* grant beside it. The wildcard fallback does not save the request; the call errors. The companion test resolve_matching_grants_ignores_unsupported_constraints_on_unrelated_grants moves the same constraint onto a srv-b grant and the request is admitted, because constraints are read only after the target matches. A scope is poisoned for a request by any grant that both targets it and carries something the runtime cannot decide.
With one wrinkle worth knowing before you debug a scope: constraints_match walks the grant’s constraint vector in author order and returns Ok(false) at the first constraint that fails, so an unevaluable constraint listed after an evaluable one that already failed is never reached. The same grant then drops silently on one request and errors the whole resolution on another, depending only on which arguments arrived. Do not read the absence of a ConstraintError as evidence that a grant carries no unevaluable constraint.
Specificity, and the index that survives
Surviving grants are ranked, not filtered further:
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))
});Three keys, descending, with grant-list order as the tiebreak. Note that the first two components test string equality against the request, not against the literal *, so exactness is measured per request rather than per grant. The third key ranks a more constrained grant higher, which is safe in the portable core for one reason: a grant only reaches the ranking after every one of its constraints already held, so constraint count is a proxy for how much of the request the grant actually described. The hosted matcher reuses the same tuple without that property. Ten of its variants return Ok(true) unevaluated, so a hosted grant can outrank a sibling on constraints nobody checked at this stage. exact_grants_sort_ahead_of_wildcard_fallbacks pins the ordering against a two-grant scope written wildcard-first: the exact grant lands at position 0 of the returned list carrying grant index 1 and specificity (1, 1, 0), and the wildcard follows at position 1 still carrying grant index 0.
evaluate then takes matches.first() and nothing else. One grant admits the request or it does not. Portable adapters that call resolve_matching_grants directly still receive the whole ranked list and decide for themselves, which is how the AG-UI proxy ends up iterating it.
The hosted kernel walks the ranked list one candidate at a time. A resumed durable admission skips any candidate permits_matching_grant rejects, which means both a grant index the recorded budget hold id does not name and a grant whose cost caps do not match the operation’s payment requirement. For each surviving candidate the order is governed-transaction validation, then the guard pipeline under its time budget, then the runtime admission hook, and only last check_and_increment_budget. Guards run before the monetary hold, not after it. Not every failure advances to the next candidate. 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 therefore decides the request only after every later candidate has also failed, and only if no candidate recorded a budget error first.
The winning index is not telemetry. It reaches GuardContext.matched_grant_index before any guard runs, crosses the WASM guard ABI as GuardRequest.matched_grant_index (chio-wasm-guards/src/abi.rs, mirrored field for field in chio-guard-sdk), lands in receipt attribution metadata as grant_index alongside the subject key, issuer key, and delegation depth, and 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. Most consequentially, the hosted kernel derives the durable budget hold id from it (admission-budget:{operation_id}:{grant_index}), so a resumed admission only permits the grant index it originally authorized. Renumbering the grants in a scope changes an identifier that durable state already committed to.
One refusal you will never see from evaluate
RequireCumulativeApprovalAbove is on the refusal list, but no evaluate entry point reaches it. On the plain path, verify_capability_with_floor passes cumulative_approval_enabled = false, so verification rejects the token first with cumulative_approval_budget was not negotiated. On the full-floor path a peer may negotiate the feature, and then evaluate_with_full_floor_and_root checks scope.has_cumulative_approval() explicitly 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, so that path is closed too. The matcher’s own ConstraintError for that variant is reachable only by calling resolve_matching_grants directly, which is what resolve_matching_grants_rejects_cumulative_approval_until_enforced does. Independent refusals stacked, one outcome. That is the intended shape: negotiated features in capability/features.rs gate whether a token is even admissible, and the matcher is the last line rather than the first.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | Of 27 Constraint variants, the portable matcher decides 8 and returns ConstraintError naming the other 19. No variant is skipped, and constraint_name is an exhaustive match, so adding a variant without deciding where it belongs does not compile. | scope.rs constraint_matches; the enum in capability/scope.rs |
| Shipped | An unevaluable constraint on a target-matching grant fails the whole resolution; the same constraint on a grant that targets a different server or tool is ignored. | Tests resolve_matching_grants_fails_closed_when_target_match_has_unsupported_constraint and resolve_matching_grants_ignores_unsupported_constraints_on_unrelated_grants |
| Shipped | Audience and memory-store allowlists treat an explicit null, a non-string, and an empty collection as fail-closed, and an absent key as inapplicable, matching the hosted kernel case for case. | collect_string_values_strict; four tests in tests/mutation_boundaries.rs |
| Proved by test | 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. | tests/scope_proptest.rs, four proptest properties at 96 cases by default, overridable through PROPTEST_CASES |
| Limit | The proptest exercises only . as a normalization case. normalize_path also implements .. and treats \ as a separator, and returns None when .. pops above the root, which makes path_has_prefix false. Neither behavior is covered by a property. | normalize_path against path_prefix_allows_normalized_descendants_and_denies_siblings |
| Proved by Kani | 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). | public_resolve_matching_grants_rejects_out_of_scope_request, public_resolve_matching_grants_preserves_wildcard_matching, both PR-lane in .kani/harnesses.toml |
| Limit | Those harnesses are narrow. The fixtures are concrete, not symbolic: fixed server and tool strings, Value::Null arguments, and kani::assume pinning the scope to one unconstrained Invoke grant. On a one-grant scope the cfg(kani) shortcut at the top of the function fires and refuses any constrained fixture outright, so no constraint evaluation runs inside the model-checked region and nothing about the argument space is checked. Constraint semantics rest on the proptest and unit suites. | assume_single_unconstrained_invoke_grant and the harness bodies in src/kani_public_harnesses.rs; the #[cfg(kani)] block in resolve_matching_grants |
| Limit | The four leaf-scanning constraints are not bound to named parameters. MaxLength applies to every string anywhere in the arguments, PathPrefix to anything that looks like a path, and domain constraints to anything that parses as a hostname. A tool whose arguments carry incidental dotted or slashed values will interact with these constraints in ways the grant author did not write down. | collect_string_leaves, looks_like_path, parse_domain |
| Limit | Path matching is lexical. normalize_path splits and compares byte-equal segments with no filesystem access, so it resolves no symlink, folds no case, and expands no ~. A symlink inside an allowed prefix that points outside it satisfies PathPrefix; catching that is the filesystem guard’s job, not the matcher’s. | normalize_path, path_has_prefix; Filesystem Guard |
| Limit | DomainGlob globs the whole hostname string, not label by label. * crosses dots, so *.example.com also admits a.b.example.com, and a trailing star is a raw prefix match: example.com* admits example.com.evil.net. The pattern is lowercased but not run through normalize_domain, so a pattern written with a trailing dot never matches. | wildcard_matches; the DomainGlob arm of constraint_matches |
| Limit | The two matchers agree on the 8 shared variants by duplication and mirrored tests, not by a shared implementation. The helpers in request_matching.rs are a byte-for-byte second copy today, and nothing in the build enforces that they stay in step. | crates/kernel/chio-kernel/src/request_matching.rs against crates/kernel/chio-kernel-core/src/scope.rs |
| Not claimed | Matching is not authorization. It reads no revocation list, charges no budget, verifies no DPoP proof, and consults no approval record. Everything it defers is either a later step of evaluate or fenced into the hosted shell. | The exclusion list in chio-kernel-core/src/evaluate.rs; Core & Shell |
| Unsupported | Resource and prompt grants on this path. resolve_matching_grants reads scope.grants only; a portable runtime has no equivalent of the hosted resource and prompt matchers. | scope.grants.iter() is the only iteration in the function |
Next steps
- Capabilities · the token, the grant fields this page ranks, and all 27 constraint variants as data
- Delegation · scope subsetting between two tokens, where
Constraint::is_preserved_bycompares constraints for equality rather than evaluating them, withRequireCumulativeApprovalAboveas the one variant that gets an ordering rule instead - Core & Shell · the exclusion list this matcher sits inside, and the five ordered steps of pure evaluation
- Fail-Closed Semantics · what the rest of the kernel does with a refusal it cannot evaluate
- Data-Layer Guards · where
table_allowlist,column_denylist, andoperation_classare actually enforced