The question this answers
How do I test a failure assumption on a real system without the test becoming the incident?
A bounded experiment guarantees exactly two things: that the deviation from steady state is measured against a baseline recorded before the injection, and that the injection stops automatically when a pre-declared threshold is crossed. It guarantees nothing about the system under test — that is the point of running it.
Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.
The experiment runner knows the metrics it is subscribed to and the injection it has applied. It does not know whether a deviation it observes was caused by its injection or by something unrelated happening at the same moment — a deploy, a traffic spike, another team’s experiment. Attribution is inference, not observation, which is why an experiment needs a pre-recorded baseline and a control period rather than a before-and-after glance.
A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.
The six parts, in order
An experiment that is written down has six parts, and skipping any of them turns it into something else. Hypothesis: a falsifiable statement about what the system will do. Steady state: the measurable normal behaviour, recorded *before* anything is touched. Injection: the specific fault, at a specific blast radius, for a specific duration. Observation: the metric that decides the hypothesis. Abort condition: the threshold at which the injection is automatically reversed. Conclusion: what was learned, including "the hypothesis held", which is a real result.
The hypothesis must be falsifiable and specific. "The system is resilient to a node failure" is not a hypothesis; it cannot be shown false by any measurement. "When one of the three API replicas is killed, checkout success rate stays above 99.5% and p99 stays under 800ms for the full five minutes" is a hypothesis, because a single run can refute it.
Note the direction of the hypothesis. You state what you *expect*, then try to break the expectation. An experiment run without a stated expectation cannot fail — whatever happens, someone will explain afterwards why that was the expected behaviour. This is the operational version of pre-registration, and it exists for the same reason.
If you cannot measure steady state, that is the finding
The second step defeats more experiments than any other. To claim the system deviated, you need a number that describes normal, with its normal variance, before you touch anything. Not "it feels fine" — a measured distribution: checkout success rate at 99.7% ± 0.1 over the last fourteen days, p99 at 480ms ± 60, queue depth under 200.
Teams routinely discover at this step that no such number exists. The success-rate metric mixes two products; the latency metric is an average, so a doubled tail is invisible in it; the queue-depth gauge is scraped every 60 seconds and the interesting behaviour lasts 20. Stop there and report it. "We cannot measure our steady state" is a more valuable result than any injection would have produced, because it means that during a real incident nobody will be able to tell whether the system is recovering.
It is also the cheapest finding available. You reached it with zero risk to production, before injecting anything, and the work it generates — a real SLI, a percentile instead of a mean, a scrape interval shorter than the phenomenon — is work you needed anyway. This is the honest answer to "we are not mature enough for chaos engineering": the first experiment you cannot run is already telling you what to fix.
# usable: a distribution, a window, and a variance checkout_success_rate 99.71% +/- 0.08 (14d, 5m buckets) checkout_p99_ms 482 +/- 61 (14d, 5m buckets) order_queue_depth <200 (never above 340 in 14d) # not usable, and each for a different reason avg_latency_ms 120 <- a mean; hides exactly the tail we are testing success_rate "good" <- not a number queue_depth_gauge scraped every 60s <- shorter than the effect we expect error_rate mixes checkout + browse <- deviation cannot be attributed
The abort condition is what makes it an experiment
An abort condition is a pre-declared threshold, expressed in the same metrics as the steady state, that automatically reverses the injection when crossed. It must be automatic. A human watching a dashboard is not an abort condition — during the ten seconds it takes to notice, interpret and act, the thing you were worried about has already happened, and the human is the person least willing to admit their experiment is going badly.
The rule is uncomfortable but simple: without an automatic abort condition, you have not run an experiment, you have caused an outage on purpose. The distinction is not moral, it is operational: an experiment has a known worst case, and the abort condition is what makes the worst case known. It converts an unbounded risk into a bounded one, and bounding the risk is the only reason it is acceptable to do this in production at all.
Two details matter. First, the abort path must be tested before the experiment — a revert that has never been exercised is exactly as reliable as an untested fallback, which is to say unknown. Second, hitting the abort condition is a *successful* experiment: you learned that the hypothesis is false, and you learned it in a bounded window at a time of your choosing with the whole team watching, rather than at 03:00 on a holiday weekend.
- Automatic: the runner reverts the injection itself, on a threshold, without a human in the loop.
- Expressed in user-visible metrics: abort on checkout success rate, not on CPU on the injected node.
- Time-bounded independently: the injection also reverts after N minutes even if no threshold is crossed.
- Independent of the injected component: an abort that reads its signal from the thing you broke cannot fire.
- Tested first: run the revert path once with no fault injected, and confirm it restores the system.
1const experiment = {2 hypothesis:3 'Killing one of three checkout replicas keeps success rate above 99.5% ' +4 'and p99 below 800ms for the full 5 minutes.',5 6 steadyState: { // measured BEFORE injection, not asserted7 window: '14d',8 checkoutSuccessRate: { mean: 0.9971, sd: 0.0008 },9 checkoutP99Ms: { mean: 482, sd: 61 },10 },11 12 injection: { kind: 'node-crash', target: 'checkout-replica-2', blastRadius: '1 of 3' },13 duration: { minutes: 5 },14 15 // Not optional. The runner enforces the threshold; no human is in this loop.16 abort: {17 when: [18 { metric: 'checkout_success_rate', below: 0.99 },19 { metric: 'checkout_p99_ms', above: 1500 },20 { metric: 'order_queue_depth', above: 1000 },21 ],22 action: 'restore-replica',23 // read from a path that does not traverse the injected component,24 // or the abort signal dies with the thing you broke.25 signalSource: 'edge-metrics',26 },27 28 rollbackTested: true, // the revert path was exercised dry first29} as constBlast radius is a dial, and it starts small
The other bound is scope. Every experiment declares how much of production it can affect, and the first run of any new hypothesis takes the smallest setting that can still refute it: one instance, one shard, one percent of traffic, one non-peak hour, staging first if the hypothesis is about a mechanism rather than about production scale.
The dial only turns up after the hypothesis has held at the smaller setting. This ordering is what makes the practice defensible to the people whose revenue is at stake — you are not asking permission to break production, you are asking permission to break 1% of it for four minutes with an automatic stop, and you have already shown the same test passing at a smaller radius.
The uncomfortable truth about scaling the radius: many failure modes only appear at scale, because they are about resource exhaustion, retry amplification or correlated capacity. A one-instance experiment cannot refute a hypothesis about [[cascading-failure]]. So the dial has to move eventually, and the discipline is that it moves one notch at a time with the abort condition re-derived at each notch.
Key points
- Six parts: hypothesis, steady state, injection, observation, abort condition, conclusion.
- The hypothesis must be falsifiable — "the system is resilient" is not one.
- Steady state is measured before the injection. If it cannot be measured, stop: that is the finding.
- The abort condition must be automatic and must not depend on the component being broken.
- Hitting the abort condition is a successful experiment, not a failed one.
- Blast radius starts at the smallest setting that could still refute the hypothesis.
The chain, answered
Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.
- • Write the hypothesis as a statement a single run can show false.
- • Record the steady-state distribution over a window long enough to include normal variance.
- • Declare the injection, its blast radius and its maximum duration.
- • Declare the abort thresholds in user-visible metrics, with a signal path independent of the injected component.
- • Dry-run the revert path with no fault injected.
- • Inject, observe against baseline, and let the runner revert on threshold or on elapsed duration.
- • Record the conclusion, including "held" — and re-run it later, because a hypothesis that held is only true of the system as it was that day.
- • The abort signal is served by the component under test and goes silent exactly when it is needed.
- • The revert path has never been exercised and does not restore the system.
- • A deploy, traffic spike or unrelated incident coincides with the injection and the deviation is misattributed.
- • The blast radius is larger than declared because the injected component is shared by something not on the diagram.
- • The injection does not actually apply — the fault is swallowed by a layer in between and the "held" conclusion is meaningless.
- • Experiment becomes incident: the operator sees error rate climbing past the abort threshold with the injection still active, because the abort was a human watching a dashboard who was in a meeting.
- • Unattributable result: the operator sees a deviation during the injection window and cannot say whether it was the experiment or the deploy that shipped four minutes earlier, because no control period was recorded.
- • False pass: the operator sees no deviation at all, and later discovers the fault never reached the target — the injected node was already out of rotation.
- • Abort blindness: the operator sees the abort condition never fire during a genuine failure, because the metric it reads is scraped through the injected service.
- • Radius overrun: the operator sees two unrelated services degrade during an experiment scoped to one, because all three share a connection pool that appears on no architecture diagram.
- • Announcing the experiment window is coordination with humans, and it is mandatory: an unannounced experiment consumes another team’s on-call time diagnosing your injection.
- • Deploy freeze during the window is cheap coordination that buys attribution — one fewer variable changing at the same time.
- • The runner needs no coordination with the system under test, and should not have any: an injection that requires the target’s cooperation cannot model the target being gone.
- • The abort path must be coordination-free in the failure case — it cannot require a quorum, a lock or a call into the broken component to reverse itself.
- • While the injection is active, the system provides whatever guarantee it genuinely has — that is what is being measured, and it may be less than the documented one.
- • The abort condition bounds the *duration* of the deviation, not its severity within the threshold window.
- • State that drifted during the experiment window is real drift and needs the same reconcile step as a real incident — an experiment is not exempt from
[[reconciliation]]. - • If the abort path itself fails, the fallback is the declared maximum duration, which is why the duration bound is a second independent safety net.
- • Detect: the runner compares live metrics to the recorded baseline continuously, not at the end.
- • Contain: the abort condition fires and the injection is reverted automatically; the maximum duration bounds the case where the abort logic fails.
- • Recover: confirm the injected component is back in rotation and serving, and that dependent queues have drained.
- • Reconcile: repair anything derived that drifted during the window, exactly as after a real incident.
- • Verify: re-measure steady state and compare it to the pre-experiment baseline before declaring the window closed.
- • The baseline itself, recorded and stored with the experiment record — an experiment without its baseline is not reproducible.
- • Deviation from baseline in the user-visible metric named by the hypothesis, sampled finer than the effect you expect.
- • Whether the abort condition fired, when, and how long the revert took to take effect.
- • A control period immediately before the injection, so a coincident unrelated change is visible as a pre-existing deviation.
- • Proof that the injection actually applied — a signal from the target, not from the runner’s intent.
- • Testing a specific resilience assumption that the team has stated but never checked: "we can lose a zone", "the breaker opens in time", "the replica promotes in 30 seconds".
- • Validating a fix after an incident, where the hypothesis is precisely the failure that just happened.
- • Discovering that steady state is unmeasurable — a finding you get before taking any risk at all.
- • On a system already known to be fragile: if you are confident the experiment will fail, you do not need the experiment, you need the fix.
- • Without an automatic abort and a bounded duration, in which case it is not an experiment.
- • During a period of high business risk — a launch, a sale, a filing deadline — where the cost of being wrong is not the cost you priced when you designed the experiment.
- • A game day: humans walk through the failure scenario against a runbook with no injection. Cheaper, safe, and it finds missing runbooks and unclear ownership — but it cannot find behaviour nobody predicted.
- • Staging or a load-test environment: safe and repeatable, but it does not reproduce production’s scale, traffic mix or shared infrastructure, which is where most of the interesting failures live.
- • Reading the incident history: your past incidents are free experiments that already ran. Re-deriving hypotheses from them is the highest-value place to start.
- • Failure-mode review on paper (an FMEA-style walk of the dependency graph) to pick which hypothesis is worth testing before spending risk on any of them.
Build an experiment — the abort condition is not optional
What people believe, and what is true
The abort condition is paperwork we can add later.
It is the property that bounds the worst case. Without it the activity has no known upper bound on harm, which is the definition of an outage rather than an experiment.
A human watching the dashboard is the abort condition.
Noticing, interpreting and acting takes tens of seconds, and the person watching is the one most invested in the experiment continuing. Abort must be automatic.
If the experiment aborts, it failed.
It succeeded. You refuted a false belief inside a bounded window at a time you chose, instead of discovering it during a real incident.
We cannot measure steady state, so we cannot do this yet.
You just did the first experiment and it produced a finding: during a real incident nobody will be able to tell whether the system is recovering. Fix that, and you have both a better system and a baseline.
A hypothesis that held means the system is resilient.
It means the system was resilient to that fault, at that radius, on that day, at that traffic level. Every deploy invalidates the result, which is why experiments are repeated rather than ticked off.
Go deeper
Only the levels this lesson can honestly fill — a missing level is a claim nobody had.
Overview
Hypothesis, steady state, injection, observation, abort, conclusion. The abort condition must be automatic; without it you are not experimenting, you are causing an outage deliberately.
Practical
Write the hypothesis so one run can refute it. Record the baseline distribution before touching anything. Give the runner abort thresholds in user-visible metrics read through a path that does not cross the injected component, plus a hard maximum duration. Dry-run the revert. Start at the smallest radius that could still refute the hypothesis.
Advanced
The structure is an attribution argument. You are claiming a causal link between an injection and a deviation, on a system with many uncontrolled variables, from a single run. The baseline supplies the counterfactual, the control period and deploy freeze remove the obvious confounders, and the blast radius bounds the cost of being wrong about all of it. Read the result as evidence about that configuration on that day — not as a property of the system.
Apply it
- 🔧 Take a resilience claim your team makes in a design document and rewrite it as a hypothesis with a baseline, an injection, an abort condition and a blast radius.
- 🔧 Pick an existing abort condition and trace its metric path. Confirm the signal does not travel through the component the experiment breaks.
- ⚡ An experiment scoped to one replica of one service degrades two unrelated services. Nothing on the architecture diagram connects them. Where do you look first?
- 💬 Write a falsifiable hypothesis for the claim "our service survives losing a zone".
- 💬 Your experiment has no automatic abort, but an experienced engineer will be watching the dashboard. What is wrong with that?
- 💬 You go to record steady state and find only an average latency metric over a mixed set of endpoints. What do you do next?