Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
In our worked incident, trace req_205 shows deploy-answerer claiming that payment-tests are approved for SHIP-4412 even though the admitted ReleaseOps evidence doesn't support that claim. The on-call points production back to the known-good template, and new requests return to expected behavior. A review question remains: which prompt, hosted-model snapshot, evidence corpus, and serving image produced the bad answer?
The DSPy compile passed its frozen holdout, but that compiled JSON still isn't a production release. A live answer can change through the prompt program, hosted-model pin, ReleaseOps corpus, serving image, decoding settings, or schema. A label such as v1 can't tell you which combination was active.
The operational question is blunt: which complete, evaluated release produced this response, and how quickly can traffic return to the last known-good release?

A release is more than model weights
deploy-answerer combines a hosted answer model, a releaseops-evidence-classifier-v2 gate, and prompt state. The gate decides which ReleaseOps excerpts are trusted enough to admit. The prompt program must constrain the answer to those excerpts and abstain when admitted evidence doesn't support the requested claim. If you roll back only one component while a newer prompt, corpus, or image remains active, you can land on a mixed release that was never evaluated.
Call that complete set a release bundle. Store every input that can change visible output or operational safety in one immutable manifest. Store the evaluation contract and resulting report with it, too. Data, code, configuration, and serving infrastructure otherwise drift along separate paths, leaving reviewers to guess which change caused a behavior shift. The bundle keeps those dependencies reviewable.[1][2]
| Bundle field | Why it belongs in the release |
|---|---|
| Answer-model and evidence-gate identifiers | Determine model behavior |
| Prompt-program artifact and compile run | Tie optimized instructions and demonstrations to their source experiment |
| Tokenizer, prompt version, and decoding | Change the text the model sees and how it samples |
| 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 |
Treat the manifest as a version tuple, not as one model number. Its coordinates cover prompt state, evidence, policy, schema, evaluator, runtime, and decoding. Change one coordinate and the release identity must change, even when weights stay put.
Compatibility sits inside that tuple. A candidate can pass answer-quality checks and still fail when its input, output, or tool schema no longer matches the router. Pin those contracts, test them with the same release ID, and diagnose an interface failure separately from a model-quality regression.
We'll use two full bundles. stable is what's serving today: the hand-edited deploy-answerer-v1 template. candidate swaps in the DSPy-compiled program after development selection and an independent holdout gate. The hosted-model pin, evidence gate, ReleaseOps corpus, and eval suite stay fixed, giving us a clean comparison: if behavior changes later, the compiled prompt or the image that loads it changed.
Read the two constructors before running the cell. Which fields should differ? Candidate changes only its compile run, prompt version, and serving image; every other behavior-producing field stays pinned.
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 prompt_compile_run: str
11 tokenizer: str
12 prompt_version: str
13 policy_version: str
14 corpus_version: str
15 serving_image: str
16 input_schema: str
17 eval_suite: str
18 evaluator_version: str
19 decoding: str
20
21stable = ReleaseBundle(
22 service="deploy-answerer",
23 answer_model="hosted-model@2026-04-01",
24 evidence_gate="releaseops-evidence-classifier-v2@sha256:41d8",
25 prompt_compile_run="manual-template-v1",
26 tokenizer="deploy-tokenizer@sha256:91aa",
27 prompt_version="deploy-answerer-v1.json",
28 policy_version="[email protected]",
29 corpus_version="releaseops-records@sha256:corpus-5",
30 serving_image="registry.example/deploy-answerer@sha256:image-a",
31 input_schema="deploy-answer.v2",
32 eval_suite="deploy-grounding-suite@sha256:suite-7",
33 evaluator_version="claim-evidence-eval-v2",
34 decoding="temperature=0",
35)
36
37candidate = replace(
38 stable,
39 prompt_compile_run="dspy-mipro-v2-run-052",
40 prompt_version="compiled_a_hosted-2026-04-01.json",
41 serving_image="registry.example/deploy-answerer@sha256:image-b",
42)
43
44print(f"stable_prompt={stable.prompt_version}")
45print(f"candidate_prompt={candidate.prompt_version}")
46print(f"candidate_compile_run={candidate.prompt_compile_run}")1stable_prompt=deploy-answerer-v1.json
2candidate_prompt=compiled_a_hosted-2026-04-01.json
3candidate_compile_run=dspy-mipro-v2-run-052A human-readable name such as v3 helps people find a release, but it can't prove what went into it. Before running the next cell, predict the result of changing only prompt_version: the release ID should change, because the canonical manifest includes that field.
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(
10 replace(candidate, prompt_version="compiled_a_hosted-2026-04-01.b.json")
11)
12
13print(f"stable={stable_id}")
14print(f"candidate={candidate_id}")
15print(f"prompt_patch={prompt_patch_id}")
16print(f"prompt_patch_is_new_release={prompt_patch_id != candidate_id}")1stable=deploy-answerer@sha256:32b8ed409b8e
2candidate=deploy-answerer@sha256:e31f2b6d7a2c
3prompt_patch=deploy-answerer@sha256:f94cb4d1892f
4prompt_patch_is_new_release=TrueThe 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.
Why should a one-line prompt correction produce a new release ID?
Answer
The prompt can change served behavior even when model weights don't move. Reusing the old release ID would make traces and rollbacks lie about what users saw.
Artifacts stay fixed; aliases move
A registry record stores an immutable bundle. An alias such as production or canary is a mutable pointer used by traffic. Because the record never changes, rollback only needs to move the pointer back; it doesn't need to rebuild or rewrite the bundle.
MLflow's current Model Registry workflow uses aliases and tags to organize model versions, and its documentation marks fixed Model Stages as deprecated. The distinction matters: a model version preserves history, while an alias names the version a loader should resolve now.[3]
An MLflow model alias still points to one registered model version. It doesn't automatically include the prompt, corpus, policy, schema, or serving image in this release bundle. Treat it as one component pointer, or register a wrapper artifact whose manifest resolves the complete bundle. A movable model alias isn't complete release identity.

