ResearchGuide

MAPL Policy Guide

A Tutorial for the MACAW Agentic Policy Language

Version: 3.7.1
Last Updated: November 2, 2026



Introduction

Welcome to the MAPL (MACAW Agentic Policy Language) tutorial! This guide will teach you how to write security policies for AI agent systems.

What is MAPL?

MAPL is a declarative policy language designed specifically for agentic AI systems. Unlike traditional access control systems designed for static files and databases, MAPL handles the unique challenges of AI agents:

  • Agents that morph identities and delegate capabilities
  • Systems that scale 10-100x per user through agent multiplication
  • Dynamic workflows where agents spawn sub-agents and invoke tools

Why MAPL is Different

Traditional policy systems require O(M×N) rules for M principals and N resources. This explodes quickly:

  • 1,000 users × 100 resources = 100,000 rules to manage ❌

MAPL uses hierarchical composition to achieve O(log M + N) policies:

  • log(1,000) ≈ 3-4 organizational levels + 100 resource policies ✅

Three Key Innovations

  1. Deferred Principal Binding: Principals are resolved at runtime from authenticated context (JWT claims), not hardcoded in policies. Alice's role changes? No policy updates needed.

  2. Dual Perspective Enforcement: Caller and resource policies are enforced independently, then composed via intersection. Even if the caller is compromised, the resource enforces its own limits.

  3. Provable Security: MAPL policies compose via mathematical intersection semantics with formal guarantees:

    • Monotonic Restriction: Adding policies can only make things more restrictive, never less
    • Transitive Denial: If any policy denies something, it stays denied
    • No Privilege Escalation: Combining policies cannot grant denied permissions

What You'll Learn

By the end of this tutorial, you'll be able to:

  • Write MAPL policies for your organization
  • Use inheritance to create hierarchical policies
  • Specify parameter constraints for security and compliance
  • Use attestations for multi-step workflows (including conditional attestations)
  • Add checks of your own with extractors, verifiers and processors
  • Understand how policies compose and intersect
  • Debug policy issues when things don't work as expected

Let's get started!


Part 1: Understanding MAPL

The Agentic Challenge

Traditional access control was designed for:

User → File System
  "Alice can READ /etc/passwd"

But AI agents are different:

User → Agent → Sub-Agent → Tool → LLM → Database
  Alice invokes AnalystAgent
    → AnalystAgent spawns DataAgent (as Alice's delegate)
      → DataAgent calls DatabaseTool
        → DatabaseTool queries SQL
          → Returns to Alice

This creates challenges:

  • Contextual identity: The same agent represents different users at different times
  • Delegation chains: Sub-agents inherit but restrict parent permissions
  • Scale: 1 user might spawn 100 agents, creating combinatorial explosion

MAPL's Solution

Instead of writing rules like:

❌ alice CAN invoke data-agent
❌ data-agent CAN query database
❌ database CAN return financial-records

MAPL uses hierarchical composition:

✅ Company policy: "employees can access company tools"
✅ Finance BU policy: "finance team can access finance:* resources"
✅ Analyst team policy: "analysts limited to read-only, 500 token limits"
✅ Alice's runtime context: {dept: "finance", team: "analysts"}
   → Policies automatically composed at invocation time

The Composition Algebra (Simplified)

When policies compose, they follow simple rules:

Resources (what you can access):

  • Parent allows A, B, C
  • Child allows B, C, D
  • Result: B, C (intersection - both must allow)

Denied Resources (what's explicitly blocked):

  • Parent denies X
  • Child denies Y
  • Result: X, Y (union - either can deny)

Constraints (limits on parameters):

  • Parent: max_tokens ≤ 2000
  • Child: max_tokens ≤ 500
  • Result: max_tokens ≤ 500 (most restrictive wins)

This means:

  • ✅ Policies always get MORE restrictive down the hierarchy
  • ✅ You can't accidentally grant more permissions by inheriting
  • ✅ Denials always propagate (no overrides!)

Part 2: Your First MAPL Policy

The Basic Structure

A MAPL policy is a JSON document with this structure:

{
  "policy_id": "unique-identifier",
  "extends": "parent-policy-id",
  "resources": ["resource-patterns"],
  "denied_resources": ["denial-patterns"],
  "attestations": ["attestation-requirements"],
  "constraints": {
    "parameters": {},
    "denied_parameters": {},
    "attestations": {}
  }
}

Your First Policy

Let's create a simple policy for a financial analyst:

{
  "policy_id": "user:alice",
  "version": "1.0",
  "description": "Alice - Financial Analyst",

  "resources": [
    "llm:openai/chat.completions",
    "tool:database/query"
  ],

  "denied_resources": [
    "admin:**",
    "secrets:**"
  ],

  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 500}
      }
    }
  }
}

What this policy does:

  • ✅ Alice can use OpenAI chat completions
  • ✅ Alice can query the database
  • ❌ Alice cannot access admin resources
  • ❌ Alice cannot access the secrets store
  • ⚠️ Alice's OpenAI requests are limited to 500 tokens

Understanding Each Section

policy_id

"policy_id": "user:alice"
  • Unique identifier for this policy
  • Convention: {scope}:{name} (e.g., company:acme, bu:finance, team:analysts, user:alice)
  • Starts with a letter or digit, then letters, digits, spaces and - _ : / @ . # (e.g., user:alice@acme.com, order:#W2611340)
  • Used for inheritance and reference

version

"version": "1.0"
  • Semantic versioning for policy tracking
  • Helps with auditing and rollback

description

"description": "Alice - Financial Analyst"
  • Human-readable explanation
  • Helps other admins understand the policy's purpose

resources

"resources": [
  "llm:openai/chat.completions",
  "tool:database/query"
]
  • Arrays of operation patterns (not resources + actions!)
  • In MAPL, resources ARE operations:
    • llm:openai/chat.completions - the operation of creating a chat completion
    • tool:database/query - the operation of querying the database
  • Supports wildcards (more on this below)
  • Left out, resources has no opinion: the other levels decide. [] allows nothing at request time; in an extends child it inherits the parent's resources.

denied_resources

"denied_resources": [
  "admin:**",
  "secrets:**"
]
  • Explicit blocks that override allows
  • Denials always win in conflicts
  • Used for sensitive resources that should never be accessed
  • Patterns match operations, not the values passed to them. To block a file a tool reads, deny the parameter value with denied_parameters

constraints

"constraints": {
  "parameters": {
    "llm:openai/chat.completions": {
      "max_tokens": {"max": 500}
    }
  }
}
  • Limits on what parameters can be passed to operations
  • We'll cover this in detail in Part 4

Resource Patterns and Wildcards

MAPL supports hierarchical resource naming with wildcards:

Pattern Meaning Examples
llm:openai/chat.completions Exact match Only that specific operation
llm:openai/* Single-level wildcard llm:openai/chat.completions, llm:openai/embeddings
llm:openai/** Multi-level wildcard All OpenAI LLM operations at any depth, but not llm:openai itself
llm:** All LLMs Any LLM provider, any operation
tool:**/generate Any depth before a segment tool:generate, tool:openai/generate, tool:openai/v2/generate
file:**/.env A name at any depth file:.env, file:app/.env, file:/home/me/.env, not file:prod.env (that is file:**/*.env)
** Everything All resources (use with caution!)

A resource is a domain, the text before the first colon, and a path of segments separated by /. A pattern's domain must equal the resource's; a pattern without a domain matches no resource that has one. Within a segment, * matches any characters. A whole segment ** matches zero or more segments, so the separators on either side of it merge (a/**/b matches a/b); at the end of a pattern it matches one or more, so llm:openai/** does not match llm:openai.

Examples:

{
  "resources": [
    "llm:openai/*",           // All OpenAI operations
    "tool:database/query",     // Specific operation
    "file:data/*/read"         // Read any file in data/
  ]
}

Why Resources ARE Operations

You might wonder: "Where are the actions like read, write, execute?"

In traditional systems:

Resource: /etc/passwd
Actions: [read, write, execute]

In MAPL (agentic/RPC world):

Operations:
  - file:/etc/passwd/read      ← operation
  - file:/etc/passwd/write     ← operation
  - file:/etc/passwd/execute   ← operation

The resource path INCLUDES the operation. This is because:

  1. Tools are callable operations by nature:

    • llm:openai/chat.completions - the operation IS creating completions
    • tool:database/query - the operation IS querying
  2. Granularity is in the path, not separate actions:

    • Instead of: database:users + action=read
    • MAPL uses: database:users/read (clearer!)
  3. No combinatorial explosion:

    • Traditional: M resources × N actions = M×N rules
    • MAPL: Resources encode operations = M patterns
  4. Wildcards group operations naturally:

    • database:users/* - all user database operations
    • database:*/read - read from any database table

Part 3: Policy Inheritance with extends

Why Inheritance?

Imagine you have:

  • 1 company
  • 5 business units
  • 20 teams
  • 500 users

Without inheritance, you'd need to repeat common rules in all 500 user policies. Inheritance lets you:

✅ Define common rules once (company level)
✅ Specialize at each level (BU, team, user)
✅ Scale from O(M×N) rules to O(log M + N) policies

Your First Inheritance Chain

Let's build a 3-level hierarchy:

Level 1: Company Policy

{
  "policy_id": "company:FinTech",
  "description": "Company-wide base policy",

  "resources": [
    "llm:openai/*"
  ],

  "denied_resources": [
    "secrets:**"
  ],

  "constraints": {
    "rate_limit": 100,
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 4000}
      }
    }
  }
}

Level 2: Business Unit Policy (extends company)

{
  "policy_id": "bu:Analytics",
  "extends": "company:FinTech",
  "description": "Analytics BU - enforces deterministic results",

  "constraints": {
    "rate_limit": 50,
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 2000},
        "temperature": {"max": 0.3}
      }
    }
  }
}

Level 3: User Policy (extends BU)

{
  "policy_id": "user:alice",
  "extends": "bu:Analytics",
  "description": "Alice - Analyst",

  "resources": [
    "llm:openai/chat.completions"
  ],

  "constraints": {
    "rate_limit": 10,
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo"],
        "max_tokens": {"max": 500}
      }
    }
  },

  "denied_resources": [
    "data:executive/*"
  ]
}

How Inheritance Works: Step-by-Step Resolution

When Alice makes a request, MAPL resolves the policy chain:

company:FinTech → bu:Analytics → user:alice

Step 1: Start with Company Policy

Resources: [llm:openai/*]
Denied: [secrets:**]
max_tokens: 4000
rate_limit: 100

Step 2: Intersect with BU Policy

Resources: [llm:openai/*]  (unchanged - BU didn't specify)
Denied: [secrets:**]  (unchanged - BU didn't add denials)
max_tokens: min(4000, 2000) = 2000  ← More restrictive!
temperature: 0.3  ← New constraint added
rate_limit: min(100, 50) = 50  ← More restrictive!

Step 3: Intersect with Alice's Policy

Resources: [llm:openai/chat.completions]  ← More specific!
  (intersection of llm:openai/* and llm:openai/chat.completions)

Denied: [secrets:**, data:executive/*]  ← Alice adds executive denial

max_tokens: min(2000, 500) = 500  ← Most restrictive!
temperature: 0.3  (Alice didn't override)
model: [gpt-3.5-turbo]  ← Alice adds model restriction
rate_limit: min(50, 10) = 10  ← Most restrictive!

Final Effective Policy for Alice:

{
  "resources": ["llm:openai/chat.completions"],
  "denied_resources": ["secrets:**", "data:executive/*"],
  "constraints": {
    "rate_limit": 10,
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo"],
        "max_tokens": {"max": 500},
        "temperature": {"max": 0.3}
      }
    }
  }
}

The Intersection Rules

Here's how different fields merge during inheritance:

Field Merge Strategy Example
resources Domain-Aware Extend+Restrict Parent [finance:**, tool:calc] + Child [finance:trading/*] = [finance:trading/*, tool:calc]
denied_resources Union Parent [X] + Child [Y] = [X,Y]
max Minimum Parent max:2000 + Child max:500 = max:500
min Maximum Parent min:0 + Child min:10 = min:10
range Intersection Parent [0,2000] + Child [0,500] = [0,500]
allowed_values Intersection Parent ["A","B","C"] + Child ["B","C"] = ["B","C"]
max_length, max_items Minimum Parent max_length:100 + Child max_length:10 = max_length:10
min_length, min_items Maximum Parent min_items:1 + Child min_items:3 = min_items:3
type, pattern Both apply Parent pattern:"^[a-z]+$" + Child pattern:"^.{1,8}$" = a value must match both
List shorthand Both apply Parent ["gpt-4", "gpt-3.5-turbo"] + Child ["gpt-4"] = only gpt-4 passes
validity Overlap Parent January–December + Child June–September = June–September

The key principle: Most restrictive always wins! The same constraint rules apply when policies from different levels (company, team, user, service) combine at request time. Resources differ: at request time, levels intersect directly, so a level that names no patterns in a domain allows nothing in it. Domain-aware inheritance (below) applies only through extends.

Which levels load. The identity provider's claims name the requester's company, business unit and team, and each of those loads its own policy, alongside the requester's user policy. For a request that passed through services, the requester is the person who started it (see Chained Invocations). Each one resolves its own extends first; then all of them intersect. Keep the claims and the extends chains consistent: if a claim names a different business unit than a team's extends chain, both apply, and the result can only be more restrictive. Each level takes one value from the claims; map several IdP groups to one team in the claims mapping.

Note on resources: Resource inheritance uses domain-aware logic (not simple set intersection). If a child doesn't mention a domain (e.g., tool:), it automatically inherits the parent's resources for that domain. If the child specifies patterns in a domain, its patterns intersect the parent's in that domain: what the child leaves out of a domain it names is not inherited. This is what enables O(log M + N) complexity - children don't need to re-specify everything they inherit!

Visual Example: Range Intersection

Company Policy:
    max_tokens: [------------ 0 to 4000 ------------]

BU Analytics:
    max_tokens: [------ 0 to 2000 ------]

User Alice:
    max_tokens: [-- 0 to 500 --]

Final Result:
    max_tokens: [-- 0 to 500 --]  ← Intersection = most restrictive

Visual Example: Allowed Values Intersection

Company Policy:
    model: [gpt-3.5-turbo, gpt-4, gpt-4-turbo]

BU Analytics:
    model: [gpt-3.5-turbo, gpt-4]  (removes gpt-4-turbo)

User Alice:
    model: [gpt-3.5-turbo]  (removes gpt-4)

Final Result:
    model: [gpt-3.5-turbo]  ← Intersection

Advanced: Domain-Aware Resource Inheritance

Why domain-aware inheritance matters: This is one of the core innovations that enables MAPL to achieve O(log M + N) complexity instead of O(M×N). Without it, every child policy would need to re-specify every resource it wants, defeating the purpose of hierarchical composition.

The Algorithm: Restrict Per Domain

Resource inheritance works differently than simple set intersection. Here's the actual algorithm:

  1. Group by domain: Group parent and child patterns by domain prefix (finance:, tool:, llm:, etc.)
  2. Intersect named domains: In each domain the child names, keep what both the parent's and the child's patterns allow
  3. Inherit unnamed domains: In each domain the child doesn't name, keep the parent's patterns as-is

Result: Child inherits the domains it doesn't mention, and in the domains it does mention, gets only what it lists and the parent allows. If the parent allows tool:trade/*, tool:reports/* and tool:database/query, a child that lists tool:trade/* and tool:reports/* no longer has tool:database/query.

Example 1: Automatic Inheritance Without Re-Specification

This is the key use case - child doesn't need to re-list everything:

// Parent: BU Finance
{
  "policy_id": "bu:finance",
  "resources": [
    "finance:**",          // All finance operations
    "tool:calculator",     // Calculator tool
    "tool:analyzer",       // Analyzer tool
    "report:*"            // All reports
  ]
}

// Child: Trading Team
{
  "policy_id": "team:trading",
  "extends": "bu:finance",
  "resources": [
    "finance:trading/*",    // Restrict finance domain
    "finance:positions/*"   // Additional finance restriction
  ]
  // NOTE: Child doesn't mention tool: or report: domains!
}

// Final Effective Policy for team:trading
{
  "resources": [
    "finance:trading/*",    // ← Restricted from finance:**
    "finance:positions/*",  // ← Additional restriction
    "tool:calculator",      // ← Inherited (child didn't mention tool: domain)
    "tool:analyzer",        // ← Inherited (child didn't mention tool: domain)
    "report:*"             // ← Inherited (child didn't mention report: domain)
  ]
}

Why this works:

  • Child specifies patterns in finance: domain → restricts parent's finance:** to specific sub-patterns
  • Child doesn't specify anything in tool: domain → inherits tool:calculator and tool:analyzer automatically
  • Child doesn't specify anything in report: domain → inherits report:* automatically

Without domain-aware inheritance, the child would need to manually list tool:calculator, tool:analyzer, and report:* - defeating the purpose of hierarchical composition!

A child can only narrow what the parent's patterns cover. * is one level: finance:* covers finance:audit, not finance:trading/buy. For a child to narrow to deeper paths such as finance:trading/*, the parent must allow them with finance:**.

The :base Fallback

What happens when a policy doesn't exist? MAPL loads {namespace}:base instead.

Every call loads the policy of the resource it calls (tool:bash for a call to tool:bash) and the policy of each level the caller's identity names (user:alice, team:traders). Ask for tool:custom-scanner when no such policy exists, and MAPL loads tool:base.

extends is different: it names the parent, and the parent must exist. A policy whose parent is missing denies everything until its extends names one that exists, so deleting a parent can only narrow what its children allow. To inherit from the namespace default, name it: "extends": "team:base".

// Requested:  data:finance/transactions   (no policy exists)
// Loaded:     data:base                   (the namespace default)

Why this matters: you write one policy per namespace instead of one per object.

{
  "policy_id": "data:base",
  "version": "1.0",
  "description": "Default rules for every dataset",
  "resources": ["tool:reporting/*"],
  "denied_resources": ["tool:*/export", "tool:*/send"]
}

