---
title: 'Compute and Networking in the Cloud'
source: 'https://academia.sh/en/courses/compute-and-networking-in-the-cloud'
language: en
updated: '2026-09-13T05:07:37+00:00'
license: 'CC BY-SA 4.0'
lessons: 15
outcomes:
    - 'Makes the instance family and right-sizing decision by resource match'
    - 'Builds the address space, subnet, and routing design item by item'
    - 'Measures filtering and access decisions in terms of exposed surface'
---

# Compute and Networking in the Cloud

Measuring the most concrete thing that is rented: the resource match of virtual machine instance families and right-sizing, the effect of the machine image and the user data script on startup time, the cost and interruption balance of persistent against interruptible capacity, launch templates for a repeatable instance definition, scaling policies and health checks, managed container services, address space planning, the separation of public and private subnets, route tables, outbound and inbound gateways, stateful and stateless filtering, the choice of load balancer layer, address stability, the content delivery network, and private access between networks.

## Cloud Compute

Renting the machine: matching instance families' resource ratio to the workload and sizing, the startup time and freshness balance of the machine image and startup script, the cost and interruption risk of persistent against interruptible capacity, launch templates for a repeatable instance definition, the combined behavior of scaling policies and health checks, and cluster and task-based managed container services.

### Virtual Machine Instances

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/virtual-machine-instances

Four workload profiles are mapped to three instance families, and each match counts the idle processor, idle memory, and the size at which the bottleneck appears; the same demand is split across six different instance sizes, measuring how fixed overhead and failure exposure move in opposite directions; on a single instance, which decisions stay with you, which cannot be undone, and how many fleet hours a correction takes are worked out.

The previous course made the rental decision and chose its geography: which layer was handed off,
which region was chosen, and what egress cost held were counted. But the most concrete thing being
rented — the machine the process runs on — was never addressed. When the region is chosen, not even
a single processor core has been allocated yet.

This lesson rents that machine. A virtual machine instance is the unit of compute the provider
carves out of a physical server and offers by type; the **instance family** states the ratio in
which processor and memory arrive, and the **size** states how many multiples of that ratio are
taken. The handed-off decision stops here: which physical machine it is, which processor
generation, what the neighbor is doing all stay with the provider. The kept decision is family and
size — and when these two items do not fit the shape of the workload, the cost is a choice paid
every hour.

**CC1.** The fleet, that of the regional measurement network — the fictional software that
collects readings from water meters — consists of four workloads; peak demands are fictional.
**CC2.** There are three instance families, and they give 2, 4, and 8 GB of memory per vCPU;
prices are in a **fictional unit**, not a real currency. **CC3.** Size steps are multiples of 2,
and the ratio stays constant within a family. **CC4.** On every instance, the operating system and
the measurement agent hold 0.6 vCPU of fixed overhead. **CC5.** The collector layer's effective
demand is 84 vCPU, and it is held constant through the scan. **CC6.** The minutes in the decision
ledger are fictional; changes proceed with two instances at a time.

#### The Shape of the Workload versus the Shape of the Family

A workload has two dimensions: how much processor, how much memory. An instance family does not
sell these two separately; it packages them at a fixed ratio and sells that. When the match ratio
does not hold, the dimension that is not needed gets taken too and sits as **idle resource**. The
model below maps four profiles to three families.

```js
// measurement-network/matching.mjs — maps workload profiles to instance families (model; not a product)

// Family: memory (GB) per vCPU and fictional unit per vCPU-hour.
export const FAMILY = {
  "compute-heavy": { gb: 2, unit: 1.0 },
  "balanced": { gb: 4, unit: 1.3 },
  "memory-heavy": { gb: 8, unit: 1.9 },
};
export const SIZE = [2, 4, 8, 16, 32, 64];          // vCPU steps within a family

// Workload: [name, peak vCPU, peak GB, hours open per day, instance count].
export const WORKLOADS = [
  ["read collector", 12, 10, 24, 7], ["meter registry", 5, 46, 24, 3],
  ["api endpoint", 7, 26, 24, 5], ["night report", 26, 38, 2, 2],
];

export const place = ([name, cpu, gb, hours, count], familyName) => {
  const f = FAMILY[familyName];
  const i = SIZE.findIndex((s) => s >= cpu && s * f.gb >= gb);
  const size = SIZE[i], memory = size * f.gb, lowerSize = SIZE[i - 1] ?? 0;
  return { name, familyName, size, memory, count, hours,
    idleCpu: size - cpu, idleGb: memory - gb,
    bottleneck: lowerSize < cpu ? (lowerSize * f.gb < gb ? "both" : "processor") : "memory",
    daily: size * f.unit * hours * count };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log("workload / family".padEnd(24) + "instance".padEnd(15) + "idle vCPU".padEnd(12) +
    "idle GB".padEnd(12) + "bottleneck".padEnd(12) + "daily unit");
  const cheapest = {};
  for (const job of WORKLOADS) {
    console.log(`${job[0]} (${job[1]} vCPU, ${job[2]} GB, ${job[4]} instances, ${job[3]} h/day)`);
    let best = null;
    for (const name of Object.keys(FAMILY)) {
      const fit = place(job, name);
      if (!best || fit.daily < best.daily) best = fit;
      console.log("  " + name.padEnd(22) + `${fit.size}/${fit.memory} GB`.padEnd(15) +
        `${fit.idleCpu} (${Math.round(100 * fit.idleCpu / fit.size)}%)`.padEnd(12) +
        `${fit.idleGb} (${Math.round(100 * fit.idleGb / fit.memory)}%)`.padEnd(12) +
        fit.bottleneck.padEnd(12) + fit.daily.toFixed(1));
    }
    cheapest[job[0]] = best;
  }
  console.log("\nfleet daily cost — standardizing on one family versus choosing per workload:");
  console.log("policy".padEnd(28) + "daily unit".padEnd(15) + "difference".padEnd(12) +
    "idle vCPU".padEnd(12) + "idle GB");
  const mixed = Object.values(cheapest);
  const sum = (l) => [l.reduce((t, fit) => t + fit.daily, 0),
    l.reduce((t, fit) => t + fit.idleCpu * fit.count, 0), l.reduce((t, fit) => t + fit.idleGb * fit.count, 0)];
  const [mixedCost] = sum(mixed);
  for (const name of Object.keys(FAMILY)) {
    const [cost, idleCpu, idleGb] = sum(WORKLOADS.map((job) => place(job, name)));
    console.log(`only ${name}`.padEnd(28) + cost.toFixed(1).padEnd(15) +
      `+${Math.round(100 * (cost - mixedCost) / mixedCost)}%`.padEnd(12) + String(idleCpu).padEnd(12) + String(idleGb));
  }
  const [cost, idleCpu, idleGb] = sum(mixed);
  console.log("per-workload choice".padEnd(28) + cost.toFixed(1).padEnd(15) + "—".padEnd(12) +
    String(idleCpu).padEnd(12) + String(idleGb));
}
```

```
workload / family       instance       idle vCPU   idle GB     bottleneck  daily unit
read collector (12 vCPU, 10 GB, 7 instances, 24 h/day)
  compute-heavy         16/32 GB       4 (25%)     22 (69%)    processor   2688.0
  balanced              16/64 GB       4 (25%)     54 (84%)    processor   3494.4
  memory-heavy          16/128 GB      4 (25%)     118 (92%)   processor   5107.2
meter registry (5 vCPU, 46 GB, 3 instances, 24 h/day)
  compute-heavy         32/64 GB       27 (84%)    18 (28%)    memory      2304.0
  balanced              16/64 GB       11 (69%)    18 (28%)    memory      1497.6
  memory-heavy          8/64 GB        3 (38%)     18 (28%)    both        1094.4
api endpoint (7 vCPU, 26 GB, 5 instances, 24 h/day)
  compute-heavy         16/32 GB       9 (56%)     6 (19%)     memory      1920.0
  balanced              8/32 GB        1 (13%)     6 (19%)     both        1248.0
  memory-heavy          8/64 GB        1 (13%)     38 (59%)    processor   1824.0
night report (26 vCPU, 38 GB, 2 instances, 2 h/day)
  compute-heavy         32/64 GB       6 (19%)     26 (41%)    both        128.0
  balanced              32/128 GB      6 (19%)     90 (70%)    processor   166.4
  memory-heavy          32/256 GB      6 (19%)     218 (85%)   processor   243.2

fleet daily cost — standardizing on one family versus choosing per workload:
policy                      daily unit     difference  idle vCPU   idle GB
only compute-heavy          7040.0         +36%        166         290
only balanced               6406.4         +24%        78          642
only memory-heavy           8268.8         +60%        54          1506
per-workload choice         5158.4         —           54          290
```

The bottleneck column is the only column that says the family was chosen wrong. `meter registry`
is a memory-heavy job: in the compute-heavy family, getting 46 GB requires taking 32 vCPU, and
**27 vCPU — 84 percent of the processor — sits idle**. The same job fits an 8-vCPU instance in the
memory-heavy family; idle processor drops from 27 to 3, and the daily cost falls from 2,304 to
1,094. The reverse holds too: `read collector` is processor-heavy, and placed in the memory-heavy
family, 118 GB of memory — 92 percent of memory — sits idle.

Idle resource is a cost but not the only cost. In the `api endpoint` row, the bottleneck column
reads "both": one size step down falls short on both processor and memory at once. That is the
sign the family is right for that match; when both dimensions are at the limit at the same time,
the shape of the package taken matches the shape of the job. A single dimension reading
"processor" or "memory" instead of "both" shows that one dimension of that instance was taken
larger than needed.

The lower table turns the decision into a policy. Standardizing the fleet on a single family
simplifies management; its cost shows up in three numbers. Even the closest single family costs
**24 percent more** per day and raises idle memory from 290 GB to 642 GB. The worst single family
costs 60 percent more. When the decision is made separately four times for four workloads, 5,158.4
fictional units are paid per day; reduced to a single family, 6,406.4 units are paid; the
1,248-unit gap is standardization's daily price.

#### How Many Pieces to Split the Same Demand Into

After the family is chosen, the second decision follows: whether the same total capacity comes
from a small number of large instances or a large number of small ones. This decision does not
show up in the size row, because total vCPU is roughly the same at both ends. The difference
arises in two other places: in each instance's own fixed overhead, and in the share lost when one
instance drops.

```js
// measurement-network/sizing.mjs — scan of splitting the same demand across different instance sizes (model)
import { FAMILY, SIZE } from "./matching.mjs";

const DEMAND = 84;                // effective vCPU demand of the collector layer (fictional)
const OVERHEAD = 0.6;             // OS + measurement agent per instance (vCPU)
const HOURS = 24, FAMILY_NAME = "balanced";

export const scan = (size) => {
  const effective = size - OVERHEAD, count = Math.ceil(DEMAND / effective);
  const provisioned = count * size, overhead = count * OVERHEAD;
  const daily = provisioned * FAMILY[FAMILY_NAME].unit * HOURS;
  return { size, count, provisioned, overhead, rounding: provisioned - DEMAND - overhead,
    idle: provisioned - DEMAND, shortfall: Math.max(0, DEMAND - (count - 1) * effective),
    daily, spare: size * FAMILY[FAMILY_NAME].unit * HOURS };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`demand ${DEMAND} effective vCPU, ${OVERHEAD} vCPU fixed overhead per instance, ${FAMILY_NAME} family\n`);
  console.log("size".padEnd(9) + "instances".padEnd(11) + "provisioned".padEnd(13) + "fixed overhead".padEnd(16) +
    "rounding".padEnd(11) + "idle".padEnd(9) + "one failure".padEnd(13) + "N+1 cost");
  for (const s of SIZE) {
    const t = scan(s);
    console.log(`${s} vCPU`.padEnd(9) + String(t.count).padEnd(11) + `${t.provisioned}`.padEnd(13) +
      t.overhead.toFixed(1).padEnd(16) + t.rounding.toFixed(1).padEnd(11) +
      `${Math.round(100 * t.idle / t.provisioned)}%`.padEnd(9) +
      `${(100 * t.shortfall / DEMAND).toFixed(1)}%`.padEnd(13) +
      `+${(100 * t.spare / t.daily).toFixed(1)}%`);
  }
  const lowest = SIZE.map(scan).reduce((a, b) => (b.idle < a.idle ? b : a));
  console.log(`\nlowest idle: ${lowest.size} vCPU (${lowest.idle.toFixed(1)} vCPU idle, ` +
    `${lowest.count} instances); there, one failure exposes ${(100 * lowest.shortfall / DEMAND).toFixed(1)}% of demand`);
}
```

```
demand 84 effective vCPU, 0.6 vCPU fixed overhead per instance, balanced family

size     instances  provisioned  fixed overhead  rounding   idle     one failure  N+1 cost
2 vCPU   61         122          36.6            1.4        31%      0.0%         +1.6%
4 vCPU   25         100          15.0            1.0        16%      2.9%         +4.0%
8 vCPU   12         96           7.2             4.8        13%      3.1%         +8.3%
16 vCPU  6          96           3.6             8.4        13%      8.3%         +16.7%
32 vCPU  3          96           1.8             10.2       13%      25.2%        +33.3%
64 vCPU  2          128          1.2             42.8       34%      24.5%        +50.0%

lowest idle: 8 vCPU (12.0 vCPU idle, 12 instances); there, one failure exposes 3.1% of demand
```

The two waste items move in opposite directions. Fixed overhead — each instance's operating
system and measurement agent — holds at 36.6 vCPU on the 2-vCPU instance and drops to 1.2 vCPU on
the 64-vCPU instance; on the small instance, nearly a third of the rented capacity is spent doing
no work. Rounding waste goes the other way: because demand does not divide evenly into the
instance size, 42.8 extra vCPU is taken on the large instance. Total idle is therefore not a flat
curve; **it falls to its lowest point between 8 and 32 vCPU** and rises at both ends.

The failure column, however, is one-directional, and it is the one that actually steers the
decision. In the 61-instance layout, one instance dropping exposes 0 percent of demand; in the
three-instance layout of 32-vCPU instances, the same failure exposes **25 percent** of demand. The
last column is the price of fixing that: adding one spare instance raises the daily cost by 1.6
percent in the 61-instance layout and by 33.3 percent in the three-instance layout. The point that
minimizes idle resource and the point that minimizes failure exposure are not the same place;
sizing is choosing a spot between these two numbers, not searching for one correct size.

#### On an Instance, Which Decision Sits Where

Once both decisions are made, the third question follows: if they turn out wrong, can they be
undone? Some of the items on an instance change while it runs, some require stopping the instance,
and some are given at launch time and **never change again**.

```js
// measurement-network/decision-ledger.mjs — which decision on an instance sits where, and which cannot be undone (model)
import { WORKLOADS } from "./matching.mjs";

const FLEET = WORKLOADS.reduce((t, job) => t + job[4], 0);   // 17 instances
const WINDOW = 2, ROUNDS = Math.ceil(FLEET / WINDOW);         // instances pulled out at once

// Kept item: [name, reversibility, change path, minutes per instance]
const KEPT = [
  ["instance family", "yes", "stop-modify-start", 4],
  ["instance size", "yes", "stop-modify-start", 4],
  ["root disk size", "one-way", "grows, never shrinks", 2],
  ["processor architecture", "no", "image rebuild + re-creation", 35],
  ["subnet placement", "no", "re-creation", 35],
  ["root disk encryption", "no", "re-creation", 35],
];
const PROVIDER = ["physical machine", "processor generation", "oversubscription ratio",
  "neighboring workload", "maintenance window timing"];

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`${FLEET}-instance fleet; changes proceed ${WINDOW} instances at a time, over ${ROUNDS} rounds\n`);
  console.log("kept item".padEnd(26) + "reversible".padEnd(17) +
    "change path".padEnd(40) + "fleet wall-clock hours");
  for (const [name, reversible, path, min] of KEPT)
    console.log(name.padEnd(26) + reversible.padEnd(17) + path.padEnd(40) + `${((ROUNDS * min) / 60).toFixed(1)} h`);
  const irreversible = KEPT.filter((x) => x[1] === "no");
  console.log(`\n${PROVIDER.length} items stay with the provider: ${PROVIDER.join(", ")}`);
  console.log(`irreversible items: ${irreversible.length}; if all three change together the fleet ` +
    `renews in ${(ROUNDS * Math.max(...irreversible.map((x) => x[3])) / 60).toFixed(1)} hours, ` +
    `a wrong family choice is fixed in ${(ROUNDS * 4 / 60).toFixed(1)} hours`);
}
```

```
17-instance fleet; changes proceed 2 instances at a time, over 9 rounds

kept item                 reversible       change path                             fleet wall-clock hours
instance family           yes              stop-modify-start                       0.6 h
instance size             yes              stop-modify-start                       0.6 h
root disk size            one-way          grows, never shrinks                    0.3 h
processor architecture    no               image rebuild + re-creation             5.3 h
subnet placement          no               re-creation                             5.3 h
root disk encryption      no               re-creation                             5.3 h

5 items stay with the provider: physical machine, processor generation, oversubscription ratio, neighboring workload, maintenance window timing
irreversible items: 3; if all three change together the fleet renews in 5.3 hours, a wrong family choice is fixed in 0.6 hours
```

**Six of the eleven items are kept, five are with the provider.** For the ones that stay with the
provider, the only thing you can do is measure: two same-sized instances running at different
speeds is a difference in neighboring workload or processor generation, not a decision but an
observation.

The six kept items split into three groups by reversibility. Family and size are **reversible**
items: a wrong family choice is fixed in 0.6 hours across a 17-instance fleet, and it can be
revisited every month as idle resource is counted. Root disk size is **one-way** — it grows, never
shrinks; a disk taken larger than needed is paid for the instance's entire life. The three
irreversible items — processor architecture, the subnet the instance is placed in, and whether the
root disk is encrypted — are given at launch time, and when a change is wanted the instance does
not change, it is **re-created**: the fleet renews in 5.3 hours, nine times as long as a family
change. These three are chosen on day one, and fixing them gets more expensive as the fleet grows;
work that takes 5.3 hours at 17 instances takes more than two days at 170 instances.

#### Summary

- When the family match does not hold, the cost is paid every hour: `meter registry` holds 27
  vCPU (84 percent of the processor) idle in the compute-heavy family; in the right family, the
  same job runs with 3 vCPU idle and the daily cost falls from 2,304 to 1,094 fictional units.
- Standardizing on a single family costs 24 percent more per day in the best case and 60 percent
  more in the worst case; a separate choice for four workloads costs 5,158.4 units, the closest
  single family 6,406.4 units.
- In sizing, the two waste items move in opposite directions: fixed overhead is 36.6 vCPU on the
  2-vCPU instance and 1.2 vCPU on the 64-vCPU instance; rounding waste climbs from 1.4 to 42.8
  vCPU, and total idle is lowest in the 8–32 vCPU range.
- Failure exposure is one-directional: one instance dropping exposes 0 percent of demand in the
  61-instance layout and 25 percent in the three-instance layout; the cost of a spare instance
  climbs from 1.6 percent to 33.3 percent.
- Six of the eleven items are kept, five are with the provider; the three irreversible items are
  processor architecture, subnet placement, and root disk encryption — changing any of the three
  renews the fleet in 5.3 hours, a family change in 0.6 hours.

#### Next Step

Every number in this lesson measured what happens after an instance opens: how many vCPU sit
idle, how much a failure takes away, how many hours changing an item costs. Startup itself passed
in a single line — "re-creation, 35 minutes" — and what happens inside those 35 minutes was never
asked.

An instance does not open with an empty disk; it opens from a **machine image**, and the more
ready-made work sits inside that image, the shorter the startup. The next lesson scans the balance
between what is embedded in the image and what is done at startup, and asks: as each item is
embedded in the image, how many seconds does startup shorten by, how many days does the image's
freshness drop by, and how many calls to how many external dependencies does the work remaining at
startup make?

### Machine Images

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/machine-images

Eight items done at startup are embedded into the image one at a time, and the seconds each item gains are counted alongside the staleness it costs; the embedding level is scanned from zero to eight, working out startup duration, external call count, dependency type, monthly bake load, and stale item-days; which dependency the work remaining at startup is bound to, and whether startup stops when that dependency drops, are measured.

The previous lesson passed over one item in a single line: re-creation, 35 minutes. The real work
inside that duration is an empty disk turning into a running machine, and that transformation
starts from a **machine image**. An image is the ready-made file system copied onto the instance's
root disk; whatever is inside it is not done at startup, and whatever is not inside it is done at
startup.

M22/K05 measured how this image is produced — the bake pipeline, its versioning, the rollout
window; that measurement is not repeated here. This lesson's measure is a single axis: **startup
duration**. The same question is asked for every item — if this item is embedded in the image, how
many seconds does startup shorten by, how many more days does the image stay stale in exchange,
and how many times a month does it need to be re-baked. The answer varies from item to item, and
the size of that variation takes the choice between "embed everything in the image" and "embed
nothing" out of the realm of taste.

**CC7.** Ten items are done at the startup of the regional measurement network — the fictional
software that collects readings from water meters; eight can be embedded in the image, two cannot.
Durations, sizes, and change intervals are fictional. **CC8.** The base image is 900 MB, the pull
speed is 60 MB/s, and kernel startup is 11 seconds on every path. **CC9.** Baking is done in a
weekly batch; a change waits an average of 3.5 days to enter the image. **CC10.** A single bake,
together with its test, takes 18 minutes. **CC11.** The embedding order is greedy: the item that
gains the most seconds per bake is embedded first. **CC12.** An item being "critical" means the
service does not start while that item is missing; the distinction is fictional.

#### The Two Sides of an Item

Every piece of work done at startup is an independent item, and embedding each item in the image
is a separate decision. Once an item is embedded, its seconds at startup disappear, but the
image's size grows — and pulling a larger image adds seconds to startup. Net gain is the
difference between these two. The cost, though, is in a different unit: the more often an item
changes, the more often embedding it in the image demands a re-bake.

```js
// measurement-network/image-items.mjs — the startup gain and staleness cost of embedding an item in the image (model)

export const SPEED = 60, BASE_MB = 900, KERNEL = 11;   // MB/s, base image, kernel startup (s)
export const BAKE_WAIT = 3.5, BAKE_MIN = 18;            // average wait in a weekly batch bake

// [name, seconds at startup, MB it adds to the image, days between changes, dependency, calls, critical?]
export const ITEM = [
  ["kernel packages", 46, 320, 45, "package repository", 6, true],
  ["runtime", 38, 210, 30, "package repository", 3, true],
  ["measurement agent", 14, 60, 14, "package repository", 2, false],
  ["log shipping", 8, 20, 21, "package repository", 1, false],
  ["certificate bundle", 9, 12, 7, "configuration server", 1, true],
  ["application binary", 22, 90, 2, "build repository", 1, true],
  ["network settings", 6, 2, 3, "configuration server", 1, true],
  ["environment variables", 3, 1, 1, "configuration server", 1, false],
];
// Items that cannot be embedded: always done at startup.
export const NOT_EMBEDDABLE = [["node secret", 7, "secret store", 1, true],
  ["clock sync", 4, "time server", 1, false]];

export const measure = ([name, sec, mb, days, , calls]) => ({
  name, gain: sec - mb / SPEED, bakes: 30 / days, calls,
  stale: (30 / days) * BAKE_WAIT,
});
// Greedy order: the item that gains the most seconds per bake is embedded first.
export const ORDER = [...ITEM].sort((a, b) =>
  measure(b).gain / measure(b).bakes - measure(a).gain / measure(a).bakes);

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log("item".padEnd(23) + "at startup".padEnd(12) + "in image".padEnd(10) + "pull".padEnd(9) +
    "net gain".padEnd(12) + "bakes/month".padEnd(14) + "stale item-days".padEnd(17) + "ratio");
  for (const k of ORDER) {
    const m = measure(k);
    console.log(m.name.padEnd(23) + `${k[1]} s`.padEnd(12) + `${k[2]} MB`.padEnd(10) +
      `${(k[2] / SPEED).toFixed(1)} s`.padEnd(9) + `${m.gain.toFixed(1)} s`.padEnd(12) +
      m.bakes.toFixed(2).padEnd(14) + m.stale.toFixed(1).padEnd(17) +
      (m.gain / m.bakes).toFixed(1));
  }
  const g = NOT_EMBEDDABLE.reduce((t, x) => t + x[1], 0);
  console.log(`\nitems that cannot be embedded: ${NOT_EMBEDDABLE.map((x) => x[0]).join(", ")} — ` +
    `${g} s and ${NOT_EMBEDDABLE.length} calls stay at every startup`);
}
```

```
item                   at startup  in image  pull     net gain    bakes/month   stale item-days  ratio
kernel packages        46 s        320 MB    5.3 s    40.7 s      0.67          2.3              61.0
runtime                38 s        210 MB    3.5 s    34.5 s      1.00          3.5              34.5
measurement agent      14 s        60 MB     1.0 s    13.0 s      2.14          7.5              6.1
log shipping           8 s         20 MB     0.3 s    7.7 s       1.43          5.0              5.4
certificate bundle     9 s         12 MB     0.2 s    8.8 s       4.29          15.0             2.1
application binary     22 s        90 MB     1.5 s    20.5 s      15.00         52.5             1.4
network settings       6 s         2 MB      0.0 s    6.0 s       10.00         35.0             0.6
environment variables  3 s         1 MB      0.0 s    3.0 s       30.00         105.0            0.1

items that cannot be embedded: node secret, clock sync — 11 s and 2 calls stay at every startup
```

The last column splits items into two groups. `kernel packages` gains 40.7 seconds and demands
0.67 bakes a month; its ratio is 61. `environment variables` gains 3 seconds and demands 30 bakes a
month; its ratio is 0.1. There is a **six-hundredfold** difference between these two items, and the
difference comes not from size but from **change frequency**. The item best suited for embedding
in the image is not the one that takes the longest at startup; it is the one that takes long and
changes rarely.

The `application binary` row is where this distinction shows most clearly. It takes 22 seconds at
startup — the third-longest item on the list — but changes every two days. Embedded, it triggers
15 bakes a month and accumulates 52.5 stale item-days. The `runtime` sells the same 20.5 seconds
for only 3.5 stale item-days. Intuition says "embed the biggest item"; the ratio column says "embed
the most stable item".

The two items that cannot be embedded draw the measure's floor. The node secret cannot be written
into the image, because anyone who can read the image can also read the secret; clock
synchronization cannot come from the image, because the thing being measured is the moment of
startup. These two items are 11 seconds and 2 calls, and no bake decision can remove them.

#### Embedding Level Scan

Individual items have been compared; the next question is how many of them to embed. The scan
below takes the list sorted by the ratio column from the top and recomputes startup at every level
from zero to eight.

```js
// measurement-network/embedding-scan.mjs — what happens to startup as more items are embedded in the image (model)
import { SPEED, BASE_MB, KERNEL, BAKE_MIN, ITEM, NOT_EMBEDDABLE, ORDER, measure } from "./image-items.mjs";

export const level = (k) => {
  const embedded = ORDER.slice(0, k), open = ORDER.slice(k);
  const mb = BASE_MB + embedded.reduce((t, x) => t + x[2], 0);
  const duration = KERNEL + mb / SPEED + open.reduce((t, x) => t + x[1], 0) +
    NOT_EMBEDDABLE.reduce((t, x) => t + x[1], 0);
  const calls = open.reduce((t, x) => t + x[5], 0) + NOT_EMBEDDABLE.reduce((t, x) => t + x[3], 0);
  const types = new Set([...open.map((x) => x[4]), ...NOT_EMBEDDABLE.map((x) => x[2])]).size;
  return { k, mb, duration, calls, types,
    bakes: embedded.reduce((t, x) => t + measure(x).bakes, 0),
    stale: embedded.reduce((t, x) => t + measure(x).stale, 0),
    last: k ? ORDER[k - 1][0] : "—" };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log("embedded".padEnd(10) + "last embedded".padEnd(23) + "image".padEnd(10) +
    "startup".padEnd(10) + "external calls".padEnd(16) + "dependency".padEnd(12) +
    "bakes/month".padEnd(14) + "baker minutes".padEnd(15) + "stale item-days");
  for (let k = 0; k <= ITEM.length; k++) {
    const d = level(k);
    console.log(`${k}/${ITEM.length}`.padEnd(10) + d.last.padEnd(23) + `${d.mb} MB`.padEnd(10) +
      `${d.duration.toFixed(1)} s`.padEnd(10) + String(d.calls).padEnd(16) + `${d.types} types`.padEnd(12) +
      d.bakes.toFixed(2).padEnd(14) + Math.round(d.bakes * BAKE_MIN).toString().padEnd(15) +
      d.stale.toFixed(1));
  }
  const s = level(0), t = level(4), h = level(ITEM.length);
  console.log(`\nfirst four items: startup shortens by ${(s.duration - t.duration).toFixed(1)} s, ` +
    `costing ${t.stale.toFixed(1)} stale item-days`);
  console.log(`last four items: startup shortens by ${(t.duration - h.duration).toFixed(1)} s, ` +
    `costing ${(h.stale - t.stale).toFixed(1)} stale item-days`);
  console.log(`staleness per second: first four ${(t.stale / (s.duration - t.duration)).toFixed(2)}, ` +
    `last four ${((h.stale - t.stale) / (t.duration - h.duration)).toFixed(2)} item-days/s`);
}
```

```
embedded  last embedded          image     startup   external calls  dependency  bakes/month   baker minutes  stale item-days
0/8       —                      900 MB    183.0 s   18              5 types     0.00          0              0.0
1/8       kernel packages        1220 MB   142.3 s   12              5 types     0.67          12             2.3
2/8       runtime                1430 MB   107.8 s   9               5 types     1.67          30             5.8
3/8       measurement agent      1490 MB   94.8 s    7               5 types     3.81          69             13.3
4/8       log shipping           1510 MB   87.2 s    6               4 types     5.24          94             18.3
5/8       certificate bundle     1522 MB   78.4 s    5               4 types     9.52          171            33.3
6/8       application binary     1612 MB   57.9 s    4               3 types     24.52         441            85.8
7/8       network settings       1614 MB   51.9 s    3               3 types     34.52         621            120.8
8/8       environment variables  1615 MB   48.9 s    2               2 types     64.52         1161           225.8

first four items: startup shortens by 95.8 s, costing 18.3 stale item-days
last four items: startup shortens by 38.3 s, costing 207.5 stale item-days
staleness per second: first four 0.19, last four 5.42 item-days/s
```

The scan is not one curve but **two different curves**. Startup duration drops from 183 seconds to
48.9 seconds with diminishing returns: the first four items gain 95.8 seconds, the last four gain
38.3 seconds. Staleness runs the opposite way and accelerates: the first four items cost 18.3 stale
item-days, the last four cost 207.5 stale item-days. The last line turns the two curves into a
single number — the price of a gained second is 0.19 item-days in the first four, 5.42 item-days in
the last four. **Twenty-eight times.**

The baker minutes column writes the same thing from the labor side. With four items embedded, the
image builder runs for 94 minutes a month; with eight items embedded, 1,161 minutes — roughly
nineteen hours. This is a cost nobody writes into the plan but that gets paid every month, and it
is why the bake pipeline itself is an infrastructure piece that needs monitoring.

The dependency column, though, does not fall smoothly — it falls **step by step**: from 5 types to
4 at the fourth item, to 3 at the sixth, to 2 at the eighth. The reason is that all four of the
first four items use the same dependency — the package repository. A dependency type is removed
only once the **last** item bound to it is also embedded; embedding the first three items drops
calls from 18 to 7 but does not change the type count at all.

#### The Surface of What Remains at Startup

