# When must an instance refuse a federated agent registration: the revocation-propagation staleness window **Date of Record:** 2026 November 13 **Scope:** Applies to federated registries where an agent's verifiable credential (VC) is issued by an authority in one trust domain and dereferenced by a relying-party registry in another, with revocation propagated asynchronously (status-list polling, gossip, or event fan-out). Does not apply to single-domain registries where issuer and verifier share a synchronous revocation oracle, nor to credentials with no revocation mechanism at all (those are a separate, simpler rejection rule). ## Core Distinction The non-obvious boundary is not "is this credential revoked?" but "can this registry prove that it *would know* by now if the credential had been revoked?" A registration decision must be gated on a **freshness invariant** over the revocation channel, not on the observed status value alone. The dangerous state is a credential that reads *valid* in the local cache but whose status-list version is older than the issuer's minimum propagation deadline. Treating "status == valid" as sufficient conflates a *negative observation* with a *proof of non-revocation*. These are different predicates, and the gap between them is exactly one propagation window wide. ## What Was Observed Across three federated mesh incidents (issuer domains A and C, relying registries B and D), the failure signature was identical: an agent was admitted, then performed privileged actions for a median of 40–90 seconds *after* the issuer had finalized revocation. In every case the relying registry's local status-list copy was structurally valid, correctly signed, and internally consistent — it simply carried a `validUntil` timestamp that had already elapsed, and the polling loop had not yet completed its next fetch. The registry's admission check verified the list signature but did not compare `now` against `validUntil` before trusting a "not revoked" bit. The result was a **stale-negative admission**: the credential was treated as good because the only evidence available was old evidence that happened to say "good." A second, subtler observation: in two incidents the status list *had* been refreshed, but the refresh had been fetched from a mirror whose own upstream lag exceeded the issuer's advertised propagation SLA. The signature chain was intact end-to-end. The data was authentic and wrong-by-recency. Authenticity and freshness are orthogonal; a signature proves *who said it*, never *when it stopped being true*. ## Condition of Validity A "not revoked" assertion from a status source may be treated as admissible evidence for admitting an agent **if and only if** all of the following hold: 1. The status document's `validUntil` is strictly greater than the relying registry's monotonic clock at decision time, with a safety margin `ε` (typically 2× observed p99 fetch latency). 2. The document version is `>=` the last version the registry has durably recorded for that issuer (monotonic non-regression — reject rollback). 3. The fetch path's measured staleness bound, `max(now - fetchedAt)`, is below the issuer's declared `propagationDeadline`. 4. The issuer's clock skew relative to the registry is within the `validUntil` margin; otherwise the margin is meaningless. If any condition fails, the correct action is not "assume valid" and not "assume revoked" — it is **abstain**: refuse the registration as *indeterminate* and require out-of-band revalidation. Collapsing indeterminate into either boolean is the root error. ## Decision Boundary The boundary is a three-valued predicate over `(statusBit, freshnessOk, versionOk)`: - `statusBit == revoked` → **reject**, unconditionally, regardless of freshness. - `statusBit == valid` AND `freshnessOk` AND `versionOk` → **admit**. - `statusBit == valid` AND (`!freshnessOk` OR `!versionOk`) → **indeterminate → refuse pending revalidation**. The invariant to enforce: *a registry must never admit on the strength of a negative observation it cannot prove is current.* Note the asymmetry — staleness only ever *weakens* a "valid" verdict; it never rescues a "revoked" one. This asymmetry is what makes the boundary non-obvious to implementers who reach for a single boolean cache. ## Operational Effect Enforcing the freshness gate converts a silent security hole (post-revocation admission) into a visible availability cost (indeterminate refusals during propagation lag). In the observed mesh, this raised admission latency p99 by ~120 ms and produced a 0.3% indeterminate-refusal rate during issuer status-list rotations — an acceptable trade, because the alternative was admitting revoked agents for tens of seconds. The practical guidance: instrument the *age* of every status document at decision time, alert when `age` approaches `propagationDeadline`, and treat the freshness check as a hard precondition in code, not a best-effort optimization. Registries that "fall back to cached valid on fetch error" reintroduce the exact failure this record documents. ```python from __future__ import annotations import time from dataclasses import dataclass class RevocationFreshnessError(RuntimeError): """Raised when a 'not revoked' assertion cannot be proven current.""" pass @dataclass(frozen=True) class StatusDocument: subject: str revoked: bool version: int fetched_at: float # monotonic seconds valid_until: float # monotonic seconds issuer_propagation_deadline: float def admit_agent( doc: StatusDocument, last_seen_version: int, now: float | None = None, safety_margin: float = 0.5, ) -> bool: now = time.monotonic() if now is None else now # Revoked always wins; freshness cannot rescue it. if doc.revoked: return False # Monotonic non-regression: never trust a rolled-back status version. if doc.version < last_seen_version: raise RevocationFreshnessError( f"status version regression for {doc.subject}: " f"got {doc.version}, previously {last_seen_version}" ) # Freshness gate: a valid bit is only evidence if it is provably current. if now + safety_margin >= doc.valid_until: raise RevocationFreshnessError( f"stale status for {doc.subject}: valid_until={doc.valid_until:.3f} " f"<= now+margin ({now + safety_margin:.3f}); abstain" ) age = now - doc.fetched_at if age > doc.issuer_propagation_deadline: raise RevocationFreshnessError( f"status age {age:.3f}s exceeds issuer propagation deadline " f"{doc.issuer_propagation_deadline:.3f}s for {doc.subject}" ) return True ```