---
title: 'Platform Engineering'
source: 'https://academia.sh/en/courses/platform-engineering'
language: en
updated: '2026-09-13T05:08:28+00:00'
license: 'CC BY-SA 4.0'
lessons: 12
outcomes:
    - 'Positions the internal developer platform as a product'
    - 'Balances standardization against autonomy by defining golden paths'
    - "Evaluates the platform's impact with metrics"
---

# Platform Engineering

Not rebuilding the layers set up across seven courses for every team: the reason platform engineering pays off at scale, the platform as a product with an internal customer and a feedback loop, recommended and supported golden paths, the balance between mandate and freedom of choice, self-service that removes the request queue, environment provisioning on demand, template and scaffold generation that starts a new service, a service catalog that makes ownership and dependencies visible, embedding rules in the pipeline, per-team cost accounting, staging platform migrations and adoption, and delivery speed and stability indicators.

## The Platform Approach

When abstraction pays off: the reason platform engineering exists at scale and the threshold it needs, setting up the internal customer and the feedback loop as a product, the scope of a recommended and supported golden path, tying the balance between mandate and freedom of choice to a number, and the cost of the self-service that removes the request queue.

### What Is Platform Engineering

Source: https://academia.sh/en/courses/platform-engineering/what-is-platform-engineering

Measuring the platform as an abstraction: the eighteen items that seven courses leave for every team to rebuild are listed by type, the rebuild work per team is counted, scanning the team count derives the threshold where the platform pays for itself, and the person-days the platform costs below that threshold appear in the same table.

The previous course closed out hardening and left a question behind. Across seven courses, each
layer was built separately: an environment was provisioned, a pipeline was set up, a cluster
object was written, an infrastructure record was kept, a dashboard was wired, the default surface
was narrowed. Is **every team going to rebuild all of this**?

The answer is not an opinion, it is a number: the cost of moving something to a shared place is
paid up front, its gain is multiplied by the team count, and the two become equal at a specific
team count. This course's unit of measure is **abstraction** — a platform hides a layer and puts
an interface in its place, and that interface carries its own cost. Every lesson counts three
things: what got hidden and how much each team saved, where the hidden part leaked back through,
and who had to step outside the path.

Where a platform stands as a **team type** — stream-aligned teams, cognitive load, team
topologies — was measured in the Team Topologies lesson; it is not repeated here. Here the
platform is not a team arrangement, it is an abstraction. The through-line is the **regional
measurement network**; this course adds the **five teams** that build the network's software:
meter reading, validation, billing, field, and reporting. Both the network and the teams are
fictional.

#### The Item Set Left by Seven Courses

**PA1 — the work left behind by seven courses splits into eighteen items across six types:
environment, pipeline, cluster object, record, dashboard, and hardening step.** Every item
carries three numbers: the cost of a team building the item itself and running it for three
years, the cost of the platform taking the item over once, and the work **remaining with the
team** after the handoff. The unit is the person-day. The reason: the platform argument cannot be
measured until the item set is counted.

**PA2 — an item's threshold is the handoff cost divided by the net per-team gain, rounded up**;
the net gain is the difference between the per-team work and the work remaining with the team.
The reason: the handoff is paid once, the gain repeats with every team.

**PA3 — two regimes are compared: one where every team builds all eighteen items itself, and one
where the platform takes all of them over.** **PA4 — a third regime, selective handoff, moves
only the items whose threshold is five or below; the rest stay with teams at full cost.** The
reason: handoff is not an organizational decision, it is made item by item.

```js
// item.mjs — the item set left by seven courses, per-item threshold and team scan (model)
import { writeFileSync } from "node:fs";

// PA1 — the 18 items left by seven courses and rebuilt by every team: type, name, per-team
// work, platform handoff (once), work left with the team after handoff. Unit is person-day,
// window three years. PA2 — item threshold = ceil(handoff / (team - remaining)). Fictional.
const ITEM = [
  ["environment", "development environment", 12, 55, 3],
  ["environment", "test environment", 9, 40, 2],
  ["environment", "secret binding", 7, 46, 2],
  ["pipeline", "build step", 8, 30, 2],
  ["pipeline", "test run", 6, 22, 4],
  ["pipeline", "evidence generation", 10, 44, 1],
  ["pipeline", "deployment step", 11, 52, 3],
  ["cluster object", "workload definition", 9, 38, 3],
  ["cluster object", "network rule", 7, 34, 2],
  ["cluster object", "scaling rule", 5, 18, 3],
  ["record", "infrastructure state record", 13, 62, 2],
  ["record", "version record", 6, 20, 1],
  ["dashboard", "four-dashboard wiring", 8, 30, 5],
  ["dashboard", "log aggregation", 7, 26, 2],
  ["dashboard", "alert rule", 9, 24, 6],
  ["hardening", "base image scanning", 10, 42, 1],
  ["hardening", "privilege narrowing", 12, 50, 4],
  ["hardening", "secret rotation", 8, 33, 2],
].map(([type, name, team, handoff, remaining]) => ({ type, name, team, handoff, remaining,
  net: team - remaining, threshold: Math.ceil(handoff / (team - remaining)), leak: remaining / team }));
writeFileSync("item.json", JSON.stringify(ITEM));

const T = (d, f) => d.reduce((t, k) => t + f(k), 0);
const share = (a, b) => `${(100 * a / b).toFixed(1)}%`;
const group = (t) => ITEM.filter((k) => k.type === t);
console.log(`${ITEM.length} items — team side ${T(ITEM, (k) => k.team)}, handoff ` +
  `${T(ITEM, (k) => k.handoff)}, remaining with the team after handoff ${T(ITEM, (k) => k.remaining)} ` +
  `person-days (remaining share ${share(T(ITEM, (k) => k.remaining), T(ITEM, (k) => k.team))})`);
console.log("remaining share by type — " + [...new Set(ITEM.map((k) => k.type))]
  .map((t) => `${t} ${share(T(group(t), (k) => k.remaining), T(group(t), (k) => k.team))}`).join(", "));

console.log("\n" + "item".padEnd(29) + "team".padStart(6) + "remaining".padStart(11) +
  "handoff".padStart(9) + "threshold".padStart(11));
for (const k of [...ITEM].sort((a, b) => a.threshold - b.threshold || a.name.localeCompare(b.name)))
  console.log(k.name.padEnd(29) + `${k.team}`.padStart(6) + `${k.remaining}`.padStart(11) +
    `${k.handoff}`.padStart(9) + `${k.threshold}`.padStart(11));

// PA3 — two regimes: every team builds all 18 items itself; or the platform takes them all over and a remainder stays with the team.
// PA4 — a third regime, selective handoff, moves only the items whose threshold is five or below; the rest stay with teams at full cost.
const FULL = T(ITEM, (k) => k.team);
const ALL_HANDOFF = T(ITEM, (k) => k.handoff), ALL_REMAINING = T(ITEM, (k) => k.remaining);
const SEL_HANDOFF = T(ITEM.filter((k) => k.threshold <= 5), (k) => k.handoff);
const SEL_REMAINING = T(ITEM, (k) => (k.threshold <= 5 ? k.remaining : k.team));
const sign = (v) => (v > 0 ? `+${v}` : `${v}`);
console.log("\n" + "team".padStart(6) + "no platform".padStart(13) + "all items".padStart(14) +
  "diff".padStart(8) + "selective".padStart(11) + "diff".padStart(8) + "winner".padStart(14));
for (let n = 1; n <= 12; n++) {
  const none = n * FULL, all = ALL_HANDOFF + n * ALL_REMAINING, sel = SEL_HANDOFF + n * SEL_REMAINING;
  const best = Math.min(none, all, sel);
  console.log(`${n}`.padStart(6) + `${none}`.padStart(13) + `${all}`.padStart(14) +
    sign(none - all).padStart(8) + `${sel}`.padStart(11) + sign(none - sel).padStart(8) +
    (best === none ? "no platform" : best === sel ? "selective" : "all items").padStart(14));
}
const threshold = (handoff, kept) => Math.ceil(handoff / (FULL - kept));
console.log(`\nthreshold if all items are handed off: ${threshold(ALL_HANDOFF, ALL_REMAINING)} teams; ` +
  `selective handoff: ${threshold(SEL_HANDOFF, SEL_REMAINING)} teams (handoff ${ALL_HANDOFF} and ${SEL_HANDOFF})`);
const heavy = ITEM.filter((k) => k.leak > 0.4).sort((a, b) => b.threshold - a.threshold);
console.log(`items with zero remaining work: ${ITEM.filter((k) => k.remaining === 0).length} / ` +
  `${ITEM.length}; items whose remainder exceeds 40% of team work: ${heavy.length} — ` +
  heavy.map((k) => `${k.name} (${share(k.remaining, k.team)}, threshold ${k.threshold})`).join(", "));
```

```
18 items — team side 157, handoff 666, remaining with the team after handoff 48 person-days (remaining share 30.6%)
remaining share by type — environment 25.0%, pipeline 28.6%, cluster object 38.1%, record 15.8%, dashboard 54.2%, hardening 23.3%

item                           team  remaining  handoff  threshold
version record                    6          1       20          4
base image scanning              10          1       42          5
build step                        8          2       30          5
evidence generation              10          1       44          5
infrastructure state record      13          2       62          6
log aggregation                   7          2       26          6
secret rotation                   8          2       33          6
test environment                  9          2       40          6
deployment step                  11          3       52          7
development environment          12          3       55          7
network rule                      7          2       34          7
privilege narrowing              12          4       50          7
workload definition               9          3       38          7
alert rule                        9          6       24          8
scaling rule                      5          3       18          9
four-dashboard wiring             8          5       30         10
secret binding                    7          2       46         10
test run                          6          4       22         11

  team  no platform     all items    diff  selective    diff        winner
     1          157           714    -557        264    -107   no platform
     2          314           762    -448        392     -78   no platform
     3          471           810    -339        520     -49   no platform
     4          628           858    -230        648     -20   no platform
     5          785           906    -121        776      +9     selective
     6          942           954     -12        904     +38     selective
     7         1099          1002     +97       1032     +67     all items
     8         1256          1050    +206       1160     +96     all items
     9         1413          1098    +315       1288    +125     all items
    10         1570          1146    +424       1416    +154     all items
    11         1727          1194    +533       1544    +183     all items
    12         1884          1242    +642       1672    +212     all items

threshold if all items are handed off: 7 teams; selective handoff: 5 teams (handoff 666 and 136)
items with zero remaining work: 0 / 18; items whose remainder exceeds 40% of team work: 4 — test run (66.7%, threshold 11), four-dashboard wiring (62.5%, threshold 10), scaling rule (60.0%, threshold 9), alert rule (66.7%, threshold 8)
```

#### Where the Threshold Lies

The first two lines give the upfront cost: a single team building all seven courses' worth of
items itself and running them for three years costs **157 person-days**; the platform taking
over the same items costs **666 person-days** — more than four times a single team's work. The
work still sitting with the team after handoff is **48 person-days**, **30.6%** of per-team work.
The highest remainder by type is in the dashboard items (54.2%), because which metric to watch
and at what value an alert fires depends on the team's own work; the lowest is in the record
items (15.8%), since how an infrastructure state record is kept does not change from team to
team.

The table decides item by item. `version record` pays for itself at four teams; `test run` does
not pay for itself before eleven teams, because four of its six person-days stay with the team
and the net gain drops to two. **In a five-team organization, only four of the eighteen items pay
for themselves.**

The scan shows three zones. **Between one and four teams, the no-platform regime wins**; at a
single team, handing off all items costs 557 person-days. At five and six teams, selective
handoff takes the lead. **Past seven teams, handing off all items wins** and the gap widens: 642
person-days at twelve teams. The five-team row is this course's first decision: when five teams
move all items to the platform, they **lose 121 person-days**, 15.4% above the no-platform
regime; the same five teams gain 9 person-days with selective handoff. The question is not
"should we build a platform," it is **"how many teams, and which items."**

The last line names the leak: none of the eighteen items has a zero remainder, four exceed 40% of
the team's work, and those four have thresholds of 8, 9, 10, and 11 — the slowest-paying end of
the list. The two numbers share a source: as the remainder grows, the net gain shrinks and the
threshold climbs. **The item that leaks the most is the item that pays for itself last.**

#### Five Teams and Who Steps Outside

**PA5 — five teams build the measurement network's software, and every team has items that do
not fit the platform's shape.** **PA6 — a team that steps outside pays the item's full cost, plus
a `ceil(team / 4)` alignment cost to reconcile its own shape with what the platform produces.**
The reason: stepping outside is not the same as escaping the platform; the team still touches the
platform's boundary and has to convert its output into the shape the platform expects.

```js
// teams.mjs — five teams, the items they step outside of, and the threshold with exemptions
import { readFileSync } from "node:fs";
const ITEM = JSON.parse(readFileSync("item.json", "utf8"));
const find = (name) => ITEM.find((k) => k.name === name);

// PA5 — the five teams that build the regional measurement network's software, and the items
// that do not fit the platform's shape; teams and reasons are both fictional. PA6 — a team
// that steps outside pays the item's full cost, plus a ceil(team / 4) alignment cost to reconcile its own shape with what the platform produces.
const TEAM = [
  ["meter reading", { "development environment": "hardware emulation", "network rule": "direct line" }],
  ["validation", { "scaling rule": "nightly batch run", "workload definition": "long-running job" }],
  ["billing", { "infrastructure state record": "retention requirement",
    "version record": "retroactive accounting", "secret rotation": "a separate signing chain" }],
  ["field", { "deployment step": "offline operation", "log aggregation": "intermittent connectivity",
    "development environment": "hardware emulation" }],
  ["reporting", {}],
].map(([name, outside]) => ({ name, outside }));

const alignment = (k) => Math.ceil(k.team / 4);
const FULL = ITEM.reduce((t, k) => t + k.team, 0);
const cost = (e, eligible) => ITEM.reduce((t, k) => t + (eligible(k)
  ? (k.name in e.outside ? k.team + alignment(k) : k.remaining) : k.team), 0);
console.log("team".padEnd(15) + "stepped outside".padStart(16) + "work outside".padStart(14) +
  "remainder inside".padStart(18) + "total".padStart(8) + "saved".padStart(10));
for (const e of TEAM) {
  const d = Object.keys(e.outside);
  e.outsideWork = d.reduce((t, name) => t + find(name).team + alignment(find(name)), 0);
  e.insideWork = ITEM.filter((k) => !d.includes(k.name)).reduce((t, k) => t + k.remaining, 0);
  e.total = e.outsideWork + e.insideWork;
  console.log(e.name.padEnd(15) + `${d.length}`.padStart(16) + `${e.outsideWork}`.padStart(14) +
    `${e.insideWork}`.padStart(18) + `${e.total}`.padStart(8) + `${FULL - e.total}`.padStart(10));
}

const PAID = TEAM.reduce((t, e) => t + e.total, 0);
const HANDOFF = ITEM.reduce((t, k) => t + k.handoff, 0);
const touched = new Set(TEAM.flatMap((e) => Object.keys(e.outside)));
const avg = PAID / TEAM.length;
console.log(`\nitems at least one team steps outside of: ${touched.size} / ${ITEM.length}; ` +
  `total step-outs: ${TEAM.reduce((t, e) => t + Object.keys(e.outside).length, 0)}`);
console.log(`5 teams — no platform ${5 * FULL}, all items without exemptions ${HANDOFF + 5 * 48}, ` +
  `all items with exemptions ${HANDOFF + PAID} person-days`);
console.log(`average team cost with exemptions ${avg.toFixed(1)}; net gain ` +
  `${(FULL - avg).toFixed(1)}; threshold ${Math.ceil(HANDOFF / (FULL - avg))} teams`);

// Selective handoff is tried again on the same five teams, exemptions left in place.
const selective = (k) => k.threshold <= 5;
const SEL = ITEM.filter(selective).reduce((t, k) => t + k.handoff, 0) +
  TEAM.reduce((t, e) => t + cost(e, selective), 0);
console.log(`5 teams, selective handoff, with exemptions: ${SEL} person-days — vs. no platform ` +
  `${5 * FULL - SEL} person-days, ${(100 * (5 * FULL - SEL) / (5 * FULL)).toFixed(1)}%`);
```

```
team            stepped outside  work outside  remainder inside   total     saved
meter reading                 2            24                43      67        90
validation                    2            19                42      61        96
billing                       3            35                43      78        79
field                         3            38                40      78        79
reporting                     0             0                48      48       109

items at least one team steps outside of: 9 / 18; total step-outs: 10
5 teams — no platform 785, all items without exemptions 906, all items with exemptions 998 person-days
average team cost with exemptions 66.4; net gain 90.6; threshold 8 teams
5 teams, selective handoff, with exemptions: 783 person-days — vs. no platform 2 person-days, 0.3%
```

Only one team is the team the platform was described for: `reporting` takes over all eighteen
items and saves 109 person-days. The other four give back part of what they save. `billing` is
outside three items — because of the retention requirement, retroactive accounting, and the
separate signing chain — and saves 79 person-days, thirty behind `reporting`. `field` also saves
79 person-days, but its outside work is heavier (38 against 35), because offline operation moves
the entire deployment step outside.

**In nine of the eighteen items, at least one team is outside the path**; half the item set
cannot serve all five teams at once. `development environment` is outside for two teams at once:
both meter reading and field need hardware emulation.

The bill for exemptions is in the last two lines. In the exemption-free accounting, five teams
paid 906 person-days; with exemptions it rises to **998 person-days**, **27% above** the
no-platform regime (785). Because the average team cost rises from 48 to 66.4, the net gain falls
from 109 to 90.6 and **the threshold shifts from seven teams to eight**. Selective handoff cannot
escape it either: `billing` is outside one of the four selected items, `version record`;
selective handoff's gain at five teams falls from 9 person-days to **2 person-days**, **0.3%** of
the 785-person-day total. A difference this small carries no decision.

#### Summary

- The work left by seven courses split into eighteen items across six types: one team building
  everything itself and running it for three years costs 157 person-days, the platform taking it
  all over costs 666.
- The remainder left with the team after handoff is 48 person-days, 30.6% of per-team work; no
  item has a zero remainder, and the highest is in the dashboard items (54.2%).
- The team scan produced three zones: the no-platform regime wins at 1–4 teams, selective handoff
  at 5–6 teams, and handing off all items wins past 7 teams. Handing off everything at five teams
  costs 121 person-days.
- In a five-team organization, only four of the eighteen items pay for themselves; the four
  leakiest items have thresholds of 8, 9, 10, and 11 — the threshold climbs as the remainder
  grows.
- Four of the five teams step outside at least one item, and nine of the eighteen items carry an
  exemption. Exemptions push the five-team total from 906 to 998 person-days, the threshold from
  seven teams to eight, and selective handoff's gain from 9 person-days to 2 (0.3%).

#### Next Step

Here the platform was built as a cost calculation: the item handed off, the remainder left with
the team, and the threshold where it pays for itself. The calculation assumed the item costs were
known; whether teams actually wanted a given item was never asked. Once the handoff cost is paid,
an item going unused turns it into a dead part of the 666-person-day investment. The next lesson
runs the platform in two operating models — on-demand and product-managed — and measures
adoption, capabilities that go unused, the work spent on them, the length of the feedback loop,
and the number of people the product approach pulls from the product teams.

### Platform as a Product

Source: https://academia.sh/en/courses/platform-engineering/platform-as-a-product

Measuring the internal customer and the feedback loop: the same twenty-four requests are run under an on-demand regime and a product-managed regime; adoption, the count of capabilities that go unused, the work spent on them, the time between a request and its fulfillment, and the gap the product approach opens in product teams are all counted.

The previous lesson built the platform as a cost calculation: eighteen items, a handoff cost, the
remainder left with the team, and the threshold where it pays for itself. Every line of that
calculation assumed **which item to hand off was already known**. In practice, a platform team
does not hold a list of items; it holds **requests**, and a request is not the same thing as a
need. Two teams state the same need through two separate requests; one team stops wanting what it
asked for by the time it arrives.

This lesson runs the same request set under two operating models. The **on-demand** regime takes
each request as it arrives and fulfills them in order. The **product-managed** regime treats
teams as an **internal customer**: it groups requests by the need behind them, checks how many
teams are waiting on a given need, and builds a need once; the cost is discovery work. What gets
counted: adoption, capabilities that go unused, the work spent on them, the time between a
request and its fulfillment, and the gap the product approach opens in product teams. The
through-line is the regional measurement network and its five teams; both the network and the
teams are fictional.

#### Two Operating Models

**PA7 — five teams open twenty-four requests in the first twelve quarters; fourteen distinct
needs sit behind those requests.** The run lasts sixteen quarters, because the backlog can only
be seen draining once the request flow stops. **PA8 — the platform's capacity is 26 person-days
per quarter, and it is the same in every run.** The reason: the two regimes can only be compared
under the same capacity.

**PA9 — the on-demand regime processes requests in arrival order and builds every request that
lands on the same need as a separate capability.** **PA10 — the product-managed regime spends the
first two quarters on discovery alone, sets aside 4 person-days of capacity per quarter for
discovery afterward, builds a need once, and moves the one with the most waiting teams to the
front.** The reason: what sets the product approach apart is that it takes priority away from
request order and ties it to the breadth of the need, and that information does not come free.

**PA11 — if the fulfillment arrives more than four quarters after the request, the team has
already built its own workaround and never uses that capability.** The reason: waiting is not
only delay; past a certain length, what gets delivered has no one left to receive it. A third
run, the product regime without discovery, is added to measure the cost of discovery work by
itself.

```js
// request.mjs — the same request set run under two operating models (model)
import { writeFileSync } from "node:fs";

// PA7 — five teams open 24 requests in the first twelve quarters, behind which sit 14 distinct needs;
// the run lasts sixteen quarters so the backlog has time to drain. Numbers are fictional.
const NEED = {
  "environment request": 28, "secret distribution": 22, "pipeline template": 18, "evidence archive": 16,
  "workload template": 24, "network rule template": 14, "scale setting": 12, "state record handoff": 34,
  "version ledger": 10, "dashboard wiring": 20, "log pipe": 26,
  "alert template": 12, "image scan gate": 18, "privilege template": 30,
};
const REQUEST = [
  [1, "meter", "environment request"], [1, "billing", "state record handoff"],
  [2, "validation", "pipeline template"], [3, "field", "environment request"],
  [3, "reporting", "dashboard wiring"], [4, "billing", "secret distribution"],
  [4, "meter", "network rule template"], [5, "validation", "scale setting"],
  [5, "field", "log pipe"], [6, "reporting", "pipeline template"],
  [6, "billing", "version ledger"], [7, "meter", "dashboard wiring"],
  [7, "validation", "workload template"], [8, "field", "alert template"],
  [8, "billing", "evidence archive"], [9, "reporting", "log pipe"],
  [9, "meter", "image scan gate"], [10, "validation", "privilege template"],
  [10, "field", "secret distribution"], [11, "billing", "pipeline template"],
  [11, "reporting", "environment request"], [12, "meter", "alert template"],
  [12, "validation", "state record handoff"], [12, "field", "privilege template"],
].map(([quarter, team, need]) => ({ quarter, team, need }));

// PA8 capacity is 26 person-days/quarter in both runs. PA9 on-demand regime: arrival order, every
// request its own capability. PA10 product regime: first two quarters discovery only, then 4
// person-days/quarter discovery, one need built once, sorted by waiting team count. PA11 patience is four quarters.
const CAPACITY = 26, DISCOVERY = 4, WINDOW = 16, PATIENCE = 4;

function run(name, product, hasDiscovery = product) {
  const requests = REQUEST.map((r) => ({ ...r, done: null }));
  const open = [], built = new Map();
  let build = 0, discovery = 0;
  for (let c = 1; c <= WINDOW; c++) {
    for (const r of requests.filter((x) => x.quarter === c)) {
      if (product && built.has(r.need)) { r.done = c; continue; }
      const match = product && open.find((a) => a.need === r.need);
      if (match) { match.team.add(r.team); continue; }
      open.push({ need: r.need, team: new Set([r.team]), remaining: NEED[r.need] });
    }
    let cap = CAPACITY;
    if (hasDiscovery) { const d = c <= 2 ? CAPACITY : DISCOVERY; discovery += d; cap -= d; }
    if (product) open.sort((a, b) => b.team.size - a.team.size || a.remaining - b.remaining);
    while (cap > 0 && open.length) {
      const job = open[0], spent = Math.min(cap, job.remaining);
      job.remaining -= spent; cap -= spent; build += spent;
      if (job.remaining > 0) break;
      open.shift();
      if (product) built.set(job.need, c);
      for (const r of requests)
        if (r.need === job.need && r.quarter <= c && r.done === null &&
          (product || job.team.has(r.team))) { r.done = c; if (!product) break; }
    }
  }
  for (const r of requests) {
    r.wait = r.done === null ? null : r.done - r.quarter;
    r.adopted = r.done !== null && r.wait <= PATIENCE;
  }
  return { name, product, requests, built: [...built], build, discovery,
    capabilities: product ? built.size : requests.filter((r) => r.done !== null).length };
}

const RUNS = [run("on-demand", false), run("product-managed", true),
  run("product, no discovery", true, false)];
writeFileSync("runs.json", JSON.stringify({ NEED, RUNS, PATIENCE }));
const avg = (v) => (v.length ? (v.reduce((a, b) => a + b, 0) / v.length).toFixed(1) : "-");
console.log("regime".padEnd(24) + "capabilities".padStart(13) + "build".padStart(7) +
  "discovery".padStart(11) + "fulfilled".padStart(12) + "unfulfilled".padStart(15) +
  "avg. wait".padStart(14) + "adopting".padStart(12));
for (const d of RUNS) {
  const b = d.requests.filter((r) => r.done !== null);
  console.log(d.name.padEnd(24) + `${d.capabilities}`.padStart(13) + `${d.build}`.padStart(7) +
    `${d.discovery}`.padStart(11) + `${b.length}`.padStart(12) +
    `${d.requests.length - b.length}`.padStart(15) + avg(b.map((r) => r.wait)).padStart(14) +
    `${d.requests.filter((r) => r.adopted).length}`.padStart(12));
}

console.log("\n" + "team".padEnd(13) + "requests".padStart(9) +
  RUNS.map((d) => d.name.padStart(24) + "adopting".padStart(12)).join(""));
for (const e of [...new Set(REQUEST.map((r) => r.team))]) {
  console.log(e.padEnd(13) + `${REQUEST.filter((r) => r.team === e).length}`.padStart(9) +
    RUNS.map((d) => {
      const x = d.requests.filter((r) => r.team === e);
      return avg(x.filter((r) => r.done !== null).map((r) => r.wait)).padStart(24) +
        `${x.filter((r) => r.adopted).length}`.padStart(12);
    }).join(""));
}
```