Call count is a surface measure: every call is a separate thing that must be standing at the
moment of startup. But not every call carries the same weight. When some items are missing, the
service does not start at all; when others are missing, the machine boots and runs incomplete.

```js
// measurement-network/startup-surface.mjs — the external surface of the work left at startup and the decision ledger (model)
import { ITEM, NOT_EMBEDDABLE, ORDER } from "./image-items.mjs";
import { level } from "./embedding-scan.mjs";

const LEVELS = [0, 4, 8];
const open = (k) => [...ORDER.slice(k).map(([name, sec, , , dep, calls, crit]) => ({ name, sec, dep, calls, crit })),
  ...NOT_EMBEDDABLE.map(([name, sec, dep, calls, crit]) => ({ name, sec, dep, calls, crit }))];

const impact = (k, dep) => {
  const l = open(k).filter((x) => x.dep === dep);
  if (!l.length) return "no effect";
  if (l.some((x) => x.crit)) return "startup fails";
  return `-${l.reduce((t, x) => t + x.sec, 0)} s, ${l.length} item${l.length === 1 ? "" : "s"} missing`;
};

// [item, holder, reversible?]
const DECISIONS = [["which item goes into the image", "yours", "yes — new image"],
  ["base operating system version", "yours", "yes — new image"],
  ["bake frequency", "yours", "yes"],
  ["published image content", "yours", "no — does not change under the same identity"],
  ["secret embedded in the image", "yours", "no — deleting is not enough once copied"],
  ["startup script given to the instance", "yours", "no — does not change after creation"],
  ["image repository durability", "provider", "—"],
  ["image copy speed", "provider", "—"],
  ["virtualization beneath the base image", "provider", "—"]];

if (import.meta.url === `file://${process.argv[1]}`) {
  const DEPS = [...new Set([...ITEM.map((x) => x[4]), ...NOT_EMBEDDABLE.map((x) => x[2])])];
  console.log("dropped dependency".padEnd(24) +
    LEVELS.map((k) => `${k}/8 embedded`.padEnd(22)).join("").trimEnd());
  for (const dep of DEPS)
    console.log((dep.padEnd(24) + LEVELS.map((k) => impact(k, dep).padEnd(22)).join("")).trimEnd());
  console.log("");
  console.log("level".padEnd(9) + "startup".padEnd(10) + "open items".padEnd(13) +
    "critical open items".padEnd(22) + "dependency blocking startup");
  for (const k of LEVELS) {
    const a = open(k);
    console.log(`${k}/8`.padEnd(9) + `${level(k).duration.toFixed(1)} s`.padEnd(10) +
      String(a.length).padEnd(13) + String(a.filter((x) => x.crit).length).padEnd(22) +
      String(new Set(a.filter((x) => x.crit).map((x) => x.dep)).size));
  }
  const count = (v) => DECISIONS.filter((x) => x[1] === v).length;
  console.log(`\ndecision ledger: ${count("yours")} items kept, ${count("provider")} items with the provider`);
  for (const [name, , rev] of DECISIONS.filter((x) => x[2].startsWith("no")))
    console.log(`  cannot be undone — ${name.padEnd(38)} ${rev.slice(5)}`);
}
```

```
dropped dependency      0/8 embedded          4/8 embedded          8/8 embedded
package repository      startup fails         no effect             no effect
configuration server    startup fails         startup fails         no effect
build repository        startup fails         startup fails         no effect
secret store            startup fails         startup fails         startup fails
time server             -4 s, 1 item missing  -4 s, 1 item missing  -4 s, 1 item missing

level    startup   open items   critical open items   dependency blocking startup
0/8      183.0 s   10           6                     4
4/8      87.2 s    6            4                     3
8/8      48.9 s    2            1                     1

decision ledger: 6 items kept, 3 items with the provider
  cannot be undone — published image content                does not change under the same identity
  cannot be undone — secret embedded in the image           deleting is not enough once copied
  cannot be undone — startup script given to the instance   does not change after creation
```

The number of dependencies that block startup drops from 4 to 1, and that is a gain independent of
seconds. With no item embedded, four separate systems must be standing at the same time; with
eight embedded, a single system — the secret store — remains. The secret store row is identical
across all three columns and cannot be changed by an embedding decision. The clock server row is
the one "soft" row: if it drops, startup still completes, and the machine begins taking work 4
seconds early and unsynchronized.

This row is where a silent trap sits. The measurement network's readings are timestamped; the
records an unsynchronized machine produces are harder to notice than a failed startup, because the
machine looks healthy. A non-critical item starting up missing is not a bug, it is a decision.

The decision ledger **leaves six of nine items with you**: what goes into the image, what the base
version is, how often it gets baked, and three items that cannot be undone. The three that stay
with the provider — image repository durability, copy speed, the virtualization beneath it —
determine the pull seconds but cannot be chosen.

The three items that cannot be undone are irreversible each in a different way. Published image
content does not change under the same identity; fixing it means a new image, and that image does
not delete the old one. A secret embedded in the image cannot be undone by deletion once the image
has been copied; that is why the secret store call is a deliberate choice, not a gap. The startup
script given to an instance, however, is set when the instance is created and does not change for
as long as that instance lives — fixing the script does not fix the running machine, it only fixes
machines **created from this point on**.

#### Summary

- An item's suitability for embedding is determined by change frequency, not size: `kernel
  packages` gains 61 seconds per bake, `environment variables` gains 0.1 — a six-hundredfold
  difference.
- `application binary` takes 22 seconds at startup but changes every two days; embedded, it costs
  15 bakes and 52.5 stale item-days a month, while `runtime` sells the same seconds for 3.5
  item-days.
- In the embedding level scan, startup drops from 183 seconds to 48.9 seconds; the price of a
  gained second is 0.19 stale item-days in the first four items, 5.42 in the last four.
- External calls drop from 18 to 2, but dependency type count falls step by step: a type is
  removed only once the last item bound to it is also embedded; baker load climbs from 94 minutes
  to 1,161 minutes.
- Dependencies that block startup drop from 4 to 1, not to zero; if the clock server drops, the
  machine boots and takes work unsynchronized — a condition harder to notice than a failed
  startup.
- Six of nine items are kept, three are with the provider; the three that cannot be undone are
  published image content, the secret embedded in the image, and the startup script given to the
  instance.

#### Next Step

This lesson counted how long startup takes and what it depends on, but it never asked how long the
machine will **live**. The entire calculation was built on the assumption that the instance stays
up for as long as it is wanted: both 183 seconds and 48.9 seconds are paid once, and then the
machine keeps doing work.

There is also an interruptible form of renting — capacity is given cheaply, and in exchange the
provider can take it back with a short notice. The previous course measured this form's price
difference; here price becomes an input, and the question shifts to the type of job. The next
lesson runs four job types on interruptible capacity and asks: as the notice period shortens, how
much work does each job lose, what does restarting cost, and which job does not tolerate
interruption at all?

### Persistent and Interruptible Capacity

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/persistent-and-interruptible-capacity

Four job types are run on interruptible capacity, and interruption notice is scanned from zero to three hundred seconds to count the work lost at each type; over a thirty-day period, interruption count, lost job-hours, restart load, and completion rate are worked out; the job type that does not tolerate interruption at all is named, and the mix of persistent with interruptible is scanned.

The previous two lessons rented the machine and started it; both rested on the same assumption —
the instance stays up for as long as it is wanted. There is also a form of renting that removes
this assumption: **interruptible capacity** is capacity the provider can take back with a short
notice when it is needed elsewhere, and in exchange it is given noticeably cheaply.

M23/K01 measured the price difference between these two forms; that comparison is not repeated
here, it is **taken as input**. This lesson's question is not price but **job type**: when the
same discount is given to four different jobs, the four do not all gain the same thing, because
when interruption arrives the four do not all lose the same thing. The measure is not the
interruption itself but **what the interruption takes away**.

**CC13.** Four job types are the jobs of the regional measurement network — the fictional software
that collects readings from water meters; their durations and instance counts are fictional.
**CC14.** The interruption rate is 0.06 interruptions per instance-hour; the generator is
self-written and the seed 20260901 is visible. **CC15.** Notice duration is scanned between 0 and
300 seconds; each job type's recovery time is fictional. **CC16.** Persistent capacity is 1.00,
interruptible capacity is 0.32, both in **fictional units**; the ratio comes as input from M23/K01,
not a real currency. **CC17.** The long run keeps no checkpoint; when interrupted it starts from
scratch. **CC18.** The period is 30 days.

#### Which Job Loses What

Interruption notice is not a news item, it is a window: the time left before capacity is taken
back. The only thing that can be done within that window is recovering the job, and each job
type's recovery operation takes a different amount of time. If the window is shorter than the
recovery time, the notice is of no use at all.

```js
// measurement-network/job-types.mjs — what four job types lose on interruption (model; not a product)

export const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;

export const NOTICE = [0, 30, 60, 120, 300];        // interruption notice scanned (s)

// recover: seconds needed to save the job within the notice window (loss occurs if not enough).
// loss: job lost in one interruption if it cannot be recovered (minutes). restart: restart cost (minutes).
export const JOBS = [
  { name: "nightly batch job", instances: 12, hours: 6, recover: 45, loss: 15, restart: 4,
    note: "15 min elapsed since checkpoint" },
  { name: "queue worker", instances: 20, hours: 24, recover: 90, loss: 0.75, restart: 3,
    note: "half task, returns to queue" },
  { name: "long single run", instances: 4, hours: 20, recover: 660, loss: 600, restart: 6,
    note: "half the run, 10 h" },
  { name: "user-facing service", instances: 30, hours: 24, recover: 20, loss: 0.08,
    restart: 2, note: "24 requests in flight" },
];

export const loss = (job, n) => (n >= job.recover ? 0 : job.loss);

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log("job type".padEnd(32) + "recovery".padEnd(11) +
    NOTICE.map((n) => `${n} s`.padEnd(9)).join("") + "lost per interruption");
  for (const job of JOBS)
    console.log(job.name.padEnd(32) + `${job.recover} s`.padEnd(11) +
      NOTICE.map((n) => `${loss(job, n).toFixed(2)}`.padEnd(9)).join("") + job.note);
  console.log("\n(cells are job-minutes lost per interruption)");
  const neverRecovers = JOBS.filter((job) => NOTICE.every((n) => loss(job, n) > 0));
  console.log(`job whose loss never zeroes out across the notice scan: ` +
    neverRecovers.map((x) => x.name).join(", "));
  console.log(`reason: recovery needs ${neverRecovers[0].recover} s, the scan's longest ` +
    `notice is ${Math.max(...NOTICE)} s`);
}
```

```
job type                        recovery   0 s      30 s     60 s     120 s    300 s    lost per interruption
nightly batch job               45 s       15.00    15.00    0.00     0.00     0.00     15 min elapsed since checkpoint
queue worker                    90 s       0.75     0.75     0.75     0.00     0.00     half task, returns to queue
long single run                 660 s      600.00   600.00   600.00   600.00   600.00   half the run, 10 h
user-facing service             20 s       0.08     0.00     0.00     0.00     0.00     24 requests in flight

(cells are job-minutes lost per interruption)
job whose loss never zeroes out across the notice scan: long single run
reason: recovery needs 660 s, the scan's longest notice is 300 s
```

In the table, three rows zero out at a threshold, one row never zeros out. The user-facing service
drains in 20 seconds and its loss ends at a 30-second notice; the nightly batch job takes an
emergency checkpoint in 45 seconds and recovers at 60 seconds; the queue worker loses nothing if it
can finish the 90-second task in hand. In these three, what makes interruption tolerable is not the
type of capacity but **whether the job is divisible**: if a job is split into small pieces, no more
than one piece is lost.

The fourth row shows what an undivided job looks like. The 20-hour run with no checkpoint loses all
progress up to that moment when interruption arrives, and recovering that requires a 660-second
checkpoint. The scan's longest notice is 300 seconds; **no matter how long the notice gets, the
loss does not change**. This is the job that does not tolerate interruption at all, and its name is
`long single run`. The fix is not a longer notice — it is splitting the job itself, that is, adding
a checkpoint; and that is an application decision, not a capacity decision.

#### The Thirty-Day Period

Loss per interruption is a unit price; the real question is how many times a month that unit is
paid. The run below produces a 30-day period with the same generator and compares two notice
levels — zero and 120 seconds — over the same interruption sequence.

```js
// measurement-network/interruption-period.mjs — a 30-day interruptible capacity run (model)
import { generator, JOBS, loss } from "./job-types.mjs";

export const DAYS = 30, RATE = 0.06;      // interruptions/instance-hour (fictional)
const r = generator(20260901);            // seed visible

export const run = (job) => {
  const hours = job.instances * job.hours * DAYS;
  let interruptions = 0;
  for (let i = 0; i < hours; i++) if (r() < RATE) interruptions++;
  const restart = interruptions * job.restart;
  const measure = (n) => (60 * hours) / (60 * hours + interruptions * loss(job, n) + restart);
  return { hours, interruptions, restart, atZero: interruptions * loss(job, 0),
    at120: interruptions * loss(job, 120), yieldZero: measure(0), yield120: measure(120) };
};

// The 20-hour run with no checkpoint: starts over from scratch on every attempt.
export const singleRun = (job) => {
  const TOTAL = DAYS * 24;
  let elapsed = 0, attempts = 0, completed = 0, wasted = 0;
  while (elapsed < TOTAL) {
    attempts++;
    let h = 0;
    while (h < job.hours && elapsed < TOTAL) { h++; elapsed++; if (r() < RATE) break; }
    if (h === job.hours) completed++; else { wasted += h; elapsed += job.restart / 60; }
  }
  return { attempts, completed, wasted, persistentCompletions: Math.floor(TOTAL / job.hours) };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`${DAYS} days, interruption rate ${RATE}/instance-hour, seed 20260901\n`);
  console.log("job type".padEnd(32) + "instance-hours".padEnd(16) + "interruptions".padEnd(15) +
    "lost h (0 s)".padEnd(17) + "lost h (120 s)".padEnd(19) +
    "restart h".padEnd(12) + "yield 0 s".padEnd(12) + "yield 120 s");
  const s = {};
  for (const job of JOBS) {
    const k = (s[job.name] = run(job));
    console.log(job.name.padEnd(32) + String(k.hours).padEnd(16) + String(k.interruptions).padEnd(15) +
      (k.atZero / 60).toFixed(1).padEnd(17) + (k.at120 / 60).toFixed(1).padEnd(19) +
      (k.restart / 60).toFixed(1).padEnd(12) +
      `${(100 * k.yieldZero).toFixed(1)}%`.padEnd(12) + `${(100 * k.yield120).toFixed(1)}%`);
  }
  const longJob = JOBS.find((x) => x.name === "long single run");
  const t = singleRun(longJob);
  console.log(`\n${longJob.hours}-hour run with no checkpoint: ${t.attempts} attempts, ` +
    `${t.completed} completions; ${t.persistentCompletions} completions on persistent capacity`);
  console.log(`run hours wasted: ${t.wasted}; completion rate ` +
    `${(100 * t.completed / t.persistentCompletions).toFixed(0)}%`);
  const service = s["user-facing service"], queue = s["queue worker"];
  console.log(`at 0 s notice: service drops ${service.interruptions * 24} requests, ` +
    `queue puts ${queue.interruptions} tasks back in the queue`);
}
```

```
30 days, interruption rate 0.06/instance-hour, seed 20260901

job type                        instance-hours  interruptions  lost h (0 s)     lost h (120 s)     restart h   yield 0 s   yield 120 s
nightly batch job               2160            137            34.3             0.0                9.1         98.0%       99.6%
queue worker                    14400           964            12.1             0.0                48.2        99.6%       99.7%
long single run                 2400            164            1640.0           1640.0             16.4        59.2%       59.2%
user-facing service             21600           1450           1.9              0.0                48.3        99.8%       99.8%

20-hour run with no checkpoint: 66 attempts, 15 completions; 36 completions on persistent capacity
run hours wasted: 415; completion rate 42%
at 0 s notice: service drops 34800 requests, queue puts 964 tasks back in the queue
```

The value of notice accumulates in a different place depending on job type. In the nightly batch
job, a 120-second notice saves 34.3 job-hours a month — 1.6 percent of a month's total work. In the
service row, the saved work is only 1.9 hours, but inside that 1.9 hours are **34,800 requests**,
and each one is a response a user was waiting for. The same unit of measure means a different
thing in the two job types: in the batch job, what is lost is time; in the service, what is lost is
users.

The restart column is a cost that notice can never save. The service takes 1,450 interruptions a
month, and each one demands a two-minute startup; the total is 48.3 hours — twenty-five times the
1.9 hours saved. On interruptible capacity, startup time is not a one-time cost; the previous
lesson's 48.9 seconds is an item paid 1,450 times a month here.

The third row must be read twice. The rough calculation counts average loss as half the run and
finds a yield of 59.2 percent. Run attempt by attempt, the real figure comes out to **42 percent**:
only 15 of 66 attempts complete, 415 run-hours go to waste, and work that finishes 36 times a month
on persistent capacity finishes 15 times on interruptible. The average-loss assumption misleads
here, because the loss is not half the run but **the entire time elapsed until interruption**, and
in a long run that time is larger than expected.

#### Cost and Mix

Once loss is counted, the price difference becomes meaningful. Interruptible capacity is cheap,
but the lost work gets redone, and redone work also takes hours. The comparison is therefore made
not on unit price but on **the hours it takes to finish the same job**.

```js
// measurement-network/capacity-cost.mjs — persistent versus interruptible capacity cost per job type (model)
import { JOBS } from "./job-types.mjs";
import { run, singleRun } from "./interruption-period.mjs";

const PERSISTENT = 1.0, INTERRUPTIBLE = 0.32;    // input from M23/K01: fictional unit, not a real price
const MIX = [1.0, 0.7, 0.5, 0.3];                // persistent-share scan

// Generator order is kept identical to the previous run: four jobs first, then the single-run attempt.
const RUNS = JOBS.map(run), SINGLE = singleRun(JOBS[2]);

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`persistent ${PERSISTENT.toFixed(2)} unit/instance-hour, interruptible ${INTERRUPTIBLE} unit ` +
    `(fictional; break-even yield ${(100 * INTERRUPTIBLE / PERSISTENT).toFixed(0)}%)\n`);
  console.log("job type".padEnd(32) + "yield".padEnd(9) + "persistent unit".padEnd(17) +
    "interruptible unit".padEnd(20) + "savings".padEnd(11) + "duration multiplier");
  JOBS.forEach((job, i) => {
    const k = RUNS[i], yieldRate = i === 2 ? SINGLE.completed / SINGLE.persistentCompletions : k.yield120;
    const persistentCost = k.hours * PERSISTENT, interruptibleCost = (k.hours / yieldRate) * INTERRUPTIBLE;
    console.log(job.name.padEnd(32) + `${(100 * yieldRate).toFixed(1)}%`.padEnd(9) +
      persistentCost.toFixed(0).padEnd(17) + interruptibleCost.toFixed(0).padEnd(20) +
      `${(100 * (1 - interruptibleCost / persistentCost)).toFixed(0)}%`.padEnd(11) + (1 / yieldRate).toFixed(2));
  });
  const service = JOBS[3], s = RUNS[3];
  console.log("\npersistent/interruptible mix for the service (30 instances, same capacity):");
  console.log("persistent share".padEnd(18) + "persistent instances".padEnd(22) + "interruptible instances".padEnd(25) +
    "monthly unit".padEnd(14) + "instantly losable");
  for (const p of MIX) {
    const persistentCount = Math.round(service.instances * p), interruptibleCount = service.instances - persistentCount;
    const monthly = (persistentCount * PERSISTENT + (interruptibleCount / s.yield120) * INTERRUPTIBLE) * service.hours * 30;
    console.log(`${(100 * p).toFixed(0)}%`.padEnd(18) + String(persistentCount).padEnd(22) +
      String(interruptibleCount).padEnd(25) + monthly.toFixed(0).padEnd(14) +
      `${(100 * interruptibleCount / service.instances).toFixed(0)}% capacity`);
  }
  const KEPT = ["putting a job type on interruptible capacity", "checkpoint frequency",
    "the work to do within the notice window", "persistent/interruptible mix ratio", "retry policy"];
  const HANDED_OFF = ["the moment of interruption", "notice duration", "how many instances are reclaimed at once",
    "when capacity comes back"];
  const IRREVERSIBLE = ["unsaved work on the interrupted instance's local disk",
    "an external write left half-done in the notice window", "the inability to add a checkpoint once a run has started"];
  console.log(`\ndecision ledger: ${KEPT.length} items kept, ${HANDED_OFF.length} items with the provider`);
  console.log(`  with the provider: ${HANDED_OFF.join(", ")}`);
  for (const g of IRREVERSIBLE) console.log(`  cannot be undone — ${g}`);
}
```

```
persistent 1.00 unit/instance-hour, interruptible 0.32 unit (fictional; break-even yield 32%)

job type                        yield    persistent unit  interruptible unit  savings    duration multiplier
nightly batch job               99.6%    2160             694                 68%        1.00
queue worker                    99.7%    14400            4623                68%        1.00
long single run                 41.7%    2400             1843                23%        2.40
user-facing service             99.8%    21600            6927                68%        1.00

persistent/interruptible mix for the service (30 instances, same capacity):
persistent share  persistent instances  interruptible instances  monthly unit  instantly losable
100%              30                    0                        21600         0% capacity
70%               21                    9                        17198         30% capacity
50%               15                    15                       14264         50% capacity
30%               9                     21                       11329         70% capacity

decision ledger: 5 items kept, 4 items with the provider
  with the provider: the moment of interruption, notice duration, how many instances are reclaimed at once, when capacity comes back
  cannot be undone — unsaved work on the interrupted instance's local disk
  cannot be undone — an external write left half-done in the notice window
  cannot be undone — the inability to add a checkpoint once a run has started
```

The savings column is the same across three job types — 68 percent — and that means every job
whose yield stays above 99 percent captures the full discount. In the long run, savings drop to 23
percent. What stands out is that the savings are **still positive**: the break-even yield is 32
percent, and the run's yield is 41.7 percent, above the threshold. Interruptible capacity is cheap
on paper for this job too.

Cheapness does not decide; the last column decides. The duration multiplier is 2.40: the same run
takes two and a half times as long on interruptible capacity and finishes 15 times a month instead
of 36. If a nightly report runs two days late, what is being paid is not the unit but **the result
arriving late**, and that cost never shows up in this table. Cheapness can be measured; the value
of delay varies by job — this is where interruptible capacity's real limit lies.

The mix table counts the service's middle path. When 30 percent of capacity is converted to
interruptible, the monthly cost drops from 21,600 to 17,198 units, and the capacity that can be
lost at once rises to 30 percent; converting 70 percent brings the cost down to 11,329, but 70
percent of capacity becomes reclaimable by a single decision. The persistent share is the smallest
capacity that must stay standing at the moment of interruption.

In the decision ledger, **five of nine items are kept, four are with the provider** — and the four
handed off are the heaviest handoff in this course so far, because among them is the **moment** of
interruption itself. The three items that cannot be undone also come from this handoff: unsaved
work on the interrupted instance's local disk does not come back, an external write left half-done
in the notice window cannot be undone, and a checkpoint cannot be added once a run has started. The
third is the most insidious: the decision to move to interruptible capacity is reversible, but
whether **the job running at that moment** tolerates interruption cannot be changed mid-run.

#### Summary

- The notice window only helps when it is longer than the recovery time: the service recovers in
  20 seconds, the batch job in 45, the queue worker in 90; the 20-hour run with no checkpoint
  demands 660 seconds and its loss never zeroes out across the scan.
- The job that does not tolerate interruption at all is `long single run`; the fix is not a longer
  notice but splitting the job — an application decision, not a capacity decision.
- The same unit of measure tells a different story: a 120-second notice saves 34.3 job-hours a
  month in the batch job and 1.9 hours in the service — but that 1.9 hours is 34,800 requests.
- Restart is a cost notice cannot save: the service takes 1,450 interruptions a month and 48.3
  hours go to startup, twenty-five times the saved time.
- The average-loss assumption misleads in the long run: the rough calculation says 59.2 percent
  yield, run attempt by attempt it comes out to 42 percent — 15 of 66 attempts complete, 415 hours
  go to waste.
- Five of nine items are kept, four are with the provider; interruptible capacity gives 68 percent
  savings in three job types, gives 23 percent in the long run but multiplies duration by 2.40.

#### Next Step

These three lessons counted every decision on a single instance: its family, its size, its image,
the work it does at startup, whether it is persistent or interruptible. Every item counted is a
decision made for a single instance — and the interruptible capacity section showed that these
decisions are **given again, often**: 1,450 instances open a month.

In each of those 1,450 instances, the same family, the same size, the same image, the same subnet,
and the same startup script must be chosen. On a manually launched instance, each of these choices
is a separate write, and every write is a chance to drift. The next lesson collects these items
into a single definition and asks: at the end of a period, in how many items do manually launched
instances and template-launched ones diverge from each other, how many instances fall behind when
the template is updated, and how large a surface of inputs does the template itself open?

### Launch Templates

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/launch-templates

Manually launched instances and template-launched ones are compared field by field at the end of a period, and drifted instances and separate configuration variants are counted; when the template is updated, instances left behind, the number of versions running at the same time, and instances a security patch has not reached are worked out; the template's own input surface and the fields that cannot be undone are named.

The previous lesson counted 1,450 instances opening a month on interruptible capacity. In each of
those 1,450 launches, the same family, the same size, the same image, the same subnet, and the
same startup script are chosen. If the choice is made manually, every launch is a separate write;
every separate write is a chance to drift from the definition.

A **launch template** collects these items into a single definition: when an instance is launched,
fields are not given one by one, the template's name is given. This lesson measures how much of
the gap the template closes. The measurement is made in three places — drift at launch time,
instances left behind when the template is updated, and the template's own input surface. M22/K01
measured drift in the same way, as the number of machines diverging from the definition; the
magnitude here is the same, but the source is not drift accumulated while running, it is **the
write made at launch time**.

**CC19.** The template has 12 fields; fields and drift probabilities are fictional. **CC20.** Over
the period, 140 instances of the regional measurement network — the fictional software that
collects readings from water meters — are launched; the seed 20260915 is visible and the generator
is self-written. **CC21.** The fleet is 60 instances, runs for 90 days, each instance's daily
replacement probability is 0.02, and the template is updated every 21 days; seed 20260921.
**CC22.** A security patch arrives with the template's second version. **CC23.** An in-place fix
takes 4 minutes, re-creation takes 35 minutes, and 2 instances are handled at once. **CC24.** An
override is a deliberate act and is modeled separately from accidental drift.

#### How Many Times to Write the Same Definition

In manual launches, every field is given separately every time; in a template, a field is given
once and, at launch time, can only be **overridden**. When the two paths are run over the same
period and counted field by field, where the difference lies becomes visible.

```js
// measurement-network/template-fields.mjs — manual launch versus template launch drift (model)

export const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;

export const LAUNCHES = 140;    // instances launched over the period (fictional)

// [name, required?, overridable?, mutable after creation?, manual drift, override]
export const FIELD = [
  ["instance family and size", true, true, true, 0.09, 0.020],
  ["machine image", true, true, false, 0.11, 0.010],
  ["subnet", true, true, false, 0.06, 0.005],
  ["security group", true, true, true, 0.08, 0.010],
  ["key pair identity", true, true, true, 0.05, 0.005],
  ["root disk size", false, true, true, 0.12, 0.020],
  ["root disk encryption", false, true, false, 0.07, 0.005],
  ["startup script", false, true, false, 0.10, 0.015],
  ["capacity type", false, true, false, 0.04, 0.030],
  ["placement preference", false, false, false, 0.05, 0.000],
  ["tags", false, true, true, 0.18, 0.040],
  ["monitoring agent", false, true, true, 0.09, 0.005],
];

// Fields that do not change after creation: their drift cannot be fixed in place, the instance is re-created.
export const IRREVERSIBLE = FIELD.map(([, , , mutable], j) => (mutable ? -1 : j)).filter((j) => j >= 0);

export const period = (seed) => {
  const r = generator(seed), drifted = FIELD.map(() => [0, 0]), signature = [new Set(), new Set()];
  const fix = [[0, 0], [0, 0]];        // [re-creation, in-place fix]
  for (let i = 0; i < LAUNCHES; i++) {
    const trace = [[], []];
    FIELD.forEach(([, , overridable, , manual, override], j) => {
      if (r() < manual) { drifted[j][0]++; trace[0].push(j); }
      if (overridable && r() < override) { drifted[j][1]++; trace[1].push(j); }
    });
    signature[0].add(trace[0].join(",")); signature[1].add(trace[1].join(","));
    [0, 1].forEach((k) => {
      if (trace[k].some((j) => IRREVERSIBLE.includes(j))) fix[k][0]++;
      else if (trace[k].length) fix[k][1]++;
    });
  }
  return { drifted, fix, variants: signature.map((s) => s.size) };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  const d = period(20260915);
  console.log(`${LAUNCHES} instances launched; seed 20260915\n`);
  console.log("field".padEnd(26) + "required".padEnd(10) + "overridable".padEnd(18) +
    "mutable later".padEnd(18) + "manual drift".padEnd(14) + "template drift");
  FIELD.forEach(([name, required, overridable, mutable], j) =>
    console.log(name.padEnd(26) + (required ? "yes" : "no").padEnd(10) +
      (overridable ? "yes" : "no").padEnd(18) + (mutable ? "yes" : "no").padEnd(18) +
      `${d.drifted[j][0]}/${LAUNCHES}`.padEnd(14) + `${d.drifted[j][1]}/${LAUNCHES}`));
  const t = [0, 1].map((k) => d.drifted.reduce((s, x) => s + x[k], 0));
  console.log(`\ntotal field drift: manual ${t[0]}, template ${t[1]} (${(t[0] / t[1]).toFixed(1)}x)`);
  console.log(`separate configuration variants: manual ${d.variants[0]}, template ${d.variants[1]}`);
}
```

```
140 instances launched; seed 20260915

field                     required  overridable       mutable later     manual drift  template drift
instance family and size  yes       yes               yes               8/140         5/140
machine image             yes       yes               no                12/140        4/140
subnet                    yes       yes               no                10/140        0/140
security group            yes       yes               yes               9/140         1/140
key pair identity         yes       yes               yes               8/140         2/140
root disk size            no        yes               yes               18/140        2/140
root disk encryption      no        yes               no                6/140         1/140
startup script            no        yes               no                12/140        2/140
capacity type             no        yes               no                5/140         7/140
placement preference      no        no                no                4/140         0/140
tags                      no        yes               yes               30/140        9/140
monitoring agent          no        yes               yes               12/140        1/140

total field drift: manual 134, template 34 (3.9x)
separate configuration variants: manual 38, template 13
```

Total drift drops from 134 to 34 — nearly fourfold. But the real number is in the last line: the
140 manually launched instances produce **38 separate configuration variants**, the
template-launched ones produce 13. Variant count tells how many different things the fleet is made
of; it is what determines, when investigating a problem and asking "how is this machine different
from the others," how many places the answer must be searched.

The `subnet` and `placement preference` rows draw the boundary of how much the template can close.
Placement preference is the one field that cannot be overridden, and its drift on the template
path is **zero** — it is the only field fully preserved. Subnet comes out at zero too, even though
it is overridable, because no one changes it deliberately; protection like this is usually enough
but is not guaranteed.

The `capacity type` row shows the reverse and must be read carefully: drift on the template path
is **higher than manual** — 7 against 5. This is not a bug, it is a usage. The previous lesson's
result requires choosing between persistent and interruptible by job type, and that choice is made
per instance. The template removes accidental drift; **it does not remove deliberate overrides**,
and it should not. The measure's job is to separate the two, not to suppress both.

#### What Happens When the Template Is Updated

A template is a definition; the definition changing does not change running instances. Whatever
version an instance was launched with, it carries that version for its entire life. That is why
the template's version and the fleet's version are not the same thing.

```js
// measurement-network/template-version.mjs — how many instances fall behind when the template is updated (model)
import { generator } from "./template-fields.mjs";

const FLEET = 60, DAYS = 90, CHANGE = 0.02, INTERVAL = 21;   // daily instance-replacement probability
const PATCH = 2;                                              // version the security patch arrives with

export const run = (seed = 20260921) => {
  const r = generator(seed);
  const instances = Array.from({ length: FLEET }, () => ({ version: 0, born: 0 }));
  const versions = [{ no: 0, day: 0, launched: FLEET }];
  for (let g = 1; g <= DAYS; g++) {
    if (g % INTERVAL === 0) versions.push({ no: versions.length, day: g, launched: 0 });
    const cur = versions[versions.length - 1];
    for (const o of instances)
      if (r() < CHANGE) { o.version = cur.no; o.born = g; cur.launched++; }
  }
  return { instances, versions };
};

if (import.meta.url === `file://${process.argv[1]}`) {
  const { instances, versions } = run();
  console.log(`${FLEET} instances, ${DAYS} days, daily replacement probability ${CHANGE}, ` +
    `template updated every ${INTERVAL} days; seed 20260921\n`);
  console.log("version".padEnd(9) + "release day".padEnd(13) + "launched with this version".padEnd(29) +
    "running at period end".padEnd(23) + "age of the oldest instance");
  for (const s of versions) {
    const k = instances.filter((o) => o.version === s.no);
    console.log(`v${s.no}`.padEnd(9) + `day ${s.day}`.padEnd(13) +
      String(s.launched).padEnd(29) + `${k.length}/${FLEET}`.padEnd(23) +
      (k.length ? `${DAYS - Math.min(...k.map((o) => o.born))} days` : "—"));
  }
  const stale = instances.filter((o) => o.version < PATCH).length;
  const latest = versions[versions.length - 1];
  console.log(`\nlatest version v${latest.no} shipped on day ${latest.day}; ${DAYS - latest.day} days later ` +
    `${FLEET - instances.filter((o) => o.version === latest.no).length}/${FLEET} instances are still behind`);
  console.log(`security patch arrived with v${PATCH} (day ${versions[PATCH].day}); ` +
    `${DAYS - versions[PATCH].day} days later ${stale}/${FLEET} instances are still on the old value`);
  console.log(`versions running at the same time: ` +
    `${new Set(instances.map((o) => o.version)).size}/${versions.length}`);
}
```

```
60 instances, 90 days, daily replacement probability 0.02, template updated every 21 days; seed 20260921

version  release day  launched with this version   running at period end  age of the oldest instance
v0       day 0        78                           14/60                  90 days
v1       day 21       30                           8/60                   69 days
v2       day 42       29                           15/60                  46 days
v3       day 63       21                           14/60                  26 days
v4       day 84       9                            9/60                   5 days

latest version v4 shipped on day 84; 6 days later 51/60 instances are still behind
security patch arrived with v2 (day 42); 48 days later 22/60 instances are still on the old value
versions running at the same time: 5/5
```

The template was versioned five times, and at the end of the period **all five versions are
running**. Even though the entire fleet was launched from a single definition, five different
moments of that definition are standing at the same time. This is the time dimension of the same
magnitude as the previous section's variant count: there, drift was born at launch time; here, the
fleet diverges **even though the template has not changed**.

The oldest instance is 90 days old and carries the first version. In a fleet with a daily
replacement probability of 0.02, the probability of an instance living 90 days is low but not
zero; 14 of 60 instances complete the period without ever changing. A plan that looks at the
average does not see these instances, because the average instance age is under 50 days.

The security row is the cost of this. The patch arrived with the second version on day 42; **48
days later, 22 of 60 instances are still on the old value**. Updating the template is not
publishing a fix, only making it apply to instances launched from that point on. The fix reaches
the fleet only as instances get replaced, and that speed is determined not by the template but by
the fleet's renewal policy.

#### The Template's Own Surface

The template reduces drift, but it is itself an input surface: twelve fields sit in one place, and
every field in that place can be wrong. The surface's size is measured not by the field count but
by **how many fields can be overridden** and by how fixable the drift is.

```js
// measurement-network/template-surface.mjs — the template's own input surface and the cost of fixing drift (model)
import { FIELD, LAUNCHES, IRREVERSIBLE, period } from "./template-fields.mjs";

const RECREATE = 35, IN_PLACE = 4, WINDOW = 2;   // minutes, minutes, instances handled at once
const count = (f) => FIELD.filter(f).length;

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`the template's input surface: ${FIELD.length} fields — ${count((a) => a[1])} required, ` +
    `${count((a) => !a[1])} defaulted, ${count((a) => a[2])} overridable, ` +
    `${IRREVERSIBLE.length} do not change after creation`);
  console.log(`field that cannot be overridden: ` +
    FIELD.filter((a) => !a[2]).map((a) => a[0]).join(", ") + "\n");
  const d = period(20260915);
  console.log("path".padEnd(11) + "drifted instances".padEnd(19) + "fixed in place".padEnd(17) +
    "re-created".padEnd(19) + "fix wall-clock hours");
  ["manual", "template"].forEach((path, k) => {
    const [recreated, inPlace] = d.fix[k];
    const minutes = Math.ceil(recreated / WINDOW) * RECREATE + Math.ceil(inPlace / WINDOW) * IN_PLACE;
    console.log(path.padEnd(11) + `${recreated + inPlace}/${LAUNCHES}`.padEnd(19) + String(inPlace).padEnd(17) +
      String(recreated).padEnd(19) + `${(minutes / 60).toFixed(1)} h`);
  });
  const KEPT = ["the template's fields", "which fields are overridable", "default values",
    "version policy", "who can update the template"];
  const HANDED_OFF = ["template storage", "how long version history is retained",
    "the launch call's validation rules"];
  console.log(`\ndecision ledger: ${KEPT.length} items kept, ${HANDED_OFF.length} items with the provider`);
  console.log(`  with the provider: ${HANDED_OFF.join(", ")}`);
  console.log(`  ${IRREVERSIBLE.length} fields that cannot be undone: ` +
    IRREVERSIBLE.map((j) => FIELD[j][0]).join(", "));
}
```

```
the template's input surface: 12 fields — 5 required, 7 defaulted, 11 overridable, 6 do not change after creation
field that cannot be overridden: placement preference

path       drifted instances  fixed in place   re-created         fix wall-clock hours
manual     92/140             47               45                 15.0 h
template   32/140             18               14                 4.7 h

decision ledger: 5 items kept, 3 items with the provider
  with the provider: template storage, how long version history is retained, the launch call's validation rules
  6 fields that cannot be undone: machine image, subnet, root disk encryption, startup script, capacity type, placement preference
```

The first line gives the template's boundary in a single number: **11 of 12 fields can be
overridden at launch time**. The template is not a gate, it is a set of defaults; drift's back
door is not closed, it is narrow. The one fully closed field is placement preference, and it is no
coincidence that this field shows zero drift on the template path.

The second table splits the cost of fixing into two items. In manual launches, 92 of 140 instances
drift; 47 of these are fixed in place, and **45 must be re-created**, because the drift is in a
field that does not change after creation. The total wall-clock cost is 15.0 hours. On the
template path, drifted instances drop to 32, instances needing re-creation to 14, and wall-clock
cost to 4.7 hours. What the template actually gains is not the drop in drift count, it is the drop
in **unfixable drift**: from 45 to 14.

The six fields that cannot be undone are the source of this distinction, and three of them come
from this course's previous lessons: machine image, subnet, and root disk encryption are given
when the instance is created, the startup script freezes for that instance, capacity type cannot
turn an instance launched as persistent into interruptible, and placement preference determines
where the instance is placed and is not moved afterward. A wrong value in any of these six fields
demands not a fix but a **replacement**.

The surface also has an unwritten side. The template does not have **how many instances to
launch**; the template says what an instance will be, not how many there will be. That number is
still given externally at every launch, and it has been given 140 times over the period. In the
same way, the template does not write **when** a launched instance will be terminated; that is why
14 instances never change in the 90-day run.

In the decision ledger, **five of eight items are kept, three are with the provider**. The three
that stay with the provider — template storage, how long version history is retained, and which
fields the launch call validates — turn the template itself into a dependency: the five kept items
are meaningful only while those three keep working.

#### Summary

- The template brings total field drift down from 134 to 34, but the more meaningful number is
  variants: 140 instances produce 38 separate configurations when launched manually and 13 when
  launched from the template.
- The template removes accidental drift, not deliberate overrides: the `capacity type` field shows
  7 drifts on the template path and 5 on the manual path, and this is not a bug, it is a decision
  made by job type.
- When the template is updated, no running instance changes: five versions run at the same time,
  and 6 days after the latest version ships, 51 of 60 instances are still behind.
- The security patch arrives with the second version, yet 48 days later 22 instances are still on
  the old value; the fix's speed is determined not by the template but by the fleet's renewal
  policy.
- The template's input surface is 12 fields, and 11 can be overridden at launch time; the one
  field fully preserved is placement preference.
- The template's real gain is in unfixable drift: instances needing re-creation drop from 45 to
  14, fix wall-clock hours from 15.0 to 4.7; the number of fields that cannot be undone is 6.

#### Next Step

This lesson reduced instance launch to a single definition, but a person still makes the launch
decision: the template exists, and so does whoever presses the button. The numbers from previous
lessons showed that this button is pressed thousands of times a month — 1,450 interruptions on
interruptible capacity, 180 instance changes over 90 days.

Producing a group of instances from a single template makes it possible to hand off two more jobs
to that group: changing the count by demand, and renewing an unhealthy instance on its own. M22/K03
measured horizontal scaling and the oscillation born from metric lag; that scan is not repeated.
The next lesson measures the **combined** behavior of these two jobs and asks: if scaling is also
triggered while an unhealthy instance is being replaced, what does group size do, how many
instances change at once, and in how many rounds does the group drop below capacity?

### Auto Scaling Groups

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/auto-scaling-groups

The scaling policy and health checks are run together in the same group, and group-size swing, the number of instances changed at once, and the rounds spent below capacity are counted; the health check threshold and the scaling cooldown duration are scanned as nine pairs, and the pair that produces constant change is named; the daily cost of constant change and the group's three items that cannot be undone are worked out.

The previous lesson reduced instance launch to a single definition and left two things the
template does not write: how many instances there will be and when they will be terminated. An
**auto scaling group** takes over both of these questions. The group is bound to a template, a
minimum and maximum instance count is given, and two separate loops begin changing the instance
count on their own.

The first loop is the **scaling policy**: it looks at a demand metric and adds or removes
instances. M22/K03 measured horizontal scaling and the oscillation born from metric lag; that scan
is not repeated here. The second loop is the **health check**: it asks the instances at regular
intervals, terminates the ones that do not respond, and launches a new one in their place. This
lesson's measure is not the two loops' behavior taken separately, it is their **combined
behavior** — because both change the same number, the group's size, and each can mistake the
other's action for demand.

**CC25.** The group is the read layer of the regional measurement network — the fictional software
that collects readings from water meters; the demand curve is fictional and identical on every
run. **CC26.** One round is one minute, the period is 240 rounds, and capacity per instance is 100
requests/round. **CC27.** Startup takes 2 rounds; the previous lessons' 48.9 seconds rounds up to
two rounds at this scale. **CC28.** The transient check failure probability is 0.08; the generator
is self-written and the seed 20261001 is visible. **CC29.** Permanent breakage occurs at rounds
40, 95, and 170; the moments are fictional and identical on every run. **CC30.** Scaling counts
only ready instances, and health replacement does not know about cooldown.

#### Two Loops in the Same Group

When a health check terminates an instance, the group shrinks. The scaling policy looks at the
group at that moment and finds the ready instance count lower than needed — the replacement
instance is still booting. The policy mistakes this for a demand increase and adds an instance.
When the booting instances become ready, the group is larger than needed, and this time the policy
shrinks it.

```js
// measurement-network/group.mjs — the combined run of an auto scaling group and health checks (model)

export const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;

export const ROUNDS = 240, CAPACITY = 100, STARTUP = 2;   // rounds, requests per instance, startup rounds
const TRANSIENT = 0.08;                                    // transient health-check failure probability
const BREAK_ROUNDS = [40, 95, 170];                         // rounds at which permanent breakage occurs (fictional)

// The demand curve is fictional and identical on every run; randomness is only in the health check.
export const demand = (t) =>
  900 + 380 * Math.sin((2 * Math.PI * t) / 120) + (t >= 150 && t < 190 ? 520 : 0);
export const needed = (t) => Math.ceil(demand(t) / CAPACITY);

export const run = ({ threshold, cooldown, seed = 20261001 }) => {
  const r = generator(seed);
  let group = Array.from({ length: needed(0) }, () => ({ ready: true, remaining: 0, streak: 0, broken: false }));
  const m = { min: 99, max: 0, concurrent: 0, ratio: 0, underCapacity: 0, started: 0,
    wrong: 0, brokenRounds: 0, scaling: 0, replaced: 0 };
  let lastAction = -cooldown;
  for (let t = 0; t < ROUNDS; t++) {
    const g = needed(t);
    if (BREAK_ROUNDS.includes(t)) {
      const victim = group.find((o) => o.ready && !o.broken);
      if (victim) victim.broken = true;
    }
    const survivors = [];                               // health check (does not know cooldown)
    for (const o of group) {
      if (!o.ready) { survivors.push(o); continue; }
      if (o.broken) m.brokenRounds++;
      const passed = !o.broken && r() >= TRANSIENT;
      o.streak = passed ? 0 : o.streak + 1;
      if (o.streak >= threshold) {
        m.replaced++; if (!o.broken) m.wrong++;
        survivors.push({ ready: false, remaining: STARTUP, streak: 0, broken: false });
        m.started++;
      } else survivors.push(o);
    }
    group = survivors;
    const readyCount = group.filter((o) => o.ready).length;  // scaling only counts ready instances
    if (t - lastAction >= cooldown) {
      if (readyCount < g) {
        for (let i = 0; i < g - readyCount; i++)
          group.push({ ready: false, remaining: STARTUP, streak: 0, broken: false });
        m.started += g - readyCount; m.scaling++; lastAction = t;
      } else if (readyCount > g + 1) {
        for (let i = 0; i < readyCount - g - 1; i++) group.splice(group.findIndex((o) => o.ready), 1);
        m.scaling++; lastAction = t;
      }
    }
    for (const o of group) if (!o.ready && --o.remaining <= 0) o.ready = true;
    const booting = group.filter((o) => !o.ready).length;
    m.min = Math.min(m.min, group.length); m.max = Math.max(m.max, group.length);
    m.concurrent = Math.max(m.concurrent, booting);
    m.ratio = Math.max(m.ratio, booting / group.length);
    if (group.filter((o) => o.ready).length < g) m.underCapacity++;
  }
  return m;
};

if (import.meta.url === `file://${process.argv[1]}`) {
  const bad = run({ threshold: 2, cooldown: 0 }), good = run({ threshold: 5, cooldown: 8 });
  console.log(`${ROUNDS} rounds, ${CAPACITY} requests per instance, ${STARTUP} startup rounds; seed 20261001`);
  console.log(`needed instances range between ${Math.min(...Array.from({ length: ROUNDS }, (_, t) => needed(t)))}` +
    ` and ${Math.max(...Array.from({ length: ROUNDS }, (_, t) => needed(t)))}\n`);
  console.log("pair".padEnd(25) + "group min-max".padEnd(15) + "swing".padEnd(13) +
    "concurrent change".padEnd(20) + "under-capacity rounds".padEnd(23) + "instances started");
  for (const [name, m] of [["threshold 2, cooldown 0", bad], ["threshold 5, cooldown 8", good]])
    console.log(name.padEnd(25) + `${m.min}-${m.max}`.padEnd(15) + String(m.max - m.min).padEnd(13) +
      `${m.concurrent} (${Math.round(100 * m.ratio)}%)`.padEnd(20) + `${m.underCapacity}/${ROUNDS}`.padEnd(23) +
      String(m.started));
  for (const [name, m] of [["threshold 2, cooldown 0", bad], ["threshold 5, cooldown 8", good]])
    console.log(`${name}: ${m.replaced} health replacements (${m.wrong} wrong), ` +
      `${m.scaling} scaling actions, broken instance stayed in traffic for ${m.brokenRounds} rounds`);
}
```

```
240 rounds, 100 requests per instance, 2 startup rounds; seed 20261001
needed instances range between 6 and 18

pair                     group min-max  swing        concurrent change   under-capacity rounds  instances started
threshold 2, cooldown 0  7-24           17           6 (26%)             10/240                 46
threshold 5, cooldown 8  7-18           11           5 (28%)             46/240                 19
threshold 2, cooldown 0: 17 health replacements (14 wrong), 40 scaling actions, broken instance stayed in traffic for 5 rounds
threshold 5, cooldown 8: 2 health replacements (0 wrong), 22 scaling actions, broken instance stayed in traffic for 11 rounds
```

The clearest number is the upper bound of group size. Even though demand never requires more than
18 instances in any round, the first pair pushes the group to **24 instances**. The extra six
instances do not come from demand; they come from the policy filling the gap that health
replacements open. The second pair never exceeds 18 on the same demand curve.

The second number is less visible but heavier: in the first pair, **26 percent of the group was
changing at the same time** — one in four instances was booting, that is, neither old nor new.
When the healthy instance count swings at this rate, the metric starts measuring the result of the
policy's own action.

The rows below separate the source. The first pair made 17 health replacements, and **14 of them
were wrong** — those instances were not broken, they only failed two consecutive checks. The
same pair produced 40 scaling actions, the second pair 22. Every wrong replacement sends the
policy a false demand signal.

But the second pair is not free. Under-capacity rounds rise from 10 to **46**: because the cooldown
is long, the policy responds late to a real demand increase. And a broken instance stays in
traffic for **11 rounds** instead of 5; because the threshold is high, an instance that is really
broken is also caught late. Each pair reduces one error and enlarges the other.

#### Scanning Threshold against Cooldown

The two endpoints showed the direction; the real question is what the pairs in between do. The
scan below runs three thresholds against three cooldown durations together — nine pairs, the same
demand curve, the same seed.

```js
// measurement-network/group-scan.mjs — combined scan of health threshold and cooldown duration (model)
import { ROUNDS, run } from "./group.mjs";

const THRESHOLDS = [2, 3, 5], COOLDOWNS = [0, 3, 8], HOURS = ROUNDS / 60;   // one round is one minute (fictional)

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`${ROUNDS} rounds = ${HOURS} hours; every row was run with the same demand curve and the same seed\n`);
  console.log("threshold".padEnd(11) + "cooldown".padEnd(10) + "group min-max".padEnd(15) +
    "swing".padEnd(13) + "concurrent".padEnd(13) + "under capacity".padEnd(16) +
    "wrong changes".padEnd(17) + "broken rounds".padEnd(15) + "started per hour");
  const all = [];
  for (const e of THRESHOLDS)
    for (const s of COOLDOWNS) {
      const m = run({ threshold: e, cooldown: s });
      all.push({ e, s, m });
      console.log(String(e).padEnd(11) + String(s).padEnd(10) + `${m.min}-${m.max}`.padEnd(15) +
        String(m.max - m.min).padEnd(13) + String(m.concurrent).padEnd(13) +
        `${m.underCapacity}/${ROUNDS}`.padEnd(16) + String(m.wrong).padEnd(17) +
        String(m.brokenRounds).padEnd(15) + (m.started / HOURS).toFixed(1));
    }
  const mostChange = all.reduce((a, b) => (b.m.started > a.m.started ? b : a));
  const leastChange = all.reduce((a, b) => (b.m.started < a.m.started ? b : a));
  const longestBroken = all.reduce((a, b) => (b.m.brokenRounds > a.m.brokenRounds ? b : a));
  console.log(`\npair producing constant change: threshold ${mostChange.e}, cooldown ${mostChange.s} — ` +
    `${(mostChange.m.started / HOURS).toFixed(1)} instances/hour, ${mostChange.m.wrong} wrong replacements`);
  console.log(`pair producing the least change: threshold ${leastChange.e}, cooldown ${leastChange.s} — ` +
    `${(leastChange.m.started / HOURS).toFixed(1)} instances/hour, ${leastChange.m.underCapacity} under-capacity rounds`);
  console.log(`pair carrying a broken instance the longest: threshold ${longestBroken.e}, cooldown ${longestBroken.s} — ` +
    `${longestBroken.m.brokenRounds} rounds in traffic`);
}
```

```
240 rounds = 4 hours; every row was run with the same demand curve and the same seed

threshold  cooldown  group min-max  swing        concurrent   under capacity  wrong changes    broken rounds  started per hour
2          0         7-24           17           6            10/240          14               5              11.5
2          3         7-18           11           5            13/240          12               6              8.5
2          8         7-18           11           4            42/240          13               6              8.5
3          0         7-23           16           5            8/240           0                8              6.3
3          3         7-18           11           5            14/240          0                7              5.0
3          8         7-18           11           5            47/240          1                8              5.3
5          0         7-23           16           5            7/240           0                12             6.0
5          3         7-18           11           5            13/240          0                12             4.8
5          8         7-18           11           5            46/240          0                11             4.8

pair producing constant change: threshold 2, cooldown 0 — 11.5 instances/hour, 14 wrong replacements
pair producing the least change: threshold 5, cooldown 3 — 4.8 instances/hour, 13 under-capacity rounds
pair carrying a broken instance the longest: threshold 5, cooldown 0 — 12 rounds in traffic
```

Each column tracks a single setting, and the four go in separate directions. **Wrong replacements
track only the threshold**: 12–14 at threshold 2, 0–1 at threshold 3, 0 at threshold 5. **Broken
rounds also track only the threshold**, but in the opposite direction: they climb from 5–6 to
11–12. The threshold is a direct trade-off between two errors — killing a healthy instance and
keeping a broken one in traffic.

Cooldown duration governs two other columns. **Swing tracks only cooldown**: 16–17 at cooldown 0,
11 at cooldowns 3 and 8. Zero cooldown lets the policy act every round, and the group mistakes its
own action's result for the metric. **Under-capacity rounds also track only cooldown**, and in the
opposite direction: they climb from 7–10 to 42–47. A long cooldown cuts the swing but delays the
response to a real demand increase.

The pairs at the extremes are read by name. The worst is **threshold 2, cooldown 0**: it launches
11.5 instances an hour, pushes the group to 24, makes 14 wrong replacements. What this pair
produces is not scaling, it is **constant change**; the group never settles. The calmest is
threshold 5, cooldown 3 — 4.8 instances an hour — but it keeps a broken instance in traffic for 12
rounds. The middle pair (threshold 3, cooldown 3) gives nearly the same calm at 5.0 instances an
hour and catches a broken instance in 7 rounds.

#### The Cost of Constant Change

The visible cost of constant change is startup: every launched instance is paid for across two
rounds and does no work at all. The invisible cost is what those instances terminate.

```js
// measurement-network/group-ledger.mjs — the cost of constant change and the group's decision ledger (model)
import { ROUNDS, STARTUP, run } from "./group.mjs";

const HOURS = ROUNDS / 60, UNIT = 1.0;      // persistent capacity unit (fictional; input from M23/K01)
const PAIRS = [[2, 0], [3, 3], [5, 3]];

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log("pair".padEnd(25) + "started per day".padEnd(18) + "spent booting".padEnd(16) +
    "daily idle unit".padEnd(18) + "under capacity".padEnd(16) + "broken rounds");
  for (const [e, s] of PAIRS) {
    const m = run({ threshold: e, cooldown: s }), daily = (m.started / HOURS) * 24;
    const idle = (daily * STARTUP) / 60;                // instance-hours: paid while booting but doing no work
    console.log(`threshold ${e}, cooldown ${s}`.padEnd(25) + daily.toFixed(0).padEnd(18) +
      `${idle.toFixed(1)} instance-h`.padEnd(16) + (idle * UNIT).toFixed(1).padEnd(18) +
      `${m.underCapacity}/${ROUNDS}`.padEnd(16) + String(m.brokenRounds));
  }
  const KEPT = ["minimum and maximum instances", "scaling metric and its thresholds", "cooldown duration",
    "health check threshold and interval", "which instance is chosen when scaling in",
    "the template version the group is bound to"];
  const HANDED_OFF = ["the health check call's own latency",
    "the latency of applying a scaling action", "whether the requested capacity is available at that moment"];
  const IRREVERSIBLE = ["the work and local disk of a terminated instance",
    "the unhealthy mark — the instance is not reverted, it is replaced",
    "capacity that has been scaled in — reclaiming it demands a new startup and cooldown round"];
  console.log(`\ndecision ledger: ${KEPT.length} items kept, ${HANDED_OFF.length} items with the provider`);
  console.log(`  with the provider: ${HANDED_OFF.join("; ")}`);
  for (const g of IRREVERSIBLE) console.log(`  cannot be undone — ${g}`);
}
```

```
pair                     started per day   spent booting   daily idle unit   under capacity  broken rounds
threshold 2, cooldown 0  276               9.2 instance-h  9.2               10/240          5
threshold 3, cooldown 3  120               4.0 instance-h  4.0               14/240          7
threshold 5, cooldown 3  114               3.8 instance-h  3.8               13/240          12

decision ledger: 6 items kept, 3 items with the provider
  with the provider: the health check call's own latency; the latency of applying a scaling action; whether the requested capacity is available at that moment
  cannot be undone — the work and local disk of a terminated instance
  cannot be undone — the unhealthy mark — the instance is not reverted, it is replaced
  cannot be undone — capacity that has been scaled in — reclaiming it demands a new startup and cooldown round
```

The bad pair launches **276 instances** a day; at the good setting, the same group does the same
job with 120. The 156-instance gap boots, becomes ready, and is terminated shortly after. The 9.2
instance-hours spent booting is capacity that costs 9.2 fictional units a day and does no work —
nothing that catches the eye of anyone looking at the bill.

The real cost is in the three items that cannot be undone. Every terminated instance takes its
work and local disk with it; in the bad pair, this happens 156 extra times a day, and most of
these are terminations of **healthy** instances. The unhealthy mark cannot be reversed: if an
instance is counted unhealthy because it failed two checks, it is not undone even if it responds
on the third check — because it no longer exists. Capacity that has been scaled in cannot be
undone either; reclaiming it demands a new startup and a cooldown round.

In the group's decision ledger, **six of nine items are kept, three are with the provider**. The
kept items are the settings this lesson scanned, and all of them can be chosen by number. The
three that stay with the provider are insidious: the health check call's own latency effectively
shifts the threshold, the latency of applying a scaling action adds to the cooldown duration, and
the requested capacity not being available at that moment quietly fails the policy.

#### Summary

- Two loops change the same number: health replacement shrinks the group, the policy mistakes
  this for demand and grows it — even though demand never exceeds 18 instances, the bad pair
  pushes the group to 24.
- In the bad pair, 26 percent of the group is changing at the same time; at this rate, neither the
  metric nor an observation tells the group's actual state at that moment.
- Threshold is a direct trade-off between two errors: 12–14 wrong replacements at threshold 2, 0
  at threshold 5 — but a broken instance stays in traffic for 11–12 rounds instead of 5–6.
- Cooldown duration governs two other columns: swing drops from 16–17 to 11, under-capacity rounds
  climb from 7–10 to 42–47.
- The pair producing constant change is threshold 2, cooldown 0: 11.5 instances an hour, 276
  launches a day, 9.2 instance-hours of idle capacity; threshold 3, cooldown 3 does the same job
  with 120 launches a day.
- Six of nine items are kept, three are with the provider; the three items that cannot be undone
  are a terminated instance's work, the unhealthy mark, and capacity that has been scaled in.

#### Next Step

This lesson handed the group off to two loops, and in exchange left six setting items: minimum and
maximum instances, the metric, thresholds, cooldown, health interval, template version. The
handoff is real — no one launches an instance manually anymore — but the number of kept items has
not dropped, only its kind has changed. Instead of choosing an instance, a policy is chosen.

The handoff can go one step further: never seeing the machine at all. M22/K02 and M22/K03 measured
containers and orchestration; that mechanics is not repeated here. The next lesson compares running
the same workload on a self-operated cluster against a managed service and asks a single question:
of the node size, version, patch, network plugin, and scaling decisions, how many stay with you at
each handoff level, what does this number become under task-based execution, and which item
becomes irreversible at each level?

### Managed Container Services

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/managed-container-services

The same workload is counted through fifteen decision items across three execution levels — self-run cluster, managed cluster, and task-based execution — and how many decisions stay with you, how many are handed off, and what the annual maintenance load is at each level are worked out; each level's items that cannot be undone are named, and the configuration that does not travel at exit along with the handoff's payback period are measured.

The previous lesson handed off instance count to a group and left six setting items in exchange.
The handoff was real but partial: the machine was still there — its family was chosen, its image
was baked, its startup was measured. The handoff can go one step further and remove the machine
entirely from view.

This lesson compares the same workload across three levels: a **self-run cluster** (running
orchestration on your own instances), a **managed cluster service** (control plane handed off,
nodes still yours), and **task-based execution** (there is no such item as a node). M22/K02
measured the container and image layer, M22/K03 the orchestration mechanics; that mechanics is not
repeated here. The single measure here is **the decision handed off**: how many items stay with
you at each level, how many are handed off, and which of the handed-off items can never be taken
back.

**CC31.** Fifteen decision items belong to the container layer of the regional measurement
network — the fictional software that collects readings from water meters; items and annual
maintenance hours are fictional. **CC32.** The three levels are as above and run the same
workload. **CC33.** "none" shows that an item does not exist at all at that level; "shared" shows
that part of the decision stays with you. **CC34.** The move rule is fictional: if the definition
is yours it moves (2 h), if it is in the provider's format it is rewritten (10 h), if it has been
fully handed off it is reinstalled (24 h), if it never existed at that level the decision is made
from scratch (16 h). **CC35.** The items that cannot be undone number three per level and are
fictional. **CC36.** Maintenance hours are annual and fictional; an item generates maintenance
load only while it is yours or shared.

#### The Same Workload, Three Execution Levels

Handoff is not an all-or-nothing decision; it is given item by item. The inventory below places
fifteen items across three levels one at a time.

```js
// measurement-network/handoff-inventory.mjs — the same workload's decision distribution across three execution levels (model)

export const LEVELS = ["self-run cluster", "managed cluster", "task-based execution"];

// [item, [level1, level2, level3], annual maintenance hours (while the item is yours or shared)]
export const ITEMS = [
  ["node size and family", ["yours", "yours", "none"], 12],
  ["node count", ["yours", "yours", "none"], 8],
  ["cluster version", ["yours", "shared", "none"], 20],
  ["node operating system patch", ["yours", "shared", "provider"], 40],
  ["control plane operation", ["yours", "provider", "provider"], 60],
  ["control plane redundancy", ["yours", "provider", "provider"], 24],
  ["network plugin choice", ["yours", "yours", "provider"], 16],
  ["storage plugin choice", ["yours", "yours", "provider"], 12],
  ["task scheduler setting", ["yours", "yours", "shared"], 18],
  ["task scaling", ["yours", "yours", "yours"], 14],
  ["resource request and limit", ["yours", "yours", "yours"], 10],
  ["image repository and pull identity", ["yours", "yours", "yours"], 8],
  ["log and metric collection", ["yours", "yours", "shared"], 22],
  ["internal network address plan", ["yours", "yours", "provider"], 14],
  ["shared process on the node", ["yours", "yours", "none"], 10],
];

