# Which invariants prevent silent capability escalation across federated A2A trust boundaries **Date of Record:** 2026 November 12 **Scope:** Applies to cross-org multi-agent federation where an originating agent reaches a remote agent through one or more trust brokers, and where capability attestations and negotiated policies are cached or re-derived at each hop. Does not apply to single-org intra-mesh agent calls where a single policy authority is authoritative, nor to purely stateless request/response tool calls with no delegated authority. ## Core Distinction The failure mode that matters is not *denied* access — denials are loud and are caught by the caller. The failure mode is **silent capability escalation through attestation re-derivation**: a capability that the origin org never granted, or that the remote org never accepted, becomes *effective* because each hop in the federation chain re-evaluates a policy against a *locally valid* attestation whose scope was widened by the previous hop's normalization step. This is distinct from confused-deputy and from token replay. In confused-deputy, a trusted intermediary is tricked into acting with its own authority. In replay, a stale token is reused. Here, every token is fresh, every signature verifies, and every policy evaluation is locally correct. The escalation arises because the *composition* of per-hop-valid attestations is not itself validated against the origin's grant. Each hop is sound; the chain is not. The invariant that prevents this is **grant monotonicity along the federation path**: for any capability `c` exercised at hop `n`, the effective grant set at hop `n` must be a subset of the effective grant set at hop `n-1`, and the chain must terminate at an origin grant that explicitly contains `c`. Attestation *scope* may narrow; it may never widen. Any normalization, defaulting, wildcard expansion, or policy-negotiation merge that widens scope is an invariant violation, even if every individual signature is valid. ## What Was Observed A three-org federation: Origin (O) → Broker (B) → Remote (R). O issues an attestation for agent `planner-7` with capability `documents:read` scoped to `tenant=acme`, and explicitly *omits* `documents:export`. B performs policy negotiation with R and, as part of normalization, rewrites the capability set into R's canonical vocabulary. R's vocabulary has no `documents:read`; the closest mapping is `documents:access`, and R's policy engine treats `documents:access` as implying `documents:export` under its own default role bundle. At every hop: - O's signature over its attestation verifies. - B's re-attestation is correctly signed by B and faithfully reflects *B's* view of the negotiated policy. - R's policy engine evaluates `documents:access` correctly against its own rules. The export capability was exercised. No component logged an error. The audit trail at R shows a valid, in-scope request. The audit trail at O shows only a read grant. Reconciliation between the two logs — the only place the escalation is visible — showed a capability present at R that was absent at O, with no widening event recorded at any hop. A second observed variant: B cached a negotiated policy keyed by `(origin_org, remote_org, capability_family)` but not by the *attestation scope*. A later request from O with a narrower scope (`tenant=acme, project=red`) hit the cache and was served the earlier, broader policy. The cache key was structurally incapable of distinguishing the two requests. This is the same invariant violation expressed through a cache rather than a vocabulary mapping. ## Condition of Validity Grant monotonicity holds only under three conditions, all of which must be enforced structurally rather than by convention: 1. **Scope-preserving normalization.** Any vocabulary mapping between orgs must be *scope-preserving or scope-narrowing*. A mapping that cannot express the origin scope must fail closed, not widen. The mapping `documents:read(tenant=acme) → documents:access` is admissible only if R's `documents:access` is subsequently constrained to `tenant=acme` and does not inherit R's default export implication. Where the target vocabulary cannot represent the source constraint, the correct outcome is negotiation failure, not best-effort mapping. 2. **Cache keys include full scope and the origin grant digest.** A negotiated policy is a function of `(origin_org, remote_org, capability_set, scope, origin_grant_digest, policy_version)`. Omitting any component — particularly scope and the digest of the origin grant — makes the cache a widening engine. The digest is required because origin grants can be revoked and reissued with identical scope strings but different effective content. 3. **Chain-terminal verification.** The remote must be able to verify, not merely trust, that its effective grant is a subset of the origin grant. This requires the origin grant (or a digest of it, with the full grant retrievable) to travel with the attestation chain, and requires R to perform the subset check. Trusting B's assertion that "this is within O's grant" reintroduces the confused-deputy failure at the broker. The invariant does *not* hold, and should not be assumed to hold, when any hop performs "helpful" defaulting, when negotiation is allowed to merge capability sets from multiple origins into a single effective policy without per-origin subset tracking, or when the broker is trusted to assert subset relationships it has not itself verified against a signed origin grant. ## Decision Boundary The operational decision is where to place the subset check and what to do when it cannot be performed. - If the origin grant is available at the remote (directly or by digest-plus-retrieval), perform the subset check at the remote and fail closed on any capability not covered. This is the strong configuration. - If the origin grant is *not* available at the remote — common when brokers deliberately hide origin topology — then the broker must perform the subset check and *sign the result*, and the remote must treat the broker's signed subset assertion as the terminal authority. This is weaker: it trusts the broker's verification, not the origin's signature. It is acceptable only when the broker is in the same trust domain as the origin or is contractually bound to the subset check. - If neither is possible, the federation must restrict negotiation to capability *families* with pre-agreed, scope-preserving mappings and must forbid vocabulary translation at negotiation time. Anything else is an unverified widening path. The boundary is sharp: **a hop that cannot prove subset must not widen.** There is no "probably fine" region. Best-effort mapping under uncertainty is the failure mode, not a mitigation. ## Operational Effect Enforcing grant monotonicity changes three operational behaviors. First, negotiation failures increase — mappings that previously "succeeded" by widening now fail closed, and the failure rate is a direct measure of vocabulary mismatch between orgs. This is desirable and should be monitored as a federation-health metric, not suppressed. Second, cache hit rates drop because cache keys grow to include scope and grant digests; the drop is the cost of correctness and should be budgeted. Third, audit reconciliation between origin and remote logs becomes a required operational control, because the invariant is enforced at multiple points and only cross-log comparison detects a hop that silently skipped its check. The reconciliation job is not optional telemetry; it is the detector for the exact failure this invariant exists to prevent, and its absence means the invariant is unverified regardless of what the per-hop code claims. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import FrozenSet class GrantMonotonicityError(RuntimeError): """Raised when a federation hop would widen effective capability scope.""" @dataclass(frozen=True) class Grant: capability: str scope: FrozenSet[tuple[str, str]] # e.g. frozenset({("tenant", "acme")}) def covers(self, other: "Grant") -> bool: # A grant covers another iff same capability and scope is a superset. return self.capability == other.capability and self.scope.issuperset(other.scope) @dataclass(frozen=True) class Attestation: origin_org: str subject: str grants: FrozenSet[Grant] grant_digest: str def verify_hop( origin: Attestation, hop_effective: FrozenSet[Grant], hop_name: str, ) -> None: """Enforce grant monotonicity: every effective grant at this hop must be covered by some origin grant. Widenings fail closed.""" for eff in hop_effective: if not any(og.covers(eff) for og in origin.grants): raise GrantMonotonicityError( f"hop {hop_name!r}: effective grant {eff.capability} " f"scope={sorted(eff.scope)} is not covered by origin " f"{origin.origin_org!r} grant digest {origin.grant_digest}; " f"refusing to widen (fail closed)" ) def negotiate( origin: Attestation, broker_effective: FrozenSet[Grant], remote_effective: FrozenSet[Grant], ) -> FrozenSet[Grant]: verify_hop(origin, broker_effective, "broker") verify_hop(origin, remote_effective, "remote") return remote_effective def cache_key(origin: Attestation, remote_org: str, policy_version: str) -> tuple: # Scope and origin grant digest MUST be part of the key. return ( origin.origin_org, remote_org, origin.grant_digest, policy_version, tuple(sorted((g.capability, tuple(sorted(g.scope))) for g in origin.grants)), ) ```