Lesson 01 / 11
Architecture Decision Records
Recording a decision with context, decision, and consequence fields: the questions someone asks six months later are modeled as a question set, the same decision set is run against that set in an unrecorded and a structured recorded scheme, the answered and unanswered questions are counted, the cost of writing the record is measured in lines and minutes, and whether the superseded-decision field keeps the old decision readable is counted.
Contents
The previous course measured how a decision gets made: the alternative–quality table was turned into a data structure, the weights were swept, and a weight with no recorded source turned up. All of it lives in exactly one place: the decision-maker’s memory. There is nothing to hand someone who asks “why is it this way” six months later.
This course’s measure follows from there. A document’s value is how many of the later questions it answers, how many it leaves unanswered, and the cost the record extracts. “Documentation matters” carries no decision; two schemes are run against the same question set.
Question Set and Decision Set
The through-line is the software system of a regional library network; the setup is fictional. The model has fourteen branches, an externally sourced catalog system, and loan services written in-house.
An architecture decision record is the structured form of the decision record used to describe a design: instead of free-form description, a document with named fields — context, decision, consequence. Having named fields is what makes measurement possible, because every question lands on a field.
The question set has ten questions and each question points to a field; the decision set has four decisions. In the unrecorded scheme, an answer can come only from the running code, the version history, or the decision-maker’s memory; in the recorded scheme, every field is in the record. Whether the fields are filled (DR1), the source mapping, and whether the decision-maker is still reachable six months later (DR2) are the model’s inputs.
// record/model.mjs — question set and decision set as a data structure; the scenario is fictional export const QUESTIONS = [ ["S1", "why is it this way", "rationale"], ["S2", "which alternative was eliminated", "alternative"], ["S3", "what was the elimination based on", "criterion"], ["S4", "what assumption was it given under", "assumption"], ["S5", "when was it given", "date"], ["S6", "who gave it", "issuer"], ["S7", "what is the known cost", "consequence"], ["S8", "is it still valid", "status"], ["S9", "did it supersede an earlier one", "supersedes"], ["S10", "where does this number come from", "threshold"], ].map(([code, text, field]) => ({ code, text, field })); // Whether each field is filled and the owner is still reachable six months later is the input (DR1, DR2). export const DECISIONS = [ { name: "loan-access", owner: false, choice: "read-only cache at the branch", rationale: "the branch network drops twice a day", alternative: "single-center, branch-copy, external-module", criterion: "weighted-score sweep", assumption: "daily volume fits in the cache", date: "Q2", issuer: "platform team", consequence: "invalidation requires maintenance", status: "valid", supersedes: "supersedes the branch-copy decision", threshold: "freshness limit 90 s" }, { name: "catalog-boundary", owner: true, choice: "access only through the mapping layer", rationale: "the catalog schema changes with every version", alternative: "direct table, view read", criterion: "calls broken per version", assumption: "the external interface stays stable", date: "Q2", issuer: "integration team", consequence: "every field is written twice", status: "valid", supersedes: "none - first decision", threshold: null }, { name: "penalty-calculation", owner: true, choice: "consolidated in a single rule engine", rationale: "the penalty rule changes a few times a year", alternative: "catalog penalty module, branch setting", criterion: "files touched per change", assumption: "the rule is the same at every branch", date: "Q3", issuer: "loan team", consequence: "the engine grows if an exception arrives", status: "valid", supersedes: "none - first decision", threshold: "delay threshold 14 days" }, { name: "return-alert", owner: false, choice: "asynchronous dispatch through a queue", rationale: "dispatch must not enter the response time", alternative: "synchronous dispatch, daily batch job", criterion: "how an outage reflects onto the loan transaction", assumption: "a one-hour delay is acceptable", date: "Q4", issuer: "loan team", consequence: "the dead-letter box must be monitored", status: "valid", supersedes: "none - first decision", threshold: "alert window 3 days" }, ]; // Which field is readable from which source in the unrecorded scheme (DR2); in the recorded scheme all of it is in the record. export const SOURCE = { code: ["status"], version: ["date", "issuer"], memory: ["rationale", "assumption", "consequence"] }; export const applicable = (d) => QUESTIONS.filter((q) => d[q.field] !== null); export function answer(d, scheme) { return applicable(d).map((q) => { let source = null; if (scheme === "recorded") source = "record"; else if (SOURCE.code.includes(q.field)) source = "code"; else if (SOURCE.version.includes(q.field)) source = "version"; else if (SOURCE.memory.includes(q.field) && d.owner) source = "memory"; return { question: q.code, source, answered: source !== null }; }); } if (import.meta.url.endsWith(process.argv[1].split("/").pop())) { const total = DECISIONS.reduce((t, d) => t + applicable(d).length, 0); console.log(`${QUESTIONS.length} questions x ${DECISIONS.length} decisions -> ${total} applicable questions`); for (const scheme of ["unrecorded", "recorded"]) { let a = 0; const sourceCount = {}; for (const d of DECISIONS) for (const r of answer(d, scheme).filter((r) => r.answered)) { a += 1; sourceCount[r.source] = (sourceCount[r.source] ?? 0) + 1; } console.log(`[${scheme}] ${a}/${total} answers, ${total - a} unanswered, source: ` + Object.entries(sourceCount).map(([s, n]) => `${s}=${n}`).join(" ")); } console.log("\ndecisions answering each question in the unrecorded scheme:"); for (const q of QUESTIONS) { const eligible = DECISIONS.filter((d) => d[q.field] !== null); const hit = eligible.filter((d) => answer(d, "unrecorded").find((r) => r.question === q.code).answered); console.log(` ${q.code.padEnd(4)} ${q.text.padEnd(36)} ${hit.length}/${eligible.length}` + `${hit.length ? "" : " <- no decision answers it"}`); } }
10 questions x 4 decisions -> 39 applicable questions [unrecorded] 18/39 answers, 21 unanswered, source: version=8 code=4 memory=6 [recorded] 39/39 answers, 0 unanswered, source: record=39 decisions answering each question in the unrecorded scheme: S1 why is it this way 2/4 S2 which alternative was eliminated 0/4 <- no decision answers it S3 what was the elimination based on 0/4 <- no decision answers it S4 what assumption was it given under 2/4 S5 when was it given 4/4 S6 who gave it 4/4 S7 what is the known cost 2/4 S8 is it still valid 4/4 S9 did it supersede an earlier one 0/4 <- no decision answers it S10 where does this number come from 0/3 <- no decision answers it
The recorded scheme’s 39/39 is true by definition: if the fields are filled, every question is answered. This is not a finding; it is the zero point of the measurement scale.
The unrecorded scheme answers 18 questions, and 12 of them come from the version history and the code. The six answers touching rationale, assumption, and consequence sit in the S1, S4, and S7 rows, which show 2 instead of 4 in the table: those three questions find an answer only in the two decisions where the decision-maker is still reachable. Memory standing in for a record depends on one person staying on the job.
Four questions find no answer in any decision: which alternative was eliminated, what the elimination was based on, whether this decision superseded an earlier one, where this number comes from. These do not come from memory either; a criterion not written down at decision time is not remembered six months later. Fifteen of the 21 unanswered questions are these four.
The Cost of a Record
An answered question is not free. The run below actually produces the record text, counts how many lines each field takes, and multiplies by minutes per line. Two writing speeds are kept separate: header fields are looked up and written, body fields are written while thinking (DR3). For reading, each source’s minutes per question is given (DR4).
// record/cost.mjs — the cost of writing a record and its yield per field import { QUESTIONS, DECISIONS, applicable, answer } from "./model.mjs"; const WIDTH = 76, SHORT_MIN = 0.4, LONG_MIN = 2.0; // minutes per line (DR3) const READ = { record: 2, code: 10, version: 8, memory: 25 }; // reading minutes per question (DR4) const SHORT = ["status", "date", "issuer", "supersedes"]; // one-line header fields const LONG = ["choice", "rationale", "assumption", "threshold", "alternative", "criterion", "consequence"]; const wrap = (text) => text.split(" ").reduce((s, w) => // the record text is really produced ((s.at(-1) + " " + w).trim().length > WIDTH ? s.push(w) : (s[s.length - 1] += " " + w), s), [""]); const lines = {}, minutes = {}; let totalLines = 0, totalMinutes = 0; const add = (f, n, m) => { lines[f] = (lines[f] ?? 0) + n; minutes[f] = (minutes[f] ?? 0) + n * m; totalLines += n; totalMinutes += n * m; }; for (const d of DECISIONS) { totalLines += 1; totalMinutes += SHORT_MIN; // the record's title line for (const f of SHORT) add(f, 1, SHORT_MIN); for (const f of LONG) if (d[f] !== null) add(f, 2 + wrap(d[f]).length, LONG_MIN); // blank line + heading + body } const N = DECISIONS.reduce((t, d) => t + applicable(d).length, 0); console.log(`${DECISIONS.length} records: ${totalLines} lines, ${totalMinutes.toFixed(0)} min; per record ` + `${(totalLines / DECISIONS.length).toFixed(1)} lines, ${(totalMinutes / DECISIONS.length).toFixed(0)} min`); const questionCount = Object.fromEntries(QUESTIONS.map((q) => [q.field, DECISIONS.filter((d) => d[q.field] !== null).length])); const items = [{ field: "header (4 fields)", min: SHORT.reduce((t, f) => t + minutes[f], 0), q: SHORT.reduce((t, f) => t + (questionCount[f] ?? 0), 0) }, ...LONG.filter((f) => questionCount[f]).map((f) => ({ field: f, min: minutes[f], q: questionCount[f] }))]; console.log("\nfield, writing minutes, questions it opens (sorted by yield per minute):"); let cumMin = 0, cumQ = 0; for (const r of items.sort((x, y) => y.q / y.min - x.q / x.min)) { cumMin += r.min; cumQ += r.q; console.log(` ${r.field.padEnd(19)} ${r.min.toFixed(1).padStart(5)} min ${r.q} q | ` + `cumulative ${String(cumQ).padStart(2)}/${N}, ${cumMin.toFixed(1).padStart(5)} min`); } console.log(` section answering no question in the set: ${(totalMinutes - cumMin).toFixed(1)} min (${(100 * (totalMinutes - cumMin) / totalMinutes).toFixed(0)}%)`); const readCost = (scheme, memMin = READ.memory) => DECISIONS.reduce((t, d) => t + answer(d, scheme).filter((r) => r.answered) .reduce((u, r) => u + (r.source === "memory" ? memMin : READ[r.source]), 0), 0); const answeredCount = (scheme) => DECISIONS.reduce((t, d) => t + answer(d, scheme).filter((r) => r.answered).length, 0); console.log("\nreading cost of asking the question set once:"); for (const scheme of ["unrecorded", "recorded"]) console.log(` ${scheme.padEnd(10)} ${answeredCount(scheme)} answers, ${readCost(scheme)} min, per answer ${(readCost(scheme) / answeredCount(scheme)).toFixed(1)} min`); const breakEven = (memMin) => (totalMinutes / (readCost("unrecorded", memMin) - answeredCount("unrecorded") * READ.record)).toFixed(2); console.log(`break-even (${answeredCount("unrecorded")} shared questions): if memory takes 25 min, ${breakEven(25)} rounds; if 5 min, ${breakEven(5)} rounds`);
4 records: 101 lines, 170 min; per record 25.3 lines, 43 min field, writing minutes, questions it opens (sorted by yield per minute): header (4 fields) 6.4 min 16 q | cumulative 16/39, 6.4 min rationale 24.0 min 4 q | cumulative 20/39, 30.4 min assumption 24.0 min 4 q | cumulative 24/39, 54.4 min threshold 18.0 min 3 q | cumulative 27/39, 72.4 min alternative 24.0 min 4 q | cumulative 31/39, 96.4 min criterion 24.0 min 4 q | cumulative 35/39, 120.4 min consequence 24.0 min 4 q | cumulative 39/39, 144.4 min section answering no question in the set: 25.6 min (15%) reading cost of asking the question set once: unrecorded 18 answers, 254 min, per answer 14.1 min recorded 39 answers, 78 min, per answer 2.0 min break-even (18 shared questions): if memory takes 25 min, 0.78 rounds; if 5 min, 1.73 rounds
Four records: 101 lines and 170 minutes. The real information is not in the total; it is in how the yield is distributed.
The four header fields open 16 questions in 6.4 minutes; the remaining 23 questions demand 138 minutes. The status, date, issuer, and supersedes fields are single-line and call for looking up, not thinking. The first thing a team starting to keep records should write is not the rationale — it is these four fields.
Fifteen percent of the total time goes to the section that answers no question in the set at all: the record’s title and the section describing what the decision is. What the decision is can already be read from the code; that section makes the record readable, does not enter the measurement, and is kept short.
For reading, the unrecorded scheme spends 14.1 minutes per answer, the recorded scheme 2.0. Over the 18 questions both schemes answer, the writing cost closes in 0.78 rounds if a memory answer takes 25 minutes, and in 1.73 rounds if it takes 5. The fivefold difference does not change the outcome: once the question set is asked twice, the record has paid for itself at both ends.
Superseded Decision
A record is not written once and done: when a decision changes, what happens to the record determines whether the old decision stays readable. The run below compares three versions of the same decision (DR5) across three record formats — one that overwrites the current record, one that overwrites and then digs the old text out of the version history, and one that keeps every version as its own record and links them with a superseded-decision field. The reading minutes are the model’s input (DR6).
// record/superseded.mjs — superseded decision: does the old decision stay readable (DR5) const CHAIN = [ // three versions of the same decision, the model's input { version: "1", choice: "full copy at every branch", rationale: "outage tolerance was weighted heaviest", dropped: "once copy overhead was measured, the cost weight came out at 0.25", supersededBy: "2" }, { version: "2", choice: "read-only cache at the branch", rationale: "highest score under the measured weights", dropped: "volume exceeded capacity at two large branches", supersededBy: "3" }, { version: "3", choice: "copy at two branches, cache at the rest", rationale: "the branch over the threshold stayed at two", dropped: null, supersededBy: null }, ]; const QUESTIONS = [["G1", "what was being done then", "choice"], ["G2", "why was it chosen", "rationale"], ["G3", "why was it dropped", "dropped"], ["G4", "what replaced it", "supersededBy"]]; const RECOVERABLE = ["choice", "rationale"]; // fields that were written into the old file text const FORMAT = { "overwrite": null, "overwrite-dig": "dig", "supersede": "record" }; const MIN = { record: 2, dig: 12 }; // reading minutes per question (DR6) const current = CHAIN.length - 1; const read = (format, i, field) => { const source = i === current ? "record" : FORMAT[format]; return source === "record" || (source === "dig" && RECOVERABLE.includes(field)) ? source : null; }; const lineCount = (v) => Object.values(v).filter((x) => x !== null).length; for (const format of Object.keys(FORMAT)) { const kept = FORMAT[format] === "record" ? CHAIN.reduce((t, v) => t + lineCount(v), 0) : lineCount(CHAIN[current]); let answered = 0, total = 0, min = 0; const missing = []; for (const [i, v] of CHAIN.entries()) for (const [code, , field] of QUESTIONS) { if (v[field] === null) continue; total += 1; const s = read(format, i, field); if (s) { answered += 1; min += MIN[s]; } else missing.push(`${code}@${v.version}`); } console.log(`${format.padEnd(17)} kept ${String(kept).padStart(2)} lines | ` + `${answered}/${total} questions, ${min} min | unanswered: ${missing.join(" ") || "-"}`); }
overwrite kept 3 lines | 2/10 questions, 4 min | unanswered: G1@1 G2@1 G3@1 G4@1 G1@2 G2@2 G3@2 G4@2 overwrite-dig kept 3 lines | 6/10 questions, 52 min | unanswered: G3@1 G4@1 G3@2 G4@2 supersede kept 13 lines | 10/10 questions, 20 min | unanswered: -
The overwrite format keeps 3 lines and answers 2 of 10 questions; the format using the superseded-decision field keeps 13 lines and answers all 10. The difference is 10 lines and 8 questions — nearly one question per line.
The dig row is a finding on its own. The version history keeps the old text, so “what was being done then” and “why was it chosen” can be answered by digging: 6 answers instead of 2. The cost is 52 minutes, that is 8.7 minutes per answer, four times reading from a record. Digging does not open two questions at any cost: “why was it dropped” and “what replaced it.” These two fields were never written in any version, because no one overwriting a decision writes down why they dropped it. The version history keeps what was written; it does not produce what was not.
This is also where past decisions resurface: if the reason for dropping something sits nowhere, an alternative eliminated two years ago comes back as a new proposal. The job of the superseded-decision field is not to archive the old decision — it is to keep the reason it was dropped readable.
Summary
- A record’s value is measured by how many questions it answers out of the question set; the same four decisions answered 18 of 39 questions in the unrecorded scheme and 39 of 39 with a structured record.
- Twelve of those 18 answers come from the version history and the code; the six answers touching rationale, assumption, and consequence appeared only in the two decisions where the decision-maker was reachable.
- Four questions found no answer in any decision: which alternative was eliminated, what the elimination was based on, whether it superseded an earlier one, where this number comes from — 15 of the 21 unanswered questions are these four.
- Four records took 101 lines and 170 minutes; the four header fields opened 16 questions in 6.4 minutes while the remaining 23 questions demanded 138 minutes, and 15 percent of the total went to a section that answers no question.
- The superseded-decision field kept 10 extra lines and answered 8 extra questions; digging through the version history raises written fields to 8.7 minutes per answer but does not open the reason for dropping something that was never written down.
Next Step
The record is what a decision looks like after it is made; everything this lesson measured started once the decision was already given. The “issuer” field in all four records holds a team name, but how that team reached the decision — who proposed it, who objected, whether the objection changed anything — does not enter the record. A decision that passes on verbal approval and one evaluated through a written proposal produce the same record. The next lesson measures that difference: the same decisions are run through two processes, and how many change direction during the evaluation round is counted.
To keep your progress and take notes, Log in
My notes
Log in to take notes.