Delegation Chains

In multi-agent systems, one agent often delegates work to another. Agent A asks Agent B to query a database, which asks Agent C to format the results. This creates a delegation chain — a sequence of agent-to-agent handoffs that MITRITY tracks, governs, and surfaces in the dashboard.

Without governance, delegation chains introduce serious security risks: privilege escalation (Agent A reaches resources through Agent B that it could not access directly), circular delegation (loops), and fan-out attacks (one agent triggering hundreds of sub-agents). MITRITY's delegation chain system addresses all of these.

Prerequisite: grant the MCP Delegate capability

Delegation is invoked via the delegate_to MCP tool that the MITRITY gateway exposes to your agents. MCP Delegate ships as a built-in in the Tools catalog (Built-in tab) with the single operation delegate_to and action pattern mcp:delegate:* — there is nothing to create. Before any agent can delegate, grant the MCP Delegate capability on each participating agent from its detail page → Capabilities → Add capability.

Without this grant, the agent's delegate_to calls fall through to policy-rule evaluation only — fine if your policy allows them, but you lose the capability-layer narrowing, and the privilege-escalation diff has no per-agent grants to compare against. If you hit "the chain never gets created" or "permissions column always shows -", a missing MCP Delegate grant is almost always the cause.

Evaluation architecture

Delegation hops are evaluated through a backend-mediated path. The control plane — not the edge node — is the source of truth on chain state.

HopWhere the decision is madeWhy
Root hop (no upstream agent)Edge node, against the local agent + capability/policy state.There is no chain to consult yet; the edge already has every input it needs.
Continuation hop (any subsequent hop in a chain)MITRITY backend, via POST /api/v1/delegation-chains/evaluate.Cycle detection, depth, and privilege escalation all require the full chain — which the edge does not hold in trustworthy form.

For continuation hops, the edge node sends the from_agent_id, the proposed to_agent_id, the chain_id, the action being requested, and the proposed delegate's capability set. The backend looks up the persisted chain from delegation_hops, walks the engine (depth, cycle, unauthorized-delegate, privilege-escalation), persists the new hop with its decision, and returns the verdict. The edge enforces what the backend returns.

This is a change from the older edge-only fast-path, which carried prior-hops in the request args and trusted the calling agent's code to be honest about chain state. Anything that worked under that model — including args.prior_hops — is now ignored. The backend rebuilds chain state from delegation_hops on every continuation hop, which means transitive cycles (where one hop alone looks fine but the chain as a whole loops) are caught.

The /evaluate request and response