That single policy covers every data: object you haven't written a policy for. Write per-object policies only for the exceptions.

The fallback is one level deep, to the namespace. data:finance/transactions falls back to data:base — not to data:finance. Path segments are not a hierarchy; they're part of the name.

If you want a middle tier, declare it:

{
  "policy_id": "data:finance/transactions",
  "extends": "data:finance"
}

Create a :base for every namespace you use. Without it, a missing policy has nothing to fall back to, and that layer adds nothing to the call. Object policies are the exception: a reference that resolves to nothing denies the call — see Part 6.

Service Agent Policies (app: prefix)

So far we've focused on organizational policies (company, BU, team, user). But MAPL also supports service agent policies that define what services themselves allow.

The Dual Perspective Model

MAPL enforces policies from two perspectives:

  1. Caller Perspective (user:alice): What can Alice request?
  2. Service Perspective (app:openai-service): What does the OpenAI service allow?

The final effective policy is the intersection of both:

Effective Policy = Caller Policies ∩ Service Policies

This means BOTH must allow an operation for it to succeed.

Chained Invocations

A service often calls other tools while it handles a request. An assistant hands a payment to an orchestrator, and the orchestrator calls the bank. The request keeps the identity of the person who started it, and each service it passes through is recorded with it. When the bank's tool is called, the caller perspective is that person and every service in between:

maya → app:assistant → app:orchestrator → tool:banking/transfer
Layer Policies
The person who started the request user:maya, and her company, business unit and team
Each service the request passed through app:assistant, app:orchestrator
The service and the tool being called app:banking, tool:banking/transfer

They all intersect, as for a single call. Two things follow:

  • A service's app: policy lists what it calls for others. For payments to pass through the assistant, app:assistant must allow tool:banking/*. A service whose policy leaves banking out cannot cause a transfer, whoever asks it.
  • The identity is the person's. Their organizational levels apply, and claim-based attestations read their token. A service in between adds its own limits and never its own identity.

A call made directly is a chain of one: its caller is the person who started it.

Why No Overrides?

You might ask: "What if I need to give Alice emergency access?"

MAPL explicitly prohibits overrides. Why?

  1. Provable security: The theorems (Monotonic Restriction, Transitive Denial) don't hold with overrides
  2. Audit nightmare: If policies can be overridden, you can't trust the policy hierarchy
  3. Privilege escalation risk: Overrides create paths to bypass restrictions

Instead, use a time-bounded policy:

{
  "policy_id": "team:incident-response",
  "validity": {
    "not_before": "2025-01-17T09:00:00Z",
    "not_after": "2025-01-17T17:00:00Z"
  },
  "resources": ["admin:**"],
  "attestations": ["incident_approval"],
  "constraints": {
    "rate_limit": 10,
    "attestations": {
      "incident_approval": {"approval_criteria": "role:incident-commander", "timeout": 300}
    }
  }
}

Bind Alice to team:incident-response for the window through her identity provider group. A policy holds only inside its validity window: outside it, the policy refuses every call, so a membership left in place grants nothing. not_before and not_after are inclusive, and each needs a UTC offset (Z or +00:00); a policy whose timestamp has none denies every call. Like every policy, it can only narrow — admin:** must already be allowed at the company or BU level.


Part 4: Parameter Constraints

What Are Parameter Constraints?

Parameter constraints limit the values passed to operations, not just which operations are allowed.

Example scenario:

  • ✅ Alice can call llm:openai/chat.completions
  • ⚠️ But her max_tokens must be ≤ 500
  • ⚠️ And she can only use gpt-3.5-turbo (not gpt-4)

This prevents:

  • Cost overruns: Limit token usage
  • Security issues: Block dangerous parameter values (injection attacks)
  • Compliance violations: Restrict parameters to approved values

Basic Constraint Structure

Parameter constraints are nested under constraints.parameters. A constraint applies when the call sends that parameter; a parameter the call omits is not checked: a parameter constraint cannot require one to be present. A verifier can refuse a call that leaves a parameter out (see Part 7).

{
  "constraints": {
    "parameters": {
      "operation-pattern": {
        "parameter_name": constraint_specification
      }
    }
  }
}

Example:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 500},
        "model": ["gpt-3.5-turbo"],
        "temperature": {"type": "number", "range": [0.0, 0.8]}
      }
    }
  }
}

Constraint Types

1. Type Validation

Ensure parameters match expected types:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"type": "integer"},
        "temperature": {"type": "number"},
        "model": {"type": "string"},
        "stream": {"type": "boolean"},
        "messages": {"type": "array"}
      }
    }
  }
}

Supported types:

  • integer - Whole numbers (1, 2, 100)
  • number - Integers or floats (1, 1.5, 3.14)
  • string - Text values
  • boolean - true or false
  • array - Lists
  • object - Dictionaries

2. Numeric Constraints

Control numeric ranges:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {
          "type": "integer",
          "min": 1,
          "max": 4000
        },
        "temperature": {
          "type": "number",
          "range": [0.0, 2.0]
        }
      },
      "finance:transfer": {
        "amount": {
          "type": "number",
          "min": 0,
          "max": 1000000
        }
      }
    }
  }
}

Available numeric constraints:

  • min - Minimum value (inclusive)
  • max - Maximum value (inclusive)
  • range - Array [min, max] (shorthand for min+max)

Note: If a constraint sets both min/max and range, all of them apply.

Always set type with numeric constraints. min, max and range apply only when the value is a number: without "type": "number" (or "integer"), a string such as "15000" passes {"max": 1000}.

3. String Constraints

Control string values:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "model": {
          "type": "string",
          "allowed_values": ["gpt-3.5-turbo", "gpt-4"]
        }
      },
      "report:generate": {
        "format": {
          "type": "string",
          "allowed_values": ["PDF", "XLSX", "CSV"]
        },
        "time_period": {
          "type": "string",
          "pattern": "^(Q[1-4]|H[1-2]|FY)\\d{4}$"
        }
      },
      "user:create": {
        "username": {
          "type": "string",
          "min_length": 3,
          "max_length": 32,
          "pattern": "^[a-zA-Z0-9_]+$"
        }
      }
    }
  }
}

Available string constraints:

  • allowed_values - Array of permitted strings (enumeration); [] allows no value
  • pattern - Regular expression in Python syntax (must match the entire string)
  • min_length - Minimum string length
  • max_length - Maximum string length (prevents buffer overflow, prompt injection)

4. Array Constraints

Control array sizes:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "messages": {
          "type": "array",
          "min_items": 1,
          "max_items": 100
        }
      },
      "database:batch_insert": {
        "records": {
          "type": "array",
          "max_items": 1000
        }
      }
    }
  }
}

Available array constraints:

  • min_items - Minimum array length
  • max_items - Maximum array length (prevents resource exhaustion)

Length and item limits are inclusive: max_items: 5 allows five.

5. Combining Constraints

You can combine multiple constraint types:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "model": {
          "type": "string",
          "allowed_values": ["gpt-3.5-turbo", "gpt-4"]
        },
        "max_tokens": {
          "type": "integer",
          "min": 1,
          "max": 4000
        },
        "temperature": {
          "type": "number",
          "range": [0.0, 2.0]
        },
        "messages": {
          "type": "array",
          "min_items": 1,
          "max_items": 100
        }
      }
    }
  }
}

Shorthand Formats

List Shorthand

A list is a shorthand allowlist:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo", "gpt-4*"]
      }
    }
  }
}

Each entry is a wildcard pattern matched against the whole value, so gpt-4* allows gpt-4 and gpt-4o. Use allowed_values for exact matches.

A single value is an exact match: "temperature": 0.7 allows only 0.7.

Denied Parameters (Injection Defense)

The constraints.denied_parameters field lets you block dangerous parameter patterns:

{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo", "gpt-4"],
        "max_tokens": {"max": 4000}
      }
    },
    "denied_parameters": {
      "llm:**": {
        "prompt": [
          "*DROP TABLE*",
          "*rm -rf*",
          "*eval(*",
          "*exec(*"
        ]
      },
      "tool:shell/*": {
        "command": [
          "*sudo*",
          "*rm -*",
          "*dd if=*"
        ]
      }
    }
  }
}

Patterns use wildcards:

  • * matches any characters, including spaces, /, :, and line breaks
  • ** matches everything (same as * for parameter values)
  • Patterns are case-sensitive: *DROP TABLE* ≠ *drop table*. So is everything else in MAPL: resource patterns, values, and policy IDs
  • A parameter the call omits is not checked, and neither are the expressions listed under it
  • Use wildcards to match substrings: *DROP TABLE* matches "foo DROP TABLE bar"
  • A pattern without * matches only the whole value: II does not match III
  • A single pattern can be written without the list: "symbol": "GME"
  • For better patterns: *rm -* (requires dash) instead of *rm * (matches "perform task")

Combining Patterns

Wrap a condition in {} when one pattern is too broad on its own:

{
  "constraints": {
    "denied_parameters": {
      "tool:shell/*": {
        "command": [
          "{params.command MATCHES '*curl*' AND params.command MATCHES '*| sh*'}"
        ]
      }
    }
  }
}

This denies curl https://example.com/install.sh | sh and allows curl https://example.com. The expression uses the condition syntax and may reference any parameter of the call.

How Constraints Merge During Inheritance

When policies inherit, constraints merge using the "most restrictive wins" rule:

Example: Numeric Constraints

// Company policy
{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 4000}
      }
    }
  }
}

// BU policy (extends company)
{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 2000}  // More restrictive
      }
    }
  }
}

// User policy (extends BU)
{
  "constraints": {
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 500}  // Even more restrictive
      }
    }
  }
}

// Final result: max_tokens max = 500 (most restrictive)

The same rules apply when policies from different levels combine at request time. See The Intersection Rules for every constraint.


Part 5: Attestations - Provable Workflow Dependencies

What Are Attestations?

Imagine you're building a financial trading system with an AI agent. The agent needs to:

  1. Verify the user's identity
  2. Get manager approval for large trades
  3. Execute the trade

Traditional approach:

# Agent memory (can be tampered with!)
agent.memory = {
    "identity_verified": True,    # ← Agent says it did this
    "manager_approved": True       # ← But can you trust it?
}

if agent.memory["identity_verified"] and agent.memory["manager_approved"]:
    execute_trade()  # Hope the agent isn't lying!

The problem: You're trusting the agent to honestly report its own state. If the agent is compromised (or just buggy), it can:

  • Skip the identity verification step
  • Lie about getting manager approval
  • Execute unauthorized trades

MAPL attestations solve this through cryptographic proofs:

# Attestation (cryptographically signed proof)
attestation = {
    "key": "identity_verified",
    "value": {"user_id": "alice@acme.com"},
    "set_by": "tool.verify_identity",    # ← Who created it
    "signature": "a4b8c2...",            # ← Cryptographic proof
    "timestamp": 1700000000,
    "one_time": True                     # ← Self-enforcing metadata
}

The signature proves:

  • ✅ The verify_identity tool actually executed
  • ✅ The attestation wasn't forged or tampered with
  • ✅ The metadata (one_time, time_to_live) is cryptographically bound

Attestation Types

An attestation is proven in one of four ways, and its definition says which:

Type Proven by Defined by
Internal a tool, after it runs "internal": true
Built-in the control plane, from the login the platform; the names are idp_verified and phishing_resistant
Claim-based the requester's token claim
External a person, when asked approval_criteria

Every attestation a call requires is declared, and has one type. Declare it under constraints.attestations in any policy that applies to the call; the built-in attestations are declared by the platform. A required attestation that no policy declares denies the call (Attestation 'tests_passed' is required but not declared), and so does a name that two policies declare as different types. This holds for a conditional requirement too: an undeclared name::{condition} denies the call even when its condition is false. A definition that names no type, such as {"time_to_live": 60}, only adds metadata to the type declared elsewhere. Examples in this guide that show only an attestations list are excerpts; the policy they come from also declares each attestation.

Internal Attestations (Workflow/Self-Set)

Created by tools during workflow execution. The tool itself sets the attestation after successful completion. Declare it internal in the policy that requires it, and no other policy can give the name another meaning:

{
  "attestations": ["tests_passed"],
  "constraints": {
    "attestations": {
      "tests_passed": { "internal": true }
    }
  }
}

Characteristics:

  • Set by the tool that performed the operation
  • Immediate (no waiting)
  • Used for workflow sequencing ("A must happen before B")
  • Kept in the caller's session: a service keeps one session per calling principal, so only that principal's later calls can use it
  • One value per name: a later attest() of the same name, for the same caller, replaces it. To keep one per record, put the record's id in the name (reservation_checked/E123) and read it in a verifier
  • Held in the service's memory: it does not survive a restart, and another service cannot see it

Built-in Attestations (Control-Plane Minted)

Two attestations are not set by a tool and not approved by a person. The control plane mints them at registration, signed by the tenant attestor, from evidence only it can see. Require them the same way you require any other: add the name to attestations. Neither takes an approval_criteria, and neither blocks waiting for anyone. Neither can be defined either: a policy that gives phishing_resistant or idp_verified a definition under constraints.attestations (a claim, an approval_criteria, or any other field) denies every call it applies to, because a definition would change what the name means for every policy that requires it.

They answer different questions about the same login, and neither implies the other. A policy that needs both requires both.

phishing_resistant — was a human present, and did they prove it with something unphishable?

Minted from the token's amr / acr claims when the login used a passkey, WebAuthn, or a hardware key. Use it where the thing you actually care about is that a person acted, not a process wearing their name:

{
  "constraints": {
    "tool:approvals/*": { "attestations": ["phishing_resistant"] }
  }
}

It is minted when the agent registers, from that login's token, and lasts as long as the token. A login with a password or another phishable method never gets it.

idp_verified — did the tenant's IdP vouch for this identity at all?

Minted when registration validated a JWT against the tenant's identity provider. Registration records the outcome in the agent record's identity_source field — idp_verified, api_key, or self_reported — and that field never leaves the control plane, so this attestation is how a policy gets to read it. Use it to keep anything sensitive away from callers whose identity is merely claimed:

{
  "resources": ["data:customer_pii/*"],
  "attestations": ["idp_verified"]
}

A self-reported agent — one that registered without a verified credential — never receives this grant, so the dataset stays closed to it.

Lifetime. phishing_resistant expires with the token that produced it, because it describes one login. idp_verified does not expire; it describes how the record was established, and is revoked if the agent re-registers with a weaker identity.

Absence is the denial. An unverified login mints nothing rather than minting something that says "weak", so a gated tool denies by default. Nothing needs to remember to check.

Claim-Based Attestations

A claim-based attestation holds when the requester's token carries the claims its definition names. Built-in attestations answer fixed questions about a login; a claim-based attestation asks the policy's own.

{
  "attestations": ["writer", "consented_to_payments", "transfer_approved::{params.amount > 25000}"],
  "constraints": {
    "attestations": {
      "writer":                { "claim": { "roles": "Banking.Writer" } },
      "consented_to_payments": { "claim": { "scopes": "Banking.Write" } },
      "transfer_approved":     { "approval_criteria": "role:Banking.Approver", "timeout": 120 }
    }
  }
}

A payment needs a token with the Banking.Writer role and the Banking.Write scope. Above 25,000 it also needs an approver.

Writing the rule. claim maps each claim field to the value it must contain:

claim Holds when
{"roles": "Banking.Writer"} roles contains Banking.Writer
{"roles": ["Banking.Reader", "Banking.Writer"]} roles contains either value
{"roles": "Banking.Writer", "scopes": "Banking.Write"} both fields hold
[{"roles": "Banking.Writer"}, {"roles": "Banking.Approver"}] both rules hold: roles contains both values

