Treat a tool schema as immutable execution data, not mutable configuration. Give each contract a stable internal ID, bind that ID to the run when the run is admitted, and keep old definitions resolvable. Route both v1 and v2 calls through a compatibility adapter that produces one stable domain command. Build replay fixtures from real checkpoint shapes, deploy old-schema readers before new-schema writers, and remove v1 after a query proves that no queued, active, failed-retryable, or retained replay state can still reference it.
The Schema Is Part of the Checkpoint
A durable agent does not execute entirely inside the deployment that created it. A run may wait in a queue before its first model call, pause after the model emitted a tool call, or resume from a checkpoint after the tool produced an external effect. If the runtime looks up “the current refund tool” at resume time, one logical run can see two incompatible contracts.
Imagine that v1 exposes refund_order with an unconstrained string field named reason. Version 2 renames the tool to request_refund, replaces that field with a controlled reason_code, and adds an optional note. A queued run may still contain a prompt that names v1. A paused run may already contain serialized arguments for v1. A replay may contain the old call plus a completed side effect. Changing one global definition breaks each state differently.
The fix is to resolve the contract once and store the decision. Persist an internal identifier such as refund_tool.v1 beside the run, then load that exact immutable definition whenever the run resumes. Do not store merely “refund tool,” a code pointer, or a mutable registry key. When several tools ship as one set, pin the toolset version on the run instead; it resolves to exactly one immutable contract ID per tool, which is why the examples below carry both names.
type RunRecord = {
id: string;
status: "queued" | "running" | "waiting_tool" | "retryable" | "done";
toolset_id: "support_tools.2026-08-01" | "support_tools.2026-08-27";
created_at: string;
checkpoint: unknown;
};
const run: RunRecord = {
id: "run_1042",
status: "queued",
toolset_id: "support_tools.2026-08-01",
created_at: "2026-08-27T06:40:00Z",
checkpoint: null
};
A schema hash can detect accidental mutation, but it is a poor operational name. Keep both: a readable immutable contract ID for routing and dashboards, plus a digest of the canonical payload for integrity. If the digest changes under the same ID, fail deployment rather than silently redefining history.
Snapshot the Provider-Facing Contract
Keep the tool definition supplied to the model separate from the handler that performs work. In the current OpenAI Responses API, a request can include an array of tools; a function-tool definition includes fields such as name, parameters, strict, and an optional output_schema. The Response has a separate lifecycle status. That API shape is a useful concrete example, but the version registry below is application architecture rather than an OpenAI requirement. See the current create-response reference.
Snapshot the complete tool payload that your provider request uses, not merely the JSON Schema fragment. A tool rename, description edit, required-field change, enum change, strictness toggle, or output-schema change can alter planning or validation. The handler address should not be embedded in that snapshot; map the immutable contract ID to executable code in your runtime.
const refundToolV1 = {
type: "function",
name: "refund_order",
description: "Refund a paid order",
strict: true,
parameters: {
type: "object",
properties: {
order_id: { type: "string" },
reason: { type: "string" }
},
required: ["order_id", "reason"],
additionalProperties: false
}
} as const;
const refundToolV2 = {
type: "function",
name: "request_refund",
description: "Request a refund for a paid order",
strict: true,
parameters: {
type: "object",
properties: {
order_id: { type: "string" },
reason_code: { type: "string", enum: ["duplicate", "faulty", "other"] },
note: { type: "string" }
},
required: ["order_id", "reason_code"],
additionalProperties: false
}
} as const;
The paired definitions have the same wrapper so the breaking differences are visible. Versioning the tool name is useful when calls can sit outside your process, because the name itself routes to the correct decoder. If a provider or framework constrains public names, keep its accepted name and carry the immutable version in your run record. Either way, do not infer the decoder from deployment time.
Normalize Both Versions Into One Domain Command
The compatibility layer should be boring: validate the historical shape, translate it, and hand one canonical command to business logic. Do not teach the refund service about every prompt-era field name. This is also where you can make a previously implicit default explicit.
type RefundCommand = {
orderId: string;
reasonCode: "duplicate" | "faulty" | "other";
note?: string;
};
function decodeRefundCall(
contractId: "refund_tool.v1" | "refund_tool.v2",
raw: unknown
): RefundCommand {
if (contractId === "refund_tool.v1") {
const value = RefundArgsV1.parse(raw);
return {
orderId: value.order_id,
reasonCode: "other",
note: value.reason
};
}
const value = RefundArgsV2.parse(raw);
return {
orderId: value.order_id,
reasonCode: value.reason_code,
note: value.note
};
}
Mapping every old unconstrained reason string to other is intentionally lossy and visible. If reporting needs the original string, retain it as migration metadata rather than pretending that historical prose fits a new enum. If no honest mapping exists, keep the v1 handler alive; a compatibility adapter is not permission to fabricate semantics.
Validation happens before normalization. A permissive “accept either shape” schema loses the information needed to choose defaults and produce useful errors. Select the validator from the stored contract ID, reject unknown fields according to that version, and attach run_id, tool_call_id, and contract_id to the resulting audit event.
Build Replay Fixtures at Three Cut Points
A single fixture for an old JSON object is not enough. Capture the states your runtime can actually restore, including the envelope around the tool arguments. The minimum useful matrix has three rows:
- Queued before model execution: the run stores
toolset_id=v1but no model output. On replay, assert that the provider request still contains the exact v1 tool name and schema digest. - Waiting with an emitted tool call: the checkpoint stores
refund_orderand v1 arguments. On replay, assert that v1 validation runs, one canonical command is produced, and the v2 validator is not consulted. - Checkpointed after the effect: the record contains the old call, its idempotency key, and a completed tool result. On replay, assert that the stored result is returned and the refund side effect executes zero times.
The third fixture joins schema compatibility to effect safety. A perfect decoder can still duplicate a refund if replay forgets that the call already committed. Use the effect ledger and idempotency design from Idempotent Tool Calls for AI Agents, and use the checkpoint ordering in Resume an Agent Run After a Crash.
it("replays a completed v1 call without refunding twice", async () => {
const fixture = loadFixture("refund-v1-after-effect.json");
const issueRefund = vi.fn();
const result = await resumeRun(fixture, { issueRefund });
expect(result.toolOutput).toEqual(fixture.completed_tool_output);
expect(result.contractId).toBe("refund_tool.v1");
expect(issueRefund).not.toHaveBeenCalled();
});
Generate sanitized fixtures from each persisted checkpoint version while the old code can still explain them. Add the schema digest and expected canonical command to the fixture. A hand-written “old” payload created after v2 ships often omits precisely the legacy field or envelope that breaks production replay.
Deploy Readers Before Writers
The rollout order is a protocol. Each step must be independently safe to pause or roll back.
- Freeze v1. Assign its immutable ID and digest, export replay fixtures, and make registry mutation a deployment failure.
- Ship compatibility readers. Every worker that can claim or resume a run must resolve v1 and v2, validate both, normalize both, and emit per-version metrics. Keep new-run admission on v1.
- Verify mixed-fleet behavior. Run v1 replay fixtures against the new workers and v2 fixtures against a canary. Do not proceed if any consumer still understands a single version.
- Switch new-run admission to v2. Change the admission default. Existing run records keep their stored
toolset_id; retries do not upgrade themselves. - Drain and observe. Watch queued, active, retryable, quarantined, and archived replay dependencies separately. A quiet live queue does not prove that retained checkpoints are safe to forget.
Rollback after step four means restoring v1 as the default for new runs while leaving both readers deployed. Rewriting v2 run records to v1 is a separate data migration and is usually unnecessary. This expands the general public-contract sequencing in The AI API Contract Change Plan to model-facing schemas and stored execution state.
Retire v1 From Evidence, Not Age
“It has been two weeks” is not a retirement proof. Your evidence must cover every state from which work can return. Query live run rows, delayed queues, dead-letter or retry stores, and the oldest checkpoint that operations promises it can replay. If cold archives are restorable, either retain the old adapter with them or migrate and verify those archives before deletion.
select status, count(*) as dependent_runs
from agent_runs
where toolset_id = 'support_tools.2026-08-01'
and status in ('queued', 'running', 'waiting_tool', 'retryable')
group by status;
select count(*) as replayable_checkpoints
from run_checkpoints
where toolset_id = 'support_tools.2026-08-01'
and expires_at > current_timestamp;
Retirement requires both result sets to be zero, the v1 adapter-use metric to stay at zero across the longest retry delay, and a restore drill to confirm that the oldest supported snapshot no longer references v1. Then remove v1 from new-provider requests, wait through one ordinary deployment rollback window, and delete the decoder in a later change. Separating those deletions gives rollback a known-good reader.
Before changing provider-facing fixtures, check the current OpenAI API changelog as well as the create-response reference. The changelog is a maintenance input, not a substitute for replaying your own stored envelopes.
Tool Contract Migration Checklist
- Identify immutably: readable contract ID plus canonical payload digest.
- Bind at admission: every run stores the exact toolset it was created with.
- Snapshot completely: name, description, parameters, strictness, outputs, and relevant provider options.
- Normalize once: version-specific validators feed one stable domain command.
- Replay three states: before model call, waiting on a tool, and after an external effect.
- Deploy readers first: mixed fleets understand both versions before new runs write v2.
- Retire from counts: prove zero live and replayable dependencies before deleting v1.
Related Guides
Resume an Agent Run After a Crash
Design checkpoints, effect boundaries, and replay classes so a durable run continues from recorded truth.
Idempotent Tool Calls for AI Agents
Prevent a replayed or redelivered tool call from repeating an external side effect.
The AI API Contract Change Plan
Sequence public API changes across producers, consumers, observability, and rollback.