Skip to content
academia.sh

Lesson 04 / 11

Acceptance Criteria

Writing the definition of done as a data structure: the acceptance criterion's subject, measure, and limit slots, the reopened-work count and discovery phase that change with who fills a slot and when, the product side's attention being a limited budget, and a criterion written at closeout only confirming observed behavior.

Contents

The previous lesson’s exploratory session produced findings in the semantic-drift class, but whether these findings were defects was not a measurement question: the tester counts a defect, the developer counts expected behavior, and the product side agrees with neither. In a case like this, escaped defect count is not a measure, it is a disagreement.

What turns disagreement into a measure is the acceptance criterion: a condition written in advance that says when a work item is done. Turning an acceptance criterion into an automated test was built in the Unit Testing and Test-Driven Development course; the question here is different — who writes the criterion and when it closes. This lesson builds those two questions as a budget distribution; the resource distributed is the product side’s attention, and it is measured by rework, meaning work that is opened again. (In the Resilience and Reliability course, a failback is returning to the primary node after a failover; what is meant here by rework is a piece of work counted as closed being reopened.)

The Criterion’s Three Slots

An acceptance criterion fills three slots: subject says what is observed, measure says how it is observed, limit says what value is accepted. A definition of done is the state where all of these criteria are met. Not every criterion needs all three slots, and which are required comes from the criterion’s type: an existence criterion needs subject and measure (“a delay notification is sent”), a numeric criterion also needs a limit (“five days after the due date”), a semantic criterion needs a limit and requires that limit not be derived from observation (“the notification goes to the member who holds the loan”).

The last item is this lesson’s sharpest point. If a criterion is written at closeout by looking at the system’s observed behavior, the limit is set equal to that behavior; the criterion never turns red, because what it tests is its own source.

TP15 (assumption) — 20 work items, 4 acceptance criteria per item, 80 criteria in total; the type distribution comes from seed 6060. The defect set’s 60 defects are distributed to items three at a time. TP16 (assumption) — the time the product side can set aside for writing criteria across the horizon is 300 minutes; reviewing one criterion takes 12 minutes. This is the distributed resource, and its sensitivity is scanned. TP17 (assumption) — a piece of rework’s cost depends on the phase where it surfaces: 5 minutes at write time, 20 during development, 60 at acceptance review, 180 after release.

// library/defects.mjs — the first lesson's defect set, unchanged (seed 20260731, 60 defects,
// 14 classes). This lesson does not use the inventory's cost columns, only the defect set.
const WEIGHT = [["rule-boundary", 9], ["schema-mismatch", 6], ["migration-data-loss", 4],
  ["contract-field", 5], ["interface-state", 8], ["visual-deviation", 3], ["data-growth", 4],
  ["sql-concatenation", 3], ["known-vulnerability", 3], ["accessibility", 4], ["degraded-response", 3],
  ["recovery-gap", 2], ["off-scenario", 5], ["semantic-drift", 4]];
export function defects(seed, n) {
  let x = seed;
  const random = () => { x = (1103515245 * x + 12345) % 2147483648; return x / 2147483648; };
  const total = WEIGHT.reduce((a, [, w]) => a + w, 0);
  const list = [];
  for (let i = 1; i <= n; i += 1) {
    let p = random() * total, cls = WEIGHT[WEIGHT.length - 1][0];
    for (const [s, w] of WEIGHT) { p -= w; if (p < 0) { cls = s; break; } }
    list.push({ id: i, cls, likelihood: 1 + Math.floor(random() * 5),
      impact: 1 + Math.floor(random() * 5), fix: 15 + 15 * Math.floor(random() * 8) });
  }
  return list;
}

Five Policies

Five policies distribute the criterion to different hands and different moments. A slot left missing surfaces at the phase when the side that will fill it first looks at the criterion: the product side fills a missing limit, the tester fills a missing measure.

// library/criteria.mjs — builds the acceptance criterion as a three-slot data structure and
// measures the reopened-work return on who writes it and when. Input: library/defects.mjs
import { defects } from "./defects.mjs";

const ITEMS = 20, PER_ITEM = 4, SEED = 20260731, N = 60, PRODUCT_MIN = 12, PRODUCT_BUDGET = 300;
const COST = [5, 20, 60, 180];        // TP17: reopened work's minutes per phase
const PHASE = ["write time", "development", "acceptance review", "after release"];
let MULTIPLIER = 6;                   // TP5: escaped defect's fix multiplier
const g = (x, n) => String(x).padStart(n);

