---
title: 'Architecture Governance Models'
source: 'https://academia.sh/en/courses/architecture-governance/architecture-governance-models'
course: 'Quality Attributes and Governance'
language: en
updated: '2026-08-23T07:01:05+00:00'
license: 'CC BY-SA 4.0'
---

# 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.
