Chapter 6 of 6 · 18 min read
Forcing functions for model pins
On this page
- A required date field and a failing build
- The three parts worth keeping
- A forcing function has to name who it forces
- One threshold cannot fit every pin
- Enforcement that is cheapest to satisfy honestly
- The decision stays with a person
- Models are not the only calendar
- Provider adapters break silently
- Three checks that fit in an afternoon
A forcing function is a mechanism that makes work happen by making its absence expensive: a check that fails, a gate that blocks, a ticket that escalates. It has an obvious job here, because a pin — the dated snapshot id recorded in your configuration rather than a floating family alias — carries a retirement date that arrives whether or not anybody scheduled the migration around it.
The mechanism most teams reach for first is a required field. Every model id lives in one config file, that file carries the deprecation date, and continuous integration — the automated build that runs against every push — fails sixty days before any listed date. Most of that is right, and the part that is wrong fails in a specific way worth being able to recognise in other policies.
A required date field and a failing build
The output of such a policy looks like this:
✗ policy:model-deprecation
claude-sonnet-4-5-20250929 (extraction) retires in 59 days (2026-11-06)
Threshold is 60 days. Failing the build.Nothing in those four lines is inaccurate: the id is real, the date is real, and the migration it is complaining about genuinely has not been scheduled.
The three parts worth keeping
Model ids belong in one enumerable place. A pin that lives inline in a call site is invisible to every process except a grep somebody remembers to run, and chapter 1's two-call-site problem — one service sending a dated snapshot, its neighbour sending the bare family alias — exists precisely because nothing enumerated them. Making a registry the only legitimate home for a model string is the foundational move.
Dates belong in version control next to the thing they constrain. A retirement date in a spreadsheet, a wiki page, or a calendar invite drifts away from the code within one reorganisation. Next to the pin, it is diffed, reviewed, and blamed like everything else.
And a deadline needs teeth. The instinct behind the policy — that a date nobody is forced to act on is a date nobody acts on — is correct, and is why it got proposed at all. Teams do not miss retirement dates because they are unaware of them; they miss them because awareness has no consequence until the day it does. The mechanism is the part that fails.
A forcing function has to name who it forces
Ask of any enforcement mechanism: whose behaviour is this trying to change, and does the mechanism actually reach that person?
Missing a migration deadline is not a mistake an individual engineer makes at push time. It is an allocation failure: nobody was given the weeks in which to run chapter 5's four phases — qualify, shadow, cut over, burn in — because those weeks belong to a roadmap somebody else owns. The person who can prevent it is the lead, the engineering manager, or the product owner who decides what a team spends November on — and the decision has to be made in August, when the option set is still wide.
The build-failure policy reaches none of those people. It reaches whoever happens to push that morning, at random, with no relationship to the work and no authority to schedule it. It is a smoke alarm wired to whichever door someone opens next. Because the interruption is arbitrary, expensive, and blocking, the pressure it creates is aimed entirely at making the alarm stop, and the cheapest way to stop it is to edit the number it read.
That is the general defect: a forcing function that is cheapest to satisfy by falsifying its own input has been designed to be defeated. The date field is data the policy reads from the repository, and the policy grants write access to that data to everyone it interrupts. Every mechanism of this shape dies the same way — a bypass flag, an allowlist entry, an edited threshold — and it dies quietly, leaving a green build behind as evidence that it is fine.
The chain under that sentence is short, and walking it is what turns the claim from a piece of cynicism about engineers into something you can apply to a policy you have not seen yet. A check computes a verdict from an input. The verdict is the only thing that creates pressure, since a policy that never fails anybody's build exerts nothing at all, which is why the interruption and the pressure are the same event. That pressure lands on whoever the check happens to interrupt, and a person under pressure takes the cheapest action that removes it — not the action the policy's author had in mind, because the pressure is what they can feel and the author's intention is not written anywhere they are looking. What the policy actually produces therefore reduces to one comparison: of everything that turns the verdict from fail to pass, which is cheapest.
For the deadline check the comparison is not close. Running chapter 5's four phases is weeks of work belonging to a quarter somebody else plans; changing a date string in a config file is an edit and a commit message. The reason the edit is on the list at all is a property of where the policy's input lives — the date is a field in the repository, and the repository is writable by every person the policy can interrupt. That is the transferable test, and it costs a minute on any policy in any codebase: find the input the check reads its verdict from, ask who has write access to it, and if the answer includes the people being interrupted, the failure mode is already decided regardless of how strict the check looks.
The belief underneath the original design is that blocking is strong enforcement, and it deserves naming rather than mockery, because it is a reasonable thing to think. Blocking is the loudest mechanism a build system offers. It stops the work, it cannot be missed, and everybody it touches learns at once that the rule exists. Loudness is easy to read as strength, which is why a blocking check gets proposed in preference to a report, and why a team can watch one fire and conclude that the mechanism is working.
What is actually true is that a mechanism's strength is the ratio between the cost of complying with it and the cost of escaping it, and blocking moves both of those at once. It raises the cost of non-compliance, which is the intended half and the half people design for. It also raises the value of an escape, because the interrupted person is now paying for the search, and a search worth funding gets funded — with a bypass flag, an allowlist entry, an edited threshold, or in the simplest case a number that has been made to look correct. Severity has to be earned by being hard to escape. A mechanism that is easy to escape gets weaker the louder it is made, since every increment of loudness buys another increment of motivation to route around it.
One threshold cannot fit every pin
There is a second defect in the deadline policy that is less obvious. A fixed sixty-day threshold ignores everything chapter 3's backwards arithmetic produced, where a shutoff date is walked back through qualification, cutover and a reserve to an internal deadline months earlier. Sixty days is generous for a scratch script that calls a model twice a day and disastrous for a revenue surface that needs qualification, shadowing, cohort cutover, and burn-in. One threshold applied to every pin either fires uselessly early on the cheap ones or fatally late on the expensive ones, and in practice it does both at once.
Enforcement that is cheapest to satisfy honestly
The fix is to keep the registry and move enforcement onto claims that are expensive to fake and cheap to satisfy honestly. Three mechanisms, each aimed at a different person.
The first is a CI check on registration, not on dates. It fails the build when a model id appears anywhere in the codebase that is not present in the registry, or when a registry entry is missing an owner, a tier, or a source URL:
✗ policy:model-registry
services/reports/client.py:63 uses model id "claude-haiku-4-5-20251001"
which is not in config/models.yaml.
Add an entry with: owner, tier, lifecycle.retires, lifecycle.sourceThis interrupts the person who introduced the pin, at the moment they introduced it, and the cheapest way to make it green is to do the thing you wanted: register the model with a named owner and a date read off the provider's page. There is no shortcut that is faster than compliance, which is the property the deadline check lacked. It also catches the failure mode that matters most in a growing codebase, where the problem is rarely a forgotten date and usually a fifth pin nobody knew existed.
It is worth being exact about that claim, because the obvious reading of it is stronger than the true one. A registry entry can be falsified about as cheaply as a date can. Anybody could invent an owner, type a plausible date and paste a URL that goes nowhere, and no part of the build would object, because nothing in it visits the URL. What changed is not the difficulty of lying but the ordering of the options. Registering honestly means opening a provider page you were about to open anyway, copying a date and writing your own name, while inventing a convincing entry takes longer than that and buys nothing — the person being interrupted is the person who wanted the pin, and they are not trying to get out of owning it. A mechanism survives when compliance is the fast path, and that is the comparison the deadline check lost, not a comparison about what was possible.
The second is a scheduled reconciler that compares the registry against the providers rather than against itself:
def reconcile(registry, providers, today):
findings = []
for name, entry in registry.items():
published = providers[entry["provider"]].lifecycle(entry["model"])
if published is None:
findings.append(Finding(name, "UNKNOWN_TO_PROVIDER", severity="high"))
continue
if published.retires != entry["lifecycle"]["retires"]:
findings.append(Finding(
name, "DATE_CHANGED", severity="high",
detail=f'{entry["lifecycle"]["retires"]} → {published.retires}',
))
days = (published.retires - today).days
for threshold in (180, 120, 90, 30, 14):
if days <= threshold:
findings.append(Finding(
name, f"T_MINUS_{threshold}", severity=SEVERITY[entry["tier"]],
owner=entry["owner"], detail=f"{days} days to shutoff",
))
break
stale = (today - entry["lifecycle"]["verified_at"]).days
if stale > 30 and not published.confirms(entry):
findings.append(Finding(name, "UNVERIFIED", severity="low", detail=f"{stale}d"))
return findingsThe reconciler's authority comes from reading the provider, so editing the
local date does not silence it — it produces a DATE_CHANGED finding instead,
which is louder. Its output goes to the named owner as a ticket that is created
once and updated thereafter, so the T-180 notice and the T-30 notice are the
same ticket getting more urgent rather than five duplicates in a backlog.
Escalating thresholds do the work the single sixty-day cliff could not: T-180,
meaning a hundred and eighty days before the shutoff, is a conversation with a
lead about next quarter, T-90 is a scheduled piece of work, and T-14 on a
revenue-tier pin is an incident.
What that authority actually rests on is where the reconciler's input is held,
and not on the schedule or the tickets, which are the parts that look like the
mechanism. The lifecycle data belongs to the provider, on the far side of a
boundary that nobody the reconciler can interrupt is able to write to, so no
action available inside your repository changes what lifecycle() returns. The
cheapest way to stop a finding is therefore to make the finding untrue: to run
the migration, or to correct the registry to match a date that genuinely moved.
Changing that one thing and leaving the rest alone is the quickest way to see it
is the load-bearing part. A reconciler on the same schedule, producing the same
tickets against the same escalating thresholds, that read the retirement date
out of config/models.yaml rather than off the provider, would die exactly as
the build check died, because the input would be back inside the boundary.
The escalating thresholds rest on a step of their own, which is that a deadline does not demand one job. It demands a different job at each distance from the date, and the jobs belong to different people. Far out, the only useful action is a decision about what a team spends a quarter on, and the person who can take it needs the question while the quarter is still unplanned. Closer in, that decision has been made and what remains is engineering that has to be scheduled against everything else in flight. Closest, there is no planning room left and the moves available are the ones made under an incident. One threshold can address exactly one of those, so it fires at the wrong distance for every pin except the one it was tuned against — early enough to be ignored on the cheap pins and late enough to be useless on the expensive ones.
The same step is why the notice is one ticket that grows more urgent rather than a fresh ticket at each threshold. What is being tracked is a single piece of work with a single owner, and its state at T-90 is its state at T-180 plus whatever happened in between; splitting it into separate rows discards that history and makes a backlog look like several small problems instead of one problem nobody has started. A ticket that has been reopened and re-prioritised repeatedly is itself evidence, and it is the kind of evidence a lead scanning a queue can act on without reading any of the detail.
The third is a genuinely hard gate, kept for the terminal case and scoped narrowly: a deploy of the affected service is blocked at T-14 if the migration has not completed, with an override that requires a named approver and writes an audit record. Reserving the blocking mechanism for the situation where blocking is proportionate is what keeps it credible; used at T-60 on every change in the monorepo, the single repository every team's work passes through, it becomes the thing people route around.
Both halves of the compliance-versus-escape ratio are visible in the contrast between those two placements. At T-14 on the affected service, the people interrupted are the people who own the migration, the work that clears the gate is work they were going to do anyway, and the escape costs a named approver's signature and leaves a record — escape exists, and it is both dearer than compliance and visible afterwards. At T-60 across the monorepo the interrupted party is unrelated to the work and compliance is not available to them at any price, so the only action they have is an escape. A gate whose sole satisfying action is an escape does not merely fail; it teaches everybody who meets it that escapes are the normal way to deal with policy.
| Mechanism | Who it interrupts | Cheapest way to satisfy | What it can detect | How it dies |
|---|---|---|---|---|
| Build fails 60 days before a listed date | Whoever pushes that morning, on any change | Edit the date field | Nothing the repo does not already claim | Edited date, or a bypass flag |
| Build fails on an unregistered model id | The author of the new call site, when they write it | Register it properly | Pins added without an owner | Rarely; compliance is the fast path |
| Reconciler against provider data | The named owner, by ticket, on a schedule | Fix the registry from the source | Dates that moved, entries never verified, ids the provider no longer knows | An untriaged ticket queue |
| Deploy gate at T-14, affected service only | The team that owns the service | Finish the migration, or file a signed override | The terminal case | Overrides becoming routine if fired too early |
| Weekly summary to leads | Nobody; it is information | Read it | Aggregate drift across all pins | Being ignored — which is at least visible |
The rows differ along the dimension that decides whether a policy survives its first bad Tuesday, which is the relationship between the cheapest satisfying action and the intended one. Where those coincide, the mechanism holds; where the cheapest action is to falsify the input or disable the check, it does not, regardless of how strict it looks on paper.
The decision stays with a person
None of this decides anything. The reconciler tells you a date moved and who owns the pin; it has no opinion on whether the successor is acceptable, which is chapter 4's per-slice question about whether an aggregate gain is hiding a collapse on one language or one tool schema, or on whether November is the right month to spend on it, which is a roadmap question. The most a mechanism can do is guarantee that a human decision happens while it is still cheap, and the machinery above is calibrated for exactly that — early, specific, addressed to a person, and hard to make go away without doing something real.
Models are not the only calendar
All of it stops at models. Deprecations run on separate calendars for API
versions, parameters, tool schema versions, and beta headers, and none of those
has a model field for the registry to key on. Each of them breaks a request
naming it exactly as thoroughly as a retired model id does. Extending the same
pattern to them is straightforward and usually not done. The Model Versioning:
Why, When, and How discussion is a good corrective here, because it puts
version policy where it belongs — as a product commitment about what you
promise to keep stable — rather than as a piece of build tooling.
Provider adapters break silently
The provider adapters are the part that ages badly. Four pins across three
providers means three lifecycle() implementations, and providers publish this
information in three different shapes: a models endpoint that returns
capability metadata but not always a retirement date, an HTML deprecations
table that changes layout, a changelog with no machine-readable form at all.
Some of those adapters will be a scrape, and a scrape breaks silently. The
defence is to make the reconciler treat "I could not read the provider" as a
finding of its own rather than as an absence of findings, so a broken adapter
surfaces as an UNKNOWN_TO_PROVIDER on every pin it covers instead of a
suspiciously quiet week.
"Silently" is carrying the whole argument in that paragraph, and unpacking it shows the same shape as the defect in the deadline check. A monitor communicates by producing findings. A healthy system produces none. A monitor that has lost sight of the system it watches also produces none. Those two states are identical in the output, so no amount of care in reading the report separates them, and a reader on an ordinary week settles on the one they were expecting.
That is also why a more reliable scrape is not the fix. Reliability lengthens
the interval between failures without changing what the output is able to
express, and the report goes quiet in precisely the same way the first time a
page's layout moves. What has to change is the set of things the reconciler
counts as an observation, so that reading the provider successfully and failing
to reach it stop producing the same output. UNKNOWN_TO_PROVIDER is that
change. It converts an absence into a claim, and a claim is something that can
be wrong, which is what makes it something a person can check.
A registry can also be satisfied without being useful. Five entries, all owned
by the same person, all with a verified_at from the day they were added, is a
list. What makes it ownership is that the reconciler runs, the tickets land on
people who have the authority to act, and somebody notices when a queue of
T-180 notices goes untouched for a month.
Three checks that fit in an afternoon
The whole topic collapses into a small number of things that are true of your service right now.
Grep for model strings and count them. Every one that is a bare family alias is either a deliberate drift-detection lane — the nightly eval arm or a named low-consequence cohort that records the served model id — or it is an unattributable change waiting to happen; there is no third category, and deciding which each one is takes minutes.
Check whether you are capturing enough to replay. If a regression appeared this weekend, could you rebuild last Tuesday's requests with their prompt version, their retrieval context, and the model that actually served them? If the answer is no, that is a two-day build that pays for itself the first time you need it.
That check is a question about rows rather than about intentions, and it is the only one of the three whose answer cannot be changed today. A pin can be moved into a registry this afternoon and a date can be read off a page this afternoon. A replay runs on requests that have already been served, and a request served without its prompt version, its retrieval context and its served model id recorded did not write those fields anywhere — capture cannot be applied backwards to traffic that is already gone. So the honest form of the check is to open a row from last Tuesday and see which fields are in it, and whatever gets built afterwards leaves that answer exactly where it was.
Then take the pins you found, put them in one file with an owner, a tier, a source URL, and a date read off the provider today, and do chapter 3's backwards arithmetic on the nearest one — the shutoff, walked back through qualification, a failed attempt, cutover, burn-in and a reserve.
The output of that afternoon is a single date on a single person's calendar, months before the shutoff. That date is the deliverable, and everything else in this topic exists to make sure somebody is standing there when it arrives, with a qualified successor, a sequenced cutover, and a rollback path that has not yet expired.
Model deprecation and version pinning — read the current version at https://daviddominguez.dev/learn/en/model-deprecation-and-version-pinning/required-field