// TP15: 20 work items x 4 acceptance criteria = 80 criteria. Criterion type sets required slots:
// existence (subject+measure), numeric (subject+measure+limit), semantic (all three; limit not from observation).
let x = 6060;
const r = () => { x = (1103515245 * x + 12345) % 2147483648; return x / 2147483648; };
const TYPE = ["existence", "numeric", "semantic"];
const CRITERIA = Array.from({ length: ITEMS * PER_ITEM }, (_, i) => { const p = r();
  return { id: i, item: Math.floor(i / PER_ITEM), type: TYPE[p < 0.45 ? 0 : p < 0.8 ? 1 : 2] }; });
const REQUIRED = { existence: ["subject", "measure"], numeric: ["subject", "measure", "limit"],
  semantic: ["subject", "measure", "limit"] };

// Defects are distributed to items three at a time. A semantic-drift defect by definition sits
// under a semantic criterion — what makes it a defect is not a rule in the code but what the
// criterion says; if the item has no semantic criterion, one of its criteria is marked semantic.
const DEFECTS = defects(SEED, N).map((k, i) => ({ ...k, item: Math.floor(i / (N / ITEMS)) }));
const SEMANTIC = DEFECTS.filter((k) => k.cls === "semantic-drift");
for (const k of SEMANTIC) {
  const itemCriteria = CRITERIA.filter((o) => o.item === k.item);
  k.criterion = itemCriteria.find((o) => o.type === "semantic")
    || Object.assign(itemCriteria[Math.floor(r() * itemCriteria.length)], { type: "semantic" });
}

// Policy: which slots are filled for each criterion, whether product fills the limit slot, whether
// the limit is derived from observation, and the phase at which the side covering the gap first reads it.
function evaluate(name, slots, productReviews, fromObservation, productPhase, testerPhase, writeMin) {
  const status = CRITERIA.map((o) => {
    const filled = slots(o);
    const missing = REQUIRED[o.type].filter((y) => filled.includes(y) === false);
    const judges = missing.length === 0 && (o.type !== "semantic" || fromObservation === false);
    return { ...o, judges, hasLimit: filled.includes("limit"),
      phase: missing.length ? productPhase : judges ? -1 : testerPhase };
  });
  const open = status.filter((o) => o.judges === false);
  const escaped = SEMANTIC.filter((k) => status[k.criterion.id].judges === false);
  const productHour = productReviews ? PRODUCT_MIN * status.filter((o) => o.hasLimit).length : 0;
  return { name, productHour, writeMin: writeMin * CRITERIA.length + productHour,
    reopened: open.length, item: new Set(open.map((o) => o.item)).size,
    reworkMin: open.reduce((a, o) => a + COST[o.phase], 0),
    distribution: [0, 1, 2, 3].map((s) => open.filter((o) => o.phase === s).length),
    escaped: escaped.length, risk: escaped.reduce((a, k) => a + k.likelihood * k.impact, 0),
    fix: MULTIPLIER * escaped.reduce((a, k) => a + k.fix, 0) };
}
const TWO = ["subject", "measure"], THREE = ["subject", "measure", "limit"];
const POLICIES = () => [evaluate("Y1 developer writes, product does not review", () => TWO, false, false, 2, 2, 5),
  evaluate("Y2 product reviews every criterion, upfront", () => THREE, true, false, 0, 1, 5),
  evaluate("Y3 tester writes at closeout", () => THREE, false, true, 3, 2, 8),
  evaluate("Y4 no criteria, one-line definition", () => [], false, false, 3, 3, 1),
  evaluate("Y5 product reviews only semantic criteria", (o) => (o.type === "semantic" ? THREE : TWO),
    true, false, 2, 2, 5)];
const total = (o) => o.writeMin + o.reworkMin + o.fix;
const fitsBudget = (t) => { const u = POLICIES().map((o, i) => [t[i], o.productHour <= PRODUCT_BUDGET, i])
  .filter(([, s]) => s); return "Y" + (u.sort((a, b) => a[0] - b[0])[0][2] + 1); };

console.log(`${ITEMS} work items x ${PER_ITEM} acceptance criteria = ${CRITERIA.length} criteria (seed 6060); type: ` +
  TYPE.map((t) => `${t} ${CRITERIA.filter((o) => o.type === t).length}`).join(", "));
console.log(`defect set seed ${SEED}, ${N} defects; ${SEMANTIC.length} semantic drift, ` +
  `${SEMANTIC.reduce((a, k) => a + k.likelihood * k.impact, 0)} risk points; ` +
  `across ${new Set(SEMANTIC.map((k) => k.item)).size} separate work items`);
console.log(`TP16 product budget ${PRODUCT_BUDGET} min (${PRODUCT_MIN} min per criterion); ` +
  `TP17 rework: ` + PHASE.map((a, i) => `${a} ${COST[i]} min`).join(", "));

console.log("\npolicy                                           product min  budget  reopened  item  discovery phase");
for (const o of POLICIES()) console.log(o.name.padEnd(46) + g(o.productHour, 14) +
  g(o.productHour <= PRODUCT_BUDGET ? "fits" : "over", 8) + g(o.reopened, 10) + g(o.item, 6) +
  "  " + (o.reopened ? PHASE[o.distribution.indexOf(Math.max(...o.distribution))] : "-"));

console.log("\ncost on the same defect set");
console.log("policy                                          write min   rework min  escaped  escaped risk  fix min  total min");
for (const o of POLICIES()) console.log(o.name.padEnd(46) + g(o.writeMin, 11) + g(o.reworkMin, 13) +
  g(o.escaped, 9) + g(o.risk, 14) + g(o.fix, 9) + g(total(o), 11));

console.log("\nTP16's sensitivity to product budget — product reviews semantic criteria first, then numeric ones");
console.log("product min   criteria reviewed   reopened   escaped   total min");
const order = [...CRITERIA].sort((a, b) => TYPE.indexOf(b.type) - TYPE.indexOf(a.type));
for (const b of [0, 204, 300, 960]) {
  const covered = new Set(order.slice(0, Math.floor(b / PRODUCT_MIN)).map((o) => o.id));
  const o = evaluate("", (t) => (covered.has(t.id) ? THREE : TWO), true, false, 2, 2, 5);
  console.log(g(b, 7) + g(covered.size, 16) + g(o.reopened, 13) + g(o.escaped, 8) + g(total(o), 12));
}

console.log("\nTP5's sensitivity: which policy is cheapest as the escaped defect multiplier changes");
console.log("mult  " + ["Y1", "Y2", "Y3", "Y4", "Y5"].map((a) => g(a, 8)).join("") +
  "   cheapest   cheapest within budget");
for (const mult of [1, 6, 20]) {
  MULTIPLIER = mult;
  const t = POLICIES().map(total);
  console.log(g(mult, 3) + t.map((n) => g(n, 8)).join("") +
    g("Y" + (t.indexOf(Math.min(...t)) + 1), 10) + g(fitsBudget(t), 22));
}
20 work items x 4 acceptance criteria = 80 criteria (seed 6060); type: existence 34, numeric 29, semantic 17
defect set seed 20260731, 60 defects; 6 semantic drift, 60 risk points; across 5 separate work items
TP16 product budget 300 min (12 min per criterion); TP17 rework: write time 5 min, development 20 min, acceptance review 60 min, after release 180 min

policy                                           product min  budget  reopened  item  discovery phase
Y1 developer writes, product does not review               0    fits        46    20  acceptance review
Y2 product reviews every criterion, upfront              960    over         0     0  -
Y3 tester writes at closeout                               0    fits        17    14  acceptance review
Y4 no criteria, one-line definition                        0    fits        80    20  after release
Y5 product reviews only semantic criteria                204    fits        29    19  acceptance review

cost on the same defect set
policy                                          write min   rework min  escaped  escaped risk  fix min  total min
Y1 developer writes, product does not review          400         2760        6            60     2970       6130
Y2 product reviews every criterion, upfront          1360            0        0             0        0       1360
Y3 tester writes at closeout                          640         1020        6            60     2970       4630
Y4 no criteria, one-line definition                    80        14400        6            60     2970      17450
Y5 product reviews only semantic criteria             604         1740        0             0        0       2344

TP16's sensitivity to product budget — product reviews semantic criteria first, then numeric ones
product min   criteria reviewed   reopened   escaped   total min
      0               0           46       6        6130
    204              17           29       0        2344
    300              25           21       0        1960
    960              80            0       0        1360

TP5's sensitivity: which policy is cheapest as the escaped defect multiplier changes
mult        Y1      Y2      Y3      Y4      Y5   cheapest   cheapest within budget
  1    3655    1360    2155   14975    2344        Y2                    Y3
  6    6130    1360    4630   17450    2344        Y2                    Y5
 20   13060    1360   11560   24380    2344        Y2                    Y5

The Cheapest Policy on Paper Is Not in the Budget

Y2 writes every criterion together with the product side and produces no rework in the table: at 1,360 minutes it is the cheapest of the five policies. But the product column says 960 minutes, and TP16’s budget is 300 — the policy cannot be carried out. The best of the five rows is not an option; it is a ceiling.

Among those that fit the budget, Y5 stands out: the product side reviews only the seventeen semantic criteria, spends 204 minutes, and drops escaped defect to zero. Y1 uses none of that budget and lets six defects through to release; its total cost is 6,130 minutes, 2.6 times Y5’s. The difference is where 204 minutes of attention gets placed.

The product budget scan gives the shape of this distribution. The first 204 minutes remove all of the escaped defect (6 → 0), because those minutes go to semantic criteria. The remaining 756 minutes save no further defect; they only drop rework from 29 to 0 and pull the total from 2,344 to 1,360. The same resource’s first part and last part buy different things: the first part buys escaped defect, the last part buys rework.

The Criterion Written at Closeout

Y3 looks reasonable in the table: it spends no product budget, produces only seventeen rework items, and at 4,630 minutes is cheaper than Y1. Yet it still lets six defects through to release. The reason is not that the criterion is missing — all three of its slots are filled. The reason is that the limit is derived from observation: the tester writes the criterion at closeout by watching the running system, and records what the system does as acceptable. The criterion does not turn red, because it is derived from the very behavior it tests. The number of criteria written is not a quality indicator; the criterion’s source is the indicator.

Y4 writes no criteria at all and leaves all eighty of the eighty criteria to be disputed after release: 14,400 minutes of rework, all twenty of the twenty items reopened. The discovery phase column is the one thing that separates this policy from the others — not the count, the moment. The same disagreement costs 60 minutes at acceptance review, 180 minutes after release.

The Return on the Decision

In this lesson, the return on the decision is a directly measured quantity. Y1 reopens all twenty of the twenty items, Y5 nineteen of them, Y3 fourteen, Y2 none. The number of reopened items changes little across policies; what changes is which phase the reopening happens at, and how many defects reach release.

The TP5 scan ties the decision to a context. When an escaped defect’s fix cost equals a caught one’s (multiplier 1), the cheapest policy that fits the budget is Y3 — a criterion written at closeout is defensible while defects are cheap. At multipliers 6 and 20, Y5 wins and widens the gap. So the answer to who writes the criterion depends on what an escaped defect costs the team; the policy cannot be chosen without knowing that number.

Summary

  • An acceptance criterion is three slots: subject, measure, limit. The criterion’s type determines which slots are required; a semantic criterion additionally requires the limit not be derived from observation.
  • The product side’s attention is a limited resource (TP16, 300 minutes); the policy that reviews every criterion asks for 960 minutes and does not fit the budget — the cheapest row in the table is not an option, it is a ceiling.
  • The same budget’s first 204 minutes remove all escaped defect, the remaining 756 minutes only lower rework; a resource’s first and last parts buy different things.
  • A criterion written at closeout fills all three slots but never turns red because it takes its limit from observed behavior, and it lets six defects through to release.
  • When no criterion is written, the disagreement does not disappear, only its phase shifts: the same rework is 60 minutes at acceptance review, 180 minutes after release.
  • Policy choice depends on the escaped defect multiplier: at multiplier 1, writing at closeout is cheapest within budget; at multipliers 6 and 20, targeted product attention is.

Next Step

Every criterion here was written by parties inside the team: the developer, the tester, the product side. All three stand on the side designing the system, and all three use the same vocabulary. Yet the expectation that must ultimately be met by the criterion sits outside the team, and that expectation was written into no slot — how many steps a library clerk expects a loan transaction to take, when a member finds a delay notification meaningful. The next lesson manages that verification: how acceptance scope is distributed across user tasks, how the returning feedback is separated into defect, request, and misunderstanding, and what each class costs the team.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close