The .md format.
One file per experiment. YAML frontmatter, freeform body. Surfaces and audience resolvers sit
alongside under dif/.
An experiment is a file
The filename is the kebab-case id with a .md extension, under dif/experiments/active/. Frontmatter is delimited by --- at the top. The
body is markdown with three convention sections: ## Brief, ## Rationale, ## Decision.
--- id: checkout-cta-v2 status: active owner: ada@acme.dev surface: checkout hypothesis: > A more urgent CTA copy on the checkout button will lift completed-checkout rate among returning visitors by reducing hesitation on the final step. audience: include: - returning_visitor: true exclude: - country: [US-CA] # legal hold variants: - id: control weight: 50 - id: variant_a weight: 50 summary: '"Get it today" copy' metrics: primary: completed_checkout guardrails: [refund_rate, support_ticket_rate] exclusion_group: checkout-copy created: 2026-05-14 --- ## Brief Returning visitors on checkout show a 14% drop-off between cart review and payment confirmation. Hypothesis: "Buy now" reads as transactional where "Get it today" reads as immediate-benefit. Copy only. ## Rationale Surface log shows prior copy tests at low traffic were inconclusive. Running 50/50 with a two-week minimum to clear the variance floor. ## Decision <!-- drafted by `dif conclude` -->
The body sections aren't mandatory at file creation, but dif conclude writes back to ## Decision by name. If you remove that heading, conclude appends one.
Frontmatter fields
| field | type | required | notes |
|---|---|---|---|
id | string | yes | Kebab-case. Must match the filename stem. Unique across the workspace. |
status | enum | yes | draft · active · concluded · archived.
Only active experiments are emitted by dif build and assigned by dif qa. |
owner | yes | One human accountable. Validated as a syntactic email. | |
surface | string | yes | Surface id. Must resolve to dif/surfaces/<name>.md. |
hypothesis | string | yes | One paragraph. Folded scalars (>) keep it readable. |
audience | map | no | Optional. include and exclude lists. Omitted means everyone. See below. |
variants | list | yes | At least one. Each has id, integer weight, optional summary. Weights sum to 100. |
metrics | map | yes | primary (string) plus optional guardrails (list). |
exclusion_group | string | no | Mutual-exclusion key. See below. |
created | date | yes | YYYY-MM-DD. Used as the exclusion priority tiebreaker. |
concluded | date | no | Set by dif conclude. Null while active. |
Surfaces
A surface is a logical area of the product: a screen, a feature, or a flow. Every experiment declares
one. Surface files live at dif/surfaces/<name>.md and carry three things: a
description, known landmines, and a learning log. The surface id comes from the filename, not the
heading.
# Surface: checkout The four screens between cart and confirmation. Single-page on desktop, three-step on mobile. Traffic skews returning. ## Known landmines - The address autocomplete is owned by a vendor. Do not test inside its DOM. - US-CA traffic is on legal hold until the privacy audit closes. - Apple Pay sheet eats clicks during exposure. Fire the event at render. ## Learnings - 2026-05-28 — checkout-cta-v2: "Get it today" lifted completed-checkout by 2.1% (CI 0.6–3.5%). Shipped. Returning visitors only. - 2026-04-11 — trust-badges-row: no effect on conversion. - 2026-03-02 — express-checkout-default: -1.8% on AOV. Reverted.
dif conclude prepends one line under ## Learnings: the conclusion date,
the experiment id, and the first line of the decision. Newest first. The next time anyone drafts on
the same surface, dif new reads the three most recent lines into the brief template as
an HTML comment.
Audience predicates
An audience is two lists of attribute predicates: include (all must match) and exclude (any matching disqualifies). Each predicate is one or more attr: value entries; multiple keys in one predicate must all match.
Supported value shapes:
- Scalar equality.
country: USmatches when the user'scountryattribute equals"US". - Sequence membership.
country: [US, CA]matches when the user'scountryis in the list.
audience: include: - returning_visitor: true - country: [US, CA, UK] exclude: - plan: free
There are no range, regex, asynchronous, or custom-operator expressions. The predicate language is
closed over the attribute schema you already keep. A missing or null included
attribute fails closed (the user is not in the audience). An empty audience (or no audience field) matches every user.
Every attribute needs a resolver
Each attribute referenced in a predicate must clear three bars:
- It is declared in
dif/config.yamlunderaudience_attributes. Anything undeclared failsdif validatewithE006. - It has a resolver file at
dif/audiences/<name>.ts. A declared attribute with no resolver fails withE008. - The resolver returns a scalar compatible with the declared type.
A resolver is a synchronous default-exported function:
// dif/audiences/plan.ts export default function resolve(): string | null { return window.currentUser?.plan ?? null; }
dif build bundles only the resolvers referenced by active experiments into dif/generated/audiences.ts, exposed as the attributes() helper you pass to dif.init(). Need the two starter resolvers in an existing project? Run dif scaffold-audiences.
Exclusion groups
Two active experiments targeting the same surface either share an exclusion_group or
have provably-disjoint audiences. Anything else fails the build with E007.
Same group, overlapping audiences. The runtime resolves in priority order:
earliest created wins, then by id alphabetically. Winners get assigned; losers get an ExclusionLoser outcome with the winner's id attached.
No group, disjoint audiences. Allowed. country: US vs country: UK can never both match the same user, so the build accepts them on the same
surface.
No group, overlapping audiences. Refused. Either declare the group (you opt into runtime mutual exclusion) or narrow the audiences (you prove disjointness). The diagnostic spells out both options.
include: country=US plus exclude: country=US is not yet recognized as disjoint. A shared exclusion_group works in every case.