Jul 2026 · Profound
Renaming a URL without breaking every page behind it
A confidence-scored resolver that reconnects orphaned content bindings when an editor restructures a route.
This is the piece of work I would show someone first. I designed and built it end to end — the database migration, the resolution engine, the API procedures, the review UI and the tests. It is about 1,450 lines of engine plus roughly 1,300 lines of tests, and four lines of it were written by somebody else.
The problem
A route in the CMS is a pattern like /blog/[slug]. Documents get bound to it through its parameters. Rename the path — say to /articles/[postSlug] — and every one of those bindings points at a parameter that no longer exists. The pages don't error. They just quietly stop resolving, and the first person to find out is a reader hitting a 404 on content that was published months ago.
So route renaming was effectively forbidden. Editors who wanted to restructure URLs had to ask an engineer, and the engineer had to do it by hand.
The approach
The shape of it is a constraint resolver. Before anything changes, take a snapshot of every existing binding. Then index every candidate document by (schema name, slug field, normalised value), reading both the primary slug field and the alias fields. For each parameter in the new route, score the candidates from 0 to 100.
Anything at 90 or above resolves automatically. Anything below that gets surfaced in a review dialog for a human to decide. A binding's confidence is the min() of its parameters, not the average — one uncertain parameter should drag the whole binding into review, and averaging would hide exactly the case you want to catch.
The parts that were actually hard
- Ambiguity detection. A single document can list the same slug across four separate alias arrays. Naive candidate counting saw four matches, called it ambiguous and pushed an unambiguous binding into manual review. The fix was collapsing candidates by document before the count — a small function that removed a large amount of pointless human work.
- Renamed parameters. When a parameter's name changes but its position doesn't, name matching finds nothing. There's a positional fallback for that, plus spelling tolerance for the near-misses.
- Two generations of data. Legacy JSONB rows store keys in snake_case; newer writes use camelCase. Every read path has to tolerate both, because there is no moment where the old rows stop existing.
Migration discipline
The schema change shipped first, on its own, in a 295-line PostgreSQL migration — an expand phase, deployed and running before any code depended on it. The engine came afterwards. That ordering is the difference between a rollout and an outage.
What I took from it
The matching algorithm is the least interesting part. The design decision that mattered was choosing where automation stops. It could have auto-resolved everything and been right most of the time — but the failure mode here is silently broken published content, and most of the time is not good enough when nobody finds out.