Skip to main content

1. Define the flag namespace

Index · Next: Lint on every commit and every PR

A boolean flag with one rule is too small to surface most issues a flagging system has to defend against. Replace welcome-banner.toml with a three-variant string flag and a percentage-bucketed A/B/C rollout, scoped to US users only (compliance is happier; the rest of the world keeps seeing the control copy). This is the flag every later chapter operates on.

The flag

marketing/flags/welcome-banner.toml

# Pin the manifest schema version this file targets. Required at the top of
# every manifest file (`namespace.toml`, every `flags/*.toml`, every
# `segments/*.toml`).
schema_version = "0.1"

[flag]
# What an evaluation returns. `string`, `boolean`, `integer`, `float`, `json`.
# Every variant value below must match this type.
type = "string"
description = "Headline copy for the welcome banner. A/B/C tested. US only."
owner = "growth-team" # Who to ask when reviewing edits.
lifecycle = "active" # `active` | `deprecated` | `permanent`.
tags = ["experiment", "banner", "us-only"] # Free-form labels for search and filtering.

# The full menu of values a successful evaluation can return. Every `variant =`
# below — both the catch-all and the rules — must reference one of these keys.
[flag.variants]
control = "Welcome aboard."
treat_a = "Glad to have you."
treat_b = "Let's get started."

# `_` is the catch-all env block. It defines what happens in every env that
# doesn't declare its own. This tutorial uses one block for every env. (Once
# you have a `staging` env that should behave differently, add a
# `[flag.environments.staging]` block beside this one.)
[flag.environments._]
variant = "control" # Returned when no rule below matches — non-US traffic, missing user.id, etc.

# Rules are walked top-to-bottom; the first match wins. The three rules below
# partition US users into three equal buckets that each return a different
# variant. A user who matches none of them falls through to `variant = "control"`
# above.

[[flag.environments._.rules]]
description = "Bucket 1: control (US only)"
segment = "welcome-banner-bucket-control"
variant = "control"

[[flag.environments._.rules]]
description = "Bucket 2: treatment A (US only)"
segment = "welcome-banner-bucket-treat-a"
variant = "treat_a"

[[flag.environments._.rules]]
description = "Bucket 3: treatment B (US only)"
segment = "welcome-banner-bucket-treat-b"
variant = "treat_b"

The three rules reference bucket segments — small files that partition user.id into deterministic, non-overlapping ranges and gate the rule on user.country = "US". The canonical predicate-plus-bucket shape (see docs/reference/manifest/06-segment.md § Three segment shapes) makes membership conjunctive: an entity matches the segment only if both the predicate matches and the bucket hits. Non-US users miss every rule and fall through to variant = "control" on the catch-all _ block.

The segments

marketing/segments/welcome-banner-bucket-control.toml

schema_version = "0.1"

[segment]
description = "US users in the control bucket (first 33 %)."

# A predicate-plus-bucket segment matches an entity only when BOTH the
# predicate matches AND the entity's bucket hash falls in `start..=end`.
# That's how the segment expresses "US users in the first 33 % bucket"
# as one piece. See `docs/reference/manifest/06-segment.md`.

[segment.predicate]
attribute = "user.country" # The context attribute the rule reads.
op = "eq" # Comparison operator. See 11-linter-rules for the full list.
value = "US"

[segment.bucket]
entity_id_attribute = "user.id" # The context attribute exd hashes to pick a bucket.
salt = "welcome-banner-2026" # Shared across the three bucket segments so they partition the same assignment space.
start = 0 # Inclusive; hash space is 0..=9999.
end = 3299 # Inclusive. Range size 3300 ≈ 33 % of 10 000.

marketing/segments/welcome-banner-bucket-treat-a.toml — same shape, start = 3300, end = 6599. marketing/segments/welcome-banner-bucket-treat-b.toml — same shape, start = 6600, end = 9999.

The shared salt is what makes the three segments partition the same assignment space. See docs/reference/manifest/08-buckets.md for the full bucketing model.

