---
title: 'Quality Attributes and Governance'
source: 'https://academia.sh/en/courses/architecture-governance'
language: en
updated: '2026-08-23T07:01:04+00:00'
license: 'CC BY-SA 4.0'
lessons: 10
outcomes:
    - 'Ties quality attributes to architectural decisions'
    - 'Tests the architecture continuously with fitness functions'
    - 'Embeds security and compliance requirements into the architecture'
---

# Quality Attributes and Governance

Writing down which attribute a decision protects and auditing that the protection holds: a quality attribute tree covering performance, availability, security, maintainability, and portability, the tensions where one attribute suppresses another, security architecture with identity, secret management, and trust boundaries, choosing the scale unit, maintainability metrics that watch the cost of change, the centralized, federated, and advisory governance models, fitness functions that check architectural rules automatically, testing structural rules at build time, evolutionary architecture that stands up to change, and the effect of regulatory constraints on architecture.

## Quality Attributes

Writing down which attribute a decision protects: gathering performance, availability, security, maintainability, and portability into a tree, measuring the tensions where raising one attribute suppresses another, building a security architecture with identity, secret management, and trust boundaries, choosing the scale unit, and maintainability metrics that watch the cost of change.

### The Quality Attribute Tree

Source: https://academia.sh/en/courses/architecture-governance/quality-attribute-tree

Breaking quality attributes into sub-attributes and leaves: how many of thirty leaves tie to a number, how many of those turn into an executable check, how many of a known set of violations a check catches versus misses, and the person-hour cost of what stands in for the leaves that cannot be checked.

The previous course built edges, separated ownership, drew the line between what gets bought
and what gets built in-house. Decisions were written down with a rationale; laid side by side, the rationales show a shared
gap: none said which **attribute** it protected. What does the decision "the catalog is bought in" defend —
development time, maintainability, portability? If the answer is not written down, the decision
cannot be argued about later — the argument has no scale.

The definition of a quality attribute and the fields of a quality attribute scenario were built
in the Architectural Decisions and Documentation course, and testability measured there; not repeated here. The question here sits one step back: which attributes are on hand, which sub-attributes
they split into, whether every leaf ties to a number. The
course's own measure follows from this — whether an attribute turns into an
**executable check**, and if not, what stands in its place.

#### Building the Tree

The block below **models** the quality attribute tree of the fictional regional library
network: five root attributes, each splitting into sub-attributes, and those into leaves. The
leaf is the only place in the tree that carries a measure; the root and the sub-attribute are
only catalog headings.

```js
// tree.mjs — models the quality attribute tree and counts how measurable each leaf is
import { mkdirSync, writeFileSync } from "node:fs";

// QA1: the regional library network is fiction (branches, a bought-in catalog, in-house loan
// and billing services, a separate membership system, the municipal identity service). The tree
// is a model: [code, root/sub, leaf, observation, unit, threshold, direction, source]; source
// "auto" = read from a log, "manual" = needs a measurement session, "-" = not tied to a number.
const DEFINITION = [
  ["B1", "performance/response", "catalog search latency", "search_ms", "ms", 400, "low", "auto"],
  ["B2", "performance/response", "loan transaction latency", "loan_ms", "ms", 600, "low", "auto"],
  ["B3", "performance/response", "report screen load", "report_s", "s", 8, "low", "auto"],
  ["B8", "performance/response", "what slowness feels like to the member", "", "", null, "", "-"],
  ["B4", "performance/volume", "hourly loan transactions", "loan_hour", "txn", 1200, "high", "auto"],
  ["B5", "performance/volume", "bulk catalog upload", "upload_min", "min", 90, "low", "auto"],
  ["B6", "performance/resource", "edge unit memory", "memory_mb", "MB", 512, "low", "auto"],
  ["B7", "performance/resource", "connection pool saturation", "pool", "%", 80, "low", "auto"],
  ["E1", "availability/outage", "monthly central outage", "outage_min", "min", 45, "low", "auto"],
  ["E2", "availability/outage", "loan continuing while offline", "offline_min", "min", 120, "high", "manual"],
  ["E3", "availability/recovery", "failover time", "failover_s", "s", 60, "low", "manual"],
  ["E4", "availability/recovery", "data loss window", "loss_min", "min", 5, "low", "auto"],
  ["E5", "availability/spread", "branches affected by an outage", "affected", "branch", 1, "low", "manual"],
  ["E6", "availability/spread", "information given to the member during an outage", "", "", null, "", "-"],
  ["G1", "security/identity", "unauthenticated endpoint", "open_endpoint", "endpoint", 0, "low", "auto"],
  ["G2", "security/identity", "staff action without a role", "no_role", "txn", 0, "low", "auto"],
  ["G3", "security/confidentiality", "plaintext secret value in storage", "open_secret", "value", 0, "low", "auto"],
  ["G4", "security/confidentiality", "history crossing outside its boundary", "out_of_bounds", "field", 0, "low", "auto"],
  ["G5", "security/traceability", "admin action without a record", "unlogged", "txn", 0, "low", "auto"],
  ["G6", "security/traceability", "whether the trust boundary is in the right place", "", "", null, "", "-"],
  ["S1", "maintainability/change", "files touched by a fee change", "file", "file", 6, "low", "auto"],
  ["S2", "maintainability/change", "manual steps to add a new branch", "step", "step", 3, "low", "manual"],
  ["S3", "maintainability/understandability", "module dependency depth", "depth", "level", 5, "low", "auto"],
  ["S4", "maintainability/understandability", "whether the code is readable", "", "", null, "", "-"],
  ["S5", "maintainability/testability", "loan rule coverage", "coverage", "%", 70, "high", "auto"],
  ["S6", "maintainability/testability", "test suite run time", "test_min", "min", 12, "low", "auto"],
  ["T1", "portability/installation", "edge unit install time", "install_min", "min", 45, "low", "manual"],
  ["T2", "portability/installation", "environment-specific call", "env_call", "call", 0, "low", "auto"],
  ["T3", "portability/data", "field lost in transfer", "lost", "field", 0, "low", "auto"],
  ["T4", "portability/data", "membership data handoff", "", "", null, "", "-"],
];

const FIELD = ["code", "branch", "name", "observation", "unit", "threshold", "direction", "source"];
const LEAF = DEFINITION.map((t) => Object.fromEntries(FIELD.map((a, i) => [a, t[i]])));

// Measurability has two steps: does the leaf tie to a number (does it have a unit and a
// threshold), does that number turn into an executable check (is its observation read from a log).
const numbered = (y) => y.threshold !== null;
const checkable = (y) => numbered(y) && y.source === "auto";

mkdirSync("tree", { recursive: true });
writeFileSync("tree/tree.mjs", `export const LEAF = ${JSON.stringify(LEAF)};\n`);

const root = (y) => y.branch.split("/")[0];
const count = (l, f) => String(l.filter(f).length).padStart(9);
console.log(`${"root attribute".padEnd(20)}${"sub".padStart(4)}${"leaf".padStart(8)}` +
  `${"check".padStart(9)}${"manual".padStart(9)}${"unnumbered".padStart(11)}`);
for (const k of [...new Set(LEAF.map(root))]) {
  const y = LEAF.filter((n) => root(n) === k);
  console.log(`${k.padEnd(20)}${String(new Set(y.map((n) => n.branch)).size).padStart(4)}` +
    `${String(y.length).padStart(8)}${count(y, checkable)}` +
    `${count(y, (n) => n.source === "manual")}${count(y, (n) => !numbered(n))}`);
}
const s = LEAF.filter(numbered), o = LEAF.filter(checkable);
console.log(`total ${LEAF.length} leaves: ${s.length} tie to a number, ` +
  `${LEAF.length - s.length} do not; ${o.length} turn into an executable check, ` +
  `${s.length - o.length} need a manual measurement`);
const list = (f) => LEAF.filter(f).map((y) => `${y.code} ${y.name}`).join("; ");
console.log(`\ncannot tie to a number: ${list((y) => !numbered(y))}`);
console.log(`has a threshold but no log: ${list((y) => numbered(y) && y.source === "manual")}`);
```

```
root attribute       sub    leaf    check   manual unnumbered
performance            3       8        7        0        1
availability           3       6        2        3        1
security               3       6        5        0        1
maintainability        3       6        4        1        1
portability            2       4        2        1        1
total 30 leaves: 25 tie to a number, 5 do not; 20 turn into an executable check, 5 need a manual measurement

cannot tie to a number: B8 what slowness feels like to the member; E6 information given to the member during an outage; G6 whether the trust boundary is in the right place; S4 whether the code is readable; T4 membership data handoff
has a threshold but no log: E2 loan continuing while offline; E3 failover time; E5 branches affected by an outage; S2 manual steps to add a new branch; T1 edge unit install time
```

Twenty-five of the thirty leaves tie to a number, five do not. The real distinction sits at the
second step: only 20 of those 25 turn into an executable check. The five in between have both a
threshold and a unit written down — what is missing is the **observation**. Whether failover
stays under 60 seconds cannot be learned without running a drill. Writing a threshold and tying a
measurement to a log are separate work; unpaid, the second leaves the attribute looking
measured but unchecked.

The distribution is not even root by root. Five of security's six leaves turn into a check,
since most are a count: how many endpoints lack authentication, how many plaintext
secret values sit in storage. Only two of availability's six leaves turn into a check; the rest
require breaking the system to observe. This does not say which attribute matters more; only which one is **cheaper to check**.

#### Checks Against a Known Set of Violations

Twenty checks got written; the next question is what they actually catch. The block below
**models** twenty-four observation windows and the eighteen violations recorded in them, applies
the checks to the set, then measures what stands in for what cannot be checked, against the same
missed violations.

```js
// check.mjs — generates checks from the leaves, runs them against a known set of violations,
// then measures what stands in for the ones that cannot be checked
import { LEAF } from "./tree/tree.mjs";

// QA2: 18 violations recorded across 24 observation windows in two quarters (fiction). The third field is
// whether the violation reached the observation stream: G4's violation happened through a path that never gets
// logged. The fourth field is how many windows the violation lasted; structural flaws persist until fixed (99).
const VIOLATION = [
  [2, "B1", true, 1], [3, "G3", true, 4], [4, "E3", false, 20], [5, "S1", true, 1],
  [6, "B5", true, 2], [7, "G6", false, 99], [8, "E1", true, 1], [9, "B7", true, 3],
  [10, "T1", false, 1], [11, "G4", false, 99], [12, "S4", false, 99], [13, "B1", true, 1],
  [14, "S5", true, 6], [15, "E4", true, 1], [16, "S2", false, 10], [17, "G1", true, 5],
  [18, "B8", false, 99], [20, "S3", true, 8],
];
const WINDOW = 24;

// QA3: noise model. Each automatic leaf's window value is drawn from a uniform distribution
// around the threshold; the threshold sits near the upper end of the distribution, so windows
// cross it even without a violation. The generator is hand-written, the seed is visible.
const generator = (seed) => () => {
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const random = generator(41307);

const AUTO = LEAF.filter((y) => y.source === "auto");
const find = (code) => LEAF.find((y) => y.code === code);
const draw = (y, r) => (y.threshold === 0 ? (r < 0.06 ? 1 : 0)
  : y.threshold * (y.direction === "high" ? 0.95 + r * 0.65 : 0.4 + r * 0.65));
const violationValue = (y) => (y.threshold === 0 ? 2 : y.threshold * (y.direction === "high" ? 0.8 : 1.3));

const record = [];
for (let p = 1; p <= WINDOW; p += 1) {
  const observation = {};
  for (const y of AUTO) observation[y.observation] = draw(y, random());
  const v = VIOLATION.find(([n]) => n === p);
  if (v && v[2]) observation[find(v[1]).observation] = violationValue(find(v[1]));
  record.push({ p, observation, violation: v ? v[1] : null });
}

// The check is generated from the leaf itself: it takes no information beyond the observation field, the threshold, and the direction.
const check = (y) => (o) => (y.direction === "high" ? o[y.observation] < y.threshold : o[y.observation] > y.threshold);
const firing = [];
for (const r of record)
  for (const y of AUTO) if (check(y)(r.observation)) firing.push([r.p, y.code, r.violation === y.code]);

const caught = firing.filter(([, , d]) => d);
const falseAlarm = firing.filter(([, , d]) => !d);
const missed = VIOLATION.filter(([p, code]) => !caught.some(([q, k]) => q === p && k === code));
console.log(`${WINDOW} windows, ${VIOLATION.length} known violations, ${AUTO.length} executable checks`);
console.log(`caught ${caught.length}, missed ${missed.length}, false alarm ${falseAlarm.length}\n`);

const alarm = {};
for (const [, code] of falseAlarm) alarm[code] = (alarm[code] || 0) + 1;
const ranked = Object.entries(alarm).sort((a, b) => b[1] - a[1]);
console.log(`${ranked.length} checks fired at least one false alarm; the three noisiest: ` +
  ranked.slice(0, 3).map(([k, n]) => `${k} (${n})`).join(", "));

// QA4: team behavior. A check that produces more than two false alarms in two quarters gets disabled.
const disabled = ranked.filter(([, n]) => n > 2).map(([k]) => k);
const remaining = caught.filter(([, code]) => !disabled.includes(code));
console.log(`disable threshold 2: disabled checks ${disabled.length} (${disabled.join(", ")}); ` +
  `caught afterward ${remaining.length}, missed ${VIOLATION.length - remaining.length}, ` +
  `false alarm ${falseAlarm.filter(([, code]) => !disabled.includes(code)).length}`);
const table = AUTO.reduce((s, y) => s + `${y.observation}${y.unit}${y.threshold}${y.direction}`.length, 0);
console.log(`check cost: rule body 1 line (same for all), threshold table ` +
  `${AUTO.length} lines / ${table} characters, run ${WINDOW * AUTO.length} comparisons, ` +
  `filled observation fields ${WINDOW * AUTO.length}`);

// QA5: two arrangements stand in for what cannot be checked; sessions run at window 12 and
// 24. Five leaves with a threshold but no log get a measurement session (6 person-hours), five
// leaves that cannot tie to a number get a review sampling 35% of the field (4 person-hours).
// A missed violation on an automatic leaf falls under neither arrangement.
const SESSION = [12, 24], SAMPLE = 0.35, HOURS = { measure: 6, review: 4 };
const arrangement = (y) => (y.source === "manual" ? "measure" : y.threshold === null ? "review" : "out-of-scope");
const second = generator(9152);

console.log(`\n${"window".padStart(7)}  ${"code".padEnd(6)}${"stands in".padEnd(14)}` +
  `${"result".padEnd(11)}delay`);
let found = 0, delay = 0;
for (const [p, code, , duration] of missed) {
  const d = arrangement(find(code));
  let s = SESSION.find((n) => n >= p && n <= p + duration &&
    (d === "measure" || (d === "review" && second() < SAMPLE)));
  if (d === "out-of-scope") s = undefined;
  if (s) { found += 1; delay += s - p; }
  console.log(`${String(p).padStart(7)}  ${code.padEnd(6)}${d.padEnd(14)}` +
    `${(s ? "caught" : "missed").padEnd(11)}${s ? s - p : "-"}`);
}
const scope = LEAF.filter((y) => y.source !== "auto");
const hours = scope.reduce((s, y) => s + HOURS[arrangement(y)], 0) * SESSION.length;
console.log(`\n${missed.length - 1} of the ${missed.length} missed violations fall within the standing-in arrangement: ` +
  `${found} caught, ${missed.length - 1 - found} missed; one is in neither arrangement`);
console.log(`average delay ${(delay / found).toFixed(1)} windows ` +
  `(0 for an executable check), cost ${hours} person-hours, ` +
  `${(hours / found).toFixed(0)} person-hours per catch`);
```

```
24 windows, 18 known violations, 20 executable checks
caught 11, missed 7, false alarm 23

15 checks fired at least one false alarm; the three noisiest: B1 (3), B4 (3), G3 (2)
disable threshold 2: disabled checks 2 (B1, B4); caught afterward 9, missed 9, false alarm 17
check cost: rule body 1 line (same for all), threshold table 20 lines / 325 characters, run 480 comparisons, filled observation fields 480

 window  code  stands in     result     delay
      4  E3    measure       caught     8
      7  G6    review        missed     -
     10  T1    measure       missed     -
     11  G4    out-of-scope  missed     -
     12  S4    review        caught     12
     16  S2    measure       caught     8
     18  B8    review        caught     6

6 of the 7 missed violations fall within the standing-in arrangement: 4 caught, 2 missed; one is in neither arrangement
average delay 8.5 windows (0 for an executable check), cost 100 person-hours, 25 person-hours per catch
```

11 of the eighteen violations get caught, seven are missed. Three of the missed ones sit on
leaves that have a threshold but no log, three on leaves that cannot tie to a number: those six
are an expected loss, the tree had already flagged them. The seventh is different: the
eleventh window's violation happened on a leaf with a written check and a zero threshold, and
still got missed — it never passed through that leaf's observation field.
**A check's scope is only as wide as the observation field feeding it** — a correctly written threshold makes the check look correct.

23 false alarms, more than double the caught count; fifteen checks fired without cause. This
is not the check's fault — it is where the threshold sits relative to the distribution: near the
upper end of observed values, it gets crossed without a violation. In the model, the team
disables any check producing more than two false alarms per two quarters; two get disabled,
dropping the caught count from 11 to 9. **A false alarm's cost is not the review time it burns — it is the violations the disabled
check will not catch afterward.** This triple — caught, missed, false alarm — matches the false-pass/false-fail pair in the
testing courses: what is measured here is the architectural rule, not a test case.

The check's own cost sits in four line items: the rule body is one line, identical across all
twenty leaves since it is generated from the leaf's threshold; the threshold table is 20 lines
and 325 characters; the run is 480 comparisons; the fourth and largest item is filling 480
observation fields. What makes the rule expensive is not its text but the data feeding it.

#### What Stands In for What Cannot Be Checked

The ten leaves that cannot be checked are not left idle: the five with a threshold but no log get
a quarterly measurement session, the other five get a review. Six of the
seven missed violations fall within these two arrangements; four get caught, two are missed. One
lasted a single window and had closed by the twelfth window's session; nothing was left to
find. The sampling review missed a flaw too: it covers a third of the field,
and the flaw fell outside it.

Average delay is 8.5 windows; for an executable check it is zero, showing up in the window it
happens. The cost is 100 person-hours over two quarters, 25 per violation caught; twenty checks
caught 11 violations in that span at no human cost. The manual arrangement
pays off too: no false alarms, and the person deciding sees the context. What is left is one violation nobody
covers; the tree names it too — not an uncovered leaf but an **uncovered path**.

#### Summary

- The quality attribute tree consists of a root, sub-attributes, and leaves; the measure sits
  only at the leaf, and 25 of thirty leaves tie to a number, five do not.
- Tying to a number is not the same as being checkable: 20 of the 25 numbered leaves turn into a
  check, five need a manual measurement despite having a written threshold, because they have no
  observation.
- Twenty checks catch 11 of eighteen violations, miss seven, and produce 23 false alarms; one is
  missed because it never reached the observation field despite having a written check.
- The cost of a false alarm is measured: disabling the two checks that produce more than two
  alarms drops the caught count from 11 to 9.
- What stands in for what cannot be checked catches four of the six covered missed violations at
  8.5 windows of delay and 100 person-hours; the cost of an executable check is one line of rule
  and 480 observation fields.

#### Next Step

Once the tree is built, every leaf looks checked on its own, by its own threshold. The next
lesson breaks that picture: the leaves are not independent of each other. A decision lowering
connection pool saturation lengthens failover time; one raising test coverage overruns the bulk
upload window. This relationship is not found by guessing but by measuring — a
tension matrix gets built, decisions that raise one attribute are applied in turn, and the drop
measured in the others is counted after each one. What comes out is how
many pairs conflict, which attribute is suppressed the most, and whether there is an attribute no
decision can improve.

### Tension Between Quality Attributes

Source: https://academia.sh/en/courses/architecture-governance/tension-between-quality-attributes

The measured drop in other attributes from a decision that raises one: building a tension matrix from twelve decisions, counting conflicting pairs and the most suppressed attribute, finding the attribute no decision improves, and measuring what a regression check catches, misses, and false-alarms on in a known set of violations.

The previous lesson built the tree and tied every leaf to its own threshold. In that table the
attributes stand independent of each other: each leaf has its own threshold, its own observation
field, its own check. But an architectural decision never touches a single leaf. A caching layer
that lowers search latency also raises the number of files touched in a change and the number of
environment-specific calls, at the same time. A per-leaf check cannot see this, because each
check only looks at its own leaf.

One attribute suppressing another is not a new observation; the tension between availability and
consistency was measured in the Introduction to System Design course, in the fundamental
properties topic, and is not repeated here. The question here is more general: **given a list of
decisions, can we count which attribute pairs conflict and which attribute is suppressed
systematically.** And the course's own question continues: can this tension be turned into an
executable check.

#### The Tension Matrix

The block below **models** the fictional regional library network's fourteen measured leaves,
twelve decisions proposed in one quarter, and the decisions' effect on those leaves. A root
attribute's score is the average of its leaves' share relative to their threshold; a decision's
impact is the score difference between before and after it is applied.