A list of values means any one of them. Every field in a rule must hold, and every rule in a list. A field contains a value when it is a list that includes it, a space-separated string that has it as a word (such as scp), or that value itself. A field the token does not carry never holds.

For example, {"claim": {"foo": ["a", "b"], "bar": "c"}}:

Token Holds
foo: ["a"], bar: "c" yes
foo: ["b", "x"], bar: "c" yes
foo: ["a"], bar: "d" no: bar does not contain c
foo: ["x"], bar: "c" no: foo contains neither a nor b
foo: ["a"], no bar no: the token has no bar

Where the claims come from. The fields are those the tenant's identity bridge maps from the identity provider's token: roles, and any other mapped field, such as scopes from Entra's scp. MACAW checks that the token is genuine and current (signed by the tenant's identity provider, issued for this application, not expired) and takes its claims as issued. Who holds which role or scope is managed in the identity provider.

How it is decided:

  • Whose token. The person or service that started the request. In a chained invocation, that is the first caller.
  • For how long. While that token is valid. Nothing is consumed, so one_time, time_to_live and timeout do not apply.
  • Only from the token. No approval and no tool can provide it. When the claims do not hold, the call is denied with Attestation 'writer' is not proven by the requester's token, and no approval is requested.
  • Only with a verified identity. A requester registered without a validated token proves no claim.
  • Only a requirement. Like any attestation, it can deny a call. It never allows a resource the policies leave out or lifts a denial.
  • When it is required is set by the ::{condition} on the requirement, as for any attestation. It is decided where a policy requires it and is not stored, so context.has_attestation() does not see it.
  • One type per name. Definitions merge by name across policies, and a name has one type (see Attestation Types).

External Attestations (Third-Party Approval)

Require approval from an external party (manager, admin, compliance officer).

{
  "attestations": [
    "trade_approved::{params.amount > 5000}"
  ],
  "constraints": {
    "attestations": {
      "trade_approved": {
        "approval_criteria": "role:manager",
        "timeout": 300,
        "time_to_live": 3600
      }
    }
  }
}

Characteristics:

  • Require approval_criteria in metadata
  • Can block execution waiting for approval (timeout > 0)
  • Used for human-in-the-loop workflows

The discriminator: approval_criteria declares an external attestation, claim a claim-based one, and "internal": true an internal one. A definition with none of them only adds metadata.

Approval Outcomes:

  • Approved: Approver approved with client.approve_attestation() - requestor proceeds
  • Denied: Approver rejected with client.deny_attestation() - requestor fails with reason

approval_criteria Formats:

Format Example Matches
user:xxx user:alice caller_id == "alice" OR email == "alice"
user:xxx@domain user:alice@fintech.com Exact match on caller_id or email
role:xxx role:manager Approver has role "manager" from IDP/JWT
xxx (bare) manager Fallback: treated as role:manager
Comma-separated role:manager,role:compliance Approver meets every listed criterion

When two policies define the same attestation with different criteria, the approver must meet all of them.

approval_criteria governs approvals made through the SDK. In the console, workspace admins, and members with permission to manage attestations, can approve any request.

The Blocking Flow

When a request requires an external attestation with timeout > 0, the system blocks and waits for approval:

Request arrives → Policy requires attestation
    ↓
Check the control plane's store → Not found
    ↓
Create pending attestation (for_agent, approval_criteria, timeout)
    ↓
Poll with backoff [1, 1, 2, 2, 3, 5, 7, 9, 10] seconds
    ↓
┌─────────────────────────────────────────────┐
│ Outcome:                                    │
│   • approved → Request proceeds             │
│   • denied → Request fails with reason      │
│   • timeout → Request fails (expired)       │
└─────────────────────────────────────────────┘

Key points:

  • An approval is looked up only in the control plane's store. A tool's attestation of the same name never satisfies it
  • The for_agent field identifies WHO needs the attestation (the requester)
  • The approval_criteria field specifies WHO can approve
  • Approvers call client.list_attestations(status='pending') to see requests they can approve
  • Approvers call client.approve_attestation() or client.deny_attestation() to respond

The for_agent Field

When an attestation is created, for_agent identifies who needs it approved:

{
  "key": "trade_approved",
  "for_agent": "alice@trading-app",    // WHO needs this approved
  "approval_criteria": "role:manager", // WHO can approve
  "invocation_id": "inv-123",          // Links to the blocked request
  "status": "pending"
}

