AI agents

Shadowing a Candidate Checker Against Live Promotions

Antonio Evans10 min read

A split-screen operations desk: on the left, a stream of live promotion rows scrolling past with a small green 'incumbent' stamp on each; on the right.
Key points

Key points

  1. 1Shadowing runs the candidate checker read-only on the same live promotion rows the incumbent sees, so you get a full decision pair on real traffic without any user-facing risk.
  2. 2Log every pair with a payload hash, both verdicts, both latencies and any exception, or the disagreement queue is unusable for triage.
  3. 3Classify each disagreement daily as a bug, an improvement or an environment artifact, and keep a running tally per class.
  4. 4Cut over only when the bug rate reaches zero on the final two days and the improvement diffs are ones you accept on purpose.

What shadow mode actually buys you past a replay

A replay set proves the candidate can reproduce decisions on rows you already collected. It says nothing about rows that only exist now. Shadow mode is the step that closes that gap: the candidate processes the same live rows as the incumbent, logs its verdict, and then returns nothing to anyone, so the incumbent keeps making every real decision as the shadow mode description lays out. The payoff is that you get to answer "what would have happened if this had been on?" with production examples rather than reconstructed history.

The distinction matters because the two modes catch different failures. A replay failure is usually a bug in your plumbing: a field you forgot to hydrate, a config you did not carry over. A shadow failure is usually a bug in your judgment: a rule that fires correctly but on the wrong rows. Regression testing after a change is a well-documented discipline, and regression suites are meant to be rerun whenever code moves, but they only protect what they already cover. Shadowing covers what you did not think to put in the suite.

It is also not the same as an A/B test. Shadow mode tells you whether the system works before you expose anyone to it. An A/B test tells you whether the system moves a business metric. For a promotion checker, the second question only makes sense after the first one is settled. Where regulatory or contractual exposure is high, shadowing may be the only evaluation you are allowed to run before cutover.

Wiring the read-only path so nothing can leak

The most important property of a shadow deployment is that the candidate cannot act. In practice that means three rules.

First, the candidate runs on a copy of the row, not on the live row. Whatever the candidate writes to its own state stays in its own namespace. Second, every outbound call the candidate makes (another service, a model endpoint, a database lookup) goes to a shadow target or a logged stub. If it would have written to the promotions table, it writes to a shadow table or only logs the intent. Third, the candidate never reads the incumbent's verdict before producing its own. If it does, you have a feedback loop that contaminates the comparison.

The same caution applies to any memory or cache the candidate keeps. Persistent agent state can be poisoned by content from one session that is later served back into another, and a poisoned write can propagate through summarization and retrieval before anyone notices. For a checker, the equivalent risk is a candidate that quietly caches a "confirmed promotion" from one row and applies it to a later, unrelated one. Run the candidate with memory scoped per row and cleared between batches, and check that the cleared state is actually cleared.

A decisive detail: separate the event time from the acceptance time in every log line. A promotion row may have been created hours before your candidate saw it, and an artifact you blame on "stale data" may simply be a clock difference. Recording event time and acceptance time separately is the standard fix, and it also lets you replay the shadow window later without re-querying source systems.

The logging schema that makes triage possible

You cannot triage what you did not record. Each shadow evaluation should emit one record per row with at least the fields below. The payload hash is the anchor: it lets you prove two runs saw the same input, and it lets you join the shadow log to the replay set and the incumbent's audit trail.

FieldTypeWhy it matters
row_idstringJoins to the incumbent's record and the replay set
payload_hashstringProves both checkers saw identical input
incumbent_verdictenumThe baseline decision, promoted or held
candidate_verdictenumThe shadow decision on the same input
candidate_reasonslistWhich rules or signals fired, for triage
incumbent_latency_msintegerBaseline for the latency comparison
candidate_latency_msintegerYour cutover risk if this is much higher
candidate_exceptionstring or nullSeparates crashes from disagreements
event_timetimestampWhen the row was created at source
accepted_attimestampWhen your pipeline saw it
checker_versionstringLets you group diffs per build

Two fields are easy to skip and expensive to omit. candidate_exception matters because a checker that crashes on 2 percent of rows will look like a checker that disagrees on 2 percent of rows if you only store verdicts. And candidate_reasons is what turns a queue into a debuggable list: without it, every disagreement needs a manual re-run before anyone can say why.

A minimal emission step looks like this. The point is the shape, not the language:

