Point it at your stack

· 9 min read

Your test profile is one of sixty-four emails

You load the preview against your own test profile. The name resolves, the tier block shows the right copy, the CTA points where it should. Ship. What you just approved is one arm of a template that branches six ways, which means you approved one of sixty-four possible emails and signed off on the other sixty-three by implication. The uncomfortable part is not the arithmetic. It is that the sixty-three are not equally likely to be caught later.

Justin Williames

By Justin Williames

Founder, Orbit · 10+ years in lifecycle marketing

SharePostPost

The asymmetry that makes this worth an afternoon

Every branch in a template creates two emails. Six branch points and you have sixty-four, and no amount of care in reviewing one of them tells you anything about the other sixty-three. That much is obvious once said. The part that actually decides how much this matters is the asymmetry in how the defects surface.

The state most of your file lands in is self-testing. Everyone sees it — you, your colleagues, your CEO, the customer who replies. A defect there shows up the same day, gets fixed and becomes a story about a near miss. The rare arm has none of that. It ships broken, it stays broken and the only detector is a customer who is annoyed enough to write in. By the time they do, the send is three weeks old and the person who built it has moved on to two programs since. Nobody connects a complaint about a confusing email to a conditional in a template.

Which is why the pre-send checks most teams run are not just incomplete — they are miscalibrated. They apply their full attention to the state that would have caught itself, and none to the states that cannot. A gate that renders one state and prints PASS is doing something worse than nothing. It manufactures confidence in proportion to how little it checked.

The four shapes that get through a full review

Orbit's branch-coverage protocol was written around four defect shapes, each one a thing that passed a complete pre-send gate. They are worth knowing individually, because they fail in ways that do not look like failures.

An arm nobody can reach. A conditional gets hoisted or narrowed by an outer condition, and one branch becomes unreachable by construction. The copy inside it is fine. It is reviewed, it is approved and no recipient can ever receive it. Eighteen green gate stages will not tell you this, because every stage was inspecting text that exists.

A drop where a swap was intended. Two mutually exclusive modules end up pointed at the same arm, so one population gets both and the other gets a hole. Every arm is still reachable. The body still clears any minimum-length check. And here is the detail that should worry you: the gate verdict is byte-identical before and after the defect is introduced. Nothing about the output moves. One whole segment receives an email with the argument missing from it.

An unmodelled construct rendering to everyone. When a resolver meets a tag or filter it does not understand and drops the tag while leaving its body inline, a guarded block goes out unconditionally and its condition is never evaluated. This is the quiet one. Every other verdict in the run was computed on a document the resolver did not fully understand, which means none of them mean anything — which is why the correct behaviour is a hard failure rather than a best-effort render with a warning.

Two parsers disagreeing about what true means. One place in your template compares against the literal 'true', another normalises with | strip | downcase first. On a clean boolean they agree perfectly. They diverge on exactly the values a real CRM stores: "True" from one integration, "1" from another, " true " with a space that came in through a CSV years ago. Feed each flag all the spellings and watch whether the branch decision moves. If it does, your segment and your template disagree about the same customer.

None of these are exotic. They are the ordinary consequences of templates being edited by several people over months, which describes every lifecycle program that has survived long enough to matter.

How to actually enumerate it

The mechanic is straightforward. The constraints around it are what require judgement. Run orbit_liquid_state_matrix on the compiled HTML with the Liquid still in it. Compiled, because that is the artefact that ships. With the Liquid intact, because a resolved render has no branches left to enumerate — feed it a preview export and the honest answer is that there is nothing to check, which the tool will say rather than reporting a pass.

The axes come from the template, not from a list you keep. Every custom attribute, property or global the template branches on becomes an axis automatically. This sounds like a convenience and is actually the whole design: a maintained list of things-to-test is a list somebody has to remember to extend. It silently stops covering the template the first time someone adds a conditional without updating it. A list like that can skip most of a program's sends while printing PASS the entire time, and everyone involved will believe the program is tested.

Non-boolean axes need their values supplied — {"loyalty_tier":["gold","silver","bronze"]}. Anything you do not list is treated as a flag and gets true and false. Supplying values does a second job. It tells the tool the axis is not a flag, so it skips the truthiness-spelling check for it.

The Liquid branch coverage skill carries the full protocol. If you are writing the snippets in the first place, the Liquid builder gets the fallback into the snippet at authoring time, which removes an entire class of finding before it exists. The Liquid reference covers the syntax itself.

Reading the result without over-reading it