export const count = (d, kind) => ITEMS.filter((k) => k[1][d] === kind).length;
export const maintenance = (d) =>
  ITEMS.filter((k) => k[1][d] !== "provider" && k[1][d] !== "none").reduce((t, k) => t + k[2], 0);

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log("decision item".padEnd(36) + LEVELS.map((d) => d.padEnd(22)).join("") + "annual maintenance");
  for (const [name, where, hours] of ITEMS)
    console.log(name.padEnd(36) + where.map((y) => y.padEnd(22)).join("") + `${hours} h`);
  console.log("");
  console.log("level".padEnd(22) + "kept".padEnd(8) + "shared".padEnd(11) +
    "provider".padEnd(14) + "none".padEnd(12) + "annual maintenance");
  LEVELS.forEach((d, i) =>
    console.log(d.padEnd(22) + String(count(i, "yours")).padEnd(8) + String(count(i, "shared")).padEnd(11) +
      String(count(i, "provider")).padEnd(14) + String(count(i, "none")).padEnd(12) + `${maintenance(i)} h`));
}
```

```
decision item                       self-run cluster      managed cluster       task-based execution  annual maintenance
node size and family                yours                 yours                 none                  12 h
node count                          yours                 yours                 none                  8 h
cluster version                     yours                 shared                none                  20 h
node operating system patch         yours                 shared                provider              40 h
control plane operation             yours                 provider              provider              60 h
control plane redundancy            yours                 provider              provider              24 h
network plugin choice               yours                 yours                 provider              16 h
storage plugin choice               yours                 yours                 provider              12 h
task scheduler setting              yours                 yours                 shared                18 h
task scaling                        yours                 yours                 yours                 14 h
resource request and limit          yours                 yours                 yours                 10 h
image repository and pull identity  yours                 yours                 yours                 8 h
log and metric collection           yours                 yours                 shared                22 h
internal network address plan       yours                 yours                 provider              14 h
shared process on the node          yours                 yours                 none                  10 h

level                 kept    shared     provider      none        annual maintenance
self-run cluster      15      0          0             0           288 h
managed cluster       11      2          2             0           204 h
task-based execution  3       2          6             4           72 h
```

The first handoff is smaller than expected. Moving to a managed cluster, only **two** of fifteen
items are fully handed off: control plane operation and its redundancy. Two more items drop to
shared — cluster version and node patching are now given jointly with the provider. The remaining
eleven items stay in place; node size, network plugin, address plan, and shared processes running
on the node are still chosen. Annual maintenance drops from 288 hours to 204 hours — 29 percent.

The "shared" column is the part of this table most likely to be misread. When the cluster version is
shared, the provider does the upgrade, and the provider is also the one who says **when** it
happens: once a version's support ends, upgrading stops being optional. Node patching works the
same way — the patch arrives, but restarting the node cuts the workload, and you are the one who
absorbs that cut. A shared item hands off half the maintenance load, and it hands off **the entire
timing decision**.

The second handoff is genuinely large, but its shape is different. Under task-based execution, six
items move to the provider and **four items disappear entirely**: node size, node count, cluster
version, and shared processes running on the node stop being questions at all. This is the
strongest form of handoff — the decision is not given to someone else, the decision **stops
existing**. The three kept items are task scaling, resource requests, and image identity; annual
maintenance drops to 72 hours.

But the last line must be read carefully. Most of the items counted in this course's first five
lessons — instance family, sizing, image freshness, startup time, health threshold — do not
disappear at the task-based level; **they become invisible**. The task still runs on a machine,
the image is still pulled, startup still takes time. What gets handed off is decisions, not
physics.

#### What Opens and Closes at Each Handoff

Handoff changes two numbers at the same time: kept items decrease, but part of the decrease turns
into items **that cannot be changed once set up**. The second number does not drop along with the
handoff ratio.

```js
// measurement-network/handoff-surface.mjs — the open surface and irreversible items at each handoff level (model)
import { LEVELS, ITEMS, count, maintenance } from "./handoff-inventory.mjs";

// Per level, items that cannot be changed once set up at that level (fictional).
const IRREVERSIBLE = [
  ["the cluster's internal address space", "cluster version upgrade (one-way)", "the node pool's architecture"],
  ["the network plugin given when the cluster is created", "cluster version upgrade (one-way)",
    "the cluster's internal address space"],
  ["the published task definition version", "the network mode given to the task",
    "the resource the execution identity is bound to"],
];

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log("level".padEnd(22) + "open surface".padEnd(14) + "handed off".padEnd(13) +
    "handoff ratio".padEnd(15) + "annual maintenance".padEnd(20) + "irreversible items");
  LEVELS.forEach((d, i) => {
    const open = count(i, "yours") + count(i, "shared");
    const handed = count(i, "provider") + count(i, "none");
    console.log(d.padEnd(22) + `${open}/${ITEMS.length}`.padEnd(14) +
      `${handed}/${ITEMS.length}`.padEnd(13) + `${Math.round((100 * handed) / ITEMS.length)}%`.padEnd(15) +
      `${maintenance(i)} h`.padEnd(20) + IRREVERSIBLE[i].length);
  });
  console.log("");
  LEVELS.forEach((d, i) => console.log(`${d}: ` + IRREVERSIBLE[i].join("; ")));
  const m0 = maintenance(0), m2 = maintenance(2);
  console.log(`\nhandoff gain: annual maintenance drops from ${m0} hours to ${m2} hours ` +
    `(${m0 - m2} hours, ${Math.round((100 * (m0 - m2)) / m0)}%)`);
  console.log(`handoff cost: kept decisions drop from ${count(0, "yours")} items to ${count(2, "yours")} items, ` +
    `but the number of irreversible items stays at ${IRREVERSIBLE[0].length} across all three`);
}
```

```
level                 open surface  handed off   handoff ratio  annual maintenance  irreversible items
self-run cluster      15/15         0/15         0%             288 h               3
managed cluster       13/15         2/15         13%            204 h               3
task-based execution  5/15          10/15        67%            72 h                3

self-run cluster: the cluster's internal address space; cluster version upgrade (one-way); the node pool's architecture
managed cluster: the network plugin given when the cluster is created; cluster version upgrade (one-way); the cluster's internal address space
task-based execution: the published task definition version; the network mode given to the task; the resource the execution identity is bound to

handoff gain: annual maintenance drops from 288 hours to 72 hours (216 hours, 75%)
handoff cost: kept decisions drop from 15 items to 3 items, but the number of irreversible items stays at 3 across all three
```

The last column summarizes what this course has been counting from the start: **as the open
surface drops from 15 to 5, the number of irreversible items stays at three**. The handoff ratio
climbs to 67 percent, the maintenance load drops by 75 percent, but the number of items that
cannot be changed once set up never changes. Only the names of the items change.

Comparing the irreversible items across the three levels reveals a pattern. In both the self-run
cluster and the managed cluster, what stays fixed is **the address space**; it is given when the
cluster is set up and stands for the cluster's entire life. At the task-based level, the address
space question disappears, but three new fixed items take its place: the published task
definition's version does not change, the network mode given to a task is set when the task is
created, and the resource the execution identity is bound to cannot be changed afterward. These
are the first lesson's subnet and architecture items returning in the same shape, under a
different name.

Where the maintenance hours go is also readable in the same table. The largest part of the
75-percent drop comes from a single item: control plane operation and its redundancy, the
inventory's two most expensive items at 84 hours a year combined, and they leave entirely at the
first handoff. Most of the 132 hours that leave at the second handoff are node maintenance —
patching, the network and storage plugins, the address plan. The handoff's gain is proportional
not to the number of items but to **which items leave**.

Cluster version upgrade is an irreversible item at two levels at once, and there is a single
reason: the upgrade is one-way. Once a version has been upgraded, returning to the old version is
not an undo, it is **setting up a new cluster** — and moving to that cluster demands the work this
lesson's last section measures.

#### What Travels at Exit

The handoff decision is a gain for as long as you stay at that level. The other side of the gain
shows up only when you leave: how much of the configuration is a portable definition, and how much
is a record written into the provider's format.

```js
// measurement-network/exit-cost.mjs — configuration that does not travel at exit, at each handoff level (model)
import { LEVELS, ITEMS, count, maintenance } from "./handoff-inventory.mjs";

// Move rule (fictional hours): yours moves; the provider's format is rewritten; fully handed off is
// reinstalled at the new place; absent at that level means the decision is made from scratch.
const MOVE = { yours: ["moves", 2], shared: ["rewritten", 10],
  provider: ["reinstalled", 24], none: ["reborn", 16] };

if (import.meta.url === `file://${process.argv[1]}`) {
  const kinds = Object.keys(MOVE);
  console.log("level".padEnd(22) + kinds.map((t) => `${MOVE[t][0]}`.padEnd(18)).join("") +
    "exit hours".padEnd(14) + "not portable");
  const hours = LEVELS.map((d, i) => {
    const n = kinds.map((t) => count(i, t));
    const s = n.reduce((a, v, j) => a + v * MOVE[kinds[j]][1], 0);
    console.log(d.padEnd(22) + n.map((v) => String(v).padEnd(18)).join("") +
      `${s} h`.padEnd(14) + `${n[2] + n[3]}/${ITEMS.length}`);
    return s;
  });
  const gain = maintenance(0) - maintenance(2), diff = hours[2] - hours[0];
  console.log(`\nannual maintenance gain ${gain} h; exit cost difference ${diff} h`);
  console.log(`the handoff's payback is ${(diff / gain).toFixed(2)} years: the time task-based execution ` +
    `gains in a year is almost entirely paid back at exit`);
  console.log(`under task-based execution, decisions dropped from ${count(0, "yours")} items to ${count(2, "yours")} items, ` +
    `but at exit ${count(2, "none")} items will be decided from scratch again`);
}
```

```
level                 moves             rewritten         reinstalled       reborn            exit hours    not portable
self-run cluster      15                0                 0                 0                 30 h          0/15
managed cluster       11                2                 2                 0                 90 h          2/15
task-based execution  3                 2                 6                 4                 234 h         10/15

annual maintenance gain 216 h; exit cost difference 204 h
the handoff's payback is 0.94 years: the time task-based execution gains in a year is almost entirely paid back at exit
under task-based execution, decisions dropped from 15 items to 3 items, but at exit 4 items will be decided from scratch again
```

The last column is the handoff decision's price tag: under task-based execution, **ten of fifteen
items do not travel**. Six are reinstalled at the new place, and four have to be decided from
scratch again — node size, node count, cluster version, and shared processes on the node come back
as questions at exit. Handing off did not eliminate the decision; **it postponed it**.

Placing the two numbers side by side produces the measure of the decision. Task-based execution
gains 216 hours of maintenance a year; leaving that level costs 204 hours more than leaving a
self-run cluster. The payback is **0.94 years**: if you stay less than a year, the handoff gains
no time; if you stay longer than a year, every additional year is a net gain. This is the only
answer the question "which level is better" can be given by number, and the answer depends not on
the level itself but on **how long you stay**.

The first column is also a result in its own right. All three levels have three items that stay
portable: task scaling, resource request and limit, and image repository identity. Because these
are never handed off at any level, they never need rewriting at any exit. These three items that
cannot be handed off are decisions that belong to the workload itself — how many copies run, how
much resource each copy requests, and which image it runs. No matter how far the handoff goes,
these three remain; they are the actual decision set that stays above the rented layer.

One warning sits where the table does not show it. Exit hours count only the rewriting of
configuration; the interruption during the move, the dual-running period, and data migration are
not in this table. The real exit takes longer than these rows say, not shorter.

#### Summary

- Moving to a managed cluster hands off less than expected: two of fifteen items are fully handed
  off, two more as shared; eleven items stay in place, and annual maintenance drops from 288 to
  204 hours.
- Under task-based execution, the handoff ratio climbs to 67 percent and four items disappear
  entirely — the decision is not given to someone else, it stops existing; maintenance drops to 72
  hours.
- As the open surface drops from 15 to 5, the number of irreversible items is three at all three
  levels; handoff changes the items' names, it does not remove irreversibility.
- Cluster version upgrade is an irreversible item at two levels: returning to the old version is
  not an undo, it is setting up a new cluster and migrating to it.
- At exit from the task-based level, ten of fifteen items do not travel; six are reinstalled, four
  are decided from scratch — handing off does not eliminate the decision, it postpones it.
- The handoff's payback is 0.94 years: it gains 216 hours of maintenance a year and makes exit 204
  hours more expensive; what decides is not the level itself but how long you stay.

#### Next Step

This topic rented the machine from end to end. Its family and size were chosen and idle resource
was counted; its image was baked and the balance between startup time and freshness was scanned;
persistent and interruptible capacity were separated by job type; the definition was collected
into a template; the count was handed off to a group; and finally, two-thirds of the decisions
were given to a service. In every lesson, two numbers moved together — decisions kept and items
that cannot be undone.

All these instances, groups, and tasks rested on one assumption: that they find each other. The
collector writes to the registry, the registry is read from the API endpoint, traffic arrives at
the scaling group. Not one address, one rule, or one boundary was written in this topic — the
machines were made to talk as if they were in the same room. The next topic builds that room, and
its first question is this: which address space do these instances stand in, does that space
change after it is set up, and which decision does the first door opened to the outside make
irreversible?

## Cloud Networking

Setting up the layer that connects machines to one another: the unchangeability of address space planning once it is set up, the separation of public and private subnets, route tables determining traffic, outbound and inbound access gateways, the rule count and false-negative difference between stateful and stateless filtering, load balancer layer choice and health checks, address stability and name integration, content delivery through edge caching, and private access between networks.

### Virtual Private Cloud

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/virtual-private-cloud

Address space planning is counted item by item: as the prefix length is swept, how many addresses stay usable, how many are reserved, how many are wasted; which period the narrow choice runs out of growth in, which neighboring blocks the wide choice collides with, and how many units the cost of this item — unchanged once it is set up — comes to.

The machine was rented, its image was chosen, and its scaling was handed to a group. Every rented
instance has an address, and that address comes from somewhere: an address space drawn before the
instances ever launch. A virtual private cloud is the space reserved for a tenant, with address
distribution inside it left to the tenant. The provider takes on the transport plane, isolation
between tenants, and the address allocation mechanism; the block, the prefix length, and the
block's placement within the organization's range are left to whoever writes them.

This item is set apart from the others in one way. A route rule that is written wrong gets fixed,
a filter rule that is loose gets narrowed — an address block, once set up, does not change. Three
things are asked: how many addresses a block actually makes usable, which period the choice runs
short in, and how much the day it runs short costs.

- **CN1.** The eight-period growth series and the neighboring block list belong to the course's
  fictional regional measurement network. No real network is set up; what is measured is the model
  built with `node`. The address arithmetic is real.
- **CN2.** All blocks are written from private address ranges; no real public block is used.
- **CN3.** The provider is assumed to reserve five addresses in every subnet. In reality this
  number varies by provider; it shifts the usable address count without changing its direction.
- **CN4.** The growth series is written by hand; there is no randomness, and every run produces the
  same numbers.
- **CN5.** The costs are in fictional units, not currency. They carry the ratio between the
  paths, not an absolute amount.
- **CN6.** The neighboring block list is cut down to seven. In reality the number of blocks
  registered at an organization is far higher; it raises the collision count without changing the
  sweep logic.

#### The Address Space Model

The model holds three things: a handful of functions that resolve a block with address
arithmetic, the neighboring blocks registered under the same organization, and the measurement
network's eight-period demand. A demand row records how many zones a period spreads across, how
many tiers it uses, and how many instances run; the subnet count is the product of the zone and
tier counts.

```js
// address.mjs — address space plan model; no real network is set up, address arithmetic is real
export const toInt = (ip) => ip.split('.').reduce((a, o) => a * 256 + Number(o), 0);
export const toIp = (n) => [16777216, 65536, 256, 1].map((b) => Math.floor(n / b) % 256).join('.');
export const block = (c) => {
  const [ip, e] = c.split('/'); const prefix = Number(e); const size = 2 ** (32 - prefix);
  const start = Math.floor(toInt(ip) / size) * size;      // shorter prefix shifts the base back
  return { name: `${toIp(start)}/${prefix}`, prefix, size, start, end: start + size - 1 };
};
export const overlaps = (a, b) => a.start <= b.end && b.start <= a.end;

// The same organization's existing blocks (fictional); all in private ranges.
export const NEIGHBORS = [
  ['central-hall', '10.0.0.0/16'], ['old-measurement', '10.8.0.0/14'], ['lab', '10.32.0.0/16'],
  ['field-temp', '10.41.0.0/16'], ['metric-archive', '10.42.128.0/17'], ['backup-collection', '10.44.0.0/14'],
  ['shared-connection', '172.20.0.0/14'],
].map(([name, c]) => ({ ...block(c), name }));

export const SEED = '10.42.0.0';            // starting address the planner writes
export const ORG = block('10.32.0.0/12');   // range allocated to the organization; new teams draw from here
export const RESERVED = 5;                  // provider reserves five addresses in every subnet (CN3)

// [period, zone, tier, instance] — the fictional measurement network's growth series (CN4)
export const DEMAND = [
  [1, 2, 2, 18], [2, 2, 2, 26], [3, 3, 2, 44], [4, 3, 2, 71],
  [5, 3, 3, 118], [6, 3, 3, 196], [7, 4, 3, 340], [8, 4, 3, 612],
];
export const subnetCount = (t) => t[1] * t[2];
export const required = (t) => t[3] + t[1] + 2;             // instance + per-zone gateway + two balancers
export const reserved = (t) => RESERVED * subnetCount(t);
export const used = (t) => required(t) + reserved(t);
export const overflow = (b) => (DEMAND.find((t) => used(t) > b.size) || [0])[0];
export const colliding = (b) => NEIGHBORS.filter((n) => overlaps(b, n));

export const TOTAL_SLICES = ORG.size / 4096;          // how many /20 slices in the org range
export const slices = (b) => {                        // the /20 slices the block holds
  const s = new Set();
  if (b.end < ORG.start || b.start > ORG.end) return s;
  const first = Math.floor((Math.max(b.start, ORG.start) - ORG.start) / 4096);
  const last = Math.floor((Math.min(b.end, ORG.end) - ORG.start) / 4096);
  for (let i = first; i <= last; i++) s.add(i);
  return s;
};
export const NEIGHBOR_SLICES = new Set(NEIGHBORS.flatMap((n) => [...slices(n)]));
```

The base calculation inside the `block` function is the model's quiet claim: the address written
is not the block chosen. As the prefix shortens, the base shifts back to align, so writing
`10.42.0.0` with a short prefix builds a block that starts somewhere else. `slices` opens a second
axis — it counts how many slices the block holds within the range allocated to the organization,
because the space taken is space no other team gets.

#### Sweeping the Prefix Length

```js
// run1.mjs — prefix length is swept: how many are usable, when it overflows, what it hits
import { block, SEED, DEMAND, subnetCount, required, reserved, used, overflow, colliding,
  slices, NEIGHBOR_SLICES, TOTAL_SLICES } from './address.mjs';

const last = DEMAND[DEMAND.length - 1];
console.log(`period-8 demand: ${last[3]} instances + ${last[1]} gateways + 2 balancers = ${required(last)} addresses; ` +
  `${subnetCount(last)} subnets x 5 = ${reserved(last)} reserved; total ${used(last)}`);
console.log(`${'block'.padEnd(17)} ${'total'.padStart(8)} ${'usable'.padStart(14)} ` +
  `${'overflow'.padStart(11)} ${'waste'.padStart(8)} ${'overlaps'.padStart(9)} ${'/20 left'.padStart(10)}  hits neighbor`);
for (const prefix of [25, 24, 23, 22, 21, 20, 18, 16, 14, 12]) {
  const b = block(`${SEED}/${prefix}`), t = overflow(b), c = colliding(b);
  const withBlock = new Set([...NEIGHBOR_SLICES, ...slices(b)]);
  console.log(`${b.name.padEnd(17)} ${String(b.size).padStart(8)} ${String(b.size - reserved(last)).padStart(14)} ` +
    `${String(t ? `period ${t}` : '-').padStart(11)} ${String(t ? '-' : b.size - used(last)).padStart(8)} ` +
    `${String(c.length).padStart(9)} ${String(TOTAL_SLICES - withBlock.size).padStart(10)}  ${c.map((k) => k.name).join(' ')}`.trimEnd());
}
```

```
period-8 demand: 612 instances + 4 gateways + 2 balancers = 618 addresses; 12 subnets x 5 = 60 reserved; total 678
block                total         usable    overflow    waste  overlaps   /20 left  hits neighbor
10.42.0.0/25           128             68    period 5        -         0        151
10.42.0.0/24           256            196    period 7        -         0        151
10.42.0.0/23           512            452    period 8        -         0        151
10.42.0.0/22          1024            964           -      346         0        151
10.42.0.0/21          2048           1988           -     1370         0        151
10.42.0.0/20          4096           4036           -     3418         0        151
10.42.0.0/18         16384          16324           -    15706         0        148
10.42.0.0/16         65536          65476           -    64858         1        144  metric-archive
10.40.0.0/14        262144         262084           -   261466         2        112  field-temp metric-archive
10.32.0.0/12       1048576        1048516           -  1047898         4          0  lab field-temp metric-archive backup-collection
```

The reserved sixty addresses are the same in every row, not the share: in the `/25` block, 47% of
the total goes to reservation, and in the `/20` block, 1.5%. In the small block, the partitioning
scheme itself eats half the block, and that bite goes to no instance. The overflow column gives
the cost of the narrow side: `/25` runs out in period five, `/24` in period seven, `/23` in period
eight. Period eight's demand is 678 addresses, and the `/23` block has 512.

The right side counts the cost in the opposite direction. The `/16` block hits exactly one
neighbor, and that neighbor is `10.42.128.0/17` — an item sitting inside the planner's own second
octet. Writing `/14` shifts the base to `10.40.0.0`, writing `/12` shifts it to `10.32.0.0`; in
both cases the address written is not the start of the block that gets built. The shift pulls two
more neighbors in. A colliding block cannot be peered: on both sides the same address would map to
two different machines, so the peering is refused and only address translation is left.

The last column is the wide choice's invisible cost. The range allocated to the organization holds
256 slices; neighbors hold 104 of them. Every choice at `/20` and below takes one slice and leaves
151, `/18` leaves 148, `/14` leaves 112, `/12` leaves zero. Some other team will ask for a block
from the same range that same day. The gap between zero and 151 produces no warning today.

The sweep draws its own window: between `/22` and `/18`, there is neither overflow nor collision.
`/20` leaves 3,418 addresses empty in period eight — this waste does not correspond to a cost item,
it only holds one slice. The difference from `/23` is a single character on setup day.

#### The Cost of the Narrow Choice

```js
// run2.mjs — /23 was chosen; at overflow, three exit paths are counted in fictional units
import { block, SEED, DEMAND, subnetCount, used, overflow } from './address.mjs';

const chosen = block(`${SEED}/23`), wide = block(`${SEED}/20`);
const t = overflow(chosen), d = DEMAND.find((x) => x[0] === t);
console.log(`selected block ${chosen.name}: ${chosen.size} addresses, period-1 demand ${used(DEMAND[0])} ` +
  `-> ${(chosen.size / used(DEMAND[0])).toFixed(1)}x headroom, setup day says nothing`);
console.log(`writing ${wide.name} that same day costs extra: 0 units, 0 extra lines, 0 extra minutes`);
console.log(`overflow period ${t}: ${d[3]} instances, ${subnetCount(d)} subnets, ${used(d)} addresses required, ${chosen.size} available\n`);

// Costs are fictional "unit" (CN5); they carry ratios, not a currency.
const PATH = [
  ['rebuild', d[3] * 2 + subnetCount(d) * 15 + 5 * 40, 0, 0],
  ['second network + peering', 120, 95, subnetCount(d)],
  ['address translation layer', 40, 60, 2],
];
const baseline = PATH[0][1];
console.log(`${'exit path'.padEnd(25)} ${'setup'.padStart(8)} ${'per period'.padStart(11)} ` +
  `${'permanent item'.padStart(15)} ${'6 periods'.padStart(10)} ${'passes rebuild in'.padStart(23)}`);
for (const [name, setup, perPeriod, permanent] of PATH) {
  let n = 0; if (perPeriod > 0) { n = 1; while (setup + perPeriod * n <= baseline) n++; }
  console.log(`${name.padEnd(25)} ${String(setup).padStart(8)} ${String(perPeriod).padStart(11)} ${String(permanent).padStart(15)} ` +
    `${String(setup + perPeriod * 6).padStart(10)} ${String(n ? `period ${n}` : '-').padStart(23)}`);
}
```

```
selected block 10.42.0.0/23: 512 addresses, period-1 demand 42 -> 12.2x headroom, setup day says nothing
writing 10.42.0.0/20 that same day costs extra: 0 units, 0 extra lines, 0 extra minutes
overflow period 8: 612 instances, 12 subnets, 678 addresses required, 512 available

exit path                    setup  per period  permanent item  6 periods       passes rebuild in
rebuild                       1604           0               0       1604                       -
second network + peering       120          95              12        690               period 16
address translation layer       40          60               2        400               period 27
```

The second line is this lesson's measure. Writing `/20` instead of `/23` costs zero units on setup
day: the same command, the same minute, the same line count. In period one, the `/23` block showed
12.2x headroom, and that appearance held true for seven periods. In period eight the block runs
out, and once it does, one of three paths has to be chosen.

Rebuilding costs 1,604 units: moving 612 instances, rebuilding 12 subnets, and opening an interruption
window. It is one-time and leaves no permanent item behind. Opening a second network and peering it
costs 120 units, and it wins every meeting — after six periods it still stands at 690 units, cheaper than
half the rebuild. It passes rebuild in period 16. The address translation layer starts even
cheaper and passes rebuild in period 27.

The permanent item column carries the real difference. The second network leaves 12 permanent
items: one row that has to be written into each of the twelve route tables and updated on every
later change. The address translation layer's two permanent items are not rows but properties —
the two sides cannot see each other's real address, and the source address in the logs turns into
the layer's address. The cost of delaying is not in the unit count; it is in carrying these two
columns into every lesson that follows.

#### The Item That Stays

| Item | Held by | Changes later? |
|---|---|---|
| Address block and prefix length | team | no |
| The block's placement within the org range | team | no |
| Zone count | team | only grows |
| Subnet boundaries | team | only grows |
| Route rules | team | yes |
| Filter rules | team | yes |
| Name resolution and flow logs | team | yes |
| Peering and private connections | team | yes, if no collision |
| Five addresses reserved per subnet | provider | — |
| Address allocation mechanism | provider | — |
| Transport plane and tenant isolation | provider | — |
| The gateway's own redundancy | provider | — |

Eight of the twelve items stay with the team, four are handed off. The four handed off could be
said to never even be asked — the five reserved addresses are not a choice but a cut, and in the
`/25` block it approaches half the total. Of the eight that stay, four change freely, two can only
grow, and two never change once set up.

"Unchanging" here is not a prohibition, it is a stack of dependencies. The moment the block is
written, three items come to depend on it: distributed addresses, route rules written against
those addresses, and filter rules written against the same addresses. Widening the block
invalidates all three at once, because widening shifts the base — turning the `10.42.0.0/23` block
into `/20` is a new block, not a grown version of the same block. It also changes its place in the
organization's record, and at that point which neighbors it hits has to be asked again.

The irreversible item is these two rows: the block itself and the block's place in the
organization's range. When this course's other items are written wrong, a rule gets fixed; when
this one is written wrong, the result is a 1,604 unit migration or a permanent add-on that touches
every route table. The moment the decision is made is the moment with the least information on
hand — period one ran 18 instances, and period eight's 612 instances were not written down
anywhere yet.

#### Summary

- Period eight's demand is 618 addresses plus 60 reserved addresses for 12 subnets. The same 60
  addresses eat 47% of the `/25` block and 1.5% of the `/20` block; the partitioning scheme's cut
  is as decisive as the block itself in a small block.
- The narrow side's cost is the overflow period: `/25` runs out in period 5, `/24` in period 7,
  `/23` in period 8. The wide side's cost is collision: `/16` hits one neighboring block, `/14`
  two, `/12` four, and a colliding block cannot be peered.
- A short prefix shifts the base back to align; writing `10.42.0.0` with `/12` builds a block
  starting at `10.32.0.0` and holds all 256 slices allocated to the organization. `/20` holds one
  slice and leaves 151.
- The irreversible item is the address block and prefix length. Writing `/20` instead of `/23` on
  setup day costs 0 units; by period eight, that same difference costs 1,604 units to rebuild, or 120 units
  plus 95 units per period and 12 permanent route rows for a second network.
- The path that looks cheap is the permanent one: the second network passes the rebuild total in
  period 16, the address translation layer in period 27, and by then the end of direct addressing
  cannot be undone.

#### Next Step

The block is chosen, and it holds a demand of 678 addresses — but the block is still one piece.
Addresses have to be split before they can be used, and the splitting scheme opens a new question:
does every instance need to be directly reachable from outside. The next lesson's question counts
both axes of splitting at once: when the same block is split into a small number of large pieces
versus a large number of small pieces, how many addresses stay usable; how many hundred addresses
the five addresses cut per subnet hold across how many pieces; and how many instances a zone loss
takes away under which arrangement — and how far closing off part of the pieces from outside cuts
the directly reachable instance count while requiring which extra component in exchange.

### Subnets

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/subnets

The same block is split on two axes, and for every layout the usable address count, the reserved share, the unassigned remainder, and the instance count lost to a zone loss are counted; how far the public/private subnet separation cuts the externally reachable item count, and how many extra components and route tables it requires in exchange, is worked out item by item.

The block is chosen, and it holds a demand of 678 addresses for period eight. For now the block is
one piece: 4,096 addresses sit side by side, and none of them can be handed to an instance. An
address is distributed from a subnet, a subnet is tied to a zone, and once a subnet boundary is
drawn, that boundary stays put.

The splitting decision carries two axes at once. The first is subnet size: a small number of large
pieces, or a large number of small pieces. The second is distribution across zones: how many zones
the same block spreads across. Both spend the same address count and produce two entirely
different results. A third question sits on top of these: does every piece need to be directly
reachable from outside.

- **CN7.** The splitting schemes and demand distribution belong to the course's fictional regional
  measurement network. No real network is split; what is measured is the model built with `node`.
  The address arithmetic is real.
- **CN8.** The previous lesson's `/20` block and the assumption of five reserved addresses per
  subnet carry over.
- **CN9.** Tier shares are written by hand as percentages: public 8, application 62, data 30. In
  reality the share varies by workload; it shifts which subnet is busiest without changing the
  sweep logic.
- **CN10.** The split is assumed uniform: every subnet is the same size. In reality mixed sizes are
  used; it reduces the unassigned remainder, and does not remove the partition-rounding penalty.
- **CN11.** Every instance is assumed to listen on four ports. This number was measured separately
  at the machine level; here it is used only as a multiplier.
- **CN12.** A zone loss is counted as a total loss. In reality partial degradation is more common;
  it lowers the lost instance count without changing its relationship to zone count.

#### The Splitting Scheme Model

The model takes one thing seriously: splitting a block uniformly requires the piece count to be a
power of two. A layout that asks for twelve subnets gets split into sixteen partitions, and four
are left unassigned. The `partitions` function does this rounding, and every other number derives
from it.

```js
// split.mjs — the /20 block is split into subnets; no real network is set up, the arithmetic is real
export const BLOCK = 4096;          // /20 chosen in the previous lesson
export const RESERVED = 5;          // provider reserves five addresses in every subnet (CN8)
export const INSTANCES = 612;       // period-8 demand
// Tier shares as percentages; they total 100 (CN9)
export const TIER = [['public', 8], ['application', 62], ['data', 30]];