Why for_agent matters:

  1. Filtering in list_attestations(): When an agent calls list_attestations(status='pending'), they see:

    • Attestations where they ARE the for_agent (attestations they're waiting on)
    • Attestations where they MATCH the approval_criteria (attestations they can approve)
  2. Activity Graph correlation: The invocation_id links the attestation to the original request, enabling end-to-end tracing in the Activity Graph.

  3. Audit trail: The for_agent field is recorded in audit events, showing who requested the attestation and who approved it.

Conditional Attestations (NEW in v3)

The most powerful feature: Attestation requirements that are conditional on runtime parameters.

Basic Syntax

"attestation_key::{condition}"

Examples:

{
  "attestations": [
    "identity_verified",
    "trade_approved::{params.amount > 5000}",
    "manager_override::{params.priority == 'urgent' AND params.amount > 10000}"
  ]
}

What this means:

  • identity_verified - Always required (no condition), on every call the policy covers
  • trade_approved::{params.amount > 5000} - Only required when amount exceeds 5000
  • manager_override::{...} - Only required when both conditions are true

Condition Syntax

References:

Pattern Description Example
params.X Invocation parameter params.amount > 5000
context.has_attestation('X') Existing attestation check context.has_attestation('mfa')

Operators:

Operator Description Example
== Equals params.currency == 'USD'
!= Not equals params.status != 'draft'
<, <=, >, >= Comparison params.amount > 5000
IN In list params.region IN ('us', 'eu')
MATCHES Glob pattern matching params.command MATCHES '*npm install*'

Boolean Logic:

Operator Description Example
AND Both true params.a > 10 AND params.b < 5
OR Either true params.x == 'a' OR params.x == 'b'
NOT Negation NOT params.currency == 'USD'
() Grouping (params.a > 10 AND params.b < 5) OR params.urgent == true

Precedence: NOT binds tightest, then AND, then OR. Use parentheses to group: NOT (params.a > 10 OR params.b > 10).

Boolean parameters are compared explicitly: params.urgent == true.

Conditions fail closed: a malformed condition requires the attestation. See Common Issue 5.

Type the parameters you compare. A numeric comparison holds only for numbers, so params.amount > 5000 is false for the string "10000". Give the parameter a type in constraints.parameters: parameter constraints are checked before attestations, so a string is refused before the condition is evaluated.

Complex Conditional Examples

Tiered approval based on amount:

{
  "attestations": [
    "identity_verified",
    "team_lead_approval::{params.amount > 1000 AND params.amount <= 10000}",
    "manager_approval::{params.amount > 10000 AND params.amount <= 50000}",
    "director_approval::{params.amount > 50000}"
  ]
}

Multi-factor conditions:

{
  "attestations": [
    "large_trade::{(params.amount > 25000 AND params.currency == 'USD') OR params.priority == 'urgent'}"
  ]
}

Pattern matching for command strings:

{
  "attestations": [
    "tier2_session::{params.command MATCHES '*npm install*'}",
    "tier3_operation::{params.command MATCHES '*rm -rf*' OR params.command MATCHES '*curl*'}",
    "tier4_critical::{params.command MATCHES '*sudo*'}"
  ]
}

Pattern syntax uses glob-style wildcards:

  • * matches any characters
  • *npm* matches any string containing "npm"
  • npm* matches strings starting with "npm"
  • *lodash matches strings ending with "lodash"

Different requirements for different callers:

Conditions read the call's parameters. The caller's identity decides which policies load (deferred principal binding), so put a caller-specific requirement in that caller's policy:

{
  "policy_id": "team:junior-traders",
  "attestations": [
    "extra_approval::{params.amount > 5000}"
  ],
  "constraints": {
    "attestations": {
      "extra_approval": { "approval_criteria": "role:manager", "timeout": 300 }
    }
  }
}

Senior traders' policies leave it out.

Attestation Keys That Are Policy IDs

An attestation key with a namespace prefix names another policy to load, not an attestation to verify:

"attestations": [
  "identity_verified",      // an attestation: must be presented and signed
  "data:customer_master"    // a reference: loads that policy
]

See Part 6: Object Policies.

Attestation Metadata

Configure attestation behavior in constraints.attestations:

{
  "constraints": {
    "attestations": {
      "trade_approved": {
        "approval_criteria": "role:manager",
        "timeout": 300,
        "time_to_live": 3600,
        "one_time": true,
        "priority": 100
      }
    }
  }
}

Metadata Fields

Field Type Description
approval_criteria string Who can approve (external attestation). Format: role:X, user:email@domain.com, admin; comma-separate criteria that must all be met
timeout integer Seconds to wait for approval. 0 or omitted = don't block, > 0 = block and wait
time_to_live integer Seconds the approval remains valid. 0 or omitted = no expiry
one_time boolean If true (the default), the approval is consumed after a single use
priority integer Processing order when multiple attestations match. Higher values processed first (default: 0)
claim object or list The claims that prove it (claim-based attestation). See Claim-Based Attestations
internal boolean true declares an internal attestation: a fact a tool records

claim defines a claim-based attestation, and internal an internal one. The other fields apply to approvals (external attestations). A tool sets the limits for internal attestations when it creates them (see Creating Attestations).

Metadata Semantics

Internal, External or Claim-based (declared by internal, approval_criteria or claim):

  • Internal attestation ("internal": true): the tool that creates it sets its lifetime and use limits.
  • External attestation (has approval_criteria): the policy's metadata governs the approval request.
  • Claim-based attestation (has claim): the requester's token decides it; no other metadata applies.
// External attestation
{
  "trade_approved": {
    "approval_criteria": "role:manager",
    "timeout": 300,
    "time_to_live": 3600,
    "one_time": true
  }
}

Blocking behavior (determined by timeout):

  • timeout: 0 or absent → Don't block, deny immediately if attestation missing. No approval request is created, so only an approval that already exists satisfies it
  • timeout: 300 with approval_criteria → Block up to 300 seconds waiting for approval

one_time (Boolean)

logger.attest(key="sudo_verified", one_time=True)

What it does:

  • Attestation is consumed after first verification
  • Second verification attempt fails (attestation deleted)

Use cases:

  • Authentication tokens (one login = one session)
  • Approval workflows (one approval = one action)
  • One-time operations (password reset, account activation)

When it is spent: A call checks its attestations one by one, highest
priority first, and spends each one-time attestation as it verifies it. A call
refused at a later attestation has already spent the earlier ones. A one-time
approval belongs to the call it was approved for and could answer no other, so
this costs something only for a one-time attestation a tool created. Checks
that spend nothing run before the attestations
(Where They Run).

Grants: Reusable Approvals (one_time: false)

Set one_time: false to create a grant that can be reused multiple times:

{
  "constraints": {
    "attestations": {
      "trade_approved": {
        "approval_criteria": "role:manager",
        "one_time": false,      // Reusable grant
        "time_to_live": 3600    // Valid for 1 hour
      }
    }
  }
}

What it does:

  • Manager approves once → Grant is created
  • Agent can execute multiple trades for 1 hour without re-approval
  • Each use emits attestation_accessed audit event
  • Grant expires when time_to_live elapses
  • The grant belongs to the agent that requested it, and covers any of its calls that need trade_approved until it expires

Use cases:

  • Batch operations (approve once, run many)
  • Time-boxed elevated access (trading window)
  • Delegated authority (manager grants permission for a period)

time_to_live (Integer, seconds)

logger.attest(key="session_authenticated", ttl=300)

What it does:

  • Attestation expires after 300 seconds (5 minutes)

Use cases:

  • Session management (5 minute authentication window)
  • Time-sensitive operations (trading hours enforcement)
  • Temporary escalations (sudo access for 10 minutes)

max_uses (Integer) - Monitor Pattern

max_uses is set when a tool creates an internal attestation:

logger.attest(key="api_quota", max_uses=10)

What it does:

  • Attestation valid for 10 verifications
  • Use counter incremented on each verification
  • Fails when use_count >= max_uses

Use cases:

  • API rate limiting (10 calls per attestation)
  • Quota enforcement (10 file downloads)
  • Multi-use but not unlimited (batch operations)

Example:

# Create attestation with max_uses
logger.attest(key="batch_quota", max_uses=3)

# Use 1
verify("batch_quota")  # ✅ Works (use_count: 1/3)

# Use 2
verify("batch_quota")  # ✅ Works (use_count: 2/3)

# Use 3
verify("batch_quota")  # ✅ Works (use_count: 3/3)

# Use 4
verify("batch_quota")  # ❌ Fails (use_count: 3/3, exhausted)

When Several Policies Define the Same Attestation

Attestation requirements accumulate: if any applicable policy requires an attestation, it is required.

When two policies both define metadata for the same attestation, each field keeps the stricter value:

Field Combined value
timeout The shorter wait
time_to_live The shorter duration (0 means no expiry)
one_time true if either policy sets it to true; a policy that leaves it out takes the other's value
approval_criteria Every criterion from both policies
priority The higher value
claim Every rule from both policies must hold

Fields a policy leaves out take the other policy's value or the default. Set a field to enforce it. An attestation declared as one type in one policy and as another in a second policy cannot be both, and the call is denied.

The Security Model: ToolAgent Signing + Registry TCB

Why Independent Verification?

In MACAW, each tool is wrapped in a ToolAgent (Policy Enforcement Point). This creates isolation:

User Agent (orchestrator)
  ↓ invokes
ToolAgent PEP (independent verifier)
  ↓ enforces policies for
Tool Code (business logic)

Benefit: If a tool is compromised, the blast radius is limited:

  • Compromised tool can't access other tools directly
  • ToolAgent PEP enforces resource policies
  • ToolAgent verifies signatures on invocations

How Verification Works

  1. ToolAgent Creates Attestations (Not the Tool)
verify_identity tool executes successfully
  ↓
ToolAgent PEP creates attestation
  ↓
ToolAgent signs with its own private key
  ↓
Stores in session context

Why this matters: The tool code never sees the private key. Even if the tool is compromised, it cannot forge attestations.

  1. Registry as Trusted Computing Base (TCB)

When verifying an attestation:

Attestation says: "Created by tool.verify_identity"
  ↓
Need public key to verify signature
  ↓
Look up in GlobalAgentRegistry (TCB)
  ↓
Registry returns: tool.verify_identity's registered public key

Why this matters:

  • Registry is part of the Trusted Computing Base (TCB)
  • All ToolAgents register their public keys on startup
  • Verifier trusts ONLY keys from the registry
  • Attackers can't embed fake public keys in attestation data

Creating Attestations

1. Auto-Attestation (Config-Driven)

For simple "I executed successfully" cases, configure attestations declaratively:

client = MACAWClient(
    app_name="trading-app",
    tools={
        "verify_identity": {
            "handler": verify_identity_handler,
            "attestation": {
                "key": "identity_verified",
                "one_time": True,
                "ttl": 300,
                "scope": "session"
            }
        }
    }
)

Use when:

  • Simple "this operation completed" semantics
  • No custom data needed in attestation value
  • Standard metadata sufficient

2. Explicit Attestation (Programmatic)

For cases where you need custom values:

def risk_check_handler(params, logger):
    """Risk check tool - records the trade it checked and the limit it applied.

    A handler that declares a `logger` parameter receives the ToolAgent's logger.
    """

    trade_id = params.get('trade_id')
    limit = risk_limit_for(trade_id)    # the service's own data

    # Create attestation with custom value
    logger.attest(
        key="risk_checked",
        value={"trade_id": trade_id, "limit": limit},
        one_time=True
    )

    return {"status": "checked", "trade_id": trade_id}

Use when:

  • Need to embed custom data (the record checked, the result)
  • Need application-specific metadata

A tool attests what it established itself: what it looked up or did. An approval comes from an approver (approval_criteria) and a built-in from the control plane; a tool's attestation never stands in for either, even under the same name.

A later call uses the attestation in either or both of two ways:

Requirement Verifier
Written as "attestations": ["risk_checked"], declared "internal": true a verifier that reads context.attestations["risk_checked"]["value"]
Checks it exists for this caller, its signature, time_to_live, max_uses anything about its value against the call's parameters
Spends one_time and max_uses are spent when the requirement is checked nothing: a verifier reads a copy
{
  "attestations": ["risk_checked"],
  "verifiers": ["risk_matches"],
  "constraints": { "attestations": { "risk_checked": { "internal": true } } }
}
def verify(invocation, context):
    value = ((context.attestations or {}).get("risk_checked") or {}).get("value") or {}
    if value.get("trade_id") != invocation.params.get("trade_id"):
        return VerificationResult(False, "risk_checked records a different trade")
    if invocation.params.get("amount", 0) > value.get("limit", 0):
        return VerificationResult(False, "amount is over the checked limit")
    return VerificationResult(True, "ok")

Verifiers run before requirements, so a call the verifier refuses spends nothing.

Enforcing Attestation Requirements in Policies

In your MAPL policy, require attestations before allowing operations:

{
  "policy_id": "intent:trading",
  "resources": [
    "tool:verify_identity",
    "tool:approve_trade",
    "tool:execute_trade"
  ],
  "constraints": {
    "tool:execute_trade": {
      "attestations": [
        "identity_verified",
        "trade_approved::{params.amount > 5000}"
      ]
    },
    "attestations": {
      "identity_verified": { "internal": true },
      "trade_approved": {
        "approval_criteria": "role:manager",
        "timeout": 300,
        "time_to_live": 3600,
        "one_time": true
      }
    }
  }
}

The requirements sit on execute_trade only. verify_identity is the tool that records identity_verified, so it cannot require it.

How enforcement works:

  1. Agent tries to execute trade with amount=10000
  2. Policy enforcer evaluates attestation requirements:
    • identity_verified - Always required → Check
    • trade_approved::{params.amount > 5000} - Condition: 10000 > 5000 = true → Required
  3. For each required attestation:
    • Find it where its type says: an internal one in the caller's session in this service, an approval or built-in in the control plane's store
    • Look up signer's public key from registry (TCB)
    • Verify cryptographic signature
    • Check metadata (one_time used? time_to_live expired? max_uses exhausted?)
  4. Decision:
    • ✅ All attestations verify → Allow execution
    • ❌ Any attestation fails → Deny (or block if timeout > 0 for external)

Real-World Example: Financial Trading Workflow

The Requirement

Scenario: Trading platform where:

  • Users can execute trades
  • Trades over $5,000 require manager approval
  • Identity must be verified before any trade
  • Each approval is one-time use (prevents replay)
  • Verifications expire after 5 minutes

Step 1: Define the Policy

{
  "policy_id": "intent:secure-trading",
  "description": "Secure trading with conditional approval",

  "resources": [
    "tool:verify_identity",
    "tool:approve_trade",
    "tool:execute_trade"
  ],

  "constraints": {
    "tool:execute_trade": {
      "attestations": [
        "identity_verified",
        "trade_approved::{params.amount > 5000}"
      ]
    },
    "attestations": {
      "identity_verified": { "internal": true },
      "trade_approved": {
        "approval_criteria": "role:manager",
        "timeout": 300,
        "time_to_live": 3600,
        "one_time": true
      }
    },
    "parameters": {
      "tool:execute_trade": {
        "amount": {"type": "number", "min": 0, "max": 1000000}
      }
    }
  }
}

Step 2: Configure Auto-Attestation

tools = {
    "verify_identity": {
        "handler": verify_identity_handler,
        "attestation": {
            "key": "identity_verified",
            "one_time": True,
            "ttl": 300
        }
    }
}

Step 3: Execute Workflow

# Trade 1: Small trade ($1000) - no manager approval needed
client.invoke_tool("verify_identity", {"user_id": "alice@acme.com"})
result = client.invoke_tool("execute_trade", {"trade_id": "T-001", "amount": 1000})
# ✅ Succeeds - identity_verified present, trade_approved not required (1000 < 5000)

# Trade 2: Large trade ($10000) - manager approval needed
client.invoke_tool("verify_identity", {"user_id": "alice@acme.com"})
result = client.invoke_tool("execute_trade", {"trade_id": "T-002", "amount": 10000})
# ⏳ Blocks waiting for manager approval (up to 300 seconds)
# Manager approves via separate interface
# ✅ Succeeds after approval

# Trade 3: Try to reuse attestations
result = client.invoke_tool("execute_trade", {"trade_id": "T-003", "amount": 500})
# ❌ Fails - identity_verified was consumed (one_time=true)

Step 4: Bob's Approval Code (Separate Terminal)

While Alice's Trade 2 is blocking, Bob runs this in another terminal:

from macaw_client import MACAWClient, RemoteIdentityProvider

# Bob authenticates via Keycloak (has "manager" role)
jwt_token, _ = RemoteIdentityProvider().login("bob", "Bob@123!")

client = MACAWClient(
    user_name="bob",
    iam_token=jwt_token,
    agent_type="user",
    app_name="attestation-approver"
)
client.register()

# List pending attestations Bob can approve (matches his roles)
attestations = client.list_attestations(status="pending")
print(f"Found {len(attestations)} pending attestations")

# Approve Alice's trade request
for att in attestations:
    if att['key'] == 'trade_approved':
        # Approve
        client.approve_attestation(att, reason="Manager approved")
        print("Approved!")

        # OR Deny
        # client.deny_attestation(att, reason="Budget exceeded")

client.unregister()

When Bob approves, Alice's blocked request (in the other terminal) automatically proceeds.

MACAWClient Approval APIs

The SDK provides three methods for working with attestations:

list_attestations(status)

List attestations visible to the current agent:

# List all pending attestations (you can approve OR are waiting on)
pending = client.list_attestations(status="pending")

# List approved attestations
approved = client.list_attestations(status="approved")

# List all attestations (no filter)
all_atts = client.list_attestations()

Returns attestations where:

  • You ARE the for_agent (attestations you're waiting on, in any status), OR
  • You MATCH the approval_criteria and the attestation is still pending (attestations you can approve)

approve_attestation(attestation, reason)

Approve a pending attestation:

client.approve_attestation(
    attestation,
    reason="Approved for Q4 trading window"
)

Requirements:

  • Attestation must be in pending status
  • Your identity must match approval_criteria (e.g., have "manager" role)
  • Emits attestation_approved audit event with your signature

deny_attestation(attestation, reason)

Deny a pending attestation:

client.deny_attestation(
    attestation,
    reason="Budget exceeded for this quarter"
)

Requirements:

  • Attestation must be in pending status
  • Your identity must match approval_criteria
  • Emits attestation_denied audit event
  • Blocked requester receives denial with your reason

Security Guarantees Achieved:

  • ✅ Agent can't skip identity verification
  • ✅ Agent can't forge manager approval
  • ✅ Agent can't reuse old approvals (one_time=true)
  • ✅ Agent can't use expired verifications (time_to_live)
  • ✅ Small trades don't need manager approval (conditional)

Comparison to Alternatives

Approach Forgeable? Verifiable? Temporal Control? Conditional? Infrastructure
Agent Memory ✅ Yes ❌ No ❌ No ❌ No None
Blockchain ❌ No ✅ Yes ⚠️ Via contract ⚠️ Complex 🔴 High
OAuth2 Scopes ❌ No ✅ Yes ✅ Yes ❌ No 🟡 Medium
MAPL Attestations ❌ No ✅ Yes ✅ Yes ✅ Yes 🟢 Low

Part 6: Object Policies

Attaching a Policy to a Thing

Every policy so far attaches to a caller — a company, a team, a user, a service. That works because callers invoke things, and invoking is what triggers policy resolution.

Some things are never invoked. A table, a file, a document, a model — these get used. A tool reads them. Nothing invokes them, so nothing loads their policy.

Object policies attach the rules to the thing itself. MAPL enforces them whenever a tool consumes it:

{
  "policy_id": "data:employee_comp",
  "version": "1.0",
  "description": "Compensation data",
  "resources": ["tool:reporting/query"],
  "denied_resources": ["tool:*/export", "tool:*/send"],
  "attestations": ["pii_training_current"],
  "constraints": {
    "attestations": { "pii_training_current": { "internal": true } }
  }
}

Read it as: only reporting/query may touch me, never an export or messaging tool, and only from someone with current PII training.

The object doesn't care which team or user is calling. Whoever consumes it is bound by it.

Loading an Object Policy

Name the object in attestations. A key with a namespace prefix is a policy reference:

{
  "policy_id": "tool:reporting/query",
  "resources": ["tool:reporting/query"],
  "attestations": ["data:employee_comp"]
}

MAPL loads data:employee_comp and merges it like any other layer.

So an attestation name must not contain a colon. The namespace prefix is
what tells MAPL to load a policy rather than look for an attestation, and it cannot
tell the two apart by anything else: "capability:web_search" in
attestations is read as a reference to a policy called
capability:web_search, and a reference that resolves to nothing denies.
Name attestations the way the shipped policies do - unknown_tool_approved,
cron_schedule_approved, capability_web_search.

This applies to the name in attestations. Colons are fine elsewhere: in the
tier::{condition} form, where the part before :: is the tier, and inside
expressions such as context.has_attestation('scope:workspace'), which the evaluator
reads rather than the resolver.

Dynamic References

Hardcoding one object per policy doesn't scale. Build the policy ID from the invocation instead:

{
  "policy_id": "app:reporting",
  "resources": ["tool:reporting/*"],
  "attestations": ["data:{params.dataset}"]
}

A call with dataset = "employee_comp" loads data:employee_comp. A call with dataset = "finance/transactions" loads data:finance/transactions — or data:base if that object has no policy of its own.

One line covers every object in your system.

List parameters expand to one reference each, and an empty list loads nothing:

// params: { "datasets": ["transactions", "employee_comp"] }
// loads:  data:transactions  AND  data:employee_comp

The Two Rules

1. Fail closed. A reference must resolve — to its own policy, or to {namespace}:base. If neither exists, the call is denied. Naming an object declares that it has rules, so a missing policy is a configuration error, not permission.

2. Narrowing only. Object policies merge by intersection like every other layer. They can restrict; they can never grant. This is the same Monotonic Restriction property that governs organizational policies, so you can generate thousands of object policies with no risk of privilege escalation.

What Can Be Substituted

Only invocation parameters, using {params.name}:

Situation Result
Parameter present Substituted into the policy ID
Parameter absent Reference doesn't apply — nothing loaded, no denial
Value contains a character outside A–Z a–z 0–9 _ . / - @ #, or contains .. Denied — a value may only name an object inside the namespace

finance/Q1-2026, #W2611340 and alice@acme.com resolve, so order:{params.order_id} loads order:#W2611340; hr:comp and ../hr are denied.

"Parameter absent" is what lets you put one reference at company level and have it apply only to the tools that have that parameter.

Binding Objects Per Tool Family

Use a tool pattern in constraints to give tool families different object defaults:

{
  "policy_id": "company:acme",
  "resources": ["tool:**"],
  "constraints": {
    "tool:reporting/*": { "attestations": ["data:{params.dataset}"] },
    "tool:docs/*":      { "attestations": ["doc:{params.document}"] }
  }
}

Chaining: Derived Objects

An object policy can reference other objects. This is how you handle a summary, view, or export whose underlying sources never appear in the call:

{
  "policy_id": "data:quarterly_summary",
  "version": "1.0",
  "description": "Derived from transactions and employee_comp",
  "resources": ["**"],
  "attestations": ["data:transactions", "data:employee_comp"]
}

It sets no restrictions of its own — ["**"] restricts nothing. Its sources carry the rules.

A call against quarterly_summary loads the derived object's policy, which loads both sources. Every restriction on either source applies.

Chains follow to any depth. Cycles terminate safely, and each policy loads once.

Worked Example: Three Objects, Any Number of Callers

The floor — applies to every object without a policy of its own:

{
  "policy_id": "data:base",
  "version": "1.0",
  "description": "Default rules for every dataset",
  "resources": ["**"],
  "denied_resources": ["tool:*/export", "tool:*/send"]
}

A classified object with one extra restriction:

{
  "policy_id": "data:transactions",
  "version": "1.0",
  "extends": "data:base",
  "description": "Transaction records",
  "resources": ["tool:reporting/*"],
  "denied_resources": ["tool:*/publish"]
}

A sensitive object that requires proof:

{
  "policy_id": "data:employee_comp",
  "version": "1.0",
  "extends": "data:base",
  "description": "Compensation data - PII",
  "resources": ["tool:reporting/query"],
  "attestations": ["data:pii_training_current"]
}

The proof it requires, as its own policy, so any object can reference it:

{
  "policy_id": "data:pii_training_current",
  "version": "1.0",
  "description": "Current PII training, confirmed by a privacy officer",
  "resources": ["**"],
  "attestations": ["pii_training_current"],
  "constraints": {
    "attestations": {
      "pii_training_current": {"approval_criteria": "role:privacy-officer", "timeout": 300}
    }
  }
}

A derived object built from the other two:

{
  "policy_id": "data:quarterly_summary",
  "version": "1.0",
  "description": "Derived from transactions and employee_comp",
  "resources": ["**"],
  "attestations": ["data:transactions", "data:employee_comp"]
}

Wire it to the tools once:

{
  "policy_id": "app:reporting",
  "version": "1.0",
  "description": "Reporting tools",
  "resources": ["tool:reporting/*"],
  "attestations": ["data:{params.dataset}"]
}

Now:

Alice calls Loads Result
reporting/query(dataset="transactions") data:transactions → data:base ✅ Allowed
reporting/export(dataset="transactions") same ❌ Denied by tool:*/export, inherited from data:base
reporting/publish(dataset="transactions") same ❌ Denied by tool:*/publish, the object's own rule
reporting/query(dataset="employee_comp") data:employee_comp → data:base, + data:pii_training_current ⏳ Blocks for pii_training_current (a privacy officer, up to 300 s); denied without it
reporting/query(dataset="quarterly_summary") derived → data:transactions + data:employee_comp ⏳ Blocks for pii_training_current; denied without it. Carries both objects' rules
reporting/query(dataset="anything_new") data:base (fallback) ✅ Allowed under the floor

Two rows carry the lesson.

Row 5 is composition. Alice never named employee_comp, but querying a summary built on it inherits its PII requirement and its resource restriction:

query(dataset="quarterly_summary")
  resources: ["tool:reporting/query"]                  ← from employee_comp
  denied:    [tool:*/export, tool:*/publish, tool:*/send]  ← publish from transactions
  attestations: ["pii_training_current"]               ← from employee_comp, via data:pii_training_current

Row 6 is the floor. An object nobody has classified still gets data:base. Without data:base, that call would be denied instead.

Limitations

  • Conditions on references are rejected. data:employee_comp::{params.mode == 'write'} is a validation error. Put the condition inside data:employee_comp instead.
  • Only parameters substitute. {principal.x} is not supported.
  • Namespaces are lowercase. Data:x is not recognized as a reference; it is read as an attestation named Data:x.
  • Objects must be namable before the call. An object that appears only in a result cannot be referenced. Use a processor for those: it sees the result and can withhold it.

Part 7: Extractors, Verifiers and Processors

Status: Available from client 0.9.20 and control plane 0.9.20. All
three are additive — a policy that uses none of them behaves exactly as in
MAPL 3.2.

What Are They?

Three roles that a policy can invoke at the enforcement point, around a call.
The pipeline is the same whatever serves the call, a tool, an LLM or another
agent, so a processor applies to an LLM's reply as it does to a tool's result.

Role Signature Runs Can deny?
Extractor extract(value, **config) -> dict before policy checks No
Verifier verify(invocation, context, **config) -> VerificationResult before the tool Yes
Processor process(result, invocation, context, **config) -> ProcessingResult after the tool Withholds the result

Example scenario:

  • ✅ Alice can call tool:deploy/run
  • ⚠️ But her script must not contain credentials — a verifier refuses it
  • ⚠️ And the token the tool echoes back is masked — a processor removes it
  • ⚠️ And approval is required when her SQL raises a bonus above $10,000 — an
    extractor supplies the number, an attestation gates on it

Where They Run

  call arrives at the tool's enforcement point
    │
  1  RESOLVE POLICY      identity + resource → one effective policy
    │
  2  EXTRACTORS          each reads its field → derived.<name>.<key>
    │                    a failure refuses the call
  3  POLICY CHECKS       resources, parameters, denied parameters; conditions can read derived.*
    │
  4  VERIFIERS           by priority; an enforcing failure refuses the call
    │
  5  RATE LIMIT          counts the call
    │
  6  ATTESTATIONS        Part 5; may wait for an approver; spends one-time attestations
    │
  7  the tool runs
    │
  8  PROCESSORS          by priority, on the result; an enforcing failure withholds it
    │
  9  sign the result → caller

Extractors run before every check that can read their output. A verifier
returns only pass or fail, so it cannot supply a value that a condition reads —
that is what extractors are for.

Every check that can refuse a call without spending anything runs before the
attestations. So a call that a verifier refuses asks no approver and spends no
attestation, and a refused call does not count against the rate limit.

Extractors run before the policy checks, so they also run on a call the checks
will refuse.

Functions Live in the Workspace

Every extractor, verifier and processor a policy names is a short Python
function stored in the workspace, beside its policies. The enforcement point
fetches it the way it fetches a policy, and runs it where the tool runs.

  Library        ~/.macaw/functions/<kind>/           MACAW's functions, and your organisation's
     │  Add (console)
     ▼
  Workspace      extractors/  verifiers/  processors/ versioned like policies; the latest is served
     │  manifest sync
     ▼
  Enforcement point                                   checks it, runs it, refuses the call if it cannot

A function enters the workspace one way: saved in the console, under
Policies → Functions, either written there or added from the library.
Nothing is stored until both checks pass:

Check Refuses the save when
Allowlist the source uses anything the allowlist does not name
Samples any sample returns something other than what it expects

The enforcement point checks the allowlist again before it loads a function.

Every save is a new version. The previous one is archived, and the history
records who saved it, when, and its content hash. Enforcement points use the
latest version; a running agent picks up a new one at its next sync, without a
restart.

Disable a function from its page under Policies → Functions. Its
versions move to the archive with their history, and enforcement points drop
it at their next sync. A function a policy applies cannot be disabled: its page
shows In use · N policies in place of Disable, and the API refuses with
Used by app:memo. Remove it from that policy first. Saving a disabled
function again continues its versions: 1.0.0, disabled, then saved again, is
1.0.1.

A policy can name only functions the workspace has. Saving one that names a
missing function is refused:

Policy names functions this workspace does not have: processors/mask_everything

Adding a function from the library copies it, with its version, into the
workspace. From then on the copy belongs to the workspace: each save there is a
new workspace version, and a change to the library does not reach it.
Upgrading the control plane refreshes the library and leaves every workspace as
it was. The console marks a library function ✓ when the workspace has the
library's version, and otherwise offers Add, which opens the library's
source in the editor and saves it as the workspace's next version.

Your organisation can put its own functions in the library folder. Give them
names MACAW does not use: when the control plane starts, it rewrites each of
its own files there that differs from the one it ships, unless the file is a
later version.

Writing a Function

A function is its kind's entry point, from the table in
What Are They?, plus any helpers and constants it needs.
config is what the policy declares for it, less the keys the enforcement
point uses itself: priority and enforce for a verifier or processor,
field for an extractor. A function lists the keys it accepts, such as
fields=(), or takes **config.

Example: the accounts named in a payment memo

ACCOUNT = re.compile(r"\bacct-[a-z0-9]+\b")

def extract(value, **config):
    found = ACCOUNT.findall(value or "")
    return {"count": len(found), "accounts": sorted(set(found))}
[
  {"value": "pay acct-nw", "expect": {"count": 1, "accounts": ["acct-nw"]}},
  {"value": "no accounts", "expect": {"count": 0, "accounts": []}}
]

The samples are part of the function. Each gives an input and what the
function should return for it, and every one must pass before the function is
stored. Each kind's sample format is in
Appendix D.4.

Run in the editor checks the allowlist, runs every sample, and reports how
long each sample's call took: 4 calls · mean 0.025 ms · slowest 0.049 ms. The
time covers the function's call only, not loading it or comparing the result.
A time that grows with the input's length, from a regular expression in
particular, is the warning sign.

Using It in a Policy

Saved as memo_accounts, the function's facts are
derived.memo_accounts.count and derived.memo_accounts.accounts. This
policy sends a payment that names an account for treasury review, and refuses
one that names more than two:

{
  "policy_id": "app:payments",
  "version": "1.0",
  "scope": "global",
  "resources": ["tool:payments/send"],
  "extractors": {
    "memo_accounts": {"field": "params.memo"}
  },
  "attestations": [
    "treasury_review::{ derived.memo_accounts.count > 0 }"
  ],
  "constraints": {
    "attestations": {
      "treasury_review": {"approval_criteria": "role:manager", "timeout": 300}
    },
    "denied_parameters": {
      "tool:payments/send": {
        "memo": ["{derived.memo_accounts.count > 2}"]
      }
    }
  }
}
memo derived.memo_accounts.count Result
"March rent" 0 ✅ allowed
"pay acct-nw" 1 ⏳ waits for role:manager approval
"split acct-nw acct-se acct-ce" 3 ❌ refused

The third call is refused even when treasury_review has been granted: an
approval satisfies an attestation and does not lift a denial. The function
decides nothing. It counts, and the policy sets both thresholds.

What a Function May Use

A function computes from what it is handed and returns. It has re and json
in scope, and the builtins for working with strings, numbers, lists and dicts.
It has no import, no files, no network, no clock, and no names that begin
with _. The allowlist in Appendix D.2 names everything a
function may contain. Anything else is refused at save, with the line:

Line 4: '.encode' is not available
'SEEN' is a list; top-level values are constants (str, number, tuple, compiled pattern)

Three more things hold where it runs:

  • Copies. Each function receives its own copies of what it is handed: the
    value, the invocation, the context and the result. A change it makes reaches
    neither the tool, the policy checks nor the next function. Only a
    processor's returned result is carried forward.
  • No state. Top-level values are constants, so a call leaves nothing
    behind for the next one.
  • A bound. A function may run 1,000,000 lines of its own code per call.
    Past that it is stopped, and it has failed.

When a Function Fails

A function fails when it raises, returns the wrong type, runs past its bound,
or is declared with a key it does not accept.

Kind enforce: true (default) enforce: false
Extractor the call is refused — extractors always enforce
Verifier the call is refused logged; the call proceeds
Processor the result is withheld logged; the result passes on without its change

When Several Policies Declare One Function

Any policy in the chain may declare a function, and all of them apply. A
function declared by one policy is used as that policy declares it. When two
policies declare the same function:

Key Combined value
enforce true if either declaration enforces; leaving it out enforces
priority The lower value, so it runs earlier
Every other key Must be the same in both

Every other key belongs to the function, and only the function knows which of
two values is stricter: for fields, a longer list; for a length limit, a
smaller number. So MAPL does not choose. Declarations of one function that
differ in those keys refuse the call:

The verifier 'secret_scanner' is declared differently by two policies

To inspect more fields than a parent declares, change the parent's declaration.
A layer that declares the same function differently cannot narrow a check
another layer relies on, or redirect what a condition reads.

Functions with the same priority run in name order.


Verifiers

What Are Verifiers?

A verifier inspects an invocation before the tool executes and returns pass or
fail. It cannot modify the invocation: the caller signed those parameters, and
rewriting them would mean the tool executes something nobody signed.

Basic Structure

{
  "constraints": {
    "verifiers": {
      "verifier-name": { "configuration" }
    }
  },
  "verifiers": ["verifier-name"]
}

constraints.verifiers.<name> declares and configures. The top-level
verifiers array applies. This is the same split as attestations, so a parent
policy can declare what a child applies.

Example:

{
  "constraints": {
    "verifiers": {
      "secret_scanner": {
        "priority": 50,
        "enforce": true,
        "fields": ["params.script"]
      }
    }
  },
  "verifiers": ["secret_scanner"]
}

Configuration Parameters

Parameter Type Default Description
priority integer 100 Execution order; lower runs first
enforce boolean true false logs the failure and allows the call

Every other key is passed to the verifier. The library's verifiers take
fields, the dotted parameter paths to inspect; see
Appendix D.6.

Patterns belong to the function, where its samples test them. A declaration
says which fields to inspect.

Set enforce: false to observe what a new verifier would refuse before
promoting it. The default is true: a verifier that does not enforce is
indistinguishable from an absent one.

Conditional Application

A verifier applies unconditionally, or only when a condition holds:

{
  "verifiers": [
    "secret_scanner",
    "length_limit::{ params.env == 'prod' }"
  ]
}

Condition syntax is identical to
conditional attestations.

In the Library

Name Inspects Fails when
secret_scanner fields a credential shape is present
length_limit fields a value exceeds max_length

Example: Blocking Credentials in Deployment Scripts

{
  "policy_id": "app:deploy",
  "version": "1.0",
  "scope": "global",
  "resources": ["tool:deploy/**"],
  "constraints": {
    "verifiers": {
      "secret_scanner": {"fields": ["params.script"]}
    }
  },
  "verifiers": ["secret_scanner"]
}
Call Result
script = "deploy --env staging" ✅ allowed
script = "export KEY=AKIA..." ❌ refused before the tool runs

Result Processors

What Are Processors?

A processor transforms a tool's result before it is signed and returned. It
runs inside the callee's authority, so it may rewrite the result — the tool
could equally have returned different data. Because it runs before signing, the
caller verifies what it actually received.

A processor can also withhold a result. It returns success=False, and if it
enforces, the caller receives a refusal in place of the result.

Basic Structure

{
  "constraints": {
    "processors": {
      "processor-name": { "configuration" }
    }
  },
  "processors": ["processor-name"]
}

Example:

{
  "constraints": {
    "processors": {
      "secret_scrubber": {"enforce": false}
    }
  },
  "processors": ["secret_scrubber"]
}

Configuration Parameters

Parameter Type Default Description
priority integer 50 Execution order; lower runs first
enforce boolean true false allows the result through if the processor fails

Every other key is passed to the processor. secret_scrubber takes fields,
the dotted result paths to transform; see Appendix D.6.

Direction Rules

Direction Permitted action Reason
Inbound (verifier) flag or block the caller signed the parameters
Outbound (processor) rewrite the tool signs after processing

Masking an inbound parameter does not protect the data: by then it has left the
caller, crossed the control plane and sat in a mailbox.

In the Library

Name Transforms Effect
secret_scrubber fields, or the whole payload replaces credential shapes with [REDACTED]

Example: Masking a Token a Tool Legitimately Holds

A deployment tool holds a GitHub token and writes it to its log.

{
  "constraints": {
    "processors": {"secret_scrubber": {}}
  },
  "processors": ["secret_scrubber"]
}
Tool returns:  {"log": "deployed with ghp_aaaa... ok"}
Caller sees:   {"log": "deployed with [REDACTED] ok"}

Extractors

What Are Extractors?

An extractor reads one parameter value and returns facts about it. Policy
conditions then read those facts. It makes no decision and modifies nothing.

Use an extractor when the value your policy needs is not a parameter, but is
computable from one — a number inside a SQL statement, the hosts inside a
request body, the paths inside a shell command.

Function Signature

extract(value, **config) -> dict
Input the value at the declared field, of any type; None if absent
Output a flat or nested dict of facts
Raises when the extractor cannot run; the enforcement point denies the call

An extractor receives no invocation, no context and no principal. It is a
function of its input alone, which is what makes it exhaustively testable.

Basic Structure

{
  "extractors": {
    "extractor-name": {"field": "params.parameter-name"}
  }
}

Extractors are declared in a single map, not a declare/apply pair. There is no
application condition and no ordering: each extractor writes only its own
namespace, and none of them can read another's output.

Example:

{
  "extractors": {
    "secrets": {"field": "params.notes"}
  }
}

Configuration Parameters

Parameter Type Required Description
field string yes Dotted path to the value; must begin with params

field may name the parameter block itself — "field": "params" passes every
parameter as a dict. Any other key in the declaration is passed to the
extractor as a keyword argument.

Paths outside params are rejected. An extractor that could read principal or
context would no longer be a function of its input. Conditions read params.*,
derived.* and context.has_attestation(); put a caller-specific rule in that
caller's policy.

Reading Facts

Facts are read as derived.<extractor>.<key>, using the same operators as
params.:

derived.secrets.count > 0
derived.sql.bonus > 10000
derived.secrets.kinds MATCHES '*aws*'
derived.sql.bonus > 10000 AND params.env == 'prod'

Nested facts are read by path: derived.sql.tables.payroll.rows.

Missing Values

Expression State Result
derived.sql.bonus extractor ran, no such fact false
derived.sql.bonus extractor was not declared raises — call refused

The second case is refused rather than evaluated as false, because a
condition that silently evaluates false produces a policy that reads as
enforcing a threshold and enforces nothing. The denial states the cause:

Denial rule {derived.sql.bonus > 10000} could not be evaluated:
No facts from extractor 'sql': it did not run for this invocation

In the Library

Name Reads Returns
secrets any value {present: bool, count: int, kinds: [str]}

A policy names a function stored in the workspace; see
Functions Live in the Workspace.

Use Case: Natural Language to SQL

An agent converts "give the top performers their bonuses" into:

UPDATE payroll SET bonus = 50000 WHERE rating = 'exceeds'

Policy must require approval for compensation changes above $10,000. The
parameter is the SQL string; the controlled value is the number assigned to
bonus.

Pattern matching cannot express this. Consider a policy that gates on the
statement text:

{
  "attestations": [
    "allow_update::{ params.query MATCHES '*UPDATE *' OR params.query MATCHES '*MERGE INTO*' }",
    "allow_delete::{ params.query MATCHES '*DELETE *' }"
  ]
}

Against MERGE INTO payroll ... WHEN MATCHED THEN DELETE:

Gate Fires Reason
allow_update ✅ matches *MERGE INTO*
allow_delete ❌ *DELETE * requires a trailing space

A row deletion is approved by a manager where the policy requires an admin. The
globs matched exactly as written — "DELETE " is a string, and "this statement
removes rows" is a property of the statement.

With an extractor, the statement is parsed and the policy reads the value:

{
  "extractors": {
    "sql": {"field": "params.query"}
  },
  "attestations": [
    "comp_change::{ derived.sql.bonus > 10000 }"
  ]
}
Statement derived.sql.bonus Approval
UPDATE payroll SET bonus = 5000 ... 5000 not required
UPDATE payroll SET bonus = 50000 ... 50000 required
MERGE INTO payroll ... THEN UPDATE SET bonus = 50000 50000 required

The rule is indifferent to statement form, comments and whitespace.

Selecting the approver by value:

{
  "attestations": [
    "manager_ok::{ derived.sql.bonus > 10000 AND derived.sql.bonus <= 100000 }",
    "admin_ok::{ derived.sql.bonus > 100000 }"
  ]
}

A SQL extractor is not in the library; the example shows the shape.


Complete Example

One policy using all three roles, where a single extracted fact produces three
different outcomes.

{
  "policy_id": "app:demo",
  "version": "1.2",
  "scope": "global",
  "resources": [
    "tool:demo/**"
  ],
  "extractors": {
    "secrets": {
      "field": "params.notes"
    }
  },
  "attestations": [
    "security_review::{ derived.secrets.count == 1 }"
  ],
  "constraints": {
    "attestations": {
      "security_review": {
        "approval_criteria": "role:manager",
        "timeout": 300,
        "time_to_live": 3600,
        "one_time": true
      }
    },
    "verifiers": {
      "secret_scanner": {
        "priority": 50,
        "enforce": true,
        "fields": [
          "params.script"
        ]
      }
    },
    "processors": {
      "secret_scrubber": {
        "enforce": false
      }
    },
    "denied_parameters": {
      "tool:demo/run": {
        "notes": [
          "{derived.secrets.count > 1}"
        ]
      }
    }
  },
  "verifiers": [
    "secret_scanner"
  ],
  "processors": [
    "secret_scrubber"
  ]
}

The workspace needs secrets, secret_scanner and secret_scrubber before
this policy can be saved. Add them from the library.

Call derived.secrets.count Result Enforced by
clean script — ✅ allowed, token in the result masked processor
script contains an AWS key — ❌ refused before the tool runs verifier
notes has no credential 0 ✅ allowed extractor + rule
notes has one credential 1 ⏳ blocks for role:manager approval extractor + attestation
notes has two credentials 2 ❌ refused extractor + denial rule

The middle row is what a verifier cannot express. A verifier returns pass or
fail, so "this needs a person" has no way to be said. The same count that
refuses at two requires an approver at one.

Changing > 1 to > 2 allows the last call, and changing == 1 to >= 1
requires approval for both. The extractor is unchanged in either case.

A runnable version is in examples/policy_driven_verifiers.py.


Limitations

Limitation Detail
Client version The enforcement point ships in the client. Policies using derived.* are inert below 0.9.9.11. Clients before 0.9.20 run only the functions compiled into their release. They refuse a call whose policy names any other extractor or verifier, and skip any other processor.
No chaining An extractor cannot read derived.
Processors combine two jobs secret_scrubber detects and transforms. A result extractor feeding a policy-selected processor is not implemented.
No state between calls A function cannot count, cache or remember. Limits over time belong to policy: rate_limit, one_time and time_to_live.
Regular expressions The bound counts the function's own lines. Time spent inside re is not counted, and at the enforcement point nothing else bounds it. The 3-second sample run at save is the check, so give samples the longest inputs you expect.
The caller in samples Samples run with a stand-in caller that has no roles, so a verifier that reads roles cannot be tested on them.
Library updates A newer library version reaches a workspace only when someone adds it.

Part 8: Complete Tutorial Example

Let's build a complete policy system for a financial services company.

The Scenario

FinTech Corp has:

  • Company-wide security baseline
  • Analytics Business Unit (BU)
    • Reporting Team
      • Alice (Junior Analyst)
      • Bob (Manager)
  • All users access OpenAI for financial analysis

Requirements:

  • Company: Max 4000 tokens, all OpenAI models allowed
  • Analytics BU: Max 2000 tokens, low temperature for consistency
  • Reporting Team: Max 1000 tokens
  • Alice: GPT-3.5 only, 500 tokens max
  • Bob: GPT-3.5 and GPT-4, 2000 tokens max
  • Large trades (>$5000) require manager approval

Step 1: Company-Wide Base Policy

{
  "policy_id": "company:FinTech",
  "version": "1.0",
  "description": "FinTech Corp company-wide policy",

  "resources": [
    "llm:openai/*",
    "tool:trade/*"
  ],

  "denied_resources": [
    "secrets:**"
  ],

  "attestations": [
    "identity_verified"
  ],

  "constraints": {
    "rate_limit": 100,
    "attestations": {
      "identity_verified": { "internal": true }
    },
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo", "gpt-4"],
        "max_tokens": {"max": 4000},
        "temperature": {"min": 0, "max": 1.0}
      }
    }
  }
}