A coverage run returns invariants rather than a score, and each failure means a specific thing about your template.

FailureWhat it means
Token reached the DOMA recipient sees a raw templating token as literal text in the email body.
Unmodelled filter or tagThe resolver did not understand part of the template. Every other verdict in the run was computed on a document it misread — treat the whole run as void, not as one failure among passes.
Near-empty stateSome combination of inputs renders an email with almost nothing in it.
Else-arm dropA branch with an else arm removes blocks instead of swapping them. One population is receiving a hole where the other gets content.
Dead armA conditional arm was taken in none of the enumerated states. Unreachable by construction.
Truthiness disagreementTwo places in the template disagree about what on means for the same attribute.

One deliberate restraint worth understanding: the else-arm check only fires where the author actually wrote an else or elsif. An {% if %} with no else is a legitimately optional module — a promo strip that appears for some people and not others is correct, not broken. A checker that flagged those would fire on nearly every well-built template, and a checker that fires on everything gets muted within a week. Restraint in what a tool reports is the difference between a gate a team keeps and one they route around.

Test the tests, or you have moved the problem up one level

Here is the part that almost no email QA process has ever had. It is also the reason to trust any of the above. A negative test seeds known defects into your own template and asserts that each check fires on them — plus that the unmutated control still passes. Both directions matter. A check that only ever passes and a check that only ever fails are equally worthless. You cannot tell which one you own from a run that came back green.

It also reports when a seeded defect failed to apply, as a distinct outcome from pass and fail. That distinction is the whole point. A mutation that stopped matching your template without complaint — because the markup changed, because a class was renamed — leaves a test reporting green while testing nothing, which is precisely the defect this exercise is about, one layer up. If your QA cannot tell "checked and clean" from "did not check", you have a confidence generator, not a gate.

What this does not prove, and the count to do today

Branch coverage is coverage of branches. It proves every arm renders and none of them is dead, empty, or accidentally universal. It says exactly nothing about whether a token resolved to the right value at send time — whether the name was the customer's name, whether the tier attribute was fresh or eleven months stale, whether the segment feeding the branch means what its name implies. That is a data problem, and a live multi-state test cohort in your ESP still owns it. No amount of static analysis substitutes for a real profile with real attributes going through a real send. The two checks are complements, and the custom attributes guide covers the half that decides whether your attribute values are trustworthy in the first place.

The count to do today takes five minutes. Open your highest-volume personalised template and count the conditionals — every if, every unless, every dynamic-content block. Raise two to that power. That is the number of emails your program sends, and if the number is bigger than the number of states anybody has ever looked at, you already know what the next hour is for.

Start with the arm nobody has seen. Not the one you suspect. The one with the smallest audience, that shipped nine months ago, that nobody has had a reason to open since.

Read next

Liquid builder — write the snippet with its fallback

Frequently asked questions

Is this Braze-specific?
No. Liquid is the templating language Braze and Klaviyo both use, and the enumeration works on compiled HTML from either. What changes per platform is what the axes are called — custom attributes in Braze, profile properties in Klaviyo — and how the platform coerces values, which is the source of the truthiness-disagreement class of defect.
Our template branches on eleven attributes. Is that 2048 renders?
It is, which is why a cap and an abstain exist. In practice you pin the axes that are genuinely independent of the content — locale in a template with one language, a flag that has been true for every customer since launch — to a single value, and enumerate the rest. Pinning is a decision you record. Sampling is a decision you hide.
How often should this run?
Every time the template changes, and before the first send of any template whose content branches at all. It is fast enough to sit in front of the send rather than beside it, and a check that only runs when someone remembers will run on the calm sends and skip the rushed ones.
What about branches that live in the ESP rather than the template — Canvas splits, flow filters?
Different layer, and this does not see them. A Canvas audience split decides who gets which message; branch coverage decides what each message renders. Both need checking, and the split logic belongs in the build sheet and the Canvas QA pass rather than here.

This post is backed by an Orbit skill

More in Point it at your stack

Found this useful? Share it with your team.

SharePostPost

You finished the playbook. Get the next.

New guides and product updates land in your inbox when they ship. One list, real lifecycle work, unsubscribe the second it stops being useful.

Guides and Orbit updates only. No sequences, no selling your address.

Use this in Claude

Claude can run this playbook for you.

Orbit is a free extension for Claude Desktop — no licence key, no card — that runs the lifecycle work you just read about. You've read how it works; Orbit hands Claude the same playbook as a skill it can execute: discovery, build, QA, push, on your own ESP.

Download Orbit — free