The small registry below enforces that rule. register() keeps a deep copy of each bundle, and move_alias() accepts only a registered ID. Before running it, predict the alias state: registering candidate should increase the record count while production still resolves to stable.
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=deploy-answerer@sha256:32b8ed409b8e
3candidate_registered=TrueA teaching registry can make move_alias() one assignment. A production control plane needs an atomic compare-and-set, the expected previous target, an authorization check, and an audit event. Those checks stop a stale pipeline from overwriting a newer rollout decision.
Promotion begins with controlled evidence
Registration makes a bundle addressable, not approved. 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]
The gate should speak the same language as the observability, evaluation, and experiment lessons that came before it. Reuse the grounded evidence contract instead of inventing one generic quality score:
supported_evidence_f1asks whether supported deploy claims are served correctly.unsupported_serve_countmust remain zero in the frozen high-risk suite. That's thereq_205failure: serving a deploy-approval claim ReleaseOps didn't support.- The suite hash includes the rollback slice from the DSPy holdout. A candidate that lifts the mean while missing that floor is a different experiment, not a comparable pass.
p95_latency_ms(p95 latency) keeps a behaviorally acceptable release inside its response budget.- Schema hash, evaluation-suite hash, and evaluator version keep incomparable evidence out of the decision.
evaluation_reportpreserves the report artifact for a reviewer or incident responder to inspect later.

Pause at the gate boundary. Candidate's 0.93 F1, zero unsupported serves, and 472 ms p95 all clear. Predict the two failures in the next cell: a weaker F1 should fail its threshold, and an otherwise identical report from evaluator v3 should fail comparability.
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 the next experiment. It doesn't replace production yet. 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=deploy-answerer@sha256:e31f2b6d7a2c
3production_still_stable=TrueManaged models need a documented pin
When your team owns weights, a digest can identify them directly. deploy-answerer calls a hosted model, so the bundle records the strongest fixed identifier the provider documents. OpenAI's model documentation describes snapshots as a way to lock a specific version so performance and behavior remain consistent.[5] In this lab that's hosted-model@2026-04-01, the same pin used during the DSPy evaluation.
A provider can change the model behind a name you thought was stable, shifting outputs with no deploy on your side. Chen, Zaharia, and Zou compared March and June 2023 GPT-4 snapshots on the same tasks. With chain-of-thought prompting, accuracy on a 1,000-question prime-versus-composite set dropped from 84.0% to 51.1%. The product name stayed familiar, but the behavior didn't.[6] Pinning a documented snapshot reduces that risk, but it doesn't remove it. Providers can retire snapshots, so keep a golden-set monitor and treat any model-identifier change as a fresh release decision.[7]
That guarantee doesn't automatically transfer to every provider or alias. Check the provider's exact documentation, store the chosen identifier in the bundle, watch deprecation notices, and rerun release gates before changing it.
Replay a failed production trace against the candidate
Frozen fixtures test failures you thought to write down. A deterministic replay starts with a failure you shipped: pull its recorded trace, then run the exact prompt and tool-call sequence against the candidate while holding the evidence snapshot fixed. With the request and evidence held constant, a behavioral difference points to the release under test rather than a new request or a moved corpus.
The regression question is narrow: would the candidate have made the same mistake on this real request? Replay sits between the offline gate and shadow because it reuses recorded inputs without inventing fixtures or spending live traffic. It's read-only: recorded tool results go back in, rather than tools running again, so ReleaseOps state can't change.
Now apply that question to req_205. Historical deploy-answerer-v1.1-regression served a deploy-approval claim that admitted evidence didn't support; traffic has since returned to known-good deploy-answerer-v1. Replay the same failure against the recovered stable bundle and compiled candidate while keeping the evidence gate and recorded inputs fixed.
Before running the cell, predict the three verdicts: historical should reproduce the failure, while stable and candidate should avoid it. If stable also reproduces it, the supposed recovery isn't a valid comparison.
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 temperature: float = 0.0
11 seed: int = 0
12 model_snapshot: str = ""
13
14def prompt_would_serve(bundle: ReleaseBundle, evidence_supports_claim: bool) -> bool:
15 # Historical v1.1 bypassed the gate; known-good v1 and the candidate don't.
16 if bundle.prompt_version == "deploy-answerer-v1.1-regression.json":
17 return True
18 return evidence_supports_claim
19
20def replay_against(trace: RecordedTrace, bundle: ReleaseBundle) -> dict[str, object]:
21 would_serve = prompt_would_serve(bundle, trace.evidence_supports_claim)
22 reproduces = would_serve and not trace.evidence_supports_claim
23 return {
24 "candidate_release": release_id(bundle),
25 "prompt_version": bundle.prompt_version,
26 "tool_sequence": trace.tool_sequence,
27 "pins": (trace.temperature, trace.seed, trace.model_snapshot),
28 "candidate_reproduces_failure": reproduces,
29 }
30
31historical_incident = replace(
32 stable,
33 prompt_compile_run="regressed-template-v1.1",
34 prompt_version="deploy-answerer-v1.1-regression.json",
35 serving_image="registry.example/deploy-answerer@sha256:image-regressed",
36)
37failed_trace = RecordedTrace(
38 request_id="req_205",
39 origin_release=release_id(historical_incident),
40 prompt_version=historical_incident.prompt_version,
41 evidence_version="releaseops-records@sha256:corpus-5",
42 tool_sequence=("lookup_release", "fetch_record", "answer"),
43 evidence_supports_claim=False,
44 served_unsupported_claim=True,
45 temperature=0.0,
46 seed=7,
47 model_snapshot=stable.answer_model,
48)
49historical_replay = replay_against(failed_trace, historical_incident)
50stable_replay = replay_against(failed_trace, stable)
51candidate_replay = replay_against(failed_trace, candidate)
52
53print(f"origin_release={failed_trace.origin_release}")
54print(f"historical_reproduces_failure={historical_replay['candidate_reproduces_failure']}")
55print(f"stable_reproduces_failure={stable_replay['candidate_reproduces_failure']}")
56print(f"replayed_against={candidate_replay['candidate_release']}")
57print(f"candidate_prompt={candidate_replay['prompt_version']}")
58print(f"inputs_held_fixed={candidate_replay['tool_sequence']}")
59print(f"pins={candidate_replay['pins']}")
60print(f"candidate_reproduces_failure={candidate_replay['candidate_reproduces_failure']}")
61
62assert historical_replay["candidate_reproduces_failure"] is True
63assert stable_replay["candidate_reproduces_failure"] is False
64assert candidate_replay["candidate_reproduces_failure"] is False1origin_release=deploy-answerer@sha256:efe48fbcb5a2
2historical_reproduces_failure=True
3stable_reproduces_failure=False
4replayed_against=deploy-answerer@sha256:e31f2b6d7a2c
5candidate_prompt=compiled_a_hosted-2026-04-01.json
6inputs_held_fixed=('lookup_release', 'fetch_record', 'answer')
7pins=(0.0, 7, 'hosted-model@2026-04-01')
8candidate_reproduces_failure=FalseThe clean replay is evidence against this one historical failure, not a general guarantee. The toy gate reads bundle.prompt_version, so the bad historical bundle disagrees with both safe releases on the same inputs while the evidence gate stays constant. A production replay harness also needs the recorded prompt, retrieved-evidence version, tool inputs and outputs, and sampling pins on the trace (temperature, seed, and model snapshot). Without them, a "replay" quietly becomes a fresh run whose difference you can't attribute. Keep a growing library of failed traces and require future candidates to clear each past production mistake before promotion.
Shadow evaluation must be read-only
Replay gives us one known failure. Offline fixtures still 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 reveals latency or evidence-support problems without exposing candidate text to customers.
The safety boundary is strict: shadow traffic can't execute tools, send messages, change ReleaseOps state, or write production state. Its output is evaluation data only. The envelope below also redacts a ship identifier before queuing the request.
Before running it, inspect the flags you expect to stay false: side effects must be disabled and candidate output must stay hidden. The candidate may abstain where stable served, and that disagreement is useful evidence rather than a user-visible incident.
1import re
2
3@dataclass(frozen=True)
4class ShadowEnvelope:
5 candidate_release: str
6 stable_release: str
7 sanitized_text: str
8 side_effects_enabled: bool
9 response_visible_to_user: bool
10 evidence_supports_claim: bool
11
12@dataclass(frozen=True)
13class ShadowComparison:
14 route_agreement: bool
15 candidate_claim_safe: bool
16 candidate_would_serve: bool
17 stable_route: str
18 candidate_route: str
19 dropped: bool
20
21def make_shadow(text: str, evidence_supports_claim: bool) -> ShadowEnvelope:
22 sanitized = re.sub(r"SHIP-\d+", "[SHIP_ID]", text)
23 return ShadowEnvelope(
24 candidate_release=registry.resolve("canary"),
25 stable_release=registry.resolve("production"),
26 sanitized_text=sanitized,
27 side_effects_enabled=False,
28 response_visible_to_user=False,
29 evidence_supports_claim=evidence_supports_claim,
30 )
31
32def compare_shadow(envelope: ShadowEnvelope, queue_full: bool = False) -> ShadowComparison:
33 if queue_full or envelope.side_effects_enabled:
34 return ShadowComparison(False, False, False, "dropped", "dropped", True)
35 # Stable already answered the user; shadow only scores the candidate copy.
36 stable_route = "serve" # production response already left the system
37 candidate_bundle = (
38 candidate if envelope.candidate_release == candidate_id else stable
39 )
40 candidate_would_serve = prompt_would_serve(
41 candidate_bundle, envelope.evidence_supports_claim
42 )
43 candidate_route = "serve" if candidate_would_serve else "abstain"
44 route_agreement = candidate_route == stable_route
45 candidate_claim_safe = candidate_route == "abstain" or envelope.evidence_supports_claim
46 return ShadowComparison(
47 route_agreement=route_agreement,
48 candidate_claim_safe=candidate_claim_safe,
49 candidate_would_serve=candidate_would_serve,
50 stable_route=stable_route,
51 candidate_route=candidate_route,
52 dropped=False,
53 )
54
55shadow = make_shadow(
56 "Is payment-tests approved for SHIP-4412 without a ReleaseOps record?",
57 evidence_supports_claim=False,
58)
59comparison = compare_shadow(shadow)
60dropped = compare_shadow(shadow, queue_full=True)
61
62print(f"shadow_text={shadow.sanitized_text}")
63print(f"candidate={shadow.candidate_release}")
64print(f"side_effects_enabled={shadow.side_effects_enabled}")
65print(f"response_visible={shadow.response_visible_to_user}")
66print(f"candidate_route={comparison.candidate_route}")
67print(f"route_agreement={comparison.route_agreement}")
68print(f"candidate_claim_safe={comparison.candidate_claim_safe}")
69print(f"dropped_when_queue_full={dropped.dropped}")
70
71assert comparison.candidate_would_serve is False
72assert comparison.route_agreement is False
73assert comparison.candidate_claim_safe is True
74assert dropped.dropped is True1shadow_text=Is payment-tests approved for [SHIP_ID] without a ReleaseOps record?
2candidate=deploy-answerer@sha256:e31f2b6d7a2c
3side_effects_enabled=False
4response_visible=False
5candidate_route=abstain
6route_agreement=False
7candidate_claim_safe=True
8dropped_when_queue_full=TrueThe one-pattern redaction is a teaching fixture. A real shadow path needs schema-aware data minimization that covers every customer identifier and secret before the request reaches a queue, log, or candidate service.
At the service boundary, put the envelope on a bounded worker queue and count dropped or failed comparisons. The compare step is the other half of shadow: after the candidate answers the sanitized copy, record route agreement, claim safety, and latency against the stable path. Feed shadow_drop_rate from dropped or timed-out comparisons. Don't start an untracked background task in request scope and assume its evaluation record will survive a process restart.
side_effects_enabled=False in this teaching envelope is metadata, not an authorization boundary. The worker still needs a read-only tool allowlist and credentials that can't write production state. Reject a shadow request that asks for a side effect.
Why must a shadow evaluation of an agent candidate be read-only?
Answer
Shadow traffic duplicates production inputs. If the candidate can send messages, modify records, or call irreversible tools, evaluation itself creates duplicate side effects instead of passive evidence.
Canary traffic is visible and sticky
Shadow results can justify limited exposure, not automatic promotion. A canary sends a small share of real conversations to the candidate and returns those responses to users. That changes the safety problem: one conversation must remain on one bundle throughout the rollout. Otherwise adjacent turns can receive conflicting answers from stable and candidate.
Use deterministic hashing of a conversation ID to choose new conversations consistently across workers. Python's built-in hash() is process-dependent, so it can't provide that bucket. Hashing alone isn't enough: when exposure widens from 1% to 10%, a higher threshold could move an existing conversation from stable to candidate. Persist the first resolved release ID for the conversation lifetime.
Predict the widening case before running the router. A thread assigned to stable at 10% should still resolve to stable after the threshold becomes 100%; an abort must be able to override that assignment.
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 governs normal routing, not recovery: an abort must override it for a failed candidate.