See the big picture with exd explain

You've just authored four files. Before you write a single line of application code, run exd explain and read the whole shape of the flag in one screen:

$ exd explain welcome-banner --env production --manifest marketing
=== Variants & metadata
variants:
control string "Welcome aboard."
treat_a string "Glad to have you."
treat_b string "Let's get started."
owner: growth-team
lifecycle: active
tags: [experiment, banner, us-only]

=== Resolution walk
[flag.environments.production].rules SKIPPED — env did not declare its own
[flag.environments.production].variant SKIPPED — env did not declare its own
[flag.environments._].rules
rule[0] segment `welcome-banner-bucket-control` variant: control
rule[1] segment `welcome-banner-bucket-treat-a` variant: treat_a
rule[2] segment `welcome-banner-bucket-treat-b` variant: treat_b
[flag.environments._].variant = control

=== Rules breakdown
rule[0]: control
audience: segment `welcome-banner-bucket-control`
source: flags/welcome-banner.toml:21
rule[1]: treat_a
audience: segment `welcome-banner-bucket-treat-a`
source: flags/welcome-banner.toml:26
rule[2]: treat_b
audience: segment `welcome-banner-bucket-treat-b`
source: flags/welcome-banner.toml:31

default: -> variant `control` (returned when no rule above matches)

=== Segments referenced (tree)
├─ welcome-banner-bucket-control predicate+bucket
│ predicate: user.country == "US"
│ bucket: entity_id_attribute=user.id salt="welcome-banner-2026" range=[0,3299]
├─ welcome-banner-bucket-treat-a predicate+bucket
│ predicate: user.country == "US"
│ bucket: entity_id_attribute=user.id salt="welcome-banner-2026" range=[3300,6599]
└─ welcome-banner-bucket-treat-b predicate+bucket
predicate: user.country == "US"
bucket: entity_id_attribute=user.id salt="welcome-banner-2026" range=[6600,9999]
bucketing attribute: user.id (from segment `welcome-banner-bucket-control`)

=== Required context (this flag in 'production')
user.id string required by segment `welcome-banner-bucket-control` (bucket)
user.country string required by segment `welcome-banner-bucket-control` (predicate, eq)

Five things to confirm before you move on:

  1. Variants match the menu in your head. Three string variants, content matches the experiment doc.
  2. Resolution walk falls through to _'s rules. No [flag.environments.production] block means step 1 and step 2 are SKIPPED, and we land on the three bucket rules under _.
  3. Default is control. When all three rules miss (non-US, missing user.id), the engine returns control. That's the safety net.
  4. Segment tree is three sibling predicate+bucket segments, all sharing the same salt, with non-overlapping ranges [0,3299], [3300,6599], [6600,9999]. If a range overlap snuck in, the tree would show it immediately.
  5. Bucketing attribute is user.id. That's the identifier the SDK will hash for bucket assignment.

If anything in those five looks wrong, fix the TOML before continuing. exd explain is the cheapest review tool you have — every later chapter is more expensive to redo.

Counterfactual probe. Add --ctx user.id=u_42 --ctx user.country=US to the command to also see what one specific context resolves to. The eighth section, Counterfactual outcome, then renders the exact treat_a/treat_b/control answer for that input — useful for sanity-checking before you sit down to write fixtures in Chapter 4.

Pitfalls and notes worth knowing

exd explain also surfaces a Pitfalls section for situations that may surprise a reviewer (catch-all rules being skipped, rule order changing bucketing) and a Notes section for configured state worth knowing (public-eval gating, redacted attributes, raw entity ids, untyped-env mode). Neither fires a lint diagnostic — they're gotchas, not errors — but they're often the difference between a flag that ships clean and one that ships with a quiet surprise.

The flag above has none of those today. The Pitfalls section will become non-empty in Chapter 5, when we add a per-env testing block on top of the shared _ rules — read that chapter's exd explain output carefully.

Next

Chapter 2 — Lint on every commit and every PR. The flag is authored; now make sure nobody can break it.