export const prefixLen = (size) => 32 - Math.log2(size);
export const partitions = (n) => 2 ** Math.ceil(Math.log2(n));   // the scheme rounds to a power of two
export const subnetSize = (n) => BLOCK / partitions(n);
export const usable = (n) => subnetSize(n) - RESERVED;
// The instance count of the busiest tier in one zone
export const densest = (zones) => Math.max(...TIER.map(([, p]) => Math.ceil(INSTANCES * p / 100 / zones)));

export const layout = (zones, tiers = TIER.length) => {
  const n = zones * tiers, k = usable(n);
  const normal = densest(zones), afterLoss = zones > 1 ? densest(zones - 1) : normal;
  return { zones, tiers, n, partitions: partitions(n), subnetSize: subnetSize(n), usable: k,
    total: n * k, reserved: RESERVED * n, unassigned: BLOCK - n * subnetSize(n),
    normal, afterLoss, lost: Math.ceil(INSTANCES / zones), fits: normal <= k,
    fitsAfterLoss: afterLoss <= k, headroom: (k / normal) };
};
```

The `afterLoss` field is the model's second claim: when one zone goes down, its load spreads
across the remaining zones and the busiest subnet's demand grows. A splitting scheme fitting on a
normal day does not have to fit on a failure day, because the subnet's boundary cannot be widened
that day.

#### Sweeping Two Axes

```js
// run1.mjs — the same /20 block is split on two axes: subnet size and zone count
import { BLOCK, INSTANCES, RESERVED, prefixLen, densest, layout } from './split.mjs';

const ZONES = 4, TIERS = 3, N = ZONES * TIERS;
console.log(`${BLOCK}-address block, ${ZONES} zones x ${TIERS} tiers = ${N} subnets, ${INSTANCES} instances`);
console.log(`${'subnet'.padEnd(7)} ${'parts'.padStart(6)} ${'usable'.padStart(14)} ` +
  `${'total'.padStart(7)} ${'reserved'.padStart(9)} ${'unassigned'.padStart(11)} ${'busiest'.padStart(9)}  status`);
for (const b of [1024, 512, 256, 128, 64, 32]) {
  const d = BLOCK / b, k = b - RESERVED, y = densest(ZONES), fits = d >= N;
  console.log(`/${String(prefixLen(b)).padEnd(6)} ${String(d).padStart(6)} ${String(k).padStart(14)} ` +
    `${String(fits ? N * k : '-').padStart(7)} ${String(fits ? RESERVED * N : '-').padStart(9)} ` +
    `${String(fits ? BLOCK - N * b : '-').padStart(11)} ${String(y).padStart(9)}  ` +
    (!fits ? `${N} subnets cannot be built, at most ${d}` : y <= k ? `fits, ${(k / y).toFixed(1)}x headroom` : `busiest tier does not fit`));
}

console.log(`\nsame block, ${TIERS} tiers fixed, zone count is swept:`);
console.log(`${'zones'.padEnd(6)} ${'subnets'.padStart(8)} ${'parts'.padStart(6)} ${'usable'.padStart(14)} ` +
  `${'busiest'.padStart(9)} ${'headroom'.padStart(12)} ${'lost on zone loss'.padStart(18)} ${'post-loss'.padStart(14)}`);
for (const a of [2, 3, 4, 6, 8]) {
  const d = layout(a);
  console.log(`${String(a).padEnd(6)} ${String(d.n).padStart(8)} ${String(d.partitions).padStart(6)} ` +
    `${String(d.usable).padStart(14)} ${String(d.normal).padStart(9)} ${`${d.headroom.toFixed(1)}x`.padStart(12)} ` +
    `${`${d.lost} instances`.padStart(18)} ${`${d.afterLoss}/${d.usable}`.padStart(14)}`);
}
```

```
4096-address block, 4 zones x 3 tiers = 12 subnets, 612 instances
subnet   parts         usable   total  reserved  unassigned   busiest  status
/22          4           1019       -         -           -        95  12 subnets cannot be built, at most 4
/23          8            507       -         -           -        95  12 subnets cannot be built, at most 8
/24         16            251    3012        60        1024        95  fits, 2.6x headroom
/25         32            123    1476        60        2560        95  fits, 1.3x headroom
/26         64             59     708        60        3328        95  busiest tier does not fit
/27        128             27     324        60        3712        95  busiest tier does not fit

same block, 3 tiers fixed, zone count is swept:
zones   subnets  parts         usable   busiest     headroom  lost on zone loss      post-loss
2             6      8            507       190         2.7x      306 instances        380/507
3             9     16            251       127         2.0x      204 instances        190/251
4            12     16            251        95         2.6x      153 instances        127/251
6            18     32            123        64         1.9x      102 instances         76/123
8            24     32            123        48         2.6x       77 instances         55/123
```

The top table's first two rows have nothing to do with address count. The `/23` subnet has 507
usable addresses, more than the total demand of twelve subnets — but the block only splits into
eight partitions, and the product of four zones and three tiers does not fit into eight. The
block's limit binds on partition count, not address count.

Further down, addresses are enough, partitions fit, and the layout still fails to hold: the `/26`
subnet's fifty-nine usable addresses cannot take the ninety-five-instance application tier. The
window is two rows wide. The `/24` layout gives the busiest subnet 2.6x headroom; in the `/25`
layout that headroom drops to 1.3x — one growth step later the subnet fills, and the subnet
boundary cannot be widened that day. The reserved sixty addresses stay the same in every row, not
the weight: 2% of the 3,012 addresses assigned in the `/24` layout, 8% of the 768 in the `/26`
layout. The unassigned column climbs from 1,024 to 3,712; those addresses are not lost, but they
are locked to the chosen partition size — a plan that picks `/26` can only add `/26` in the future.

The lower table measures the second axis and turns up an unexpected penalty. When the zone count
is a power of two, growth headroom stays above 2.6x: 2.7x at two zones, 2.6x at four, 2.6x at
eight. Three and six zones use the same block worse — 2.0x and 1.9x. The cause is not address
demand but rounding: nine subnets round up to sixteen partitions, eighteen subnets to thirty-two,
and the partitions that get no return shrink the subnet size by a notch. Pulling the zone count
down to three is a decision made for resilience, and it charges its cost against the address plan.

The last two columns separate out the failure impact. A zone loss takes 306 instances at two zones
and 77 instances at eight zones: same block, same address count, a fourfold difference. In the
post-loss column, though, every layout fits — the load spread across the remaining zones cannot
fill the busiest subnet. On a failure day, splitting schemes are told apart not by address count
but by how many instances stay standing.

#### Public and Private Separation

```js
// run2.mjs — the same 12 subnets in three access layouts; the externally visible surface is counted
import { INSTANCES, TIER } from './split.mjs';

const ZONES = 4, PORTS = 4;                    // ports each instance listens on (CN11)
const count = (p) => Math.ceil(INSTANCES * p / 100 / ZONES) * ZONES;
const perTier = Object.fromEntries(TIER.map(([name, p]) => [name, count(p)]));
const TOTAL = Object.values(perTier).reduce((a, b) => a + b, 0);
console.log(`${ZONES} zones x ${TIER.length} tiers: ` +
  TIER.map(([name]) => `${name} ${perTier[name]}`).join(', ') + `, total ${TOTAL} instances, ${PORTS} ports per instance`);

// [name, public-addressed tiers, extra balancer, gateway per zone]
const LAYOUT = [
  ['no separation', ['public', 'application', 'data'], 0, 0],
  ['public tier only', ['public'], 0, 0],
  ['none public', [], 1, 1],
];
console.log(`\n${'layout'.padEnd(20)} ${'public'.padStart(9)} ${'private'.padStart(8)} ${'public addr'.padStart(12)} ` +
  `${'open item'.padStart(11)} ${'not public'.padStart(11)} ${'extra parts'.padStart(12)} ${'tables'.padStart(7)}`);
for (const [name, pub, extraLB, gw] of LAYOUT) {
  const publicSubnets = pub.length * ZONES, privateSubnets = (TIER.length - pub.length) * ZONES;
  const publicInstances = pub.reduce((a, k) => a + perTier[k], 0);
  const item = publicInstances * PORTS + extraLB;      // extra balancer listens on a single port
  const parts = 1 + extraLB + gw * ZONES;              // internet gateway + balancer + gateway per zone
  const tables = gw ? 1 + ZONES : publicSubnets && privateSubnets ? 2 : 1;
  console.log(`${name.padEnd(20)} ${String(publicSubnets).padStart(9)} ${String(privateSubnets).padStart(8)} ` +
    `${String(publicInstances).padStart(12)} ${String(item).padStart(11)} ${String(TOTAL - publicInstances).padStart(11)} ` +
    `${String(parts).padStart(12)} ${String(tables).padStart(7)}`);
}
console.log(`\ninside the block every instance can reach every instance: ${TOTAL} x ${TOTAL - 1} directions, same in all three layouts`);
console.log(`externally open items: ${TOTAL * PORTS} -> ${perTier.public * PORTS} -> 1`);
```

```
4 zones x 3 tiers: public 52, application 380, data 184, total 616 instances, 4 ports per instance

layout                  public  private  public addr   open item  not public  extra parts  tables
no separation               12        0          616        2464           0            1       1
public tier only             4        8           52         208         564            1       2
none public                  0       12            0           1         616            6       5

inside the block every instance can reach every instance: 616 x 615 directions, same in all three layouts
externally open items: 2464 -> 208 -> 1
```

The layout with no separation does not look like a choice, because no one picks it; it happens
because the public-address-assignment behavior was left open when the subnet was created. Its
result is 2,464 items: all four ports of all 616 instances are reachable from outside. Separating
out the public tier cuts that number to 208 — a twelvefold drop — and its only cost is the route
table count going from one to two.

The third row is the separation taken all the way. The only externally reachable item is the port
the load balancer listens on. All 616 instances still listen on four ports; none of them can be
called from outside. Its cost sits in the two right-hand columns: the component count rises from
one to six, and the route table count from one to five. The five tables come from each of the four
zones having to look at its own gateway, and that number is a warning of a new burden — every new
subnet now has to be written into the right table, not just a table.

The last two lines say what the separation does not measure. Inside the block, all 616 instances
can reach one another, and that number is the same in all three layouts: subnet separation does
not touch the internal surface. It is also true that all 616 of the 616 instances can no longer
get outside on their own; getting out needs four of the six components counted above. These two
gaps are the subject of the next two lessons: first, in which table and in which order the rules
are written; second, how a closed subnet reaches outside. Both bills are opened by the separation
made in this lesson — the decision that cuts the externally open item count from 2,464 to 1 writes
a debt of five tables and four gateways at the same time.

This lesson's item stays with the team in five pieces: partition size, zone count, tier count,
which tier is public, and which zone each subnet attaches to. Three items are handed off and never
even asked: the five addresses cut per subnet, the rounding of the piece count to a power of two
in a uniform split, and a subnet's inability to change zone once it is created. Two of the three
handed off produce the numbers in the tables above directly — the sixty-address cut and the growth
headroom dropping to 2.0x at three zones. An item sitting with the provider does not make it
powerless, only invisible.

The irreversible item here is the subnet boundary. Once a subnet is created and an instance is
placed inside it, its prefix cannot be narrowed and it cannot be moved to another zone; whether
it can be widened depends on the provider — some allow growing the prefix, some do not allow any
change at all. This lesson treats the prefix as fixed. The partition size choice sits on top of
this: a plan that picks `/24` can only add
`/24` in the future. Zone assignment is permanent too — the cost of choosing three zones is a 2.0x
growth headroom, and once that headroom fills, the fix is adding a new subnet, not growing the
existing one.

#### Summary

- The same 4,096-address block, the same 12 subnets: the `/22` and `/23` layouts fail not because
  of address count but partition count, and in the `/26` and `/27` layouts the busiest tier's 95
  instances do not fit into 59 and 27 usable addresses. The window is two rows wide.
- The 60 reserved addresses are the same in every layout, not the weight: 2% of what is assigned
  in the `/24` layout, 8% in the `/26` layout. The unassigned remainder climbs from 1,024 to 3,712
  and locks to the chosen partition size.
- When the zone count is a power of two, growth headroom is 2.6–2.7x; at 3 and 6 zones it drops to
  2.0x and 1.9x. The penalty comes from partition rounding: 9 subnets round up to 16 partitions,
  18 subnets to 32, shrinking the subnet size by a notch.
- A zone loss takes 306 instances at 2 zones and 77 at 8 zones — a fourfold difference. After a
  failure, addresses are enough in every layout; layouts are told apart by how many instances stay
  standing.
- Public and private separation cuts the externally open item count from 2,464 to 208, then to 1,
  and pulls the public address count from 616 to 0. Its price is 6 components and 5 tables in
  place of 1 and 1; the internal surface — 616 instances reaching one another — does not change
  across the three layouts.

#### Next Step

Five route tables passed as a single number in the last run, yet that is where the real load sits.
The four zones' private subnets look at their own gateway, and public subnets look somewhere else,
and which traffic goes where is written inside those tables. The next lesson's question works
these rules out item by item: given a destination address, which row wins, and under which
conditions does the longest prefix match pick a path that runs against intuition; how many subnets
does a single row, written short or written wide, leave unreachable, and what does routing traffic
out an unexpected path add to the egress fee; how many tables does the same rule repeat across, and
how many tables have to be touched when a thirteenth subnet is added.

### Route Tables

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/route-tables

Five route tables are modeled and the winning row for every flow is worked out by longest prefix match: how many flows a widely written rule swallows silently, how many units the same flow costs a narrowly written rule to swallow, how many instances and how many units a single row's drop affects, and how many tables the same rule repeats across.

The previous lesson closed with five route tables, and five was just a number. Inside those tables
is written where a packet leaving a subnet goes: into the block's own interior, into a peered
network, into a private link, or outside. A packet can match more than one row, and which one wins
depends on a single rule — the longest prefix.

This rule does two things at once. The expected side: traffic bound for the block's own interior
is never caught by another row, no matter how widely that row is written. The unexpected side is
that writing a row grants no transport ability — the winning row can point to a target, that
target may not carry the address, and the packet does not come back. Both sides sit in the same
table, often the same row; what tells them apart is not how the row is written but what the target
it points to actually carries.

- **CN13.** Five tables, six rules, and eight flows belong to the course's fictional regional
  measurement network. No real network is routed; what is measured is the model built with `node`.
  The prefix match is computed with real address arithmetic.
- **CN14.** All targets are chosen from private address blocks and blocks reserved for
  documentation.
- **CN15.** Writing a table row grants no transport capacity; the range each target actually
  carries is modeled separately. In reality this separation lives in the connection's own
  configuration.
- **CN16.** The eight flows and their periodic volumes are written by hand; there is no
  randomness, and every run produces the same numbers.
- **CN17.** The egress fee is counted as two fictional units per hundred volume units. It
  is not a currency; it carries the ratio between the paths.
- **CN18.** The four zones' private tables are assumed identical. In reality zones differ from one
  another; it lowers the duplicate count without changing the touch count.

#### The Route Table Model

The model keeps two things apart: the row written into the table, and the range the target
actually carries. The `CARRIES` dictionary is the second, and the model's main claim lives there.
The `result` function first finds the winning row, then checks whether the target that row points
to carries the address, and returns one of four outcomes.

```js
// route.mjs — route table model and longest prefix match; no real network
export const toInt = (ip) => ip.split('.').reduce((a, o) => a * 256 + Number(o), 0);
export const block = (c) => {
  const [ip, e] = c.split('/'); const n = Number(e), b = 2 ** (32 - n);
  const start = Math.floor(toInt(ip) / b) * b; return { prefix: n, start, end: start + b - 1 };
};
export const contains = (c, ip) => { const b = block(c), a = toInt(ip); return a >= b.start && a <= b.end; };
export const PRIVATE_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
export const isPrivate = (ip) => PRIVATE_RANGES.some((c) => contains(c, ip));

// What the target actually carries; writing a row does not grant transport capacity (CN15). null: carries everywhere, billed.
export const CARRIES = {
  'local': ['10.42.0.0/20'], 'peer-central': ['10.0.0.0/16'], 'peer-backup': ['10.44.0.0/14'],
  'peer-legacy': ['10.8.0.0/14'], 'private-link': ['172.20.0.0/14'], 'gateway': null,
};
export const longestMatch = (t, ip) => t.filter(([c]) => contains(c, ip))
  .sort((a, b) => block(b[0]).prefix - block(a[0]).prefix)[0];      // longest prefix wins
export const result = (t, ip) => {
  const s = longestMatch(t, ip);
  if (!s) return { row: '-', target: '-', outcome: 'no route', billed: false };
  const carries = CARRIES[s[1]];
  const outcome = carries === null ? (isPrivate(ip) ? 'gateway-wasted' : 'gateway')
    : carries.some((c) => contains(c, ip)) ? 'reaches' : 'black hole';
  return { row: s[0], target: s[1], outcome, billed: outcome.startsWith('gateway') };
};

const COMMON = [['10.42.0.0/20', 'local'], ['172.20.0.0/14', 'private-link'], ['0.0.0.0/0', 'gateway']];
export const SET = {
  wide: [...COMMON, ['10.0.0.0/8', 'peer-central']],
  narrow: [...COMMON, ['10.0.0.0/16', 'peer-central']],
  full: [...COMMON, ['10.0.0.0/16', 'peer-central'], ['10.44.0.0/14', 'peer-backup'],
    ['10.8.0.0/14', 'peer-legacy']],
};
// [name, destination address, periodic flow volume] — the fictional measurement network's flows (CN16)
export const FLOW = [
  ['internal reading collector', '10.42.3.14', 4200], ['internal data store', '10.42.9.7', 9100],
  ['central hall record', '10.0.4.7', 2600], ['old measurement archive', '10.8.3.1', 340],
  ['shared connection endpoint', '172.20.5.9', 780], ['backup collection', '10.44.2.3', 150],
  ['external packet store', '198.51.100.9', 60], ['external measurement service', '203.0.113.4', 1900],
];
export const FEE = (h) => Math.round(h * 2) / 100;   // 2 fictional units per 100 volume units (CN17)
export const ZONES = 4;
```

The three table sets grow from the same skeleton: three common rows plus one form of the peering
rule on top. The `wide` set tries to cover the entire internal address space with a single row,
the `narrow` set writes only the range that is actually peered, and the `full` set adds the two
targets the others leave out. Of the flow list's eight rows, two go into the block's interior,
three go to the organization's other blocks, one goes to the shared connection, and two go
outside; the volumes are periodic, and they are what determines the fee.

#### Winning Row

```js
// run1.mjs — the winning row is worked out for every flow, then three table sets are compared
import { SET, FLOW, FEE, result, contains } from './route.mjs';

console.log(`wide set (${SET.wide.length} rows), winning row flow by flow:`);
console.log(`${'flow'.padEnd(30)} ${'target address'.padEnd(17)} ${'matches'.padStart(7)} ` +
  `${'winning row'.padEnd(14)} ${'next hop'.padEnd(14)} outcome`);
for (const [name, ip] of FLOW) {
  const matches = SET.wide.filter(([c]) => contains(c, ip)).length, r = result(SET.wide, ip);
  console.log(`${name.padEnd(30)} ${ip.padEnd(17)} ${String(matches).padStart(7)} ` +
    `${r.row.padEnd(14)} ${r.target.padEnd(14)} ${r.outcome}`);
}

console.log(`\n${'set'.padEnd(7)} ${'rows'.padStart(6)} ${'reaches'.padStart(8)} ${'black hole'.padStart(11)} ` +
  `${'gateway-wasted'.padStart(15)} ${'billed volume'.padStart(14)} ${'egress fee'.padStart(13)}`);
for (const [name, t] of Object.entries(SET)) {
  const r = FLOW.map(([, ip, h]) => ({ ...result(t, ip), h }));
  const n = (s) => r.filter((x) => x.outcome === s).length;
  const volume = r.filter((x) => x.billed).reduce((a, x) => a + x.h, 0);
  console.log(`${name.padEnd(7)} ${String(t.length).padStart(6)} ${String(n('reaches')).padStart(8)} ` +
    `${String(n('black hole')).padStart(11)} ${String(n('gateway-wasted')).padStart(15)} ` +
    `${String(volume).padStart(14)} ${String(FEE(volume)).padStart(13)}`);
}
```

```
wide set (4 rows), winning row flow by flow:
flow                           target address    matches winning row    next hop       outcome
internal reading collector     10.42.3.14              3 10.42.0.0/20   local          reaches
internal data store            10.42.9.7               3 10.42.0.0/20   local          reaches
central hall record            10.0.4.7                2 10.0.0.0/8     peer-central   reaches
old measurement archive        10.8.3.1                2 10.0.0.0/8     peer-central   black hole
shared connection endpoint     172.20.5.9              2 172.20.0.0/14  private-link   reaches
backup collection              10.44.2.3               2 10.0.0.0/8     peer-central   black hole
external packet store          198.51.100.9            1 0.0.0.0/0      gateway        gateway
external measurement service   203.0.113.4             1 0.0.0.0/0      gateway        gateway

set       rows  reaches  black hole  gateway-wasted  billed volume    egress fee
wide         4        4           2               0           1960          39.2
narrow       4        4           0               2           2450            49
full         6        6           0               0           1960          39.2
```

The first two rows are the rule's expected side: traffic bound for the block's interior matches
three rows at once, and `/20` wins. Whoever wrote the `10.0.0.0/8` row knew it would not break
this, and they were right. The fourth and sixth rows are the same rule's other side. `10.8.3.1`
and `10.44.2.3` also match the `/8` row, that row wins, and the packet goes to the peering
connection — but that connection only carries the `10.0.0.0/16` range. The winning row is written
correctly; the target it points to does not carry the address. The packet leaves, does not come
back, produces no fee, and leaves no trace on the bill.

The matches column makes the rule's mechanics visible: six of the eight flows match two or three
rows at once, and only two match a single row. Nowhere in the table is it written which row wins;
the winner is decided on the spot, once the prefix lengths are sorted. The consequence is this:
adding a new row can change a flow's path without changing any existing row.

The bottom three rows are the real comparison. In the wide set, two flows disappear silently.
Narrowing the rule — writing `/16` instead of `/8` — zeroes out the black hole, but it does not fix
the flows, it only drops them onto the default path. Both leave through the egress gateway, try to
reach a private address, still do not arrive, and this time cost 9.8 extra units a period: 49 units,
against the wide set's 39.2.

The narrow set's difference looks like a loss, and it is not. A silent failure turned into a
billed failure; a billed failure has a row somewhere. The correct answer is the third row: adding
two rows makes a six-row set, all eight of the eight flows reach, and the egress fee drops back to
the wide set's fee. Narrowing the rule did not fix it; writing the two missing targets did.

#### Missing Row and Duplication

```js
// run2.mjs — five tables' duplication, then a single row is dropped from the zone-3 table
import { SET, FLOW, FEE, ZONES, result } from './route.mjs';

const PUBLIC = [['10.42.0.0/20', 'local'], ['0.0.0.0/0', 'gateway']];
const TABLES = [['public', PUBLIC], ...Array.from({ length: ZONES }, (_, i) => [`private-zone-${i + 1}`, SET.full])];
const rowCount = TABLES.reduce((a, [, t]) => a + t.length, 0);
const unique = new Set(TABLES.flatMap(([, t]) => t.map((r) => r.join(' '))));
const tableCount = (r) => TABLES.filter(([, t]) => t.some((x) => x.join(' ') === r.join(' '))).length;
const INSTANCES_PER_ZONE = 141;                // private instances per zone (from the previous lesson's distribution)

const baseline = FLOW.map(([, ip, h]) => ({ ...result(SET.full, ip), h }));
const baselineVolume = baseline.filter((x) => x.billed).reduce((a, x) => a + x.h, 0);
console.log(`${TABLES.length} tables, ${rowCount} rows, ${unique.size} unique rules, ${rowCount - unique.size} duplicates`);
console.log(`\nif a single row is dropped from the zone-3 table (${INSTANCES_PER_ZONE} instances affected):`);
console.log(`${'dropped row'.padEnd(15)} ${'next hop'.padEnd(14)} ${'tables'.padStart(8)} ${'broken'.padStart(8)} ` +
  `${'new outcome'.padEnd(12)} ${'fee delta'.padStart(12)}`);
for (const r of SET.full) {
  const without = SET.full.filter((x) => x !== r);
  const after = FLOW.map(([, ip, h]) => ({ ...result(without, ip), h }));
  const broken = after.filter((x, i) => x.outcome !== baseline[i].outcome);
  const volume = after.filter((x) => x.billed).reduce((a, x) => a + x.h, 0);
  const delta = FEE(volume) - FEE(baselineVolume);
  console.log(`${r[0].padEnd(15)} ${r[1].padEnd(14)} ${String(tableCount(r)).padStart(8)} ` +
    `${String(broken.length).padStart(8)} ${[...new Set(broken.map((x) => x.outcome))].join(',').padEnd(12)} ` +
    `${String(delta > 0 ? `+${delta.toFixed(1)}` : delta.toFixed(1)).padStart(12)}`);
}
console.log(`\nperiodic egress fee (full set): ${FEE(baselineVolume)} units`);
console.log(`table touched when a new subnet is added:  1  (association)`);
console.log(`tables touched when a new destination network is added: ${ZONES}  (${ZONES} rows, the same row ${ZONES} times)`);
console.log(`when a new zone is added: 1 new table, ${SET.full.length} rows, 1 gateway`);
```

```
5 tables, 26 rows, 6 unique rules, 20 duplicates

if a single row is dropped from the zone-3 table (141 instances affected):
dropped row     next hop         tables   broken new outcome     fee delta
10.42.0.0/20    local                 5        2 gateway-wasted       +266.0
172.20.0.0/14   private-link          4        1 gateway-wasted        +15.6
0.0.0.0/0       gateway               5        2 no route            -39.2
10.0.0.0/16     peer-central          4        1 gateway-wasted        +52.0
10.44.0.0/14    peer-backup           4        1 gateway-wasted         +3.0
10.8.0.0/14     peer-legacy           4        1 gateway-wasted         +6.8

periodic egress fee (full set): 39.2 units
table touched when a new subnet is added:  1  (association)
tables touched when a new destination network is added: 4  (4 rows, the same row 4 times)
when a new zone is added: 1 new table, 6 rows, 1 gateway
```

The row that looks most ordinary is the most expensive. If the local route drops, in-block traffic
slides onto the default path, leaves through the gateway, and adds 266.0 units a period — roughly
seven times the full set's entire egress fee. Traffic between the reading collector and the data
store both stops and gets billed. The default route dropping works the other way: the fee falls by
39.2 units and two external flows are left without a route. Access is cut while the bill drops.

The third column says why this goes unnoticed. Every row is written into four or five tables, and
the drop was made from only one zone's table: 141 instances are affected, and the 475 instances in
the other three zones are not. Three-quarters of the same request works and one-quarter does not.
This is the hardest failure shape a single broken row produces — intermittent, passes on retry, and
blamed on the network itself rather than the route table.

A single row dropping looks like a remote possibility on paper, yet the sweep models real work.
Four identical tables are not written by one hand; one gets fixed by hand, one gets regenerated
from a template, one gets temporarily changed during an incident and never restored. There is a
reason to add a row; there is no reason to verify the row exists in all four of the four tables —
because nothing looks broken.

The last three lines give duplication's bill. Only six of the twenty-six rows are unique; twenty
are copies. Adding a new subnet costs a single touch, because the subnet is associated with an
existing table. Adding a new destination network costs four touches and four identical rows.
Writing three of the four and skipping one lands exactly on the state the sweep above produced.

The irreversible item here is not the row itself. A route row is written, deleted, fixed — this is
the easiest item to reverse in the entire course. What cannot be reversed is what happens during
the period the row is wrong: the 490 volume units of measurement swallowed silently cannot be
collected again, and the 266.0 units already paid does not come back. Three items stay with the team
— rule width, table count, and which subnet is associated with which table. Two items are handed
off: the longest prefix match rule itself, and the local route being written into every table
automatically. Both are closed to debate, and they produce half the numbers above.

#### Summary

- Longest prefix match protects in-block traffic: `10.42.3.14` matches three rows, and `/20` wins.
  The same rule works in the opposite direction too — the `10.0.0.0/8` row also wins the
  `10.8.3.1` and `10.44.2.3` flows, and since the peering connection only carries `10.0.0.0/16`,
  both fall into a black hole.
- Writing the rule as `/16` instead of `/8` drops the black hole count from 2 to 0 but does not
  fix the flows: the same two flows dropped onto the default path leave and still do not arrive,
  and the periodic fee rises from 39.2 units to 49 units. What fixes it is not narrowing but writing the
  two missing targets; the 6-row set delivers all eight of the eight flows and brings the fee back
  to 39.2 units.
- A single row dropping does not cost equally: the local route costs +266.0 units, the peering rows
  between +3.0 and +52.0 units, and the default route −39.2 units with two flows left routeless. The
  change that lowers the fee is the same change that cuts off access.
- 5 tables hold 26 rows, only 6 of them unique, 20 duplicates. Every rule is written into 4 or 5
  tables; a row dropped from one zone's table affects 141 instances and leaves 475 untouched, and
  the failure looks intermittent.
- A new subnet costs 1 touch, a new destination network costs 4 touches and 4 identical rows, a
  new zone costs 1 table plus 6 rows plus 1 gateway. The irreversible item is not the row, but the
  490 volume units of measurement swallowed and the 266 units paid during the period the row was
  wrong.

#### Next Step

In the last two runs, the gateway appeared as both the fix and the bill: every flow leaving went
out through it, every flow dropped onto the wrong path fell there, and every new zone asked for
one more. The gateway itself was never measured. The next lesson's question counts a private
subnet's instances' relationship with the outside in three arrangements: what setting up no
gateway at all blocks, and by name; how many instances' traffic a single translation gateway
routes through one point, and which bottleneck and which failure point that creates; how splitting
the gateway per zone divides that up and what it raises the cost to — and how far giving every
instance a public address raises the externally reachable surface, in item count.

### Internet and Address Translation Gateways

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/internet-and-address-translation-gateways

A private subnet's instances' relationship with the outside is counted in four layouts: which five jobs stall by name in the no-gateway layout, how many instances' traffic a single gateway routes through one point and in which period it crosses the connection limit, how far a gateway per zone divides the failure radius and what it raises the cost to, and how many items and how many external records a public address per instance opens.

In the previous two lessons, the gateway appeared as both the fix and the bill: every outbound
flow left through it, every flow dropped onto the wrong path fell there, and every new zone asked
for one more. The gateway itself was never counted. Once every subnet was made private, the
externally reachable item count dropped to one — leaving the question in the opposite direction:
getting from inside to outside.

This question does not look like a security question, and it is one. An outbound path creates a
dependency even without opening an inbound one: the gateway's externally visible address gets
recorded by the other side, and those records make up the hardest item to reverse. Four layouts
are compared under the same workload.

- **CN19.** The four layouts, the work list, and the costs belong to the course's fictional
  regional measurement network. No real gateway is set up; what is measured is the model built
  with `node`.
- **CN20.** The gateway's concurrent connection limit is written by hand as 55,000, and peak-time
  outbound connections per instance as 90. In reality both vary by provider and workload; it shifts
  the saturation period without changing whether the bottleneck exists.
- **CN21.** Costs are in fictional units, not currency. The fixed component is written per
  gateway and per address, the flow component per hundred volume units.
- **CN22.** Three external services are assumed to write the egress address into their own
  records. In reality this number grows with the dependency count; it raises the reversal cost
  without changing its direction.
- **CN23.** In the single-gateway layout, three zones' traffic crosses the zone boundary and is
  billed separately for it.
- **CN24.** The gateway's own redundancy is counted as the provider's. "If the gateway drops"
  means a zone-level loss; it does not change how the failure radius is computed.

#### The Model of Four Layouts

The model defines a layout with three numbers: how many gateways there are, whether every
instance has been given a public address, and which results follow from these. The `measure`
function returns six separate items at once — instances able to exit, externally reachable items,
stalled work, peak connections, failure radius, and external records. Cost splits into fixed and
flow, because the two grow at different rates with each period.

```js
// gateway.mjs — a private subnet's relationship with the outside; no real gateway is set up, the model is counted
export const INSTANCES = 616, ZONES = 4, PORTS = 4;   // the previous lesson's third layout: everything in private subnets
export const LIMIT = 55000;                     // gateway's concurrent connection limit (fictional, CN20)
export const PEAK_PER_INSTANCE = 90;             // peak-time outbound connections per instance (CN20)
export const VOLUME = 1960;                      // periodic external egress volume (from the previous lesson)
export const EXTERNAL_PARTIES = 3;               // external services that record the gateway's address (CN22)
// Costs are fictional units (CN21); written per 100 volume units, they carry a ratio, not a currency.
export const GATEWAY_FIXED = 40, ADDRESS_RENT = 0.5, EGRESS = 2, PROCESSING = 1, CROSS_ZONE = 1;

