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
@TableTestimplementation
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, notage,userAge, orint).
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
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)