```
regime                   capabilities  build  discovery   fulfilled    unfulfilled     avg. wait    adopting
on-demand                          20    416          0          20              4           3.2          16
product-managed                    14    284        108          24              0           2.3          20
product, no discovery              14    284          0          24              0           0.7          24

team          requests               on-demand    adopting         product-managed    adopting   product, no discovery    adopting
meter                5                     3.0           3                     1.0           5                     0.4           5
billing              5                     3.2           4                     4.4           3                     1.8           5
validation           5                     3.3           3                     3.4           4                     0.2           5
field                5                     3.5           3                     1.4           4                     0.8           5
reporting            4                     3.0           3                     1.3           4                     0.0           4
```

#### Wait and Adoption

The first table puts the two regimes side by side. The on-demand regime builds **twenty
capabilities** and spends **416 person-days**; four requests never even make it into the queue
across sixteen quarters. The product regime meets the same twenty-four requests with **fourteen
capabilities** and **284 person-days** of build work — four more requests fulfilled, with one
hundred thirty-two fewer person-days. The difference is not in build speed, it is in
**duplication**: both `pipeline template` and `environment request` get built three times over,
because every request is its own capability.

The wait numbers soften this. Average wait is **3.2 quarters** in the on-demand regime and **2.3
quarters** in the product regime; it drops to **0.7 quarters** in the discovery-free run. The
1.6-quarter gap between them is the price of discovery work: the whole of the first two quarters,
and four person-days of every quarter after, never go to build.

The second table shows who the product regime takes from. `meter`'s wait drops from 3.0 to 1.0,
`field`'s from 3.5 to 1.4. `billing` moves the other way: its wait **rises from 3.2 to 4.4
quarters** and the number of requests it adopts falls from four to three. The cause is the
prioritization rule — `version ledger` and `evidence archive` are needs only `billing` asks for,
and they fall to the back of a queue sorted by waiting-team count. **The product approach moves
the majority's need to the front while pushing the minority's need to the back; this is not a
side effect, it is the rule itself.**

#### Dead Capabilities and the Cost of the Product Approach

**PA12 — the product regime's discovery work is subtracted from platform capacity; on top of
that, every request costs the requesting team 2 person-days of consultation, and every quarter
costs every team 1 person-day of feedback.** This second item is the gap opened in product teams,
and it is never written to the platform's own ledger. Forty person-days count as one
person-quarter. The reason: the cost of the product approach does not accumulate in the platform
team, it accumulates in the teams it measures; the two sides cannot be added without converting to
the same unit.

```js
// adoption.mjs — adoption distribution, dead capabilities, and the cost of the product approach
import { readFileSync } from "node:fs";
const { NEED, RUNS, PATIENCE } = JSON.parse(readFileSync("runs.json", "utf8"));
const TEAM = [...new Set(RUNS[0].requests.map((r) => r.team))];

// The adopting teams of a capability: in the on-demand regime it belongs to the single team that opened the request;
// in the product regime every team that wanted the same need shares one capability.
function capabilitiesOf(d, patience = PATIENCE) {
  const adopts = (r) => r.done !== null && r.done - r.quarter <= patience;
  if (!d.product)
    return d.requests.filter((r) => r.done !== null)
      .map((r) => ({ need: r.need, work: NEED[r.need], adopters: adopts(r) ? 1 : 0 }));
  return d.built.map(([need]) => ({ need, work: NEED[need],
    adopters: d.requests.filter((r) => r.need === need && adopts(r)).length }));
}

console.log("regime".padEnd(24) + "capabilities".padStart(13) + "duplicated build".padStart(18) +
  "dead capabilities".padStart(19) + "work on dead capabilities".padStart(27) +
  "team-capability pairs".padStart(23) + "per capability".padStart(16));
for (const d of RUNS) {
  const Y = capabilitiesOf(d), dead = Y.filter((y) => y.adopters === 0);
  const distinctCost = [...new Set(Y.map((y) => y.need))].reduce((t, i) => t + NEED[i], 0);
  const pairs = Y.reduce((t, y) => t + y.adopters, 0);
  console.log(d.name.padEnd(24) + `${Y.length}`.padStart(13) + `${d.build - distinctCost}`.padStart(18) +
    `${dead.length}`.padStart(19) + `${dead.reduce((t, y) => t + y.work, 0)}`.padStart(27) +
    `${pairs}`.padStart(23) + (pairs / Y.length).toFixed(2).padStart(16));
}

const count = (need) => new Set(RUNS[0].requests.filter((r) => r.need === need).map((r) => r.team)).size;
for (const d of RUNS.slice(0, 2)) {
  const dead = [...new Set(capabilitiesOf(d).filter((y) => y.adopters === 0).map((y) => y.need))];
  console.log(`${d.name}: capabilities never used — ` +
    dead.map((need) => `${need} (${NEED[need]} person-days, ${count(need)} team${count(need) === 1 ? "" : "s"} wanted it)`).join("; "));
}

// PA12 — the product regime's discovery work is subtracted from platform capacity; on top of it, every request costs
// the requesting team 2 person-days of consultation and every quarter costs every team 1 person-day of feedback. This
// second item is the gap opened in product teams. 40 person-days count as one person-quarter.
const PERSON_QUARTER = 40, WINDOW = 16;
const consultation = RUNS[0].requests.length * 2, feedback = TEAM.length * WINDOW;
const gap = consultation + feedback, discovery = RUNS[1].discovery;
console.log(`\ncost of the product regime — platform-side discovery ${discovery} person-days ` +
  `(${(discovery / PERSON_QUARTER).toFixed(1)} person-quarters); product-team consultation ${consultation} + ` +
  `feedback ${feedback} = ${gap} person-days (${(gap / PERSON_QUARTER).toFixed(1)} person-quarters)`);
console.log(`platform's gain in build work: ${RUNS[0].build} - ${RUNS[1].build} = ` +
  `${RUNS[0].build - RUNS[1].build} person-days; gap written to product teams ${gap} person-days ` +
  `(${(100 * gap / (RUNS[0].build - RUNS[1].build)).toFixed(0)}% of the gain)`);

// The length of the feedback loop is the one lever: the patience window is scanned.
console.log("\n" + "patience (quarters)".padEnd(21) +
  RUNS.map((d) => d.name.padStart(24)).join("") + "   (adopted requests / work on dead capabilities)");
for (let s = 2; s <= 6; s++)
  console.log(`${s}`.padEnd(21) + RUNS.map((d) => {
    const Y = capabilitiesOf(d, s);
    const b = d.requests.filter((r) => r.done !== null && r.done - r.quarter <= s).length;
    return `${b} / ${Y.filter((y) => y.adopters === 0).reduce((t, y) => t + y.work, 0)}`
      .padStart(24);
  }).join(""));
```

```
regime                   capabilities  duplicated build  dead capabilities  work on dead capabilities  team-capability pairs  per capability
on-demand                          20               132                  4                         88                     16            0.80
product-managed                    14                 0                  1                         24                     20            1.43
product, no discovery              14                 0                  0                          0                     24            1.71
on-demand: capabilities never used — image scan gate (18 person-days, 1 team wanted it); privilege template (30 person-days, 2 teams wanted it); secret distribution (22 person-days, 2 teams wanted it); pipeline template (18 person-days, 3 teams wanted it)
product-managed: capabilities never used — workload template (24 person-days, 1 team wanted it)

cost of the product regime — platform-side discovery 108 person-days (2.7 person-quarters); product-team consultation 48 + feedback 80 = 128 person-days (3.2 person-quarters)
platform's gain in build work: 416 - 284 = 132 person-days; gap written to product teams 128 person-days (97% of the gain)

patience (quarters)                 on-demand         product-managed   product, no discovery   (adopted requests / work on dead capabilities)
2                                     7 / 254                 15 / 40                  21 / 0
3                                    13 / 154                 18 / 24                  22 / 0
4                                     16 / 88                 20 / 24                  24 / 0
5                                     19 / 22                 21 / 24                  24 / 0
6                                      20 / 0                 22 / 24                  24 / 0
```

The adoption column carries the real difference. In
the on-demand regime, a capability is used by **0.80 teams** on average — below one, because four
of the capabilities are never used at all. The same figure is **1.43** in the product regime and
**1.71** in the discovery-free run. Twenty capabilities produce sixteen team-capability pairs,
while fourteen capabilities produce twenty: **fewer capabilities, more use.**

The row for capabilities never used tells a different story in each regime. In the on-demand
regime, four capabilities are dead and **88 person-days** went into them; one of those, `pipeline
template`, is a need three teams asked for — it mattered when the request was made and arrived
too late by the time it was fulfilled. In the product regime there is exactly one dead
capability: **`workload template`, 24 person-days, a need only one team asked for.** The source
of the dead work is opposite in each case: in one it is **the length of the queue**, in the other
it is **prioritization itself**.

The cost line writes out the product approach's bill. The work that goes to discovery on the
platform side is **108 person-days**, that is 2.7 person-quarters. The gap opened in product
teams is **128 person-days**, 3.2 person-quarters. The platform gains 132 person-days in build
work, and the gap written to product teams is **97%** of that. Almost everything the platform's
own ledger shows as a gain is paid back in a ledger nobody sees.

The last scan varies the patience window. At a two-quarter patience, the on-demand regime
fulfills only seven of the twenty-four requests on time and **254 person-days** go to dead work;
the same numbers in the product regime are 15 and 40. When the window opens to six quarters, the
on-demand regime's dead work drops to **zero**, while the product regime's **stays at 24
person-days**. For patient teams, waiting in the queue pays off; a need the prioritization pushes
to the back does not arrive just because someone waits.

#### Summary

- The same 24 requests (14 distinct needs, 5 teams, 16 quarters, 26 person-days of capacity per
  quarter) were run under two regimes: on-demand built 20 capabilities for 416 person-days,
  product-managed built 14 capabilities for 284 person-days.
- Duplicated build work in the on-demand regime is 132 person-days; four requests are never
  fulfilled. The product regime fulfills all twenty-four requests.
- Average wait is 3.2 quarters on-demand, 2.3 quarters product-managed, and 0.7 quarters in the
  discovery-free product run; the 1.6-quarter gap between them is the price of discovery work.
- Adoption per capability rises from 0.80 to 1.43; capabilities never used drop from four (88
  person-days) to one (`workload template`, 24 person-days). The source of dead work is queue
  length in one case, priority in the other.
- `billing`'s wait rises from 3.2 to 4.4 quarters in the product regime and the requests it
  adopts fall from four to three: a need only one team asks for falls to the back of the queue.
- The product approach costs 108 person-days on the platform side and 128 person-days on the
  product-team side; the second figure equals 97% of the 132 person-days the platform gains in
  build work.

#### Next Step

Both regimes answer the same question: what should the platform do. The product regime tied its
answer to the breadth of the need and pushed the minority's need to the back; the on-demand
regime kept the order and paid 132 person-days of duplicated build work. What neither one says is
the **boundary**: what the platform covers and does not cover was never written down anywhere.
The next lesson defines that boundary — a path is written as a scope, applied to the five teams'
requirements, the number of teams that fit and what the ones who step outside lose item by item
are counted, and then what expanding the path does to maintenance load is scanned.

### Golden Paths

Source: https://academia.sh/en/courses/platform-engineering/golden-paths

Measuring the recommended and supported path as a scope: a golden path is defined along the axes of language, data store, and deployment format, applied to five teams' requirements, the support a team loses by stepping outside is counted item by item, and as the scope expands, the number of teams that fit, the maintenance load, and the combination count are scanned together.

The previous lesson compared two operating models and showed that neither one says the same
thing: the platform's **boundary**. The on-demand regime tracked order, the product regime
tracked the breadth of the need; neither gave a written answer to the question "is this request
in scope."

The **golden path** is that written answer: the scope of the path the platform recommends and
supports. A path is not a statement of good intentions, it is a **scope definition** — which
language, which data store, which deployment format. The moment the scope is written, it splits
everything in two: what stays inside gets support, what stays outside does not. This lesson
writes the path as a scope, applies it to five teams, counts item by item what those left outside
lose, and then expands the path. The through-line is the regional measurement network and its
five teams; both the network and the teams are fictional.

#### Writing the Path as a Scope

**PA13 — a golden path is defined as a scope along three axes: language, data store, deployment
format. The path has ten support items, and every item is tied to a single axis.** Every item
carries two numbers: the person-days a team gains yearly from that item, and the maintenance the
platform pays yearly for each option added to that axis. The reason: support is not a single
whole; a team can get part of the path and not the rest, and that can only be measured once items
are tied to axes.

**PA14 — the five teams' requirements are written along three axes; a team fits the path fully
only if it is inside the scope on all three axes.** **PA15 — a team stepping outside an axis
loses every support item tied to it, and keeps using the items of the axes it stays inside on.**
The reason: stepping outside the path is not all-or-nothing; partial benefit is also a condition
that must be measured.

```js
// path.mjs — a golden path defined as a scope and applied to five teams (model)
import { writeFileSync } from "node:fs";

// PA13 — a golden path is defined as a scope along three axes: language, data store, deployment format.
// The path has ten support items; each item is tied to a single axis. Value is the person-days a team
// gains yearly from that item; maintenance is what the platform pays yearly for each option added to that axis.
const ITEM = [
  ["scaffolding", "language", 6, 4], ["build pipeline", "language", 8, 5],
  ["dependency scanning", "language", 5, 3], ["test run", "language", 4, 3],
  ["schema migration", "store", 7, 5], ["backup and restore", "store", 9, 6],
  ["connection pool", "store", 4, 3], ["deployment step", "deployment", 10, 6],
  ["rollback step", "deployment", 6, 4], ["dashboard wiring", "deployment", 5, 3],
].map(([name, axis, value, maintenance]) => ({ name, axis, value, maintenance }));
const AXIS = ["language", "store", "deployment"];

// PA14 — the five teams' requirements are written along three axes; a team fits the path
// only if it is inside the scope on all three axes. Teams and requirements are fictional.
const TEAM = [
  ["meter reading", "language A", "time-series", "edge unit"],
  ["validation", "language A", "relational", "batch job"],
  ["billing", "language B", "relational", "long-lived service"],
  ["field", "language C", "document", "edge unit"],
  ["reporting", "language A", "relational", "long-lived service"],
].map(([name, language, store, deployment]) => ({ name, want: { language, store, deployment } }));

// PA15 — a team that steps outside loses every support item tied to the axis it is outside
// on, and keeps using the items of the axes it stays inside on.
const PATH0 = { language: ["language A"], store: ["relational"], deployment: ["long-lived service"] };
const outside = (path, e) => AXIS.filter((x) => !path[x].includes(e.want[x]));
const lost = (path, e) => ITEM.filter((k) => outside(path, e).includes(k.axis));
const gain = (path, e) => ITEM.filter((k) => !outside(path, e).includes(k.axis))
  .reduce((t, k) => t + k.value, 0);
writeFileSync("path.json", JSON.stringify({ ITEM, AXIS, TEAM, PATH0 }));

const axisTotal = (x) => ITEM.filter((k) => k.axis === x).reduce((t, k) => t + k.value, 0);
console.log("axis".padEnd(12) + "items".padStart(6) + "total value".padStart(14) +
  "maintenance/option".padStart(22) + "   items");
for (const x of AXIS) {
  const items = ITEM.filter((k) => k.axis === x);
  console.log(x.padEnd(12) + `${items.length}`.padStart(6) + `${axisTotal(x)}`.padStart(14) +
    `${items.reduce((t, k) => t + k.maintenance, 0)}`.padStart(22) + "   " + items.map((k) => k.name).join(", "));
}
console.log(`yearly gain for a team that fits the path fully: ` +
  `${ITEM.reduce((t, k) => t + k.value, 0)} person-days`);

const FULL = ITEM.reduce((t, k) => t + k.value, 0);
console.log("\npath: language A / relational / long-lived service");
console.log("team".padEnd(15) + "axes fit".padStart(12) + "gain".padStart(8) +
  "path share".padStart(12) + "lost items".padStart(13) + "lost value".padStart(13) +
  "   axes left outside");
for (const e of TEAM) {
  const d = outside(PATH0, e), L = lost(PATH0, e);
  console.log(e.name.padEnd(15) + `${AXIS.length - d.length}/3`.padStart(12) +
    `${gain(PATH0, e)}`.padStart(8) +
    `${(100 * gain(PATH0, e) / FULL).toFixed(0)}%`.padStart(12) + `${L.length}`.padStart(13) +
    `${L.reduce((t, k) => t + k.value, 0)}`.padStart(13) +
    "   " + (d.length ? d.map((x) => `${x} (${e.want[x]})`).join(", ") : "-"));
}
console.log("teams left outside by axis — " +
  AXIS.map((x) => `${x}: ${TEAM.filter((e) => outside(PATH0, e).includes(x)).length}`).join(", "));

console.log("\nsupport lost item by item for those who step outside:");
for (const e of TEAM.filter((e) => outside(PATH0, e).length))
  console.log(`  ${e.name}: ` + lost(PATH0, e).map((k) => `${k.name} (${k.value})`).join(", "));
```

```
axis         items   total value    maintenance/option   items
language         4            23                    15   scaffolding, build pipeline, dependency scanning, test run
store            3            20                    14   schema migration, backup and restore, connection pool
deployment       3            21                    13   deployment step, rollback step, dashboard wiring
yearly gain for a team that fits the path fully: 64 person-days

path: language A / relational / long-lived service
team               axes fit    gain  path share   lost items   lost value   axes left outside
meter reading           1/3      23         36%            6           41   store (time-series), deployment (edge unit)
validation              2/3      43         67%            3           21   deployment (batch job)
billing                 2/3      41         64%            4           23   language (language B)
field                   0/3       0          0%           10           64   language (language C), store (document), deployment (edge unit)
reporting               3/3      64        100%            0            0   -
teams left outside by axis — language: 2, store: 2, deployment: 3

support lost item by item for those who step outside:
  meter reading: schema migration (7), backup and restore (9), connection pool (4), deployment step (10), rollback step (6), dashboard wiring (5)
  validation: deployment step (10), rollback step (6), dashboard wiring (5)
  billing: scaffolding (6), build pipeline (8), dependency scanning (5), test run (4)
  field: scaffolding (6), build pipeline (8), dependency scanning (5), test run (4), schema migration (7), backup and restore (9), connection pool (4), deployment step (10), rollback step (6), dashboard wiring (5)
```

#### Who Is Left Outside

A team that fits the path fully gains **64 person-days** a year. On the baseline path — language
A, relational store, long-lived service — **only one of the five teams** collects that gain:
`reporting`. The other four are outside on seven team-axis pairs combined.

Being outside has degrees, and the table separates them. `validation` is inside on two of the
three axes: it gets language and store support and loses only the deployment items — deployment
step (10), rollback step (6), dashboard wiring (5), **21 person-days** in total. `billing` is
also 2/3, but the axis it loses is language: four items, **23 person-days**. `meter reading` is
outside on two axes and loses six items, **41 person-days** — since language is the only axis it
stays inside on, its gain stays at 23.

`field` is inside on zero axes. It loses **all** ten support items and gets **nothing** from the
path. What this distinction is worth deciding: for `validation`, stepping outside the path is a
21-person-day shortfall; for `field`, the path existing and the path not existing are the same
thing. **The same path, in the same organization, looks like a small shortfall to one team and
total absence to another.**

Partially fitting teams are what this course calls a **leaky abstraction**. `meter reading` is
inside the platform — it gets scaffolding, the build pipeline, dependency scanning, and the test
run from the platform — but builds its own schema migration, backup, connection pool,
deployment, rollback, and dashboard wiring. The team has to know both worlds at once; the
simplification the path gives it stays at **36%**. It is 67% for `validation`, 64% for `billing`.
The path's average effect across these five teams is 53%, though the description "every team but
one gets partial support" makes it sound far better than it is.

The last line says which axis to expand: two teams are outside on the language axis, two on the
store axis, and **three** on the deployment axis. The scope decision starts with the axis that
strikes the most teams — in the table, that axis is the deployment format.

#### Expanding the Path

**PA16 — a path's maintenance load is each support item's maintenance multiplied by the number of
options on the axis it is tied to**; every option added to scope brings the maintenance of that
axis's entire item set back once more. **PA17 — a path's own complexity is the number of
combinations that must be tested (language × store × deployment), and every combination costs 3
person-days a year.** The reason: as scope expands, items do not just multiply, they collide with
each other; a language working with one store does not mean it will work with another. **PA18 —
expansion adds one option to one axis at each step.**

```js
// expand.mjs — the path's scope is expanded step by step
import { readFileSync } from "node:fs";
const { ITEM, AXIS, TEAM, PATH0 } = JSON.parse(readFileSync("path.json", "utf8"));

// PA16 — the path's maintenance load is each support item's maintenance multiplied by the number
// of options on the axis it is tied to: every option added to scope brings that axis's full set
// of items' maintenance back once more. PA17 — the path's own complexity is the number of
// combinations that must be tested (language x store x deployment), and every combination costs
// 3 person-days a year. PA18 — expansion adds one option to one axis at each step.
const COMBINATION_COST = 3;
const STEP = [
  ["deployment", "batch job"], ["language", "language B"], ["store", "time-series"],
  ["deployment", "edge unit"], ["language", "language C"], ["store", "document"],
];
const outside = (path, e) => AXIS.filter((x) => !path[x].includes(e.want[x]));
const measure = (path) => {
  const gain = TEAM.reduce((t, e) => t + ITEM
    .filter((k) => !outside(path, e).includes(k.axis)).reduce((a, k) => a + k.value, 0), 0);
  const maintenance = ITEM.reduce((t, k) => t + k.maintenance * path[k.axis].length, 0);
  const combinations = AXIS.reduce((t, x) => t * path[x].length, 1);
  return { gain, maintenance, combinations, load: maintenance + combinations * COMBINATION_COST,
    fits: TEAM.filter((e) => outside(path, e).length === 0).length,
    stepOuts: TEAM.reduce((t, e) => t + outside(path, e).length, 0) };
};

const path = { language: [...PATH0.language], store: [...PATH0.store], deployment: [...PATH0.deployment] };
const STAGE = [{ name: "baseline", ...measure(path), scope: "1/1/1" }];
for (const [axis, option] of STEP) {
  path[axis] = [...path[axis], option];
  STAGE.push({ name: `+${option}`, ...measure(path),
    scope: AXIS.map((x) => path[x].length).join("/") });
}
console.log("stage".padEnd(16) + "scope".padStart(8) + "fitting teams".padStart(14) +
  "step-outs".padStart(13) + "gain".padStart(8) + "maintenance".padStart(13) +
  "combos".padStart(9) + "combo load".padStart(14) + "total load".padStart(12) +
  "net".padStart(6));
for (const s of STAGE)
  console.log(s.name.padEnd(16) + s.scope.padStart(8) + `${s.fits}/5`.padStart(14) +
    `${s.stepOuts}`.padStart(13) + `${s.gain}`.padStart(8) + `${s.maintenance}`.padStart(13) +
    `${s.combinations}`.padStart(9) + `${s.combinations * COMBINATION_COST}`.padStart(14) +
    `${s.load}`.padStart(12) + `${s.gain - s.load}`.padStart(6));

const best = STAGE.reduce((a, b) => (a.gain - a.load >= b.gain - b.load ? a : b));
const last = STAGE[STAGE.length - 1], first = STAGE[0];
console.log(`\nhighest net: "${best.name}" stage, scope ${best.scope}, ` +
  `${best.fits}/5 teams fit, net ${best.gain - best.load} person-days`);
console.log(`stage where all five teams fit (${last.scope}): net ${last.gain - last.load} — ` +
  `${first.gain - first.load - last.gain + last.load} person-days below the baseline ` +
  `(${first.scope}, ${first.fits}/5 teams)`);
console.log(`going from a one-option scope to a three-option scope, gain rose ` +
  `${(100 * (last.gain / first.gain - 1)).toFixed(0)}%, load rose ` +
  `${(100 * (last.load / first.load - 1)).toFixed(0)}%`);
```

```
stage              scope fitting teams    step-outs    gain  maintenance   combos    combo load  total load   net
baseline           1/1/1           1/5            7     171           42        1             3          45   126
+batch job         1/1/2           2/5            6     192           55        2             6          61   131
+language B        2/1/2           3/5            5     215           70        4            12          82   133
+time-series       2/2/2           3/5            4     235           84        8            24         108   127
+edge unit         2/2/3           4/5            2     277           97       12            36         133   144
+language C        3/2/3           4/5            1     300          112       18            54         166   134
+document          3/3/3           5/5            0     320          126       27            81         207   113

highest net: "+edge unit" stage, scope 2/2/3, 4/5 teams fit, net 144 person-days
stage where all five teams fit (3/3/3): net 113 — 13 person-days below the baseline (1/1/1, 1/5 teams)
going from a one-option scope to a three-option scope, gain rose 87%, load rose 360%
```

The scan starts in the expected direction. Once batch job is added
to the deployment axis, `validation` fits the path and net rises from 126 to 131; once language B
is added, `billing` fits and net reaches 133. The break appears at the third step: the
time-series store is added to scope, gain rises from 215 to 235, but **the number of fitting
teams does not change** — `meter reading` is still waiting on the edge unit — and net drops from
133 to 127. Scope has grown, and no one has entered the path.

The fourth step is the high point: once the edge unit is added, `meter reading` fits the path and
`field` gains the deployment items, and net reaches **144 person-days**. The scope is 2/2/3, and
four of the five teams fit. Everything past this loses ground: adding language C brings net down
to 134, and adding the document store brings it down to **113**.

The last line is the lesson's verdict. **The path that fits all five teams (3/3/3) is 13
person-days worse than the path that was never expanded at all** — even though only one of the
five teams fit the baseline path. As scope goes from a one-option axis to a three-option axis,
gain rose **87%**, load rose **360%**. The number of combinations that must be tested went from
one to twenty-seven; the path's own complexity overtook the complexity it was meant to remove. A
path that covers everyone is no longer a path.

#### Summary

- The golden path was written as a scope along three axes (language, data store, deployment
  format) and ten support items were tied to those axes; a team that fits the path fully gains
  64 person-days a year.
- On the baseline path, only `reporting` fits fully among the five teams; the other four are
  outside on seven team-axis pairs combined.
- The degree of being outside was measured: `validation` loses 21 person-days, `billing` 23,
  `meter reading` 41; `field` loses all ten items and gets nothing from the path.
- A partially fitting team is where the abstraction leaks: `meter reading` gets only 36% of the
  path's simplification, builds the rest itself, and has to know both worlds at once.
- As scope expands, net follows 126, 131, 133, 127, 144, 134, and 113 person-days; the high point
  is at a 2/2/3 scope, the stage where four of the five teams fit.
- The path that fits all five teams is 13 person-days worse than the baseline path; as scope grew
  to three options, gain rose 87% and load rose 360%, and the number of combinations to test went
  from one to twenty-seven.

#### Next Step

In this lesson the path was written as a scope, and both sides of that scope were counted: the
gain for those who stay inside, the loss for those left outside. Something the path's definition
never said was this — **was entering the path mandatory?** Throughout the table, teams'
requirements were held fixed, and the path fit them only as much as it fit. If mandate had been
imposed, would `field` have given up the edge unit, or would the path have blocked a legitimate
requirement of theirs? The next lesson tries three modes for every item — mandatory, default but
changeable, free — and counts diversity and alignment together: how many distinct choices emerge,
in how many items the shared tool becomes useless, and in how many teams mandate blocks a genuine
requirement.

### Standardization and Autonomy Balance

Source: https://academia.sh/en/courses/platform-engineering/standardization-and-autonomy-balance

Measuring mandate and freedom of choice together: each of ten items is run in mandatory, default, and free modes, the number of distinct choices born and the number of items where the shared tool still works are counted side by side, how many teams' legitimate requirements mandate blocks is derived, and the gap between guardrail and wall is shown in person-days.

The previous lesson wrote the golden path as a scope and counted both sides of it: the gain for
those who stay inside, the loss for those left outside. Throughout the table, teams'
requirements were held fixed, and the path fit them only as much as it fit. The question that
went unasked was this: **was entering the path mandatory?**

Mandate and freedom of choice are two ends of the same axis, and each has its own measure.
Mandate produces **alignment**: if an item has only one choice, a single shared tool is enough to
cover it. Freedom produces **diversity**: every team picks what fits its own work, but the shared
tool becomes useless as the option count grows. This lesson counts both in the same table and
separates two distinct forms of mandate. The through-line is the regional measurement network
and its five teams; both the network and the teams are fictional.

#### Three Modes and Ten Items

**PA19 — each of the ten items is run in one of three modes: mandatory, default but changeable,
free.** Every item has a **shared-tool value**: the person-days gained yearly if that item stays
at a single option across the whole organization. **PA20 — every team's per-item preference is
written down along with the reason for it; the reason is either a legitimate requirement the work
calls for, or the team's habit.** The reason: mandate blocks both, but they do not cost the same,
and that cannot be measured without separating them.

**PA21 — the number of distinct choices born in an item is diversity; the shared tool gives its
full value if the choice count is one, half if it is two, and nothing at three or above.** In
free mode, the platform proposes no option at all, and teams with no preference build their own
choice too. The reason: with no default option, teams have no reason to converge on the same
point.

**PA22 — mandate is run in two forms. Wall allows no exception at all; guardrail grants an
exception for a legitimate reason, and not for habit.** The reason: in practice "mandatory" is
not a single thing, and the difference between the two only shows up in the count of blocked
legitimate requirements.

```js
// mode.mjs — every item is run in three modes: mandatory, default, free (model)
import { writeFileSync } from "node:fs";

// PA19 — each of the ten items is run in one of three modes. Every item has a "shared-tool
// value": the person-days gained yearly across the whole organization if that item stays at a
// single option. PA20 — every team's per-item preference is written as a string: "-" accepts
// the default, "2m" wants option two for a legitimate requirement, "3a" wants option three
// out of habit. Team order: meter reading, validation, billing, field, reporting. Fictional.
const TEAM = ["meter reading", "validation", "billing", "field", "reporting"];
const ITEM = [
  ["log format", 14, "- - - - -"],
  ["dashboard naming", 12, "- - 2a - -"],
  ["base image", 11, "- - - 2m -"],
  ["secret store", 10, "2m - - - -"],
  ["deployment step order", 8, "- 2a - 3m -"],
  ["test threshold", 7, "- 2m - - 3a"],
  ["language version", 6, "- - 2m - -"],
  ["network rule format", 5, "2m - - 2m -"],
  ["data store version", 4, "2m 3a - 4m -"],
  ["backup interval", 3, "- - 2m 3a 4a"],
].map(([name, shared, row]) => ({ name, shared, preferences: row.split(" ").map((s) =>
  s === "-" ? null : { option: parseInt(s, 10), legitimate: s.endsWith("m") }) }));

// PA21 — the number of distinct choices born in an item is diversity; the shared tool gives its
// full value if the choice count is 1, half (rounded down) if it is 2, and nothing at 3 or above.
// In free mode the platform proposes no option at all; every team with no preference builds its
// own choice too. The reason: with no default option, teams have no reason to converge on the same point.
const MODE = {
  wall: (t) => 1,
  guardrail: (t) => (t && t.legitimate ? t.option : 1),
  default: (t) => (t ? t.option : 1),
  free: (t, i) => (t ? t.option : 100 + i),
};
const selections = (k, mode) => k.preferences.map((t, i) => MODE[mode](t, i));
const diversity = (k, mode) => new Set(selections(k, mode)).size;
const alignment = (k, mode) => { const c = diversity(k, mode);
  return c === 1 ? k.shared : c === 2 ? Math.floor(k.shared / 2) : 0; };
const blocked = (k, mode, legitimate) => k.preferences.filter((t, i) =>
  t && t.legitimate === legitimate && MODE[mode](t, i) !== t.option).length;
writeFileSync("mode.json", JSON.stringify({ TEAM, ITEM }));

const MODES = ["wall", "guardrail", "default", "free"];
console.log("item".padEnd(23) + "shared".padStart(7) +
  MODES.map((x) => `${x} d/a`.padStart(16)).join("") + "blocked l/h".padStart(12));
for (const k of ITEM)
  console.log(k.name.padEnd(23) + `${k.shared}`.padStart(7) +
    MODES.map((x) => `${diversity(k, x)} / ${alignment(k, x)}`.padStart(16)).join("") +
    `${blocked(k, "wall", true)} / ${blocked(k, "wall", false)}`.padStart(12));
const T = (f) => ITEM.reduce((t, k) => t + f(k), 0);
console.log("TOTAL".padEnd(23) + `${T((k) => k.shared)}`.padStart(7) +
  MODES.map((x) => `${T((k) => diversity(k, x))} / ${T((k) => alignment(k, x))}`.padStart(16)).join("") +
  `${T((k) => blocked(k, "wall", true))} / ${T((k) => blocked(k, "wall", false))}`.padStart(12));
console.log("\nd = number of distinct choices born in the item, a = person-days the shared tool gives");
console.log("blocked l/h = legitimate requirements / habits blocked in wall mode");
```

```
item                    shared        wall d/a   guardrail d/a     default d/a        free d/a blocked l/h
log format                  14          1 / 14          1 / 14          1 / 14           5 / 0       0 / 0
dashboard naming            12          1 / 12          1 / 12           2 / 6           5 / 0       0 / 1
base image                  11          1 / 11           2 / 5           2 / 5           5 / 0       1 / 0
secret store                10          1 / 10           2 / 5           2 / 5           5 / 0       1 / 0
deployment step order        8           1 / 8           2 / 4           3 / 0           5 / 0       1 / 1
test threshold               7           1 / 7           2 / 3           3 / 0           5 / 0       1 / 1
language version             6           1 / 6           2 / 3           2 / 3           5 / 0       1 / 0
network rule format          5           1 / 5           2 / 2           2 / 2           4 / 0       2 / 0
data store version           4           1 / 4           3 / 0           4 / 0           5 / 0       2 / 1
backup interval              3           1 / 3           2 / 1           4 / 0           5 / 0       1 / 2
TOTAL                       80         10 / 80         19 / 49         25 / 35          49 / 0      10 / 6

d = number of distinct choices born in the item, a = person-days the shared tool gives
blocked l/h = legitimate requirements / habits blocked in wall mode
```

#### Diversity and Alignment

The totals row shows all four modes at a glance. In wall mode, diversity is **10** — ten items,
ten choices — and the shared-tool value is captured **in full**: 80 person-days. In free mode,
diversity rises to **49** and the shared tool gives **none of the 80 person-days**. The two modes
in between sit at diversity 19 and 25, worth 49 and 35 person-days respectively.

The numbers reveal a rule independent of any mode's name: when diversity doubles, the shared
tool's value does not halve, it falls far faster. In the `data store version` item, default mode
produces four distinct choices and the four-person-day shared-tool value goes to zero; the same
happens in `backup interval`. In free mode, every item except `network rule format` produces five
distinct choices, because teams with no preference go their own way too.

The last column separates what mandate blocks. Wall mode blocks **ten legitimate requirements**
and **six habits**. Those ten requirements are spread across four teams and are all choices the
work requires: `field`'s base image, `meter reading`'s secret store and network rule format,
`billing`'s language version. When mandate blocks these, the team does not go without the
work — **it builds a workaround**, and the cost of that workaround is in the next table.

#### Guardrail or Wall

**PA23 — the costs: every blocked legitimate requirement is 8 person-days, every blocked habit is
1 person-day, and every option after the first on an item is 4 person-days of shared
maintenance.** The reason: blocking a habit creates friction, blocking a legitimate requirement
creates work; the two cannot be written with the same number. **PA24 — a fifth regime, mixed,
computes the net value of all four modes separately for every item and picks the highest; ties
favor the freer mode.**

```js
// regimes.mjs — five regimes, the gap between guardrail and wall, and the best mode per item
import { readFileSync } from "node:fs";
const { ITEM } = JSON.parse(readFileSync("mode.json", "utf8"));

// PA23 — costs: every blocked legitimate requirement is 8 person-days (the team builds a workaround),
// every blocked habit is 1 person-day (friction), and every option after the first on an item
// is 4 person-days of shared maintenance. PA24 — a fifth regime, mixed, computes all four modes'
// net value separately for each item and picks the highest; ties favor the freer mode.
const LEGITIMATE = 8, HABIT = 1, OPTION = 4;
const MODE = { wall: (t) => 1, guardrail: (t) => (t && t.legitimate ? t.option : 1),
  default: (t) => (t ? t.option : 1), free: (t, i) => (t ? t.option : 100 + i) };
const MODES = ["wall", "guardrail", "default", "free"];
const diversity = (k, x) => new Set(k.preferences.map((t, i) => MODE[x](t, i))).size;
const alignment = (k, x) => { const c = diversity(k, x);
  return c === 1 ? k.shared : c === 2 ? Math.floor(k.shared / 2) : 0; };
const blocked = (k, x, m) => k.preferences.filter((t, i) =>
  t && t.legitimate === m && MODE[x](t, i) !== t.option).length;
const net = (k, x) => alignment(k, x) - LEGITIMATE * blocked(k, x, true) -
  HABIT * blocked(k, x, false) - OPTION * (diversity(k, x) - 1);

const best = ITEM.map((k) => {
  const s = MODES.map((x) => ({ x, n: net(k, x) }));
  const top = Math.max(...s.map((y) => y.n));
  return { ...k, mode: s.filter((y) => y.n === top).pop().x, n: top };
});
const REGIME = [...MODES.map((x) => ({ name: x, mode: () => x })),
  { name: "mixed", mode: (k) => best.find((y) => y.name === k.name).mode }];

const T = (f) => ITEM.reduce((t, k) => t + f(k), 0);
console.log("regime".padEnd(12) + "diversity".padStart(11) + "shared tool".padStart(12) +
  "useless".padStart(13) + "alignment".padStart(11) + "blocked legit.".padStart(16) +
  "blocked habit".padStart(15) + "option maint.".padStart(16) + "net".padStart(6));
for (const d of REGIME) {
  const c = (k) => diversity(k, d.mode(k));
  console.log(d.name.padEnd(12) + `${T(c)}`.padStart(11) +
    `${ITEM.filter((k) => c(k) <= 2).length}/10`.padStart(12) +
    `${ITEM.filter((k) => c(k) >= 3).length}`.padStart(13) +
    `${T((k) => alignment(k, d.mode(k)))}`.padStart(11) +
    `${T((k) => blocked(k, d.mode(k), true))}`.padStart(16) +
    `${T((k) => blocked(k, d.mode(k), false))}`.padStart(15) +
    `${OPTION * (T(c) - ITEM.length)}`.padStart(16) +
    `${T((k) => net(k, d.mode(k)))}`.padStart(6));
}

const diff = T((k) => alignment(k, "wall")) - T((k) => alignment(k, "guardrail"));
console.log(`\ngap between guardrail and wall: wall blocks 10 legitimate requirements and raises ` +
  `the shared-tool value by ${diff} person-days — ${(diff / 10).toFixed(1)} person-days per ` +
  `blocked requirement, while blocking one requirement costs ${LEGITIMATE} person-days`);
console.log("best mode per item — " + MODES
  .map((x) => `${x}: ${best.filter((y) => y.mode === x).length}`).join(", "));
for (const x of MODES) {
  const g = best.filter((y) => y.mode === x);
  if (g.length) console.log(`  ${x}: ` + g.map((y) => `${y.name} (${y.n})`).join(", "));
}
```

```
regime        diversity shared tool      useless  alignment  blocked legit.  blocked habit   option maint.   net
wall                 10       10/10            0         80              10              6               0    -6
guardrail            19        9/10            1         49               0              6              36     7
default              25        6/10            4         35               0              0              60   -25
free                 49        0/10           10          0               0              0             156  -156
mixed                17        9/10            1         60               2              6              28    10

gap between guardrail and wall: wall blocks 10 legitimate requirements and raises the shared-tool value by 31 person-days — 3.1 person-days per blocked requirement, while blocking one requirement costs 8 person-days
best mode per item — wall: 2, guardrail: 5, default: 3, free: 0
  wall: base image (3), secret store (2)
  guardrail: dashboard naming (11), deployment step order (-1), test threshold (-2), data store version (-9), backup interval (-5)
  default: log format (14), language version (-1), network rule format (-2)
```

Every uniform regime either loses money or barely gains any. Wall gives **−6**, default **−25**,
free **−156** person-days; the only uniform regime with a positive number is guardrail (**+7**).
The free regime's numbers leave no room for argument: the shared tool becomes useless in **all**
ten items, and 156 person-days go to option maintenance alone.

The result for default mode is more surprising. It blocks no legitimate requirement and no
habit — and yet it is **19 person-days worse than wall**. The reason is in the two middle
columns: it drives diversity up to 25, ends the shared tool in four items, and costs 60
person-days of option maintenance. **Blocking nothing is not free.**

The gap between guardrail and wall is counted in a single line. By blocking the ten legitimate
requirements guardrail allows, wall raises the shared-tool value from 49 to 80, a gain of **31
person-days**. That is **3.1 person-days** per blocked requirement, while the cost of blocking
one legitimate requirement is 8 person-days. **Wall pays more than two and a half times what it
takes in.** The number of habits the two modes block is the same (6): guardrail grants no
exception to habit either. Guardrail's only difference from wall is in legitimate requirements,
and in exchange it pays the cost (36 person-days) of letting diversity climb from 10 to 19.

The mixed regime decides item by item and comes out highest at **+10**. The split is: wall in two
items, guardrail in five, default in three, **free in none**. The two items wall wins are `base
image` (11) and `secret store` (10) — two of the items with the highest shared-tool value. In
these two, mandate **is worth blocking a legitimate requirement for**, because the shared tool's
full value exceeds the cost of blocking. It is not worth it in the remaining eight. Choosing a
mode is not a management preference, it is a calculation between an item's shared-tool value and
the number of requirements it blocks.

#### Summary

- Ten items were run in four modes: diversity is 10 in wall, 19 in guardrail, 25 in default, 49
  in free; the value the shared tool gives is 80, 49, 35, and 0 person-days respectively.
- In free mode the shared tool becomes useless in all ten items; in default mode, in four items;
  in guardrail, in one; in wall, in none.
- Wall blocks ten legitimate requirements and six habits; guardrail blocks the same six habits
  but blocks none of the legitimate requirements.
- The gap wall takes from guardrail is 31 additional person-days of shared-tool value, 3.1
  person-days per blocked requirement — below the 8-person-day cost of blocking one requirement.
- Net across uniform regimes: wall −6, guardrail +7, default −25, free −156 person-days. Default,
  which blocks nothing, is 19 person-days worse than wall.
- Choosing the best mode per item yields a net of +10: wall in two items, guardrail in five,
  default in three, free in none.

#### Next Step

Across four lessons, the platform was measured as a cost calculation, an operating model, a
scope, and a mode choice. All four shared one assumption: when a team wants something, **someone
on the platform team does it**. Changing a default option in the mode table, entering the path in
the scope table, getting a capability in the request table — all of it passed through a person's
hands. The next lesson removes that assumption: the same request set is run in a request queue
and in a self-service regime; wait time, the platform team's interruptions, and self-service's
own cost — the work of writing the interface, misuse, and irreversible operations — are counted.

### Self-Service

Source: https://academia.sh/en/courses/platform-engineering/self-service

Measuring what removing the request queue is worth: the same request set is run through a request queue and through a self-service regime, wait time and the platform team's interruptions are counted, the costs of writing the interface, misuse, and irreversible operations are derived, and the request type where automation does not pay off is named.

Across four lessons, the platform was measured as a cost calculation, an operating model, a
scope, and a mode choice. All four shared one assumption: when a team wants something, **someone
on the platform team does it**. Changing a default option, entering the path, getting a
capability — all of it passed through a person's hands. That person stands behind a queue.

This lesson measures the queue and tries to remove it. Self-service takes the request out of the
platform team's hands and puts it in the hands of the team that opens it. What it gains is wait
time and interruptions; what it costs is the work of writing the interface, the interface's
maintenance, misuse, and **irreversible operations**. The numbers also show where automation does
not pay off. The through-line is the regional measurement network and its five teams; both the
network and the teams are fictional.

#### The Request Queue

**PA25 — there are twelve request types; each is written with how many times a year it arrives,
how many hours a manual fulfillment takes, and its variability.** If variability is zero, the
request is identical every time; near one, it is different every time. The reason: whether a
request can be automated does not come from its frequency, it comes from how much it changes from
one occurrence to the next; the threshold cannot be derived without separating the two numbers.

**PA26 — in the request queue, a request goes to the platform team; the team sets aside 4 hours a
day for requests and works through them in order. Every request adds 0.25 hours of interruption
on top of its manual work.** The reason: the cost of manual work is not just the time the work
itself takes; the team steps out of another task, handles the request, and has to rebuild the
context of the task it returns to. The window is three years, a year is 250 workdays, a workday
is 8 hours.

```js
// queue.mjs — the same request set is run through a request queue (model)
import { writeFileSync } from "node:fs";

// PA25 — twelve request types: name, how many times a year it arrives, the hours a manual fulfillment
// takes, and variability (0 = identical every time, 1 = different every time). Numbers are fictional.
const TYPE = [
  ["pipeline variable change", 150, 0.3, 0.05], ["privilege grant", 120, 0.4, 0.05],
  ["secret addition", 90, 0.5, 0.05], ["new environment setup", 40, 3, 0.10],
  ["dashboard panel addition", 30, 2, 0.30], ["database copy", 24, 4, 0.20],
  ["domain name binding", 20, 1.5, 0.15], ["new service scaffold", 18, 6, 0.25],
  ["audit log export", 12, 2.5, 0.40], ["restore from backup", 6, 8, 0.60],
  ["cluster version upgrade", 4, 20, 0.50], ["region migration", 3, 40, 0.90],
].map(([name, perYear, hours, variability]) => ({ name, perYear, hours, variability }));

// PA26 — request queue: a request goes to the platform team, which sets aside 4 hours a day for requests and
// works through them in order. Every request adds a 0.25-hour interruption on top of its manual work: the
// team steps out of another task and back into the request. Window is three years, a year is 250 workdays, a workday is 8 hours.
const DAILY = 4, YEAR = 250, WINDOW = 3, INTERRUPTION = 0.25, DAY_HOURS = 8;

function queue(scale) {
  const arrivals = TYPE.flatMap((t, i) => {
    const n = t.perYear * WINDOW, work = t.hours * (scale ? scale(t) : 1);
    return Array.from({ length: n }, (_, j) => ({ type: i, day: (j + 0.5) * YEAR * WINDOW / n,
      work: work + INTERRUPTION }));
  }).sort((a, b) => a.day - b.day);
  let free = 0, total = 0;
  for (const g of arrivals) {
    free = Math.max(free, g.day * DAILY) + g.work;
    g.wait = free / DAILY - g.day;
    total += g.work;
  }
  const w = arrivals.map((g) => g.wait).sort((x, y) => x - y);
  return { arrivals, count: arrivals.length, total,
    avg: w.reduce((x, y) => x + y, 0) / w.length, median: w[w.length >> 1], longest: w.at(-1),
    utilization: total / (YEAR * WINDOW * DAILY) };
}
const Q = queue(null);
writeFileSync("queue.json", JSON.stringify({ TYPE, baseline: {
  avg: Q.avg, median: Q.median, longest: Q.longest, total: Q.total, count: Q.count } }));

console.log("type".padEnd(26) + "per year".padStart(9) + "hours".padStart(6) +
  "variability".padStart(13) + "3-yr requests".padStart(14) + "manual work (days)".padStart(19) +
  "avg. wait (days)".padStart(20));
for (let i = 0; i < TYPE.length; i++) {
  const g = Q.arrivals.filter((x) => x.type === i);
  console.log(TYPE[i].name.padEnd(26) + `${TYPE[i].perYear}`.padStart(9) +
    `${TYPE[i].hours}`.padStart(6) + `${TYPE[i].variability.toFixed(2)}`.padStart(13) +
    `${g.length}`.padStart(14) +
    `${(g.length * TYPE[i].hours / DAY_HOURS).toFixed(1)}`.padStart(19) +
    `${(g.reduce((t, x) => t + x.wait, 0) / g.length).toFixed(1)}`.padStart(20));
}
const interruptionDays = Q.count * INTERRUPTION / DAY_HOURS;
console.log(`\nover three years, ${Q.count} requests, ${(Q.total / DAY_HOURS).toFixed(1)} person-days ` +
  `(of which ${interruptionDays.toFixed(1)} person-days are interruption alone), utilization ` +
  `${(100 * Q.utilization).toFixed(1)}%`);
console.log(`wait: average ${Q.avg.toFixed(1)} days, median ${Q.median.toFixed(1)} days, ` +
  `longest ${Q.longest.toFixed(1)} days`);
```

```
type                       per year hours  variability 3-yr requests manual work (days)    avg. wait (days)
pipeline variable change        150   0.3         0.05           450               16.9                 4.8
privilege grant                 120   0.4         0.05           360               18.0                 4.7
secret addition                  90   0.5         0.05           270               16.9                 4.7
new environment setup            40     3         0.10           120               45.0                 5.3
dashboard panel addition         30     2         0.30            90               22.5                 5.0
database copy                    24     4         0.20            72               36.0                 5.6
domain name binding              20   1.5         0.15            60               11.3                 4.5
new service scaffold             18     6         0.25            54               40.5                 5.7
audit log export                 12   2.5         0.40            36               11.3                 4.4
restore from backup               6     8         0.60            18               18.0                 7.4
cluster version upgrade           4    20         0.50            12               30.0                 8.4
region migration                  3    40         0.90             9               45.0                11.1

over three years, 1551 requests, 359.7 person-days (of which 48.5 person-days are interruption alone), utilization 95.9%
wait: average 5.0 days, median 4.5 days, longest 13.0 days
```

Over three years, the queue sees **1,551 requests** and spends **359.7 person-days**. Of that,
**48.5 person-days** advance no request at all: purely the cost of context switching, **13.5%**
of the work spent. Utilization is **95.9%**, and that figure explains why the wait runs this
long: average wait is **5 days**, the longest is **13 days**.

Wait does not spread evenly across types. A pipeline variable change waits 4.8 days on average, a
forty-hour region migration waits 11.1 days; small jobs wait behind large ones. The median (4.5)
sitting below the average (5.0) shows this — a handful of long jobs pull the distribution
upward.

#### The Cost of Self-Service

**PA27 — for a type, building an interface costs 4 + 20 × variability person-days, its yearly
maintenance is a fifth of that cost, and the interface covers only (1 − variability) of
requests; the rest is still done manually.** The reason: putting a request that differs every
time behind an interface only captures the part of it that does not change.

**PA28 — under self-service, 0.02 + 0.10 × variability of requests are misused; 85% of those are
reversible (0.5-person-day fix), 15% are irreversible (6-person-day recovery).** The reason:
whoever is performing the operation behind the interface is doing it for the first time, and no
one is looking over their shoulder. **PA29 — an interface for a type pays for itself if its
three-year-window total does not exceed the manual regime's total.** **PA30 — the mixed regime
writes an interface only for types that pay for themselves; the rest, plus the manual share of
what is automated, stay in the queue.**

```js
// service.mjs — the cost of self-service, per-type threshold, and the queue in the mixed regime
import { readFileSync } from "node:fs";
const { TYPE, baseline } = JSON.parse(readFileSync("queue.json", "utf8"));

// PA27 — for a type, building an interface costs 4 + 20 x variability person-days, yearly
// maintenance is a fifth of that cost, and the interface covers only (1 - variability) of
// requests; the rest is still done manually. PA28 — under self-service, 0.02 + 0.10 x
// variability of requests are misused; 85% of those are reversible (0.5 person-day fix),
// 15% are irreversible (6 person-day recovery). PA29 — an interface for a type pays for
// itself if its three-year-window total does not exceed the manual regime's total.
const DAILY = 4, YEAR = 250, WINDOW = 3, INTERRUPTION = 0.25, DAY_HOURS = 8;
const REVERSIBLE = 0.5, IRREVERSIBLE = 6;

const calc = TYPE.map((t) => {
  const n = t.perYear * WINDOW, manual = n * (t.hours + INTERRUPTION) / DAY_HOURS;
  const interfaceCost = 4 + 20 * t.variability, maintenance = WINDOW * interfaceCost / 5;
  const misuse = n * (0.02 + 0.10 * t.variability);
  const damage = misuse * 0.85 * REVERSIBLE + misuse * 0.15 * IRREVERSIBLE;
  const selfService = interfaceCost + maintenance + t.variability * manual + damage;
  return { ...t, n, manual, interfaceCost, maintenance, misuse, damage, selfService, diff: manual - selfService };
});

