Caveats
Caveats are constraints attached to a delegation. They define what the delegated agent can do, on which resources, for how long, and at what cost. Every caveat is embedded in the cryptographically signed payload, so tampering with caveats invalidates the delegation signature.
Caveat Types
| Type | Purpose | Value | Example |
|---|---|---|---|
action_scope | Restrict allowed actions | string[] | ["resolve", "search"] |
expires_at | Set a time limit | RFC 3339 timestamp | "2026-04-01T00:00:00.000Z" |
max_cost | Cap the operation cost | number | 5.0 |
resource | Restrict to resource patterns | Glob string | "entity:customer:*" |
context | Bind to a specific context | {key, value} | {"key": "task_id", "value": "t-123"} |
custom | Arbitrary user-defined rule | {key, value} | {"key": "env", "value": "staging"} |
action_scope
Restricts which actions the delegate can perform.
JSON
{ "type": "action_scope", "value": ["resolve", "search"] }Behavior
During verification, the invocation's action field is checked against the allowed list. If the action is not in the list, verification fails with a CaveatViolation error.
When to Use
Almost always. Every delegation should specify which actions the delegate can perform. A delegation without an action_scope caveat allows any action.
Edge Cases
- An empty list
[]blocks all actions. This is valid but useless. - Multiple
action_scopecaveats in a chain are all evaluated. If the root grants["resolve", "search", "merge"]and the sub-delegation adds["resolve"], the agent can only resolve. Both caveats must pass, and"search"fails the second check.
// Root: resolve + search + merge
let d1 = Delegation::create_root(&root, &mgr.identity().did, vec![
Caveat::ActionScope(vec!["resolve".into(), "search".into(), "merge".into()]),
]).unwrap();
// Manager narrows to resolve only
let d2 = Delegation::delegate(&mgr, &worker.identity().did, vec![
Caveat::ActionScope(vec!["resolve".into()]),
], d1).unwrap();
// Worker tries to search - BLOCKED by d2's caveat
let inv = Invocation::create(&worker, "search", serde_json::json!({}), d2).unwrap();
let result = verify_invocation(&inv, &worker.identity(), &root.identity());
// Error: CaveatViolation("action 'search' not in allowed scope [\"resolve\"]")expires_at
Sets a hard expiration time on the delegation.
JSON
{ "type": "expires_at", "value": "2026-04-01T00:00:00.000Z" }Behavior
During verification, the current time is compared to the expiry timestamp. If now > expires_at, verification fails. Timestamps are compared as strings in RFC 3339 format (lexicographic ordering works correctly for UTC timestamps with the Z suffix).
When to Use
For time-bounded tasks. A delegation for a nightly batch job should expire at the end of the run window. A delegation for an interactive session should expire when the session ends.
Edge Cases
- Timestamps must use the Z suffix (UTC). Using
+00:00is technically valid RFC 3339 but may not compare correctly in all implementations. - A sub-delegation can set an earlier expiry than its parent (narrowing). It cannot set a later one and have that be effective - the parent's expiry will still be checked and will fail first.
[
{ "type": "expires_at", "value": "2026-04-01T00:00:00.000Z" },
{ "type": "expires_at", "value": "2026-03-20T00:00:00.000Z" }
]Both are checked. The effective expiry is the earlier of the two (March 20).
max_cost
Sets a ceiling on the cost of the delegated operation.
JSON
{ "type": "max_cost", "value": 5.0 }Behavior
The invocation's args must contain a cost field (a number). If cost > max_cost, verification fails. If the cost field is missing entirely, verification also fails - the caveat requires the invoker to declare cost.
When to Use
When delegating to agents that consume billable resources. Set a per-invocation cost limit to prevent runaway spending.
Edge Cases
The
costfield must be present inargs. Omitting it is an error, not a pass:rust// No cost field - verification FAILS (not silently passes) let inv = Invocation::create(&agent, "resolve", serde_json::json!({}), delegation); // Error: CaveatViolation("max_cost caveat requires 'cost' field in args")Multiple
max_costcaveats accumulate. If the parent setsmax_cost: 10.0and the sub-delegation setsmax_cost: 5.0, the effective cap is $5.00 (both checks must pass, and 6.0 > 5.0 fails the second).Cost is a
f64. Standard floating-point caveats apply. For financial-grade precision, consider using integer cents and a max_cost of500instead of5.0.
resource
Restricts the delegation to a specific resource pattern using glob-style matching.
JSON
{ "type": "resource", "value": "entity:customer:*" }Behavior
The invocation's args must contain a resource field (a string). The resource is matched against the pattern. Glob matching supports a trailing * wildcard. If the resource does not match, verification fails. If the resource field is missing, verification also fails.
Patterns
| Pattern | Matches | Does Not Match |
|---|---|---|
entity:* | entity:customer:123, entity:order:456 | source:crm:abc |
entity:customer:* | entity:customer:123, entity:customer:abc | entity:order:456 |
entity:customer:123 | entity:customer:123 | entity:customer:456 |
* | Everything | Nothing (matches all) |
When to Use
When an agent should only operate on a subset of your entity graph. Combine with action_scope for fine-grained policies: "this agent can resolve customer entities but not order entities."
Edge Cases
- Only trailing
*is supported. Mid-string globs likeentity:*:123are not supported - the pattern is treated as a prefix match after stripping the trailing*. - The
resourcefield must be present inargs. Omitting it fails verification. - Resource patterns are domain-specific. Kanoniv does not prescribe a naming convention, but
{type}:{subtype}:{id}works well.
context
Binds the delegation to a specific key-value context pair.
JSON
{ "type": "context", "value": { "key": "task_id", "value": "task-7f3a" } }Behavior
The invocation's args must contain a field matching the context key, and its value must exactly equal the context value. String comparison is exact (case-sensitive, no trimming).
When to Use
To bind a delegation to a specific task, session, or workflow. An agent that receives a task-scoped delegation cannot use it for a different task.
let task_delegation = Delegation::create_root(
&root,
&agent.identity().did,
vec![
Caveat::ActionScope(vec!["resolve".into()]),
Caveat::Context { key: "task_id".into(), value: "task-7f3a".into() },
],
).unwrap();
// Agent must include the exact task_id in its invocation args
let inv = Invocation::create(
&agent,
"resolve",
serde_json::json!({"task_id": "task-7f3a", "entity_id": "cust-42"}),
task_delegation,
).unwrap();
// Verification passes
// Different task_id - BLOCKED
let bad_inv = Invocation::create(
&agent,
"resolve",
serde_json::json!({"task_id": "task-other", "entity_id": "cust-42"}),
task_delegation,
).unwrap();
// Error: CaveatViolation("context 'task_id' expected 'task-7f3a', got 'task-other'")Edge Cases
- Multiple
contextcaveats can coexist. Each one requires its key to be present and match. You can bind to both atask_idand asession_idsimultaneously. - Missing key in args fails verification (reports the actual value as
<missing>). - Values are compared as strings. Numeric task IDs should be serialized consistently.
custom
An arbitrary key-value caveat for domain-specific rules.
JSON
{ "type": "custom", "value": { "key": "env", "value": "staging" } }Behavior
The invocation's args must contain a field matching the custom key, and its value must be deeply equal to the custom value. The value can be any JSON type - string, number, boolean, object, or array.
When to Use
When the built-in caveat types do not cover your constraint. Custom caveats let you encode domain-specific rules without forking the library.
Examples:
{"key": "env", "value": "staging"}- restrict to staging environment{"key": "region", "value": "us-east-1"}- restrict to a specific region{"key": "approved_models", "value": ["gpt-4", "claude-3"]}- restrict which LLM models can be used
Edge Cases
- Comparison is deep equality via JSON serialization. Object key ordering matters in some implementations - use consistent serialization.
- Like all caveats, the key must be present in
args. Omitting it fails verification.
Combining Caveats
Caveats are combined with AND semantics. Every caveat in the chain must pass for the invocation to be verified. This is how you build precise authority grants:
let delegation = Delegation::create_root(
&root,
&agent.identity().did,
vec![
// What actions
Caveat::ActionScope(vec!["resolve".into()]),
// On which resources
Caveat::Resource("entity:customer:*".into()),
// How much it can cost
Caveat::MaxCost(1.0),
// Until when
Caveat::ExpiresAt("2026-03-16T12:00:00.000Z".into()),
// For which task
Caveat::Context { key: "task_id".into(), value: "task-7f3a".into() },
],
).unwrap();
// This agent can ONLY:
// - resolve (not search, merge, etc.)
// - customer entities (not orders, not accounts)
// - at most $1.00 per invocation
// - until noon on March 16
// - for task-7f3a specificallyAccumulation Semantics
Caveats in a delegation chain accumulate. When Agent A delegates to Agent B with caveats X, and Agent B delegates to Agent C with additional caveats Y, then Agent C's invocations are checked against X + Y.
This is the core attenuation property: caveats can only narrow authority, never widen it.
Root -> Manager: [action_scope: [resolve, search, merge]]
Manager -> Worker: [action_scope: [resolve]]
Worker's effective caveats:
1. action_scope: [resolve, search, merge] (from root)
2. action_scope: [resolve] (from manager)
Worker invokes "search":
- Check #1: "search" in [resolve, search, merge]? YES
- Check #2: "search" in [resolve]? NO -> BLOCKEDEven though the root allowed searching, the manager's narrower delegation blocks it. The worker cannot escape the restrictions imposed by any link in its chain.
WARNING
Caveats are extracted from the signed payload of each delegation, not from the outer caveats field. This prevents tampering - if someone modifies the outer caveats array to widen authority, the signed payload still contains the original restrictions. See Verification for details.
Recipes
Read-Only Agent
[
{ "type": "action_scope", "value": ["resolve", "search", "get_entity"] }
]Time-Boxed Batch Worker
[
{ "type": "action_scope", "value": ["resolve", "merge"] },
{ "type": "expires_at", "value": "2026-03-15T06:00:00.000Z" },
{ "type": "max_cost", "value": 100.0 }
]Single-Task Specialist
[
{ "type": "action_scope", "value": ["resolve"] },
{ "type": "resource", "value": "entity:customer:*" },
{ "type": "context", "value": { "key": "task_id", "value": "dedup-run-42" } },
{ "type": "expires_at", "value": "2026-03-15T12:00:00.000Z" },
{ "type": "max_cost", "value": 5.0 }
]Staging-Only Agent
[
{ "type": "action_scope", "value": ["resolve", "search", "merge", "split"] },
{ "type": "custom", "value": { "key": "env", "value": "staging" } }
]