```js
// tension.mjs — models attributes and decisions and builds the tension matrix
import { mkdirSync, writeFileSync } from "node:fs";

// QA6: the fictional regional library network's fourteen measured leaves and their starting
// values. [code, root attribute, name, unit, threshold, direction, starting value]. Model.
const LEAF = [
  ["P1", "performance", "search latency", "ms", 400, "low", 310],
  ["P2", "performance", "loan transaction latency", "ms", 600, "low", 420],
  ["P3", "performance", "hourly loan transactions", "txn", 1200, "high", 1500],
  ["E1", "availability", "monthly outage", "min", 45, "low", 22],
  ["E2", "availability", "failover", "s", 60, "low", 38],
  ["E3", "availability", "data loss window", "min", 5, "low", 2],
  ["G1", "security", "authentication coverage", "%", 100, "high", 96],
  ["G2", "security", "secret rotation interval", "day", 90, "low", 60],
  ["G3", "security", "audit record coverage", "%", 90, "high", 94],
  ["S1", "maintainability", "files touched in a change", "file", 6, "low", 4],
  ["S2", "maintainability", "dependency depth", "level", 5, "low", 3],
  ["S3", "maintainability", "test coverage", "%", 70, "high", 78],
  ["T1", "portability", "environment-specific call", "call", 12, "low", 7],
  ["T2", "portability", "edge unit install", "min", 45, "low", 30],
];

// QA7: twelve architectural decisions proposed in one quarter. Each decision gives the new value of the
// leaves it touches, relative to the starting values; a leaf it does not touch stays unchanged. Model.
const DECISION = [
  ["search cache", { P1: 180, S1: 5, S2: 4, T1: 9 }],
  ["synchronous replication", { E3: 0.5, E2: 30, P2: 560 }],
  ["authenticate every request", { G1: 100, P1: 360, P2: 470 }],
  ["secret value rotation", { G2: 30, S1: 5, T1: 10 }],
  ["audit record propagation", { G3: 99, P3: 1380, S1: 5 }],
  ["layer separation", { S2: 2, S1: 3, P2: 450 }],
  ["expanding the test suite", { S3: 88 }],
  ["local queue at the branch", { E1: 12, T2: 40, S1: 5, S2: 4 }],
  ["automatic failover", { E2: 20, E1: 18, S2: 4, G3: 90 }],
  ["per-request rate limiting", { E1: 16, P3: 1320, P1: 330 }],
  ["separating membership data", { G1: 99, S1: 6, P2: 500, T2: 38 }],
  ["moving bulk upload to nighttime", { P1: 290, P3: 1560, E3: 3 }],
];

const ROOT = [...new Set(LEAF.map((y) => y[1]))];
// Share: threshold minus value for a low-direction leaf, value minus threshold for a high-direction leaf;
// divided by the threshold to strip the unit. A root attribute's score is the average of its leaves' shares.
const share = ([, , , , threshold, direction], value) =>
  (direction === "low" ? threshold - value : value - threshold) / threshold;
const score = (value) => Object.fromEntries(ROOT.map((k) => {
  const y = LEAF.filter((n) => n[1] === k);
  return [k, y.reduce((s, n) => s + share(n, value[n[0]]), 0) / y.length];
}));

const BASELINE = Object.fromEntries(LEAF.map((y) => [y[0], y[6]]));
const baseline = score(BASELINE);
const SENSITIVITY = 0.005;   // a score change smaller than this does not count as a change

const impact = DECISION.map(([name, change]) => {
  const d = score({ ...BASELINE, ...change });
  const delta = Object.fromEntries(ROOT.map((k) => [k, d[k] - baseline[k]]));
  return { name, change, delta };
});

mkdirSync("model", { recursive: true });
writeFileSync("model/model.mjs",
  `export const LEAF = ${JSON.stringify(LEAF)};\n` +
  `export const DECISION = ${JSON.stringify(DECISION)};\n` +
  `export const BASELINE = ${JSON.stringify(BASELINE)};\n`);

const short = (k) => k.slice(0, 4);
console.log(`${"decision".padEnd(32)}${"rising".padEnd(22)}falling (measured drop)`);
for (const e of impact) {
  const up = ROOT.filter((k) => e.delta[k] > SENSITIVITY);
  const down = ROOT.filter((k) => e.delta[k] < -SENSITIVITY);
  console.log(`${e.name.padEnd(32)}${(up.map(short).join(",") || "-").padEnd(22)}` +
    `${down.map((k) => `${short(k)} ${(-e.delta[k] * 100).toFixed(1)}`).join(", ") || "-"}`);
}

// Tension matrix: row is the rising attribute, column is the falling attribute; the cell is how many decisions produce that pair.
const matrix = Object.fromEntries(ROOT.map((a) => [a, Object.fromEntries(ROOT.map((b) => [b, 0]))]));
const dropTotal = Object.fromEntries(ROOT.map((k) => [k, 0]));
const riseCount = Object.fromEntries(ROOT.map((k) => [k, 0]));
for (const e of impact) {
  const up = ROOT.filter((k) => e.delta[k] > SENSITIVITY);
  const down = ROOT.filter((k) => e.delta[k] < -SENSITIVITY);
  for (const a of up) { riseCount[a] += 1; for (const b of down) matrix[a][b] += 1; }
  for (const b of down) dropTotal[b] += -e.delta[b];
}

console.log(`\ntension matrix (row rises, column falls; cell is decision count)`);
console.log(`${"".padEnd(20)}${ROOT.map((k) => short(k).padStart(7)).join("")}`);
for (const a of ROOT)
  console.log(`${a.padEnd(20)}${ROOT.map((b) => (a === b ? "-" : String(matrix[a][b])).padStart(7)).join("")}`);

const pair = ROOT.flatMap((a) => ROOT.filter((b) => a !== b && matrix[a][b] > 0).map((b) => [a, b]));
const mutual = pair.filter(([a, b]) => matrix[b][a] > 0).length / 2;
console.log(`\nconflicting ordered pairs ${pair.length}/${ROOT.length * (ROOT.length - 1)}, ` +
  `${mutual} of them mutual (suppression runs both ways)`);
const suppresses = (k) => impact.filter((e) => e.delta[k] < -SENSITIVITY).length;
const mostSuppressed = ROOT.slice().sort((a, b) => dropTotal[b] - dropTotal[a])[0];
console.log(`most suppressed attribute: ${mostSuppressed}; ${suppresses(mostSuppressed)} decisions ` +
  `drop it for ${ROOT.filter((a) => matrix[a][mostSuppressed] > 0).length} other attributes, ` +
  `total measured drop ${(dropTotal[mostSuppressed] * 100).toFixed(1)} share points`);
for (const k of ROOT)
  if (riseCount[k] === 0)
    console.log(`attribute no decision improves: ${k}; ${suppresses(k)} of ${DECISION.length} decisions ` +
      `drop it, none raises it`);
```

```
decision                        rising                falling (measured drop)
search cache                    perf                  main 12.2, port 8.3
synchronous replication         avai                  perf 7.8
authenticate every request      secu                  perf 6.9
secret value rotation           secu                  main 5.6, port 12.5
audit record propagation        secu                  perf 3.3, main 5.6
layer separation                main                  perf 1.7
expanding the test suite        main                  -
local queue at the branch       avai                  main 12.2, port 11.1
automatic failover              avai                  secu 1.5, main 6.7
per-request rate limiting       avai                  perf 6.7
separating membership data      secu                  perf 4.4, main 11.1, port 8.9
moving bulk upload to nighttime perf                  avai 6.7

tension matrix (row rises, column falls; cell is decision count)
                       perf   avai   secu   main   port
performance               -      1      0      1      1
availability              2      -      1      2      1
security                  3      0      -      3      2
maintainability           1      0      0      -      0
portability               0      0      0      0      -

conflicting ordered pairs 11/20, 2 of them mutual (suppression runs both ways)
most suppressed attribute: maintainability; 6 decisions drop it for 3 other attributes, total measured drop 53.3 share points
attribute no decision improves: portability; 4 of 12 decisions drop it, none raises it
```

11 of the twenty ordered attribute pairs measure a conflict: at least one decision that raises
one attribute drops the other. Two are mutual — performance with availability, performance with
maintainability. A mutual pair is one where the other drops no matter which direction you move;
it cannot be closed, only whose share of the drop gets chosen.

The most suppressed attribute is maintainability: six of the twelve decisions drop it, for three
separate attributes, a total of 53.3 share points. Two of maintainability's leaves — files
touched in a change and dependency depth — rise on almost every decision that adds a piece. **A
piece gets added for performance or security; maintainability pays the cost every time.**

The matrix's last row is filled with zeros. No decision raises portability; four drop it. This
does not show that portability is unimportant — it shows that no one is proposing a decision that
targets it. Writing a threshold does not mean a decision defending that attribute will get
proposed; the matrix is the one row that makes the missing decision visible.

One decision drops no attribute: expanding the test suite. None of the fourteen measured leaves
carries this decision's cost; an unmeasured cost shows up in the matrix as zero. **The matrix is
only as honest as the leaves it measures.**

#### The Regression Check

A matrix fills a table; on its own it stops nothing. The next question is whether tension can be
turned into an executable check: when a decision is applied, no root attribute's score should
drop by more than a set threshold. The block below applies this check to a known set of
violations and counts what the threshold choice changes.

```js
// regression.mjs — turns tension into an executable regression check and runs it against a
// known set of violations, then counts caught / missed / false alarm
import { LEAF, DECISION, BASELINE } from "./model/model.mjs";

// QA8: known set of violations. After the twelve decisions were applied, five were flagged in
// the records as "unacceptable regression"; the remaining seven's drop was a knowingly accepted
// trade-off. This flag is a judgment call, it does not come out of the measurement. Model.
const VIOLATION = ["search cache", "secret value rotation", "local queue at the branch",
  "separating membership data", "layer separation"];

const ROOT = [...new Set(LEAF.map((y) => y[1]))];
const CEILING = { "%": 100 };   // a leaf carrying a percentage cannot exceed 100
const share = ([, , , , threshold, direction], v) => (direction === "low" ? threshold - v : v - threshold) / threshold;
const score = (value) => Object.fromEntries(ROOT.map((k) => {
  const y = LEAF.filter((n) => n[1] === k);
  return [k, y.reduce((s, n) => s + share(n, value[n[0]]), 0) / y.length];
}));
const baseline = score(BASELINE);
const biggestDrop = ([, change]) => {
  const d = score({ ...BASELINE, ...change });
  return Math.max(0, ...ROOT.map((k) => (baseline[k] - d[k]) * 100));
};

// Check: when a decision is applied, no root attribute's score should drop by more than the threshold.
// Where to set the threshold does not come out of the measurement; the sweep below counts the options.
console.log(`${"threshold (share points)".padEnd(25)}${"fired".padStart(7)}${"caught".padStart(8)}` +
  `${"missed".padStart(8)}${"false alarm".padStart(13)}`);
for (const threshold of [2, 4, 6, 8, 10, 12]) {
  const fired = DECISION.filter((k) => biggestDrop(k) > threshold).map(([name]) => name);
  const caught = fired.filter((name) => VIOLATION.includes(name));
  console.log(`${String(threshold).padEnd(25)}${String(fired.length).padStart(7)}` +
    `${String(caught.length).padStart(8)}${String(VIOLATION.length - caught.length).padStart(8)}` +
    `${String(fired.length - caught.length).padStart(13)}`);
}

const CHOSEN = 6;
const fired = DECISION.filter((k) => biggestDrop(k) > CHOSEN).map(([name]) => name);
console.log(`\nchosen threshold ${CHOSEN} share points`);
for (const name of VIOLATION.filter((a) => !fired.includes(a)))
  console.log(`  missed: ${name} (biggest drop ` +
    `${biggestDrop(DECISION.find(([k]) => k === name)).toFixed(1)} share points, below threshold)`);
for (const name of fired.filter((a) => !VIOLATION.includes(a)))
  console.log(`  false alarm: ${name} (drop accepted in the record)`);

// Cumulative effect: decisions are applied in order, each change scaling that leaf's value relative to its own starting
// value. A single-decision check only ever looks at one decision at a time, so it cannot see the accumulation.
const end = { ...BASELINE };
for (const [, change] of DECISION)
  for (const [code, value] of Object.entries(change)) {
    const y = LEAF.find((n) => n[0] === code);
    end[code] = Math.min(end[code] * (value / BASELINE[code]), CEILING[y[3]] ?? Infinity);
  }

const exceeding = LEAF.filter((y) => share(y, end[y[0]]) < 0);
const single = DECISION.flatMap(([name, d]) =>
  LEAF.filter((y) => y[0] in d && share(y, d[y[0]]) < 0).map((y) => [name, y[0]]));
console.log(`\n(decision, leaf) pairs exceeding the threshold in a single decision ${single.length} ` +
  `(${single.map(([a, k]) => `${a}/${k}`).join(", ")}); with all twelve decisions applied together, ` +
  `leaves exceeding the threshold ${exceeding.length}, across ${[...new Set(exceeding.map((n) => n[1]))].length} attributes`);
for (const y of exceeding)
  console.log(`  ${y[0]}  ${y[2].padEnd(26)} ${BASELINE[y[0]]} -> ${end[y[0]].toFixed(1)} ` +
    `${y[3]} (threshold ${y[4]})`);

// QA9: the part that cannot be checked. The check measures the drop, not its acceptability; that is a
// judgment call. An accepted trade-off record stands in its place, and the check looks at the record instead.
const acceptedTradeOff = fired.filter((a) => !VIOLATION.includes(a));
console.log(`\ncannot be checked: whether a drop is acceptable (a judgment call). ${acceptedTradeOff.length} ` +
  `accepted trade-off records stand in its place; after the record, false alarm ${acceptedTradeOff.length} -> 0, ` +
  `caught ${fired.length - acceptedTradeOff.length}, missed ${VIOLATION.length - (fired.length - acceptedTradeOff.length)}`);
console.log(`check cost: ${LEAF.length} re-measurements per decision, ` +
  `${DECISION.length} decisions x ${DECISION.length * LEAF.length} measurements; ` +
  `run ${DECISION.length * ROOT.length + LEAF.length} comparisons; ` +
  `maintenance ${acceptedTradeOff.length} accepted trade-off records (reviewed every time a decision changes)`);
```

```
threshold (share points)   fired  caught  missed  false alarm
2                             10       4       1            6
4                             10       4       1            6
6                              9       4       1            5
8                              4       4       1            0
10                             4       4       1            0
12                             3       3       2            0

chosen threshold 6 share points
  missed: layer separation (biggest drop 1.7 share points, below threshold)
  false alarm: synchronous replication (drop accepted in the record)
  false alarm: authenticate every request (drop accepted in the record)
  false alarm: automatic failover (drop accepted in the record)
  false alarm: per-request rate limiting (drop accepted in the record)
  false alarm: moving bulk upload to nighttime (drop accepted in the record)

(decision, leaf) pairs exceeding the threshold in a single decision 1 (separating membership data/G1); with all twelve decisions applied together, leaves exceeding the threshold 4, across 3 attributes
  P2  loan transaction latency   420 -> 799.3 ms (threshold 600)
  S1  files touched in a change  4 -> 11.0 file (threshold 6)
  T1  environment-specific call  7 -> 12.9 call (threshold 12)
  T2  edge unit install          30 -> 50.7 min (threshold 45)

cannot be checked: whether a drop is acceptable (a judgment call). 5 accepted trade-off records stand in its place; after the record, false alarm 5 -> 0, caught 4, missed 1
check cost: 14 re-measurements per decision, 12 decisions x 168 measurements; run 74 comparisons; maintenance 5 accepted trade-off records (reviewed every time a decision changes)
```

The threshold sweep shows no single threshold finishes the job. At eight share points the check
catches four of five known violations and never false-alarms; lowering it to six does not raise
the catch, and false alarms climb to five. Even loosened to two, the fifth violation still gets
missed. The reason is not sensitivity: that decision's biggest drop is 1.7 share points, and what
makes it count as unacceptable is not the size of the drop but where it lands — loan transactions
at peak hour. The check looks at the score average, not at the distribution below that average.
**The missed violation is not below the check's sensitivity; it is outside the size it looks
at.**

All five false alarms at the six-point threshold are knowingly accepted trade-offs. This puts a
name on what cannot be checked: the check can measure the **size** of a drop, not its
**acceptability**, because acceptability is a judgment call. An accepted trade-off record stands
in its place — for every accepted drop, which attribute it was accepted for gets written down,
and the check looks at the record. After five records, the false alarm count drops to zero. The
cost is clear: the five records must be reviewed every time a decision changes, and once a record
goes stale, the check goes quiet, and its silence is no longer correct.

The last measurement shows the check's structural blindness. A single-decision check sees
threshold exceedance in only one (decision, leaf) pair across the twelve decisions; when the
decisions are applied together, four leaves exceed the threshold, across three separate
attributes. Worse, the single exceedance the single-decision check does see closes under
accumulation — a later decision carries that leaf back above the threshold. **A single-decision
check and a cumulative check find the opposite of each other on the same set**; choosing one
means the other is missed entirely. The cost has two line items: re-measuring all fourteen leaves
after every decision (168 measurements) and maintaining five accepted trade-off records; the run
itself, 74 comparisons, is negligible next to the measurement.

#### Summary

- The tension matrix counts the measured drop in other attributes from a decision that raises
  one; 11 of the twenty ordered pairs conflict, two are mutual.
- The most suppressed attribute is maintainability: in six decisions, for three separate
  attributes, a total of 53.3 share points; no decision targets it, yet it pays the cost of every
  addition.
- No decision raises portability, four drop it; an attribute with a written threshold only falls
  as long as no decision defending it gets proposed.
- No threshold in the sweep catches all five violations at once: 4/1/0 at eight points, 4/1/5 at
  six points (caught / missed / false alarm); the missed one is outside the size the check looks
  at.
- The check measures the drop, not its acceptability; five accepted trade-off records stand in
  its place and the false alarm count drops to zero — where the single-decision check sees one
  exceedance, the cumulative check finds four others.

#### Next Step

In the matrix, security rose with four decisions, and in return suppressed performance and
maintainability with three decisions each. But in this account, security was a single score: the
average of three leaves' share. The next lesson opens up that score, because the cost of a
security decision does not fit into one number. A system is made of boundaries that carry data
from one place to another; how many boundaries there are, which data crosses which boundary, and
how many checks stand at each boundary can all be counted. Counting them turns up two things:
crossings that pass through no check at all, and the new paths a removed boundary opens.

### Security Architecture

Source: https://academia.sh/en/courses/architecture-governance/security-architecture

Counting trust boundaries as a graph: how many boundaries there are, which data crosses which boundary, checks per boundary and unchecked crossings, how many paths removing a boundary opens, and what a rule catches, misses, and false-alarms on in a known set of security incidents.

In the previous lesson security was a single score — the average of three leaves' share
relative to their threshold — and it did not say what changed when it rose. A security decision
lands not on a number but on a place: the point where data crosses from one zone into another.

How authentication and authorization get implemented was measured in the Authentication and
Authorization course, secret value management in the Server Security and Going to Production
course; neither is repeated here. The question here sits at the architectural level: how many
trust boundaries there are, which data crosses which boundary, how many checks stand at each
boundary, which crossings pass through no check at all. And the course's question continues — can
this graph be turned into an executable rule.

#### The Trust Boundary Graph

The block below **models** the fictional regional library network's trust zones, the boundaries
between them, the checks each boundary carries, the checks each data type requires, and the
crossings observed in one quarter. An unchecked crossing is one where at least one of the
required checks is missing at the boundary it crosses.

```js
// boundary.mjs — models the trust boundary graph and counts checks per boundary
import { mkdirSync, writeFileSync } from "node:fs";

// QA10: the fictional regional library network's seven trust zones and the eight boundaries
// between them. Each boundary carries the checks applied when crossing it. Model.
const BOUNDARY = [
  ["S1", "member device", "central service", ["identity", "encryption"]],
  ["S2", "branch edge unit", "branch network", ["identity"]],
  ["S3", "branch network", "central service", ["encryption", "logging"]],
  ["S4", "central service", "data layer", ["authorization", "encryption"]],
  ["S5", "central service", "municipal identity", ["identity", "encryption", "logging"]],
  ["S6", "central service", "external catalog", ["encryption"]],
  ["S7", "branch network", "data layer", []],
  ["S8", "member device", "external catalog", []],
];

// QA11: seven data types and the checks each one requires when it crosses a boundary. A
// catalog record is open data and requires no check. Model.
const REQUIRED = {
  "member identity": ["identity", "encryption"],
  "loan history": ["identity", "authorization", "masking"],
  "catalog record": [],
  "fee record": ["identity", "authorization", "logging"],
  "staff authorization": ["identity", "authorization", "logging"],
  "audit record": ["authorization", "logging"],
  "secret value": ["encryption", "logging"],
};

// QA12: eighteen data crossings observed in one quarter [data type, source zone, target zone]. Model.
const FLOW = [
  ["member identity", "member device", "central service"],
  ["member identity", "central service", "municipal identity"],
  ["member identity", "branch edge unit", "branch network"],
  ["loan history", "data layer", "central service"],
  ["loan history", "central service", "member device"],
  ["loan history", "data layer", "branch network"],
  ["loan history", "branch network", "branch edge unit"],
  ["catalog record", "external catalog", "central service"],
  ["catalog record", "external catalog", "member device"],
  ["catalog record", "central service", "data layer"],
  ["catalog record", "branch network", "central service"],
  ["fee record", "central service", "data layer"],
  ["fee record", "branch edge unit", "branch network"],
  ["staff authorization", "municipal identity", "central service"],
  ["staff authorization", "central service", "branch network"],
  ["audit record", "central service", "data layer"],
  ["secret value", "central service", "external catalog"],
  ["secret value", "central service", "data layer"],
];

const ZONE = [...new Set(BOUNDARY.flatMap(([, a, b]) => [a, b]))];
const findBoundary = (a, b) => BOUNDARY.find(([, x, y]) => (x === a && y === b) || (x === b && y === a));
const missing = (type, s) => REQUIRED[type].filter((c) => !s[3].includes(c));

mkdirSync("graph", { recursive: true });
writeFileSync("graph/graph.mjs",
  `export const BOUNDARY = ${JSON.stringify(BOUNDARY)};\n` +
  `export const REQUIRED = ${JSON.stringify(REQUIRED)};\n` +
  `export const FLOW = ${JSON.stringify(FLOW)};\n`);

console.log(`${ZONE.length} trust zones, ${BOUNDARY.length} boundaries, ${FLOW.length} data crossings, ` +
  `${Object.keys(REQUIRED).length} data types`);
console.log(`\n${"boundary".padEnd(10)}${"zone pair".padEnd(38)}${"checks".padStart(8)}` +
  `${"crossings".padStart(10)}${"crossings missing a check".padStart(27)}`);
for (const s of BOUNDARY) {
  const crossing = FLOW.filter(([, a, b]) => findBoundary(a, b) === s);
  console.log(`${s[0].padEnd(10)}${`${s[1]} - ${s[2]}`.padEnd(38)}${String(s[3].length).padStart(8)}` +
    `${String(crossing.length).padStart(10)}` +
    `${String(crossing.filter(([v]) => missing(v, s).length > 0).length).padStart(27)}`);
}

const flawed = FLOW.map((a) => [a, findBoundary(a[1], a[2])])
  .map(([a, s]) => [a, s, missing(a[0], s)]).filter(([, , e]) => e.length > 0);
console.log(`\ncrossing missing a check: ${flawed.length}/${FLOW.length} crossings miss at least one required check`);
console.log(`${"boundary".padEnd(10)}${"data type".padEnd(21)}${"direction".padEnd(39)}missing check`);
for (const [a, s, e] of flawed)
  console.log(`${s[0].padEnd(10)}${a[0].padEnd(21)}${`${a[1]} -> ${a[2]}`.padEnd(39)}${e.join(", ")}`);

const noCheckAtAll = flawed.filter(([, s]) => s[3].length === 0);
console.log(`\nboundary with no check at all: ${BOUNDARY.filter((s) => s[3].length === 0).map((s) => s[0]).join(", ")}; ` +
  `data crossings needing a check through these boundaries ${noCheckAtAll.length}`);
const perType = Object.keys(REQUIRED).map((v) =>
  [v, flawed.filter(([a]) => a[0] === v).length]).filter(([, n]) => n > 0);
console.log(`crossings missing a check, per data type: ` +
  perType.map(([v, n]) => `${v} ${n}`).join(", "));
```

```
7 trust zones, 8 boundaries, 18 data crossings, 7 data types

boundary  zone pair                               checks crossings  crossings missing a check
S1        member device - central service              2         2                          1
S2        branch edge unit - branch network            1         3                          3
S3        branch network - central service             2         2                          1
S4        central service - data layer                 2         5                          4
S5        central service - municipal identity         3         2                          1
S6        central service - external catalog           1         2                          1
S7        branch network - data layer                  0         1                          1
S8        member device - external catalog             0         1                          0

crossing missing a check: 12/18 crossings miss at least one required check
boundary  data type            direction                              missing check
S2        member identity      branch edge unit -> branch network     encryption
S4        loan history         data layer -> central service          identity, masking
S1        loan history         central service -> member device       authorization, masking
S7        loan history         data layer -> branch network           identity, authorization, masking
S2        loan history         branch network -> branch edge unit     authorization, masking
S4        fee record           central service -> data layer          identity, logging
S2        fee record           branch edge unit -> branch network     authorization, logging
S5        staff authorization  municipal identity -> central service  authorization
S3        staff authorization  central service -> branch network      identity, authorization
S4        audit record         central service -> data layer          logging
S6        secret value         central service -> external catalog    logging
S4        secret value         central service -> data layer          logging

boundary with no check at all: S7, S8; data crossings needing a check through these boundaries 1
crossings missing a check, per data type: member identity 1, loan history 4, fee record 2, staff authorization 2, audit record 1, secret value 2
```

12 of eighteen crossings miss at least one required check. The distribution is not even boundary
by boundary: the boundary facing the municipal identity zone carries three checks, the one
between the branch network and the data layer carries none. But the two boundaries without
checks are not the same thing: there is also no check between the member device and the external
catalog, but only the catalog record — open data — crosses there. **An unchecked boundary is not
by itself a flaw; the flaw is data requiring a check crossing an unchecked boundary.**

The busiest boundary is the one with the most missing checks: five crossings run between the
central service and the data layer, four with a missing check. This is not a coincidence — as
more data types cross a boundary, the required set of checks grows, but the boundary carries only
one fixed set.

By data type, loan history leads with four missing checks: it crosses four separate boundaries,
and one of its three required checks, masking, is not defined at any of the eight boundaries.
**One of the required checks does not exist anywhere in the system**, and this only shows up once
the required set and the existing set get placed side by side.