console.log("type".padEnd(26) + "manual".padStart(7) + "interface".padStart(10) + "maint.".padStart(7) +
  "manual left".padStart(13) + "misuse".padStart(8) + "irreversible".padStart(15) +
  "damage".padStart(8) + "self-service".padStart(13) + "diff".padStart(8));
for (const h of calc)
  console.log(h.name.padEnd(26) + h.manual.toFixed(1).padStart(7) + h.interfaceCost.toFixed(1).padStart(10) +
    h.maintenance.toFixed(1).padStart(7) + (h.variability * h.manual).toFixed(1).padStart(13) +
    h.misuse.toFixed(1).padStart(8) + (h.misuse * 0.15).toFixed(2).padStart(15) +
    h.damage.toFixed(1).padStart(8) + h.selfService.toFixed(1).padStart(13) +
    ((h.diff >= 0 ? "+" : "") + h.diff.toFixed(1)).padStart(8));
const payoff = calc.filter((h) => h.diff > 0), noPayoff = calc.filter((h) => h.diff <= 0);
console.log(`\ntypes that pay for themselves: ${payoff.length} / ${TYPE.length}, ` +
  `total gain ${payoff.reduce((t, h) => t + h.diff, 0).toFixed(1)} person-days`);
console.log("the ones that do not — " + noPayoff.map((h) => `${h.name} (${h.diff.toFixed(1)})`).join(", "));

// PA30 — mixed regime: an interface is written only for types that pay for themselves; the
// rest, plus the manual share of the ones automated, stay in the queue. The queue is run again.
const queue = (scale) => {
  const arrivals = TYPE.flatMap((t, i) => {
    const n = Math.round(t.perYear * WINDOW * scale(calc[i]));
    return Array.from({ length: n }, (_, j) => ({ day: (j + 0.5) * YEAR * WINDOW / n,
      work: t.hours + INTERRUPTION }));
  }).sort((a, b) => a.day - b.day);
  let free = 0, total = 0;
  for (const g of arrivals) {
    free = Math.max(free, g.day * DAILY) + g.work;
    g.wait = free / DAILY - g.day;
    total += g.work;
  }
  const w = arrivals.map((g) => g.wait).sort((x, y) => x - y);
  return { count: arrivals.length, days: total / DAY_HOURS,
    avg: w.reduce((x, y) => x + y, 0) / w.length, longest: w.at(-1),
    utilization: total / (YEAR * WINDOW * DAILY) };
};
const MIXED = queue((h) => (h.diff > 0 ? h.variability : 1));
const ALL_AUTOMATED = queue((h) => h.variability);
console.log("\n" + "regime".padEnd(30) + "entering queue".padStart(15) + "manual work".padStart(12) +
  "utilization".padStart(12) + "avg. wait".padStart(14) + "longest".padStart(9));
console.log("request queue (all manual)".padEnd(30) + `${baseline.count}`.padStart(15) +
  (baseline.total / DAY_HOURS).toFixed(1).padStart(12) +
  `${(100 * baseline.total / (YEAR * WINDOW * DAILY)).toFixed(1)}%`.padStart(12) +
  baseline.avg.toFixed(1).padStart(14) + baseline.longest.toFixed(1).padStart(9));
for (const [name, K] of [["mixed (interface for 6 types)", MIXED], ["interface for all", ALL_AUTOMATED]])
  console.log(name.padEnd(30) + `${K.count}`.padStart(15) + K.days.toFixed(1).padStart(12) +
    `${(100 * K.utilization).toFixed(1)}%`.padStart(12) + K.avg.toFixed(1).padStart(14) +
    K.longest.toFixed(1).padStart(9));
const interfaceWork = (H) => H.reduce((t, h) => t + h.interfaceCost + h.maintenance + h.damage, 0);
console.log(`\ninterface + maintenance + damage: mixed regime ${interfaceWork(payoff).toFixed(1)}, ` +
  `all types ${interfaceWork(calc).toFixed(1)} person-days`);
```

```
type                       manual interface maint.  manual left  misuse   irreversible  damage self-service    diff
pipeline variable change     30.9       5.0    3.0          1.5    11.3           1.69    14.9         24.5    +6.5
privilege grant              29.3       5.0    3.0          1.5     9.0           1.35    11.9         21.4    +7.9
secret addition              25.3       5.0    3.0          1.3     6.8           1.01     8.9         18.2    +7.1
new environment setup        48.8       6.0    3.6          4.9     3.6           0.54     4.8         19.2   +29.5
dashboard panel addition     25.3      10.0    6.0          7.6     4.5           0.67     6.0         29.6    -4.2
database copy                38.3       8.0    4.8          7.7     2.9           0.43     3.8         24.3   +14.0
domain name binding          13.1       7.0    4.2          2.0     2.1           0.32     2.8         16.0    -2.8
new service scaffold         42.2       9.0    5.4         10.5     2.4           0.36     3.2         28.2   +14.0
audit log export             12.4      12.0    7.2          5.0     2.2           0.32     2.9         27.0   -14.6
restore from backup          18.6      16.0    9.6         11.1     1.4           0.22     1.9         38.6   -20.1
cluster version upgrade      30.4      14.0    8.4         15.2     0.8           0.13     1.1         38.7    -8.3
region migration             45.3      22.0   13.2         40.8     1.0           0.15     1.3         77.3   -32.0

types that pay for themselves: 6 / 12, total gain 79.0 person-days
the ones that do not — dashboard panel addition (-4.2), domain name binding (-2.8), audit log export (-14.6), restore from backup (-20.1), cluster version upgrade (-8.3), region migration (-32.0)

regime                         entering queue manual work utilization     avg. wait  longest
request queue (all manual)               1551       359.7       95.9%           5.0     13.0
mixed (interface for 6 types)             320       172.6       46.0%           2.1     10.3
interface for all                         170       108.8       29.0%           2.3     10.1

interface + maintenance + damage: mixed regime 108.4, all types 253.9 person-days
```

#### Where Automation Does Not Pay Off

**Six** of the twelve types pay for themselves, for a total gain of **79 person-days**. Six do
not, and the end of that list is the situation this lesson takes its name from: **`region
migration` — a request that arrives three times a year and is different every time.** Doing it
manually costs 45.3 person-days over three years; putting it behind an interface costs **77.3
person-days**, **32 person-days more**. The math comes from three items: the interface at 22
person-days, three years of maintenance at 13.2 person-days, and — the decisive one — since the
interface covers only a tenth of the work, **40.8 person-days of manual work still remain in
place**. The same pattern repeats in `restore from backup` and `cluster version upgrade`.

A second cost sits in the middle of the table. In the `pipeline variable change` type, the
interface pays for itself, but its gain is only 6.5 person-days; over three years it produces
**11.3 instances of misuse**, and **1.69 of them are irreversible** — 14.9 person-days of damage
in total, more than the combined interface and maintenance cost (8 person-days). **The most
frequent request is the one whose interface gets misused the most**, because misuse count scales
with frequency. This item did not exist at all in the manual regime: a person looked at the
operation before performing it.

The last table shows what became of the queue. In the mixed regime, requests entering the queue
drop **from 1,551 to 320**, manual work **from 359.7 to 172.6 person-days**, and utilization
**from 95.9% to 46%**; average wait falls **from 5.0 days to 2.1 days**. **Automating six types
also shortens the wait for the six types that remain in the queue** — because the queue has
drained.

Automating everything does not go any further than this. Requests entering the queue drop to 170,
manual work to 108.8 person-days, but average wait **rises to 2.3 days**: what remains in the
queue is now only long, variable work, and a single region migration closes the queue for days at
a time. The interface, maintenance, and damage paid for this comes to **253.9 person-days**, more
than double the mixed regime's **108.4 person-days**. Removing the queue entirely is **145
person-days more expensive** than shortening it, and gives nothing back in wait.

#### Summary

- Twelve request types entered the queue over a three-year window: 1,551 requests, 359.7
  person-days of manual work, of which 48.5 person-days (13.5%) are interruption alone;
  utilization 95.9%, average wait 5.0 days, longest 13.0 days.
- Wait does not spread evenly across types: pipeline variable change waits 4.8 days, region
  migration waits 11.1 days; the median (4.5) sits below the average (5.0).
- Six of the twelve types pay for themselves and gain 79 person-days; six do not.
- The type where automation does not pay off is `region migration`: three times a year,
  different every time. Manual costs 45.3 person-days, an interface costs 77.3 — a 32-person-day
  loss, because 40.8 person-days of manual work stay in place.
- Self-service's second cost is misuse: `pipeline variable change` produces 11.3 instances of
  misuse over three years, 1.69 of them irreversible, 14.9 person-days total — larger than the
  combined interface and maintenance cost.
- In the mixed regime, requests entering the queue drop from 1,551 to 320, utilization from 95.9%
  to 46%, average wait from 5.0 to 2.1 days. Automating everything raises wait to 2.3 days and
  costs 145 person-days more.

#### Next Step

Across these five lessons, the platform was defined as an approach and its boundaries were
drawn: the team count past which it pays for itself, the adoption and dead-capability counts of
two operating models, the path's scope and the loss for those left outside, mandate's three
modes, the cost of the queue, and where self-service does not pay off. All of them were numbers
about **how** the platform would be run.

What went unsaid is **what the platform offers**. Across five lessons, the names "capability,"
"item," "request type" appeared, but none of them was opened up: how an environment arrives on
demand, what scaffolding does and does not generate, how a catalog keeps track of ownership, how
policy gets embedded in the pipeline, how cost gets allocated, how migration proceeds, and by
what measure the platform's effect gets counted. The next topic opens these capabilities one by
one and counts each with the same measure — the layer it hides, the part that leaks back
through, and who steps outside it.

## Platform Capabilities

The concrete capabilities the platform offers and the measurement of their effect: environment generation on demand, template and scaffold generation that starts a new service, the developer portal and service catalog that make ownership and dependencies visible, automating rules by embedding them in the pipeline, cost visibility through per-team resource accounting, staging platform migrations and adoption, and delivery speed and stability indicators.

### On-Demand Environment Provisioning

Source: https://academia.sh/en/courses/platform-engineering/environment-provisioning

The same ninety-day request flow is run through two paths: a human-staffed request queue and an environment generated on demand. Wait time, environment lifespan, idle environment-days, and abandoned environments are counted; sweeping the lifespan limit derives the tradeoff between rebuild work and data loss.

The previous topic defined what a platform is and drew its boundaries: which decision is taken over,
which stays with the team, who the internal customer is. The definition holds, but it still has not said
what a team concretely asks the platform for when it starts work in the morning. The first thing asked
for is almost always the same: an environment to work in.

**Environment on demand** is an environment that a team starts and finishes itself, instead of writing
its environment setup into a request form and waiting. How the environment is provisioned — the path
from declaration to reality, the drift between record and reality — was measured in M22/K01's
environment provisioning lesson and in M22/K05; it is not repeated here. What this lesson measures is
not the provisioning mechanics but the environment's **life cycle**: how long it waited, how long it
lived, and how much of that lifespan went unused.

The measurement runs on the **regional measurement network**: **fictional** software that collects
water-meter readings, validates them, and opens field work orders. No real infrastructure is
provisioned; what is measured is a **model** written in `node`.

**PC1.** Five teams produce the network's software: meter reading, validation, billing, field,
reporting. The teams and request rates are fictional.
**PC2.** The observation window is 90 days. The request flow is drawn from a self-written generator; the
seed is visible.
**PC3.** Manually setting up an environment is 6 hours of human labor, and the platform team's manual
setup capacity is one environment per day.
**PC4.** Generation on the on-demand path takes 0.5 hours; the pool of concurrently open environments is
16.
**PC5.** An environment delivered on the queue path is never closed — waiting again is expensive. On the
on-demand path the team closes it once its use is done; the cases where it forgets to close are
**written in advance**, with no randomness.
**PC6.** The platform offers two environment shapes: small and medium. A field-equipped shape is not
offered. An **abandoned environment** is one that stays open for at least seven days after its last day
of use.

#### Request Queue and On Demand

```js
// data.mjs — ninety-day environment demand for five teams; model, generator self-written, seed visible.
export const TEAM = ["meter-reading", "validation", "billing", "field", "reporting"];
export const DAYS = 90, SEED = 20260801;
export const generator = (t) => () => ((t = (t * 1103515245 + 12345) % 2147483648) / 2147483648);

const RATE = { "meter-reading": 0.24, validation: 0.16, billing: 0.10, field: 0.30, reporting: 0.06 };
const SHAPE = { "meter-reading": "small", validation: "medium", billing: "medium",
  field: "field-equipped", reporting: "medium" };   // the platform offers only small and medium
export const OFFERED = ["small", "medium"];

export const requests = () => {                     // independent draw, day by day, per team
  const r = generator(SEED), t = [];
  for (let g = 0; g < DAYS; g++)
    for (const e of TEAM) {
      const [a, b, c] = [r(), r(), r()];
      if (a < RATE[e]) t.push({ team: e, day: g, shape: SHAPE[e],
        usage: 1 + Math.floor(b * 9),                // how many days it will actually be used
        stateful: c < 0.45 });                        // does generated data persist in the environment
    }
  return t;
};
```

The same request flow is run through two paths. On the queue path, a human meets every request. On the
platform path, requests in an offered shape are generated on demand; those in an unoffered shape stay
outside the golden path, and that team builds its own environment.

```js
// path.mjs — the same request flow is run through two paths: the request queue and on-demand.
import { requests, DAYS, TEAM, OFFERED } from "./data.mjs";

const CAPACITY = 1, POOL = 16, PROVISION_HOURS = 0.5, MANUAL_HOURS = 6, ABANDON = 7;
const T = requests();
const avg = (d) => (d.length ? d.reduce((a, b) => a + b, 0) / d.length : 0).toFixed(1);

const queue = (list) => {                            // a human builds it: at most one env per day
  const waiting = [], env = [];
  for (let g = 0; g < DAYS; g++) {
    waiting.push(...list.filter((t) => t.day === g));
    for (let k = 0; k < CAPACITY && waiting.length; k++) {
      const t = waiting.shift();
      env.push({ ...t, opened: g, wait: (g - t.day) * 24 + MANUAL_HOURS, closed: DAYS });
    }                                                 // on the queue path an env is never released
  }
  return { env, undelivered: waiting.length, full: 0 };
};

const onDemand = (list) => {                          // generated by one request, pool-limited
  const waiting = [], env = [], open = [];
  let full = 0;
  for (let g = 0; g < DAYS; g++) {
    for (let i = open.length - 1; i >= 0; i--) if (open[i].closed <= g) open.splice(i, 1);
    waiting.push(...list.filter((t) => t.day === g));
    while (waiting.length && open.length < POOL) {
      const t = waiting.shift();
      const abandoned = (t.day * 7 + t.usage) % 4 === 0;   // written in advance, not random
      const o = { ...t, opened: g, wait: (g - t.day) * 24 + PROVISION_HOURS, abandoned,
        closed: abandoned ? DAYS : Math.min(DAYS, g + t.usage) };
      env.push(o); open.push(o);
    }
    if (open.length >= POOL) full++;
  }
  return { env, undelivered: waiting.length, full };
};

const selfBuilt = (list) => ({ full: 0, undelivered: 0,   // outside the golden path, the team builds and never closes it
  env: list.map((t) => ({ ...t, opened: t.day, wait: MANUAL_HOURS, closed: DAYS })) });

const measure = (label, { env, undelivered, full }) => {
  const envDays = env.reduce((a, o) => a + (o.closed - o.opened), 0);
  const used = env.reduce((a, o) => a + Math.min(o.usage, o.closed - o.opened), 0);
  const abandonedCount = env.filter((o) => o.closed - o.opened - o.usage >= ABANDON).length;
  console.log(`${label.padEnd(22)} delivered ${String(env.length).padStart(2)}` +
    `  undelivered ${undelivered}  wait ${avg(env.map((o) => o.wait)).padStart(5)} h` +
    `  env-days ${String(envDays).padStart(4)}  idle ${String(envDays - used).padStart(4)}` +
    ` (${String(Math.round((envDays - used) / envDays * 100)).padStart(2)}%)  abandoned ${abandonedCount}`);
  return { envDays, used, abandonedCount, full, n: env.length };
};

const inScope = T.filter((t) => OFFERED.includes(t.shape));
const outOfScope = T.filter((t) => !OFFERED.includes(t.shape));
console.log(`requests ${T.length}  window ${DAYS} days  teams ${TEAM.length}  manual setup ` +
  `${MANUAL_HOURS} h, ${CAPACITY}/day  pool ${POOL} concurrent  provision ${PROVISION_HOURS} h`);
const A = measure("request queue (all)", queue(T));
const B = measure("on demand (in scope)", onDemand(inScope));
const C = measure("outside golden path", selfBuilt(outOfScope));
console.log(`days the pool was full ${B.full}/${DAYS}; while the pool is full incoming requests wait,` +
  ` ${B.full ? "hidden capacity leaks back" : "no leak"}`);
console.log(`outside golden path ${outOfScope.length}/${T.length} requests, sole reason field-equipped shape` +
  ` (${[...new Set(outOfScope.map((t) => t.team))].join(", ")}); none of them benefit from the platform service`);
console.log(`human labor: queue ${A.n * MANUAL_HOURS} h   platform ${C.n * MANUAL_HOURS} h` +
  ` (outside path only)   saved ${(A.n - C.n) * MANUAL_HOURS} h`);
```

```
requests 90  window 90 days  teams 5  manual setup 6 h, 1/day  pool 16 concurrent  provision 0.5 h
request queue (all)    delivered 83  undelivered 7  wait  83.2 h  env-days 3528  idle 3133 (89%)  abandoned 73
on demand (in scope)   delivered 60  undelivered 3  wait  10.1 h  env-days  877  idle  580 (66%)  abandoned 12
outside golden path    delivered 27  undelivered 0  wait   6.0 h  env-days 1212  idle 1090 (90%)  abandoned 22
days the pool was full 16/90; while the pool is full incoming requests wait, hidden capacity leaks back
outside golden path 27/90 requests, sole reason field-equipped shape (field); none of them benefit from the platform service
human labor: queue 498 h   platform 162 h (outside path only)   saved 336 h
```

The wait gap is the most visible number: an average of 83.2 hours on the queue path, 10.1 hours on the
on-demand path. On the queue path, 7 of the 90 requests are still waiting when the window closes. But
automation alone is not what lowers the wait; the queue path's capacity is one environment per day and
demand is roughly one request per day, so the queue already runs at its saturation limit. The decision
handed over is this: **the team, not a human, decides when the environment comes into existence and
when it goes away.**

The second number is more interesting than the first. On the queue path, 3,133 of 3,528 environment-days
are idle — environments go unused for 89 percent of their lifespan. The reason lies in the waiting
itself: because getting an environment takes days, nobody releases the one they got, and 73 of 83
environments turn into abandoned ones. Wait time and idle resources are not two independent problems;
**one produces the other**.

#### Lifespan Limit: The Tradeoff of a Short Life

```js
// lifespan.mjs — the lifespan limit is swept: cost, rebuilds, data loss; then the threshold is computed.
import { requests, DAYS, TEAM, OFFERED } from "./data.mjs";

const POOL = 16, MANUAL_HOURS = 6, ABANDON = 7, SETUP = 320, OPEX_MONTH = 8, MONTHS = 3;
const T = requests(), inScope = T.filter((t) => OFFERED.includes(t.shape));

const sweep = (lifespan) => {              // when the lifespan runs out the env is deleted; unfinished work is requested again
  const waiting = [], open = [];
  let totalDays = 0, provisioned = 0, rebuilds = 0, loss = 0, abandonedCount = 0;
  for (let g = 0; g < DAYS; g++) {
    for (let i = open.length - 1; i >= 0; i--) {
      const o = open[i], age = g - o.opened, finished = o.remaining === 0 && !o.abandoned;
      if (!finished && age < lifespan) continue;
      open.splice(i, 1);
      totalDays += age;
      if (age - (o.usage - o.remaining) >= ABANDON) abandonedCount++;
      if (o.remaining > 0) { rebuilds++; loss += o.stateful ? 1 : 0; waiting.push(o); }
    }
    waiting.push(...inScope.filter((t) => t.day === g).map((t) => ({ ...t, remaining: t.usage,
      abandoned: (t.day * 7 + t.usage) % 4 === 0 })));
    while (waiting.length && open.length < POOL) {
      open.push({ ...waiting.shift(), opened: g }); provisioned++;
    }
    for (const o of open) if (o.remaining > 0) o.remaining--;
  }
  for (const o of open) {                            // still open at the end of the window
    totalDays += DAYS - o.opened;
    if (DAYS - o.opened - (o.usage - o.remaining) >= ABANDON) abandonedCount++;
  }
  return { totalDays, provisioned, rebuilds, loss, abandonedCount, undelivered: waiting.length };
};

console.log("lifespan  env-days  provisioned  rebuilds  data loss  abandoned  undelivered");
for (const s of [1, 2, 3, 5, 7, 14, 90]) {
  const r = sweep(s);
  console.log(`${(s === 90 ? "none" : s + (s === 1 ? " day" : " days")).padEnd(10)}${String(r.totalDays).padStart(6)}` +
    `${String(r.provisioned).padStart(12)}${String(r.rebuilds).padStart(11)}` +
    `${String(r.loss).padStart(11)}${String(r.abandonedCount).padStart(11)}${String(r.undelivered).padStart(13)}`);
}

const benefit = Object.fromEntries(TEAM.map((e) =>              // human-hours saved per team
  [e, inScope.filter((t) => t.team === e).length * MANUAL_HOURS]));
const cost = SETUP + OPEX_MONTH * MONTHS;
let k = 0, threshold = 0;
const cumulative = TEAM.map((e) => { k += benefit[e]; if (k >= cost && !threshold) threshold = TEAM.indexOf(e) + 1;
  return `${e} ${benefit[e]} (${k})`; });
console.log(`\nplatform cost ${cost} person-hours (setup ${SETUP} + ${MONTHS} months opex)` +
  `   five teams' total gain ${k} person-hours`);
console.log(`cumulative gain per team: ${cumulative.join("  ")}`);
console.log(`threshold ${threshold ? "crossed at team " + threshold : "not crossed in this period"}` +
  `; teams contributing ${TEAM.filter((e) => benefit[e] > 0).length}/${TEAM.length}`);
```

```
lifespan  env-days  provisioned  rebuilds  data loss  abandoned  undelivered
1 day        314         314        251        113          0            0
2 days       322         175        112         50          0            0
3 days       331         127         64         27          0            0
5 days       343          90         27         12          0            0
7 days       355          76         13          8          0            0
14 days      425          63          0          0         10            0
none         877          60          0          0         12            3

platform cost 344 person-hours (setup 320 + 3 months opex)   five teams' total gain 378 person-hours
cumulative gain per team: meter-reading 174 (174)  validation 78 (252)  billing 90 (342)  field 0 (342)  reporting 36 (378)
threshold crossed at team 5; teams contributing 4/5
```

The sweep shows the two curves moving at different speeds. With no lifespan limit, 877 environment-days
are spent; pulling the limit to fourteen days drops that to 425 — under half — and in exchange
**produces zero rebuilds**. Past this point it stops being equally cheap: dropping from fourteen days to
seven days only pulls the cost from 425 to 355, but produces 13 rebuilds and 8 data-loss incidents.
Dropping to one day lowers the cost to 314 — a twelve-percent gain over seven days — and in exchange
produces 251 rebuilds and 113 data-loss incidents. As the cost curve flattens, the rebuild curve
steepens.

The limit also has an unseen benefit. With no lifespan limit, 3 requests are never met at all, because
abandoned environments fill the pool; the moment the limit is set, undelivered requests drop to zero.
**The cost of an unused environment is paid not only on the ledger, but in other teams' waiting.**

#### The Cost of the Hidden Layer

| Measure | Request queue | On demand | Outside golden path |
|---|---|---|---|
| average wait | 83.2 h | 10.1 h | 6.0 h |
| delivered / undelivered | 83 / 7 | 60 / 3 | 27 / 0 |
| environment-days | 3,528 | 877 | 1,212 |
| idle environment-days | 3,133 (89%) | 580 (66%) | 1,090 (90%) |
| abandoned environments | 73 | 12 | 22 |
| human labor per environment | 6 h | 0 h | 6 h |

The hidden layer is the environment's setup steps, and the gain is 336 person-hours: 498 hours of work
on the queue path drops to 162 hours on the platform path. The threshold comes from the same
computation. The platform's cost over one quarter is 344 person-hours, and the five teams' combined gain
is 378 person-hours; **the threshold is crossed only at the fifth team**, and the team that crosses it
is reporting, the team with the lowest annual demand. With three teams, the same platform does not pay
for itself. The number does not point in only one direction: the same model also says the platform
should not be built for a network where few teams operate.

The abstraction leaks in two places. The first is the pool: on 16 of 90 days the pool is full, and on
those days waiting comes back the way it did on the queue path — the team assumes it will get an
environment the moment it asks, but in reality it hits the lower layer's capacity. The second is the
lifespan limit: the fact that the environment is not permanent becomes visible to the team only at a
data-loss incident, and that knowledge is learned not by reading documentation but by losing work.

Those who stay outside do so for a single reason. All 27 requests asking for a field-equipped
environment belong to the field team, and this team benefits from **none** of the platform service: it
builds its own environment, the lifespan limit does not apply, 90 percent of its environment-days sit
idle, and 22 of its environments are abandoned. There is no partial benefit; if a request's shape does not
fit one of the two offered shapes, that request spends its entire life cycle outside the platform.

#### Summary

- The same 90 requests waited an average of 83.2 hours on the queue path and 10.1 hours on the
  on-demand path; on the queue path, 7 requests were never met before the window closed.
- Waiting produced idle resources: on the queue path, 89% of 3,528 environment-days went unused, and 73
  of 83 environments turned into abandoned ones.
- Pulling the lifespan limit to 14 days dropped the cost from 877 environment-days to 425 and produced
  zero rebuilds; pulling it to 1 day dropped the cost only to 314 but produced 251 rebuilds and 113
  data-loss incidents.
- The lifespan limit also emptied the pool: the 3 requests that went unmet on the unlimited path dropped
  to zero once the limit was set.
- The platform's quarterly cost was 344 person-hours and the five teams' gain was 378 person-hours; the
  threshold was crossed only at the fifth team, and the field team never contributed to that gain.
- The abstraction leaked in two places: the pool filled on 16 of 90 days and waiting came back; the
  lifespan limit is existence became visible to the team only through data loss.

#### Next Step

The environment now exists the moment it is requested, but what goes inside it is still written by hand.
When a sixth service needs to be added to the measurement network, the team starts from an empty
directory and makes every decision over again, from log format to health endpoint, from the build step
to metric publishing — and the five teams make these decisions without knowing what the others chose.
The next lesson takes up the flow for starting a new service: real files are generated, the generated
output is compared against what was written by hand, and once the template is updated after generation,
the number of services left behind is counted.

### Template and Scaffold Generation

Source: https://academia.sh/en/courses/platform-engineering/template-and-scaffold-generation

How many files, lines, and decisions it takes to start a new service by hand, and how many of those a scaffold removes. Five services are generated as real files and compared line by line against a hand-written service, then the number of services left behind once the template is updated, and what catching that drift costs, is measured.

The previous lesson measured the path that makes an environment exist the moment it is requested:
waiting dropped to ten hours, and idle environment-days fell by nearly half. The environment is now
ready, but what goes inside it still starts from an empty directory. When a sixth service needs to be
added to the measurement network, the team makes every decision over again, from log format to health
endpoint, from pipeline steps to the metrics that will be published — and the five teams make these
decisions without knowing what the others chose.

**Scaffold generation** is the flow that generates a new service's starting files from a template
repository. The template's own cost — the number of variables, conditional branches, drift between
copies — was measured in M22/K03's packaging and templating lesson and is not repeated here. This
lesson measures two things: what goes unwritten **at start time**, and what the template leaves behind
**after generation**.

The measurement runs on the **regional measurement network**: **fictional** software that collects
water-meter readings, validates them, and opens field work orders. The files in this lesson are
actually written to disk and counted by reading them back; the file formats carry no product name —
they are **our own small format**, defined within the lesson.

**PC7.** A service added to the measurement network starts with six files: service definition, pipeline
definition, metric definition, log definition, smoke test, and documentation.
**PC8.** The template repository asks three questions in version 1: name, owner, port.
**PC9.** The meter-reading team hand-wrote its own service before the scaffold existed; those files
stand as-is in the model, and the comparison is done against them.
**PC10.** Generation actually runs; the files and lines counted are read back from disk.
**PC11.** The local edits made after generation are **written in advance**, with no randomness.
**PC12.** Template version 2 brings three changes and adds one new question. Generated files are not
tied to the template; they do not change on their own when the template changes.

#### Template Repository and Three Questions

```js
// template.mjs — template repository (version 1), five services' answers, one service written by hand. Model.
export const VERSION = 1;
export const TEMPLATE = {
  "service.def": `service {{name}}
  owner {{owner}}
  port {{port}}
  health-endpoint /health
  log-format line-json
  log-level summary`,
  "pipeline.def": `pipeline {{name}}
  step build
  step unit-test
  step package
  step deploy-staging
  step smoke-test
  step deploy-production`,
  "metric.def": `metric {{name}}
  counter processed_record
  counter failed_record
  gauge request_duration_ms`,
  "log.def": `log {{name}}
  field time
  field level
  field request-id`,
  "smoke.def": `smoke {{name}}
  call /health expect 200
  call /metric expect 200`,
  "DOC.md": `# {{name}}
owner {{owner}}, port {{port}}`,
};

