Three Candidates, One Champion: Registry Stages and the Promotion Policy That Enforces Them
A retrain finished overnight and improved AUC by 0.004. Two more candidates from last week sit in a models/ folder on the training box named xgb_v3_final_FINAL2.pkl. Nobody knows which file the serving pods load, and last month's rollback took ninety minutes of archaeology. A model registry fixes this by making promotion a tracked state transition instead of a file copy — and the first thing to design is not the tool, it is the policy.
The vocabulary, using MLflow (the de-facto standard; SageMaker Model Registry and Vertex AI Model Registry express the same states):
| Stage | Meaning | Who writes it | Who reads it |
|---|---|---|---|
None |
Registered but unvetted | training job (auto) | nobody |
Staging |
Candidate under evaluation | training job or author | eval harness, QA |
Production |
Serving traffic reads this | CI/CD gate only | serving pods |
Archived |
Retired, kept for lineage/rollback | promotion or decay policy | audits, rollback |
Registering a run's model is one call at logging time:
import mlflow
from mlflow.tracking import MlflowClient
mlflow.set_experiment('churn-classifier')
with mlflow.start_run(run_name='xgb-2025-10-14') as run:
mlflow.log_params({'max_depth': 6, 'lr': 0.05, 'n_estimators': 400})
mlflow.log_metrics({'auc': 0.9132, 'logloss': 0.318})
mlflow.log_tag('git_sha', git_sha())
mlflow.log_artifact('feature_schema.json')
info = mlflow.sklearn.log_model(model, artifact_path='model',
registered_model_name='churn-classifier')
run_id = run.info.run_id
client = MlflowClient()
client.transition_model_version_stage(
name='churn-classifier', version=4, stage='Staging')
Newer MLflow steers toward aliases instead of stages — a named pointer that does not occupy a lifecycle slot:
client.set_registered_model_alias('churn-classifier', 'champion', version=4)
client.set_registered_model_alias('churn-classifier', 'challenger', version=5)
# serving never loads a version number:
model = mlflow.pyfunc.load_model('models:/churn-classifier@champion')
Serving must resolve by alias, never by hardcoded version. The consequence is your rollback story: a bad release is fixed by pointing champion back to the previous version — seconds, no redeploy, no archaeology. An alias is mutable; a version is immutable. That pairing (append-only versions, mutable pointers) is exactly the design Git uses, and it is why models:/churn-classifier@champion is the only model reference that belongs in serving code.
The promotion policy is where interviews separate people who used a registry from people who designed one. Four rules, each with a reason:
- Only automation may move a model into Production. Humans (and training jobs) may register and stage; the gate that flips
championis the CI job that re-ran the evaluation harness and compared against the incumbent. This makes promotion reproducible and auditable — the same reason only bots push tomain. - Promotion requires beating the incumbent on a fixed eval suite, not just on a training metric: golden datasets, slice metrics (the fraud class you cannot afford to lose), and fairness/regression checks. A +0.004 average AUC that costs −0.03 on your priority segment is a loss.
- Never two producers of truth.
transition_model_version_stage(..., archive_existing_versions=True)archives whatever was in Production so exactly one version holds the stage (or one alias value per name). Drift between "the Production model" and "the model actually serving" is the failure this prevents. - Every transition carries a note and an actor — who, when, why, tied to a ticket or run id.
client.set_model_version_tag(v, 'approval', 'PR-482')is cheap, and the audit trail is the deliverable your future self and your auditors share.
What the registry deliberately does not do: hold training code, retrain models, or validate them. It holds versions of artifacts with metadata and a lineage pointer. The version numbers themselves need a semantics — when does a new version merit a new major number, versus a patch — which is the next chapter's question.
Is the New Model a Major, Minor, or Patch? Version Semantics for Artifacts
The API team asks whether they can deploy model version 7. Without a versioning contract, the answer is a shrug — and a shrug means every consumer must read your release notes to know whether their client code still works. Models are not source code, but their version numbers carry the same question: what did this change break for whoever depends on the artifact?
Borrow semantic versioning and re-define it for models, in terms of the inference contract (feature schema: names, types, order, ranges) and behavior:
| Bump | Trigger for a model | Consumer impact | Example |
|---|---|---|---|
| MAJOR (x) | Inference contract breaks | clients must change before upgrading | features added/removed, dtype change, categorical encoding change, output meaning change (probability → score) |
| MINOR (x.y) | New model, same contract | drop-in; metrics improve | scheduled retrain on fresh data, same features and target |
| PATCH (x.y.z) | No behavior change at all | none | metadata/description fix, re-export to a new format, docstring, model-card update |
Concrete cases that decide real debates:
- Retrained nightly on the same pipeline, AUC 0.9132 → 0.9151: MINOR. The contract is identical; consumers upgrade by flipping the alias.
- Added a
device_typefeature so the model can score new rows: MAJOR. Old clients do not send it; loading this model behind their requests produces silent wrongness, which is exactly what a major bump exists to prevent. - Re-exported the same weights from joblib to ONNX with verified parity (the golden-batch check from Model Serialization and Packaging): PATCH — same behavior, different bytes.
- Changed the positive class from
churned_30dtochurned_7d— output means something else now: MAJOR, even though every input feature is unchanged.
Two alternative schemes deserve knowing because you will meet them. Calendar versioning (2025-10-14) suits scheduled retrains: the number states provenance rather than compatibility, and it works only while retrains never break contracts. Content-addressed versions (sha256 of the artifact, or a runs:/ URI) give absolute immutability — no ambiguity about which bytes a name refers to — and are ideal as the underlying identity over which human-readable aliases float.
Whatever the top-level scheme, two invariants hold underneath it:
- Registry versions are immutable and append-only. If the bytes at version 4 change, every historical claim ("version 4 served in Q2 with AUC 0.9132") becomes a lie. Fix forward with a new version; never overwrite an artifact — MLflow does not let you, and that constraint is a feature of the design, not a limitation.
- The version number is not the deployment handle. Consumers pin an alias (
champion) or a stage; versions record history. Code that hardcodesmodels:/churn-classifier/4couples a deployable service to a run of numbers and bypasses the alias layer that made rollback cheap.
The mapping between schemes and systems, since teams routinely mix them:
git commit sha -> code that produced the model (via run tags)
data snapshot hash -> the training data (DVC / lakehouse version)
run id -> hyperparameters, metrics, logs (experiment tracker)
registry version -> the artifact + stage/alias (model registry)
semantic tag -> what changed for consumers (your convention)
That five-line ledger is the whole answer to "how do you version ML models?" — each layer names a different dependency, and a registry entry that links all five is what makes the next chapter's lineage questions answerable.
Audit Question: Which Run, Which Data, Which Metrics Produced This Model?
Eight months from now someone asks the only questions that matter: the Production model is degrading — which training run produced it, on what data snapshot, with which hyperparameters, and what were its acceptance metrics when it shipped? If those answers require searching Slack, the registry is decoration. A registry entry is only as good as the lineage it carries, and lineage is something you attach at training time, not something you reconstruct during an incident.
The minimum viable lineage per registered version:
with mlflow.start_run() as run:
mlflow.log_param('max_depth', 6)
mlflow.log_metric('auc', 0.9132)
mlflow.log_tag('git_sha', subprocess.check_output(
['git', 'rev-parse', 'HEAD']).decode().strip())
mlflow.log_tag('data_snapshot', 'lake://churn_features@2025-10-13')
mlflow.log_artifact('feature_schema.json') # the inference contract
mlflow.sklearn.log_model(pipe, artifact_path='model',
registered_model_name='churn-classifier',
signature=mlflow.models.infer_signature(X_demo, preds),
input_example=X_demo.head(3))
# later, during an incident — one call from the running model's metadata:
version = client.get_model_version('churn-classifier', '4')
run = mlflow.get_run(version.run_id)
print(run.data.params, run.data.metrics, run.data.tags['git_sha'])
print(version.source) # runs:/<run_id>/model — the artifact's exact bytes
Read that round trip as the acceptance test for your registry setup: from a version number alone you can name the run, the code revision, the data snapshot, the schema, and the bytes (runs:/<run_id>/model — immutable, referenced by URI rather than copied). signature and input_example matter more than they look: they make the registry self-describing, and MLflow serving validates incoming requests against the logged signature, turning a schema mismatch into an explicit error instead of a silent bad prediction.
The lifecycle of an entry, and the hygiene rules that keep the system trustworthy:
| Event | Rule |
|---|---|
| Training finishes | register the model automatically with run id, sha, data tag — no manual uploads |
| Enters Staging | eval harness writes its report as an artifact and tags the version |
| Promotes to champion | CI re-runs the harness against the incumbent; only CI writes the alias, with a PR/ticket tag |
| Served | serving logs the resolved version per prediction — you must be able to say which model answered last Tuesday |
| Superseded | old alias value overwritten; versions never deleted, older stages archived |
| Incident | champion alias re-pointed to the previous version — rollback is a pointer move |
Three failure modes the rules counter directly. Manual artifact uploads ("I'll register the file from my laptop") sever the run-to-version link — the artifact is now unattributable, and the next person cannot reproduce it. A latest-style alias that floats to whatever was registered most recently means a retrain by one engineer silently becomes everyone's Production candidate; aliases must be explicit, deliberate pointers. And evaluating at Staging but not re-evaluating at promotion lets a model that failed a new data slice slip through, because the world moved between staging and promotion — the gate must run against current data.
For the interview, compress the whole topic into the sentence a senior engineer wants to hear: every model version is an immutable artifact linked to the run that produced it, the data snapshot it saw, the code SHA that trained it, and the metrics that justified its stage — and serving resolves models by alias so promotion and rollback are pointer moves, audited by the same CI that gates the release. With versions tracked and lineage attached, the remaining question is operational: how the chosen version gets in front of requests with a latency budget — the subject of Online and Batch Serving.
Quiz
1. Why should serving code load models via models:/churn-classifier@champion instead of models:/churn-classifier/4?
2. A new model version adds a device_type feature to the input schema but leaves all other features and the target unchanged. What version bump does the contract-based scheme demand?
3. An engineer registers a model by uploading model.pkl from their laptop instead of logging it from the training run. What is the core damage?
4. What does the transition policy require before a model moves into Production (the champion alias)?
Flashcards
Question
What do the registry stages None/Staging/Production/Archived mean, and how do MLflow aliases relate to them?
Click to reveal answer
Answer
None: registered, unvetted. Staging: under evaluation by the harness. Production: the version serving traffic. Archived: retired but retained for lineage and rollback. Aliases (champion/challenger) are newer, movable named pointers that express the same roles without occupying lifecycle slots — often preferred because several aliases can coexist and alias transitions are lightweight and unambiguous.
Question
State the four rules of a model promotion policy and the reason for each.
Click to reveal answer
Answer
1) Only automation moves models into Production — reproducible and auditable. 2) Promotion must beat the incumbent on a fixed evaluation suite including slice/regression checks — a small average gain can hide a segment loss. 3) Never two producers of truth — archive the previous Production version so exactly one holds the stage/alias. 4) Every transition records actor, reason, and ticket/run — the audit trail is the deliverable.
Question
Apply the model semver contract to three cases: nightly retrain (AUC 0.913 -> 0.915); added one input feature; same weights re-exported to ONNX with verified parity.
Click to reveal answer
Answer
Nightly retrain: MINOR — same inference contract, better metrics, drop-in for consumers. Added feature: MAJOR — old clients no longer match the schema and would silently degrade. Re-export with parity: PATCH — same behavior in new bytes, no consumer impact. The axis is always the consumer's contract, never the metric delta.
Question
What five identifiers form complete model lineage, and which system owns each?
Click to reveal answer
Answer
Git commit SHA → the training code (tagged on the run); data snapshot hash/URI → the training data (lakehouse version or DVC); run id → hyperparameters, metrics, logs (experiment tracker); registry version → the immutable artifact plus stage/alias (model registry); semantic tag (major/minor/patch) → what changed for consumers (team convention). A registry version linking all five makes any incident question answerable in one call.
Question
Why log a model signature and input example at registration time?
Click to reveal answer
Answer
They make the registry entry self-describing: consumers see the expected feature names, types, and shapes without reading code, and MLflow serving validates incoming requests against the signature — a schema mismatch becomes an explicit error instead of a silently wrong prediction. The input example also documents the preprocessing contract alongside the artifact it applies to.
Revision Notes
Key Takeaways
- 1. The registry tracks versions, stages, and metadata — and only automation may promote into Production, so every promotion is reproducible and auditable
- 2. Serving resolves models by alias (champion), making promotion and rollback pointer moves; hardcoded version numbers in service code bypass the safety layer
- 3. Model semver is judged by the inference contract: broken schema = MAJOR, retrain with same schema = MINOR, no behavior change = PATCH
- 4. Lineage is captured at registration time — run id, git SHA, data snapshot, signature — because it cannot be reconstructed during an incident
- 5. Versions are immutable and append-only; a registry entry whose artifact can be replaced makes every historical metric claim meaningless
Interview Tips
- • "How do you roll back a bad model?" — the serving path resolves the champion alias, so rollback is repointing the alias to the previous version, seconds with no redeploy; add that the previous version was never deleted and the eval report justified its original promotion
- • "How do you decide major vs minor for a model release?" — name the axis (the consumer's inference contract), give one example of each bump, and mention that metric improvements alone never justify a major
- • "What belongs in a registry entry?" — the artifact (immutable), stage/alias, run id linking params/metrics/logs, git SHA, data snapshot, signature and input example, plus who promoted it and why; state that all of it is attached at training time
- • "How do you know the Production model is the one you approved?" — alias-based resolution plus per-request logging of the resolved version, and a CI gate as the only writer of the champion pointer; this closes the gap between what is registered and what is serving
Cheat Sheet
Model Registry and Versioning
Stages / aliases:
| Stage | Meaning | Written by |
|---|---|---|
| None | registered, unvetted | training job |
| Staging | under evaluation | author / job |
| Production | serving traffic | CI gate only |
| Archived | retired, kept for lineage/rollback | promotion or policy |
mlflow.sklearn.log_model(pipe, artifact_path='model',
registered_model_name='churn-classifier',
signature=sig, input_example=X_demo.head(3))
client.transition_model_version_stage('churn-classifier', 4, 'Staging')
client.set_registered_model_alias('churn-classifier', 'champion', 4)
model = mlflow.pyfunc.load_model('models:/churn-classifier@champion') # serving path
Promotion policy: CI-only into Production · beat the incumbent on a fixed eval suite (slices + regressions) · archive_existing_versions=True / one alias value · every transition tagged with actor, reason, ticket.
Version contract (axis = consumer's inference contract):
- MAJOR: feature add/remove/type change, output meaning change
- MINOR: same contract, new retrain, better metrics
- PATCH: no behavior change (metadata, re-export with verified parity)
Registry versions are immutable and append-only; aliases are the mutable pointers serving resolves. Rollback = re-point the alias.
Lineage per version: git SHA + data snapshot URI + run id (params/metrics/artifacts) + signature/input example + semantic tag — attached at training time, verified by reading it back from the version alone.