A canary window answers a new question
Offline evidence covers frozen examples. A live window asks a different question: how does candidate behave under real traffic mix, serving latency, error rate, and evidence pressure? Set thresholds and a minimum sample size before sending candidate traffic. A rate from a handful of requests isn't enough to widen exposure. For deploy-answerer, support is a hard safety invariant, so one live unsupported serve fails the window instead of disappearing inside an error budget.
Read the two windows in the figure as a decision, not a scorecard. The 1% sample clears this contract; the 10% sample fails even though its latency and error rate pass. Zero unsupported serves is the controlling condition.
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)}")
28print(
29 "ten_percent_unsupported_count="
30 f"{round(window_10_percent.unsupported_serve_rate * window_10_percent.request_count)}"
31)1one_percent=Decision(allowed=True, reason='live window passed')
2ten_percent=Decision(allowed=False, reason='unsupported serve rate must remain zero')
3ten_percent_unsupported_count=112Both windows exceed the 1,000-request minimum, so this teaching contract can issue a verdict for each. A low-volume window would remain blocked even if every observed rate happened to be zero.
Use the failed signal to choose the next inspection. A request that can't parse points toward schema or serving-image compatibility. A parsed request with an unsupported answer belongs in trace replay against the pinned evidence snapshot. A safe but slow candidate points toward runtime capacity or decoding, while missing telemetry is a reason to stop exposure because the gate can't prove safety.
Abort a canary; roll back a promotion
The right recovery verb depends on which alias moved:
- If candidate fails while only
canaryreceives traffic, abort the rollout.productionnever moved. - If candidate was promoted and fails later, roll back by repointing
productionto the retained stable release.
Check the alias state before naming the incident. That distinction keeps a report from calling an unpromoted canary failure a production rollback.
In the next drill, canary failure should set exposure to zero and restore the canary pointer. The separate promoted-release drill should move production to candidate, detect failure, then return it to stable.
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 # Zero traffic isn't enough: any consumer of registry.resolve("canary") must leave the failed ID.
7 registry.move_alias("canary", stable_id)
8
9print(f"canary_percent_after_abort={canary_percent}")
10print(f"canary_alias_after_abort={registry.resolve('canary')}")
11print(f"production_after_abort={registry.resolve('production')}")
12print(f"pinned_canary_thread_restored_stable={assigned_release(canary_thread, canary_percent) == stable_id}")
13
14assert registry.resolve("canary") == stable_id
15assert registry.resolve("production") == stable_id
16
17# Separate rollback drill: a promoted candidate later regresses at wider traffic.
18rollback_drill = deepcopy(registry)
19previous_production = rollback_drill.resolve("production")
20rollback_drill.move_alias("production", candidate_id)
21post_promotion_incident = replace(window_10_percent, name="100%")
22
23if not live_gate(post_promotion_incident).allowed:
24 rollback_drill.move_alias("production", previous_production)
25
26print(f"drill_production_after_rollback={rollback_drill.resolve('production')}")
27print(f"drill_restored_stable={rollback_drill.resolve('production') == stable_id}")
28print(f"actual_production_unchanged={registry.resolve('production') == stable_id}")1canary_percent_after_abort=0
2canary_alias_after_abort=deploy-answerer@sha256:32b8ed409b8e
3production_after_abort=deploy-answerer@sha256:32b8ed409b8e
4pinned_canary_thread_restored_stable=True
5drill_production_after_rollback=deploy-answerer@sha256:32b8ed409b8e
6drill_restored_stable=True
7actual_production_unchanged=TrueReal progressive-delivery controllers encode the same mechanics. Argo Rollouts supports weighted canary steps and pauses, and its analysis can abort an unsuccessful rollout. With traffic routing, keeping the stable replica set available lets traffic move back immediately on abort, at the cost of extra capacity during rollout.[8]
Why might keeping the stable deployment warm during a canary be worth its GPU cost?
Answer
An alias change is only a fast recovery if the stable release can serve immediately. If old weights must reload after an incident, rollback can trade a behavior failure for a latency outage.
Record decisions so a later engineer can reconstruct them
A pipeline that moves aliases without recording why still creates mystery during an incident. Store release IDs, evidence references, gate verdicts, rollout windows, actor or controller identity, decision timestamp, and final alias state as append-only events.
The event stream below records candidate's rejection after its 10% window serves unsupported deploy-approval claims. The failed release remains addressable for diagnosis, while traffic stays with the known-good bundle. That pairing matters: recovery changes routing, while evidence preserves the path that led to the decision.
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=deploy-answerer@sha256:32b8ed409b8eWhere the lab maps onto a live stack
The lab uses plain Python so the state transitions stay 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, decoding, 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.
Keep one invariant as you ship: every request, gate, and alias event names the same immutable release ID. Then a failed answer can be replayed, a canary can be stopped, and production can return to known-good without guessing which bundle was active.