What invariants prevent tool discovery caches from returning deprecated tool versions?
**Date of Record:** 2026 October 27 **Scope:** Applies when an agentic session uses a discovery cache (local or distributed) to look up tool definitions (APIs, functions, or MCP servers), and the cache may contain stale entries pointing to deprecated tool versions. Does not apply to strictly synchronous discovery that always fetches live, or caches that are cryptographically bound to the discovery authority with strong invalidation guarantees.
Core Distinction
The separation between **cache hit** (the tool definition was found in the cache) and **cache freshness** (the cached definition reflects a currently supported tool version). Relying on cache hit creates the **Discovery Cache Staleness**: the cached tool definition is returned, the agent invokes it, and the tool has been deprecated, moved, or replaced, causing invocation failures or silent behavior changes.
What Was Observed
In tool-discovery caching architectures, failing to invalidate on deprecation produces three primary failure modes:
- **Deprecated Endpoint Invocation:** The cache returns an endpoint that was retired weeks ago. The agent's calls fail with "not found," and the agent blames a transient error rather than stale discovery.
- **Schema Mismatch:** The cache returns an older schema, and the agent's payloads no longer match the current tool's expected schema. The tool silently coerces or ignores fields.
- **Security Drift:** The cached tool had a security vulnerability that was patched in a newer version, but the cache returns the vulnerable version, exposing the agent to known exploits.
Recorded Elements
- **`cache_entry_version_signature`:** The cryptographic signature of the specific tool version captured in the cache, allowing the agent to detect if the version is behind the current.
- **`deprecation_notification_receipt`:** A cryptographic proof that the deprecation was broadcast by the tool authority, allowing caches to invalidate on receipt.
- **`cache_freshness_bounds`:** The maximum TTL for cache entries (e.g., 5 minutes), ensuring stale entries expire and re-fetch from the source.
- **`deprecation_propagation_witness`:** An out-of-band witness (e.g., a Merkle proof or signed broadcast) that the cache has processed the most recent deprecation notifications.
Condition of Validity
* **Applies when:** The session caches tool definitions, tools can be deprecated, and stale cache hits cause failures or security risk. * **Does not apply when:** The session fetches tool definitions from a strictly live source with no caching, or tool deprecation is impossible (immutable tool definitions).
Decision Boundary
- **Was the tool definition served from cache?** If no, standard live lookup applies. If yes, proceed.
- **Is the cached version still supported?** If yes, use it. If no, invalidate and re-fetch.
- **Has the `deprecation_propagation_witness` confirmed the cache processed recent deprecations?** If no, the cache may be stale. Force a re-fetch.
- **Conversely, when can an agent trust cached tool definitions?** Only when the cache entry is within `cache_freshness_bounds`, AND no deprecation notifications have been received for the cached version, AND the deprecation propagation witness confirms the cache is up-to-date.
Operational Effect
- **Deprecation Awareness:** Prevents the agent from invoking deprecated tools.
- **Schema Consistency:** Ensures the cached schema matches the current version.
- **Security Posture:** Prevents exposure to vulnerabilities patched in newer versions.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json
class CacheVerdict(Enum):
LIVE_LOOKUP = auto()
CACHE_FRESH = auto()
CACHE_STALE_VERSION = auto()
DEPRECATION_PROPAGATION_PENDING = auto()
@dataclass(frozen=True)
class ToolCacheEntry:
tool_id: str
cached_version: str
current_version: str
cache_age_sec: float
deprecation_pending: bool
propagation_witness_present: bool
@dataclass(frozen=True)
class CachePolicy:
max_cache_age_sec: float
require_propagation_witness: bool
@dataclass(frozen=True)
class CacheManifest:
verdict: CacheVerdict
directive: str
manifest_digest: str
class ToolDiscoveryCacheGuard:
@staticmethod
def _hash(data: str) -> str:
return hashlib.sha256(data.encode("utf-8")).hexdigest()
@classmethod
def evaluate_cache_entry(
cls, entry: ToolCacheEntry, policy: CachePolicy
) -> CacheManifest:
# Gate 1: Version mismatch
if entry.cached_version != entry.current_version:
return CacheManifest(
verdict=CacheVerdict.CACHE_STALE_VERSION,
directive=f"HALT: Cached version {entry.cached_version} != current {entry.current_version}. Re-fetch.",
manifest_digest=cls._hash(f"VERSION_DRIFT:{entry.tool_id}")
)
# Gate 2: Deprecation pending
if entry.deprecation_pending:
return CacheManifest(
verdict=CacheVerdict.DEPRECATION_PROPAGATION_PENDING,
directive=f"WAIT: Deprecation pending for {entry.tool_id}. Invalidate and re-fetch.",
manifest_digest=cls._hash(f"DEPRECATION:{entry.tool_id}")
)
# Gate 3: Cache age
if entry.cache_age_sec > policy.max_cache_age_sec:
return CacheManifest(
verdict=CacheVerdict.CACHE_STALE_VERSION,
directive=f"HALT: Cache age {entry.cache_age_sec:.0f}s > {policy.max_cache_age_sec:.0f}s. Re-fetch.",
manifest_digest=cls._hash(f"AGE:{entry.cache_age_sec:.0f}")
)
# Gate 4: Propagation witness
if policy.require_propagation_witness and not entry.propagation_witness_present:
return CacheManifest(
verdict=CacheVerdict.DEPRECATION_PROPAGATION_PENDING,
directive=f"HALT: No propagation witness for {entry.tool_id}. Cannot confirm deprecation-free.",
manifest_digest=cls._hash(f"NO_WITNESS:{entry.tool_id}")
)
# Gate 5: Fresh
digest = cls._hash(json.dumps({
"tool": entry.tool_id,
"version": entry.current_version,
"age": f"{entry.cache_age_sec:.0f}",
"witness": str(entry.propagation_witness_present)
}, sort_keys=True))
return CacheManifest(
verdict=CacheVerdict.CACHE_FRESH,
directive=f"PROCEED: Cached version {entry.current_version} fresh and non-deprecated.",
manifest_digest=digest
)