A PR renaming an avatar endpoint. The author updated the web client in the same PR. Mesrai cross-referenced the old route against `client/ios/` and `client/android/` and found three live app versions still calling the original. Breaking change caught pre-merge.
The vulnerable diff
// api/users-photo.ts (the rename PR)
- app.get("/users/:id/avatar", requireAuth, getAvatar);
+ app.get("/users/:id/photo", requireAuth, getAvatar);
// client/web/UserProfile.tsx (also updated in PR — fine)
- fetch(`/api/users/${id}/avatar`)
+ fetch(`/api/users/${id}/photo`)
// client/ios/UserViewModel.swift (untouched — still calls old route)
let url = "/api/users/\(id)/avatar" // → 404 in production after mergeWhat is wrong
REST endpoints are part of the public contract of the service, even for first-party clients. Mobile clients carry an install base that updates over weeks or months — a route rename without an alias breaks every old client at deploy time. The fix is a transitional alias: keep the old route working for at least one full mobile-release cycle (typically 30-90 days), then deprecate, then remove. The deprecation window is a rollout boundary the team agrees on; the code change is a one-line alias or 301 redirect.
The attack
Production timeline without alias:
t+0 — Deploy. Web works. iOS 4.12 (40% of users) starts 404ing.
t+0:15 — Pager. Rollback.
t+1d — Rebuild PR with alias.
t+8d — Ship mobile update.
t+90d — Telemetry confirms < 1% on old route. Safe to remove alias.Alias-first is the only path that doesn't burn engineering goodwill on every rename.
Mesrai's review comment
mesraipilot · Bot · reviewed 90 sec ago
[mesrai] [code-review] [Logic] [Breaking-Change] [critical]
Renaming `/users/:id/avatar` → `/users/:id/photo`. Code-search shows
old path still referenced in:
- client/ios/UserViewModel.swift:42
- client/android/data/User.kt:88
- client/mobile-web/Profile.tsx:12
Mobile builds in the wild can't update overnight. Add a transitional
alias for at least 90 days, then remove in a follow-up PR:
app.get("/users/:id/photo", requireAuth, getAvatar);
app.get("/users/:id/avatar", requireAuth, getAvatar); // deprecated alias
Optionally include a Deprecation header so client telemetry can find
remaining callers:
res.set("Deprecation", "version=2026-09-01");
res.set("Link", '</users/:id/photo>; rel="successor-version"');
Add a follow-up issue to remove the alias once telemetry shows it's
unused (90 days, typically).The fix
// api/users-photo.ts — fixed
const photoHandler = async (req, res) => { /* ... */ };
app.get("/users/:id/photo", requireAuth, photoHandler);
app.get("/users/:id/avatar", requireAuth, (req, res, next) => {
res.set("Deprecation", "version=2026-09-01");
res.set("Link", `</users/${req.params.id}/photo>; rel="successor-version"`);
return photoHandler(req, res, next);
});The alias serves the same handler, with a Deprecation header so client telemetry can identify legacy callers. After 90 days of low traffic, the alias is removed in a follow-up. Mobile rollout cadence drives the removal date — never the merge date of the rename.
Why human review missed it
Renaming endpoints feels like a refactor, but it's a contract change. Reviewers who own only the web client miss the mobile/native callers. Mesrai catches the pattern by cross-referencing the removed route against every client codebase in the monorepo.
Related rules + further reading
Mesrai rule pack: logic/breaking-route-rename — flags route removal/rename with active callers in any sibling client codebase.
RFC 8594: The Deprecation HTTP Response Header.
Apollo + Stripe deprecation playbooks both formalize the alias-then-remove pattern.
Takeaway
Aliases are mandatory on route renames. 90-day window. Telemetry-driven removal. Mesrai catches every rename without an alias.