#### Removing a Boundary and the Rule's Limit

The graph's second question: what happens if a boundary is removed. Removing one means the two
zones become a single trust zone, and data in either one crosses to the other unconditionally.
The block below computes this for every boundary, then runs the rule against known incidents.

```js
// removal.mjs — counts the paths a removed boundary opens and runs the check against a known set of incidents
import { BOUNDARY, REQUIRED, FLOW } from "./graph/graph.mjs";

// QA13: the zone each data type is born in; reach is the transitive closure that starts at
// the source and follows the flow arrows. Model.
const SOURCE = {
  "member identity": "member device", "loan history": "data layer",
  "catalog record": "external catalog", "fee record": "branch edge unit",
  "staff authorization": "municipal identity", "audit record": "central service",
  "secret value": "central service",
};
const DATA = Object.keys(REQUIRED);

// When a boundary is removed, the two zones become a single trust zone: every data type gets
// an unconditional crossing between the two ends.
const reach = (type, removed) => {
  const reached = new Set([SOURCE[type]]);
  const merged = removed ? [removed[1], removed[2]] : [];
  for (let n = 0; n < BOUNDARY.length + 1; n += 1)
    for (const [v, a, b] of FLOW) {
      if (v === type && reached.has(a)) reached.add(b);
      if (merged.length && reached.has(merged[0])) reached.add(merged[1]);
      if (merged.length && reached.has(merged[1])) reached.add(merged[0]);
    }
  return reached;
};

const baseline = Object.fromEntries(DATA.map((v) => [v, reach(v, null)]));
const baselineTotal = DATA.reduce((s, v) => s + baseline[v].size, 0);
console.log(`baseline reach: ${baselineTotal} (data type, zone) pairs, ` +
  `${DATA.length} data types x ${new Set(BOUNDARY.flatMap((s) => [s[1], s[2]])).size} zones`);
console.log(`\n${"boundary".padEnd(10)}${"zones merged if removed".padEnd(40)}` +
  `${"paths opened".padStart(13)}  data type opened the most`);
const opened = BOUNDARY.map((s) => {
  const added = DATA.map((v) => [v, reach(v, s).size - baseline[v].size]);
  return [s, added.reduce((t, [, n]) => t + n, 0), added.sort((a, b) => b[1] - a[1])[0]];
}).sort((a, b) => b[1] - a[1]);
for (const [s, total, [v, n]] of opened)
  console.log(`${s[0].padEnd(10)}${`${s[1]} + ${s[2]}`.padEnd(40)}${String(total).padStart(13)}` +
    `  ${n > 0 ? `${v} (${n})` : "-"}`);

// Check: every crossing must pass through the checks its data type requires at the boundary it crosses.
const findBoundary = (a, b) => BOUNDARY.find(([, x, y]) => (x === a && y === b) || (x === b && y === a));
const missing = (type, s) => (s ? REQUIRED[type].filter((c) => !s[3].includes(c)) : null);
const warning = FLOW.map((a) => [a, findBoundary(a[1], a[2])])
  .filter(([a, s]) => missing(a[0], s).length > 0);

// QA14: known set of incidents — ten security incidents on record over the last two quarters
// [data type, source, target, note]. Model.
const INCIDENT = [
  ["loan history", "data layer", "branch network", ""],
  ["fee record", "branch edge unit", "branch network", ""],
  ["secret value", "central service", "external catalog", ""],
  ["loan history", "branch network", "branch edge unit", ""],
  ["staff authorization", "central service", "branch network", ""],
  ["fee record", "central service", "data layer", ""],
  ["loan history", "data layer", "data layer", "in-zone backup copy"],
  ["secret value", "central service", "central service", "secret that fell into a log file"],
  ["member identity", "data layer", "external catalog", "path undefined in the graph"],
  ["member identity", "member device", "central service", "boundary with full checks"],
];
// QA15: three of the flagged crossings have their missing check met somewhere other than the boundary.
const METELSEWHERE = [["audit record", "central service", "data layer", "data layer transaction log"],
  ["secret value", "central service", "data layer", "data layer transaction log"],
  ["staff authorization", "municipal identity", "central service", "authorization is signed at the source"]];

const matches = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
const caught = INCIDENT.filter((i) => warning.some(([a]) => matches(a, i)));
const missed = INCIDENT.filter((i) => !caught.includes(i));
const falseAlarm = warning.filter(([a]) => METELSEWHERE.some((k) => matches(a, k)));
const openGap = warning.length - caught.length - falseAlarm.length;
console.log(`\n${warning.length} warnings, ${INCIDENT.length} known incidents: caught ${caught.length}, ` +
  `missed ${missed.length}, false alarm ${falseAlarm.length}, ` +
  `open gap with no incident yet ${openGap}`);
for (const i of missed) {
  const s = findBoundary(i[1], i[2]);
  console.log(`  missed: ${i[0].padEnd(20)} ${`${i[1]} -> ${i[2]}`.padEnd(36)} ` +
    `${i[3] || (s ? "boundary has no missing check" : "no such boundary")}`);
}
for (const [a] of falseAlarm) {
  const k = METELSEWHERE.find((n) => matches(a, n));
  console.log(`  false alarm: ${a[0].padEnd(20)} check met elsewhere: ${k[3]}`);
}

// QA16: the part that cannot be checked. The graph checks crossings at the boundaries it
// knows about; it cannot check whether a boundary is in the right place, or a path that was
// never reported. In its place: a review every quarter, 3 person-hours per boundary. Model.
const HOURS = 3;
const outsideGraph = missed.filter((i) => !findBoundary(i[1], i[2])).length;
console.log(`\ncannot be checked: whether the boundary is in the right place, and an unreported path; ` +
  `${outsideGraph} of the ${missed.length} missed incidents are a crossing with no path in the graph`);
console.log(`stands in its place: a review for ${BOUNDARY.length} boundaries, ` +
  `${BOUNDARY.length * HOURS} person-hours/quarter; the removal ranking gives the order ` +
  `(top of the list: ${opened[0][0][0]}, ${opened[0][1]} paths)`);
const record = BOUNDARY.length + FLOW.length + DATA.length;
console.log(`check cost: ${record} graph records held by hand (boundary, flow, data type); ` +
  `run ${FLOW.reduce((s, a) => s + REQUIRED[a[0]].length, 0)} check comparisons + ` +
  `${BOUNDARY.length} removal computations; if the graph goes stale, missed grows`);
```

```
baseline reach: 22 (data type, zone) pairs, 7 data types x 7 zones

boundary  zones merged if removed                  paths opened  data type opened the most
S3        branch network + central service                    6  fee record (2)
S7        branch network + data layer                         5  catalog record (1)
S5        central service + municipal identity                4  loan history (1)
S6        central service + external catalog                  4  member identity (1)
S1        member device + central service                     3  staff authorization (1)
S8        member device + external catalog                    3  member identity (1)
S4        central service + data layer                        2  member identity (1)
S2        branch edge unit + branch network                   1  staff authorization (1)

12 warnings, 10 known incidents: caught 6, missed 4, false alarm 3, open gap with no incident yet 3
  missed: loan history         data layer -> data layer             in-zone backup copy
  missed: secret value         central service -> central service   secret that fell into a log file
  missed: member identity      data layer -> external catalog       path undefined in the graph
  missed: member identity      member device -> central service     boundary with full checks
  false alarm: staff authorization  check met elsewhere: authorization is signed at the source
  false alarm: audit record         check met elsewhere: data layer transaction log
  false alarm: secret value         check met elsewhere: data layer transaction log

cannot be checked: whether the boundary is in the right place, and an unreported path; 3 of the 4 missed incidents are a crossing with no path in the graph
stands in its place: a review for 8 boundaries, 24 person-hours/quarter; the removal ranking gives the order (top of the list: S3, 6 paths)
check cost: 33 graph records held by hand (boundary, flow, data type); run 36 check comparisons + 8 removal computations; if the graph goes stale, missed grows
```

Boundaries do not carry equal weight. Removing the boundary between the branch network and the
central service opens six new paths; removing the one between the branch edge unit and the
branch network opens one. The ranking does not track the number of checks at the boundary: a
boundary carrying two checks opens more paths than one carrying none. **A boundary's value is
measured not by the checks it carries but by how the zones it separates connect into the graph.**

The rule produces twelve warnings, and the record holds ten incidents: six get caught, four are
missed, three are false alarms. The remaining three warnings are neither — the missing check
really is missing, it just has not produced an incident yet. **A warning with no incident is not
a false alarm**; a false alarm is a warning where the check is actually met but looks missing.
Without this distinction, half the warnings get written off as noise and the rule gets loosened.

The four missed incidents need to be read separately. Two stayed within a zone — the backup copy
and the log file never crossed a boundary. One passed through a path undefined in the graph. The
fourth is the most instructive: it happened at a boundary with a full set of checks. **The graph
sees whether a check exists, not whether it works correctly.** This is where the part that cannot
be checked comes from: whether a boundary is in the right place, and whether an unreported path
exists, cannot be checked, and three of the missed incidents fall in that scope. In their place, a
quarterly review is set for the eight boundaries, 24 person-hours; the removal analysis gives its
order.

The weight of the cost is not in the run: the rule is thirty-six comparisons plus eight removal
computations, but the thirty-three hand-held graph records must be updated with every
architectural change. If the graph goes stale, the rule fails silently: every incident on a flow
missing from the graph becomes a missed one.

#### Summary

- The trust boundary graph has seven zones, eight boundaries, eighteen crossings; twelve of them
  miss at least one required check.
- An unchecked boundary is not by itself a flaw: of the two unchecked boundaries, only one
  carries data that requires a check; the busiest boundary is also the one with the most missing
  checks.
- One required check (masking) is not defined at any of the eight boundaries; this only shows up
  once the required set and the existing set get placed side by side.
- Removing a boundary opens between one and six new paths; the ranking is set not by the check
  count but by how the zones connect into the graph.
- Twelve warnings, ten incidents: 6 caught, 4 missed, 3 false alarms, 3 open gaps; three of the
  missed incidents are a crossing with no path in the graph, one happened at a boundary with a
  full set of checks.

#### Next Step

The boundary graph said which zone is separated from which; it did not say how many copies of it
run. Once the system grows, the real question is: **what gets copied**. The next lesson lines up
three scale unit candidates — copying the whole system, copying individual services, copying per
tenant — and for each candidate counts the copied component, the shared remaining resource, and
the singleton that cannot be copied under any of them.

### Scalability Decisions

Source: https://academia.sh/en/courses/architecture-governance/scalability-decisions

Where the scale unit's boundary gets drawn: counting the copied component, the shared remaining resource, and the singleton that cannot be copied for the whole-system, service, and tenant candidates, and what the copy rule catches, misses, and false-alarms on in past scaling attempts.

The trust boundary graph separated zones from each other, but did not say how many copies of
each zone run. Once the system grows, this question comes to the front, and it usually gets
asked the wrong way. How many copies are needed and how capacity gets computed was measured in
the Performance Anti-Patterns and Monitoring course, in the Capacity Planning lesson — node count
is the larger of the load constraint and the failure constraint; it is not repeated here. The
question here comes before the arithmetic, and it is one sentence: **what gets copied.**

The copied whole is called a scale unit. The term is the same one used in the Deployment Stamps
and Geo-Replicas lesson; the question there was how many copies of the unit are needed, the
question here is where the unit's boundary gets drawn. The boundary can be drawn in different
places, and each drawing copies one thing and leaves another shared. This lesson lines up three
candidates and pulls out three numbers: the copied component, the shared remaining resource, the
singleton that cannot be copied.

#### Three Scale Unit Candidates

The block below **models** the fictional regional library network's components, the
dependencies between them, and three scale unit candidates. The network serves several
municipalities; a tenant means one municipality's full set of branches. Each candidate is a rule
that selects which component gets copied.

```js
// unit.mjs — models three scale unit candidates and counts what gets copied, shared, singleton
import { mkdirSync, writeFileSync } from "node:fs";

// QA17: the fictional regional library network's fourteen components. The network serves
// several municipalities; a tenant means one municipality's full set of branches. Fields:
// [code, name, state, splits by tenant, reason it cannot be copied]. Model.
const COMPONENT = [
  ["C1", "loan service", "stateless", true, ""],
  ["C2", "fee service", "stateless", true, ""],
  ["C3", "membership service", "stateless", true, ""],
  ["C4", "notification sender", "stateless", true, ""],
  ["C5", "management console", "stateless", false, ""],
  ["C6", "search index", "stateful", true, ""],
  ["C7", "catalog cache", "stateful", false, ""],
  ["C8", "loan database", "stateful", true, ""],
  ["C9", "membership database", "stateful", true, ""],
  ["C10", "fee ledger", "stateful", true, ""],
  ["C11", "external catalog connector", "stateless", false, "single external contract"],
  ["C12", "identity connector", "stateless", false, "single external contract"],
  ["C13", "number generator", "stateful", false, "global sequence"],
  ["C14", "audit record store", "stateful", false, "single-writer obligation"],
];

// QA18: eighteen dependencies between components [source, target, kind]. Model.
const DEPENDENCY = [
  ["C1", "C8", "writer"], ["C1", "C9", "reader"], ["C1", "C13", "writer"], ["C1", "C14", "writer"],
  ["C1", "C7", "reader"], ["C2", "C10", "writer"], ["C2", "C8", "reader"], ["C2", "C14", "writer"],
  ["C3", "C9", "writer"], ["C3", "C12", "reader"], ["C3", "C14", "writer"], ["C4", "C8", "reader"],
  ["C4", "C9", "reader"], ["C5", "C14", "reader"], ["C5", "C10", "reader"], ["C6", "C7", "reader"],
  ["C11", "C7", "writer"], ["C11", "C6", "writer"],
];

// A scale unit is the whole that runs as more than one concurrent copy. The term is the same
// as in the deployment stamp lesson; there the question was how many copies the unit needs,
// here it is where the unit's boundary gets drawn.
const CANDIDATE = {
  "whole system": (b) => b[4] === "",
  service: (b) => b[4] === "" && b[2] === "stateless",
  tenant: (b) => b[4] === "" && b[3],
};
const singleton = COMPONENT.filter((b) => b[4] !== "");
const find = (k) => COMPONENT.find((b) => b[0] === k);

mkdirSync("model", { recursive: true });
writeFileSync("model/model.mjs",
  `export const COMPONENT = ${JSON.stringify(COMPONENT)};\n` +
  `export const DEPENDENCY = ${JSON.stringify(DEPENDENCY)};\n` +
  `export const CANDIDATE = ${JSON.stringify(Object.keys(CANDIDATE))};\n`);

console.log(`${COMPONENT.length} components, ${DEPENDENCY.length} dependencies, ` +
  `${DEPENDENCY.filter((e) => e[2] === "writer").length} of them writes`);
console.log(`\n${"scale unit candidate".padEnd(22)}${"copied".padStart(9)}` +
  `${"shared".padStart(9)}${"singleton".padStart(11)}${"dependency into the shared part".padStart(34)}`);
for (const [name, pick] of Object.entries(CANDIDATE)) {
  const copy = COMPONENT.filter(pick);
  const shared = COMPONENT.filter((b) => b[4] === "" && !pick(b));
  const into = DEPENDENCY.filter(([, h]) => shared.some((p) => p[0] === h)).length;
  console.log(`${name.padEnd(22)}${String(copy.length).padStart(9)}` +
    `${String(shared.length).padStart(9)}${String(singleton.length).padStart(11)}` +
    `${String(into).padStart(34)}`);
}

console.log(`\nsingleton that cannot be copied under any candidate (${singleton.length}):`);
for (const b of singleton) console.log(`  ${b[0].padEnd(4)}${b[1].padEnd(28)}${b[4]}`);
const intoSingleton = DEPENDENCY.filter(([, h]) => singleton.some((t) => t[0] === h));
console.log(`dependencies into a singleton ${intoSingleton.length}, ` +
  `${intoSingleton.filter((e) => e[2] === "writer").length} of them writes; ` +
  `writing components: ${[...new Set(intoSingleton.filter((e) => e[2] === "writer")
    .map((e) => find(e[0])[1]))].join(", ")}`);
```

```
14 components, 18 dependencies, 9 of them writes

scale unit candidate     copied   shared  singleton   dependency into the shared part
whole system                 10        0          4                                 0
service                       5        5          4                                12
tenant                        8        2          4                                 3

singleton that cannot be copied under any candidate (4):
  C11 external catalog connector  single external contract
  C12 identity connector          single external contract
  C13 number generator            global sequence
  C14 audit record store          single-writer obligation
dependencies into a singleton 6, 4 of them writes; writing components: loan service, fee service, membership service
```

The three candidates' copied column differs from each other: the whole system copies ten
components, tenant eight, service five. But the singleton column is four under all three
candidates. **Changing the scale unit does not change the singletons.** Wherever the boundary is
drawn, four components stay at one copy, because the reason they cannot be copied has nothing to
do with the unit's boundary: two are bound to a single external contract, one produces a global
sequence, one carries a single-writer obligation. The last two are a technical constraint, the
first two are not. Singleness that comes from a contract does not get solved by an architectural
decision; it gets solved by a different decision, changing the contract, and that sits outside
this scale unit discussion.

The real cost shows in the last column. The service candidate copies the fewest components and
leaves twelve dependencies flowing into the shared remainder; the tenant candidate leaves three,
the whole-system candidate leaves zero. **The count of copied components and the load left in the
shared part move in opposite directions**, and this relationship is the candidates' real
comparison: the service candidate is cheap to copy but rests twelve dependencies on a shared
resource; each is a bottleneck candidate as the copies grow.

The whole-system candidate's zero needs a careful read. Zero does not mean nothing is shared;
that candidate's shared column is empty because everything shared moved into the singleton column
instead. Four of the six dependencies into the four singletons are writes, and the writers are
three separate services. So whichever candidate gets picked, three services keep writing to a
component that stays at one copy. Choosing a scale unit does not remove these three writes, it
only changes which boundary they show up inside.

#### The Copy Rule

Three columns fill a table; the next question is whether this can be turned into an executable
rule. The rule can be written like this: a component inside a scale unit must not write to a
stateful component outside the unit. If it does, two copies of the unit touch the same state
concurrently. The block below writes this rule and runs it against ten scaling steps attempted
over the past year.

```js
// copyrule.mjs — writes the scale unit rule and runs it against attempted scaling steps
import { COMPONENT, DEPENDENCY } from "./model/model.mjs";

const find = (k) => COMPONENT.find((b) => b[0] === k);
const PICK = {
  "whole system": (b) => b[4] === "",
  service: (b) => b[4] === "" && b[2] === "stateless",
  tenant: (b) => b[4] === "" && b[3],
};
// Under the service candidate the unit is a single service; under the other two candidates the unit
// is every component the candidate selects. Rule: a component inside the unit must not write to a
// stateful component outside the unit. If it does, the copies touch the same state concurrently.
const unit = (candidate, component) =>
  (candidate === "service" ? [component] : COMPONENT.filter(PICK[candidate]).map((b) => b[0]));
const violatingEdge = (u) => DEPENDENCY.filter(([k, h, t]) =>
  t === "writer" && u.includes(k) && !u.includes(h) && find(h)[2] === "stateful");

// QA19: known set of violations — ten scaling steps attempted over the last year, and their
// real outcome [candidate, component, outcome, note]. Model.
const ATTEMPT = [
  ["service", "C1", "problem", "two copies writing to the global loan number"],
  ["service", "C2", "problem", "duplicate entry in the fee ledger"],
  ["service", "C3", "problem", "duplicate write to the membership database"],
  ["service", "C4", "no problem", "read-only"],
  ["service", "C5", "no problem", "read-only"],
  ["service", "C6", "no problem", "the index gets rebuilt per copy"],
  ["service", "C7", "problem", "two copies share the same local disk path"],
  ["service", "C11", "problem", "the single external contract does not tolerate two copies"],
  ["tenant", "", "no problem", "writes split by tenant key, audit record is append-only"],
  ["whole system", "", "problem", "two stamps writing to the audit record store"],
];

console.log(`${"candidate".padEnd(14)}${"unit".padEnd(28)}${"rule".padEnd(11)}` +
  `${"actual".padEnd(12)}outcome`);
let caught = 0, missed = 0, falseAlarm = 0, silence = 0;
for (const [candidate, code, actual, note] of ATTEMPT) {
  const u = unit(candidate, code);
  const edge = violatingEdge(u);
  const fires = edge.length > 0;
  const outcome = fires && actual === "problem" ? "caught"
    : fires ? "false alarm" : actual === "problem" ? "missed" : "correct silence";
  if (outcome === "caught") caught += 1;
  else if (outcome === "missed") missed += 1;
  else if (outcome === "false alarm") falseAlarm += 1;
  else silence += 1;
  console.log(`${candidate.padEnd(14)}${(code ? find(code)[1] : "whole candidate").padEnd(28)}` +
    `${(fires ? `fires (${edge.length})` : "silent").padEnd(11)}${actual.padEnd(12)}${outcome}`);
}
console.log(`\n${ATTEMPT.length} attempts, ${ATTEMPT.filter((d) => d[2] === "problem").length} had a problem: ` +
  `caught ${caught}, missed ${missed}, false alarm ${falseAlarm}, correct silence ${silence}`);
for (const d of ATTEMPT) {
  const fires = violatingEdge(unit(d[0], d[1])).length > 0;
  if ((fires && d[2] === "no problem") || (!fires && d[2] === "problem"))
    console.log(`  ${fires ? "false alarm" : "missed"}: ${d[0]}/${d[1] ? find(d[1])[1] : "whole"} — ${d[3]}`);
}

// QA20: the part that cannot be checked. The rule looks at declared dependencies; it cannot see
// two copies sharing an undeclared resource (the same disk path, the same process, the same
// scheduler). In its place: a copy test — run two copies side by side and watch for a collision,
// 6 person-hours per attempt. The test shows the missed attempt the moment the copies come up.
const HOURS = 6;
console.log(`\ncannot be checked: an undeclared shared resource; this is why the missed attempt was missed`);
console.log(`stands in its place: a copy test, ${ATTEMPT.length} attempts x ${HOURS} = ` +
  `${ATTEMPT.length * HOURS} person-hours; the test catches the missed case and separates the false alarm too`);
const field = COMPONENT.length * 5 + DEPENDENCY.length * 3;
console.log(`rule cost: ${field} fields held by hand (${COMPONENT.length} components x 5, ` +
  `${DEPENDENCY.length} dependencies x 3); run ${ATTEMPT.length * DEPENDENCY.length} edge ` +
  `comparisons; if the record goes stale, missed grows`);
```

```
candidate     unit                        rule       actual      outcome
service       loan service                fires (3)  problem     caught
service       fee service                 fires (2)  problem     caught
service       membership service          fires (2)  problem     caught
service       notification sender         silent     no problem  correct silence
service       management console          silent     no problem  correct silence
service       search index                silent     no problem  correct silence
service       catalog cache               silent     problem     missed
service       external catalog connector  fires (2)  problem     caught
tenant        whole candidate             fires (4)  no problem  false alarm
whole system  whole candidate             fires (4)  problem     caught

10 attempts, 6 had a problem: caught 5, missed 1, false alarm 1, correct silence 3
  missed: service/catalog cache — two copies share the same local disk path
  false alarm: tenant/whole — writes split by tenant key, audit record is append-only

cannot be checked: an undeclared shared resource; this is why the missed attempt was missed
stands in its place: a copy test, 10 attempts x 6 = 60 person-hours; the test catches the missed case and separates the false alarm too
rule cost: 124 fields held by hand (14 components x 5, 18 dependencies x 3); run 180 edge comparisons; if the record goes stale, missed grows
```

Six of the ten attempts had produced a problem. The rule catches five, misses one, false-alarms
once, and correctly stays silent three times. Correct silence gets counted separately, because a
rule's value does not live only in its firings: the rule stayed silent on the notification
sender, management console, and search index attempts, and none of the three had a problem.
Counting only the firings would make the rule's accuracy look like five out of six; counting the
silences too, it is eight out of ten.

