Turn an evaluated LLM change into an immutable release bundle, promote it through measured traffic, and roll back without losing lineage.
A precision candidate can stay blocked until a target-GPU profile proves speed and stability. Suppose that missing profile now passes. You still don't have something safe to deploy. A live answer can change because of weights, an evidence gate, a prompt, a tokenizer, a policy corpus, a container image, or a schema.
For the incident-response assistant you've been building, deployment means answering one exact question: which complete, evaluated release produced this response, and how quickly can traffic return to the last known good release?
The service now contains an answer model plus the runbook-evidence-classifier-v2 gate trained in the previous lesson. That gate blocks answers whose incident claim isn't supported by a runbook excerpt. If you roll back only its weights but leave a newer prompt, policy, or corpus active, you haven't restored previous behavior.
This is the release-bundle idea: store every input that can change visible output or operational safety in one immutable manifest. Keep the declared evaluation contract there too, then attach the resulting report artifact to the promotion decision. ML systems accumulate hidden dependencies between data, code, configuration, and serving infrastructure; deployment records need to make those dependencies reviewable.[1][2]
| Bundle field | Why it belongs in the release |
|---|---|
| Answer-model and evidence-gate identifiers | Determine model behavior |
| Training run and precision policy | Explain how the new gate was produced |
| Tokenizer and prompt version | Change the text the model sees |
| Policy and corpus versions | Decide which evidence is available and when it's sufficient to serve |
| Serving image and schema | Change runtime behavior and API compatibility |
| Evaluation-suite hash and evaluator version | Declare the comparable scoring contract required before promotion |
Start with two full bundles. stable represents what's serving today. candidate changes the evidence gate after the profiled BF16 training run has passed outside the previous lesson's blocked fixture.
1from dataclasses import asdict, dataclass, replace
2import hashlib
3import json
4
5@dataclass(frozen=True)
6class ReleaseBundle:
7 service: str
8 answer_model: str
9 evidence_gate: str
10 gate_training_run: str
11 precision_policy: str
12 tokenizer: str
13 prompt_version: str
14 policy_version: str
15 corpus_version: str
16 serving_image: str
17 input_schema: str
18 eval_suite: str
19 evaluator_version: str
20
21stable = ReleaseBundle(
22 service="incident-evidence-answerer",
23 answer_model="answer-model@sha256:3b07",
24 evidence_gate="runbook-evidence-classifier-v1@sha256:0f12",
25 gate_training_run="run_fp32_baseline",
26 precision_policy="fp32",
27 tokenizer="incident-tokenizer@sha256:91aa",
28 prompt_version="[email protected]",
29 policy_version="[email protected]",
30 corpus_version="incident-runbooks@sha256:corpus-5",
31 serving_image="registry.example/answerer@sha256:image-a",
32 input_schema="incident-answer.v2",
33 eval_suite="incident-grounding-suite@sha256:suite-7",
34 evaluator_version="claim-evidence-eval-v2",
35)
36
37candidate = replace(
38 stable,
39 evidence_gate="runbook-evidence-classifier-v2@sha256:41d8",
40 gate_training_run="run_bf16_profiled_target_gpu",
41 precision_policy="bf16",
42 prompt_version="[email protected]",
43 serving_image="registry.example/answerer@sha256:image-b",
44)
45
46print(f"stable_gate={stable.evidence_gate}")
47print(f"candidate_gate={candidate.evidence_gate}")
48print(f"candidate_training_run={candidate.gate_training_run}")
49print(f"candidate_precision={candidate.precision_policy}")1stable_gate=runbook-evidence-classifier-v1@sha256:0f12
2candidate_gate=runbook-evidence-classifier-v2@sha256:41d8
3candidate_training_run=run_bf16_profiled_target_gpu
4candidate_precision=bf16A version name such as v2 is useful for people, but it doesn't prove contents stayed fixed. The next cell derives identity from the canonical manifest. Change a prompt or image digest and the release ID changes too.
1def release_id(bundle: ReleaseBundle) -> str:
2 payload = json.dumps(asdict(bundle), sort_keys=True, separators=(",", ":"))
3 # Short prefix keeps the teaching output readable. Retain the full digest in production.
4 digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
5 return f"{bundle.service}@sha256:{digest}"
6
7stable_id = release_id(stable)
8candidate_id = release_id(candidate)
9prompt_patch_id = release_id(replace(candidate, prompt_version="[email protected]"))
10
11print(f"stable={stable_id}")
12print(f"candidate={candidate_id}")
13print(f"prompt_patch={prompt_patch_id}")
14print(f"prompt_patch_is_new_release={prompt_patch_id != candidate_id}")1stable=incident-evidence-answerer@sha256:026746ee8fb8
2candidate=incident-evidence-answerer@sha256:fa60321b1dce
3prompt_patch=incident-evidence-answerer@sha256:e016f9a2b187
4prompt_patch_is_new_release=TrueThis lab abbreviates each SHA-256 digest to 12 hexadecimal characters so the state transitions stay readable. A production registry should retain the full digest as identity and use short prefixes only for display.
A registry record stores an immutable bundle. An alias such as production or canary is a mutable pointer used by traffic. This separation is what makes rollback simple: preserve both bundles and move the pointer back.
MLflow's current Model Registry workflow provides version aliases and tags, and its documentation marks fixed Model Stages as deprecated. That distinction matters: a model version is history; an alias expresses the current deployment decision.[3]
An MLflow model alias still points to one registered model version, not automatically to the prompt, corpus, policy, schema, and serving image in the release bundle. Treat it as one component pointer, or register a wrapper artifact whose manifest resolves the complete bundle. Don't mistake a movable model alias for complete release identity.
The small registry below enforces that rule. register() keeps a deep copy of the bundle, and move_alias() only points at a registered ID.
1from copy import deepcopy
2
3class ReleaseRegistry:
4 def __init__(self) -> None:
5 self._bundles: dict[str, ReleaseBundle] = {}
6 self._aliases: dict[str, str] = {}
7
8 def register(self, bundle: ReleaseBundle) -> str:
9 bundle_id = release_id(bundle)
10 existing = self._bundles.get(bundle_id)
11 if existing is not None and existing != bundle:
12 raise ValueError("release digest collision")
13 self._bundles[bundle_id] = deepcopy(bundle)
14 return bundle_id
15
16 def move_alias(self, alias: str, bundle_id: str) -> None:
17 if bundle_id not in self._bundles:
18 raise KeyError(f"unregistered release: {bundle_id}")
19 self._aliases[alias] = bundle_id
20
21 def resolve(self, alias: str) -> str:
22 return self._aliases[alias]
23
24registry = ReleaseRegistry()
25assert registry.register(stable) == stable_id
26assert registry.register(candidate) == candidate_id
27registry.move_alias("production", stable_id)
28
29print(f"registered={len(registry._bundles)}")
30print(f"production={registry.resolve('production')}")
31print(f"candidate_registered={candidate_id in registry._bundles}")1registered=2
2production=incident-evidence-answerer@sha256:026746ee8fb8
3candidate_registered=TrueThe teaching registry keeps move_alias() deliberately small. A production control plane should make that update atomic, record the expected previous target, and reject a stale promotion if another rollout moved the alias first.
Registration isn't approval. Continuous delivery for machine learning adds evaluation gates and monitoring to ordinary build-and-deploy practices because a valid artifact can still produce unacceptable behavior.[4]
In this running system, don't introduce a generic "quality score" after spending several lessons defining a grounded evidence contract. The release gate should use the same measurements the incident, evaluation, and experiment lessons established:
supported_evidence_f1 measures whether supported incident claims are served correctly.unsupported_serve_count must remain zero in the frozen high-risk suite.p95_latency_ms (p95 latency) prevents a behaviorally acceptable gate from breaking the response budget.evaluation_report preserves the report artifact a reviewer or incident responder can inspect later.
1@dataclass(frozen=True)
2class OfflineEvidence:
3 release_id: str
4 eval_suite: str
5 evaluator_version: str
6 input_schema: str
7 evaluation_report: str
8 supported_evidence_f1: float
9 unsupported_serve_count: int
10 p95_latency_ms: int
11
12@dataclass(frozen=True)
13class Decision:
14 allowed: bool
15 reason: str
16
17def offline_gate(bundle: ReleaseBundle, evidence: OfflineEvidence) -> Decision:
18 if evidence.release_id != release_id(bundle):
19 return Decision(False, "evidence belongs to another release")
20 if evidence.eval_suite != bundle.eval_suite:
21 return Decision(False, "evaluation suite changed")
22 if evidence.evaluator_version != bundle.evaluator_version:
23 return Decision(False, "evaluator version changed")
24 if evidence.input_schema != bundle.input_schema:
25 return Decision(False, "schema mismatch")
26 if not evidence.evaluation_report:
27 return Decision(False, "evaluation report missing")
28 if evidence.supported_evidence_f1 < 0.92:
29 return Decision(False, "supported_evidence_f1 below 0.92")
30 if evidence.unsupported_serve_count != 0:
31 return Decision(False, "unsupported answer was served")
32 if evidence.p95_latency_ms > 500:
33 return Decision(False, "p95 latency exceeds 500 ms")
34 return Decision(True, "offline gate passed")
35
36candidate_offline = OfflineEvidence(
37 release_id=candidate_id,
38 eval_suite=candidate.eval_suite,
39 evaluator_version=candidate.evaluator_version,
40 input_schema=candidate.input_schema,
41 evaluation_report="reports/candidate-suite-7-redacted.json",
42 supported_evidence_f1=0.93,
43 unsupported_serve_count=0,
44 p95_latency_ms=472,
45)
46weaker_candidate = replace(candidate_offline, supported_evidence_f1=0.89)
47changed_evaluator = replace(candidate_offline, evaluator_version="claim-evidence-eval-v3")
48
49print(f"candidate={offline_gate(candidate, candidate_offline)}")
50print(f"weak_metric={offline_gate(candidate, weaker_candidate)}")
51print(f"changed_evaluator={offline_gate(candidate, changed_evaluator)}")1candidate=Decision(allowed=True, reason='offline gate passed')
2weak_metric=Decision(allowed=False, reason='supported_evidence_f1 below 0.92')
3changed_evaluator=Decision(allowed=False, reason='evaluator version changed')Passing the offline gate permits further evaluation; it doesn't immediately replace production. Open a canary alias while production still points to the known-good bundle.
1def open_canary(bundle: ReleaseBundle, evidence: OfflineEvidence) -> Decision:
2 decision = offline_gate(bundle, evidence)
3 if decision.allowed:
4 registry.move_alias("canary", release_id(bundle))
5 return decision
6
7canary_decision = open_canary(candidate, candidate_offline)
8
9print(f"canary_opened={canary_decision.allowed}")
10print(f"canary={registry.resolve('canary')}")
11print(f"production_still_stable={registry.resolve('production') == stable_id}")1canary_opened=True
2canary=incident-evidence-answerer@sha256:fa60321b1dce
3production_still_stable=TrueWhen your team owns weights, a digest can identify them directly. If a service calls a hosted model, the bundle must instead record the strongest fixed identifier that provider documents. For example, OpenAI model pages that expose snapshots describe snapshots as locking a particular model version for consistent behavior.[5]
A provider can change the model behind a name you thought was stable, shifting your outputs with no deploy on your side. A controlled study comparing the March and June 2023 releases of GPT-4 and GPT-3.5 reported large behavior swings on the same tasks between snapshots, so the "same" service was not always the same service.[6] Pinning a documented snapshot reduces that risk but doesn't remove it: snapshots get deprecated, and a golden-set monitor against production still earns its keep.[7]
Don't generalize that guarantee to every provider or every alias. Verify the exact provider documentation, store the chosen model identifier in the bundle, monitor deprecation notices, and rerun release gates before changing it.
Frozen fixtures test the failures you already imagined. A deterministic replay tests a failure you actually shipped: pull the recorded trace for a request that went wrong under the current release, then re-run its exact prompt and tool-call sequence against the candidate release while holding the recorded evidence snapshot fixed. The only thing that varies is the release under test, so a difference in behavior is attributable to the candidate, not to a new request or a moved corpus.
This answers a precise regression question: would the candidate have made the same mistake on this real request? It sits between the offline gate and the shadow because it reuses recorded inputs rather than inventing fixtures or spending live traffic. The replay is read-only: it feeds recorded tool results back in rather than re-executing tools, so no incident state changes.
The stable release served a claim unsupported by admitted evidence. Replay that trace against the candidate's evidence gate:
1@dataclass(frozen=True)
2class RecordedTrace:
3 request_id: str
4 origin_release: str
5 prompt_version: str
6 evidence_version: str
7 tool_sequence: tuple[str, ...]
8 evidence_supports_claim: bool
9 served_unsupported_claim: bool
10
11def candidate_gate_serves(evidence_supports_claim: bool) -> bool:
12 # The v2 evidence gate serves a factual claim only when admitted evidence supports it.
13 return evidence_supports_claim
14
15def replay_against(trace: RecordedTrace, bundle: ReleaseBundle) -> dict[str, object]:
16 would_serve = candidate_gate_serves(trace.evidence_supports_claim)
17 reproduces = would_serve and not trace.evidence_supports_claim
18 return {
19 "candidate_release": release_id(bundle),
20 "tool_sequence": trace.tool_sequence,
21 "candidate_reproduces_failure": reproduces,
22 }
23
24failed_trace = RecordedTrace(
25 request_id="req_88213",
26 origin_release=stable_id,
27 prompt_version=stable.prompt_version,
28 evidence_version="incident-runbooks@sha256:corpus-5",
29 tool_sequence=("lookup_incident", "fetch_runbook", "answer"),
30 evidence_supports_claim=False,
31 served_unsupported_claim=True,
32)
33replay_result = replay_against(failed_trace, candidate)
34
35print(f"origin_release={failed_trace.origin_release}")
36print(f"replayed_against={replay_result['candidate_release']}")
37print(f"inputs_held_fixed={replay_result['tool_sequence']}")
38print(f"candidate_reproduces_failure={replay_result['candidate_reproduces_failure']}")1origin_release=incident-evidence-answerer@sha256:026746ee8fb8
2replayed_against=incident-evidence-answerer@sha256:fa60321b1dce
3inputs_held_fixed=('lookup_incident', 'fetch_runbook', 'answer')
4candidate_reproduces_failure=FalseA clean replay is real evidence that the candidate fixes this specific incident, but it isn't a general guarantee. A production replay harness needs the recorded prompt, retrieved evidence version, tool inputs and outputs, and any non-deterministic settings pinned (temperature, seed, and model snapshot), or the "replay" quietly becomes a fresh run whose difference you can't attribute. Keep a growing library of failed traces and add each new incident to it, so a future candidate must clear every past production mistake before promotion.
Offline fixtures can't cover every real request shape. A shadow sends a sanitized copy of a production request to the candidate while the stable release alone supplies the user-visible answer. It can reveal latency or evidence-support problems without exposing the candidate's text to customers.
The safety rule is easy to miss: a shadow isn't permitted to execute tools, send messages, change incident state, or write production state. Its output is evaluation data only. The envelope below also redacts an incident identifier before queuing the shadow request.
1import re
2
3@dataclass(frozen=True)
4class ShadowEnvelope:
5 candidate_release: str
6 sanitized_text: str
7 side_effects_enabled: bool
8 response_visible_to_user: bool
9
10def make_shadow(text: str) -> ShadowEnvelope:
11 sanitized = re.sub(r"INC-\d+", "[INCIDENT_ID]", text)
12 return ShadowEnvelope(
13 candidate_release=registry.resolve("canary"),
14 sanitized_text=sanitized,
15 side_effects_enabled=False,
16 response_visible_to_user=False,
17 )
18
19shadow = make_shadow("Can INC-48192 roll back based on runbook RB-7?")
20
21print(f"shadow_text={shadow.sanitized_text}")
22print(f"candidate={shadow.candidate_release}")
23print(f"side_effects_enabled={shadow.side_effects_enabled}")
24print(f"response_visible={shadow.response_visible_to_user}")1shadow_text=Can [INCIDENT_ID] roll back based on runbook RB-7?
2candidate=incident-evidence-answerer@sha256:fa60321b1dce
3side_effects_enabled=False
4response_visible=FalseThe one-pattern redaction is only a teaching fixture. A production shadow path needs schema-aware data minimization that covers every customer identifier and secret before the request reaches a queue, log, or candidate service.
In a deployed service, enqueue this envelope to a bounded worker queue and count dropped or failed comparisons. Don't start an untracked background task in request scope and assume its evaluation record will survive process restarts.
The boolean in this teaching envelope is metadata, not an authorization boundary. Its worker still needs a read-only tool allowlist and credentials that can't write production state. Reject a shadow request if it asks for a side effect.
Shadow results can justify limited exposure, not automatic promotion. A canary sends a small share of real conversations to the candidate and returns those candidate responses to users. For conversational systems, one thread must remain on one bundle throughout the rollout. Otherwise a user can receive conflicting answers from stable and candidate releases in adjacent turns.
Use deterministic hashing of a conversation ID to choose new conversations reproducibly across workers. Python's built-in hash() is intentionally process-dependent, so it's the wrong bucketing function for that job. Hashing alone isn't enough, though: when canary traffic widens from 1% to 10%, the higher threshold could move an existing conversation from stable to candidate. Persist the first resolved release ID for the conversation lifetime.
1def bucket(conversation_id: str) -> int:
2 digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()
3 return int(digest[:8], 16) % 100
4
5conversation_assignments: dict[str, str] = {}
6aborted_releases: set[str] = set()
7
8def assigned_release(conversation_id: str, canary_percent: int) -> str:
9 existing = conversation_assignments.get(conversation_id)
10 if existing is not None and existing not in aborted_releases:
11 return existing
12 alias = "canary" if bucket(conversation_id) < canary_percent else "production"
13 bundle_id = registry.resolve(alias)
14 if bundle_id in aborted_releases:
15 bundle_id = registry.resolve("production")
16 conversation_assignments[conversation_id] = bundle_id
17 return bundle_id
18
19canary_thread = next(
20 f"thread-{index}" for index in range(1000) if bucket(f"thread-{index}") < 10
21)
22assignments = [assigned_release(canary_thread, canary_percent=10) for _ in range(3)]
23stable_thread = next(
24 f"thread-{index}" for index in range(1000) if bucket(f"thread-{index}") >= 10
25)
26stable_before_widen = assigned_release(stable_thread, canary_percent=10)
27stable_after_widen = assigned_release(stable_thread, canary_percent=100)
28
29print(f"canary_thread={canary_thread}")
30print(f"bucket={bucket(canary_thread)}")
31print(f"same_release_each_turn={len(set(assignments)) == 1}")
32print(f"assigned_to_candidate={assignments[0] == candidate_id}")
33print(f"existing_stable_thread_pinned_after_widen={stable_before_widen == stable_after_widen == stable_id}")1canary_thread=thread-6
2bucket=6
3same_release_each_turn=True
4assigned_to_candidate=True
5existing_stable_thread_pinned_after_widen=TrueThe dictionary is a teaching fixture. A real router persists assignments in conversation state or a rollout store, writes the first assignment atomically so concurrent opening turns can't disagree, expires it when the conversation ends, and records the resolved release ID in traces. Stickiness is normal-routing behavior, not permission to keep serving a failed candidate: an abort must override it.
Offline evidence proves behavior on frozen examples. A live window tests traffic mix, serving latency, error rate, and evidence failures under actual request volume. Define those thresholds and a minimum sample size before sending any candidate traffic. A rate computed from a handful of requests isn't enough evidence to widen exposure. For this incident assistant, support is a hard safety invariant: one live unsupported serve fails the window rather than being absorbed into an error budget.
1@dataclass(frozen=True)
2class LiveWindow:
3 name: str
4 request_count: int
5 p95_latency_ms: int
6 error_rate: float
7 unsupported_serve_rate: float
8 shadow_drop_rate: float
9
10def live_gate(window: LiveWindow) -> Decision:
11 if window.request_count < 1_000:
12 return Decision(False, "fewer than 1000 requests observed")
13 if window.p95_latency_ms > 550:
14 return Decision(False, "live latency exceeded 550 ms")
15 if window.error_rate > 0.01:
16 return Decision(False, "error rate exceeded 1%")
17 if window.unsupported_serve_rate > 0.0:
18 return Decision(False, "unsupported serve rate must remain zero")
19 if window.shadow_drop_rate > 0.02:
20 return Decision(False, "shadow telemetry incomplete")
21 return Decision(True, "live window passed")
22
23window_1_percent = LiveWindow("1%", 1_200, 481, 0.002, 0.000, 0.001)
24window_10_percent = LiveWindow("10%", 8_000, 493, 0.003, 0.014, 0.001)
25
26print(f"one_percent={live_gate(window_1_percent)}")
27print(f"ten_percent={live_gate(window_10_percent)}")1one_percent=Decision(allowed=True, reason='live window passed')
2ten_percent=Decision(allowed=False, reason='unsupported serve rate must remain zero')Both windows exceed the 1,000-request minimum, so the metric verdicts are meaningful under this teaching contract. A low-volume window would remain blocked even if every observed rate happened to be zero.
These actions sound similar but refer to different alias states:
canary alias receives traffic, abort the rollout. production never moved.production to the retained stable release.Keeping the distinction explicit prevents an incident report from claiming production was rolled back when the candidate was never production.
1canary_percent = 10
2failed_window = live_gate(window_10_percent)
3if not failed_window.allowed:
4 aborted_releases.add(candidate_id)
5 canary_percent = 0
6
7print(f"canary_percent_after_abort={canary_percent}")
8print(f"production_after_abort={registry.resolve('production')}")
9print(f"pinned_canary_thread_restored_stable={assigned_release(canary_thread, canary_percent) == stable_id}")
10
11# Separate rollback drill: a promoted candidate later regresses at wider traffic.
12rollback_drill = deepcopy(registry)
13previous_production = rollback_drill.resolve("production")
14rollback_drill.move_alias("production", candidate_id)
15post_promotion_incident = replace(window_10_percent, name="100%")
16
17if not live_gate(post_promotion_incident).allowed:
18 rollback_drill.move_alias("production", previous_production)
19
20print(f"drill_production_after_rollback={rollback_drill.resolve('production')}")
21print(f"drill_restored_stable={rollback_drill.resolve('production') == stable_id}")
22print(f"actual_production_unchanged={registry.resolve('production') == stable_id}")1canary_percent_after_abort=0
2production_after_abort=incident-evidence-answerer@sha256:026746ee8fb8
3pinned_canary_thread_restored_stable=True
4drill_production_after_rollback=incident-evidence-answerer@sha256:026746ee8fb8
5drill_restored_stable=True
6actual_production_unchanged=TrueReal progressive-delivery controllers encode the same operational mechanics. Argo Rollouts supports weighted canary steps and pauses; its background analysis can abort an unsuccessful rollout. With traffic routing, keeping the stable replica set available allows traffic to move back immediately on abort, at the cost of additional capacity during rollout.[8]
A pipeline that moves aliases but doesn't persist why it moved them still creates mystery during an incident. Store the release IDs, evidence references, gate verdicts, rollout windows, actor or controller identity, decision timestamp, and final alias state as append-only events.
The candidate below is correctly rejected for production after its 10% canary window serves unsupported incident claims. The release isn't deleted. It remains available for diagnosis, while traffic stays with the known-good bundle.
1@dataclass(frozen=True)
2class ReleaseEvent:
3 stage: str
4 release_id: str
5 decision: str
6 evidence: str
7
8events = (
9 ReleaseEvent("register", candidate_id, "RECORDED", "manifest_sha"),
10 ReleaseEvent("offline_gate", candidate_id, "PASSED", candidate_offline.evaluation_report),
11 ReleaseEvent("canary_1_percent", candidate_id, "PASSED", "live:window-001"),
12 ReleaseEvent("canary_10_percent", candidate_id, "ABORTED", "live:window-010"),
13 ReleaseEvent("production", stable_id, "UNCHANGED", "rollback:not-needed"),
14)
15
16for event in events:
17 print(f"{event.stage}:{event.decision}:{event.evidence}")
18print("release_decision=REJECT_CANDIDATE_AFTER_CANARY_REGRESSION")
19print(f"active_production={registry.resolve('production')}")1register:RECORDED:manifest_sha
2offline_gate:PASSED:reports/candidate-suite-7-redacted.json
3canary_1_percent:PASSED:live:window-001
4canary_10_percent:ABORTED:live:window-010
5production:UNCHANGED:rollback:not-needed
6release_decision=REJECT_CANDIDATE_AFTER_CANARY_REGRESSION
7active_production=incident-evidence-answerer@sha256:026746ee8fb8The lab deliberately uses plain Python so the state transitions are visible. A deployed stack usually splits the same responsibilities:
| Responsibility | Production form |
|---|---|
| Store immutable model or component version | Artifact store plus model registry |
| Store prompt, policy, corpus, tokenizer, schema, and evaluator pins | Release manifest in source control or deployment registry |
| Move candidate/production pointers | Registry aliases, deployment config, or feature flags limited to registered releases |
| Run offline evidence gates | CI job tied to exact manifest digest with a retained report artifact |
| Shift live traffic and pause on regressions | Progressive-delivery controller and metric analysis |
| Reconstruct impact | Request trace logs with resolved release ID and rollout event log |
Feature flags remain useful, but their values must resolve to registered immutable release IDs. A flag that points at an arbitrary model name makes rapid changes easy and incident reconstruction impossible.
production or canary.corpus_version in ReleaseBundle. Show that a new policy-document snapshot creates a different release ID even if weights and prompts remain fixed.gate_training_run. Explain why this prevents a registered model from escaping experiment lineage.rollback_ready check that refuses canary exposure unless the stable serving target is healthy, warm, and compatible with the active request schema.production and canary aliases.eval_suite and evaluator_version in the bundle, then retain the exact evaluation_report artifact used for promotion.Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Hidden Technical Debt in Machine Learning Systems.
Sculley et al. · 2015
Challenges in Deploying Machine Learning: a Survey of Case Studies.
Paleyes, A., Urma, R. G., & Lawrence, N. D. · 2022 · ACM Computing Surveys
Model Registry Workflows | MLflow AI Platform
MLflow · 2026
Continuous Delivery for Machine Learning.
Sato, D., Wider, A., & Windheuser, C. · 2019
Models | OpenAI API
OpenAI · 2026
How Is ChatGPT's Behavior Changing over Time?
Chen, L., Zaharia, M., & Zou, J. · 2023
Deprecations | OpenAI API
OpenAI · 2026
Argo Rollouts - Kubernetes Progressive Delivery Controller
Argo Project · 2026
Questions and insights from fellow learners.