Ship a new receipt inference contract through caller tests, staged traffic and a documented v1 retirement gate.
Project: migrate receipt inference callers without breaking decisions
Freeze current caller behavior
The review UI reads route, decision ID and score; a nightly ledger reads route and model digest. Capture redacted v1 requests, expected error codes and client retry behavior. The proposed v2 adds an explicit fallback reason and revises the route names. Treat that rename as breaking, even though both versions still return JSON. Register both owners and schedules in the consumer inventory. Keep the feature vector hidden behind the public API boundary.
Implement and test the adapter
Provide a v1 response adapter that maps v2 decision states to old route names without discarding the decision ID. Reject unmappable states instead of fabricating a score. Replay client fixtures through both routes and force invalid currency, stale feature, timeout and duplicate request key. Assert that the ledger never interprets a fallback as an ordinary score. Compatibility tests should be owned by callers and run before moving real traffic.
Cut over on separate clocks
Move the review UI first and inspect rejected requests, fallback, tail latency and retry counts by client ID. Keep the nightly ledger on v1 until its scheduled run and reconciliation check pass on v2. Log API version, decision ID and served model digest with redacted identifiers. If a bad route mapping appears, return the UI to v1 while preserving already issued decision records. A reversible pointer handles routing; it does not undo a decision already consumed.
Prove retirement or hold
Watch v1 traffic for a window longer than the least frequent caller schedule. Require owner sign-off and a gateway inventory showing no unknown v1 clients. If the ledger makes one old call, retain v1 and investigate it; a planned deprecation date does not override observed traffic. Deliver a matrix of caller, fixture result, rollout state, last old call and rollback path. The incident drill is a useful follow-up for a contract failure discovered after cutover.
Implementation
def adapt_route_for_v1(decision):
routes = {"HUMAN_REVIEW": "review", "AUTO_CLEAR": "clear"}
if decision["route"] not in routes:
raise ValueError("v1 cannot represent this decision state")
return {"decision_id": decision["decision_id"],
"route": routes[decision["route"]],
"score": decision["score"]}
new_decision = {"decision_id": "decision-47", "route": "HUMAN_REVIEW",
"score": 0.82}
assert adapt_route_for_v1(new_decision)["route"] == "review"
try:
adapt_route_for_v1({**new_decision, "route": "FEATURE_UNAVAILABLE"})
except ValueError:
pass
else:
raise AssertionError("unmappable fallback must fail closed")
Performance and operating cost
The adapter is O(1) time and space per response. Running two contracts costs test maintenance, gateway routing and monitoring until the old caller population is gone. The adapter deliberately rejects an unrepresentable state; preserving an old schema by concealing a new failure could cause a downstream decision error.
Common Mistakes
- Mapping feature failure to a valid score just to satisfy v1.
- Cutting over a scheduled ledger before its first full v2 run.
- Deleting v1 after owner sign-off while gateway still sees old calls.
- Rolling back traffic without preserving decisions already issued.
Read next
- Inference API contracts: version the decision, not only the payload
- Migrate inference clients with compatibility tests and usage evidence
- Model retirement: find consumers before removing a version
- Project: operate receipt scoring with a deadline and overload path
- Project: run a receipt-model incident drill with honest mitigation