Step 2: Analytics BU Policy

{
  "policy_id": "bu:Analytics",
  "version": "1.0",
  "extends": "company:FinTech",
  "description": "Analytics BU - deterministic results",

  "attestations": [
    "trade_approved::{params.amount > 5000}"
  ],

  "constraints": {
    "rate_limit": 50,
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 2000},
        "temperature": {"max": 0.3}
      }
    },
    "attestations": {
      "trade_approved": {
        "approval_criteria": "role:manager",
        "timeout": 300,
        "time_to_live": 3600,
        "one_time": true
      }
    }
  }
}

Step 3: Reporting Team Policy

{
  "policy_id": "team:Reporting",
  "version": "1.0",
  "extends": "bu:Analytics",
  "description": "Reporting team under Analytics BU",

  "constraints": {
    "rate_limit": 30,
    "parameters": {
      "llm:openai/chat.completions": {
        "max_tokens": {"max": 1000}
      }
    }
  }
}

Step 4: Alice's Policy (Junior Analyst)

{
  "policy_id": "user:alice",
  "version": "1.0",
  "extends": "team:Reporting",
  "description": "Alice - Junior Financial Analyst",

  "resources": [
    "llm:openai/chat.completions"
  ],

  "constraints": {
    "rate_limit": 10,
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo"],
        "max_tokens": {"max": 500},
        "temperature": {"max": 0.5}
      }
    }
  },

  "denied_resources": [
    "data:executive/*",
    "data:confidential/*"
  ]
}