The missed attempt is the catalog cache. Two copies collided because they shared the same local
disk path, but this sharing is not in the dependency list — because the list carries dependencies
between components, not a component's bond with its environment. **The rule looks at declared
dependencies; an undeclared sharing sits outside the rule**, and this is a matter of the rule's
scope, not its sensitivity.

The false alarm shows up on the tenant candidate. The rule sees something true: components inside
the tenant unit write to four stateful points outside the unit. But every one of those writes is
either split by the tenant key or append-only to the audit record, so two copies never touch the
same row. **The rule sees that a write exists, not the shape the write takes.** Telling apart a
split write from a colliding write needs one more field in the dependency record, and that field
gets filled in by hand for every dependency.

There is also one row where the right outcome came from the wrong reason. In the external catalog
connector attempt, the rule fired and the attempt really did produce a problem, but the cause was
the single external contract, not the write edge the rule pointed at. If the edge the rule
flagged had been fixed, the problem would have persisted exactly the same. **The caught count
does not measure whether the reason is correct**; that distinction only shows up once every
catch's cause gets read.

The part of the rule that cannot be checked is the undeclared shared resource, and that is
exactly why the one attempt got missed. In its place a copy test is set up: two copies run side
by side and the collision gets watched for, six person-hours per attempt, 60 person-hours across
the ten attempts. The test shows the missed case the moment the copies come up, and it separates
out the false alarm too, because a split write produces no collision to watch for. The rule's own
cost is a separate line item: 124 fields declared by hand for fourteen components and eighteen
dependencies. The run itself, 180 edge comparisons, carries no cost worth measuring; a stale
declaration, though, turns directly into a missed case.

#### Summary

- A scale unit is the whole that runs as more than one concurrent copy; this lesson asks not
  how many copies the unit needs but where its boundary gets drawn.
- The three candidates differ in what gets copied (whole system 10, tenant 8, service 5) but not
  in the singleton: four components cannot be copied under any of them, and two of the reasons
  are contractual, not technical.
- The copied and the shared-remaining counts move in opposite directions: the service candidate
  copies the least and leaves twelve dependencies flowing into the shared resource, tenant three,
  whole system zero.
- The copy rule gives 5 caught, 1 missed, 1 false alarm, 3 correct silences across ten attempts;
  the miss is a matter of scope, not sensitivity, and the false alarm comes from the rule not
  seeing the shape of the write.
- In one catch the reason is wrong: the rule pointed at a write edge, but the problem's cause was
  the single external contract; the copy test standing in for what cannot be checked costs 60
  person-hours across ten attempts.

#### Next Step

Everything counted so far — the quality attribute tree, the tension matrix, the trust boundary
graph, the scale unit — requires changing the system. Changing itself has never been measured.
The next lesson runs the same set of changes against two separate structures and counts, per
change, the files touched, the modules touched, and how many steps out the change spreads. Then
it tests the metrics themselves: do these numbers show decay over time, or do they hold steady
while the structure gets worse.

### Maintainability Criteria

Source: https://academia.sh/en/courses/architecture-governance/sustainability-criteria

Observing the cost of change directly: running the same twelve changes against two structures and counting the files touched, modules touched, and spread depth per change, testing whether the metrics show decay over six quarters, and what a threshold rule catches, misses, and false-alarms on against a real cost record.

Every measure in this topic proposed a change. The quality attribute tree showed the missing
checks, the tension matrix showed which decision suppressed what, the trust boundary graph
showed which crossing went unchecked, the scale unit showed what gets copied. All of them shared
one assumption — that the system can be changed — and that assumption was never measured.
Maintainability was a root attribute in the tree, the most suppressed attribute in the tension
matrix; but in both places it was read indirectly, from leaf values. This lesson measures it
directly: the same set of changes gets run against two separate structures.

Technical debt's interest was measured in the Architectural Decisions and Documentation course,
in the decision record topic, and it is not repeated here. The cost of change is that interest's
observed face — interest does not show up as a line item, it shows up as the extra file touched
by every change. Three quantities get measured: the files touched per change, the modules
touched, and how many steps out a change spreads.

#### The Same Change in Two Structures

The block below **models** two structures. The module set is the same in both; two things
differ — the dependency graph and how concepts are placed into modules. The same twelve change
requests get run against both structures. A request that changes a module's surface also touches
the modules that depend on it.

```js
// structure.mjs — runs the same change set against two structures and measures change cost
import { mkdirSync, writeFileSync } from "node:fs";

// QA25: the fictional regional library network's two structures carrying the same functionality. The module
// set is the same in both; the dependency graph and the placement of concepts into modules differ. Model.
const MODULE = ["loan", "fee", "membership", "catalog", "notification", "report", "branch", "shared"];
const DEPENDENCY = {
  A: [["loan", "shared"], ["loan", "fee"], ["loan", "membership"], ["fee", "shared"],
    ["fee", "membership"], ["membership", "shared"], ["catalog", "shared"], ["notification", "loan"],
    ["notification", "membership"], ["report", "loan"], ["report", "fee"], ["report", "membership"],
    ["branch", "loan"], ["branch", "fee"], ["branch", "catalog"]],
  B: [["loan", "shared"], ["fee", "shared"], ["membership", "shared"], ["catalog", "shared"],
    ["notification", "shared"], ["report", "shared"], ["branch", "shared"], ["report", "loan"],
    ["branch", "loan"]],
};

// QA26: twelve change requests. Each request touches one concept; which module the concept
// is in and in how many files varies by structure. surface = does the change reach the
// module's public surface (the same for both structures, for a given request). Model.
const CHANGE = [
  ["fee schedule", true, { fee: 2, loan: 1, branch: 1, report: 1 }, { fee: 2 }],
  ["late fee threshold", false, { fee: 1, notification: 1, loan: 1 }, { fee: 1 }],
  ["member field", true, { membership: 2, loan: 1, report: 1, branch: 1 }, { membership: 2 }],
  ["loan duration", false, { loan: 2, fee: 1, notification: 1 }, { loan: 2 }],
  ["reservation rule", true, { loan: 2, branch: 1 }, { loan: 2 }],
  ["catalog field", true, { catalog: 2, branch: 1, report: 1 }, { catalog: 2 }],
  ["notification text", false, { notification: 2 }, { notification: 2 }],
  ["branch calendar", false, { branch: 2, loan: 1 }, { branch: 2 }],
  ["report column", false, { report: 2, fee: 1 }, { report: 2 }],
  ["currency", true, { shared: 1, fee: 2, report: 1, branch: 1 }, { shared: 2 }],
  ["identity field", true, { membership: 2, shared: 1, loan: 1 }, { membership: 2 }],
  ["discount rule", false, { fee: 2, loan: 1, report: 1 }, { fee: 2 }],
];

// Spread: a change that reaches the public surface also touches every module that depends on
// the module it touches directly; each indirect module gets one call site fixed. Depth is the
// number of steps from the directly touched module to the farthest indirect module.
const measure = (structure, surface, layout) => {
  const direct = Object.keys(layout);
  let file = Object.values(layout).reduce((s, n) => s + n, 0);
  const seen = new Map(direct.map((m) => [m, 0]));
  if (surface) {
    let front = [...direct], depth = 0;
    while (front.length) {
      depth += 1;
      const next = [];
      for (const m of front)
        for (const [a, b] of DEPENDENCY[structure])
          if (b === m && !seen.has(a)) { seen.set(a, depth); next.push(a); file += 1; }
      front = next;
    }
  }
  return { file, module: seen.size, depth: Math.max(...seen.values()) };
};

mkdirSync("model", { recursive: true });
writeFileSync("model/model.mjs",
  `export const MODULE = ${JSON.stringify(MODULE)};\n` +
  `export const DEPENDENCY = ${JSON.stringify(DEPENDENCY)};\n` +
  `export const CHANGE = ${JSON.stringify(CHANGE)};\n` +
  `export const measure = ${measure};\n`);

console.log(`${MODULE.length} modules; dependency A ${DEPENDENCY.A.length}, B ${DEPENDENCY.B.length}`);
console.log(`\n${"change".padEnd(20)}${"surface".padEnd(9)}` +
  `${"A file".padStart(8)}${"A module".padStart(9)}${"A depth".padStart(8)}` +
  `${"B file".padStart(9)}${"B module".padStart(9)}${"B depth".padStart(8)}`);
const measured = CHANGE.map(([name, surface, a, b]) =>
  [name, surface, measure("A", surface, a), measure("B", surface, b)]);
for (const [name, surface, a, b] of measured)
  console.log(`${name.padEnd(20)}${(surface ? "yes" : "no").padEnd(9)}` +
    `${String(a.file).padStart(8)}${String(a.module).padStart(9)}${String(a.depth).padStart(8)}` +
    `${String(b.file).padStart(9)}${String(b.module).padStart(9)}${String(b.depth).padStart(8)}`);

const avg = (structure, field) =>
  (measured.reduce((s, o) => s + o[structure][field], 0) / measured.length).toFixed(2);
console.log(`\naverage per change — A: file ${avg(2, "file")}, module ${avg(2, "module")}, ` +
  `depth ${avg(2, "depth")}`);
console.log(`average per change — B: file ${avg(3, "file")}, module ${avg(3, "module")}, ` +
  `depth ${avg(3, "depth")}`);
const worst = measured.slice().sort((a, b) => b[3].module - a[3].module)[0];
console.log(`most expensive change in both structures: ${worst[0]} ` +
  `(A ${worst[2].module} modules, B ${worst[3].module} modules)`);
```

```
8 modules; dependency A 15, B 9

change              surface    A file A module A depth   B file B module B depth
fee schedule        yes             6        5       1        2        1       0
late fee threshold  no              3        3       0        1        1       0
member field        yes             7        6       1        2        1       0
loan duration       no              4        3       0        2        1       0
reservation rule    yes             5        4       1        4        3       1
catalog field       yes             4        3       0        2        1       0
notification text   no              2        1       0        2        1       0
branch calendar     no              3        2       0        2        1       0
report column       no              3        2       0        2        1       0
currency            yes             9        8       2        9        8       1
identity field      yes             9        8       1        2        1       0
discount rule       no              4        3       0        2        1       0

average per change — A: file 4.92, module 4.00, depth 0.50
average per change — B: file 2.67, module 1.75, depth 0.17
most expensive change in both structures: currency (A 8 modules, B 8 modules)
```

All three metrics point the same direction but diverge by a different magnitude. The ratio is
1.8 in files, 2.3 in modules, 3 in depth. The weakest divergence is in files, because most of the
directly touched file count comes from the work itself — a fee schedule change needs the two
files the schedule is written in, whichever structure it happens in. **The structural difference
shows up not in the file count but in which modules those files are spread across.**

The entire difference comes from the six changes that touch the surface. In the six requests
that do not touch the surface, there is no spread, depth is zero, and even the scattered
structure stays between two and four files. The structure's cost is paid not on every change but
only on a change that reaches the exposed surface; if a measurement does not separate these two
sets, the average shows a smaller difference than the real one.

One row is identical in both structures: the currency change touches eight modules in either
one. In the tidy structure the concept is gathered into a single module, but that module is the
shared module, and everyone depends on it. **Gathering a concept gains nothing if the concept
already sits where everyone depends on it.** A modular structure's payoff is selective: large
where it can tuck a concept away in a corner, zero where it has to gather it at the center.

#### Do the Metrics Show Decay?

A metric's real test is not a single measurement but the curve it gives over time. The block
below **models** the tidy structure decaying over six quarters — one concept leaks into one
extra module every quarter, and two quarters also open a new dependency edge — and tests whether
the three metrics show this decay. Then it writes a threshold rule and runs it against the real
cost record from the sixth quarter.

```js
// decay.mjs — tests whether the metrics show decay over time, then runs a threshold rule
// against a known cost record
import { DEPENDENCY, CHANGE, measure } from "./model/model.mjs";

// QA27: structure B decays over six quarters. Each quarter one concept leaks into one extra
// module [concept, module, dependency edge]; two quarters also open a new dependency edge. Model.
const LEAK = [
  ["fee schedule", "branch", null],
  ["member field", "loan", null],
  ["loan duration", "branch", ["report", "fee"]],
  ["fee schedule", "report", null],
  ["catalog field", "report", ["notification", "membership"]],
  ["member field", "report", null],
];

const layout = CHANGE.map(([name, surface, , b]) => [name, surface, { ...b }]);
const reading = [];
const take = () => {
  const m = layout.map(([name, surface, y]) => [name, measure("B", surface, y)]);
  const avg = (field) => m.reduce((s, [, v]) => s + v[field], 0) / m.length;
  return { m, file: avg("file"), module: avg("module"), depth: avg("depth"),
    violation: m.filter(([, v]) => v.module > 2).length };
};
reading.push(take());
for (const [concept, mod, edge] of LEAK) {
  const y = layout.find(([name]) => name === concept)[2];
  y[mod] = (y[mod] || 0) + 1;
  if (edge) DEPENDENCY.B.push(edge);
  reading.push(take());
}

console.log(`${"quarter".padEnd(8)}${"file/change".padStart(13)}${"module/change".padStart(15)}` +
  `${"depth".padStart(10)}${"changes over threshold".padStart(24)}  (Q0 = before decay)`);
reading.forEach((m, i) =>
  console.log(`${`Q${i}`.padEnd(8)}${m.file.toFixed(2).padStart(13)}` +
    `${m.module.toFixed(2).padStart(15)}${m.depth.toFixed(2).padStart(10)}` +
    `${String(m.violation).padStart(24)}`));

const firstMove = (field) => reading.findIndex((m, i) => i > 0 && m[field] > reading[0][field]);
const rising = (field) => reading.every((m, i) => i === 0 || m[field] >= reading[i - 1][field]);
for (const field of ["file", "module", "depth", "violation"])
  console.log(`${field.padEnd(9)} first move Q${firstMove(field)}, ` +
    `end-to-end rise ${(reading.at(-1)[field] - reading[0][field]).toFixed(2)}, ` +
    `ever reverses: ${rising(field) ? "no" : "yes"}`);

// QA28: at the end of six quarters the real cost of the twelve changes is read from the team's record;
// four came in expensive. The record is an observation, it does not come out of the metric. Model.
const EXPENSIVE = ["fee schedule", "member field", "reservation rule", "catalog field"];
// Rule: a change should touch at most two modules.
const THRESHOLD = 2;
const last = reading.at(-1).m;
let caught = 0, missed = 0, falseAlarm = 0, silent = 0;
const mismatch = [];
for (const [name, v] of last) {
  const fires = v.module > THRESHOLD, expensive = EXPENSIVE.includes(name);
  if (fires && expensive) caught += 1; else if (fires) falseAlarm += 1;
  else if (expensive) missed += 1; else silent += 1;
  if (fires !== expensive) mismatch.push(`  ${fires ? "false alarm" : "missed"}: ${name} ` +
    `(${v.module} modules, recorded as ${expensive ? "expensive" : "cheap"})`);
}
console.log(`\nthreshold ${THRESHOLD} modules, ${last.length} changes, ${EXPENSIVE.length} expensive: ` +
  `caught ${caught}, missed ${missed}, false alarm ${falseAlarm}, correct silence ${silent}`);
for (const line of mismatch) console.log(line);

// QA29: the part that cannot be checked. The metric counts breadth, not difficulty; whether a
// concept is in the right module is also a judgment call. In its place: a quarterly layout
// review, 12 changes x 1 person-hour.
const HOURS = 1;
console.log(`\ncannot be checked: a change's difficulty and whether the concept is in the right module ` +
  `(a judgment call); false alarm ${falseAlarm}, missed ${missed} fall exactly in this scope`);
console.log(`stands in its place: a quarterly layout review, ` +
  `${last.length * HOURS} person-hours/quarter, ${last.length * HOURS * 6} across six quarters`);
const record = CHANGE.length * 3 + DEPENDENCY.B.length;
console.log(`metric cost: ${record} records (12 changes x 3 fields + ` +
  `${DEPENDENCY.B.length} dependency edges); run ${reading.length * last.length} measurements; ` +
  `change cost is the observed face of what gets measured as debt interest`);
```

```
quarter   file/change  module/change     depth  changes over threshold  (Q0 = before decay)
Q0               2.67           1.75      0.17                       2
Q1               2.75           1.83      0.17                       2
Q2               3.00           2.08      0.25                       3
Q3               3.17           2.25      0.33                       4
Q4               3.17           2.25      0.25                       4
Q5               3.42           2.50      0.33                       4
Q6               3.42           2.50      0.33                       4
file      first move Q1, end-to-end rise 0.75, ever reverses: no
module    first move Q1, end-to-end rise 0.75, ever reverses: no
depth     first move Q2, end-to-end rise 0.17, ever reverses: yes
violation first move Q2, end-to-end rise 2.00, ever reverses: no

threshold 2 modules, 12 changes, 4 expensive: caught 3, missed 1, false alarm 1, correct silence 7
  missed: catalog field (2 modules, recorded as expensive)
  false alarm: currency (8 modules, recorded as cheap)

cannot be checked: a change's difficulty and whether the concept is in the right module (a judgment call); false alarm 1, missed 1 fall exactly in this scope
stands in its place: a quarterly layout review, 12 person-hours/quarter, 72 across six quarters
metric cost: 47 records (12 changes x 3 fields + 11 dependency edges); run 84 measurements; change cost is the observed face of what gets measured as debt interest
```

The file and module metrics move in the first quarter and never reverse across all six. The
depth metric does neither: it moves in the second quarter and reverses in the fourth. While the
structure is only getting worse, the metric looks like it improved for one quarter. The reason
sits in the metric's own definition: a leak writes a concept directly into a module it had
previously touched only indirectly, so that module now counts as a starting point instead of a
spread, and depth drops. **If the decay itself changes the metric's input, the metric does not
stay one-directional** — and this does not make the metric useless, it only means it cannot be
read alone.

The count of changes over the threshold moves in steps: two, three, four, then flat for three
quarters. The blunt count moves late and shows no decay at all over the last three quarters; the
average, over the same three quarters, climbs from 2.25 to 2.50. The two do not substitute for
each other: the average gives an early warning, the threshold count marks a decision point.

The rule itself, applied where four of the twelve changes in the sixth quarter came in
expensive, produces three catches, one miss, one false alarm, and seven correct silences. The
miss is the catalog field change, which touched only two modules yet came in expensive; the
false alarm is the currency change, which touched eight modules yet came in cheap — the same
constant's name got fixed in eight places. **Both errors come from one source: the metric counts
breadth, not difficulty.** This is exactly the part that cannot be checked; a change's difficulty
and whether a concept sits in the right module are both a judgment call. In their place a
quarterly layout review is set: 12 person-hours for the twelve changes, 72 across six quarters.

The metric's own cost looks small — 47 records and 84 measurements. But every one of the records
is held by hand: which concept each change request touches, whether it changes the surface, and
which module it appears in, in how many files. If this record gets dropped, the metric does not
go silent, it speaks wrong; the curve that shows decay becomes the curve of the record going
stale.

#### Summary

- Running the same twelve changes against both structures, the scattered structure costs 4.92
  files, 4.00 modules, and 0.50 depth per change; the tidy structure costs 2.67 files, 1.75
  modules, and 0.17 depth.
- The divergence is weakest in files (1.8x), sharpest in depth (3x); the entire difference comes
  from the six surface-changing requests, the two structures do not diverge on the rest.
- A change touching the shared module spreads across eight modules in both structures: gathering
  gains nothing when the concept already sits where everyone depends on it.
- Over six quarters of decay, the file and module metrics move in the first quarter and never
  reverse; depth reverses in the fourth quarter, because the leak turns an indirect touch into a
  direct one.
- The threshold rule gives 3 caught, 1 missed, 1 false alarm, 7 correct silences; both errors
  come from the metric counting breadth and not difficulty.
- In place of what cannot be checked, a quarterly layout review of 12 person-hours is set; the
  metric's run is 84 measurements, its upkeep 47 hand-held records.

#### Next Step

This topic tied attributes to a measure. The tree counted thirty leaves and separated which turn
into a check; the tension matrix showed which decision suppressed which attribute; trust
boundaries and unchecked crossings were counted; the scale unit's three candidates were compared
on what gets copied, shared, and left as a singleton; the cost of change was measured directly
across two structures. It is now possible to write down which decision protects which attribute.

But every one of these measurements was taken once. The thresholds are one day's thresholds, the
graph is one day's graph, the layout is one day's layout. Nothing says whether any of it still
holds six months later — even this lesson's decay table was a hand-built model, not a mechanism
that runs on its own. What is needed is a mechanism that repeats the measurement, reports the
drift, and settles who owns each measure; without it, every number in this topic stays a
photograph of the day it was taken.

## Governance

Auditing that a decision holds: the trade-off between decision speed and consistency across centralized, federated, and advisory governance models, fitness functions that automatically test architectural rules, checking dependency and layer rules at build time, building an evolutionary architecture that stands up to change, and embedding regulatory constraints into the architecture.

### Architecture Governance Models

Source: https://academia.sh/en/courses/architecture-governance/architecture-governance-models

Running centralized, federated, and advisory governance through the same decision flow: waiting rounds per decision, the conformance rate, caught and missed violations, the false alarms produced by missed local context, and a sweep of the trade-off between decision speed and consistency.

The previous topic tied every attribute to a measure and wrote down which decision protects which
attribute. Those measures share a common flaw: all of them were taken once. A measure is true on
the day it is taken; there is no mechanism that says whether it is still true six months later. In
the meantime new modules enter, new dependencies open, and the people who made the decisions leave
the environment. What keeps a measure fresh is not the measure itself, but the order in which
decisions are made.

Governance is exactly this: by whom, in what order, and under what rule architectural decisions are
made. This first lesson measures the decision flow itself; the next two lessons write the
machine-side checking of rules. The through-line is the regional library network — branch systems,
an externally sourced catalog, in-house loan and fee services, a separate membership system, and
the municipality's identity service — and it is fiction.

#### The Decision Flow to Measure

The block below is a model, not a measurement: it is not drawn from a real institution's decision
record; it is a data structure that makes the decision flow runnable across three governance
models. The model's input is one quarter's architectural decisions.

**GV1 — twenty-four architectural decisions are made in one quarter, and whether each decision
actually violates a rule is known afterward.** Reason: catching and missing can only be counted
against a known violation set; this lesson fixes that set as input. Each decision carries four
separate pieces of information: **symptom** (the surface-level detail that trips the rule),
**local justification** (the detail that makes the decision legitimate but is seen only by the
unit), **latent signal** (a facet noticed only in a joint review), and whether the decision is
sensitive to the **rule version**.

```js
// governance.mjs — runs the same decision flow through three governance models (model)
// flag: b symptom  m local justification  y new rule  g rule relaxed  o latent signal  d consulted
import { writeFileSync } from "node:fs";

const DECISIONS = [
  "direct write to the catalog table       | loan       | shared | bo",
  "branch shelf cache                      | branch     | local  | bm",
  "copying member fields                   | fee        | shared | b",
  "identity service call from branch front | branch     | shared | bmd",
  "shelf label field                       | catalog    | local  | -",
  "version tag dropped at the shared front | catalog    | shared | by",
  "late-fee calculation moved into loan    | fee        | shared | b",
  "address field stored in notification    | membership | local  | bmd",
  "raw storage of external catalog reply   | loan       | shared | byd",
  "branch report read from storage         | branch     | shared | b",
  "fee table written to by loan            | loan       | shared | bo",
  "hold flag kept in loan                  | membership | shared | bm",
  "identity token written to the log       | identity   | shared | by",
  "notification text kept in membership    | membership | local  | bg",
  "manual timeout value adjustment         | loan       | local  | -",
  "branch bulk import                      | branch     | local  | bmd",
  "price read from external catalog        | fee        | shared | bo",
  "catalog front publishing without paging | catalog    | shared | byd",
  "membership record duplicated at branch  | membership | shared | bm",
  "identity cache duration extended        | identity   | local  | bgd",
  "loan event written to notification      | loan       | shared | bd",
  "branch display direct query             | branch     | local  | bo",
  "fee refund touching membership          | fee        | shared | bm",
  "search result cached at branch          | catalog    | local  | bmd",
].map((s) => {
  const [name, unit, scope, flags] = s.split("|").map((x) => x.trim());
  const v = (c) => flags.includes(c);
  return { name, unit, scope, symptom: v("b"), justification: v("m"), newRule: v("y"),
           relaxed: v("g"), latent: v("o"), consulted: v("d") };
});

