Chapter 1 of 6 · 15 min read
Family aliases and dated snapshots
On this page
- Two strings in one repository
- The alias resolves on their side, the snapshot resolves on yours
- You have already had this argument about dependencies
- Rollback is a one-line diff, or it is a support ticket
- Two lanes where the alias is kept on purpose
- Sampling and the API surface move under a fixed id
- The prompt, the retrieval context and the traffic are still free
Every service that calls a hosted model names a version, whether or not anybody decided which one. That name settles two things at once — which weights answer today, and what happens on the day the provider stops serving them — and a team that has not thought about the first has usually not thought about the second either. This topic is about both halves: holding a version still long enough to attribute a change to it, and treating the expiry that comes attached as scheduled work rather than as an outage.
The unit both halves rest on is the model identifier, the string a request
carries in its model field to tell the provider which of its models should
answer. Providers publish two kinds. A family alias — claude-sonnet-4-5,
gpt-4o, gemini-2.5-flash — names a family and leaves the choice of version
within it to the provider, so the alias follows their current best as they ship
improvements. A dated snapshot id — claude-sonnet-4-5-20250929,
gpt-4o-2024-08-06 — names one set of weights that was frozen on the date in the
name and is never edited afterwards. Sending the snapshot id where you could have
sent the alias is version pinning: recording the exact version your service
runs, in your own configuration, so that it changes when you change it.
Two strings in one repository
The difference is easy to carry for months without noticing, because both forms sit in the same field and neither one announces itself. A single search turns them up:
$ rg -n --no-heading 'model=' services/ | rg -i claude
services/extract/client.py:41: model="claude-sonnet-4-5-20250929",
services/summarise/client.py:28: model="claude-sonnet-4-5",Both strings are valid. Both return a 200. Both bill at the same published rate, and in code review nobody has ever flagged the difference, because the second one looks like a tidier version of the first. It becomes visible on the day the provider moves the alias, at which point one of these services keeps running the weights it was qualified against and the other quietly changes model in production without a deploy, a ticket, or a line in a changelog.
Pinning exists because the mechanism that hands you improvements is the same one that takes stability away. Models improve, and providers deliver those improvements by moving aliases under running services, which arrives as a free upgrade for anyone who was not measuring and as an unexplained quality change for anyone who was. A pin holds one version still, so an improvement becomes something you adopt on a date you chose. The pin also acquires an expiry that a locked library version never has, because model deprecation — the provider announcing that a version will stop being served, then eventually switching it off — is what makes a pin something you tend rather than something you set once.
The alias resolves on their side, the snapshot resolves on yours
What separates the two forms in practice is when the choice of weights gets made, and who is in the room when it happens. An alias is a pointer the provider maintains, so resolution happens inside their routing layer at request time, on a schedule they publish and control. A snapshot id resolved in your config file at the moment somebody typed it, and everything downstream — which weights answer, when that changes, and whose commit changed it — is a consequence of that one decision.
Both forms are resolved; the question is only where the resolution happens and whether it leaves a record. When your request carries an alias, the string travels to the provider, their routing layer looks it up in a table they maintain, and the lookup produces a snapshot id. Every request performs that lookup again, so a change to the table takes effect on the next request with no deployment anywhere and no event on your side. When your request carries a snapshot id there is no table to consult, because the string already names the thing. The lookup did happen — somebody read a model list, chose an entry and typed it — but it happened once, in a pull request, months ago, and the result of it is sitting in your repository where a diff can find it.
The resolution is not hidden from you, it is just not in your request. Providers echo the model that actually served the call back on the response, and that field is where an alias move shows up first:
{
"id": "msg_01Qk4mZr8vTn6cWpLdA3xEyH",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5-20250929",
"content": [{ "type": "text", "text": "{\"invoice_total\": \"1284.50\", …}" }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 1841, "output_tokens": 96 }
}The model field on that response is the only trustworthy record of which
weights answered. If your request body said claude-sonnet-4-5 and the response
says claude-sonnet-4-5-20250929, you have learned the current target of the
alias — and if next Tuesday the same request comes back naming a different
snapshot, you have learned that the pointer moved. Nothing in your codebase
changed; you have to read it off the wire or you do not know.
The reason that field outranks anything in your own configuration is a distinction between two kinds of statement. Your request says what you asked for, which is a fact about your intention. The response reports what was done, which is a fact about their system, and a resolution step performed by somebody else sits between the two. Logging only what you sent therefore records the half of the transaction that could not have surprised you. On a fully pinned service the two halves agree and the extra field looks redundant — until a service you did not write, or a default in a client library, sends an alias on your behalf, at which point the echoed value is the only line in your logs that knows.
Snapshot naming
conventions differ by provider and are worth checking rather than assuming: some
families expose an explicit date suffix, some publish fixed non-dated ids that
are themselves stable, and Vertex AI uses an @ separator where the first-party
API uses a hyphen. The Anthropic Claude Model Migration Guide is a useful
cross-check on which form is a snapshot and which is a moving pointer for a given
family.
| Family alias | Dated snapshot | |
|---|---|---|
| Who changes what it points at | The provider, on their schedule | You, in a config change |
| Notice you get | A blog post or changelog entry you may not read | A pull request with your name on it |
| Rollback | Nothing to revert; you can only pin forward to whichever snapshot is still available | git revert, redeploy |
| Incident attribution | "Possibly the model" stays a hypothesis | The version is a value in a diff with a timestamp |
| Failure mode at end of life | Silent replacement, then eventually a 404 | A 404 on a date you were told in advance |
| What it buys you | Free upgrades, and early sight of provider drift | Stability, and a migration you schedule |
The rows sort into a shape worth naming: an alias trades attribution for currency, and a snapshot trades currency for attribution. Neither column is the safe one in general. The column that is right depends entirely on whether, when the output changes, you need to be able to say why.
You have already had this argument about dependencies
None of this is a new category of problem, which is useful because the industry
has already fought the argument out somewhere else and landed on an answer.
FROM ubuntu:latest in a Dockerfile builds a different image in March than it
did in January; FROM ubuntu:24.04@sha256:1e3f… does not. A dependency range of
^4.2.0 picks up whatever the registry serves at install time; a lockfile
records the exact version and the hash. A CNAME resolves to whatever the target
points at when the query is made; an A record with a literal address does not. In
every one of those cases the ecosystem started with the convenient floating form,
discovered that unreproducible builds are unattributable builds, and converged on
pinning by default with deliberate, scheduled updates.
Model ids are the same mechanism with two differences that make the argument
sharper rather than weaker. The first is that the floating form is the one most
documentation and most quickstarts show, so the default in a new service is the
unpinned one — the opposite of the dependency ecosystem, where npm install
writes a lockfile without being asked. The second is that a pinned model version
expires. A container digest works forever; a snapshot id stops resolving on a
published date, which means the discipline that dependency management taught
cannot be adopted wholesale. Pinning a model is closer to pinning a dependency
that the registry will delete in nine months, and that difference is what the
rest of this topic is about.
The analogy is also worth not over-extending. A lockfile records a hash you can verify; a model snapshot id is a name you trust the provider to keep meaning the same thing, and there is no digest you can check it against. What you get is a commitment rather than a proof, and the response echo on every call is the closest thing available to verification.
Rollback is a one-line diff, or it is a support ticket
The practical argument for pinning is what happens in the forty minutes after somebody says "extraction looks off". On a pinned service the model version lives in the same config the rest of the service is deployed from:
# config/models.yaml
extraction:
provider: anthropic
model: claude-sonnet-4-5-20250929
previous: claude-sonnet-4-5-20250514
max_tokens: 2048Reverting is a one-line edit, a merge, and whatever your deploy takes — twelve
minutes on a normal Thursday. Just as importantly, the presence of that line
makes the sentence "nothing changed this week" into a claim somebody can verify
rather than a thing people say in a channel.
git log --since='7 days' config/models.yaml either returns commits or it does
not, and that answer costs a second to obtain.
The reason that matters more than it sounds is that the floating service cannot make the same sentence mean anything. "Nothing changed" is a claim about the absence of an event, and an absence can only be checked against a place where the event would have been recorded. On a pinned service that place exists and is the file above. On a floating service the event — the provider retargeting the alias — leaves no trace on your side at all, so the sentence is not false, it is unfalsifiable, and an unfalsifiable claim in an incident channel is worse than no claim because everyone treats it as evidence.
On the floating service there is nothing to revert. The change you are trying to undo was never in your repository. Your options are to pin forward to a snapshot you have not qualified, pin backward to the snapshot the alias used to point at if it is still being served, or open a support ticket and wait. All three are worse than a revert, and all three are being chosen under time pressure by someone who does not yet know whether the model is even the cause.
There is a second-order effect that matters more than the revert itself. When the
version is a value you set, every incident timeline gets an entry it could not
otherwise have. Your log lines carry model_requested and model_served, your
deploy history carries the config change, and the two can be joined. When the
version is a pointer somebody else moves, the timeline has a gap exactly where
the explanation should be, and gaps in incident timelines get filled with
whatever theory is loudest.
Two lanes where the alias is kept on purpose
The tempting conclusion is that aliases are a mistake and a codebase should contain exactly one model string, pinned. That answer is clean, it sounds disciplined, and it throws away the one thing an alias is actually good for, which is telling you that the provider moved. A shop that pins everywhere and never runs the alias learns about the successor's behaviour on the day its pin is retired, with no history, no comparison, and no time.
The rule that survives contact with a real system is narrower: float only where a change can be attributed. That admits exactly two lanes.
The first is the scheduled job whose whole point is noticing drift. A nightly eval already runs a fixed set of inputs through a fixed prompt and scores the outputs; run it twice, once against the pinned production snapshot and once against the alias, and the diff between those two scores is a continuous, dated readout of how the provider's current best differs from what you are serving. Nothing user-facing depends on the alias arm, so a surprise costs you a graph, not a customer.
ARMS = {
"pinned": "claude-sonnet-4-5-20250929", # what production serves
"floating": "claude-sonnet-4-5", # whatever the provider points at today
}
def nightly_eval(cases):
for arm, model_id in ARMS.items():
for case in cases:
resp = client.messages.create(
model=model_id,
max_tokens=2048,
system=PROMPT_TEMPLATES[case.template], # same template both arms
messages=case.messages,
)
record(
run_id=RUN_ID,
arm=arm,
case_id=case.id,
model_requested=model_id,
model_served=resp.model, # off the response, never off config
output=resp.content[0].text,
score=case.score(resp.content[0].text),
)Recording model_served alongside model_requested is what turns the floating
arm from a curiosity into evidence. Two months later, when someone asks when the
alias moved, the answer is a GROUP BY over the eval table.
The second lane is a named, low-consequence slice of user-serving traffic. Not a percentage sampled at random across everything, which spreads an unqualified model thinly over every surface you have, but one surface chosen because a bad answer there is recoverable. That cohort runs the alias, records the served model id on every response, and stores it on the row with the output. The pinned majority provides the comparison. The instrumentation is small and it is the entire point of the lane:
SELECT date_trunc('day', created_at) AS day,
model_served,
count(*) AS n,
avg(accepted::int) AS accept_rate,
percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_ms
FROM tag_suggestions
WHERE created_at > now() - interval '30 days'
GROUP BY 1, 2
ORDER BY 1;A row appearing with a model_served value nobody has seen before is the alias
moving, timestamped, with an acceptance rate and a latency profile attached. That
is a very different position to be in than reading a changelog.
Sampling and the API surface move under a fixed id
Pinning fixes the weights, and treating that as a guarantee of stability leads to a second class of confusion. The same snapshot answering the same prompt twice can produce different text, because sampling, batching, hardware, and kernel versions all vary underneath a stable model id. Pinning removes one source of variance and leaves the others in place, which makes a pinned service attributable rather than reproducible.
It is worth being exact about what the id fixes and what it leaves free, because the gap between those two is where most of the surprise lives. What a dated snapshot pins is one specific set of trained numbers — the weights, which are what the training run produced and which nobody edits afterwards. What it leaves free is everything that stands between those numbers and the text you receive. The picking step that turns the model's scores into an actual next fragment is allowed to be random, so two identical requests can diverge with nothing at all having changed. The arithmetic underneath is affected by how many requests were processed together, because a sum of many numbers depends on the order they are added in when each addition rounds, and batch composition is decided by the provider's scheduler according to who else is calling at that instant. The hardware a request lands on, and the kernels compiled for it, are theirs to change. So a pinned id names the one component that cannot move and says nothing about the several that can.
Two consequences follow, and they are the reason the distinction is worth carrying. The first is that a difference between two outputs is not evidence of a model change, which is why the next chapter builds an instrument rather than comparing two responses. The second is what pinning is actually for. It does not give you a fixed output; it gives you one fewer free variable, and the variable it removes happens to be the one you cannot otherwise observe, since you can see your own prompt changes in a diff and you can see your traffic in a dashboard, and you cannot see an alias move at all. That is the whole of the difference between attributable and reproducible, and a team that expects the second from a pin will conclude the pin is not working.
Nor does a pin freeze the API surface around the model. Providers change which parameters are accepted, tighten validation, and adjust defaults on schedules unrelated to the weights — a request shape that worked last quarter can start returning a 400 without any model change at all. The safety and routing layers that sit in front of the model move on their own schedule too, and can decline a request the weights would have answered. None of that appears in a diff of your configuration.
The prompt, the retrieval context and the traffic are still free
The model version is one of several things that determine an output, alongside the prompt template, the retrieval context, the tool schemas, and the input distribution itself. Pinning is the first variable you get under control, and on its own it does not let you attribute anything, because it leaves the others free to move. A team that pins and then reads every quality change as a model change has swapped one confident wrong answer for another.
The last thing a pin does not do is avoid the migration. A snapshot has a retirement date; when that date arrives the id stops resolving and every request naming it fails, which is a worse Tuesday than a quality regression if nobody scheduled the work. What pinning buys is that the change happens on a date you can read in advance rather than one you discover from a graph. It converts an unscheduled quality event into a scheduled piece of engineering, and the value of that conversion is entirely conditional on somebody doing the engineering.
Two strings that look like versions of the same thing are two different agreements about who decides. The snapshot is a decision you made and can revisit; the alias is a decision the provider makes on a schedule you read about afterwards, if at all. Pinning by default is what makes "nothing changed this week" a claim somebody can check, and keeping a small number of deliberate floating lanes — each one recording the version that actually served — is what stops the pin from also making you blind to what the provider has been shipping.
That is enough to make a model change visible. It is not enough to make one provable. The next chapter takes the case where the alias did move, over a weekend, at the same time as two other things, and shows why the obvious experiment — pinning back and watching the error rate — cannot answer the question everyone will believe it answered.
Model deprecation and version pinning — read the current version at https://daviddominguez.dev/learn/en/model-deprecation-and-version-pinning/family-aliases-and-dated-snapshots