Step 5: Bob's Policy (Manager)

{
  "policy_id": "user:bob",
  "version": "1.0",
  "extends": "team:Reporting",
  "description": "Bob - Finance Manager",

  "constraints": {
    "rate_limit": 30,
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo", "gpt-4"],
        "max_tokens": {"max": 2000},
        "temperature": {"max": 0.8}
      }
    }
  }
}

Final Effective Policies

Alice's Final Policy:

{
  "resources": ["llm:openai/chat.completions", "tool:trade/*"],
  "denied_resources": ["secrets:**", "data:executive/*", "data:confidential/*"],
  "attestations": ["identity_verified", "trade_approved::{params.amount > 5000}"],
  "constraints": {
    "rate_limit": 10,
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo"],
        "max_tokens": {"max": 500},
        "temperature": {"min": 0, "max": 0.3}
      }
    }
  }
}

Bob's Final Policy:

{
  "resources": ["llm:openai/*", "tool:trade/*"],
  "denied_resources": ["secrets:**"],
  "attestations": ["identity_verified", "trade_approved::{params.amount > 5000}"],
  "constraints": {
    "rate_limit": 30,
    "parameters": {
      "llm:openai/chat.completions": {
        "model": ["gpt-3.5-turbo", "gpt-4"],
        "max_tokens": {"max": 1000},
        "temperature": {"min": 0, "max": 0.3}
      }
    }
  }
}

Testing the Policies

Alice tries to execute trades:

# Test 1: Alice, small trade ($1000)
client.invoke_tool("trade/execute", {"trade_id": "T-001", "amount": 1000})
# Result: ✅ SUCCESS (identity_verified required, trade_approved NOT required)

# Test 2: Alice, large trade ($10000)
client.invoke_tool("trade/execute", {"trade_id": "T-002", "amount": 10000})
# Result: ⏳ BLOCKS waiting for manager approval
# After Bob approves: ✅ SUCCESS

Alice tries to call OpenAI:

# Test 1: Alice, gpt-3.5-turbo, 400 tokens
request = {"model": "gpt-3.5-turbo", "max_tokens": 400}
# Result: ✅ SUCCESS

# Test 2: Alice, gpt-3.5-turbo, 600 tokens
request = {"model": "gpt-3.5-turbo", "max_tokens": 600}
# Result: ❌ BLOCKED - "max_tokens=600 exceeds maximum: 500"

# Test 3: Alice, gpt-4, 400 tokens
request = {"model": "gpt-4", "max_tokens": 400}
# Result: ❌ BLOCKED - "model=gpt-4 not in allowed values"

Part 9: Best Practices

1. Start with Least Privilege

Inheritance only narrows, so the top of the hierarchy holds the broadest allow-list and each level below restricts it. Keep that top list to what the organization actually uses:

{
  "policy_id": "company:acme",
  "resources": ["llm:openai/*", "tool:reporting/*", "tool:database/query"],
  "denied_resources": ["admin:**"]
}

A child cannot grant what its parent does not allow, and a denial is permanent: "denied_resources": ["**"] at the top would deny everything below it.

2. Use Clear Policy IDs

Follow a consistent naming convention:

  • company:{name} - Top-level company policy
  • bu:{name} - Business unit policies
  • team:{name} - Team policies
  • user:{username} - User-specific policies

Make ids differ in a letter, digit, _, - or .. The control plane names a
policy's files after its id with every other character replaced by _, so
data:sales/orders and data:sales_orders would share files, and the second
one is refused.

3. Document Your Policies

Always include meaningful descriptions:

{
  "policy_id": "team:analysts",
  "description": "Financial analysts: GPT-3.5 only, 500 token limit, read-only finance data",
  "version": "1.2.0"
}

4. Version Your Policies

The control plane numbers versions. A new policy keeps the version it is saved
with. Each later save becomes the next version (1.0.0 → 1.0.1), whatever
version the saved policy names, and the previous one is archived with its
history. A policy that is disabled and added again continues from its last
version. Say what changed in description.

5. Use Wildcards Wisely

{
  "resources": [
    "llm:openai/chat.completions",      // ✅ Specific first
    "llm:openai/text-embedding-*",      // ✅ Targeted wildcard
    "tool:database/query"                // ✅ Specific operation
  ],
  "denied_resources": [
    "llm:openai/gpt-4*",                 // ✅ Block expensive models
    "admin:**"                           // ✅ Block all admin operations
  ]
}

Avoid overly broad wildcards like ** unless necessary.

6. Leverage Inheritance for DRY

Don't repeat constraints across policies:

// ✅ GOOD: Define once in company policy, inherit everywhere
// company.json
{
  "constraints": {
    "parameters": {
      "llm:**": {"max_tokens": {"max": 4000}}
    }
  }
}

// team-analysts.json (only override what changes)
{
  "extends": "company:acme",
  "constraints": {
    "parameters": {
      "llm:**": {"max_tokens": {"max": 500}}
    }
  }
}

7. Use Conditional Attestations for Tiered Approval

{
  "attestations": [
    "team_lead_approval::{params.amount > 1000 AND params.amount <= 10000}",
    "manager_approval::{params.amount > 10000 AND params.amount <= 50000}",
    "director_approval::{params.amount > 50000}"
  ],
  "constraints": {
    "attestations": {
      "team_lead_approval": { "approval_criteria": "role:team-lead", "timeout": 300 },
      "manager_approval":   { "approval_criteria": "role:manager", "timeout": 300 },
      "director_approval":  { "approval_criteria": "role:director", "timeout": 600 }
    }
  }
}

Declare every tier. An undeclared tier denies every call, including those below its threshold.

8. Set Appropriate Timeouts for External Attestations

{
  "constraints": {
    "attestations": {
      "urgent_approval": {
        "approval_criteria": "role:manager",
        "timeout": 60,  // Short timeout for urgent
        "time_to_live": 300
      },
      "standard_approval": {
        "approval_criteria": "role:manager",
        "timeout": 3600,  // 1 hour for standard
        "time_to_live": 86400
      }
    }
  }
}

9. Use one_time for Security-Critical Attestations

{
  "constraints": {
    "attestations": {
      "trade_approved": {
        "one_time": true  // Prevents replay attacks
      }
    }
  }
}

10. Monitor and Audit

Audit logging is configured on the MACAW service. Review audit logs regularly for:

  • Unexpected denials
  • Policy violations
  • Usage patterns
  • Attestation failures

11. Create a :base for Every Namespace You Reference

Object references fail closed. Without data:base, a reference to an unclassified object denies the call.

{
  "policy_id": "data:base",
  "version": "1.0",
  "description": "Floor for every dataset",
  "resources": ["**"],
  "denied_resources": ["tool:*/export"]
}

Make the base restrictive. It is what protects the objects nobody has classified yet.

12. Put Object Rules on the Object

If a rule is about a dataset, write it in that dataset's policy — not as a parameter constraint on every tool that might touch it. One object policy applies to every tool, including tools added later.

// Prefer this - one policy, applies everywhere
{ "policy_id": "data:employee_comp", "denied_resources": ["tool:*/export"] }

// Over this - repeated per tool, and misses the next tool you add
{ "constraints": { "denied_parameters": { "tool:reporting/export": { "dataset": ["employee_comp"] } } } }

Part 10: Troubleshooting

Common Issue 1: Access Denied Unexpectedly

Symptom: Request is denied but should be allowed.

Debugging steps:

  1. Check inheritance chain: Verify which policies are being merged. With DEBUG logging (step 5), the resolver logs each policy it loads and the chain it merges.

  2. Check denied_resources: Denials override allows

    {
      "resources": ["llm:openai/*"],
      "denied_resources": ["llm:openai/gpt-4"]  // ← This blocks gpt-4!
    }
    
  3. Check wildcards: Ensure patterns match intended resources

    • llm:openai/* matches llm:openai/chat.completions
    • But NOT llm:openai/v1/chat.completions (too deep!)
    • Use llm:openai/** for multi-level
  4. Check parameter constraints: Even if resource is allowed, constraints can cause denial

  5. Enable debug logging:

    export MACAW_LOG_LEVEL=DEBUG
    python your_app.py
    

Common Issue 2: Attestation Verification Failing

Symptom: "Missing or invalid attestation" error but attestation was created.

Debugging steps:

  1. Check where it was recorded:

    • A tool's attestation is kept for the caller whose call recorded it, in the service whose tool recorded it
    • Another caller, a tool in another service, or the same service after a restart does not see it
  2. Check its type:

    • A name declared as an approval or a built-in is looked up only in the control plane's store
    • A tool that attests the same name does not satisfy it
  3. Check conditional attestation evaluation:

    • Is the condition evaluating to true?
    • Check parameter values: params.amount > 5000 - is amount actually > 5000?
  4. Enable attestation debug logging:

    import logging
    logging.getLogger("macaw.protocol.mcp_types").setLevel(logging.DEBUG)
    
  5. Check metadata expiration:

    # time_to_live expired?
    if "expires_at" in attestation:
        import time
        if time.time() > attestation["expires_at"]:
            print("Attestation expired!")
    
    # one_time already used?
    if attestation.get("one_time") and "use_count" in attestation:
        print("Attestation already consumed!")
    
    # max_uses exhausted?
    if attestation.get("max_uses") and attestation.get("use_count", 0) >= attestation["max_uses"]:
        print("Attestation max_uses exhausted!")
    
  6. Verify ToolAgent registered:

    • Check if ToolAgent is in registry
    • Is the ToolAgent's public key in the registry?

Common Issue 3: External Attestation Not Blocking

Symptom: Request denied immediately instead of waiting for approval.

Debugging steps:

  1. Check timeout value:

    {
      "attestations": {
        "trade_approved": {
          "approval_criteria": "role:manager",
          "timeout": 300  // Must be > 0 to block
        }
      }
    }
    
  2. Check approval_criteria is present:

    • External attestations require approval_criteria
    • Without it the attestation is not an approval: a definition with no type only adds metadata, and a name declared internal or claim never waits for anyone
  3. Check other policies that define the same attestation:

    • When several policies define it, the shortest timeout applies
  4. Check condition evaluation:

    • Is the conditional attestation actually required?
    • Verify: trade_approved::{params.amount > 5000} - is amount > 5000?

Common Issue 4: Conditional Attestation Not Triggering

Symptom: Attestation should be required but isn't.

Cause: The condition evaluated to false. A comparison on a parameter the call does not send is false, so a misspelled parameter name means the requirement never applies.

Debugging steps:

  1. Check parameter names against what the tool sends:

    // ❌ Wrong parameter name
    "trade_approved::{params.total > 5000}"  // Should be 'amount'
    
    // ✅ Correct
    "trade_approved::{params.amount > 5000}"
    
  2. Check boolean logic:

    // Both must be true
    "approval::{params.a > 10 AND params.b < 5}"
    
    // Either can be true
    "approval::{params.a > 10 OR params.b < 5}"
    
    // NOT binds tightest; group with parentheses when negating more
    "approval::{NOT (params.a > 10 OR params.b < 5)}"
    

Common Issue 5: Attestation Required on Every Call

Symptom: A conditional attestation, or a {...} denial pattern, applies to every call regardless of parameter values.

Cause: The condition is malformed, and MAPL fails closed: it requires the attestation, or denies the call for a denial pattern.

Debugging steps:

  1. Check the logs: the message names the condition that could not be evaluated.

  2. Check for these common mistakes:

    // ❌ Unknown operator
    "trade_approved::{params.amount => 5000}"   // Use >=
    
    // ❌ Parameter used without a comparison
    "override_ok::{params.urgent}"               // Use params.urgent == true
    
    // ❌ Unbalanced parentheses
    "approval::{(params.a > 10 AND params.b < 5}"
    

Common Issue 6: External Attestation Approval Rejected

Symptom: "Not authorized: caller does not match criteria" when approving.

Debugging steps:

  1. Check approver's roles match approval_criteria:

    # approval_criteria: "role:manager"
    # Bob's JWT must have "manager" in realm_access.roles
    claims = decode_jwt(bob_token)
    print(claims['realm_access']['roles'])  # Should include 'manager'
    
  2. Check approval_criteria format:

    // For exact user match
    "approval_criteria": "user:bob@acme.com"
    
    // For role match (most common)
    "approval_criteria": "role:manager"
    
    // Bare name (treated as role)
    "approval_criteria": "manager"
    
  3. Ensure approver is registered:

    • Bob must call client.register() before approving
    • Bob's roles are extracted from JWT claims and stored in registry
    • Check: metadata.principal.roles in registry entry
  4. Verify approver can see the attestation:

    # Bob should see attestations matching his approval_criteria
    attestations = client.list_attestations(status="pending")
    if not attestations:
        print("No pending attestations visible - check roles")
    

Common Issue 7: Object Reference Denies Everything

Symptom: Every call is denied with "not in allowed resources", and the policy chain shows an object layer.

Cause: A reference didn't resolve. The referenced policy doesn't exist, and neither does its {namespace}:base. Or the substituted value is not an object name: it holds a character outside A–Z a–z 0–9 _ . / - @ #, or ... Object references fail closed, so either denies the call.

Diagnosis: look for the resolver warning naming the id it couldn't load, or the value it refused.

[RESOLVER] Object reference data:employee_comp did not resolve: Policy not found
[RESOLVER] Bad object reference 'data:{params.dataset}': unsafe value for {params.dataset}: 'q1 report'

Fix: create the object's policy, or create the namespace base:

{
  "policy_id": "data:base",
  "version": "1.0",
  "resources": ["**"],
  "denied_resources": ["tool:*/export"]
}

Common Issue 8: Dynamic Reference Never Fires

Symptom: "attestations": ["data:{params.dataset}"] appears to do nothing. No object policy is loaded, and no denial occurs.

Cause: the parameter isn't present on the invocation. A reference whose parameter is absent doesn't apply — that's deliberate, so one reference can sit at company level across tools that don't all share the parameter.

Fix: check the actual parameter name the tool sends. If the tool calls it table, the reference must be data:{params.table}.

Common Issue 9: Claim-Based Attestation Not Proven

Symptom: Attestation 'writer' is not proven by the requester's token for someone who holds the role.

