Mandate Evidence Specification v1¶
Status: Draft v1.0.5 (January 2026) Scope: Cryptographically-signed user authorization evidence for AI agent tool calls ADR: ADR-017: Mandate/Intent Evidence
Changelog: - v1.0.5: Runtime semantics clarifications: - Revocation timing: explicit "no skew" rule (hard cutoff at revoked_at) - Audit log deduplication: normative guidance for retry scenarios - v1.0.4: Fixed normative inconsistencies: - use_id MUST be deterministic (content-addressed), not UUID - Fixed signature field names in examples (content_id + signed_payload_digest) - Amount canonicalization: consistent "no trailing zeros" rule with examples - Removed created_at from hashable transaction_object schema - tool_call_id MUST in tool.decision schema - require_signed_lifecycle_events type clarified as enum - v1.0.3: Added normative runtime enforcement section (§7), SQLite store schema, nonce replay prevention, transaction_ref verification flow, idempotency semantics, crash recovery model - v1.0.2: Fixed payload_digest semantics (DSSE alignment), removed mandate_kind=revocation, added conformance test vectors, normative transaction_ref schema, require_signed_lifecycle default for commit - v1.0.1: Fixed mandate_id circularity, added lifecycle event trust model, normative glob semantics, operation_class ordering
1. Overview¶
This specification defines the mandate evidence format for proving user authorization of AI agent actions. Mandates are cryptographically-signed, tamper-proof records that link tool decisions to explicit user intent.
Design Principles¶
- AP2-aligned - Compatible with emerging agent commerce protocols (AP2, UCP, ACP)
- Deterministic - Same mandate content always produces same
mandate_id - Offline-verifiable - Verification requires only trusted keys, no network
- Privacy-preserving - Opaque principal identifiers, no PII
- DSSE-compatible - Uses same signing envelope as tool signing
Mandate Kinds¶
| Kind | Purpose | Allowed Operation Classes |
|---|---|---|
intent | Standing authority for discovery/browsing | read |
transaction | Final authorization for commits/purchases | read, write, commit |
Note (v1.0.2):
revocationwas removed as a mandate kind. Revocation is handled exclusively viaassay.mandate.revoked.v1events. This simplifies the model: mandates authorize, events record lifecycle transitions.
2. Normative Definitions¶
2.1 mandate_id Computation (MUST)¶
Where: - JCS = RFC 8785 JSON Canonicalization Scheme - hashable_content = the data object excluding both mandate_id and signature fields - The result is a 71-character string: sha256: (7 chars) + 64 hex chars
Critical: The mandate_id is computed from content that does NOT include mandate_id itself. This avoids circularity and ensures implementations in any language produce identical IDs.
Normative example:
// Step 1: Build hashable_content (WITHOUT mandate_id and signature):
{
"mandate_kind": "intent",
"principal": { "subject": "user-123", "method": "oidc" },
"scope": { "tools": ["search_*"], "operation_class": "read" },
"validity": { "issued_at": "2026-01-28T10:00:00Z" },
"constraints": {},
"context": { "audience": "myorg/app", "issuer": "auth.myorg.com" }
}
// Step 2: JCS canonical form (single line, sorted keys):
{"constraints":{},"context":{"audience":"myorg/app","issuer":"auth.myorg.com"},"mandate_kind":"intent","principal":{"method":"oidc","subject":"user-123"},"scope":{"operation_class":"read","tools":["search_*"]},"validity":{"issued_at":"2026-01-28T10:00:00Z"}}
// Step 3: Compute mandate_id = "sha256:" + hex(SHA256(canonical_bytes))
// Step 4: Set data.mandate_id = computed mandate_id
// Step 5: Proceed to signing (which signs the full content including mandate_id)
Digest semantics (v1.0.2):
The signature object contains TWO digest fields:
| Field | Computed From | Purpose |
|---|---|---|
content_id | JCS(hashable_content) without mandate_id/signature | Content-addressed identifier = mandate_id |
signed_payload_digest | JCS(signable_content) with mandate_id, without signature | Standard DSSE payload digest |
Binding rule: Verifiers MUST check BOTH:
1. mandate_id == signature.content_id == "sha256:" + hex(SHA256(JCS(content_without_mandate_id_and_signature)))
2. signature.signed_payload_digest == "sha256:" + hex(SHA256(JCS(content_with_mandate_id_but_without_signature)))
This separates the content-addressed identifier (for lookups/references) from the signed payload digest (for DSSE verification), avoiding implementer confusion.
2.2 Operation Classes (Normative Ordering)¶
Normative ordering: read < write < commit
| Class | Ordinal | Description | Example Tools | Mandate Kind Required |
|---|---|---|---|---|
read | 0 | Discovery, browsing, read-only | search_*, list_*, get_* | intent or transaction |
write | 1 | Modifications, non-financial | update_*, fs.write_*, edit_* | intent or transaction |
commit | 2 | Financial transactions, irreversible | purchase_*, transfer_*, order_* | transaction only |
Highest-allowed semantics:
When a mandate specifies operation_class, it authorizes that class and all lower classes: - operation_class: "commit" → allows read, write, commit - operation_class: "write" → allows read, write (NOT commit) - operation_class: "read" → allows only read
Default: If operation_class is absent, default is read.
2.3 Payload Type¶
This value MUST be used in signature.payload_type for type confusion prevention.
3. Event Schemas¶
3.1 assay.mandate.v1¶
CloudEvents envelope with mandate grant payload.
CloudEvents requirements (MUST):
| Field | Requirement |
|---|---|
specversion | MUST be "1.0" |
id | MUST be present, unique per source |
type | MUST be "assay.mandate.v1" |
source | MUST be present, valid URI |
time | MUST be present, RFC 3339 UTC timestamp |
datacontenttype | MUST be "application/json" |
data | MUST be JSON object (not string-encoded) |
subject | MAY be present for tool_call_id correlation |
v1.0.2: Explicit required attributes list aligns with CloudEvents v1.0 §2.1. The
subjectattribute MAY be used as CloudEvents-native correlation alternative todata.tool_call_id.
{
"specversion": "1.0",
"id": "evt_abc123",
"type": "assay.mandate.v1",
"source": "assay://myorg/myapp",
"time": "2026-01-28T10:00:00Z",
"datacontenttype": "application/json",
"data": {
"mandate_id": "sha256:abc123def456...",
"mandate_kind": "intent",
"principal": {
"subject": "opaque-subject-id",
"method": "oidc",
"display": "Alice (shopping)",
"credential_ref": "sha256:789xyz..."
},
"scope": {
"tools": ["search_*", "list_*"],
"resources": ["/products/**", "/catalog/**"],
"operation_class": "read",
"max_value": null
},
"validity": {
"not_before": "2026-01-28T10:00:00Z",
"expires_at": "2026-01-28T18:00:00Z",
"issued_at": "2026-01-28T09:55:00Z"
},
"constraints": {
"single_use": false,
"max_uses": null,
"require_confirmation": false
},
"context": {
"audience": "myorg/myapp",
"issuer": "auth.myorg.com",
"nonce": null,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
},
"signature": {
"version": 1,
"algorithm": "ed25519",
"payload_type": "application/vnd.assay.mandate+json;v=1",
"content_id": "sha256:abc123def456...",
"signed_payload_digest": "sha256:789abc012def...",
"key_id": "sha256:signing-key-id...",
"signature": "base64-encoded-signature...",
"signed_at": "2026-01-28T09:55:00Z"
}
}
}
3.2 Field Definitions¶
3.2.1 Root Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
mandate_id | string | Yes | Content-addressed identifier (see §2.1) |
mandate_kind | enum | Yes | One of: intent, transaction |
principal | object | Yes | Who granted the mandate |
scope | object | Yes | What the mandate authorizes |
validity | object | Yes | When the mandate is valid |
constraints | object | Yes | Usage limits |
context | object | Yes | Binding context for replay prevention |
signature | object | No | Cryptographic signature (see §4) |
3.2.2 Principal Object¶
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | Yes | Opaque identifier (MUST NOT contain PII) |
method | enum | Yes | Authentication method (see below) |
display | string | No | Human-readable name (UX only, MUST NOT use for verification) |
credential_ref | string | No | Hash reference to verifiable credential |
method enum values:
| Value | Description |
|---|---|
oidc | OpenID Connect (OAuth 2.0) |
did | Decentralized Identifier |
spiffe | SPIFFE/SPIRE workload identity |
local_user | Local system user |
service_account | Service-to-service |
api_key | API key authentication |
credential_ref format:
Where credential_bytes is: - For JWT VP: raw UTF-8 bytes of the compact JWT - For JSON VP: JCS-canonicalized bytes - v1: Opaque string, MUST be stable within organization
3.2.3 Scope Object¶
| Field | Type | Required | Description |
|---|---|---|---|
tools | string[] | Yes | Tool name patterns (glob syntax) |
resources | string[] | No | Resource path patterns (glob syntax) |
operation_class | enum | No | Highest operation class allowed (default: read) |
max_value | object | No | Maximum transaction value |
transaction_ref | string | No | Hash of cart/order intent object (for commit mandates) |
transaction_ref (for commit mandates):
For operation_class: commit mandates, transaction_ref provides object-level authorization binding:
{
"scope": {
"tools": ["purchase_item"],
"operation_class": "commit",
"transaction_ref": "sha256:cart-content-hash-here..."
}
}
Computation: transaction_ref = "sha256:" + hex(SHA256(JCS(transaction_object)))
Where transaction_object is the cart, order, or payment intent that this mandate authorizes. This prevents mandate reuse for different transactions within the validity window.
Transaction Intent Object Schema (v1.0.4 NORMATIVE):
For interoperability, the transaction_object MUST conform to this schema when computing transaction_ref:
{
"merchant": "string", // REQUIRED: Merchant identifier
"items": [ // REQUIRED: Line items (order preserved)
{
"product_id": "string", // REQUIRED: Product identifier
"quantity": 1, // REQUIRED: Integer quantity
"unit_price": "10" // OPTIONAL: Decimal string (canonical form)
}
],
"total": { // REQUIRED: Total amount
"amount": "100", // Decimal string, canonical form
"currency": "USD" // ISO 4217, MUST be uppercase
},
"idempotency_key": "string" // OPTIONAL: Stable idempotency key
}
MUST NOT include in hashable transaction_object: - created_at, updated_at, or any timestamps - Request-specific nonces or session IDs - Any fields that vary per-request
Amount canonicalization rules (NORMATIVE): - MUST be decimal strings (never floats) - MUST strip leading zeros: "007" → "7" - MUST strip trailing zeros in fraction: "10.00" → "10", "10.50" → "10.5" - MUST strip trailing dot if fraction empty: "10." → "10" - Examples: "99.99" (ok), "100" (ok), "10.5" (ok), "100.00" (WRONG)
Normalization rules for JCS hashing: - amount fields MUST use canonical decimal form (see above) - currency MUST be uppercase ISO 4217 - items array order MUST be preserved (JCS preserves array order) - No optional fields should be present with null values; omit them entirely
Verification: Runtime MUST verify that the actual transaction content hashes to the same value as transaction_ref before allowing commit tools.
tools pattern syntax (NORMATIVE):
Pattern matching rules (producers and verifiers MUST use identical algorithm):
| Rule | Specification |
|---|---|
| Anchoring | Pattern MUST match the full tool name (not substring) |
| Case sensitivity | Matching is case-sensitive |
* (single glob) | Matches any sequence of characters except . (dot) |
** (double glob) | Matches any sequence of characters including . (dot) |
| Literal characters | All non-glob characters match themselves exactly |
| Escaping | Use \* to match literal *; use \\ to match literal \ |
Examples:
search_* → matches: search_products, search_users
→ does NOT match: search.products (dot not matched by *)
fs.read_* → matches: fs.read_file, fs.read_dir
→ does NOT match: fs.read.file (second dot)
fs.** → matches: fs.read_file, fs.write.nested.path
* → matches: search, list (single-segment names only)
** → matches: any tool name (universal wildcard)
Implementation requirements (v1.0.2):
⚠️ MUST NOT use OS glob libraries. Standard glob implementations (Python's
fnmatch, shell glob, Go'sfilepath.Match) use different semantics for*(often matches.). Implementers MUST use the Assay Glob v1 algorithm defined above, or a conforming implementation.
Conforming implementations are available in: - Rust: assay_evidence::mandate::glob - Python: assay.glob (planned)
Canonicalization: Tool names MUST be normalized to lowercase before matching if the runtime uses case-insensitive tool names. The tools array in mandates SHOULD use lowercase patterns for maximum compatibility.
max_value object:
{
"amount": "100.00", // Decimal as string, MUST NOT use float
"currency": "USD" // ISO 4217 currency code
}
3.2.4 Validity Object¶
| Field | Type | Required | Description |
|---|---|---|---|
issued_at | datetime | Yes | When mandate was created (ISO 8601 UTC) |
not_before | datetime | No | Mandate valid after this time |
expires_at | datetime | No | Mandate expires at this time |
Time comparison semantics:
not_before: mandate valid ifnow >= not_beforeexpires_at: mandate valid ifnow < expires_at- If omitted: no constraint on that boundary
3.2.5 Constraints Object¶
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
single_use | boolean | No | false | Syntactic sugar for max_uses: 1 |
max_uses | integer | No | null | Maximum uses (null = unlimited) |
require_confirmation | boolean | No | false | Require interactive confirmation |
max_uses semantics:
| Value | Meaning |
|---|---|
null | Unlimited uses |
1 | Single use (equivalent to single_use: true) |
N | Maximum N uses; rejected after Nth use |
3.2.6 Context Object¶
| Field | Type | Required | Description |
|---|---|---|---|
audience | string | Yes | Target application/org identifier |
issuer | string | Yes | Signing authority identifier |
nonce | string | No | Session binding (for interactive flows) |
traceparent | string | No | W3C Trace Context for correlation |
3.3 assay.mandate.used.v1¶
Consumption receipt for usage tracking.
{
"specversion": "1.0",
"id": "evt_use456",
"type": "assay.mandate.used.v1",
"source": "assay://myorg/myapp",
"time": "2026-01-28T10:05:00Z",
"datacontenttype": "application/json",
"data": {
"mandate_id": "sha256:abc123def456...",
"use_id": "sha256:use789...",
"tool_call_id": "tc_456",
"consumed_at": "2026-01-28T10:05:00Z",
"use_count": 1
}
}
| Field | Type | Required | Description |
|---|---|---|---|
mandate_id | string | Yes | Reference to consumed mandate |
use_id | string | Yes | Unique identifier for this use |
tool_call_id | string | Yes | Tool call that consumed the mandate |
consumed_at | datetime | Yes | When consumption occurred |
use_count | integer | Yes | Ordinal use number (1-indexed) |
3.4 assay.mandate.revoked.v1¶
Revocation event for mandate cancellation.
{
"specversion": "1.0",
"id": "evt_rev789",
"type": "assay.mandate.revoked.v1",
"source": "assay://myorg/myapp",
"time": "2026-01-28T10:30:00Z",
"datacontenttype": "application/json",
"data": {
"mandate_id": "sha256:abc123def456...",
"revoked_at": "2026-01-28T10:30:00Z",
"reason": "user_requested",
"revoked_by": "opaque-subject-id"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
mandate_id | string | Yes | Mandate being revoked |
revoked_at | datetime | Yes | Effective revocation time |
reason | enum | Yes | Revocation reason |
revoked_by | string | Yes | Subject who revoked |
reason enum values:
| Value | Description |
|---|---|
user_requested | User explicitly revoked |
admin_override | Administrative action |
policy_violation | Automated policy enforcement |
expired_early | Voluntary early expiration |
Revocation semantics:
| Aspect | Behavior |
|---|---|
| Effect | Mandate MUST NOT be used after revoked_at |
| Retroactivity | NOT retroactive; uses before revoked_at remain valid |
| Ordering | Runtime: reject if now >= revoked_at; Lint: compare tool.decision.time vs revoked_at |
3.5 Event Trust Model¶
Mandate lifecycle events (used, revoked) are vulnerable to injection attacks without proper trust controls.
Trust requirements (MUST):
| Event Type | Trust Requirement |
|---|---|
assay.mandate.v1 | MUST be signed (as per §4) |
assay.mandate.used.v1 | MUST originate from trusted source (see below) |
assay.mandate.revoked.v1 | MUST originate from trusted source (see below) |
Trusted source verification:
# In policy config
mandate_trust:
# Trusted sources for lifecycle events
trusted_event_sources:
- "assay://myorg/myapp"
- "assay://myorg/auth-service"
# Require signed lifecycle events
# DEFAULT (v1.0.2): true when mandate_kind=transaction OR tool ∈ commit_tools
require_signed_lifecycle_events: auto # "auto" | true | false
v1.0.2 default behavior for require_signed_lifecycle_events: auto:
| Mandate Kind | Tool Classification | Lifecycle Events |
|---|---|---|
intent | read tools | Source check only |
intent | write tools | Source check only |
transaction | any tool | MUST be signed |
| any | commit tools | MUST be signed |
This default acknowledges that lifecycle events for high-value operations (transactions, commits) are high-risk injection targets.
Verification rules:
event.sourceMUST be intrusted_event_sourceslist- If signatures required (see table above):
usedandrevokedevents MUST include asignatureobject- Signature verification follows same algorithm as mandates (see §4)
- Signature
payload_typeMUST beapplication/vnd.assay.mandate.used+json;v=1orapplication/vnd.assay.mandate.revoked+json;v=1 - Evidence bundles MUST be treated as tamper-evident containers; events from untrusted sources MUST be rejected at ingest
Adversarial model considerations:
Without these controls, attackers could: - Inject fake revoked events → DoS (mandate appears invalid) - Inject fake used events → Force max_uses exceeded - Replay old lifecycle events → State confusion
Optional signature for lifecycle events:
For high-risk deployments (commerce, financial), add signature to used/revoked events:
{
"type": "assay.mandate.used.v1",
"data": {
"mandate_id": "sha256:...",
"use_id": "sha256:...",
"tool_call_id": "tc_456",
"consumed_at": "2026-01-28T10:05:00Z",
"use_count": 1,
"signature": {
"version": 1,
"algorithm": "ed25519",
"payload_type": "application/vnd.assay.mandate.used+json;v=1",
"content_id": "sha256:...",
"signed_payload_digest": "sha256:...",
"key_id": "sha256:...",
"signature": "base64...",
"signed_at": "2026-01-28T10:05:00Z"
}
}
}
3.6 Tool Decision Extension¶
Extended assay.tool.decision with mandate linkage.
{
"type": "assay.tool.decision",
"data": {
"tool": "purchase_item",
"decision": "allow",
"reason_code": "P_MANDATE_VALID",
"args_schema_hash": "sha256:...",
"tool_call_id": "tc_456",
"mandate_id": "sha256:abc123def456...",
"mandate_scope_match": true,
"mandate_kind_match": true
}
}
| Field | Type | Required | Description |
|---|---|---|---|
tool_call_id | string | MUST | Unique identifier for this tool call (idempotency key) |
mandate_id | string | Conditional | Mandate authorizing this decision (MUST for commit tools) |
mandate_scope_match | boolean | No | Whether tool matched mandate scope |
mandate_kind_match | boolean | No | Whether mandate kind allows operation class |
reason_code | string | MUST | Machine-parseable decision reason (see Error Taxonomy §7.10) |
tool_call_id requirements: - MUST be unique per tool call attempt - MUST be stable across retries (same logical call = same ID) - Used for idempotency in mandate consumption and crash recovery correlation
4. Signing Process¶
Mandate signing follows the same DSSE-compatible process as SPEC-Tool-Signing-v1.
4.1 Signature Object¶
{
"version": 1,
"algorithm": "ed25519",
"payload_type": "application/vnd.assay.mandate+json;v=1",
"content_id": "sha256:abc123...",
"signed_payload_digest": "sha256:def789...",
"key_id": "sha256:signing-key-id...",
"signature": "base64-encoded-signature...",
"signed_at": "2026-01-28T09:55:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
version | integer | Yes | Schema version. MUST be 1 |
algorithm | string | Yes | MUST be "ed25519" for v1 |
payload_type | string | Yes | MUST be "application/vnd.assay.mandate+json;v=1" |
content_id | string | Yes | MUST equal mandate_id (content-addressed identifier) |
signed_payload_digest | string | Yes | SHA256 of signed payload bytes (DSSE standard) |
key_id | string | Yes | SHA-256 of SPKI public key |
signature | string | Yes | Base64-encoded Ed25519 signature |
signed_at | datetime | Yes | Signing timestamp (metadata only) |
v1.0.2 change: Renamed
payload_digesttocontent_idand addedsigned_payload_digestfor DSSE alignment. This prevents implementer confusion where "payload_digest" is expected to be the digest of the signed payload.
4.2 Signing Algorithm¶
1. Build hashable_content = data object WITHOUT {mandate_id, signature}
2. Compute canonical_for_id = JCS(hashable_content)
3. Compute mandate_id = "sha256:" + hex(SHA256(canonical_for_id))
4. Build signable_content = hashable_content + {mandate_id: mandate_id}
5. Compute canonical_for_sig = JCS(signable_content)
6. Compute signed_payload_digest = "sha256:" + hex(SHA256(canonical_for_sig))
7. Compute PAE = DSSEv1_PAE(payload_type, canonical_for_sig)
8. Sign: signature_bytes = ed25519_sign(private_key, PAE)
9. Build signature object:
- content_id = mandate_id
- signed_payload_digest = signed_payload_digest (from step 6)
- signature = base64_encode_with_padding(signature_bytes)
10. Build final_content = signable_content + {signature: signature_object}
11. Emit CloudEvents envelope with data = final_content
Important: - Steps 1-3 compute the content-addressed ID from content WITHOUT mandate_id (avoiding circularity) - Steps 4-6 compute the signed payload digest from content WITH mandate_id - Steps 7-8 sign using DSSE PAE encoding - content_id = identifier for lookups/references - signed_payload_digest = standard DSSE payload digest for verification
4.3 PAE Encoding (DSSE)¶
PAE(type, payload) =
"DSSEv1" + SP +
LEN(type) + SP + type + SP +
LEN(payload) + SP + payload
Where:
SP = 0x20 (space character)
LEN(s) = ASCII decimal byte length, no leading zeros
5. Verification Process¶
5.1 Verification Algorithm¶
1. Parse event, extract data as mandate_content
2. Extract sig = mandate_content.signature
3. If sig is missing:
a. If config.require_signed: FAIL (UNSIGNED)
b. Else: PASS (unsigned allowed)
4. Validate sig.version == 1
5. Validate sig.algorithm == "ed25519"
6. Validate sig.payload_type == "application/vnd.assay.mandate+json;v=1"
// Verify content_id == mandate_id (content-addressed)
7. Extract claimed_id = mandate_content.mandate_id
8. Validate claimed_id == sig.content_id
9. Build hashable = mandate_content WITHOUT {mandate_id, signature}
10. Compute canonical_for_id = JCS(hashable)
11. Compute computed_id = "sha256:" + hex(SHA256(canonical_for_id))
12. Validate computed_id == claimed_id // CRITICAL: proves ID is content-addressed
// Verify signed_payload_digest (DSSE alignment)
13. Build signable = mandate_content WITHOUT {signature} (but WITH mandate_id)
14. Compute canonical_for_sig = JCS(signable)
15. Compute computed_signed_digest = "sha256:" + hex(SHA256(canonical_for_sig))
16. Validate computed_signed_digest == sig.signed_payload_digest
// Verify signature
17. Compute PAE = DSSEv1_PAE(sig.payload_type, canonical_for_sig)
18. Obtain public_key by sig.key_id from trust policy
19. Verify ed25519_verify(public_key, PAE, base64_decode(sig.signature))
20. If invalid: FAIL (INVALID_SIGNATURE)
// Additional checks
21. Check context binding (see §5.2)
22. Check validity window with clock skew (see §5.3)
23. Check revocation status (see §5.4)
24. PASS
Note: Steps 7-12 verify content addressing; steps 13-16 verify signed payload digest (DSSE standard). Both MUST pass.
5.2 Context Binding Verification¶
1. Load config.expected_audience and config.trusted_issuers
2. Validate mandate.context.audience == config.expected_audience
3. Validate mandate.context.issuer IN config.trusted_issuers
4. If nonce present: verify against session store (implementation-specific)
5. If any check fails: FAIL (CONTEXT_MISMATCH)
5.3 Validity Window Verification¶
Runtime (wall clock with clock skew):
For runtime enforcement with clock skew tolerance, see §7.6.
fn check_validity(
mandate: &Mandate,
now: DateTime<Utc>,
clock_skew: Duration, // default: 30 seconds
) -> Result<()> {
if let Some(nb) = mandate.validity.not_before {
if now < nb - clock_skew { return Err(NotYetValid); }
}
if let Some(exp) = mandate.validity.expires_at {
if now >= exp + clock_skew { return Err(Expired); }
}
Ok(())
}
Lint (event time):
fn check_validity_lint(mandate: &Mandate, event_time: DateTime<Utc>) -> Result<()> {
// Same logic but WITHOUT clock skew (audit context)
// Uses event.time instead of Utc::now()
}
5.4 Revocation Check¶
1. Query store for revocation events with matching mandate_id
2. If revocation exists:
a. Runtime: reject if now >= revocation.revoked_at
b. Lint: reject if tool_decision.time >= revocation.revoked_at
5.5 Exit Codes¶
| Code | Name | Description |
|---|---|---|
| 0 | SUCCESS | Valid signature, trusted key, valid context |
| 1 | ERROR | I/O error, malformed JSON |
| 2 | UNSIGNED | No signature when required |
| 3 | UNTRUSTED | Valid signature, untrusted key |
| 4 | INVALID_SIGNATURE | Bad signature, digest mismatch |
| 5 | CONTEXT_MISMATCH | Audience/issuer verification failed |
| 6 | EXPIRED | Mandate outside validity window |
| 7 | REVOKED | Mandate has been revoked |
| 8 | MAX_USES_EXCEEDED | Consumption limit reached |
6. Trust Policy¶
6.1 Configuration Format¶
# assay.yaml or policy.yaml
mandate_trust:
# Require all mandates to be signed
require_signed: true
# Expected audience (must match mandate.context.audience)
# Format: {org}/{app} or {org}/{app}/{env}
expected_audience: "myorg/myapp"
# Trusted issuers (mandate.context.issuer must be in list)
# Comparison is exact string match
trusted_issuers:
- "auth.myorg.com"
- "idp.partner.com"
# Trusted signing key IDs
trusted_key_ids:
- "sha256:abc123..." # Production key
- "sha256:def456..." # CI key
# Allow embedded public key (development only)
allow_embedded_key: false
# Clock skew tolerance in seconds (default: 30)
clock_skew_tolerance_seconds: 30
# Trusted sources for lifecycle events (used, revoked)
trusted_event_sources:
- "assay://myorg/myapp"
- "assay://myorg/auth-service"
# Require signed lifecycle events (recommended for high-risk)
require_signed_lifecycle_events: false
# Tool classification for operation_class enforcement
# Patterns use same glob syntax as mandate scope
commit_tools:
- "purchase_*"
- "transfer_*"
- "order_*"
- "payment_*"
write_tools:
- "update_*"
- "edit_*"
- "fs.write_*"
- "fs.delete_*"
6.2 Operation Class Enforcement¶
To determine if a tool requires transaction mandate:
1. Match tool name against commit_tools patterns
2. If match: require mandate_kind == "transaction"
3. Match tool name against write_tools patterns
4. If match: require mandate_kind in ["intent", "transaction"]
5. Else: require any valid mandate
7. Runtime Enforcement (Normative)¶
This section defines the runtime behavior for mandate authorization. Runtime enforcement provides real-time guarantees that lint-time analysis cannot (e.g., atomic single-use, nonce replay prevention).
7.1 Architecture Overview¶
┌─────────────────────────────────────────────────────────────────┐
│ MCP Proxy │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Policy Check │───▶│ Mandate Auth │───▶│ Forward to Tool │ │
│ └──────────────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌───────▼───────┐ ┌──────▼──────┐ │
│ │ MandateStore │ │ Tool Server │ │
│ │ (SQLite) │ └─────────────┘ │
│ └───────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Execution order (consume-before-exec):
- Policy check (deny/allow lists, rate limits)
- Mandate verification (signature, validity, scope)
- Mandate consumption (atomic, idempotent)
- Emit
assay.mandate.used.v1event - Forward to tool server
- Emit
assay.tool.decisionevent (ALWAYS, even on failure)
7.2 SQLite Store Schema (Normative)¶
Implementations MUST use a durable store with atomic transactions. SQLite with WAL mode is the reference implementation.
-- Schema version: 2 (mandate runtime enforcement)
PRAGMA journal_mode = WAL;
-- Mandate metadata (immutable after insert)
CREATE TABLE IF NOT EXISTS mandates (
mandate_id TEXT PRIMARY KEY, -- sha256:...
mandate_kind TEXT NOT NULL, -- intent | transaction
audience TEXT NOT NULL,
issuer TEXT NOT NULL,
expires_at TEXT, -- ISO8601, nullable = no expiry
single_use INTEGER NOT NULL DEFAULT 0,
max_uses INTEGER, -- nullable = unlimited
use_count INTEGER NOT NULL DEFAULT 0,
canonical_digest TEXT NOT NULL, -- sha256 of JCS(hashable_content)
key_id TEXT NOT NULL,
inserted_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Use tracking (append-only, immutable)
CREATE TABLE IF NOT EXISTS mandate_uses (
use_id TEXT PRIMARY KEY, -- Content-addressed (see §7.4)
mandate_id TEXT NOT NULL REFERENCES mandates(mandate_id),
tool_call_id TEXT NOT NULL UNIQUE, -- Idempotency key
use_count INTEGER NOT NULL, -- 1-based, at time of use
consumed_at TEXT NOT NULL, -- ISO8601
tool_name TEXT,
operation_class TEXT, -- read | write | commit
nonce TEXT, -- Copy from context (for audit)
source_run_id TEXT,
UNIQUE(mandate_id, use_count) -- Enforce monotonic
);
-- Nonce replay prevention (transaction mandates)
CREATE TABLE IF NOT EXISTS nonces (
audience TEXT NOT NULL,
issuer TEXT NOT NULL,
nonce TEXT NOT NULL,
mandate_id TEXT NOT NULL,
first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (audience, issuer, nonce)
);
CREATE INDEX IF NOT EXISTS idx_mandates_audience_issuer
ON mandates(audience, issuer);
CREATE INDEX IF NOT EXISTS idx_mandate_uses_mandate_id
ON mandate_uses(mandate_id);
7.3 Mandate Upsert (MUST)¶
Before consuming a mandate, it MUST exist in the store. Implementations MUST use upsert semantics:
INSERT INTO mandates (
mandate_id, mandate_kind, audience, issuer, expires_at,
single_use, max_uses, use_count, canonical_digest, key_id
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(mandate_id) DO NOTHING;
Collision detection (SHOULD): After upsert, implementations SHOULD verify that stored metadata matches the mandate being consumed. Mismatches indicate either: - Hash collision (cryptographically unlikely) - Store corruption - Attempted mandate_id spoofing
// After upsert, verify consistency
let stored = store.get_mandate(mandate_id)?;
if stored.canonical_digest != computed_digest
|| stored.audience != mandate.context.audience
|| stored.issuer != mandate.context.issuer {
return Err(MandateError::StoreInconsistency);
}
7.4 Consume Flow (Normative)¶
The consume_mandate() function MUST be atomic and idempotent.
Function signature:
async fn consume_mandate(
store: &MandateStore,
mandate_id: &str,
tool_call_id: &str, // Idempotency key
nonce: Option<&str>, // From mandate.context.nonce
audience: &str,
issuer: &str,
single_use: bool,
max_uses: Option<u32>,
tool_name: &str,
operation_class: OperationClass,
) -> Result<AuthzReceipt, AuthzError>
Atomic transaction (pseudocode):
BEGIN IMMEDIATE; -- Acquire write lock immediately
-- Step 1: Idempotency check
SELECT use_id, use_count, consumed_at
FROM mandate_uses WHERE tool_call_id = ?;
-- If found: COMMIT and return existing receipt (no increment)
-- Step 2: Nonce replay check (transaction mandates only)
-- Use INSERT to atomically check+insert (no SELECT first)
INSERT INTO nonces (audience, issuer, nonce, mandate_id)
VALUES (?, ?, ?, ?);
-- If UNIQUE constraint fails: ROLLBACK, return NonceReplay error
-- Step 3: Get current use count
SELECT use_count FROM mandates WHERE mandate_id = ?;
-- If not found: ROLLBACK, return MandateNotFound error
-- Step 4: Check constraints
-- If single_use AND use_count > 0: ROLLBACK, return AlreadyUsed
-- If max_uses AND use_count >= max_uses: ROLLBACK, return MaxUsesExceeded
-- Step 5: Atomic increment + insert use record
UPDATE mandates SET use_count = use_count + 1 WHERE mandate_id = ?;
INSERT INTO mandate_uses (
use_id, mandate_id, tool_call_id, use_count, consumed_at,
tool_name, operation_class, nonce, source_run_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
COMMIT;
use_id computation (NORMATIVE v1.0.4):
The use_id MUST be content-addressed (deterministic) for audit verifiability:
Example: mandate_id="sha256:abc...", tool_call_id="tc_001", use_count=1 → use_id = "sha256:" + hex(SHA256("sha256:abc...:tc_001:1"))
This allows third parties to recompute and verify use receipts without runtime access.
Critical invariants:
| Invariant | Enforcement |
|---|---|
| Idempotency | tool_call_id UNIQUE constraint + check-before-increment |
| Single-use | single_use=true → reject if use_count > 0 |
| Max uses | use_count < max_uses check before increment |
| Nonce replay | INSERT into nonces table (not SELECT+INSERT) |
| Monotonic counts | UNIQUE(mandate_id, use_count) constraint |
| use_id determinism | Content-addressed from mandate_id + tool_call_id + use_count |
7.5 Nonce Replay Prevention (Normative)¶
For mandate_kind=transaction, nonces provide session binding and replay prevention.
Requirements:
| Requirement | Specification |
|---|---|
| Scope | Nonces are scoped to (audience, issuer) tuple |
| Atomicity | Check+insert MUST be atomic (single INSERT, not SELECT+INSERT) |
| Persistence | Nonces MUST survive process restart |
| Error | Replay attempt MUST return NonceReplay error |
Implementation pattern:
// WRONG: Race condition between SELECT and INSERT
if store.nonce_exists(audience, issuer, nonce) {
return Err(NonceReplay);
}
store.insert_nonce(audience, issuer, nonce, mandate_id);
// CORRECT: Atomic INSERT, handle constraint violation
match store.insert_nonce(audience, issuer, nonce, mandate_id) {
Ok(_) => { /* continue */ }
Err(e) if e.is_unique_violation() => {
return Err(AuthzError::NonceReplay { nonce: nonce.to_string() });
}
Err(e) => return Err(e.into()),
}
7.6 Validity Window Enforcement (Normative)¶
Clock skew tolerance:
Runtime MUST allow configurable clock skew (default: 30 seconds).
Normative validity check:
let now = Utc::now();
let skew = Duration::seconds(config.clock_skew_tolerance_seconds);
// Not yet valid check
if let Some(not_before) = &mandate.validity.not_before {
if now < *not_before - skew {
return Err(AuthzError::NotYetValid {
not_before: *not_before,
now,
});
}
}
// Expired check (widened window)
if let Some(expires_at) = &mandate.validity.expires_at {
if now >= *expires_at + skew {
return Err(AuthzError::Expired {
expires_at: *expires_at,
now,
});
}
}
Semantics:
| Check | Condition | Result |
|---|---|---|
| Not yet valid | now < not_before - skew | Reject |
| Valid | not_before - skew <= now < expires_at + skew | Accept |
| Expired | now >= expires_at + skew | Reject |
Revocation timing (NORMATIVE):
revoked_at is interpreted as a hard cutoff: runtime MUST reject if now >= revoked_at (without skew tolerance).
// Revocation check (NO skew - intentional)
if let Some(revoked_at) = store.get_revoked_at(&mandate.mandate_id)? {
if now >= revoked_at {
return Err(AuthzError::Revoked { revoked_at });
}
}
| Check | Condition | Skew Applied | Result |
|---|---|---|---|
| Not yet valid | now < not_before - skew | Yes | Reject |
| Expired | now >= expires_at + skew | Yes | Reject |
| Revoked | now >= revoked_at | No | Reject |
Rationale: Revocation is an intentional control-plane action (human or automated policy decision). Applying clock skew would create an unintended "revocation grace period" that could allow continued use after explicit revocation. Expiry/not_before are tolerant for clock drift between systems; revocation is not.
7.7 transaction_ref Verification (Normative)¶
For operation_class=commit tools with scope.transaction_ref, runtime MUST verify the transaction binding.
Verification flow:
if operation_class == OperationClass::Commit {
if let Some(expected_ref) = &mandate.scope.transaction_ref {
// 1. Extract transaction object from tool call
let tx_object = extract_transaction_object(&tool_call)
.ok_or(AuthzError::MissingTransactionObject)?;
// 2. Compute hash using same algorithm as mandate creation
let actual_ref = compute_transaction_ref(&tx_object)?;
// 3. Compare
if actual_ref != *expected_ref {
return Err(AuthzError::TransactionRefMismatch {
expected: expected_ref.clone(),
actual: actual_ref,
});
}
}
}
Transaction object extraction:
The transaction object MUST be deterministically extractable from the tool call. Implementations SHOULD support:
| Method | Description | Use When |
|---|---|---|
| Explicit field | tool_call.args.transaction | Tool contract specifies transaction field |
| Session lookup | Lookup by tool_call.args.transaction_id | Transaction stored in session state |
Anti-patterns (MUST NOT):
- Using entire
argsobject without explicit contract - Including timestamps or request-specific nonces in transaction object
- Silent fallback to different extraction method
7.8 Idempotency Semantics (Normative)¶
Mandate layer:
| Scenario | Behavior |
|---|---|
Same tool_call_id, first call | Consume, increment, return receipt |
Same tool_call_id, retry | Return existing receipt, NO increment |
Different tool_call_id, same mandate | Consume again (subject to constraints) |
Tool layer integration:
Runtime SHOULD propagate tool_call_id to tool execution for downstream idempotency:
// In tool call forwarding
let mut request = tool_call.clone();
request.metadata.insert(
"idempotency_key".to_string(),
tool_call.id.clone().into()
);
7.9 Crash Recovery (Normative)¶
Chosen semantics: Consume-before-exec
The mandate is consumed BEFORE tool execution. This guarantees single-use constraints but may result in "consumed but not executed" on crash.
Invariants:
| Event | Guaranteed |
|---|---|
mandate.used emitted | Mandate was consumed in store |
| Tool executed | NOT guaranteed (may crash before exec) |
tool.decision emitted | SHOULD be guaranteed (see below) |
Tool decision guarantee:
Implementations MUST emit assay.tool.decision even on execution failure:
// WRONG: Decision only on success
let response = forward_to_tool(request).await?;
emit_tool_decision(request, response, receipt);
// CORRECT: Decision always emitted
let response = forward_to_tool(request).await;
emit_tool_decision(
request,
response.as_ref().ok(),
receipt,
response.as_ref().err().map(|e| e.to_string())
);
Recovery detection (lint-time):
Lint rules can detect potential crash scenarios:
IF mandate.used EXISTS
AND tool.decision NOT EXISTS for same tool_call_id
THEN WARN "Mandate consumed but tool decision not recorded (possible crash)"
Audit log deduplication (NORMATIVE):
Implementations MAY emit assay.mandate.used.v1 events on retries of the same tool_call_id. When this occurs:
- CloudEvents.id MUST equal
use_id(deterministic, content-addressed) - Consumers MUST deduplicate by CloudEvents.id
- Producers SHOULD use
CloudEvents.id = use_idto make deduplication trivial
| Scenario | Events Emitted | Consumer Action |
|---|---|---|
| First consume | 1 × mandate.used | Accept |
| Retry (same tool_call_id) | 1 × mandate.used (same id) | Deduplicate by id |
| Different tool_call_id | 1 × mandate.used (new id) | Accept |
Rationale: Retries can occur after partial failures (e.g., event emission succeeded but acknowledgment lost). Duplicates in append-only audit logs are acceptable as long as deduplication is deterministic. The
use_idformula guarantees identical event IDs for identical logical operations.
7.10 Error Taxonomy¶
| Error | Code | When | Severity |
|---|---|---|---|
MandateNotFound | E_MANDATE_NOT_FOUND | mandate_id not in store | Error |
AlreadyUsed | E_MANDATE_ALREADY_USED | single_use=true, use_count>0 | Error |
MaxUsesExceeded | E_MANDATE_MAX_USES | use_count >= max_uses | Error |
NonceReplay | E_NONCE_REPLAY | Nonce already used | Error |
Expired | E_MANDATE_EXPIRED | now >= expires_at + skew | Error |
NotYetValid | E_MANDATE_NOT_YET_VALID | now < not_before - skew | Error |
TransactionRefMismatch | E_TRANSACTION_REF_MISMATCH | Hash mismatch | Error |
MissingTransactionObject | E_MISSING_TRANSACTION | Commit tool without tx obj | Error |
StoreInconsistency | E_STORE_INCONSISTENT | Metadata mismatch after upsert | Error |
ScopeMismatch | E_SCOPE_MISMATCH | Tool not in mandate.scope.tools | Error |
KindMismatch | E_KIND_MISMATCH | Wrong mandate_kind for operation | Error |
7.11 Lint Enforcement¶
Lint provides post-hoc verification complementing runtime enforcement.
1. Collect all assay.mandate.used.v1 events for mandate_id
2. Count unique use_id values
3. If mandate.constraints.single_use && count > 1: FAIL
4. If mandate.constraints.max_uses && count > max_uses: FAIL
5. If mandate.used exists without matching tool.decision: WARN (crash recovery)
8. Pack Rules¶
8.1 mandate-baseline.yaml¶
| Rule ID | Check | Severity | Scope | Engine Support |
|---|---|---|---|---|
| MANDATE-001 | decision=allow for commit tools MUST have mandate_id | error | commit tools only | v1 (conditional) |
| MANDATE-002 | mandate_id MUST reference existing assay.mandate.v1 | error | all | v1.1 (reference_exists) |
| MANDATE-003 | Tool decision time within mandate validity window | error | all | v1.1 (temporal_range) |
| MANDATE-004 | single_use/max_uses mandate has valid receipt count | error | all | v1.1 (use_count_valid) |
| MANDATE-005 | commit tools require mandate_kind=transaction | warning | commit tools | v1.1 (mandate_kind_check) |
Engine capability requirements:
| Check Type | Minimum Engine Version | Status |
|---|---|---|
conditional | v1.0 | Implemented |
json_path_exists | v1.0 | Implemented |
reference_exists | v1.1 | Planned |
temporal_range | v1.1 | Planned |
use_count_valid | v1.1 | Planned |
mandate_kind_check | v1.1 | Planned |
Note: Rules requiring v1.1 check types will be skipped with a warning on v1.0 engines. The mandate-baseline.yaml pack will be published when engine v1.1 is available.
Note on MANDATE-001 scope: To prevent false positives in discovery flows, this rule only applies to tools classified as commit (per mandate_trust.commit_tools). Read-only discovery operations do not require mandate linkage.
8.2 Rule Definitions¶
rules:
- id: MANDATE-001
description: "Commit tool decisions must have mandate authorization"
check:
type: conditional
condition:
all:
- path: "/data/decision"
equals: "allow"
- path: "/data/tool"
matches_any: "${mandate_trust.commit_tools}"
then:
type: json_path_exists
paths: ["/data/mandate_id"]
event_types: ["assay.tool.decision"]
severity: error
- id: MANDATE-002
description: "mandate_id must reference existing mandate"
check:
type: reference_exists
source_path: "/data/mandate_id"
target_event_type: "assay.mandate.v1"
target_path: "/data/mandate_id"
event_types: ["assay.tool.decision"]
severity: error
- id: MANDATE-003
description: "Tool decision must be within mandate validity window"
check:
type: temporal_range
event_time_path: "/time"
mandate_ref_path: "/data/mandate_id"
not_before_path: "/data/validity/not_before"
expires_at_path: "/data/validity/expires_at"
event_types: ["assay.tool.decision"]
severity: error
- id: MANDATE-004
description: "Single-use mandate must have exactly one use receipt"
check:
type: use_count_valid
mandate_path: "/data/mandate_id"
single_use_path: "/data/constraints/single_use"
max_uses_path: "/data/constraints/max_uses"
event_types: ["assay.mandate.v1"]
severity: error
- id: MANDATE-005
description: "Commit tools require transaction mandate"
check:
type: conditional
condition:
all:
- path: "/data/tool"
matches_any: "${mandate_trust.commit_tools}"
- path: "/data/decision"
equals: "allow"
then:
type: mandate_kind_check
mandate_ref_path: "/data/mandate_id"
required_kind: "transaction"
event_types: ["assay.tool.decision"]
severity: warning
9. Examples¶
9.1 Intent Mandate (Standing Authority)¶
{
"specversion": "1.0",
"id": "evt_intent_001",
"type": "assay.mandate.v1",
"source": "assay://acme-corp/shopping-agent",
"time": "2026-01-28T09:00:00Z",
"data": {
"mandate_id": "sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"mandate_kind": "intent",
"principal": {
"subject": "usr_K7xM2nP9qR4s",
"method": "oidc",
"display": "Alice (shopping)"
},
"scope": {
"tools": ["search_*", "list_*", "get_product_*"],
"resources": ["/products/**", "/reviews/**"],
"operation_class": "read",
"max_value": null
},
"validity": {
"not_before": "2026-01-28T09:00:00Z",
"expires_at": "2026-01-28T17:00:00Z",
"issued_at": "2026-01-28T08:55:00Z"
},
"constraints": {
"single_use": false,
"max_uses": null,
"require_confirmation": false
},
"context": {
"audience": "acme-corp/shopping-agent",
"issuer": "auth.acme-corp.com",
"nonce": null,
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
},
"signature": {
"version": 1,
"algorithm": "ed25519",
"payload_type": "application/vnd.assay.mandate+json;v=1",
"content_id": "sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"signed_payload_digest": "sha256:b2c3d4e5f6789012345678901234567890123456789012345678901234abcdef",
"key_id": "sha256:prod-signing-key-fingerprint-here-64-hex-chars-total-ok",
"signature": "MEUCIQC...",
"signed_at": "2026-01-28T08:55:00Z"
}
}
}
9.2 Transaction Mandate (Final Authorization)¶
{
"specversion": "1.0",
"id": "evt_txn_001",
"type": "assay.mandate.v1",
"source": "assay://acme-corp/shopping-agent",
"time": "2026-01-28T10:30:00Z",
"data": {
"mandate_id": "sha256:f1e2d3c4b5a6789012345678901234567890123456789012345678901234wxyz",
"mandate_kind": "transaction",
"principal": {
"subject": "usr_K7xM2nP9qR4s",
"method": "oidc",
"credential_ref": "sha256:vp-hash-from-interactive-confirmation"
},
"scope": {
"tools": ["purchase_item"],
"resources": ["/cart/current"],
"operation_class": "commit",
"max_value": {
"amount": "99.99",
"currency": "USD"
},
"transaction_ref": "sha256:e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5"
},
"validity": {
"not_before": "2026-01-28T10:30:00Z",
"expires_at": "2026-01-28T10:35:00Z",
"issued_at": "2026-01-28T10:30:00Z"
},
"constraints": {
"single_use": true,
"max_uses": 1,
"require_confirmation": true
},
"context": {
"audience": "acme-corp/shopping-agent",
"issuer": "auth.acme-corp.com",
"nonce": "confirm_session_xyz789",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-b7ad6b7169203331-01"
},
"signature": {
"version": 1,
"algorithm": "ed25519",
"payload_type": "application/vnd.assay.mandate+json;v=1",
"content_id": "sha256:f1e2d3c4b5a6789012345678901234567890123456789012345678901234wxyz",
"signed_payload_digest": "sha256:c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4",
"key_id": "sha256:prod-signing-key-fingerprint-here-64-hex-chars-total-ok",
"signature": "MEYCIQDy...",
"signed_at": "2026-01-28T10:30:00Z"
}
}
}
9.3 Tool Decision with Mandate¶
{
"specversion": "1.0",
"id": "evt_decision_001",
"type": "assay.tool.decision",
"source": "assay://acme-corp/shopping-agent",
"time": "2026-01-28T10:31:00Z",
"data": {
"tool": "purchase_item",
"decision": "allow",
"reason_code": "P_MANDATE_VALID",
"tool_call_id": "tc_purchase_001",
"mandate_id": "sha256:f1e2d3c4b5a6789012345678901234567890123456789012345678901234wxyz",
"mandate_scope_match": true,
"mandate_kind_match": true
}
}
9.4 Consumption Receipt¶
{
"specversion": "1.0",
"id": "evt_use_001",
"type": "assay.mandate.used.v1",
"source": "assay://acme-corp/shopping-agent",
"time": "2026-01-28T10:31:00Z",
"data": {
"mandate_id": "sha256:f1e2d3c4b5a6789012345678901234567890123456789012345678901234wxyz",
"use_id": "sha256:use_abc123",
"tool_call_id": "tc_purchase_001",
"consumed_at": "2026-01-28T10:31:00Z",
"use_count": 1
}
}
10. Security Considerations¶
10.1 Principal Privacy¶
subjectMUST be opaque; MUST NOT contain email, name, or other PIIdisplayis for UX only; verifiers MUST NOT use it for trust decisionsdisplaySHOULD be absent in exported audit bundles unless explicitly neededdisplayMUST be redacted when sharing evidence with third parties- Use organizational pseudonyms or hashed identifiers (e.g.,
usr_K7xM2nP9qR4s)
Anti-pattern examples (MUST NOT):
// BAD - contains PII
"display": "user@example.com"
"display": "John Smith"
"display": "+1-555-123-4567"
// GOOD - no PII
"display": "Alice (shopping)"
"display": "user-1234"
"display": null
10.2 Replay Prevention¶
context.audienceMUST be a stable identifier of application+tenant (e.g.,org/appororg/app/env)context.issuerMUST map to a trust policy entry (string equality, no normalization)- Transaction mandates SHOULD use
noncefor session binding - Standing mandates rely on
audience+issuer+ short validity
Nonce requirements (for transaction mandates):
| Requirement | Specification |
|---|---|
| Presence | SHOULD be present for mandate_kind: transaction |
| Entropy | Minimum 128 bits (e.g., 22+ Base64 characters) |
| Uniqueness | MUST be unique per session/confirmation flow |
| Storage | Runtime MUST track used nonces to prevent replay |
10.3 Clock Skew¶
Clock skew tolerance is configurable and MUST be auditable.
Policy configuration:
Behavior:
- Runtime validity check:
now - skew <= not_beforeandnow + skew < expires_at - Lint mode uses CloudEvents
timefield, not wall clock not_beforemay be slightly in the future to account for distribution
Audit reporting:
Lint reports MUST include skew information when tolerance is applied:
{
"rule": "MANDATE-003",
"result": "pass",
"details": {
"validity_check": "passed_with_skew",
"skew_applied_seconds": 27,
"configured_tolerance_seconds": 30
}
}
10.4 Context Binding (Normative)¶
audience verification:
expected_audience SHOULD follow pattern: {org}/{app} or {org}/{app}/{env}
issuer verification:
Comparison is exact string match; no URL normalization is performed.
traceparent binding:
If present, traceparent SHOULD match the W3C Trace Context of the current request. This enables correlation in distributed tracing systems but is NOT used for security decisions.
10.5 Key Management¶
- Same key management as tool signing (SPEC-Tool-Signing-v1)
- Private keys: mode
0600, not in version control - Rotate keys periodically; old keys remain trusted for verification
10.6 Base64 Encoding¶
All Base64 values in this specification (signatures, hashes) MUST use: - Standard Base64 alphabet (RFC 4648 §4) - WITH padding (= characters)
Parsers MAY accept Base64 without padding for compatibility, but producers MUST include padding.
11. Conformance Test Vectors (v1.0.2)¶
Implementations MUST pass all test vectors in this section.
11.1 Glob Matching Vectors¶
| Pattern | Input | Expected | Reason |
|---|---|---|---|
search_* | search_products | ✓ match | * matches products |
search_* | search_users | ✓ match | * matches users |
search_* | search_ | ✓ match | * matches empty string |
search_* | search.products | ✗ no match | * stops at . |
search_* | search | ✗ no match | Missing _ |
search_* | Search_products | ✗ no match | Case-sensitive |
fs.read_* | fs.read_file | ✓ match | Literal . matches |
fs.read_* | fs.read.file | ✗ no match | * stops at second . |
fs.** | fs.read_file | ✓ match | ** matches any |
fs.** | fs.write.nested.path | ✓ match | ** matches . |
* | search | ✓ match | * matches single segment |
* | ns.tool | ✗ no match | * stops at . |
** | anything.at.all | ✓ match | Universal wildcard |
file\*name | file*name | ✓ match | Escaped * |
path\\to | path\to | ✓ match | Escaped \ |
11.2 JCS Canonicalization Vector¶
Input (JSON with unordered keys):
{
"mandate_kind": "intent",
"context": {"issuer": "auth.myorg.com", "audience": "myorg/app"},
"principal": {"method": "oidc", "subject": "user-123"},
"validity": {"issued_at": "2026-01-28T10:00:00Z"},
"scope": {"tools": ["search_*"], "operation_class": "read"},
"constraints": {}
}
Expected JCS output (single line, sorted keys):
{"constraints":{},"context":{"audience":"myorg/app","issuer":"auth.myorg.com"},"mandate_kind":"intent","principal":{"method":"oidc","subject":"user-123"},"scope":{"operation_class":"read","tools":["search_*"]},"validity":{"issued_at":"2026-01-28T10:00:00Z"}}
Expected mandate_id:
Note: Actual hash value depends on exact JCS output bytes. Implementations MUST produce identical bytes to produce identical hashes.
11.3 Time Validity Vectors¶
| now (event time) | not_before | expires_at | skew_seconds | Expected |
|---|---|---|---|---|
| 10:00:00 | 09:00:00 | 11:00:00 | 0 | ✓ valid |
| 10:00:00 | 10:00:30 | 11:00:00 | 30 | ✓ valid (skew) |
| 10:00:00 | 10:01:00 | 11:00:00 | 30 | ✗ not_yet_valid |
| 10:00:00 | 09:00:00 | 10:00:00 | 0 | ✗ expired (exclusive) |
| 10:00:00 | 09:00:00 | 09:59:30 | 30 | ✗ expired |
| 10:00:00 | null | 11:00:00 | 0 | ✓ valid |
| 10:00:00 | 09:00:00 | null | 0 | ✓ valid |
11.4 use_id Generation (NORMATIVE v1.0.4)¶
use_id MUST be content-addressed (deterministic):
Test vector:
| mandate_id | tool_call_id | use_count | use_id |
|---|---|---|---|
sha256:abc123 | tc_001 | 1 | sha256: + hex(SHA256("sha256:abc123:tc_001:1")) |
This ensures: - Deterministic generation (same inputs → same ID) - Uniqueness (different tool_call_id or use_count → different ID) - Verifiability (third parties can recompute from receipt data) - No JSON parsing required (simple string concatenation)
11.5 JSON Parsing Requirements (NORMATIVE)¶
Parsers MUST reject JSON with: - Duplicate keys: {"a": 1, "a": 2} MUST be rejected - Trailing data: {"a": 1}garbage MUST be rejected - Comments: {"a": 1 /* comment */} MUST be rejected (not valid JSON)
Rationale: Canonicalization attacks exploit parser differences in duplicate key handling.
12. Future Extensions (v2)¶
| Feature | Description |
|---|---|
| OpenID4VP binding | Normative VP canonicalization per credential format |
| Sigstore keyless | Fulcio certificates + Rekor transparency log |
| Delegation chains | Mandate-to-mandate delegation with proof chain |
| Transaction details | Cart hash, line items for commerce verification |
| Multi-signature | Require N-of-M signatures for high-value mandates |
13. References¶
- ADR-017: Mandate/Intent Evidence - Design decision
- SPEC-Tool-Signing-v1 - Signing format (reused)
- RFC 8785: JSON Canonicalization Scheme - JCS
- DSSE: Dead Simple Signing Envelope - PAE format
- CloudEvents v1.0 - Event envelope
- AP2 Protocol - Agent payments
- OpenID4VP - Verifiable presentations
- W3C Trace Context - Distributed tracing