export const WORK = ['patching and package retrieval', 'calls to the external measurement service', 'time source',
  'certificate renewal', 'log shipping'];

// [name, gateway count, public address per instance?]
export const LAYOUT = [['no gateway', 0, false], ['single translation gateway', 1, false],
  ['gateway per zone', ZONES, false], ['public address per instance', 0, true]];

export const measure = ([name, gateways, addressed], instances = INSTANCES, volume = VOLUME) => {
  const addresses = addressed ? instances : 0;                       // one public address per instance
  const canExit = gateways > 0 || addresses > 0;
  const crossZone = gateways === 1 ? volume * (ZONES - 1) / ZONES : 0;   // a single gateway pulls three zones' traffic
  const flow = canExit ? (volume * EGRESS + (gateways ? volume * PROCESSING + crossZone * CROSS_ZONE : 0)) / 100 : 0;
  const fixed = gateways * GATEWAY_FIXED + addresses * ADDRESS_RENT;
  return { name, gateways, addresses, canExit: canExit ? instances : 0, item: addresses * PORTS,
    stalled: canExit ? 0 : WORK.length, peak: gateways ? Math.round(instances * PEAK_PER_INSTANCE / gateways) : 0,
    radius: gateways ? Math.round(instances / gateways) : 0, records: (gateways || addresses) * EXTERNAL_PARTIES,
    flow: Math.round(flow * 10) / 10, fixed, total: Math.round((flow + fixed) * 10) / 10 };
};
```

#### The Same Workload, Four Layouts

```js
// run1.mjs — four layouts under the same workload: surface, bottleneck, failure radius, and cost
import { LAYOUT, WORK, LIMIT, INSTANCES, VOLUME, measure } from './gateway.mjs';

console.log(`${INSTANCES} private instances, periodic external volume ${VOLUME}, gateway concurrent connection limit ${LIMIT}`);
console.log(`${'layout'.padEnd(29)} ${'exits'.padStart(6)} ${'open item'.padStart(11)} ${'stalled'.padStart(9)} ` +
  `${'peak conn'.padStart(14)} ${'failure radius'.padStart(15)} ${'records'.padStart(10)} ${'total cost'.padStart(13)}`);
for (const d of LAYOUT) {
  const m = measure(d);
  const peak = m.gateways ? `${m.peak} (${Math.round(m.peak * 100 / LIMIT)}%)` : '-';
  console.log(`${m.name.padEnd(29)} ${String(m.canExit).padStart(6)} ${String(m.item).padStart(11)} ` +
    `${String(m.stalled).padStart(9)} ${peak.padStart(14)} ${String(m.radius ? `${m.radius} instances` : '-').padStart(15)} ` +
    `${String(m.records).padStart(10)} ${String(m.total).padStart(13)}`);
}
console.log(`\nstalled work in the no-gateway layout (${WORK.length} items): ${WORK.join(', ')}`);
const [single, perZone] = [measure(LAYOUT[1]), measure(LAYOUT[2])];
console.log(`cost breakdown  single gateway: fixed ${single.fixed} + flow ${single.flow}   ` +
  `per zone: fixed ${perZone.fixed} + flow ${perZone.flow}`);
console.log(`moving from single gateway to per zone: ${perZone.records - single.records} new external records, the same count on the way back`);
```

```
616 private instances, periodic external volume 1960, gateway concurrent connection limit 55000
layout                         exits   open item   stalled      peak conn  failure radius    records    total cost
no gateway                         0           0         5              -               -          0             0
single translation gateway       616           0         0   55440 (101%)   616 instances          3         113.5
gateway per zone                 616           0         0    13860 (25%)   154 instances         12         218.8
public address per instance      616        2464         0              -               -       1848         347.2

stalled work in the no-gateway layout (5 items): patching and package retrieval, calls to the external measurement service, time source, certificate renewal, log shipping
cost breakdown  single gateway: fixed 40 + flow 73.5   per zone: fixed 160 + flow 58.8
moving from single gateway to per zone: 9 new external records, the same count on the way back
```

The first row is full of zeros, and that does not make it good. The no-gateway layout has no open
item, no external record, no cost — and no instance able to exit either. The five items in the
stalled work column are written by name: patching and package retrieval, calls to the external
measurement service, time source, certificate renewal, log shipping. The first means machines stay
unpatched, the third means the clock drifts, the fourth means certificates expire. The zero-cost
layout is the layout that gives back every gain made at the machine level, a few periods later.

The single-gateway row carries three numbers at once. It has the lowest cost: 113.5 units, less than
half the per-zone gateway. The peak connection column shows the first limit — 55,440, 101% of the
limit. In this period the gateway fills, and what happens when it fills shows up not as a failure
but as slowdown: new connections queue. The failure radius is the second limit: if the zone the
gateway sits in is lost, all 616 instances become unable to exit, because the 462 instances in the
other three zones were also routing their traffic through it.

A gateway per zone fixes both at once and flips the bill. The fixed cost rises from 40 to 160, and
the flow cost drops from 73.5 to 58.8 — because no traffic crosses a zone boundary anymore. The
total rises from 113.5 to 218.8, roughly double. In exchange, peak fill drops from 101% to 25%,
and the failure radius from 616 instances to 154.

The 14.7 units difference in flow cost is small on its own, and where it comes from matters. A single
gateway pulls three of the four zones' traffic into its own zone; that traffic crosses the zone
boundary twice, and each crossing is billed separately. This item shows up in no route table,
shows up in no gateway count, and shows up only on the bill — and because it depends on which zone
the gateway is placed in, it is the result of a single-row decision.

The last row is the fourth layout, and it is a warning on its own. Giving every instance a public
address removes the gateway problem entirely: no bottleneck, no failure radius, the lowest flow
cost. In exchange, two surfaces open up. The first is 2,464 open items — the number the previous
lesson cut to one comes back, and more than comes back. The second is 1,848 external records:
every instance's address sits in three external services' records.

#### Across Eight Periods

```js
// run2.mjs — across eight periods: when the single gateway fills, and when each layout turns expensive
import { LAYOUT, LIMIT, VOLUME, INSTANCES, measure } from './gateway.mjs';

// The previous lessons' growth series; period 8 is the value rounded per zone
const SERIES = [[1, 18], [2, 26], [3, 44], [4, 71], [5, 118], [6, 196], [7, 340], [8, 616]];
console.log(`${'period'.padEnd(7)} ${'instances'.padStart(10)} ${'single gw fill'.padStart(18)} ${'per zone'.padStart(12)} ` +
  `${'single gw'.padStart(10)} ${'per zone'.padStart(12)} ${'per instance'.padStart(13)} ${'open item'.padStart(11)}`);
for (const [d, n] of SERIES) {
  const h = Math.round(VOLUME * n / INSTANCES);
  const [single, perZone, perAddr] = [1, 2, 3].map((i) => measure(LAYOUT[i], n, h));
  const pct = (m) => `${Math.round(m.peak * 100 / LIMIT)}%`;
  console.log(`${String(d).padEnd(7)} ${String(n).padStart(10)} ` +
    `${`${single.peak} ${pct(single)}${single.peak > LIMIT ? ' over' : ''}`.padStart(18)} ${pct(perZone).padStart(12)} ` +
    `${String(single.total).padStart(10)} ${String(perZone.total).padStart(12)} ${String(perAddr.total).padStart(13)} ` +
    `${String(perAddr.item).padStart(11)}`);
}
const cheapest = SERIES.map(([d, n]) => {
  const h = Math.round(VOLUME * n / INSTANCES);
  const m = [1, 2, 3].map((i) => measure(LAYOUT[i], n, h));
  return [d, m.reduce((a, b) => (b.total < a.total ? b : a)).name];
});
const records = (n) => measure(LAYOUT[3], n).records;
console.log(`\nthe way back from the public-address-per-instance layout: ${records(71)} external records in period 4, ${records(616)} in period 8`);
console.log('period by period, the cheapest layout:');
for (const [name, periods] of Object.entries(cheapest.reduce((a, [d, name]) => ({ ...a, [name]: [...(a[name] || []), d] }), {})))
  console.log(`  ${name.padEnd(29)} period ${periods.join(', ')}`);
```

```
period   instances     single gw fill     per zone  single gw     per zone  per instance   open item
1               18            1620 3%           1%       42.1        161.7          10.1          72
2               26            2340 4%           1%       43.1        162.5          14.7         104
3               44            3960 7%           2%       45.3        164.2          24.8         176
4               71           6390 12%           3%       48.5        166.8            40         284
5              118          10620 19%           5%       54.1        171.3          66.5         472
6              196          17640 32%           8%       63.4        178.7         110.5         784
7              340          30600 56%          14%       80.6        192.5         191.6        1360
8              616    55440 101% over          25%      113.5        218.8         347.2        2464

the way back from the public-address-per-instance layout: 213 external records in period 4, 1848 in period 8
period by period, the cheapest layout:
  public address per instance   period 1, 2, 3, 4
  single translation gateway    period 5, 6, 7, 8
```

The sweep shows two curves. The gateway layouts' cost starts from a fixed base and grows slowly;
the address-per-instance layout's cost starts from zero and grows linearly with the instance
count. They cross in period five. For the first four periods, the cheapest layout is the layout
with the highest open item count, and in those periods the open item count runs between 72 and
284 — large against the gateway layouts' zero, but not large enough to trigger an alarm.

The single gateway column's fill value is the real warning. 56% in period seven, 101% in period
eight. Between the two periods the instance count grew 81%, and fill jumped 45 points; on a linear
curve, the surprise is not in the curve itself but in where the limit sits. The gateway-per-zone
stays at 25% in the same period, meaning it has three more periods of headroom. Moving from a
single gateway to per zone costs nine new external records, and that is work that cannot be done
at the moment it needs to be done — it depends on the external parties' calendar.

Fill crossing 100% does not produce a failure record of its own, and it does not show up where it
gets looked for either. A gateway pressed against its limit does not reject a connection, it holds
it; the side looking from inside sees this as a slowing external call. Three of the five jobs —
calls to the external measurement service, certificate renewal, and log shipping — start timing
out, and all three read as a failure of their own component. The number measured sits at the
gateway; the symptom sits in five other places.

The irreversible item in this lesson is not the gateway itself, but the trace the egress address
leaves outside. The gateway gets deleted, rebuilt, its count increased — but once an address is
written into three external services' records, those records are not in hand. This is exactly the
reversal cost of the address-per-instance layout, and it grows with the period: 213 records in
period four, 1,848 in period eight. The layout that is cheapest for the first four periods is the
layout most expensive to abandon in period five.

The item staying with the team is four pieces: gateway count, which zone the gateway sits in,
which instances get a public address, and which address the exit is seen from. Three items are
handed off — the gateway's concurrent connection limit, its own redundancy, and the translation
operation's latency. The first of the three handed off decides the entire table for period eight,
and it is asked nowhere.

#### Summary

- The no-gateway layout produces 0 open items, 0 external records, and 0 cost; in exchange none of
  the 616 instances can exit, and 5 jobs stall by name: patching and package retrieval, calls to
  the external measurement service, time source, certificate renewal, log shipping.
- The single translation gateway is the cheapest gateway layout — 113.5 units — and it carries two
  limits at once: peak connections at 55,440, 101% of the limit, and a failure radius of 616
  instances. The 462 instances in three zones route their traffic through another zone.
- A gateway per zone drops fill to 25% and the failure radius to 154 instances. The fixed cost
  rises from 40 to 160, and the flow cost drops from 73.5 to 58.8 since no zone boundary is
  crossed; the total rises from 113.5 to 218.8, roughly double.
- A public address per instance zeroes out the bottleneck and the failure radius, and opens 2,464
  open items and 1,848 external records in exchange. This is the cheapest layout for the first 4
  periods; in period 5 the single gateway takes over.
- The irreversible item is the trace the egress address leaves outside. Moving from a single
  gateway to per zone costs 9 new external records; leaving the address-per-instance layout costs
  213 records in period 4 and 1,848 in period 8, and none of these are on our own calendar.

#### Next Step

In all four of the four layouts, outbound traffic was counted, inbound connections were counted —
but nowhere was it written which connection is allowed. The open item column assumed every
instance listens on four ports, and how many of those ports actually need to stay open was never
asked. The next lesson's question writes the same policy in two separate mechanisms and runs it
against a known flow set: how many rules a filter that recognizes return traffic on its own needs,
how many rules the same policy needs from a filter that does not recognize it, which flows get
caught, which get away, and which get closed by mistake — and when the two mechanisms are used
together, which one is evaluated first, and in which case that order produces a result that cannot
be understood just by reading what is written.

### Security Groups and Network Access Lists

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/security-groups-and-network-access-lists

The same policy is written in two mechanisms, stateful and stateless, and run against twelve known flows: how many rules each needs, how many rows are added for return traffic, how many flows escape and how many legitimate flows get closed by mistake, what changing the rule number fixes, and which one decides when the two are used together.

The previous lesson's open item column assumed every instance listens on four ports, and how many
of those ports actually need to stay open was never asked. Subnet separation blocks an instance
from being called from outside; it does not say which call gets accepted. Two separate mechanisms
say that, and in this model both are present in the same network at once. Not every provider
offers both; some networks have only the stateful one, and the comparison below then collapses
to a single column.

The only difference between them is whether they keep state. One remembers an outgoing request and
accepts its return on its own; the other evaluates every packet on its own, remembering nothing.
Open ports and default-deny at the machine level were measured in a separate lesson; the question
here is not whether a port is open, but how many rules the same policy comes to in each mechanism,
and where the difference blows up.

- **CN25.** Twelve flows, four blocks, and the rule sets belong to the course's fictional regional
  measurement network. No real packet flows; what is measured is the model built with `node`.
- **CN26.** All flows are written relative to the application subnet, and a single zone is
  modeled. In reality every zone has its own list; it multiplies the rule count by the zone count
  without changing the direction of the result.
- **CN27.** The ephemeral port range is assumed to be 32768–60999. In reality the range varies by
  operating system; it shifts the port numbers of escaped flows without changing the mechanism of
  the escape.
- **CN28.** The state table's capacity and timeout are not modeled; the group is counted as
  recognizing the return of every outgoing request.
- **CN29.** Whether a flow is legitimate is marked by hand; there is no randomness, and every run
  produces the same classification.
- **CN30.** External-party addresses are chosen from blocks reserved for documentation.

#### The Model of Two Mechanisms

The model derives both mechanisms from the same policy. The group writes only the request
direction, and the `run` function lets the return through on its own. The list is made of
numbered rows, the first match wins, and each flow's two directions are evaluated separately —
this separate evaluation is the model's entire claim.

```js
// filter.mjs — the same policy in two mechanisms; no real packet flows, the model runs
export const toInt = (ip) => ip.split('.').reduce((a, o) => a * 256 + Number(o), 0);
export const contains = (c, ip) => {
  const [a, e] = c.split('/'); const b = 2 ** (32 - Number(e));
  const start = Math.floor(toInt(a) / b) * b, n = toInt(ip);
  return n >= start && n <= start + b - 1;
};
export const SUBNET = { public: '10.42.0.0/24', application: '10.42.1.0/24', data: '10.42.2.0/24',
  management: '10.42.3.0/24', any: '0.0.0.0/0' };
const EPHEMERAL = [32768, 60999];                       // ephemeral port range (CN27)

// Stateful group: only the request direction is written, the return opens on its own
export const GROUP = {
  narrow: { inbound: [[SUBNET.public, 8080], [SUBNET.management, 22], [SUBNET.management, 9100]], outbound: [[SUBNET.data, 5432], [SUBNET.any, 443]] },
  loose: { inbound: [[SUBNET.any, 8080], [SUBNET.management, 22], [SUBNET.management, 9100]], outbound: [[SUBNET.data, 5432], [SUBNET.any, 443]] },
};
// Stateless list: numbered, first match wins; the return needs its own row
const L_INBOUND = [[100, SUBNET.public, 8080, 8080, 'allow'], [110, SUBNET.management, 22, 22, 'allow'],
  [120, SUBNET.management, 9100, 9100, 'allow'], [130, SUBNET.data, ...EPHEMERAL, 'allow'], [140, SUBNET.any, ...EPHEMERAL, 'allow']];
const L_OUTBOUND = [[100, SUBNET.data, 5432, 5432, 'allow'], [110, SUBNET.any, 443, 443, 'allow'],
  [120, SUBNET.public, ...EPHEMERAL, 'allow'], [130, SUBNET.management, ...EPHEMERAL, 'allow']];
export const LIST = {
  baseline: { inbound: L_INBOUND, outbound: L_OUTBOUND },
  'deny-145': { inbound: [...L_INBOUND, [145, SUBNET.any, ...EPHEMERAL, 'deny']], outbound: L_OUTBOUND },
  'deny-135': { inbound: [...L_INBOUND, [135, SUBNET.any, ...EPHEMERAL, 'deny']], outbound: L_OUTBOUND },
};
// [name, direction, counterpart, destination port, counterpart port, legitimate?] — by the application subnet (CN26)
export const FLOW = [
  ['public -> application', 'inbound', '10.42.0.5', 8080, 41200, true],
  ['old client', 'inbound', '10.42.0.9', 8080, 5000, true],
  ['management shell', 'inbound', '10.42.3.4', 22, 52100, true],
  ['metric scrape', 'inbound', '10.42.3.7', 9100, 44300, true],
  ['data query', 'outbound', '10.42.2.6', 5432, 39800, true],
  ['call to external service', 'outbound', '198.51.100.9', 443, 47700, true],
  ['external -> application:8080', 'inbound', '203.0.113.4', 8080, 51000, false],
  ['external -> application:22', 'inbound', '203.0.113.4', 22, 51100, false],
  ['external -> metric endpoint', 'inbound', '203.0.113.4', 9100, 51200, false],
  ['external -> ephemeral range', 'inbound', '203.0.113.4', 45000, 8443, false],
  ['spoofed return (source 443)', 'inbound', '203.0.113.4', 46000, 443, false],
  ['data -> application:22', 'inbound', '10.42.2.6', 22, 53000, false],
];

export const expand = ([, dir, ip, dp, cp]) => dir === 'inbound'
  ? { request: ['inbound', ip, dp], return: ['outbound', ip, cp] }
  : { request: ['outbound', ip, dp], return: ['inbound', ip, cp] };
export const groupAllows = (g, [d, ip, k]) => g[d].some(([c, port]) => contains(c, ip) && port === k);
export const listAllows = (l, [d, ip, k]) => {
  const s = [...l[d]].sort((a, b) => a[0] - b[0]).find(([, c, lo, hi]) => contains(c, ip) && k >= lo && k <= hi);
  return { allowed: !!s && s[4] === 'allow', no: s ? s[0] : '-' };
};
export const run = (a, { g, l } = {}) => {
  const { request, return: ret } = expand(a);
  const ok = (d) => (g ? groupAllows(g, d) : true) && (l ? listAllows(l, d).allowed : true);
  if (!ok(request)) return 'request stopped';
  if (l && !listAllows(l, ret).allowed) return 'return stopped';   // the list is stateless, it checks the return separately
  return 'passed';
};
export const classify = (a, outcome) => a[5] ? (outcome === 'passed' ? 'correct' : 'wrongly closed')
  : (outcome === 'request stopped' ? 'caught' : 'escaped');
export const count = (choice) => {
  const r = FLOW.map((a) => classify(a, run(a, choice)));
  const n = (s) => r.filter((x) => x === s).length;
  return { correct: n('correct'), wrong: n('wrongly closed'), caught: n('caught'), escaped: n('escaped') };
};
export const ruleCount = ({ g, l } = {}) =>
  (g ? g.inbound.length + g.outbound.length : 0) + (l ? l.inbound.length + l.outbound.length : 0);
```

#### Same Policy, Two Mechanisms

```js
// run1.mjs — the same policy in two mechanisms, flow by flow across twelve known flows
import { FLOW, GROUP, LIST, run, classify, count, ruleCount } from './filter.mjs';

const g = GROUP.narrow, l = LIST.baseline;
console.log(`same policy: stateful group ${ruleCount({ g })} rules, stateless list ${ruleCount({ l })} rules`);
console.log(`${'flow'.padEnd(30)} ${'dest port'.padStart(11)} ${'peer port'.padStart(11)} ${'legit'.padStart(6)}  ` +
  `${'group'.padEnd(26)} list`);
for (const a of FLOW) {
  const [sg, sl] = [run(a, { g }), run(a, { l })];
  console.log(`${a[0].padEnd(30)} ${String(a[3]).padStart(11)} ${String(a[4]).padStart(11)} ` +
    `${String(a[5] ? 'yes' : 'no').padStart(6)}  ${`${sg} (${classify(a, sg)})`.padEnd(26)} ${sl} (${classify(a, sl)})`);
}
console.log(`\n${'mechanism'.padEnd(22)} ${'rules'.padStart(6)} ${'correct'.padStart(8)} ${'wrongly closed'.padStart(15)} ` +
  `${'caught'.padStart(10)} ${'escaped'.padStart(8)}`);
for (const [name, s] of [['stateful group', { g }], ['stateless list', { l }]]) {
  const r = count(s);
  console.log(`${name.padEnd(22)} ${String(ruleCount(s)).padStart(6)} ${String(r.correct).padStart(8)} ` +
    `${String(r.wrong).padStart(15)} ${String(r.caught).padStart(10)} ${String(r.escaped).padStart(8)}`);
}
```

```
same policy: stateful group 5 rules, stateless list 9 rules
flow                             dest port   peer port  legit  group                      list
public -> application                 8080       41200    yes  passed (correct)           passed (correct)
old client                            8080        5000    yes  passed (correct)           return stopped (wrongly closed)
management shell                        22       52100    yes  passed (correct)           passed (correct)
metric scrape                         9100       44300    yes  passed (correct)           passed (correct)
data query                            5432       39800    yes  passed (correct)           passed (correct)
call to external service               443       47700    yes  passed (correct)           passed (correct)
external -> application:8080          8080       51000     no  request stopped (caught)   request stopped (caught)
external -> application:22              22       51100     no  request stopped (caught)   request stopped (caught)
external -> metric endpoint           9100       51200     no  request stopped (caught)   request stopped (caught)
external -> ephemeral range          45000        8443     no  request stopped (caught)   return stopped (escaped)
spoofed return (source 443)          46000         443     no  request stopped (caught)   passed (escaped)
data -> application:22                  22       53000     no  request stopped (caught)   request stopped (caught)

mechanism               rules  correct  wrongly closed     caught  escaped
stateful group              5        6               0          6        0
stateless list              9        5               1          4        2
```

The same policy comes to five rows and nine rows. The difference is four rows, and none of the
four write any policy: they exist only for return traffic. Two of them, on the inbound side, allow
the data tier and anywhere else into the ephemeral port range; the other two, on the outbound
side, allow the public and management blocks into the same range. The policy amounted to three
permitted calls; close to half the rows have nothing to do with the policy itself.

The two escaped flows pass through the same row: the inbound row that allows any source into the
ephemeral port range. That row had to be written, because the return of a call to an external
service comes from outside by definition, and its source is not known in advance. The spoofed
return flow sits right on this boundary: source port 443, destination port in the ephemeral range
— indistinguishable at the packet level from a legitimate return, and it passes in both
directions. The only piece of information that could tell them apart is whether a request went to
that side earlier, and the stateless mechanism does not hold that information.

The one flow closed by mistake shows the same flaw in the opposite direction. The old client
connects not from a port above eight thousand but from five thousand. The request passes, the
application receives and processes it; the return cannot exit to port five thousand because the
outbound rows only cover the ephemeral range. The server log shows a successful request, the
client shows a timeout, and both are two ends of the same event.

#### Order and Combined Use

```js
// run2.mjs — the deny row's rule number, and the two mechanisms evaluated together
import { FLOW, GROUP, LIST, run, classify, count, ruleCount, listAllows, expand } from './filter.mjs';

console.log(`${'list form'.padEnd(14)} ${'rules'.padStart(6)} ${'correct'.padStart(8)} ${'wrongly closed'.padStart(15)} ` +
  `${'caught'.padStart(10)} ${'escaped'.padStart(8)}  row matching the ephemeral range`);
for (const [name, l] of Object.entries(LIST)) {
  const r = count({ l }), s = listAllows(l, ['inbound', '203.0.113.4', 45000]);
  console.log(`${name.padEnd(14)} ${String(ruleCount({ l })).padStart(6)} ${String(r.correct).padStart(8)} ` +
    `${String(r.wrong).padStart(15)} ${String(r.caught).padStart(10)} ${String(r.escaped).padStart(8)}  ` +
    `row ${s.no} wins`);
}

const K = [['group (narrow)', { g: GROUP.narrow }], ['group (loose)', { g: GROUP.loose }],
  ['list (baseline)', { l: LIST.baseline }], ['narrow + list', { g: GROUP.narrow, l: LIST.baseline }],
  ['loose + list', { g: GROUP.loose, l: LIST.baseline }]];
console.log(`\n${'mechanism'.padEnd(16)} ${'rules'.padStart(6)} ${'correct'.padStart(8)} ${'wrongly closed'.padStart(15)} ` +
  `${'caught'.padStart(10)} ${'escaped'.padStart(8)}`);
for (const [name, s] of K) {
  const r = count(s);
  console.log(`${name.padEnd(16)} ${String(ruleCount(s)).padStart(6)} ${String(r.correct).padStart(8)} ` +
    `${String(r.wrong).padStart(15)} ${String(r.caught).padStart(10)} ${String(r.escaped).padStart(8)}`);
}

console.log('\nwhen the two say different things about the same flow, which one decides:');
console.log(`${'flow'.padEnd(38)} ${'group only'.padEnd(18)} ${'list only'.padEnd(18)} together`);
for (const g of [GROUP.narrow, GROUP.loose])
  for (const a of FLOW) {
    const [sg, sl, sb] = [run(a, { g }), run(a, { l: LIST.baseline }), run(a, { g, l: LIST.baseline })];
    if (sg === sl) continue;
    const tag = g === GROUP.narrow ? 'narrow' : 'loose';
    console.log(`${`${a[0]} [${tag}]`.padEnd(38)} ${sg.padEnd(18)} ${sl.padEnd(18)} ${sb} (${classify(a, sb)})`);
  }
const oldClient = FLOW[1], d = expand(oldClient);
console.log(`\nin the "${oldClient[0]}" flow, the request enters on port ${oldClient[3]} in the inbound direction, ` +
  `and the return leaves on port ${d.return[2]} in the outbound direction; the list sees these two directions as two separate rows.`);
```

```
list form       rules  correct  wrongly closed     caught  escaped  row matching the ephemeral range
baseline            9        5               1          4        2  row 140 wins
deny-145           10        5               1          4        2  row 140 wins
deny-135           10        4               2          6        0  row 135 wins

mechanism         rules  correct  wrongly closed     caught  escaped
group (narrow)        5        6               0          6        0
group (loose)         5        6               0          5        1
list (baseline)       9        5               1          4        2
narrow + list        14        5               1          6        0
loose + list         14        5               1          6        0

when the two say different things about the same flow, which one decides:
flow                                   group only         list only          together
old client [narrow]                    passed             return stopped     return stopped (wrongly closed)
external -> ephemeral range [narrow]   request stopped    return stopped     request stopped (caught)
spoofed return (source 443) [narrow]   request stopped    passed             request stopped (caught)
old client [loose]                     passed             return stopped     return stopped (wrongly closed)
external -> application:8080 [loose]   passed             request stopped    request stopped (caught)
external -> ephemeral range [loose]    request stopped    return stopped     request stopped (caught)
spoofed return (source 443) [loose]    request stopped    passed             request stopped (caught)

