Lesson 06 / 10
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.
Contents
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.
// 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.
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.