# concepts v0.1

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

fieldtyperequirednotes
idstringyesKebab-case. Must match the filename stem. Unique across the workspace.
statusenumyesdraft · active · concluded · archived. Only active experiments are emitted by dif build and assigned by dif qa.
owneremailyesOne human accountable. Validated as a syntactic email.
surfacestringyesSurface id. Must resolve to dif/surfaces/<name>.md.
hypothesisstringyesOne paragraph. Folded scalars (>) keep it readable.
audiencemapnoOptional. include and exclude lists. Omitted means everyone. See below.
variantslistyesAt least one. Each has id, integer weight, optional summary. Weights sum to 100.
metricsmapyesprimary (string) plus optional guardrails (list).
exclusion_groupstringnoMutual-exclusion key. See below.
createddateyesYYYY-MM-DD. Used as the exclusion priority tiebreaker.
concludeddatenoSet 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-28checkout-cta-v2: "Get it today" lifted completed-checkout
  by 2.1% (CI 0.6–3.5%). Shipped. Returning visitors only.
- 2026-04-11trust-badges-row: no effect on conversion.
- 2026-03-02express-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.

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.

// noteDisjointness 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.