in the "old client" flow, the request enters on port 8080 in the inbound direction, and the return leaves on port 5000 in the outbound direction; the list sees these two directions as two separate rows.
```

A deny row was written to close the two escaped flows, and given the number 145. The rule count
rose from nine to ten, and none of the outcome columns changed. The right-hand column gives the
reason: the row that matches the ephemeral range is still number 140, because the first match wins
and 140 comes first. The review sees a deny row in the list; the run never exercises it.

Pulling the number down to 135 actually works: the escaped count drops from two to zero. The same
move takes the correct count from five to four and the wrongly-closed count from one to two. The
second thing that closes is the return of the call to the external service. The escape and that
return match the same row, because as packets they are the same thing: a packet arriving from
outside, headed to a port in the ephemeral range. Order does not tell them apart; it only chooses
which one gets sacrificed. In the stateless mechanism, no row can be written that separates these
two flows.

The table below puts the two together. The order of evaluation is this: on the inbound side the
subnet list first, then the instance's group; on the outbound side, the reverse. The narrow group
alone gives the same result as the fourteen rules together. The nine-row list closes no escape and
brings in one wrongly-closed flow. The loose group row shows the list's gain: the group let port
eight thousand eighty through from outside, and the list stops it at the subnet boundary.

The surprising result is in the last table's first row. In the old-client flow, the group says
"passed," the list says "return stopped," and together it comes out "return stopped." A correctly
written group does not rescue a wrongly written list; the reverse rescues it. The stateless layer
can only subtract — it can close what the group opened by mistake, and it can also close what the
group opened correctly, but it can never open a return path. The connection shows as "established"
in the group's state table while the packet drops at the subnet boundary, and the two records,
checked in two places, do not confirm each other.

The irreversible item is this row. A rule gets written, its number gets changed, a row gets
deleted — all of that is reversible. What cannot be reversed is the work the server side does for
a request whose return drops: the request is processed, the response never goes out, the client
retries, and the same measurement gets recorded twice. Fixing the row does not undo the duplicate
record. The item staying with the team is four pieces — which mechanism to use, rule width,
numbering, and the ephemeral-range assumption. The items handed off are also four: the state table
itself, the two mechanisms' evaluation order, the ephemeral port being chosen on the client side,
and the state table's capacity. All three of the escapes and the one wrongly-closed flow above
trace back to this handed-off foursome.

#### Summary

- The same policy comes to 5 rules in the stateful mechanism and 9 in the stateless one. The 4
  rows between them write no policy; they exist only for return traffic.
- The stateless list lets 2 flows escape, and both pass through the same row: the one allowing any
  source into the ephemeral port range. The spoofed return flow passes in both directions, because
  it is indistinguishable at the packet level from a legitimate return.
- The same list wrongly closes 1 legitimate flow: the request from the client with source port
  5000 arrives, and its response cannot get out. The server log shows a successful request, the
  client shows a timeout.
- Writing the deny row as number 145 changes no number; 140 matches first. Writing it as 135 drops
  escaped from 2 to 0, but also drops correct from 5 to 4 and raises wrongly-closed from 1 to 2 —
  since the escape and the legitimate return match the same row, order only chooses which one gets
  sacrificed.
- Used together, the narrow group plus list costs 14 rules to deliver the same result the narrow
  group alone delivers with 5, and adds 1 wrongly-closed flow on top; the list's gain shows up only
  in the flow where the group was written wrong. The irreversible item is the duplicate record a
  request with a dropped return leaves on the server.

#### Next Step

For two lessons now, the only externally reachable item has been the port the load balancer
listens on, and that port has passed as nothing more than a number so far. The choice behind it is
not small: the balancer can meet traffic at the transport layer or at the application layer, and
the two layers see different things while looking at the same request. The next lesson's question
counts this choice item by item: which fields each layer can read from the request, how many extra
round trips and how much extra processing reading them costs, under which arrangement the instance
behind it can still see who the other side really is — and which new items raising the layer adds
to the outward-facing surface.

### Load Balancers

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/load-balancers

What layer choice determines in a load balancer: the transport layer seeing two fields and being able to apply only one of five rules, and sending 1,024 of 1,200 requests to the wrong pool; port split bringing this down to 653 while writing two items to the client; the application layer applying all five rules, dropping key copies from six to one, and raising the termination point from six to seven; the trade-off the health check threshold sets up between detection time and false removal.

The previous lesson filtered traffic: which request, from which source to which port, would pass
was written rule by rule, and the ones that did not were counted. A request that passes the
filter is now inside the network.

Which instance the passing request goes to is still undetermined. Six instances serve the
measurement network's dashboard; one is currently restarting, one is under high load, one just
launched and is not ready. What binds the request to an instance is the load balancer.

The balancing algorithms — round robin, least connections, session affinity — and what a health
check is were measured earlier; this lesson does not repeat them. The decision that stays with
the renter in this layer is different: **which layer** the balancer runs at. The layer determines
how much of the request the balancer can see, and how far it can route. Three things are measured
here: the fields each layer sees and the rules it can apply, the round the application layer adds
and the termination point it opens, and a sweep of the health check threshold.

- **CN31.** The fictional regional measurement network: two hosts (`dashboard`, `asset`), five
  content paths, six backend instances. The request flow is produced by our own generator; the
  seed `30631` is visible and gives the same sequence run to run. 1,200 requests.
- **CN32.** The operator's intent is written in five rules: two path prefixes, one host, one
  management path, and one default. Rules are tried in order, the first match wins.
- **CN33.** The transport layer balancer sees only the address and the port; the application
  layer sees the address, the port, the path, the host, and the session field. Under the
  port-split layout, the client sends asset and management traffic to separate ports — this is
  the item that changes on the client side.
- **CN34.** The handshake round, termination point, and key copy counts are model constants: the
  transport layer passes the connection through (one round, the key on all six backends), the
  application layer terminates the connection and opens a new one to the back end (two rounds,
  the key on the balancer).
- **CN35.** The health check model is six instances and 300 check rounds; the third instance
  fails permanently at round 120. For a healthy instance, the probability of a transient
  deviation is independent at every check and is not affected by the interval; seed `41129`.
- **CN36.** An instance is removed from traffic after as many consecutive failed checks as the
  threshold, and reinstated after two consecutive successful checks. The model does not measure
  time; the second values are the product of the round count and the check interval.

```js
// load-balancer/tier.mjs — model: load balancer layer selection and the health check threshold.
// The fictional regional measurement network's request flow is produced by our own generator;
// the seed is visible. The balancing algorithm is not redefined; what is measured is the
// information the layer sees, the routing it can do with that information, and the surface it opens.
const SEED = 30631, REQUESTS = 1200, BACKENDS = 6;
// [path, host, weight, pool the operator wants]
const FLOW = [
  ["/map/region", "dashboard", 30, "map"], ["/counter/list", "dashboard", 25, "counter"],
  ["/nightly/digest", "dashboard", 15, "general"], ["/asset/logo.png", "asset", 20, "asset"],
  ["/management/node", "dashboard", 10, "management"],
];
const TOTAL = FLOW.reduce((a, x) => a + x[2], 0);
// [field examined, value, pool] — rules are tried in order, the last one is the default
const RULES = [["path", "/map", "map"], ["path", "/counter", "counter"],
  ["host", "asset", "asset"], ["path", "/management", "management"], ["port", "*", "general"]];
const SPLIT_POOLS = ["asset", "management"];  // pools moved to a separate port under port split
const generator = (t) => { let s = t >>> 0; return () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296; };
const pick = (u) => { let e = u * TOTAL; return FLOW.find((x) => (e -= x[2]) < 0) ?? FLOW[0]; };

function run({ fields, split }) {
  const rng = generator(SEED);
  let correct = 0, wrong = 0, examined = 0;
  for (let i = 0; i < REQUESTS; i += 1) {
    const [path, host, , target] = pick(rng());
    let routedTo = "general";
    if (fields.includes("path")) {               // application layer: the rule chain is run
      for (const [field, value, pool] of RULES) {
        examined += 1;
        if (field === "port" || (field === "path" ? path.startsWith(value) : host === value)) { routedTo = pool; break; }
      }
    } else {                                      // transport layer: only the port is looked at
      examined += 1;
      routedTo = split && SPLIT_POOLS.includes(target) ? target : "general";
    }
    if (routedTo === target) correct += 1; else wrong += 1;
  }
  const rules = RULES.filter(([field, , pool]) =>
    fields.includes(field) || (split && SPLIT_POOLS.includes(pool))).length;
  return { correct, wrong, examined, rules };
}

// [name, fields seen, port split, handshake round, termination point, key copy]
const LAYOUT = [
  ["transport/single port", ["address", "port"], false, 1, BACKENDS, BACKENDS],
  ["transport/port split", ["address", "port"], true, 1, BACKENDS, BACKENDS],
  ["application layer", ["address", "port", "path", "host", "session"], false, 2, BACKENDS + 1, 1],
];
const E = [22, 13, 16, 14, 13, 15];
const print = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](E[i])).join("");
console.log(print(["layout", "fields seen", "rules applied", "correct pool", "wrong pool",
  "client item"]));
const O = [];
for (const [name, fields, split] of LAYOUT) {
  const o = run({ fields, split });
  O.push(o);
  console.log(print([name, fields.length, o.rules, o.correct, o.wrong, split ? SPLIT_POOLS.length : 0]));
}
const P = [22, 17, 18, 20, 16];
const print2 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](P[i])).join("");
console.log("\n" + print2(["layout", "handshake round", "fields examined", "termination point",
  "key copy"]));
LAYOUT.forEach(([name, , , round, endpoint, keys], i) =>
  console.log(print2([name, round, O[i].examined, endpoint, keys])));

// --- Health check threshold ------------------------------------------------------
// Model: six instances, 300 check rounds; the third instance fails permanently at round 120.
// For a healthy instance, the deviation probability is independent at every check and is not affected by the interval.
const INSTANCES = 6, CHECKS = 300, FAIL_ROUND = 120, DEVIATION = 0.06, SEED2 = 41129, BROKEN = 3;
function health(threshold) {
  const rng = generator(SEED2);
  const consecutive = Array(INSTANCES).fill(0), evicted = Array(INSTANCES).fill(false);
  const successes = Array(INSTANCES).fill(0);
  let detectRound = 0, falseRemoval = 0, lostRounds = 0;
  for (let t = 1; t <= CHECKS; t += 1) {
    for (let inst = 0; inst < INSTANCES; inst += 1) {
      const permanent = inst === BROKEN && t >= FAIL_ROUND;
      const failed = (rng() < DEVIATION) || permanent;
      if (failed) { consecutive[inst] += 1; successes[inst] = 0; } else { consecutive[inst] = 0; successes[inst] += 1; }
      if (!evicted[inst] && consecutive[inst] >= threshold) {
        evicted[inst] = true;
        if (permanent) { if (detectRound === 0) detectRound = t; } else falseRemoval += 1;
      } else if (evicted[inst] && successes[inst] >= 2) evicted[inst] = false;
      if (evicted[inst] && !permanent) lostRounds += 1;
    }
  }
  return { detectRound, falseRemoval, lostRounds };
}
const S = [14, 11, 17, 16, 17, 20, 13];
const print3 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](S[i])).join("");
console.log("\n" + print3(["interval(s)", "threshold", "detect round", "detect(s)", "false removal",
  "lost capacity(s)", "checks/min"]));
for (const interval of [2, 5, 10]) {
  for (const threshold of [1, 2, 3]) {
    const result = health(threshold);
    console.log(print3([interval, threshold, result.detectRound, (result.detectRound - FAIL_ROUND + 1) * interval,
      result.falseRemoval, result.lostRounds * interval, (60 / interval) * INSTANCES]));
  }
}
```

```
layout                  fields seen   rules applied  correct pool   wrong pool    client item
transport/single port             2               1           176         1024              0
transport/port split              2               3           547          653              2
application layer                 5               5          1200            0              0

layout                  handshake round   fields examined   termination point        key copy
transport/single port                 1              1200                   6               6
transport/port split                  1              1200                   6               6
application layer                     2              3075                   7               1

interval(s)     threshold     detect round       detect(s)    false removal    lost capacity(s)   checks/min
2                       1              120               2               78                 330          180
2                       2              121               4                2                   8          180
2                       3              122               6                0                   0          180
5                       1              120               5               78                 825           72
5                       2              121              10                2                  20           72
5                       3              122              15                0                   0           72
10                      1              120              10               78                1650           36
10                      2              121              20                2                  40           36
10                      3              122              30                0                   0           36
```

#### Fields Seen, Rules That Can Be Applied

The first table's first column is the number of fields the layer sees: two for the transport
layer, five for the application layer. The second column is the number of rules that view allows.

Of the five rules the operator wrote, the transport layer can apply only one: the default. The
other four look at the path or the host, fields the transport layer never sees. The result is in
the third column: of the 1,200 requests, only 176 reached the pool the operator wanted, and 1,024
fell into the wrong pool — the only correct ones being nightly digest requests that already
belonged to the default.

This is not a misconfiguration: the rule is written and correct, but the balancer cannot apply it
because it does not see the field the rule looks at — a five-rule intent does not fit into a
two-field view.

The second row measures the known fix for this squeeze: **port split**. Asset traffic is moved to
one port, management traffic to another. Because the transport layer sees the port, it can now
apply these two rules — applied rules rise from one to three, and correct-pool requests climb
from 176 to 547.

The gain is paid for in the last column. Port split is not a change in the balancer but in the
client: two client configuration items changed. Map and counter requests still arrive at the same
host — there is no way to separate them by port, so 653 requests stay in the wrong pool.

In the third row, the application layer sees all five fields, applies all five rules, and both
wrong-pool requests and changed client items fall to zero.

#### The Application Layer's Bill

The second table counts the cost of the same three layouts.

The fields-examined column is computed: the transport layer looks at a single field on every
request (the port), for a total of 1,200 looks. The application layer runs the rule chain until
it matches — the map request matches at the first rule, the counter at the second, the asset at
the third, the management at the fourth, and the nightly digest falls through to the fifth rule.
The weighted total is 3,075 looks, two and a half times as many. The chain's **order** is itself measurable: putting heavy traffic first lowers this number.

The handshake round column is sharper. The transport layer passes the connection through: the
encrypted session is established between the client and the back end, the balancer only carries
bytes, and the round count is one. The application layer must read the request's path, which
means decrypting it: the session terminates at the balancer, a second session is opened to the
back end, and the round count is two.

The last two columns show two faces of this necessity, pointing in opposite directions.

Key copies number six under the transport layer: because the encryption terminates at the back
end, the private key must sit on all six instances. Rotating the key means touching six machines.
Under the application layer, key copies drop to one — the key lives only on the balancer.

The termination point moves the other way: from six to seven. The head of the request turns back
into plaintext first at the balancer, then again at the back end. The added point is unlike the
other six: it is the **rented component**. A component you do not operate, one that sees the
path, the host, the session cookie, and the requesting address, has entered the chain. That
component also logs these fields, and where that log stops is not this lesson's decision.

The trade-off fits in one sentence: key copies from six to one, the termination point from six to
seven, misrouting from 1,024 to zero.

#### The Health Check's Three Numbers

The third table measures the balancer's second job. How the health check itself works is the
provider's concern; three numbers stay with the renter: the **interval**, the **threshold**, and
the check path. The sweep runs across two of these three numbers.

The detect round depends on the threshold and is independent of the interval: at threshold one,
the broken instance is removed at round 120; at threshold three, at round 122. That same round
count is two to six seconds at a two-second interval, and ten to thirty seconds at a ten-second
interval. This is how long the broken instance keeps receiving traffic.

The false removal column runs the other way. At threshold one, a healthy instance was removed
from traffic 78 times: a single failed check — a transient deviation — pulled it out. When the
threshold rises to two, this number drops to two, and at three, to zero.

The lost capacity column is the bill for false removal. At threshold one and interval ten, 1,650
seconds: one instance was pulled out and put back repeatedly, leaving capacity short for roughly
28 minutes. In the same row, the broken instance had been detected in ten seconds. A ten-second
gain cost 1,650 seconds of capacity.

The last column counts a third cost: shortening the interval multiplies check requests. At
interval ten, 36 check requests a minute; at interval two, 180. These requests land on the back
end, creating a measurable load if the check path is not cheap.

Together, the three columns show a pattern: **the interval sets the speed and charges for the
load, the threshold sets the accuracy and charges for the delay.** Threshold three with interval
two gives detection in six seconds, zero false removals, and 180 check requests a minute.
Threshold one and interval two detect in two seconds but lose 330 seconds of capacity: a
four-second difference costs 330 seconds.

#### The Decision That Stays

In a rented balancer, the provider's items are clear: the distribution algorithm's
implementation, the balancer's capacity and scaling, the connection queue's limits, where the
nodes sit. None of these were measured, because there is no decision left to make about them.

Nine items stay with the renter: the layer, the rule chain and its order, the definition of the
back-end pools, the health check path, the interval, the threshold, the timeout, where the key
sits, session affinity. Eight of them can change at any moment.

The ninth does not. **The layer is fixed the moment the balancer is created.** A transport layer
balancer does not later turn into an application layer one. What has to be done is create a new
balancer, wait for it to be given a new endpoint name, and move every client to that name. This
is this lesson's **item that cannot be taken back**.

The cost sits right in the table: a setup starting with the transport layer brings 1,024
misroutes down to 653 with port split, at a cost of two client items. Closing the remaining 653
means changing layers, which means changing the name the client connects to — the two items
touched for port split get touched again.

#### Summary

- The transport layer sees two fields (address, port) and can apply only the default out of five
  rules; 1,024 of the 1,200 requests fell into the wrong pool. The application layer sees five
  fields, applies all five rules, and misrouting is zero.
- Port split gave the transport layer two more rules and raised correct routing from 176 to 547;
  the cost was two client items changed, and map and counter traffic arriving at the same host
  could not be separated.
- The application layer raised fields examined from 1,200 to 3,075 and the handshake round from
  one to two; it dropped key copies from six to one while raising the termination point from six
  to seven, and the added point is the rented component itself.
- In the health check, raising the threshold from one to three dropped false removals from 78 to
  zero and extended detection time by as many rounds as the threshold; the interval sets speed
  and charges for check load, the threshold sets accuracy and charges for delay.
- At threshold one and interval ten, the broken instance was detected in ten seconds, but 1,650
  seconds of healthy capacity sat outside needlessly; that is the cost of the ten-second gain.
- Eight of the nine items that stay with the renter can change at any moment; the layer cannot.
  Changing the layer means a new balancer and a new endpoint name — this lesson's item that
  cannot be taken back.

#### Next Step

This lesson set up the balancer and connected six instances behind it to traffic. Every
measurement assumed one thing was fixed: the **endpoint** the client connects to.

That endpoint is not fixed. Its address changes with the instance, its name changes when the
balancer is re-created, and every scaling-group addition arrives with a new address.
What the client holds is a single address or name, written separately in the configuration file,
the monitoring target, and the third party's allow list.

The next lesson compares address stability across three layouts: the address going with the
instance, moving as a separate resource, and binding to a name. What is measured: how many
seconds the client takes to notice the change, how many items are updated by hand, and how many
requests reach a stale address. The persistent address's own cost is counted too: its count is
limited, moving it is an operation, and an unused one is billed.

### Persistent Address and Name Integration

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/persistent-address-and-name-integration

Measuring what happens to the address when the instance changes, across three layouts: under the ephemeral address, five items updated by hand raise detection time to 450 seconds and stale requests to 10,800; the persistent address brings this down to 200; name-based resolution sits in between; and the persistent address's own bill — the regional quota, the move operation, the idle address's monthly fee, and its being unrecoverable once released.

The previous lesson set up the balancer and made every measurement by assuming one thing was
fixed: the endpoint the client connects to. That endpoint is not fixed.

The fictional regional measurement network's collectors change regularly. One is re-created
during a maintenance window, one is replaced because it dropped out of the health check, one is
added by the scaling group's decision. On every change the machine itself is renewed — and the
machine's address belongs to the machine, it goes with the machine.

The places that hold the address, though, are not the machine. It is written separately in the
client's configuration file, in the monitoring system's target list, in a security group's source
field, in third-party sensor owners' allow list, and in the runbook. This lesson compares the
address's stability across three layouts and counts the persistent address's own bill.

How a name record's time-to-live works was measured earlier; this lesson does not repeat that
measurement, it takes the time-to-live as an **input**.

- **CN37.** In the fictional measurement network, one period is 3,600 seconds and contains three
  instance changes: maintenance, unhealthiness, scaling. The request rate reaching the collector
  is a constant eight requests/second.
- **CN38.** The items holding the address are five: client configuration, monitoring target,
  security group source, the third party's allow list, the runbook. Updating one item by hand
  fictionally takes 90 seconds, and items are updated in sequence.
- **CN39.** Moving the persistent address to a new instance takes 25 seconds, updating the name
  record takes 90 seconds; both are fictional. The name record's time-to-live is 60 seconds and
  is an **input** — how this value was measured was done earlier, it is not re-measured here.
- **CN40.** Under the name-based layout, the old instance is kept up for the duration of the
  time-to-live; stale resolutions go to the old instance for this reason, not into a void.
- **CN41.** The persistent address quota is five per region. In this model an attached address is
  free and an idle one is one unit per hour; a month is 720 hours. The currency is fictional and
  its name is **unit**. Real providers today bill a public address whether it is attached or not;
  the model draws this line to isolate the extra cost of sitting idle.
- **CN42.** A released address returns to the pool and is given to another tenant in the second
  week. The weekly decay multiplier for stale requests is 0.55; the starting value and the
  multiplier are fictional.

```js
// address/stability.mjs — model: what happens to the address when the instance changes. The
// fictional regional measurement network's collectors change three times. The name record's
// time-to-live was measured earlier; here it is an input, not re-measured.
const EVENT = [["maintenance", 600], ["unhealthiness", 1500], ["scaling", 2900]];
const RATE = 8;                // requests/second, constant
const ITEM = ["client configuration", "monitoring target", "security group source",
  "third party's allow list", "runbook"];
const ITEM_TIME = 90;          // fictional: one item updated by hand takes 90 seconds
const MOVE = 25;                // fictional: moving the persistent address to a new instance
const RECORD = 90;              // fictional: updating the name record
const TTL = 60;                 // input: the name record's time-to-live (measured earlier)
// [name, hand items, move, record, is TTL in play, where stale requests go]
const LAYOUT = [
  ["ephemeral address", ITEM.length, 0, 0, false, "old address / new owner"],
  ["persistent address", 0, MOVE, 0, false, "dropped"],
  ["name-based", 0, 0, RECORD, true, "old instance"],
  ["persistent address + name", 0, MOVE, 0, false, "dropped"],
];
const detect = (row) => row[1] * ITEM_TIME + row[2] + row[3] + (row[4] ? TTL : 0);
const E = [28, 12, 15, 18, 16, 25];
const print = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](E[i])).join("");
console.log(print(["layout", "hand items", "detect(s)", "requests/event",
  `in ${EVENT.length} events`, "where stale requests go"]));
for (const row of LAYOUT) {
  const secs = detect(row);
  console.log(print([row[0], row[1], secs, secs * RATE, secs * RATE * EVENT.length, row[5]]));
}

// --- The persistent address's own bill -------------------------------------------
// Collectors are written by address into third-party sensor owners' allow lists; that is
// why every instance needs its own persistent address. Quota and fee are fictional.
const QUOTA = 5, IDLE_FEE = 1, HOUR = 720;         // unit/hour, fictional monthly hours
const K = [16, 19, 19, 26, 21, 15];
const print2 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](K[i])).join("");
console.log("\n" + print2(["peak instances", "average instances", "addresses needed",
  "instances outside quota", "idle address-hours", "monthly unit"]));
for (const peak of [3, 5, 7, 9]) {
  const average = Math.round(peak * 0.6);
  const assigned = Math.min(peak, QUOTA);
  const idle = Math.max(0, assigned - average);
  console.log(print2([peak, average, peak, Math.max(0, peak - QUOTA), idle * HOUR,
    idle * HOUR * IDLE_FEE]));
}

// --- Released address: the item that cannot be taken back ------------------------
// A released address returns to the pool. Because it was published, it keeps living in clients;
// the decay multiplier is fictional. The address is given to another tenant in the second week.
const START = 4200, DECAY = 0.55, WEEKS = 6, HANDOVER = 2;
const H = [8, 16, 22, 29];
const print3 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](H[i])).join("");
console.log("\n" + print3(["week", "stale requests", "address owner", "gone to a stranger (total)"]));
let total = 0;
for (let w = 1; w <= WEEKS; w += 1) {
  const requests = Math.round(START * DECAY ** (w - 1));
  if (w >= HANDOVER) total += requests;
  console.log(print3([w, requests, w < HANDOVER ? "in pool" : "other tenant",
    w < HANDOVER ? 0 : total]));
}
```

```
layout                        hand items      detect(s)    requests/event     in 3 events  where stale requests go
ephemeral address                      5            450              3600           10800  old address / new owner
persistent address                     0             25               200             600                  dropped
name-based                             0            150              1200            3600             old instance
persistent address + name              0             25               200             600                  dropped

peak instances    average instances   addresses needed   instances outside quota   idle address-hours   monthly unit
3                                 2                  3                         0                  720            720
5                                 3                  5                         0                 1440           1440
7                                 4                  7                         2                  720            720
9                                 5                  9                         4                    0              0

week      stale requests         address owner   gone to a stranger (total)
1                   4200               in pool                            0
2                   2310          other tenant                         2310
3                   1271          other tenant                         3581
4                    699          other tenant                         4280
5                    384          other tenant                         4664
6                    211          other tenant                         4875
```

#### Item Count Sets the Time

The first table's second column is this lesson's core: the number of items updated by hand.

Under the ephemeral address layout this number is five. When the instance changes, the address
changes too, and all five places holding the address must be updated by hand. The third column is
this converted into time: five items, 90 seconds per item, 450 seconds total. This is how long it
takes the client to notice the change.

Detection time is not a matter of skill, it is a direct multiple of the item count. If a sixth
place started holding the address, the time would rise to 540 seconds. In a setup that does not
know how many places write the address, detection time is not known either.

The fourth column converts this into requests: eight requests a second, 3,600 stale requests per
event. Since there are three events in the period, the total is 10,800.

The second row places the persistent address. The address no longer belongs to the instance; it
is a separate resource and is moved to the new instance when the instance changes. The number of
items updated by hand drops from five to zero, because the value at the five places never
changes. Detection time drops to the move operation itself, 25 seconds; stale requests fall to
200 per event, 600 for the period. From 10,800 to 600: **eighteen times**.

The third row measures name-based resolution. The address is ephemeral again, but the five items
hold the name, not the address; the only thing updated on a change is the name record. Items by
hand are zero, but the time is six times longer than the persistent address's: 90 seconds of
record update plus 60 seconds of time-to-live, 150 seconds total. The time-to-live is not
measured here, it is **added in**.

The fourth row combines the two: the name points to the persistent address, only the address is
moved when the instance changes, the record never changes. The time returns to the persistent
address's time, 25 seconds, and the time-to-live drops out of the equation. This is where name
integration does not make the persistent address unnecessary — it works together with it instead.

#### Where the Stale Request Goes

The last column shows that the same numbers carry three very different meanings. The number of
stale requests does not by itself carry a decision; **where those requests land** does.

Under the persistent address layouts, stale requests **are dropped**. At the moment of the move,
the address is not attached to any interface; the client cannot connect, gets an error, and
retries. Six hundred requests were lost, and the client knows it too.

Under the name-based layout, stale requests go to the **old instance**. This is not a side effect
of the layout but the result of a decision: the old instance is kept up for the duration of the
time-to-live. Three thousand six hundred requests get the correct answer. The cost does not show
up in the table but can be counted: 60 seconds × three events = 180 instance-seconds during which
two instances run at once. As the time-to-live grows, both the window and this extra capacity
grow with it.

Under the ephemeral address layout, stale requests go to the **old address**, and that address is
now free. Over the 450-second window, 3,600 requests carry measurement data and identifying
information to an address whose ownership is unknown. Of the three layouts, the worst by count is
also the worst in kind — but these two harms are independent of each other. Dropping stale
requests to zero does not stop them from going to a stranger; for that, **who owns the address**
must not change.

#### The Persistent Address's Bill

The second table measures the persistent address on its own. Because collectors are written by
address in third-party sensor owners' allow lists, every instance needs its own persistent
address.

The third column shows the need, the fourth the **quota**. There are five persistent addresses
per region. When peak instances rise to seven, two instances cannot get a persistent address; at
nine, four cannot. No matter how correctly these instances are configured, they cannot get past
the third party's allow list. The persistent address is a resource with a fixed count, attached
to a group that scales: the group can grow, the address pool cannot.

The fifth and sixth columns count the cost in the opposite direction. The assigned addresses have
to be held for the peak, but the group runs below peak on average. At peak five and average
three, two addresses are attached to no instance for most of the month: 1,440 address-hours,
1,440 units a month. **An unused address is billed; an attached one is not.**

Read together, the two columns show the persistent address's narrow band. Between peak three and
five, the quota is enough, but the idle fee is at its highest point. At peak nine, the idle fee
drops to zero — because all five addresses are always attached — but four instances are left
without an address. The persistent address does not sit well with a group that scales too little
or too much; it works in a narrow range in between.

A third cost sits not as a number in the table but in the first table's second row: **the move is
an operation.** The address does not move to the new instance on its own; a call is made, the
address is detached from the old interface and attached to the new one. The 25-second window and
the 600 dropped requests are that operation's duration. The operation has another face too: the
permission to take an address from one instance and attach it to another is a **management
endpoint**, and whoever holds that permission can pull traffic to their own instance. While the
persistent address fixes the value the client sees, it adds a switch that can change the machine
behind that value.

#### The Released Address

The third table measures this lesson's **item that cannot be taken back**.

When a persistent address is released, it returns to the pool. Release is a single operation and
it is not reversible: there is no such thing as getting the same address back. The address is
given to another tenant in the second week.

The problem is not that the address is gone. The problem is that the address was **published**.
It was written in five places, and not all five of those places are under your control: the third
party's allow list and someone else's runbook do not enter your update round. In the first week,
4,200 requests keep going to the released address. The decay continues but does not end: in the
sixth week there are still 211 requests.

The second column's total is the number that matters. From the second week through the sixth,
4,875 requests went to the address's new owner. Inside those requests is measurement data,
collector identity, and maybe an access token. The new owner did nothing; they rented the address
and the traffic arrived.

Closing this surface is not retroactive. Once the address is released, the only thing that can be
done is update the items, and the items under your control were already updated. The only
decision that closes the surface is **not releasing the address** — that is, continuing to pay
the idle address's 1,440 units a month. The bill and the surface are two faces of the same
decision.

#### The Decision That Stays

In this layer, the items the provider takes over: managing the address pool, redistributing a
released address, the name resolution infrastructure itself, and the size of the quota.

Six items stay with the renter: which layout to use, how many items the address is written in,
the time-to-live value, how long the old instance stays up, how many persistent addresses to
assign, and whether to release the address. Five of them can change at any moment, and all five
were measured with numbers in this lesson.

The sixth does not: **a released address cannot be reclaimed.** Like a balancer's layer, this too
is a decision that locks not at creation but at the moment of release — and release is the
easiest of the items that stay with the renter to carry out: a single call, no warning, the
monthly fee zeroed out. Its cost shows up months later, when you read it in the allow list of a
tenant you never knew held the address.

The surfaces opened are also three, and all three were added in this lesson: the move permission
(a switch that can pull traffic to a different instance), the permission to change the name
record (the client redirects all traffic without noticing anything), and the requests a released
address carries to a stranger. None of the three are visible inside the sentence "keep the
address stable."

#### Summary

- Under the ephemeral address layout, five items are updated by hand, and detection time is a
  direct multiple of that number: 450 seconds, 3,600 stale requests per event, 10,800 across
  three events.
- The persistent address dropped hand-updated items to zero, detection time to 25 seconds, and
  stale requests to 600 — eighteen times. Name-based resolution sits in between: 150 seconds,
  3,600 requests, because the record update has the time-to-live added on top.
- The number of stale requests does not by itself carry a decision: under the persistent address
  they are dropped, under the name-based layout they go to the old instance kept up for the
  time-to-live (180 extra instance-seconds of capacity), under the ephemeral address they go to
  an address that has already been freed.
- The persistent address works in a narrow band: at peak nine instances, four are left outside
  the quota; at peak five, two addresses sit idle and write 1,440 units a month. In the model an
  attached address is free, an idle one is billed.
- A released address cannot be reclaimed, and because it was published it does not die: from the
  second week through the sixth, 4,875 requests went to the address's new owner. The only
  decision that closes the surface is not releasing the address — that is, continuing to pay the
  idle fee.

#### Next Step

The last two lessons set up the endpoint the request finds: the balancer decided which instance
to go to, the address and the name kept that endpoint stable. Every request reaching the
measurement network's dashboard now arrives at the correct machine.

Every request still arrives **at that machine**. The regional map, the counter list, and the
asset files travel the same path for a client far away too, and an egress fee is charged for
every byte leaving the origin server. The same map leaves the same region thousands of times a
day.

A portion of these responses can be kept much closer to the client. The edge cache's mechanics
were measured earlier; the next lesson treats the edge as a **rented service** and counts three
things: how much the hit rate lowers requests reaching the origin, at which hit rate the total
bill turns over when edge egress and origin egress are priced separately, and how many requests
and seconds an invalidation costs. The decisions the edge takes over are also counted item by
item: how many of the cache key, freshness, and compression decisions stay with the renter.

### Content Delivery Network

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/content-delivery-network

Measuring the edge rented as a service: the bill turning over at a 67% hit rate when edge egress and origin egress are priced separately, and the bill still staying high at 60% even though origin load has dropped; prefix invalidation looking free while producing 180,000 warming requests; invalidation closing the 90-second edge window while never touching the 3,600-second client window; and nine of fifteen decision items staying with the renter.

The last two lessons set up the endpoint the request finds and made that endpoint stable. Every
request reaching the measurement network's dashboard now arrives at the correct machine — and
every request arrives **at that machine**.

The regional map, the counter list, and the asset files travel the same path for a client far
away too. An egress fee is charged for every byte leaving the origin server, and the same map
leaves the same region thousands of times a day.

How the edge cache works, how the hit rate forms, and how freshness sets the window were measured
earlier; this lesson does not repeat that mechanics. Here the edge is a **rented service**, and
three things are measured: at which hit rate the bill turns over under two separate egress
prices, how many requests and seconds an invalidation costs, and how many of the decisions handed
to the edge stay with the renter. The hit rate here is not a result but an **axis that is swept**.

- **CN43.** The fictional regional measurement network's monthly traffic is four million requests
  and a 40 KB average response; 160 GB total. When the edge is turned on, every byte going to the
  client leaves through the edge.
- **CN44.** The pricing is fictional, the currency's name is **unit**: origin egress is nine
  units per GB, edge egress is five units per GB, the request fee is 0.4 units per ten thousand
  requests. The one point where the model rests on reality is that edge egress is cheaper than
  origin egress, but not zero.
- **CN45.** There are forty edge nodes, and an object sits on ten nodes on average; the coverage
  is fictional.
- **CN46.** The time for an invalidation to reach the slowest node is fictional: 90 seconds for
  path by path, 150 seconds for prefix. A thousand paths a month are free, beyond that it is
  0.005 units per path.
- **CN47.** In one release, 2,400 asset files change; there are 18,000 objects total under the
  prefix.
- **CN48.** The response sits in the client's own cache for the duration of the freshness period
  — 3,600 seconds, fictionally — and the invalidation never reaches the client.

```js
// edge/distribution.mjs — model: what changes when the edge is rented as a service. The cache
// mechanics and how the hit rate forms were measured earlier; here the hit rate is an axis,
// it is swept. Prices are fictional, the currency's name is "unit".
const REQUESTS_MONTH = 4e6, AVG_KB = 40;
const DATA = (REQUESTS_MONTH * AVG_KB) / 1e6;           // GB
const ORIGIN_EGRESS = 9, EDGE_EGRESS = 5, REQUEST_FEE = 0.4;   // unit/GB, unit/GB, unit/10k
const requestFee = (n) => (n / 1e4) * REQUEST_FEE;
const bill = (hit, hasEdge) => {
  const landing = Math.round(REQUESTS_MONTH * (1 - hit));
  const originGB = DATA * (hasEdge ? 1 - hit : 1);
  return { landing, originGB, origin: originGB * ORIGIN_EGRESS,
    edge: hasEdge ? DATA * EDGE_EGRESS : 0, request: hasEdge ? requestFee(REQUESTS_MONTH) : 0 };
};
const total = (f) => f.origin + f.edge + f.request;
const BASELINE = total(bill(0, false));
const E = [13, 21, 20, 15, 13, 13, 15];
const print = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](E[i])).join("");
console.log(print(["hit rate(%)", "requests to origin", "origin egress(GB)", "origin fee",
  "edge fee", "request fee", "total(unit)"]));