Debugging steps: a policy sees the claims the identity bridge maps from the token.

  1. The field is mapped. The identity provider's claims mapping includes it (roles_path, scopes_path, or a custom field).
  2. The role filter allows it. The role is in the provider's role_filter.allowed. With case_sensitive: false, roles reach policies in lowercase; set it to true to keep the identity provider's spelling.
  3. The token carries it. Decode the token and look for the claim. A role assigned after the person signed in appears in their next token.
  4. The right requester. In a chained invocation, the token is that of the person who started the request.

Common Issue 10: Every Call Is Refused with "could not be evaluated"

The policy reached the enforcement point without its extractors block. Read
the policy back from the store and confirm the key survived. A control plane
below 0.9.9.11 drops keys it does not recognise when saving, which leaves the
rule in place and removes the extractor that supplies its input.

Common Issue 11: A Condition on derived.* Never Fires

Confirm the extractor produces the key being read. derived.sql.bonus is
false when the extractor ran and found no bonus, which is correct for a
statement that does not assign that column.

Common Issue 12: A Verifier Does Not Run

Check both halves. constraints.verifiers.<name> declares it and
verifiers: [...] applies it. A declaration without an application is a policy
that reads as protected and is not.

Common Issue 13: A Processor Does Not Modify the Result

Confirm fields names a path that exists in the result. An absent path is
skipped, not reported.

Common Issue 14: A Function Will Not Save

Symptom: Run fails in the function editor, and Save stays disabled.

Cause: the source uses something the allowlist does not name, or a sample
does not return what it expects. Messages that begin Line come from the
allowlist; messages that begin sample come from a sample.

Fix: Appendix D.5 lists each message and its cause, and
Appendix D.2 lists what a function may use. the samples did not finish within 3 s means a loop or a regular expression is too slow on
one of the samples.

Common Issue 15: A Policy Will Not Save

Symptom: Policy names functions this workspace does not have: verifiers/secret_scanner

Cause: the policy names an extractor, verifier or processor the workspace
does not have.

Fix: open Policies → Functions, add the function from the library or
write it, then save the policy again.

Common Issue 16: Calls Refused with "ran more than 1,000,000 lines"

Symptom: a call that names a function is refused, or its result withheld,
with ran more than 1,000,000 lines.

Cause: the function loops longer than its bound on this input.

Fix: add the input as a sample and run it in the editor. To watch a
verifier or processor on live traffic while you fix it, set enforce: false.

Common Issue 17: Every Call Is Refused with "cannot be resolved"

Symptom: The audit record shows a policy that allows nothing, with the reason Policy team:trading cannot be resolved: ...

Cause: The policy's extends cannot be met, so the policy denies everything:

Reason Fix
its parent bu:finance does not exist Restore the parent, or name one that exists
Circular inheritance detected Break the cycle in the extends chain
a definition the parent and child make two ways, such as The verifier 'secret_scanner' is declared differently by two policies Make the two declarations agree, or keep one

Common Issue 18: Calls Refused Because of an Attestation's Declaration

Symptom: a call is denied with one of these, before any approver is asked:

Message Cause Fix
Attestation 'tests_passed' is required but not declared No policy that applies declares its type. This holds for a conditional requirement even when its condition is false Declare it under constraints.attestations: "internal": true, approval_criteria or claim
Attestation 'trade_approved' is defined as approval and as internal Two policies that apply declare it as different types Give the name one type in every policy, or rename one
Attestation 'idp_verified' is built in, and a policy cannot define it A policy defines a built-in attestation Remove the definition and require it by name

See Attestation Types.

Common Issue 19: A Function Will Not Disable

Symptom: the function's page shows In use · N policies instead of Disable, and the API answers Used by app:memo. Remove it from that policy first.

Cause: a policy applies the function. A policy that names a function the workspace does not have denies every call it covers, so the function stays while any policy names it.

Fix: remove the function from the policies listed under Used by, then disable it.

Getting Help

If you encounter issues:

  1. Check audit logs for policy evaluation traces
  2. Enable DEBUG logging for detailed policy resolution
  3. Validate policies before saving them (Console → Policies)
  4. Review this guide's examples and best practices

Appendix A: Quick Reference

Complete Policy Template

{
  "policy_id": "{scope}:{name}",
  "version": "1.0",
  "extends": "parent-policy-id",
  "description": "Human-readable description",

  "validity": {
    "not_before": "2026-01-01T00:00:00Z",
    "not_after": "2026-12-31T23:59:59Z"
  },

  "resources": [
    "operation-pattern",
    "llm:openai/*",
    "tool:database/query"
  ],

  "denied_resources": [
    "admin:**",
    "secrets:**"
  ],

  "attestations": [
    "always_required",
    "conditional::{params.amount > threshold}",
    "data:some_object",
    "data:{params.dataset}"
  ],

  "extractors": {
    "extractor_name": {"field": "params.param_name"}
  },
  "verifiers": [
    "verifier_name",
    "conditional_verifier::{params.env == 'prod'}"
  ],
  "processors": ["processor_name"],

  "constraints": {
    "rate_limit": 100,

    "operation-pattern": {
      "attestations": ["attestation_key"]
    },

    "parameters": {
      "operation-pattern": {
        "param_name": {
          "type": "integer|number|string|boolean|array|object",
          "min": 0,
          "max": 1000,
          "range": [0, 1000],
          "allowed_values": ["value1", "value2"],
          "pattern": "^regex$",
          "min_length": 1,
          "max_length": 100,
          "min_items": 1,
          "max_items": 100
        }
      }
    },

    "denied_parameters": {
      "operation-pattern": {
        "param_name": ["*dangerous*", "*DROP TABLE*"]
      }
    },

    "attestations": {
      "always_required": { "internal": true },
      "conditional": { "claim": { "roles": "role-name" } },
      "attestation_key": {
        "approval_criteria": "role:manager",
        "timeout": 300,
        "time_to_live": 3600,
        "one_time": true,
        "priority": 100
      }
    },

    "verifiers": {
      "verifier_name": {"priority": 100, "enforce": true, "fields": ["params.param_name"]}
    },

    "processors": {
      "processor_name": {"priority": 50, "enforce": true}
    }
  }
}

An operation pattern inside constraints can carry its own attestations, required only for calls it matches; every pattern that matches a call applies. rate_limit is calls per minute for each caller and operation: 0 sets no limit and does not lift another level's.

extractors, verifiers and processors name functions in the workspace. See Part 7 and Appendix D.

Attestation Formats

Simple (always required):

"attestations": ["identity_verified", "mfa_complete"]

Conditional (required when condition is true):

"attestations": [
  "trade_approved::{params.amount > 5000}",
  "manager_override::{params.priority == 'urgent' AND params.amount > 10000}"
]

Policy reference (loads another policy):

"attestations": [
  "data:some_object",           // static
  "data:{params.dataset}"       // built from the invocation
]

Each attestation name is declared under constraints.attestations (see Attestation Types). An undeclared one denies the call, conditional or not.

A key with a lowercase namespace prefix names a policy to load. Conditions are not allowed on a reference. See Part 6.

Namespace Fallback

Requested Falls back to
data:finance/transactions (missing) data:base
data:base (missing) nothing — a reference to it denies the call
extends: "team:missing" nothing — the policy denies everything

The fallback is one level deep, to the namespace. Path segments are not a hierarchy. extends has no fallback: it names its parent.

Condition Syntax Reference

Pattern Example
Parameter comparison params.amount > 5000
String equality params.currency == 'USD'
In list params.region IN ('us', 'eu')
Glob pattern match params.command MATCHES '*rm -rf*'
Boolean parameter params.urgent == true
Attestation check context.has_attestation('mfa')
Boolean AND params.a > 10 AND params.b < 5
Boolean OR params.x == 'a' OR params.x == 'b'
Negation NOT params.currency == 'USD'
Grouping (params.a > 10 OR params.b > 10) AND params.urgent == true

NOT binds tightest, then AND, then OR.

Attestation Metadata Fields