def shadow_check(row, incumbent_verdict):
    payload = canonical_json(row)          # sorted keys, stable datetime format
    record = {
        "row_id": row.id,
        "payload_hash": sha256(payload),
        "incumbent_verdict": incumbent_verdict,
        "event_time": row.created_at,
        "accepted_at": now_utc(),
        "checker_version": CANDIDATE_VERSION,
    }
    try:
        result = candidate.evaluate(parse(payload))   # shadow client, no writes
        record["candidate_verdict"] = result.verdict
        record["candidate_reasons"] = result.reasons
        record["candidate_latency_ms"] = result.elapsed_ms
    except Exception as exc:
        record["candidate_verdict"] = "ERROR"
        record["candidate_exception"] = repr(exc)
    shadow_log.write(record)               # append-only, never blocks the incumbent
    return incumbent_verdict               # incumbent still owns the decision

Note that the shadow call must not block the incumbent's path. If your gateway mirroring adds tail latency to production, you have traded a correctness risk for a performance one. Mirror asynchronously, and if the queue backs up, drop shadow rows rather than delay real ones.

Building the daily disagreement queue

The disagreement queue is the operational heart of the week. It is a filtered view over the shadow log where candidate_verdict != incumbent_verdict or the candidate errored. Build it as a scheduled job, not a dashboard someone has to remember to open, and route it to the person who owns the checker.

Sort it by risk, not by time. A practical ordering:

  1. Rows where the candidate errored or timed out. These are bugs by default until proven otherwise.
  2. Rows where the candidate would promote and the incumbent held. These are the expensive direction: a false promotion puts a bad entity into downstream systems, and false promotion is exactly the failure mode the memory governance literature treats as the one to control.
  3. Rows where the candidate would hold and the incumbent promoted. These are the "we just made the system stricter" diffs, often acceptable but sometimes a quiet recall loss.
  4. Everything else, grouped by candidate_reasons so you see clusters rather than individual rows.

Track two numbers every day: the disagreement rate (disagreements divided by shadowed rows) and the bug count per class. On a healthy rollout both trend down as you fix and redeploy. If the disagreement rate is flat while the bug count falls, you are probably converting bugs into improvements, which is the point.

Give the queue a fixed size discipline. If a day produces more than a few dozen disagreements, do not triage all of them; sample, classify the sample, and use the mix to decide what to look at next. A queue nobody finishes is worse than a sample everybody finishes.

Triage: bug, improvement, or environment artifact

Every diff gets exactly one label, and the label decides what happens next.

A bug is a diff where the incumbent is right and the candidate is wrong on the merits, or where the candidate crashed, timed out, or produced a verdict outside its allowed set. Bugs get a ticket, a fix, and a re-run on the same row. The re-run should be stored alongside the original so you can see the fix confirmed.

An improvement is a diff where the candidate is right and the incumbent was wrong or too coarse. Improvements get logged with a short justification and an owner who is willing to accept the behavior change. A candidate can be perfectly correct and still not cut over, because a deployment state is a decision about appetite as much as about correctness; the improvement diffs are where you record that appetite.

An environment artifact is a diff caused by something other than checker logic. Typical causes: the row changed between when the incumbent saw it and when the candidate did, a lookup service returned a different answer, a time-dependent rule fired differently because the two evaluations straddled a boundary, or a feature is present in one environment and not the other. Artifacts are not bugs, but they are not free either: a high artifact rate means your shadow window is not measuring what you think.

This third class is where most disagreements quietly go to die, and it is where the discipline pays. An audit trail that records who or what changed a value, and when, is what lets you distinguish a real behavior change from an artifact. If you cannot answer "did the input change between the two evaluations?" for a given row, your queue will drift toward blaming the candidate for the environment.

A worked example helps here. Suppose a week of shadowing produces 8,000 rows and 640 disagreements, an 8 percent rate. On review, 390 are artifacts (the row was modified midway through the day by a support fix), 180 are improvements (the incumbent was applying an outdated scope), and 70 are bugs, of which 55 are one parser defect. Fix the parser, re-run those 55 rows, and the true bug count falls to 15. Then fix the artifact source, which in this case is a race between two jobs, and the disagreement rate drops to about 3 percent on day five. That is the shape of a week that ends in a cutover decision, and the only way to see it is to record the four categories separately.

LabelDefinitionAction
BugCandidate wrong on the merits, or crashed, timed out or returned an invalid verdictTicket, fix, re-run the same rows
ImprovementCandidate right, incumbent wrong or too coarseLog with justification and an owner who accepts it
ArtifactInput or environment differed between the two evaluationsFix the pipeline, not the checker
UndecidedNeither side is clearly rightTime-box it, then treat as a bug until proven otherwise