export const ANSWER = [                              // version 1 asks three questions
  { name: "reading-collector", owner: "meter-reading", port: 8080 },
  { name: "validation-flow", owner: "validation", port: 8081 },
  { name: "billing-calculator", owner: "billing", port: 8082 },
  { name: "field-order", owner: "field", port: 8083 },
  { name: "report-compiler", owner: "reporting", port: 8084 }];

export const generate = (s, c) => s.replace(/\{\{([\w-]+)\}\}/g, (_, a) => String(c[a] ?? `<missing:${a}>`));
export const lines = (m) => m.split("\n").filter((s) => s.trim()).length;

export const MANUAL = {                              // the meter-reading team hand-wrote this before the scaffold existed
  "service.def": `service reading-collector
  owner meter-reading
  port 8080
  health-endpoint /status
  log-format plain-text`,
  "pipeline.def": `pipeline reading-collector
  step build
  step unit-test
  step package
  step manual-approval
  step deploy-production`,
  "metric.def": `metric reading-collector
  counter record_count
  gauge request_duration_ms`,
  "log.def": `log reading-collector
  field time
  field level`,
  "DOC.md": `# reading-collector`,
};
```

Most of the values inside the template are never asked as questions. The health endpoint's path, the
log format, the order of the pipeline's six steps, and the names of the three metrics to be published
are fixed in the template; only three things are asked of the team. This distinction is the very thing
the lesson measures: **every field that is not asked is a decision the team did not make** — and at the
same time, a decision that team can no longer see.

#### Generated Versus Hand-Written

```js
// generate.mjs — five services are generated as real files and compared against the hand-written one.
import { mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs";
import { TEMPLATE, ANSWER, MANUAL, VERSION, generate, lines } from "./template.mjs";

const FILES = Object.keys(TEMPLATE), start = process.hrtime.bigint();
for (const c of ANSWER) {
  mkdirSync(`services/${c.name}`, { recursive: true });
  for (const d of FILES)
    writeFileSync(`services/${c.name}/${d}`, `# generator ${VERSION}\n${generate(TEMPLATE[d], c)}\n`);
}
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;

const onDisk = readdirSync("services").flatMap((s) =>             // was it really written
  readdirSync(`services/${s}`).map((d) => `services/${s}/${d}`));
const diskLines = onDisk.reduce((a, y) => a + lines(readFileSync(y, "utf8")), 0);
const fields = Object.values(TEMPLATE).join("\n").split("\n").filter((s) => s.startsWith("  ")).length;
const variables = new Set(Object.values(TEMPLATE).join("\n").match(/\{\{([\w-]+)\}\}/g)).size;
const bandMax = FILES.length * ANSWER.length * 20;                // the expected upper bound derives from the input

console.log(`template ${FILES.length} files, ${lines(Object.values(TEMPLATE).join("\n"))} lines,` +
  ` ${fields} fields, ${variables} variables`);
console.log(`manual bootstrap : per service ${FILES.length} files, ` +
  `${lines(Object.values(TEMPLATE).join("\n"))} lines, ${fields} decisions` +
  `   for five services ${fields * ANSWER.length} decisions`);
console.log(`with scaffold    : per service 0 files, 0 lines, ${variables} answers` +
  `   for five services ${variables * ANSWER.length} answers`);
console.log(`written to disk ${onDisk.length} files, ${diskLines} lines` +
  `   generation time in the ${0}-${bandMax} ms band: ${elapsed <= bandMax ? "yes" : "no"}`);

const key = (s) => s.trim().split(" ")[0];                        // generated versus hand-written
const unique = (l, k) => l.filter((x) => key(x) === k).length === 1;
let totalShared = 0, totalDifferent = 0, totalMissing = 0, totalExtra = 0;
console.log(`\nhand-written reading-collector ${Object.keys(MANUAL).length} files ` +
  `${lines(Object.values(MANUAL).join("\n"))} lines  |  generated ${FILES.length} files ` +
  `${lines(Object.values(TEMPLATE).join("\n"))} lines`);
for (const d of FILES) {
  const g = generate(TEMPLATE[d], ANSWER[0]).split("\n").filter((s) => s.trim());
  const e = (MANUAL[d] ?? "").split("\n").filter((s) => s.trim());
  const diffs = [];
  let s = 0, f = 0, m = 0;
  for (const l of g) {
    if (e.some((x) => x.trim() === l.trim())) s++;
    else if (unique(g, key(l)) && unique(e, key(l))) {
      f++; diffs.push(`${l.trim()} -> ${e.find((x) => key(x) === key(l)).trim()}`);
    } else m++;
  }
  const x = e.filter((v) => !g.some((l) => l.trim() === v.trim()) &&
    !diffs.some((p) => key(p) === key(v))).length;
  [totalShared, totalDifferent, totalMissing, totalExtra] = [totalShared + s, totalDifferent + f, totalMissing + m, totalExtra + x];
  console.log(`  ${d.padEnd(14)} shared ${s}  different ${f}  missing ${m}  extra ${x}` +
    `${MANUAL[d] === undefined ? "  (file never written)" : ""}` +
    `${diffs.length ? "  [" + diffs.join("; ") + "]" : ""}`);
}
console.log(`total: shared ${totalShared}  different value ${totalDifferent}  missing ${totalMissing}  extra ${totalExtra}`);
```

```
template 6 files, 26 lines, 19 fields, 3 variables
manual bootstrap : per service 6 files, 26 lines, 19 decisions   for five services 95 decisions
with scaffold    : per service 0 files, 0 lines, 3 answers   for five services 15 answers
written to disk 30 files, 160 lines   generation time in the 0-600 ms band: yes

hand-written reading-collector 5 files 18 lines  |  generated 6 files 26 lines
  service.def    shared 3  different 2  missing 1  extra 0  [health-endpoint /health -> health-endpoint /status; log-format line-json -> log-format plain-text]
  pipeline.def   shared 5  different 0  missing 2  extra 1
  metric.def     shared 2  different 0  missing 2  extra 1
  log.def        shared 3  different 0  missing 1  extra 0
  smoke.def      shared 0  different 0  missing 3  extra 0  (file never written)
  DOC.md         shared 1  different 0  missing 1  extra 0
total: shared 14  different value 2  missing 10  extra 2
```

The bootstrap cost is in three numbers, and not all three move the same way. The file and line counts
are the visible gain: six files and twenty-six lines per service are no longer written, dropping a
hundred thirty lines for five services. The real item is the third: **nineteen decisions collapse into
three answers**. Fifteen answers are given for five services instead of ninety-five decisions; the
remaining eighty decisions were made once in the template and are never asked again.

The comparison against the hand-written service shows what those eighty decisions mean. The meter-reading
team's own service is eighteen lines and overlaps with the generated twenty-six lines in only **fourteen
lines**. Two fields have different values — the health endpoint is `/status` instead of `/health`, the log
format is plain text instead of line-json — and these two differences produce no error, they only keep
shared tooling from recognizing that service. Ten lines are missing: two pipeline steps, two counters in
the metric definition, the request-id field in the log, the entire smoke-test file, one line in the
service definition and one line in the doc file. Two lines are extra and have no counterpart in the
template: the manual-approval step in the pipeline and a counter written under its own name.

For this team to move onto the golden path it needs to fix twelve lines and add one file; the two extra
lines have no place in the template, so it will either give them up or stay outside. **The cost of
staying outside is twelve lines of fixing and giving up two habits, per service.**

#### What Happens When the Template Is Updated

```js
// drift.mjs — the template is updated after generation; services left behind are counted from disk.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { TEMPLATE, ANSWER, generate, lines } from "./template.mjs";

const LOCAL = {                                      // local edits made after generation, written in advance
  "reading-collector": { "pipeline.def": ["  step schema-check", "  step load-test"],
    "metric.def": ["  counter skipped_record"] },
  "validation-flow": { "log.def": ["  field source-meter"] },
  "billing-calculator": { "pipeline.def": ["  step reconciliation"], "metric.def": ["  gauge invoice_amount"],
    "smoke.def": ["  call /reconciliation expect 200"] },
  "field-order": {},
  "report-compiler": { "smoke.def": ["  call /report expect 200"], "DOC.md": ["nightly run note"] } };

let local = 0;
for (const [s, d] of Object.entries(LOCAL))
  for (const [file, extraLines] of Object.entries(d)) {
    writeFileSync(`services/${s}/${file}`,
      readFileSync(`services/${s}/${file}`, "utf8") + extraLines.join("\n") + "\n");
    local += extraLines.length;
  }
const p = `services/validation-flow/service.def`;    // the validation team saw the announcement and patched by hand
writeFileSync(p, readFileSync(p, "utf8").replace("health-endpoint /health",
  "health-endpoint /status/health\n  data-class measurement"));
writeFileSync("services/validation-flow/pipeline.def",
  readFileSync("services/validation-flow/pipeline.def", "utf8") + "  step signing\n");

const TEMPLATE2 = { ...TEMPLATE, "service.def": TEMPLATE["service.def"]
  .replace("health-endpoint /health", "health-endpoint /status/health\n  data-class {{data-class}}"),
  "pipeline.def": TEMPLATE["pipeline.def"] + "\n  step signing" };
const RULES = [["service.def", "health-endpoint /status/health"], ["service.def", "data-class "],
  ["pipeline.def", "  step signing"]];

const marker = ANSWER.filter((c) =>                   // first scan: the generator-version line
  readFileSync(`services/${c.name}/service.def`, "utf8").startsWith("# generator 1"));
let missingLines = 0, filesRead = 0;
const content = ANSWER.filter((c) => {
  const missing = RULES.filter(([d, k]) => !readFileSync(`services/${c.name}/${d}`, "utf8").includes(k));
  filesRead += new Set(RULES.map(([d]) => d)).size;
  missingLines += missing.length;
  return missing.length > 0;
});

mkdirSync("trial", { recursive: true });               // a regeneration attempt with no answers
let gaps = 0;
for (const c of ANSWER) {
  const m = generate(TEMPLATE2["service.def"], c);
  writeFileSync(`trial/${c.name}.def`, m);
  gaps += (m.match(/<missing:/g) ?? []).length;
}
const newAnswers = ANSWER.filter((c) => c["data-class"] === undefined).length;

console.log(`local edits ${local} lines, ${Object.values(LOCAL).filter((d) =>
  Object.keys(d).length).length}/${ANSWER.length} services`);
console.log(`template version 2: ${RULES.length} changes, ${lines(TEMPLATE2["service.def"] +
  "\n" + TEMPLATE2["pipeline.def"]) - lines(TEMPLATE["service.def"] + "\n" + TEMPLATE["pipeline.def"])}` +
  ` new lines, 1 new question; none of the generated services change on their own`);
console.log(`marker scan  : behind ${marker.length}/${ANSWER.length} services,` +
  ` files read ${ANSWER.length}, false positives ${marker.length - content.length}`);
console.log(`content scan : behind ${content.length}/${ANSWER.length} services,` +
  ` missing lines ${missingLines}, files read ${filesRead}` +
  `   (${content.map((c) => c.name).join(", ")})`);
console.log(`regeneration : ${ANSWER.length * Object.keys(TEMPLATE).length} files rewritten,` +
  ` local lines lost ${local}, new answers to collect first ${newAnswers}` +
  `; gaps in the answerless trial ${gaps}`);
console.log(`manual patch : ${missingLines} touches, local lines lost 0,` +
  ` the same work is required again in the next version`);
```

```
local edits 9 lines, 4/5 services
template version 2: 3 changes, 2 new lines, 1 new question; none of the generated services change on their own
marker scan  : behind 5/5 services, files read 5, false positives 1
content scan : behind 4/5 services, missing lines 12, files read 10   (reading-collector, billing-calculator, field-order, report-compiler)
regeneration : 30 files rewritten, local lines lost 9, new answers to collect first 5; gaps in the answerless trial 5
manual patch : 12 touches, local lines lost 0, the same work is required again in the next version
```

Template version 2 changes the health endpoint, makes the data-class field mandatory, and adds a
signing step to the pipeline. None of the five services on disk become aware of this, because **the
generated file is not tied to the generator**. The template was read once, the output was written, and
the link broke right there. This is the exact opposite kind of leak from the previous lesson's
environment abstraction: there, the hidden layer leaked back; here, the hiding never lasts at all.

There are two ways to find who is left behind, and they give different numbers. Reading the generator
version finishes with five files read and shows all five of the five services as behind; but the
validation team saw the announcement and made the three changes by hand, so this scan produces **one
false positive**. Checking the content requires reading ten files, gives four services as left behind,
and names the twelve missing lines in total. The correct result costs twice the reading.

Catching it also has two ways. Regeneration rewrites all thirty files and finishes in one move, but it
has two costs: nine local lines are lost — pipeline steps, metrics, smoke-test calls — and because the
new question's answer is not recorded anywhere, five new answers have to be collected for five services.
Running the trial with no answers shows this directly: five files are left with a `<missing:data-class>`
gap. The manual patch, by contrast, finishes with twelve touches and loses no local lines, but because
the link to the template still is not made, the same work is required again from scratch when version 3
arrives.

The one-sentence balance of gain and cost is this: the scaffold cut ninety-five decisions **at start
time** down to fifteen answers, and took over zero decisions **after start time**. The four services
that made local edits stepped outside the template by exactly as many lines as they edited locally;
field-order, the one service that made no edits at all, loses nothing from regeneration.

#### Summary

- Starting a new service by hand means 6 files, 26 lines, and 19 decisions per service; 95 decisions
  come out for five services, and the scaffold gives 15 answers instead.
- Five services were actually generated: 30 files and 160 lines were written to disk, and generation
  time stayed within the band derived from the input.
- The hand-written service overlapped the generated one in only 14 of 26 lines: 2 fields with different
  values, 10 lines missing, 2 lines extra; the cost of moving onto the golden path is 12 lines of fixing
  and giving up 2 habits.
- Once the template moved to version 2, none of the generated services changed on their own; the scan
  based on the generator version said 5/5 behind and produced 1 false positive, while the content scan
  found 4/5 behind and 12 missing lines, at the cost of reading twice as many files.
- Both ways of catching it had a cost: regeneration deleted 9 local lines and required 5 new answers,
  the manual patch finished with 12 touches but, because it made no link to the template, required the
  same work again in the next version.

#### Next Step

Five services were generated, their files sit on disk, and each one's definition has an owner line.
That line was correct the day it was written; nobody knows whether it is correct today. When the
billing calculator goes down at midnight, the person answering the page first has to find out: who owns
this service, what does it depend on, and who depends on it. The next lesson builds an ownership and
dependency graph from catalog records; orphaned services, orphan dependencies pointing to a service that
does not exist, and the time to reach the right person are counted both with and without the catalog.
The record's own cost is also measured: how many records are updated by hand, and how many records
disagree with reality.

### Developer Portal and Service Catalog

Source: https://academia.sh/en/courses/platform-engineering/developer-portal-and-service-catalog

An ownership and dependency graph is built from twelve catalog records; unowned services, orphan dependencies, and unregistered services are counted. Time to reach the correct oncall list across five incidents is measured with and without the catalog, then the record's own cost is derived: how many fields are written by hand and how many records disagree with reality.

The previous lesson generated five services as real files; each one's definition has an owner line.
That line was correct the day it was written. But the three questions the person answering the page
asks at midnight when the billing calculator goes down are different: who is on call for this service,
what does this service depend on, and who is affected if this service goes down. The answer to the
third question is written in no service's own file, because nobody keeps the reverse edge in their own
repository.

A **service catalog** is a list that keeps the ownership and dependency records of running services in
one place; a **developer portal** is the surface that makes that list queryable. What this lesson
measures is not the portal's appearance, but the questions the catalog answers and **how correct the
answer is**.

The measurement runs on the **regional measurement network**: **fictional** software that collects
water-meter readings, validates them, and opens field work orders. The catalog records and the ground
truth are a **model** written in `node`.

**PC13.** The catalog holds twelve records. A record has five fields: name, owner team, oncall list,
dependency list, days since the last manual update.
**PC14.** The ground truth is derived from the last ninety days of run history — observed call edges
and the team that ran the pipeline — and stands as a separate structure in the model.
**PC15.** No person's name appears; what is reached is an **oncall list**.
**PC16.** Five incidents and every step's minutes are **written in advance**, with no randomness.
**PC17.** The owner field can be read from the service definition the scaffold generates; the oncall
list and the dependency list are written by hand.
**PC18.** An **unowned service** is a record whose owner field is empty or names a team that does not
exist. An **orphan dependency** is an edge pointing to a service that has no record in the catalog.

#### From Records to a Graph

The catalog is a flat list; what is queryable is not the list itself but the graph built from it. Each
record gives a node, each dependency gives an edge; the ownership field ties the node to a team. The
graph's value comes from two places: the edge's **reverse direction** and the **gap** between record and
reality. Neither can be seen by looking at individual records one at a time.

```js
// catalog.mjs — service catalog records and the ground truth derived from the run history. Model, hand-written.
// record: [name, owner team, oncall list, dependencies, days since last manual update]
export const RECORD = [
  ["reading-collector", "meter-reading", "oncall-meter", ["measurement-store", "identity-edge"], 9],
  ["reading-resolver", "meter-reading", "oncall-meter", ["reading-collector"], 21],
  ["validation-flow", "validation", "oncall-validation", ["reading-collector", "rule-store"], 14],
  ["rule-store", "validation", "oncall-validation", [], 27],
  ["billing-calculator", "billing", "oncall-billing", ["validation-flow", "tariff-service"], 24],
  ["tariff-service", "billing", "oncall-billing", [], 210],
  ["invoice-writer", "billing", "oncall-billing", ["billing-calculator"], 33],
  ["field-order", "field", "oncall-field", ["validation-flow", "map-edge"], 150],
  ["map-edge", "", "", [], 260],
  ["report-compiler", "reporting", "oncall-reporting",
    ["measurement-store", "archive-store", "invoice-writer"], 120],
  ["dashboard-edge", "reporting", "oncall-reporting", ["report-compiler"], 19],
  ["measurement-store", "distributed-team", "oncall-legacy", [], 180]];

export const ACTUAL_DEPS = {                         // call edges observed over the last ninety days
  "reading-collector": ["measurement-store", "identity-edge"], "reading-resolver": ["reading-collector"],
  "validation-flow": ["reading-collector", "rule-store"], "rule-store": [],
  "billing-calculator": ["validation-flow", "tariff-service", "identity-edge"], "tariff-service": [],
  "invoice-writer": ["billing-calculator"], "field-order": ["validation-flow"],
  "map-edge": ["identity-edge"], "report-compiler": ["measurement-store", "invoice-writer"],
  "dashboard-edge": ["report-compiler"], "measurement-store": [], "identity-edge": [] };

export const ACTUAL_OWNER = {                        // team that ran the pipeline over the last ninety days
  "reading-collector": "meter-reading", "reading-resolver": "meter-reading",
  "validation-flow": "validation", "rule-store": "validation", "billing-calculator": "billing",
  "tariff-service": "reporting", "invoice-writer": "billing", "field-order": "field",
  "map-edge": "field", "report-compiler": "reporting", "dashboard-edge": "reporting",
  "measurement-store": "meter-reading", "identity-edge": "validation" };

export const MULTI_TEAM = ["measurement-store", "map-edge"];   // service touched by more than one team
export const TEAM = ["meter-reading", "validation", "billing", "field", "reporting"];
```

```js
// graph.mjs — an ownership and dependency graph is built from catalog records and compared to reality.
import { RECORD, ACTUAL_DEPS, ACTUAL_OWNER, TEAM } from "./catalog.mjs";

const registered = new Set(RECORD.map((k) => k[0]));
const reverse = {};                                  // who depends on me: the catalog builds the reverse edge
for (const [name, , , deps] of RECORD) for (const b of deps) (reverse[b] ??= []).push(name);

const unregistered = Object.keys(ACTUAL_DEPS).filter((s) => !registered.has(s));
const unowned = RECORD.filter(([, s]) => !s || !TEAM.includes(s));
const orphaned = RECORD.flatMap(([name, , , deps]) => deps.filter((b) => !registered.has(b)).map((b) => [name, b]));
console.log(`catalog ${RECORD.length} records, ${RECORD.reduce((a, k) => a + k[3].length, 0)} dependency` +
  ` edges, reverse edges derivable for ${Object.keys(reverse).length} services`);
console.log(`services actually running but never registered ${unregistered.length}: ${unregistered.join(", ")}`);
console.log(`unowned services ${unowned.length}: ` +
  unowned.map(([a, s]) => `${a} (${s || "empty field"})`).join(", "));
console.log(`orphan dependencies ${orphaned.length}: ` + orphaned.map(([a, b]) =>
  `${a} -> ${b} [${b in ACTUAL_DEPS ? "service exists, no record" : "no service, stale record"}]`).join("; "));
const affectedByUnregistered = Object.entries(ACTUAL_DEPS)
  .filter(([, b]) => b.some((x) => unregistered.includes(x))).length;
console.log(`${affectedByUnregistered} services actually connect to an unregistered service;` +
  ` the portal's dependency answer is incomplete for those ${affectedByUnregistered} services`);

let mismatchedFields = 0, recordsMismatched = 0, depMismatch = 0, ownerMismatch = 0;   // record aging: record versus reality
const age = { young: [0, 0], old: [0, 0] };
for (const [name, owner, , deps, day] of RECORD) {
  const g = ACTUAL_DEPS[name] ?? [];
  const missing = g.filter((b) => !deps.includes(b)).length;
  const extra = deps.filter((b) => !g.includes(b)).length;
  const ownerDiff = ACTUAL_OWNER[name] !== owner ? 1 : 0;
  const diff = missing + extra + ownerDiff;
  mismatchedFields += diff; depMismatch += missing + extra; ownerMismatch += ownerDiff;
  recordsMismatched += diff > 0 ? 1 : 0;
  const bucket = day <= 30 ? "young" : "old";
  age[bucket][0]++; age[bucket][1] += diff > 0 ? 1 : 0;
}
const manual = RECORD.length * 2, generated = RECORD.length * 2;     // oncall and dependency are written by hand
console.log(`\nrecord fields ${manual + generated}: ${generated} are read from the service` +
  ` definition the scaffold generates, ${manual} (oncall list, dependencies) are written by hand`);
console.log(`records disagreeing with reality ${recordsMismatched}/${RECORD.length}, mismatched fields ${mismatchedFields}`);
console.log(`record age <= 30 days: ${age.young[1]}/${age.young[0]} mismatched` +
  `   > 30 days: ${age.old[1]}/${age.old[0]} mismatched`);
console.log(`source of the mismatch: dependencies ${depMismatch}, ownership ${ownerMismatch};` +
  ` both are derivable from the run history, the oncall list is not`);

const affected = (s) => {                            // walk over the reverse edge
  const stack = [s], result = new Set();
  while (stack.length) for (const u of reverse[stack.pop()] ?? [])
    if (!result.has(u)) { result.add(u); stack.push(u); }
  return [...result];
};
console.log(`\n"who is affected if billing-calculator goes down" — with the catalog ${affected("billing-calculator")
  .length} services (${affected("billing-calculator").join(", ")}), 1 graph walk`);
console.log(`the same question without the catalog: ${RECORD.length} services' dependency lists are scanned by hand,` +
  ` the reverse edge is not written in any file`);
```

```
catalog 12 records, 14 dependency edges, reverse edges derivable for 11 services
services actually running but never registered 1: identity-edge
unowned services 2: map-edge (empty field), measurement-store (distributed-team)
orphan dependencies 2: reading-collector -> identity-edge [service exists, no record]; report-compiler -> archive-store [no service, stale record]
3 services actually connect to an unregistered service; the portal's dependency answer is incomplete for those 3 services

record fields 48: 24 are read from the service definition the scaffold generates, 24 (oncall list, dependencies) are written by hand
records disagreeing with reality 6/12, mismatched fields 7
record age <= 30 days: 1/6 mismatched   > 30 days: 5/6 mismatched
source of the mismatch: dependencies 4, ownership 3; both are derivable from the run history, the oncall list is not

"who is affected if billing-calculator goes down" — with the catalog 3 services (invoice-writer, report-compiler, dashboard-edge), 1 graph walk
the same question without the catalog: 12 services' dependency lists are scanned by hand, the reverse edge is not written in any file
```

Building the graph shows three gaps at once. **There are two unowned services**, and they are unowned
in different ways: map-edge's owner field is empty, while measurement-store's owner is a team that no
longer exists. The second is more dangerous, because the record looks filled in and no empty-field scan
catches it. **There are also two orphan dependencies**, and their causes are opposite: identity-edge is
a service that genuinely runs but has no record at all in the catalog, while archive-store is a service
that sits in a record but does not exist in reality. One is the record's absence; the other is the
record's staleness.

The reverse edge is the answer only the catalog can give on its own. When the billing calculator goes
down, the three affected services come out with a single graph walk; answering the same question
without the catalog requires scanning all twelve services' dependency lists one by one, because no
service knows who depends on it.

#### The Correct Oncall List in an Incident

```js
// incident.mjs — time to reach the correct oncall list across five incidents: with and without the catalog.
import { RECORD, ACTUAL_OWNER, MULTI_TEAM } from "./catalog.mjs";

const STEP = { repo: 6, firstMessage: 8, teamChannel: 15, findOncall: 12, readCatalog: 2, call: 3,
  wrongList: 10, review: 4 };                       // minutes, written in advance; no randomness
const INCIDENT = ["billing-calculator", "map-edge", "tariff-service", "field-order", "measurement-store"];

const withoutCatalog = (s) => STEP.repo + STEP.firstMessage + STEP.findOncall +
  (MULTI_TEAM.includes(s) ? STEP.teamChannel : 0);  // repo history, message, team channel if needed
const withCatalog = (s) => {
  const rec = RECORD.find(([a]) => a === s);
  if (!rec || !rec[2]) return [STEP.readCatalog + withoutCatalog(s), rec ? "oncall field empty" : "no record"];
  if (ACTUAL_OWNER[s] !== rec[1])
    return [STEP.readCatalog + STEP.wrongList + withoutCatalog(s), "record pointed to the wrong list"];
  return [STEP.readCatalog + STEP.call, "record up to date"];
};

console.log("incident             without cat  with cat  diff  status");
let a = 0, b = 0, faster = 0;
for (const s of INCIDENT) {
  const x = withoutCatalog(s), [y, note] = withCatalog(s);
  a += x; b += y; faster += y < x ? 1 : 0;
  console.log(`${s.padEnd(20)}${String(x).padStart(9)} min${String(y).padStart(7)} min` +
    `${String(y - x).padStart(6)}  ${note}`);
}
const upToDate = INCIDENT.length * (STEP.readCatalog + STEP.call);
console.log(`total ${a} min -> ${b} min   average ${(a / INCIDENT.length).toFixed(1)} min -> ` +
  `${(b / INCIDENT.length).toFixed(1)} min   incidents shortened ${faster}/${INCIDENT.length}`);
console.log(`if every record were up to date: ${upToDate} min, average ` +
  `${(upToDate / INCIDENT.length).toFixed(1)} min; current gain ${a - b} min, possible gain ${a - upToDate} min`);

const maintenance = RECORD.length * STEP.review;    // monthly review: four minutes per record
const perIncident = (a - upToDate) / INCIDENT.length;
console.log(`\nmonthly review ${maintenance} min (${RECORD.length} records x ${STEP.review} min);` +
  ` an up-to-date record's gain per incident ${perIncident} min`);
console.log(`threshold: review pays for itself after ${Math.ceil(maintenance / perIncident)} incidents a month;` +
  ` it does not pay off at ${Math.ceil(maintenance / perIncident) - 1} incident`);
```

```
incident             without cat  with cat  diff  status
billing-calculator         26 min      5 min   -21  record up to date
map-edge                   41 min     43 min     2  oncall field empty
tariff-service             26 min     38 min    12  record pointed to the wrong list
field-order                26 min      5 min   -21  record up to date
measurement-store          41 min     53 min    12  record pointed to the wrong list
total 160 min -> 144 min   average 32.0 min -> 28.8 min   incidents shortened 2/5
if every record were up to date: 25 min, average 5.0 min; current gain 16 min, possible gain 135 min

monthly review 48 min (12 records x 4 min); an up-to-date record's gain per incident 27 min
threshold: review pays for itself after 2 incidents a month; it does not pay off at 1 incident
```

Where the catalog pays off is clear: for the two services whose records are up to date, the time to
reach the right list drops from twenty-six minutes to five. Where it does not pay off is more
instructive. In three of the five incidents, the catalog **lengthened** the time. The record with an
empty oncall field adds two minutes of reading cost and says nothing; the two records with the wrong
owner add twelve minutes, because the person paging first writes to the wrong oncall list, waits for a
reply, and only then falls back to the no-catalog path. **A wrong record is more expensive than no
record at all** — the absence of a record misleads nobody.

Overall, the average drops from 32 minutes to 28.8 minutes; the measured gain is sixteen minutes. If
every record agreed with reality, the same five incidents would finish in twenty-five minutes total and
the gain would be a hundred thirty-five minutes. Only **twelve percent** of the value the catalog offers
is actually captured, and what is missing is not the portal — it is the record's accuracy.

#### The Record's Own Cost

The record's cost comes down to two numbers. Half of the catalog's forty-eight fields are read from the
service definition the scaffold generates and cost nothing; the other half — the oncall list and the
dependency list — are written by hand. The hand-written fields age: six of twelve records disagree with
reality, and seven fields are wrong. The aging tracks with age itself; one of the six records younger
than thirty days disagrees with reality, against five of the six records older than thirty days.

The math on maintenance follows from this. Monthly review is forty-eight minutes at four minutes per
record; an up-to-date record's gain per incident is twenty-seven minutes. **The threshold is two
incidents a month**: in a network with only one incident a month, review does not pay for itself; it
pays off after two. In a place with as few services as the measurement network, this threshold sits
close by, and that is exactly why catalog maintenance looks good on paper and does not happen in
practice.

The layer the catalog hides is this: every team no longer has to read other teams' repositories, channel
history, and deployment records — it reads a single record instead. The leak sits at exactly this point
too — the record behaves like a single source of truth but is not one, and when it leaks it gives no
sign of doing so. Those left outside are countable: three services actually connect to the one
unregistered service, meaning the portal's dependency answer is incomplete for those three services. The
teams behind those three services look at the catalog and see a false sense of completeness; a team
working without the catalog at least knows it is looking at an incomplete picture.

There is a third path, and it changes the number at its root. Four of the seven mismatches come from
dependencies, three from ownership; dependency edges can be derived from run records, and ownership from
pipeline runs. If both are derived, the mismatch drops to zero and the hand-written fields drop from
twenty-four to twelve. The one field left is the **oncall list**, and it cannot be derived from any run
history, because oncall is not an observation — it is a decision.

#### Summary

- The graph built from twelve records showed three gaps: 2 unowned services, 2 orphan dependencies, and
  1 service that actually runs but has no record at all in the catalog.
- The two cases of unownedness and the two orphan dependencies were different in kind: one an empty
  field, the other a team that no longer exists; one the record's absence, the other the record's
  staleness.
- The reverse edge was the answer only the catalog could give: when the billing calculator went down,
  the 3 affected services came out with a single walk, while the same question without the catalog
  would require scanning 12 dependency lists.
- Across five incidents, the average time to reach the right list dropped from 32 minutes to 28.8
  minutes, but in 3 of the 5 incidents the catalog lengthened the time; a wrong record added 12 minutes,
  an empty field added 2 minutes.
- If the records had been up to date, the same five incidents would have finished in 25 minutes: the
  measured gain was 16 minutes, the possible gain was 135 minutes.
- 24 of 48 record fields are written by hand and 6 of 12 records disagree with reality; all 7 mismatches
  can be closed by deriving them from run history — the one field that cannot be closed is the oncall
  list.

#### Next Step

The catalog says who is responsible, but it sets no rule anyone is required to follow. For the
measurement network's three services carrying personal data, retention period, log masking, and signed
packages are mandatory; today these rules are written in a document, and every team is expected to read
and apply them. The next lesson compares the same rule set in two arrangements: publishing it as a
document and embedding it in the pipeline. What is measured is the compliance rate, the number of
violations, and the time teams spend — and the real number is how many teams comply without ever seeing
the rule.

### Policy and Compliance Automation

Source: https://academia.sh/en/courses/platform-engineering/policy-and-compliance-automation

The same six rules are run against the same twelve service definitions in two arrangements: publishing the rule as a document and embedding the rule in the pipeline. Compliance rate, violation count, and time teams spend are compared; the real measure is invisibility — how many teams comply without ever seeing the rule, how many ask for an exemption, and how long that exemption's lifespan runs.

The catalog says who is responsible, but it sets no rule anyone is required to follow. For the
measurement network's services carrying personal data, log masking, retention period, and signed
packages are mandatory. Today these rules are written in a document, and every team is expected to read
and apply them. Expecting is not a mechanism; this lesson's question is where the rule actually stands.

**Policy automation** takes a rule out of being text to be read and moves it into a gate that accepts a
declaration. How rules are scanned — the fitness function, the scan order, caught and missed violations
— was measured in M20/K04 and in M22/K05's validation lesson, and is not repeated here. This lesson's
unit of measure is **invisibility**: once a rule is embedded in the pipeline, how many teams comply with
it without ever seeing it.

The measurement runs on the **regional measurement network**: **fictional** software that collects
water-meter readings, validates them, and opens field work orders. The rule set and the definition set
are both a **model** written in `node`.

**PC19.** The rule set is six rules, and it is defined within the lesson in **our own small format**.
The definition set is twelve services; each definition carries eight fields.
**PC20.** In the document arrangement, a team reads the document and applies it to its own services.
Durations are written in advance: reading the document is 20 minutes per team, review is 15 minutes per
service, and fixing a violation caught in an audit is 25 minutes.
**PC21.** In the pipeline-embedded arrangement, four rules are of the kind the pipeline fixes itself,
and two rules produce a reject. Fixing a reject is 8 minutes, and applying for an exemption is 30
minutes.
**PC22.** Exemption records, durations granted, and reasons are written in advance; today is referred to
by the number of days elapsed since the day it was granted.
**PC23.** The one-time cost of embedding the rules in the pipeline is 90 minutes per rule.
**PC24.** A team **seeing** a rule means either getting a reject message or filing for an exemption; a
violation the pipeline silently fixes never becomes visible to the team at all.

#### Two Arrangements, One Rule Set

```js
// rules.mjs — six rules and twelve service definitions; both defined within the lesson. Model.
export const FIELD = ["team", "data-class", "masking", "signing", "health-endpoint",
  "log-level", "retention-days", "smoke"];
const RAW = {
  "reading-collector":   ["meter-reading", "measurement", "off", "yes", "/health", "summary", 365, "yes"],
  "reading-resolver":    ["meter-reading", "measurement", "off", "yes", "/health", "summary", 365, "yes"],
  "validation-flow":     ["validation", "measurement", "off", "yes", "/health", "summary", 400, "yes"],
  "rule-store":          ["validation", "measurement", "off", "no", "/health", "summary", 365, "yes"],
  "billing-calculator":  ["billing", "personal", "on", "yes", "/health", "summary", 400, "yes"],
  "tariff-service":      ["billing", "summary", "off", "no", "none", "detail", 700, "no"],
  "invoice-writer":      ["billing", "personal", "off", "no", "/health", "summary", 500, "no"],
  "field-order":         ["field", "personal", "off", "no", "none", "detail", 800, "no"],
  "map-edge":            ["field", "summary", "off", "no", "none", "summary", 365, "no"],
  "report-compiler":     ["reporting", "summary", "off", "yes", "/health", "summary", 365, "yes"],
  "dashboard-edge":      ["reporting", "summary", "off", "no", "/health", "summary", 365, "no"],
  "measurement-store":   ["meter-reading", "personal", "off", "yes", "/health", "summary", 400, "yes"] };
export const DEF = Object.fromEntries(Object.entries(RAW).map(([a, d]) =>
  [a, Object.fromEntries(FIELD.map((x, i) => [x, d[i]]))]));

// rule: [name, check, type, fix]  — type "automatic" means the pipeline fixes it itself, "reject" means the team fixes it
export const RULE = [
  ["K1 masking", (t) => t["data-class"] !== "personal" || t.masking === "on", "reject",
    (t) => ({ ...t, masking: "on" })],
  ["K2 signed package", (t) => t.signing === "yes", "automatic", (t) => ({ ...t, signing: "yes" })],
  ["K3 health endpoint", (t) => t["health-endpoint"] !== "none", "automatic",
    (t) => ({ ...t, "health-endpoint": "/health" })],
  ["K4 log level", (t) => t["log-level"] !== "detail", "automatic",
    (t) => ({ ...t, "log-level": "summary" })],
  ["K5 retention period", (t) => t["retention-days"] <= 400, "reject", (t) => ({ ...t, "retention-days": 400 })],
  ["K6 smoke test", (t) => t.smoke === "yes", "automatic", (t) => ({ ...t, smoke: "yes" })]];

// exemption: [service, rule, day granted (days ago from today), duration granted (days), reason]
export const EXEMPTION = [
  ["measurement-store", "K1 masking", 210, 30, "masking raw measurements breaks validation"],
  ["field-order", "K5 retention period", 120, 90, "field records must be kept for 800 days"],
  ["tariff-service", "K4 log level", 240, 14, "an open bug investigation is ongoing"],
  ["map-edge", "K6 smoke test", 45, 60, "the external endpoint has no test harness"]];

export const TEAM = ["meter-reading", "validation", "billing", "field", "reporting"];
export const DURATION = { read: 20, review: 15, fix: 25, reject: 8, apply: 30, embed: 90 };
```

The rule set is the same in both arrangements; the only thing that changes is where the rule stands. In
the document arrangement, the rule is text, and enforcement depends on the team's attention. In the
pipeline-embedded arrangement, the rule sits in a gate, and for four rules the gate does the fixing
itself, while for two rules it produces a reject. The distinction between rules is not technical — it is
informational: the platform knows the health endpoint's path, but only the service's own team knows the
retention period.

```js
// arrangement.mjs — the same rule set is run in two arrangements: published as a document and embedded in the pipeline.
import { DEF, RULE, EXEMPTION, TEAM, DURATION } from "./rules.mjs";

const SERVICE = Object.keys(DEF), pairs = SERVICE.length * RULE.length;
const exempt = (s, k) => EXEMPTION.some(([a, b]) => a === s && b === k);

const document = () => {                             // the rule is written in a document; the team reads and applies it
  let violations = 0;
  const teamViolations = Object.fromEntries(TEAM.map((e) => [e, 0]));
  for (const s of SERVICE) for (const [name, check] of RULE)
    if (!check(DEF[s])) { violations++; teamViolations[DEF[s].team]++; }
  const time = TEAM.length * DURATION.read + SERVICE.length * DURATION.review + violations * DURATION.fix;
  return { violations, compliant: pairs - violations, time, teamViolations, seen: pairs };
};

const pipeline = () => {                             // the rule is embedded in the pipeline: automatic fix or reject
  let automatic = 0, reject = 0, exempted = 0;
  const aware = new Set();
  for (const s of SERVICE) {
    let t = DEF[s];
    for (const [name, check, type, fix] of RULE) {
      if (check(t)) continue;
      if (exempt(s, name)) { exempted++; aware.add(t.team); continue; }   // an exemption makes the rule visible
      if (type === "automatic") { t = fix(t); automatic++; }
      else { t = fix(t); reject++; aware.add(t.team); }
    }
  }
  const time = reject * DURATION.reject + EXEMPTION.length * DURATION.apply;
  return { violations: exempted, compliant: pairs - exempted, time, automatic, reject, aware,
    seen: reject + EXEMPTION.length };
};

const B = document(), H = pipeline();
console.log(`rules ${RULE.length}  services ${SERVICE.length}  pairs checked ${pairs}` +
  `  teams ${TEAM.length}`);
console.log(`document arrangement : compliant ${B.compliant}/${pairs} (${(B.compliant / pairs * 100).toFixed(0)}%)` +
  `  violations ${B.violations}  team time spent ${B.time} min`);
console.log(`embedded in pipeline  : compliant ${H.compliant}/${pairs} (${(H.compliant / pairs * 100).toFixed(0)}%)` +
  `  recorded exemptions ${H.violations}  team time spent ${H.time} min`);
console.log(`the pipeline fixed ${H.automatic} violations without ever asking a team, rejected ${H.reject} violations`);
console.log(`violations per team (document arrangement): ` +
  TEAM.map((e) => `${e} ${B.teamViolations[e]}`).join("  "));

const neverSaw = TEAM.filter((e) => !H.aware.has(e));
console.log(`\ninvisibility: ${pairs - H.seen}/${pairs} rule-pairs complied with, never seen at all` +
  `  (what teams saw ${H.seen}: ${H.reject} rejects + ${EXEMPTION.length} exemptions)`);
console.log(`teams that never saw the rule ${neverSaw.length}/${TEAM.length}: ${neverSaw.join(", ")}` +
  `   in the document arrangement all five of five teams read all ${RULE.length} rules`);
const embed = RULE.length * DURATION.embed;
console.log(`embedding cost ${embed} min (one-time, ${RULE.length} rules x ${DURATION.embed} min);` +
  ` gain per period ${B.time - H.time} min; pays off in ${(embed / (B.time - H.time)).toFixed(2)} periods`);
```

```
rules 6  services 12  pairs checked 72  teams 5
document arrangement : compliant 50/72 (69%)  violations 22  team time spent 830 min
embedded in pipeline  : compliant 68/72 (94%)  recorded exemptions 4  team time spent 152 min
the pipeline fixed 14 violations without ever asking a team, rejected 4 violations
violations per team (document arrangement): meter-reading 1  validation 1  billing 9  field 9  reporting 2

invisibility: 64/72 rule-pairs complied with, never seen at all  (what teams saw 8: 4 rejects + 4 exemptions)
teams that never saw the rule 2/5: validation, reporting   in the document arrangement all five of five teams read all 6 rules
embedding cost 540 min (one-time, 6 rules x 90 min); gain per period 678 min; pays off in 0.80 periods
```

Three numbers move in the same direction. The compliance rate climbs from 69 percent to 94 percent,
violations drop from 22 to 4 recorded exemptions, and the time teams spend falls from 830 minutes to 152
minutes. The one-time cost of embedding the rules in the pipeline is 540 minutes, and the gain from a
single audit period covers it.

The real number is on the fourth line: **the pipeline fixed fourteen violations without ever asking a
team.** In sixty-four of seventy-two pairs, the rule was met without ever becoming visible; what teams
saw was only eight pairs, and of those, four were rejects and four were exemptions. Two of the five
teams — validation and reporting — saw none of the rules this period, while in the document arrangement
all five had to read all six rules in full.

How violations distribute across teams shows something else. Eighteen of the document arrangement's 22
violations come from two teams: billing 9, field 9. The document was written equally for everyone, and
not everyone read it equally. In the pipeline-embedded arrangement, this inequality does not disappear —
it **becomes invisible**: because the pipeline fixes it silently, which team was careless is no longer
written down anywhere. The hidden layer here hides not just the rule, but the information about whose
hands the rule was violated in.

The hidden layer leaks back somewhere. When the fix the pipeline makes silently changes the behavior a
team was counting on, the team learns about it from the outcome, not from the rule's text: when the log
level is pulled from detail down to summary, an open bug investigation loses the detail it was working
from. That leak is exactly what leads to filing for an exemption; it is why the tariff service's
exemption was requested. The silent fix raises the compliance rate and, at the same time, opens up an
area in the team's own system where it does not know what changed or why.

#### An Exemption's Lifespan

```js
// exemption.mjs — the lifespan of exemptions: duration granted, time elapsed, expired and still-open ones.
import { DEF, RULE, EXEMPTION, DURATION } from "./rules.mjs";

const automatic = new Set(RULE.filter(([, , t]) => t === "automatic").map(([a]) => a));
console.log("service            rule                 granted  elapsed  overdue  status");
let grantedTotal = 0, elapsedTotal = 0, overdueOpen = 0;
for (const [s, k, elapsed, granted] of EXEMPTION) {
  const overdue = elapsed - granted;
  grantedTotal += granted; elapsedTotal += elapsed; overdueOpen += overdue > 0 ? 1 : 0;
  console.log(`${s.padEnd(19)}${k.padEnd(21)}${String(granted).padStart(5)} d${String(elapsed).padStart(7)}` +
    `${String(Math.max(0, overdue)).padStart(9)}  ${overdue > 0 ? "expired, still open" : "within duration"}`);
}
const n = EXEMPTION.length;
console.log(`average duration granted ${(grantedTotal / n).toFixed(1)} days, average elapsed ` +
  `${(elapsedTotal / n).toFixed(1)} days -> lifespan ${(elapsedTotal / grantedTotal).toFixed(1)} times the duration granted`);
console.log(`expired and still open ${overdueOpen}/${n}`);

const autoExempt = EXEMPTION.filter(([, k]) => automatic.has(k));
const teams = [...new Set(EXEMPTION.map(([s]) => DEF[s].team))];
console.log(`exemptions on automatic rules ${autoExempt.length}/${n}` +
  ` (${autoExempt.map(([s, k]) => `${s}/${k.split(" ")[0]}`).join(", ")});` +
  ` these rules became visible to their team only because of the exemption`);
console.log(`services that would not pass the pipeline with no exemption regime ${n}/${Object.keys(DEF).length}` +
  ` (${Math.round(n / Object.keys(DEF).length * 100)}%), ${teams.length} teams: ${teams.join(", ")}`);

const pairs = Object.keys(DEF).length * RULE.length;
console.log(`\napplication time ${n * DURATION.apply} min; the exemption record makes ${n} violations visible,` +
  ` the same ${n} violations were indistinguishable within the 22 violations of the document arrangement`);
for (const q of [0, 1, 2, 3, 4]) {                  // assumption: one new exemption per quarter
  const compliant = pairs - (n + q);
  if (q === 0 || q === 4) console.log(`${q === 0 ? "today" : "quarter " + q}: open exemptions ` +
    `${n + q}, compliant ${compliant}/${pairs} (${(compliant / pairs * 100).toFixed(0)}%)`);
}
```

```
service            rule                 granted  elapsed  overdue  status
measurement-store  K1 masking              30 d    210      180  expired, still open
field-order        K5 retention period     90 d    120       30  expired, still open
tariff-service     K4 log level            14 d    240      226  expired, still open
map-edge           K6 smoke test           60 d     45        0  within duration
average duration granted 48.5 days, average elapsed 153.8 days -> lifespan 3.2 times the duration granted
expired and still open 3/4
exemptions on automatic rules 2/4 (tariff-service/K4, map-edge/K6); these rules became visible to their team only because of the exemption
services that would not pass the pipeline with no exemption regime 4/12 (33%), 3 teams: meter-reading, field, billing

application time 120 min; the exemption record makes 4 violations visible, the same 4 violations were indistinguishable within the 22 violations of the document arrangement
today: open exemptions 4, compliant 68/72 (94%)
quarter 4: open exemptions 8, compliant 64/72 (89%)
```

An exemption is the one recorded way to step outside the pipeline, and it has been used on four
services. The durations granted are short — an average of 48.5 days — but the time that has actually
elapsed averages 153.8 days: **an exemption's lifespan runs three times its granted duration**, and
three of the four are still open even though their duration has expired. The longest is overdue by two
hundred twenty-six days, and its reason is a supposedly temporary bug investigation.

Two of the exemptions sit on rules the pipeline fixes itself. These two rules became visible to their
teams **only because of the exemption**: an exemption is the only way to make an invisible rule visible.
A team that complies without ever seeing a rule also cannot argue with it; only the team that requests
an exemption ever notices the rule is wrong.

The count of who stays outside is also read from here. Without the exemption regime, these four
services would never pass the pipeline at all: four of twelve services, thirty-three percent, and three
teams would sit entirely outside the golden path. The exemption's cost is 120 minutes of application
time, and in exchange four violations become visible by name; in the document arrangement, those same
four violations were indistinguishable within the 22 violations. But the record does not produce closure
on its own: if one new exemption opens per quarter and none of them close, compliance drops from 94
percent to 89 percent by the fourth quarter. Closing an exemption is the job of whoever granted it, not
the pipeline's.

#### Summary

- The same 6 rules were run against the same 12 service definitions: compliance was 50/72 (69%) in the
  document arrangement and 68/72 (94%) in the pipeline-embedded arrangement; violations dropped from 22
  to 4 recorded exemptions.
- Team time spent fell from 830 minutes to 152 minutes; embedding's 540-minute one-time cost was
  covered within a single audit period.
- Invisibility was measured: in 64 of 72 pairs, the rule was met without ever becoming visible; 2 of 5
  teams saw none of the rules this period, while in the document arrangement all 5 of 5 teams had to
  read all 6 rules.
- 18 of the 22 violations in the document arrangement came from two teams; the pipeline fixed them, but
  at the same time made invisible the information about which team was careless.
- The 4 exemptions had an average granted duration of 48.5 days and an average elapsed time of 153.8
  days — a lifespan 3.2 times the duration granted, and 3 of the 4 open even though their duration had
  expired.
- Without the exemption regime, 4 of 12 services (33%) and 3 teams would stay entirely outside the
  golden path; an exemption is the one way that shows a team the rule the pipeline has hidden.

#### Next Step

The environment now arrives the moment it is requested, the scaffold starts new services, the catalog
shows an owner, and the rules are embedded in the pipeline. All four capabilities hid something, and
somebody paid the cost of what they hid — but who that cost gets charged to has not been asked yet. The
measurement network has a shared repository, a shared pipeline pool, and a shared dashboard stack; their
bill is written today to a single line item, and no team sees what it itself consumes. The next lesson
takes up team-based accounting: allocating shared resources, the unallocatable line item, and the effect
of making cost visible on behavior.

### Cost Visibility

Source: https://academia.sh/en/courses/platform-engineering/cost-visibility

Team-based resource accounting: only 47.5% of a daily 1,376 fictional units can be directly assigned, each of three allocation rules misleads a different team in a different direction (the equal-share rule loads field +79.0 while the request rule loads meter-reading +140.7), only 15.6% of the bill drops after cost is shown while 29.7% never moves at all, and central accounting pays for itself at three teams but two teams opting out drags the five-team gain of 85 units down to 3 units.

The previous lesson embedded the rules in the pipeline: five teams complied without ever opening the
policy engine, the number of exemptions was counted, and the rule's invisibility was written down as a
gain. For those same five teams, one thing is still invisible — **how much they spend**. The platform
buys the resource and reads the bill on their behalf.

Unit cost, idle resources, and economies of scale were measured in M22/K06; here that bill is the
input. This lesson's question is a single division: how is the same amount split across **five teams**,
how much cannot be split, and what changes once the split amount is shown to each team.

#### Splitting the Bill Across Five Teams

The example is again the fictional **regional measurement network**, and the five teams are fictional:
meter reading, validation, billing, field, reporting. The line items, prices, and usage measures are
fictional; **the currency is fictional too** and is called `u`, and it does not correspond to any real
price list.

**PC25:** there are nine cost items, and each item's daily cost is written down. **PC26:** items
are three types — **directly** assignable (carries a team tag), **shared** (cluster control plane,
shared build and deploy pipeline, monitoring stack), and **unallocatable**; the last of these has no
relationship to any team's usage and is paid even with no team. **PC27:** a shared item's **actual
driver** is known in the model (concurrent jobs, build minutes, series count) but is not in the bill.
**PC28:** allocation rules can use only measures readable from the bill: equal share, observed
resource-usage ratio, request count. **PC29:** once cost is shown to a team, each item drops by its own
**audit share**; this is a single-period reaction. **PC30:** the central accounting pipeline has a fixed
daily cost, and so does a team standing up its own accounting.

Teams, items, and the bill are all a **model** built within the lesson; there is no real bill.

```js
// cost/item.mjs — team-based accounting model: there is no real bill. Teams, line items, and
// prices are a data structure; the currency is fictional and is called `u`, and it does not
// correspond to any real price list. A shared item's *actual driver* is known in the model,
// it is not in the bill; allocation rules can only use measures readable from the bill.

export const TEAM = ["meter-reading", "validation", "billing", "field", "reporting"];
export const N = TEAM.length;

// directly assignable: name, daily u per team, drop share after visibility
export const DIRECT = [
  ["service instances",         [96, 54, 120, 38, 72], 0.18],
  ["block storage",             [24, 18,  40, 12, 58], 0.10],
  ["temporary environment copies", [30, 22, 26, 10, 34], 0.55],
];

// shared: name, daily u, driver name, driver values (known in the model), drop share
export const SHARED = [
  ["cluster control plane", 210, "concurrent jobs", [34, 12, 28, 6, 9],           0.00],
  ["shared build and deploy", 168, "build minutes",  [220, 180, 260, 40, 610],     0.12],
  ["monitoring stack",      146, "series count",    [8200, 3100, 5400, 900, 2600], 0.30],
];

// unallocatable: has no relationship to any team's usage; paid even with no team
export const UNALLOCATABLE = [
  ["regional backbone network", 88], ["audit log retention", 46], ["standby region", 64],
];

// team measures readable from the bill
export const METRIC = {
  usage: [0.31, 0.17, 0.29, 0.08, 0.15],   // observed resource-usage ratio
  requests: [412, 96, 138, 21, 33],        // daily thousand requests
};

export const CENTRAL = 120;   // fixed daily cost of the central accounting pipeline (u)
export const PER_TEAM = 41;   // daily cost of a team standing up its own accounting (u)

export const sum = (v) => v.reduce((a, b) => a + b, 0);
export const ratio = (v) => { const t = sum(v); return v.map((x) => x / t); };

export const RULE = {
  equal: () => TEAM.map(() => 1 / N),
  usage: () => ratio(METRIC.usage),
  requests: () => ratio(METRIC.requests),
};

// each shared item's *actual* per-team share: every item is split by its own driver
export const actualShare = () => TEAM.map((_, e) =>
  sum(SHARED.map(([, u, , d]) => u * ratio(d)[e])));

// the deviation a rule produces on shared items: charged minus actual
export const deviation = (rule) => {
  const p = RULE[rule](), totalU = sum(SHARED.map((x) => x[1])), actual = actualShare();
  return TEAM.map((_, e) => totalU * p[e] - actual[e]);
};
```

```js
// cost/measure.mjs — item breakdown, the deviation of three allocation rules, visibility's
// effect on behavior, and central accounting's threshold. All numbers derive from the model above.
import { TEAM, N, DIRECT, SHARED, UNALLOCATABLE, RULE, CENTRAL, PER_TEAM,
  sum, deviation } from "./item.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 t0 = process.hrtime.bigint();

const D = sum(DIRECT.map((x) => sum(x[1])));
const P = sum(SHARED.map((x) => x[1]));
const B = sum(UNALLOCATABLE.map((x) => x[1]));
const T = D + P + B, pctOf = (a) => `${(100 * a / T).toFixed(1)}%`;

const COUNT = DIRECT.length + SHARED.length + UNALLOCATABLE.length;
console.log(`${N} teams, ${COUNT} items, daily ${T} u (fictional currency)`);

console.log("\n1. item breakdown: what is directly assigned, what is shared, what is unallocatable");
const A = [-30, 17, 11, 9, 18, 9];
write(A, "item", "type", "daily u", "share", "actual driver", "audit");
for (const [name, v, d] of DIRECT)
  write(A, name, "direct", sum(v), pctOf(sum(v)), "team tag", `${(100 * d).toFixed(0)}%`);
for (const [name, u, driver, , d] of SHARED)
  write(A, name, "shared", u, pctOf(u), driver, `${(100 * d).toFixed(0)}%`);
for (const [name, u] of UNALLOCATABLE)
  write(A, name, "unallocatable", u, pctOf(u), "none", "0%");
write(A, "total", "-", T, "100%", "-", "-");
console.log(`  direct ${D} u (${pctOf(D)}), shared ${P} u (${pctOf(P)}),`);
console.log(`  unallocatable ${B} u (${pctOf(B)}); not directly assignable ${pctOf(P + B)}`);

console.log("\n2. allocation rule sweep: charged minus actual (u/day, shared items)");
const C = [-11, ...TEAM.map(() => 15), 12];
write(C, "rule", ...TEAM, "abs. total");
const score = {};
for (const k of Object.keys(RULE)) {
  const s = deviation(k);
  score[k] = sum(s.map(Math.abs));
  write(C, k, ...s.map((x) => (x >= 0 ? "+" : "") + x.toFixed(1)), score[k].toFixed(1));
}
const best = Object.keys(score).reduce((a, b) => (score[a] <= score[b] ? a : b));
for (const k of Object.keys(RULE)) {
  const s = deviation(k), i = s.indexOf(Math.max(...s)), j = s.indexOf(Math.min(...s));
  console.log(`  ${k.padEnd(9)} charges most to ${TEAM[i]} +${s[i].toFixed(1)}, ` +
    `saves most for ${TEAM[j]} ${s[j].toFixed(1)}`);
}
console.log(`  least wrong rule: ${best}`);
console.log(`  every rule also spreads the ${B} u unallocatable item; that amount's deviation`);
console.log("  cannot be measured, because there is no actual share to compare against");

console.log("\n3. visibility's effect on behavior: after cost is shown to a team");
const E = [-30, 11, 9, 11, 30];
write(E, "item", "daily u", "audit", "dropped u", "reason it stays fixed");
const items = [
  ...DIRECT.map(([name, v, d]) => [name, sum(v), d, "-"]),
  ...SHARED.map(([name, u, , , d]) => [name, u, d, d ? "-" : "platform decision"]),
  ...UNALLOCATABLE.map(([name, u]) => [name, u, 0, "not under the team's control"]),
];
let dropped = 0, fixedU = 0, fixedCount = 0;
for (const [name, u, d, reason] of items) {
  dropped += u * d;
  if (d === 0) { fixedU += u; fixedCount++; }
  write(E, name, u, `${(100 * d).toFixed(0)}%`, (u * d).toFixed(1), reason);
}
console.log(`  total drop ${dropped.toFixed(1)} u/day (${(100 * dropped / T).toFixed(1)}%)`);
console.log(`  never moves ${fixedCount} items, ${fixedU} u/day (${pctOf(fixedU)})`);

console.log("\n4. central accounting's threshold and the cost of opting out");
const F = [-8, 13, 15, 11, 18];
write(F, "teams", "central u", "separate u", "gain", "pays for itself");
for (const n of [1, 2, 3, 5, 8]) {
  const separate = n * PER_TEAM;
  write(F, n, CENTRAL, separate, (separate - CENTRAL).toFixed(0), separate > CENTRAL ? "yes" : "no");
}
const s = deviation(best), sorted = TEAM.map((e, i) => [e, Math.abs(s[i])])
  .sort((a, b) => b[1] - a[1]).slice(0, 2);
const optOut = sorted.length * PER_TEAM, gross = N * PER_TEAM - CENTRAL;
const threshold = Math.ceil(CENTRAL / PER_TEAM);
console.log(`  threshold ${threshold} teams; gross gain at ${N} teams ${gross} u/day`);
console.log(`  under the ${best} rule, the ones deviating most, ${sorted.map((x) => x[0]).join(" and ")}, stand up`);
console.log(`  their own measurement: ${optOut} u/day of duplicated measurement, net gain ` +
  `${gross - optOut} u/day`);

const ms = Number(process.hrtime.bigint() - t0) / 1e6;   // raw measurement is not printed, the band is audited
console.log(`\nfour tables and rule sweep in the 2000 ms band: ${ms < 2000 ? "yes" : "no"}`);
```

```
5 teams, 9 items, daily 1376 u (fictional currency)

1. item breakdown: what is directly assigned, what is shared, what is unallocatable
item                                       type    daily u    share     actual driver    audit
service instances                        direct        380    27.6%          team tag      18%
block storage                            direct        152    11.0%          team tag      10%
temporary environment copies             direct        122     8.9%          team tag      55%
cluster control plane                    shared        210    15.3%   concurrent jobs       0%
shared build and deploy                  shared        168    12.2%     build minutes      12%
monitoring stack                         shared        146    10.6%      series count      30%
regional backbone network         unallocatable         88     6.4%              none       0%
audit log retention               unallocatable         46     3.3%              none       0%
standby region                    unallocatable         64     4.7%              none       0%
total                                         -       1376     100%                 -        -
  direct 654 u (47.5%), shared 524 u (38.1%),
  unallocatable 198 u (14.4%); not directly assignable 52.5%

2. allocation rule sweep: charged minus actual (u/day, shared items)
rule         meter-reading     validation        billing          field      reporting  abs. total
equal                -62.9          +31.0          -33.6          +79.0          -13.5       220.0
usage                 -5.3          +15.3          +13.5          +16.1          -39.7        89.8
requests            +140.7           -1.9          -35.1          -10.1          -93.6       281.4
  equal     charges most to field +79.0, saves most for meter-reading -62.9
  usage     charges most to field +16.1, saves most for reporting -39.7
  requests  charges most to meter-reading +140.7, saves most for reporting -93.6
  least wrong rule: usage
  every rule also spreads the 198 u unallocatable item; that amount's deviation
  cannot be measured, because there is no actual share to compare against

3. visibility's effect on behavior: after cost is shown to a team
item                              daily u    audit  dropped u         reason it stays fixed
service instances                     380      18%       68.4                             -
block storage                         152      10%       15.2                             -
temporary environment copies          122      55%       67.1                             -
cluster control plane                 210       0%        0.0             platform decision
shared build and deploy               168      12%       20.2                             -
monitoring stack                      146      30%       43.8                             -
regional backbone network              88       0%        0.0  not under the team's control
audit log retention                    46       0%        0.0  not under the team's control
standby region                         64       0%        0.0  not under the team's control
  total drop 214.7 u/day (15.6%)
  never moves 4 items, 408 u/day (29.7%)

4. central accounting's threshold and the cost of opting out
teams       central u     separate u       gain   pays for itself
1                 120             41        -79                no
2                 120             82        -38                no
3                 120            123          3               yes
5                 120            205         85               yes
8                 120            328        208               yes
  threshold 3 teams; gross gain at 5 teams 85 u/day
  under the usage rule, the ones deviating most, reporting and field, stand up
  their own measurement: 82 u/day of duplicated measurement, net gain 3 u/day

four tables and rule sweep in the 2000 ms band: yes
```

The numbers are of the **measurement** class; their inputs are the assumptions above.

#### Half the Bill Cannot Be Directly Assigned

The first table splits the daily 1,376 units into three types. Directly assignable items add up to 654
units (47.5%): service instances, block storage, and temporary environment copies carry a team tag, and
which team used how much is readable from the bill. The remaining **52.5%** is not.

Of that, 524 units (38.1%) are **shared** items. The cluster control plane, the shared build and deploy
pipeline, and the monitoring stack are each run once for all five teams; this is exactly the layer the
platform took over. It appears as a single lump sum, and belongs to nobody.

That leaves 198 units (14.4%) of **unallocatable** items. The regional backbone network, audit log
retention, and the standby region cost the same amount whether there are three teams or five; no team's
departure shrinks them. This item has no owner and no driver — when an allocation rule is applied to it,
the number it produces is not a **measurement**, it is a **decision**.

#### Each Rule Misleads a Different Team in a Different Direction

The second table sweeps the three rules over the shared 524 units. The point of comparison is the
**actual share** known in the model: each shared item is split by its own driver. The deviation is the
amount the rule charges minus the actual share; a plus sign shows the team was overcharged, a minus sign
shows it got off easy.

**Equal share** gives the crudest result (absolute total 220.0). The field team is overcharged **+79.0
units**: it is the smallest of the five teams, its concurrent jobs are six, its build minutes are forty,
its series count is nine hundred — yet it pays one-fifth. Meter reading gets off **−62.9 units**; it is
the heaviest user of the cluster control plane but never sees that in its bill.

**Usage ratio** is the least wrong rule (89.8), but it is not one-directional: reporting gets off by
**−39.7**, because even though its resource usage is low, it alone accounts for 46% of build minutes.
The observed usage ratio never sees the build pipeline's driver at all.

**Request count** is the most misleading rule (281.4). Meter reading is charged **+140.7 units** because
it has the highest count, 412 thousand requests a day; yet its cost per request is low. Reporting gets
off by **−93.6**: its nightly batch job never turns into a request.

The three rules mislead three different teams in three different directions, and none corrects another's
error. On top of that, the 198-unit unallocatable item is still spread out under every rule, and that
amount's deviation **cannot be measured** — there is no actual share to compare it against. Under
**showback** this is a tolerable wrong: the number is shown, nobody pays it. Under **chargeback** the
same number comes out of a budget, and the argument shifts onto the rule itself.

#### What Visibility Moves

The third table shows the cost to each team and counts what drops. The total drop is **214.7 units**
(15.6%). The drop does not come from where it is expected: the highest response rate is in **temporary
environment copies** (55%, 67.1 units) — a team can delete an unused environment itself. Service
instances drop a similar amount thanks to their size (68.4 units), but their rate is only 18%; changing
size requires a deployment. The monitoring stack drops 30%, because a team can thin out its own tags.

Four items **never move at all**: 408 units, **29.7%** of the bill. The cluster control plane is a
platform decision; the regional backbone network, audit log retention, and standby region are not under
any team's control — audit log retention, in fact, is itself the cost of the policy rule from the
previous lesson. Showing these items on a team's bill produces no drop, only a **question**.

This is where the abstraction leaks. A team started using the platform to avoid seeing its own
infrastructure layer; now four items sit on its bill, and changing their amount means learning that
layer anyway. **Showing an item nobody controls is not visibility — it is noise.**

#### Central Accounting's Threshold

The fourth table asks whether the platform pays off at all. The central accounting pipeline costs a
fixed 120 units a day; a team standing up its own accounting pays 41 units. The threshold is **three
teams**: at one team, the central pipeline is 79 units **more expensive**; at two teams, 38 units more
expensive. With few teams, platform accounting does not pay for itself, and the table shows this is
arithmetic, not a preference.

At five teams, the gross gain is 85 units. Onto that come the ones who opt out: reporting (−39.7) and
field (+16.1), the two that deviate most under the usage rule, do not trust the result and stand up
their own measurement. Their duplicated measurement costs **82 units**; the gross 85-unit gain drops to
**3 units**. Five teams run central accounting at a point where it almost pays nothing, and the reason
is not the cost itself — it is **distrust of the allocation rule**.

#### Summary

- Only 47.5% of a daily 1,376 fictional units is directly assigned; 38.1% is shared, 14.4% is
  unallocatable — **52.5%** of the bill cannot be written to a team without a rule.
- The three allocation rules mislead three different teams in opposite directions: equal share loads
  field by +79.0, request count loads meter reading by +140.7 and lets reporting off by −93.6; even
  usage ratio, the least wrong rule, has an absolute deviation of 89.8 units.
- The 198-unit unallocatable item is spread out under every rule, but its deviation cannot be measured;
  a wrong that is tolerable under showback comes out of a budget under chargeback.
- The bill drops 15.6% once cost is shown; the highest response is in temporary environment copies
  (55%). Four items never move at all — 408 units, 29.7% — because they are not under a team's control.
- Central accounting pays for itself after three teams; at two teams it is 38 units more expensive. At
  five teams the gross gain is 85 units, but two teams that do not trust the rule standing up their own
  measurement drags the gain down to 3 units.

#### Next Step

This lesson's numbers say how much the platform costs, but not who it benefits. The next lesson spreads
the question out over time: the five teams do not move to the new arrangement all at once, they move in
waves, and the wave's size sets both how long the two arrangements run side by side and how many are
left behind. The cost to a team left behind is a line item that never appears in this lesson's table at
all.

### Migration Strategies

Source: https://academia.sh/en/courses/platform-engineering/migration-strategies

Splitting a platform migration into waves: when five fictional teams migrate in a single wave, the program drops from 65 days to 15 days while the number migrated drops from 4 to 2; the three reasons for being left behind (incompatible requirement, critical period, orphaned service) kick in one after another; dual-run load drops from 105.3 to 0, while peak support load stays under the 6-unit budget only in the single-team wave; and, because of the one team that never migrates, the legacy arrangement stays open indefinitely, costing 41,610 fictional units over 1,095 days.

The previous lesson split the bill across five teams and counted the item that could not be split. That
table's silent assumption was that all five teams were already on the platform. In reality none of them
was born there — every one of them either migrated from somewhere or has not migrated yet. What this
lesson measures is that migration itself.

The gradual-migration pattern itself — the **strangler fig** that wraps around the legacy arrangement
and takes its place piece by piece — was measured in M16/K06 and M20/K03; it is not repeated here. The
question here is not about the pattern, it is about the **schedule**: what happens when five teams
migrate in groups of a given size.

#### Splitting the Program into Waves

The example is again the fictional **regional measurement network**, and the five teams are fictional.
The loads, obstacles, and costs are fictional; **the currency is fictional too** and is called `u`.

**PC31:** every team has a **migration load**, in support-units. **PC32:** the program is planned
as `ceil(5 / wave)` waves; each wave takes 5 days of prep and 8 days of cutover-and-validate. A team
that misses its wave falls to the next one, and if it misses the last wave too it is **left behind**.
**PC33:** the platform team's daily support budget is 6 units; if a wave's load exceeds the eight-day
budget, the excess extends the wave. **PC34:** there are three obstacles — a **critical period** (cannot
migrate between days 0 and 30), an **incompatible requirement** (can never migrate; the incompatibility
surfaces during validation and wastes 40% of the load), an **orphaned service** (load rises to 1.6
times, and the team migrates only if the wave has slack). **PC35:** every migrated team produces 0.9
units of daily **dual-run load** up to the last cutover. **PC36:** the legacy arrangement's fixed daily
operating cost, and the one-time cost of adding the missing capability to the platform, are both written
down.

The plan and the schedule are a **model** built within the lesson; there is no real migration and no
randomness.

```js
// migration/wave.mjs — migration wave model: there is no real migration. Five teams, their
// obstacles, and the platform team's support budget are a data structure. No randomness, the
// plan is entirely deterministic. The program is planned as `ceil(N/w)` waves; a team that
// misses its wave falls to the next one, missing the last wave too leaves it behind.

export const PREP = 5;          // every wave's fixed prep duration (days)
export const BASE = 8;          // every wave's cutover-and-validate duration (days)
export const CAPACITY = 6;      // the platform team's daily support budget (support-units)
export const DUAL = 0.9;        // daily dual-run load per migrated team
export const WASTE = 0.4;       // fraction of load wasted on a failed attempt
export const STEPS = 14;        // infrastructure steps a team would rewrite if it migrated itself
export const REMAINING_STEPS = 4;   // steps left to the team after the platform takes over
export const LEGACY_FIXED = 38;     // the legacy arrangement's fixed daily operating cost (u)
export const CAPABILITY = 2400;     // one-time cost of adding the missing capability to the platform (u)

// team: name, migration load (support-units), obstacle, critical-period window (day range)
export const TEAM = [
  { name: "meter-reading", load: 18, obstacle: null, window: null },
  { name: "validation", load: 14, obstacle: null, window: null },
  { name: "billing", load: 22, obstacle: "critical period", window: [0, 30] },
  { name: "field", load: 26, obstacle: "incompatible requirement", window: null },
  { name: "reporting", load: 16, obstacle: "orphaned service", window: null },
];
export const N = TEAM.length;
export const effectiveLoad = (e) => (e.obstacle === "orphaned service" ? e.load * 1.6 : e.load);
const overlaps = (p, a, b) => p !== null && !(b < p[0] || a > p[1]);

export function plan(w) {
  const count = Math.ceil(N / w), waves = [], migrated = [], leftBehind = [];
  let t = 0, remaining = TEAM.map((_, i) => i), wasted = 0;
  for (let d = 0; d < count && remaining.length; d++) {
    const start = t + PREP, end = start + BASE - 1, selected = [];
    let load = 0;
    for (const i of remaining) {
      if (selected.length >= w) break;
      const e = TEAM[i], effLoad = effectiveLoad(e);
      if (overlaps(e.window, start, end)) continue;    // critical period: falls to the next wave
      if (e.obstacle === "orphaned service" && load + effLoad > CAPACITY * BASE) continue;  // no slack
      selected.push(i); load += effLoad;
    }
    if (!selected.length) { t += PREP + BASE; continue; }
    const delay = Math.max(0, Math.ceil((load - CAPACITY * BASE) / CAPACITY));
    const duration = PREP + BASE + delay, cutover = t + duration - 1;
    for (const i of selected) {
      if (TEAM[i].obstacle === "incompatible requirement") {   // incompatibility surfaces during validation
        leftBehind.push([i, "incompatible requirement"]); wasted += TEAM[i].load * WASTE;
      } else migrated.push([i, cutover]);
    }
    waves.push({ start, end, duration, load, selected });
    remaining = remaining.filter((i) => !selected.includes(i));
    t += duration;
  }
  for (const i of remaining) leftBehind.push([i, TEAM[i].obstacle ?? "no wave left"]);

  const cutovers = migrated.map((x) => x[1]);
  const first = cutovers.length ? Math.min(...cutovers) : 0;
  const last = cutovers.length ? Math.max(...cutovers) : 0;
  const dayLoad = new Array(t + 1).fill(0);
  for (const x of waves) for (let g = x.start; g <= x.end; g++) dayLoad[g] += x.load / BASE;
  for (const [, k] of migrated) for (let g = k + 1; g <= last; g++) dayLoad[g] += DUAL;
  return { w, waves: waves.length, program: t, migrated, leftBehind, wasted, overlap: last - first,
    peak: Math.max(...dayLoad), dual: migrated.reduce((a, [, k]) => a + DUAL * (last - k), 0),
    steps: migrated.length * (STEPS - REMAINING_STEPS) };
}
```

```js
// migration/measure.mjs — wave-size sweep, why each straggler is left behind, and the cost of never migrating at all.
import { TEAM, N, CAPACITY, LEGACY_FIXED, CAPABILITY, STEPS, REMAINING_STEPS, plan } from "./wave.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 t0 = process.hrtime.bigint();

const W = [1, 2, 3, 5], SETTING = W.map(plan);
console.log(`${N} teams, platform support budget ${CAPACITY} units/day`);

console.log("\n1. wave-size sweep");
const A = [-7, 8, 10, 10, 9, 15, 11, 11];
write(A, "wave", "count", "program", "migrated", "behind", "cutover span", "peak load",
  "dual load");
for (const r of SETTING)
  write(A, r.w, r.waves, `${r.program} days`, `${r.migrated.length}/${N}`, r.leftBehind.length,
    `${r.overlap} days`, r.peak.toFixed(2), r.dual.toFixed(1));
console.log(`  settings exceeding the support budget: ` +
  SETTING.filter((r) => r.peak > CAPACITY).map((r) => `wave=${r.w}`).join(", "));
console.log("  cutover span: from the first cutover to the last — the portion of the time the two");
console.log("  arrangements run together depends on wave size; the legacy arrangement never closes");

console.log("\n2. who is left behind, and why");
const B = [-16, -26, ...W.map(() => 9)];
write(B, "team", "reason", ...W.map((w) => `wave=${w}`));
for (let i = 0; i < N; i++) {
  const row = SETTING.map((r) => r.leftBehind.find((x) => x[0] === i));
  if (!row.some(Boolean)) continue;
  write(B, TEAM[i].name, row.find(Boolean)[1], ...row.map((g) => (g ? "behind" : "migrated")));
}
write(B, "steps recovered", `${STEPS} instead of ${REMAINING_STEPS}`, ...SETTING.map((r) => r.steps));
console.log(`  the incompatibility surfaces during validation: support wasted ` +
  `${SETTING[0].wasted.toFixed(1)} units (same in every setting)`);

console.log("\n3. the long-term cost of never migrating at all (fictional currency)");
const remainingTeams = SETTING[0].leftBehind.length, beforeShare = LEGACY_FIXED / N;
const afterShare = LEGACY_FIXED / remainingTeams, breakeven = Math.ceil(CAPABILITY / LEGACY_FIXED);
const C = [-32, 12, 12, 13];
write(C, "item", "daily u", "365 days", "1095 days");
write(C, "running the legacy arrangement", LEGACY_FIXED, LEGACY_FIXED * 365, LEGACY_FIXED * 1095);
write(C, "adding the missing capability", "-", CAPABILITY, CAPABILITY);
write(C, "difference", "-", LEGACY_FIXED * 365 - CAPABILITY, LEGACY_FIXED * 1095 - CAPABILITY);
console.log(`  breakeven day ${breakeven}; the legacy arrangement stays open for ${remainingTeams} team`);
console.log(`  legacy cost per team ${beforeShare.toFixed(1)} -> ${afterShare.toFixed(1)} ` +
  `u/day (${(afterShare / beforeShare).toFixed(1)}x)`);

const ms = Number(process.hrtime.bigint() - t0) / 1e6;   // raw measurement is not printed, the band is audited
console.log(`\nthree tables and wave sweep in the 2000 ms band: ${ms < 2000 ? "yes" : "no"}`);
```

```
5 teams, platform support budget 6 units/day

1. wave-size sweep
wave      count   program  migrated   behind   cutover span  peak load  dual load
1             5   65 days       4/5        1        52 days       5.95      105.3
2             3   39 days       4/5        1        26 days       7.75       46.8
3             2   28 days       3/5        2        13 days       7.25       23.4
5             1   15 days       2/5        3         0 days       7.25        0.0
  settings exceeding the support budget: wave=2, wave=3, wave=5
  cutover span: from the first cutover to the last — the portion of the time the two
  arrangements run together depends on wave size; the legacy arrangement never closes

2. who is left behind, and why
team            reason                       wave=1   wave=2   wave=3   wave=5
billing         critical period            migrated migrated   behind   behind
field           incompatible requirement     behind   behind   behind   behind
reporting       orphaned service           migrated migrated migrated   behind
steps recovered 14 instead of 4                  40       40       30       20
  the incompatibility surfaces during validation: support wasted 10.4 units (same in every setting)

3. the long-term cost of never migrating at all (fictional currency)
item                                 daily u    365 days    1095 days
running the legacy arrangement            38       13870        41610
adding the missing capability              -        2400         2400
difference                                 -       11470        39210
  breakeven day 64; the legacy arrangement stays open for 1 team
  legacy cost per team 7.6 -> 38.0 u/day (5.0x)

three tables and wave sweep in the 2000 ms band: yes
```

The numbers are of the **measurement** class; their inputs are the assumptions above.

#### As the Wave Grows, the Schedule Shortens and the Scope Narrows

The first table lines up four settings side by side, and two columns move in opposite directions. The
program's duration is **65 days** at a wave of one team, **15 days** at a single wave of five — more
than four times shorter. Moving the same direction, the cutover span drops from 52 days to 0, and the
dual-run load drops from 105.3 units to 0. A bigger wave shortens the schedule, and there is nothing
hidden in that.

The column moving in the opposite direction is the number of teams migrated: it drops from **4/5 to
2/5**. The five-team wave shortens the program by more than four times while cutting the scope in half.
A migration that finishes in fifteen days is measured by having never done half of the migration at
all.

A third column behaves independently of both. Peak support load is **5.95 units** at a wave of one, and
between 7.25 and 7.75 across the other three settings; the platform team's daily budget is 6 units. Only
`wave=1` stays inside the budget. A bigger wave squeezes the same work into a shorter window, and the
squeezed work overflows somewhere: in the `wave=3` and `wave=5` settings, the first wave's load exceeds
the eight-day budget and extends the wave by two days.

There is a part of this table that goes unread: a setting with a cutover span of **zero** does not mean
the two arrangements never ran together. Zero means the two migrated teams hit cutover on the same day.
The legacy arrangement does not close in any of the four settings, and the reason is in the second
table.

#### The One Left Behind Has a Name

The second table writes down who is left behind, by name and by reason. Three reasons kick in across
three different settings.

**Incompatible requirement** leaves someone behind in every setting. The field team's edge nodes keep
collecting data even while offline; the platform assumes a constant connection. Wave size never affects
this obstacle at all, because the obstacle is not in the schedule — it is in the capability. Because the
incompatibility surfaces during cutover validation, 40% of the team's load, **10.4 units** of support,
is wasted in every setting.

**Critical period** kicks in at `wave=3` and `wave=5`. The billing team cannot migrate between days 0
and 30; in a program of two waves or one, no wave falls outside that window. In a five-wave program, the
third wave starts on day 31 and the team migrates. **Schedule slack is a capacity, and it exists only as
the number of waves grows.**

**Orphaned service** kicks in only at `wave=5`. Two of the reporting team's services have no owner; the
platform team does the work instead, and the load rises to 1.6 times, 25.6 units. In the single-wave
program, the other three teams already fill the eight-day budget, so there is no room left for this
load.

The last row counts what the platform actually gains for a team: a migrated team hands **10 of its 14**
migration steps to the platform, keeping 4 for itself. The gain scales directly with how many teams
migrate — 40 steps at `wave=1`, 20 at `wave=5`. The field team never gains a single step in any setting.

The abstraction leaks at the cutover span. For that number of days, migrated teams work in the new
arrangement while unmigrated ones work in the old, and whoever builds a connection between the two has
to know **both** arrangements' configuration. The layer the platform claims to have closed reopens for
exactly the duration of the migration, and at `wave=1` that lasts 52 days. The setting that migrates the
most teams is the setting where the abstraction leaks the longest.

#### The Bill for Never Migrating

The third table asks about a single item: how much never migrating costs. The legacy arrangement's fixed
operating cost is 38 units a day, and it is paid for as long as a single team stays there — **13,870**
units over 365 days, **41,610** units over 1,095 days. Adding the missing capability to the platform is
a one-time 2,400 units, with a breakeven point at **day 64**. The three-year difference of 39,210 units
shows that this decision is not a budget line item — it is a threshold.

The second number is sharper. While the legacy arrangement was shared by five teams, it came to 7.6
units a day per team; once four teams have migrated, the field team alone carries the same 38 units —
**five times as much**. Migration does not lower the cost for the one left behind, it multiplies it by
five. The case where the platform does not pay off shows up here in a single multiplier: the field team
has gained 0 steps, spent 10.4 units of support, and multiplied its operating share by five.

Two paths lead out from here, and both are in the table: add the capability to the platform (2,400
units, pays for itself in 64 days), or run the two arrangements indefinitely. A third path — declaring
the migration complete — corresponds to none of these numbers.

#### Summary

- As wave size climbs from 1 to 5, the program drops from 65 days to 15, the cutover span from 52 days
  to 0, and the dual-run load from 105.3 units to 0; against that, the number of teams migrated drops
  from 4/5 to 2/5, and those left behind rise from 1 to 3.
- Peak support load stays under the daily 6-unit budget only at a wave of one (5.95); the other three
  settings run between 7.25 and 7.75, and at `wave=3` and `wave=5` the first wave runs two days long.
- The three reasons for being left behind kick in at separate settings: incompatible requirement in
  every setting (field, 10.4 units of wasted support), critical period at `wave>=3` (billing), orphaned
  service only at `wave=5` (reporting).
- The platform's gain is proportional to how many teams migrate — 10 of 14 steps are handed over, 40
  steps at `wave=1`, 20 at `wave=5`; a team that never migrates gains not a single step.
- Because of the one team that never migrates, the legacy arrangement stays open indefinitely: 38 units
  a day, 41,610 units over 1,095 days. Adding the missing capability is 2,400 units and pays for itself
  by day 64; the operating share of the one left behind climbs from 7.6 to 38.0 units, a fivefold
  increase.

#### Next Step

Six lessons have counted what the platform does: it provisioned environments, generated scaffolds, kept
a catalog, embedded rules, split cost, and moved teams in waves. Every lesson also had a counter-number
— a leaking case, a team stepping outside, a threshold that did not pay off. The next lesson ties those
two columns to a single question: can the gap between teams that adopt the platform and teams that do
not be measured, and at what point do the platform's own metrics start showing that adoption works, not
just that adoption happened.

### Platform Metrics

Source: https://academia.sh/en/courses/platform-engineering/platform-metrics

Measuring whether the platform actually works: only twenty-five of forty team-capability pairs are filled, two capabilities costing fifty-four work-days serve at most one team, 48%-77% of the raw gap in four delivery metrics comes from selection bias and the difference-in-differences stays smaller than the within-group spread in all four, the same 550-minute outage turns into 2,750 team-minutes under central adoption, and forcing adoption improves the metric by 60% while making work per usage 18% worse.

The previous lesson moved five teams in waves and counted who was left behind, by name. Throughout
this topic, every lesson counted **what the platform did**; none of them asked whether any of it
actually made teams' work faster.

The four delivery metrics were defined in M22/K01 and are not repeated here; they are the input. What
is measured is not the metrics themselves, but **where the gap in the metric comes from**. The
through-line is again the fictional **regional measurement network** and its five fictional teams.

#### Tying Adoption to a Single Number

**PC37:** each of the eight capabilities has its setup work, annual maintenance, number of teams
using it, and usage count written down. **PC38:** the five teams' four delivery metrics are read before
and after adoption. **PC39:** three teams have adopted; the order comes from the migration wave, meaning
the teams with no obstacle migrated first. **PC40:** the platform is a dependency; over twelve months,
outages, their durations, the number of teams on the platform at that moment, and the source are written
down. **PC41:** if adoption is forced, every team-capability pair brings one unit of compliance work,
and only a portion of the forced ones produce usage. **PC42:** annual support requests per team and the
platform team's capacity are written down.

Capabilities, metrics, and outages are a **model** built within the lesson; there is no randomness.

```js
// metric/data.mjs — platform metrics model: there is no real metrics pipeline; capabilities,
// team metrics, and outages are a data structure, with no randomness. Metric *definitions* were
// established in M22/K01 and are an input here. Work-days are fictional.

export const TEAM = ["meter-reading", "validation", "billing", "field", "reporting"];
export const ADOPTED = [0, 1, 4];      // comes from the migration wave: teams with no obstacle went first
export const NOT_ADOPTED = [2, 3];     // critical period and incompatible requirement

// capability: name, setup work, annual maintenance (work-days), teams using it, usage in the period
export const CAPABILITY = [
  ["environment on demand", 34, 7, 4, 60], ["scaffold generation", 22, 5, 5, 5],
  ["service catalog", 18, 6, 3, 5], ["policy gate", 27, 8, 5, 72],
  ["showback", 15, 4, 3, 30], ["migration tool", 31, 3, 4, 4],
  ["secret rotation", 19, 5, 0, 0], ["shared dashboard library", 24, 6, 1, 3],
];

// metric: name, unit, direction (+1 higher is better, -1 lower is better), before adoption, after (team order)
export const METRIC = [
  ["deployment frequency", "week", +1, [4.0, 3.5, 1.6, 1.1, 2.8], [8.4, 4.6, 2.4, 1.5, 5.9]],
  ["lead time", "hours", -1, [52, 61, 96, 120, 47], [18, 52, 84, 112, 12]],
  ["failure rate", "%", -1, [14, 11, 18, 21, 9], [6, 9.5, 15.5, 19, 4]],
  ["time to restore", "min", -1, [190, 145, 240, 310, 160], [95, 130, 215, 300, 70]],
];

// outage: month, duration (min), number of teams on the platform at that time, source
export const OUTAGE = [[2, 45, 1, "platform"], [4, 120, 2, "lower layer"],
  [6, 25, 3, "platform"], [8, 210, 3, "platform"], [10, 60, 3, "lower layer"],
  [12, 90, 3, "platform"]];

export const REQUEST_PER_TEAM = 34, CAPACITY = 90;  // annual requests per team / platform capacity
export const COMPLY_WORK = 4, FORCED_USERS = 4, FORCED_USAGE = 3;   // the cost of forcing adoption

export const sum = (v) => v.reduce((a, b) => a + b, 0);
const avg = (v) => sum(v) / v.length;
const group = (g, d, y) => avg(g.map((i) => d[i])) * y;

// for one metric: raw gap, selection bias, difference-in-differences, and the adopting group's own internal spread
export function decompose([, , direction, before, after]) {
  const d = (i) => (after[i] - before[i]) * direction;      // improvement, sign-corrected
  const adoptedDiff = ADOPTED.map(d);
  return { afterGap: group(ADOPTED, after, direction) - group(NOT_ADOPTED, after, direction),
    beforeGap: group(ADOPTED, before, direction) - group(NOT_ADOPTED, before, direction),
    diffInDiff: avg(adoptedDiff) - avg(NOT_ADOPTED.map(d)),
    spread: Math.max(...adoptedDiff) - Math.min(...adoptedDiff) };
}
```

```js
// metric/measure.mjs — adoption breakdown, decomposition of the four delivery metrics, the
// platform's own metric, and what forcing adoption does to the metric.
import { TEAM, ADOPTED, CAPABILITY, METRIC, OUTAGE, REQUEST_PER_TEAM, CAPACITY, COMPLY_WORK,
  FORCED_USERS, FORCED_USAGE, sum, decompose } from "./data.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 t0 = process.hrtime.bigint();
const WORK = sum(CAPABILITY.map((y) => y[1] + y[2])), USAGE = sum(CAPABILITY.map((y) => y[4]));
const pairs = sum(CAPABILITY.map((y) => y[3])), totalPairs = CAPABILITY.length * TEAM.length;

console.log(`${TEAM.length} teams, ${CAPABILITY.length} capabilities, ${ADOPTED.length} adopters`);

console.log("\n1. adoption: teams and usage per capability");
const A = [-26, 8, 8, 9, 11, 14];
write(A, "capability", "setup", "maint.", "teams", "usage", "usage/work");
for (const [name, k, b, e, u] of CAPABILITY)
  write(A, name, k, b, `${e}/${TEAM.length}`, u, u ? ((k + b) / u).toFixed(2) : "-");
write(A, "total", sum(CAPABILITY.map((y) => y[1])), sum(CAPABILITY.map((y) => y[2])),
  `${pairs}/${totalPairs}`, USAGE, (WORK / USAGE).toFixed(2));
const idle = CAPABILITY.filter((y) => y[3] <= 1), idleWork = sum(idle.map((y) => y[1] + y[2]));
console.log(`  adoption ${(100 * pairs / totalPairs).toFixed(1)}%, ` +
  `${(pairs / CAPABILITY.length).toFixed(2)} teams/capability; least-used ` +
  `${idle.length} capabilities ${idleWork} work-days (${(100 * idleWork / WORK).toFixed(0)}%)`);

console.log("\n2. four delivery metrics: does the gap come from the platform");
const B = [-29, 11, 17, 14, 10, 10];
write(B, "metric", "raw gap", "selection bias", "diff-in-diff", "spread", "separable");
for (const o of METRIC) {
  const r = decompose(o);
  write(B, `${o[0]} (${o[1]})`, r.afterGap.toFixed(2), r.beforeGap.toFixed(2),
    r.diffInDiff.toFixed(2), r.spread.toFixed(2),
    Math.abs(r.diffInDiff) > r.spread ? "yes" : "no");
}
const p = METRIC.map((o) => { const r = decompose(o); return 100 * r.beforeGap / r.afterGap; });
console.log(`  selection bias explains ${Math.min(...p).toFixed(0)}%-` +
  `${Math.max(...p).toFixed(0)}% of the raw gap`);
console.log("  diff-in-diff is smaller than the spread in all four: the remaining share cannot be separated");

console.log("\n3. the platform is a dependency: how many teams does the same outage stop");
const duration = sum(OUTAGE.map((k) => k[1]));
const actual = sum(OUTAGE.map((k) => k[1] * k[2]));
const platformOutages = OUTAGE.filter((k) => k[3] === "platform");
console.log(`  ${OUTAGE.length} outages ${duration} min; platform-caused ${platformOutages.length} outages ` +
  `(${sum(platformOutages.map((k) => k[1]))} min), lower layer ${OUTAGE.length - platformOutages.length} outages`);
const C = [-11, 14, 18, 15, 16];
write(C, "adopters", "distributed", "central team-min", "support req.", "over capacity");
for (const n of [1, 3, 5])
  write(C, n, `${duration} team-min`, n * duration, n * REQUEST_PER_TEAM,
    n * REQUEST_PER_TEAM > CAPACITY ? `${(100 * (n * REQUEST_PER_TEAM / CAPACITY - 1)).toFixed(0)}%` : "-");
console.log(`  actual ${actual} team-min (adoption grew within the period), ` +
  `${(actual / duration).toFixed(1)}x the distributed figure`);

console.log("\n4. what forcing adoption does to the metric");
const forced = totalPairs - pairs, extraWork = forced * COMPLY_WORK;
const extraUsage = FORCED_USERS * FORCED_USAGE;
const D = [-22, 14, 13, 20];
write(D, "arrangement", "adoption", "usage", "work per usage");
write(D, "voluntary", `${(100 * pairs / totalPairs).toFixed(1)}%`, USAGE, (WORK / USAGE).toFixed(2));
write(D, "forced", "100.0%", USAGE + extraUsage, ((WORK + extraWork) / (USAGE + extraUsage)).toFixed(2));
const better = (100 * (totalPairs / pairs - 1)).toFixed(0);
const worse = (100 * ((WORK + extraWork) / (USAGE + extraUsage) / (WORK / USAGE) - 1)).toFixed(0);
const noUsage = forced - FORCED_USERS;
console.log(`  of the ${forced} forced pairs, ${noUsage} produce no usage`);
console.log(`  adoption improves ${better}% while work per usage worsens ${worse}%`);

const ms = Number(process.hrtime.bigint() - t0) / 1e6;   // raw measurement is not printed, the band is audited
console.log(`\nfour tables in the 2000 ms band: ${ms < 2000 ? "yes" : "no"}`);
```

```
5 teams, 8 capabilities, 3 adopters

1. adoption: teams and usage per capability
capability                   setup  maint.    teams      usage    usage/work
environment on demand           34       7      4/5         60          0.68
scaffold generation             22       5      5/5          5          5.40
service catalog                 18       6      3/5          5          4.80
policy gate                     27       8      5/5         72          0.49
showback                        15       4      3/5         30          0.63
migration tool                  31       3      4/5          4          8.50
secret rotation                 19       5      0/5          0             -
shared dashboard library        24       6      1/5          3         10.00
total                          190      44    25/40        179          1.31
  adoption 62.5%, 3.13 teams/capability; least-used 2 capabilities 54 work-days (23%)

2. four delivery metrics: does the gap come from the platform
metric                           raw gap   selection bias  diff-in-diff    spread separable
deployment frequency (week)         4.35             2.08          2.27      3.30        no
lead time (hours)                  70.67            54.67         16.00     26.00        no
failure rate (%)                   10.75             8.17          2.58      6.50        no
time to restore (min)             159.17           110.00         49.17     80.00        no
  selection bias explains 48%-77% of the raw gap
  diff-in-diff is smaller than the spread in all four: the remaining share cannot be separated

3. the platform is a dependency: how many teams does the same outage stop
  6 outages 550 min; platform-caused 4 outages (370 min), lower layer 2 outages
adopters      distributed  central team-min   support req.   over capacity
1            550 team-min               550             34               -
3            550 team-min              1650            102             13%
5            550 team-min              2750            170             89%
  actual 1440 team-min (adoption grew within the period), 2.6x the distributed figure

4. what forcing adoption does to the metric
arrangement                 adoption        usage      work per usage
voluntary                      62.5%          179                1.31
forced                        100.0%          191                1.54
  of the 15 forced pairs, 11 produce no usage
  adoption improves 60% while work per usage worsens 18%

four tables in the 2000 ms band: yes
```

The numbers are of the **measurement** class; their inputs are the assumptions above.

#### Adoption Is Three Numbers, Not One

Twenty-five of forty team-capability pairs are filled (**62.5%**), coming to 3.13 teams per capability.
The third column changes the table: work per usage is 0.49 work-days for the `policy gate` capability
and **10.00 work-days** for the `shared dashboard library` capability — a twentyfold difference.

Two capabilities go unrewarded. `secret rotation` is never used at all: 19 work-days of setup, 5
work-days of maintenance, zero usage. `shared dashboard library` serves a single team. Together they
account for **54 work-days**, **23%** of total investment — the case where the platform does not pay
off is readable here in a single column: the work was done, and nothing came back for it.

#### Does the Gap Come From the Platform

The raw gap is the difference between the two groups after adoption, and it is large across all four
metrics: 4.35 deployments a week, 70.67 hours of lead time, 10.75 points of failure rate, 159.17 minutes
of time to restore.

The second column writes down how much of that gap **already existed before adoption**: of lead time's
70.67-hour gap, **54.67** of it; of failure rate's 10.75 points, 8.17. The confounder's name is
**selection bias**, and its cause was measured in the previous lesson — the teams that migrated to the
platform first were the ones with no obstacle, and they were already faster. It explains **48% to 77%**
of the raw gap.

The third column takes the difference between the two. The difference-in-differences also subtracts the
non-adopting group's own improvement over the same period — clearing out the second confounder, the
**common trend**. What is left is 2.27 for deployment frequency and 16.00 hours for lead time.

The fourth column knocks down even what is left. The spread among the three adopting teams themselves is
3.30 for deployment frequency and 26.00 for lead time; it is **bigger** than the difference-in-differences
on every metric, and the last column says `no` on all four: **the gap is real, but where it comes from
cannot be separated out.**

#### The Platform Is a Dependency

The third table counts the platform's own metric: six outages and 550 minutes over twelve months, four
of them platform-caused (370 minutes). The real number is not the duration — it is **team-minutes**.
Under a distributed arrangement, one failure stops one team: 550 team-minutes. Under central adoption,
that same 550 minutes gets multiplied by however many teams have adopted — **2,750 team-minutes** at
five teams. What actually happened is 1,440, **2.6 times** the distributed figure.

This is where the abstraction leaks: because a single layer is hidden, a single failure stops five teams
at once. **The more successful the platform is, the more expensive one of its outages becomes.** Support
requests also exceed capacity by **89%** at five teams.

#### Where the Metric Points the Wrong Way

The fourth table is the result of targeting the adoption rate. Once forced, the rate climbs from 62.5%
to 100%; the fifteen forced pairs bring 60 work-days of compliance work, and **eleven of them produce no
usage at all**. **The metric improves 60% while work per usage worsens from 1.31 to 1.54, an 18%
decline** — the metric moves, the thing it was supposed to measure does not.

The second wrong-way case is in the second table. Migrating the easy teams first is the right call for
the migration program, but that same call produces 48% to 77% of the gap in the delivery metrics: an
ordering that is efficiency in one place is bias in another.

#### Summary

- 25 of forty team-capability pairs are filled (62.5%); work per usage ranges from 0.49 to 10.00
  work-days, and the two least-used capabilities hold 23% of the investment (54 work-days).
- Across the four delivery metrics, 48%-77% of the raw gap comes from selection bias; the
  difference-in-differences also clears out the common trend, but stays smaller than the within-group
  spread on all four — where the gap comes from cannot be separated out.
- The same 550-minute outage is 550 team-minutes under a distributed arrangement and 2,750 team-minutes
  at five teams; what actually happened is 1,440, 2.6 times the distributed figure, and support
  requests exceed capacity by 89%.
- Forcing adoption pushes the rate to 100%, and 11 of the 15 forced pairs produce no usage at all; work
  per usage worsens by 18%.

#### Course Wrap-Up

| Lesson | Hidden layer | How many teams, how much gain | Leak or stepping outside |
|---|---|---|---|
| What Is Platform Engineering | 18 items, 666 work-days | threshold 7 teams; at 5, −121 days | leftover 30.6%; exemption in 9/18 |
| Platform as a Product | request→need, 20→14 capabilities | 5 teams, 132 work-days | 128 days to product teams (97%) |
| Golden Paths | 10 support items, three axes | 64 work-days/year; 1/5 fits | `field` loses all ten items |
| Standardization and Autonomy Balance | mode choice, 80 work-days | hybrid arrangement +10 work-days | the wall blocks 10 legitimate requirements |
| Self-Service | request queue, 1,551 requests | 6/12 types, 79 work-days | `region transfer` −32 work-days |
| On-Demand Environment Provisioning | environment setup steps | 336 person-hours; threshold 5 teams | pool full 16/90 days; `field` outside |
| Template and Scaffold Generation | 19 decisions collapse into 3 answers | 5 services, 95→15 answers | 4/5 services behind at version 2 |
| Developer Portal and Service Catalog | reverse edge and ownership | 32→28.8 min; threshold 2 incidents | 6/12 records disagree |
| Policy and Compliance Automation | the text of 6 rules | 5 teams, 830→152 min | 4 exemptions, lifespan 3.2x |
| Cost Visibility | the bill and the accounting pipeline | 85 u/day; threshold 3 teams | 408 u/day outside audit |
| Migration Strategies | 10 of migration's 14 steps | 4 teams at wave=1, 40 steps | 1–3 teams behind, legacy arrangement indefinite |
| Platform Metrics | the measurement pipeline itself | 3 teams, 25/40 pairs (62.5%) | 1,440 team-min of outage |

In none of the twelve rows is the third column ever without the fourth. In every lesson, the gain was
tied to a number, and a cost stood right next to it: **the abstraction does not remove the layer it
hides** — it multiplies its gain by the number of teams, and makes someone pay its cost through leaking
or stepping outside. A threshold showed up explicitly in three lessons (7, 5, and 3 teams), and in all
three, staying under it made the platform harmful.

| Course | Measurement axis | Unit of measure |
|---|---|---|
| K01 DevOps Culture and Fundamentals | where the gap is hidden | gap item |
| K02 Containers | isolation is a budget | byte, layer, resource |
| K03 Container Orchestration | the cost of delegating a decision | information item |
| K04 Continuous Integration and Delivery | what each gate catches, what it misses | marginal gate value |
| K05 Infrastructure as Code | the gap between record and reality | record item |
| K06 Observability and Operations | not data, a question | cost per question |
| K07 Servers, Networking and Hardening | the default itself | open-surface item |
| K08 Platform Engineering | the cost of abstraction | hidden layer |

Eight courses told eight different stories, but measured in a single form. The gap item, the
open-surface item, the hidden layer — all of them were asking the same three things: **a decision, a
number, and the cost paid in exchange for that number.** In K02, the cost of isolation was measured in
bytes; in K04, a gate's value was measured by the violations it caught; in K08, the value of abstraction
was measured by the number of teams it saved. In none of them did "it is good" carry a decision on its
own.

Measuring itself was not free either: K06 counted the measuring side as its own line item on the bill;
K08 showed that 23% of the measurement investment went unrewarded. **Not writing down the unmeasurable
as if it were measured is itself a measurement decision** — and that is this curriculum's last line.