Field Type Description
approval_criteria string Who can approve (makes it external attestation); comma-separate criteria that must all be met
claim object or list The claims that prove it (makes it claim-based)
internal boolean true makes it internal: a fact a tool records
timeout integer Seconds to wait (0 or omitted = don't block, >0 = block)
time_to_live integer Validity duration after approval, in seconds (0 or omitted = no expiry)
one_time boolean Consume after single use (default: true)
priority integer Processing order (higher = first, default: 0)
claim object or list Claims the requester's token must carry (claim-based attestation)

Attestation Status Lifecycle

Status Description
pending Awaiting approval (external) or set (internal)
approved Approved by authorized party
denied Rejected by authorized party
expired Time window (TTL) elapsed
approved + alive=false Consumed: one-time attestation used (status remains approved, but inactive)

approval_criteria Format

Format Example Matches
user:xxx user:alice caller_id or email == "alice"
user:xxx@domain user:alice@fintech.com Exact match on caller_id or email
role:xxx role:manager Approver has role from IDP/JWT
xxx (bare) manager Treated as role:manager
Comma-separated role:manager,role:compliance Approver meets every listed criterion

Attestation Audit Events

Event When Key Fields
attestation_created Pending request created key, for_agent, approval_criteria
attestation_approved Approver granted approved_by, reason, signature
attestation_denied Approver rejected denied_by, reason
attestation_accessed Grant reused (one_time=false) invocation_id, accessed_by
attestation_consumed One-time used up consumed_at
attestation_expired Timeout/TTL exceeded expires_at, reason

All Constraint Types

Constraint Applies To Format Example
type All String {"type": "integer"}
min Numbers Number {"min": 0}
max Numbers Number {"max": 4000}
range Numbers [min, max] {"range": [0, 100]}
allowed_values All Array {"allowed_values": ["A", "B"]}, {"allowed_values": [false]}
pattern Strings Regex matching the whole value {"pattern": "^[a-z]+$"}
min_length Strings Integer {"min_length": 3}
max_length Strings Integer {"max_length": 255}
min_items Arrays Integer {"min_items": 1}
max_items Arrays Integer {"max_items": 100}

Inheritance Merge Rules

Constraint Merge Strategy Example
resources Domain-Aware Child restricts within domains it mentions
denied_resources Union Both enforced
attestations Union All required
range Intersection Parent [0,100] + Child [10,50] = [10,50]
min, min_length, min_items Maximum Parent min:0 + Child min:10 = min:10
max, max_length, max_items Minimum Parent max:100 + Child max:50 = max:50
allowed_values Intersection Parent ["A","B","C"] + Child ["B","C","D"] = ["B","C"]
type, pattern, list shorthand Both apply A value must satisfy both
denied_parameters Union Both patterns enforced
validity Overlap Later not_before, earlier not_after
Attestation timeout Minimum Parent 300 + Child 60 = 60
Attestation time_to_live Shorter positive duration Parent 0 (no expiry) + Child 300 = 300
Attestation one_time true if either sets it Parent false + Child true = true
Attestation type (internal, claim, approval_criteria) Must be the same Internal in one policy, a claim in another: the call is denied
Attestation approval_criteria All criteria role:manager + role:compliance = both required
Attestation priority Maximum Parent 0 + Child 100 = 100 (higher wins)
verifiers, processors Union Every layer's are applied
extractors Union by name A layer may add one
Function enforce true if either enforces Parent false + Child unset = true
Function priority Minimum Parent 10 + Child 90 = 10 (runs earlier)
A function's own keys (field, fields, …) Must be equal Different fields for one verifier: the call is refused

These rules apply to extends and to policies combining at request time, except resources: at request time, levels intersect directly.


Appendix B: Theoretical Foundations

Composition Algebra

A policy P = (R, D, A, C) consists of:

  • R: Allowed resource patterns
  • D: Denied resource patterns
  • A: Required attestations (simple and conditional)
  • C: Operational constraints (parameters, attestation metadata)

Policy Intersection P1 ∩ P2 = (R', D', A', C') where:

  • R' = R1 ∩ R2 (allow only if both policies permit)
  • D' = D1 ∪ D2 (deny if either policy forbids)
  • A' = A1 ∪ A2 (all attestations required)
  • C' = MostRestrictive(C1, C2) (apply tightest constraint)

Formal Security Properties

Theorem 1 (Monotonic Restriction): For composition P0 ∩ ... ∩ Pn:

∀i, j : (i < j) ⇒ π(P0 ∩ ... ∩ Pj) ⊆ π(P0 ∩ ... ∩ Pi)

Proof Sketch: By construction, Pi+1 = Pi ∩ Pnext. Resource intersection: Ri+1 ⊆ Ri. Denial union: Di+1 ⊇ Di. Therefore π(Pi+1) ⊆ π(Pi). By induction, π(Pj) ⊆ π(Pi) for i < j. □

Theorem 2 (Transitive Denial): If resource r is denied by any policy, it remains denied:

∃i: r ∈ Match(Di) ⇒ r ∈ Match(Deff)

Proof Sketch: Deff = D0 ∪ D1 ∪ ... ∪ Dn. If r ∈ Di for any i, then r ∈ Deff by set union. □

Theorem 3 (No Privilege Escalation): If base policy P0 denies r, no composition grants access:

(r ∈ Match(D0)) ⇒ r ∉ Allowed(Peff)

Proof: By Theorem 2, if r ∈ Match(D0), then r ∈ Match(Deff). By definition of π, denied resources cannot be allowed. □

Attestation Security Properties

Theorem 4 (Unforgeable Attestations): An adversary without access to a ToolAgent's private key cannot create a valid attestation that verifies under that ToolAgent's public key.

Proof Sketch: Follows from the security of the signature scheme (RSA/Ed25519). □

Theorem 5 (Metadata Binding): An adversary cannot modify attestation metadata (time_to_live, one_time, max_uses) without invalidating the signature.

Proof Sketch: Metadata is included in the canonical representation signed by the ToolAgent. Any modification changes the signed data, causing signature verification to fail. □

Theorem 6 (Conditional Attestation Soundness): A conditional attestation key::{condition} is required if and only if the condition evaluates to true at runtime. (A condition that cannot be evaluated counts as true: the attestation is required, so it fails closed.)

Proof Sketch: The condition reads the invocation parameters and existing attestations. Evaluation is deterministic and side-effect-free. □

Security Implications

These theorems provide formal guarantees:

  • Theorem 1 prevents privilege expansion
  • Theorem 2 ensures any layer's denial is absolute
  • Theorem 3 prevents escalation
  • Theorems 4-6 ensure attestations are cryptographically verifiable and unforgeable
  • Theorem 6 ensures conditional attestations are correctly enforced

Appendix C: Future Enhancements

The following features are planned for future releases of MAPL:

Phase 2 Enhancements

Enhanced Error Messages with Policy Context:

// Current error:
"Parameter 'max_tokens'=600 exceeds maximum: 500"

// Future error:
"Parameter 'max_tokens'=600 exceeds maximum: 500
  Policy Chain: company:acme → bu:finance → team:analysts → user:alice
  Constraint Source: team:analysts.json (line 15)
  Resolved Path: 4000 → 2000 → 500 (most restrictive)"

Performance Optimizations:

  • Condition evaluation caching
  • Policy resolution caching
  • Optimized inheritance chain evaluation

Phase 3 Enhancements (Advanced)

Array Item Validation:

{
  "constraints": {
    "parameters": {
      "database:batch_insert": {
        "records": {
          "type": "array",
          "max_items": 1000,
          "items": {
            "type": "object",
            "required": ["id", "name"]
          }
        }
      }
    }
  }
}

Nested Object Validation:

{
  "constraints": {
    "parameters": {
      "user:create": {
        "profile": {
          "type": "object",
          "properties": {
            "email": {"pattern": "^[^@]+@[^@]+$"},
            "age": {"min": 18, "max": 120}
          }
        }
      }
    }
  }
}

Appendix D: Function Reference

The contract for extractors, verifiers and processors.
Part 7 explains how to use them.

D.1 Entry Points

Kind Entry point Receives Must return
Extractor extract(value, **config) a copy of the value at the declared field; None if absent dict
Verifier verify(invocation, context, **config) copies of the invocation and the caller's context VerificationResult
Processor process(result, invocation, context, **config) copies of the tool's result, the invocation and the context ProcessingResult

config is the policy's declaration of the function, less the keys the
enforcement point uses:

Kind Declared in Not passed to the function
Extractor extractors.<name> field
Verifier constraints.verifiers.<name> priority, enforce
Processor constraints.processors.<name> priority, enforce

What a function can read:

Object Fields
invocation params, resource_id, operation, tool_name, invocation_id, context_id, timestamp, prompt
context principal_context, attestations, has_attestation(name), embedded_context, metadata, context_id
context.principal_context user_id, roles, groups, attributes, has_role(role), has_group(group)
result (processor) result (the payload), status, invocation_id, context_id

What a function returns:

Type Constructor Meaning
VerificationResult VerificationResult(success, message="", metadata=None) success=False refuses the call when the verifier enforces. message appears in the denial and the audit log, so name what was found and leave out its value.
ProcessingResult ProcessingResult(result, modified=False, success=True, message="", metadata={}) result is the result object it was handed, with the payload at result.result. success=False withholds the result when the processor enforces.

D.2 The Allowlist

A function's source is checked when it is saved, and again when an
enforcement point loads it. Everything in it must be on these lists.

Top level: function definitions, constants assigned to plain names, and a
docstring. A constant is a str, int, float, bool, None, a compiled
pattern, or a tuple or frozenset of constants. Argument defaults are
constants too.

Statements and expressions:

Allowed def (nested too), assignment, augmented assignment, if, for, while, break, continue, pass, return, raise, try / except / finally, lambda, comprehensions, conditional expressions, f-strings, slices, and the arithmetic, comparison, boolean and bitwise operators
Refused import, class, with, global, nonlocal, del, assert, yield, async / await, :=, match, and an except that names no exception

Names a function may read, besides its own variables, arguments and
helpers:

Group Names
Builtins abs all any bool dict enumerate float frozenset int isinstance len list max min range reversed round set sorted str sum tuple zip
Exceptions Exception ValueError KeyError TypeError IndexError
re compile search match fullmatch findall finditer sub subn split escape IGNORECASE MULTILINE DOTALL I M S
json dumps loads
Results VerificationResult ProcessingResult

Every name a function binds starts with a letter.

Attributes a function may use:

On Attributes
The objects in D.1 params resource_id operation context_id invocation_id tool_name timestamp prompt metadata principal_context user_id roles groups attributes embedded_context attestations has_role has_group has_attestation result status success message modified
Strings lower upper strip lstrip rstrip split rsplit splitlines startswith endswith replace find rfind count join isdigit isalpha isalnum isspace casefold partition rpartition
Lists, dicts and sets append extend insert pop remove index sort reverse copy clear get items keys values setdefault update add discard union intersection difference issubset issuperset
Patterns and matches the re names above, and pattern group groups groupdict start end span

D.3 Limits

Limit Value Checked
Lines per call 1,000,000 of the function's own lines at every call
Sample run 3 seconds for all of a function's samples at save
Samples at least one at save
Name lowercase letters, digits and _, starting with a letter at save

D.4 Samples

A sample is a JSON object. Its input takes the form the kind receives:

Kind A sample Passes when
Extractor {"value": …, "expect": {facts}} the returned facts equal expect
Verifier {"params": {…}, "expect": "pass"}, or "fail" the result's success agrees
Processor {"params": {…}, "result": …, "expect": …} the returned payload equals expect

Any sample may add "config", the declaration a policy would make:

{"params": {"query": "xxxxxxxxxxx"},
 "config": {"fields": ["params.query"], "max_length": 10},
 "expect": "fail"}

The invocation is built from params, and has no parameters when a sample
leaves it out. The caller is a stand-in with no roles, groups or attestations.

D.5 Messages

When a function is saved:

Message Meaning
Line 6: '.encode' is not available an attribute missing from D.2
Line 6: 'open' is not available a name missing from D.2
Line 6: With is not allowed a statement or expression missing from D.2
Line 6: name the exception to catch an except with no exception
Line 6: argument defaults are constants a default that is a list, dict or call
Line 6: assign top-level values to plain names A, B = … or X[0] = … at the top level
Line 6: the top level may hold functions, constants and a docstring a statement other than those at the top level
'SEEN' is a list; top-level values are constants (str, number, tuple, compiled pattern) a top-level value that can change
'_cache': names start with a letter a name that begins with _
verify() is missing no entry point for the kind
'Wire-Memo': names are lowercase letters, digits and _ the function's name
A function needs at least one sample no samples
sample 2: expected {'count': 1}, got {'count': 0} a sample's result differs from expect
sample 2: KeyError: 'memo' the function raised on a sample
the samples did not finish within 3 s a loop or a regular expression is too slow on a sample

When a policy is saved:

Message Meaning
Policy names functions this workspace does not have: verifiers/secret_scanner add the function, then save the policy

At the enforcement point (the call is refused, or the result withheld, as
in When a Function Fails):

Message Meaning
Policy names an unknown verifier: 'x' the workspace has no such function. Also extractor and processor.
ran more than 1,000,000 lines the function ran past its bound on this input
verify() returned dict, not a VerificationResult the wrong return type. Also process().
Extractor 'x' returned list, not facts an extractor must return a dict
Extractor 'x' failed on params.memo: … the extractor raised
Extractor 'x' declares no field to read the declaration has no field
Extractor 'x' may only read params, not 'principal.user_id' field is outside params

D.6 The Library

MACAW's functions, in the library on the control plane. A workspace has one
only after someone adds it under Policies → Functions. Each entry below
gives the function's source, how it works, the samples it ships with, how a
policy declares it, and what a caller sees.

Function Kind Version What it does
secrets extractor 1.0.0 reports which credential shapes a value contains
secret_scanner verifier 1.0.0 refuses a call whose named parameters carry a credential
length_limit verifier 1.0.0 refuses a call whose named parameters are too long
secret_scrubber processor 1.0.0 masks credentials in the result

The Credential Shapes

secrets, secret_scanner and secret_scrubber each begin with the same
constant, so what the verifier refuses on the way in and what the processor
masks on the way out are the same shapes:

SECRET_PATTERNS = (
    ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
    ("slack_token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b")),
    ("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
    ("stripe_key", re.compile(r"\b[rs]k_(?:live|test)_[0-9A-Za-z]{24,}\b")),
    ("private_key", re.compile(r"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----")),
    ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
)
Kind Shape
aws_access_key AKIA and 16 characters
github_token ghp_, gho_, ghu_, ghs_ or ghr_ and 36 or more
slack_token xoxb-, xoxa-, xoxp-, xoxr- or xoxs- and 10 or more
google_api_key AIza and 35 characters
stripe_key sk_live_, sk_test_, rk_live_ or rk_test_ and 24 or more
private_key a -----BEGIN … PRIVATE KEY----- line
jwt three base64url segments, the first two starting eyJ

A function cannot import another, so each of the three carries its own copy.
Editing the table in one of them in the workspace leaves the other two as they
were. To add a shape, edit all three.

secrets (extractor)

Reports which credential shapes a value contains, so a policy can decide what
to do about them.

def extract(value, **config):
    if value is None:
        return {"present": False, "count": 0, "kinds": []}
    text = value if isinstance(value, str) else json.dumps(value, default=str)
    kinds = []
    count = 0
    for name, pattern in SECRET_PATTERNS:
        found = len(pattern.findall(text))
        if found:
            kinds.append(name)
            count += found
    return {"present": count > 0, "count": count, "kinds": kinds}
  • An absent field arrives as None and returns the empty facts, so a
    condition on derived.secrets.count evaluates instead of failing.
  • A value that is not a string is searched as its JSON text, so a
    credential anywhere inside a dict or list is found.
  • count adds up every match of every shape. kinds names each shape
    found once, in the table's order.
Reads the value at field, of any type
Returns {"present": bool, "count": int, "kinds": [str]}
Config none

Samples:

[
  {"value": "deploy --env staging", "expect": {"present": false, "count": 0, "kinds": []}},
  {"value": "export KEY=AKIAIOSFODNN7EXAMPLE", "expect": {"present": true, "count": 1, "kinds": ["aws_access_key"]}},
  {"value": {"a": "AKIAIOSFODNN7EXAMPLE", "b": "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
   "expect": {"present": true, "count": 2, "kinds": ["aws_access_key", "github_token"]}},
  {"value": null, "expect": {"present": false, "count": 0, "kinds": []}}
]

Declared, and read by a condition:

"extractors": {"secrets": {"field": "params.notes"}},
"attestations": ["security_review::{ derived.secrets.count > 0 }"]

For notes = "export KEY=AKIAIOSFODNN7EXAMPLE", the facts are
derived.secrets.present = true, derived.secrets.count = 1 and
derived.secrets.kinds = ["aws_access_key"], and the call waits for
security_review. The Complete Example uses the same
count three ways.

secret_scanner (verifier)

Refuses a call when a credential is in one of the parameters the policy names.

After SECRET_PATTERNS:

def read(invocation, path):
    """The value at a dotted path such as "params.body.token", or None."""
    parts = path.split(".")
    if parts[0] != "params":
        return None
    current = invocation.params
    for part in parts[1:]:
        if not isinstance(current, dict):
            return None
        current = current.get(part)
    return current


def verify(invocation, context, fields=()):
    found = {}
    for path in fields:
        value = read(invocation, path)
        if value is None:
            continue
        text = value if isinstance(value, str) else str(value)
        kinds = [name for name, pattern in SECRET_PATTERNS if pattern.search(text)]
        if kinds:
            found[path] = kinds
    if not found:
        return VerificationResult(True, "No credentials found")
    # The kinds, never the values: this message reaches logs and audit records.
    summary = ", ".join(f"{path} ({', '.join(kinds)})" for path, kinds in sorted(found.items()))
    return VerificationResult(
        False,
        f"Credentials present in {summary}. They have already left the caller - "
        f"rotate them rather than only blocking this call.",
        metadata={"fields": sorted(found),
                  "kinds": sorted({k for kinds in found.values() for k in kinds})},
    )
  • read follows a dotted path under params. A path outside params, or
    one that is absent, gives None, and that field is skipped.
  • fields is the only thing it inspects. With none declared, the loop
    has nothing to read and the call passes, so always declare the fields.
  • A value that is not a string is searched as str(value), which includes
    every string inside a dict or list.
  • The message names each field and the shapes found in it, never the
    value, because it reaches the denial, the logs and the audit record.
Config fields: dotted parameter paths
Fails Credentials present in params.script (aws_access_key). They have already left the caller - rotate them rather than only blocking this call. The metadata lists fields and kinds.
Passes No credentials found

Samples:

[
  {"params": {"script": "deploy --env staging"}, "config": {"fields": ["params.script"]}, "expect": "pass"},
  {"params": {"script": "export KEY=AKIAIOSFODNN7EXAMPLE"}, "config": {"fields": ["params.script"]}, "expect": "fail"},
  {"params": {"notes": "export KEY=AKIAIOSFODNN7EXAMPLE"}, "config": {"fields": ["params.script"]}, "expect": "pass"}
]

The third sample is the boundary: a credential in a field the policy did not
name is not inspected.

Declared and applied:

"constraints": {"verifiers": {"secret_scanner": {"fields": ["params.script"]}}},
"verifiers": ["secret_scanner"]

A call with script = "export KEY=AKIAIOSFODNN7EXAMPLE" is refused before the
tool runs, with the message above.

length_limit (verifier)

Refuses a call when one of the parameters the policy names is longer than
max_length.

After the same read as secret_scanner:

def verify(invocation, context, fields=(), max_length=10000):
    over = []
    for path in fields:
        value = read(invocation, path)
        if value is None:
            continue
        size = len(value) if isinstance(value, str) else len(json.dumps(value, default=str))
        if size > max_length:
            over.append(f"{path} ({size})")
    if over:
        return VerificationResult(False, f"Longer than {max_length}: {', '.join(over)}")
    return VerificationResult(True, "Within length limits")
  • A string is measured in characters. Anything else is measured by its
    JSON text, so [1, 2, 3] has length 9.
  • An absent field is skipped. With no fields declared, the call passes.
  • The message lists every field over the limit with its length.
Config fields: dotted parameter paths. max_length: characters, default 10000.
Fails Longer than 5000: params.query (12000)
Passes Within length limits

Samples:

[
  {"params": {"query": "xxxxxxxxxx"}, "config": {"fields": ["params.query"], "max_length": 10}, "expect": "pass"},
  {"params": {"query": "xxxxxxxxxxx"}, "config": {"fields": ["params.query"], "max_length": 10}, "expect": "fail"},
  {"params": {"rows": [1, 2, 3]}, "config": {"fields": ["params.rows"], "max_length": 5}, "expect": "fail"}
]

The first two are the boundary: 10 characters pass at max_length: 10, and 11
fail.

Declared and applied:

"constraints": {"verifiers": {"length_limit": {"fields": ["params.query"], "max_length": 5000}}},
"verifiers": ["length_limit"]

secret_scrubber (processor)

Masks credentials in a tool's result before it is signed and returned.

After SECRET_PATTERNS:

MASK = "[REDACTED]"


def scrub(value):
    if isinstance(value, str):
        for name, pattern in SECRET_PATTERNS:
            value = pattern.sub(MASK, value)
        return value
    if isinstance(value, dict):
        return {k: scrub(v) for k, v in value.items()}
    if isinstance(value, list):
        return [scrub(v) for v in value]
    return value


def process(result, invocation, context, fields=()):
    payload = result.result
    changed = []
    if fields:
        for path in fields:
            parts = path.split(".")
            if parts[0] == "result":
                parts = parts[1:]
            target = payload
            for part in parts[:-1]:
                target = target.get(part) if isinstance(target, dict) else None
            if parts and isinstance(target, dict) and parts[-1] in target:
                masked = scrub(target[parts[-1]])
                if masked != target[parts[-1]]:
                    target[parts[-1]] = masked
                    changed.append(path)
    else:
        masked = scrub(payload)
        if masked != payload:
            payload = masked
            changed.append("<payload>")
    if not changed:
        return ProcessingResult(result=result, modified=False, message="No credentials found")
    result.result = payload
    return ProcessingResult(result=result, modified=True,
                            message=f"Masked credentials in {', '.join(changed)}",
                            metadata={"fields": changed})
  • scrub replaces every match of every shape with [REDACTED], through
    nested dicts and lists. Numbers and other values pass unchanged.
  • With no fields, the whole payload is scrubbed.
  • With fields, only those paths are. A leading result. is optional,
    so result.log and log name the same field. A path that is
    absent is skipped. The paths reach into a dict payload, so for a payload
    that is a plain string, declare no fields.
  • It never withholds. It always returns success=True, and modified is
    true only when something was masked. What enforce governs is a failure
    of the function itself.
Config fields: dotted paths in the result. With none, the whole payload.
Message Masked credentials in result.log, Masked credentials in <payload> when no fields are declared, or No credentials found

Samples:

[
  {"result": {"log": "deployed with ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok"}, "expect": {"log": "deployed with [REDACTED] ok"}},
  {"result": {"log": "deployed ok"}, "expect": {"log": "deployed ok"}},
  {"result": {"body": "AKIAIOSFODNN7EXAMPLE", "note": "AKIAIOSFODNN7EXAMPLE"},
   "config": {"fields": ["result.body"]},
   "expect": {"body": "[REDACTED]", "note": "AKIAIOSFODNN7EXAMPLE"}},
  {"result": {"data": {"result": "AKIAIOSFODNN7EXAMPLE", "id": "AKIAIOSFODNN7EXAMPLE"}},
   "config": {"fields": ["result.data.result"]},
   "expect": {"data": {"result": "[REDACTED]", "id": "AKIAIOSFODNN7EXAMPLE"}}}
]

The third sample shows fields narrowing the mask: note is left as it was.
The fourth shows that only the leading result. is dropped: data.result is
a key in the payload, and id beside it is left as it was.

Declared and applied:

"constraints": {"processors": {"secret_scrubber": {"fields": ["result.log"]}}},
"processors": ["secret_scrubber"]
Tool returns:  {"log": "deployed with ghp_aaaa... ok", "status": "done"}
Caller sees:   {"log": "deployed with [REDACTED] ok", "status": "done"}

Conclusion

Congratulations! You now know how to write MAPL policies for AI agent systems.

Key Takeaways:

  1. MAPL scales from O(M×N) to O(log M + N) through hierarchical composition
  2. Resources ARE operations (no separate actions needed)
  3. Inheritance composes via intersection (most restrictive always wins)
  4. Denials propagate (no overrides, provably secure)
  5. Parameter constraints provide fine-grained control
  6. Attestations enable provable workflow dependencies
  7. Conditional attestations allow dynamic, context-aware requirements
  8. External attestations support human-in-the-loop workflows
  9. Deferred principal binding enables dynamic identity resolution
  10. Extractors, verifiers and processors are workspace functions: checked, sampled and versioned

Next Steps:

  1. Write policies for your organization
  2. Deploy and monitor
  3. Iterate based on usage patterns

Need Help?

  • Review the examples in this guide
  • Check the troubleshooting section
  • Enable debug logging
  • Consult the quick reference

Happy policy writing!


Document Version: 3.7.1
MACAW SDK Version: 3.x
Last Updated: November 2, 2026


Copyright MACAW Security. All rights reserved.

MACAW · AI Controls for autonomous agents. Every action an agent takes, checked against policy before it runs.