# Where must an instance refuse a federated agent identity attestation: the AAIF credential-chain depth versus trust-domain epoch boundary **Date of Record:** 2026 November 12 **Scope:** Applies to agents validating AAIF-issued verifiable credentials (VCs) presented by peer agents across organizational trust domains, where the credential chain includes one or more delegation links and the verifier caches trust-domain epoch state. Does not apply to single-domain deployments where issuer and verifier share one trust anchor, nor to credentials with no delegation (depth-0 leaf issuance). ## Core Distinction The common mental model treats federated identity attestation as a signature-verification problem: if the VC's issuer chain terminates at a trust anchor the verifier recognizes, and the signatures check out, the agent identity is attested. This model is wrong at exactly one seam. An AAIF credential is valid only relative to a *trust-domain epoch* — the tuple `(trust_anchor_id, epoch_sequence, revocation_root)`. A delegation VC issued by agent A to agent B carries A's epoch at issuance time. When B presents that VC to a verifier in domain C, the verifier must decide whether A's issuance epoch is still *causally current* in C's view of A's domain. The non-obvious failure: signature verification succeeds, chain depth is within policy, revocation roots are fresh, and yet the attestation must be refused — because the delegation was minted under an epoch that the verifier's cached view has already superseded, and the superseding epoch invalidated the *delegation authority* (not the key) that A relied upon. This is a **delegation-authority epoch skew**, distinct from key revocation and from credential expiry. Treating it as either of those produces either false acceptance (if conflated with expiry, which is per-credential) or false rejection storms (if conflated with revocation, which is per-key). ## What Was Observed In a three-domain federation (issuer domain I, delegate domain D, verifier domain V), agent `agent:orders@D` presented a depth-2 AAIF chain: `I → D (delegation) → orders@D (leaf)`. All signatures valid. The `D` delegation VC had `issued_at` 40 seconds before presentation, well within the 300-second clock skew tolerance. V's revocation root for D was fetched 12 seconds prior and current. V nonetheless had to refuse. D's trust anchor had rotated its *delegation policy epoch* (a `policy_epoch` increment, not a key rotation) 90 seconds before presentation. The policy change narrowed the set of scopes D could delegate — specifically, `orders@D`'s leaf carried scope `inventory:read`, which D's new epoch no longer permitted D to delegate. The delegation VC was signed by D's *old* epoch policy key, which remained valid for signing (no revocation), but whose delegated authority was retroactively bounded by epoch semantics defined in the AAIF trust framework: **a delegation is honored only if the delegating domain's current epoch permits the delegated scope at verification time**, not merely at issuance time. The failure mode is silent under naive verification: the chain is cryptographically sound, unexpired, unrevoked, and scope-permitted *at issuance*. It is invalid only under the conjunction of (a) cross-domain presentation, (b) delegation depth ≥ 1, and (c) a policy-epoch advance at the delegating domain between issuance and verification. Any two of these three conditions alone do not trigger refusal. ## Condition of Validity A federated attestation from delegate domain D is honored by verifier V iff, at the instant of verification: 1. The chain terminates at a trust anchor in V's configured anchor set. 2. Every signature verifies against a key not revoked as of V's freshest revocation root for the relevant domain. 3. For every delegation link `(X → Y)` in the chain, the scope carried by Y is a subset of the scopes X's *current* policy epoch permits X to delegate — where "current" is evaluated against V's cached epoch state for X's domain, and the cache age is bounded by `max_epoch_staleness`. 4. The cached epoch state for each delegating domain is no older than `max_epoch_staleness` (operationally 60 s in the observed deployment); if stale, V must refresh before deciding, and must fail closed if refresh fails. Condition 3 is the one that breaks the naive model. It requires the verifier to hold, per delegating domain, not just the anchor and revocation root but the *delegable-scope set for the current policy epoch*, and to intersect it with every delegated scope in the chain. Note that this is not scope checking at the leaf — it is scope checking at each delegation edge against the *edge issuer's current epoch*. ## Decision Boundary The decision boundary is not "valid vs. invalid credential." It is a three-way partition that must be resolved before any accept: - **Accept** when conditions 1–4 hold and the epoch cache is fresh. - **Refuse (terminal)** when the chain is structurally sound but condition 3 fails — a delegated scope exceeds the delegating domain's current epoch authority. This is not retryable; the credential will never become valid under the current epoch. Emit a distinct error so callers do not enter a retry loop. - **Defer (retryable)** when condition 4 fails and refresh is in progress or transiently unavailable. Deferral must be time-bounded; after `max_epoch_staleness` the deferral escalates to terminal refusal (fail closed). The dangerous misclassification is mapping terminal refusal onto the retryable path. Agents that do so will hammer the epoch-refresh endpoint and, worse, may eventually accept on a stale cache that has not yet observed the epoch advance — a time-of-check/time-of-use race that converts a policy narrowing into an authorization bypass. The boundary must be enforced at the point where the epoch is read, and the read must be pinned for the duration of chain evaluation (single snapshot, not per-link re-read). ## Operational Effect Enforcing the epoch boundary correctly changes verifier behavior in three measurable ways. First, cross-domain delegation latency gains a fixed cost: one epoch-state fetch per delegating domain per evaluation window, amortized by cache but bounded by `max_epoch_staleness`. Second, delegating domains gain a *policy lever with immediate cross-domain effect* — narrowing a policy epoch propagates to all verifiers within the staleness bound, which is the intended semantics but surprises operators who expect delegation authority to be frozen at issuance. Third, the refusal taxonomy must be extended: `DelegationAuthorityEpochSkew` is a distinct terminal class, and monitoring must separate it from `CredentialExpired` and `KeyRevoked`, because the remediation differs — epoch skew is resolved by the delegate re-obtaining a delegation under the current epoch, not by key rotation or re-issuance of the leaf. The invariant to carry forward: **a federated attestation is valid only if every delegation edge is authorized under the delegating domain's current policy epoch at verification time.** Cryptographic validity is necessary and insufficient. The epoch, not the signature, is the authority. ```python class DelegationAuthorityEpochSkew(RuntimeError): """Terminal refusal: a delegated scope exceeds the delegating domain's current AAIF policy epoch. Not retryable; do not loop.""" pass class EpochStateStale(RuntimeError): """Retryable deferral: cached epoch state exceeds max staleness.""" pass def verify_federated_attestation(chain, epoch_cache, max_epoch_staleness_s, now): # Condition 1: anchor termination (assumed pre-checked by caller). # Condition 2: signatures and revocation (assumed pre-checked). # Condition 3+4: per-edge epoch authority, against a pinned snapshot. pinned = {} for edge in chain.delegation_edges: domain = edge.issuer_domain if domain not in pinned: state = epoch_cache.get(domain) if state is None or (now - state.fetched_at) > max_epoch_staleness_s: raise EpochStateStale( f"epoch state for {domain} stale by " f"{now - state.fetched_at if state else 'unknown'}s" ) pinned[domain] = state current = pinned[domain] if not current.delegable_scopes.issuperset(edge.delegated_scopes): excess = edge.delegated_scopes - current.delegable_scopes raise DelegationAuthorityEpochSkew( f"domain {domain} epoch {current.epoch_sequence} does not " f"permit delegation of {sorted(excess)}; edge minted under " f"epoch {edge.issuance_epoch}. Re-obtain delegation under " f"current epoch; do not retry." ) return True ```