The "undecided" row matters. Ambiguous matches are a normal part of any matching problem, and recognizing them as a category rather than forcing them into pass or fail is how mature resolution systems are built. If undecided cases are more than a few percent of disagreements, your specification is underspecified, not your checker.

Deciding when a week is enough

The duration is a floor, not a target. Seven days is a reasonable minimum because it captures a full weekly traffic cycle, including the day your batch jobs run and the day support works on escalations. But the real gate is coverage: you want several thousand shadowed rows and evidence that each high-stakes edge case appeared at least once. Check coverage daily rather than at the end, because if a category never appeared you may need to wait, or to seed a shadow subset deliberately.

Use these criteria as the cutover gate:

CriterionThresholdNotes
Shadow durationAt least 7 consecutive daysNo gaps longer than an hour
Shadowed rowsSeveral thousandEnough for the disagreement sample to stabilize
Bug rate on final 2 daysZeroIncludes crashes and invalid verdicts
Improvement diffsReviewed and acceptedEach with an owner
Latency p95Within your agreed budgetCompare candidate against incumbent
Artifact rateLow and explainedA rising rate means fix the harness first

The bug-rate-to-zero rule on the last two days is the one people argue with, and it is the one worth defending. If you cut over with a known bug rate, the first days of production become your shadow window, except now the mistakes are real. Two clean days is a low bar that nonetheless forces you to finish the queue rather than carry it forward.

There is a second condition people underweight: the improvement diffs. It is entirely possible to reach zero bugs and still not be ready, because the candidate promotes rows you actually wanted held. Read those diffs as a policy decision, not a test result.

Keeping the evidence after cutover

Shadow logs are audit logs, and audit logs are useless if they are not queryable when somebody asks. Retain the shadow window, the replay set, and the disagreement queue with their labels for at least as long as your change management policy requires. Auditors treat logs as primary evidence and expect retention beyond the lifetime of the system that produced them, which is a good default even if you are not in scope for any particular framework.

Two habits make the record durable. First, store the labels with a link to the row hash so a later reader can reconstruct why a decision was made. Second, when the candidate goes live, keep a residual shadow: run the old incumbent in shadow for a short period after cutover so you can compare live behavior against the baseline you validated against. The direction reverses, but the mechanics are the same, and it catches the class of bug where the candidate behaves differently once it is allowed to write.

If the shadowing harness itself needs to run at volume across several models, one API key for text, image and embedding models removes a chunk of credential and routing work; Quanta Inference is one option, and the logging schema above is model-agnostic either way. And if your team would rather hand the harness and the triage queue to someone who has built this before, a consulting call is the shorter path.

The one thing not to do is treat shadowing as a formality to schedule after the real decision has been made. The value is in the queue and the labels, and those only exist if someone reads them daily.

FAQ

FAQ

How long should the shadow mode run before cutover?

At least seven consecutive days, or until you have several thousand shadowed rows that include the edge cases you care about, whichever is later. The seven-day floor exists to cover a full weekly traffic cycle. If a high-stakes category never appears in the window, extend it rather than declaring coverage you do not have.

What is the difference between a shadow deployment and a canary?

A canary routes a small share of real users to the new version, so early users see the new behavior and absorb any mistakes. A shadow sends a copy of live traffic to the new version and never returns its output, so no user is exposed. Canary is the step after shadowing, once shadow has answered whether the system works at all.

How do I tell a bug from an environment artifact?

Ask whether the two checkers saw the same input. If the payload hash matches and both had the same lookup results, a diff is a bug or an improvement. If the row changed between evaluations or a dependent service answered differently, it is an artifact. Recording event time and acceptance time separately is what makes this answerable per row.

Should I fix every disagreement before cutover?

No. Fix every bug, accept or reject each improvement on the record, and eliminate artifacts at the pipeline level. Undecided cases can be time-boxed, but if they are more than a few percent of disagreements your specification is the thing that needs work, not the checker.

What should happen to the shadow logs after the cutover?

Keep them as audit evidence for at least your change management retention period, with the triage labels attached. Then run the old incumbent in shadow for a short period after cutover, reversing the direction, to catch behavior that only shows up once the new checker is allowed to write.

Sources

Sources

  1. Shadow mode deployments
  2. Regression Testing: An In-Depth Guide for 2026
  3. Defending Agent Memory Against Poisoning | Zep Blog
  4. When Not to Write Memory: Governing False Promotion ...
  5. Audit Logs Are Not Optional: The Hidden Backbone of ...
  6. What Is Entity Resolution? How It Works & Why It Matters
  7. SOC 2 Logging Pipelines: Key Requirements, Steps, and ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles