# When must an instance refuse a delegated AAIF credential exchange: the audience-binding temporal boundary **Date of Record:** 2026 November 13 **Scope:** Applies to AAIF (Agent Identity and Authorization Interoperability Framework) delegated credential exchanges where an acting agent (the *delegate*) presents a credential minted by a *delegator* to a *resource holder* (the *audience*). Does not apply to first-party credential issuance, to root-of-trust bootstrapping, or to bearer tokens that carry no `act`/`may_act` chain. ## Core Distinction The failure mode is not "expired credential" and not "forged signature." It is a *disagreement between two clocks that are both correct*. AAIF delegated credentials carry two independent temporal claims: 1. `nbf`/`exp` — the validity window of the credential itself. 2. `delegation_exp` — the expiry of the *delegator's own authority* at the moment of minting, which the delegate must not extend. A delegate is permitted to present a credential while `now < exp`. But it is only *authorized* to exercise the delegated scope while `now < delegation_exp` **and** the audience still recognizes the delegator's signing key as authoritative for that scope. These are not the same instant. The non-obvious invariant is that **the credential's `exp` may legitimately outlive the delegator's authority**, and a naive verifier that checks only `exp` will accept a token whose underlying authority has already been revoked or rotated. The boundary is temporal but the decision is about *authority provenance*, not wall-clock validity. ## What Was Observed During an interoperability test between two AAIF-conformant stacks (call them Origin and Edge), a delegate presented a chain `Origin → Delegate → Edge`. Origin had issued the delegation with `exp = T+3600` but rotated its signing key at `T+900` and marked the prior key `retired_after = T+900` in its JWKS with a `retired_after` extension. Edge cached the JWKS at `T+0` and, because the credential's `exp` was still in the future, accepted the exchange at `T+1200`. The exchange *succeeded* cryptographically — the signature verified against the cached key — and *failed operationally*: Edge acted on a scope that Origin had, in its own ledger, already withdrawn. The observable symptom was not an auth error but a **silent over-authorization**: Edge performed an action that Origin's audit log recorded as unauthorized-by-delegator. The two systems reconciled only when a downstream reconciliation job flagged the ledger divergence 40 minutes later. The second observation is the important one: the delegate *believed it was compliant* because it checked `exp`. The audience *believed it was compliant* because it checked the signature. Neither checked the conjunction that AAIF actually requires: `now < min(exp, delegation_exp)` **and** the delegator's key was not retired before the credential's `iat`. ## Condition of Validity The refusal is correct only under the following conjunction. If any clause is false, the delegate should *not* refuse; it should proceed or escalate differently. - The credential carries a `delegation_exp` claim (or its AAIF equivalent, `delegator_authority_exp`). If absent, this boundary does not apply — treat as first-party and fall through to standard `exp` checking. - The audience's cached key material is older than `now - max_jwks_staleness`, where `max_jwks_staleness` is the audience's declared freshness bound. If the cache is fresh, the retired-key condition cannot be detected and refusal is not warranted (the audience owns that risk). - The delegate has a verifiable path to the delegator's current key state (JWKS with `retired_after`, or a revocation list). Without that path, the delegate cannot *know* the authority lapsed and must not fabricate a refusal. When all three hold and `now >= delegation_exp` **or** the signing key's `retired_after <= credential.iat`, the delegate must refuse before presenting. Refusal is a *pre-exchange* action, not a post-exchange error. ## Decision Boundary The precise boundary, stated as a predicate the delegate evaluates locally: ``` refuse iff delegation_exp is present and now >= delegation_exp and jwks_freshness(now) is verifiable OR refuse iff key.retired_after <= credential.iat and jwks_freshness(now) is verifiable ``` Note the asymmetry: the first clause is about the *delegator's clock*, the second is about the *delegator's key lifecycle*. A credential can pass the first and fail the second (key rotated between minting and presentation). It can also pass the second and fail the first (key still valid, but the delegator's scope grant expired). Both are refusals. A credential that fails *neither* but whose audience cache is stale is **not** a delegate refusal — it is an audience-side risk the delegate should surface as a warning, not block on. The boundary is deliberately *not* symmetric with the audience's check. The audience checks signature + `exp` + `aud`. The delegate checks authority provenance + delegation window. These are different questions asked by different parties, and conflating them is the origin of the silent over-authorization above. ## Operational Effect When the delegate refuses correctly, the exchange never reaches the audience. The delegate returns a structured refusal to its caller with the failing clause named (`delegation_exp_lapsed` vs `delegator_key_retired`), which lets the orchestrator re-mint from a fresh delegator rather than retry blindly. Retries against a lapsed delegation are pure waste and, worse, train operators to widen `max_jwks_staleness` — the exact knob that caused the original incident. When the delegate refuses *incorrectly* (e.g., because its own JWKS cache is stale and it mistakes staleness for retirement), it produces a false negative that looks identical to a real refusal. This is why the `jwks_freshness` clause is load-bearing: without it, the refusal predicate is unfalsifiable and the delegate becomes an unreliable oracle. The operational discipline is to log the *evidence* for the refusal (the `retired_after` value, the `iat`, the cache age) so a false refusal is diagnosable in one pass. The net effect: delegated exchanges become *fail-closed on authority provenance* rather than *fail-open on validity window*. The cost is occasional re-minting; the benefit is that the ledger divergence observed at T+1200 never occurs, because the delegate declines to present authority it can no longer prove. ```python from dataclasses import dataclass from datetime import datetime, timedelta class DelegationAuthorityLapsed(RuntimeError): """Raised when a delegate must refuse to present a credential whose delegator authority has lapsed or whose signing key was retired before the credential was minted. This is a pre-exchange refusal, not a post-exchange error.""" def __init__(self, clause: str, evidence: dict): self.clause = clause self.evidence = evidence super().__init__(f"delegation refused: {clause} ({evidence})") @dataclass class Credential: iat: datetime exp: datetime delegation_exp: datetime | None signing_key_id: str @dataclass class KeyState: retired_after: datetime | None jwks_cached_at: datetime MAX_JWKS_STALENESS = timedelta(minutes=5) def _jwks_is_verifiable(now: datetime, key: KeyState) -> bool: return (now - key.jwks_cached_at) <= MAX_JWKS_STALENESS def must_refuse_exchange(now: datetime, cred: Credential, key: KeyState) -> None: """Evaluate the AAIF delegate-side refusal predicate. Refuses iff (delegation_exp present and now >= delegation_exp) OR (key.retired_after <= cred.iat), AND the JWKS state is fresh enough to make the determination meaningful. Raises DelegationAuthorityLapsed on refusal; returns None when the exchange may proceed. """ if not _jwks_is_verifiable(now, key): # Cannot prove authority lapsed; do not fabricate a refusal. return None if cred.delegation_exp is not None and now >= cred.delegation_exp: raise DelegationAuthorityLapsed( "delegation_exp_lapsed", { "delegation_exp": cred.delegation_exp.isoformat(), "now": now.isoformat(), "key_id": cred.signing_key_id, }, ) if key.retired_after is not None and key.retired_after <= cred.iat: raise DelegationAuthorityLapsed( "delegator_key_retired", { "retired_after": key.retired_after.isoformat(), "credential_iat": cred.iat.isoformat(), "key_id": cred.signing_key_id, }, ) return None ```