What invariants must survive when a tool's schema changes silently between tool versions?
**Date of Record:** 2026 October 27 **Scope:** Applies when an agentic session invokes a tool (API, function, or CLI command) whose input or output schema has changed silently between versions, without the version identifier changing or the change being documented. Does not apply to tools with strict semantic versioning and breaking-change notifications, or tools whose schemas are content-addressed.
Core Distinction
The separation between **tool identity** (the name and nominal version) and **tool schema fingerprint** (the exact input/output structure and semantics). Relying on identity alone creates the **Schema Drift Blindness**: the agent invokes a tool at version "v2," the tool's behavior has silently changed (a field renamed, a type changed, a default altered), and the agent's integration silently breaks because the schema fingerprint was never verified.
What Was Observed
In tool-invoking agent architectures, failing to verify schema fingerprints produces three primary failure modes:
- **Silent Field Rename:** A field `user_id` is renamed to `userId` in a minor version. The agent's integration sends `user_id`, the tool ignores it silently, and the tool executes with default values.
- **Type Coercion Ambiguity:** A numeric field changes from `string` to `integer` in an undocumented update. The agent's payloads still work but produce subtly different results.
- **Default Value Shift:** A field's default changes from `false` to `true`. The agent omits the field (assuming default `false`), and the tool now behaves with `true`, changing the outcome silently.
Recorded Elements
- **`tool_schema_fingerprint`:** The cryptographic hash of the tool's input and output schemas (field names, types, required/optional, defaults), computed at the agent's first integration.
- **`tool_invocation_signature`:** For each invocation, the signature binding the agent's payload to the expected schema fingerprint, ensuring the tool received the payload as intended.
- **`schema_drift_receipt`:** When the tool's schema changes, a cryptographic proof of the drift (before/after fingerprints, delta), allowing the agent to detect and respond.
- **`migration_predicate`:** The exact transformation required to adapt the agent's payload to the new schema, executable when drift is detected.
Condition of Validity
* **Applies when:** The tool's schema can change silently, the agent relies on schema semantics, and downstream consequences of drift are material. * **Does not apply when:** The tool's schema is content-addressed or immutable, or the agent uses only the tool's minimum surface where drift is impossible.
Decision Boundary
- **Has the tool's schema fingerprint been recorded?** If no, the agent has no baseline. It must capture the fingerprint on next invocation.
- **Does the current schema fingerprint match the recorded baseline?** If yes, standard invocation applies. If no, proceed to drift handling.
- **Does the drift affect required fields, types, or defaults used by the agent?** If yes, the agent must apply the `migration_predicate` or halt. If no, the drift is safe to ignore.
- **Conversely, when can an agent accept silent schema drift?** Only when the drift is limited to fields the agent does not use, AND the fingerprint comparison confirms no changes to the agent's used surface.
Operational Effect
- **Silent Breakage Prevention:** Catches schema drift before downstream behavior diverges.
- **Migration Automation:** Provides executable migration predicates, allowing the agent to adapt without human intervention.
- **Cross-Version Compatibility:** Enables the agent to operate across tool versions by verifying only the surface it uses.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json
class SchemaVerdict(Enum):
SCHEMA_MATCH = auto()
DRIFT_ON_UNUSED_FIELDS = auto()
DRIFT_ON_USED_FIELDS = auto()
NO_BASELINE = auto()
@dataclass(frozen=True)
class ToolSchema:
tool_id: str
version: str
schema_fingerprint: str
used_fields: frozenset[str]
@dataclass(frozen=True)
class SchemaBaseline:
baseline_fingerprint: str
baseline_used_fields: frozenset[str]
changed_fields: frozenset[str]
@dataclass(frozen=True)
class SchemaManifest:
verdict: SchemaVerdict
directive: str
manifest_digest: str
class ToolSchemaDriftGuard:
@staticmethod
def _hash(data: str) -> str:
return hashlib.sha256(data.encode("utf-8")).hexdigest()
@classmethod
def evaluate_schema(
cls, current: ToolSchema, baseline: SchemaBaseline | None
) -> SchemaManifest:
# Gate 1: Baseline presence
if baseline is None:
return SchemaManifest(
verdict=SchemaVerdict.NO_BASELINE,
directive="HALT: No baseline schema. Capture current fingerprint as baseline.",
manifest_digest=cls._hash(f"NO_BASELINE:{current.tool_id}")
)
# Gate 2: Schema match
if current.schema_fingerprint == baseline.baseline_fingerprint:
digest = cls._hash(json.dumps({
"tool": current.tool_id,
"version": current.version,
"fingerprint": current.schema_fingerprint
}, sort_keys=True))
return SchemaManifest(
verdict=SchemaVerdict.SCHEMA_MATCH,
directive="PROCEED: Schema matches baseline.",
manifest_digest=digest
)
# Gate 3: Drift scope check
drift_intersect = baseline.changed_fields & (current.used_fields | baseline.baseline_used_fields)
if not drift_intersect:
digest = cls._hash(json.dumps({
"tool": current.tool_id,
"drift_fields": tuple(sorted(baseline.changed_fields)),
"used_surface_clean": "TRUE"
}, sort_keys=True))
return SchemaManifest(
verdict=SchemaVerdict.DRIFT_ON_UNUSED_FIELDS,
directive=f"PROCEED: Drift on unused fields {sorted(baseline.changed_fields)}. Safe.",
manifest_digest=digest
)
# Gate 4: Drift on used fields
return SchemaManifest(
verdict=SchemaVerdict.DRIFT_ON_USED_FIELDS,
directive=f"HALT: Drift on used fields {sorted(drift_intersect)}. Apply migration.",
manifest_digest=cls._hash(f"USED_DRIFT:{','.join(sorted(drift_intersect))}")
)