// GV2: rule version 2 is in effect; "new" decisions read as a violation under 2 and as compliant under 1; "relaxed" decisions
// read the opposite way. Units' versions are distributed by model (they stay as current as their coordination ritual).
const RULE_VERSION = {
  federated: { branch: 2, catalog: 1, loan: 2, fee: 2, membership: 2, identity: 1 },
  advisory:  { branch: 1, catalog: 1, loan: 2, fee: 1, membership: 1, identity: 1 },
};

const isViolation = (k) => (k.relaxed ? false : k.symptom && !k.justification);   // known violation set
const unitDecision = (k, s) => {
  if (k.latent) return false;                       // a unit alone does not see the latent signal
  if (k.newRule) return s === 2 && k.symptom && !k.justification;
  if (k.relaxed) return s === 1 && k.symptom;
  return k.symptom && !k.justification;
};
const MODEL = {
  "centralized":                   (k) => k.symptom && !k.relaxed,      // pulls in the surface, misses the justification
  "federated":                     (k) => k.newRule || k.relaxed
    ? unitDecision({ ...k, latent: false }, RULE_VERSION.federated[k.unit])
    : k.symptom && !k.justification,                                   // shared list opens up the latent signal
  "advisory":                      (k) => k.consulted ? isViolation(k) : unitDecision(k, RULE_VERSION.advisory[k.unit]),
  "centralized + question round":  (k) => isViolation(k),               // asks about local context
};

// waiting: each model works its own queue at its own capacity (round by round)
function schedule(list, capacity, extraRound = () => 0) {
  const pending = list.map((k) => ({ k, left: 1 + extraRound(k) }));
  const round = new Map(list.map((k) => [k.name, 0]));
  let t = 0;
  while (pending.length) {
    t += 1; let cap = capacity; const deferred = [];
    while (cap > 0 && pending.length) {
      const it = pending.shift(); cap -= 1; it.left -= 1;
      if (it.left === 0) round.set(it.k.name, t); else deferred.push(it);
    }
    pending.push(...deferred);
  }
  return round;
}
const WAITING = {
  "centralized": schedule(DECISIONS, 3),
  "federated": new Map([...schedule(DECISIONS.filter((k) => k.scope === "shared"), 4),
                        ...DECISIONS.filter((k) => k.scope === "local").map((k) => [k.name, 0])]),
  "advisory": new Map([...schedule(DECISIONS.filter((k) => k.consulted), 5),
                       ...DECISIONS.filter((k) => !k.consulted).map((k) => [k.name, 0])]),
  "centralized + question round": schedule(DECISIONS, 3, (k) => (k.justification ? 1 : 0)),
};

writeFileSync("decisions.json", JSON.stringify(DECISIONS));
const violations = DECISIONS.filter(isViolation).length;
console.log(`decisions: ${DECISIONS.length} (${DECISIONS.filter((k) => k.scope === "shared").length} shared scope), ` +
  `known violations: ${violations}, with local justification: ${DECISIONS.filter((k) => k.justification).length}, ` +
  `with latent signal: ${DECISIONS.filter((k) => k.latent).length}`);
console.log(`\n${"model".padEnd(31)}${"avg. wait".padStart(11)}${"longest".padStart(9)}` +
  `${"caught".padStart(11)}${"missed".padStart(8)}${"false alarm".padStart(14)}${"conformance".padStart(13)}`);
for (const [modelName, decide] of Object.entries(MODEL)) {
  const y = DECISIONS.filter((k) => isViolation(k) && decide(k)).length;
  const kc = DECISIONS.filter((k) => isViolation(k) && !decide(k)).length;
  const ya = DECISIONS.filter((k) => !isViolation(k) && decide(k)).length;
  const wait = DECISIONS.map((k) => WAITING[modelName].get(k.name));
  const avg = wait.reduce((a, b) => a + b, 0) / wait.length;
  console.log(`${modelName.padEnd(31)}${avg.toFixed(2).padStart(11)}${String(Math.max(...wait)).padStart(9)}` +
    `${`${y}/${violations}`.padStart(11)}${String(kc).padStart(8)}${String(ya).padStart(14)}` +
    `${`${((100 * (DECISIONS.length - kc - ya)) / DECISIONS.length).toFixed(1)}%`.padStart(13)}`);
}

// source of the false alarm: local context, or rule version
console.log(`\nsource of false alarms (by decision name):`);
for (const [modelName, decide] of Object.entries(MODEL)) {
  const ya = DECISIONS.filter((k) => !isViolation(k) && decide(k));
  const ctx = ya.filter((k) => k.justification).length;
  console.log(`  ${modelName.padEnd(31)}${String(ya.length).padStart(3)} false alarm(s); ` +
    `${ctx} missed local context, ${ya.length - ctx} outdated rule version` +
    (ya.length ? ` (${ya.map((k) => k.name.split(" ")[0]).join(", ")})` : ""));
}
```

```
decisions: 24 (15 shared scope), known violations: 12, with local justification: 8, with latent signal: 4

model                            avg. wait  longest     caught  missed   false alarm  conformance
centralized                           4.50        8      12/12       0             8        66.7%
federated                             1.50        4       9/12       3             1        83.3%
advisory                              0.46        2       6/12       6             1        70.8%
centralized + question round          6.17       11      12/12       0             0       100.0%

source of false alarms (by decision name):
  centralized                      8 false alarm(s); 8 missed local context, 0 outdated rule version (branch, identity, address, hold, branch, membership, fee, search)
  federated                        1 false alarm(s); 0 missed local context, 1 outdated rule version (identity)
  advisory                         1 false alarm(s); 0 missed local context, 1 outdated rule version (notification)
  centralized + question round     0 false alarm(s); 0 missed local context, 0 outdated rule version
```

#### Reading the Three Models

The models differ in who sees what. **Centralized governance** reviews every decision in a single
board: there is one rule version, it sees the latent signal, but it does not know why the branch
put that cache in place. **Federated governance** leaves the decision to the unit, the unit sees
the local justification, and shared-scope decisions go to a board of representatives; the shared
review list also opens up the latent signal. **Advisory governance** leaves the decision entirely
to the unit, and the center speaks only when consulted.

The centralized model **catches all twelve of the twelve violations and misses none**, but it
produces eight false alarms and its conformance rate stays at 66.7%. **All eight of the eight false
alarms come from missed local context**: the board sees a decision's surface but not its
justification, so it blocks a legitimate decision. The average wait per decision is 4.5 rounds, the
longest wait is 8 rounds.

The advisory model does the opposite: the average wait drops to 0.46 rounds, but **six of the
twelve violations get through**. Four of the ones that get through are decisions with a latent
signal — with no review step, no one notices. Two were made under an outdated rule version.

The federated model gives the highest conformance of the three (83.3%) and does it with a
1.5-round wait. Its cost is three missed violations, and all three come from the same source: units
whose rule version is not current. Its single false alarm has the same source — a unit reading a
relaxed rule under an old version blocks a decision that is now free. **What the federated model
measures is not the rule itself, but the speed of propagation.**

The fourth row is not a model but a lower bound on the centralized model: it assumes the board
already knows which eight decisions have a local justification, and opens a question round only
for those. The result is complete — 12/12 caught, zero missed, zero false alarms, 100%
conformance — and its cost is the average wait rising from 4.5 to 6.17 rounds and the longest wait
rising from 8 to 11. This row is not a reachable way of working; it is the cheapest possible form
of the question round.

#### The Trade-off Between Speed and Consistency

A real board does not know in advance which decision has a justification; it asks the next
candidate its question, and most questions go to waste. The block below sweeps this: the board
opens a question round for the first `s` of the twenty decisions it would block from the surface.

```js
// tradeoff.mjs — reads the decision flow governance.mjs wrote; sweeps the centralized board's question count
import { readFileSync } from "node:fs";

const DECISIONS = JSON.parse(readFileSync("decisions.json", "utf8"));
const isViolation = (k) => (k.relaxed ? false : k.symptom && !k.justification);
const onSurface = (k) => k.symptom && !k.relaxed;               // what the board sees without asking

function waitTimes(asked) {                                     // an asked decision waits one extra round
  const pending = DECISIONS.map((k) => ({ k, left: asked.has(k.name) ? 2 : 1 }));
  const round = new Map(); let t = 0;
  while (pending.length) {
    t += 1; let cap = 3; const deferred = [];
    while (cap > 0 && pending.length) {
      const it = pending.shift(); cap -= 1; it.left -= 1;
      if (it.left === 0) round.set(it.k.name, t); else deferred.push(it);
    }
    pending.push(...deferred);
  }
  return [...round.values()];
}

const toBlock = DECISIONS.filter(onSurface);                    // the 20 decisions the board would look at
console.log(`decisions the board would block from the surface: ${toBlock.length}; ` +
  `${toBlock.filter((k) => k.justification).length} of them have a local justification`);
console.log(`\n${"asked".padStart(8)}${"avg. wait".padStart(13)}${"extra wait".padStart(12)}` +
  `${"longest".padStart(9)}${"false alarm".padStart(14)}${"alarm lifted".padStart(14)}` +
  `${"alarm / extra round".padStart(22)}`);
for (const s of [0, 4, 8, 12, 16, 20]) {
  const asked = new Set(toBlock.slice(0, s).map((k) => k.name));
  const decide = (k) => (asked.has(k.name) ? isViolation(k) : onSurface(k));
  const ya = DECISIONS.filter((k) => !isViolation(k) && decide(k)).length;
  const b = waitTimes(asked);
  const avg = b.reduce((a, c) => a + c, 0) / b.length;
  const extra = avg - 4.5;
  console.log(`${String(s).padStart(8)}${avg.toFixed(2).padStart(13)}${extra.toFixed(2).padStart(12)}` +
    `${String(Math.max(...b)).padStart(9)}${String(ya).padStart(14)}${String(8 - ya).padStart(14)}` +
    `${(s === 0 ? "-" : ((8 - ya) / extra).toFixed(2)).padStart(22)}`);
}
```

```
decisions the board would block from the surface: 20; 8 of them have a local justification

   asked    avg. wait  extra wait  longest   false alarm  alarm lifted   alarm / extra round
       0         4.50        0.00        8             8             0                     -
       4         5.83        1.33       10             6             2                  1.50
       8         7.13        2.63       11             5             3                  1.14
      12         8.38        3.88       12             4             4                  1.03
      16         9.54        5.04       14             2             6                  1.19
      20        10.67        6.17       15             0             8                  1.30
```

The first four questions remove two false alarms at the cost of 1.33 rounds of extra wait; asking
every candidate removes all eight and raises the average wait per decision from 4.5 to 10.67
rounds. The real measure is the last column: **false alarms lifted per extra wait round**. Because
the board cannot know in advance which candidate to ask, most questions go to waste — of twenty
questions, only eight lift an alarm.

**No model is good at both ends at once**: the centralized model zeroes out missed violations, the
advisory model zeroes out the wait, and the federated model gives the highest conformance between
the two and pays its cost in rule propagation.

#### Can a Governance Rule Be Converted to a Check

The entire model above rests on one assumption: the decision is reviewed by a human. This course's
question is whether the rule can be checked by machine instead. When the rule set is sorted with
that lens, two piles emerge.

```js
// rule.mjs — checkability of the governance rule set and what the sample catches
import { readFileSync } from "node:fs";

const RULES = [                                    // each line: rule | can it be checked by machine
  ["every shared front carries a version tag", true],
  ["no unit writes to another unit's table", true],
  ["every external call carries a timeout value", true],
  ["the identity token is not written to the log", true],
  ["a new dependency comes with a decision record", true],
  ["the boundary is drawn by change coupling", false],
  ["a decision is justified by the cost of at least two options", false],
  ["a local solution is discussed before it becomes a shared contract", false],
  ["a public name describes the module's job", false],
];
const checkable = RULES.filter(([, d]) => d);
console.log(`rules: ${RULES.length}; convertible to a check ${checkable.length}, not convertible ${RULES.length - checkable.length}`);

// GV3: what stands in for the rules that cannot be converted is a sample review of one in three decisions
const DECISIONS = JSON.parse(readFileSync("decisions.json", "utf8"));
const isViolation = (k) => (k.relaxed ? false : k.symptom && !k.justification);
const sample = DECISIONS.filter((_, i) => i % 3 === 2);
const caught = sample.filter(isViolation).length;
const total = DECISIONS.filter(isViolation).length;
console.log(`\nsample: ${sample.length} of the ${DECISIONS.length} decisions were reviewed (1/3); ` +
  `${caught} of the ${total} violations are inside the sample, ${total - caught} are outside`);
console.log(`review load: ${sample.length} decisions x ${RULES.length - checkable.length} ` +
  `non-convertible rules = ${sample.length * (RULES.length - checkable.length)} manual look(s)`);
```

```
rules: 9; convertible to a check 5, not convertible 4

sample: 8 of the 24 decisions were reviewed (1/3); 5 of the 12 violations are inside the sample, 7 are outside
review load: 8 decisions x 4 non-convertible rules = 32 manual look(s)
```

Five of the nine rules convert to a check: each corresponds to a pattern searchable in the source
or the configuration. Four cannot be converted, because what they measure is not the shape of the
text but the quality of the reasoning; a function that tests the "justified by the cost of at least
two options" rule can count whether a justification exists, but not whether it is sufficient.

What stands in for the four that cannot be converted is a sample review, and its cost runs two
ways: thirty-two manual looks across eight decisions for four rules, and a coverage gap — seven of
the twelve known violations fall outside the sample. The five rules converted to a check have no
such gap; they apply to every decision. **As the number of machine-checked rules rises, the number
a human has to review falls, and the board's waiting round drops.**

#### Summary

- The same 24-decision flow was run through three models; the centralized model catches 12 of 12
  violations and misses none, but it produces 8 false alarms and conformance stays at 66.7% — all
  8 alarms come from missed local context.
- The advisory model brings the wait down to 0.46 rounds and misses 6 of the 12 violations; 4 of
  the ones it misses are decisions with a latent signal that no review step ever sees.
- The federated model gives the highest conformance (83.3%, 1.50 rounds of wait), and its one
  weakness is rule propagation: the source of the 3 missed violations and the 1 false alarm is an
  outdated rule version.
- The centralized board reaches 100% conformance by asking about local context; the cost is the
  average wait rising from 4.50 to 10.67 rounds and the longest wait rising from 8 to 15, and only
  8 of 20 questions lift an alarm.
- 5 of the nine governance rules convert to a check, 4 do not; the 1/3 sample that stands in for
  the ones that cannot requires 32 manual looks and misses 7 of the 12 violations.

#### Next Step

This lesson's last table drew a boundary: five of the nine rules can be checked by machine. The
cost of converting those five into checks has not been counted yet. Converting an architectural
rule into a runnable function means reducing the rule to a **number** and a **threshold**; the
moment the threshold is chosen, both missed violations and false alarms appear. The next lesson
actually writes a fitness function: the function that converts an architectural rule into a number
is run against a known violation set, the threshold is swept end to end, and at each threshold the
number of violations caught, the number missed, and the number of false alarms produced are all
counted. The same run asks a second question — what behavior a rule that produces false alarms
creates on the team, and how many violations get through once the rule is disabled.

### Fitness Functions

Source: https://academia.sh/en/courses/architecture-governance/fitness-functions

Converting an architectural rule into a runnable number: writing two fitness functions over real module files, counting caught and missed violations plus false alarms against a known leak set, sweeping the threshold end to end, the rule's line-count and file-reading cost, and the false alarm's effect of getting the rule disabled.

The previous lesson wrote that five of the nine governance rules could be converted to a check, but
it did not count the cost of that conversion. Conversion means this: the rule is reduced to a
**number** and a **threshold**. The function that produces the number is called a **fitness
function**; it reads the source, the configuration, or a graph, and returns a single quantity. The
threshold is the decision itself — above this number is a violation, below it is compliant.

The moment a threshold is chosen, two errors appear at once. A loose threshold lets real violations
pass underneath; a tight one flags compliant modules as violations. This pair is the same
phenomenon as the missed defect and the false pass in quality metrics; the difference here is that
the one making the error is an architectural rule, not a test. This lesson actually writes a
fitness function, runs it against a known leak set, and counts three numbers at once: caught,
missed, false alarm. The through-line is the regional library network, and it is fiction.

#### The Module Set to Measure

The rule to be checked is this: **a module does not expose more to the outside than it needs to do
its job.** As a sentence, it cannot be checked; to be checkable, "more" has to be tied to a number.
The block below writes the codebase to be measured to disk: twelve modules, each exporting its own
public symbols and importing others' symbols.

**GV4 — the regional library network's codebase consists of twelve modules, and the number of
symbols each module exports and who imports them is produced from the map below.** Reason: a
fitness function must read real files; the map is the input that generates the files to be read.
If the numbers change, the threshold sweep below changes with them; the function itself does not.

```js
// setup.mjs — writes the regional library network's modules to disk with their exported symbols
import { mkdirSync, writeFileSync } from "node:fs";

// open: number of exported symbols | consumer: which module imports which symbol indices
const MODULES = {
  sharedFormat:       { open: 9, consumer: { reporting: [0, 1, 2], loanFlow: [0, 3, 4],
                                             branchFront: [1, 5], settings: [2] } },
  branchFront:        { open: 11, consumer: { reporting: [0, 1, 2] } },
  catalogBridge:      { open: 7, consumer: { loanFlow: [0, 1, 2, 3, 4, 5, 6] } },
  loanFlow:           { open: 8, consumer: { branchFront: [0, 1, 2, 3], reporting: [4, 5],
                                             feeCalculation: [6] } },
  feeCalculation:     { open: 4, consumer: { branchFront: [0] } },
  membershipRegistry: { open: 3, consumer: { branchFront: [0, 1], loanFlow: [2] } },
  identityBridge:     { open: 2, consumer: { branchFront: [0], membershipRegistry: [1] } },
  notificationQueue:  { open: 5, consumer: { loanFlow: [0] } },
  reporting:          { open: 6, consumer: { branchFront: [0, 1, 2, 3, 4, 5] } },
  storageAccess:      { open: 3, consumer: { loanFlow: [0] } },
  eventLog:           { open: 4, consumer: { loanFlow: [0] } },
  settings:           { open: 4, consumer: { branchFront: [0], loanFlow: [1], reporting: [2],
                                             feeCalculation: [3] } },
};

mkdirSync("modules", { recursive: true });
for (const name of Object.keys(MODULES)) {
  const imports = Object.entries(MODULES)
    .filter(([, m]) => m.consumer[name])
    .map(([source, m]) => `import { ${m.consumer[name].map((i) => `${source}${i + 1}`).join(", ")} }` +
      ` from "./${source}.mjs";`);
  const exports = Array.from({ length: MODULES[name].open },
    (_, i) => `export const ${name}${i + 1} = (x) => x;`);
  writeFileSync(`modules/${name}.mjs`, `${imports.join("\n")}\n${exports.join("\n")}\n`);
}

const edges = Object.values(MODULES).reduce((s, m) => s + Object.keys(m.consumer).length, 0);
const symbols = Object.values(MODULES).reduce((s, m) => s + m.open, 0);
console.log(`${Object.keys(MODULES).length} module files written; ` +
  `${symbols} exported symbols, ${edges} import edges`);
console.log(`\n${"module".padEnd(21)}${"exported".padStart(10)}   importing module`);
for (const [name, m] of Object.entries(MODULES)) {
  console.log(`${name.padEnd(21)}${String(m.open).padStart(10)}   ` +
    `${Object.keys(m.consumer).join(", ") || "-"}`);
}
```

```
12 module files written; 66 exported symbols, 22 import edges

module                 exported   importing module
sharedFormat                  9   reporting, loanFlow, branchFront, settings
branchFront                  11   reporting
catalogBridge                 7   loanFlow
loanFlow                      8   branchFront, reporting, feeCalculation
feeCalculation                4   branchFront
membershipRegistry            3   branchFront, loanFlow
identityBridge                2   branchFront, membershipRegistry
notificationQueue             5   loanFlow
reporting                     6   branchFront
storageAccess                 3   loanFlow
eventLog                      4   loanFlow
settings                      4   branchFront, loanFlow, reporting, feeCalculation
```

#### The Function That Converts the Rule to a Number

The same rule can be converted into two different numbers. **F1 — surface size** is the cheapest
conversion: it counts the symbols a module exports and reads a single file. **F2 — dead surface
ratio** is more expensive: to find how many exported symbols are never imported by any module, it
reads the entire codebase. Both are candidates for the same rule; which one is better can only be
said by testing against a known violation set.

```js
// fitness.mjs — two fitness functions that convert an architectural rule into a number, and a threshold sweep
import { readFileSync, readdirSync } from "node:fs";

const DIR = "modules";
const files = readdirSync(DIR);

// >>> F1  surface size: the module's own file is enough
function F1(name) {
  const t = readFileSync(`${DIR}/${name}.mjs`, "utf8");
  return [...t.matchAll(/^export const (\w+)/gm)].length;
}
// <<< F1

// >>> F2  dead surface ratio: how many exported symbols are never imported
function F2(name) {
  const open = new Set([...readFileSync(`${DIR}/${name}.mjs`, "utf8")
    .matchAll(/^export const (\w+)/gm)].map((m) => m[1]));
  const used = new Set();
  for (const d of files) {
    for (const m of readFileSync(`${DIR}/${d}`, "utf8")
      .matchAll(/^import \{ ([^}]+) \} from "\.\/(\w+)\.mjs"/gm)) {
      if (m[2] === name) for (const s of m[1].split(", ")) used.add(s);
    }
  }
  return (open.size - used.size) / open.size;
}
// <<< F2

// GV5: the architect reviewed the twelve modules by hand and marked five as a real surface leak. branchFront and
// feeCalculation carry dead symbols; notificationQueue and storageAccess expose internal detail; catalogBridge
// opens all seven of its symbols to a single consumer, carrying its internal representation outward.
const LEAK = new Set(["branchFront", "catalogBridge", "feeCalculation", "notificationQueue", "storageAccess"]);
const names = files.map((d) => d.replace(".mjs", ""));

console.log(`${"module".padEnd(21)}${"F1 surface".padStart(12)}${"F2 dead ratio".padStart(15)}${"architect label".padStart(18)}`);
for (const a of names) {
  console.log(`${a.padEnd(21)}${String(F1(a)).padStart(12)}${F2(a).toFixed(2).padStart(15)}` +
    `${(LEAK.has(a) ? "leak" : "compliant").padStart(18)}`);
}

function sweep(fn, thresholds, label) {
  console.log(`\n${label.padEnd(15)}${"flagged".padStart(11)}${"caught".padStart(11)}` +
    `${"missed".padStart(8)}${"false alarm".padStart(14)}`);
  for (const e of thresholds) {
    const flagged = names.filter((a) => fn(a) > e);
    const y = flagged.filter((a) => LEAK.has(a)).length;
    console.log(`${`threshold > ${e}`.padEnd(15)}${String(flagged.length).padStart(11)}` +
      `${`${y}/${LEAK.size}`.padStart(11)}${String(LEAK.size - y).padStart(8)}` +
      `${String(flagged.length - y).padStart(14)}`);
  }
}
sweep(F1, [2, 3, 4, 5, 6, 7, 8, 10], "F1 surface");
sweep(F2, [0.2, 0.4, 0.5, 0.6, 0.7], "F2 dead ratio");

