---
title: 'Process, Team and Delivery'
source: 'https://academia.sh/en/courses/process-and-team'
language: en
updated: '2026-08-23T07:01:06+00:00'
license: 'CC BY-SA 4.0'
lessons: 10
outcomes:
    - 'Compares process models against their context'
    - 'Measures the effect of team structure on the architecture'
    - 'Builds the delivery process with a change and handover contract'
---

# Process, Team and Delivery

The human structure that shapes the architecture: comparing lifecycle models by feedback delay, the frameworks of iterative delivery, work-in-progress limits and flow metrics, the cost of multi-team coordination, engineering practices such as continuous integration and pair programming, how the communication structure is reflected in the architecture, stream-aligned team topologies, estimating under uncertainty, controlling scope change, and the support contract between development and operations.

## Process Models

Choosing the shape of the workflow: waterfall, V, spiral, and incremental lifecycles are compared by feedback delay and rework, the common structure of iterative delivery frameworks, the effect of limiting work in progress on flow time, the coordination-round price of multi-team coordination, and the effect of continuous integration and pair programming on feedback delay.

### Lifecycle Models

Source: https://academia.sh/en/courses/process-and-team/lifecycle-models

Running waterfall, V, spiral, and incremental lifecycles on the same eighteen work items: handoffs, waiting at the gate, feedback delay, and rework are counted, and what each model loses where it wins is written down.

The previous course turned an architectural rule into an object: the rule was written, turned into a
check, and what it caught and missed was counted. All of those measurements silently left one thing
out. It is **people** who write the rule, run the check, disable the rule in the face of a false
alarm, and make the next change; the order in which these people work, who they hand work to, and
when they learn of a mistake is written directly into the architecture.

This course's unit of measure is the **work item**: the path from the moment a change request is
born to the moment it reaches operations. Every lesson counts three things — how many boundaries an
item crosses and how many rounds it waits at each crossing, how many items come back and why, and how
much these grow where the team boundary and the module boundary do not overlap. The through-line is
the **regional library network**: branch systems, an externally purchased catalog, in-house loan and
fee services, a separate membership system, and the municipality's identity service. It is fiction.

#### The Work Item Set to Measure

The block below is a **model**, not a measurement: it is a data structure that makes it possible to
run the same set of work items through four lifecycles.

**PM1 — eighteen work items pass through five steps and a single team works them.** Each item
consumes 3 work units at every step, and the team processes 9 work units per round. The reasoning:
models are only comparable under the same workload and the same capacity; what creates the difference
must be **batching**, not the amount of work.

**PM2 — ten of the items carry a defect that is born somewhere, and its birth step and type are known
as input.** The types are kept apart: **missing information** (a detail was never asked for),
**wrong boundary** (two items hold the same data in different places, and the mistake shows up only
once the second one is written), **late-learned constraint** (the constraint surfaces only in
operations). Feedback delay can only be counted against a known set of defects.

**PM3 — the batching rule is the model itself.** Waterfall and V run a single batch; spiral splits
into three cycles by risk order, and incremental splits into six increments by dependency order.

**PM4 — the step a defect is found in depends on the model.** Missing information and wrong boundary
surface in testing; late-learned constraint surfaces in operations. In the V model, test design is
done together with the implementation step, so missing information catches the eye **in the step it
is born in**. Once a constraint is learned in operations, an item carrying the same constraint in a
**later batch** is no longer born defective; the ones in the same batch have already been written.

**PM5 — fixed costs depend on the model.** In the V model, the first three steps each consume 1 extra
work unit per item. Putting a batch into operation costs 6 work units, independent of the item count.

```js
// lifecycle.mjs — runs the same 18 work items through four lifecycle models (model)
// defect code: e missing information, s wrong boundary, k late-learned constraint; digit is the step the defect is born in
import { writeFileSync } from "node:fs";

const STEP = ["analysis", "design", "implementation", "testing", "operations"];
const UNIT = 3;       // work units a work item consumes at one step
const CAPACITY = 9;   // work units per round (single team)
const RELEASE = 6;    // release overhead per batch, independent of item count

const ITEM = [
  "member verification front  |membership|2|e0",
  "catalog search front       |catalog   |1|-",
  "loan record creation       |loan      |2|s1 S1",
  "shelf status query         |catalog   |0|-",
  "late fee calculation       |fee       |2|k0 K1",
  "branch stock turnover      |branch    |1|e2",
  "membership suspension      |membership|1|s1 S1",
  "penalty notification       |fee       |0|-",
  "branch shelf label         |branch    |0|-",
  "reservation queue          |loan      |2|s1 S2",
  "identity matching          |membership|2|k0 K1",
  "catalog import             |catalog   |1|-",
  "fee refund                 |fee       |1|e2",
  "inter-branch transfer      |branch    |2|s1 S2",
  "loan extension             |loan      |0|-",
  "late report                |fee       |0|-",
  "member notification        |membership|0|k0 K2",
  "branch daily closeout      |branch    |1|-",
].map((line, i) => {
  const [name, module, risk, defect] = line.split("|").map((x) => x.trim());
  const [code, key = null] = defect.split(" ");
  const TYPE = { e: "missing information", s: "wrong boundary", k: "late constraint" };
  return { name, module, risk: +risk, increment: (i / 3) | 0, key,
           type: code === "-" ? null : TYPE[code[0]], origin: code === "-" ? -1 : +code[1] };
});

// PM3: the batching rule is the model itself; waterfall and V run a single batch, spiral splits
// into three cycles by risk order, incremental splits into six increments by dependency order.
const byRisk = [...ITEM].sort((a, b) => b.risk - a.risk);
const BATCH = {
  waterfall: [ITEM],
  V:         [ITEM],
  spiral:    [0, 1, 2].map((c) => byRisk.slice(c * 6, c * 6 + 6)),
  incremental: [0, 1, 2, 3, 4, 5].map((a) => ITEM.filter((k) => k.increment === a)),
};
const stepCost = (model, k, s) => (k.release ? RELEASE : UNIT + (model === "V" && s <= 2 ? 1 : 0));
// In V, test design is done together with the implementation step: missing information shows up in the step it is born in.
const foundStep = (model, k) =>
  (model === "V" && k.type === "missing information") ? k.origin : (k.type === "late constraint" ? 4 : 3);

function run(model) {
  const batches = BATCH[model];
  const state = new Map(ITEM.map((k) => [k.name, { stepFinish: new Map(), batch: 0 }]));
  batches.forEach((p, i) => p.forEach((k) => { state.get(k.name).batch = i; }));
  const learned = new Set(), resolved = new Set(), findings = [];
  let round = 0, waiting = 0, handoffs = 0, rework = 0, total = 0;

  const segment = (list, unitCost) => {                 // every segment starts a fresh round
    round += 1;
    let cap = CAPACITY;
    const finish = new Map();
    for (const k of list) {
      let remaining = unitCost(k);
      total += remaining;
      while (remaining > 0) {
        if (cap === 0) { round += 1; cap = CAPACITY; }
        const taken = Math.min(cap, remaining); cap -= taken; remaining -= taken;
      }
      finish.set(k.name, round);
    }
    return finish;
  };

  batches.forEach((batch, p) => {
    for (let s = 0; s < STEP.length; s++) {
      const list = s === 4 ? [...batch, { name: "__release", release: true }] : batch;
      const finish = segment(list, (k) => stepCost(model, k, s));
      for (const k of batch) {
        waiting += round - finish.get(k.name);
        state.get(k.name).stepFinish.set(s, finish.get(k.name));
      }
      handoffs += batch.length;

      const found = ITEM.filter((k) => {                // defects that surface at this step
        if (k.origin < 0) return false;
        if (k.type === "missing information") return batch.includes(k) && s === foundStep(model, k);
        if (k.type === "late constraint") return s === 4 && batch.includes(k) && !learned.has(k.key);
        const group = ITEM.filter((x) => x.key === k.key);   // wrong boundary: paired item
        return s === 3 && !resolved.has(k.key) &&
          group.every((x) => state.get(x.name).batch <= p) && group.some((x) => state.get(x.name).batch === p);
      });
      for (const k of found) {
        const steps = [...Array(s - k.origin + 1)].map((_, i) => k.origin + i);
        const cost = steps.reduce((a, x) => a + stepCost(model, k, x), 0);
        const foundRound = s === k.origin ? finish.get(k.name) : round;   // found in the same step: not gated
        findings.push({ model, item: k.name, type: k.type, origin: k.origin, found: s, cost,
          delayStep: (p - state.get(k.name).batch) * STEP.length + s - k.origin,
          delayRound: foundRound - state.get(k.name).stepFinish.get(k.origin),
          builtOn: ITEM.filter((x) => state.get(x.name).stepFinish.get(k.origin) <= foundRound).length });
        rework += cost;
        handoffs += steps.length;
        if (k.type === "wrong boundary") resolved.add(k.key);
      }
      if (found.length) segment(found, (k) =>
        [...Array(s - k.origin + 1)].reduce((a, _, i) => a + stepCost(model, k, k.origin + i), 0));
      if (s === 4) for (const k of batch) if (k.type === "late constraint") learned.add(k.key);
    }
  });
  return { model, round, waiting, handoffs, rework, total, findings };
}

const RESULT = ["waterfall", "V", "spiral", "incremental"].map(run);
writeFileSync("findings.json", JSON.stringify(RESULT.flatMap((s) => s.findings)));

const TYPE3 = ["missing information", "wrong boundary", "late constraint"];
const table = (headers, rows, caption) => {             // negative width left-aligns
  const write = (h) => console.log(h.map((c, i) =>
    headers[i][1] < 0 ? String(c).padEnd(-headers[i][1]) : String(c).padStart(headers[i][1])).join(""));
  console.log();
  if (caption) console.log(caption);
  write(headers.map((b) => b[0]));
  rows.forEach(write);
};
const avg = (g, f) => (g.length ? (g.reduce((a, b) => a + f(b), 0) / g.length) : null);

console.log(`work items: ${ITEM.length}, steps: ${STEP.length}, defective items: ` +
  `${ITEM.filter((k) => k.origin >= 0).length} ` +
  `(${TYPE3.map((t) => `${ITEM.filter((k) => k.type === t).length} ${t}`).join(", ")})`);

table([["model", -11], ["batch", 7], ["round", 7], ["waiting", 9], ["handoffs", 10], ["total units", 14]],
  RESULT.map((s) => [s.model, BATCH[s.model].length, s.round, s.waiting, s.handoffs, s.total]));

table([["model", -11], ["found defects", 15], ["avg. delay (steps)", 21], ["avg. delay (rounds)", 22],
       ["avg. built-on", 15], ["rework", 9]],
  RESULT.map((s) => [s.model, s.findings.length, avg(s.findings, (b) => b.delayStep).toFixed(2),
    avg(s.findings, (b) => b.delayRound).toFixed(2), avg(s.findings, (b) => b.builtOn).toFixed(2), s.rework]));

table([["model", -11], ["missing information", 22], ["wrong boundary", 17], ["late constraint", 18]],
  RESULT.map((s) => [s.model, ...TYPE3.map((t) => {
    const o = avg(s.findings.filter((b) => b.type === t), (b) => b.delayRound);
    return o === null ? "-" : o.toFixed(1);
  })]), "average delay by defect type (rounds):");
```

```
work items: 18, steps: 5, defective items: 10 (3 missing information, 4 wrong boundary, 3 late constraint)

model        batch  round  waiting  handoffs   total units
waterfall        1     43      243       125           381
V                1     50      288       120           440
spiral           3     47       63       125           393
incremental      6     48       18       120           396

model        found defects   avg. delay (steps)   avg. delay (rounds)  avg. built-on   rework
waterfall               10                 2.50                 20.10          18.00      105
V                       10                 2.00                 19.00          14.70      110
spiral                  10                 3.00                  8.50           9.60      105
incremental              9                 4.00                  5.22          10.67       90

average delay by defect type (rounds):
model         missing information   wrong boundary   late constraint
waterfall                    13.3             14.8              34.0
V                             0.0             18.5              38.7
spiral                        4.0              9.3              12.0
incremental                   1.7              7.8               5.5
```

#### Reading the Four Models

The first table measures flow, the second measures learning, and the two move in opposite directions.

**Waterfall** finishes in the fewest rounds (43) because it pays its fixed overhead once. Its price is
in the second column: **243 rounds of waiting** accumulate, because an item that finishes its own
step still waits for the batch's last item, and it waits again at every gate. The learning side is
harsher — the delay is **20.1 rounds**, and when a defect is found, **eighteen of the eighteen items**
sit on top of that wrong assumption. For a late-learned constraint, the delay climbs to 34 rounds,
because operations happens only once, at the very end.

**The V model** produces a clear gain on exactly one defect type: missing-information delay drops
from **13.3 rounds to 0**, because the question is asked at the step where the answer is needed. Its
price shows up in two places. Total work effort rises from 381 to **440 units** (270 base work, a
54-unit test-design surcharge, 6 units of release, 110 units of rework), and the flow stretches to 50
rounds. On the other two types, V does nothing at all — wrong boundary **rises** from 14.8 to 18.5
rounds and late-learned constraint from 34 to 38.7, only because the whole run is longer.

**Spiral** splits the batch by risk order; because the six riskiest items go all the way to operations
in the first cycle, late-learned constraint delay drops from 34 rounds to **12**, average delay to 8.5
rounds, and waiting to 63. Its price is the release overhead paid three times (18 units) and one
missed learning opportunity: because the risk order puts the two items carrying the same constraint
(`late fee calculation` and `identity matching`) in the **same cycle**, the constraint is learned only
after both have already been written, and both go into rework.

**The incremental** model splits into six increments by dependency order. Waiting drops to 18 rounds,
delay to 5.22 rounds, and **found defects drop from ten to nine**: the constraint learned in the first
increment means the twin item in the third increment is born without the defect, and rework falls to
90 units. Its price is in rounds — six release overheads stretch the flow to 48 rounds, the longest of
the four models — and **its step-based delay is the highest of all** (4.00): the wrong boundary that
crosses an increment boundary shows up only in the second increment, reopening an item the first
increment had already delivered.

#### How Delay Turns Into Rework

**PM6 — rework's cost comes from two factors:** the number of steps redone and the number of items
built on top of the same assumption. The block below pools the thirty-nine findings from the four
runs and breaks these two factors out against delay.

```js
// delay.mjs — reads the findings lifecycle.mjs wrote; delay against rework across all four runs
import { readFileSync } from "node:fs";

const FINDINGS = JSON.parse(readFileSync("findings.json", "utf8"));
const BUCKET = [[0, 0], [1, 5], [6, 12], [13, 24], [25, 99]];

console.log(`${FINDINGS.length} total defect findings across four models; bucket = feedback delay (rounds)\n`);
console.log(`${"delay".padStart(9)}${"found".padStart(7)}${"avg. rework (units)".padStart(21)}` +
  `${"avg. steps redone".padStart(19)}${"avg. built-on items".padStart(21)}`);
for (const [a, b] of BUCKET) {
  const g = FINDINGS.filter((x) => x.delayRound >= a && x.delayRound <= b);
  if (!g.length) continue;
  const avg = (f) => (g.reduce((s, x) => s + f(x), 0) / g.length).toFixed(1);
  console.log(`${(a === b ? `${a}` : `${a}-${b}`).padStart(9)}${String(g.length).padStart(7)}` +
    `${avg((x) => x.cost).padStart(21)}${avg((x) => x.found - x.origin + 1).padStart(19)}` +
    `${avg((x) => x.builtOn).padStart(21)}`);
}

// PM6: rework's cost comes from two factors; the number of steps redone and the number of items
// built on the same assumption. Both grow along with the delay.
const STEP_NAME = (i) => ["analysis", "design", "implementation", "testing", "operations"][i];
const joinUnique = (g, f) => [...new Set(g.map(f))].join(" and ");
const costliest = [...FINDINGS].sort((a, b) => b.cost - a.cost).slice(0, 5);
console.log(`\ntop five costliest findings: type ${joinUnique(costliest, (x) => x.type)}, ` +
  `origin ${joinUnique(costliest, (x) => STEP_NAME(x.origin))}, found ${joinUnique(costliest, (x) => STEP_NAME(x.found))}, ` +
  `avg. ${(costliest.reduce((a, x) => a + x.cost, 0) / 5).toFixed(1)} units of rework`);
```

```
39 total defect findings across four models; bucket = feedback delay (rounds)

    delay  found  avg. rework (units)  avg. steps redone  avg. built-on items
        0      3                  4.0                1.0                  7.0
      1-5     11                  8.7                2.9                 10.4
     6-12      6                 10.5                3.5                 13.5
    13-24     13                 10.8                3.4                 15.0
    25-99      6                 16.5                5.0                 18.0

top five costliest findings: type late constraint, origin analysis, found operations, avg. 16.8 units of rework
```

The buckets move in a single direction: rework is 4 units when delay is zero, and 16.5 units once
delay passes twenty-five rounds. The gap comes from two factors. Steps redone rises from 1.0 to 5.0,
because a defect found late invalidates more steps; items built on the same assumption rises from 7.0
to 18.0, because work has piled up around the defect in the meantime. All five of the costliest
findings are of the same type — a late-learned constraint born in analysis and found in operations.

Base work is 270 units in all four models; the difference is created by fixed overhead and rework.
There is no single number that says one model is "better": small batches win where late-learned
constraints dominate, early validation wins where missing information dominates, and large batches
win where the fixed release cost is high.

#### Summary

- The same 18 work items were run through four lifecycles; the 270 units of base work were held
  fixed, so only the batching rule and the fixed overhead created the difference.
- Waterfall gives the shortest flow (43 rounds) but accumulates 243 rounds of waiting, pushes delay to
  20.1 rounds, and leaves all 18 items sitting on the wrong assumption at every defect.
- The V model cuts missing-information delay from 13.3 rounds to 0; in exchange, total work effort
  rises from 381 to 440 units and delay rises for the other two defect types.
- Spiral cuts delay to 8.5 rounds but puts two items carrying the same constraint into the same cycle
  and misses the cross-batch learning; incremental cuts waiting to 18 rounds and rework to 90 units
  and never even births one defect, at the price of the longest flow, 48 rounds.
- Across the pooled 39 findings, once delay passes 25 rounds rework climbs from 4 to 16.5 units; steps
  redone rises from 1.0 to 5.0, and items built on top rise from 7.0 to 18.0.

#### Next Step

This lesson's single strongest variable was batch size: the difference between one batch of eighteen
items and six batches of three items changed waiting by a factor of thirteen and feedback delay by a
factor of four. But batch size was chosen by hand here — nowhere was it measured why six increments
and not some other number. The next lesson takes up iterative delivery frameworks by their common
structure and scans the real variable: how flow time, rework, and **time spent in ceremony** change as
iteration length grows from one week to four weeks. A short iteration lowers delay, but every
iteration's planning and review overhead is fixed, and an item that does not fit in the iteration is
left half-done and carried over to the next one. Because the two costs grow in opposite directions,
the scan does not produce a single best length.

### Agile Frameworks

Source: https://academia.sh/en/courses/process-and-team/agile-frameworks

The common structure of iterative delivery frameworks and a scan of iteration length: at every length from one week to six weeks, flow time, feedback delay, rework, time spent in ceremony, and half-done work items are counted.

The previous lesson showed how powerful a variable batch size is: between one batch of eighteen items
and six batches of three items, waiting changed by a factor of thirteen and feedback delay by a
factor of four. But those batches were chosen by hand. Iterative delivery frameworks institutionalize
exactly this choice — they tie batch size to a fixed **duration** and turn that duration into a
rhythm.

This lesson takes up frameworks by their **common structure**, because structure is what is
measurable: a **backlog**, a fixed-length **iteration**, a planning session at the start of the
iteration and a **review ceremony** at the end, and a **role separation** that keeps the person doing
the work apart from the person accepting it. Once these four elements are chosen, the rest of the
numbers fall out on their own. The through-line is the regional library network, and it is fiction; in
this lesson, change requests coming in for the network's loan, fee, membership, and branch services
are pooled into a single backlog.

#### The Common Skeleton

The block below is a **model**, not a measurement. It runs a framework's skeleton, not its name.

**PM7 — thirty work items are derived from a generator; the generator is self-written and the seed is
visible.** The same item set is used at every iteration length; the difference between lengths comes
only from the rhythm. Every item has a size (2–8 person-days), an arrival day, and earlier items it
touches the same data as.

**PM8 — the team is four people and produces 4 person-days of work per day.** The ceremony cost per
iteration is `4 + L` person-days: a fixed planning-and-evaluation overhead plus a review share that
grows with iteration length. This is the single number that carries the cost of a short iteration.

**PM9 — acceptance is granted only at the review ceremony.** This is the measurable consequence of
role separation: the person doing the work and the person accepting it are different people, and the
accepter is only at the table during the ceremony. Even if an item finishes on the iteration's first
day, it receives acceptance only on the iteration's last day.

**PM10 — the defect surfaces at the moment of acceptance.** Rework size is one-third of the item plus
1 person-day for every item that touches it and was completed in the same iteration after the
defective item finished. In a long iteration, more items finish after the defective one, so this
surcharge grows.

**PM11 — an item that does not fit in the iteration becomes a carry-over item, left half-done,** and
pays a 1-person-day carryover cost in the next iteration. This is the second price of a short
iteration.

```js
// agile.mjs — the common skeleton of iterative delivery (model); detailed run at one length
import { writeFileSync } from "node:fs";

const PEOPLE = 4;      // team; PEOPLE person-days of work come out per day
const CARRYOVER = 1;   // return cost a carried-over item pays in the next iteration
const ceremony = (L) => 4 + L;              // planning + review + evaluation (person-days)

// PM7: the generator is self-written, the seed is visible; the item set is the same across every iteration length.
let seed = 20250401;
const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const ITEM = [...Array(30)].map((_, id) => ({
  id, size: 2 + Math.floor(rand() * 7), arrival: Math.floor(rand() * 40),
  defect: rand() < 0.35, linked: [],
}));
for (const k of ITEM)                        // earlier items touching the same data
  for (const o of ITEM) if (o.id < k.id && rand() < 0.18) k.linked.push(o.id);

function run(L) {
  const day = 5 * L, net = PEOPLE * day - ceremony(L);
  const pool = ITEM.map((k) => ({ ...k, remaining: k.size, done: -1, started: false, reworked: false }));
  const log = [], delays = [];
  let n = 0, halfDone = 0, carryover = 0, ceremonyTotal = 0, rework = 0;

  while (pool.some((k) => k.done < 0)) {
    const start = n * day, end = start + day - 1;          // the review ceremony sits on the last day
    ceremonyTotal += ceremony(L);
    let cap = net, spent = 0;
    const candidates = pool.filter((k) => k.done < 0 && k.arrival <= start)
      .sort((a, b) => (b.started - a.started) || (a.arrival - b.arrival) || (a.id - b.id));
    const finished = [];
    let worked = 0, halfDoneThis = 0;
    for (const k of candidates) {                          // capacity's worth is pulled in at planning
      const extra = k.started ? CARRYOVER : 0;
      if (cap <= extra) break;
      if (extra) { cap -= extra; spent += extra; carryover += extra; }
      k.started = true; worked += 1;
      const taken = Math.min(cap, k.remaining);
      k.remaining -= taken; cap -= taken; spent += taken;
      if (k.remaining === 0) { k.done = start + Math.min(day - 1, Math.floor(spent / PEOPLE)); finished.push(k); }
      else { halfDone += 1; halfDoneThis += 1; }
    }
    // PM10: acceptance is granted only at the review ceremony; the defect surfaces that day.
    let found = 0;
    for (const k of finished) {
      if (!k.defect || k.reworked) continue;
      const touching = finished.filter((x) => x.linked.includes(k.id) && x.done >= k.done).length;
      const size = Math.ceil(k.size / 3) + touching;
      pool.push({ id: 100 + k.id, size, remaining: size, arrival: end + 1, done: -1,
                   started: false, defect: false, reworked: true, linked: [] });
      rework += size; found += 1;
      delays.push(end - k.done);
    }
    log.push({ n: n + 1, start, end, pulled: worked, finished: finished.length,
                 halfDone: halfDoneThis, backlog: candidates.length - worked, found });
    n += 1;
  }
  const original = pool.filter((k) => !k.reworked);
  const avg = (a) => a.reduce((x, y) => x + y, 0) / a.length;
  const base = ITEM.reduce((a, k) => a + k.size, 0);
  return { L, iterations: n, day: Math.max(...pool.map((k) => k.done)) + 1,
    flow: avg(original.map((k) => k.done - k.arrival + 1)), delay: avg(delays),
    defective: delays.length, rework, halfDone, carryover, ceremony: ceremonyTotal,
    base, total: base + rework + carryover + ceremonyTotal, log };
}

const SCAN = [1, 2, 3, 4, 6].map(run);
writeFileSync("scan.json", JSON.stringify(SCAN.map(({ log, ...r }) => r)));

console.log(`work items: ${ITEM.length}, base work: ${ITEM.reduce((a, k) => a + k.size, 0)} person-days, ` +
  `defective items: ${ITEM.filter((k) => k.defect).length}, ` +
  `linked pairs: ${ITEM.reduce((a, k) => a + k.linked.length, 0)} (seed ${20250401})`);

const twoWeek = SCAN[1];
console.log(`\ntwo-week iteration, detailed run (ceremony ${ceremony(2)} person-days, ` +
  `net capacity ${PEOPLE * 10 - ceremony(2)} person-days):`);
console.log(`${"iteration".padStart(9)}${"work day".padStart(9)}${"pulled".padStart(9)}` +
  `${"finished".padStart(10)}${"half-done".padStart(13)}${"backlog left".padStart(15)}` +
  `${"defects found".padStart(15)}`);
for (const r of twoWeek.log)
  console.log(`${String(r.n).padStart(9)}${`${r.start}-${r.end}`.padStart(9)}` +
    `${String(r.pulled).padStart(9)}${String(r.finished).padStart(10)}` +
    `${String(r.halfDone).padStart(13)}${String(r.backlog).padStart(15)}` +
    `${String(r.found).padStart(15)}`);
console.log(`total ${twoWeek.day} work days, ${twoWeek.iterations} iterations; ` +
  `${twoWeek.defective} defects found with an average delay of ${twoWeek.delay.toFixed(1)} work days`);
```

```
work items: 30, base work: 152 person-days, defective items: 8, linked pairs: 75 (seed 20250401)

two-week iteration, detailed run (ceremony 6 person-days, net capacity 34 person-days):
iteration work day   pulled  finished    half-done   backlog left  defects found
        1      0-9        1         1            0              0              0
        2    10-19        8         8            0              5              1
        3    20-29        8         7            1              3              2
        4    30-39        7         6            1              3              1
        5    40-49        8         8            0              4              2
        6    50-59        6         6            0              0              2
        7    60-69        2         2            0              0              0
total 62 work days, 7 iterations; 8 defects found with an average delay of 5.5 work days
```

In the two-week rhythm, the first iteration is nearly empty: on day zero the backlog holds only one
item, and everything that arrives afterward waits for the next planning session. From the third
iteration on, a queue of three to four items forms in the backlog; one item is left half-done in
the third and fourth iterations, and after the fifth the queue clears. All eight defects are found
at review ceremonies, and they surface only after waiting an average of 5.5 work days.

#### Scanning Iteration Length

Rhythm by itself is not a value, it is a setting. The block below runs the same thirty items at one-,
two-, three-, four-, and six-week iterations.

**PM12 — two costs grow in opposite directions.** A short iteration lowers delay and the duration a
half-done item sits idle, but it raises the ceremony share; a long iteration lowers the ceremony share
but makes an item wait both to start and for acceptance.

```js
// scan.mjs — reads the scan results agile.mjs wrote; iteration length is scanned
import { readFileSync } from "node:fs";

const T = JSON.parse(readFileSync("scan.json", "utf8"));
const table = (header, row, caption) => {
  const write = (h) => console.log(h.map((c, i) => String(c).padStart(header[i][1])).join(""));
  console.log(); console.log(caption); write(header.map((b) => b[0])); row.forEach(write);
};

table([["length", 8], ["iterations", 12], ["work day", 10], ["avg. flow time", 18],
       ["avg. delay", 13], ["rework", 11], ["half-done", 12]],
  T.map((r) => [`${r.L} week`, r.iterations, r.day, r.flow.toFixed(1), r.delay.toFixed(1),
                r.rework, r.halfDone]),
  "iteration length scan (flow time and delay in work days):");

table([["length", 8], ["base work", 11], ["ceremony", 10], ["carryover", 11], ["rework", 10],
       ["total", 9], ["ceremony share", 16]],
  T.map((r) => [`${r.L} week`, r.base, r.ceremony, r.carryover, r.rework, r.total,
                `${((100 * r.ceremony) / r.total).toFixed(1)}%`]),
  "distribution of work effort (person-days):");

// PM12: two costs grow in opposite directions; the net price of stretching one step is below.
const signed = (x, d = 0) => (x > 0 ? "+" : "") + x.toFixed(d);
console.log(`\nthe cost of stretching by one step:`);
for (let i = 1; i < T.length; i++) {
  const a = T[i - 1], b = T[i];
  console.log(`  ${a.L} -> ${b.L} week: ceremony ${signed(b.ceremony - a.ceremony)}, ` +
    `carryover ${signed(b.carryover - a.carryover)}, rework ${signed(b.rework - a.rework)}, ` +
    `net ${signed(b.total - a.total)} person-days; flow time ${signed(b.flow - a.flow, 1)}, ` +
    `delay ${signed(b.delay - a.delay, 1)} work days`);
}
const least = T.reduce((a, b) => (b.total < a.total ? b : a));
const fastest = T.reduce((a, b) => (b.flow < a.flow ? b : a));
console.log(`\nleast work effort at ${least.L} week (${least.total} person-days), ` +
  `shortest flow time at ${fastest.L} week (${fastest.flow.toFixed(1)} work days)`);
```

```
iteration length scan (flow time and delay in work days):
  length  iterations  work day    avg. flow time   avg. delay     rework   half-done
  1 week          13        63              12.5          2.3         19           8
  2 week           7        62              13.8          5.5         21           2
  3 week           5        63              17.6          4.9         20           1
  4 week           5        81              21.6         10.6         22           1
  6 week           4        93              31.2         17.0         23           0

distribution of work effort (person-days):
  length  base work  ceremony  carryover    rework    total  ceremony share
  1 week        152        65          8        19      244           26.6%
  2 week        152        42          2        21      217           19.4%
  3 week        152        35          1        20      208           16.8%
  4 week        152        40          1        22      215           18.6%
  6 week        152        40          0        23      215           18.6%

the cost of stretching by one step:
  1 -> 2 week: ceremony -23, carryover -6, rework +2, net -27 person-days; flow time +1.3, delay +3.3 work days
  2 -> 3 week: ceremony -7, carryover -1, rework -1, net -9 person-days; flow time +3.8, delay -0.6 work days
  3 -> 4 week: ceremony +5, carryover 0, rework +2, net +7 person-days; flow time +4.0, delay +5.8 work days
  4 -> 6 week: ceremony 0, carryover -1, rework +1, net 0 person-days; flow time +9.6, delay +6.4 work days

least work effort at 3 week (208 person-days), shortest flow time at 1 week (12.5 work days)
```

#### Two Metrics Point to Two Different Lengths

The scan does not produce a single winner, it produces two separate winners. **Flow time is shortest at
one week** (12.5 work days) and degrades in a single direction as length grows: at six weeks it
reaches 31.2 work days, two and a half times as long. Feedback delay moves the same way — from 2.3
work days to 17.0 work days, a factor of seven. The reason is twofold: an item waits for the next
planning session to start and for the end of the iteration to be accepted, and both waits are directly
proportional to iteration length.

**Total work effort, on the other hand, is lowest at three weeks** (208 person-days). The one-week
rhythm spends 244 person-days, and 65 of those are ceremony — **26.6% of the total**. The same rhythm
produces a half-done item eight times and pays 8 person-days of carryover cost; the six-week rhythm
has no half-done items at all. Going from one week to two weeks saves 23 person-days of ceremony and 6
person-days of carryover, at the cost of adding only 1.3 work days to flow time — this step is cheap.
Going from three weeks to four weeks, on the other hand, **adds** 7 person-days and piles 4.0 work
days onto flow time; there is nothing left to gain at this step.

Rework moves less than expected: from 19 to 23 person-days, only a one-fifth change. The number of
linked items completed after a defective one grows in a long iteration, but in a set of thirty items
this surcharge stays small. **The real price of a long iteration is not the volume of rework but the
delay itself**; the one-unit drop in rework between two weeks and three weeks is part of this same
noise and should not be read as a trend.

The work-day column carries a separate warning: the one-week rhythm finishes in 63 work days, the
two-week rhythm in 62. A short iteration shortens flow time but does not finish the work as a whole
any sooner, because the capacity it loses goes to ceremony.

#### Summary

- Iterative frameworks were reduced to four common elements — a backlog, a fixed-length iteration, a
  planning session and review ceremony, and a role separation that keeps acceptance authority apart —
  and this skeleton was run.
- The same 30 items were run at five lengths; flow time is 12.5 work days at one week and 31.2 at six
  weeks, and feedback delay rises from 2.3 to 17.0 work days.
- Total work effort is lowest at three weeks (208 person-days); the one-week rhythm spends 244
  person-days, of which 26.6% is ceremony, and it also leaves 8 items half-done and pays 8 person-days
  of carryover cost.
- Because two metrics point to two different lengths, the scan does not produce a single best one:
  flow time points to one week, total work effort points to three weeks.
- Rework is the column that moves the least, rising from 19 to 23 person-days; the price of a long
  iteration is the delay itself far more than the volume of rework.

#### Next Step

Every measurement in this lesson rested on a fixed-rhythm assumption: a work item can only start at
the next planning session and can only be accepted at the end of the iteration. Once the rhythm is
removed, the only control lever left is **how many items are open at the same time**. The next lesson
scans that limit: as the work-in-progress limit rises from one upward, how cycle time and the share of
flow time spent waiting change, at which limit throughput stops rising, and what is lost when the
limit is held too low. Flow metrics are defined in the same run — lead time, cycle time, and
throughput — and what decision each one drives is written down separately.

### Flow-Based Management

Source: https://academia.sh/en/courses/process-and-team/flow-based-management

Measuring work-in-progress limits and flow metrics: lead time, cycle time, and throughput are defined in the same run, the WIP limit is scanned from one to sixteen, and idle capacity below the limit is counted against work effort lost to switching above it.

Every measurement in the previous lesson rested on a rhythm assumption: a work item can only start
at the next planning session and can only be accepted at the end of the iteration. Once the rhythm is
removed, no batch remains, and a work item is pulled the moment it is ready. What is left is a single
control lever — **how many items are open at the same time**. This lesson scans that number from one
end to the other.

For the scan to mean anything, the three metrics must first be separated. **Lead time** is the total
duration from the day an item is requested to the day it reaches operations. **Cycle time** is the
duration from the day an item is pulled into work to the day it finishes — lead time with the portion
spent queued removed. **Throughput** is the number of items finished per unit of time. All three come
out of the same run, and each points to a different decision. A work-in-progress (WIP) limit is a
backpressure lever; because queueing mechanics and backpressure are measured elsewhere in this
curriculum, what is counted here is not the queue itself but the limit's effect on human flow. The
through-line is the regional library network, and it is fiction.

#### Three Metrics, One Run

The block below is a **model**, not a measurement.

**PM13 — sixty work items are derived from a generator; the generator is self-written and the seed is
visible.** Every item has a size (2–8 person-days) and an arrival day; the same set is used at every
limit value.

**PM14 — an item can receive at most 1 person-day per day**, because only one person works on it. This
is the rule that decides what is lost below the limit: with two items open, a four-person team can
spend at most 2 person-days a day, and the remaining two people sit idle.

**PM15 — once the number of open items exceeds the number of people, part of the day goes to
switching.** Every extra open item per person takes away 15% of that day's capacity. This is the rule
that decides what is lost above the limit.

**PM16 — the WIP limit caps the number of open items.** An item that arrives while the limit is full
waits in the queue, and that wait counts toward lead time, not cycle time.

**PM17 — items are pulled in arrival order, and an item once started is never set aside
half-finished.** Changing the priority order would change the distribution of lead time; holding it
fixed ensures the only thing that changes in the scan is the limit itself.

```js
// flow.mjs — work-in-progress-limited pull system (model); flow metrics defined at a single limit
import { writeFileSync } from "node:fs";

const TEAM = 4;         // team
const PENALTY = 0.15;   // daily loss per person for every extra open item

// PM13: the generator is self-written, the seed is visible; the same item set is used at every limit.
let seed = 20250715;
const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
let t = 0;
const ITEM = [...Array(60)].map((_, id) => {
  t += rand() < 0.35 ? 0 : 1;                  // arrivals average around 1.5 items per day
  return { id, size: 2 + Math.floor(rand() * 7), arrival: t };
});

function run(wip) {
  const queue = ITEM.map((k) => ({ ...k, remaining: k.size, start: -1, end: -1 }));
  const open = [];
  let day = 0, idleCapacity = 0, switchingLoss = 0;
  while (queue.some((k) => k.end < 0)) {
    day += 1;
    for (const k of queue)                      // the WIP limit's slack pulls in items
      if (open.length < wip && k.start < 0 && k.arrival <= day) { k.start = day; open.push(k); }
    const n = open.length;
    if (n === 0) { idleCapacity += TEAM; continue; }
    // PM15: with more than one open item per person, part of the day goes to switching
    const effective = TEAM * (1 - PENALTY * Math.max(0, n / TEAM - 1));
    switchingLoss += TEAM - effective;
    const share = Math.min(1, effective / n);    // PM14: an item can get at most 1 person-day per day
    idleCapacity += Math.max(0, effective - share * n);
    for (const k of [...open]) {
      k.remaining -= share;
      if (k.remaining <= 1e-9) { k.end = day; open.splice(open.indexOf(k), 1); }
    }
  }
  const measurements = queue.map((k) => ({
    id: k.id, size: k.size, leadTime: k.end - k.arrival + 1, cycle: k.end - k.start + 1,
    wait: k.end - k.start + 1 - k.size, queued: k.start - k.arrival,
  }));
  const avg = (f) => measurements.reduce((a, x) => a + f(x), 0) / measurements.length;
  return { wip, day, measurements,
    leadTime: avg((x) => x.leadTime), cycle: avg((x) => x.cycle), queued: avg((x) => x.queued),
    waitShare: avg((x) => x.wait) / avg((x) => x.cycle),
    throughput: ITEM.length / day, idleCapacity: +idleCapacity.toFixed(1), switchingLoss: +switchingLoss.toFixed(1) };
}

const SCAN = [1, 2, 3, 4, 5, 6, 8, 10, 12, 16].map(run);
writeFileSync("scan.json", JSON.stringify(SCAN.map(({ measurements, ...r }) => r)));

const five = SCAN[4];
console.log(`work items: ${ITEM.length}, base work: ${ITEM.reduce((a, k) => a + k.size, 0)} person-days, ` +
  `last arrival: day ${ITEM[ITEM.length - 1].arrival}, team ${TEAM} (seed ${20250715})`);
console.log(`\nWIP limit ${five.wip}: three metrics on the first eight items (days):`);
console.log(`${"item".padStart(6)}${"size".padStart(7)}${"queued".padStart(10)}` +
  `${"cycle time".padStart(15)}${"of which wait".padStart(17)}${"lead time".padStart(15)}`);
for (const x of five.measurements.slice(0, 8))
  console.log(`${String(x.id).padStart(6)}${String(x.size).padStart(7)}${String(x.queued).padStart(10)}` +
    `${String(x.cycle).padStart(15)}${String(x.wait).padStart(17)}${String(x.leadTime).padStart(15)}`);
console.log(`\nlead time = time queued + cycle time; cycle time = work + wait`);
console.log(`average: lead time ${five.leadTime.toFixed(1)}, cycle ${five.cycle.toFixed(1)}, ` +
  `queued ${five.queued.toFixed(1)} days; wait share ${(100 * five.waitShare).toFixed(1)}%; ` +
  `throughput ${five.throughput.toFixed(2)} items/day`);
```

```
work items: 60, base work: 285 person-days, last arrival: day 40, team 4 (seed 20250715)

WIP limit 5: three metrics on the first eight items (days):
  item   size    queued     cycle time    of which wait      lead time
     0      7         1              9                2             10
     1      7         0              9                2              9
     2      7         0              9                2              9
     3      5         0              7                2              7
     4      2         0              3                1              3
     5      2         2              3                1              5
     6      2         4              3                1              7
     7      3         3              4                1              7

lead time = time queued + cycle time; cycle time = work + wait
average: lead time 21.9, cycle 6.6, queued 15.3 days; wait share 27.8%; throughput 0.74 items/day
```

Item six shows in a single line why the metrics have to be kept apart: a two-person-day piece of work
finishes in three days once it is pulled into work, but it takes seven days from the day it was
requested. Cycle time says the item carries one-third of a day's wait; lead time says
**three-quarters** of the duration passed before work even began. In the same run, these two metrics
can be four times apart from each other.

#### Scanning the WIP Limit

**PM18 — three metrics drive three separate decisions,** and the point of the scan is to see which one
points to which limit. The block below scans the limit from one to sixteen.

```js
// limit.mjs — reads the scan results flow.mjs wrote; the WIP limit is scanned end to end
import { readFileSync } from "node:fs";

const T = JSON.parse(readFileSync("scan.json", "utf8"));
const BASE = 285;
const table = (header, row, caption) => {
  const write = (h) => console.log(h.map((c, i) => String(c).padStart(header[i][1])).join(""));
  if (caption) { console.log(); console.log(caption); }
  write(header.map((b) => b[0])); row.forEach(write);
};

table([["WIP limit", 10], ["total days", 12], ["lead time", 15], ["cycle time", 15],
       ["queued", 10], ["wait share", 14], ["throughput", 12]],
  T.map((r) => [r.wip, r.day, r.leadTime.toFixed(1), r.cycle.toFixed(1), r.queued.toFixed(1),
                `${(100 * r.waitShare).toFixed(1)}%`, r.throughput.toFixed(2)]));

table([["WIP limit", 10], ["total capacity", 17], ["base work", 10], ["idle", 11],
       ["to switching", 14], ["idle share", 12]],
  T.map((r) => [r.wip, 4 * r.day, BASE, r.idleCapacity.toFixed(1), r.switchingLoss.toFixed(1),
                `${((100 * r.idleCapacity) / (4 * r.day)).toFixed(1)}%`]),
  "where the capacity goes (person-days):");

// PM18: three metrics drive three different decisions; each one points to a different limit.
const least = (f) => T.reduce((a, b) => (f(b) < f(a) ? b : a));
const peak = least((r) => -r.throughput), last = T[T.length - 1];
console.log(`\nshortest cycle time at limit ${least((r) => r.cycle).wip}, ` +
  `shortest lead time at limit ${least((r) => r.leadTime).wip}, ` +
  `highest throughput at limit ${peak.wip} (${peak.throughput.toFixed(2)} items/day)`);
console.log(`throughput peaks at limit ${peak.wip}, then falls: ` +
  `at limit ${last.wip} it is ${last.throughput.toFixed(2)} items/day and ${last.switchingLoss.toFixed(1)} person-days go to switching`);
console.log(`lost below the limit: at limit 1, ` +
  `${((100 * T[0].idleCapacity) / (4 * T[0].day)).toFixed(1)}% of capacity sits idle, ` +
  `at limit ${peak.wip} it is ${((100 * peak.idleCapacity) / (4 * peak.day)).toFixed(1)}%`);
```

```
 WIP limit  total days      lead time     cycle time    queued    wait share  throughput
         1         285          118.5            4.8     113.8          0.0%        0.21
         2         143           51.0            4.8      46.3          0.0%        0.42
         3          98           28.4            4.8      23.7          0.0%        0.61
         4          72           17.3            4.8      12.5          0.0%        0.83
         5          81           21.9            6.6      15.3         27.8%        0.74
         6          83           23.8            8.1      15.7         41.2%        0.72
         8          89           27.6           11.3      16.3         57.9%        0.67
        10          94           31.4           14.6      16.8         67.5%        0.64
        12         102           36.7           18.9      17.9         74.8%        0.59
        16         123           51.0           29.9      21.1         84.1%        0.49

where the capacity goes (person-days):
 WIP limit   total capacity base work       idle  to switching  idle share
         1             1140       285      855.0           0.0       75.0%
         2              572       285      287.0           0.0       50.2%
         3              392       285      107.0           0.0       27.3%
         4              288       285        3.0           0.0        1.0%
         5              324       285        5.0          11.4        1.5%
         6              332       285        4.0          23.6        1.2%
         8              356       285        7.0          49.2        2.0%
        10              376       285        4.0          75.7        1.1%
        12              408       285        4.0         109.1        1.0%
        16              492       285        5.0         195.9        1.0%

shortest cycle time at limit 1, shortest lead time at limit 4, highest throughput at limit 4 (0.83 items/day)
throughput peaks at limit 4, then falls: at limit 16 it is 0.49 items/day and 195.9 person-days go to switching
lost below the limit: at limit 1, 75.0% of capacity sits idle, at limit 4 it is 1.0%
```

#### Which Metric Drives Which Decision

The table's most instructive row is the first one. **When the WIP limit is one, cycle time is
shortest at 4.8 days and wait share is exactly zero** — the single open item is worked without
interruption and without any wait at all. In the same row, lead time is 118.5 days and **75% of
capacity sits idle**. A flow managed by watching cycle time alone makes the worst possible system look
flawless.

When the limit rises to four, four people spread across four items, idle capacity drops from 75% to
**1%**, throughput rises from 0.21 to 0.83 items/day, and lead time falls from 118.5 to 17.3 days. Up
to this point cycle time has not degraded at all (4.8 days), because each item still has a full person
assigned to it.

Past a limit of five, a second mechanism kicks in. The moment more than one item falls to each person,
cycle time rises from 4.8 to 6.6 days and **wait share jumps from zero to 27.8%**. At sixteen, cycle
time is 29.9 days and wait share is 84.1%: five-sixths of an item's life passes with no one working on
it. Throughput peaks at four and drops to 0.49 at sixteen; 195.9 person-days go to switching. **Raising
the limit opens more items at once, but finishes none of them any sooner.**

The three metrics look at three separate decisions. Cycle time measures the flow inside the work
itself and shows where in the process wait accumulates; read alone, it hides everything sitting in the
queue. Lead time is the measure of the promise given to demand, and it drives the capacity decision.
Throughput says whether new work can be accepted, and the point where it saturates marks the upper end
of the limit. In this run, the three point to limits of one, four, and four respectively; reading only
one of them means missing two of the three.

#### Summary

- Lead time, cycle time, and throughput were defined in the same run; lead time is queue wait plus
  cycle time, and cycle time is work plus wait.
- At limit 1, cycle time is shortest (4.8 days) and wait share is zero, but lead time is 118.5 days
  and 75% of capacity sits idle — cycle time alone is misleading.
- At limit 4, idle capacity drops to 1%, throughput peaks at 0.83 items/day, and lead time falls to
  17.3 days; up to this point cycle time never degrades.
- Above limit 4, wait share climbs from zero to 84.1%, cycle time stretches to 29.9 days, throughput
  falls to 0.49, and 195.9 person-days go to switching.
- In this run the three metrics point to limits of 1, 4, and 4 respectively; the limit cannot be chosen
  without reading all three together.

#### Next Step

Every measurement in this lesson was made on a single team: four people, one backlog, one WIP limit.
Once that assumption is removed, a new cost item is born. When a work item requires a change that
touches more than one team, a **coordination round** has to pass between teams, and that round is
neither work nor queue wait. The next lesson counts the price of multi-team coordination: as the
number of teams grows, how many coordination rounds fall to each dependent work item, what a joint
planning round costs, how much delay the coordination layer adds on its own, and whether this cost
grows linearly or quadratically with team count. The same run asks a second question — how many rounds
are gained by removing which type of dependency.

### Scaled Frameworks

Source: https://academia.sh/en/courses/process-and-team/scaled-frameworks

Counting the price of multi-team coordination: the same twenty-four people and twenty-four modules are split across one to twelve teams, and coordination rounds per dependent work item, the number of shared channels, the coordination layer's delay, and the cost of a joint planning round are all measured.

Every measurement in the previous lesson was made on a single team: one backlog, one WIP limit, four
people. Once that assumption is removed, a new cost item is born. When a work item touches modules
owned by more than one team, a **coordination round** has to pass between the teams; that round is
neither work nor queue wait, it adds to flow time without bringing any item closer to done.

Scaled frameworks are an attempt to bring order to that round: a **coordination layer**, a regular
**joint planning round**, and a list that makes cross-team dependencies visible in advance. This
lesson takes up frameworks not by name but by these three elements, and it measures a single question
— how much the coordination cost grows as the number of teams rises. The through-line is the regional
library network, and it is fiction; the network's twenty-four modules and twenty-four-person
development roster are held fixed, and only the team boundaries change.

#### The Same Work, Split Differently

The block below is a **model**, not a measurement.

**PM19 — twenty-four modules and twenty-four people are fixed; `E` teams only change the
boundaries.** Modules are split into contiguous blocks, and each team holds `24 / E` people. For the
comparison to mean anything, the roster does not grow: what is measured is not workforce, it is the
boundary itself.

**PM20 — forty work items are derived from a generator; the generator is self-written and the seed is
visible.** Every item touches one to four modules; every touch after the first carries a
**dependency type** — shared data, shared endpoint, shared infrastructure, or sequential delivery.

**PM21 — if an item spreads across `t` teams, it needs `t - 1` coordination rounds,** and every round
costs the two teams 2 person-days. A standing channel is also opened between every pair of teams the
item touches.

**PM22 — the coordination layer processes at most six rounds in one period**; an item waiting its turn
is delayed. The work itself, independent of coordination, takes eight periods; if coordination backs
up, the period count stretches.

**PM23 — at the joint planning round, every team presents and everyone listens.** Its cost grows with
the product of the period count, the people count, and the team count.

**PM24 — in-team coordination grows with the number of person pairs**: in an arrangement of `E` teams,
the total number of internal pairs is `E · s(s-1)/2`, where `s = 24 / E`. This is the only item that
**shrinks** as the number of teams grows.

```js
// scale.mjs — the same workload split across different team counts (model)
import { writeFileSync } from "node:fs";

const MODULE = 24, PEOPLE = 24;    // module and people counts are fixed; only team boundaries change
const ROUND_COST = 2;              // cost of one coordination round to two teams (person-days)
const INTERNAL_COST = 0.2;         // in-team coordination cost per person pair, per period (person-days)
const PLAN_COST = 0.1;             // joint planning round, per person and per team (person-days)
const BASE_PERIOD = 8;             // the work itself, independent of coordination, takes this many periods
const PERIOD_ROUNDS = 6;           // rounds the coordination layer can process in one period

// PM20: the generator is self-written, the seed is visible; the same item set is used at every team count.
let seed = 20251103;
const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const TYPE = ["shared data", "shared endpoint", "shared infrastructure", "sequential delivery"];
const ITEM = [...Array(40)].map((no) => {
  const count = 1 + Math.floor(rand() * 4);
  const modules = [], types = [];
  while (modules.length < count) {
    const m = Math.floor(rand() * MODULE);
    if (!modules.includes(m)) { modules.push(m); if (modules.length > 1) types.push(TYPE[Math.floor(rand() * 4)]); }
  }
  return { no, modules, types };
});

// PM19: E teams split the modules into contiguous blocks; each team has PEOPLE / E people.
const teamOf = (E) => (m) => Math.min(E - 1, Math.floor((m * E) / MODULE));

function run(E, skip = null) {
  const team = teamOf(E);
  let round = 0, dependent = 0, spread = 0;
  const pairs = new Set();
  const coordQueue = [];                              // rounds entering the coordination layer, item order
  for (const k of ITEM) {
    const touches = k.modules.filter((_, i) => i === 0 || k.types[i - 1] !== skip);   // the skipped type is dropped
    const t = [...new Set(touches.map(team))];
    if (t.length <= 1) continue;
    dependent += 1; spread += t.length; round += t.length - 1;
    for (let i = 0; i < t.length; i++)
      for (let j = i + 1; j < t.length; j++) pairs.add(`${Math.min(t[i], t[j])}-${Math.max(t[i], t[j])}`);
    coordQueue.push({ no: k.no, round: t.length - 1 });
  }
  // PM22: the coordination layer processes PERIOD_ROUNDS rounds per period; an item queued behind others is delayed.
  let backlog = 0, delayTotal = 0;
  for (const d of coordQueue) { backlog += d.round; delayTotal += Math.ceil(backlog / PERIOD_ROUNDS); }
  const periods = Math.max(BASE_PERIOD, Math.ceil(round / PERIOD_ROUNDS));
  const peoplePerTeam = PEOPLE / E;
  return { E, peoplePerTeam, dependent, round, pairs: pairs.size, periods,
    delay: coordQueue.length ? delayTotal / coordQueue.length : 0,
    spread: dependent ? spread / dependent : 1,
    externalCost: round * ROUND_COST,
    planCost: +(periods * PEOPLE * E * PLAN_COST).toFixed(1),   // every team presents, everyone listens
    internalCost: +(E * ((peoplePerTeam * (peoplePerTeam - 1)) / 2) * INTERNAL_COST * periods).toFixed(1) };
}

const TEAMS = [1, 2, 3, 4, 6, 8, 12];
const SCAN = TEAMS.map((E) => {
  const r = run(E);
  return { ...r, total: +(r.externalCost + r.planCost + r.internalCost).toFixed(1) };
});
writeFileSync("scale.json", JSON.stringify({ SCAN, TYPE,
  skipScan: TYPE.map((t) => ({ type: t, row: TEAMS.map((E) => run(E, t).round) })) }));

console.log(`modules: ${MODULE}, people: ${PEOPLE}, work items: ${ITEM.length}, ` +
  `dependency links: ${ITEM.reduce((a, k) => a + k.types.length, 0)} (seed ${20251103})`);
console.log(`type distribution: ${TYPE.map((t) =>
  `${t} ${ITEM.reduce((a, k) => a + k.types.filter((x) => x === t).length, 0)}`).join(", ")}`);

const four = SCAN[3];
console.log(`\nfour teams, detailed: ${four.peoplePerTeam} people per team, ${MODULE / 4} modules per team`);
console.log(`  ${four.dependent}/${ITEM.length} items spread across more than one team, ` +
  `spreading items touch an average of ${four.spread.toFixed(2)} teams`);
console.log(`  coordination rounds ${four.round}, team pairs needing a channel ${four.pairs}/${(4 * 3) / 2}, ` +
  `${four.periods} periods`);
console.log(`  cost: external coordination ${four.externalCost}, joint planning ${four.planCost}, ` +
  `in-team ${four.internalCost} person-days`);
```

```
modules: 24, people: 24, work items: 40, dependency links: 69 (seed 20251103)
type distribution: shared data 18, shared endpoint 13, shared infrastructure 21, sequential delivery 17

four teams, detailed: 6 people per team, 6 modules per team
  31/40 items spread across more than one team, spreading items touch an average of 2.61 teams
  coordination rounds 50, team pairs needing a channel 6/6, 9 periods
  cost: external coordination 100, joint planning 86.4, in-team 108 person-days
```

In the four-team arrangement, **thirty-one of the forty** items spread across more than one team, and
a spreading item touches an average of 2.61 teams. **All six** of the six possible team pairs end up
linked because of at least one item: none of the four teams is isolated from the others.

#### Scanning the Team Count

```js
// coordination.mjs — reads the scan results scale.mjs wrote; team count is scanned end to end
import { readFileSync } from "node:fs";

const { SCAN, TYPE, skipScan } = JSON.parse(readFileSync("scale.json", "utf8"));
const table = (header, row, caption) => {
  const write = (h) => console.log(h.map((c, i) => String(c).padStart(header[i][1])).join(""));
  if (caption) { console.log(); console.log(caption); }
  write(header.map((b) => b[0])); row.forEach(write);
};

table([["team", 6], ["people/team", 13], ["dependent items", 18], ["spread", 9], ["coord. rounds", 15],
       ["team pairs", 12], ["delay/item", 22]],
  SCAN.map((r) => [r.E, r.peoplePerTeam, `${r.dependent}/40`, r.spread.toFixed(2), r.round, r.pairs,
                     r.delay.toFixed(1)]));

// PM24: the growth pattern is read through two ratios; whichever one stays flat tells the order of growth.
table([["team", 6], ["coord. rounds", 15], ["rounds / team", 14], ["team pairs", 12],
       ["pairs / (E(E-1)/2)", 20]],
  SCAN.filter((r) => r.E > 1).map((r) => [r.E, r.round, (r.round / r.E).toFixed(1), r.pairs,
    (r.pairs / ((r.E * (r.E - 1)) / 2)).toFixed(2)]),
  "growth pattern:");

table([["team", 6], ["periods", 9], ["ext. coord.", 13], ["joint planning", 16], ["in-team", 10],
       ["total", 9]],
  SCAN.map((r) => [r.E, r.periods, r.externalCost, r.planCost, r.internalCost, r.total]),
  "coordination cost (person-days):");

const least = SCAN.reduce((a, b) => (b.total < a.total ? b : a));
console.log(`\nleast coordination cost at ${least.E} teams (${least.total} person-days); ` +
  `at one team ${SCAN[0].total}, at twelve teams ${SCAN[SCAN.length - 1].total} person-days`);

table([["dependency type", 24], ...SCAN.map((r) => [`${r.E} teams`, 10])],
  skipScan.map((a) => [a.type, ...a.row.map((t, i) => t - SCAN[i].round)]),
  "change in coordination rounds if a dependency type is removed:");
const four = skipScan.map((a) => ({ type: a.type, gain: SCAN[3].round - a.row[3] }))
  .sort((a, b) => b.gain - a.gain);
console.log(`\nmost rounds gained at four teams: ${four.map((d) => `${d.type} (${d.gain})`).join(", ")}`);
```

```
  team  people/team   dependent items   spread  coord. rounds  team pairs            delay/item
     1           24              0/40     1.00              0           0                   0.0
     2           12             27/40     2.00             27           1                   2.8
     3            8             30/40     2.37             41           3                   3.8
     4            6             31/40     2.61             50           6                   4.6
     6            4             32/40     2.69             54          15                   4.8
     8            3             33/40     2.76             58          27                   5.2
    12            2             34/40     2.94             66          55                   5.9

growth pattern:
  team  coord. rounds rounds / team  team pairs  pairs / (E(E-1)/2)
     2             27          13.5           1                1.00
     3             41          13.7           3                1.00
     4             50          12.5           6                1.00
     6             54           9.0          15                1.00
     8             58           7.3          27                0.96
    12             66           5.5          55                0.83

coordination cost (person-days):
  team  periods  ext. coord.  joint planning   in-team    total
     1        8            0            19.2     441.6    460.8
     2        8           54            38.4     211.2    303.6
     3        8           82            57.6     134.4      274
     4        9          100            86.4       108    294.4
     6        9          108           129.6      64.8    302.4
     8       10          116             192        48      356
    12       11          132           316.8      26.4    475.2

least coordination cost at 3 teams (274 person-days); at one team 460.8, at twelve teams 475.2 person-days

change in coordination rounds if a dependency type is removed:
         dependency type   1 teams   2 teams   3 teams   4 teams   6 teams   8 teams  12 teams
             shared data         0        -6       -10       -12       -15       -14       -18
         shared endpoint         0        -4        -5        -8        -8       -11       -13
   shared infrastructure         0        -4        -9       -11       -11       -17       -18
     sequential delivery         0        -7       -11       -14       -15       -14       -17

most rounds gained at four teams: sequential delivery (14), shared data (12), shared infrastructure (11), shared endpoint (8)
```

#### Not Linear — Two Separate Patterns

The answer is not a single growth pattern, it is two separate patterns, and they read in opposite
directions.

**The coordination round count stays below linear.** As team count rises sixfold, the round count
rises from 27 to 66; the `rounds / team` ratio **drops** from 13.5 to 5.5. The reason is a ceiling:
because an item touches at most four modules, it can spread across at most four teams, and its round
count cannot exceed three. As the team count grows, no new dependent items are found — the existing
ones just spread a little further, and spread rises from 2.00 to 2.94 and stops there.

**The number of shared channels, by contrast, grows exactly quadratically.** The `pairs / (E(E-1)/2)`
ratio is exactly 1.00 at two, three, four, and six teams: **every possible team pair** ends up linked
because of at least one item. Saturation only begins at eight and twelve teams (0.96 and 0.83), and
even there the pair count is 27 and 55. The coordination round count is bounded, but the **channel
count** those rounds spread across is not.

The cost table shows the sum of these two patterns. With one team, external coordination is zero, but
internal coordination among the twenty-four people costs 441.6 person-days. With twelve teams,
internal coordination falls to 26.4 person-days, but the joint planning round rises to 316.8
person-days — because every team presents and everyone listens, this item grows directly with team
count. The total **is lowest at three teams (274 person-days)** and nearly equal at both ends: 460.8
versus 475.2. The same work, the same twenty-four people, costs twice as much by two different routes.

The coordination layer's own delay is a separate column: it rises from 2.8 periods to 5.9 periods per
dependent item. For that entire stretch, no one is working on the item.

#### What Removing Which Dependency Gains

The last table breaks down where the rounds come from. In the four-team arrangement, removing the
**sequential delivery** dependency erases fourteen of the fifty rounds, **shared data** twelve,
**shared infrastructure** eleven, **shared endpoint** eight. The order shifts with team count: at
twelve teams, shared data and shared infrastructure move to the front (eighteen rounds each), and
sequential delivery drops to seventeen.

What matters here is the measure, not the ranking. At four teams, eliminating a single dependency type
erases **between 16% and 28%** of the rounds; it is not necessary to remove all four, but none of them
solves the problem alone either. The cheapest improvement available without changing the team boundary
is removing the dependency type that produces the most rounds from the architecture.

#### Summary

- The same 24 modules and 24 people were split across one to twelve teams; because the roster was
  held fixed, the only thing measured is the boundary itself.
- The coordination round count grows below linear (27 to 66, `rounds / team` drops from 13.5 to 5.5),
  because an item touches at most four modules and its spread stops at 2.94.
- Shared-channel count grows quadratically: between two and six teams every possible team pair ends
  up linked (`pairs / (E(E-1)/2)` exactly 1.00), and at twelve teams the pair count is 55.
- Total coordination cost is lowest at three teams (274 person-days) and nearly equal at both ends —
  441.6 person-days of internal coordination at one team, 316.8 person-days of joint planning at
  twelve teams.
- At four teams, removing a single dependency type erases 8 to 14 of the fifty rounds; sequential
  delivery gains the most, shared endpoint the least, and the order shifts with team count.

#### Next Step

Every number in this lesson stands on one assumption: when a defect is found, there is a return trip.
How many rounds that return trip costs was counted, but **when the defect is found** was always
assumed to sit in the same place — at the end of the work. The next lesson removes that assumption and
closes the topic: the distance between the step a defect is born in and the step it is found in, that
is, **feedback delay**, is measured directly. Integration frequency is scanned from once a day to once
a week, pairing ratio is scanned from zero to one, and at every setting, how many steps and how many
hours this delay spans, how much rework changes, and what the total work effort comes to are all
counted. Pair programming's two-person cost is factored in — having two people do one piece of work,
even if it cheapens the defect found, doubles the work effort.

### Engineering Practices

Source: https://academia.sh/en/courses/process-and-team/engineering-practices

Measuring feedback delay: how many steps and how many hours separate the step a defect is born in from the step it is found in, and how this delay, rework, and total work effort — including pair programming's two-person cost — change as integration frequency is scanned from two hours to forty hours and pairing ratio from zero to one.

The previous four lessons always took one number as an assumption: when a defect is found, there is a
return trip. How many items came back and what they cost was counted, but **when the defect is found**
was read off the shape of the process every single time. As this lesson closes the topic, it measures
that number directly: **feedback delay**, the distance between the step a defect is born in and the
step it is found in.

The distance is measured in two separate units, and they do not say the same thing. Delay in **steps**
gives how many stages later a defect appears; delay in **hours** gives how long those stages took. Two
practices touch these two units separately: integration frequency shortens the hours, pair programming
shortens the steps. Because the delivery pipeline's mechanics are run elsewhere in this curriculum, here the
pipeline is nothing more than a period. The through-line is the regional library network, and it is
fiction.

#### Where the Defect Is Found

The block below is a **model**, not a measurement.

**PM25 — forty-eight work items are derived from a generator; the generator is self-written and the
seed is visible.** Every item has a size (3–12 hours) and a module it touches; the same set is used at
every setting. Eight developers work.

**PM26 — the earliest step a defect can be seen in is given as input, as a class.** The path is five
steps: writing, local, integration, release, operations. A **writing**-class defect is caught while
it is being written if a second pair of eyes is present; otherwise it stays hidden until integration.
An **integration**-class defect surfaces only once it is merged with someone else's work, a
**release**-class defect at release testing, and an **operations**-class defect only in operations.
Release testing runs every forty hours, and the operations window opens every eighty hours.

**PM27 — in a paired item, a writing-class defect is fixed on the spot** and never becomes a separate
work item. This is pair programming's only gain in the model.

**PM28 — every integration event costs a fixed 0.5 hours**, plus 0.06 hours of overlap cost for every
pair of items integrated in the same batch. This is where the price of infrequent integration comes
from: as the batch grows, the pair count grows quadratically.

Rework is one-third of the item plus 1 hour for every item completed **in the same module** between
the defect's birth and the moment it is found.

```js
// practice.mjs — feedback delay (model); integration frequency and pairing ratio are scanned
import { writeFileSync } from "node:fs";

const DEVS = 8;             // developer count
const RELEASE = 40;         // release testing runs on this cadence (hours)
const OPERATIONS = 80;      // operations window
const FIXED = 0.5;          // fixed cost of one integration event (hours)
const OVERLAP = 0.06;       // overlap cost for every pair of items integrated in the same batch (hours)
const STEP = ["writing", "local", "integration", "release", "operations"];

// PM25: the generator is self-written, the seed is visible; the same item set is used at every setting.
let seed = 20260127;
const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const MODULE = ["loan", "fee", "membership", "catalog", "branch", "identity"];
const ITEM = [...Array(48)].map((no) => {
  const size = 3 + Math.floor(rand() * 10);              // 3-12 hours
  const m = MODULE[Math.floor(rand() * MODULE.length)];
  const r = rand();
  // PM26: the earliest step a defect can be seen in is a class, given as input
  const kind = r < 0.55 ? null : r < 0.76 ? "writing" : r < 0.90 ? "integration" : r < 0.96 ? "release" : "operations";
  return { no, size, module: m, kind };
});

const next = (t, p) => Math.ceil((t + 1e-9) / p) * p;   // the next periodic occurrence

function run(T, ratio) {
  const free = Array(DEVS).fill(0);
  const item = ITEM.map((k, i) => ({ ...k, paired: i % 4 < Math.round(ratio * 4) }));
  let effort = 0;
  for (const k of item) {
    const count = k.paired ? 2 : 1;
    const chosen = free.map((t, i) => [t, i]).sort((a, b) => a[0] - b[0]).slice(0, count);
    const start = Math.max(...chosen.map(([t]) => t));
    for (const [, i] of chosen) free[i] = start + k.size;
    k.end = start + k.size;
    effort += k.size * count;
  }
  // PM27: in a paired item, a writing-class defect is caught on the spot; others wait for their period
  const findings = [];
  for (const k of item) {
    if (!k.kind) continue;
    const [step, at] =
      k.kind === "writing" && k.paired ? [0, k.end]
      : k.kind === "release" ? [3, next(k.end, RELEASE)]
      : k.kind === "operations" ? [4, next(k.end, OPERATIONS)]
      : [2, next(k.end, T)];
    const builtOn = item.filter((x) => x.module === k.module && x.end > k.end && x.end <= at).length;
    // a defect caught while writing is fixed on the spot; it never becomes a separate work item
    findings.push({ no: k.no, kind: k.kind, step, delayHour: at - k.end,
                 rework: step === 0 ? 0 : Math.ceil(k.size / 3) + builtOn });
  }
  const rework = findings.reduce((a, b) => a + b.rework, 0);
  // PM28: every integration event pays a fixed cost, plus overlap for every pair of items in the batch
  const last = Math.max(...item.map((k) => k.end));
  let events = 0, overlapCost = 0;
  for (let t = T; t <= next(last, T); t += T) {
    const batch = item.filter((k) => k.end > t - T && k.end <= t).length;
    if (batch === 0) continue;
    events += 1; overlapCost += (OVERLAP * batch * (batch - 1)) / 2;
  }
  const integrationCost = +(events * FIXED + overlapCost).toFixed(1);
  const avg = (f) => findings.reduce((a, b) => a + f(b), 0) / findings.length;
  return { T, ratio, duration: last, findings, findingCount: findings.length, events, overlapCost: +overlapCost.toFixed(1),
    delayStep: avg((b) => b.step), delayHour: avg((b) => b.delayHour),
    pairFound: findings.filter((b) => b.step === 0).length,
    rework, integrationCost, effort,
    total: +(effort + rework + integrationCost).toFixed(1) };
}

const strip = ({ findings, ...r }) => r;
const FREQUENCY = [2, 4, 8, 16, 40].map((T) => run(T, 0));
const RATIO = [0, 0.25, 0.5, 0.75, 1].map((o) => run(4, o));
writeFileSync("practice.json", JSON.stringify({ FREQUENCY: FREQUENCY.map(strip), RATIO: RATIO.map(strip) }));

console.log(`work items: ${ITEM.length}, base work: ${ITEM.reduce((a, k) => a + k.size, 0)} hours, ` +
  `developers: ${DEVS} (seed ${20260127})`);
console.log(`defect class: ${["writing", "integration", "release", "operations"]
  .map((s) => `${s} ${ITEM.filter((k) => k.kind === s).length}`).join(", ")}; ` +
  `defect-free ${ITEM.filter((k) => !k.kind).length}`);
console.log(`steps: ${STEP.map((a, i) => `${i} ${a}`).join(", ")}`);

const base = FREQUENCY[1];
console.log(`\nintegration every 4 hours, no pairing: where the defect is found`);
console.log(`${"defect class".padEnd(14)}${"count".padStart(6)}${"found at step".padStart(16)}` +
  `${"delay (steps)".padStart(16)}${"delay (hours)".padStart(16)}${"rework".padStart(12)}`);
for (const s of ["writing", "integration", "release", "operations"]) {
  const g = base.findings.filter((b) => b.kind === s);
  const o = (f) => (g.reduce((a, b) => a + f(b), 0) / g.length).toFixed(1);
  console.log(`${s.padEnd(14)}${String(g.length).padStart(6)}${STEP[g[0].step].padStart(16)}` +
    `${o((b) => b.step).padStart(16)}${o((b) => b.delayHour).padStart(16)}` +
    `${String(g.reduce((a, b) => a + b.rework, 0)).padStart(12)}`);
}
```

```
work items: 48, base work: 354 hours, developers: 8 (seed 20260127)
defect class: writing 14, integration 5, release 2, operations 2; defect-free 25
steps: 0 writing, 1 local, 2 integration, 3 release, 4 operations

integration every 4 hours, no pairing: where the defect is found
defect class   count   found at step   delay (steps)   delay (hours)      rework
writing           14     integration             2.0             2.1          45
integration        5     integration             2.0             3.4          16
release            2         release             3.0            20.5          15
operations         2      operations             4.0            56.5          13
```

The table shows why feedback delay has to be measured in two units. The fourteen writing-class
defects, with no pairing, are found **two steps** later, an average of 2.1 hours later; they wait for
integration when they could have been fixed on the spot. At the other end there are only two
operations defects, but their delay is 56.5 hours and they produce thirteen hours of rework — 6.5
hours per defect, twice the writing class. A small number of late-found defects is more expensive than
a large number of early-found ones.

#### Scanning the Two Settings

```js
// delay-scan.mjs — reads the results practice.mjs wrote; two settings are scanned separately
import { readFileSync } from "node:fs";

const { FREQUENCY, RATIO } = JSON.parse(readFileSync("practice.json", "utf8"));
const FIXED = 0.5;
const table = (header, row, caption) => {
  const write = (h) => console.log(h.map((c, i) => String(c).padStart(header[i][1])).join(""));
  if (caption) { console.log(); console.log(caption); }
  write(header.map((b) => b[0])); row.forEach(write);
};

table([["integration", 13], ["events", 8], ["delay (steps)", 16], ["delay (hours)", 16],
       ["rework", 12], ["integration cost", 19], ["total", 9]],
  FREQUENCY.map((r) => [`${r.T} h`, r.events, r.delayStep.toFixed(2), r.delayHour.toFixed(1),
                     r.rework, r.integrationCost, r.total]),
  "integration frequency (no pairing):");

// PM29: the only cost of a shorter cadence is the fixed per-event cost; the break-even point follows.
const a = FREQUENCY[0], b = FREQUENCY[1];
const breakEven = (b.rework + b.overlapCost - a.rework - a.overlapCost) / (a.events - b.events);
console.log(`\nthe two-hour rhythm produces ${a.events} events, the four-hour rhythm ${b.events}; ` +
  `at a fixed cost of ${FIXED} hours per event, the two-hour rhythm comes out ${(b.total - a.total).toFixed(1)} hours ahead.`);
console.log(`this lead disappears once the fixed cost per event passes ${breakEven.toFixed(2)} hours.`);

table([["pairing ratio", 15], ["duration (hours)", 19], ["caught while writing", 23], ["delay (steps)", 16],
       ["delay (hours)", 16], ["rework", 12], ["effort", 9], ["total", 9]],
  RATIO.map((r) => [`${(100 * r.ratio).toFixed(0)}%`, r.duration, `${r.pairFound}/${r.findingCount}`,
                   r.delayStep.toFixed(2), r.delayHour.toFixed(1), r.rework, r.effort, r.total]),
  "pairing ratio (integration every 4 hours):");

// PM30: if pairing's two-person cost exceeds its gain, at what multiplier does it break even?
const y = RATIO[0], z = RATIO[RATIO.length - 1];
const multiplier = (z.effort - y.effort) / (y.rework - z.rework);
console.log(`\nat 100% pairing, delay falls from ${y.delayHour.toFixed(1)} hours to ` +
  `${z.delayHour.toFixed(1)} hours, and rework from ${y.rework} hours to ${z.rework} hours; ` +
  `in exchange, effort rises from ${y.effort} hours to ${z.effort} hours and the work stretches ${z.duration - y.duration} hours.`);
console.log(`rework per defect is ${(y.rework / y.findingCount).toFixed(1)} hours; ` +
  `for pairing to pay for itself, this number has to grow to ${multiplier.toFixed(1)} times that.`);
```

```
integration frequency (no pairing):
  integration  events   delay (steps)   delay (hours)      rework   integration cost    total
          2 h      21            2.26             8.0          82               13.3    449.3
          4 h      13            2.26             8.7          89               12.3    455.3
          8 h       7            2.26            10.0          94               13.6    461.6
         16 h       4            2.26            13.4         103               22.9    479.9
         40 h       2            2.26            21.4         132               53.6    539.6

the two-hour rhythm produces 21 events, the four-hour rhythm 13; at a fixed cost of 0.5 hours per event, the two-hour rhythm comes out 6.0 hours ahead.
this lead disappears once the fixed cost per event passes 1.25 hours.

pairing ratio (integration every 4 hours):
  pairing ratio   duration (hours)   caught while writing   delay (steps)   delay (hours)      rework   effort    total
             0%                 50                   0/23            2.26             8.7          89      354    455.3
            25%                 62                   4/23            1.91             7.8          69      443    523.8
            50%                 73                   7/23            1.65             6.3          59      530    601.9
            75%                 85                   9/23            1.48             5.3          50      615    677.5
           100%                 90                  14/23            1.04             4.7          35      708    755.8

at 100% pairing, delay falls from 8.7 hours to 4.7 hours, and rework from 89 hours to 35 hours; in exchange, effort rises from 354 hours to 708 hours and the work stretches 40 hours.
rework per defect is 3.9 hours; for pairing to pay for itself, this number has to grow to 6.6 times that.
```

#### Two Practices, Two Separate Components

The first table's most striking column is the one that does not move. Even as integration goes from
every two hours to every forty, **delay in steps stays fixed at 2.26**: the defect is still found at
the same step, only reaching that step takes longer. Delay in hours rises from 8.0 to 21.4, and rework
from 82 to 132 hours. At the forty-hour rhythm, integration cost also jumps from 13.3 to 53.6 hours,
because as the number of items integrated in a single batch grows, the overlap-pair count grows
quadratically. **Integrating more often loses nothing anywhere in this model**; the two-hour rhythm is
6.0 hours ahead of the four-hour rhythm, and that lead only disappears once the fixed cost per event
passes 1.25 hours. That is the number that needs to be measured: how long one integration event takes.

The second table does exactly the opposite. Pairing touches **delay in steps** — it falls from 2.26 to
1.04, because fourteen out of fourteen writing defects are caught at step zero and produce no rework
at all. Rework falls from 89 to 35 hours. Its price, though, is on a scale seen nowhere else in the
settings: effort rises from 354 hours to **708 hours**, because the same work is now done by two
people, and the work stretches by forty hours, because eight people now run half as many items at the
same time.

The arithmetic is plain. 354 hours of extra effort buys 54 hours of rework savings. For pairing to pay
for itself in this model, rework per defect has to grow from 3.9 hours to **6.6 times** that. This
multiplier is not imaginary: operations-class defects already run at 6.5 hours per defect, and if a
defect slips into operations, rework climbs well above what this model shows. So pairing is an
expensive habit where defects are cheap, and a cheap insurance policy where defects are expensive — and
which one applies is a measurable question.

#### Summary

- Feedback delay was measured in two units: delay in steps gives how many stages later a defect
  appears, delay in hours gives how long those stages took.
- At four-hour integration, 14 writing defects are found 2 steps and 2.1 hours later; 2 operations
  defects are found 4 steps and 56.5 hours later and produce the most expensive rework at 6.5 hours
  per defect.
- Integration frequency never changes the step count (fixed at 2.26), but it changes the hours: going
  from 2 hours to 40 hours, delay rises from 8.0 to 21.4 hours, rework from 82 to 132 hours,
  integration cost from 13.3 to 53.6 hours.
- The only price of integrating more often is the fixed cost per event; the two-hour rhythm's lead
  only disappears once that cost passes 1.25 hours.
- Pairing lowers delay in steps from 2.26 to 1.04 and rework from 89 to 35 hours, but raises effort
  from 354 to 708 hours and stretches the work by 40 hours; breaking even requires rework per defect to
  grow to 6.6 times its size.

#### Next Step

Five lessons chose the shape of the process, limited flow, counted the price of coordination, and
shortened feedback delay. All five share one blind spot. Every one of these measurements took **who
hands off to whom** as a given: the step boundary in the lifecycle model, the backlog's owner in the
iteration, the team the WIP limit covers in the flow model, which team a module falls to in the scaled
arrangement. Every time, that boundary was an input, and it was never questioned. But that boundary
comes from somewhere: teams were split some way, modules were divided up some way, and every boundary
a work item crosses is really a **team boundary**. The next topic asks that question — how much does
the way teams are split determine the architecture, and when the communication structure and the
module structure do not overlap, how much do the waiting, rework, and coordination rounds counted
across these five lessons grow.

## Team and Delivery

The relationship between the team boundary and the architectural boundary: how communication structure maps onto module structure, comparing stream-aligned team arrangements by handoff and waiting, the margin of error in estimation under uncertainty, the rework that scope change creates in the flow, and the delegable and non-delegable items in the responsibility contract between development and operations.

### Conway's Law

Source: https://academia.sh/en/courses/process-and-team/conway-law

Measuring how communication structure maps onto module structure: overlaying two separate team splits on the same module graph, counting teams per module and the module bonds that cross the team boundary, the distribution of those bonds by communication distance, finding the bonds that stand between teams with no communication path, and the handoff, waiting, and rework counts the same set of work items produces under each split.

The previous topic chose the shape of the process, bounded the flow, and shortened the defect
detection delay. All of those measurements shared one silent input: who the work passed from and to
was taken as given data, and where that boundary came from was never asked. This lesson asks that
question.

**Conway's law** is the term for the claim that a resemblance holds between the communication
structure of the organization producing a system and the structure of the system it produces. The
term alone gives no measure; the measure is built by laying two graphs side by side — the **module
graph** (which module depends on which module) and the **communication graph** (which team has a
standing channel with which team).

A graph of this same kind was read before for other purposes: a layering rule was checked, boundary
violations were counted. **That is not the count here.** What gets overlaid on the graph is not a
rule but a **team boundary**. The through-line is the **regional library network** and it is
fictional.

#### Two Graphs, One Codebase

**TD1 — the codebase consists of seventeen modules and forty-three dependency bonds.** Rationale:
overlap only carries meaning in a graph where the bond count is far larger than the team count.

**TD2 — the component-aligned split has five teams, each owning one technical component, and four
standing channels.** **TD3 — the stream-aligned split has six teams, each owning one service
stream, and nine channels.** Rationale: the two splits distribute the same seventeen modules, so
**the module graph is bit-for-bit identical in both runs**; the only thing that changes is where
the boundary falls.

**TD4 — the workload is twelve items; each item lists its modules in the order the work follows,
and the first module is where the request lands.** **TD5 — a module's "contributors" is the union
of its owner and the teams that open the items touching that module.** Rationale: in the
organization, code is open to everyone; the team that opens an item writes a change in another
team's module when it must, and asks the owner for a review.

The block below is a **model**; it was not read from an actual codebase.

```js
// build.mjs — the module graph and the communication graph are laid side by side, overlap measured (model)
import { writeFileSync } from "node:fs";

// TD1 — 17 modules; an "a: b c" line says module a imports modules b and c
const IMPORTS = {
  branchFront: "loanFlow feeFlow reservationFlow sharedFormat",
  staffFront: "membershipFlow loanFlow feeFlow sharedFormat",
  mobileAccess: "loanFlow reservationFlow catalogBridge sharedFormat",
  loanFlow: "loanRule catalogRule storeAccess notificationQueue sharedFormat",
  feeFlow: "feeRule storeAccess notificationQueue sharedFormat",
  membershipFlow: "membershipRule identityBridge storeAccess sharedFormat",
  reservationFlow: "loanRule catalogBridge storeAccess notificationQueue sharedFormat",
  loanRule: "sharedFormat", feeRule: "loanRule sharedFormat",
  catalogRule: "sharedFormat", membershipRule: "sharedFormat",
  storeAccess: "eventLog sharedFormat", catalogBridge: "catalogRule sharedFormat",
  identityBridge: "sharedFormat", notificationQueue: "eventLog sharedFormat",
  eventLog: "sharedFormat", sharedFormat: "",
};
const M = Object.keys(IMPORTS);
const EDGES = Object.entries(IMPORTS)
  .flatMap(([a, s]) => (s ? s.split(" ").map((b) => [a, b]) : []));

// TD2 — component-aligned split: five teams, each owns one technical component
const A = { name: "A (component-aligned)", teams: {
  interface: "branchFront staffFront mobileAccess",
  flow: "loanFlow feeFlow membershipFlow reservationFlow",
  rule: "loanRule feeRule catalogRule membershipRule",
  infra: "storeAccess catalogBridge identityBridge notificationQueue eventLog",
  shared: "sharedFormat" },
  channels: "interface-flow flow-rule flow-infra rule-infra" };

// TD3 — stream-aligned split: six teams, each owns one service stream
const B = { name: "B (stream-aligned)", teams: {
  experience: "branchFront staffFront mobileAccess",
  loan: "loanFlow loanRule reservationFlow storeAccess",
  fee: "feeFlow feeRule",
  membership: "membershipFlow membershipRule identityBridge",
  catalog: "catalogRule catalogBridge",
  platform: "sharedFormat eventLog notificationQueue" },
  channels: "experience-loan experience-fee experience-membership loan-fee loan-catalog " +
    "loan-platform fee-platform membership-platform catalog-platform" };

// TD4 — twelve work items; modules listed in the order the work follows, the first is where the request lands
const ITEMS = [
  ["late fee rate", "feeFlow feeRule branchFront sharedFormat"],
  ["reservation cancellation", "mobileAccess reservationFlow loanRule notificationQueue"],
  ["membership reminder", "membershipFlow membershipRule notificationQueue staffFront"],
  ["catalog record field", "catalogBridge catalogRule sharedFormat mobileAccess"],
  ["loan period extension", "loanFlow loanRule branchFront"],
  ["second identity step", "identityBridge membershipFlow staffFront sharedFormat"],
  ["fee refund record", "feeFlow storeAccess eventLog"],
  ["branch delay report", "staffFront storeAccess loanFlow"],
  ["notification text format", "notificationQueue sharedFormat"],
  ["reservation queue", "mobileAccess reservationFlow branchFront"],
  ["loan rule exception", "loanRule feeRule loanFlow feeFlow"],
  ["event log field", "eventLog sharedFormat storeAccess"],
];

function build({ name, teams, channels }) {
  const owner = {}, T = Object.keys(teams), adj = Object.fromEntries(T.map((e) => [e, []]));
  for (const [e, ms] of Object.entries(teams)) for (const m of ms.split(" ")) owner[m] = e;
  for (const k of channels.split(" ")) {
    const [x, y] = k.split("-");
    adj[x].push(y); adj[y].push(x);
  }
  const d = {};                                  // shortest path in the communication graph; -1 if no path
  for (const s of T) {
    d[s] = Object.fromEntries(T.map((e) => [e, -1]));
    d[s][s] = 0;
    for (let q = [s]; q.length; ) {
      const u = q.shift();
      for (const v of adj[u]) if (d[s][v] < 0) { d[s][v] = d[s][u] + 1; q.push(v); }
    }
  }
  // TD5 — code is open to everyone: the team that opens an item modifies another team's module when it must
  const contributors = Object.fromEntries(M.map((m) => [m, new Set([owner[m]])]));
  for (const [, ms] of ITEMS) for (const m of ms.split(" ")) contributors[m].add(owner[ms.split(" ")[0]]);
  return { name, teams, owner, d, contributors: Object.fromEntries(M.map((m) => [m, [...contributors[m]]])) };
}

const D = [build(A), build(B)];
writeFileSync("network.json", JSON.stringify({ M, EDGES, ITEMS, D }));

const measure = (s) => {
  const outside = EDGES.filter(([a, b]) => s.owner[a] !== s.owner[b]);
  const far = (f) => outside.filter(([a, b]) => f(s.d[s.owner[a]][s.owner[b]])).length;
  const at = (n) => M.filter((m) => s.contributors[m].length >= n).length;
  return {
    "module bonds crossing the team boundary": `${outside.length}/${EDGES.length}`,
    "  between teams at communication distance 1": far((u) => u === 1),
    "  between teams at communication distance 2": far((u) => u === 2),
    "  between teams with NO COMMUNICATION PATH": far((u) => u < 0),
    "module changed by more than one team": at(2),
    "  module changed by three or more teams": at(3),
    "average teams per module":
      (M.reduce((t, m) => t + s.contributors[m].length, 0) / M.length).toFixed(2),
  };
};

const O = D.map(measure);
console.log(`${M.length} modules, ${EDGES.length} bonds, ${ITEMS.length} work items\n`);
console.log(`${"measure".padEnd(42)}${D.map((s) => s.name.padStart(22)).join("")}`);
for (const k of Object.keys(O[0]))
  console.log(`${k.padEnd(42)}${O.map((o) => String(o[k]).padStart(22)).join("")}`);
for (const s of D) {
  const c = {};
  for (const [a, b] of EDGES)
    if (s.d[s.owner[a]][s.owner[b]] < 0) {
      const k = `${s.owner[a]}->${s.owner[b]}`;
      c[k] = (c[k] ?? 0) + 1;
    }
  console.log(`\n${s.name} — changed by three or more teams: ` +
    M.filter((m) => s.contributors[m].length >= 3).map((m) => `${m}(${s.contributors[m].length})`).join(", "));
  console.log(`  module bond standing between teams with no communication path: ` +
    (Object.keys(c).length ? Object.entries(c).map(([k, n]) => `${k} ${n}`).join(", ") : "none"));
}
```

```
17 modules, 43 bonds, 12 work items

measure                                    A (component-aligned)    B (stream-aligned)
module bonds crossing the team boundary                    40/43                 32/43
  between teams at communication distance 1                    23                    27
  between teams at communication distance 2                     1                     5
  between teams with NO COMMUNICATION PATH                    16                     0
module changed by more than one team                          15                    12
  module changed by three or more teams                        6                     4
average teams per module                                    2.24                  2.06

A (component-aligned) — changed by three or more teams: staffFront(3), loanFlow(3), loanRule(3), storeAccess(3), notificationQueue(3), sharedFormat(3)
  module bond standing between teams with no communication path: interface->shared 3, flow->shared 4, rule->shared 4, infra->shared 5

B (stream-aligned) — changed by three or more teams: branchFront(3), storeAccess(4), notificationQueue(3), sharedFormat(4)
  module bond standing between teams with no communication path: none
```

#### Where the Overlap Breaks Down

The sharpest row in the table sits in the middle. In the component-aligned split, **sixteen module
bonds stand between two teams that have no connection at all in the communication graph** — more
than a third of the forty-three bonds; in the stream-aligned split the number is zero.

The last output lines name where these sixteen bonds come from: all of them sit between the
`shared` team and the other four teams. `shared` owns a single module — `sharedFormat` — and has no
communication channel at all; requests reach it through a written queue. In the module graph,
though, `sharedFormat` is imported by sixteen modules. **The codebase's most depended-on module
sits in the hands of the team nobody talks to.** When the two graphs do not overlap, the module
graph wins: the dependency keeps standing in the code, and the missing channel shows itself as
waiting for every item that crosses that bond.

The stream-aligned split brings this number to zero, but grows another one. In split A no module
exceeds three teams; in B, `storeAccess` and `sharedFormat` are changed by four teams, because
stream-aligned teams reach directly into shared modules to finish their items end to end. **B's
average is better (2.06 against 2.24), but its worst module is worse.**

#### The Same Work's Flow Under Two Splits

To see the cost, the same twelve items are run through both splits.

**TD6 — an item works one round for every module it touches; each handoff between two teams waits
1 round if the communication distance is 1, 3 if the distance is 2, 5 if the distance is 3, and 8
if there is no path.** Rationale: two teams with a direct channel speak in the same round; every step
that falls to an intermediary adds one more queue's wait.

**TD7 — three rework reasons are counted: `late` (a late-learned constraint) if any handoff has a
distance greater than 1, `bound` (a wrong boundary) if the item touches a module changed by three or
more teams, `info` (missing information) if the item spreads across four or more teams; each rework
adds 2 rounds of work and charges the longest wait once more.** Rationale: the three reasons point
to three separate structural flaws — a missing channel, unclear ownership, spreading scope.

```js
// flow.mjs — the same twelve work items are run through both splits: handoffs, waiting, rework
import { readFileSync } from "node:fs";
const { ITEMS, D } = JSON.parse(readFileSync("network.json", "utf8"));

const WAIT = [0, 1, 3, 5], NONE = 8;       // TD6: wait rounds by communication distance
const run = (s) => ITEMS.map(([name, mods]) => {
  const ms = mods.split(" ");
  const order = [...new Set(ms.map((m) => s.owner[m]))];   // teams, in the order the work follows
  let wait = 0, longest = 0, broken = 0;
  for (let j = 1; j < order.length; j++) {
    const u = s.d[order[j - 1]][order[j]], b = u < 0 ? NONE : WAIT[u];
    wait += b; longest = Math.max(longest, b);
    if (u < 0 || u >= 2) broken += 1;
  }
  const reasons = [];                                              // TD7: three rework reasons
  if (broken) reasons.push("late");                                // late-learned constraint
  if (ms.some((m) => s.contributors[m].length >= 3)) reasons.push("bound");  // wrong boundary
  if (order.length >= 4) reasons.push("info");                     // missing information
  const work = ms.length + reasons.length * 2;      // each rework adds 2 rounds of work
  const waitT = wait + reasons.length * longest;     // and charges the longest wait once more
  return { name, team: order.length, hnd: order.length - 1, work, wait: waitT, flow: work + waitT, reasons };
});

const R = D.map((s) => ({ name: s.name, k: run(s) }));
const HEAD = ["team", "hnd", "wait", "flow"];
const cell = (r) => [r.team, r.hnd, r.wait, r.flow].map((x) => String(x).padStart(6)).join("") +
  `  ${(r.reasons.join("+") || "-").padEnd(17)}`;
console.log(`${"work item".padEnd(28)}${R.map((r) => r.name.padEnd(43)).join("")}`.trimEnd());
console.log(`${"".padEnd(28)}${R.map(() =>
  HEAD.map((h) => h.padStart(6)).join("") + `  ${"reason".padEnd(17)}`).join("")}`.trimEnd());
for (let i = 0; i < ITEMS.length; i++)
  console.log(`${R[0].k[i].name.padEnd(28)}${R.map((r) => cell(r.k[i])).join("")}`.trimEnd());

const summary = (r) => {
  const T = (f) => r.k.reduce((t, x) => t + f(x), 0);
  const n = (c) => r.k.filter((x) => x.reasons.includes(c)).length;
  return {
    "handoffs": T((x) => x.hnd), "wait rounds": T((x) => x.wait),
    "flow time (rounds)": T((x) => x.flow),
    "wait share of flow time": `${(100 * T((x) => x.wait) / T((x) => x.flow)).toFixed(1)}%`,
    "rework (items / total)":
      `${r.k.filter((x) => x.reasons.length).length} / ${T((x) => x.reasons.length)}`,
    "  reason: late-learned constraint": n("late"),
    "  reason: wrong boundary": n("bound"), "  reason: missing information": n("info"),
  };
};

const Z = R.map(summary);
console.log(`\n${"total".padEnd(40)}${R.map((r) => r.name.padStart(22)).join("")}`);
for (const k of Object.keys(Z[0]))
  console.log(`${k.padEnd(40)}${Z.map((z) => String(z[k]).padStart(22)).join("")}`);
```

```
work item                   A (component-aligned)                      B (stream-aligned)
                              team   hnd  wait  flow  reason             team   hnd  wait  flow  reason
late fee rate                    4     3    36    46  late+bound+info       3     2    10    18  late+bound
reservation cancellation         4     3     5    13  bound+info            3     2     3     9  bound
membership reminder              4     3    14    24  late+bound+info       3     2    10    18  late+bound
catalog record field             4     3    41    51  late+bound+info       3     2    10    18  late+bound
loan period extension            3     2    10    17  late+bound            2     1     2     7  bound
second identity step             4     3    34    44  late+bound+info       3     2    10    18  late+bound
fee refund record                2     1     2     7  bound                 3     2     3     8  bound
branch delay report              3     2    10    17  late+bound            2     1     2     7  bound
notification text format         2     1    24    30  late+bound            1     0     0     4  bound
reservation queue                2     1     1     4  -                     2     1     2     7  bound
loan rule exception              2     1     2     8  bound                 2     1     1     5  -
event log field                  2     1    24    31  late+bound            2     1     2     7  bound

total                                    A (component-aligned)    B (stream-aligned)
handoffs                                                    24                    17
wait rounds                                                203                    55
flow time (rounds)                                         292                   126
wait share of flow time                                  69.5%                 43.7%
rework (items / total)                                 11 / 24               11 / 15
  reason: late-learned constraint                            8                     4
  reason: wrong boundary                                    11                    11
  reason: missing information                                5                     0
```

#### What Did Not Change When the Split Changed

In the totals table, three numbers drop sharply and one number does not move at all.

The drops: handoffs fall from 24 to 17, waiting from 203 rounds to 55 rounds, flow time from 292 to
126; the wait share drops from 69.5% to 43.7%. What matters is the disproportion: **handoffs fall
only 29% while waiting falls 73%.** Most of the cost is not in the number of handoffs but in the
distance to the team being handed off to. In split A, "notification text format" touches only two
teams and contains a single handoff, but because that handoff goes to the `shared` team, the item
reaches thirty rounds — more than double "reservation cancellation," which spreads across four
teams.

The number that does not move is the `bound` reason: in both splits, eleven items enter rework for
this reason. In A, six modules are changed by three teams; in B that number drops to four, but two
modules climb to four teams, and those two (`storeAccess`, `sharedFormat`) show up in most of the
items. **Moving the team boundary does not remove the shared module; it only changes who is
waiting.** By contrast, the `info` reason drops from 5 to 0, and `late` falls from 8 to 4 but
does not reach zero; the remaining four come from the two-step distance between `platform` and
`experience`.

The two observations together are this lesson's conclusion. Changing the communication structure
can shorten flow time by more than half without touching the module graph at all; but a shared
dependency cannot be resolved through communication structure alone. **There are two directions for
building overlap, and a single split decision uses only one of them.**

#### Summary

- Two splits were overlaid on the same 17-module, 43-bond graph: component-aligned with 5 teams
  (4 channels) and stream-aligned with 6 teams (9 channels); the module graph did not change between
  the two runs.
- In the component-aligned split, 40/43 bonds cross the team boundary, and **16 of those stand
  between teams with no path at all in the communication graph**; in the stream-aligned split the
  bonds crossing the boundary drop to 32, and no team pair is left without a path.
- All sixteen disconnected bonds sit around a single module: `sharedFormat`, imported by 16 modules,
  sits in the hands of a one-module team with no channel at all.
- When the same 12 work items are run through both splits, handoffs fall from 24 to 17, waiting
  from 203 rounds to 55 rounds, flow time from 292 to 126; the wait share drops from 69.5% to 43.7%.
- Waiting falls 73%, handoffs only 29%: the cost is not in the number of handoffs but in the
  counterpart team's distance in the communication graph.
- The `bound` rework reason shows up in 11 items in both splits; the stream-aligned split raises the
  most shared module from 3 teams to 4. Contention over a shared module cannot be removed just by
  moving the team boundary.

#### Next Step

This lesson's second split was called "stream-aligned," but it was used with only a single
property: each team owns one service stream. The numbers showed this property shortening flow time
by more than half — and left one thing entirely unmeasured: **how much load those teams carry.**
The next lesson counts that load: the same set of work items is run through three separate team
arrangements, and **cognitive load** per team — the number of modules it owns and the number of
external interfaces it depends on — is added alongside the handoff, waiting, and rework counts.
Where the stream-aligned arrangement wins was seen in this lesson's table; where it loses will be
seen there.

### Team Topologies

Source: https://academia.sh/en/courses/process-and-team/team-topologies

Measuring stream-aligned team arrangements: the same module graph and the same set of work items are run through three team arrangements — component-aligned, stream-aligned, and hybrid; cognitive load per team, external demand, duplicated expertise, and the shared component turning into a bottleneck are added alongside the handoff, waiting, and rework counts.

The previous lesson compared two team splits on the same module graph and counted the
stream-aligned split shortening flow time by more than half. One column was missing from that
table: **the load the teams carry.** To finish an item end to end, a stream-aligned team has to
know not only the modules it owns but also the external interfaces it depends on.

**Cognitive load** is the size of what a team has to hold in mind at once to do its work; here it
is built from two numbers: the number of modules the team **owns**, and the number of modules its
modules **import from outside**. The same twelve work items are run through three arrangements —
component-aligned, stream-aligned, and hybrid. The through-line is the regional library network and
it is fictional.

#### A Third Arrangement and a Load Measure

**TD8 — the previous lesson's seventeen-module graph, its two splits, and its twelve work items are
used unchanged.** Rationale: three arrangements can only be compared over the same graph and the
same workload.

**TD9 — the hybrid arrangement has seven teams: three stream teams, one interface, one rule, one
bridge, and one platform team; there are twelve channels.** Rationale: the hybrid arrangement is an
attempt to give domain knowledge to the streams and technical knowledge to a shared team; because
the team count grows, so does the channel count.

**TD10 — a team's cognitive load is the number of modules it owns plus the number of distinct
modules its modules import from outside.** **TD11 — a team's external demand is the number of items
opened by another team that touch that team's modules.** Rationale: load measures what a team
knows, external demand measures the work that comes to it from someone else; in one team the two
can point in opposite directions.

```js
// layout.mjs — the same module graph and work items run through three team arrangements (model)
import { writeFileSync } from "node:fs";

// TD8 — the previous lesson's 17-module graph, its two splits, and its 12 work items are used unchanged
const IMPORTS = {
  branchFront: "loanFlow feeFlow reservationFlow sharedFormat",
  staffFront: "membershipFlow loanFlow feeFlow sharedFormat",
  mobileAccess: "loanFlow reservationFlow catalogBridge sharedFormat",
  loanFlow: "loanRule catalogRule storeAccess notificationQueue sharedFormat",
  feeFlow: "feeRule storeAccess notificationQueue sharedFormat",
  membershipFlow: "membershipRule identityBridge storeAccess sharedFormat",
  reservationFlow: "loanRule catalogBridge storeAccess notificationQueue sharedFormat",
  loanRule: "sharedFormat", feeRule: "loanRule sharedFormat",
  catalogRule: "sharedFormat", membershipRule: "sharedFormat",
  storeAccess: "eventLog sharedFormat", catalogBridge: "catalogRule sharedFormat",
  identityBridge: "sharedFormat", notificationQueue: "eventLog sharedFormat",
  eventLog: "sharedFormat", sharedFormat: "",
};
const M = Object.keys(IMPORTS);
const EDGES = Object.entries(IMPORTS)
  .flatMap(([a, s]) => (s ? s.split(" ").map((b) => [a, b]) : []));
const ITEMS = [
  ["late fee rate", "feeFlow feeRule branchFront sharedFormat"],
  ["reservation cancellation", "mobileAccess reservationFlow loanRule notificationQueue"],
  ["membership reminder", "membershipFlow membershipRule notificationQueue staffFront"],
  ["catalog record field", "catalogBridge catalogRule sharedFormat mobileAccess"],
  ["loan period extension", "loanFlow loanRule branchFront"],
  ["second identity step", "identityBridge membershipFlow staffFront sharedFormat"],
  ["fee refund record", "feeFlow storeAccess eventLog"],
  ["branch delay report", "staffFront storeAccess loanFlow"],
  ["notification text format", "notificationQueue sharedFormat"],
  ["reservation queue", "mobileAccess reservationFlow branchFront"],
  ["loan rule exception", "loanRule feeRule loanFlow feeFlow"],
  ["event log field", "eventLog sharedFormat storeAccess"],
];

const LAYOUTS = [
  { name: "component-aligned", teams: {                     // TD8: the previous lesson's split A
    interface: "branchFront staffFront mobileAccess",
    flow: "loanFlow feeFlow membershipFlow reservationFlow",
    rule: "loanRule feeRule catalogRule membershipRule",
    infra: "storeAccess catalogBridge identityBridge notificationQueue eventLog",
    shared: "sharedFormat" },
    channels: "interface-flow flow-rule flow-infra rule-infra" },
  { name: "stream-aligned", teams: {                        // TD8: the previous lesson's split B
    experience: "branchFront staffFront mobileAccess",
    loan: "loanFlow loanRule reservationFlow storeAccess",
    fee: "feeFlow feeRule",
    membership: "membershipFlow membershipRule identityBridge",
    catalog: "catalogRule catalogBridge",
    platform: "sharedFormat eventLog notificationQueue" },
    channels: "experience-loan experience-fee experience-membership loan-fee loan-catalog " +
      "loan-platform fee-platform membership-platform catalog-platform" },
  { name: "hybrid", teams: {                                // TD9: stream teams + shared component teams
    experience: "branchFront staffFront mobileAccess",
    loan: "loanFlow reservationFlow",
    fee: "feeFlow", membership: "membershipFlow",
    rule: "loanRule feeRule catalogRule membershipRule",
    bridge: "storeAccess catalogBridge identityBridge",
    platform: "sharedFormat eventLog notificationQueue" },
    channels: "experience-loan experience-fee experience-membership loan-rule fee-rule membership-rule " +
      "loan-bridge membership-bridge rule-bridge rule-platform bridge-platform experience-platform" },
];

function build({ name, teams, channels }) {
  const owner = {}, T = Object.keys(teams), adj = Object.fromEntries(T.map((e) => [e, []]));
  for (const [e, ms] of Object.entries(teams)) for (const m of ms.split(" ")) owner[m] = e;
  for (const k of channels.split(" ")) {
    const [x, y] = k.split("-");
    adj[x].push(y); adj[y].push(x);
  }
  const d = {};
  for (const s of T) {
    d[s] = Object.fromEntries(T.map((e) => [e, -1]));
    d[s][s] = 0;
    for (let q = [s]; q.length; ) {
      const u = q.shift();
      for (const v of adj[u]) if (d[s][v] < 0) { d[s][v] = d[s][u] + 1; q.push(v); }
    }
  }
  const contributors = Object.fromEntries(M.map((m) => [m, new Set([owner[m]])]));
  for (const [, ms] of ITEMS) for (const m of ms.split(" ")) contributors[m].add(owner[ms.split(" ")[0]]);
  // TD10 — cognitive load: modules owned + externally imported modules (external interface)
  const load = Object.fromEntries(T.map((e) => [e, { owned: teams[e].split(" ").length,
    extInterface: new Set(), extTeam: new Set(), extDemand: 0 }]));
  for (const [a, b] of EDGES) if (owner[a] !== owner[b]) {
    load[owner[a]].extInterface.add(b);
    load[owner[a]].extTeam.add(owner[b]);
  }
  // TD11 — external demand: an item opened by another team that touches this team's modules
  for (const [, ms] of ITEMS) {
    const mods = ms.split(" "), head = owner[mods[0]];
    for (const e of new Set(mods.map((m) => owner[m]))) if (e !== head) load[e].extDemand += 1;
  }
  return { name, channelCount: channels.split(" ").length, teams, owner, d, contributors, load, T };
}

const D = LAYOUTS.map(build);
writeFileSync("layout.json", JSON.stringify({ M, EDGES, ITEMS, D: D.map((x) =>
  ({ ...x, contributors: Object.fromEntries(M.map((m) => [m, [...x.contributors[m]]])) })) }));

const spread = (v) => `${v[0]} / ${v[(v.length - 1) >> 1]} / ${v[v.length - 1]}`;
console.log(`${"layout".padEnd(19)}${"teams".padStart(6)}${"channels".padStart(9)}` +
  `${"modules owned".padStart(22)}${"cognitive load".padStart(22)}`);
console.log(`${"".padEnd(34)}${"low/median/high".padStart(22)}${"low/median/high".padStart(22)}`);
for (const x of D) {
  const owned = x.T.map((e) => x.load[e].owned).sort((a, b) => a - b);
  const ld = x.T.map((e) => x.load[e].owned + x.load[e].extInterface.size).sort((a, b) => a - b);
  console.log(`${x.name.padEnd(19)}${String(x.T.length).padStart(6)}` +
    `${String(x.channelCount).padStart(9)}${spread(owned).padStart(22)}${spread(ld).padStart(22)}`);
}

for (const x of D) {
  console.log(`\n${x.name} — cognitive load per team`);
  console.log(`${"team".padEnd(12)}${"owned".padStart(7)}${"ext iface".padStart(11)}` +
    `${"ext team".padStart(10)}${"load".padStart(6)}${"ext demand".padStart(12)}`);
  for (const e of x.T)
    console.log(`${e.padEnd(12)}${String(x.load[e].owned).padStart(7)}` +
      `${String(x.load[e].extInterface.size).padStart(11)}${String(x.load[e].extTeam.size).padStart(10)}` +
      `${String(x.load[e].owned + x.load[e].extInterface.size).padStart(6)}` +
      `${String(x.load[e].extDemand).padStart(12)}`);
}
```

```
layout              teams channels         modules owned        cognitive load
                                         low/median/high       low/median/high
component-aligned       5        4             1 / 4 / 5            1 / 7 / 13
stream-aligned          6        9             2 / 3 / 4             3 / 5 / 9
hybrid                  7       12             1 / 3 / 4             3 / 5 / 9

component-aligned — cognitive load per team
team          owned  ext iface  ext team  load  ext demand
interface         3          6         3     9           5
flow              4          9         3    13           5
rule              4          1         1     5           5
infra             5          2         2     7           4
shared            1          0         0     1           5

stream-aligned — cognitive load per team
team          owned  ext iface  ext team  load  ext demand
experience        3          6         5     9           5
loan              4          5         2     9           5
fee               2          4         2     6           1
membership        3          2         2     5           0
catalog           2          1         1     3           0
platform          3          0         0     3           6

hybrid — cognitive load per team
team          owned  ext iface  ext team  load  ext demand
experience        3          6         5     9           5
loan              2          6         3     8           4
fee               1          4         3     5           1
membership        1          4         3     5           1
rule              4          1         1     5           5
bridge            3          3         2     6           3
platform          3          0         0     3           6
```

#### How the Load Is Distributed

The first table lays out the component-aligned arrangement's problem: load ranges from a low of 1
to a high of 13. The `flow` team owns four modules but depends on nine external interfaces; the
`shared` team owns a single module and depends on no external interface at all. **In the same
organization, one team has to know thirteen things and another has to know one.** In the
stream-aligned and hybrid arrangements the range narrows to 3–9.

How the balancing happens shows up in the second table. The `flow` team's nine external interfaces
split across three teams: `loan` depends on five, `fee` on four, `membership` on two. The load did
not vanish, it **split** — and the split works, because each stream team also owns the rule for its
own domain: the `loan` team keeps `loanRule` in-house, and that module no longer counts as an
external interface.

The external demand column reads in the opposite direction. In the stream-aligned arrangement, the
`platform` team's cognitive load is three — one of the lowest values — but its **external demand is
six**. The team with the lightest load is the team the work passes through the most.

#### Flow Numbers and Duplicated Expertise

The second run compares the three arrangements in terms of flow and adds two new numbers.

**TD12 — a module's duplicated expertise is the number of teams that import it without owning it.**
Rationale: every team that imports a module has to learn its interface; the more teams the same
knowledge is repeated in, the more duplicated the expertise.

**TD13 — the bottleneck is the team receiving the most external demand; the rework items touching
that team's modules are counted separately.** Rationale: where the cost accumulates can only be seen
by reading these two numbers together.

```js
// flow2.mjs — flow numbers across three layouts, and duplicated expertise
import { readFileSync } from "node:fs";
const { M, EDGES, ITEMS, D } = JSON.parse(readFileSync("layout.json", "utf8"));

const WAIT = [0, 1, 3, 5], NONE = 8;      // TD6's wait table is used unchanged
const run = (x) => ITEMS.map(([name, mods]) => {
  const ms = mods.split(" "), order = [...new Set(ms.map((m) => x.owner[m]))];
  let wait = 0, longest = 0, broken = 0;
  for (let j = 1; j < order.length; j++) {
    const u = x.d[order[j - 1]][order[j]], b = u < 0 ? NONE : WAIT[u];
    wait += b; longest = Math.max(longest, b);
    if (u < 0 || u >= 2) broken += 1;
  }
  const reasons = [];
  if (broken) reasons.push("late");
  if (ms.some((m) => x.contributors[m].length >= 3)) reasons.push("bound");
  if (order.length >= 4) reasons.push("info");
  const work = ms.length + reasons.length * 2, waitT = wait + reasons.length * longest;
  return { name, team: order.length, hnd: order.length - 1, work, wait: waitT, flow: work + waitT, reasons };
});

const R = D.map((x) => ({ name: x.name, k: run(x) }));
const T = (r, f) => r.k.reduce((t, y) => t + f(y), 0);
const n = (r, c) => r.k.filter((y) => y.reasons.includes(c)).length;
console.log(`${"layout".padEnd(19)}${"hnd.".padStart(6)}${"wait".padStart(6)}` +
  `${"flow".padStart(6)}${"wait share".padStart(13)}${"rework".padStart(8)}` +
  `${"late".padStart(6)}${"bound".padStart(7)}${"info".padStart(6)}${"1-team".padStart(8)}`);
for (const r of R)
  console.log(`${r.name.padEnd(19)}${String(T(r, (y) => y.hnd)).padStart(6)}` +
    `${String(T(r, (y) => y.wait)).padStart(6)}${String(T(r, (y) => y.flow)).padStart(6)}` +
    `${`${(100 * T(r, (y) => y.wait) / T(r, (y) => y.flow)).toFixed(1)}%`.padStart(13)}` +
    `${String(T(r, (y) => y.reasons.length)).padStart(8)}${String(n(r, "late")).padStart(6)}` +
    `${String(n(r, "bound")).padStart(7)}${String(n(r, "info")).padStart(6)}` +
    `${String(r.k.filter((y) => y.team === 1).length).padStart(8)}`);

// TD12 — duplicated expertise: the number of teams that import a module without owning it
const dependent = (x, m) => new Set(EDGES.filter(([a, b]) => b === m && x.owner[a] !== x.owner[m])
  .map(([a]) => x.owner[a])).size;
const relevant = M.filter((m) => D.some((x) => dependent(x, m) >= 2));
console.log(`\n${"module".padEnd(18)}${D.map((x) => x.name.padStart(19)).join("")}   owner (three layouts)`);
for (const m of relevant)
  console.log(`${m.padEnd(18)}${D.map((x) => String(dependent(x, m)).padStart(19)).join("")}` +
    `   ${D.map((x) => x.owner[m]).join(" / ")}`);
console.log(`${"TOTAL team-module".padEnd(18)}` +
  `${D.map((x) => String(M.reduce((t, m) => t + dependent(x, m), 0)).padStart(19)).join("")}`);

// TD13 — bottleneck: the team with the most external demand and the rework items touching its modules
console.log(`\n${"layout".padEnd(19)}${"team with most external demand".padStart(31)}` +
  `${"ext demand".padStart(11)}${"owned".padStart(7)}${"rework via its modules".padStart(24)}`);
D.forEach((x, i) => {
  const e = x.T.reduce((a, b) => (x.load[a].extDemand >= x.load[b].extDemand ? a : b));
  const hit = R[i].k.filter((y, j) => y.reasons.length &&
    ITEMS[j][1].split(" ").some((m) => x.owner[m] === e)).length;
  console.log(`${x.name.padEnd(19)}${e.padStart(31)}${String(x.load[e].extDemand).padStart(11)}` +
    `${String(x.load[e].owned).padStart(7)}${String(hit).padStart(24)}`);
});
```

```
layout               hnd.  wait  flow   wait share  rework  late  bound  info  1-team
component-aligned      24   203   292        69.5%      24     8     11     5       0
stream-aligned         17    55   126        43.7%      15     4     11     0       1
hybrid                 25    78   163        47.9%      22     5     12     5       1

module              component-aligned     stream-aligned             hybrid   owner (three layouts)
catalogRule                         2                  1                  2   rule / catalog / rule
storeAccess                         1                  2                  3   infra / loan / bridge
catalogBridge                       2                  2                  2   infra / catalog / bridge
notificationQueue                   1                  2                  2   infra / platform / platform
sharedFormat                        4                  5                  6   shared / platform / platform
TOTAL team-module                  18                 18                 24

layout              team with most external demand ext demand  owned  rework via its modules
component-aligned                        interface          5      3                       7
stream-aligned                            platform          6      3                       8
hybrid                                    platform          6      3                       8
```

#### Where the Stream-Aligned Arrangement Loses

In the flow numbers, the stream-aligned arrangement leads all three: the fewest handoffs (17), the
least waiting (55 rounds), the shortest flow (126 rounds), the least rework (15). The hybrid
arrangement does the opposite of what one would expect — with twenty-five handoffs it is the highest
of the three arrangements, exceeding the component-aligned arrangement's twenty-four as well. The
reason is structural: the hybrid arrangement splits the flow, but it also gives the rule, the
bridge, and the platform to separate teams, so an item crosses both the stream boundary and the
layer boundary. It is also highest in the `bound` reason (12).

Where the stream-aligned arrangement loses is in the second table. **The total team-module
dependency pair count is 18 in the component-aligned arrangement and 18 in the stream-aligned
arrangement as well** — the stream-aligned arrangement does not reduce duplicated expertise at all.
Row by row, though, the distribution shifts: `storeAccess` is imported from outside by two teams
instead of one, `notificationQueue` by two instead of one, `sharedFormat` by five teams instead of
four. The number of teams that have to know the shared format module has grown, because every
stream team runs into that module's interface while finishing its own item end to end. In the
hybrid arrangement the total climbs to 24 and `sharedFormat` reaches six teams: **the more an
arrangement is split, the more duplicated expertise grows.**

The third table names what the shared component is. In the stream-aligned and hybrid arrangements
the bottleneck is the `platform` team: it owns three modules, receives six external demands, and
eight of the items entering rework touch one of its modules. The shared component is not
ownerless — it has an owner — but it produces no item of its own stream; it only makes someone
else's item wait. In the component-aligned arrangement the same role is played by the `interface`
team (five external demands, seven rework items), but there the bottleneck's load is also high (9);
in the stream-aligned arrangement it is three. **As the arrangement changes, the bottleneck's
location changes; its existence does not.**

The stream-aligned arrangement improves the flow numbers and the load balance together; it does
this without reducing duplicated expertise, and even increases it in the shared modules. The hybrid
arrangement balances the load by the same measure, but pushes handoffs and duplicated expertise to
their worst point.

#### Summary

- The same graph and the same 12 items were run through three arrangements: component-aligned
  (5 teams, 4 channels), stream-aligned (6 teams, 9 channels), and hybrid (7 teams, 12 channels).
- In the component-aligned arrangement, cognitive load spreads from 1 to 13; the `flow` team owns
  4 modules while depending on 9 external interfaces. In the stream-aligned and hybrid arrangements,
  the range narrows to 3–9.
- In the flow numbers, the stream-aligned arrangement leads: 17 handoffs, 55 rounds of waiting,
  126 rounds of flow, 15 rework items. The hybrid arrangement, with 25 handoffs, is the highest of
  the three, exceeding the component-aligned arrangement's 24 as well.
- The stream-aligned arrangement gains nothing in duplicated expertise: the total team-module
  dependency pair count is 18 in both the component-aligned and the stream-aligned arrangements, and
  24 in the hybrid arrangement.
- The number of teams importing `sharedFormat` from outside is 4, 5, and 6 across the three
  arrangements in order; the more the arrangement is split, the more teams have to know the shared
  format.
- In the stream-aligned and hybrid arrangements the bottleneck is the `platform` team: its cognitive
  load is 3, its external demand is 6, and 8 of the items entering rework touch its modules. The
  bottleneck's location changes; its existence does not.

#### Next Step

These two lessons measured where the team boundary falls and ran a quarter's worth of workload
through three arrangements. In every run, the modules a work item would touch were known from the
start. In reality this information does not exist when an item begins: how long an item will take
can only be estimated, and the estimate narrows as work proceeds. The next lesson measures
estimation — a set of estimates is compared against the durations that actually occurred, the margin
of error and the stage-by-stage narrowing of uncertainty are counted, and it is shown why a
single-item estimate and a total estimate behave differently.

### Estimation and Planning

Source: https://academia.sh/en/courses/process-and-team/estimation-and-planning

Estimating work item duration under uncertainty: comparing estimates produced in three stages against the flow time that actually occurred, separating the margin of error from the bias, the stage-by-stage narrowing of the uncertainty cone, how many items the plan built on the estimate held for, and explaining the difference between a single-item estimate and a total estimate by whether the errors cancel each other out.

In the previous two lessons, the modules each work item would touch were known from the start.
Handoffs, waiting, and rework were computed from that information. In reality this information does
not exist when an item begins: how long the item will take is **estimated**, and the estimate
changes as the work proceeds.

The impact of architectural decisions was estimated before; here what is estimated is the work
item's **duration**, and its unit is the round. The lesson compares a set of estimates against the
durations that actually occurred and produces four numbers: the margin of error, the direction of
the error, the stage-by-stage narrowing of the **uncertainty cone**, and how many items the plan
built on the estimate held for. Then it is counted why a single-item estimate and a total estimate
behave differently. The through-line is the regional library network and it is fictional.

#### Estimated Versus Actual

**TD15 — the previous two lessons' stream-aligned arrangement and twelve work items are used
unchanged.** **TD16 — an item's actual duration comes from the previous lessons' flow model: work
rounds plus wait rounds plus the rounds rework adds.** Rationale: the "actual" the estimate will be
compared against has to come from somewhere; here that somewhere is the model already run and
printed earlier, so the actual duration is not independent data for this lesson but the output of
that same model.

**TD17 — the deviation is drawn from a generator; the generator is a linear map, and its seed is
visibly 20250401.** Rationale: estimation error has a random component, and the run has to be
reproducible.

**TD18 — there are three estimation stages. In stage 1 only the item's title is known, and the
number of modules it will touch is estimated with ±80% deviation. In stage 2 the item has been
reviewed, the touched modules are known exactly, and the deviation is ±30%. In stage 3 the first
team has finished its work; the work rounds and how many teams the item will spread across are
known, one round of waiting is added per handoff, and the deviation is ±10%.** Rationale: the
stages represent the three main waypoints where uncertainty naturally decreases; at each stage what
the estimator knows is written down separately, because the cone's narrowing comes from an increase
in knowledge.

```js
// estimate.mjs — three-stage estimation, the same items compared against actual flow time (model)
import { writeFileSync } from "node:fs";

// TD15 — the previous two lessons' stream-aligned arrangement and twelve items are used unchanged
const TEAMS = {
  experience: "branchFront staffFront mobileAccess",
  loan: "loanFlow loanRule reservationFlow storeAccess",
  fee: "feeFlow feeRule",
  membership: "membershipFlow membershipRule identityBridge",
  catalog: "catalogRule catalogBridge",
  platform: "sharedFormat eventLog notificationQueue",
};
const CHANNELS = "experience-loan experience-fee experience-membership loan-fee loan-catalog " +
  "loan-platform fee-platform membership-platform catalog-platform";
const ITEMS = [
  ["late fee rate", "feeFlow feeRule branchFront sharedFormat"],
  ["reservation cancellation", "mobileAccess reservationFlow loanRule notificationQueue"],
  ["membership reminder", "membershipFlow membershipRule notificationQueue staffFront"],
  ["catalog record field", "catalogBridge catalogRule sharedFormat mobileAccess"],
  ["loan period extension", "loanFlow loanRule branchFront"],
  ["second identity step", "identityBridge membershipFlow staffFront sharedFormat"],
  ["fee refund record", "feeFlow storeAccess eventLog"],
  ["branch delay report", "staffFront storeAccess loanFlow"],
  ["notification text format", "notificationQueue sharedFormat"],
  ["reservation queue", "mobileAccess reservationFlow branchFront"],
  ["loan rule exception", "loanRule feeRule loanFlow feeFlow"],
  ["event log field", "eventLog sharedFormat storeAccess"],
];

const owner = {}, T = Object.keys(TEAMS), adj = Object.fromEntries(T.map((e) => [e, []]));
for (const [e, ms] of Object.entries(TEAMS)) for (const m of ms.split(" ")) owner[m] = e;
for (const k of CHANNELS.split(" ")) {
  const [x, y] = k.split("-");
  adj[x].push(y); adj[y].push(x);
}
const d = {};
for (const s of T) {
  d[s] = Object.fromEntries(T.map((e) => [e, -1]));
  d[s][s] = 0;
  for (let q = [s]; q.length; ) {
    const u = q.shift();
    for (const v of adj[u]) if (d[s][v] < 0) { d[s][v] = d[s][u] + 1; q.push(v); }
  }
}
const contributors = {};
for (const [, ms] of ITEMS) for (const m of ms.split(" "))
  (contributors[m] ??= new Set([owner[m]])).add(owner[ms.split(" ")[0]]);

// TD16 — actual duration comes from the previous lessons' flow model: work rounds + wait rounds + rework rounds
const WAIT = [0, 1, 3, 5], NONE = 8;
const ACTUAL = ITEMS.map(([name, mods]) => {
  const ms = mods.split(" "), order = [...new Set(ms.map((m) => owner[m]))];
  let wait = 0, longest = 0, broken = 0;
  for (let j = 1; j < order.length; j++) {
    const u = d[order[j - 1]][order[j]], b = u < 0 ? NONE : WAIT[u];
    wait += b; longest = Math.max(longest, b);
    if (u < 0 || u >= 2) broken += 1;
  }
  const reasons = [];
  if (broken) reasons.push("late");
  if (ms.some((m) => contributors[m].size >= 3)) reasons.push("bound");
  if (order.length >= 4) reasons.push("info");
  const work = ms.length + reasons.length * 2, waitT = wait + reasons.length * longest;
  return { name, modules: ms.length, team: order.length, work, wait: waitT,
    rework: reasons.length, actual: work + waitT };
});

// TD17 — deviation is drawn from a generator; the generator is a linear map, its seed is visibly 20250401
const SEED = 20250401;
function generator(t) {
  return () => { t = (t * 1103515245 + 12345) % 2147483648; return t / 2147483648; };
}
const r = generator(SEED);
const deviate = (width) => 1 + (r() * 2 - 1) * width;

// TD18 — three estimation stages: at the request, once the item is reviewed, once the first team is done
const T2 = ACTUAL.map((g) => {
  const s1 = Math.max(1, Math.round(g.modules * deviate(0.8)));    // sees only the title
  const s2 = Math.round(g.modules * deviate(0.3));                 // knows the touched modules
  const s3 = Math.round((g.work + (g.team - 1)) * deviate(0.1));   // also knows the team count
  return { ...g, s1, s2, s3 };
});
writeFileSync("estimate.json", JSON.stringify(T2));

console.log(`${"work item".padEnd(28)}${"mods".padStart(6)}${"team".padStart(5)}` +
  `${"stage1".padStart(8)}${"stage2".padStart(8)}${"stage3".padStart(8)}${"actual".padStart(8)}` +
  `${"  = work + wait"}`);
for (const t of T2)
  console.log(`${t.name.padEnd(28)}${String(t.modules).padStart(6)}${String(t.team).padStart(5)}` +
    `${String(t.s1).padStart(8)}${String(t.s2).padStart(8)}${String(t.s3).padStart(8)}` +
    `${String(t.actual).padStart(8)}    ${String(t.work).padStart(2)} + ${String(t.wait).padStart(2)}` +
    `${t.rework ? `  (${t.rework} rework)` : ""}`);
console.log(`\nseed ${SEED}; total actual ${T2.reduce((s, t) => s + t.actual, 0)} rounds ` +
  `(${T2.reduce((s, t) => s + t.work, 0)} work + ${T2.reduce((s, t) => s + t.wait, 0)} wait)`);
```

```
work item                     mods team  stage1  stage2  stage3  actual  = work + wait
late fee rate                    4    3       5       4      11      18     8 + 10  (2 rework)
reservation cancellation         4    3       1       3       7       9     6 +  3  (1 rework)
membership reminder              4    3       6       5      10      18     8 + 10  (2 rework)
catalog record field             4    3       4       3       9      18     8 + 10  (2 rework)
loan period extension            3    2       1       2       6       7     5 +  2  (1 rework)
second identity step             4    3       3       3      10      18     8 + 10  (2 rework)
fee refund record                3    3       4       4       6       8     5 +  3  (1 rework)
branch delay report              3    2       4       2       6       7     5 +  2  (1 rework)
notification text format         2    1       3       3       4       4     4 +  0  (1 rework)
reservation queue                3    2       5       3       6       7     5 +  2  (1 rework)
loan rule exception              4    2       6       5       5       5     4 +  1
event log field                  3    2       5       4       6       7     5 +  2  (1 rework)

seed 20250401; total actual 126 rounds (71 work + 55 wait)
```

#### Margin of Error and the Cone's Narrowing

**TD19 — the plan is counted as "held" for an item if the estimate stays within a 20% band of the
actual.** **TD20 — items are split into two sets by how many teams they touch: those touching one
or two teams, and those touching three or more.** Rationale: without a band, the word "held" cannot
be turned into a number; the second split isolates the team boundary's share of the estimate.

```js
// cone.mjs — margin of error, the uncertainty cone narrowing, single-item vs. total estimate
import { readFileSync } from "node:fs";
const T = JSON.parse(readFileSync("estimate.json", "utf8"));

const STAGES = ["s1", "s2", "s3"];
const LABEL = { s1: "stage 1 (request)", s2: "stage 2 (review)", s3: "stage 3 (first team done)" };
const total = (f) => T.reduce((s, t) => s + f(t), 0);
const pct = (x) => `${(100 * x).toFixed(1)}%`;
// TD19 — the plan counts as "held" for an item if the estimate stays within a 20% band of the actual
const BAND = 0.2;

console.log(`${"stage".padEnd(26)}${"mean absolute error".padStart(21)}${"mean bias".padStart(13)}` +
  `${"cone: estimate/actual".padStart(23)}${"width".padStart(9)}${"plan held".padStart(12)}`);
for (const a of STAGES) {
  const ratio = T.map((t) => t[a] / t.actual).sort((x, y) => x - y);
  const mape = total((t) => Math.abs(t[a] - t.actual) / t.actual) / T.length;
  const bias = total((t) => (t[a] - t.actual) / t.actual) / T.length;
  const held = T.filter((t) => Math.abs(t[a] - t.actual) / t.actual <= BAND).length;
  console.log(`${LABEL[a].padEnd(26)}${pct(mape).padStart(21)}${pct(bias).padStart(13)}` +
    `${`${ratio[0].toFixed(2)} - ${ratio[ratio.length - 1].toFixed(2)}`.padStart(23)}` +
    `${(ratio[ratio.length - 1] - ratio[0]).toFixed(2).padStart(9)}${`${held}/${T.length}`.padStart(12)}`);
}

console.log(`\n${"stage".padEnd(26)}${"total estimate".padStart(16)}${"total actual".padStart(14)}` +
  `${"error of the total".padStart(20)}${"mean single-item error".padStart(24)}`);
for (const a of STAGES) {
  const te = total((t) => t[a]), ta = total((t) => t.actual);
  const mape = total((t) => Math.abs(t[a] - t.actual) / t.actual) / T.length;
  console.log(`${LABEL[a].padEnd(26)}${String(te).padStart(16)}${String(ta).padStart(14)}` +
    `${pct((te - ta) / ta).padStart(20)}${pct(mape).padStart(24)}`);
}

// where the error comes from: stage 2 estimates only the work
const s2 = total((t) => t.s2), actual = total((t) => t.actual);
console.log(`\nstage 2 total ${s2} rounds; actual ${actual} rounds; difference ${actual - s2} rounds`);
console.log(`  components of the difference: wait ${total((t) => t.wait)} rounds, ` +
  `work added by rework ${total((t) => 2 * t.rework)} rounds, ` +
  `modules touched ${total((t) => t.modules)} rounds (stage 2 estimates this)`);

// TD20 — items are split by how many teams they touch
console.log(`\n${"item set".padEnd(26)}${"items".padStart(7)}${"mean actual".padStart(13)}` +
  `${"wait share".padStart(14)}${"stage 2 error".padStart(16)}${"stage 3 error".padStart(16)}`);
for (const [name, f] of [["touches 1-2 teams", (t) => t.team <= 2], ["touches 3+ teams", (t) => t.team >= 3]]) {
  const k = T.filter(f), s = (g) => k.reduce((x, t) => x + g(t), 0);
  console.log(`${name.padEnd(26)}${String(k.length).padStart(7)}` +
    `${(s((t) => t.actual) / k.length).toFixed(1).padStart(13)}` +
    `${pct(s((t) => t.wait) / s((t) => t.actual)).padStart(14)}` +
    `${pct(s((t) => Math.abs(t.s2 - t.actual) / t.actual) / k.length).padStart(16)}` +
    `${pct(s((t) => Math.abs(t.s3 - t.actual) / t.actual) / k.length).padStart(16)}`);
}
```

```
stage                       mean absolute error    mean bias  cone: estimate/actual    width   plan held
stage 1 (request)                         55.8%       -52.5%            0.11 - 1.20     1.09        1/12
stage 2 (review)                          58.4%       -58.4%            0.17 - 1.00     0.83        1/12
stage 3 (first team done)                 23.5%       -23.5%            0.50 - 1.00     0.50        6/12

stage                       total estimate  total actual  error of the total  mean single-item error
stage 1 (request)                       47           126              -62.7%                   55.8%
stage 2 (review)                        41           126              -67.5%                   58.4%
stage 3 (first team done)               86           126              -31.7%                   23.5%

stage 2 total 41 rounds; actual 126 rounds; difference 85 rounds
  components of the difference: wait 55 rounds, work added by rework 30 rounds, modules touched 41 rounds (stage 2 estimates this)

item set                    items  mean actual    wait share   stage 2 error   stage 3 error
touches 1-2 teams               6          6.2         24.3%           44.6%            9.5%
touches 3+ teams                6         14.8         51.7%           72.2%           37.5%
```

#### Why the Error Stopped While the Cone Narrowed

Two columns in the first table go in opposite directions. **The cone really does narrow:** the
width of the estimate/actual ratio falls from 1.09 to 0.83, then to 0.50. But the mean absolute
error does not follow the same path — 55.8% at stage 1, 58.4% at stage 2. **The error did not
decrease while the uncertainty decreased.**

The reason is written in the bias column. Bias is negative in all three stages: -52.5%, -58.4%,
-23.5%. That is, the estimates are not merely scattered — **they are all wrong in the same
direction**: at every stage the duration is estimated lower than it is. What the estimator learns
going from stage 1 to stage 2 is the full list of touched modules; this knowledge brings the
deviation down from ±80% to ±30% but does not touch the bias, because the bias was not coming from
a wrong module count.

Where it comes from is named by the line in the middle of the second output block. Stage 2's total
estimate is 41 rounds, the total actual is 126 rounds; of the 85 rounds between them, 55 are
**waiting**, 30 are the **work added by rework**. Stage 2 knows the number of touched modules (41
rounds) almost exactly — and that is the only thing it estimates. **The estimate estimates the
work; the actual duration is the flow time.** The gap between the two is exactly what the previous
two lessons measured: the rounds expected in a handoff, and the items that come back because of a
wrong boundary.

Stage 3 brings the error down to 23.5% and raises the number of items the plan held for from 1 to
6. All it does is bring the concept of waiting into the estimate — one round per handoff. Even this
crude correction closes more than half the error; the remaining 23.5% comes from handoffs where the
wait is longer than one round, and from rework.

#### Single-Item Estimate Versus Total Estimate

The second table tests a common expectation: that the errors made on individual items cancel out
in the total. **They do not.** At stage 2, the mean single-item absolute error is 58.4%, while the
error of the total is -67.5% — **the total is worse than the single item.** The same relationship
holds across all three stages.

Errors cancel out only when they scatter in two directions. Here, the stage 2 estimate does not
exceed the actual for any of the twelve items; the highest ratio in the cone column is 1.00. **When
errors are biased in one direction, their magnitudes add up too.** The total of a set of estimates
is more reliable than a single-item error only if the errors in the set are unbiased; when bias is
present, the total estimate is less reliable than the single-item estimate.

The last table shows where the bias comes from. The six items touching one or two teams have a
mean actual duration of 6.2 rounds, of which 24.3% is waiting; the stage 2 error is 44.6%, the
stage 3 error 9.5%. The six items touching three or more teams have a mean duration of 14.8 rounds,
a wait share of 51.7%, a stage 2 error of 72.2%, and a stage 3 error of 37.5%. **The same estimation
method works about twice as badly on items that cross the team boundary heavily.** Where estimation
gets worse is the same place where the previous two lessons' flow time got longer — items where the
module boundary and the team boundary do not overlap.

#### Summary

- The actual duration of the twelve items was taken from the previous lessons' flow model (total
  126 rounds = 71 work + 55 wait) and compared against estimates produced in three stages;
  generator seed `20250401`.
- The uncertainty cone narrows stage by stage (the width of the estimate/actual ratio goes
  1.09 → 0.83 → 0.50), but the mean absolute error does not follow the same path: 55.8% → 58.4% →
  23.5%. Going from stage 1 to stage 2, the cone narrowed but the error did not decrease.
- Bias is negative in all three stages (-52.5%, -58.4%, -23.5%): the estimates are not scattered,
  they are all wrong in the same direction. Of the 85 rounds between stage 2's 41-round total
  estimate and the 126-round actual, 55 are waiting and 30 are the work added by rework.
- Bringing waiting into the estimate crudely (one round per handoff) brings the error down to
  23.5% and raises the number of items held within the 20% band from 1/12 to 6/12.
- Errors do not cancel out: at stage 2, the mean single-item error is 58.4% while the error of the
  total is -67.5%. When errors are biased in one direction, their magnitudes add up too.
- Estimation gets about twice as bad on items that cross the team boundary heavily: for items
  touching 1–2 teams the stage 2 error is 44.6% and the wait share is 24.3%, while for items
  touching 3+ teams these are 72.2% and 51.7%.

#### Next Step

Every number in this lesson rested on one assumption: **the set of work items stays fixed for the
quarter.** Twelve items were counted, estimated, and run through, all from the start. In reality
the set of items does not stand still — a new request arrives mid-quarter, an existing item's scope
grows, another one gets canceled. In that case the estimate's margin of error grows not only for
the reasons above but because the work being estimated changes too. The next lesson takes up that
change: what happens in the flow when an item's scope grows, past what point an accepted change
makes rework unavoidable, and how many rounds a control regime for change itself adds to the flow.

### Change Management

Source: https://academia.sh/en/courses/process-and-team/change-management

Change control of scope: the same request writes 0 rounds of rework at analysis and 8 at validation, moving the item from 12 rounds to 39; on the same six requests the accept-on-arrival policy takes two and produces 8 rounds of rework while the batch-at-boundary policy takes four and keeps the request waiting 30 rounds on average; once control is removed, the plan holds for one of eight items by the second request, and a change that falls outside the team boundary delivers the same item in 28 rounds instead of 23.

The previous lesson estimated the duration of a work item and measured the estimate's error. But
what actually breaks a plan is not the estimate's deviation: an estimate is a number about the
duration of work whose scope is known to be fixed; when the scope beneath the plan changes, that
number does not come out wrong — it becomes moot.

**Change control** is the decision path that determines at which point and at what cost a scope
change enters the flow. The four measurements below turn it into a number.

#### The Moment a Change Arrives

The example is the course's fictional **regional library network**, and the three teams that
build it; both the network and the teams are fictional. **TD22:** module ownership splits three
ways — E1 holds the branch front end, the terminal, and the membership gate; E2 holds loans and
fees; E3 holds the catalog gate, reporting, and notifications. **TD23:** a work item passes
through four stages (analysis, development, review, validation); if an item touches a second
team's module, that team takes on half a development stage plus the review. Each team works one
step at a time, and once a step finishes, the item returns to the back of the queue. **TD24:** the
initial scope is eight items and 71 rounds of work; the plan leaves the items at round 0, 6, and
12 — the **scope baseline** is this plan's delivery rounds. **TD25:** a change sends the item back
to analysis; everything done up to that point is **rework**, and the reversal itself is a handoff.
**TD26:** the iteration is ten rounds, and the **change budget** per iteration is eight rounds; the
amount deducted from the budget is the extra work plus the rework it triggers, and a request that
does not fit is rejected. The flow and the two control policies are an in-process **model**; there
is no network or clock — rounds are the unit of count.

```js
// change/flow.mjs — the fictional library network's work item flow and change control
// (model): there is no network or database, queues are a data structure, no randomness.

export const TEAM = { "branch-front": "E1", "branch-terminal": "E1", "membership-gate": "E1",
  loan: "E2", fee: "E2", report: "E3", notification: "E3", "catalog-gate": "E3" };
export const TEAMS = [...new Set(Object.values(TEAM))];
export const ITERATION = 10, BUDGET = 8, ANALYSIS = 1;

const table = (s, f) => s.trim().split("\n").map((r) => f(r.trim().split(/\s+/)));

// Initial scope: name, module, stage rounds, plan entry.
export const SCOPE = table(`
  late-fee-threshold       fee              2,4,2,2   0
  branch-shelf-view        branch-front     2,3,1,2   0
  catalog-field-mapping    catalog-gate     1,3,1,1   0
  loan-duration-extension  loan             2,4,2,2   6
  terminal-card-read       branch-terminal  1,3,1,2   6
  monthly-loan-report      report,loan      2,3,2,1   6
  balance-notification     notification,fee 2,4,2,2  12
  membership-verification  membership-gate  2,3,1,2  12`,
([name, m, i, g]) => ({ name, module: m.split(","), work: i.split(",").map(Number), entry: +g }));

// Change: arrival round, name, target, module, extra work.
export const CHANGE = table(`
   3  fee-waiver             late-fee-threshold       fee              3
   6  shelf-filter           branch-shelf-view        branch-front     2
   9  extension-cap          loan-duration-extension  loan             3
  12  fee-column-in-report   monthly-loan-report      fee              3
  15  terminal-extension     terminal-card-read       loan             2
  18  second-field-mapping   catalog-field-mapping    catalog-gate     3`,
([t, name, target, m, e]) => ({ round: +t, name, target, module: m.split(","), extra: +e }));

// Owning team resolves and develops; a second team takes on half a development stage plus review.
export function steps(module, work) {
  const e = [...new Set(module.map((m) => TEAM[m]))], owner = e[0];
  const a = [{ name: "analysis", team: owner, work: work[0] },
    { name: "development", team: owner, work: work[1] }];
  for (const x of e.slice(1)) a.push({ name: "development", team: x, work: Math.ceil(work[1] / 2) });
  return [...a, { name: "review", team: e.at(-1), work: work[2] },
    { name: "validation", team: owner, work: work[3] }];
}

const build = (name, module, work, entry, source) => ({ name, module: [...module], base: [...work],
  step: steps(module, work), i: 0, progress: 0, entry, source, finish: null, waiting: 0,
  handoffs: 0, rework: 0 });

export const workOf = (k) => k.step.reduce((s, a) => s + a.work, 0);

// "accept" decides when the request arrives, "batch" decides at the iteration boundary; over budget is rejected.
export function run(policy, changes, settings = {}) {
  const budget = settings.budget ?? BUDGET;
  const pool = SCOPE.map((k) => build(k.name, k.module, k.work, k.entry, "scope"));
  const items = [], spent = {}, accepted = [], rejected = [], buffer = [];
  const queue = Object.fromEntries(TEAMS.map((e) => [e, []]));
  const current = Object.fromEntries(TEAMS.map((e) => [e, null]));
  const place = (k) => { items.push(k); queue[k.step[0].team].push(k); };

  function decide(d, round, iter) {
    const k = items.find((x) => x.name === d.target && x.finish === null);
    const inPlace = policy === "accept" && k;             // if not taken in place, becomes a separate item
    // Taking it in place turns work already done into rework; a separate item sets up context (ANALYSIS).
    const done = inPlace
      ? k.step.slice(0, k.i).reduce((s, a) => s + a.work, 0) + k.progress : ANALYSIS;
    const stage = inPlace ? k.step[k.i].name : k ? "boundary" : "post-delivery";
    const m = d.extra + done;                             // amount deducted from the budget
    if ((spent[iter] ?? 0) + m > budget) return rejected.push({ d, stage, m });
    spent[iter] = (spent[iter] ?? 0) + m;
    if (!inPlace) {
      const y = build(d.name, d.module, [ANALYSIS, d.extra, 1, 1], d.round, "change");
      y.rework = ANALYSIS; place(y);
      return accepted.push({ d, item: y, stage, m });
    }
    k.rework += done; k.handoffs += 1;                     // the reversal itself is a handoff
    k.module = [...new Set([...k.module, ...d.module])];
    k.base[1] += d.extra; k.step = steps(k.module, k.base); k.i = 0; k.progress = 0;
    for (const e of TEAMS) {
      if (current[e] === k) current[e] = null;
      queue[e] = queue[e].filter((x) => x !== k);
    }
    queue[k.step[0].team].push(k);
    accepted.push({ d, item: k, stage, m });
  }

  for (let round = 0; round < 80; round++) {
    pool.filter((k) => k.entry === round).forEach(place);
    for (const d of changes.filter((x) => x.round === round))
      if (policy === "accept") decide(d, round, Math.floor(round / ITERATION)); else buffer.push(d);
    if (policy === "batch" && round > 0 && round % ITERATION === 0)
      for (const d of buffer.splice(0)) decide(d, round, round / ITERATION);
    for (const e of TEAMS) if (!current[e] && queue[e].length) current[e] = queue[e].shift();
    for (const e of TEAMS) for (const k of queue[e]) k.waiting++;
    for (const e of TEAMS) {
      const k = current[e];
      if (!k || ++k.progress < k.step[k.i].work) continue;
      k.progress = 0; k.i++; current[e] = null;
      if (k.i >= k.step.length) k.finish = round + 1;
      else { k.handoffs++; queue[k.step[k.i].team].push(k); }
    }
  }
  if (items.some((k) => k.finish === null)) throw new Error("run did not finish within 80 rounds");
  return { items, accepted, rejected, end: Math.max(...items.map((k) => k.finish)) };
}
```

```js
// change/measure.mjs — cost of the moment of entry, two control policies, scope creep, team boundary.
import { run, workOf, SCOPE, CHANGE, BUDGET, ITERATION } from "./flow.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const sum = (r, a) => r.items.reduce((s, k) => s + k[a], 0);
const avg = (a) => (a.reduce((s, x) => s + x, 0) / a.length).toFixed(1);
const inScope = (r) => r.items.filter((k) => k.source === "scope");

const T = run("accept", []), BASELINE = Object.fromEntries(T.items.map((k) => [k.name, k.finish]));
const BASE_WORK = T.items.reduce((s, k) => s + workOf(k), 0);
const row = (r, name) => {                        // one item's flow measures
  const k = r.items.find((x) => x.name === name);
  return [k.rework, k.handoffs, k.waiting, k.finish - k.entry, r.end];
};
console.log(`baseline plan: ${SCOPE.length} items, ${BASE_WORK} rounds of work, last delivery round ${T.end}, ` +
  `average delivery time ${avg(inScope(T).map((k) => k.finish - k.entry))} rounds; ` +
  `iteration ${ITERATION} rounds, iteration budget ${BUDGET} rounds`);

// 1. Same change, different arrival round; budget unlimited.
const TARGET = "late-fee-threshold";
const G = [-9, -15, 8, 10, 9, 15, 10];
console.log("\n1. same change (fee-waiver, 3 rounds extra work), different arrival round");
write(G, "arrival", "entry stage", "rework", "handoffs", "waiting", "delivery time",
  "flow end");
write(G, "none", "-", ...row(T, TARGET));
for (const round of [0, 8, 14]) {
  const r = run("accept", [{ round, name: "fee-waiver", target: TARGET, module: ["fee"], extra: 3 }],
    { budget: Infinity });
  write(G, round, r.accepted[0].stage, ...row(r, TARGET));
}

// 2. Two control policies, same six requests, same budget.
const R = { accept: run("accept", CHANGE), batch: run("batch", CHANGE) };
const outcome = (r, d) => {                       // the request's outcome under this policy
  const a = r.accepted.find((x) => x.d.name === d.name), x = r.rejected.find((y) => y.d.name === d.name);
  return a ? `accepted/${a.stage}/${a.m} -> ${a.item.finish}` : `rejected/${x.stage}/${x.m}`;
};
const B = [-23, 9, -34, -1];
console.log("\n2. change control: accept-on-arrival policy vs batch-at-boundary policy");
write(B, "change", "arrival", "  accept-on-arrival", "  batch-at-boundary");
for (const d of CHANGE)
  write(B, d.name, d.round, "  " + outcome(R.accept, d), "  " + outcome(R.batch, d));
const O = [-30, 22, 22];
console.log();
write(O, "measure", "accept-on-arrival", "batch-at-boundary");
const summary = (r) => ({
  "initial scope avg. delivery": avg(inScope(r).map((k) => k.finish - k.entry)),
  "finished by baseline round": `${inScope(r).filter((k) => k.finish <= BASELINE[k.name]).length}/8`,
  rework: sum(r, "rework"), waiting: sum(r, "waiting"),
  handoffs: sum(r, "handoffs"),
  "accepted / rejected": `${r.accepted.length}/${r.rejected.length}`,
  "change avg. delivery": avg(r.accepted.map((x) => x.item.finish - x.d.round)),
  "flow end": r.end,
});
const sA = summary(R.accept), sB = summary(R.batch);
for (const k of Object.keys(sA)) write(O, k, sA[k], sB[k]);

// 3. How long the baseline holds if control never rejects.
const K = [-12, 12, 16, 11, 10];
console.log("\n3. scope creep (budget unlimited: every request is accepted)");
write(K, "accepted", "added work", "added/baseline", "plan held", "flow end");
for (const n of [0, 1, 2, 6]) {
  const r = run("accept", CHANGE.slice(0, n), { budget: Infinity });
  const sc = inScope(r), added = r.items.reduce((s, k) => s + workOf(k), 0) - BASE_WORK;
  let held = 0;                                    // in plan order, up to the first miss
  for (const k of [...sc].sort((a, b) => BASELINE[a.name] - BASELINE[b.name])) {
    if (k.finish > BASELINE[k.name]) break;
    held++;
  }
  write(K, n, added, `${((100 * added) / BASE_WORK).toFixed(1)}%`, `${held}/8`, r.end);
}

// 4. Whether the change falls inside or outside the team boundary.
const M = [-28, 6, 8, 10, 9, 15, 10];
console.log("\n4. same change (terminal-card-read, round 12, 2 rounds extra work): where it falls");
write(M, "where it falls", "step", "rework", "handoffs", "waiting", "delivery time", "flow end");
const NAME = "terminal-card-read";
write(M, "no change", T.items.find((k) => k.name === NAME).step.length, ...row(T, NAME));
for (const [where, module] of [["inside the team boundary", ["branch-terminal"]],
  ["outside the team boundary", ["loan"]]]) {
  const r = run("accept", [{ round: 12, name: "terminal-change", target: NAME, module, extra: 2 }],
    { budget: Infinity });
  write(M, where, r.items.find((k) => k.name === NAME).step.length, ...row(r, NAME));
}
```

```
baseline plan: 8 items, 71 rounds of work, last delivery round 30, average delivery time 12.9 rounds; iteration 10 rounds, iteration budget 8 rounds

1. same change (fee-waiver, 3 rounds extra work), different arrival round
arrival  entry stage      rework  handoffs  waiting  delivery time  flow end
none     -                     0         3        2             12        30
0        analysis              0         4        8             21        33
8        validation            8         7       18             39        39
14       post-delivery         0         3        2             12        34

2. change control: accept-on-arrival policy vs batch-at-boundary policy
change                   arrival  accept-on-arrival                 batch-at-boundary
fee-waiver                     3  accepted/development/6 -> 22      accepted/boundary/4 -> 41
shelf-filter                   6  rejected/validation/8             accepted/post-delivery/3 -> 26
extension-cap                  9  rejected/analysis/3               rejected/boundary/4
fee-column-in-report          12  accepted/development/8 -> 36      accepted/boundary/4 -> 44
terminal-extension            15  rejected/validation/7             accepted/post-delivery/3 -> 45
second-field-mapping          18  rejected/post-delivery/4          rejected/post-delivery/4

measure                            accept-on-arrival     batch-at-boundary
initial scope avg. delivery                     16.3                  16.3
finished by baseline round                       4/8                   3/8
rework                                             8                     4
waiting                                           44                   133
handoffs                                          31                    38
accepted / rejected                              2/4                   4/2
change avg. delivery                            21.5                  30.0
flow end                                          36                    45

3. scope creep (budget unlimited: every request is accepted)
accepted      added work  added/baseline  plan held  flow end
0                      0            0.0%        8/8        30
1                      3            4.2%        2/8        34
2                      5            7.0%        1/8        34
6                     23           32.4%        1/8        44

4. same change (terminal-card-read, round 12, 2 rounds extra work): where it falls
where it falls                step  rework  handoffs  waiting  delivery time  flow end
no change                        4       0         3        4             11        30
inside the team boundary         4       4         6       10             23        30
outside the team boundary        5       4         7       12             28        34
```

The numbers are in the **measurement** class; their inputs are the assumptions above. The first
table sends the same request into the same flow at three different moments. A request arriving
during analysis writes zero rounds of rework; the same request arriving during validation brings
eight rounds of rework, four extra handoffs, and sixteen extra rounds of waiting: delivery time
rises from 12 rounds to 39, and three rounds of extra work cost twenty-seven rounds. Arriving early
is not free either — the growing item falls behind the ones released after it and finishes in 21
rounds. If the item has already been delivered, the request becomes a separate item: the target
stays at its planned 12 rounds, and the flow end moves from 30 to 34. **The cost leaves the item
and moves into the flow.**

#### Batch-at-Boundary versus Accept-on-Arrival

The second measurement runs the same six requests through the same budget in two policies: the
accept-on-arrival policy takes the request into the item itself, the batch-at-boundary policy
finishes the item according to plan and takes the request in as a separate item at the boundary.
The accept-on-arrival policy takes two and rejects four. Because the amount deducted from the
budget also includes rework, a request arriving at an item that is in validation eats the entire
eight-round budget; the expensive request that fills the budget also gets the cheap one behind it
rejected: the three-round request at round nine does not fit while its target is in analysis. The
batch-at-boundary policy takes four and cuts rework in half (4 instead of 8); its cost is the value
it delays — the average from arrival to delivery is 30 rounds, versus 21.5 under accept-on-arrival.
Two of the four requests it takes arrive at the boundary after the target item has already
finished. The initial scope's average delivery time comes out to 16.3 rounds under both policies;
**the average hides the split**: items finished by their baseline round are 4 of 8 against 3 of 8 —
under accept-on-arrival the delay concentrates on the items that changed, under batch-at-boundary
it spreads across all of them.

#### Scope Creep and the Baseline's Lifespan

**TD27:** the third measurement removes control, and every request is accepted. Six requests add
23 rounds to 71 rounds of work (a 32.4 percent **scope creep**), but the baseline does not degrade
in proportion to that rate. In plan order, the count of items that still hold up to the first miss
drops from eight to two after one request, to one after the second, and stays there through the
sixth. The added work keeps growing, and the baseline's descriptive power bottoms out at the
second change. **Past that point the scope baseline is not a plan but a date**: it does not say
which item finishes when, it says what was once believed.

#### Which Boundary a Change Falls Into

The fourth measurement sends two requests of the same size into the same item at the same round;
the only difference is which module they touch. The request that stays inside the boundary
preserves the item's four steps; the one that falls outside turns it into an item that two teams
touch: the step count rises to five, handoffs from six to seven, waiting from 10 to 12, delivery
time becomes 28 rounds instead of 23, and the flow end moves from 30 to 34. Rework is four rounds
in both cases; the difference comes entirely from crossing the boundary. **A request that does not
overlap the team boundary is expensive**, and what grows the difference is not the request itself
but how module ownership is split.

#### Summary

- The same request writes zero rounds of rework at analysis and eight at validation; delivery time
  rises from 12 rounds to 39. A request that arrives at an already delivered item becomes a
  separate item.
- On the same six requests and the same budget, the accept-on-arrival policy takes two and the
  batch-at-boundary policy takes four; rework is 8 versus 4, waiting is 44 versus 133, and the
  flow end is 36 versus 45. The batch-at-boundary policy's cost is the value it delays: the
  average from arrival to delivery is 30 rounds versus 21.5 under accept-on-arrival; the initial
  scope's average is 16.3 rounds under both (finished by baseline round: 4 of 8 versus 3 of 8).
- Once control is removed, six requests grow the scope by 32.4 percent, but the plan breaks even
  earlier: from 8 of 8 to 2 of 8 after the first request, to 1 of 8 after the second.
- A request that falls outside the team boundary adds one step, one handoff, and two rounds of
  waiting; delivery time is 28 rounds instead of 23.

#### Next Step

Change has been controlled, the scope baseline has been kept, and its cost has been counted in
rework and waiting. But every one of these measurements stops the counter the moment the item is
delivered. In the model, delivery is the last step finishing; in the field it is where the
software's real lifetime begins: which team a call at night goes to, and what the work that cannot
be handed over costs, appear in none of these tables. The next lesson carries the counter past
delivery: what items make up the responsibility between the team that builds and the team that
operates, and who is left holding the item that cannot be handed over?

### Operations Handover

Source: https://academia.sh/en/courses/process-and-team/operations-handover

The responsibility contract between development and operations: twenty of twenty-eight responsibility items are handed over and eight stay with development for lack of information, tooling, or a runbook; the same fourteen events close in 3.8 rounds under shared on-call versus 5.0 under full handover, while the interruption cost pulled from the development flow rises from 37 to 54 and rework from 9 to 20; once three undocumented runbooks are written, escalations fall from 8 to 5 and the flow end from round 94 to 84.

The previous lesson tied scope change to control and counted its cost, but it stopped the counter
the moment the item was delivered. Delivery is the last step finishing in the model; in the field
it is the first day the service also has to stay up at night.

**Operations handover** is a service's operating responsibility moving from the team that wrote it
to the team that operates it, and it is not one piece — it is handed over item by item. This
lesson models the handover as a **contract**: which item was handed over, why an unhandled one
could not be, and where its bill lands.

#### The Handover Contract, Item by Item

The example is the course's fictional **regional library network** and the three teams that build
it. **TD28:** seven services are split across three writing teams, plus one operations team; three
teams together touch the `shared-registry` service. Each team has six items ahead of it, six
rounds of work per item. **TD29:** the day is ten rounds; the first six rounds are the workday, the
last four are night, and an item only advances during the workday. **TD30:** the contract is four
responsibility items — operation, first response, restart, configuration change; the reason an
item cannot be handed over is a lack of **information**, **tooling**, or a **runbook**. **TD31:** a
critical event is met at night too, a minor event waits until morning. **TD32:** the interrupted
team is pulled out of the flow for the response time plus two rounds of recovery; if the
interruption lands in the middle of an item, at most two rounds of rework are written, and a team
woken at night loses two rounds the next morning. **TD33:** the operations team closes a
handed-over responsibility in two rounds; because the context has to be rebuilt secondhand,
resolving an escalated event costs the writing team three rounds (two under shared on-call).

The contract and the two on-call policies are an in-process **model**; there is no real on-call
schedule or incident log.

```js
// handover/oncall.mjs — the fictional library network's operations handover and on-call model
// (model): no real on-call schedule or incident log exists, rounds are a data structure. No randomness.

export const DAY = 10, WORKDAY = 6;         // each day is 10 rounds: first 6 rounds workday, last 4 night
export const FIRST = 1, WAKE = 2, TRANSFER = 1, RESOLVE_OPS = 2, RESOLVE_BUILD = 3, RESOLVE_LOCAL = 2;
export const RECOVERY = 2, SLEEP_COST = 2, REWORK_CAP = 2;
export const ITEM_WORK = [6, 6, 6, 6, 6, 6];   // six items ahead of each writing team
export const TEAMS = ["E1", "E2", "E3"];

export const isNight = (t) => t % DAY >= WORKDAY;
export const nextMorning = (t) => (Math.floor(t / DAY) + 1) * DAY;

const table = (s, f) => s.trim().split("\n").map((r) => f(r.trim().split(/\s+/)));

// Handover contract: service, owning team, number of teams touching it, four responsibilities. "+" is
// handed over; anything else is not handed over, and names the reason (information, tooling, runbook).
export const CONTRACT = table(`
  loan             E2  1  +  +  +            information
  fee              E2  1  +  +  +            +
  branch-front     E1  1  +  +  +            +
  membership-gate  E1  1  +  +  tooling      tooling
  catalog-gate     E3  1  +  +  +            runbook
  notification     E3  1  +  +  runbook      runbook
  shared-registry  E3  3  +  +  information  information`,
([service, team, teams, ...h]) => ({ service, team, teams: +teams,
  status: { operation: h[0], "first-response": h[1], restart: h[2], configuration: h[3] } }));

// Event: arrival round, service, responsibility needed to close it, severity.
export const EVENT = table(`
   3  loan             configuration  critical
   7  fee              restart        critical
  12  shared-registry  configuration  critical
  16  notification     restart        critical
  18  branch-front     configuration  critical
  23  membership-gate  restart        critical
  27  catalog-gate     configuration  minor
  31  loan             restart        critical
  36  shared-registry  restart        critical
  39  notification     configuration  critical
  44  fee              configuration  critical
  47  membership-gate  configuration  minor
  50  catalog-gate     restart        critical
  52  branch-front     restart        minor`,
([round, service, resolution, a]) => ({ round: +round, service, resolution, critical: a === "critical" }));

// policy: "full" means the operations team meets every event, "shared" means the writing team is on call.
// closeReason: responsibility items not handed over for this reason are treated as closed (counter-run).
export function run(policy, closeReason = null) {
  const S = Object.fromEntries(CONTRACT.map((s) => [s.service, s]));
  const team = Object.fromEntries(TEAMS.map((e) =>
    [e, { busy: 0, i: 0, progress: 0, diverted: 0, rework: 0, finish: null }]));
  const pending = {}, log = [];
  const schedule = (e, t, duration) => ((pending[t] ??= []).push([e, duration]));
  const isOpen = (s, c) => s.status[c] === "+" || s.status[c] === closeReason;

  const handle = (o, t) => {
    const s = S[o.service];
    if (policy === "full" && isOpen(s, o.resolution))       // resolved by ops via a written runbook
      return { s, first: FIRST, closure: FIRST + RESOLVE_OPS, handoffs: 1, escalated: false,
        woke: false, interruption: 0, reason: "-" };
    const escalated = policy === "full";
    const handoffs = (escalated ? 2 : 1) + (s.teams > 1 ? 1 : 0);
    const transfer = TRANSFER * (handoffs - 1), resolve = escalated ? RESOLVE_BUILD : RESOLVE_LOCAL;
    const firstResponse = escalated ? FIRST : isNight(t) ? (o.critical ? WAKE : nextMorning(t) - t) : FIRST;
    const reason = escalated ? s.status[o.resolution] : "-";
    const common = { s, first: firstResponse, handoffs, escalated, reason };
    if (!isNight(t)) {
      const interruption = (escalated ? 0 : FIRST) + transfer + resolve + RECOVERY;
      schedule(s.team, t, interruption);
      return { ...common, closure: firstResponse + transfer + resolve, woke: false, interruption };
    }
    if (o.critical) {                                        // night wake-up: the cost lands next morning
      schedule(s.team, nextMorning(t), SLEEP_COST);
      return { ...common, closure: (escalated ? FIRST + WAKE : WAKE) + transfer + resolve, woke: true,
        interruption: SLEEP_COST };
    }
    const interruption = transfer + resolve + RECOVERY;      // a minor night event waits until morning
    schedule(s.team, nextMorning(t), interruption);
    return { ...common, closure: (escalated ? nextMorning(t) - t : firstResponse) + transfer + resolve, woke: false, interruption };
  };

  const events = policy === "none" ? [] : EVENT;
  for (let t = 0; t < 200; t++) {
    for (const o of events.filter((x) => x.round === t)) log.push({ o, ...handle(o, t) });
    for (const [e, duration] of pending[t] ?? []) {
      const k = team[e];
      k.busy += duration;
      const d = Math.min(k.progress, REWORK_CAP);            // interruption landed mid-item
      k.progress -= d; k.rework += d;
    }
    if (isNight(t)) continue;                                // items do not advance outside the workday
    for (const e of TEAMS) {
      const k = team[e];
      if (k.finish !== null) continue;
      if (k.busy > 0) { k.busy--; k.diverted++; continue; }
      if (++k.progress < ITEM_WORK[k.i]) continue;
      k.progress = 0; k.i++;
      if (k.i >= ITEM_WORK.length) k.finish = t + 1;
    }
  }
  const total = (f) => TEAMS.reduce((a, e) => a + f(team[e]), 0);
  const avg = (f) => (log.length ? log.reduce((a, x) => a + f(x), 0) / log.length : 0);
  return { log, team,
    first: avg((x) => x.first).toFixed(1), closure: avg((x) => x.closure).toFixed(1),
    handoffs: log.reduce((a, x) => a + x.handoffs, 0), escalated: log.filter((x) => x.escalated).length,
    woke: log.filter((x) => x.woke).length,
    diverted: total((k) => k.diverted), rework: total((k) => k.rework),
    end: Math.max(...TEAMS.map((e) => team[e].finish)) };
}
```

```js
// handover/measure.mjs — handover contract, two handover policies, cost of what cannot be handed over, team boundary.
import { run, CONTRACT, EVENT, isNight } from "./oncall.mjs";

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const avg = (a) => (a.length ? (a.reduce((s, x) => s + x, 0) / a.length).toFixed(1) : "-");
const RESPONSIBILITY = ["operation", "first-response", "restart", "configuration"];

const items = CONTRACT.flatMap((s) => RESPONSIBILITY.map((c) => s.status[c]));
const reasons = ["information", "tooling", "runbook"];
const count = (x) => items.filter((k) => k === x).length;
console.log(`contract: ${items.length} items, handed over ${count("+")}, not handed over ` +
  `${items.length - count("+")} (${reasons.map((n) => `${n} ${count(n)}`).join(", ")})`);
const R = { none: run("none"), full: run("full"), shared: run("shared"),
  withRunbooks: run("full", "runbook") };
console.log(`\n${EVENT.length} events, ${EVENT.filter((o) => isNight(o.round)).length} outside the workday, ` +
  `${EVENT.filter((o) => o.critical).length} critical; without events the flow ends at round ${R.none.end}`);

const O = [-34, 15, 17, 29];
console.log("\n1. the same set of events under two handover policies (third column: counter-run)");
write(O, "measure", "full handover", "shared on-call", "full handover + 3 runbooks");
const MEASURES = [["time to first response (rounds)", "first"], ["closure delay (rounds)", "closure"],
  ["handoffs", "handoffs"], ["escalations", "escalated"], ["out-of-hours pages", "woke"],
  ["interruption cost (rounds)", "diverted"], ["rework (rounds)", "rework"],
  ["end of development flow", "end"]];
for (const [name, a] of MEASURES) write(O, name, R.full[a], R.shared[a], R.withRunbooks[a]);

const N = [-13, 8, 14, 22, 21];
console.log("\n2. why events escalate under full handover");
write(N, "reason", "events", "avg. closure", "interruption cost", "out-of-hours pages");
for (const n of reasons) {
  const g = R.full.log.filter((x) => x.reason === n);
  write(N, n, g.length, avg(g.map((x) => x.closure)), g.reduce((s, x) => s + x.interruption, 0),
    g.filter((x) => x.woke).length);
}

const M = [-13, 8, 14, 21];
console.log("\n3. does the service overlap the team boundary? (full handover)");
write(M, "service", "events", "avg. closure", "handoffs per event");
for (const [name, f] of [["single-team", (x) => x.s.teams === 1],
  ["multi-team", (x) => x.s.teams > 1]]) {
  const g = R.full.log.filter(f);
  write(M, name, g.length, avg(g.map((x) => x.closure)), avg(g.map((x) => x.handoffs)));
}
```

```
contract: 28 items, handed over 20, not handed over 8 (information 3, tooling 2, runbook 3)

14 events, 7 outside the workday, 11 critical; without events the flow ends at round 56

1. the same set of events under two handover policies (third column: counter-run)
measure                             full handover   shared on-call   full handover + 3 runbooks
time to first response (rounds)               1.0              1.6                          1.0
closure delay (rounds)                        5.0              3.8                          4.1
handoffs                                       24               16                           21
escalations                                     8                0                            5
out-of-hours pages                              3                5                            1
interruption cost (rounds)                     37               54                           27
rework (rounds)                                 9               20                           10
end of development flow                        94              105                           84

2. why events escalate under full handover
reason         events  avg. closure     interruption cost   out-of-hours pages
information         3           6.3                    15                    1
tooling             2           6.0                    12                    0
runbook             3           7.0                    10                    2

3. does the service overlap the team boundary? (full handover)
service        events  avg. closure   handoffs per event
single-team        12           4.7                  1.5
multi-team          2           7.0                  3.0
```

The numbers are in the **measurement** class; their inputs are the assumptions above. Twenty of
the twenty-eight items are handed over, and the contract table says this: **operation and first
response are handed over in all seven of the seven services**, and the entire unhandled eight sit
in the restart and configuration columns. The handover does not stop at keeping the service
running — it stops where the service needs to be touched.

#### Full Handover versus Shared On-Call

The first table's first two columns meet the same fourteen events under two policies. Under full
handover, the operations team is on call, and time to first response is 1.0 rounds with no
day-night distinction; under shared on-call, the writing team gives the first response and the
delay rises to 1.6 rounds, because a minor event arriving at night waits until morning. At closure
the order reverses: shared on-call closes in 3.8 rounds, full handover in 5.0. The difference is
that eight events cannot be resolved and escalate back to the writing team; every escalation is a
handoff and a context rebuilt secondhand (24 handoffs against 16).

The bill is in the rows below. With no events, the three teams finish their work at round 56;
under full handover the flow stretches to round 94, under shared on-call to round 105,
interruption cost is 37 against 54, and rework is 9 against 20. Out-of-hours pages are also higher
under shared on-call (5 against 3): under full handover, only the critical night event that
cannot be handed over wakes anyone. **Shared on-call closes events faster and pays for it out of
the flow.**

#### The Cost of What Cannot Be Handed Over

The second table splits the eight escalated events by reason. The most expensive reason is the
**undocumented runbook**: three events, an average closure of 7.0 rounds, and two of the three
out-of-hours pages. Missing information accounts for three events at 6.3 rounds and produces the
highest interruption cost (15); missing tooling accounts for two events at 6.0 rounds.

The first table's third column gives the price of the runbook: once three runbooks are written,
escalations fall from 8 to 5, out-of-hours pages from 3 to 1, interruption cost from 37 to 27, and
the flow end from round 94 to 84. **An undocumented runbook is not a missing document — it is ten
rounds of flow delay and two out-of-hours pages.**

#### Which Team Boundary an Event Falls Into

The third table splits events by whether the service overlaps the team boundary. In single-team
services, twelve events close in 4.7 rounds and 1.5 handoffs per event; in the `shared-registry`
service that three teams touch, two events close in 7.0 rounds and 3.0 handoffs. The reason is in
the contract: both of that service's unhandled items are for the reason `information`, because
information does not stay inside a single team. **A service that does not overlap the team
boundary is expensive not only while being built, but at night too.**

#### Summary

- The handover contract splits into 28 items; 20 are handed over and 8 are not (information 3,
  tooling 2, runbook 3), and all of the unhandled items sit in the restart and configuration
  columns.
- The same 14 events close in 5.0 rounds under full handover and 3.8 under shared on-call; time
  to first response is reversed (1.0 against 1.6), and handoffs are 24 against 16.
- The cost is in the development flow: with no events the flow ends at round 56, but it stretches
  to 94 under full handover and 105 under shared on-call; interruption cost is 37 against 54 and
  rework is 9 against 20.
- Once three undocumented runbooks are written, escalations fall from 8 to 5, out-of-hours pages
  from 3 to 1, and the flow end from round 94 to 84; the service that three teams touch closes its
  events in 7.0 rounds and 3.0 handoffs (against 4.7 and 1.5 in a single-team service).

#### Course Wrap-Up

| Lesson | Flow Object | Handoffs / Waiting / Rework | Cost of Architectural Alignment |
|---|---|---|---|
| Lifecycle Models | 18 items, 4 cycles | 125 → 120 / 243 → 18 rounds / 105 → 90 units | wrong-boundary delay 14.8 → 7.8 rounds |
| Agile Frameworks | 30 items, 1–6 weeks | carry-over 8 → 0 / flow 12.5 → 31.2 days / 19 → 23 person-days | 75 linked pairs determine rework |
| Flow-Based Management | 60 items, limit 1–16 | lead time 118.5 → 17.3 days / waiting share 0% → 84.1% | the limit is the WIP limit, not the team; 195.9 person-days go to switching |
| Scaled Frameworks | 40 items, 1–12 teams | coordination rounds 0 → 66 / per item 0 → 5.9 periods | shared channels grow quadratically; lowest cost at 3 teams |
| Engineering Practices | 48 items, 2–40 hours | incidents 21 → 2 / delay 8.0 → 21.4 hours / 82 → 132 hours | rework grows with same-module items |
| Conway's Law | 12 items, two splits | 24 → 17 / 203 → 55 rounds / 24 → 15 | boundary-crossing links 40/43 → 32/43; pathless cross-team links 16 → 0 |
| Team Topologies | 12 items, three arrangements | 24 / 17 / 25 — 203 / 55 / 78 rounds — 24 / 15 / 22 | cognitive load falls from 1–13 to 3–9, duplicated expertise 18 / 18 / 24 |
| Estimation and Planning | duration of 12 items | folding waiting into the estimate takes error from 58.4% to 23.5% | at 3+ teams, waiting share is 51.7% and error 72.2% (24.3% and 44.6% at 1–2 teams) |
| Change Management | 8 items, 6 requests | 31 / 38 — 44 / 133 rounds — 8 / 4 rounds | a request that falls outside the boundary makes delivery 28 rounds instead of 23 |
| Operations Handover | 14 events, 28 items | 24 / 16 — closure 5.0 / 3.8, interruption cost 37 / 54 — 9 / 20 rounds | the service that three teams touch has 3.0 handoffs instead of 1.5 |

This is the rule of the **Process, Team and Delivery** course: **process and team decisions shape
the architecture from wherever the work is waiting.** No lesson found a process good on its own;
each one was run on the same set of work items, and what it cost where it won was counted.

The **Software Architecture** curriculum ends here. The five courses' five measurement axes come
together in one sentence: a decision is an object and its traceability is measured (**The
Architect's Role**), a document is measured by the question it answers (**Architectural Decisions
and Documentation**), at the scale of the enterprise the measure is a link (**Enterprise Context
and Integration**), a rule is governable to the extent it can be checked by machine (**Quality
Attributes and Governance**), and process and team decisions are measured from wherever the work
is waiting (**Process, Team and Delivery**).