const row = (name, f) => console.log(print([name, f.landing, f.originGB.toFixed(1), f.origin.toFixed(1),
  f.edge.toFixed(1), f.request.toFixed(1), total(f).toFixed(1)]));
row("no edge", bill(0, false));
for (const p of [0, 40, 60, 67, 80, 90, 95]) row(String(p), bill(p / 100, true));
let breakeven = 100;
for (let p = 0; p <= 100; p += 1) if (total(bill(p / 100, true)) <= BASELINE) { breakeven = p; break; }
console.log(`\nwithout the edge, the bill is ${BASELINE.toFixed(1)} units; the hit rate where the edge passes it: ${breakeven}%`);

// --- The invalidation's hidden bill -----------------------------------------------
// In one release, 2,400 asset files change. Under the prefix there are 18,000 objects total.
// Propagation times, coverage, and fees are fictional.
const NODES = 40, COVERAGE = Math.round(NODES / 4), QUOTA = 1000, PATH_FEE = 0.005;
const CHANGED = 2400, PREFIX_OBJECTS = 18000, FRESHNESS = 3600;
// [method, invalidation requests, dropped objects, edge window, client window]
const METHOD = [
  ["path by path", CHANGED, CHANGED, 90, FRESHNESS],
  ["prefix", 1, PREFIX_OBJECTS, 150, FRESHNESS],
  ["versioned path", 0, 0, 0, 0],
];
const Y = [16, 16, 14, 18, 20, 17, 18];
const print2 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](Y[i])).join("");
console.log("\n" + print2(["method", "requests", "fee(unit)", "edge window", "client window",
  "dropped objects", "warming requests"]));
for (const [name, requests, dropped, edgeWindow, clientWindow] of METHOD) {
  const fee = Math.max(0, requests - QUOTA) * PATH_FEE;
  const warming = (name === "versioned path" ? CHANGED : dropped) * COVERAGE;
  console.log(print2([name, requests, fee.toFixed(2), edgeWindow, clientWindow, dropped, warming]));
}

// --- The decision handed off: counting items --------------------------------------
// [group, item, whose, fixed at creation?]
const DECISION = [
  ["key", "path", "you", false], ["key", "query parameters", "you", false],
  ["key", "selected headers", "you", false], ["key", "cookie", "you", false],
  ["freshness", "freshness duration", "you", false], ["freshness", "stale object on error", "you", false],
  ["freshness", "edge's freshness ceiling", "provider", false],
  ["compression", "on or off", "you", false], ["compression", "algorithm and level", "provider", false],
  ["distribution", "origin definition", "you", false], ["distribution", "distribution name", "you", true],
  ["distribution", "node count and placement", "provider", false],
  ["distribution", "node the client lands on", "provider", false],
  ["distribution", "object evicted when memory fills", "provider", false],
  ["distribution", "propagation time", "provider", false],
];
const G = [14, 10, 9, 15, 20];
const print3 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](G[i])).join("");
console.log("\n" + print3(["group", "item", "you", "provider", "fixed at creation"]));
const groups = [...new Set(DECISION.map((k) => k[0]))];
const count = (l) => [l.length, l.filter((k) => k[2] === "you").length,
  l.filter((k) => k[2] === "provider").length, l.filter((k) => k[3]).length];
for (const g of groups) console.log(print3([g, ...count(DECISION.filter((k) => k[0] === g))]));
console.log(print3(["total", ...count(DECISION)]));
```

```
hit rate(%)     requests to origin   origin egress(GB)     origin fee     edge fee  request fee    total(unit)
no edge                    4000000               160.0         1440.0          0.0          0.0         1440.0
0                          4000000               160.0         1440.0        800.0        160.0         2400.0
40                         2400000                96.0          864.0        800.0        160.0         1824.0
60                         1600000                64.0          576.0        800.0        160.0         1536.0
67                         1320000                52.8          475.2        800.0        160.0         1435.2
80                          800000                32.0          288.0        800.0        160.0         1248.0
90                          400000                16.0          144.0        800.0        160.0         1104.0
95                          200000                 8.0           72.0        800.0        160.0         1032.0

without the edge, the bill is 1440.0 units; the hit rate where the edge passes it: 67%

method                  requests     fee(unit)       edge window       client window  dropped objects  warming requests
path by path                2400          7.00                90                3600             2400             24000
prefix                         1          0.00               150                3600            18000            180000
versioned path                 0          0.00                 0                   0                0             24000

group               item      you       provider   fixed at creation
key                    4        4              0                   0
freshness              3        2              1                   0
compression            2        1              1                   0
distribution           6        2              4                   1
total                 15        9              6                   1
```

#### Two Different Thresholds

The first table's first row is the baseline: no edge, all four million requests land on the
origin, 160 GB leaves the origin, the bill is 1,440 units.

The second row turns the edge on and holds the hit rate at zero. The bill rises to 2,400 — a 67%
increase. The reason is plain: every byte going to the client now leaves through the edge, and
edge egress is not free (800 units), plus a request fee is charged on top (160 units). Origin
egress did not drop at all, because the hit rate is zero. Turning the edge on is not by itself a
saving; it is **adding two bill items**.

In the rows below, the only item that changes is origin egress. The edge fee stays fixed at 800
units, the request fee at 160 units — these two items are not affected by the hit rate at all.
The total bill's curve is therefore different from the origin curve: there is a fixed base of 960
units, and origin savings stack on top of that base.

The **breakeven point** was computed at the end of the run: 67%. At this hit rate the total is
1,435.2 units, just below the edgeless layout's 1,440 units.

The real reading is in the 60% row. At a 60% hit rate, requests landing on the origin have
dropped from four million to 1.6 million; the origin server's load has dropped 60%, its scaling
has eased, its instance count can be lowered. The bill, though, is 1,536 units — still higher
than the edgeless layout. **Lowering origin load and lowering the bill are two different
thresholds**, and in this model the second one arrives later.

What determines the breakeven point is the ratio of the two prices. Because edge egress is five
ninths of origin egress, the share that can be saved is only a portion of the origin item. If the
two prices were equal, the breakeven point would never arrive: the fixed 800 plus 160 units would
never fall below the 1,440 units that can at most be won back on the origin side. The number the
decision rests on is not "the edge is cheap" but **how many times cheaper**.

At 90% the bill drops to 1,104 units, at 95% to 1,032 units. Against the baseline layout, the
best saving is about 28%, and in exchange for it, requests landing on the origin have dropped
twentyfold. What the edge really delivers is **origin load**, more than money; money is a
secondary gain that arrives later.

#### The Invalidation's Hidden Bill

The second table measures a release: 2,400 asset files changed, and the stale copies on the edge
need to drop.

The path-by-path method sends 2,400 invalidation requests. A thousand of them are within the free
quota, the remaining 1,400 cost 0.005 units per path: seven units total. Next to the monthly bill
of 1,440 units, this is an invisible number. The window is 90 seconds, dropped objects 2,400.

The prefix method sends a single request and its fee is zero. Reading the two columns side by
side, the prefix looks superior in every respect: one request instead of 2,400, zero instead of
seven units.

The last column breaks this appearance. **All 18,000 objects under the prefix drop**, not just
the 2,400 that actually changed. Since each dropped object sits on ten nodes on average, 180,000
requests land on the origin during warming. Under the path-by-path method this number is 24,000: a
sevenfold-and-a-half difference.

The size of this number becomes visible when compared against the first table. In a month with a
90% hit rate, total requests landing on the origin were 400,000. A single prefix invalidation
produces **45%** of that month's origin load, compressed into a few minutes. The fee is zero, but
the bill is not seven units; it is written on the origin side, in the instances the scaling group
opens.

The prefix also propagates more slowly: 150 seconds against 90. The method that looks cheap is
both wider and slower.

The third row removes invalidation altogether. Under the versioned path, a changed file is
published under a new name; the old name is never invalidated, the new name is not yet on the
edge at all. Invalidation requests zero, fee zero, window zero, dropped objects zero. Warming
requests are 24,000 — only for the 2,400 files that actually changed. The cost does not show up in
the table: the old names keep sitting on the edge and in storage, and they have to be cleaned up
somewhere.

#### Where Invalidation Cannot Reach

The fourth and fifth columns hold this lesson's quietest number.

The edge window in the first method is 90 seconds: after 90 seconds, the stale copy is gone from
all forty of the forty nodes. The client window, in the same row, is 3,600 seconds. A fortyfold
difference.

The reason fits in one sentence: invalidation is a message sent to the edge, and there is no
message that goes to the client. A response published with a one-hour freshness sits in the
client's own cache for an hour, and for that whole time the client makes no request at all. The 90
seconds invalidation closes is one fortieth of the real window.

This is the course's **item that cannot be taken back**: the published response. The copy on the
edge is deleted with a call, the copy on the client cannot be deleted. If a wrong price, a wrong
regional map, or a response meant for one person went out with a one-hour freshness, that response
is in circulation for an hour, and there is no switch that stops it.

The third row shows the one fix: the client window is zero, because under the versioned path the
response's name changes and the client never asks for the old name it holds. But this too is not
retroactive — a response distributed under yesterday's name still sits in the client today. The
versioned path resolves everything after this point, not before it.

A short freshness is the second fix that comes to mind, and it ties back to the first table:
shortening the freshness narrows the client window, but it also lowers the edge hit rate, and if
the hit rate drops below 67% the bill rises above the edgeless layout. Three numbers are tied at
one knot: **freshness, hit rate, bill**.

#### What Was Handed to the Edge

The third table applies this course's question to this lesson. Fifteen decision items were
counted: **nine with you**, six with the provider, one fixed at creation.

All four of the key group are yours. What goes into the path, the query parameters, the selected
headers, and the cookie is your configuration line even in a rented service. That a personalized
response in a shared cache can go to someone else was measured earlier; renting the edge as a
service does not hand off that decision, it only raises the number of nodes the mistake can spread
across to forty.

In freshness, two of three items are yours. The third — the edge's own freshness ceiling — is
not: the duration you write can hit a cap, and that cap is not your decision.

In compression, one of two items is yours. You decide whether compression is on; the provider
decides which algorithm at which level. This second item ties directly back to the first table:
the compression level sets the response's byte size, the byte size sets the 160 GB total, and
therefore both the edge and origin egress fees. The bill's multiplier sits in the provider's
choice.

Only two of the distribution group's six items are yours. Node count and placement, which node
the client lands on, which object gets evicted when memory fills, and how long propagation takes —
all four directly affect the numbers this lesson measured, and all four are off the table for you.
The eviction policy in particular matters: the 67% breakeven point is your calculation, but
whether that point is actually reached is partly decided by the provider's memory management.

The one item fixed at creation is the distribution name. A distribution's name does not change
afterward; changing it means creating a new distribution and rebuilding the name integration the
previous lesson measured.

#### Summary

- Turning on the edge by itself raised the bill from 1,440 to 2,400 units: edge egress and the
  request fee are independent of the hit rate and add a fixed base of 960 units.
- The bill turns over at a 67% hit rate. At 60%, requests landing on the origin had dropped from
  four million to 1.6 million, while the bill was still above the edgeless layout: lowering
  origin load and lowering the bill are two different thresholds, and the second one arrives
  later.
- Prefix invalidation sends one request at zero fee but drops 18,000 objects, bringing 180,000
  warming requests down onto the origin — 45% of a 90%-hit-rate month's origin load. Path by path
  costs seven units and produces 24,000 requests.
- Invalidation closes the 90-second edge window and never touches the 3,600-second client window.
  A published response cannot be recalled: this is the course's item that cannot be taken back,
  and the versioned path resolves only what comes after it.
- Nine of fifteen decision items stayed with the renter: all four of the key, two of freshness,
  one of compression, two of distribution. The compression level sits with the provider, and it
  sets the bill's multiplier.

#### Next Step

With this lesson, the path of the request coming from outside is complete: the address space was
split, traffic was routed, filtered, balanced, its address was made stable, and it was moved to
the edge. Every step of the path serves a client arriving from the **public network**.

The measurement network's own components also send requests to each other. The collector writes
to the data warehouse, the dashboard calls the billing service, and there is also a shared
validation service sitting in another account. None of this traffic is client traffic, but today
it flows through the same path: with public addresses, over the public network, using the full
surface of the edge and the balancer. The egress fee is charged to the same column too.

Two networks can connect to each other without ever going out to the public network. The next
lesson measures this: how many items the opened surface differs by between peering two networks
directly and opening a single service with a private link, what happens when address spaces
overlap, and which part of this decision stays fixed once it is set up.

### Private Connectivity and Peering

Source: https://academia.sh/en/courses/compute-and-networking-in-the-cloud/private-connectivity-and-peering

The three ways two networks connect to each other are counted on the same workload: peering raises reachable items from one to 384 and hands off no decision, private link keeps items at one but gives four of six items to the provider; in a seven-network address plan, four of twenty-one pairs cannot be peered because of overlap, and the fix is re-addressing three networks; because peering is not transitive, the sixty-six peerings twelve networks need call for 660 routing lines.

The previous lesson moved content to the edge and completed the path of the request coming from
outside. This whole path faces a single direction: toward a client arriving from the **public
network**.

The fictional regional measurement network's own components also send requests to each other. The
collector network writes to the data warehouse, the dashboard calls a shared validation service
sitting in another account. None of this traffic is client traffic — but today it flows through
the same path as client traffic: with public addresses, over the public network, using the full
surface of the gateway and the edge.

Two networks can connect to each other without ever going out to the public network. **Peering**
connects two networks directly, **private link** opens only a single service through a single
endpoint. This lesson compares all three on the same workload and asks the course's question one
last time.

- **CN49.** The fictional measurement network's components sit in separate networks; the period's
  flow volume is 2,400. The counterpart network has 96 instances, and each instance listens on
  four ports. Our own side has five route tables.
- **CN50.** The latency values are fictional model constants: 14 ms for the public endpoint, two
  for peering, three for private link. The model does not measure time, it writes the constants.
- **CN51.** The prices are fictional, the currency's name is **unit**: public egress is two units
  per hundred volume, the inter-network private path is one unit, the private link endpoint's
  period rent is 25 units. The central transit layer is a fixed 120 units a period plus 30 units
  per connection.
- **CN52.** Seven networks' address blocks are fictional and all in private ranges; the address
  arithmetic is real. The 1,604-unit cost of re-addressing comes in as an **input** from this
  topic's first lesson.
- **CN53.** Peering is not transitive: a peering only reaches its own two ends. The network to
  re-address is chosen **greedily** — the most-overlapping network moves first.

```js
// connectivity/inter-network.mjs — model: the ways two networks connect to each other. Fictional
// regional measurement network; no real network is set up, the address arithmetic is real. The unit's name is "unit".
const PEER_INSTANCES = 96, PORTS = 4, TABLES = 5, VOLUME = 2400;
// [path, reachable items, latency(ms), unit per 100 volume, period-fixed,
//  routing lines, filter rule, external record]
const PATH = [
  ["public endpoint", 1, 14, 2.0, 0, 0, 2, 1],
  ["peering", PEER_INSTANCES * PORTS, 2, 1.0, 0, TABLES, 2, 0],
  ["private link", 1, 3, 1.0, 25, 0, 1, 0],
];
// [decision item, public endpoint, peering, private link]
const DECISION = [
  ["which range the route is written to", "you", "you", "provider"],
  ["which service on the other side is reachable", "other side", "you", "provider"],
  ["which direction traffic can originate from", "you", "you", "provider"],
  ["the exit address being registered on the other side", "you", "n/a", "n/a"],
  ["name resolution", "you", "you", "provider"],
  ["filter rules", "you", "you", "you"],
  ["address spaces not overlapping", "n/a", "you", "n/a"],
  ["the subnet the endpoint sits in", "n/a", "n/a", "you"],
];
const kept = (i) => DECISION.filter((k) => k[i + 1] === "you").length;
const handedOff = (i) => DECISION.filter((k) => ["provider", "other side"].includes(k[i + 1])).length;
const E = [18, 17, 13, 15, 13, 8, 13];
const print = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](E[i])).join("");
console.log(print(["path", "reachable items", "latency(ms)", "period unit", "rules+lines",
  "kept", "handed off"]));
PATH.forEach(([name, item, ms, rate, fixed, lines, filters, records], i) =>
  console.log(print([name, item, ms, ((VOLUME / 100) * rate + fixed).toFixed(1),
    lines + filters + records, kept(i), handedOff(i)])));

// --- Address space overlap: peering's item that cannot be taken back -------------
// Seven networks' blocks are fictional, all in private ranges; the address arithmetic is real.
const toInt = (ip) => ip.split(".").reduce((a, o) => a * 256 + Number(o), 0);
const block = (cidr) => { const [ip, e] = cidr.split("/"); const b = 2 ** (32 - Number(e));
  const start = Math.floor(toInt(ip) / b) * b; return { start, end: start + b - 1 }; };
const overlaps = (a, b) => a.start <= b.end && b.start <= a.end;
const NETWORK = [["measurement-collector", "10.42.0.0/20"], ["data-warehouse", "10.42.16.0/20"],
  ["shared-validation", "10.0.0.0/16"], ["field-unit", "10.0.0.0/16"],
  ["partner-a", "172.31.0.0/16"], ["partner-b", "172.31.0.0/16"],
  ["archive", "10.42.0.0/16"]].map(([name, c]) => ({ name, c, ...block(c) }));
const PAIR = NETWORK.flatMap((a, i) => NETWORK.slice(i + 1).map((b) => [a, b]));
const OVERLAPPING = PAIR.filter(([a, b]) => overlaps(a, b));
const RELOCATE = [];                                  // greedy: the most-overlapping network moves first
for (let remaining = [...OVERLAPPING]; remaining.length; ) {
  const counts = new Map();
  for (const [a, b] of remaining) for (const x of [a, b]) counts.set(x, (counts.get(x) ?? 0) + 1);
  const most = [...counts.entries()].sort((p, q) => q[1] - p[1] || NETWORK.indexOf(p[0]) - NETWORK.indexOf(q[0]))[0][0];
  RELOCATE.push(most);
  remaining = remaining.filter(([a, b]) => a !== most && b !== most);
}
const RELOCATE_COST = 1604;                           // input: from this topic's first lesson
const A = [24, 17, 19, 13, 20];
const print2 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](A[i])).join("");
console.log("\n" + print2(["network", "block", "peerable networks", "overlapping", "re-addressed"]));
for (const a of NETWORK) {
  const c = NETWORK.filter((b) => b !== a && overlaps(a, b)).length;
  console.log(print2([a.name, a.c, NETWORK.length - 1 - c, c, RELOCATE.includes(a) ? "yes" : "no"]));
}
console.log(`\npairs ${PAIR.length}; peering ${PAIR.length - OVERLAPPING.length}/${PAIR.length}, ` +
  `private link ${PAIR.length}/${PAIR.length} (address space is invisible)`);
console.log(`networks to re-address: ${RELOCATE.length} (${RELOCATE.map((x) => x.name).join(", ")}) ` +
  `= ${RELOCATE.length * RELOCATE_COST} units`);

// --- No transitivity: peering count for N networks --------------------------------
// A peering only reaches its own two ends; the reached pairs equal the direct edge count.
const TRANSIT_FIXED = 120, TRANSIT_LINK = 30;         // fictional unit
const G = [10, 14, 15, 12, 17, 15, 16, 12];
const print3 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](G[i])).join("");
console.log("\n" + print3(["networks", "pairs needed", "star reached", "full mesh", "full mesh lines",
  "transit links", "transit lines", "unit/pair"]));
for (const n of [3, 4, 6, 8, 12]) {
  const pairs = (n * (n - 1)) / 2, fee = TRANSIT_FIXED + TRANSIT_LINK * n;
  console.log(print3([n, pairs, n - 1, pairs, n * (n - 1) * TABLES, n, (TABLES + 1) * n,
    (fee / pairs).toFixed(1)]));
}
```

```
path                reachable items  latency(ms)    period unit  rules+lines    kept   handed off
public endpoint                   1           14           48.0            3       5            1
peering                         384            2           24.0            7       6            0
private link                      1            3           49.0            1       2            4

network                             block  peerable networks  overlapping        re-addressed
measurement-collector        10.42.0.0/20                  5            1                  no
data-warehouse              10.42.16.0/20                  5            1                  no
shared-validation             10.0.0.0/16                  5            1                 yes
field-unit                    10.0.0.0/16                  5            1                  no
partner-a                   172.31.0.0/16                  5            1                 yes
partner-b                   172.31.0.0/16                  5            1                  no
archive                      10.42.0.0/16                  4            2                 yes

pairs 21; peering 17/21, private link 21/21 (address space is invisible)
networks to re-address: 3 (archive, shared-validation, partner-a) = 4812 units

networks    pairs needed   star reached   full mesh  full mesh lines  transit links   transit lines   unit/pair
3                      3              2           3               30              3              18        70.0
4                      6              3           6               60              4              24        40.0
6                     15              5          15              150              6              36        20.0
8                     28              7          28              280              8              48        12.9
12                    66             11          66              660             12              72         7.3
```

#### The Bill for Three Paths

The first table's second column is this lesson's core measure: how many items become reachable
once the connection is set up.

For the public endpoint this number is one — the single endpoint the other side publishes. For
private link it is also one. For peering it is **384**: 96 instances' four ports each. Peering
does not connect a service, it connects a **network**; which service is reachable is now the job
of the filter rules, not the routing.

The bill column is unexpected. Peering is the cheapest — **24 units** — because an inter-network
fee is charged instead of the public egress fee. The public endpoint is 48 units. Private link is
the most expensive at **49 units**: its volume fee is the same as peering's, plus the endpoint's
rent stacks on top. Read side by side, the three numbers show a pattern: **the cheapest path is
the path that opens the widest surface.** The 25-unit gap between them is the price of closing off
383 items.

The rule count points the same way. Peering writes seven items: one line in each of the five
tables, plus two filter rules. All five lines are the same line; writing four and skipping one
silently drops one zone's traffic onto a different path. Private link writes a **single item**.

#### The Decision Handed Off

The last two columns are this course's own measure. For the public endpoint, five of eight
decision items stay with the renter, one is the other side's; no item is handed off to a provider.
For peering, **six items stay, zero are handed off**. Peering does not hand off a single decision
— which range the route is written to, traffic's direction, name resolution, filters, and address
spaces not overlapping are all the joint work of the two sides.

Private link is the reverse: **two items stay, four sit with the provider**. No route is written —
the endpoint takes an address in one of your subnets, and the local route already covers it. Which
service is reachable is not chosen; the endpoint is bound to a single service. Direction is not
chosen either: the connection is one-way, the other side cannot connect back to you.

The two items that stay are the filter rules and which subnet the endpoint sits in. The second is
this lesson's small trap: the endpoint sits in a subnet, consumes one of its addresses, and the
subnet boundary is a previous lesson's **item that cannot be taken back**. The handoff is not a
loss but a trade: four items went, and in exchange 383 reachable items and five routing lines went
with them.

#### Address Space Overlap

The second table measures peering's **item that cannot be taken back**, and it ties back to this
course's first lesson.

For two networks to be peerable, their address spaces must not overlap. If they do, the same
address corresponds to two different machines, and no routing decision can be made. In the
seven-network fictional plan there are 21 pairs, and **four cannot be peered**.

The reason reads right off the table. `shared-validation` and `field-unit` wrote the same block;
so did `partner-a` and `partner-b`. These are not mistakes but a **default**: two separate teams
left the same starting value in place, unaware of each other. The fourth overlap is different in
kind: `archive` wrote its block one bit wider and swallowed two networks from its own organization
inside it — on its own it costs two pairs.

The fix is not in routing. The peering is rejected, and no rule can force it through; the only
thing that can be done is **re-address one side**. The model produces three networks to close four
overlaps: **4,812 fictional units**. Which side gets moved is a separate decision; moving a shared
validation service is a different job from moving a field unit.

The last line gives the real comparison. Private link connects **21 of 21 pairs**. The address
spaces still overlap, but it no longer matters: the endpoint takes its own address in your subnet,
and the counterpart network's address space is never visible to you at all. This is the real thing
received in exchange for the four-item handoff.

#### The Absence of Transitivity

The third table counts peering's second limit. A peering only reaches its own two ends: if A is
peered with B and B is peered with C, A and C cannot talk. The network in the middle is not a
**path**.

The star column is the result. Connecting six networks to a central network needs five peerings
and reaches **five pairs** — while what is needed is 15 pairs. Once the setup is done, every
network is connected to something, and two thirds of the pairs cannot find each other.

A full mesh solves this, and it grows **quadratically**. Six networks need 15 peerings and 150
routing lines; twelve networks need 66 peerings and **660 lines**. The line count is multiplied by
five tables, because every network has its own tables.

A central transit layer makes the count **linear**: 12 connections for twelve networks, 72 lines,
and the same 66 pairs. Its cost is in the last column, and it drops as scale grows: 70 units per
pair at three networks, 7.3 units at twelve.

#### Summary

- Peering raises reachable items from one to 384 and is the cheapest path at 24 units; private
  link keeps items at one and is the most expensive at 49 units. The 25-unit gap between them is
  the price of closing off 383 items.
- Peering hands off no decision (six items stay, zero handed off); private link gives four items
  to the provider and leaves two — the filter rules and the subnet the endpoint sits in.
- In the seven-network plan, four of 21 pairs cannot be peered because of address overlap; the
  fix is re-addressing three networks, 4,812 fictional units. Private link connects 21 of 21
  pairs in the same plan.
- Because peering is not transitive, the star layout reaches only five of six networks' pairs; at
  twelve networks, a full mesh needs 660 routing lines while a central transit layer connects the
  same 66 pairs with 72 lines.

#### Course Wrap-Up

| Lesson | Decision That Stays | Item That Cannot Be Taken Back | Surface Opened or Its Cost |
|---|---|---|---|
| Virtual Machine Instances | 6/11 items, family and size | 3 items: architecture, subnet, encryption | 5.3 hours of fleet renewal |
| Machine Images | 6/9 items, embedding level | 3 items: published image, embedded secret | 1,161 minutes of bake load |
| Persistent and Interruptible Capacity | 5/9 items, job type match | 3 items: unsaved work | 2.40× duration |
| Launch Templates | 5/8 items, field defaults | 6 fields: image, subnet, script | 11/12 fields overridden |
| Auto Scaling Groups | 6/9 items, threshold and cooldown | 3 items: unhealthy stamp | 276 launches a day |
| Managed Container Services | 3/15 items, task scaling | 3 items: task definition version | 234 hours of exit |
| Virtual Private Cloud | 8/12 items, prefix length | 2 items: block and placement | 1,604 units to rebuild |
| Subnets | 5 items, slice size and zone | 1 item: subnet boundary | 6 components, 5 tables |
| Route Tables | 3 items, rule width | 490 volume units, swallowed measurement | 266 units of wrong egress |
| Internet and Address Translation Gateways | 4 items, gateway count and placement | 1,848 external log entries | 101% connection fill |
| Security Groups and Network Access Lists | 4 items, mechanism and order | double-recorded measurement | 9 rules, 2 escaped flows |
| Load Balancers | 9 items, layer and threshold | 1 item: the balancer's layer | 7 termination points |
| Persistent Address and Name Integration | 6 items, address layout | released address | 4,875 requests to a stranger |
| Content Delivery Network | 9/15 items, cache key | published response | 3,600 seconds on the client |
| Private Connectivity and Peering | 6 items under peering, 2 under private link | address space overlap | 384 reachable items |

The second column writes this course's rule: **even in a rented layer, a portion of the decisions
stays with you; knowing which of them cannot be undone determines more than the rental decision
itself.** Even in the lesson with the highest handoff rate, three items stayed with the renter, and
three items that cannot be undone stood alongside them.

The third column gathers into four clusters. **Address space and its boundary** in four lessons:
the block itself, the subnet boundary, the subnet an instance lands on, and the overlap that
blocks peering. **A definition fixed at creation** in four lessons: the published image, the
startup script, the task definition version, the balancer's layer. **A trace that has escaped
outward** in three lessons: the gateway's external log entries, the released address, the
published response — all three living somewhere outside your control. **Work lost or repeated** in
four lessons: the interrupted instance's unsaved work, what the terminated instance was holding,
the swallowed measurement, the double record.

None of the four clusters is a line on a bill. In none of the fifteen lessons did the item that
cannot be undone produce a warning; every one of them was written silently on setup day, and its
cost was measured periods later.

The machine was rented, scaled, and partly handed off; the network was split, routed, filtered,
balanced, and moved to the edge. What runs on top of them, though — the **data** — still has not
settled anywhere; measurement records and nightly digests passed through this course only as
traffic. **Storage and Data in the Cloud** takes up that data and asks the same question: which
decision is handed off when data is rented, and which item, once written, never changes again?

