# Spec By Example

> Clarify behaviour through concrete example tables before or during implementation. Trigger when requirements use vague terms ("eligible customers", "valid input", "appropriate discount"), when behaviour depends on multiple conditions whose combinations aren't fully worked out, when an edge case surfaces mid-implementation, or when the user asks to work through examples of a feature. Do NOT use when the user asks to write tests, convert tests, or create a @TableTest — use the tabletest skill instead, which has its own requirements decomposition workflow and will produce executable @TableTest code directly.

- Skill: `nchaugen/spec-by-example` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nchaugen/spec-by-example`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nchaugen/spec-by-example/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: nchaugen (https://skillmd.com/u/nchaugen)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nchaugen/spec-by-example

---


# Spec by Example

Use this skill when behaviour has multiple cases, conditions, or rules that are not
yet pinned down by concrete examples. The table becomes a shared specification —
readable and verifiable by domain experts and developers alike — and a natural
starting point for a TableTest once coding starts.

The approach is inspired by the FIT (Framework for Integrated Tests) workflow,
where teams work out business rules collaboratively by filling in tables of
examples. The goal is not a finished test, but enough agreed-upon examples to
implement with confidence.

## When to Use This Skill

This skill is useful at any point — before implementation begins, mid-way through,
or when revisiting a feature. The trigger is encountering conditional logic or
variation that needs examples to pin down, not the phase of development.

When you already have working tests to consolidate or refine and the requirements
are already clear, use `/tabletest` directly — it will produce executable
`@TableTest` code.

---

## Quick Example

An example table for car rental eligibility:

| Scenario                          | Customer Age | Has Licence | Car Category      | Eligible? | Reason?              |
|-----------------------------------|--------------|-------------|-------------------|-----------|----------------------|
| Standard adult customer           | 30           | yes         | Economy           | yes       |                      |
| Underage applicant                | 17           | no          | Economy           | no        | Under 18             |
| No driving licence                | 25           | no          | Economy           | no        | No licence           |
| Young driver in premium car       | 22           | yes         | Premium           | no        | Under 25 for Premium |
| Underage regardless of category   | 17           | yes         | {Economy, Premium}  | no        | Under 18             |
| Senior with valid licence         | 72           | yes         | Economy           | yes       |                      |

Key properties of this table:

- Written entirely in business language — no code, no types, no variable names
- Each row is a complete, verifiable example a domain expert can confirm or challenge
- Outputs (suffixed `?`) are traceable to input values
- One row uses multiple values (`Economy, Premium`) to show a rule holds regardless of category
- The table maps directly to a future `@TableTest` implementation

---

## Elicitation Workflow

**Start with the table that is clearest and most central, and let the others emerge.** Additional
tables announce themselves as rows that do not fit — that is the signal to split, and it arrives
during the conversation rather than before it. Do not design a set of tables upfront.

### 1. Name the Behaviour

Start by agreeing on what the table will describe. Use a verb phrase from the domain:

- "Car Rental Eligibility"
- "Blood Donation Deferral"
- "Medication Dose Calculation"
- "Waste Sorting Classification"

Getting the name right focuses the examples and later becomes the test method name.
If you cannot name it cleanly, the behaviour may be two concerns — keep that in mind.

### 2. Find the First Example

Ask for the simplest, most obvious case where the behaviour works as intended:

- "What does a typical successful case look like?"
- "Give me one concrete example — with real values — where this works."
- "Walk me through the default situation."

Write this as the first data row. The *set* of columns will change as more examples
arrive — but name each one in the domain's words as you write it, never in technical
ones you mean to fix later. There may be no later.

### 3. Identify the Columns

**Input columns** — what varies between examples:

- Ask: "What information does the system need to make this decision?"
- Ask: "What changes between one example and the next?"
- Each distinct piece of information becomes a column.
- Use the domain's own words (`Customer Age`, not `age`, `userAge`, or `int`).

**Output columns** — what the system produces or decides:

- Ask: "What is the system's response or decision?"
- Ask: "What do we verify to know the behaviour is correct?"
- Suffix output column names with `?` (`Eligible?`, `Deferral Period?`, `Error Message?`).

The `?` suffix is reserved for output columns — see *Name Expectation Columns Clearly* below.

### 4. Add More Examples

Work through variations systematically:

**Different outcomes** — what causes the decision to go the other way?

- "What makes the answer change from yes to no?"
- "What other rules apply?"

**Boundary conditions** — where exactly do rules trigger?

- "At what exact value does this rule kick in?"
- "What happens just at the threshold, just above, and just below?"
- Include rows at exact boundaries (e.g., 13 hours, 14 hours for a duty-time limit)
- Boundaries are where misunderstandings live — a table that only shows mid-range values illustrates rule *types* but does not *specify* the behaviour

**Special cases** — important situations that may surprise people:

- "Is there a case that surprises new team members?"
- "What's a common misunderstanding about this behaviour?"

**Missing or absent inputs** — what happens when information is not provided?

- "What if this field is empty or not given?"
- "Is there a sensible default, or does absence cause a rejection?"

### 5. Probe for Irrelevant Inputs

Ask which inputs the rule is indifferent to, and record the answer as data:

- "Does this rule still hold whatever the category is?"
- "Which of these inputs could I change without changing the answer?"

An input the expert says does not matter is a claim, and a claim needs a row that could
contradict it — see *Show That an Input Does Not Change the Outcome* below.

### 6. Ask for the Decision and the Calculation Separately

When the conversation mixes a decision with a calculation, split the questions:

- "How do you decide which band this falls into?"
- "Once you know the band, how is the number worked out?"

Two questions, two tables — see *Separate Rules from Arithmetic* below.

### 7. Ask for State as Before and After

When the behaviour involves state, ask for it as before-and-after rather than as a sequence:

- "What state is it in before this happens, and what state after?"
- "What does the system say when the action is not allowed from that state?"

A described sequence is not a table — see *Frame Stateful Features as Transition Rules* below.

### 8. Review the Table

Show the table to a domain expert (or read it as one) and ask:

- "Does every row describe a situation that can actually happen?"
- "Is the outcome in every row what you would expect?"
- "Is there an important case missing from this table?"
- "Are any two rows testing the same thing?"
- "Could a new team member understand each row without asking questions?"

### 9. Note What Is Still Open

Not everything needs to be resolved before coding starts. Mark uncertain cells or
add a notes column for open questions:

| Scenario             | Customer Age | Has Licence | Car Category | Eligible? | Open Questions         |
|----------------------|--------------|-------------|--------------|-----------|------------------------|
| Senior age limit?    | 75           | yes         | Economy      | ?         | Is there a maximum age? |

Open cells signal decisions that need resolving — through conversation, a domain
decision, or implementation exploration. They are not failures; they are honest
about what is known and unknown.

---

## Table Design

<!-- BEGIN GENERATED table-design — do not edit here; source is shared/table-design/ -->

### One Rule, One Axis

A table is one rule varying along one axis. The axis is what the rows change; everything else is
either held constant or collapsed into a value set. Most decomposition questions are that one
question asked again — *what is this table's axis, and does every column and row serve it?*

**If you cannot name a behaviour without using "and", it is two concerns.** Split them, and give each
its own table.

**The naming test passes on a conjunction, and a conjunction is still several rules.** A rule of the
form *"X holds only if C1 and C2 and C3"*, where the conditions do not mention one another, names
cleanly in one breath — "sorts waste into bins" — while being three independent claims. Give
each condition its own table, holding the others satisfied. Crossing them instead multiplies rows
without adding a claim, and no row then isolates the condition it was meant to show.

**The test is whether the rule can be *stated* about each condition alone — not whether the inputs
are separate.** Several inputs that are each a *contribution to one answer* are one rule, however
separately they arrive: quantities that are weighted and summed, amounts that accumulate into a
total, parts that combine into a whole. There is no claim to make about one of them by itself,
because the answer is the combination. Splitting those gives one table per input, each holding the
others at nothing, and **no table then shows them combining — which is the only interesting case**.
Keep them in one table with a column each, and let some of its rows carry several contributions
at once. Those rows belong to that table, which owns the combining rule; they are not a second
table run end to end — see *A Combining Table Needs Its Own Rule*.

Hold the inputs belonging to *other* concerns at one obviously-valid value. An input **this** rule
claims not to affect the outcome is the opposite situation and has to vary — see *Show That an
Input Does Not Change the Outcome*.

Separate tables reduce rows by avoiding unnecessary permutations, and the table count guides the
implementation: five concern tables suggest five functions.

| Scenario       | Hours Since Rest | Max Duty Hours (Policy) | Fit To Fly? |
|----------------|------------------|-------------------------|-------------|
| At the limit   | 13               | 13                      | yes         |
| Past the limit | 14               | 13                      | no          |

"Duty eligibility **and** rest credit" fails the "and" test — rest credit gets its own table.

### Include All Outputs of a Concern

When an operation produces several observable outputs, include them all as expectation columns in one
table. Each row then gives the complete picture of what happens for that scenario. Splitting the
outputs of one concern forces the reader to cross-reference several tables to understand one
behaviour.

Separate tables are for separate **concerns**, never for separate outputs of the same concern.

**Every expectation column must be exercised by the rows the table varies.** A column that is
constant down every row, or that changes only as a side effect of another column, is not being
tested. Two repairs, and which is right depends on the rule:

- **Give it rows that vary it**, when the column does belong to this table's axis and the rows
  were missing.
- **Move it to the table whose axis varies it**, and drop it here rather than keeping it "for
  completeness".

| Scenario            | Humidity % | Temp (C) | Vent Position? | Heater? | Alert?            |
|---------------------|------------|----------|----------------|---------|-------------------|
| Warm and damp       | 80         | 28       | OPEN           | off     |                   |
| Cold and damp       | 80         | 8        | CLOSED         | on      | Condensation risk |
| Within target range | 55         | 21       | CLOSED         | off     |                   |

All three outputs of one climate decision, so a reader sees the whole response per scenario.

### Decompose When You See These Signs

*One Rule, One Axis* gives the first test — a behaviour you cannot name without "and" is two
concerns. These are the signs that show up later, once the table exists:

- Some rows need columns that other rows leave blank throughout.
- Scenario names need qualifiers — "…for eligibility" against "…for pricing".
- The table has two groups of expectation columns that never both apply in the same row.

**Missing concern:** an input to one rule is itself derived from raw data. The derivation has its own
edge cases and needs boundary rows of its own. The rule's table then takes the *derived value* as
a direct input column, not the raw data. Two tables, not one.

**Ask of every input column where its value comes from.** Either it arrives from outside, or a rule
computes it — and a rule that computes it is a table you have not written yet. Two shapes say you
skipped it, and they look nothing alike: **the raw data is a column and the derived value is nowhere**,
so the derivation happens inside the rows where no row can put a boundary on it; or **both are
columns of the same table**, so the derived one restates a value already present and the rule
connecting them is legible only by reading the rows against each other. Split either way — the
deriving rule takes the raw data and reports the value, and this table takes that value as an input
column and never sees the raw data. **That the value must be visible is not the question**; which
table it is a column *of* is.

**A column blank throughout for most of its rows is a column decision before it is a table
decision.** Ask what the sparse columns feed. Several feeding the *same* expectation column are one
family: collapse them into one column keyed by member, and the table stays whole. Feeding
*different* expectation columns, they are different concerns and split into separate tables.

**Do not over-split either.** Several tables that fix the same setup, each vary one sub-rule, and all
report the same expectation column are one concern scattered — one table per adjustment, per option,
per flag. That shape is the symptom; the cause is a family you did not name.

**If you can name what several tables have in common in one term, they are one concern — that term is
the table, and its members are a column.** This is the mirror of the "and" test. Renal impairment,
low body weight and an interacting drug all *adjust the standard dose*: three rules, one family, one
table with an adjustment column. Naming the members instead commits to the split before a single
row exists, which is why this is decided when you name the table.

**Members of a family compute differently, and that is not a reason to split.** One adjustment is a
flat reduction, another a percentage, another a recalculation. The differing computation is what the
rows show; it is not what makes them separate tables.

**Collapsing a family means one table, not necessarily one column.** Where the members arrive as
*separate inputs* the system reads independently, a single column keyed by member cannot feed them —
routing one value to the right input would put a decision in the test itself, which is never the
answer. **Give each member its own column in the one table, and leave it blank throughout on the
rows where that member does not apply.** The family is still stated as one rule, the members
still sit side by side, and the sparse columns are what shows which member each row exercises.
Reach for the keyed column when the members are values one input takes; reach for a column each when
they are inputs of their own. **Splitting into a table per member is the wrong answer in both
cases** — and it is the tempting one, because it needs no decision.

**Collapse on a family, never on a bag.** A family is a domain category, not "everything that affects
the answer". The check: the family name works as a column header with the members as its values.
Where no such name exists the tables are genuinely distinct and belong apart — and so they do where
collapsing would cross-multiply, or leave rows whose purpose is no longer legible.

One family, one table with an adjustment column — not three tables each fixing the same setup:

| Scenario                   | Adjustments                        | Daily Dose? |
|----------------------------|------------------------------------|-------------|
| No adjustment              | [:]                                | 500         |
| Renal impairment           | [renal: severe]                    | 250         |
| Low body weight            | [weightKg: 20]                     | 200         |
| Interacting drug           | [interaction: true]                | 400         |
| Renal and interacting drug | [renal: severe, interaction: true] | 200         |

`Adjustment` works as a column header with those as its values, which is what makes it a family and
not a bag.

### A Combining Table Needs Its Own Rule

Once every rule has a table, the pull is to add one more that runs the whole feature end to end. It
re-proves what the single-rule tables already established, and it reads as redundant however clean
those tables are.

**A table that combines concerns earns its place only where the combination behaves in a way neither
concern shows alone** — a precedence, an ordering, an interaction whose result neither parent table
produces — and then it carries only the rows that show it. A table proving that a weight-based
dose is computed *before* the daily maximum caps it is a real table: the question is which rule
applies first, and its expected values appear in no other table. A table whose rows re-run each
dose band through the public entry point is not.

**Collapsing several same-fixture tables into one is not a combining table**, and *Decompose When You
See These Signs* requires it. The difference is what the merged table states: a family table states
one rule with its members as a column, while a combining table re-runs rules other tables have
already established. The first has a rule of its own; the second is a second pass over the ladder.

Two symptoms:

- **The description gives it away.** If the description you would write is "end-to-end scenarios
  combining the rules from the tables above", the table has no rule of its own. Delete it.
- **Wiring is not a rule.** Reaching a rule through the public API rather than the unit under test
  does not make it a new rule. If the wiring genuinely needs showing, that is one row, not a
  second pass over the ladder.

**Salvage its rows before you delete it — and then delete it.** One or two rows of an
end-to-end table often reach a case no single-rule table does. Deleting the table takes those with it
and nothing reports the loss, so list the obligations only its rows discharge and move each into
the table that owns its rule.

**This is a salvage step, not a reprieve — no outcome of it keeps the table.** A row worth
keeping is worth keeping *somewhere else*. Nor does shrinking the table save it: a single test that
runs the whole feature to re-prove one already-proven total is the same combining table with fewer
rows.

Earns its place — which rule applies first is a question no other table answers:

| Scenario                   | Body Weight (kg) | Daily Max (mg) | Daily Dose? |
|----------------------------|------------------|----------------|-------------|
| Weight-based below the cap | 40               | 400            | 200         |
| Weight-based above the cap | 120              | 400            | 400         |

Does not earn its place — re-runs each band end to end and answers nothing new:

| Scenario              | Body Weight (kg) | Daily Dose? |
|-----------------------|------------------|-------------|
| Low weight end to end | 20               | 100         |
| Adult end to end      | 70               | 350         |

### Separate Rules from Arithmetic

Tables specify the interesting decisions — classifications, eligibility rules, tier lookups, state
transitions — not that multiplication works.

**The symptom is an expectation cell you cannot predict in one step.** If reading a row means
classifying first and then computing, the table has fused two rules and states neither.

Give the classification its own table, whose expectation columns *are* the classification. Give the
arithmetic its own, taking the classification as an input. Each table then states one rule, and every
cell is predictable from its row.

This usually needs a narrower function to call. A table that can only reach the fused result means
the seam is missing, not that the table must fuse.

**Putting the classification in a column of the fused table satisfies this test without splitting
anything.** With the classified value beside the raw data, every cell is predictable in one step
again — and the rule that produces it has still not been stated anywhere. One-step predictability is
necessary, not sufficient; *Decompose When You See These Signs* asks the second question.

**Where you may not add the seam, name it.** Code you cannot change still has the boundary in its
behaviour, and a table that fuses two rules without saying why reads as a design choice. One sentence
on a published surface fixes that — *"the intermediate score is not observable, so the decision and
the amount are verified together; an accessor for it would allow two tables."* Whether the gap gets
closed in the code or bridged here is then the reader's decision to make, which it cannot be while
the gap is invisible.

Table 1 — the classification (how do these duty hours divide?):

| Scenario        | Duty Hours | Normal Hours? | Extended Hours? |
|-----------------|------------|---------------|-----------------|
| Below the limit | 8          | 8             | 0               |
| At the limit    | 13         | 13            | 0               |
| Past the limit  | 14         | 13            | 1               |

Table 2 — the arithmetic (extended hours earn rest credit at double rate):

| Scenario      | Normal Hours | Extended Hours | Rest Credit? |
|---------------|--------------|----------------|--------------|
| Ordinary duty | 13           | 0              | 13.0         |
| Duty ran long | 13           | 1              | 15.0         |

### Give Each Obligation Exactly One Row

**The obligation list comes from the inputs, and producing it is the step most often skipped.**
Before counting rows, take each input the rule reads and ask three questions of it. Where an
answer is not obvious from the requirement, it names rows nothing else will.

- **Counted, or read in bands?** A rule reading an input in bands gives two different values the same
  answer, and the only way to state that is two rows differing in that input and agreeing in the
  expectation. A table whose every value of it carries a different expectation has stated "the answer
  rises with this input", which is a different rule from the one you meant.
- **Per unit of it, or once for having any?** Two rows — none and one — show a difference and are
  equally consistent with both readings, because a flat charge for having any at all is an ordinary
  rule. **A third consecutive value is what decides between them.** Without it the table leaves
  ambiguous the very rule it was written to state.
- **Does its effect depend on another input?** Where it does, the pair showing that effect has to
  appear on **both sides** of the other input's boundary. One pair, however well chosen, states an
  effect that is wrong wherever the other input differs.

Answer all three before writing rows. The questions cost nothing, and the rows they produce
are exactly the ones a reader cannot infer from the others.

The right number of rows is a covering problem. List the concern's **obligations** — the distinct
behaviours the rule must demonstrate — then write the smallest set of rows that covers all of
them. Both errors are real and they do not read alike: a missing obligation lets a wrong
implementation pass, while a repeated one costs the reader time and suggests a distinction that is
not there.

**The test for a redundant row, and it is decidable inside the table in front of you: where two
rows share an expectation, ask whether swapping one's differing input for the other's would
change an expectation cell *in this table*.** If it would not, they are one row — and a value set
is how you say so.

**"Exactly one" is a floor as well as a ceiling, and consolidating is where the floor gets broken.**
Trimming a table is the moment to re-read the obligation list, because the rows that look most
redundant are often the ones carrying an obligation of their own. Three shapes account for nearly
every obligation dropped that way:

- **A second input in a different *state*, mistaken for a larger value of the same one.** Acting on
  something already populated is not a bigger version of acting on something fresh — it is the case
  where existing content has to survive, and nothing else shows it.
- **The transition that empties or fills.** Removing the last member, filling the final slot: the
  row looks like the ordinary case with smaller numbers, and it is the only one that reaches the
  boundary of the container.
- **A distinct branch that shares its expectation with a neighbour.** Two rows agreeing on the
  answer are not redundant when they reach it by different routes — but **the routes have to differ
  in what this table expects, not in what its rule mentions**, which is what the test above decides.
  **A value the rule names is not thereby a branch:** enumerating the members is how a rule gets
  stated, and the table's job is to show which of them the answer turns on. Kinds of a thing that
  another rule tells apart are the usual false positive: three rows for three kinds, where the
  rule under test reads only whether the thing was valid.
  **Collapsing means the value set, not the delete key.** Put every kind in the surviving cell —
  `{percentage, fixed, product-specific}` — because the description will still claim the kind makes
  no difference, and deleting the rows leaves that claim with nothing behind it.

When you cut a row, say which surviving row discharges its obligation. If none does, keep it —
but **a value set discharges every obligation its members carried**, because it expands into one case
per value. Collapsing rows into a value set is not cutting them, and the floor is not in play.

**Two closed sets of inputs are where the floor gets misread.** With m values of one input and n of
another, every one of the m×n combinations is a case the rule names, so every one looks like an
obligation of its own and the rows grow to the full cross-product. The obligations are the
distinct *answers*, not the combinations: group the combinations that share an expectation, give
each group one row, and let the value sets carry the members. This is a row count, not a
table count — one rule still means one table, however its inputs multiply.

And three shapes account for nearly every genuinely redundant row:

- **Further past the same boundary.** A pair that *straddles* a boundary earns both its rows: the
  outcomes differ, and that is the rule. A second row on the same side does not, and the same
  holds for rejections — one row just past a limit rejects, and a row further past it rejects
  for no new reason. It earns its place only where the point *is* that two inputs collapse to one
  behaviour, and then a value set says that in one row; keep two and the scenario names have to carry
  why.
- **A larger n in the same direction.** If two incompatible items force a batch into separate streams,
  three incompatible items force it for the same reason. One obligation, one row.
- **A value the rule ignores.** The redundancy test above, applied directly: one row carrying a
  value set. **Where the value that differs sits inside a composite cell, the collapse needs the
  column reshaped first** — a value set varies a whole cell, never one part of one. Ask what else in
  that cell this table reads. If nothing does, the object does not belong in the cell and the value
  does: give it a column of its own and fix the object's other parts outside the table — bar any
  part a surface makes a claim about, which has to stay visible (*Assume the Table Is Published*).
  If other parts are read too, add a further element instead, so one row carries every state and
  the near-duplicate pair never arises.

**One value can carry two obligations, in two different tables.** A value that is a boundary for one
rule is often the subject of another. A zero duty period is both the accepted end of "duty hours
cannot be negative" *and* the input that should produce no rest requirement whatever the crew size —
two rules, two questions, two rows in two tables. Showing the value once, in whichever table you
reached first, feels like coverage and is not. **Count obligations per rule, never per value.**

| Scenario                 | Duty Hours | Extra Rest Required? |
|--------------------------|------------|----------------------|
| At the duty limit        | 13         | no                   |
| Just past the duty limit | 13.5       | yes                  |
| Well past the duty limit | 20         | yes                  |

The third row is redundant — 13.5 already discharged "past the limit requires extra rest". Keep the
straddling pair, drop the one further out.

### Cover Every Tier and Both Sides of Every Boundary

When inputs map to tiers — rate bands, size categories, standings — every tier appears in the
rows, and every boundary is exercised from both sides: the last value inside a tier and the first
value of the next.

**Middle-tier boundaries are the ones most often skipped.** Outer edges alone do not pin down where
the middle tiers change.

**Pick the pair's unit from the finest distinction the rule draws, before writing either value.**
Where the rule separates 29 days 23 hours from 30 days 1 hour, whole-day rows of 30 and 31
straddle nothing — the column is `Hours Ago` and not `Days Ago`. A boundary drawn in a unit coarser
than the rule is not drawn at all, however many rows surround it.

**And express it as an offset from the reference point, not as an absolute value restated in every
row.** `Hours Ago` is the whole example: it fixes the unit *and* keeps the row readable, where
absolute instants pin the same boundary while making the reader subtract before the rule is visible.
*Assume the Table Is Published* sends the reference point itself to a column; this rule owns the unit,
and one choice satisfies both. A boundary win bought with an unreadable cell has been paid for twice.

**A formula behind the tiers does not reduce the tiers.** If you find yourself arguing that two tiers
and the delta between them determine the rest, that is the formula talking: the table pins the tiers
the rule names, and identifying the formula is the implementation's job. Nine tiers stay nine
rows.

**And it does not reduce the boundaries.** Where the tier is decided by a formula over several
inputs, every input still has a value at which the outcome flips, and that pair is what the rows
have to straddle — one just below it, one just above, the other inputs held. Sampling that input at
two comfortable values instead exercises the arithmetic and leaves the boundary untested.

**A ladder repeated across several classes states its boundaries once.** Where one ladder is priced
or graded differently per class — the same usage bands on every tariff, the same age brackets in
every region — exercise both sides of every boundary in **one** class, and give each other class one
row per tier:

```
Standard, at band 1 limit | standard | 100    | 12.00
Standard, band 2 begins   | standard | 100.01 | 18.00
Standard, at band 2 limit | standard | 500    | 18.00
Standard, band 3 begins   | standard | 500.01 | 30.00
Economy, band 1           | economy  | 50     |  9.00
Economy, band 2           | economy  | 300    | 13.50
Economy, band 3           | economy  | 800    | 22.50
```

**The check is whether the boundary positions differ between classes.** If they differ, each class
has its own ladder and owes its own straddling pairs. If they do not, the positions belong to the
ladder and the classes differ only in their values — which is what their one-row-per-tier lines
state.

**Duplicated implementation is not a reason to repeat the pairs.** That the same comparison is
written out once per class is a property of the code, and *Design Black-Box Tables* is where that
argument stops: the table states the rule the specification gives, and a specification with shared
bands declares one ladder. Without this, the obligation above reads as *boundaries × classes* and
the row count multiplies with nothing added.

Where a tier is a range rather than a single value, a value set spanning it carries its own
boundaries — **provided its first and last members are the tier's own first and last values.** The
straddling pair is then already written: the last member of one row's set and the first member of
the next row's. **State the tier's edges, not two comfortable values inside it** — a set of middle
values straddles nothing and the explicit pair is still owed. Done that way a separate "tier begins"
row discharges nothing the "tier holds" row has not, and **one row per tier covers the
whole ladder and every boundary in it**. That is economy inside a row and buys no licence to drop
rows: shortening each tier to one cell makes the ladder look repetitive long before it is
complete.

This is the coverage half of *Give Each Obligation Exactly One Row*, and the two meet at a
boundary: the straddling pair is required here and earns both its rows there. A third row
further past the same boundary is what the other rule removes.

| Scenario                     | Haemoglobin | Donation Band? |
|------------------------------|-------------|----------------|
| Below the minimum            | 124         | DEFER          |
| At the minimum               | 125         | STANDARD       |
| Top of the standard band     | 159         | STANDARD       |
| First value of the high band | 160         | REVIEW         |

Every band appears, and each boundary is shown from both sides.

### Show That an Input Does Not Change the Outcome

**You have just concluded that some input does not affect this rule. That conclusion is a rule too,
and it needs a row that could contradict it**: put every value the rule ignores in the cell, as
a value set.

**Dropping the input states nothing.** Leaving the column out reads exactly like having forgotten it
— nothing on the page tells a reader which happened, and no row can contradict a claim the table
never makes. A blank is no better and wrongly suggests the field is absent. The value set makes the
claim explicit — *this rule holds for all these values* — and one row states it more precisely
than two near-identical ones.

**The clearest sign you want one: a column that could carry every one of its values on every
row without changing anything.** That is the rule saying, in data, that it does not read the
column.

**If the operation does not take the input, fix that before the table.** A rule cannot say it ignores
what never reaches it, and leaving the parameter out publishes nothing: the reader sees an operation
that was never offered the value, which is silence rather than a claim. **A column the code never
receives will not do instead** — that route is open to a threshold, which only has to be readable
(*Make Thresholds Visible*), and closed to this rule, whose claim has to be exercised to be
contradictable. Decide it by asking who supplies the value: **if a caller hands it over with the
request it is an input, so keep it and let the table vary it**, even where the code will not read it.

**If the ignored value sits inside a composite cell, the value set is the wrong tool — and it is not
the only one.** `{a, b}` written inside a cell adds no rows: expansion varies a whole cell, never
one part of one. Two routes remain, and the cheap one is easy to miss. **Where the cell holds a list,
vary the ignored value across its elements** — one row, three entries, three different values,
nothing reshaped. Where it holds a single object, give the value its own column if this table reads
nothing else in that cell (*Give Each Obligation Exactly One Row*).

**Fixing the value in the converter is the move to catch yourself making.** It is the cheapest thing
to write and it drops the claim altogether: what reaches the reader is one hard-coded value they
cannot tell from an oversight, and a sentence about a rule that no row exercises. The reshape is
worth it because the claim is; if the claim is not worth a column, it was not worth the sentence
either.

**A value set cannot vary an expectation.** It expands the row into one case per value, and every
expanded case keeps the same expectation cells. Where the answer differs per value, those are
ordinary distinct rows.

**Every value in the set must produce the same result.** If the results differ, the input does matter
and belongs as ordinary distinct rows. Never use a value set as shorthand for "test several
values".

**Two different situations, two different treatments.** An input that *another* rule owns is held at
one obviously-valid value. An input that *this* rule claims not to affect has to vary across the
values it ignores — otherwise no row could ever contradict the claim.

**Value sets work on two axes — check both.** *Within* a row, group input values that produce the
same outcome. *Across* rows, collapse duplicates: when two input values produce the same
expectation cells **in this table**, one row carrying both replaces two identical ones. It is
easy to apply one axis and miss the other.

**Judge that per table, not across the whole class.** Two values that this rule treats alike collapse
here even if a neighbouring rule tells them apart — grouping them says *this* rule does not
distinguish them, which is exactly what the neighbouring table then contradicts, on the record. Ask
only whether the expectation cells match in the rows in front of you. A category you have named
as a catch-all is the easy case and gets collapsed almost automatically; **the one that gets missed
is two values you think of as distinct that this particular rule happens to treat the same.**

| Scenario                           | Donor Age | Haemoglobin | Recent Travel | Eligible? |
|------------------------------------|-----------|-------------|---------------|-----------|
| Below the minimum age              | 16        | {125, 140}  | {yes, no}     | no        |
| Eligible adult donor               | 35        | 140         | no            | yes       |
| Travelled recently, otherwise fine | 35        | 140         | yes           | no        |

The first row claims age alone decides it. A blank in those cells would have said the values were
*absent*, which is a different statement.

### Frame Stateful Features as Transition Rules

When a feature involves state — queues, workflows, inventories — frame each row as a state
transition rule: the state before, the action, the state after, and any message or result.

Each row is independent: given this state, when this action happens, expect this result. No row
depends on a previous one having run.

**Include the before and after columns** even when the description states the operation procedurally.

A sequential path — step 1, then step 2, then step 3 — creates row dependencies and is not a table
at all.

| Scenario                 | Bin Before     | Action            | Bin After?     | Message?     |
|--------------------------|----------------|-------------------|----------------|--------------|
| Accept a labelled item   | [:]            | deposit cardboard | [CARDBOARD: 1] | Accepted     |
| Fill to the bulk limit   | [CARDBOARD: 1] | deposit cardboard | [CARDBOARD: 2] | Accepted     |
| Reject a mismatched item | [CARDBOARD: 1] | deposit solvent   | [CARDBOARD: 1] | Wrong stream |

Each row stands alone — none of them assumes the row above ran first.

### Assume the Table Is Published

Write every table as if a reader will meet it in a published report, never having seen the code. Only
three surfa

…(truncated)