// the rule's own cost: lines, files read, run
const source = readFileSync("fitness.mjs", "utf8").split("\n");
const lines = (m) => {
  const b = source.findIndex((l) => l.startsWith(`// >>> ${m}`));
  return source.findIndex((l) => l.startsWith(`// <<< ${m}`)) - b - 1;
};
const totalLines = names.reduce((s, a) => s + readFileSync(`${DIR}/${a}.mjs`, "utf8").split("\n").length, 0);
console.log(`\n${"function".padEnd(12)}${"rule lines".padStart(13)}${"files read".padStart(13)}` +
  `${"lines scanned".padStart(15)}`);
console.log(`${"F1".padEnd(12)}${String(lines("F1")).padStart(13)}${String(names.length).padStart(13)}` +
  `${String(totalLines).padStart(15)}`);
console.log(`${"F2".padEnd(12)}${String(lines("F2")).padStart(13)}` +
  `${String(names.length * (1 + files.length)).padStart(13)}${String((1 + names.length) * totalLines).padStart(15)}`);
console.log("run time depends on the environment; the quantities printed here are environment-independent " +
  `(F2 / F1 read ratio ${(names.length * (1 + files.length)) / names.length}x)`);
```

```
module                 F1 surface  F2 dead ratio   architect label
branchFront                    11           0.73              leak
catalogBridge                   7           0.00              leak
eventLog                        4           0.75         compliant
feeCalculation                  4           0.75              leak
identityBridge                  2           0.00         compliant
loanFlow                        8           0.13         compliant
membershipRegistry              3           0.00         compliant
notificationQueue               5           0.80              leak
reporting                       6           0.00         compliant
settings                        4           0.00         compliant
sharedFormat                    9           0.33         compliant
storageAccess                   3           0.67              leak

F1 surface         flagged     caught  missed   false alarm
threshold > 2           11        5/5       0             6
threshold > 3            9        4/5       1             5
threshold > 4            6        3/5       2             3
threshold > 5            5        2/5       3             3
threshold > 6            4        2/5       3             2
threshold > 7            3        1/5       4             2
threshold > 8            2        1/5       4             1
threshold > 10           1        1/5       4             0

F2 dead ratio      flagged     caught  missed   false alarm
threshold > 0.2          6        4/5       1             2
threshold > 0.4          5        4/5       1             1
threshold > 0.5          5        4/5       1             1
threshold > 0.6          5        4/5       1             1
threshold > 0.7          4        3/5       2             1

function       rule lines   files read  lines scanned
F1                      4           12            106
F2                     12          156           1378
run time depends on the environment; the quantities printed here are environment-independent (F2 / F1 read ratio 13x)
```

#### Reading the Threshold

The F1 sweep shows plainly what the threshold is. At the loosest end (`> 2`), all five of the five
leaks are caught and none get through — but eleven of the twelve modules get flagged, and six of
those are false alarms. At the tightest end (`> 10`), false alarms drop to zero and four leaks get
through. There is no threshold in between where both missing and false alarms are low at once: the
`> 4` threshold catches three, misses two, and gives three false alarms. **F1 cannot separate
missing from false alarms, because the number it measures is not even close to what the rule is
asking** — `sharedFormat`, six of whose nine symbols are used, and `storageAccess`, two of whose
three symbols are dead, sit on the same axis.

The F2 sweep lands somewhere different against the same violation set. Between `> 0.4` and `> 0.6`
the result does not change: four caught, one missed, one false alarm. This flatness counts as a
good sign — if the threshold's exact value does not determine the outcome, the rule is not overly
sensitive to the threshold. F2's one miss is `catalogBridge`, and the reason is in the definition
itself: all seven of its seven symbols are imported, so its dead surface is zero; the reason the
architect calls it a leak is not that symbols go unused but that all of them are opened to a single
consumer. F2's one false alarm is `eventLog`: three of its four symbols are open for operational
tools outside the graph, and the checker does not see those tools.

**The rule is defined inside the code, but the rule itself cannot be derived from the code.** F2
sits much closer to the architect's label than F1, but both are approximations; no threshold
classifies the `catalogBridge`/`eventLog` pair correctly at the same time.

#### The Rule's Own Cost

The last table is the rule's cost. F1 is four lines and, on a full run, reads twelve files and 106
lines. F2 is twelve lines and, on a full run, reads 156 files and 1,378 lines — because it scans
the whole directory for every module, its reading is thirteen times as much. Because run time
depends on the environment, the quantities printed are the environment-independent ones: files
read and lines scanned. As the module count grows, F2's reading grows quadratically, F1's grows
linearly.

What follows from this is that the way to make the rule cheaper is to lower the read count without
breaking the measure: F2 could read every file once instead of once per module and build the import
map in memory. But the real cost this lesson needs to count is not the run — it is
**maintenance**: because F2 parses the import syntax, every new form of import syntax breaks the
rule; the only thing F1 parses is the `export` line.

#### The Behavior a False Alarm Produces

A false alarm is not a number, it is a behavior. When a rule produces a false alarm, the team first
reviews it, then adds an exception, and eventually closes the rule down. The block below models
this.

```js
// circuit.mjs — the behavior a false alarm produces on the team: the rule goes offline on its third alarm
import { readFileSync, readdirSync } from "node:fs";

const DIR = "modules";
const files = readdirSync(DIR);
const openSymbols = (a) => [...readFileSync(`${DIR}/${a}.mjs`, "utf8")
  .matchAll(/^export const (\w+)/gm)].map((m) => m[1]);
const F1 = (a) => openSymbols(a).length;
const F2 = (a) => {
  const open = new Set(openSymbols(a));
  const used = new Set();
  for (const d of files) {
    for (const m of readFileSync(`${DIR}/${d}`, "utf8")
      .matchAll(/^import \{ ([^}]+) \} from "\.\/(\w+)\.mjs"/gm)) {
      if (m[2] === a) for (const s of m[1].split(", ")) used.add(s);
    }
  }
  return (open.size - used.size) / open.size;
};
const LEAK = new Set(["branchFront", "catalogBridge", "feeCalculation", "notificationQueue", "storageAccess"]);

// GV6: each of the twenty releases touches a single module, and the rule only runs on the touched module. GV7:
// the team takes the rule offline after the third false alarm (disabling it instead of loosening the threshold).
const RELEASES = ["settings", "branchFront", "loanFlow", "reporting", "feeCalculation", "sharedFormat",
  "catalogBridge", "eventLog", "storageAccess", "loanFlow", "notificationQueue", "membershipRegistry",
  "reporting", "branchFront", "settings", "identityBridge", "feeCalculation", "sharedFormat",
  "storageAccess", "catalogBridge"];

function run(fn, threshold) {
  let alarms = 0, active = true, caught = 0, missed = 0, closedAt = 0, missedAfterClosed = 0;
  RELEASES.forEach((m, i) => {
    if (!active) { if (LEAK.has(m)) { missed += 1; missedAfterClosed += 1; } return; }
    const flagged = fn(m) > threshold;
    if (flagged && LEAK.has(m)) caught += 1;
    else if (!flagged && LEAK.has(m)) missed += 1;
    else if (flagged) {
      alarms += 1;
      if (alarms === 3) { active = false; closedAt = i + 1; }
    }
  });
  return { alarms, caught, missed, closedAt, missedAfterClosed };
}

const leakyReleases = RELEASES.filter((m) => LEAK.has(m)).length;
console.log(`${RELEASES.length} releases, ${leakyReleases} of them touch a leaking module`);
console.log(`\n${"rule".padEnd(19)}${"false alarm".padStart(11)}${"closed at release".padStart(19)}` +
  `${"caught".padStart(11)}${"missed".padStart(8)}${"missed after closed".padStart(22)}`);
for (const [label, fn, threshold] of [["F1 threshold > 3", F1, 3], ["F2 threshold > 0.5", F2, 0.5]]) {
  const r = run(fn, threshold);
  console.log(`${label.padEnd(19)}${String(r.alarms).padStart(11)}` +
    `${(r.closedAt ? String(r.closedAt) : "not closed").padStart(19)}` +
    `${`${r.caught}/${leakyReleases}`.padStart(11)}${String(r.missed).padStart(8)}` +
    `${String(r.missedAfterClosed).padStart(22)}`);
}

// GV8: what stands in for the rule that cannot be converted is manually reading every third symbol
const symbols = files.flatMap((d) => openSymbols(d.replace(".mjs", "")));
const sample = symbols.filter((_, i) => i % 3 === 2);
console.log(`\nnon-convertible rule ("a public name describes the module's job"): ` +
  `${sample.length} of ${symbols.length} symbols are read manually, ${symbols.length - sample.length} are not read`);
```

```
20 releases, 9 of them touch a leaking module

rule               false alarm  closed at release     caught  missed   missed after closed
F1 threshold > 3             3                  4        1/9       8                     8
F2 threshold > 0.5           1         not closed        7/9       2                     0

non-convertible rule ("a public name describes the module's job"): 22 of 66 symbols are read manually, 44 are not read
```

The result is different from what the threshold sweep alone said. When the F1 threshold was set at
`> 3`, the sweep table showed it catching four of the five leaks; in the twenty-release run, though,
**it closes on the fourth release** and looks at nothing for the remaining sixteen. Of the nine
releases with a violation, only one is caught, eight get through, and all eight of those eight come
after the rule closed. F2 gives a single false alarm in the same run, never closes, and catches
seven of the nine releases with a violation.

The difference does not come from the choice of threshold; it comes from the false alarm rate. From
the moment a rule closes, what it measures is zero; so a rule's quality is measured not by its
catch count in a single run, but by **how many releases it stays open on the team**. The rule's
non-convertible part stands there by name: no function can say whether an exported name describes
the module's job, and the sample that stands in for it reads 22 of the 66 symbols; 44 are never
read.

#### Summary

- The same architectural rule was converted into two fitness functions: F1 surface size (number of
  exported symbols), F2 dead surface ratio (share of symbols never imported); both read real
  module files.
- No threshold is good in F1's sweep: `> 2` catches five of five leaks and gives 6 false alarms,
  `> 10` zeroes out the false alarms and misses 4 leaks.
- F2 does not change between `> 0.4` and `> 0.6` against the same violation set (4 caught, 1
  missed, 1 false alarm), and insensitivity to the threshold is a sign of the rule's robustness;
  both of its errors come from the definition — it misses `catalogBridge` (all seven of its
  symbols are used), and gives a false alarm on `eventLog` (three of its symbols are open for
  tools outside the graph).
- The rule's cost is 4 lines, 12 files, and 106 lines read for F1; 12 lines, 156 files, and 1,378
  lines read for F2; the gap is 13-fold and grows quadratically with the module count.
- In the twenty-release run, F1 closes on the fourth release at its third false alarm and misses 8
  of 9 releases with a violation; F2 stays open with a single false alarm and catches 7 of them.
- The non-convertible rule was written down by name ("a public name describes the module's job"),
  and the sample that stands in for it reads 22 of 66 symbols.

#### Next Step

This lesson's fitness function looked at a single module's surface and used its neighbors only
while counting symbols. A portion of architectural rules, though, never shows up in a single
module at all: which module may import which module lives in the codebase's **graph**, and can
only be checked by reading the whole graph. The next lesson writes that rule: a layer checker is
built that runs on a real import graph, and the real question is not the checker but the rule
itself — the same layer rule can be written as an allowlist form or a denylist form. Both forms are
run on the same graph and three numbers are compared: which gives fewer false alarms, which misses
more violations, and how many places the rule has to be updated when a new module is added to the
codebase.

### Dependency and Layer Control

Source: https://academia.sh/en/courses/architecture-governance/dependency-and-layer-control

Testing structural architectural rules against a real import graph: writing the same layer rule in allowlist and denylist form, comparing the caught, missed, and false alarm counts of five rule forms against a known violation set, the aging of sanctioned exceptions, and counting how many places a rule updates when a new module is added.

The previous lesson's fitness function looked at a single module's file. A portion of
architectural rules, though, never shows up in a single file: which module may import which module
can only be checked by reading the entire graph. **Layer check** and **dependency check** are the
names for this kind of rule, and both read the same data — the codebase's import graph.

This graph itself was measured before: the Service Boundaries and Communication course counted
boundary violations from the same kind of import graph. The question here is not that count. **The
question here is the rule itself.** The same layer rule can be written in at least two forms —
allowlist ("these edge types are free") and denylist ("these edge types are forbidden") — and the
two forms give different results on the same graph. The lesson measures this difference with three
numbers: which gives fewer false alarms, which misses more violations, and how many places the
rule updates when a new module enters the codebase. The through-line is the regional library
network, and it is fiction.

#### The Import Graph to Check

**GV9 — the codebase consists of thirteen modules, each module declares its own layer in its
source, and there are five layers: entry, application, domain, infrastructure, and shared.**
Reason: a layer check can only run on a codebase where every module is assigned to a layer; keeping
the layer declaration in the source means the checker does not depend on an external map.

```js
// setup.mjs — writes the regional library network's codebase to disk with its layers
import { mkdirSync, writeFileSync } from "node:fs";

const LAYER = {
  branchFront: "entry", staffFront: "entry",
  loanFlow: "application", feeFlow: "application", membershipFlow: "application",
  loanRule: "domain", feeRule: "domain", catalogRule: "domain",
  storageAccess: "infrastructure", catalogBridge: "infrastructure", identityBridge: "infrastructure",
  eventLog: "infrastructure", sharedFormat: "shared",
};

const EDGES = [
  "branchFront->loanFlow", "branchFront->feeFlow", "branchFront->sharedFormat",
  "staffFront->membershipFlow", "staffFront->loanFlow", "staffFront->sharedFormat",
  "loanFlow->loanRule", "loanFlow->catalogRule", "loanFlow->storageAccess",
  "loanFlow->catalogBridge", "loanFlow->sharedFormat",
  "feeFlow->feeRule", "feeFlow->storageAccess", "feeFlow->sharedFormat",
  "membershipFlow->identityBridge", "membershipFlow->sharedFormat",
  "loanRule->sharedFormat", "feeRule->sharedFormat", "catalogRule->sharedFormat",
  "storageAccess->sharedFormat", "eventLog->sharedFormat", "catalogBridge->sharedFormat",
  "catalogBridge->catalogRule", "identityBridge->sharedFormat",
  "branchFront->catalogBridge", "feeFlow->loanFlow",          // the two edges with an exception record
  "loanRule->storageAccess", "feeRule->catalogBridge", "storageAccess->loanFlow",
  "branchFront->storageAccess", "branchFront->catalogRule", "sharedFormat->eventLog",
  "loanFlow->identityBridge", "feeFlow->catalogBridge",
];

mkdirSync("code", { recursive: true });
for (const name of Object.keys(LAYER)) {
  const imports = EDGES.filter((k) => k.split("->")[0] === name)
    .map((k) => `import { ${k.split("->")[1]}Op } from "./${k.split("->")[1]}.mjs";`);
  writeFileSync(`code/${name}.mjs`, `// layer: ${LAYER[name]}\n${imports.join("\n")}\n` +
    `export const ${name}Op = (x) => x;\n`);
}

const L = ["entry", "application", "domain", "infrastructure", "shared"];
const count = {};
for (const k of EDGES) {
  const [a, b] = k.split("->");
  count[`${LAYER[a]}->${LAYER[b]}`] = (count[`${LAYER[a]}->${LAYER[b]}`] ?? 0) + 1;
}
console.log(`${Object.keys(LAYER).length} module files, ${EDGES.length} import edges`);
console.log(`\nlayer-to-layer edge count (row: importer, column: imported)`);
console.log(`${"".padEnd(16)}${L.map((k) => k.padStart(16)).join("")}`);
for (const a of L) {
  console.log(`${a.padEnd(16)}${L.map((b) => String(count[`${a}->${b}`] ?? 0).padStart(16)).join("")}`);
}
```

```
13 module files, 34 import edges

layer-to-layer edge count (row: importer, column: imported)
                           entry     application          domain  infrastructure          shared
entry                          0               4               1               2               2
application                    0               1               3               6               3
domain                         0               0               0               2               3
infrastructure                 0               1               1               0               4
shared                         0               0               0               1               0
```

The matrix shows which cells the rule is broken in before the rule is even written: the domain row
has two edges to infrastructure, the entry row has one edge to domain and two to infrastructure,
the shared row has one edge to infrastructure. **Writing the rule means naming these cells** — and
there are two separate ways to name them.

#### The Same Rule in Five Forms

The checker reads the files, pulls the layer from the `// layer:` line, and the edge from the
`from "./..."` lines. The same rule is written in five forms: an allowlist at layer granularity,
an exception list added to that (with its expiry checked and unchecked), an allowlist at module
granularity, and a denylist at layer granularity.

**GV10 — the architect read all thirty-four edges by hand and marked nine of them as a real
violation; two edges carry a sanctioned exception record, and one of them has expired.** Reason:
catching, missing, and false alarm can only be counted against a hand-labeled set; the exception
records are part of that set too, because counting an approved edge as a violation is a false
alarm.

```js
// layer.mjs — reads the real import graph from the code/ directory; the same rule is run in five forms
import { readFileSync, readdirSync, writeFileSync } from "node:fs";

function graph(dir = "code") {
  const layer = {}, edges = [];
  for (const d of readdirSync(dir)) {
    const name = d.replace(".mjs", "");
    const t = readFileSync(`${dir}/${d}`, "utf8");
    layer[name] = t.match(/^\/\/ layer: (\w+)$/m)[1];
    for (const m of t.matchAll(/from "\.\/(\w+)\.mjs"/g)) edges.push(`${name}->${m[1]}`);
  }
  return { layer, edges };
}
const { layer, edges } = graph();
const lay = (k) => `${layer[k.split("->")[0]]}->${layer[k.split("->")[1]]}`;

// GV10: the architect read all thirty-four edges by hand; the nine below are real violations. The
// exception record on branchFront->catalogBridge is valid; the exception on feeFlow->loanFlow has expired.
const VIOLATIONS = new Set([
  "feeFlow->loanFlow",
  "loanRule->storageAccess", "feeRule->catalogBridge", "storageAccess->loanFlow",
  "branchFront->storageAccess", "branchFront->catalogRule", "sharedFormat->eventLog",
  "loanFlow->identityBridge", "feeFlow->catalogBridge",
]);
const QUARTER = 4;
const EXCEPTIONS = [                                   // edge | end quarter | reason
  ["branchFront->catalogBridge", 5, "migration period: branch front reads the old catalog bridge"],
  ["feeFlow->loanFlow", 2, "temporary: fee flow reads from loan flow"],
];

const LAYER_ALLOWLIST = ["entry->application", "entry->shared", "application->domain", "application->infrastructure",
  "application->shared", "domain->shared", "infrastructure->domain", "infrastructure->shared"];
const LAYER_DENYLIST = ["domain->infrastructure", "domain->application", "domain->entry",
  "infrastructure->application", "infrastructure->entry", "application->entry"];
const MODULE_ALLOWLIST = [
  "branchFront->loanFlow", "branchFront->feeFlow", "branchFront->sharedFormat",
  "staffFront->membershipFlow", "staffFront->loanFlow", "staffFront->sharedFormat",
  "loanFlow->loanRule", "loanFlow->catalogRule", "loanFlow->storageAccess",
  "loanFlow->catalogBridge", "loanFlow->sharedFormat",
  "feeFlow->feeRule", "feeFlow->storageAccess", "feeFlow->sharedFormat",
  "membershipFlow->identityBridge", "membershipFlow->sharedFormat",
  "loanRule->sharedFormat", "feeRule->sharedFormat", "catalogRule->sharedFormat",
  "storageAccess->sharedFormat", "eventLog->sharedFormat", "catalogBridge->sharedFormat",
  "catalogBridge->catalogRule", "identityBridge->sharedFormat",
  "branchFront->catalogBridge",
];

writeFileSync("module_allowlist.json", JSON.stringify(MODULE_ALLOWLIST));

const allowedEdge = (checkExpiry) => new Set(EXCEPTIONS
  .filter(([, end]) => !checkExpiry || end >= QUARTER).map(([k]) => k));
const FORMS = {
  "layer allowlist":               { n: LAYER_ALLOWLIST.length, f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) },
  "layer allowlist + exception":   { n: LAYER_ALLOWLIST.length + EXCEPTIONS.length,
    f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) && !allowedEdge(false).has(k) },
  "layer allowlist + timed exc.":  { n: LAYER_ALLOWLIST.length + EXCEPTIONS.length,
    f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) && !allowedEdge(true).has(k) },
  "module allowlist":              { n: MODULE_ALLOWLIST.length, f: (k) => !MODULE_ALLOWLIST.includes(k) },
  "layer denylist":                { n: LAYER_DENYLIST.length, f: (k) => LAYER_DENYLIST.includes(lay(k)) },
};

console.log(`${edges.length} edges read; known violations ${VIOLATIONS.size}, exception records ${EXCEPTIONS.length} ` +
  `(${EXCEPTIONS.filter(([, b]) => b < QUARTER).length} expired)`);
console.log(`\n${"rule form".padEnd(29)}${"rule lines".padStart(11)}${"flagged".padStart(13)}` +
  `${"caught".padStart(11)}${"missed".padStart(7)}${"false alarm".padStart(14)}`);
for (const [name, b] of Object.entries(FORMS)) {
  const flagged = edges.filter(b.f);
  const y = flagged.filter((k) => VIOLATIONS.has(k)).length;
  console.log(`${name.padEnd(29)}${String(b.n).padStart(11)}${String(flagged.length).padStart(13)}` +
    `${`${y}/${VIOLATIONS.size}`.padStart(11)}${String(VIOLATIONS.size - y).padStart(7)}` +
    `${String(flagged.length - y).padStart(14)}`);
}

console.log(`\nmissed violations (by rule form):`);
for (const [name, b] of Object.entries(FORMS)) {
  const missed = [...VIOLATIONS].filter((k) => !b.f(k));
  console.log(`  ${name.padEnd(29)}${missed.length ? missed.map((k) => `${k} (${lay(k)})`).join("; ") : "-"}`);
}
```

```
34 edges read; known violations 9, exception records 2 (1 expired)

rule form                     rule lines      flagged     caught missed   false alarm
layer allowlist                        8            8        7/9      2             1
layer allowlist + exception           10            6        6/9      3             0
layer allowlist + timed exc.          10            7        7/9      2             0
module allowlist                      25            9        9/9      0             0
layer denylist                         6            3        3/9      6             0

missed violations (by rule form):
  layer allowlist              loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
  layer allowlist + exception  feeFlow->loanFlow (application->application); loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
  layer allowlist + timed exc. loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
  module allowlist             -
  layer denylist               feeFlow->loanFlow (application->application); branchFront->storageAccess (entry->infrastructure); branchFront->catalogRule (entry->domain); sharedFormat->eventLog (shared->infrastructure); loanFlow->identityBridge (application->infrastructure); feeFlow->catalogBridge (application->infrastructure)
```

#### The Difference Between an Allowlist and a Denylist

The table breaks a common expectation. **The denylist gives zero false alarms and misses six of
nine violations.** The reason is in the form itself: a denylist only sees the edge types it
enumerates. `domain->infrastructure` and `infrastructure->application` are written into the list,
so three violations are caught; but `entry->infrastructure`, `entry->domain`,
`shared->infrastructure`, and the same-layer `application->application` are not in the list, and
those edges are never seen at all. The more incomplete a denylist is, the more silent it is, and
nowhere does its incompleteness show.

**An allowlist works the opposite way: everything not in the list is a violation.** The
layer-granularity allowlist catches seven of nine violations with eight lines. The two it misses
are the limit of the rule's expressive power: `loanFlow->identityBridge` and
`feeFlow->catalogBridge` fall into the `application->infrastructure` pair, which is free at the
layer level; a rule that says both are forbidden has to be written by module name, not by layer.
In exchange, the allowlist has one false alarm, and that alarm flags an approved migration edge.

The exception list removes that false alarm but brings its own flaw. In the exception list whose
expiry is not checked, the catch count drops from 7 to 6: an exception whose term ended two
quarters ago is still hiding a real violation. When the expiry is checked, the catch count returns
to 7 and the false alarm count stays at zero. **An exception list erodes the rule itself for as
long as its expiry goes unchecked** — and the size of that erosion is exactly the number of
expired exceptions.

