# Which invariants prevent silent capability resurrection across federated agent trust brokers during partial revocation **Date of Record:** 2026 November 12 **Scope:** Applies to federated agent systems where capabilities are minted as scoped, attenuated tokens by an origin broker and where revocation is propagated to downstream relying parties via an asynchronous fan-out channel. Does not apply to single-tenant brokers with synchronous revocation, nor to systems where every capability check performs a live introspection call against the origin authority. ## Core Distinction The failure mode is *not* revocation latency. Operators routinely budget for a propagation window of tens of seconds and treat "stale but bounded" as acceptable. The non-obvious boundary is that revocation of a *parent* capability does not, by itself, invalidate *derived* capabilities unless the derivation chain is cryptographically bound to the parent's revocation epoch at mint time. A child token minted at epoch 7 and presented after the parent is revoked at epoch 9 will pass signature verification, pass scope checks, and pass expiry checks — because none of those checks consult the parent's epoch. The child is a valid, signed, in-scope token that authorizes an action the parent no longer permits. We call this *silent capability resurrection*: the revoked authority reappears under a token identity the revocation fan-out never enumerated. The distinction that matters operationally: propagation completeness (did the revocation message reach every relying party?) is a different invariant from derivation soundness (does every derived token carry a proof that its ancestor was live at mint time?). Systems that instrument the first and assume it implies the second fail exactly at the boundary where a child token outlives its parent's epoch. ## What Was Observed During a cross-org federation drill, Org A (origin broker) revoked a delegation grant `G` at 14:02:11 that had authorized Org B's planner agent to spawn sub-agents with `files:read` scope on a shared artifact store. The revocation fan-out reached Org B's relying-party cache at 14:02:19 — within the 30s SLO. Org B's broker confirmed `G` was tombstoned. At 14:02:47, a sub-agent spawned by Org B's planner at 14:01:55 presented a derived token `T_child` with `files:read` scope. The token verified cleanly: signature chain to Org A's root, scope subset of `G`, expiry 14:31:55. The relying party admitted the request. The artifact store returned 4.2 GB of files over the next eleven minutes before a human noticed the audit trail. The post-incident reconstruction found three compounding properties: 1. `T_child` was minted before revocation, so its signature was legitimately valid. 2. The token carried no reference to `G`'s revocation epoch — only a `parent_id` pointer that the relying party resolved against a cache that had already been updated to mark `G` tombstoned, but the relying party's validation logic checked only `T_child`'s own fields, not the parent's liveness. 3. The revocation fan-out enumerated `G` but not its descendants, because the origin broker did not maintain a derivation index. Descendant discovery would have required walking every relying party's mint log. The system had a propagation SLO, a signature chain, and scope attenuation. It lacked a *lineage liveness check* and a *descendant enumeration index*. Both omissions are invisible until a child token is presented after its parent's revocation epoch but before its own expiry. ## Condition of Validity The invariant that prevents resurrection is: > For every derived token `T` with ancestor `P`, the relying party MUST verify that `P.epoch_at_mint(T) >= P.revocation_epoch`, where `epoch_at_mint(T)` is committed into `T` at mint time and signed by the minting authority, and `P.revocation_epoch` is the highest epoch at which `P` was observed live by any authority in the federation. This holds only when three conditions are met: - **Epoch commitment at mint.** Every derived token embeds the parent's epoch counter as observed at mint time, inside the signed payload. Without this, the relying party has no way to distinguish a pre-revocation child from a post-revocation one. - **Monotonic epoch advance.** The origin broker's epoch counter for a capability must advance on every revocation and never regress. A regressing epoch (e.g., after a broker restore from backup) re-validates children that should be dead. - **Lineage resolution at verification.** The relying party must resolve the full ancestor chain, not just the immediate parent, and check each ancestor's revocation epoch. A two-hop derivation `P -> C1 -> C2` where `P` is revoked but `C1` is not requires checking `P`'s epoch against `C1`'s mint epoch and `C1`'s epoch against `C2`'s mint epoch. Checking only the immediate parent leaves the grandparent's revocation invisible. The invariant does *not* hold when tokens are minted by a relying party that has not yet received the parent's revocation epoch — the relying party will commit a stale epoch into the child. This is why the fan-out must carry epoch numbers, not just tombstones, and why the relying party must refuse to mint children for any parent whose epoch it has not confirmed within a bounded staleness window. ## Decision Boundary The operational question is whether to enforce lineage liveness at verification time (cost: one ancestor-chain resolution per request, plus cache pressure) or to rely on descendant enumeration at revocation time (cost: a derivation index maintained by every minting authority, plus fan-out amplification proportional to descendant count). The boundary is determined by derivation fan-out shape: - **Shallow, narrow derivation** (depth ≤ 2, branching ≤ 4): enforce lineage liveness at verification. The ancestor chain is cheap to resolve and the derivation index is not worth maintaining. - **Deep or wide derivation** (depth > 2 or branching > 16): maintain a derivation index and enumerate descendants at revocation. Verification-time lineage resolution becomes a cache-miss amplifier under load. - **Mixed**: enforce lineage liveness at verification *and* maintain the index, but accept that the index is advisory (for fast-path tombstoning) while the epoch check is authoritative. Never let the index's completeness be load-bearing. The failure we observed was in the mixed case: the team maintained neither, and the propagation SLO gave false confidence that the index existed. ## Operational Effect Enforcing the epoch invariant changes three operational properties: 1. **Revocation latency becomes bounded by mint-time epoch staleness, not fan-out latency.** A child minted during the propagation window commits a stale epoch and will be rejected at verification once the origin's epoch advances. The window shrinks to the relying party's epoch-confirmation staleness budget, which is a tunable you control, rather than the fan-out's tail latency, which you do not. 2. **Revocation fan-out must carry epochs, not tombstones.** A tombstone says "this capability is dead." An epoch says "this capability is dead as of epoch N, and any child claiming a mint epoch below N is suspect." The second is what the verification path needs. 3. **Audit trails gain a lineage dimension.** Post-incident reconstruction can walk the derivation chain and identify every descendant of a revoked capability, even those minted before the revocation. Without epoch commitment, descendants are invisible to the audit. The cost is real: every mint must read the parent's current epoch (a round-trip or a cached read with staleness bounds), and every verification must resolve the ancestor chain. Systems that skip this pay in resurrection events, which are silent, scope-valid, and indistinguishable from legitimate traffic until the data is already gone. ```python class CapabilityResurrectionError(RuntimeError): """Raised when a derived token's ancestor was revoked before the child's mint epoch.""" class EpochRegressionError(RuntimeError): """Raised when an authority's epoch counter regresses, invalidating monotonicity.""" def verify_lineage_liveness( token: dict, ancestor_epochs: dict[str, int], token_mint_epochs: dict[str, int], ) -> None: """ Enforce the lineage liveness invariant across the full ancestor chain. token: {"id": str, "parent_id": str | None, "mint_epoch": int, ...} ancestor_epochs: capability_id -> current revocation epoch at origin token_mint_epochs: capability_id -> epoch observed at that token's mint time """ chain = [] cursor = token while cursor is not None: chain.append(cursor) parent_id = cursor.get("parent_id") cursor = None if parent_id is None else {"id": parent_id, "parent_id": None, "mint_epoch": token_mint_epochs.get(parent_id, -1)} # Walk from root to leaf; each child's mint epoch must be >= parent's revocation epoch. for i in range(len(chain) - 1, 0, -1): child = chain[i] parent = chain[i - 1] parent_revocation_epoch = ancestor_epochs.get(parent["id"]) if parent_revocation_epoch is None: raise CapabilityResurrectionError( f"ancestor {parent['id']} has no known epoch; cannot prove liveness for {child['id']}" ) if parent_revocation_epoch < 0: raise EpochRegressionError( f"ancestor {parent['id']} epoch regressed to {parent_revocation_epoch}" ) if child["mint_epoch"] < parent_revocation_epoch: raise CapabilityResurrectionError( f"token {child['id']} minted at epoch {child['mint_epoch']} " f"but ancestor {parent['id']} revoked at epoch {parent_revocation_epoch}" ) def admit_request(token: dict, ancestor_epochs: dict[str, int], token_mint_epochs: dict[str, int]) -> bool: try: verify_lineage_liveness(token, ancestor_epochs, token_mint_epochs) except (CapabilityResurrectionError, EpochRegressionError): return False return True ```