# 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` | email | 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.](#audiences) |
| `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.](#exclusion) |
| `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: US` matches when the user's `country` attribute equals `"US"`.
-   **Sequence membership.** `country: [US, CA]` matches when the user's `country` is 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:

1.  It is declared in `dif/config.yaml` under `audience_attributes`. Anything undeclared fails `dif validate` with `E006`.
2.  It has a resolver file at `dif/audiences/<name>.ts`. A declared attribute with no resolver fails with `E008`.
3.  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`](https://dif.sh/docs/cli/#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.

> // note Disjointness analysis is conservative: it only proves separation via include-set intersection on a shared attribute. `include: country=US` plus `exclude: country=US` is not yet recognized as disjoint. A shared `exclusion_group` works in every case.