curl -X POST https://api.mitrity.com/api/v1/delegation-chains/evaluate \
  -H "Authorization: Bearer mk_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "chain_id": "ch_8k2m4n",
    "from_agent_id": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
    "to_agent_id": "3f2a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
    "action_requested": "db.postgres.insert",
    "proposed_capabilities": [
      { "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
    ]
  }'
{
  "decision": "blocked",
  "action_taken": "blocked",
  "blocked_reason": "privilege_escalation",
  "hop_index": 1,
  "effective_permissions": {
    "delegator_permissions": [
      { "tool_id": "postgres-read", "allowed_operations": ["select"] }
    ],
    "delegate_permissions": [
      { "tool_id": "postgres-read", "allowed_operations": ["select"] },
      { "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
    ],
    "escalated_resources": [
      { "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
    ]
  }
}

action_taken vocabulary

action_taken on the evaluate response is past tense — it describes what already happened, not what should happen next. The four values are:

action_takenMeaning
allowedThe hop was allowed. The edge proceeds with the delegated call.
blockedThe hop was blocked. The edge returns a denial to the calling agent.
alertedThe hop was allowed, but flagged. An alert was raised and the action was logged for review. The edge proceeds.
heldThe hop is paused awaiting human approval. The edge waits for the approval workflow.

Earlier internal drafts used present-tense verbs (allow / block); those are not the wire format. Always read action_taken as past tense.

Model

The system records two related entities:

  • A chain — one per multi-agent workflow. Tracks the initiator, depth, status, and (if blocked) the reason.
  • Zero or more hops — one per agent-to-agent handoff within the chain. Tracks the from-agent, to-agent, action requested, and the governance decision.

Every chain has a stable chain_id (string, generated by the edge). Hops within a chain are ordered by hop_index (zero-based).

Chain

{
  "id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "tenant_id": "9f8a7b6c-5d4e-3f2a-1b2c-4d5e6f7a8b9c",
  "chain_id": "ch_8k2m4n",
  "initiator_agent_id": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
  "initiator_agent_name": "orchestrator",
  "chain_depth": 3,
  "status": "completed",
  "blocked_at_hop": null,
  "blocked_reason": null,
  "started_at": "2026-05-22T10:00:00Z",
  "completed_at": "2026-05-22T10:00:02Z",
  "created_at": "2026-05-22T10:00:00Z"
}

Status values

ValueMeaning
activeChain has at least one hop and is still receiving new hops.
completedChain finished without any blocked hop.
blockedA hop was blocked; the chain is closed in this state. blocked_at_hop and blocked_reason are populated.
timed_outNo new hop arrived within the timeout window after the last hop. Closed in this state.

Hop

{
  "id": "2d95038-4c54-7b2a-9d35-597a1e4f3b21",
  "chain_id": "ch_8k2m4n",
  "hop_index": 0,
  "from_agent_id": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
  "from_agent_name": "orchestrator",
  "to_agent_id": "3f2a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
  "to_agent_name": "data-fetcher",
  "action_requested": "db.postgres.query",
  "decision": "allowed",
  "effective_permissions": ["read:public.analytics_*"],
  "occurred_at": "2026-05-22T10:00:01Z"
}

decision is "allowed" or "blocked". from_agent_id is null on the first hop of root-level invocations (no upstream agent).

Blocked hops are persisted. When a continuation hop is denied by the engine — depth, cycle, fan-out, unauthorized-delegate, or privilege-escalation — the hop is still written to delegation_hops with decision: "blocked" and the chain's blocked_reason populated. This is what powers the dashboard's Blocked tab and lets you audit denied attempts after the fact.

Governance controls

Tenant-wide settings

The tenant-wide delegation policy applies to every agent unless overridden per agent. Configured at Security > Delegation Chains > Settings in the dashboard, or via the API:

curl -X PUT https://api.mitrity.com/api/v1/delegation-chains/settings \
  -H "Authorization: Bearer mk_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "max_chain_depth": 5,
    "depth_exceeded_action": "deny",
    "max_fan_out": 10,
    "fan_out_window_seconds": 60,
    "circular_action": "deny",
    "privilege_escalation_action": "deny"
  }'
FieldTypeDefaultDescription
max_chain_depthinteger (1–20)5Maximum number of hops in a delegation chain.
depth_exceeded_actionenumdenyAction when the next hop would exceed the limit. One of deny, alert, hold.
max_fan_outinteger (1–100)10Maximum number of concurrent delegations from a single agent within fan_out_window_seconds.
fan_out_window_secondsinteger (10–3600)60Rolling window for the fan-out count.
circular_actionenumdenyAction when a delegation would re-enter an agent already in the chain.
privilege_escalation_actionenumdenyAction when a hop would grant the delegate access the delegator lacks.

Tenant settings are a Pro+ feature. Starter tenants run with the built-in defaults above.

Per-agent overrides

Override the tenant-wide limits for individual agents:

curl -X PATCH https://api.mitrity.com/api/v1/agents/1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e \
  -H "Authorization: Bearer mk_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "delegation_settings": {
      "max_chain_depth": 8,
      "allowed_delegates": ["agt-uuid-a", "agt-uuid-b"],
      "disallowed_delegates": []
    }
  }'

allowed_delegates is an allow-list — when non-empty, the agent can only delegate to listed agent IDs. disallowed_delegates is a deny-list. The two are independent; both can be set. Per-agent values fall back to tenant settings when null.

Blocked reasons

When a hop is blocked, the chain's blocked_reason explains why:

ReasonDescription
depth_exceededThe chain would exceed max_chain_depth.
circular_delegationThe delegation would re-enter an agent already in the chain.
privilege_escalationThe delegate would gain access the delegator does not have.
fan_out_exceededThe delegating agent has exceeded max_fan_out within the rolling window.
unauthorized_delegateThe delegate isn't in allowed_delegates (or is in disallowed_delegates) for the delegating agent.

Privilege-escalation detail

The privilege-escalation check fires inside the backend /evaluate call. At each continuation hop, MITRITY computes the (tool, operation) pairs the proposed delegate holds but the calling agent does not. If that set is non-empty, the hop is flagged as privilege escalation, and the resolver populates delegation_hops.effective_permissions (JSONB) with the diff. The check is per-hop — delegator vs. proposed delegate. (Chain-intersection across every hop in the chain is a future enhancement.)

The action taken depends on the tenant's privilege_escalation_action setting:

SettingBehavior
deny (default)The hop is blocked with blocked_reason: "privilege_escalation".
alertThe hop is allowed (action_taken: "alerted"); an alert event is raised; the escalated resources are still persisted on the hop for review.
logThe hop is allowed (action_taken: "allowed"); the escalated resources are persisted but no alert is raised.

The full diff payload looks like:

{
  "hop_index": 1,
  "from_agent_id": "read-only-bot-uuid",
  "to_agent_id": "full-access-bot-uuid",
  "action_requested": "db.postgres.insert",
  "decision": "blocked",
  "effective_permissions": {
    "delegator_permissions": [
      { "tool_id": "postgres-read", "allowed_operations": ["select"] }
    ],
    "delegate_permissions": [
      { "tool_id": "postgres-read", "allowed_operations": ["select"] },
      { "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
    ],
    "escalated_resources": [
      { "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
    ]
  }
}

escalated_resources is the set difference — the (tool, operation) pairs the delegate holds that the calling agent does not. The dashboard's chain-detail page surfaces this diff in an Escalated resources card under the hops table whenever a hop's blocked_reason (or action_taken) carries the privilege_escalation signal. The card is rendered for both denied hops and alert/log-mode hops where the escalation was permitted but flagged.

Delegation policies

Beyond the tenant + per-agent settings above, you can write policies that target delegation specifically via the delegate.* action pattern and the constraints.delegation block:

{
  "name": "hold-cross-env-delegation",
  "action_pattern": "delegate.*",
  "action": "hold",
  "hold_timeout_minutes": 15,
  "timeout_action": "deny",
  "constraints": {
    "delegation": {
      "cross_environment": true
    }
  }
}

Policy constraints.delegation fields:

FieldTypeDescription
disallowed_delegatesarray of agent IDsBlock delegations to any of these agents (matches if to_agent_id is in the list).
allowed_delegatesarray of agent IDsAllow delegations only to these agents (matches if to_agent_id is NOT in the list).
cross_environmentboolMatch when delegator and delegate belong to different environments.
max_depth_overrideintegerOverride the tenant max_chain_depth for the matching policy scope.

Policy hits override tenant defaults and per-agent overrides — policies are the most specific control surface.

API Reference

All endpoints mounted at /api/v1/delegation-chains. Bearer-token auth, tenant-scoped.

List chains

curl "https://api.mitrity.com/api/v1/delegation-chains?status=blocked&blocked_reason=privilege_escalation&limit=50" \
  -H "Authorization: Bearer mk_your-api-key"

Query parameters — all optional, composable, empty values treated as absent.

ParameterTypeDescription
agent_idUUIDChains where this agent is the initiator or appears as from/to in any hop.
statusenumactive, completed, blocked, timed_out.
blocked_reasonenumFilter blocked chains by reason.
min_depthintegerChains with chain_depth >= N.
started_afterRFC3339Chains started after this timestamp.
started_beforeRFC3339Chains started before this timestamp.
limitinteger (1–100)Default 50.
offsetintegerDefault 0.

Returns an array of chain objects. Sorted by started_at desc.

Get a chain

curl https://api.mitrity.com/api/v1/delegation-chains/ch_8k2m4n \
  -H "Authorization: Bearer mk_your-api-key"

Returns the chain plus its ordered list of hops:

{
  "id": "...",
  "chain_id": "ch_8k2m4n",
  "initiator_agent_id": "...",
  "...": "...",
  "hops": [
    { "hop_index": 0, "from_agent_id": null, "to_agent_id": "...", "decision": "allowed", "..." : "..." },
    { "hop_index": 1, "from_agent_id": "...", "to_agent_id": "...", "decision": "allowed", "..." : "..." }
  ]
}

Summary

curl https://api.mitrity.com/api/v1/delegation-chains/summary \
  -H "Authorization: Bearer mk_your-api-key"

Aggregate counts for the calling tenant — lifetime totals.

{
  "total_chains": 4521,
  "total_hops": 12847,
  "avg_depth": 2.8,
  "max_depth_observed": 7,
  "by_status": {
    "active": 12,
    "completed": 4475,
    "blocked": 30,
    "timed_out": 4
  },
  "by_blocked_reason": {
    "circular_delegation": 3,
    "depth_exceeded": 8,
    "privilege_escalation": 15,
    "fan_out_exceeded": 3,
    "unauthorized_delegate": 1
  },
  "top_initiators": [
    { "agent_id": "...", "agent_name": "orchestrator", "chain_count": 2340 }
  ],
  "top_delegates": [
    { "agent_id": "...", "agent_name": "data-fetcher", "delegation_count": 3450 }
  ]
}

Settings

GET /api/v1/delegation-chains/settings
PUT /api/v1/delegation-chains/settings

See Tenant-wide settings for the request body.

Evaluate a continuation hop

POST /api/v1/delegation-chains/evaluate

Called by edge nodes on every continuation hop. The backend resolves the full chain from delegation_hops, runs the engine, persists the new hop, and returns the decision. Most customers will never call this endpoint directly — it's exercised on your behalf by your edge nodes — but it's documented here for completeness and for customers building custom integrations. See Evaluation architecture above for the request and response shape.

Dashboard

Navigate to Security > Delegation Chains. Four tabs:

  • Chains — paginated list with the same filters as the API.
  • Blocked — pre-filtered to status=blocked; populates with every denied hop the engine catches (depth, cycle, fan-out, unauthorized-delegate, privilege-escalation). Quick triage for hot spots and the first place to look when a customer reports "the agent stopped delegating."
  • Topology — DAG of all delegation pairs within a rolling window (7 / 30 / 90 days), node = agent, edge thickness = delegation count, red = blocked attempts.
  • Settings — the tenant-wide controls described above (Pro+ only).

The chain detail view shows the full hop sequence and the chain visualization (linear for chains under depth 6, DAG for deeper). When a hop carries the privilege_escalation signal — whether it was blocked or merely alerted — the detail page renders an Escalated resources card showing the (tool, operation) diff between the calling agent and the proposed delegate.

Best practices

Start with the defaults

The tenant defaults — max_chain_depth: 5, max_fan_out: 10, all *_action set to deny — are a safe baseline for most deployments. Loosen knobs only with evidence (a specific workflow needs depth 8, a specific agent legitimately fans out to 20 sub-agents).

Use allowed_delegates for critical agents

For agents that touch sensitive resources (billing, customer data, infrastructure), explicitly enumerate which agents they may delegate to. Drift in production agent topologies is real; an allow-list catches new delegation paths before they go live.

Treat privilege escalation as deny

privilege_escalation_action: alert lets the escalation happen and just logs it. In production, deny is the right choice unless you have a specific reason — the whole point of intersection semantics is that the delegator's reach is the ceiling.

Review blocked chains weekly

Blocked delegations are signal. Spend 15 minutes a week in the Blocked tab. Recurring patterns point at either an over-tight policy (loosen with intent) or a misbehaving agent (the actual goal of the system).

Related documentation

Delegation Chains — Documentation | MITRITY