The module-granularity allowlist is flawless in the table: all nine of nine violations are caught,
nothing gets through, no false alarms. Its cost shows up in the line count — twenty-five lines
instead of eight, meaning the rule is no longer a summary of the architecture but a full copy of
the graph. Its real cost does not show up in this table at all; the next run measures it.

#### When a New Module Enters

A rule's maintenance cost is zero for as long as the codebase stands still. The block below adds a
new module to the codebase and runs the same rules in their unupdated form.

```js
// new.mjs — a new module is added to the codebase; how many places does each rule form update
import { readFileSync, readdirSync, writeFileSync } from "node:fs";

// GV11: in the next quarter a single new module enters the codebase and opens six new import edges
const NEW = "reservationFlow";
const NEW_EDGES = [                                            // five comply with the rule, one is a violation
  `branchFront->${NEW}`, `staffFront->${NEW}`, `${NEW}->loanRule`,
  `${NEW}->storageAccess`, `${NEW}->sharedFormat`, `${NEW}->staffFront`,
];
writeFileSync(`code/${NEW}.mjs`, `// layer: application\n` +
  NEW_EDGES.filter((k) => k.startsWith(`${NEW}->`))
    .map((k) => `import { ${k.split("->")[1]}Op } from "./${k.split("->")[1]}.mjs";`).join("\n") +
  `\nexport const ${NEW}Op = (x) => x;\n`);
for (const k of NEW_EDGES.filter((k) => k.endsWith(`->${NEW}`))) {
  const a = k.split("->")[0];
  const t = readFileSync(`code/${a}.mjs`, "utf8");
  writeFileSync(`code/${a}.mjs`, t.replace(/\n/, `\nimport { ${NEW}Op } from "./${NEW}.mjs";\n`));
}

const layer = {}, edges = [];
for (const d of readdirSync("code")) {
  const name = d.replace(".mjs", "");
  const t = readFileSync(`code/${d}`, "utf8");
  layer[name] = t.match(/^\/\/ layer: (\w+)$/m)[1];
  for (const m of t.matchAll(/from "\.\/(\w+)\.mjs"/g)) edges.push(`${name}->${m[1]}`);
}
const lay = (k) => `${layer[k.split("->")[0]]}->${layer[k.split("->")[1]]}`;

const LAYER_ALLOWLIST = ["entry->application", "entry->shared", "application->domain", "application->infrastructure",
  "application->shared", "domain->shared", "infrastructure->domain", "infrastructure->shared"];
const LAYER_DENYLIST = ["domain->infrastructure", "domain->application", "domain->entry",
  "infrastructure->application", "infrastructure->entry", "application->entry"];
const MODULE_ALLOWLIST_OLD = JSON.parse(readFileSync("module_allowlist.json", "utf8"));
const compliantNew = NEW_EDGES.filter((k) => LAYER_ALLOWLIST.includes(lay(k)));
const MODULE_ALLOWLIST_NEW = [...MODULE_ALLOWLIST_OLD, ...compliantNew];

const FORMS = {
  "layer allowlist":            { data: LAYER_ALLOWLIST, f: (k) => !LAYER_ALLOWLIST.includes(lay(k)) },
  "layer denylist":             { data: LAYER_DENYLIST, f: (k) => LAYER_DENYLIST.includes(lay(k)) },
  "module allowlist (old)":     { data: MODULE_ALLOWLIST_OLD, f: (k) => !MODULE_ALLOWLIST_OLD.includes(k) },
  "module allowlist (updated)": { data: MODULE_ALLOWLIST_NEW, f: (k) => !MODULE_ALLOWLIST_NEW.includes(k) },
};
const NEW_VIOLATIONS = new Set([`${NEW}->staffFront`]);

console.log(`new module: ${NEW} (layer: application), ${NEW_EDGES.length} new edges; ` +
  `${compliantNew.length} comply with the layer rule, ${NEW_EDGES.length - compliantNew.length} violate it`);
console.log(`graph now has ${Object.keys(layer).length} modules and ${edges.length} edges`);
console.log(`\n${"rule form".padEnd(29)}${"rule lines".padStart(11)}${"lines updated".padStart(17)}` +
  `${"new violation caught".padStart(22)}${"new false alarm".padStart(17)}`);
for (const [name, b] of Object.entries(FORMS)) {
  const flagged = NEW_EDGES.filter(b.f);
  const y = flagged.filter((k) => NEW_VIOLATIONS.has(k)).length;
  const updated = name === "module allowlist (updated)" ? compliantNew.length : 0;
  console.log(`${name.padEnd(29)}${String(b.data.length).padStart(11)}${String(updated).padStart(17)}` +
    `${`${y}/${NEW_VIOLATIONS.size}`.padStart(22)}${String(flagged.length - y).padStart(17)}`);
}
console.log(`\nthe new module's own file has the same single line for every rule form: "// layer: application"`);
```

```
new module: reservationFlow (layer: application), 6 new edges; 5 comply with the layer rule, 1 violate it
graph now has 14 modules and 40 edges

rule form                     rule lines    lines updated  new violation caught  new false alarm
layer allowlist                        8                0                   1/1                0
layer denylist                         6                0                   1/1                0
module allowlist (old)                25                0                   1/1                5
module allowlist (updated)            30                5                   1/1                0

the new module's own file has the same single line for every rule form: "// layer: application"
```

This is the hidden cost of the module-granularity allowlist. The new module arrives with a
single-line layer declaration; both layer-granularity forms are updated nowhere and still catch
the new violation. The module-granularity allowlist, left unupdated, produces **five false
alarms**, and all five of those alarms sit on edges that fully comply with the rule; once updated,
the alarms drop to zero and the rule grows from twenty-five lines to thirty. **Every new module
grows the rule by five lines.**

This is the same phenomenon as the previous lesson's disabling run. A check that blocks a
compliant change gets closed after a few releases, or the rule quietly turns into a self-approving
list. The module-granularity allowlist looks flawless in the table, but in practice it is the most
fragile form, because its correctness depends on being updated by hand at every new edge.

The choice among the three measured forms is a combination of these three numbers: the denylist
needs no maintenance and misses six violations; the layer allowlist needs no maintenance, misses
two violations, and depends on the exception list's expiry check to catch them; the module
allowlist misses nothing and asks for five lines of maintenance per module. **As the rule's
granularity rises, the catch rate rises, and the maintenance cost rises with it; no form makes
both cheap at once.**

#### Summary

- Layer checking was run on an import graph read from real files: 13 modules, 34 edges, 9
  hand-labeled violations, and 2 sanctioned exception records.
- The denylist form gives zero false alarms with 6 lines but misses 6 of 9 violations; the edge
  types it misses pass silently because they are not enumerated in the list.
- The layer-granularity allowlist catches 7 violations with 8 lines, misses 2, and gives 1 false
  alarm on an approved migration edge; the two it misses are rules that can only be written by
  module name, not by layer.
- The exception list whose expiry is not checked drops the catch count from 7 to 6; a single
  expired exception hides one real violation, and once the expiry check is added, the catch count
  returns to 7 and the false alarm count is zero.
- The module-granularity allowlist catches 9 of 9 with 25 lines and gives zero false alarms; its
  cost shows up at a new module — left unupdated it gives a false alarm on 5 compliant edges, and
  updated it grows by 5 lines.
- As the rule's granularity rises, the catch rate rises and the maintenance cost rises with it;
  because the layer declaration lives in the source, the layer forms ask for 0 lines of update at
  a new module.

#### Next Step

Across these three lessons, rules were written, converted into checks, and their catches and
misses counted. All three share one assumption: that the architecture is fixed and the rule
protects it. Yet this lesson's last run showed the opposite — the moment a module entered the
codebase, the rule itself had to change too. The quality an architecture needs to be measured on is
not only whether it complies with the rule today, but **how cheaply it accepts change**. The next
lesson takes up that quality: can the structural decisions that make change cheap themselves be
converted into a check, and if so, what number is measured, and what stands in for the part that
cannot be converted.

### Evolutionary Architecture

Source: https://academia.sh/en/courses/architecture-governance/evolutionary-architecture

Checking a change-resistant structure: the same three changes touching 18 modules in one structure versus 5 in the isolation-pointed structure, isolation permanently costing 5 nodes and 4 edges, the fitness function catching 7 of 8 violations while giving 1 false alarm, and the same rule set flagging 5 correct changes as violations once a dependency is brought in-house.

The previous lesson turned dependency and layer rules into an executable check: a rule is no longer a
sentence in a document but a predicate that runs over the import graph. But all of those rules protect
today's structure: they freeze today's layer separation, and when the structure itself has to change,
they either keep the right change cheap or flag it as a violation.

**Evolutionary architecture** is the effort to make the structural decisions that keep change cheap
themselves auditable. The course's question shifts by one step: can the structural decision that makes
change cheap be checked? Four measures are taken: modules touched and reversal steps; the fitness
function's catches, misses, and false alarms; the rule's own aging; and the untranslatable remainder.

#### The Same Change Set, Two Structures

The example is again the course's fictional regional library network. **GV12:** six internal modules
(branch front, branch terminal, loan, fee, reporting, notification) and three external dependencies
(an outside catalog, a separate membership system, the municipality's identity service); today's
structure A links them with 16 edges. **GV13:** an edge is a shape coupling if it depends on internal
shape, or a contract coupling if it depends on a declared contract. **GV14 — leaking module:** in A,
`loan` and `fee` re-emit the shape they receive unchanged; in B, an isolation point (gate) that maps
shape stands in front of every external dependency, and no module leaks. **GV15 — change set:** adding
a field to the catalog record, changing the membership system, splitting fee assessment in two.

The two files build a graph, its propagation, and five rules as an in-process model; there is no
network or store.

```js
// evolution/structure.mjs — the fictional library network's module graph and rule checker (model).
// Edge notation is "dependent>dependency:type"; "b" is shape coupling, "s" is contract coupling.

export const parseEdges = (s) => s.trim().split(/\s+/).map((e) => e.split(/[>:]/));

export const STRUCTURE_A = parseEdges(`
  branch-front>catalog:b branch-front>loan:b branch-front>identity:b
  branch-terminal>catalog:b branch-terminal>loan:b branch-terminal>identity:b
  loan>catalog:b loan>membership:b loan>fee:b fee>loan:b fee>membership:b
  report>loan:b report>fee:b report>catalog:b notification>loan:b notification>membership:b`);

export const STRUCTURE_B = parseEdges(`
  branch-front>catalog-gate:s branch-front>loan-gate:s branch-front>identity-gate:s
  branch-terminal>catalog-gate:s branch-terminal>loan-gate:s branch-terminal>identity-gate:s
  loan>catalog-gate:s loan>membership-gate:s loan>fee-gate:s fee>membership-gate:s
  report>loan-gate:s report>fee:b report>catalog-gate:s
  notification>loan-gate:s notification>membership-gate:s
  catalog-gate>catalog:b membership-gate>membership:b identity-gate>identity:b
  loan-gate>loan:b fee-gate>fee:b`);

// Modules that re-emit the shape they receive, unchanged (rough marker)
export const LEAKING = { A: new Set(["loan", "fee"]), B: new Set() };
export const EDGE_LAYER = ["branch-front", "branch-terminal"];

// "external" and "gate" are not the structure's to declare — the configuration declares them.
export const OLD = {
  external: ["catalog", "membership", "identity"],
  gate: { "catalog-gate": "catalog", "membership-gate": "membership", "identity-gate": "identity",
    "loan-gate": "loan", "fee-gate": "fee" },
  edgeAllow: ["catalog-gate", "loan-gate", "identity-gate"],
  exceptions: [["report", "fee"]],
};
const { "membership-gate": _, ...NEW_GATES } = OLD.gate;      // after membership is brought in-house
export const NEW = { ...OLD, external: ["catalog", "identity"], gate: NEW_GATES,
  edgeAllow: [...OLD.edgeAllow, "membership"] };

export const modules = (k) => [...new Set(k.flatMap(([a, b]) => [a, b]))];

export const apply = (k, o) => {
  const s = (o.remove ?? []).map((x) => `${x[0]}>${x[1]}`);
  return [...k.filter(([a, b]) => !s.includes(`${a}>${b}`)), ...(o.add ?? [])];
};

// A shape edge carries the shape that changed; a contract edge only crosses when the contract changes.
export function touched(edges, leaking, external, change) {
  const set = new Set([change.root]);
  for (let added = true; added; ) {
    added = false;
    for (const [a, b, type] of edges) {
      if (set.has(a) || !set.has(b)) continue;
      if (type === "b" ? b === change.root || leaking.has(b) : change.contractChanged.includes(b)) {
        set.add(a); added = true;
      }
    }
  }
  return [...set].filter((m) => !external.includes(m));
}

// A change that stays behind a single gate is reversed with one key.
export const reversalSteps = (touched, gate) => (touched.length === 1 && touched[0] in gate ? 1 : touched.length);

export function cycleMembers(edges) {
  const mods = modules(edges), reach = new Map(mods.map((x) => [x, new Set()]));
  for (const [a, b] of edges) reach.get(a).add(b);
  for (let changed = true; changed; ) {                        // transitive closure
    changed = false;
    for (const a of mods) for (const b of [...reach.get(a)]) for (const c of reach.get(b) ?? [])
      if (!reach.get(a).has(c)) { reach.get(a).add(c); changed = true; }
  }
  return mods.filter((x) => reach.get(x).has(x));
}

// Fitness function: openness to change
export function fitness(edges, config) {
  const gateNames = Object.keys(config.gate);
  const fullyIsolated = config.external.filter((x) => {
    const sources = edges.filter(([, b]) => b === x).map(([a]) => a);
    return sources.length === 1 && gateNames.includes(sources[0]);
  });
  return { node: modules(edges).length, edge: edges.length,
    "cyclic module": cycleMembers(edges).length,
    "unisolated external edge": edges.filter(([a, b]) => config.external.includes(b) && !gateNames.includes(a)).length,
    "fully isolated external": `${fullyIsolated.length}/${config.external.length}` };
}

// Five architectural rules; each returns a violation count
export function rules(config) {
  const gateNames = Object.keys(config.gate);
  const internalTargets = Object.values(config.gate).filter((m) => !config.external.includes(m));
  return {
    K1: (k) => k.filter(([a, b]) => config.external.includes(b) && !gateNames.includes(a)).length,
    K2: (k) => cycleMembers(k).length,
    K3: (k) => k.filter(([a, b, t]) => t === "b" && internalTargets.includes(b) && !gateNames.includes(a)
      && !config.exceptions.some(([x, z]) => x === a && z === b)).length,
    K4: (k) => k.filter(([a, b]) => EDGE_LAYER.includes(a) && !config.edgeAllow.includes(b)).length,
    K5: (k) => config.external.filter((x) => k.filter(([, b]) => b === x).length !== 1
      || !gateNames.includes((k.find(([, b]) => b === x) ?? [])[0])).length,
  };
}
```

```js
// evolution/measure.mjs — the same change set on two structures, the known violation set, the rules aging.
import { STRUCTURE_A, STRUCTURE_B, LEAKING, OLD, NEW, parseEdges, apply, touched, reversalSteps, fitness, rules }
  from "./structure.mjs";

const printRow = (w, ...s) => console.log(s.map((v, i) =>
  (w[i] < 0 ? String(v).padEnd(-w[i]) : String(v).padStart(w[i]))).join(""));

const fitA = fitness(STRUCTURE_A, OLD), fitB = fitness(STRUCTURE_B, OLD);
printRow([-24, 8, 8], "fitness function", "A", "B");
for (const k of Object.keys(fitA)) printRow([-24, 8, 8], k, fitA[k], fitB[k]);

const CHANGES = [
  { name: "new field (catalog)", root: "catalog", contractChanged: [] },
  { name: "change dependency (membership)", root: "membership", contractChanged: [] },
  { name: "split the fee in two", root: "fee", contractChanged: ["fee"] },
];
const CHANGE_W = [-30, 14, 14, 14, 14], TOTAL = { A: [0, 0], B: [0, 0] };
console.log();
printRow(CHANGE_W, "change", "A: touched", "A: reversal", "B: touched", "B: reversal");
for (const change of CHANGES) {
  const res = {};
  for (const [id, structure, leaking] of [["A", STRUCTURE_A, LEAKING.A], ["B", STRUCTURE_B, LEAKING.B]]) {
    const t = touched(structure, leaking, OLD.external, change), r = reversalSteps(t, OLD.gate);
    res[id] = [t.length, r]; TOTAL[id][0] += t.length; TOTAL[id][1] += r;
  }
  printRow(CHANGE_W, change.name, res.A[0], res.A[1], res.B[0], res.B[1]);
}
printRow(CHANGE_W, "total", TOTAL.A[0], TOTAL.A[1], TOTAL.B[0], TOTAL.B[1]);

// Known violation set: each proposal is hand-labeled (model input).
const PROPOSALS = [
  ["fast search", "branch-front>catalog:b", "violation"],
  ["nightly transfer", "report>catalog:b", "violation"],
  ["balance lookup", "fee>loan:b", "violation"],
  ["fee in notification", "notification>fee:b", "violation"],
  ["gate to catalog", "loan-gate>catalog:b", "violation"],
  ["new kiosk", "branch-kiosk>loan:b", "violation"],
  ["notification identity", "notification>identity-gate:s", "clean"],
  ["report membership", "report>membership-gate:s", "clean"],
  ["archive module", "archive>loan-gate:s archive>catalog-gate:s", "clean"],
  ["gate chain", "catalog-gate>membership-gate:s", "violation"],
  ["terminal membership", "branch-terminal>membership-gate:s", "clean"],
  ["notification membership", "notification>membership:b", "violation"],
].map(([name, e, actual]) => ({ name, add: parseEdges(e), actual }));

function run(base, proposals, config, disabled = []) {
  const K = rules(config), names = Object.keys(K).filter((x) => !disabled.includes(x));
  const baseline = Object.fromEntries(names.map((x) => [x, K[x](base)]));
  const rows = proposals.map((p) => {
    const fired = names.filter((x) => K[x](apply(base, p)) > baseline[x]);
    const outcome = p.actual === "violation" ? (fired.length ? "caught" : "missed")
      : fired.length ? "false alarm" : "clean pass";
    return { name: p.name, actual: p.actual, fired, outcome };
  });
  const count = (s) => rows.filter((x) => x.outcome === s).length;
  return { rows, baseline, caught: count("caught"), missed: count("missed"),
    falseAlarm: count("false alarm"), cleanPass: count("clean pass") };
}

const R = run(STRUCTURE_B, PROPOSALS, OLD), violations = PROPOSALS.filter((p) => p.actual === "violation").length;
console.log(`\nB baseline: ${Object.entries(R.baseline).map(([a, b]) => `${a}=${b}`).join(" ")}`);
const PROPOSAL_W = [-25, 11, 14, 14];
printRow(PROPOSAL_W, "proposal", "actual", "fired", "outcome");
for (const row of R.rows) printRow(PROPOSAL_W, row.name, row.actual, row.fired.join(",") || "-", row.outcome);
console.log(`caught ${R.caught}/${violations}, missed ${R.missed}, ` +
  `false alarm ${R.falseAlarm}/${PROPOSALS.length - violations} clean`);

// The rules aging: the structure is unchanged, the configuration changed.
const AFTER = [
  ["loan direct", "loan>membership:b", "", "clean"],
  ["remove the gate", "loan>membership:s fee>membership:s notification>membership:s",
    "membership-gate>membership loan>membership-gate fee>membership-gate notification>membership-gate", "clean"],
  ["notification direct", "notification>membership:b", "", "clean"],
  ["front membership", "branch-front>membership:s", "", "clean"],
  ["membership to identity", "membership>identity:b", "", "violation"],
  ["report membership", "report>membership:s", "", "clean"],
].map(([name, e, s, actual]) => ({ name, add: parseEdges(e), remove: s ? parseEdges(s) : [], actual }));

const DISABLED = ["K1", "K5"];                                // the two rules that read the external list
console.log("\nafter membership is brought in-house, the same six proposals (5 clean, 1 violation)");
const AGING_W = [-22, 11, 8, 14, 13];
printRow(AGING_W, "configuration", "caught", "missed", "false alarm", "clean pass");
for (const [name, config, closed] of [["old (unchanged)", OLD, []], ["updated", NEW, []],
  [`old, ${DISABLED.join("+")} disabled`, OLD, DISABLED]]) {
  const r = run(STRUCTURE_B, AFTER, config, closed);
  printRow(AGING_W, name, r.caught, r.missed, r.falseAlarm, r.cleanPass);
}
const oldRules = rules(OLD), newRules = rules(NEW);
const changed = Object.keys(OLD).filter((a) => JSON.stringify(OLD[a]) !== JSON.stringify(NEW[a]));
const differ = Object.keys(oldRules).filter((x) =>
  AFTER.some((p) => oldRules[x](apply(STRUCTURE_B, p)) !== newRules[x](apply(STRUCTURE_B, p))));
console.log(`update: ${changed.length} of ${Object.keys(OLD).length} configuration entries change ` +
  `(${changed.join(", ")}); ${differ.length} of ${Object.keys(oldRules).length} rules ` +
  `(${differ.join(", ")}) give a different result on the same proposal`);

const relaxed = run(STRUCTURE_B, PROPOSALS, OLD, DISABLED);
console.log(`disabling ${DISABLED.join("+")}, on the first violation set: caught ${R.caught} -> ` +
  `${relaxed.caught}, missed ${R.missed} -> ${relaxed.missed}, false alarm ${R.falseAlarm} -> ${relaxed.falseAlarm}`);

// Untranslatable part: the cheapness of a change that has not yet been written down.
const [SCENARIOS, REVIEWS, INCOMING, COVERED] = [6, 4, 12, 5];    // model input
console.log(`\nchange scenarios ${SCENARIOS}, ${REVIEWS} reviews a year -> ` +
  `${SCENARIOS * REVIEWS} manual-review items`);
console.log(`${COVERED} of the ${INCOMING} changes that arrived in a year matched a written scenario ` +
  `(${((100 * COVERED) / INCOMING).toFixed(2)}%); the remaining ${INCOMING - COVERED} were not counted until they arrived`);
```

```
fitness function               A       B
node                           9      14
edge                          16      20
cyclic module                  2       0
unisolated external edge       9       0
fully isolated external      0/3     3/3

change                            A: touched   A: reversal    B: touched   B: reversal
new field (catalog)                        6             6             1             1
change dependency (membership)             6             6             1             1
split the fee in two                       6             6             3             3
total                                     18            18             5             5

B baseline: K1=0 K2=0 K3=0 K4=0 K5=0
proposal                      actual         fired       outcome
fast search                violation      K1,K4,K5        caught
nightly transfer           violation         K1,K5        caught
balance lookup             violation         K2,K3        caught
fee in notification        violation            K3        caught
gate to catalog            violation            K5        caught
new kiosk                  violation            K3        caught
notification identity          clean             -    clean pass
report membership              clean             -    clean pass
archive module                 clean             -    clean pass
gate chain                 violation             -        missed
terminal membership            clean            K4   false alarm
notification membership    violation         K1,K5        caught
caught 7/8, missed 1, false alarm 1/4 clean

after membership is brought in-house, the same six proposals (5 clean, 1 violation)
configuration              caught  missed   false alarm   clean pass
old (unchanged)                 1       0             5            0
updated                         1       0             0            5
old, K1+K5 disabled             0       1             1            4
update: 3 of 4 configuration entries change (external, gate, edgeAllow); 3 of 5 rules (K1, K4, K5) give a different result on the same proposal
disabling K1+K5, on the first violation set: caught 7 -> 4, missed 1 -> 4, false alarm 1 -> 1

change scenarios 6, 4 reviews a year -> 24 manual-review items
5 of the 12 changes that arrived in a year matched a written scenario (41.67%); the remaining 7 were not counted until they arrived
```

The numbers belong to the measurement class; their inputs are the assumptions above. In A, all three
changes touch all six of the six internal modules: because the shape coupling leaks through `loan` and
`fee`, adding a field to the catalog record propagates all the way to notification. In B, the same
three changes touch 1, 1, and 3 modules — 18 against 5. The reversal column gives a second distinction:
in A, every touched module is reverted one by one; in B, the first two changes stay behind a single
gate, so each is reverted with one key turn. This is a structural reversibility, not the migration
round of the strangler fig from the Service Architectures course. The cost of isolation also shows in
the table: B carries 14 and 20 against A's 9 nodes and 16 edges. The third change touching three
modules in B shows that the isolation is partial: `report` is linked to fee assessment by a shape
coupling, and that edge sits on the exception list.

#### Fitness Function and the Known Violation Set

A **fitness function** measures openness to change here and counts three quantities: modules in a
cycle, unisolated external edges, fully isolated external dependencies. In A, `loan` and `fee` depend
on each other, nine edges go straight to an external dependency, and no external dependency has a
single gate; in B, the figures are 0, 0, and 3/3.

Five **architectural rules** are the thresholds on these quantities: K1 forbids an internal module
connecting directly to an external dependency, K2 forbids a cycle, K3 forbids a shape coupling to a
gated internal module, K4 forbids the edge layer stepping outside the allow list, K5 forbids an
external dependency lacking a single gate; all five return zero on the B baseline. **GV16 — known
violation set:** twelve proposals are hand-labeled, eight of which raise the cost of change.

The check catches seven of the eight violations, misses one, and gives a false alarm on one of the four
clean proposals. The missed proposal is the **gate chain**: a gate connecting to another gate. The gate
stops being a shape-mapping layer and turns back into a domain module, but no rule forbids this — the
check only sees what is written. The false alarm is **terminal membership**; the flaw belongs not to
the proposal but to K4's allow list. Catching and the false alarm are the same phenomenon as the
false-pass/false-fail pair in the testing courses; what is tested here is not code but a graph.

#### The Rule Itself Ages

**GV17 — evolution event:** the separate membership system is brought in-house and becomes the
network's own module. Not a single edge in the graph changes; what changes is the "external" list in
the rule configuration. The same five rules, applied to the six proposals that are now correct after
membership is internalized, flag all five clean proposals as violations. Removing the gate, `loan`
calling membership directly, `report` reading from membership — all of them trip K1 and K5, because
both still assume membership is external. The rule did not break; its world changed.

The fix is in the configuration: three of the four inputs (the external, gate, and edge-allow lists)
are updated, and three of the five rules give a different result on the same proposal. Afterward the
false alarm rate drops to zero, and the one real violation — the internalized membership calling the
identity service without a gate — keeps being caught.

The second route is relaxing: disabling the two rules that read the external list; the bottom row
gives the price. In the post-evolution set, the false alarm falls from five to one but the one real
violation escapes; on the first violation set, caught falls from seven to four, missed rises from one
to four, and the false alarm stays at one — the remaining false alarm was K4's, and K4 stayed on.
**What a false alarm produces in a team is the habit of disabling rules; disabling does not take away
the rule that caused the false alarm, but the rule that catches the most violations.**

#### The Untranslatable Part

What cannot be tied to a number is the cheapness of a change that has not yet been requested: the
fitness function measures the structure against a written change set, and produces no number for what
is not in that set. What takes its place is a periodically reviewed **change scenario**. **GV18:** six
scenarios and four reviews a year come to 24 manual-review items a year; of the twelve changes that
arrived in a year, five matched a written scenario (41.67 percent), and the remaining seven were priced
only after they arrived. This ratio is counted in hindsight; the cost of the thirteenth, unwritten
change cannot be counted in advance.

#### Summary

- The same three changes touch all six of A's six internal modules (18 touches, 18 reversal steps); in
  the isolation-pointed B, they touch 1, 1, and 3 modules (5 and 5).
- The fitness function counts openness with three quantities (cyclic module 2 → 0, unisolated external
  edge 9 → 0, fully isolated external dependency 0/3 → 3/3); the permanent cost of isolation is B's 14
  nodes and 20 edges against A's 9 nodes and 16 edges.
- The check catches seven of eight violations, misses a gate-to-gate edge, and gives a false alarm on
  one clean proposal; both come from how the rule is written.
- When a dependency is brought in-house, the rule ages without the graph changing: all five correct
  changes get flagged as violations; when three of the four configuration inputs are updated, the false
  alarm rate drops to zero, and if two rules are disabled instead of updating, caught falls from 7 to 4.
- The untranslatable part is the cheapness of a change that has not yet been requested; in its place,
  six scenarios and four reviews a year (24 items) cover five of the twelve changes that arrived.

#### Next Step

All of this lesson's rules came from the inside: the architecture itself chose the gate, allow, and
exception lists, and updated them itself when they aged; relaxing them was also in its own hands. Some
constraints, though, come from the outside and are not open to negotiation — where data must stay, how
long it must be kept, how an erasure request must be honored. The next lesson takes these up: what
happens when a constraint that offers no option to remove it is turned into a check, and what takes
the place of the part that cannot be translated?

### Compliance Requirements

Source: https://academia.sh/en/courses/architecture-governance/compliance-requirements

Turning externally imposed constraints into a check: running five constraint types over 15 reported copies, a narrow access definition catching 7 of 10 violations and missing 3, catching rising to 8 and the false alarm to 3 once the definition broadens, producing 27,375 lines of evidence a year instead of relaxing, and the constraint costing the architecture one new component and 11 new edges.

The previous lesson measured how a rule ages and counted two ways out: updating the configuration or
relaxing the rule. Both were in the architecture's own hands, because the architecture itself had set
the rules — the same side chose the gate list, the allow list, and the exceptions, and the same side
changed them when they aged.

This lesson's constraints come from outside. A **compliance requirement** is the share of an obligation
outside the system that falls on the architecture; removing it, relaxing it, or writing your own
exception is not an option. The course's measure stays the same — can the constraint be turned into an
executable check, and if not, what takes its place — but it diverges in three places: evidence takes
the place of relaxing, the untranslatable part sits in the rule's interpretation, and the constraint
directly changes the architecture's shape. Constraints are named here by **type**: a retention-period
requirement, a data-residency constraint, the right to erasure, an access-log retention obligation, an
auditability obligation.

#### Five Constraint Types, Five Checks

The example is again the course's fictional regional library network. **GV19 — data-class table:** five
data classes, each with a retention floor, a retention ceiling, an allowed zone, whether the right to
erasure applies, and an access-log obligation. **GV20 — reported-copy table:** fifteen copies; each row
is one instance of a data class sitting in one component, in one zone, and carries the age of its oldest
record, whether an access log is kept, and whether it is wired into the erasure flow. **GV21 — known
violation set:** ten hand-labeled violations; two of them are unreported copies that never appear in the
table at all. **GV22 — two scopes of the access definition:** the narrow definition counts only direct
reads as access, the broad definition also counts derived reads.

The file below builds the tables, the five checks, and the measurement. It is an in-process model; there
is no real store, network, or record.

```js
// compliance/measure.mjs — turning externally imposed constraints into an executable check. In-process
// model: the fictional regional library network; there is no real store, network, or record, each copy is one row.

const parseRows = (s) => s.trim().split("\n").map((l) => l.trim().split("|"));
const printRow = (w, ...s) => console.log(s.map((v, i) =>
  (w[i] < 0 ? String(v).padEnd(-w[i]) : String(v).padStart(w[i]))).join(""));

// Data class: name|retention floor (days)|ceiling (0 = none)|allowed zone|right to erasure|access log
const DATA_CLASS = Object.fromEntries(parseRows(`
  member-identity|0|1825|internal|1|1
  loan-record|365|1095|internal|1|1
  payment-record|3650|0|internal|0|1
  search-history|0|180|internal external|1|1
  access-log|730|2555|internal|0|0`).map(([name, min, max, z, erase, log]) => [name, { min: +min, max: +max,
  zone: z.split(" "), eraseRight: erase === "1", logRequired: log === "1" }]));

// Reported copy: class|component|zone|age of oldest record (days)|access log kept|erasure-wired|read
const COPY = parseRows(`
  member-identity|membership|internal|900|1|1|direct
  member-identity|loan|internal|1200|1|1|direct
  member-identity|notification|internal|400|0|0|direct
  member-identity|archive|external|2000|0|0|direct
  member-identity|backup|internal|2200|0|0|direct
  loan-record|loan|internal|1200|1|1|direct
  loan-record|report|internal|1500|0|0|derived
  loan-record|archive|external|2000|0|0|direct
  payment-record|fee|internal|2000|1|0|direct
  payment-record|backup|internal|2200|0|0|direct
  payment-record|report|internal|3700|0|0|derived
  search-history|catalog-gate|internal|30|0|1|derived
  search-history|report|internal|300|0|0|derived
  access-log|event-log|internal|800|1|0|direct
  access-log|backup|internal|2200|0|0|direct`).map(([cls, component, zone, age, logged, erased, read]) =>
  ({ id: `${cls}|${component}`, class: cls, component, zone, age: +age,
    logged: logged === "1", erasureWired: erased === "1", read }));

// Five constraint types. "scope" is the access definition: narrow counts only direct reads as access.
const checks = (scope) => ({
  "D1 retention ceiling": (k) => DATA_CLASS[k.class].max > 0 && k.age > DATA_CLASS[k.class].max,
  "D2 retention floor": (k) => DATA_CLASS[k.class].min > 0 && k.age < DATA_CLASS[k.class].min,
  "D3 data residency": (k) => !DATA_CLASS[k.class].zone.includes(k.zone),
  "D4 right to erasure": (k) => DATA_CLASS[k.class].eraseRight && !k.erasureWired,
  "D5 access log": (k) => DATA_CLASS[k.class].logRequired && !k.logged
    && (scope === "broad" || k.read === "direct"),
});

// Hand-labeled actual violation set (model input); the last two are unreported copies
const ACTUAL = parseRows(`
  member-identity|notification
  member-identity|archive
  loan-record|loan
  loan-record|report
  loan-record|archive
  payment-record|backup
  search-history|catalog-gate
  search-history|report
  member-identity|report
  payment-record|notification`).map((x) => x.join("|"));

const SCOPES = ["narrow", "broad"];
console.log(`${COPY.length} reported copies, ${Object.keys(DATA_CLASS).length} data classes, ` +
  `${Object.keys(checks("narrow")).length} constraint types, ${ACTUAL.length} hand-labeled violations ` +
  `(${ACTUAL.filter((a) => !COPY.some((k) => k.id === a)).length} of them unreported copies)`);

const CONSTRAINT_W = [-24, 12, 12];
console.log();
printRow(CONSTRAINT_W, "constraint type", "flagged: narrow", "broad");
for (const name of Object.keys(checks("narrow")))
  printRow(CONSTRAINT_W, name, ...SCOPES.map((s) => COPY.filter(checks(s)[name]).length));

const SCOPE_W = [-16, 16, 11, 7, 14, 13], R = {};
console.log();
printRow(SCOPE_W, "access definition", "flagged copies", "caught", "missed", "false alarm", "clean pass");
for (const s of SCOPES) {
  const d = checks(s);
  const flagged = COPY.filter((k) => Object.values(d).some((f) => f(k)));
  const caught = flagged.filter((k) => ACTUAL.includes(k.id)).length;
  R[s] = { flagged: flagged.length, caught, missed: ACTUAL.length - caught,
    falseAlarm: flagged.length - caught,
    cleanPass: COPY.length - flagged.length - (ACTUAL.length - caught
      - ACTUAL.filter((a) => !COPY.some((k) => k.id === a)).length) };
  printRow(SCOPE_W, s, R[s].flagged, `${caught}/${ACTUAL.length}`, R[s].missed, R[s].falseAlarm, R[s].cleanPass);
}

// Evidence: relaxing is not an option, a run log is required. Evidence is itself a data class.
const BYTES_PER_ROW = 120, DAYS = 365, KEEP = DATA_CLASS["access-log"].max;
const rowsPerRun = COPY.length * Object.keys(checks("narrow")).length;
console.log(`\nevidence: ${COPY.length} x 5 = ${rowsPerRun} rows per run; one run a day -> ` +
  `${(rowsPerRun * DAYS).toLocaleString("en-US")} rows a year, ` +
  `${((rowsPerRun * DAYS * BYTES_PER_ROW) / 1e6).toFixed(2)} MB`);
console.log(`evidence is kept for ${KEEP} days (the access-log retention ceiling) -> ` +
  `${(rowsPerRun * KEEP).toLocaleString("en-US")} rows, ${((rowsPerRun * KEEP * BYTES_PER_ROW) / 1e6).toFixed(2)} MB`);

// The constraint's cost to the architecture: components displaced and new edges
const COST_W = [-36, 10, 12];
console.log();
printRow(COST_W, "cost to architecture", "narrow", "broad");
const relocated = new Set(COPY.filter(checks("narrow")["D3 data residency"]).map((k) => k.component));
const eraseEdges = COPY.filter(checks("narrow")["D4 right to erasure"]).length;
printRow(COST_W, "component displaced", relocated.size, relocated.size);
printRow(COST_W, "new component (erasure broadcast)", 1, 1);
printRow(COST_W, "new edge: erasure flow", eraseEdges, eraseEdges);
const logEdges = SCOPES.map((s) => COPY.filter(checks(s)["D5 access log"]).length);
printRow(COST_W, "new edge: access log", ...logEdges);
printRow(COST_W, "new edge total", ...logEdges.map((x) => x + eraseEdges));

// Untranslatable part: the interpretation of the obligation and the boundary of its scope
const [INTERPRETATIONS, REVIEWS, HOURS] = [6, 2, 3];                // model input
console.log(`\nuntranslatable: the obligation's interpretation and the scope's boundary; in their place ` +
  `${INTERPRETATIONS} interpretation records, ${REVIEWS} reviews a year x ${HOURS} person-hours = ${INTERPRETATIONS * REVIEWS * HOURS} person-hours/year`);
console.log(`when the definition moves from narrow to broad: caught ${R.narrow.caught} -> ${R.broad.caught}, ` +
  `missed ${R.narrow.missed} -> ${R.broad.missed}, false alarm ${R.narrow.falseAlarm} -> ${R.broad.falseAlarm}, ` +
  `new edges ${logEdges[0] + eraseEdges} -> ${logEdges[1] + eraseEdges}`);
```

```
15 reported copies, 5 data classes, 5 constraint types, 10 hand-labeled violations (2 of them unreported copies)

constraint type         flagged: narrow       broad
D1 retention ceiling               6           6
D2 retention floor                 2           2
D3 data residency                  2           2
D4 right to erasure                6           6
D5 access log                      5           9

access definition  flagged copies     caught missed   false alarm   clean pass
narrow                         9       7/10      3             2            5
broad                         11       8/10      2             3            4

evidence: 15 x 5 = 75 rows per run; one run a day -> 27,375 rows a year, 3.29 MB
evidence is kept for 2555 days (the access-log retention ceiling) -> 191,625 rows, 23.00 MB

cost to architecture                    narrow       broad
component displaced                          1           1
new component (erasure broadcast)            1           1
new edge: erasure flow                       6           6
new edge: access log                         5           9
new edge total                              11          15

untranslatable: the obligation's interpretation and the scope's boundary; in their place 6 interpretation records, 2 reviews a year x 3 person-hours = 36 person-hours/year
when the definition moves from narrow to broad: caught 7 -> 8, missed 3 -> 2, false alarm 2 -> 3, new edges 11 -> 15
```

The numbers belong to the measurement class; their inputs are the assumptions above. All five constraint
types turn into an executable check: each end of the retention period is a comparison, residency is a
set membership, the right to erasure is a reachability question, and the access log is a predicate.
Under the narrow definition, nine of the fifteen copies are flagged; seven of the ten violations are
caught, three are missed, and two clean copies get a false alarm.

Two of the three missed violations sit where the check cannot see them: unreported copies that never
appear in the table. The check only reads reported copies, and it produces no number for the unreported
— this is not the rule missing something, it is a **gap in the data source**, and tightening the rule
does not close it. Both false alarms are scope defects: the member identity in `backup` is flagged for
exceeding the retention ceiling, but `backup` is the subject of a separate regime; the payment record in
the `fee` component looks like it is under the retention floor, but the record that satisfies the floor
sits in a different store. Neither is the check's mistake — both come from the narrowness of the table
the check is looking at.

#### No Relaxing, Evidence Instead

In the previous lesson, the behavior a false alarm produced was disabling a rule, and disabling two
rules dropped caught from seven to four. There is no such switch here. What sets an externally imposed
constraint apart is that what is demanded is not showing that it was violated, but showing that it was
**honored**: evidence instead of an exception.

Evidence is an output, and its cost is counted. Fifteen copies times five constraints per run comes to
75 lines; one run a day comes to 27,375 lines and 3.29 MB a year. The evidence record is itself kept
under the auditability obligation — **GV23**, 2555 days, the access log's retention ceiling — for a
total of 191,625 lines, 23.00 MB. The closed loop here is worth stating: **evidence is itself a data
class.** It has its own retention period, its own residency constraint, and its own access-log
obligation; the check has to check the record it produces, too.

#### The Constraint's Cost to the Architecture

A constraint does not just add a check; it also displaces components. **GV24 — how the constraint is
applied:** the component carrying copies that sit outside the allowed zone is moved into the zone, an
**erasure-broadcast** component is added for the right to erasure and opens one edge to every copy not
already wired into the flow, and one edge opens to the event log for every access whose log is not
kept.

The table below counts this: one component is displaced (`archive`, because it carries two copies
outside the zone), one component is born, and eleven new edges open — six for the erasure flow, five
for the access log. None of these edges was chosen to raise a quality attribute, and none is the result
of a trade-off; here, an external obligation is dictating the architecture's shape. That is the point
of the measure, too: the constraint's cost does not sit in one line item called "compliance cost" but
as displaced components and opened edges.

#### The Untranslatable Part: Interpreting the Obligation

All five checks assume a definition has already been given. What is "access" — does a report's derived
read count as access? What is "erasure" — does masking count as erasure? The check does not answer
these questions; it takes the answer as **input**. This is the untranslatable part: the interpretation
of the obligation and the boundary of its scope.

What takes its place is a written **interpretation record**, and its cost is counted — **GV25:** six
interpretation records, two reviews a year, three person-hours per item, 36 person-hours total. But the
real measurement is the outcome of the interpretation. The same check measures something different once
the "access" definition moves from narrow to broad: caught rises from seven to eight, missed falls from
three to two, the false alarm rises from two to three, and the new edges in the architecture from eleven
to fifteen. A single word's scope changes four numbers at once. The untranslatable part is not outside
the check; it is in its **input** — when it is not written as explicitly as the rule itself, every
number the check produces becomes contestable.

#### Summary

- All five external constraint types turned into executable checks: the retention period's floor and
  ceiling, data residency, the right to erasure, the access log.
- Under the narrow access definition, nine of the fifteen reported copies are flagged; seven of ten
  violations are caught, three are missed, and two clean copies get a false alarm.
- Two of the three missed violations are unreported copies: the check only reads reported copies and
  produces no number for the unreported.
- There is no option to relax; evidence is required in its place: 27,375 lines and 3.29 MB a year,
  191,625 lines and 23.00 MB with 2555 days of retention. Evidence is itself a data class and is subject
  to its own constraints.
- The constraint's cost to the architecture was counted: one component is displaced, one component is
  born, eleven new edges open (fifteen under the broad definition).
- The untranslatable part is the interpretation of the obligation and the boundary of its scope; the six
  interpretation records that take its place cost 36 person-hours a year, and broadening the definition
  moves caught from 7 to 8 and the false alarm from 2 to 3.

#### Course Wrap-Up

Over ten lessons, the course asked a single question: can a quality attribute or an architectural rule
be turned into an executable check, and if not, what takes its place?

| Lesson | Turned into a check | Caught / missed / false alarm | Untranslatable, and what replaced it |
|---|---|---|---|
| The Quality Attribute Tree | 25 of 30 leaves tied to a number, 20 to a check | 11 / 7 / 23 | 10 leaves; a quarterly measurement session and review catches 4 of the missed with an 8.5-window delay and 100 person-hours |
| Tension Between Quality Attributes | a tension matrix from 12 decisions, threshold sweep | 4/1/0 at eight points, 4/1/5 at six points | whether the drop is acceptable is a judgment call; in its place, 5 accepted trade-off records, false alarm 5 → 0 |
| Security Architecture | 7 zones, 8 boundaries, 18 crossings; the check required per boundary | 6 / 4 / 3 (3 open gaps) | whether the boundary sits in the right place; a quarterly review of the 8 boundaries, 24 person-hours |
| Scalability Decisions | a replication rule across three scale-unit candidates | 5 / 1 / 1 (3 correct silences) | an undeclared shared resource; in its place, a replication trial, 60 person-hours over 10 attempts |
| Maintainability Criteria | a file, module, and depth metric across 12 changes; a threshold rule | 3 / 1 / 1 (7 correct silences) | a change's difficulty and whether the concept sits in the right module; a quarterly layout review, 12 person-hours a quarter, 72 across six quarters |
| Architecture Governance Models | 5 of 9 governance rules turned into checks; 24 decisions | centralized 12 / 0 / 8, federated 9 / 3 / 1, advisory 6 / 6 / 1 | 4 rules; in their place, a 1/3 sample, 32 manual reviews, missing 7 of 12 violations |
| Fitness Functions | F1 public surface size, F2 dead export ratio | F1 5/0/6 at `> 2`, 1/4/0 at `> 10`; F2 4/1/1 | "a public name describes the module's job"; in its place, a sample reading 22 of 66 symbols |
| Dependency and Layer Control | five forms of the same layer rule; 13 modules, 34 edges | denylist 3 / 6 / 0, layer allowlist 7 / 2 / 1, module allowlist 9 / 0 / 0 | an approved migration edge cannot be expressed by a rule; in its place, 2 sanctioned exception records, catching 7 → 6 without an expiry check |
| Evolutionary Architecture | a fitness function measuring openness to change; 12 proposals | 7 / 1 / 1 | the cheapness of a change not yet requested; in its place, 6 scenarios and 4 reviews a year (24 items), covering 5 of the 12 changes that arrived |
| Compliance Requirements | five external constraint types, 15 reported copies | 7/3/2 under the narrow definition, 8/2/3 under the broad | the interpretation of the obligation and the boundary of its scope; in its place, 6 interpretation records, 36 person-hours a year |

The table's rule deserves to be named: a quality attribute or an architectural rule is governable to the
extent it can be turned into an executable check; the untranslatable part is written down by name and
what replaces it is counted. The sentence "this attribute matters" carried no decision anywhere in this
course. The three columns do not substitute for one another, either: the second says what the check
looks at, the third says how much of it the check gets right, the fourth says what is done where the
check cannot look at all. A governance regime that does not write down all three knows only what it has
caught.

Two patterns repeat in the table. First, every setting that raises catching also raises the false alarm
— the Quality Attribute Tree's 23 alarms, Tension Between Quality Attributes' six-point threshold, this
lesson's broad access definition — and the price of a false alarm is a rule getting disabled: in the
Quality Attribute Tree, disabling two checks took caught from 11 to 9; in Fitness Functions, once F1
closed on its fourth release it missed 8 of the 9 releases with a violation; in Evolutionary
Architecture, disabling two rules took caught from 7 to 4. Second, what replaces the untranslatable is, in every lesson, human time,
and it is expensive: 100, 24, 60, 72, and 36 person-hours, 32 manual reviews, a 22-symbol sample, 24
review items.

The question the course leaves behind sits below the table. Rules were written, turned into checks, and
their catches and misses were counted. But it is people who write the rule, run it, disable it in the
face of a false alarm, and sit down for the review session for the untranslatable part. How teams are
split, at which boundary a decision gets made, and how delivery flows directly shape the architecture;
none of this was taken up anywhere in this course. The next course, **Process, Team and Delivery**,
takes up that layer.

